/** * configure-login/generate.ts — Pure builders (no I/O). * * Split out so every transform is unit-testable in isolation: * - buildBackendPatches : appsettings patch, partitioned into committed (no * secrets in local-file mode) + local (secrets only). * - buildAuthLiteral : the `auth: {...}` object literal text for main.tsx. * - injectProviderAuth : idempotent, marker-based insertion into main.tsx. * - generate : assembles the JSON appsettings GeneratedFile[]. * * index.ts owns all disk I/O and the deep-merge-on-write (via lib/json-merge). */ import type { ConfigureLoginInput, GeneratedFile, ProjectLayout } from './types.js'; type Json = Record; // ───────────────────────────────────────────────────────────────────────────── // Backend appsettings patch (secret-safe partition) // ───────────────────────────────────────────────────────────────────────────── /** * Build the appsettings patch, partitioned by secrecy. * - `committed` is deep-merged into appsettings.json (committed). In 'local-file' * mode it carries NO secrets (ClientSecret / InitialAdmin.Password are omitted, * so the existing empty stubs survive). In 'placeholders' mode secrets are * written as "" (still nothing real committed). * - `local` is deep-merged into appsettings.Local.json (gitignored) and carries * only the secret values. `null` when there is nothing secret to write. */ export function buildBackendPatches(spec: ConfigureLoginInput): { committed: Json; local: Json | null } { const committed: Json = {}; const local: Json = {}; const ct = spec.connectionTypes; const placeholders = spec.secretsMode === 'placeholders'; // Authentication — Microsoft / Google OAuth. const authCommitted: Json = {}; const authLocal: Json = {}; const addProvider = ( name: 'Microsoft' | 'Google', creds?: { clientId: string; clientSecret: string; secretExpiresAt?: string }, ): void => { if (!creds) return; // Enabled is always written, not only when false: the key is what lets an operator switch the // provider off later WITHOUT deleting the credentials, and a key absent from appsettings is a // key nobody discovers. The backend defaults it to true, so writing true changes nothing. const pub: Json = { Enabled: creds.enabled, ClientId: creds.clientId }; if (creds.secretExpiresAt) pub.SecretExpiresAt = creds.secretExpiresAt; if (placeholders) { authCommitted[name] = { ...pub, ClientSecret: '' }; } else { authCommitted[name] = pub; // ClientSecret omitted → existing "" stub preserved authLocal[name] = { ClientSecret: creds.clientSecret }; } }; addProvider('Microsoft', ct.microsoft); addProvider('Google', ct.google); // EntraSso is bound at Authentication:EntraSso (EntraSsoSettings.SectionName), // so it lives UNDER Authentication, not at the top level. clientId is public // (the SPA app registration id), no secret. The accepted-tenant whitelist is // NOT stored here: EntraSsoSettings dropped AllowedTenantIds — it moved to // Authentication:Microsoft:AllowedTenants (shared with social Microsoft login, // see MicrosoftTenantPolicy), so we route it there instead. // // Since package 3.69.0 that list is ALSO editable at // /administration/configuration/authentication, stored on auth_ProviderCredentials. // Writing it HERE still wins — but only when it names real directories: ["common"] // is both the shipped default and this file's usual value, so it counts as // "unspecified" and lets the stored list through. Emit it only when the client // really means to restrict, or the screen's field will look inert. // // And it no longer decides whether an ADDRESS is proven. That rests on the // domain-bound claim (upn / preferred_username), which Azure only issues on a // DNS-verified domain — so a client who leaves this list open still gets accounts // linked and confirmed. See docs/architecture/email-verification.md §4. // // Since package 3.66.0 this block is ALSO what the browser gets: GET /api/config/features serves // ClientId + Authority so MSAL can run. There is no frontend variable any more — writing one here // would put the same value in two places, free to diverge. `Authority` is optional: left out, the // package derives it from MetadataAddress. if (ct.entra) { const entra: Json = { Enabled: ct.entra.enabled, ClientId: ct.entra.clientId }; if (ct.entra.authority) entra.Authority = ct.entra.authority; authCommitted.EntraSso = entra; if (ct.entra.allowedTenantIds) { const microsoft = (authCommitted.Microsoft as Json | undefined) ?? {}; microsoft.AllowedTenants = ct.entra.allowedTenantIds; authCommitted.Microsoft = microsoft; } } // The local password door itself is never switched off — see ConnectionTypesSchema. What IS // configurable is whether accounts managed by the DIRECTORY may still use it: a federated account // keeping a usable local password survives its own offboarding. // // Emitted ONLY when it departs from the default. The generated appsettings.json template already // carries `AllowForFederatedAccounts: true`, so the key is discoverable in every project without // us restating it — and this generator writes what you asked for, nothing else (a local-only run // must still carry no Authentication section at all). if (spec.allowLocalPasswordForFederatedAccounts === false) { authCommitted.LocalPassword = { AllowForFederatedAccounts: false }; } if (Object.keys(authCommitted).length > 0) committed.Authentication = authCommitted; if (Object.keys(authLocal).length > 0) local.Authentication = authLocal; // Security — InitialAdmin only. There is deliberately NO EnableSelfRegistration // key: the backend (SecuritySettings) has no such flag and /api/auth/register // is always open (limited only by the license seat quota). Writing it would be // a dead, misleading key. The Security section is emitted only when an initial // admin is supplied. (The default seeded admin makes the API fail-fast on first // boot until Security:InitialAdmin:Password is set — see index.ts nextSteps.) if (spec.initialAdmin) { const admin: Json = { Email: spec.initialAdmin.email }; if (spec.initialAdmin.requirePasswordChange !== undefined) { admin.RequirePasswordChange = spec.initialAdmin.requirePasswordChange; } if (placeholders) { admin.Password = ''; } else { local.Security = { InitialAdmin: { Password: spec.initialAdmin.password } }; } committed.Security = { InitialAdmin: admin }; } // Email — sender + the chosen provider's settings block. Without the provider // block no mail is ever sent (the framework needs the host / api-key / conn // string). Provider secrets (SMTP password, SendGrid key, ACS connection // string) are partitioned into `local`; public fields stay committed. if (spec.email) { const e = spec.email; const email: Json = { Enabled: e.enabled, Provider: e.provider, FromEmail: e.fromEmail, FromName: e.fromName, }; const emailLocal: Json = {}; if (e.smtp) { const smtp: Json = { Host: e.smtp.host }; if (e.smtp.port !== undefined) smtp.Port = e.smtp.port; if (e.smtp.username !== undefined) smtp.Username = e.smtp.username; if (e.smtp.useSsl !== undefined) smtp.UseSsl = e.smtp.useSsl; if (e.smtp.senderAddress !== undefined) smtp.SenderAddress = e.smtp.senderAddress; if (e.smtp.password !== undefined) { if (placeholders) smtp.Password = ''; else emailLocal.Smtp = { Password: e.smtp.password }; } email.Smtp = smtp; } if (e.sendGrid) { if (placeholders) email.SendGrid = { ApiKey: '' }; else emailLocal.SendGrid = { ApiKey: e.sendGrid.apiKey }; } if (e.azureAcs) { const acs: Json = { SenderAddress: e.azureAcs.senderAddress }; if (placeholders) acs.ConnectionString = ''; else emailLocal.AzureAcs = { ConnectionString: e.azureAcs.connectionString }; email.AzureAcs = acs; } committed.Email = email; if (Object.keys(emailLocal).length > 0) local.Email = emailLocal; } return { committed, local: Object.keys(local).length > 0 ? local : null }; } // ───────────────────────────────────────────────────────────────────────────── // Frontend SmartStackProvider config.auth injection // ───────────────────────────────────────────────────────────────────────────── const AUTH_START = '/* @login-config:auth */'; const AUTH_END = '/* @end:login-config:auth */'; const AUTH_BLOCK_RE = /\/\* @login-config:auth \*\/[\s\S]*?\/\* @end:login-config:auth \*\//; export type InjectStatus = 'injected' | 'updated' | 'skipped-customised' | 'not-found'; function isCustomised(source: string): boolean { const head = source.trimStart().slice(0, 100); return head.startsWith('/* @customised') || head.startsWith('// @customised'); } /** The `auth: {...}` object literal text injected into the provider config. */ export function buildAuthLiteral(spec: ConfigureLoginInput): string { const parts: string[] = []; // No `entra` here, deliberately. The client id and authority reach the browser from the SERVER // (GET /api/config/features), which is what lets an administrator rotate them from // /administration/configuration/authentication and see the change on the next page load. Putting // them in the bundle too would recreate the split that made Entra SSO unconfigurable in the first // place: two copies of one value, free to diverge in silence. parts.push(`allowRegistration: ${spec.allowRegistration}`); if (spec.branding) { const b = spec.branding; const bParts: string[] = []; if (b.appName) bParts.push(`appName: ${JSON.stringify(b.appName)}`); if (b.logoUrl) bParts.push(`logoUrl: ${JSON.stringify(b.logoUrl)}`); if (b.heroTitle) bParts.push(`heroTitle: ${JSON.stringify(b.heroTitle)}`); if (b.heroSubtitle) bParts.push(`heroSubtitle: ${JSON.stringify(b.heroSubtitle)}`); if (bParts.length) parts.push(`branding: { ${bParts.join(', ')} }`); } return `auth: { ${parts.join(', ')} }`; } /** * Idempotently insert/refresh the `auth` property inside the SmartStackProvider * `config={{ … }}`. Re-runs replace the marked block in place (never duplicate). * Returns the (possibly unchanged) content plus a status the caller maps to * warnings/nextSteps. */ export function injectProviderAuth( source: string, spec: ConfigureLoginInput, ): { content: string; status: InjectStatus } { if (isCustomised(source)) return { content: source, status: 'skipped-customised' }; const block = `${AUTH_START} ${buildAuthLiteral(spec)}, ${AUTH_END}`; if (AUTH_BLOCK_RE.test(source)) { return { content: source.replace(AUTH_BLOCK_RE, block), status: 'updated' }; } const m = /config=\{\{/.exec(source); if (!m) return { content: source, status: 'not-found' }; const at = m.index + m[0].length; return { content: source.slice(0, at) + block + source.slice(at), status: 'injected' }; } // ───────────────────────────────────────────────────────────────────────────── // Entra SSO needs no frontend variable // ───────────────────────────────────────────────────────────────────────────── // // `buildEnvAppend` lived here and wrote `# VITE_MSAL_CLIENT_ID/-AUTHORITY/-REDIRECT_URI` into the // client's .env.example. Those keys could never work: Vite substitutes `import.meta.env.VITE_*` when // the bundle CONTAINING the expression is built — that is @atlashub/smartstack itself, not the // client app — so the value was frozen at publish time and a client rebuild changed nothing. // // Since package 3.66.0 the browser reads `clientId` and `authority` from GET /api/config/features, // which is served from `Authentication:EntraSso` written just above. One place to configure, and it // takes effect on the next page load. // ───────────────────────────────────────────────────────────────────────────── // File assembly // ───────────────────────────────────────────────────────────────────────────── /** * Pure: build the JSON appsettings GeneratedFile[] (paths relative to * projectPath, POSIX slashes). The frontend injection + .env.example are handled * in index.ts because they transform existing on-disk content. */ export function generate(spec: ConfigureLoginInput, layout: ProjectLayout): GeneratedFile[] { const apiDir = layout.apiDir.replace(/\\/g, '/').replace(/\/+$/, ''); const { committed, local } = buildBackendPatches(spec); const files: GeneratedFile[] = [ { path: `${apiDir}/appsettings.json`, content: JSON.stringify(committed, null, 2) + '\n', strategy: 'deep-merge-json', }, ]; if (local) { files.push({ path: `${apiDir}/appsettings.Local.json`, content: JSON.stringify(local, null, 2) + '\n', strategy: 'deep-merge-json', }); } return files; }