#!/usr/bin/env node /** * cli:configure-login — Configure the generated client app's login/auth. * * Writes (deep-merged, idempotent): * - {apiDir}/appsettings.json — providers (public), email (sender + provider block), admin email * - {apiDir}/appsettings.Local.json — secrets (ClientSecret, admin password, SMTP pwd / SendGrid key / ACS conn) [gitignored] * - {webDir}/src/main.tsx — SmartStackProvider config.auth [only when frontendAuth:true] * * Entra SSO writes NOTHING to the frontend: the browser reads its clientId and authority from * GET /api/config/features, served from the Authentication:EntraSso block written here. * * Does NOT scaffold a login page — that UI ships in @atlashub/smartstack; this * CLI only writes the configuration the package reads. */ import { parseArgs } from 'node:util'; import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { validate } from './validate.js'; import { generate, injectProviderAuth, buildAuthLiteral } from './generate.js'; import { deepMerge } from '../../../lib/json-merge.js'; import { generateEnvelope, failGenerate, printEnvelope } from '../../../lib/output.js'; const COMMAND = 'configure-login'; async function main(): Promise { const { values } = parseArgs({ options: { spec: { type: 'string' }, dry_run: { type: 'boolean', default: false }, }, strict: true, }); if (!values.spec) { printEnvelope(failGenerate(COMMAND, ['--spec is required'])); process.exit(1); } let raw: unknown; try { raw = JSON.parse(values.spec); } catch { printEnvelope(failGenerate(COMMAND, ['Invalid JSON in --spec'])); process.exit(1); } const v = await validate(raw); if (!v.valid || !v.spec || !v.layout) { printEnvelope(failGenerate(COMMAND, v.errors)); process.exit(1); } const spec = v.spec; const layout = v.layout; const projectRoot = resolve(spec.projectPath); const files = generate(spec, layout); const providersConfigured: string[] = []; if (spec.connectionTypes.local) providersConfigured.push('local'); if (spec.connectionTypes.microsoft) providersConfigured.push('microsoft'); if (spec.connectionTypes.google) providersConfigured.push('google'); if (spec.connectionTypes.entra) providersConfigured.push('entra'); const warnings = [...v.warnings]; const nextSteps: string[] = []; // The backend has no switch to disable self-registration — POST /api/auth/register // stays open (limited only by the license seat quota). Say so plainly so nobody // believes allowRegistration:false closes signup server-side. if (!spec.allowRegistration) { warnings.push( 'allowRegistration:false has NO backend effect — POST /api/auth/register stays open (limited only by the license seat quota). ' + 'To hide the "create account" link, scaffold a custom login page (scaffold-login-page); to block signup server-side, restrict it at the network layer.', ); } // The seeded default admin makes the API fail-fast on first boot until a real // password is set; warn when no initial admin was configured. if (!spec.initialAdmin) { warnings.push( 'No initialAdmin configured: the API fail-fasts on first boot until Security:InitialAdmin:Password is provided (the default seeded admin keeps a placeholder hash). Re-run with initialAdmin, or set the secret in appsettings.Local.json / user-secrets.', ); } if (values.dry_run) { printEnvelope( generateEnvelope(COMMAND, { data: { dryRun: true, files: files.map((f) => f.path), providersConfigured, registrationEnabled: spec.allowRegistration, secretsMode: spec.secretsMode, frontendAuth: spec.frontendAuth, apiDir: layout.apiDir, webDir: layout.webDir, }, warnings, }), ); process.exit(0); } const filesCreated: string[] = []; const filesModified: string[] = []; let secretsTarget: string | null = null; // 1) Backend appsettings — deep-merge JSON (preserves every other key). for (const file of files) { const abs = resolve(join(projectRoot, file.path)); if (file.path.endsWith('appsettings.Local.json')) secretsTarget = file.path; if (existsSync(abs)) { let existingJson: unknown = {}; try { existingJson = JSON.parse(readFileSync(abs, 'utf-8')); } catch { existingJson = {}; // malformed — fall back to the patch } const merged = deepMerge(existingJson, JSON.parse(file.content)); const out = JSON.stringify(merged, null, 2) + '\n'; if (!spec.force && readFileSync(abs, 'utf-8') === out) continue; // no drift writeFileSync(abs, out, 'utf-8'); filesModified.push(abs); } else { mkdirSync(dirname(abs), { recursive: true }); writeFileSync(abs, file.content, 'utf-8'); filesCreated.push(abs); } } // 2) Frontend SmartStackProvider config.auth — gated (default OFF). let providerInjected = false; let providerSkipped = false; if (spec.frontendAuth) { const mainAbs = resolve(join(projectRoot, layout.webDir, 'src', 'main.tsx')); if (!existsSync(mainAbs)) { providerSkipped = true; warnings.push(`frontendAuth: ${layout.webDir}/src/main.tsx not found — provider injection skipped.`); nextSteps.push(`Add into SmartStackProvider config={{ … }}: ${buildAuthLiteral(spec)}`); } else { const src = readFileSync(mainAbs, 'utf-8'); const { content, status } = injectProviderAuth(src, spec); if (status === 'injected' || status === 'updated') { if (content !== src) { writeFileSync(mainAbs, content, 'utf-8'); filesModified.push(mainAbs); } providerInjected = true; } else { providerSkipped = true; if (status === 'skipped-customised') { warnings.push('frontendAuth: main.tsx is marked @customised — provider injection skipped.'); } else { warnings.push('frontendAuth: could not find `config={{` in main.tsx — provider injection skipped.'); nextSteps.push(`Add into SmartStackProvider config={{ … }}: ${buildAuthLiteral(spec)}`); } } } } else { nextSteps.push( 'Frontend auth injection is OFF (frontendAuth:false). Enable it once @atlashub/smartstack exposes SmartStackProvider config.auth, then re-run with frontendAuth:true.', ); } // nextSteps / warnings if (secretsTarget) { nextSteps.push(`Secrets written to ${secretsTarget} (gitignored) — verify it is NOT committed.`); } if (spec.secretsMode === 'placeholders') { nextSteps.push('Placeholders mode: populate real secrets in appsettings.Local.json or dotnet user-secrets (nothing secret was written).'); } nextSteps.push('Restart `ss dev` (or the API) to apply the backend configuration.'); if (filesCreated.length === 0 && filesModified.length === 0) { nextSteps.unshift('Login configuration already up-to-date — no changes needed.'); } printEnvelope( generateEnvelope(COMMAND, { data: { providersConfigured, registrationEnabled: spec.allowRegistration, secretsMode: spec.secretsMode, apiDir: layout.apiDir, webDir: layout.webDir, providerInjected, providerSkipped, fileCount: filesCreated.length + filesModified.length, }, filesCreated, filesModified, warnings, nextSteps, }), ); } void main();