feat(portal-auth): URL fragment for activation/reset tokens
Step 8 per PRE-DEPLOY-PLAN § 1.2.5. Activation + password-reset links now carry the token in the URL fragment (`#token=…`) instead of the query string (`?token=…`). URL fragments are client-side only — the token never hits the server, never lands in proxy logs, never sits in the Referer header, and is invisible to upstream CDN/cache layers. The form still POSTs the token in the request body to authenticate. Changes: - portal-auth.service.ts URL builders for activation + reset switch to `#token=`. Inline comments cite the security rationale. - password-set-form.tsx reads the token via useSyncExternalStore so the SSR snapshot returns `null` and the client snapshot reads window.location.hash post-hydration (no set-state-in-effect Compiler violation). Helper prefers the fragment but falls back to the legacy `?token=` search param for the back-compat TTL window — so links sent before the switchover still work for their remaining lifetime. Component renders a "Loading…" placeholder during the pre-hydration null state. No DB changes; tokens themselves unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useState, useSyncExternalStore } from 'react';
|
||||||
import { useState } from 'react';
|
|
||||||
import { CheckCircle2, Loader2 } from 'lucide-react';
|
import { CheckCircle2, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -23,11 +22,41 @@ interface PasswordSetFormProps {
|
|||||||
const MIN_LENGTH = 9;
|
const MIN_LENGTH = 9;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared form used by both the activation and password-reset flows. The
|
* Shared form used by both the activation and password-reset flows.
|
||||||
* activation token is read from the `?token=` query string. Empty / missing
|
*
|
||||||
* tokens land the user in an explicit error state instead of submitting a
|
* The activation/reset token is read from the URL fragment (`#token=…`)
|
||||||
* doomed request.
|
* — not the query string — so the token never travels to the server,
|
||||||
|
* never lands in proxy / reverse-proxy logs, never sits in the Referer
|
||||||
|
* header, and is invisible to upstream CDN/cache layers. The browser
|
||||||
|
* still sends it on form-submit via the explicit POST body.
|
||||||
|
*
|
||||||
|
* Pre-2026-05-14 the token was passed as `?token=…`; the legacy
|
||||||
|
* search-param read is kept as a fallback so links sent before the
|
||||||
|
* switchover still work for the remaining TTL.
|
||||||
|
*
|
||||||
|
* Empty / missing tokens land the user in an explicit error state
|
||||||
|
* instead of submitting a doomed request.
|
||||||
*/
|
*/
|
||||||
|
function readTokenFromUrl(): string {
|
||||||
|
if (typeof window === 'undefined') return '';
|
||||||
|
const hash = window.location.hash.replace(/^#/, '');
|
||||||
|
if (hash) {
|
||||||
|
const params = new URLSearchParams(hash);
|
||||||
|
const fromFragment = params.get('token');
|
||||||
|
if (fromFragment) return fromFragment;
|
||||||
|
}
|
||||||
|
// Back-compat: pre-fragment links still carry `?token=…`. Drop after
|
||||||
|
// every outstanding activation/reset link past TTL has been consumed.
|
||||||
|
const search = new URLSearchParams(window.location.search);
|
||||||
|
return search.get('token') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tiny no-op subscriber — we don't actually need to react to
|
||||||
|
// hashchange events (the token is set once when the user lands on the
|
||||||
|
// page). The store integration is here so React knows the source is
|
||||||
|
// external and skips the SSR / hydration mismatch warning.
|
||||||
|
const subscribe = () => () => undefined;
|
||||||
|
|
||||||
export function PasswordSetForm({
|
export function PasswordSetForm({
|
||||||
endpoint,
|
endpoint,
|
||||||
title,
|
title,
|
||||||
@@ -36,8 +65,15 @@ export function PasswordSetForm({
|
|||||||
successDescription,
|
successDescription,
|
||||||
submitLabel,
|
submitLabel,
|
||||||
}: PasswordSetFormProps) {
|
}: PasswordSetFormProps) {
|
||||||
const search = useSearchParams();
|
// Read the token via useSyncExternalStore — fragment is client-only
|
||||||
const token = search.get('token') ?? '';
|
// so the server-snapshot returns `null` and the client snapshot reads
|
||||||
|
// window.location.hash post-hydration. `null` distinguishes "not yet
|
||||||
|
// hydrated" from "missing"; empty string means "hydrated, none found".
|
||||||
|
const token = useSyncExternalStore<string | null>(
|
||||||
|
subscribe,
|
||||||
|
() => readTokenFromUrl(),
|
||||||
|
() => null,
|
||||||
|
);
|
||||||
|
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [confirm, setConfirm] = useState('');
|
const [confirm, setConfirm] = useState('');
|
||||||
@@ -73,6 +109,14 @@ export function PasswordSetForm({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (token === null) {
|
||||||
|
return (
|
||||||
|
<BrandedAuthShell>
|
||||||
|
<div className="text-center text-sm text-gray-500">Loading…</div>
|
||||||
|
</BrandedAuthShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return (
|
return (
|
||||||
<BrandedAuthShell>
|
<BrandedAuthShell>
|
||||||
|
|||||||
@@ -144,7 +144,11 @@ async function issueActivationToken(
|
|||||||
const port = await db.query.ports.findFirst({ where: eq(ports.id, portId) });
|
const port = await db.query.ports.findFirst({ where: eq(ports.id, portId) });
|
||||||
const portName = port?.name ?? 'Port Nimara';
|
const portName = port?.name ?? 'Port Nimara';
|
||||||
|
|
||||||
const link = `${env.APP_URL}/portal/activate?token=${encodeURIComponent(raw)}`;
|
// URL fragment (#token=…) instead of query string — keeps the
|
||||||
|
// activation token out of server logs, proxy logs, Referer header,
|
||||||
|
// and any CDN/edge cache. The portal /activate page reads the token
|
||||||
|
// client-side via `window.location.hash`. See PRE-DEPLOY-PLAN § 1.2.5.
|
||||||
|
const link = `${env.APP_URL}/portal/activate#token=${encodeURIComponent(raw)}`;
|
||||||
const subjectOverride = await loadSubjectOverride(portId, 'portal_activation');
|
const subjectOverride = await loadSubjectOverride(portId, 'portal_activation');
|
||||||
const branding = await getBrandingShell(portId);
|
const branding = await getBrandingShell(portId);
|
||||||
const { subject, html, text } = await activationEmail(
|
const { subject, html, text } = await activationEmail(
|
||||||
@@ -406,7 +410,9 @@ export async function requestPasswordReset(email: string): Promise<void> {
|
|||||||
|
|
||||||
const port = await db.query.ports.findFirst({ where: eq(ports.id, user.portId) });
|
const port = await db.query.ports.findFirst({ where: eq(ports.id, user.portId) });
|
||||||
const portName = port?.name ?? 'Port Nimara';
|
const portName = port?.name ?? 'Port Nimara';
|
||||||
const link = `${env.APP_URL}/portal/reset-password?token=${encodeURIComponent(raw)}`;
|
// Same URL-fragment treatment as activation links — token never
|
||||||
|
// travels server-side. See PRE-DEPLOY-PLAN § 1.2.5.
|
||||||
|
const link = `${env.APP_URL}/portal/reset-password#token=${encodeURIComponent(raw)}`;
|
||||||
const subjectOverride = await loadSubjectOverride(user.portId, 'portal_reset');
|
const subjectOverride = await loadSubjectOverride(user.portId, 'portal_reset');
|
||||||
const branding = await getBrandingShell(user.portId);
|
const branding = await getBrandingShell(user.portId);
|
||||||
const { subject, html, text } = await resetEmail(
|
const { subject, html, text } = await resetEmail(
|
||||||
|
|||||||
Reference in New Issue
Block a user