Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions alembic/versions/20260803_1000_0024_password_reset_tokens.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
"""password_reset_tokens table + RLS (Phase 7 — account lifecycle)

Before this, the platform had no self-service password recovery and no way
to invite a user with a set-your-own-password link: password_hash could
only be set by seed scripts or bootstrap_admin. This table backs both
flows: forgot-password (purpose='reset', 30 min) and admin invites
(purpose='invite', 7 days). Only the SHA-256 of the token is stored.

Revision ID: 20260803_1000_0024
Revises: 20260717_0900_0023
Create Date: 2026-08-03
"""

from typing import Sequence, Union

import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import UUID

revision: str = "20260803_1000_0024"
down_revision: Union[str, Sequence[str], None] = "20260717_0900_0023"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None

GUC = "spectra.current_org_id"
ADMIN = "admin"
TABLE = "password_reset_tokens"


def _is_postgres() -> bool:
return op.get_bind().dialect.name == "postgresql"


def upgrade() -> None:
op.create_table(
TABLE,
sa.Column(
"id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")
),
sa.Column(
"organization_id",
UUID(as_uuid=True),
sa.ForeignKey("organizations.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"user_id",
UUID(as_uuid=True),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("token_hash", sa.String(64), nullable=False),
sa.Column("purpose", sa.String(20), nullable=False, server_default="reset"),
sa.Column("expires_at", sa.TIMESTAMP(timezone=True), nullable=False),
sa.Column("used_at", sa.TIMESTAMP(timezone=True), nullable=True),
sa.Column(
"created_at",
sa.TIMESTAMP(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.Column(
"updated_at",
sa.TIMESTAMP(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
)
op.create_index("ix_password_reset_tokens_organization_id", TABLE, ["organization_id"])
op.create_index("ix_password_reset_tokens_user_id", TABLE, ["user_id"])
op.create_index("ix_password_reset_tokens_hash", TABLE, ["token_hash"], unique=True)

if not _is_postgres():
return

# Same tenant_isolation shape as every other org-scoped table.
predicate = (
f"current_setting('{GUC}', true) = '{ADMIN}'"
f" OR organization_id::text = current_setting('{GUC}', true)"
)
op.execute(
f"ALTER TABLE {TABLE} ALTER COLUMN organization_id SET DEFAULT "
f"NULLIF(current_setting('{GUC}', true), '{ADMIN}')::uuid"
)
op.execute(f"ALTER TABLE {TABLE} ENABLE ROW LEVEL SECURITY")
op.execute(f"ALTER TABLE {TABLE} FORCE ROW LEVEL SECURITY")
op.execute(
f"CREATE POLICY tenant_isolation ON {TABLE} "
f"USING ({predicate}) WITH CHECK ({predicate})"
)


def downgrade() -> None:
if _is_postgres():
op.execute(f"DROP POLICY IF EXISTS tenant_isolation ON {TABLE}")
op.drop_table(TABLE)
2 changes: 1 addition & 1 deletion apps/web/src/app/ClientLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { useTokenRefresh } from '@/hooks/useTokenRefresh';
// a session — the
// app's home (/) just redirects to /dashboard. Listed here so the guard below
// can let the login page through without a redirect loop.
const PUBLIC_PATHS = new Set(['/login', '/auth/callback']);
const PUBLIC_PATHS = new Set(['/login', '/auth/callback', '/forgot-password', '/reset-password']);

// Session 7.1: AuthProvider removed. The auth store hydrates from
// localStorage at module load time (services/web/src/stores/useAuthStore.ts).
Expand Down
103 changes: 103 additions & 0 deletions apps/web/src/app/forgot-password/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"use client";

/**
* Forgot-password page (Phase 7 — account lifecycle).
*
* Posts the email to /api/auth/forgot-password and shows the same neutral
* confirmation whatever the server found — the endpoint is deliberately
* enumeration-safe, and so is this UI.
*/

import { useState } from 'react';
import Link from 'next/link';

export default function ForgotPasswordPage() {
const [email, setEmail] = useState('');
const [sent, setSent] = useState(false);
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
const res = await fetch('/api/v1/lims/auth/forgot-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
if (res.status === 429) {
setError('Too many attempts — wait a minute and try again.');
} else if (!res.ok) {
setError('Something went wrong. Try again.');
} else {
setSent(true);
}
} catch {
setError('Network error. Try again.');
} finally {
setLoading(false);
}
};

return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
Reset your password
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Enter your account email and we&apos;ll send a reset link
</p>
</div>

{sent ? (
<div className="rounded-md bg-green-50 p-4 text-sm text-green-800" data-testid="reset-sent">
If an account exists for <strong>{email}</strong>, a reset link is on
its way. The link is valid for 30 minutes.
</div>
) : (
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
{error && (
<div className="rounded-md bg-red-50 p-4">
<h3 className="text-sm font-medium text-red-800">{error}</h3>
</div>
)}
<div>
<label htmlFor="email-address" className="sr-only">
Email address
</label>
<input
id="email-address"
name="email"
type="email"
autoComplete="email"
required
className="appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
placeholder="Email address"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={loading}
/>
</div>
<button
type="submit"
disabled={loading}
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Sending…' : 'Send reset link'}
</button>
</form>
)}

<p className="text-center text-sm text-gray-600">
<Link href="/login" className="font-medium text-indigo-600 hover:text-indigo-500">
Back to sign in
</Link>
</p>
</div>
</div>
);
}
10 changes: 10 additions & 0 deletions apps/web/src/app/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,16 @@ export default function LoginPage() {
{loading ? 'Signing in...' : 'Sign in'}
</button>
</div>

<p className="text-center text-sm">
<a
href="/forgot-password"
data-testid="forgot-password-link"
className="font-medium text-indigo-600 hover:text-indigo-500"
>
Forgot your password?
</a>
</p>
</form>

{/* SSO entry point (Phase 6.6). Rendered only when the deploy sets
Expand Down
164 changes: 164 additions & 0 deletions apps/web/src/app/reset-password/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
"use client";

/**
* Reset-password page (Phase 7 — account lifecycle).
*
* Terminal page for BOTH flows that email a set-password link:
* - forgot-password (?token=...) — "Reset your password"
* - admin invite (?token=...&welcome=1) — "Set your password"
* Posts to /api/auth/reset-password; the token is single-use and expiring,
* so a 400 sends the user back to request a fresh link.
*/

import { Suspense, useState } from 'react';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';

const MIN_LEN = 12;

function ResetPasswordForm() {
const params = useSearchParams();
const router = useRouter();
const token = params.get('token') ?? '';
const isWelcome = params.get('welcome') === '1';

const [password, setPassword] = useState('');
const [confirm, setConfirm] = useState('');
const [error, setError] = useState('');
const [done, setDone] = useState(false);
const [loading, setLoading] = useState(false);

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (password.length < MIN_LEN) {
setError(`Password must be at least ${MIN_LEN} characters.`);
return;
}
if (password !== confirm) {
setError('Passwords do not match.');
return;
}
setLoading(true);
try {
const res = await fetch('/api/v1/lims/auth/reset-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token, new_password: password }),
});
if (res.ok) {
setDone(true);
setTimeout(() => router.push('/login'), 2500);
} else if (res.status === 400) {
setError('This link is invalid or has expired. Request a new one.');
} else if (res.status === 422) {
setError(`Password must be at least ${MIN_LEN} characters.`);
} else if (res.status === 429) {
setError('Too many attempts — wait a minute and try again.');
} else {
setError('Something went wrong. Try again.');
}
} catch {
setError('Network error. Try again.');
} finally {
setLoading(false);
}
};

return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
{isWelcome ? 'Welcome — set your password' : 'Choose a new password'}
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
At least {MIN_LEN} characters
</p>
</div>

{!token && (
<div className="rounded-md bg-red-50 p-4 text-sm text-red-800">
This link is missing its token.{' '}
<Link href="/forgot-password" className="font-medium underline">
Request a new reset link
</Link>
.
</div>
)}

{done ? (
<div className="rounded-md bg-green-50 p-4 text-sm text-green-800" data-testid="reset-done">
Password set. Redirecting you to sign in…
</div>
) : token ? (
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
{error && (
<div className="rounded-md bg-red-50 p-4">
<h3 className="text-sm font-medium text-red-800">{error}</h3>
</div>
)}
<div className="rounded-md shadow-sm -space-y-px">
<div>
<label htmlFor="new-password" className="sr-only">
New password
</label>
<input
id="new-password"
type="password"
autoComplete="new-password"
required
minLength={MIN_LEN}
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
placeholder="New password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={loading}
/>
</div>
<div>
<label htmlFor="confirm-password" className="sr-only">
Confirm password
</label>
<input
id="confirm-password"
type="password"
autoComplete="new-password"
required
minLength={MIN_LEN}
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
placeholder="Confirm password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
disabled={loading}
/>
</div>
</div>
<button
type="submit"
disabled={loading}
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Saving…' : isWelcome ? 'Set password' : 'Reset password'}
</button>
</form>
) : null}

<p className="text-center text-sm text-gray-600">
<Link href="/login" className="font-medium text-indigo-600 hover:text-indigo-500">
Back to sign in
</Link>
</p>
</div>
</div>
);
}

export default function ResetPasswordPage() {
// useSearchParams needs a Suspense boundary in the app router.
return (
<Suspense fallback={null}>
<ResetPasswordForm />
</Suspense>
);
}
Loading
Loading