(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 (
-
-
- Could not load your profile
-
-
- Something went wrong while loading your account. Try signing out and
- signing in again. If this keeps happening, try again later.
-
-
-
- );
- }
-
- if (gate.kind === 'profile_missing') {
- return (
-
-
- No profile for this account
-
-
- This sign-in does not have an ABStrack profile yet. Practitioner
- accounts must be created through the correct invitation flow.
-
-
-
- );
- }
-
- if (gate.kind === 'wrong_app_role') {
- return (
-
-
- Wrong account type for this app
-
-
- This app is for healthcare practitioners. Your account is registered
- as {gate.appRole} .
- Use the patient or caretaker app instead.
-
-
- Sign out to use a different account, or open the patient or caretaker
- app.
-
-
-
- );
- }
-
- 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 ? (
-
- {isEnrolling ? 'Starting enrollment...' : 'Set up TOTP'}
-
- ) : null}
-
-
- {enrollment ? (
-
-
- Verify authenticator app
-
-
-
-
-
-
-
- Setup key:{' '}
-
- {enrollment.secret}
-
-
-
- Factor name: {enrollment.friendlyName}
-
-
-
- Six-digit code
-
- {
- 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 ? (
-
- {verifyFailureMessage}
-
- ) : null}
-
- Enter digits only — not the otpauth link. If the code fails, wait
- for a new code cycle and try again.
-
-
-
-
- {isVerifying ? 'Verifying code...' : 'Verify and finish'}
-
- void cancelEnrollment()}
- disabled={isVerifying || isCanceling}
- className="min-h-11 rounded-md border border-app-border bg-app-surface px-4 py-2 text-sm font-medium text-app-ink transition hover:bg-[var(--app-nav-hover-bg)] disabled:cursor-not-allowed disabled:opacity-50"
- >
- {isCanceling ? 'Canceling…' : 'Cancel'}
-
-
-
- ) : 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;
}