diff --git a/.env.example b/.env.example index 5c3d8774..29cb9ca0 100644 --- a/.env.example +++ b/.env.example @@ -51,7 +51,10 @@ NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=sb_publishable_your_key # apps/web: canonical site URL for SEO (metadataBase, sitemap, Open Graph). Production: https://abstrack.org # apps/mobile: use EXPO_PUBLIC_USER_WEB_ORIGIN with the same origin (Universal Links / App Links). -NEXT_PUBLIC_USER_WEB_ORIGIN=https://abstrack.org +NEXT_PUBLIC_USER_WEB_ORIGIN=http://localhost:3000 + +# apps/practitioner: canonical origin for metadataBase / Open Graph. Production: https://practitioner.abstrack.org +# NEXT_PUBLIC_PRACTITIONER_WEB_ORIGIN=http://localhost:3000 # apps/practitioner: set `NEXT_PUBLIC_APP_NAME` so the TOTP issuer in authenticator apps matches # your product name per environment. If unset, the issuer falls back to "ABStrack". @@ -72,7 +75,7 @@ NEXT_PUBLIC_APP_NAME=ABStrack EXPO_PUBLIC_SUPABASE_URL=https://YOUR_PROJECT_REF.supabase.co EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY=sb_publishable_your_key # Same origin as NEXT_PUBLIC_USER_WEB_ORIGIN on user web (caretaker invite / App Links). -EXPO_PUBLIC_USER_WEB_ORIGIN=https://abstrack.org +EXPO_PUBLIC_USER_WEB_ORIGIN=http://localhost:3000 # PowerSync Service HTTP/WebSocket endpoint for mobile offline replication (configure instance + upload `packages/powersync/sync-rules.yaml`). # Copy the current instance URL from the PowerSync Dashboard — it may change if the Cloud project was disabled and re-enabled (see docs/SUPABASE_CLOUD_DEVELOPER.md). diff --git a/apps/practitioner/public/og.png b/apps/practitioner/public/og.png new file mode 100644 index 00000000..13d26427 Binary files /dev/null and b/apps/practitioner/public/og.png differ diff --git a/apps/practitioner/specs/index.spec.tsx b/apps/practitioner/specs/index.spec.tsx index cc94d98b..e2aa4cdf 100644 --- a/apps/practitioner/specs/index.spec.tsx +++ b/apps/practitioner/specs/index.spec.tsx @@ -1,6 +1,6 @@ import { render, screen } from '@testing-library/react'; import { LiveAnnouncerProvider } from '@abstrack/ui/a11y-web'; -import Page from '../src/app/page'; +import Page from '../src/app/mfa/page'; import type { PractitionerSupabaseProfilesRow } from '../src/lib/supabase-wiring'; import { useAuth } from '../src/lib/auth-provider'; import type { Session } from '@abstrack/supabase'; @@ -59,11 +59,11 @@ const patientProfileRow: PractitionerSupabaseProfilesRow = { updated_at: '2020-01-01T00:00:00.000Z', }; -/** Home page calls `getSupabaseBrowserClient()` on mount; env-public throws without URL + key. */ +/** MFA enrollment page calls `getSupabaseBrowserClient()` on mount; env-public throws without URL + key. */ const JEST_SUPABASE_URL = 'https://test.supabase.co'; const JEST_SUPABASE_KEY = 'sb_publishable_test_jest_placeholder'; -describe('Page', () => { +describe('MfaEnrollmentPage', () => { let prevUrl: string | undefined; let prevKey: string | undefined; diff --git a/apps/practitioner/specs/login-page.spec.tsx b/apps/practitioner/specs/login-page.spec.tsx index ddd40f53..fc08df83 100644 --- a/apps/practitioner/specs/login-page.spec.tsx +++ b/apps/practitioner/specs/login-page.spec.tsx @@ -396,7 +396,7 @@ describe('LoginPage MFA state machine', () => { await submitCredentials(); await waitFor(() => { - expect(mockPush).toHaveBeenCalledWith('/'); + expect(mockPush).toHaveBeenCalledWith('/mfa'); }); expect(mockedClearBundle).toHaveBeenCalled(); }); diff --git a/apps/practitioner/src/app/invite/join/page.tsx b/apps/practitioner/src/app/invite/join/page.tsx index 6ccbc9d8..c0b9c459 100644 --- a/apps/practitioner/src/app/invite/join/page.tsx +++ b/apps/practitioner/src/app/invite/join/page.tsx @@ -236,7 +236,7 @@ export default function PractitionerInviteJoinPage() {
{donePasswordSet ? ( Set up two-factor authentication diff --git a/apps/practitioner/src/app/layout.tsx b/apps/practitioner/src/app/layout.tsx index dfb89a36..4eda53be 100644 --- a/apps/practitioner/src/app/layout.tsx +++ b/apps/practitioner/src/app/layout.tsx @@ -1,5 +1,4 @@ import './global.css'; -import type { Metadata } from 'next'; import type { ReactNode } from 'react'; import { Plus_Jakarta_Sans } from 'next/font/google'; import Script from 'next/script'; @@ -8,6 +7,7 @@ import { PractitionerPublicTopNav } from '../components/app-shell/PractitionerPu import { LiveAnnouncerRoot } from '../components/a11y/LiveAnnouncerRoot'; import { ThemeProvider } from '../components/theme/ThemeProvider'; import { AuthProvider } from '../lib/auth-provider'; +import { buildRootPractitionerMetadata } from '../lib/site-seo'; import { THEME_INIT_SCRIPT } from '../lib/theme-init-script'; const fontSans = Plus_Jakarta_Sans({ @@ -16,16 +16,7 @@ const fontSans = Plus_Jakarta_Sans({ display: 'swap', }); -export const metadata: Metadata = { - title: 'ABStrack Practitioner', - description: - 'Practitioner access for ABStrack patient support and care workflows', - robots: { - index: false, - follow: false, - googleBot: { index: false, follow: false }, - }, -}; +export const metadata = buildRootPractitionerMetadata(); export default function RootLayout({ children }: { children: ReactNode }) { return ( diff --git a/apps/practitioner/src/app/login/page.tsx b/apps/practitioner/src/app/login/page.tsx index 7bdd5ed6..580690c5 100644 --- a/apps/practitioner/src/app/login/page.tsx +++ b/apps/practitioner/src/app/login/page.tsx @@ -123,7 +123,7 @@ async function waitForSessionWithJwtAal2( * Practitioner email/password login with MFA step-up and optional device trust (browser storage). * Successful verification with “Remember this device” set to ask every time clears any stored * bundle so trust matches the choice for this sign-in. If there is no verified TOTP factor, any existing bundle - * is cleared before redirecting to practitioner home for TOTP enrollment so stale tokens are not kept in storage. + * is cleared before redirecting to `/mfa` for TOTP enrollment so stale tokens are not kept in storage. * After email/password authentication succeeds, password state is cleared immediately so the secret * is not retained through MFA or device-trust checks. * If `getUser` does not return a user id after a successful password sign-in, the client signs out @@ -331,7 +331,7 @@ export default function LoginPage() { const message = 'No verified TOTP factor yet. Redirecting so you can enroll an authenticator.'; announce(message, { politeness: 'assertive' }); - router.push('/'); + router.push('/mfa'); router.refresh(); return; } diff --git a/apps/practitioner/src/app/manifest.json b/apps/practitioner/src/app/manifest.json index 610e44a1..cf99d001 100644 --- a/apps/practitioner/src/app/manifest.json +++ b/apps/practitioner/src/app/manifest.json @@ -1,8 +1,8 @@ { "name": "ABStrack Practitioner", "short_name": "ABStrack", - "description": "Practitioner access for ABStrack patient support and care workflows", - "start_url": "/", + "description": "Invite-only clinician access for ABStrack patient support and care workflows", + "start_url": "/patients", "display": "browser", "icons": [ { diff --git a/apps/practitioner/src/app/mfa/page.tsx b/apps/practitioner/src/app/mfa/page.tsx new file mode 100644 index 00000000..a835427e --- /dev/null +++ b/apps/practitioner/src/app/mfa/page.tsx @@ -0,0 +1,652 @@ +'use client'; + +import { getSupabaseBrowserClient } from '@abstrack/supabase/browser'; +import { useAnnounce } from '@abstrack/ui/a11y-web'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { useEffect, useMemo, useState } from 'react'; +import { useAuth } from '@/lib/auth-provider'; +import { + isUnenrollAlreadyGoneError, + looksLikeTotpSetupPayload, + mapMfaUnenrollErrorToUserMessage, + mapMfaVerifyErrorToUserMessage, + normalizeTotpCode, +} from '@/lib/mfa-user-messages'; +import { PractitionerSignOutButton } from '@/components/practitioner-sign-out-button'; + +type TotpEnrollment = { + id: string; + qrCodeImageSrc: string; + secret: string; + friendlyName: string; +}; + +/** + * Issuer label shown in authenticator apps for the enrollment QR. + * Set `NEXT_PUBLIC_APP_NAME` per deployment so labels stay explicit; if unset, defaults to `ABStrack`. + * + * @returns Display issuer string. + */ +function getTotpIssuer(): string { + return process.env.NEXT_PUBLIC_APP_NAME?.trim() || 'ABStrack'; +} + +/** + * Builds a Key URI–compatible `otpauth://` string from the enrolled secret. We generate the QR + * from this locally so authenticator apps see a stable issuer/account label. Supabase may return + * `qr_code` as SVG or raw URI with `localhost` embedded; encoding that bitmap bypasses our labels. + * Verification still uses the same `secret` returned by enroll. + * + * @param secret - Base32 secret from `mfa.enroll()`. + * @param account - Account name (typically the user email). + * @returns Full `otpauth://totp/...` URI. + */ +function buildOtpauthUriFromSecret(secret: string, account: string): string { + const issuer = getTotpIssuer(); + const labelAccount = account.trim() || 'practitioner'; + // Key URI label is `issuer:account` with a **literal** colon. Encoding the whole string as one + // component turns `:` into `%3A`; many apps then fail to parse otpauth and paste the URI as text. + // Encode issuer and account separately, then join with `:` (matches common Google Authenticator URIs). + const pathLabel = `${encodeURIComponent(issuer)}:${encodeURIComponent(labelAccount)}`; + const url = new URL(`otpauth://totp/${pathLabel}`); + url.searchParams.set('secret', secret); + url.searchParams.set('issuer', issuer); + url.searchParams.set('algorithm', 'SHA1'); + url.searchParams.set('digits', '6'); + url.searchParams.set('period', '30'); + return url.toString(); +} + +/** + * Encodes an `otpauth://` URI as a PNG data URL for use in ``. + * + * @param otpauthUri - Full TOTP key URI. + * @returns Data URL for a PNG QR code. + */ +async function otpauthUriToQrPngDataUrl(otpauthUri: string): Promise { + const { toDataURL } = await import('qrcode'); + return toDataURL(otpauthUri, { + errorCorrectionLevel: 'H', + // Wider quiet zone + pure B/W helps some in-app scanners (e.g. picky camera pipelines). + margin: 4, + width: 320, + color: { dark: '#000000', light: '#ffffff' }, + }); +} + +/** + * Practitioner TOTP MFA enrollment and session verification (`/mfa`). + * Root `/` redirects to the patient workspace; invite and login flows send + * password accounts here when no verified factor exists yet. + * + * @returns Client page rendering enrollment status and setup actions. + */ +export default function MfaEnrollmentPage() { + const router = useRouter(); + const supabase = useMemo(() => getSupabaseBrowserClient(), []); + const { announce } = useAnnounce(); + const { session, loading: sessionLoading, gate } = useAuth(); + + const [isLoading, setIsLoading] = useState(true); + const [isEnrolling, setIsEnrolling] = useState(false); + const [isVerifying, setIsVerifying] = useState(false); + const [userEmail, setUserEmail] = useState(null); + const [verifiedTotpCount, setVerifiedTotpCount] = useState(0); + const [enrollment, setEnrollment] = useState(null); + const [verifyCode, setVerifyCode] = useState(''); + const [statusMessage, setStatusMessage] = useState(null); + const [statusTone, setStatusTone] = useState<'polite' | 'assertive'>( + 'polite', + ); + /** Inline message under the code field when verify/challenge fails (not only in page header). */ + const [verifyFailureMessage, setVerifyFailureMessage] = useState< + string | null + >(null); + const [isCanceling, setIsCanceling] = useState(false); + + const mfaReady = verifiedTotpCount > 0; + + const setAccessibleStatus = ( + message: string, + tone: 'polite' | 'assertive' = 'polite', + ) => { + setStatusMessage(message); + setStatusTone(tone); + announce(message, { politeness: tone }); + }; + + const refreshMfaState = async () => { + const factorsResult = await supabase.auth.mfa.listFactors(); + if (factorsResult.error) { + throw factorsResult.error; + } + setVerifiedTotpCount( + factorsResult.data.totp.filter((f) => f.status === 'verified').length, + ); + }; + + useEffect(() => { + if (gate.kind !== 'profile_error') { + return; + } + console.error('Practitioner profile load failed', gate.error); + }, [gate]); + + useEffect(() => { + if (sessionLoading) { + return; + } + + const isAuthenticated = Boolean(session?.access_token); + setUserEmail(session?.user.email ?? null); + + const load = async () => { + setIsLoading(true); + if (!isAuthenticated) { + setEnrollment(null); + setVerifyCode(''); + setVerifyFailureMessage(null); + setVerifiedTotpCount(0); + setIsLoading(false); + return; + } + + // Only list MFA factors for practitioner profiles; avoid API + SR noise on error gates. + if (gate.kind !== 'practitioner') { + setEnrollment(null); + setVerifyCode(''); + setVerifyFailureMessage(null); + setVerifiedTotpCount(0); + setStatusMessage(null); + setIsLoading(false); + return; + } + + try { + await refreshMfaState(); + } catch (error) { + const message = + error instanceof Error + ? error.message + : 'Unable to load your MFA status right now.'; + setAccessibleStatus(message, 'assertive'); + } finally { + setIsLoading(false); + } + }; + + void load(); + }, [session, sessionLoading, gate.kind]); + + const startEnrollment = async () => { + const isAuthenticated = Boolean(session?.access_token); + if (!isAuthenticated) { + setAccessibleStatus( + 'You must sign in to a practitioner account before setting up TOTP.', + 'assertive', + ); + return; + } + + setIsEnrolling(true); + setStatusMessage(null); + setVerifyFailureMessage(null); + try { + const { data, error } = await supabase.auth.mfa.enroll({ + factorType: 'totp', + friendlyName: `Practitioner device ${new Date().toISOString()}`, + }); + if (error) { + throw error; + } + + const account = session?.user?.email ?? userEmail ?? 'practitioner'; + const otpauthUri = buildOtpauthUriFromSecret(data.totp.secret, account); + const qrCodeImageSrc = await otpauthUriToQrPngDataUrl(otpauthUri); + setEnrollment({ + id: data.id, + qrCodeImageSrc, + secret: data.totp.secret, + friendlyName: data.friendly_name ?? 'Practitioner TOTP', + }); + setAccessibleStatus( + 'TOTP enrollment is ready. Scan the QR code and enter your six-digit code to verify.', + ); + } catch (error) { + const message = + error instanceof Error + ? error.message + : 'Unable to start TOTP enrollment. Please try again.'; + setAccessibleStatus(message, 'assertive'); + } finally { + setIsEnrolling(false); + } + }; + + const verifyEnrollment = async () => { + if (!enrollment) { + return; + } + const isAuthenticated = Boolean(session?.access_token); + if (!isAuthenticated) { + setAccessibleStatus( + 'Your session is missing or expired. Sign in again, then retry verification.', + 'assertive', + ); + return; + } + + setIsVerifying(true); + setStatusMessage(null); + if (!/^\d{6}$/.test(verifyCode)) { + const msg = + 'Enter exactly six digits from your authenticator, not a setup link.'; + setVerifyFailureMessage(msg); + setAccessibleStatus(msg, 'assertive'); + setIsVerifying(false); + return; + } + try { + const challenge = await supabase.auth.mfa.challenge({ + factorId: enrollment.id, + }); + if (challenge.error) { + throw challenge.error; + } + + const verify = await supabase.auth.mfa.verify({ + factorId: enrollment.id, + challengeId: challenge.data.id, + code: verifyCode, + }); + if (verify.error) { + throw verify.error; + } + + setVerifyCode(''); + setVerifyFailureMessage(null); + setEnrollment(null); + await refreshMfaState(); + setAccessibleStatus( + 'Two-factor authentication is enabled for your practitioner account.', + ); + } catch (error) { + const message = mapMfaVerifyErrorToUserMessage(error); + setVerifyFailureMessage(message); + setAccessibleStatus(message, 'assertive'); + } finally { + setIsVerifying(false); + } + }; + + const clearEnrollmentUiAfterCancel = async () => { + setEnrollment(null); + setVerifyCode(''); + setVerifyFailureMessage(null); + try { + await refreshMfaState(); + } catch { + /* listFactors failure is non-blocking after successful unenroll */ + } + setAccessibleStatus( + 'TOTP setup was canceled. You can restart enrollment whenever you are ready.', + ); + }; + + const cancelEnrollment = async () => { + if (!enrollment) { + return; + } + setIsCanceling(true); + setVerifyFailureMessage(null); + try { + const { error } = await supabase.auth.mfa.unenroll({ + factorId: enrollment.id, + }); + if (error) { + if (isUnenrollAlreadyGoneError(error)) { + await clearEnrollmentUiAfterCancel(); + } else { + setAccessibleStatus( + mapMfaUnenrollErrorToUserMessage(error), + 'assertive', + ); + } + return; + } + await clearEnrollmentUiAfterCancel(); + } catch (error: unknown) { + if (isUnenrollAlreadyGoneError(error)) { + await clearEnrollmentUiAfterCancel(); + } else { + setAccessibleStatus( + mapMfaUnenrollErrorToUserMessage(error), + 'assertive', + ); + } + } finally { + setIsCanceling(false); + } + }; + + const isAuthenticated = Boolean(session?.access_token); + const showLoadingState = sessionLoading || isLoading; + + useEffect(() => { + if (!sessionLoading && !isAuthenticated) { + router.replace('/login'); + } + }, [sessionLoading, isAuthenticated, router]); + + if (sessionLoading) { + return ( +
+

+ Checking sign-in status… +

+
+ ); + } + + if (!isAuthenticated) { + return ( +
+

+ Redirecting to sign in… +

+
+ ); + } + + if (gate.kind === 'profile_error') { + return ( + + ); + } + + if (gate.kind === 'profile_missing') { + return ( + + ); + } + + if (gate.kind === 'wrong_app_role') { + return ( + + ); + } + + const mfaAssuranceReady = + gate.kind === 'practitioner' && gate.hasMfaAssuranceAal2; + + return ( +
+
+

+ Two-factor authentication +

+

+ Enroll at least one TOTP factor to make this practitioner account + MFA-ready. + {mfaAssuranceReady ? ( + + Current session has MFA assurance (AAL2) for patient-data access. + + ) : ( + + After you verify TOTP, complete a challenge when prompted so your + session reaches AAL2 — required for patient data access. + + )} +

+ {userEmail ? ( +

+ Signed in as {userEmail} +

+ ) : null} +
+ {isAuthenticated ? ( + + ) : ( + + Log in + + )} + {mfaAssuranceReady && mfaReady ? ( + + Patient workspace + + ) : null} +
+
+ +
+

+ MFA readiness status +

+

+ {showLoadingState + ? 'Loading current MFA status...' + : !isAuthenticated + ? 'No active practitioner session detected. Sign in before enrolling TOTP.' + : mfaReady + ? `MFA ready. You have ${verifiedTotpCount} verified TOTP factor${verifiedTotpCount === 1 ? '' : 's'}.` + : 'MFA not ready yet. Add and verify a TOTP factor to complete setup.'} +

+ + {statusTone === 'assertive' ? ( +

+ {statusMessage} +

+ ) : ( +

+ {statusMessage} +

+ )} + + {!mfaReady && !enrollment ? ( + + ) : null} +
+ + {enrollment ? ( +
+

+ Verify authenticator app +

+ +
+ TOTP enrollment QR code +
+ +

+ Setup key:{' '} + + {enrollment.secret} + +

+

+ Factor name: {enrollment.friendlyName} +

+ + + { + const raw = event.target.value; + if (looksLikeTotpSetupPayload(raw)) { + setVerifyCode(''); + const msg = + 'Enter only the six-digit code from your authenticator, not the setup link or key URI.'; + setVerifyFailureMessage(msg); + setAccessibleStatus(msg, 'assertive'); + return; + } + setVerifyFailureMessage(null); + setVerifyCode(normalizeTotpCode(raw)); + }} + aria-describedby={ + verifyFailureMessage + ? 'totp-verify-error totp-code-help' + : 'totp-code-help' + } + /> + {verifyFailureMessage ? ( + + ) : null} +

+ Enter digits only — not the otpauth link. If the code fails, wait + for a new code cycle and try again. +

+ +
+ + +
+
+ ) : null} +
+ ); +} diff --git a/apps/practitioner/src/app/page.tsx b/apps/practitioner/src/app/page.tsx index 84a39199..46e19ce7 100644 --- a/apps/practitioner/src/app/page.tsx +++ b/apps/practitioner/src/app/page.tsx @@ -1,650 +1,11 @@ -'use client'; - -import { getSupabaseBrowserClient } from '@abstrack/supabase/browser'; -import { useAnnounce } from '@abstrack/ui/a11y-web'; -import Link from 'next/link'; -import { useRouter } from 'next/navigation'; -import { useEffect, useMemo, useState } from 'react'; -import { useAuth } from '../lib/auth-provider'; -import { - isUnenrollAlreadyGoneError, - looksLikeTotpSetupPayload, - mapMfaUnenrollErrorToUserMessage, - mapMfaVerifyErrorToUserMessage, - normalizeTotpCode, -} from '../lib/mfa-user-messages'; -import { PractitionerSignOutButton } from '../components/practitioner-sign-out-button'; - -type TotpEnrollment = { - id: string; - qrCodeImageSrc: string; - secret: string; - friendlyName: string; -}; - -/** - * Issuer label shown in authenticator apps for the enrollment QR. - * Set `NEXT_PUBLIC_APP_NAME` per deployment so labels stay explicit; if unset, defaults to `ABStrack`. - * - * @returns Display issuer string. - */ -function getTotpIssuer(): string { - return process.env.NEXT_PUBLIC_APP_NAME?.trim() || 'ABStrack'; -} +import { redirect } from 'next/navigation'; /** - * Builds a Key URI–compatible `otpauth://` string from the enrolled secret. We generate the QR - * from this locally so authenticator apps see a stable issuer/account label. Supabase may return - * `qr_code` as SVG or raw URI with `localhost` embedded; encoding that bitmap bypasses our labels. - * Verification still uses the same `secret` returned by enroll. + * Practitioner root: no public landing. The proxy sends signed-out visitors to `/login`; + * signed-in visitors are redirected to the patient workspace (`/patients`). * - * @param secret - Base32 secret from `mfa.enroll()`. - * @param account - Account name (typically the user email). - * @returns Full `otpauth://totp/...` URI. + * TOTP enrollment lives at `/mfa` (also linked from Settings → Security). */ -function buildOtpauthUriFromSecret(secret: string, account: string): string { - const issuer = getTotpIssuer(); - const labelAccount = account.trim() || 'practitioner'; - // Key URI label is `issuer:account` with a **literal** colon. Encoding the whole string as one - // component turns `:` into `%3A`; many apps then fail to parse otpauth and paste the URI as text. - // Encode issuer and account separately, then join with `:` (matches common Google Authenticator URIs). - const pathLabel = `${encodeURIComponent(issuer)}:${encodeURIComponent(labelAccount)}`; - const url = new URL(`otpauth://totp/${pathLabel}`); - url.searchParams.set('secret', secret); - url.searchParams.set('issuer', issuer); - url.searchParams.set('algorithm', 'SHA1'); - url.searchParams.set('digits', '6'); - url.searchParams.set('period', '30'); - return url.toString(); -} - -/** - * Encodes an `otpauth://` URI as a PNG data URL for use in ``. - * - * @param otpauthUri - Full TOTP key URI. - * @returns Data URL for a PNG QR code. - */ -async function otpauthUriToQrPngDataUrl(otpauthUri: string): Promise { - const { toDataURL } = await import('qrcode'); - return toDataURL(otpauthUri, { - errorCorrectionLevel: 'H', - // Wider quiet zone + pure B/W helps some in-app scanners (e.g. picky camera pipelines). - margin: 4, - width: 320, - color: { dark: '#000000', light: '#ffffff' }, - }); -} - -/** - * Practitioner home: TOTP MFA enrollment and session verification. - * - * @returns Client page rendering enrollment status and setup actions. - */ -export default function Index() { - const router = useRouter(); - const supabase = useMemo(() => getSupabaseBrowserClient(), []); - const { announce } = useAnnounce(); - const { session, loading: sessionLoading, gate } = useAuth(); - - const [isLoading, setIsLoading] = useState(true); - const [isEnrolling, setIsEnrolling] = useState(false); - const [isVerifying, setIsVerifying] = useState(false); - const [userEmail, setUserEmail] = useState(null); - const [verifiedTotpCount, setVerifiedTotpCount] = useState(0); - const [enrollment, setEnrollment] = useState(null); - const [verifyCode, setVerifyCode] = useState(''); - const [statusMessage, setStatusMessage] = useState(null); - const [statusTone, setStatusTone] = useState<'polite' | 'assertive'>( - 'polite', - ); - /** Inline message under the code field when verify/challenge fails (not only in page header). */ - const [verifyFailureMessage, setVerifyFailureMessage] = useState< - string | null - >(null); - const [isCanceling, setIsCanceling] = useState(false); - - const mfaReady = verifiedTotpCount > 0; - - const setAccessibleStatus = ( - message: string, - tone: 'polite' | 'assertive' = 'polite', - ) => { - setStatusMessage(message); - setStatusTone(tone); - announce(message, { politeness: tone }); - }; - - const refreshMfaState = async () => { - const factorsResult = await supabase.auth.mfa.listFactors(); - if (factorsResult.error) { - throw factorsResult.error; - } - setVerifiedTotpCount( - factorsResult.data.totp.filter((f) => f.status === 'verified').length, - ); - }; - - useEffect(() => { - if (gate.kind !== 'profile_error') { - return; - } - console.error('Practitioner profile load failed', gate.error); - }, [gate]); - - useEffect(() => { - if (sessionLoading) { - return; - } - - const isAuthenticated = Boolean(session?.access_token); - setUserEmail(session?.user.email ?? null); - - const load = async () => { - setIsLoading(true); - if (!isAuthenticated) { - setEnrollment(null); - setVerifyCode(''); - setVerifyFailureMessage(null); - setVerifiedTotpCount(0); - setIsLoading(false); - return; - } - - // Only list MFA factors for practitioner profiles; avoid API + SR noise on error gates. - if (gate.kind !== 'practitioner') { - setEnrollment(null); - setVerifyCode(''); - setVerifyFailureMessage(null); - setVerifiedTotpCount(0); - setStatusMessage(null); - setIsLoading(false); - return; - } - - try { - await refreshMfaState(); - } catch (error) { - const message = - error instanceof Error - ? error.message - : 'Unable to load your MFA status right now.'; - setAccessibleStatus(message, 'assertive'); - } finally { - setIsLoading(false); - } - }; - - void load(); - }, [session, sessionLoading, gate.kind]); - - const startEnrollment = async () => { - const isAuthenticated = Boolean(session?.access_token); - if (!isAuthenticated) { - setAccessibleStatus( - 'You must sign in to a practitioner account before setting up TOTP.', - 'assertive', - ); - return; - } - - setIsEnrolling(true); - setStatusMessage(null); - setVerifyFailureMessage(null); - try { - const { data, error } = await supabase.auth.mfa.enroll({ - factorType: 'totp', - friendlyName: `Practitioner device ${new Date().toISOString()}`, - }); - if (error) { - throw error; - } - - const account = session?.user?.email ?? userEmail ?? 'practitioner'; - const otpauthUri = buildOtpauthUriFromSecret(data.totp.secret, account); - const qrCodeImageSrc = await otpauthUriToQrPngDataUrl(otpauthUri); - setEnrollment({ - id: data.id, - qrCodeImageSrc, - secret: data.totp.secret, - friendlyName: data.friendly_name ?? 'Practitioner TOTP', - }); - setAccessibleStatus( - 'TOTP enrollment is ready. Scan the QR code and enter your six-digit code to verify.', - ); - } catch (error) { - const message = - error instanceof Error - ? error.message - : 'Unable to start TOTP enrollment. Please try again.'; - setAccessibleStatus(message, 'assertive'); - } finally { - setIsEnrolling(false); - } - }; - - const verifyEnrollment = async () => { - if (!enrollment) { - return; - } - const isAuthenticated = Boolean(session?.access_token); - if (!isAuthenticated) { - setAccessibleStatus( - 'Your session is missing or expired. Sign in again, then retry verification.', - 'assertive', - ); - return; - } - - setIsVerifying(true); - setStatusMessage(null); - if (!/^\d{6}$/.test(verifyCode)) { - const msg = - 'Enter exactly six digits from your authenticator, not a setup link.'; - setVerifyFailureMessage(msg); - setAccessibleStatus(msg, 'assertive'); - setIsVerifying(false); - return; - } - try { - const challenge = await supabase.auth.mfa.challenge({ - factorId: enrollment.id, - }); - if (challenge.error) { - throw challenge.error; - } - - const verify = await supabase.auth.mfa.verify({ - factorId: enrollment.id, - challengeId: challenge.data.id, - code: verifyCode, - }); - if (verify.error) { - throw verify.error; - } - - setVerifyCode(''); - setVerifyFailureMessage(null); - setEnrollment(null); - await refreshMfaState(); - setAccessibleStatus( - 'Two-factor authentication is enabled for your practitioner account.', - ); - } catch (error) { - const message = mapMfaVerifyErrorToUserMessage(error); - setVerifyFailureMessage(message); - setAccessibleStatus(message, 'assertive'); - } finally { - setIsVerifying(false); - } - }; - - const clearEnrollmentUiAfterCancel = async () => { - setEnrollment(null); - setVerifyCode(''); - setVerifyFailureMessage(null); - try { - await refreshMfaState(); - } catch { - /* listFactors failure is non-blocking after successful unenroll */ - } - setAccessibleStatus( - 'TOTP setup was canceled. You can restart enrollment whenever you are ready.', - ); - }; - - const cancelEnrollment = async () => { - if (!enrollment) { - return; - } - setIsCanceling(true); - setVerifyFailureMessage(null); - try { - const { error } = await supabase.auth.mfa.unenroll({ - factorId: enrollment.id, - }); - if (error) { - if (isUnenrollAlreadyGoneError(error)) { - await clearEnrollmentUiAfterCancel(); - } else { - setAccessibleStatus( - mapMfaUnenrollErrorToUserMessage(error), - 'assertive', - ); - } - return; - } - await clearEnrollmentUiAfterCancel(); - } catch (error: unknown) { - if (isUnenrollAlreadyGoneError(error)) { - await clearEnrollmentUiAfterCancel(); - } else { - setAccessibleStatus( - mapMfaUnenrollErrorToUserMessage(error), - 'assertive', - ); - } - } finally { - setIsCanceling(false); - } - }; - - const isAuthenticated = Boolean(session?.access_token); - const showLoadingState = sessionLoading || isLoading; - - useEffect(() => { - if (!sessionLoading && !isAuthenticated) { - router.replace('/login'); - } - }, [sessionLoading, isAuthenticated, router]); - - if (sessionLoading) { - return ( -
-

- Checking sign-in status… -

-
- ); - } - - if (!isAuthenticated) { - return ( -
-

- Redirecting to sign in… -

-
- ); - } - - if (gate.kind === 'profile_error') { - return ( - - ); - } - - if (gate.kind === 'profile_missing') { - return ( - - ); - } - - if (gate.kind === 'wrong_app_role') { - return ( - - ); - } - - const mfaAssuranceReady = - gate.kind === 'practitioner' && gate.hasMfaAssuranceAal2; - - return ( -
-
-

- Two-factor authentication -

-

- Enroll at least one TOTP factor to make this practitioner account - MFA-ready. - {mfaAssuranceReady ? ( - - Current session has MFA assurance (AAL2) for patient-data access. - - ) : ( - - After you verify TOTP, complete a challenge when prompted so your - session reaches AAL2 — required for patient data access. - - )} -

- {userEmail ? ( -

- Signed in as {userEmail} -

- ) : null} -
- {isAuthenticated ? ( - - ) : ( - - Log in - - )} - {mfaAssuranceReady && mfaReady ? ( - - Patient workspace - - ) : null} -
-
- -
-

- MFA readiness status -

-

- {showLoadingState - ? 'Loading current MFA status...' - : !isAuthenticated - ? 'No active practitioner session detected. Sign in before enrolling TOTP.' - : mfaReady - ? `MFA ready. You have ${verifiedTotpCount} verified TOTP factor${verifiedTotpCount === 1 ? '' : 's'}.` - : 'MFA not ready yet. Add and verify a TOTP factor to complete setup.'} -

- - {statusTone === 'assertive' ? ( -

- {statusMessage} -

- ) : ( -

- {statusMessage} -

- )} - - {!mfaReady && !enrollment ? ( - - ) : null} -
- - {enrollment ? ( -
-

- Verify authenticator app -

- -
- TOTP enrollment QR code -
- -

- Setup key:{' '} - - {enrollment.secret} - -

-

- Factor name: {enrollment.friendlyName} -

- - - { - const raw = event.target.value; - if (looksLikeTotpSetupPayload(raw)) { - setVerifyCode(''); - const msg = - 'Enter only the six-digit code from your authenticator, not the setup link or key URI.'; - setVerifyFailureMessage(msg); - setAccessibleStatus(msg, 'assertive'); - return; - } - setVerifyFailureMessage(null); - setVerifyCode(normalizeTotpCode(raw)); - }} - aria-describedby={ - verifyFailureMessage - ? 'totp-verify-error totp-code-help' - : 'totp-code-help' - } - /> - {verifyFailureMessage ? ( - - ) : null} -

- Enter digits only — not the otpauth link. If the code fails, wait - for a new code cycle and try again. -

- -
- - -
-
- ) : null} -
- ); +export default function Index(): never { + redirect('/patients'); } diff --git a/apps/practitioner/src/app/update-password/page.tsx b/apps/practitioner/src/app/update-password/page.tsx index ccfc08a0..a0e3ad59 100644 --- a/apps/practitioner/src/app/update-password/page.tsx +++ b/apps/practitioner/src/app/update-password/page.tsx @@ -182,7 +182,7 @@ export default function PractitionerUpdatePasswordPage() { 'Password saved. Taking you to two-factor authentication setup…', ); redirectTimeoutRef.current = setTimeout(() => { - router.replace('/'); + router.replace('/mfa'); }, 800); return; } diff --git a/apps/practitioner/src/components/practitioner-patient-routes-gate.tsx b/apps/practitioner/src/components/practitioner-patient-routes-gate.tsx index 5ec8def0..d3848292 100644 --- a/apps/practitioner/src/components/practitioner-patient-routes-gate.tsx +++ b/apps/practitioner/src/components/practitioner-patient-routes-gate.tsx @@ -220,7 +220,7 @@ export function PractitionerPatientRoutesGate({
{mfaBlockReason === 'enrollment' ? ( Set up two-factor authentication diff --git a/apps/practitioner/src/lib/practitioner-nav-items.ts b/apps/practitioner/src/lib/practitioner-nav-items.ts index f9eddb7d..5ffa1934 100644 --- a/apps/practitioner/src/lib/practitioner-nav-items.ts +++ b/apps/practitioner/src/lib/practitioner-nav-items.ts @@ -2,13 +2,10 @@ import type { AppSideNavItem } from '@abstrack/ui-web'; /** * Primary practitioner app routes for the side navigation. + * Brand / logo links to `/` (proxy redirects signed-in users to `/patients`, signed-out to `/login`). + * TOTP setup is under `/mfa` and Settings. */ export const PRACTITIONER_APP_NAV_ITEMS: AppSideNavItem[] = [ - { - href: '/', - label: 'Home', - match: (path) => path === '/', - }, { href: '/patients', label: 'Patients', diff --git a/apps/practitioner/src/lib/site-seo.ts b/apps/practitioner/src/lib/site-seo.ts new file mode 100644 index 00000000..2b1f5a7a --- /dev/null +++ b/apps/practitioner/src/lib/site-seo.ts @@ -0,0 +1,62 @@ +import type { Metadata } from 'next'; +import { getMetadataBase } from './site-url'; + +/** Default meta description for ABStrack practitioner web (invite-only; not indexed). */ +export const PRACTITIONER_SITE_DESCRIPTION = + 'ABStrack Practitioner — invite-only clinician access for authorized patient episode review and care workflows.'; + +/** Social preview image in `apps/practitioner/public/og.png` (same asset as user web). */ +const OG_IMAGE_PATH = '/og.png'; + +const OG_IMAGE_METADATA = { + url: OG_IMAGE_PATH, + width: 1731, + height: 909, + alt: 'ABStrack — health tracking for Auto-Brewery Syndrome', +} as const; + +/** + * Root layout metadata for practitioner web: title, icons, Open Graph, and noindex. + * + * @returns Next.js `Metadata` for `apps/practitioner/src/app/layout.tsx`. + */ +export function buildRootPractitionerMetadata(): Metadata { + const metadataBase = getMetadataBase(); + return { + metadataBase, + title: { + default: 'ABStrack Practitioner', + template: '%s | ABStrack Practitioner', + }, + description: PRACTITIONER_SITE_DESCRIPTION, + applicationName: 'ABStrack Practitioner', + icons: { + icon: [ + { url: '/favicon.ico', sizes: 'any' }, + { url: '/favicon-16x16.png', sizes: '16x16', type: 'image/png' }, + { url: '/favicon-32x32.png', sizes: '32x32', type: 'image/png' }, + ], + apple: [{ url: '/apple-touch-icon.png', sizes: '180x180' }], + }, + robots: { + index: false, + follow: false, + googleBot: { index: false, follow: false }, + }, + openGraph: { + type: 'website', + locale: 'en_US', + siteName: 'ABStrack Practitioner', + title: 'ABStrack Practitioner', + description: PRACTITIONER_SITE_DESCRIPTION, + url: '/', + images: [OG_IMAGE_METADATA], + }, + twitter: { + card: 'summary_large_image', + title: 'ABStrack Practitioner', + description: PRACTITIONER_SITE_DESCRIPTION, + images: [OG_IMAGE_PATH], + }, + }; +} diff --git a/apps/practitioner/src/lib/site-url.spec.ts b/apps/practitioner/src/lib/site-url.spec.ts new file mode 100644 index 00000000..3aa4ca1c --- /dev/null +++ b/apps/practitioner/src/lib/site-url.spec.ts @@ -0,0 +1,45 @@ +import { + PRODUCTION_PRACTITIONER_WEB_ORIGIN, + getMetadataBase, + getSiteUrl, +} from './site-url'; + +describe('getSiteUrl', () => { + const prevOrigin = process.env.NEXT_PUBLIC_PRACTITIONER_WEB_ORIGIN; + const prevNodeEnv = process.env.NODE_ENV; + + afterEach(() => { + if (prevOrigin === undefined) { + delete process.env.NEXT_PUBLIC_PRACTITIONER_WEB_ORIGIN; + } else { + process.env.NEXT_PUBLIC_PRACTITIONER_WEB_ORIGIN = prevOrigin; + } + process.env.NODE_ENV = prevNodeEnv; + }); + + it('uses NEXT_PUBLIC_PRACTITIONER_WEB_ORIGIN when set', () => { + process.env.NEXT_PUBLIC_PRACTITIONER_WEB_ORIGIN = + 'https://practitioner.example.com/'; + expect(getSiteUrl()).toBe('https://practitioner.example.com'); + }); + + it('defaults to production origin in production when env is unset', () => { + delete process.env.NEXT_PUBLIC_PRACTITIONER_WEB_ORIGIN; + process.env.NODE_ENV = 'production'; + expect(getSiteUrl()).toBe(PRODUCTION_PRACTITIONER_WEB_ORIGIN); + }); + + it('defaults to localhost in non-production when env is unset', () => { + delete process.env.NEXT_PUBLIC_PRACTITIONER_WEB_ORIGIN; + process.env.NODE_ENV = 'development'; + expect(getSiteUrl()).toBe('http://localhost:3000'); + }); +}); + +describe('getMetadataBase', () => { + it('returns a trailing-slash URL', () => { + process.env.NEXT_PUBLIC_PRACTITIONER_WEB_ORIGIN = + 'https://practitioner.abstrack.org'; + expect(getMetadataBase().href).toBe('https://practitioner.abstrack.org/'); + }); +}); diff --git a/apps/practitioner/src/lib/site-url.ts b/apps/practitioner/src/lib/site-url.ts new file mode 100644 index 00000000..d07a5c50 --- /dev/null +++ b/apps/practitioner/src/lib/site-url.ts @@ -0,0 +1,27 @@ +/** Canonical production origin for practitioner web. */ +export const PRODUCTION_PRACTITIONER_WEB_ORIGIN = + 'https://practitioner.abstrack.org'; + +/** + * Resolves the practitioner-web origin for `metadataBase` and Open Graph URLs. + * Prefer `NEXT_PUBLIC_PRACTITIONER_WEB_ORIGIN` when set (e.g. local or staging). + * + * @returns Origin without trailing slash (e.g. `https://practitioner.abstrack.org`). + */ +export function getSiteUrl(): string { + const fromEnv = process.env.NEXT_PUBLIC_PRACTITIONER_WEB_ORIGIN?.trim(); + if (fromEnv) { + return fromEnv.replace(/\/$/, ''); + } + if (process.env.NODE_ENV === 'production') { + return PRODUCTION_PRACTITIONER_WEB_ORIGIN; + } + return 'http://localhost:3000'; +} + +/** + * @returns `metadataBase` for Next.js root metadata. + */ +export function getMetadataBase(): URL { + return new URL(`${getSiteUrl()}/`); +} diff --git a/apps/practitioner/src/proxy.spec.ts b/apps/practitioner/src/proxy.spec.ts index 8a6a14d0..6ae72bd2 100644 --- a/apps/practitioner/src/proxy.spec.ts +++ b/apps/practitioner/src/proxy.spec.ts @@ -126,7 +126,7 @@ describe('practitioner proxy', () => { ); }); - it('does not redirect signed-in root away from MFA home', async () => { + it('redirects signed-in root to /patients', async () => { createServerClientMock.mockReturnValue({ auth: { getUser: jest.fn(async () => ({ @@ -138,6 +138,31 @@ describe('practitioner proxy', () => { const result = await proxy(makeRequest('/')); - expect(result).toEqual(expect.objectContaining({ type: 'next' })); + expect(result).toEqual( + expect.objectContaining({ + type: 'redirect', + location: 'https://practitioner.example.com/patients', + }), + ); + }); + + it('redirects signed-in /login to /patients', async () => { + createServerClientMock.mockReturnValue({ + auth: { + getUser: jest.fn(async () => ({ + data: { user: { id: 'user-1' } }, + error: null, + })), + }, + } as unknown as ServerClient); + + const result = await proxy(makeRequest('/login')); + + expect(result).toEqual( + expect.objectContaining({ + type: 'redirect', + location: 'https://practitioner.example.com/patients', + }), + ); }); }); diff --git a/apps/practitioner/src/proxy.ts b/apps/practitioner/src/proxy.ts index 5e603e52..0995dec2 100644 --- a/apps/practitioner/src/proxy.ts +++ b/apps/practitioner/src/proxy.ts @@ -31,8 +31,9 @@ function withSupabaseCookies( /** * Next.js 16 **proxy** (replaces `middleware.ts`): session refresh, auth-route redirects, and the * Supabase implicit-auth rewrite for `/auth/callback` (same pattern as `apps/web/src/proxy.ts`). - * Signed-out visits to `/` redirect to `/login`. Patient routes (`/patients` and below) stay on the - * requested URL when signed out so the client gate component can render the in-app sign-in UI. + * Signed-out visits to `/` redirect to `/login`. Signed-in visits to `/` or `/login` redirect to + * `/patients` (patient workspace). Patient routes stay on the requested URL when signed out so the + * client gate can render the in-app sign-in UI. TOTP enrollment lives at `/mfa`. */ export default async function proxy(req: NextRequest) { const { pathname, searchParams } = req.nextUrl; @@ -80,7 +81,7 @@ export default async function proxy(req: NextRequest) { if (isAuthRoute && user) { return withSupabaseCookies( - NextResponse.redirect(new URL('/', req.url)), + NextResponse.redirect(new URL('/patients', req.url)), supabaseResponse, ); } @@ -92,6 +93,13 @@ export default async function proxy(req: NextRequest) { ); } + if (user && pathname === '/') { + return withSupabaseCookies( + NextResponse.redirect(new URL('/patients', req.url)), + supabaseResponse, + ); + } + return supabaseResponse; }