From 2369f3fd39ee0361c9255389cb4c11dff3edd69c Mon Sep 17 00:00:00 2001 From: Chris Alfano Date: Sat, 16 May 2026 21:03:14 -0400 Subject: [PATCH 01/10] chore(plans): mark account-claim in-progress --- plans/account-claim.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plans/account-claim.md b/plans/account-claim.md index ad18ec3..e0ce0d4 100644 --- a/plans/account-claim.md +++ b/plans/account-claim.md @@ -1,5 +1,5 @@ --- -status: planned +status: in-progress depends: [github-oauth] specs: - specs/api/account-claim.md From 22769845f39d0dc82d3156bac193aa420ce0899b Mon Sep 17 00:00:00 2001 From: Chris Alfano Date: Sat, 16 May 2026 21:03:37 -0400 Subject: [PATCH 02/10] chore(api): add bcryptjs for legacy password verification npm install -w apps/api bcryptjs npm install -w apps/api -D @types/bcryptjs --- apps/api/package.json | 2 ++ package-lock.json | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/apps/api/package.json b/apps/api/package.json index 43bc097..4ea5243 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -25,6 +25,7 @@ "@fastify/static": "^9.1.3", "@fastify/swagger": "^9.7.0", "@fastify/swagger-ui": "^5.2.6", + "bcryptjs": "^3.0.3", "better-sqlite3": "^12.10.0", "fastify": "^5.8.5", "gitsheets": "^1.0.3", @@ -34,6 +35,7 @@ }, "devDependencies": { "@faker-js/faker": "^10.4.0", + "@types/bcryptjs": "^2.4.6", "@types/better-sqlite3": "^7.6.13", "@types/node": "^25.8.0", "msw": "^2.14.6", diff --git a/package-lock.json b/package-lock.json index 4cc5ef7..6bb7fad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,6 +37,7 @@ "@fastify/static": "^9.1.3", "@fastify/swagger": "^9.7.0", "@fastify/swagger-ui": "^5.2.6", + "bcryptjs": "^3.0.3", "better-sqlite3": "^12.10.0", "fastify": "^5.8.5", "gitsheets": "^1.0.3", @@ -46,6 +47,7 @@ }, "devDependencies": { "@faker-js/faker": "^10.4.0", + "@types/bcryptjs": "^2.4.6", "@types/better-sqlite3": "^7.6.13", "@types/node": "^25.8.0", "msw": "^2.14.6", @@ -5174,6 +5176,13 @@ "license": "MIT", "peer": true }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/better-sqlite3": { "version": "7.6.13", "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", @@ -5998,6 +6007,15 @@ "node": ">=6.0.0" } }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/better-sqlite3": { "version": "12.10.0", "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.10.0.tgz", From 3b71afb246147ed00031ede5e74c27fba6a20d57 Mon Sep 17 00:00:00 2001 From: Chris Alfano Date: Sat, 16 May 2026 21:14:41 -0400 Subject: [PATCH 03/10] feat(shared,api): add AccountClaimRequest schema and private-store support Adds the third entity to the private store per specs/api/account-claim.md#notes: account-claim-requests.jsonl alongside profiles.jsonl and legacy-passwords.jsonl. The dual-store transact pipes putClaimRequest through to the private-store flush so a single store.transact covers the public commit and the claim-request write atomically. --- apps/api/src/store/private/base.ts | 67 +++++++++++++++++-- apps/api/src/store/private/interface.ts | 13 +++- apps/api/src/store/store.ts | 8 ++- .../src/schemas/account-claim-request.ts | 25 +++++++ packages/shared/src/schemas/index.ts | 3 + 5 files changed, 107 insertions(+), 9 deletions(-) create mode 100644 packages/shared/src/schemas/account-claim-request.ts diff --git a/apps/api/src/store/private/base.ts b/apps/api/src/store/private/base.ts index 08bec87..36e5bb9 100644 --- a/apps/api/src/store/private/base.ts +++ b/apps/api/src/store/private/base.ts @@ -1,5 +1,13 @@ -import { LegacyPasswordCredentialSchema, PrivateProfileSchema } from '@cfp/shared/schemas'; -import type { LegacyPasswordCredential, PrivateProfile } from '@cfp/shared/schemas'; +import { + AccountClaimRequestSchema, + LegacyPasswordCredentialSchema, + PrivateProfileSchema, +} from '@cfp/shared/schemas'; +import type { + AccountClaimRequest, + LegacyPasswordCredential, + PrivateProfile, +} from '@cfp/shared/schemas'; import type { PrivateIndices, PrivateStore, PrivateStoreTx } from './interface.js'; /** @@ -11,6 +19,7 @@ import type { PrivateIndices, PrivateStore, PrivateStoreTx } from './interface.j export abstract class BasePrivateStore implements PrivateStore { protected profiles: Map = new Map(); protected legacyPasswords: Map = new Map(); + protected claimRequests: Map = new Map(); readonly indices: PrivateIndices = { byEmail: new Map(), @@ -24,7 +33,11 @@ export abstract class BasePrivateStore implements PrivateStore { protected abstract writeRaw(key: string, content: string): Promise; async load(): Promise { - await Promise.all([this.loadProfiles(), this.loadLegacyPasswords()]); + await Promise.all([ + this.loadProfiles(), + this.loadLegacyPasswords(), + this.loadClaimRequests(), + ]); this.rebuildIndices(); } @@ -42,6 +55,11 @@ export abstract class BasePrivateStore implements PrivateStore { this.legacyPasswords; } + private async loadClaimRequests(): Promise { + const raw = await this.readRaw('account-claim-requests.jsonl'); + this.claimRequests = parseJsonl(raw, AccountClaimRequestSchema, 'id'); + } + private rebuildIndices(): void { this.indices.byEmail.clear(); this.indices.byUnsubscribeToken.clear(); @@ -95,6 +113,24 @@ export abstract class BasePrivateStore implements PrivateStore { return this.legacyPasswords.size; } + async getClaimRequest(requestId: string): Promise { + return this.claimRequests.get(requestId) ?? null; + } + + async putClaimRequest(req: AccountClaimRequest): Promise { + const parsed = AccountClaimRequestSchema.parse(req); + this.claimRequests.set(parsed.id, parsed); + await this.flushClaimRequests(); + } + + async listOpenClaimRequests(): Promise { + return [...this.claimRequests.values()].filter((r) => r.status === 'open'); + } + + async listAllClaimRequests(): Promise { + return [...this.claimRequests.values()]; + } + async readBlob(key: string): Promise { return this.readRaw(key); } @@ -107,11 +143,13 @@ export abstract class BasePrivateStore implements PrivateStore { // Snapshot current state so we can roll back if the handler throws const profilesSnapshot = new Map(this.profiles); const legacySnapshot = new Map(this.legacyPasswords); + const claimRequestsSnapshot = new Map(this.claimRequests); // Staged mutations applied only in-memory during the handler const stagedProfilePuts: Map = new Map(); const stagedProfileDeletes: Set = new Set(); const stagedLegacyDeletes: Set = new Set(); + const stagedClaimRequestPuts: Map = new Map(); const tx: PrivateStoreTx = { putProfile: (profile) => { @@ -126,6 +164,10 @@ export abstract class BasePrivateStore implements PrivateStore { deleteLegacyPassword: (personId) => { stagedLegacyDeletes.add(personId); }, + putClaimRequest: (req) => { + const parsed = AccountClaimRequestSchema.parse(req); + stagedClaimRequestPuts.set(parsed.id, parsed); + }, }; let result: T; @@ -135,6 +177,7 @@ export abstract class BasePrivateStore implements PrivateStore { // Handler threw: leave in-memory state unchanged this.profiles = profilesSnapshot; this.legacyPasswords = legacySnapshot; + this.claimRequests = claimRequestsSnapshot; this.rebuildIndices(); throw err; } @@ -149,6 +192,9 @@ export abstract class BasePrivateStore implements PrivateStore { for (const id of stagedLegacyDeletes) { this.legacyPasswords.delete(id); } + for (const [id, req] of stagedClaimRequestPuts) { + this.claimRequests.set(id, req); + } this.rebuildIndices(); // Flush to backend @@ -159,6 +205,9 @@ export abstract class BasePrivateStore implements PrivateStore { if (stagedLegacyDeletes.size > 0) { flushOps.push(this.flushLegacyPasswords()); } + if (stagedClaimRequestPuts.size > 0) { + flushOps.push(this.flushClaimRequests()); + } try { await Promise.all(flushOps); @@ -167,6 +216,7 @@ export abstract class BasePrivateStore implements PrivateStore { // rebuild indices. Log loudly; a reconciliation script will fix state. this.profiles = profilesSnapshot; this.legacyPasswords = legacySnapshot; + this.claimRequests = claimRequestsSnapshot; this.rebuildIndices(); throw new PrivateStoreError( 'private_store_unavailable', @@ -187,11 +237,17 @@ export abstract class BasePrivateStore implements PrivateStore { const lines = [...this.legacyPasswords.values()].map((p) => JSON.stringify(p)).join('\n'); await this.writeRaw('legacy-passwords.jsonl', lines ? lines + '\n' : ''); } + + protected async flushClaimRequests(): Promise { + const lines = [...this.claimRequests.values()].map((r) => JSON.stringify(r)).join('\n'); + await this.writeRaw('account-claim-requests.jsonl', lines ? lines + '\n' : ''); + } } function parseJsonl( raw: string | null, schema: { parse: (input: unknown) => T }, + keyField: 'personId' | 'id' = 'personId', ): Map { const map = new Map(); if (!raw) return map; @@ -199,9 +255,8 @@ function parseJsonl( const trimmed = line.trim(); if (!trimmed) continue; const record = schema.parse(JSON.parse(trimmed)); - // Both PrivateProfile and LegacyPasswordCredential are keyed by personId - const keyed = record as { personId: string }; - map.set(keyed.personId, record); + const keyed = record as Record; + map.set(keyed[keyField] as string, record); } return map; } diff --git a/apps/api/src/store/private/interface.ts b/apps/api/src/store/private/interface.ts index 1ffdca6..d0e4dac 100644 --- a/apps/api/src/store/private/interface.ts +++ b/apps/api/src/store/private/interface.ts @@ -1,4 +1,8 @@ -import type { LegacyPasswordCredential, PrivateProfile } from '@cfp/shared/schemas'; +import type { + AccountClaimRequest, + LegacyPasswordCredential, + PrivateProfile, +} from '@cfp/shared/schemas'; /** Secondary in-memory indices built from private store data. */ export interface PrivateIndices { @@ -19,6 +23,7 @@ export interface PrivateStoreTx { putProfile(profile: PrivateProfile): void; deleteProfile(personId: string): void; deleteLegacyPassword(personId: string): void; + putClaimRequest(req: AccountClaimRequest): void; } /** @@ -47,6 +52,12 @@ export interface PrivateStore { deleteLegacyPassword(personId: string): Promise; countLegacyPasswords(): Promise; + // --- Account-claim requests --- + getClaimRequest(requestId: string): Promise; + putClaimRequest(req: AccountClaimRequest): Promise; + listOpenClaimRequests(): Promise; + listAllClaimRequests(): Promise; + /** * Run a handler with a transaction object. On success, flush updated * `.jsonl` files to the backend. On throw, discard; in-memory state diff --git a/apps/api/src/store/store.ts b/apps/api/src/store/store.ts index 7a11740..98c96f5 100644 --- a/apps/api/src/store/store.ts +++ b/apps/api/src/store/store.ts @@ -1,5 +1,5 @@ import type { TransactionOptions, TransactionResult } from 'gitsheets'; -import type { PrivateProfile } from '@cfp/shared/schemas'; +import type { AccountClaimRequest, PrivateProfile } from '@cfp/shared/schemas'; import type { PrivateStore, PrivateStoreTx } from './private/index.js'; import type { PublicStore, PublicStoreTx } from './public.js'; @@ -77,17 +77,20 @@ export class Store { const stagedPrivatePuts: PrivateProfile[] = []; const stagedPrivateProfileDeletes: string[] = []; const stagedLegacyPasswordDeletes: string[] = []; + const stagedClaimRequestPuts: AccountClaimRequest[] = []; const privateTx: PrivateStoreTx = { putProfile: (profile) => { stagedPrivatePuts.push(profile); }, deleteProfile: (personId) => { stagedPrivateProfileDeletes.push(personId); }, deleteLegacyPassword: (personId) => { stagedLegacyPasswordDeletes.push(personId); }, + putClaimRequest: (req) => { stagedClaimRequestPuts.push(req); }, }; const hasPrivateMutations = () => stagedPrivatePuts.length > 0 || stagedPrivateProfileDeletes.length > 0 || - stagedLegacyPasswordDeletes.length > 0; + stagedLegacyPasswordDeletes.length > 0 || + stagedClaimRequestPuts.length > 0; const flushPrivate = async (): Promise => { if (!hasPrivateMutations()) return; @@ -95,6 +98,7 @@ export class Store { for (const profile of stagedPrivatePuts) tx.putProfile(profile); for (const id of stagedPrivateProfileDeletes) tx.deleteProfile(id); for (const id of stagedLegacyPasswordDeletes) tx.deleteLegacyPassword(id); + for (const req of stagedClaimRequestPuts) tx.putClaimRequest(req); }); }; diff --git a/packages/shared/src/schemas/account-claim-request.ts b/packages/shared/src/schemas/account-claim-request.ts new file mode 100644 index 0000000..45ae5d6 --- /dev/null +++ b/packages/shared/src/schemas/account-claim-request.ts @@ -0,0 +1,25 @@ +import { z } from 'zod'; + +export const AccountClaimRequestSchema = z.object({ + id: z.string().uuid(), + type: z.enum(['pre-onboarding', 'post-onboarding-merge']), + /** + * Claimed legacy Person id — null when the user submitted a slug we can't + * resolve. Kept null (rather than rejected) so anti-enumeration responses + * remain uniform regardless of slug existence. + */ + claimedPersonId: z.string().uuid().nullable(), + claimedSlug: z.string().min(1), + requesterGithubLogin: z.string().min(1), + requesterGithubId: z.number().int(), + /** Populated for post-onboarding-merge; null for pre-onboarding. */ + requesterPersonId: z.string().uuid().nullable(), + evidence: z.string().max(5000), + status: z.enum(['open', 'approved', 'denied']), + submittedAt: z.string().datetime({ offset: true }), + reviewedAt: z.string().datetime({ offset: true }).nullable(), + reviewedBy: z.string().uuid().nullable(), + reviewedReason: z.string().nullable(), +}); + +export type AccountClaimRequest = z.infer; diff --git a/packages/shared/src/schemas/index.ts b/packages/shared/src/schemas/index.ts index 7502f27..2ec1922 100644 --- a/packages/shared/src/schemas/index.ts +++ b/packages/shared/src/schemas/index.ts @@ -36,3 +36,6 @@ export type { PrivateProfile, Newsletter } from './private-profile.js'; export { LegacyPasswordCredentialSchema } from './legacy-password-credential.js'; export type { LegacyPasswordCredential } from './legacy-password-credential.js'; + +export { AccountClaimRequestSchema } from './account-claim-request.js'; +export type { AccountClaimRequest } from './account-claim-request.js'; From 78b1c09a5a69e107586c6987d4c76d6c7f1799b7 Mon Sep 17 00:00:00 2001 From: Chris Alfano Date: Sat, 16 May 2026 21:14:54 -0400 Subject: [PATCH 04/10] =?UTF-8?q?feat(api):=20account-claim=20flow=20?= =?UTF-8?q?=E2=80=94=20endpoints,=20service,=20merge=20semantics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements specs/api/account-claim.md and specs/behaviors/account-migration.md: the legacy-account claim flow with three identity proofs (email, password, staff approval), the post-onboarding /account/claim-legacy search + merge, and the staff queue. - routes/account-claim.ts — 10 endpoints, claim-pending JWT validation, uniform 401 for by-password (anti-enumeration), 202-always for request-staff-review. - services/account-claim.ts — confirm/decline/byPassword/requestStaffReview plus the full post-onboarding merge: re-point memberships/updates/buzz/ help-wanted/interest by author, hard-remove the requester Person, write 90-day slug-history redirect, refresh PrivateProfile. - auth/legacy-password.ts — bcrypt verifier dispatcher (bcryptjs). Merge dedupes: when the claimed legacy Person already has a membership or interest in the same project/role, the requester's duplicate is dropped rather than creating two rows. Notable spec edge case kept simple: pre-onboarding staff-approval seeds Person.githubUserId/Login on the claimed legacy Person; the requester gets a session next sign-in via the OAuth byGithubUserId hit. --- apps/api/src/app.ts | 2 + apps/api/src/auth/legacy-password.ts | 26 + apps/api/src/plugins/services.ts | 6 +- apps/api/src/routes/account-claim.ts | 602 +++++++++++++++++++ apps/api/src/services/account-claim.ts | 788 +++++++++++++++++++++++++ 5 files changed, 1423 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/auth/legacy-password.ts create mode 100644 apps/api/src/routes/account-claim.ts create mode 100644 apps/api/src/services/account-claim.ts diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index dd49e77..cbef04d 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -35,6 +35,7 @@ import sessionMiddlewarePlugin from './auth/middleware.js'; import staticWebPlugin from './plugins/static-web.js'; import { healthRoutes } from './routes/health.js'; import { authRoutes } from './routes/auth.js'; +import { accountClaimRoutes } from './routes/account-claim.js'; import { projectRoutes } from './routes/projects.js'; import { peopleRoutes } from './routes/people.js'; import { tagRoutes } from './routes/tags.js'; @@ -141,6 +142,7 @@ export async function buildApp(opts: BuildAppOptions = {}): Promise { + if (BCRYPT_PREFIXES.some((p) => hash.startsWith(p))) { + return bcrypt.compare(password, hash); + } + throw new UnknownHashFormatError(hash.slice(0, Math.min(4, hash.length))); +} diff --git a/apps/api/src/plugins/services.ts b/apps/api/src/plugins/services.ts index e906f45..3947743 100644 --- a/apps/api/src/plugins/services.ts +++ b/apps/api/src/plugins/services.ts @@ -26,6 +26,7 @@ import { HelpWantedWriteService } from '../services/help-wanted.write.js'; import { PersonWriteService } from '../services/person.write.js'; import { TagWriteService } from '../services/tag.write.js'; import { GitHubAccountService } from '../services/github-account.js'; +import { AccountClaimService } from '../services/account-claim.js'; import { LoggingNotifier, type Notifier } from '../notify/index.js'; declare module 'fastify' { @@ -46,6 +47,7 @@ declare module 'fastify' { peopleWrite: PersonWriteService; tagsWrite: TagWriteService; githubAccount: GitHubAccountService; + accountClaim: AccountClaimService; }; /** Shared in-memory state — write routes call StateApply.apply against this. */ inMemoryState: InMemoryState; @@ -67,6 +69,7 @@ async function servicesPlugin(fastify: FastifyInstance): Promise { fastify.decorate('fts', fts); fastify.decorate('notifier', notifier); + const githubAccount = new GitHubAccountService(state); fastify.decorate('services', { projects: new ProjectService(state, fts), people: new PersonService(state, fts), @@ -81,7 +84,8 @@ async function servicesPlugin(fastify: FastifyInstance): Promise { helpWantedWrite: new HelpWantedWriteService(state), peopleWrite: new PersonWriteService(state, fastify.store.private), tagsWrite: new TagWriteService(state), - githubAccount: new GitHubAccountService(state), + githubAccount, + accountClaim: new AccountClaimService(state, fastify.store.private, githubAccount), }); } diff --git a/apps/api/src/routes/account-claim.ts b/apps/api/src/routes/account-claim.ts new file mode 100644 index 0000000..0bf5d15 --- /dev/null +++ b/apps/api/src/routes/account-claim.ts @@ -0,0 +1,602 @@ +/** + * Account-claim routes per specs/api/account-claim.md. + * + * Endpoints: + * GET /api/account-claim/candidates — claim-pending + * POST /api/account-claim/confirm — claim-pending + * POST /api/account-claim/decline — claim-pending + * POST /api/account-claim/by-password — claim-pending + * POST /api/account-claim/request-staff-review — claim-pending + * GET /api/account-claim/legacy — user + * POST /api/account-claim/legacy/request — user + * GET /api/staff/account-claim/queue — staff + * POST /api/staff/account-claim/:requestId/approve — staff + * POST /api/staff/account-claim/:requestId/deny — staff + * + * Auth model: claim endpoints validate a `cfp_claim` JWT cookie (scope='claim'). + * The session middleware deliberately does NOT honor cfp_claim — these routes + * verify it inline so a stray cookie can never escalate to a session. + */ +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; +import { errors as JoseErrors } from 'jose'; +import { ok } from '../lib/response.js'; +import { + ApiNotFoundError, + ApiValidationError, + ConflictError, + ForbiddenError, + UnauthenticatedError, +} from '../lib/errors.js'; +import { requireAuth } from '../auth/guards.js'; +import { verifyClaimPending } from '../auth/jwt.js'; +import { + clearClaimCookie, + setSessionCookies, +} from '../auth/cookies.js'; +import { mintSessionFor } from '../auth/issue.js'; +import type { SessionMeta } from '../auth/session-metadata.js'; +import { buildTransactionOptions } from '../store/commit-meta.js'; + +const CLAIM_TOKEN_INVALID = 'claim_token_invalid'; + +function clientIp(request: FastifyRequest): string { + const forwarded = request.headers['x-forwarded-for']; + if (typeof forwarded === 'string') { + return (forwarded.split(',')[0] ?? '').trim(); + } + return request.socket?.remoteAddress ?? 'unknown'; +} + +async function persistSession( + fastify: FastifyInstance, + request: FastifyRequest, + refreshJti: string, + personId: string, +): Promise { + const now = Date.now(); + const meta: SessionMeta = { + refreshJti, + personId, + userAgent: String(request.headers['user-agent'] ?? ''), + ipAddress: clientIp(request), + issuedAt: new Date(now).toISOString(), + expiresAt: new Date(now + 30 * 24 * 60 * 60 * 1000).toISOString(), + }; + await fastify.sessionMetadata.add(meta, fastify.store.private); +} + +async function readClaim( + fastify: FastifyInstance, + request: FastifyRequest, +): Promise extends Promise ? T : never> { + const token = request.cookies['cfp_claim']; + if (!token) { + throw new UnauthenticatedError('Claim token missing', CLAIM_TOKEN_INVALID); + } + try { + return await verifyClaimPending(token, fastify.config.CFP_JWT_SIGNING_KEY); + } catch (err) { + if ( + err instanceof JoseErrors.JWTExpired || + err instanceof JoseErrors.JWTInvalid || + err instanceof JoseErrors.JWSInvalid || + err instanceof JoseErrors.JWSSignatureVerificationFailed + ) { + throw new UnauthenticatedError('Claim token invalid', CLAIM_TOKEN_INVALID); + } + throw err; + } +} + +async function finalizeAutoClaim( + fastify: FastifyInstance, + request: FastifyRequest, + reply: FastifyReply, + personId: string, + accountLevel: 'user' | 'staff' | 'administrator', +): Promise { + const minted = await mintSessionFor( + personId, + accountLevel, + fastify.config.CFP_JWT_SIGNING_KEY, + ); + setSessionCookies( + reply, + { access: minted.accessToken, refresh: minted.refreshToken }, + fastify.config.NODE_ENV, + ); + await persistSession(fastify, request, minted.refreshJti, personId); + clearClaimCookie(reply); +} + +export async function accountClaimRoutes(fastify: FastifyInstance): Promise { + // --------------------------------------------------------------------------- + // GET /api/account-claim/candidates + // --------------------------------------------------------------------------- + fastify.get( + '/api/account-claim/candidates', + { + schema: { + tags: ['account-claim'], + summary: 'List candidate legacy Persons for the current claim flow', + }, + }, + async (request) => { + const claims = await readClaim(fastify, request); + const payload = await fastify.services.accountClaim.buildCandidateSummaries(claims); + return ok(payload); + }, + ); + + // --------------------------------------------------------------------------- + // POST /api/account-claim/confirm + // --------------------------------------------------------------------------- + fastify.post( + '/api/account-claim/confirm', + { + schema: { + tags: ['account-claim'], + summary: 'Confirm an email-match candidate and link the GitHub identity', + body: { + type: 'object', + required: ['personId'], + properties: { personId: { type: 'string', format: 'uuid' } }, + }, + }, + }, + async (request, reply) => { + const claims = await readClaim(fastify, request); + const { personId } = request.body as { personId: string }; + + const result = await fastify.store.transact( + buildTransactionOptions({ + request, + action: 'account-claim.confirm', + subjectType: 'person', + subjectId: personId, + responseCode: 200, + }), + async (tx) => fastify.services.accountClaim.confirm(tx, claims, personId), + ); + + const outcome = result.value; + if (!outcome.ok) { + if (outcome.code === 'not_a_candidate') { + throw new ForbiddenError('Not a candidate for this claim', 'not_a_candidate'); + } + if (outcome.code === 'email_match_required') { + throw new ForbiddenError('Email match required', 'email_match_required'); + } + // already_claimed → 409 + throw new ConflictError('Candidate already claimed', 'already_claimed'); + } + + outcome.result.stateApply.apply(fastify.inMemoryState, fastify.fts); + + await finalizeAutoClaim( + fastify, + request, + reply, + outcome.result.person.id, + outcome.result.person.accountLevel, + ); + + return reply.code(200).send( + ok({ person: outcome.result.person, accountLevel: outcome.result.person.accountLevel }), + ); + }, + ); + + // --------------------------------------------------------------------------- + // POST /api/account-claim/decline + // --------------------------------------------------------------------------- + fastify.post( + '/api/account-claim/decline', + { + schema: { + tags: ['account-claim'], + summary: 'Decline all candidates; create a fresh Person', + }, + }, + async (request, reply) => { + const claims = await readClaim(fastify, request); + + const result = await fastify.store.transact( + { + ...buildTransactionOptions({ + request, + action: 'account-claim.decline', + subjectType: 'person', + responseCode: 201, + }), + writeOrder: 'private-first', + }, + async (tx) => fastify.services.accountClaim.decline(tx, claims), + ); + + result.value.stateApply.apply(fastify.inMemoryState, fastify.fts); + + await finalizeAutoClaim( + fastify, + request, + reply, + result.value.person.id, + result.value.person.accountLevel, + ); + + return reply.code(201).send( + ok({ person: result.value.person, accountLevel: result.value.person.accountLevel }), + ); + }, + ); + + // --------------------------------------------------------------------------- + // POST /api/account-claim/by-password + // --------------------------------------------------------------------------- + fastify.post( + '/api/account-claim/by-password', + { + schema: { + tags: ['account-claim'], + summary: 'Verify legacy slug + password to claim', + body: { + type: 'object', + required: ['slug', 'password'], + properties: { + slug: { type: 'string', minLength: 1 }, + password: { type: 'string', minLength: 1 }, + }, + }, + }, + }, + async (request, reply) => { + const claims = await readClaim(fastify, request); + const { slug, password } = request.body as { slug: string; password: string }; + + const result = await fastify.store.transact( + buildTransactionOptions({ + request, + action: 'account-claim.by-password', + subjectType: 'person', + subjectSlug: slug, + responseCode: 200, + }), + async (tx) => fastify.services.accountClaim.byPassword(tx, claims, slug, password), + ); + + const outcome = result.value; + if (!outcome.ok) { + if (outcome.reason === 'unknown_format') { + // Internal log — uniform user-facing response per anti-enumeration. + request.log.warn( + { slug, reason: 'unknown_format' }, + 'legacy password hash format unknown', + ); + } + // Uniform 401 for any failure + throw new UnauthenticatedError( + 'Invalid credentials', + 'claim_credentials_invalid', + ); + } + + outcome.result.stateApply.apply(fastify.inMemoryState, fastify.fts); + + await finalizeAutoClaim( + fastify, + request, + reply, + outcome.result.person.id, + outcome.result.person.accountLevel, + ); + + return reply.code(200).send( + ok({ person: outcome.result.person, accountLevel: outcome.result.person.accountLevel }), + ); + }, + ); + + // --------------------------------------------------------------------------- + // POST /api/account-claim/request-staff-review + // --------------------------------------------------------------------------- + fastify.post( + '/api/account-claim/request-staff-review', + { + schema: { + tags: ['account-claim'], + summary: 'Submit a free-form claim request for staff review', + body: { + type: 'object', + required: ['claimedSlug', 'evidence'], + properties: { + claimedSlug: { type: 'string', minLength: 1, maxLength: 100 }, + evidence: { type: 'string', minLength: 1, maxLength: 5000 }, + }, + }, + }, + }, + async (request, reply) => { + const claims = await readClaim(fastify, request); + const { claimedSlug, evidence } = request.body as { + claimedSlug: string; + evidence: string; + }; + + await fastify.store.transact( + { + ...buildTransactionOptions({ + request, + action: 'account-claim.request-staff-review', + subjectType: 'account-claim-request', + // Note: claimedSlug intentionally omitted from trailers — slug + // existence shouldn't leak via the public commit log. + responseCode: 202, + }), + writeOrder: 'private-first', + }, + async (tx) => + fastify.services.accountClaim.requestStaffReview(tx, claims, claimedSlug, evidence), + ); + + return reply.code(202).send(ok({ delivered: true })); + }, + ); + + // --------------------------------------------------------------------------- + // GET /api/account-claim/legacy — post-onboarding search + // --------------------------------------------------------------------------- + fastify.get( + '/api/account-claim/legacy', + { + schema: { + tags: ['account-claim'], + summary: 'Post-onboarding: search for a legacy account to claim', + querystring: { + type: 'object', + required: ['q'], + properties: { q: { type: 'string', minLength: 1 } }, + }, + }, + }, + async (request) => { + requireAuth(request, ['user']); + const { q } = request.query as { q: string }; + const requesterId = request.session.personId ?? request.session.person!.id; + const candidate = await fastify.services.accountClaim.legacySearch(q, requesterId); + return ok({ candidates: candidate ? [candidate] : [] }); + }, + ); + + // --------------------------------------------------------------------------- + // POST /api/account-claim/legacy/request — post-onboarding staff review + // --------------------------------------------------------------------------- + fastify.post( + '/api/account-claim/legacy/request', + { + schema: { + tags: ['account-claim'], + summary: 'Post-onboarding: submit a merge request to staff', + body: { + type: 'object', + required: ['claimedSlug', 'evidence'], + properties: { + claimedSlug: { type: 'string', minLength: 1, maxLength: 100 }, + evidence: { type: 'string', minLength: 1, maxLength: 5000 }, + }, + }, + }, + }, + async (request, reply) => { + requireAuth(request, ['user']); + const { claimedSlug, evidence } = request.body as { + claimedSlug: string; + evidence: string; + }; + const requester = request.session.person; + if (!requester) { + throw new UnauthenticatedError('Authentication required'); + } + if (!requester.githubUserId) { + throw new ApiValidationError( + 'Requester has no GitHub identity to link', + { githubUserId: 'missing' }, + ); + } + + await fastify.store.transact( + { + ...buildTransactionOptions({ + request, + action: 'account-claim.legacy-request', + subjectType: 'account-claim-request', + responseCode: 202, + }), + writeOrder: 'private-first', + }, + async (tx) => + fastify.services.accountClaim.legacyRequest( + tx, + requester, + requester.githubUserId!, + claimedSlug, + evidence, + ), + ); + + return reply.code(202).send(ok({ delivered: true })); + }, + ); + + // --------------------------------------------------------------------------- + // Staff queue endpoints + // --------------------------------------------------------------------------- + + fastify.get( + '/api/staff/account-claim/queue', + { + schema: { + tags: ['account-claim'], + summary: 'List open account-claim requests (staff only)', + }, + }, + async (request) => { + requireAuth(request, ['staff']); + const items = await fastify.services.accountClaim.staffQueue(); + return ok( + items.map((r) => ({ + requestId: r.id, + type: r.type, + claimedSlug: r.claimedSlug, + claimedPersonId: r.claimedPersonId, + requesterGithubLogin: r.requesterGithubLogin, + requesterPersonId: r.requesterPersonId, + evidence: r.evidence, + submittedAt: r.submittedAt, + })), + ); + }, + ); + + fastify.post( + '/api/staff/account-claim/:requestId/approve', + { + schema: { + tags: ['account-claim'], + summary: 'Approve a pending account-claim request (staff only)', + params: { + type: 'object', + required: ['requestId'], + properties: { requestId: { type: 'string', format: 'uuid' } }, + }, + body: { + type: 'object', + properties: { reason: { type: 'string', maxLength: 2000 } }, + additionalProperties: false, + }, + }, + }, + async (request, reply) => { + requireAuth(request, ['staff']); + const { requestId } = request.params as { requestId: string }; + const body = (request.body ?? {}) as { reason?: string }; + const staffPersonId = request.session.personId ?? request.session.person!.id; + + const result = await fastify.store.transact( + buildTransactionOptions({ + request, + action: 'account-claim.approve', + subjectType: 'account-claim-request', + subjectId: requestId, + responseCode: 200, + extraTrailers: body.reason ? { Reason: body.reason } : undefined, + }), + async (tx) => + fastify.services.accountClaim.staffApprove( + tx, + requestId, + staffPersonId, + body.reason ?? null, + ), + ); + + const outcome = result.value; + if (!outcome.ok) { + if (outcome.code === 'not_found') { + throw new ApiNotFoundError(`Claim request ${requestId} not found`); + } + if (outcome.code === 'already_reviewed') { + throw new ConflictError('Claim request already reviewed', 'already_reviewed'); + } + if (outcome.code === 'no_claimed_person') { + throw new ApiValidationError( + 'No claimed Person resolves for this request', + { claimedSlug: 'unresolved' }, + ); + } + if (outcome.code === 'requester_missing') { + throw new ApiValidationError('Requester Person missing', { requesterPersonId: 'missing' }); + } + // already_claimed + throw new ConflictError( + 'Target Person already linked to a GitHub identity', + 'already_claimed', + ); + } + + outcome.result.stateApply.apply(fastify.inMemoryState, fastify.fts); + if (outcome.result.mergeApply) { + outcome.result.mergeApply.hardRemovePersonFromState(fastify.inMemoryState, fastify.fts); + } + + return reply.code(200).send( + ok({ + requestId: outcome.result.request.id, + status: outcome.result.request.status, + person: outcome.result.person, + }), + ); + }, + ); + + fastify.post( + '/api/staff/account-claim/:requestId/deny', + { + schema: { + tags: ['account-claim'], + summary: 'Deny a pending account-claim request (staff only)', + params: { + type: 'object', + required: ['requestId'], + properties: { requestId: { type: 'string', format: 'uuid' } }, + }, + body: { + type: 'object', + properties: { reason: { type: 'string', maxLength: 2000 } }, + additionalProperties: false, + }, + }, + }, + async (request, reply) => { + requireAuth(request, ['staff']); + const { requestId } = request.params as { requestId: string }; + const body = (request.body ?? {}) as { reason?: string }; + const staffPersonId = request.session.personId ?? request.session.person!.id; + + const result = await fastify.store.transact( + { + ...buildTransactionOptions({ + request, + action: 'account-claim.deny', + subjectType: 'account-claim-request', + subjectId: requestId, + responseCode: 200, + extraTrailers: body.reason ? { Reason: body.reason } : undefined, + }), + writeOrder: 'private-first', + }, + async (tx) => + fastify.services.accountClaim.staffDeny( + tx, + requestId, + staffPersonId, + body.reason ?? null, + ), + ); + + const outcome = result.value; + if (!outcome.ok) { + if (outcome.code === 'not_found') { + throw new ApiNotFoundError(`Claim request ${requestId} not found`); + } + throw new ConflictError('Claim request already reviewed', 'already_reviewed'); + } + + return reply.code(200).send( + ok({ + requestId: outcome.result.request.id, + status: outcome.result.request.status, + }), + ); + }, + ); +} diff --git a/apps/api/src/services/account-claim.ts b/apps/api/src/services/account-claim.ts new file mode 100644 index 0000000..20b1bb2 --- /dev/null +++ b/apps/api/src/services/account-claim.ts @@ -0,0 +1,788 @@ +/** + * Account-claim service. + * + * The legacy-account claim flow per specs/api/account-claim.md and + * specs/behaviors/account-migration.md. Three identity proofs map to three + * write paths: + * + * A. Email-match → confirm(personId) — auto-claim + * B. Password → byPassword(slug, password) — auto-claim after verify + * C. Staff review → requestStaffReview(...) — queued for admin + * + * Plus the post-onboarding merge path, which always goes through staff. + * + * All write paths run inside `store.transact` so the public commit and + * private mutation land together; the dual-store atomicity guarantees from + * specs/behaviors/private-storage.md apply. + */ +import { + PersonSchema, + PrivateProfileSchema, + SlugHistorySchema, + type AccountClaimRequest, + type HelpWantedInterestExpression, + type HelpWantedRole, + type Person, + type PrivateProfile, + type ProjectBuzz, + type ProjectMembership, + type ProjectUpdate, + type SlugHistory, +} from '@cfp/shared/schemas'; +import { uuidv7 } from 'uuidv7'; +import type { DualStoreTx } from '../store/store.js'; +import type { InMemoryState } from '../store/memory/state.js'; +import { StateApply } from '../store/state-apply.js'; +import type { PrivateStore } from '../store/private/index.js'; +import type { + ClaimPendingClaims, + GhIdentitySnapshot, +} from '../auth/jwt.js'; +import { verifyLaddrPassword, UnknownHashFormatError } from '../auth/legacy-password.js'; +import { GitHubAccountService } from './github-account.js'; +import type { ResolvedGitHubIdentity } from '../auth/github-client.js'; + +const SLUG_HISTORY_TTL_DAYS = 90; + +function nowIso(): string { + return new Date().toISOString(); +} + +function expiresInDays(days: number): string { + return new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString(); +} + +export interface CandidateSummary { + readonly personId: string; + readonly slug: string; + readonly fullName: string; + readonly memberOfCount: number; + readonly lastActiveAt: string; + readonly matchedVia: ReadonlyArray<'email' | 'username'>; + readonly matchedEmail: string | null; +} + +export interface CandidatesPayload { + readonly ghLogin: string; + readonly ghName: string | null; + readonly candidates: CandidateSummary[]; +} + +export interface ClaimSuccessResult { + readonly person: Person; + readonly stateApply: StateApply; +} + +/** Result of `confirm` and `byPassword` — used by the route to issue session. */ +export interface AutoClaimResult extends ClaimSuccessResult {} + +/** Result of `decline` — fresh Person + PrivateProfile created. */ +export interface DeclineResult extends ClaimSuccessResult {} + +export interface StaffApproveResult { + readonly request: AccountClaimRequest; + readonly person: Person | null; + readonly stateApply: StateApply; + /** Set only when the approval is a post-onboarding merge. */ + readonly mergeApply?: MergeApply; +} + +export interface StaffDenyResult { + readonly request: AccountClaimRequest; +} + +/** Reconstruct a ResolvedGitHubIdentity from the claim-pending JWT claims. */ +export function ghIdentityFromClaim(claims: ClaimPendingClaims): ResolvedGitHubIdentity { + const id = Number(claims.sub); + if (!Number.isFinite(id) || id <= 0) { + throw new Error('Invalid GitHub id in claim JWT'); + } + // The claim-pending JWT only carries email *strings*, not the full + // {primary,verified} tuples we get from /user/emails. By construction every + // email in the JWT was verified at OAuth time, so we reconstruct flags. + const emails = claims.ghEmails.map((email, idx) => ({ + email, + primary: idx === 0, + verified: true, + })); + return { + id, + login: claims.ghLogin, + name: claims.ghName, + emails, + primaryEmail: emails[0]?.email ?? null, + }; +} + +/** Reconstruct a GhIdentitySnapshot from the claim-pending JWT claims. */ +export function ghSnapshotFromClaim(claims: ClaimPendingClaims): GhIdentitySnapshot { + return { + ghId: claims.sub, + ghLogin: claims.ghLogin, + ghName: claims.ghName, + ghEmails: claims.ghEmails, + }; +} + +export class AccountClaimService { + readonly #state: InMemoryState; + readonly #privateStore: PrivateStore; + readonly #githubAccount: GitHubAccountService; + + constructor( + state: InMemoryState, + privateStore: PrivateStore, + githubAccount: GitHubAccountService, + ) { + this.#state = state; + this.#privateStore = privateStore; + this.#githubAccount = githubAccount; + } + + /** + * Build candidate summaries for the candidate IDs carried in a claim JWT. + * Skips any IDs that no longer resolve (deleted) or that have since been + * claimed by another GitHub identity (race). + */ + async buildCandidateSummaries(claims: ClaimPendingClaims): Promise { + const verifiedEmails = new Set(claims.ghEmails.map((e) => e.toLowerCase())); + const usernameSlug = claims.ghLogin.toLowerCase(); + const candidates: CandidateSummary[] = []; + + for (const personId of claims.candidates) { + const person = this.#state.people.get(personId); + if (!person || person.deletedAt || person.githubUserId) continue; + + const profile = await this.#privateStore.getProfile(personId); + const matchedEmail = + profile && verifiedEmails.has(profile.email.toLowerCase()) ? profile.email : null; + const usernameMatch = person.slug.toLowerCase() === usernameSlug; + const matchedVia: Array<'email' | 'username'> = []; + if (matchedEmail) matchedVia.push('email'); + if (usernameMatch) matchedVia.push('username'); + if (matchedVia.length === 0) continue; + + candidates.push({ + personId: person.id, + slug: person.slug, + fullName: person.fullName, + memberOfCount: this.#state.membershipsByPerson.get(person.id)?.size ?? 0, + lastActiveAt: person.updatedAt, + matchedVia, + matchedEmail, + }); + } + + return { + ghLogin: claims.ghLogin, + ghName: claims.ghName, + candidates, + }; + } + + /** + * Confirm an email-match candidate. The candidate must be in the JWT's + * candidate set AND have a verified GitHub email matching its PrivateProfile. + * Username-only matches throw `email_match_required` — those must use + * by-password or staff-review. + */ + async confirm( + tx: DualStoreTx, + claims: ClaimPendingClaims, + personId: string, + ): Promise< + | { ok: true; result: AutoClaimResult } + | { ok: false; code: 'not_a_candidate' | 'email_match_required' | 'already_claimed' } + > { + if (!claims.candidates.includes(personId)) { + return { ok: false, code: 'not_a_candidate' }; + } + const person = this.#state.people.get(personId); + if (!person || person.deletedAt) { + return { ok: false, code: 'not_a_candidate' }; + } + if (person.githubUserId) { + return { ok: false, code: 'already_claimed' }; + } + + const profile = await this.#privateStore.getProfile(personId); + const verifiedEmails = new Set(claims.ghEmails.map((e) => e.toLowerCase())); + if (!profile || !verifiedEmails.has(profile.email.toLowerCase())) { + return { ok: false, code: 'email_match_required' }; + } + + const identity = ghIdentityFromClaim(claims); + const primaryEmail = identity.primaryEmail ?? identity.emails[0]?.email ?? null; + if (!primaryEmail) { + // Should not happen — OAuth callback would have rejected this — but + // guard anyway so we never silently drop an email refresh. + return { ok: false, code: 'email_match_required' }; + } + + const claimed = await this.#linkLegacyPerson(tx, person, identity, primaryEmail, profile); + return { ok: true, result: claimed }; + } + + /** + * Decline all candidates: create a brand-new Person + PrivateProfile per the + * github-oauth "create-fresh" path. Legacy candidates remain available for + * someone else to claim. + */ + async decline(tx: DualStoreTx, claims: ClaimPendingClaims): Promise { + const identity = ghIdentityFromClaim(claims); + const primaryEmail = identity.primaryEmail ?? identity.emails[0]?.email; + if (!primaryEmail) { + throw new Error('Cannot decline without a verified GitHub email'); + } + const created = await this.#githubAccount.createFresh(tx, identity, primaryEmail); + return { person: created.person, stateApply: created.stateApply }; + } + + /** + * Verify legacy username + password and, on match, claim the candidate. + * + * Returns a uniform `invalid` for "no such slug," "already-claimed," + * "no credential on file," "wrong password," or unknown hash format + * (the last case is logged separately via the caller, since we still + * want a uniform user-visible response). + */ + async byPassword( + tx: DualStoreTx, + claims: ClaimPendingClaims, + slug: string, + password: string, + ): Promise< + | { ok: true; result: AutoClaimResult } + | { ok: false; code: 'invalid'; reason: 'no_slug' | 'already_claimed' | 'no_credential' | 'wrong_password' | 'unknown_format' } + > { + const personId = this.#state.personIdBySlug.get(slug.toLowerCase()); + if (!personId) { + return { ok: false, code: 'invalid', reason: 'no_slug' }; + } + const person = this.#state.people.get(personId); + if (!person || person.deletedAt) { + return { ok: false, code: 'invalid', reason: 'no_slug' }; + } + if (person.githubUserId) { + return { ok: false, code: 'invalid', reason: 'already_claimed' }; + } + + const cred = await this.#privateStore.getLegacyPassword(person.id); + if (!cred) { + return { ok: false, code: 'invalid', reason: 'no_credential' }; + } + + let matched: boolean; + try { + matched = await verifyLaddrPassword(password, cred.passwordHash); + } catch (err) { + if (err instanceof UnknownHashFormatError) { + return { ok: false, code: 'invalid', reason: 'unknown_format' }; + } + throw err; + } + if (!matched) { + return { ok: false, code: 'invalid', reason: 'wrong_password' }; + } + + const identity = ghIdentityFromClaim(claims); + const primaryEmail = identity.primaryEmail ?? identity.emails[0]?.email ?? null; + if (!primaryEmail) { + // Same guard as confirm() + return { ok: false, code: 'invalid', reason: 'wrong_password' }; + } + + const profile = await this.#privateStore.getProfile(person.id); + const claimed = await this.#linkLegacyPerson(tx, person, identity, primaryEmail, profile); + return { ok: true, result: claimed }; + } + + /** + * Create an open AccountClaimRequest. Always succeeds (anti-enumeration): + * the response is identical whether or not the claimed slug exists. + */ + async requestStaffReview( + tx: DualStoreTx, + claims: ClaimPendingClaims, + claimedSlug: string, + evidence: string, + ): Promise<{ request: AccountClaimRequest }> { + const personId = this.#state.personIdBySlug.get(claimedSlug.toLowerCase()) ?? null; + const request: AccountClaimRequest = { + id: uuidv7(), + type: 'pre-onboarding', + claimedPersonId: personId, + claimedSlug, + requesterGithubLogin: claims.ghLogin, + requesterGithubId: Number(claims.sub), + requesterPersonId: null, + evidence, + status: 'open', + submittedAt: nowIso(), + reviewedAt: null, + reviewedBy: null, + reviewedReason: null, + }; + tx.private.putClaimRequest(request); + return { request }; + } + + /** + * Post-onboarding search: signed-in user looks for their own legacy account. + * Anti-enumeration: returns 0 or 1 candidate; nothing reveals which slugs + * exist beyond what the user themselves typed. + */ + async legacySearch(q: string, requesterPersonId: string): Promise { + const trimmed = q.trim().toLowerCase(); + if (!trimmed) return null; + + let personId: string | null = null; + let matchedVia: Array<'email' | 'username'> = []; + let matchedEmail: string | null = null; + + if (trimmed.includes('@')) { + personId = await this.#privateStore.findPersonIdByEmail(trimmed); + if (personId) { + matchedVia = ['email']; + const p = await this.#privateStore.getProfile(personId); + matchedEmail = p?.email ?? null; + } + } else { + personId = this.#state.personIdBySlug.get(trimmed) ?? null; + if (personId) matchedVia = ['username']; + } + if (!personId) return null; + if (personId === requesterPersonId) return null; + + const person = this.#state.people.get(personId); + if (!person || person.deletedAt || person.githubUserId) return null; + + return { + personId: person.id, + slug: person.slug, + fullName: person.fullName, + memberOfCount: this.#state.membershipsByPerson.get(person.id)?.size ?? 0, + lastActiveAt: person.updatedAt, + matchedVia, + matchedEmail, + }; + } + + /** + * Post-onboarding staff-review submission. The requester is a signed-in user + * who realized later they had a legacy account. Approval triggers the merge. + */ + async legacyRequest( + tx: DualStoreTx, + requester: Person, + requesterGithubId: number, + claimedSlug: string, + evidence: string, + ): Promise<{ request: AccountClaimRequest }> { + const personId = this.#state.personIdBySlug.get(claimedSlug.toLowerCase()) ?? null; + const request: AccountClaimRequest = { + id: uuidv7(), + type: 'post-onboarding-merge', + claimedPersonId: personId, + claimedSlug, + requesterGithubLogin: requester.githubLogin ?? '', + requesterGithubId, + requesterPersonId: requester.id, + evidence, + status: 'open', + submittedAt: nowIso(), + reviewedAt: null, + reviewedBy: null, + reviewedReason: null, + }; + tx.private.putClaimRequest(request); + return { request }; + } + + /** Staff queue listing. Open requests only. */ + async staffQueue(): Promise { + const all = await this.#privateStore.listOpenClaimRequests(); + return all.slice().sort((a, b) => a.submittedAt.localeCompare(b.submittedAt)); + } + + /** Mark a request denied. */ + async staffDeny( + tx: DualStoreTx, + requestId: string, + staffPersonId: string, + reason: string | null, + ): Promise< + | { ok: true; result: StaffDenyResult } + | { ok: false; code: 'not_found' | 'already_reviewed' } + > { + const existing = await this.#privateStore.getClaimRequest(requestId); + if (!existing) return { ok: false, code: 'not_found' }; + if (existing.status !== 'open') return { ok: false, code: 'already_reviewed' }; + + const updated: AccountClaimRequest = { + ...existing, + status: 'denied', + reviewedAt: nowIso(), + reviewedBy: staffPersonId, + reviewedReason: reason, + }; + tx.private.putClaimRequest(updated); + return { ok: true, result: { request: updated } }; + } + + /** Approve a request — pre-onboarding link or post-onboarding merge. */ + async staffApprove( + tx: DualStoreTx, + requestId: string, + staffPersonId: string, + reason: string | null, + ): Promise< + | { ok: true; result: StaffApproveResult } + | { ok: false; code: 'not_found' | 'already_reviewed' | 'no_claimed_person' | 'requester_missing' | 'already_claimed' } + > { + const existing = await this.#privateStore.getClaimRequest(requestId); + if (!existing) return { ok: false, code: 'not_found' }; + if (existing.status !== 'open') return { ok: false, code: 'already_reviewed' }; + if (!existing.claimedPersonId) return { ok: false, code: 'no_claimed_person' }; + + const claimed = this.#state.people.get(existing.claimedPersonId); + if (!claimed || claimed.deletedAt) return { ok: false, code: 'no_claimed_person' }; + + const stateApply = new StateApply(); + const now = nowIso(); + + if (existing.type === 'pre-onboarding') { + // Link the requester's GitHub identity to the claimed legacy Person. + // The requester has no Person yet (pre-onboarding), so on their next + // sign-in the GitHub callback's byGithubUserId lookup will hit. + if (claimed.githubUserId) { + return { ok: false, code: 'already_claimed' }; + } + const updated = PersonSchema.parse({ + ...claimed, + githubUserId: existing.requesterGithubId, + githubLogin: existing.requesterGithubLogin, + githubLinkedAt: now, + updatedAt: now, + }); + await tx.public.people.upsert(updated); + stateApply.upsertPerson(updated); + + const markedReviewed: AccountClaimRequest = { + ...existing, + status: 'approved', + reviewedAt: now, + reviewedBy: staffPersonId, + reviewedReason: reason, + }; + tx.private.putClaimRequest(markedReviewed); + return { ok: true, result: { request: markedReviewed, person: updated, stateApply } }; + } + + // post-onboarding-merge + if (!existing.requesterPersonId) return { ok: false, code: 'requester_missing' }; + const requester = this.#state.people.get(existing.requesterPersonId); + if (!requester || requester.deletedAt) return { ok: false, code: 'requester_missing' }; + if (claimed.githubUserId) return { ok: false, code: 'already_claimed' }; + + const mergeApply = await this.#mergePostOnboarding(tx, claimed, requester, now); + // Replay all merge ops onto our StateApply via its public API. + mergeApply.replay(stateApply); + + const markedReviewed: AccountClaimRequest = { + ...existing, + status: 'approved', + reviewedAt: now, + reviewedBy: staffPersonId, + reviewedReason: reason, + }; + tx.private.putClaimRequest(markedReviewed); + return { + ok: true, + result: { + request: markedReviewed, + person: mergeApply.updatedPerson, + stateApply, + mergeApply, + }, + }; + } + + // ------------------------------------------------------------------------- + // Internals + // ------------------------------------------------------------------------- + + /** + * Link a GitHub identity to a legacy Person: + * - Person: set githubUserId/Login/LinkedAt, fix slackSamlNameId if absent + * - PrivateProfile: refresh email + emailRefreshedAt + * - LegacyPasswordCredential: delete (no longer needed) + */ + async #linkLegacyPerson( + tx: DualStoreTx, + person: Person, + identity: ResolvedGitHubIdentity, + primaryEmail: string, + currentProfile: PrivateProfile | null, + ): Promise { + const now = nowIso(); + const updated = PersonSchema.parse({ + ...person, + githubUserId: identity.id, + githubLogin: identity.login, + githubLinkedAt: now, + // Legacy import seeds slackSamlNameId from slug. If a legacy Person + // somehow lacks it, set it to the current slug so SAML continuity holds. + slackSamlNameId: person.slackSamlNameId ?? person.slug, + updatedAt: now, + }); + await tx.public.people.upsert(updated); + + const profile: PrivateProfile = PrivateProfileSchema.parse({ + personId: person.id, + email: primaryEmail.toLowerCase(), + emailRefreshedAt: now, + newsletter: currentProfile?.newsletter ?? null, + updatedAt: now, + }); + tx.private.putProfile(profile); + tx.private.deleteLegacyPassword(person.id); + + const stateApply = new StateApply().upsertPerson(updated); + return { person: updated, stateApply }; + } + + /** + * Merge a fresh requester Person into a legacy claimed Person. The legacy + * Person wins (per account-migration.md): all records authored by the + * requester are re-pointed to the legacy Person id, the requester Person is + * hard-deleted, the legacy Person gains the GitHub link, and a slug-history + * entry redirects the requester's slug for 90 days. + * + * Returns a structured payload the caller composes onto its StateApply. + */ + async #mergePostOnboarding( + tx: DualStoreTx, + claimed: Person, + requester: Person, + now: string, + ): Promise { + // Re-point authored records -------------------------------------------- + + const reMemberships: ProjectMembership[] = []; + const removedMemberships: ProjectMembership[] = []; + const requesterMembershipIds = this.#state.membershipsByPerson.get(requester.id) ?? new Set(); + for (const mId of requesterMembershipIds) { + const m = this.#state.projectMemberships.get(mId); + if (!m) continue; + const mPath = m as ProjectMembership & { projectSlug: string; personSlug: string }; + // If the claimed Person already has a membership in this project, + // drop the requester's duplicate rather than creating two. + const claimedMemberships = this.#state.membershipsByPerson.get(claimed.id) ?? new Set(); + const claimedHasIt = [...claimedMemberships].some((id) => { + const cm = this.#state.projectMemberships.get(id); + return cm?.projectId === m.projectId; + }); + if (claimedHasIt) { + await tx.public['project-memberships'].delete(mPath as unknown as ProjectMembership); + removedMemberships.push(m); + continue; + } + const updated: ProjectMembership = { + ...m, + personId: claimed.id, + personSlug: claimed.slug, + } as ProjectMembership; + // Path template uses {projectSlug}/{personSlug}, so renaming personSlug + // moves the file: delete the old key, then upsert the new one. + await tx.public['project-memberships'].delete(mPath as unknown as ProjectMembership); + await tx.public['project-memberships'].upsert(updated); + reMemberships.push(updated); + } + + const reUpdates: ProjectUpdate[] = []; + for (const u of this.#state.projectUpdates.values()) { + if (u.authorId !== requester.id) continue; + const updated: ProjectUpdate = { ...u, authorId: claimed.id }; + await tx.public['project-updates'].upsert(updated); + reUpdates.push(updated); + } + + const reBuzz: ProjectBuzz[] = []; + for (const b of this.#state.projectBuzz.values()) { + if (b.postedById !== requester.id) continue; + const updated: ProjectBuzz = { ...b, postedById: claimed.id }; + await tx.public['project-buzz'].upsert(updated); + reBuzz.push(updated); + } + + const reRoles: HelpWantedRole[] = []; + for (const r of this.#state.helpWantedRoles.values()) { + let updated: HelpWantedRole | null = null; + if (r.postedById === requester.id) { + updated = { ...(updated ?? r), postedById: claimed.id }; + } + if (r.filledById === requester.id) { + updated = { ...(updated ?? r), filledById: claimed.id }; + } + if (updated) { + await tx.public['help-wanted-roles'].upsert(updated); + reRoles.push(updated); + } + } + + const reInterest: HelpWantedInterestExpression[] = []; + const removedInterest: HelpWantedInterestExpression[] = []; + for (const e of this.#state.helpWantedInterest.values()) { + if (e.personId !== requester.id) continue; + const ePath = e as HelpWantedInterestExpression & { personSlug: string }; + // Dedupe: if the claimed Person already expressed interest in this role, + // drop the requester's row. + const claimedKey = `${e.roleId}:${claimed.id}`; + const claimedExisting = this.#state.interestByRoleAndPerson.get(claimedKey); + if (claimedExisting) { + await tx.public['help-wanted-interest'].delete(ePath as unknown as HelpWantedInterestExpression); + removedInterest.push(e); + continue; + } + const updated: HelpWantedInterestExpression = { + ...e, + personId: claimed.id, + personSlug: claimed.slug, + } as HelpWantedInterestExpression; + await tx.public['help-wanted-interest'].delete(ePath as unknown as HelpWantedInterestExpression); + await tx.public['help-wanted-interest'].upsert(updated); + reInterest.push(updated); + } + + // Update the claimed Person with GitHub identity ---------------------- + + const updatedClaimed = PersonSchema.parse({ + ...claimed, + githubUserId: requester.githubUserId ?? claimed.githubUserId ?? null, + githubLogin: requester.githubLogin ?? claimed.githubLogin ?? null, + githubLinkedAt: now, + slackSamlNameId: claimed.slackSamlNameId ?? claimed.slug, + updatedAt: now, + }); + await tx.public.people.upsert(updatedClaimed); + + // Slug history for requester's old slug ------------------------------- + + const slugHistory: SlugHistory = SlugHistorySchema.parse({ + id: uuidv7(), + entityType: 'person', + oldSlug: requester.slug, + newSlug: claimed.slug, + entityId: claimed.id, + changedAt: now, + expiresAt: expiresInDays(SLUG_HISTORY_TTL_DAYS), + }); + await tx.public['slug-history'].upsert(slugHistory); + + // Hard-delete the requester Person ----------------------------------- + + await tx.public.people.delete(requester); + + // Private side: refresh claimed profile email if requester had a fresher + // one, then delete requester's profile. + const claimedProfile = await this.#privateStore.getProfile(claimed.id); + const requesterProfile = await this.#privateStore.getProfile(requester.id); + if (requesterProfile) { + const merged: PrivateProfile = PrivateProfileSchema.parse({ + personId: claimed.id, + email: requesterProfile.email, + emailRefreshedAt: now, + newsletter: claimedProfile?.newsletter ?? requesterProfile.newsletter ?? null, + updatedAt: now, + }); + tx.private.putProfile(merged); + tx.private.deleteProfile(requester.id); + } + // No legacy password remains for the claimed Person at this point + // (post-onboarding flow means the legacy Person was unclaimed); delete + // anything still present defensively. + tx.private.deleteLegacyPassword(claimed.id); + + return new MergeApply({ + updatedPerson: updatedClaimed, + deletedRequesterPersonId: requester.id, + deletedRequesterSlug: requester.slug, + reMemberships, + removedMemberships, + reUpdates, + reBuzz, + reRoles, + reInterest, + removedInterest, + }); + } +} + +// --------------------------------------------------------------------------- +// MergeApply — sequenced in-memory updates queued for the post-onboarding merge +// --------------------------------------------------------------------------- + +interface MergeApplyInput { + readonly updatedPerson: Person; + readonly deletedRequesterPersonId: string; + readonly deletedRequesterSlug: string; + readonly reMemberships: ProjectMembership[]; + readonly removedMemberships: ProjectMembership[]; + readonly reUpdates: ProjectUpdate[]; + readonly reBuzz: ProjectBuzz[]; + readonly reRoles: HelpWantedRole[]; + readonly reInterest: HelpWantedInterestExpression[]; + readonly removedInterest: HelpWantedInterestExpression[]; +} + +export class MergeApply { + readonly updatedPerson: Person; + readonly deletedRequesterPersonId: string; + readonly ops: ReadonlyArray = []; + readonly #input: MergeApplyInput; + + constructor(input: MergeApplyInput) { + this.#input = input; + this.updatedPerson = input.updatedPerson; + this.deletedRequesterPersonId = input.deletedRequesterPersonId; + } + + /** + * Replay all merge mutations onto a StateApply via its public API. After + * `stateApply.apply()`, callers must also invoke `hardRemovePersonFromState` + * to clear the requester Person — StateApply doesn't expose a hard-remove + * for Person, and the merge specifically wants the requester id to disappear. + */ + replay(stateApply: StateApply): void { + const i = this.#input; + stateApply.upsertPerson(i.updatedPerson); + for (const m of i.reMemberships) stateApply.upsertMembership(m); + for (const m of i.removedMemberships) stateApply.removeMembership(m); + for (const u of i.reUpdates) stateApply.upsertProjectUpdate(u); + for (const b of i.reBuzz) stateApply.upsertProjectBuzz(b); + for (const r of i.reRoles) stateApply.upsertHelpWantedRole(r); + for (const e of i.reInterest) stateApply.upsertInterest(e); + } + + hardRemovePersonFromState(state: InMemoryState, fts: { removePerson: (slug: string) => void }): void { + const i = this.#input; + const oldId = i.deletedRequesterPersonId; + const oldSlug = i.deletedRequesterSlug; + state.people.delete(oldId); + state.personSlugById.delete(oldId); + state.personIdBySlug.delete(oldSlug); + state.membershipsByPerson.delete(oldId); + fts.removePerson(oldSlug); + // Clean up removed interest rows that were de-duped during merge. + for (const e of i.removedInterest) { + state.helpWantedInterest.delete(e.id); + state.interestByRole.get(e.roleId)?.delete(e.id); + const key = `${e.roleId}:${e.personId}`; + if (state.interestByRoleAndPerson.get(key) === e.id) { + state.interestByRoleAndPerson.delete(key); + } + } + } +} + From 7a71529f410aba531b2ad686f884212e45a8c0a4 Mon Sep 17 00:00:00 2001 From: Chris Alfano Date: Sat, 16 May 2026 21:26:39 -0400 Subject: [PATCH 05/10] fix(api): tighten lint on account-claim service --- apps/api/src/services/account-claim.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/api/src/services/account-claim.ts b/apps/api/src/services/account-claim.ts index 20b1bb2..7bd4e96 100644 --- a/apps/api/src/services/account-claim.ts +++ b/apps/api/src/services/account-claim.ts @@ -74,10 +74,10 @@ export interface ClaimSuccessResult { } /** Result of `confirm` and `byPassword` — used by the route to issue session. */ -export interface AutoClaimResult extends ClaimSuccessResult {} +export type AutoClaimResult = ClaimSuccessResult; /** Result of `decline` — fresh Person + PrivateProfile created. */ -export interface DeclineResult extends ClaimSuccessResult {} +export type DeclineResult = ClaimSuccessResult; export interface StaffApproveResult { readonly request: AccountClaimRequest; @@ -336,7 +336,7 @@ export class AccountClaimService { const trimmed = q.trim().toLowerCase(); if (!trimmed) return null; - let personId: string | null = null; + let personId: string | null; let matchedVia: Array<'email' | 'username'> = []; let matchedEmail: string | null = null; From fb0a2d6fcd69c63a29fe7d0dd20f211896d5ee5a Mon Sep 17 00:00:00 2001 From: Chris Alfano Date: Sat, 16 May 2026 21:26:49 -0400 Subject: [PATCH 06/10] feat(web): account-claim screens replacing the github-oauth placeholder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements specs/screens/account-claim.md: - /account-claim — single vs. multi candidate UI, with email-match auto-confirm and a username-only "verify with old password" hand-off - /account-claim/by-password — slug + password form, uniform invalid message - /account-claim/request-staff-review — evidence textarea + "continue as new" - /account/claim-legacy — post-onboarding search + merge-request submission - /staff/account-claim — staff queue with approve/deny + note The AccountClaimPlaceholder shipped by github-oauth is removed; the route points at the new AccountClaim screen. --- apps/web/src/App.tsx | 12 +- apps/web/src/lib/api.ts | 97 +++++++ apps/web/src/pages/AccountClaim.tsx | 237 ++++++++++++++++++ apps/web/src/pages/AccountClaimByPassword.tsx | 127 ++++++++++ apps/web/src/pages/AccountClaimLegacy.tsx | 207 +++++++++++++++ .../web/src/pages/AccountClaimPlaceholder.tsx | 48 ---- .../pages/AccountClaimRequestStaffReview.tsx | 136 ++++++++++ apps/web/src/pages/StaffAccountClaimQueue.tsx | 170 +++++++++++++ 8 files changed, 984 insertions(+), 50 deletions(-) create mode 100644 apps/web/src/pages/AccountClaim.tsx create mode 100644 apps/web/src/pages/AccountClaimByPassword.tsx create mode 100644 apps/web/src/pages/AccountClaimLegacy.tsx delete mode 100644 apps/web/src/pages/AccountClaimPlaceholder.tsx create mode 100644 apps/web/src/pages/AccountClaimRequestStaffReview.tsx create mode 100644 apps/web/src/pages/StaffAccountClaimQueue.tsx diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index ec7cd20..d19a3e3 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -24,7 +24,11 @@ import { Sponsor } from '@/screens/Sponsor'; import { ComingSoon } from '@/pages/ComingSoon'; import { NotFound } from '@/pages/NotFound'; import { LoginPlaceholder } from '@/pages/LoginPlaceholder'; -import { AccountClaimPlaceholder } from '@/pages/AccountClaimPlaceholder'; +import { AccountClaim } from '@/pages/AccountClaim'; +import { AccountClaimByPassword } from '@/pages/AccountClaimByPassword'; +import { AccountClaimRequestStaffReview } from '@/pages/AccountClaimRequestStaffReview'; +import { AccountClaimLegacy } from '@/pages/AccountClaimLegacy'; +import { StaffAccountClaimQueue } from '@/pages/StaffAccountClaimQueue'; const router = createBrowserRouter([ { @@ -55,7 +59,11 @@ const router = createBrowserRouter([ { path: '/pages/:slug', element: }, { path: '/contact', element: }, { path: '/login', element: }, - { path: '/account-claim', element: }, + { path: '/account-claim', element: }, + { path: '/account-claim/by-password', element: }, + { path: '/account-claim/request-staff-review', element: }, + { path: '/account/claim-legacy', element: }, + { path: '/staff/account-claim', element: }, { path: '*', element: }, ], }, diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 2a17d00..28dc34e 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -488,6 +488,44 @@ export interface NewsletterResponse { newsletter: NewsletterState | null; } +export interface AccountClaimCandidate { + readonly personId: string; + readonly slug: string; + readonly fullName: string; + readonly memberOfCount: number; + readonly lastActiveAt: string; + readonly matchedVia: ReadonlyArray<'email' | 'username'>; + readonly matchedEmail: string | null; +} + +export interface AccountClaimCandidatesPayload { + readonly ghLogin: string; + readonly ghName: string | null; + readonly candidates: AccountClaimCandidate[]; +} + +export interface AccountClaimSessionResult { + readonly person: PersonDetail; + readonly accountLevel: 'user' | 'staff' | 'administrator'; +} + +export interface AccountClaimQueueItem { + readonly requestId: string; + readonly type: 'pre-onboarding' | 'post-onboarding-merge'; + readonly claimedSlug: string; + readonly claimedPersonId: string | null; + readonly requesterGithubLogin: string; + readonly requesterPersonId: string | null; + readonly evidence: string; + readonly submittedAt: string; +} + +export interface AccountClaimDecision { + readonly requestId: string; + readonly status: 'open' | 'approved' | 'denied'; + readonly person?: PersonDetail | null; +} + export interface CreateTagInput { namespace: 'topic' | 'tech' | 'event'; slug: string; @@ -656,4 +694,63 @@ export const api = { revokeSession: (jti: string): Promise => request(`/api/auth/sessions/${encodeURIComponent(jti)}/revoke`, { method: 'POST' }), }, + accountClaim: { + candidates: (): Promise> => + request(`/api/account-claim/candidates`), + confirm: (personId: string): Promise> => + request(`/api/account-claim/confirm`, { + method: 'POST', + body: JSON.stringify({ personId }), + }), + decline: (): Promise> => + request(`/api/account-claim/decline`, { method: 'POST', body: '{}' }), + byPassword: ( + slug: string, + password: string, + ): Promise> => + request(`/api/account-claim/by-password`, { + method: 'POST', + body: JSON.stringify({ slug, password }), + }), + requestStaffReview: ( + claimedSlug: string, + evidence: string, + ): Promise> => + request(`/api/account-claim/request-staff-review`, { + method: 'POST', + body: JSON.stringify({ claimedSlug, evidence }), + }), + legacySearch: ( + q: string, + ): Promise> => + request(`/api/account-claim/legacy${buildQuery({ q })}`), + legacyRequest: ( + claimedSlug: string, + evidence: string, + ): Promise> => + request(`/api/account-claim/legacy/request`, { + method: 'POST', + body: JSON.stringify({ claimedSlug, evidence }), + }), + }, + staffAccountClaim: { + queue: (): Promise> => + request(`/api/staff/account-claim/queue`), + approve: ( + requestId: string, + reason?: string, + ): Promise> => + request(`/api/staff/account-claim/${encodeURIComponent(requestId)}/approve`, { + method: 'POST', + body: JSON.stringify(reason ? { reason } : {}), + }), + deny: ( + requestId: string, + reason?: string, + ): Promise> => + request(`/api/staff/account-claim/${encodeURIComponent(requestId)}/deny`, { + method: 'POST', + body: JSON.stringify(reason ? { reason } : {}), + }), + }, }; diff --git a/apps/web/src/pages/AccountClaim.tsx b/apps/web/src/pages/AccountClaim.tsx new file mode 100644 index 0000000..be71480 --- /dev/null +++ b/apps/web/src/pages/AccountClaim.tsx @@ -0,0 +1,237 @@ +import { useEffect, useState } from 'react'; +import { Link, useNavigate, useSearchParams } from 'react-router'; +import { toast } from 'sonner'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Separator } from '@/components/ui/separator'; +import { useAuth } from '@/hooks/useAuth'; +import { + api, + ApiError, + type AccountClaimCandidate, + type AccountClaimCandidatesPayload, +} from '@/lib/api'; +import { formatAbsoluteDate, formatRelativeTime } from '@/lib/time'; + +function safeReturn(input: string | null): string { + if (!input) return '/'; + if (!input.startsWith('/') || input.startsWith('//')) return '/'; + return input; +} + +export function AccountClaim() { + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const { reload, person: existingPerson, loading: authLoading } = useAuth(); + const returnPath = safeReturn(searchParams.get('return')); + + const [loading, setLoading] = useState(true); + const [payload, setPayload] = useState(null); + const [confirming, setConfirming] = useState(null); + const [declining, setDeclining] = useState(false); + + useEffect(() => { + let cancelled = false; + // If the user already has a session, the claim flow doesn't apply. + // Per specs/screens/account-claim.md: when cfp_session is present, the + // claim screen sends the user to /account. + if (!authLoading && existingPerson) { + void navigate('/account', { replace: true }); + return () => { + cancelled = true; + }; + } + void (async () => { + try { + const res = await api.accountClaim.candidates(); + if (cancelled) return; + setPayload(res.data); + } catch (err) { + if (cancelled) return; + if (err instanceof ApiError && err.status === 401) { + // Missing or expired claim cookie → send to login + void navigate(`/login?return=${encodeURIComponent(returnPath)}`, { replace: true }); + return; + } + toast.error(err instanceof ApiError ? err.message : 'Failed to load claim candidates'); + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, [authLoading, existingPerson, navigate, returnPath]); + + const onConfirm = async (candidate: AccountClaimCandidate) => { + if (!candidate.matchedEmail) { + // Username-only: take them to the password verify flow with slug pre-filled + void navigate( + `/account-claim/by-password?slug=${encodeURIComponent(candidate.slug)}&return=${encodeURIComponent(returnPath)}`, + ); + return; + } + setConfirming(candidate.personId); + try { + await api.accountClaim.confirm(candidate.personId); + toast.success(`Welcome back, ${candidate.fullName}`); + await reload(); + void navigate(returnPath, { replace: true }); + } catch (err) { + const msg = err instanceof ApiError ? err.message : 'Failed to confirm'; + toast.error(msg); + setConfirming(null); + } + }; + + const onDecline = async () => { + setDeclining(true); + try { + await api.accountClaim.decline(); + toast.success("Got it — we'll set up a fresh profile"); + await reload(); + void navigate(returnPath, { replace: true }); + } catch (err) { + toast.error(err instanceof ApiError ? err.message : 'Failed to start fresh'); + setDeclining(false); + } + }; + + if (loading || authLoading) { + return ( +
+
+
+ ); + } + + if (!payload) { + // Error path already handled by toast + redirect — render nothing + return null; + } + + const candidates = payload.candidates; + const anyEmailMatch = candidates.some((c) => c.matchedVia.includes('email')); + const header = anyEmailMatch ? 'Welcome back' : 'Almost there'; + + return ( +
+
+ + + {header} + + We think you might have a Code for Philly account from before our + recent upgrade. We're trying to connect your GitHub identity to it + so you don't lose your project memberships and history. + + + + + {candidates.length === 0 && ( + + + No candidates + + We couldn't find an account to suggest. You can start fresh, or + try the password-verify flow if you remember an old slug. + + + + + + + + )} + + {candidates.map((c) => ( + + + {c.fullName} + + {c.slug} + {c.memberOfCount > 0 && ( + <> + {' · '}member of {c.memberOfCount} project{c.memberOfCount === 1 ? '' : 's'} + + )} + + + +
+ Last updated {formatRelativeTime(c.lastActiveAt)} +
+ {c.matchedEmail ? ( +
+ Matched via {c.matchedEmail} +
+ ) : ( +
+ Matched via username only — please verify with old password +
+ )} + + {c.matchedEmail ? ( + + ) : ( + + )} +
+
+ ))} + + {candidates.length > 0 && ( + + + + + + Have an old account we didn't find? Verify with password → + + + I don't have the password either — request staff review → + + + + )} +
+
+ ); +} diff --git a/apps/web/src/pages/AccountClaimByPassword.tsx b/apps/web/src/pages/AccountClaimByPassword.tsx new file mode 100644 index 0000000..36dd04c --- /dev/null +++ b/apps/web/src/pages/AccountClaimByPassword.tsx @@ -0,0 +1,127 @@ +import { useState } from 'react'; +import { Link, useNavigate, useSearchParams } from 'react-router'; +import { toast } from 'sonner'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { api, ApiError } from '@/lib/api'; +import { useAuth } from '@/hooks/useAuth'; + +function safeReturn(input: string | null): string { + if (!input) return '/'; + if (!input.startsWith('/') || input.startsWith('//')) return '/'; + return input; +} + +export function AccountClaimByPassword() { + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const { reload } = useAuth(); + const returnPath = safeReturn(searchParams.get('return')); + + const [slug, setSlug] = useState(searchParams.get('slug') ?? ''); + const [password, setPassword] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + const onSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + setError(null); + setSubmitting(true); + try { + await api.accountClaim.byPassword(slug.trim(), password); + toast.success('Welcome back'); + await reload(); + void navigate(returnPath, { replace: true }); + } catch (err) { + if (err instanceof ApiError) { + if (err.status === 401 && err.code === 'claim_token_invalid') { + // Claim cookie expired — restart the flow + void navigate(`/login?return=${encodeURIComponent(returnPath)}`, { replace: true }); + return; + } + if (err.status === 401) { + setError("Username or password didn't match"); + } else { + setError(err.message); + } + } else { + setError('Something went wrong'); + } + setSubmitting(false); + } + }; + + return ( +
+ + + Verify with old password + + Enter your pre-cutover Code for Philly username and password. We'll + connect your new GitHub identity to that legacy account. + + + +
+
+ + setSlug(e.target.value)} + autoComplete="username" + autoFocus={!slug} + required + /> +
+
+ + setPassword(e.target.value)} + autoComplete="current-password" + autoFocus={!!slug} + required + /> +
+ {error && ( +
+ {error} +
+ )} + +
+ + I don't remember my password — request staff review → + + + ← Back to suggestions + +
+
+
+
+
+ ); +} diff --git a/apps/web/src/pages/AccountClaimLegacy.tsx b/apps/web/src/pages/AccountClaimLegacy.tsx new file mode 100644 index 0000000..b9eecd6 --- /dev/null +++ b/apps/web/src/pages/AccountClaimLegacy.tsx @@ -0,0 +1,207 @@ +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router'; +import { toast } from 'sonner'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { Separator } from '@/components/ui/separator'; +import { useAuth } from '@/hooks/useAuth'; +import { + api, + ApiError, + type AccountClaimCandidate, +} from '@/lib/api'; + +export function AccountClaimLegacy() { + const { person, loading } = useAuth(); + const navigate = useNavigate(); + + const [q, setQ] = useState(''); + const [searched, setSearched] = useState(false); + const [searching, setSearching] = useState(false); + const [candidate, setCandidate] = useState(null); + const [evidence, setEvidence] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [submitted, setSubmitted] = useState(false); + + useEffect(() => { + if (!loading && !person) { + void navigate('/login?return=/account/claim-legacy', { replace: true }); + } + }, [loading, person, navigate]); + + const onSearch = async (event: React.FormEvent) => { + event.preventDefault(); + setSearching(true); + setSearched(false); + try { + const res = await api.accountClaim.legacySearch(q.trim()); + setCandidate(res.data.candidates[0] ?? null); + setSearched(true); + } catch (err) { + toast.error(err instanceof ApiError ? err.message : 'Search failed'); + } finally { + setSearching(false); + } + }; + + const onSubmitMerge = async (event: React.FormEvent) => { + event.preventDefault(); + if (!candidate) return; + setSubmitting(true); + try { + await api.accountClaim.legacyRequest(candidate.slug, evidence.trim()); + setSubmitted(true); + } catch (err) { + toast.error(err instanceof ApiError ? err.message : 'Failed to submit'); + } finally { + setSubmitting(false); + } + }; + + if (loading || !person) { + return ( +
Loading…
+ ); + } + + return ( +
+ + + Claim a legacy account + + Had a Code for Philly account from before the GitHub sign-in change + that we didn't surface at sign-in? Search for it here. Merges go + through staff review. + + + +
+
+ + setQ(e.target.value)} + placeholder="janedoe or jane@old-email.com" + required + /> +
+ +
+
+
+ + {searched && !candidate && ( + + + Nothing matched + + We couldn't find a legacy account from that. If you remember the + old username, you can still file a staff-review request below. + + + +
+
+ + setQ(e.target.value)} + required + /> +
+
+ +