From dfd45213dd3bbc049dffbcb005a18f6cc9bac81a Mon Sep 17 00:00:00 2001 From: Chris Alfano Date: Sun, 31 May 2026 21:14:22 -0400 Subject: [PATCH 1/3] feat(scripts): import-laddr-credentials + operator doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the missing private-side cutover tool. Reads a CSV exported from laddr's MySQL (Username,Email,Password) and a bare clone of the codeforphilly-data published branch, joins by slug, and emits profiles.jsonl + legacy-passwords.jsonl ready for upload to the runtime private store (PVC for sandbox, S3-compat bucket including GCS for prod). The public import-laddr script deliberately doesn't touch private data because laddr's public JSON API doesn't expose emails or password hashes; this new script consumes a MySQL extract directly, which fills the gap. Full-replace semantics: each run overwrites the local output files. Cutover-time use only — re-running after in-app rehash-on-login would clobber argon2id rotations with the SHA-1 originals. Schema validation routes invalid emails / malformed hashes through the same Zod schemas the runtime uses, so the output is guaranteed to load cleanly into the in-memory private store. Reports counts and warnings; defaults to producing files under .scratch/ so PII stays out of git. Operator doc at docs/operations/legacy-credentials-import.md covers the CSV format, run command, deploy paths for both sandbox (kubectl cp + rollout restart, since reload-data only covers public state) and prod (gsutil/aws s3 cp + restart). Linked from cutover.md's T-1 section, which previously incorrectly steered operators toward the deferred account-claim flow for credentials. .gitignore picks up .claude/scheduled_tasks.lock (per-session ScheduleWakeup artifact, never want it tracked). Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 3 + apps/api/package.json | 1 + apps/api/scripts/import-laddr-credentials.ts | 363 +++++++++++++++++++ docs/operations/cutover.md | 6 +- docs/operations/legacy-credentials-import.md | 173 +++++++++ 5 files changed, 545 insertions(+), 1 deletion(-) create mode 100644 apps/api/scripts/import-laddr-credentials.ts create mode 100644 docs/operations/legacy-credentials-import.md diff --git a/.gitignore b/.gitignore index 7ae6cb1..641b0bb 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,9 @@ codeforphilly-data/ # Agent worktrees (claude-code background agents create these here). .claude/worktrees/ +# Per-session scheduler lockfile (claude-code ScheduleWakeup). +.claude/scheduled_tasks.lock + # Editor / OS .DS_Store .vscode/* diff --git a/apps/api/package.json b/apps/api/package.json index 82471f9..f5f75cd 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -13,6 +13,7 @@ "script:scrub-data": "tsx scripts/scrub-data.ts", "script:setup-dev-data": "tsx scripts/setup-dev-data.ts", "script:import-laddr": "tsx scripts/import-laddr.ts", + "script:import-laddr-credentials": "tsx scripts/import-laddr-credentials.ts", "script:reconcile": "tsx scripts/reconcile.ts", "script:cutover-dry-run": "tsx scripts/cutover-dry-run.ts", "script:cutover-mailout": "tsx scripts/cutover-mailout.ts" diff --git a/apps/api/scripts/import-laddr-credentials.ts b/apps/api/scripts/import-laddr-credentials.ts new file mode 100644 index 0000000..ae56e01 --- /dev/null +++ b/apps/api/scripts/import-laddr-credentials.ts @@ -0,0 +1,363 @@ +/** + * import-laddr-credentials.ts — One-shot importer for legacy laddr + * email + password-hash records into the private store's JSONL files. + * + * The public `import-laddr.ts` script deliberately handles only public + * data; private fields (email, password hashes) are out of scope there + * because the public laddr JSON API doesn't expose them. + * + * This script consumes a CSV exported from the laddr MySQL database + * with columns `Username,Email,Password` (one row per active user), + * joins each row against the in-repo Person records by slug, and + * emits two JSONL files: + * + * profiles.jsonl — one PrivateProfile per resolved user + * legacy-passwords.jsonl — one LegacyPasswordCredential per row with + * a non-empty Password + * + * Both files are full-replace artifacts: the script always writes the + * complete set, not a diff. Re-running it after some users have + * already rehashed their credential (via login or password-reset) + * would clobber those argon2id hashes with the original SHA-1/bcrypt + * — so this is meant for the cutover seed, not mid-life maintenance. + * + * Output files are local. Deployment to the runtime backend + * (FilesystemPrivateStore PVC for sandbox / S3-compat bucket including + * GCS for prod) is a separate step — see docs/operations/cutover.md. + * + * Usage: + * npm run -w apps/api script:import-laddr-credentials -- \ + * --input .scratch/legacy-logins-export.csv \ + * --data-repo /path/to/codeforphilly-data \ + * --output-dir .scratch/private-import \ + * [--dry-run] [--verbose] + * + * Defaults: + * --input .scratch/legacy-logins-export.csv + * --data-repo $CFP_DATA_REPO_PATH (required if flag not given) + * --output-dir .scratch/private-import + */ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { join, resolve } from 'node:path'; + +import type { + LegacyPasswordCredential, + Person, + PrivateProfile, +} from '@cfp/shared/schemas'; +import { + LegacyPasswordCredentialSchema, + PrivateProfileSchema, +} from '@cfp/shared/schemas'; +import { openPublicStore } from '../src/store/public.js'; + +interface CliArgs { + readonly input: string; + readonly dataRepo: string; + readonly outputDir: string; + readonly dryRun: boolean; + readonly verbose: boolean; +} + +function parseArgs(argv: readonly string[]): CliArgs { + const opts: Record = {}; + for (const a of argv) { + if (!a.startsWith('--')) continue; + const eq = a.indexOf('='); + if (eq === -1) opts[a.slice(2)] = true; + else opts[a.slice(2, eq)] = a.slice(eq + 1); + } + + const envRepo = process.env['CFP_DATA_REPO_PATH']; + const dataRepoRaw = + typeof opts['data-repo'] === 'string' && opts['data-repo'] !== '' + ? (opts['data-repo'] as string) + : envRepo; + if (!dataRepoRaw) { + process.stderr.write( + 'missing --data-repo= (or set CFP_DATA_REPO_PATH)\n', + ); + process.exit(2); + } + + const input = + typeof opts['input'] === 'string' && opts['input'] !== '' + ? (opts['input'] as string) + : '.scratch/legacy-logins-export.csv'; + const outputDir = + typeof opts['output-dir'] === 'string' && opts['output-dir'] !== '' + ? (opts['output-dir'] as string) + : '.scratch/private-import'; + + return { + input: resolve(input), + dataRepo: resolve(dataRepoRaw), + outputDir: resolve(outputDir), + dryRun: opts['dry-run'] === true, + verbose: opts['verbose'] === true, + }; +} + +interface CsvRow { + readonly username: string; + readonly email: string; + readonly password: string; + readonly lineNumber: number; +} + +/** + * Minimal RFC-4180-ish CSV parser sufficient for our export shape. + * Handles double-quoted fields with embedded commas and "" escapes. + * Does not handle multi-line quoted fields (the laddr export has none — + * Username, Email, Password are all single-line atoms). + */ +function parseCsvLine(line: string): string[] { + const out: string[] = []; + let cur = ''; + let inQuotes = false; + for (let i = 0; i < line.length; i += 1) { + const ch = line[i]; + if (inQuotes) { + if (ch === '"') { + if (line[i + 1] === '"') { + cur += '"'; + i += 1; + } else { + inQuotes = false; + } + } else { + cur += ch; + } + } else { + if (ch === ',') { + out.push(cur); + cur = ''; + } else if (ch === '"' && cur.length === 0) { + inQuotes = true; + } else { + cur += ch; + } + } + } + out.push(cur); + return out; +} + +async function readCsv(path: string): Promise { + const raw = await readFile(path, 'utf8'); + const lines = raw.split(/\r?\n/); + if (lines.length === 0) return []; + const header = parseCsvLine(lines[0] ?? '').map((h) => h.trim()); + const idxUsername = header.indexOf('Username'); + const idxEmail = header.indexOf('Email'); + const idxPassword = header.indexOf('Password'); + if (idxUsername === -1 || idxEmail === -1 || idxPassword === -1) { + throw new Error( + `CSV header missing required columns Username/Email/Password — got: ${header.join(',')}`, + ); + } + + const rows: CsvRow[] = []; + for (let i = 1; i < lines.length; i += 1) { + const line = lines[i]; + if (line === undefined || line.length === 0) continue; + const cells = parseCsvLine(line); + rows.push({ + username: (cells[idxUsername] ?? '').trim(), + email: (cells[idxEmail] ?? '').trim(), + password: cells[idxPassword] ?? '', + lineNumber: i + 1, + }); + } + return rows; +} + +interface ImportReport { + readonly runAt: string; + readonly inputRows: number; + readonly profilesWritten: number; + readonly credentialsWritten: number; + readonly skippedNoUsername: number; + readonly skippedNoEmail: number; + readonly skippedInvalidEmail: number; + readonly skippedNoPersonMatch: number; + readonly skippedDeletedPerson: number; + readonly skippedDuplicatePersonId: number; + readonly warnings: readonly string[]; + readonly profilesPath: string | null; + readonly credentialsPath: string | null; +} + +async function run(args: CliArgs): Promise { + const runAt = new Date().toISOString(); + const warnings: string[] = []; + + console.log(`[import-creds] input=${args.input}`); + console.log(`[import-creds] data-repo=${args.dataRepo}`); + console.log(`[import-creds] output-dir=${args.outputDir}`); + console.log(`[import-creds] dry-run=${args.dryRun}`); + + if (!existsSync(args.input)) { + throw new Error(`Input file not found: ${args.input}`); + } + + const { store: publicStore } = await openPublicStore(args.dataRepo); + const people = await publicStore.people.queryAll(); + const bySlug = new Map(); + for (const p of people) bySlug.set(p.slug.toLowerCase(), p); + console.log(`[import-creds] loaded ${people.length} Person records from data repo`); + + const rows = await readCsv(args.input); + console.log(`[import-creds] parsed ${rows.length} CSV rows`); + + const profiles: PrivateProfile[] = []; + const credentials: LegacyPasswordCredential[] = []; + const seenPersonIds = new Set(); + let skippedNoUsername = 0; + let skippedNoEmail = 0; + let skippedInvalidEmail = 0; + let skippedNoPersonMatch = 0; + let skippedDeletedPerson = 0; + let skippedDuplicatePersonId = 0; + + for (const row of rows) { + if (!row.username) { + skippedNoUsername += 1; + continue; + } + if (!row.email) { + skippedNoEmail += 1; + if (args.verbose) warnings.push(`line ${row.lineNumber}: no email for username "${row.username}"`); + continue; + } + const person = bySlug.get(row.username.toLowerCase()); + if (!person) { + skippedNoPersonMatch += 1; + if (args.verbose) warnings.push(`line ${row.lineNumber}: no Person for username "${row.username}"`); + continue; + } + if (person.deletedAt) { + skippedDeletedPerson += 1; + continue; + } + if (seenPersonIds.has(person.id)) { + skippedDuplicatePersonId += 1; + warnings.push( + `line ${row.lineNumber}: duplicate username "${row.username}" → personId ${person.id}; keeping first occurrence`, + ); + continue; + } + + // Validate the email shape via the schema's parse — laddr's DB can + // hold malformed addresses (e.g. trailing whitespace already stripped + // by us, but also literal junk). Schema rejection → skip + warn. + let profile: PrivateProfile; + try { + profile = PrivateProfileSchema.parse({ + personId: person.id, + email: row.email, + emailRefreshedAt: runAt, + newsletter: null, + updatedAt: runAt, + }); + } catch (err) { + skippedInvalidEmail += 1; + if (args.verbose) { + warnings.push( + `line ${row.lineNumber}: invalid email "${row.email}" for "${row.username}" — ${(err as Error).message}`, + ); + } + continue; + } + profiles.push(profile); + seenPersonIds.add(person.id); + + // Empty password column → user has an email-only account (rare, + // some laddr users were created without a password). Emit the + // profile but no credential — they'll have to use the password-reset + // flow if they ever want one. + if (row.password.length === 0) continue; + + try { + const cred = LegacyPasswordCredentialSchema.parse({ + personId: person.id, + passwordHash: row.password, + importedAt: runAt, + lastUsedAt: null, + }); + credentials.push(cred); + } catch (err) { + warnings.push( + `line ${row.lineNumber}: invalid passwordHash for "${row.username}" — ${(err as Error).message}`, + ); + } + } + + const profilesLines = profiles.map((p) => JSON.stringify(p)).join('\n'); + const credentialsLines = credentials.map((c) => JSON.stringify(c)).join('\n'); + + let profilesPath: string | null = null; + let credentialsPath: string | null = null; + if (!args.dryRun) { + await mkdir(args.outputDir, { recursive: true }); + profilesPath = join(args.outputDir, 'profiles.jsonl'); + credentialsPath = join(args.outputDir, 'legacy-passwords.jsonl'); + await writeFile(profilesPath, profilesLines ? profilesLines + '\n' : ''); + await writeFile(credentialsPath, credentialsLines ? credentialsLines + '\n' : ''); + } + + return { + runAt, + inputRows: rows.length, + profilesWritten: profiles.length, + credentialsWritten: credentials.length, + skippedNoUsername, + skippedNoEmail, + skippedInvalidEmail, + skippedNoPersonMatch, + skippedDeletedPerson, + skippedDuplicatePersonId, + warnings, + profilesPath, + credentialsPath, + }; +} + +function printReport(report: ImportReport): void { + console.log(`\n=== import-creds report ===`); + console.log(`runAt: ${report.runAt}`); + console.log(`input rows: ${report.inputRows}`); + console.log(`profiles written: ${report.profilesWritten}`); + console.log(`credentials written: ${report.credentialsWritten}`); + console.log(`skipped (no username): ${report.skippedNoUsername}`); + console.log(`skipped (no email): ${report.skippedNoEmail}`); + console.log(`skipped (invalid email): ${report.skippedInvalidEmail}`); + console.log(`skipped (no person match): ${report.skippedNoPersonMatch}`); + console.log(`skipped (deleted person): ${report.skippedDeletedPerson}`); + console.log(`skipped (duplicate person): ${report.skippedDuplicatePersonId}`); + console.log(`warnings: ${report.warnings.length}`); + for (const w of report.warnings.slice(0, 25)) console.log(` ${w}`); + if (report.warnings.length > 25) { + console.log(` ... (${report.warnings.length - 25} more — re-run with --verbose to see all)`); + } + if (report.profilesPath) console.log(`profiles: ${report.profilesPath}`); + if (report.credentialsPath) console.log(`credentials: ${report.credentialsPath}`); +} + +async function main(): Promise { + const args = parseArgs(process.argv.slice(2)); + const report = await run(args); + printReport(report); +} + +const isMain = + process.argv[1] !== undefined && + import.meta.url.endsWith(process.argv[1].replace(/\\/g, '/')); + +if (isMain) { + main().catch((err: unknown) => { + console.error('[import-creds] failed:', err); + process.exit(1); + }); +} diff --git a/docs/operations/cutover.md b/docs/operations/cutover.md index 1b1e9e7..d6bb14c 100644 --- a/docs/operations/cutover.md +++ b/docs/operations/cutover.md @@ -85,7 +85,11 @@ silently-dropped data. The production import is a snapshot commit on the `legacy-import` branch of the production data repo. Private data (emails, password hashes) is **not** -populated by this importer — see the [account-claim flow](../../specs/behaviors/account-migration.md). +populated by this importer — that is a separate one-shot run of the +credentials importer; see +[legacy-credentials-import.md](./legacy-credentials-import.md) for the +CSV format, run command, and deploy steps (sandbox PVC or production +S3-compat bucket). 1. **Bare-clone** the production data repo locally — the importer matches the running pod's invariant ([storage.md → "The data clone is bare"](../../specs/behaviors/storage.md)) diff --git a/docs/operations/legacy-credentials-import.md b/docs/operations/legacy-credentials-import.md new file mode 100644 index 0000000..66947e4 --- /dev/null +++ b/docs/operations/legacy-credentials-import.md @@ -0,0 +1,173 @@ +# Legacy credentials import + +How to seed `profiles.jsonl` (PrivateProfile) + `legacy-passwords.jsonl` +(LegacyPasswordCredential) into a deployment's private store from a +laddr MySQL export. + +The public-side importer ([`script:import-laddr`](../../apps/api/scripts/import-laddr.ts)) +deliberately handles only public data — laddr's public JSON API doesn't +expose emails or password hashes. This second importer fills the +private-side gap. + +It runs **once per environment** at cutover. After legacy users start +signing in, the in-app rehash-on-login flow gradually rotates SHA-1 / +bcrypt hashes to argon2id; re-running this importer would clobber those +rehashed credentials with the originals. The script is re-runnable +shape-wise (it always produces a complete replacement), but in practice +it's run once per env unless something went wrong with the first run. + +## What you need + +### CSV export from laddr + +A CSV with header `Username,Email,Password`, one row per active laddr +user. Each cell is double-quoted; embedded quotes use `""` escape. + +```csv +"Username","Email","Password" +"chris","chris@codeforphilly.org","$2y$10$LinW…" +"hhutch","hunter.hutchinson@gmail.com","7746451fd8c30b5b4068cd45fa7b1052cef54068" +``` + +- **Username** — laddr `User.Handle` (= the slug that became `Person.slug`). +- **Email** — required; rows with empty Email are skipped (warned). +- **Password** — the raw `PasswordHash` column from laddr's `users` table. + Mixed algorithms in the wild: older users are unsalted SHA-1 (40 lowercase + hex chars, no prefix); newer users are bcrypt (`$2a$ / $2b$ / $2y$`). + Empty Password is allowed — emits a PrivateProfile but no + LegacyPasswordCredential; user will have to use the password-reset + flow if they ever want one. The runtime verifier handles all three + formats; see [specs/behaviors/password-hash-rotation.md](../../specs/behaviors/password-hash-rotation.md). + +Produce the file with: + +```bash +mysql -h -u -p laddr \ + -B -e "SELECT Handle AS Username, Email, PasswordHash AS Password + FROM users WHERE Email IS NOT NULL AND PasswordHash IS NOT NULL" \ + | sed 's/"/\\\\"/g' | awk -F'\t' 'BEGIN{print "\"Username\",\"Email\",\"Password\""} NR>1 {printf "\"%s\",\"%s\",\"%s\"\n", $1, $2, $3}' \ + > .scratch/legacy-logins-export.csv +``` + +(Or use whatever extract tooling you prefer — the importer just needs +the CSV shape above. Land the file in `.scratch/` which is gitignored.) + +### A bare clone of `codeforphilly-data` + +The importer reads Person records (for the `slug → personId` map) via +the same `openPublicStore` interface the runtime uses, which requires a +bare clone. If your dev sibling clone is a working tree, make a +side-clone for the importer: + +```bash +git clone --bare --branch published \ + ~/Repositories/codeforphilly-data \ + /tmp/codeforphilly-data-bare-published.git +``` + +## Run + +```bash +npm run -w apps/api script:import-laddr-credentials -- \ + --input=/absolute/path/to/.scratch/legacy-logins-export.csv \ + --data-repo=/tmp/codeforphilly-data-bare-published.git \ + --output-dir=/absolute/path/to/.scratch/private-import \ + [--dry-run] [--verbose] +``` + +Defaults (when flags are omitted): + +| Flag | Default | +|----------------|-------------------------------------------| +| `--input` | `.scratch/legacy-logins-export.csv` (resolved from `apps/api/`) | +| `--data-repo` | `$CFP_DATA_REPO_PATH` (required if unset) | +| `--output-dir` | `.scratch/private-import` (resolved from `apps/api/`) | + +**Pass absolute paths** when running via npm — `npm run -w` changes +directory into the workspace and relative paths resolve from there. + +The report prints input row count, write counts, and a breakdown of +skip reasons (no-username / no-email / no-person-match / deleted-person +/ duplicate-person). Expect a ~95% match rate on a healthy laddr corpus +— the missing 5% are typically deleted laddr users that didn't survive +the public-data import. + +## Deploy to sandbox (FilesystemPrivateStore on a PVC) + +The sandbox uses `STORAGE_BACKEND=filesystem` with `/app/private-storage` +mounted to a PersistentVolumeClaim (see `deploy/kustomize/base/`). + +1. **Copy the files into the pod:** + + ```bash + POD=$(kubectl -n codeforphilly-rewrite-sandbox get pods -o jsonpath='{.items[0].metadata.name}') + kubectl -n codeforphilly-rewrite-sandbox cp \ + .scratch/private-import/profiles.jsonl "$POD:/app/private-storage/profiles.jsonl" + kubectl -n codeforphilly-rewrite-sandbox cp \ + .scratch/private-import/legacy-passwords.jsonl "$POD:/app/private-storage/legacy-passwords.jsonl" + ``` + +2. **Restart the pod** to reload the private store into memory. The + `POST /api/_internal/reload-data` webhook **does not** cover the + private store — it only reloads public + FTS. A full pod restart + is the supported path. + + ```bash + kubectl -n codeforphilly-rewrite-sandbox rollout restart deployment + ``` + +3. **Verify** the new pod sees the credentials: + + ```bash + curl -sS https://next-v2.codeforphilly.org/api/health/ready # waits until private store is loaded + ``` + +## Deploy to production (S3PrivateStore on GCS or S3) + +Production uses `STORAGE_BACKEND=s3` with `S3_ENDPOINT` pointing at +either GCS's XML API (`https://storage.googleapis.com`) or an actual +S3 bucket. Upload the two files to the bucket at the configured +`keyPrefix` (default empty): + +```bash +# GCS via gsutil +gsutil cp .scratch/private-import/profiles.jsonl \ + gs:///profiles.jsonl +gsutil cp .scratch/private-import/legacy-passwords.jsonl \ + gs:///legacy-passwords.jsonl + +# OR: any S3 endpoint via awscli +aws s3 cp .scratch/private-import/profiles.jsonl \ + s3:///profiles.jsonl --endpoint-url +aws s3 cp .scratch/private-import/legacy-passwords.jsonl \ + s3:///legacy-passwords.jsonl --endpoint-url +``` + +Then restart the prod pod the same way (private-store reload is +not in the hot-reload path; see above). + +## Safety notes + +- **The script never writes credentials to git.** Output goes to a local + directory you control. Upload to the runtime backend is a separate + manual step. +- **`.scratch/` is gitignored.** Keep the CSV and the generated JSONL + files there. Never commit either. +- **Re-running the importer overwrites the output files** locally, but + does not touch the runtime backend until you deploy the new files. +- **Re-deploying overwrites the runtime files in full.** If users have + already signed in and their credentials have been rehashed to + argon2id (via the in-app rehash-on-login flow), a re-deploy would + revert those to the SHA-1/bcrypt originals. After cutover, only + re-run + re-deploy if you've confirmed nobody has signed in (or + you're OK with the rotation reset). +- **PII risk.** The generated JSONL files contain every legacy user's + email and password hash. Treat them as you would the source MySQL + dump — never paste into chat, never check into git, delete from + local disk after deploy if you don't need them retained. + +## Cross-references + +- [specs/behaviors/private-storage.md](../../specs/behaviors/private-storage.md) — what these files store and the rules around them. +- [specs/behaviors/password-hash-rotation.md](../../specs/behaviors/password-hash-rotation.md) — how the verifier handles SHA-1 / bcrypt / argon2id and rehashes on login. +- [docs/operations/cutover.md](./cutover.md) — full cutover sequence; this importer fits between the public-data import and the DNS flip. From d6894311558736f7e54e96dea4d73ca6e4c7f18f Mon Sep 17 00:00:00 2001 From: Chris Alfano Date: Sun, 31 May 2026 21:14:34 -0400 Subject: [PATCH 2/3] =?UTF-8?q?feat(web):=20two-column=20/login=20?= =?UTF-8?q?=E2=80=94=20password=20+=20GitHub=20side-by-side?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the single-card layout (GitHub button primary, password form hidden behind a disclosure) with a two-column grid: password on the left, GitHub on the right, both fully expanded. Cutover-time users arriving at /login are now overwhelmingly legacy users; the disclosure made the path they need invisible by default. Layout adjusts to max-w-5xl with a centered header; columns stack on mobile via grid-cols-1 md:grid-cols-2. Forgot-password link stays on the password card. WhyGitHub explainer moves under the GitHub card since it's the long-form explainer for that column. Test updates: drops the disclosure-expansion tests (no longer applicable); adds an "both columns visible" assertion as the entry- point check. Other tests (gated submit, inline 401 error) stay as-is since the field selectors haven't changed. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/web/src/pages/LoginPlaceholder.tsx | 111 +++++++++++------------ apps/web/tests/LoginPlaceholder.test.tsx | 42 ++------- 2 files changed, 64 insertions(+), 89 deletions(-) diff --git a/apps/web/src/pages/LoginPlaceholder.tsx b/apps/web/src/pages/LoginPlaceholder.tsx index 50decf4..ec37c0a 100644 --- a/apps/web/src/pages/LoginPlaceholder.tsx +++ b/apps/web/src/pages/LoginPlaceholder.tsx @@ -10,7 +10,6 @@ import { import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -import { Separator } from '@/components/ui/separator'; import { useAuth } from '@/hooks/useAuth'; import { api, ApiError } from '@/lib/api'; @@ -125,52 +124,64 @@ export function LoginPlaceholder() { ? `/api/auth/github/start?return=${encodeURIComponent(returnPath)}` : '/api/auth/github/start'; - return ( -
- - - Sign in to Code for Philly - - We use GitHub for sign-in. If you do not have a - GitHub account yet, it is free and takes about a minute. - - - - - {errorCode && ERROR_MESSAGES[errorCode] && ( -
- {ERROR_MESSAGES[errorCode]} -
- )} + const handleLegacySuccess = () => { + const target = + returnPath && returnPath.startsWith('/') ? returnPath : '/'; + void navigate(target, { replace: true }); + }; - + return ( +
+
+

Sign in to Code for Philly

+

+ Returning member? Use the password you had before our 2026 switch to + GitHub. New here? Sign in with GitHub. +

+
- + {errorCode && ERROR_MESSAGES[errorCode] && ( +
+ {ERROR_MESSAGES[errorCode]} +
+ )} -

- Returning Code for Philly member? If you had an - account before our 2026 switch to GitHub sign-in, you can sign in - with your old password below — or use GitHub if your old email - matches. -

+
+ + + Returning member + + Sign in with the username (or email) and password you used at + codeforphilly.org before our switch to GitHub sign-in. + + + + + + - { - const target = - returnPath && returnPath.startsWith('/') ? returnPath : '/'; - void navigate(target, { replace: true }); - }} - /> - - + + + New here? + + We use GitHub for all new sign-ups. It is free and takes about a + minute if you do not have an account yet. + + + + + + + +
); } @@ -180,7 +191,6 @@ interface LegacyPasswordLoginProps { } function LegacyPasswordLogin({ onSuccess }: LegacyPasswordLoginProps) { - const [open, setOpen] = useState(false); const [usernameOrEmail, setUsernameOrEmail] = useState(''); const [password, setPassword] = useState(''); const [submitting, setSubmitting] = useState(false); @@ -213,19 +223,6 @@ function LegacyPasswordLogin({ onSuccess }: LegacyPasswordLoginProps) { } } - if (!open) { - return ( - - ); - } - return (
diff --git a/apps/web/tests/LoginPlaceholder.test.tsx b/apps/web/tests/LoginPlaceholder.test.tsx index 6b82d54..a118502 100644 --- a/apps/web/tests/LoginPlaceholder.test.tsx +++ b/apps/web/tests/LoginPlaceholder.test.tsx @@ -29,42 +29,25 @@ describe('LoginPlaceholder', () => { ); } - it('renders the primary GitHub button + a collapsed password disclosure', async () => { + it('renders both columns side-by-side: password form on the left + GitHub button on the right', async () => { render(); + // Password form fields are visible immediately — no disclosure to expand. await waitFor(() => { - expect(screen.getByRole('link', { name: /sign in with github/i })).toBeInTheDocument(); + expect(screen.getByLabelText(/username or email/i)).toBeInTheDocument(); }); - // Disclosure exists but is closed — fields are not yet in the DOM - expect( - screen.getByRole('button', { name: /sign in with your code for philly password/i }), - ).toBeInTheDocument(); - expect(screen.queryByLabelText(/username or email/i)).not.toBeInTheDocument(); - }); - - it('expanding the disclosure reveals the password form', async () => { - render(); - await waitFor(() => { - expect( - screen.getByRole('button', { name: /sign in with your code for philly password/i }), - ).toBeInTheDocument(); - }); - fireEvent.click( - screen.getByRole('button', { name: /sign in with your code for philly password/i }), - ); - expect(screen.getByLabelText(/username or email/i)).toBeInTheDocument(); expect(screen.getByLabelText(/^password$/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^sign in$/i })).toBeInTheDocument(); + // GitHub button is also visible. + expect(screen.getByRole('link', { name: /sign in with github/i })).toBeInTheDocument(); + // Forgot-password link is on the password card. + expect(screen.getByRole('link', { name: /forgot your password/i })).toBeInTheDocument(); }); it('keeps submit disabled until both fields are filled', async () => { render(); await waitFor(() => { - expect( - screen.getByRole('button', { name: /sign in with your code for philly password/i }), - ).toBeInTheDocument(); + expect(screen.getByLabelText(/username or email/i)).toBeInTheDocument(); }); - fireEvent.click( - screen.getByRole('button', { name: /sign in with your code for philly password/i }), - ); const submitBtn = screen.getByRole('button', { name: /^sign in$/i }); expect(submitBtn).toBeDisabled(); @@ -100,13 +83,8 @@ describe('LoginPlaceholder', () => { render(); await waitFor(() => { - expect( - screen.getByRole('button', { name: /sign in with your code for philly password/i }), - ).toBeInTheDocument(); + expect(screen.getByLabelText(/username or email/i)).toBeInTheDocument(); }); - fireEvent.click( - screen.getByRole('button', { name: /sign in with your code for philly password/i }), - ); fireEvent.change(screen.getByLabelText(/username or email/i), { target: { value: 'jane' }, }); From bf913337d60783bc6102017214522b87141ab673 Mon Sep 17 00:00:00 2001 From: Chris Alfano Date: Sun, 31 May 2026 21:31:48 -0400 Subject: [PATCH 3/3] fix(web): refresh useAuth after legacy login before navigating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The legacy password sign-in flow called navigate() after a successful POST /api/auth/login but never reloaded the AuthProvider's snapshot, so the navbar (and every other useAuth consumer) stayed in its anonymous state until the next hard refresh. The session cookies were set on the server, the user was technically signed in, the UI just hadn't noticed. GitHub OAuth doesn't have this bug because the flow is a full-page navigation through /api/auth/github/start → callback → return path, which re-mounts AuthProvider and re-fetches /api/auth/me on its own. The password-reset confirm flow already awaits reload() before navigating; the legacy login path had drifted from that pattern. Same fix: await reload() in LoginPlaceholder's onSuccess before navigating. Widens the onSuccess type to allow a Promise return. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/web/src/pages/LoginPlaceholder.tsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/web/src/pages/LoginPlaceholder.tsx b/apps/web/src/pages/LoginPlaceholder.tsx index ec37c0a..5b82177 100644 --- a/apps/web/src/pages/LoginPlaceholder.tsx +++ b/apps/web/src/pages/LoginPlaceholder.tsx @@ -96,7 +96,7 @@ function WhyGitHub() { } export function LoginPlaceholder() { - const { person, loading } = useAuth(); + const { person, loading, reload } = useAuth(); const navigate = useNavigate(); const [searchParams] = useSearchParams(); @@ -124,7 +124,11 @@ export function LoginPlaceholder() { ? `/api/auth/github/start?return=${encodeURIComponent(returnPath)}` : '/api/auth/github/start'; - const handleLegacySuccess = () => { + const handleLegacySuccess = async () => { + // Refresh /api/auth/me so the navbar + every consumer of useAuth() + // picks up the new session before we navigate. Without this, the + // navbar stays in its anonymous state until the next hard refresh. + await reload(); const target = returnPath && returnPath.startsWith('/') ? returnPath : '/'; void navigate(target, { replace: true }); @@ -187,7 +191,7 @@ export function LoginPlaceholder() { } interface LegacyPasswordLoginProps { - onSuccess: () => void; + onSuccess: () => Promise | void; } function LegacyPasswordLogin({ onSuccess }: LegacyPasswordLoginProps) { @@ -203,7 +207,7 @@ function LegacyPasswordLogin({ onSuccess }: LegacyPasswordLoginProps) { setErrorMessage(null); try { await api.auth.login(usernameOrEmail.trim(), password); - onSuccess(); + await onSuccess(); } catch (err) { if (err instanceof ApiError) { if (err.status === 429) {