#!/usr/bin/env node /** * cli:scaffold-login-page — Scaffold an editable login page OVERRIDE into a * generated SmartStack client app, the UI counterpart to configure-login. * * Writes an editable React component the developer OWNS, and wires it through the * package's public override seam: * - auth/{ComponentName}.tsx → the login page (uses useLoginForm + PageProps) * - extensions/login.generated.ts → loginExtensions = { pages: { [PAGE_KEYS.LOGIN]: ... } } * - main.tsx → import the wiring + spread loginExtensions into the provider config * * Works as soon as @atlashub/smartstack >= 3.55.0 is installed (the seam ships in * the package). It renders only the providers + registration link the spec asks * for — the UI-side masking the backend cannot do. */ 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, injectLoginExtensions, snippetForManualWiring } from './generate.js'; import { generateEnvelope, failGenerate, printEnvelope } from '../../../lib/output.js'; const COMMAND = 'scaffold-login-page'; 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 providersShown = Object.entries(spec.providers) .filter(([, on]) => on) .map(([k]) => k); const warnings = [...v.warnings]; if (v.versionWarning) warnings.push(v.versionWarning); const nextSteps: string[] = []; // Honesty: the registration link is a UI affordance only. if (spec.allowRegistration) { warnings.push( 'The "create account" link is shown, but the backend cannot disable signup either way — /api/auth/register stays open (license-quota limited).', ); } if (spec.providers.entra) { nextSteps.push( 'Entra SSO button → /sso works once Authentication:EntraSso holds a ClientId — in appsettings, or ' + 'filled from /administration/configuration/authentication. The browser reads it from ' + 'GET /api/config/features, so a change takes effect on the next page load; there is no ' + 'frontend variable and nothing to rebuild.', ); nextSteps.push( 'This page decides at SCAFFOLD time which buttons to show. The button therefore stays visible ' + 'even if Entra is later switched off server-side — /sso then explains why instead of signing ' + 'the user in. To follow the server instead, read isAuthProviderEnabled("entra") from ' + 'useFeatureConfigOptional(), as the package\'s own login page does.', ); } if (values.dry_run) { printEnvelope( generateEnvelope(COMMAND, { data: { dryRun: true, files: files.map((f) => `${f.path} [${f.strategy}]`), componentName: spec.componentName, providersShown, allowRegistration: spec.allowRegistration, webDir: layout.webDir, smartStackVersion: v.smartStackVersion, }, warnings, }), ); process.exit(0); } const filesCreated: string[] = []; const filesModified: string[] = []; for (const file of files) { const abs = resolve(join(projectRoot, file.path)); const exists = existsSync(abs); if (file.strategy === 'skip-if-exists') { if (exists && !spec.force) continue; // dev-owned — preserve edits if (exists && readFileSync(abs, 'utf-8') === file.content) continue; mkdirSync(dirname(abs), { recursive: true }); writeFileSync(abs, file.content, 'utf-8'); (exists ? filesModified : filesCreated).push(abs); } else { // overwrite (generated wiring) if (exists && !spec.force && readFileSync(abs, 'utf-8') === file.content) continue; mkdirSync(dirname(abs), { recursive: true }); writeFileSync(abs, file.content, 'utf-8'); (exists ? filesModified : filesCreated).push(abs); } } // Wire main.tsx (import the generated module + spread loginExtensions). const mainAbs = resolve(join(projectRoot, layout.webDir, 'src', 'main.tsx')); let providerWired = false; let providerSkipped = false; if (!existsSync(mainAbs)) { providerSkipped = true; warnings.push(`main.tsx not found at ${layout.webDir}/src/main.tsx — wiring skipped.`); nextSteps.push(snippetForManualWiring()); } else { const src = readFileSync(mainAbs, 'utf-8'); const { content, status } = injectLoginExtensions(src); if (status === 'applied') { writeFileSync(mainAbs, content, 'utf-8'); filesModified.push(mainAbs); providerWired = true; } else if (status === 'unchanged') { providerWired = true; } else if (status === 'skipped-customised') { providerSkipped = true; warnings.push('main.tsx is marked `// @customised` — wiring skipped.'); nextSteps.push(snippetForManualWiring()); } else { providerSkipped = true; warnings.push('Could not find an `extensions: {…}` block in main.tsx — wiring skipped (was it customized?).'); nextSteps.push(snippetForManualWiring()); } } if (!v.smartStackVersion) { nextSteps.push( 'Could not read the installed @atlashub/smartstack version — run `npm install` in the web app and ensure it is >= 3.55.0 (login-override seam).', ); } nextSteps.push( `Edit web/${layout.webDir.split('/').pop()}/src/auth/${spec.componentName}.tsx to taste, then restart \`ss dev\` (or Vite) and open /login.`, ); if (filesCreated.length === 0 && filesModified.length === 0) { nextSteps.unshift('Login page already up-to-date — no changes needed.'); } printEnvelope( generateEnvelope(COMMAND, { data: { componentName: spec.componentName, providersShown, allowRegistration: spec.allowRegistration, webDir: layout.webDir, smartStackVersion: v.smartStackVersion, providerWired, providerSkipped, fileCount: filesCreated.length + filesModified.length, }, filesCreated, filesModified, warnings, nextSteps, }), ); } void main();