---
name: login-config
description: >
  Customize the login / authentication experience of a generated SmartStack
  client app, through two complementary levers. (1) configure-login WRITES
  CONFIGURATION the package reads: connection providers (Local password,
  Microsoft / Google OAuth, Entra ID SSO), the initial admin, and the email
  provider — into secret-safe appsettings. (2) scaffold-login-page SCAFFOLDS an
  editable login page OVERRIDE via the package's `extensions.pages[PAGE_KEYS.LOGIN]`
  seam (branding + UI-side masking of providers and the sign-up link). Neither
  edits the package; the default login UI ships in @atlashub/smartstack.
  Conversational, then invokes the colocated CLIs.
argument-hint: "[describe the login you want, e.g. 'local + Microsoft, no signup, branded Acme']"
allowed-tools: Read, Grep, Glob, Bash
---

# login-config — Customize the login / auth experience

The default login page, the `/api/auth/*` endpoints and the registration flow
live in the **SmartStack package** the client consumes (`@atlashub/smartstack`
npm + `SmartStack.*` NuGet). Customizing `/login` therefore means two things,
and this skill ships a CLI for each:

| Lever | CLI | What it does |
|------|-----|--------------|
| **Configure** | `configure-login` | Writes backend `appsettings` (providers, initial admin, email) — secret-safe. |
| **Customize the page** | `scaffold-login-page` | Emits an editable login page override (`extensions.pages[PAGE_KEYS.LOGIN]`) — branding + which providers / sign-up link are shown. |

Use them together: `configure-login` enables a provider **server-side**;
`scaffold-login-page` decides whether its button is **shown** and how the page
**looks**.

## Ground truth (verified against SmartStack.app, package 3.55.0)

- The package ships `LoginPage` / `RegisterPage`. Since **3.66.0** the default
  login renders its provider buttons from the SERVER: `GET /api/config/features`
  (public) returns `authProviders`, and a provider appears only when it is
  switched on AND holds credentials. Before 3.66.0 the Microsoft and Google
  buttons were hardcoded, so a project that had configured neither still showed
  two buttons leading to an error — masking them required a page override.
- **Disabling a provider is therefore a configuration matter now, not a
  scaffolding one.** `Authentication:{Microsoft,Google,EntraSso}:Enabled`
  (default `true`) turns one off *while keeping its credentials*; emptying the
  credentials also disables it, as it always did.
- **The local password form has no switch, by design.** It is how the initial
  administrator signs in and the only provider depending on no external system.
  `connectionTypes.local` accepts only `true`. What IS configurable is whether
  *directory-managed* accounts may use it — see
  `allowLocalPasswordForFederatedAccounts`.
- `SmartStackConfig` still does **not** accept an `auth` key — keep
  `frontendAuth` off. Branding and the sign-up link still need an override.
- There is **no** self-registration flag. `POST /api/auth/register` is always
  open, limited only by the license seat quota. Nothing in config closes it.
- The override seam IS real and public: `extensions.pages[PAGE_KEYS.LOGIN]`
  replaces `/login`; `useLoginForm()` (the real login flow) and `PageProps` are
  exported by `@atlashub/smartstack >= 3.55.0`.

## What takes effect when (be honest with the user)

| Capability | How |
|---|---|
| Enable MS / Google / Entra **backend** | ✅ `configure-login` → appsettings |
| **Disable** a provider — button gone AND endpoint dead | ✅ `configure-login` → `Authentication:*:Enabled: false` (package ≥ 3.66.0; the default page reads the server) |
| **Disable** the local password form | ❌ By design — it is the initial administrator's way in. Close it to *federated* accounts instead (`allowLocalPasswordForFederatedAccounts: false`) |
| Initial admin + email (confirmation / reset) | ✅ `configure-login` → appsettings (provider block required, see below) |
| **Brand** the login, **hide** "create account" | ✅ `scaffold-login-page` (you render only what you ask for) |
| **Disable** account creation server-side | ❌ Not supported — the backend has no flag; restrict at the network layer if truly required |
| Frontend `config.auth` injection (`frontendAuth:true`) | ❌ `SmartStackConfig` has no `auth` key yet — keep OFF (injecting it breaks the client TS build) |

### appsettings is no longer the only place credentials can live

Since package 3.66.0 an administrator can hold the providers' credentials **in the database** and
rotate them from `/administration/configuration/authentication`, without editing JSON on the host.
`configure-login` still writes appsettings, and appsettings still WINS — so tell the user which one
they want before running the CLI:

| The client wants | Use |
|---|---|
| Providers pinned by the deployment (IaC, identical across environments) | `configure-login` → appsettings. The admin screen shows them read-only. |
| An administrator able to rotate the secret themselves, from the UI | Leave `Authentication:<Provider>` **out** of appsettings, and let them fill the screen. |

Precedence, in order: `Enabled: false` in appsettings wins outright (the break-glass), then a
`ClientId` in appsettings, then the database row. Writing a `ClientId` into appsettings therefore
makes the admin screen read-only for that provider — which is a legitimate choice, but a surprising
one if nobody said so.

Two things are worth passing on: the database row is **cryptographically inert outside its own
environment** (its DataProtection key stays on the host, so a restored dump carries a secret nobody
can read — by design), and storing credentials there requires `DataProtection:Certificate`, without
which the package refuses to start outside a developer machine.

### Entra SSO is configured server-side only

Say this plainly, because the CLI used to say the opposite: **there is no frontend variable for
Entra.** Write `Authentication:EntraSso:{Enabled,ClientId}` (and `Authority` for a single-tenant
directory), or let an administrator fill the screen — the browser reads both from
`GET /api/config/features`, the anonymous endpoint the login page already calls to know which
buttons to draw.

| Key | Meaning |
|---|---|
| `ClientId` | The Azure App Registration id. Not a secret: in the code+PKCE flow the SPA runs, MSAL puts it in clear text in the URL of every `authorize` request. |
| `Authority` | What the BROWSER signs in against, e.g. `https://login.microsoftonline.com/{tenantId}`. **Leave empty** to derive it from `MetadataAddress` — writing the tenant twice is how the two come to disagree. |
| `MetadataAddress` | The OIDC discovery document the BACKEND validates tokens against. |

Until package 3.66.0 the browser read `VITE_MSAL_CLIENT_ID`, and this skill wrote it into
`.env.example`. It could never work: Vite substitutes `import.meta.env.VITE_*` when the bundle
CONTAINING the expression is built — `@atlashub/smartstack` itself, not the client app — so the value
was frozen at publish time and Rollup then dropped the whole MSAL branch as dead code. A client
rebuilding their own frontend changed nothing, without any warning. Do not reintroduce a `VITE_MSAL_*`
key, and do not put the client id into `SmartStackProvider config` either: two copies of one value
are free to diverge, and that divergence is exactly what made the feature unusable.

### When does a configuration change reach the running server?

Tell the user this — the skill writes a file, it does not restart anything.

| The value lives in | Taken into account |
|---|---|
| `appsettings*.json` (what `configure-login` writes) | **Immediately**, no restart: package ≥ 3.66.0 registers the OAuth schemes unconditionally and reads their credentials through `IOptionsMonitor` |
| An environment variable (`Authentication__Google__ClientSecret`) | **At the next start** — that configuration provider emits no change token. Azure App Service recycles the app on its own when an app setting changes |
| `Authentication:EntraSso:{ClientId,Authority}` (what the Entra button needs) | **At the next page load** — the browser reads them from `GET /api/config/features`, so there is no frontend variable and nothing to rebuild |

Rotating a secret without downtime: create the second secret in the portal (Azure AD and Google both
accept two valid secrets at once), write it, verify a real sign-in, and only then revoke the first.

`SecretExpiresAt` is a date the operator declares by hand — no provider tells the platform when its
secret dies. The package re-checks it at startup, on every configuration change and once a day, and
logs an error once expired, a warning within 30 days. An absent value stays silent: it means
"not declared", never "does not expire".

---

## CLI 1 — configure-login (backend configuration)

Writes (deep-merged, idempotent) the configuration the framework reads:

```bash
npx --prefer-offline tsx skills/login-config/cli/configure-login/index.ts \
  --spec '{
    "projectPath": "/abs/project",
    "appCode": "demo",
    "connectionTypes": {
      "local": true,
      "microsoft": { "clientId": "…", "clientSecret": "…" },
      "entra": { "clientId": "…", "allowedTenantIds": ["…"] }
    },
    "allowRegistration": false,
    "branding": { "appName": "Acme Portal" },
    "initialAdmin": { "email": "admin@acme.io", "password": "…", "requirePasswordChange": true },
    "email": {
      "provider": "Smtp",
      "fromEmail": "no-reply@acme.io",
      "fromName": "Acme",
      "smtp": { "host": "smtp.office365.com", "port": 587, "username": "no-reply@acme.io", "password": "…", "useSsl": true }
    },
    "secretsMode": "local-file",
    "frontendAuth": false
  }'
```

Add `--dry_run` to preview target files without writing.

### Spec fields

| Field | Required | Notes |
|---|---|---|
| `projectPath`, `appCode` | ✅ | generated app root + app code |
| `connectionTypes` | ✅ | `{ local, microsoft?, google?, entra? }`. Written as `Authentication:Microsoft/Google` + `Authentication:EntraSso` (the Entra tenant whitelist goes to `Authentication:Microsoft:AllowedTenants`, shared with social Microsoft login — since 3.69.0 it is **also** editable at `/administration/configuration/authentication`, and what is written here wins only when it names real directories: `["common"]` counts as "unspecified" and lets the stored list through. It decides **who may sign in**, never whether an address is proven — that rests on the domain-bound claim). `local` accepts **only `true`** — see ground truth. Each external provider takes `enabled` (default `true`): `false` writes `Enabled: false` and **keeps the credentials**. |
| `allowRegistration` | ✅ | **UI signal only** — NOT written to appsettings (no backend flag exists). Used by `scaffold-login-page` + the gated front literal. `false` emits a warning. |
| `allowLocalPasswordForFederatedAccounts` | — | default `true`. `false` writes `Authentication:LocalPassword:AllowForFederatedAccounts: false` — an Entra-linked account can then no longer sign in with its local password (`FEDERATED_ACCOUNT_LOCAL_PASSWORD_DISABLED`). Nothing is written when left at `true`: the appsettings template already carries the default. |
| `branding` | — | `appName`, `logoUrl`, `heroTitle`, `heroSubtitle` (front only; takes effect via `scaffold-login-page` or once the package reads it) |
| `initialAdmin` | — | `{ email, password, requirePasswordChange? }`. **Strongly recommended:** the API fail-fasts on first boot until `Security:InitialAdmin:Password` is set. |
| `email` | — | `{ enabled?, provider, fromEmail, fromName, smtp?, sendGrid?, azureAcs? }`. `provider` ∈ `Development\|Smtp\|SendGrid\|AzureAcs`. **The matching provider block is required** (`smtp.host` / `sendGrid.apiKey` / `azureAcs.connectionString`) or no mail is sent. |
| `secretsMode` | — | `local-file` (default) \| `placeholders`. Secrets (OAuth secrets, admin password, SMTP password, SendGrid key, ACS connection string) → gitignored `appsettings.Local.json`. |
| `frontendAuth` | — | default **false** — keep OFF (see ground truth). |
| `force` | — | rewrite files even when unchanged |

### Conversational flow

1. **Locate the project** — confirm `projectPath` + `appCode`; the CLI auto-detects the API folder + `web/{appCode}-web`.
2. **Connection types** — which external providers to offer: Microsoft / Google / Entra. MS/Google need `clientId` + `clientSecret`; Entra needs `clientId` (+ optional `authority`, `allowedTenantIds`).
   - **State plainly that the local password form stays on** — it is how the initial administrator signs in, and the only provider depending on no external system. Do not offer it as a choice; if the user asks to remove it, explain why and offer step 2b instead.
   - A provider the user does **not** want: simply omit it. To turn off one that is already configured *without losing its credentials*, pass it with `enabled: false`.
2b. **Local password for directory-managed accounts** — ask this **only when Entra is enabled**; it means nothing otherwise. Put the trade-off in one breath, then take the answer:
   > "Un compte lié à Entra doit-il pouvoir encore se connecter avec son mot de passe local ?
   > **Oui** (défaut) : vous gardez une porte de secours le jour où l'annuaire est injoignable.
   > **Non** : Entra devient le seul chemin — un compte fédéré ne survit plus à son offboarding,
   > mais assurez-vous d'avoir un compte de secours **non lié** à l'annuaire avant de fermer."
   → `allowLocalPasswordForFederatedAccounts`.
3. **Initial admin** — collect `initialAdmin` (recommend it; warn about the first-boot fail-fast).
4. **Email** — pick the provider AND collect its block (SMTP host/port/user/pwd, or SendGrid key, or ACS connection string), else mail won't send.
5. **Registration** — set `allowRegistration`; be explicit that it cannot be disabled server-side (network layer only). It drives the sign-up link in a scaffolded page.
6. **Secrets** — default `local-file`; if `.gitignore` doesn't exclude `appsettings.Local.json`, the CLI downgrades to placeholders and warns.
7. **Theme** — delegate to `/scaffold-theme` (never write `index.css` here).
8. **Invoke** `configure-login`; relay `warnings` + `nextSteps`.

---

## CLI 2 — scaffold-login-page (editable login override)

Emits an editable login page the developer OWNS and wires it through the
override seam — branding + only the providers / sign-up link you ask for:

```bash
npx --prefer-offline tsx skills/login-config/cli/scaffold-login-page/index.ts \
  --spec '{
    "projectPath": "/abs/project",
    "appCode": "demo",
    "componentName": "CustomLoginPage",
    "providers": { "local": true, "microsoft": true, "entra": false },
    "allowRegistration": false,
    "branding": { "appName": "Acme Portal", "subtitle": "Connectez-vous", "logoUrl": "/logo.svg" }
  }'
```

Emits (relative to `web/{appCode}-web/src/`):
- `auth/{ComponentName}.tsx` — the editable page (`skip-if-exists`; uses `useLoginForm()` + `PageProps`). Email/password is wired through `useLoginForm().handleSubmit` (tokens land in httpOnly cookies, redirects + onboarding handled by the hook — you only own presentation). OAuth → `/api/auth/{microsoft,google}`, Entra SSO → `/sso`.
- `extensions/login.generated.ts` — `loginExtensions = { pages: { [PAGE_KEYS.LOGIN]: … } }` (`overwrite`).
- wires `main.tsx`: imports the module + spreads `...loginExtensions` into `extensions: {}` (idempotent; **coexists** with `...vitrineExtensions` from `/site-vitrine`).

### Spec fields

| Field | Required | Notes |
|---|---|---|
| `projectPath`, `appCode` | ✅ | generated app root + app code |
| `providers` | ✅ | `{ local, microsoft?, google?, entra? }` — which affordances to RENDER (≥ 1). `local` ⇒ email/password form. |
| `componentName` | — | PascalCase, default `CustomLoginPage` |
| `allowRegistration` | — | default false; shows the "create account" link (UI affordance only) |
| `branding` | — | `appName`, `logoUrl`, `title`, `subtitle` |
| `registerHref` / `forgotPasswordHref` | — | default `/register` / `/forgot-password` |
| `showForgotPassword` | — | default true |
| `force` | — | overwrite the page even if it exists (loses dev edits) |

Non-blocking version guard: the seam needs `@atlashub/smartstack >= 3.55.0`
(warns, never refuses). The Entra button needs `Authentication:EntraSso:ClientId` **server-side** —
nothing at build time.

⚠ This page decides which buttons to render at SCAFFOLD time. A button therefore survives the
provider being switched off later: `/sso` then explains why instead of signing the user in. To follow
the server instead, read `isAuthProviderEnabled('entra')` from `useFeatureConfigOptional()`, the way
the package's own login page does.

## Idempotency

- **appsettings** are **deep-merged** (configure-login) — only owned keys change; secrets partitioned to `appsettings.Local.json`.
- **page component** is `skip-if-exists` — re-running preserves dev edits (use `force` to regenerate).
- **wiring module** is regenerated; **main.tsx** injection is marker-aware (`// @customised` skips it) and idempotent.

## When NOT to use

- Restyle the app theme → `/scaffold-theme` (the login inherits it).
- Permission gating inside pages → `scaffold-frontend-auth`.
- Change the OAuth button rendering at the framework level, the registration
  endpoint behavior, or add a server-side registration switch → that lives in
  SmartStack.app (the package), not in a generated client app.
- Public pre-auth pages (home, presentation) → `/site-vitrine`.
