/** * scaffold-login-page/generate.ts — PURE spec -> GeneratedFile[] (zero I/O). * * Emits, into web/{appCode}-web/src/: * - auth/{ComponentName}.tsx the editable login override (dev-owned) * - extensions/login.generated.ts wiring: loginExtensions = { pages: { [PAGE_KEYS.LOGIN]: ... } } * * and (via index.ts) wires main.tsx to import + spread `loginExtensions` into the * provider's `extensions: {}` — coexisting with any `...vitrineExtensions` spread * already there (the two register different PAGE_KEYS). * * The component consumes the package's public seam: `useLoginForm()` drives the * real /api/auth/login flow (tokens land in httpOnly cookies, redirects + onboarding * handled inside the hook — the component only owns presentation), OAuth buttons * hit /api/auth/{microsoft,google}, and the Entra SSO button goes to /sso. Only the * providers + registration link the spec asks for are emitted — that is the UI-side * masking the backend cannot do. */ import type { GeneratedFile, ProjectLayout, ScaffoldLoginPageInput } from './types.js'; const BANNER = '/* Scaffolded by /login-config (scaffold-login-page) — editable. Re-running keeps your edits (use force to overwrite). */\n'; /** JSON string literal for embedding in generated source. */ const s = (v: string): string => JSON.stringify(v); function providerButton(label: string, target: string): string { return ` `; } // ============================================================================ // Login page component (dev-owned, editable; emitted once unless --force) // ============================================================================ export function buildLoginComponent(spec: ScaffoldLoginPageInput): string { const { providers, branding } = spec; const comp = spec.componentName; const title = branding?.title ?? branding?.appName ?? 'Connexion'; const imports = [ `import type { ReactElement } from 'react';`, `import type { PageProps } from '@atlashub/smartstack';`, ]; if (providers.local) imports.push(`import { useLoginForm } from '@atlashub/smartstack';`); // Branding header (text passed as JSX expressions so any character is safe). const brandingParts: string[] = []; if (branding?.logoUrl) { brandingParts.push(` {${s(title)}}`); } brandingParts.push(`

{${s(title)}}

`); if (branding?.subtitle) { brandingParts.push(`

{${s(branding.subtitle)}}

`); } // Email/password form (only when the local provider is shown). let formBlock = ''; if (providers.local) { const forgot = spec.showForgotPassword ? `
Mot de passe oublié ?
` : ''; formBlock = ` {error ? (
{error.message}
) : null}
setEmail(e.target.value)} className="w-full rounded-lg border border-[var(--border-color)] bg-[var(--bg-secondary)] px-3 py-2 focus:outline-none focus:ring-2 focus:ring-primary-500" />
setPassword(e.target.value)} className="w-full rounded-lg border border-[var(--border-color)] bg-[var(--bg-secondary)] px-3 py-2 pr-16 focus:outline-none focus:ring-2 focus:ring-primary-500" />
${forgot}
`; } // External providers (OAuth redirect / Entra SSO). const extButtons: string[] = []; if (providers.microsoft) extButtons.push(providerButton('Continuer avec Microsoft', '/api/auth/microsoft')); if (providers.google) extButtons.push(providerButton('Continuer avec Google', '/api/auth/google')); if (providers.entra) extButtons.push(providerButton('Connexion SSO (Entra ID)', '/sso')); let externalBlock = ''; if (extButtons.length > 0) { const divider = providers.local ? `
ou
` : ''; externalBlock = `${divider}
${extButtons.join('\n')}
`; } // Registration link (UI affordance only — backend signup stays open regardless). const registerBlock = spec.allowRegistration ? `

Pas encore de compte ?{' '} Créer un compte

` : ''; const hookLine = providers.local ? ` const { email, setEmail, password, setPassword, showPassword, setShowPassword, isLoading, error, handleSubmit } = useLoginForm();\n\n` : ''; return `${BANNER}${imports.join('\n')} export function ${comp}(_props?: PageProps): ReactElement { ${hookLine} return (
${brandingParts.join('\n')}
${formBlock}${externalBlock}${registerBlock}
); } `; } // ============================================================================ // Wiring module (regenerated each run) // ============================================================================ export function buildWiringFile(spec: ScaffoldLoginPageInput): string { const comp = spec.componentName; return `/* AUTO-GENERATED by /login-config (scaffold-login-page) — regenerated on each run. Do not edit. */ import { PAGE_KEYS } from '@atlashub/smartstack'; import type { ExtensionConfig } from '@atlashub/smartstack'; import { ${comp} } from '../auth/${comp}'; export const loginExtensions: ExtensionConfig = { pages: { [PAGE_KEYS.LOGIN]: ${comp} } }; `; } // ============================================================================ // main.tsx wiring (idempotent string transform — coexists with vitrine) // ============================================================================ export const LOGIN_IMPORT_LINE = `import { loginExtensions } from './extensions/login.generated';`; export interface InjectResult { content: string; status: 'applied' | 'unchanged' | 'not-found' | 'skipped-customised'; } /** * Ensure main.tsx (a) imports the wiring module and (b) spreads `loginExtensions` * into the provider's `extensions: {…}`. Handles an empty `extensions: {}` AND a * non-empty one (e.g. already carrying `...vitrineExtensions`) — the spread is * inserted right after the opening brace so both coexist. Idempotent; atomic * (bails to 'not-found' without writing a dangling import if no extensions block * is present). */ export function injectLoginExtensions(source: string): InjectResult { if (/\/\/\s*@customised/.test(source)) { return { content: source, status: 'skipped-customised' }; } const hasImport = source.includes(LOGIN_IMPORT_LINE); const hasSpread = source.includes('...loginExtensions'); if (hasImport && hasSpread) return { content: source, status: 'unchanged' }; let content = source; if (!hasSpread) { if (/extensions:\s*\{\s*\}/.test(content)) { content = content.replace(/extensions:\s*\{\s*\}/, 'extensions: { ...loginExtensions }'); } else if (/extensions:\s*\{/.test(content)) { content = content.replace(/extensions:\s*\{/, 'extensions: { ...loginExtensions,'); } else { // No extensions block to spread into — don't leave a dangling import. return { content: source, status: 'not-found' }; } } if (!hasImport) { content = `${LOGIN_IMPORT_LINE}\n${content}`; } return { content, status: 'applied' }; } export function snippetForManualWiring(): string { return ( 'Wire the login override manually in web/-web/src/main.tsx: add ' + `\`${LOGIN_IMPORT_LINE}\` near the other imports, and change ` + '`extensions: {}` to `extensions: { ...loginExtensions }` (or add `...loginExtensions` to the existing spread) in the SmartStackProvider config.' ); } // ============================================================================ // Top-level generate // ============================================================================ export function generate(spec: ScaffoldLoginPageInput, layout: ProjectLayout): GeneratedFile[] { const srcPrefix = `${layout.webDir}/src`; const comp = spec.componentName; return [ { path: `${srcPrefix}/auth/${comp}.tsx`, content: buildLoginComponent(spec), strategy: 'skip-if-exists', }, { path: `${srcPrefix}/extensions/login.generated.ts`, content: buildWiringFile(spec), strategy: 'overwrite', }, ]; }