Files
pn-new-crm/src/app/api/portal/auth/reset-password/route.ts

37 lines
1.1 KiB
TypeScript
Raw Normal View History

feat(portal): replace magic-link with email/password + admin-initiated activation The client portal no longer uses passwordless / magic-link sign-in. Each client now has a `portal_users` row with a scrypt-hashed password, created by an admin from the client detail page; the admin's invite mails an activation link that the client uses to set their own password. Forgot-password is wired through the same token mechanism. Schema (migration `0009_outgoing_rumiko_fujikawa.sql`): - `portal_users` — one per client account, separate from the CRM `users` table (better-auth) so the auth realms stay isolated. Email is globally unique, password is null until activation. - `portal_auth_tokens` — single-use activation / reset tokens. Stores only the SHA-256 hash so a DB compromise never leaks live tokens. Services: - `src/lib/portal/passwords.ts` — scrypt hash/verify (no new deps; uses node:crypto), token mint+hash helpers. - `src/lib/services/portal-auth.service.ts` — createPortalUser, resendActivation, activateAccount, signIn (timing-safe), requestPasswordReset, resetPassword. Auth failures throw the new UnauthorizedError (401); enumeration-safe behaviour everywhere. Routes: - POST /api/portal/auth/sign-in — sets the existing portal JWT cookie. - POST /api/portal/auth/forgot-password — always 200. - POST /api/portal/auth/reset-password — token + new password. - POST /api/portal/auth/activate — token + initial password. - POST /api/v1/clients/:id/portal-user — admin invite (and `?action=resend`). - Removed: /api/portal/auth/request, /api/portal/auth/verify (magic link). UI: - /portal/login — replaced email-only magic-link form with email + password + "forgot password" link. - /portal/forgot-password, /portal/reset-password, /portal/activate — new. - New shared `PasswordSetForm` component used by activate + reset. - New `PortalInviteButton` rendered on the client detail header. Email send: - `createTransporter` now wires SMTP auth when SMTP_USER+SMTP_PASS are set (gmail app-password or marina-server creds, configured via env). - `SMTP_FROM` env var lets the sender address be overridden without pinning it to `noreply@${SMTP_HOST}`. Tests: - Smoke spec 17 (client-portal) updated to the new flow: 7/7 green. - Smoke specs 02-crud-spine, 05-invoices, 20-critical-path updated to match the post-refactor client + invoice forms (drop companyName, use OwnerPicker + billingEmail). - Vitest 652/652 still green; type-check clean. Drops the dead `requestMagicLink` from portal.service.ts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 15:34:02 +02:00
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { enforcePublicRateLimit } from '@/lib/api/route-helpers';
fix(audit-tier-2-routes): manual NextResponse.json error sweep + admin form banners Two final waves of error-surface hygiene closing the audit's MED §12 + HIGH §15 + HIGH §17 findings: * 50 route files swept (61 sites): manual NextResponse.json({error, status: 4xx|5xx}) early-returns replaced by typed throws + errorResponse(err) at the catch. - Super-admin gates (13 sites) use new requireSuperAdmin(ctx, action) helper from src/lib/api/helpers.ts so denials hit the audit log. - Path-param + body validation 400s become ValidationError throws. - 404s become NotFoundError or CodedError('NOT_FOUND') for AI feature-flag paths. - 11 manual 5xx returns now re-throw so error_events captures the request-id (the admin error inspector becomes usable from real incidents). - website-analytics 200-with-error anti-pattern flipped to 409 + UMAMI_NOT_CONFIGURED. 502 upstream paths use UMAMI_UPSTREAM_ERROR. - 11 sites intentionally preserved: storage/[token] anti-enumeration token-failure paths, webhook-secret 401, "Unknown port" 400 in public intake. * 7 admin forms (roles, users, ports, webhooks, custom-fields, document-templates, tags) gain a formatErrorBanner() helper from src/lib/api/toast-error.ts that builds a multi-line "Error code / Reference ID" banner — the rep can copy the request id when reporting a failed save. Banners get whitespace-pre-line so newlines render. Test status: 1168/1168 vitest, tsc clean. Refs: docs/audit-comprehensive-2026-05-05.md MED §12 (auditor-F Issue 1) + HIGH §15 (auditor-F Issue 2) + HIGH §17 (auditor-H Issue 2). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:36:59 +02:00
import { errorResponse, ValidationError } from '@/lib/errors';
import { resetPassword } from '@/lib/services/portal-auth.service';
feat(portal): replace magic-link with email/password + admin-initiated activation The client portal no longer uses passwordless / magic-link sign-in. Each client now has a `portal_users` row with a scrypt-hashed password, created by an admin from the client detail page; the admin's invite mails an activation link that the client uses to set their own password. Forgot-password is wired through the same token mechanism. Schema (migration `0009_outgoing_rumiko_fujikawa.sql`): - `portal_users` — one per client account, separate from the CRM `users` table (better-auth) so the auth realms stay isolated. Email is globally unique, password is null until activation. - `portal_auth_tokens` — single-use activation / reset tokens. Stores only the SHA-256 hash so a DB compromise never leaks live tokens. Services: - `src/lib/portal/passwords.ts` — scrypt hash/verify (no new deps; uses node:crypto), token mint+hash helpers. - `src/lib/services/portal-auth.service.ts` — createPortalUser, resendActivation, activateAccount, signIn (timing-safe), requestPasswordReset, resetPassword. Auth failures throw the new UnauthorizedError (401); enumeration-safe behaviour everywhere. Routes: - POST /api/portal/auth/sign-in — sets the existing portal JWT cookie. - POST /api/portal/auth/forgot-password — always 200. - POST /api/portal/auth/reset-password — token + new password. - POST /api/portal/auth/activate — token + initial password. - POST /api/v1/clients/:id/portal-user — admin invite (and `?action=resend`). - Removed: /api/portal/auth/request, /api/portal/auth/verify (magic link). UI: - /portal/login — replaced email-only magic-link form with email + password + "forgot password" link. - /portal/forgot-password, /portal/reset-password, /portal/activate — new. - New shared `PasswordSetForm` component used by activate + reset. - New `PortalInviteButton` rendered on the client detail header. Email send: - `createTransporter` now wires SMTP auth when SMTP_USER+SMTP_PASS are set (gmail app-password or marina-server creds, configured via env). - `SMTP_FROM` env var lets the sender address be overridden without pinning it to `noreply@${SMTP_HOST}`. Tests: - Smoke spec 17 (client-portal) updated to the new flow: 7/7 green. - Smoke specs 02-crud-spine, 05-invoices, 20-critical-path updated to match the post-refactor client + invoice forms (drop companyName, use OwnerPicker + billingEmail). - Vitest 652/652 still green; type-check clean. Drops the dead `requestMagicLink` from portal.service.ts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 15:34:02 +02:00
const bodySchema = z.object({
token: z.string().min(1),
password: z.string().min(9),
feat(portal): replace magic-link with email/password + admin-initiated activation The client portal no longer uses passwordless / magic-link sign-in. Each client now has a `portal_users` row with a scrypt-hashed password, created by an admin from the client detail page; the admin's invite mails an activation link that the client uses to set their own password. Forgot-password is wired through the same token mechanism. Schema (migration `0009_outgoing_rumiko_fujikawa.sql`): - `portal_users` — one per client account, separate from the CRM `users` table (better-auth) so the auth realms stay isolated. Email is globally unique, password is null until activation. - `portal_auth_tokens` — single-use activation / reset tokens. Stores only the SHA-256 hash so a DB compromise never leaks live tokens. Services: - `src/lib/portal/passwords.ts` — scrypt hash/verify (no new deps; uses node:crypto), token mint+hash helpers. - `src/lib/services/portal-auth.service.ts` — createPortalUser, resendActivation, activateAccount, signIn (timing-safe), requestPasswordReset, resetPassword. Auth failures throw the new UnauthorizedError (401); enumeration-safe behaviour everywhere. Routes: - POST /api/portal/auth/sign-in — sets the existing portal JWT cookie. - POST /api/portal/auth/forgot-password — always 200. - POST /api/portal/auth/reset-password — token + new password. - POST /api/portal/auth/activate — token + initial password. - POST /api/v1/clients/:id/portal-user — admin invite (and `?action=resend`). - Removed: /api/portal/auth/request, /api/portal/auth/verify (magic link). UI: - /portal/login — replaced email-only magic-link form with email + password + "forgot password" link. - /portal/forgot-password, /portal/reset-password, /portal/activate — new. - New shared `PasswordSetForm` component used by activate + reset. - New `PortalInviteButton` rendered on the client detail header. Email send: - `createTransporter` now wires SMTP auth when SMTP_USER+SMTP_PASS are set (gmail app-password or marina-server creds, configured via env). - `SMTP_FROM` env var lets the sender address be overridden without pinning it to `noreply@${SMTP_HOST}`. Tests: - Smoke spec 17 (client-portal) updated to the new flow: 7/7 green. - Smoke specs 02-crud-spine, 05-invoices, 20-critical-path updated to match the post-refactor client + invoice forms (drop companyName, use OwnerPicker + billingEmail). - Vitest 652/652 still green; type-check clean. Drops the dead `requestMagicLink` from portal.service.ts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 15:34:02 +02:00
});
export async function POST(req: NextRequest): Promise<NextResponse> {
// 10/hour/IP — bounds brute-force against the 32-byte reset token.
const limited = await enforcePublicRateLimit(req, 'portalToken');
if (limited) return limited;
feat(portal): replace magic-link with email/password + admin-initiated activation The client portal no longer uses passwordless / magic-link sign-in. Each client now has a `portal_users` row with a scrypt-hashed password, created by an admin from the client detail page; the admin's invite mails an activation link that the client uses to set their own password. Forgot-password is wired through the same token mechanism. Schema (migration `0009_outgoing_rumiko_fujikawa.sql`): - `portal_users` — one per client account, separate from the CRM `users` table (better-auth) so the auth realms stay isolated. Email is globally unique, password is null until activation. - `portal_auth_tokens` — single-use activation / reset tokens. Stores only the SHA-256 hash so a DB compromise never leaks live tokens. Services: - `src/lib/portal/passwords.ts` — scrypt hash/verify (no new deps; uses node:crypto), token mint+hash helpers. - `src/lib/services/portal-auth.service.ts` — createPortalUser, resendActivation, activateAccount, signIn (timing-safe), requestPasswordReset, resetPassword. Auth failures throw the new UnauthorizedError (401); enumeration-safe behaviour everywhere. Routes: - POST /api/portal/auth/sign-in — sets the existing portal JWT cookie. - POST /api/portal/auth/forgot-password — always 200. - POST /api/portal/auth/reset-password — token + new password. - POST /api/portal/auth/activate — token + initial password. - POST /api/v1/clients/:id/portal-user — admin invite (and `?action=resend`). - Removed: /api/portal/auth/request, /api/portal/auth/verify (magic link). UI: - /portal/login — replaced email-only magic-link form with email + password + "forgot password" link. - /portal/forgot-password, /portal/reset-password, /portal/activate — new. - New shared `PasswordSetForm` component used by activate + reset. - New `PortalInviteButton` rendered on the client detail header. Email send: - `createTransporter` now wires SMTP auth when SMTP_USER+SMTP_PASS are set (gmail app-password or marina-server creds, configured via env). - `SMTP_FROM` env var lets the sender address be overridden without pinning it to `noreply@${SMTP_HOST}`. Tests: - Smoke spec 17 (client-portal) updated to the new flow: 7/7 green. - Smoke specs 02-crud-spine, 05-invoices, 20-critical-path updated to match the post-refactor client + invoice forms (drop companyName, use OwnerPicker + billingEmail). - Vitest 652/652 still green; type-check clean. Drops the dead `requestMagicLink` from portal.service.ts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 15:34:02 +02:00
try {
fix(audit-tier-2-routes): manual NextResponse.json error sweep + admin form banners Two final waves of error-surface hygiene closing the audit's MED §12 + HIGH §15 + HIGH §17 findings: * 50 route files swept (61 sites): manual NextResponse.json({error, status: 4xx|5xx}) early-returns replaced by typed throws + errorResponse(err) at the catch. - Super-admin gates (13 sites) use new requireSuperAdmin(ctx, action) helper from src/lib/api/helpers.ts so denials hit the audit log. - Path-param + body validation 400s become ValidationError throws. - 404s become NotFoundError or CodedError('NOT_FOUND') for AI feature-flag paths. - 11 manual 5xx returns now re-throw so error_events captures the request-id (the admin error inspector becomes usable from real incidents). - website-analytics 200-with-error anti-pattern flipped to 409 + UMAMI_NOT_CONFIGURED. 502 upstream paths use UMAMI_UPSTREAM_ERROR. - 11 sites intentionally preserved: storage/[token] anti-enumeration token-failure paths, webhook-secret 401, "Unknown port" 400 in public intake. * 7 admin forms (roles, users, ports, webhooks, custom-fields, document-templates, tags) gain a formatErrorBanner() helper from src/lib/api/toast-error.ts that builds a multi-line "Error code / Reference ID" banner — the rep can copy the request id when reporting a failed save. Banners get whitespace-pre-line so newlines render. Test status: 1168/1168 vitest, tsc clean. Refs: docs/audit-comprehensive-2026-05-05.md MED §12 (auditor-F Issue 1) + HIGH §15 (auditor-F Issue 2) + HIGH §17 (auditor-H Issue 2). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:36:59 +02:00
let body: unknown;
try {
body = await req.json();
} catch {
throw new ValidationError('Invalid request body');
}
feat(portal): replace magic-link with email/password + admin-initiated activation The client portal no longer uses passwordless / magic-link sign-in. Each client now has a `portal_users` row with a scrypt-hashed password, created by an admin from the client detail page; the admin's invite mails an activation link that the client uses to set their own password. Forgot-password is wired through the same token mechanism. Schema (migration `0009_outgoing_rumiko_fujikawa.sql`): - `portal_users` — one per client account, separate from the CRM `users` table (better-auth) so the auth realms stay isolated. Email is globally unique, password is null until activation. - `portal_auth_tokens` — single-use activation / reset tokens. Stores only the SHA-256 hash so a DB compromise never leaks live tokens. Services: - `src/lib/portal/passwords.ts` — scrypt hash/verify (no new deps; uses node:crypto), token mint+hash helpers. - `src/lib/services/portal-auth.service.ts` — createPortalUser, resendActivation, activateAccount, signIn (timing-safe), requestPasswordReset, resetPassword. Auth failures throw the new UnauthorizedError (401); enumeration-safe behaviour everywhere. Routes: - POST /api/portal/auth/sign-in — sets the existing portal JWT cookie. - POST /api/portal/auth/forgot-password — always 200. - POST /api/portal/auth/reset-password — token + new password. - POST /api/portal/auth/activate — token + initial password. - POST /api/v1/clients/:id/portal-user — admin invite (and `?action=resend`). - Removed: /api/portal/auth/request, /api/portal/auth/verify (magic link). UI: - /portal/login — replaced email-only magic-link form with email + password + "forgot password" link. - /portal/forgot-password, /portal/reset-password, /portal/activate — new. - New shared `PasswordSetForm` component used by activate + reset. - New `PortalInviteButton` rendered on the client detail header. Email send: - `createTransporter` now wires SMTP auth when SMTP_USER+SMTP_PASS are set (gmail app-password or marina-server creds, configured via env). - `SMTP_FROM` env var lets the sender address be overridden without pinning it to `noreply@${SMTP_HOST}`. Tests: - Smoke spec 17 (client-portal) updated to the new flow: 7/7 green. - Smoke specs 02-crud-spine, 05-invoices, 20-critical-path updated to match the post-refactor client + invoice forms (drop companyName, use OwnerPicker + billingEmail). - Vitest 652/652 still green; type-check clean. Drops the dead `requestMagicLink` from portal.service.ts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 15:34:02 +02:00
fix(audit-tier-2-routes): manual NextResponse.json error sweep + admin form banners Two final waves of error-surface hygiene closing the audit's MED §12 + HIGH §15 + HIGH §17 findings: * 50 route files swept (61 sites): manual NextResponse.json({error, status: 4xx|5xx}) early-returns replaced by typed throws + errorResponse(err) at the catch. - Super-admin gates (13 sites) use new requireSuperAdmin(ctx, action) helper from src/lib/api/helpers.ts so denials hit the audit log. - Path-param + body validation 400s become ValidationError throws. - 404s become NotFoundError or CodedError('NOT_FOUND') for AI feature-flag paths. - 11 manual 5xx returns now re-throw so error_events captures the request-id (the admin error inspector becomes usable from real incidents). - website-analytics 200-with-error anti-pattern flipped to 409 + UMAMI_NOT_CONFIGURED. 502 upstream paths use UMAMI_UPSTREAM_ERROR. - 11 sites intentionally preserved: storage/[token] anti-enumeration token-failure paths, webhook-secret 401, "Unknown port" 400 in public intake. * 7 admin forms (roles, users, ports, webhooks, custom-fields, document-templates, tags) gain a formatErrorBanner() helper from src/lib/api/toast-error.ts that builds a multi-line "Error code / Reference ID" banner — the rep can copy the request id when reporting a failed save. Banners get whitespace-pre-line so newlines render. Test status: 1168/1168 vitest, tsc clean. Refs: docs/audit-comprehensive-2026-05-05.md MED §12 (auditor-F Issue 1) + HIGH §15 (auditor-F Issue 2) + HIGH §17 (auditor-H Issue 2). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 20:36:59 +02:00
const parsed = bodySchema.safeParse(body);
if (!parsed.success) {
throw new ValidationError(parsed.error.errors[0]?.message ?? 'Invalid input');
}
feat(portal): replace magic-link with email/password + admin-initiated activation The client portal no longer uses passwordless / magic-link sign-in. Each client now has a `portal_users` row with a scrypt-hashed password, created by an admin from the client detail page; the admin's invite mails an activation link that the client uses to set their own password. Forgot-password is wired through the same token mechanism. Schema (migration `0009_outgoing_rumiko_fujikawa.sql`): - `portal_users` — one per client account, separate from the CRM `users` table (better-auth) so the auth realms stay isolated. Email is globally unique, password is null until activation. - `portal_auth_tokens` — single-use activation / reset tokens. Stores only the SHA-256 hash so a DB compromise never leaks live tokens. Services: - `src/lib/portal/passwords.ts` — scrypt hash/verify (no new deps; uses node:crypto), token mint+hash helpers. - `src/lib/services/portal-auth.service.ts` — createPortalUser, resendActivation, activateAccount, signIn (timing-safe), requestPasswordReset, resetPassword. Auth failures throw the new UnauthorizedError (401); enumeration-safe behaviour everywhere. Routes: - POST /api/portal/auth/sign-in — sets the existing portal JWT cookie. - POST /api/portal/auth/forgot-password — always 200. - POST /api/portal/auth/reset-password — token + new password. - POST /api/portal/auth/activate — token + initial password. - POST /api/v1/clients/:id/portal-user — admin invite (and `?action=resend`). - Removed: /api/portal/auth/request, /api/portal/auth/verify (magic link). UI: - /portal/login — replaced email-only magic-link form with email + password + "forgot password" link. - /portal/forgot-password, /portal/reset-password, /portal/activate — new. - New shared `PasswordSetForm` component used by activate + reset. - New `PortalInviteButton` rendered on the client detail header. Email send: - `createTransporter` now wires SMTP auth when SMTP_USER+SMTP_PASS are set (gmail app-password or marina-server creds, configured via env). - `SMTP_FROM` env var lets the sender address be overridden without pinning it to `noreply@${SMTP_HOST}`. Tests: - Smoke spec 17 (client-portal) updated to the new flow: 7/7 green. - Smoke specs 02-crud-spine, 05-invoices, 20-critical-path updated to match the post-refactor client + invoice forms (drop companyName, use OwnerPicker + billingEmail). - Vitest 652/652 still green; type-check clean. Drops the dead `requestMagicLink` from portal.service.ts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 15:34:02 +02:00
await resetPassword(parsed.data.token, parsed.data.password);
return NextResponse.json({ success: true });
} catch (err) {
return errorResponse(err);
}
}