improvement/v1-demo-improvements #64
@@ -36,7 +36,7 @@ CLINIC + LAB KPIs/charts in `modules/today/today.service.ts`. Deep links: `compo
|
|||||||
|
|
||||||
## Lab case share link
|
## Lab case share link
|
||||||
|
|
||||||
QR + URL for **sent** cases; focus page `/lab-case/[token]`. Auth redirect via `postAuthRedirect.ts`. Skill: `.cursor/skills/lab-case-share-link/SKILL.md`.
|
QR + URL for **sent** cases; focus page `/lab-case/[token]`. Auth redirect via `postAuthRedirect.ts` + `useEnterAppWhenAuthenticated` (not inside `login()`). Skill: `.cursor/skills/lab-case-share-link/SKILL.md`.
|
||||||
|
|
||||||
## Notifications (inbox + live tabs)
|
## Notifications (inbox + live tabs)
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ alwaysApply: false
|
|||||||
- **Route:** `/lab-case/[token]` → `CaseTasksFocusView` (dashboard layout, auth required).
|
- **Route:** `/lab-case/[token]` → `CaseTasksFocusView` (dashboard layout, auth required).
|
||||||
- **Access:** lab (`TAB_TASKS_*`) or clinic treatment **provider** (`TAB_TREATMENT_EDIT` + `isActorTreatmentProvider`); else `LAB_CASE_ACCESS_DENIED`.
|
- **Access:** lab (`TAB_TASKS_*`) or clinic treatment **provider** (`TAB_TREATMENT_EDIT` + `isActorTreatmentProvider`); else `LAB_CASE_ACCESS_DENIED`.
|
||||||
- **Task status on link page:** same assignee rule as Tasks — `canEditLabTaskStatus`; backend `PATCH /tasks/:id` enforces assignee.
|
- **Task status on link page:** same assignee rule as Tasks — `canEditLabTaskStatus`; backend `PATCH /tasks/:id` enforces assignee.
|
||||||
- **Auth redirect:** `postAuthRedirect.ts`; dashboard stores path on logout redirect; login consumes **once** after org ready — ❌ do not consume in `useAuth.login()`.
|
- **Auth redirect:** `postAuthRedirect.ts`; dashboard stores path on logout redirect; login stores `?from=` **then** `useEnterAppWhenAuthenticated` consumes **once** after org ready — ❌ do not consume in `useAuth.login()` / `registerTrial`. Invites: `login()` then `navigateIntoAppIfOrgSelected` (no enter-app hook on invite pages).
|
||||||
- **Login page:** wrap `useSearchParams` in `<Suspense>` for `next build`.
|
- **Login page:** wrap `useSearchParams` in `<Suspense>` for `next build`.
|
||||||
|
|
||||||
Skill: `.cursor/skills/lab-case-share-link/SKILL.md`
|
Skill: `.cursor/skills/lab-case-share-link/SKILL.md`
|
||||||
|
|||||||
16
.cursor/rules/post-auth-navigation.mdc
Normal file
16
.cursor/rules/post-auth-navigation.mdc
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
description: Post-auth enter-app routing — login, register, invites, share-link redirect
|
||||||
|
globs: frontend/src/lib/auth/postAuthRedirect.ts,frontend/src/lib/hooks/useEnterAppWhenAuthenticated.ts,frontend/src/lib/hooks/useAuth.tsx,frontend/src/app/**/login/page.tsx,frontend/src/app/**/register/page.tsx,frontend/src/app/**/accept-invite/page.tsx,frontend/src/app/**/accept-organization-invite/page.tsx,frontend/src/app/**/forgot-password/page.tsx,frontend/src/app/**/(dashboard)/layout.tsx
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Post-auth navigation
|
||||||
|
|
||||||
|
`login()` / `registerTrial()` select the only org (or push `/select-organization`). They do **not** go to `/today`.
|
||||||
|
|
||||||
|
- **Login + register:** `useEnterAppWhenAuthenticated` after org ready → `appPathAfterAuth()` (`consumeAuthRedirect()` once, else `/today`).
|
||||||
|
- **Login `?from=`:** `storeAuthRedirectFromPath` **before** that hook (effect order).
|
||||||
|
- **Staff / org invite:** accept → `login(email, password)` → `navigateIntoAppIfOrgSelected`. ❌ Do not put the hook on invite pages (logged-in visitors must finish accept).
|
||||||
|
- **Forgot password:** navigates itself to `/settings/account?reset=1`. ❌ Do not add the enter-app hook there.
|
||||||
|
- **Multi-org:** redirect stays in sessionStorage until `selectOrganization()` → `appPathAfterAuth()`.
|
||||||
|
- ❌ Never `consumeAuthRedirect()` inside `useAuth.login()` or `registerTrial`.
|
||||||
@@ -53,10 +53,13 @@ Task status updates use **`PATCH /tasks/:id`** (not token routes) — same assig
|
|||||||
Helpers: `lib/auth/postAuthRedirect.ts` (`sessionStorage` key `authRedirect`).
|
Helpers: `lib/auth/postAuthRedirect.ts` (`sessionStorage` key `authRedirect`).
|
||||||
|
|
||||||
1. Logged-out user hits `/lab-case/{token}` → dashboard layout stores path + `router.replace('/login?from=…')`.
|
1. Logged-out user hits `/lab-case/{token}` → dashboard layout stores path + `router.replace('/login?from=…')`.
|
||||||
2. Login page `useSearchParams` (inside **Suspense**) calls `storeAuthRedirectFromPath(from)`.
|
2. Login page `useSearchParams` (inside **Suspense**) calls `storeAuthRedirectFromPath(from)` **before** `useEnterAppWhenAuthenticated`.
|
||||||
3. After login + org ready: **one** `consumeAuthRedirect()` on login page (wait for `!isLoading` and org selected).
|
3. After login/register + org ready: **one** consume via `appPathAfterAuth()` in that hook. Staff/org invite: `login()` then `navigateIntoAppIfOrgSelected` (❌ no hook on invite pages).
|
||||||
4. **Do not** `consumeAuthRedirect()` inside `useAuth.login()` — double consume sends user to `/today`.
|
4. **Do not** `consumeAuthRedirect()` inside `useAuth.login()` or `registerTrial` — double consume sends user to `/today`.
|
||||||
5. Multi-org: redirect stays in storage until `selectOrganization()` consumes it.
|
5. Multi-org: redirect stays in storage until `selectOrganization()` → `appPathAfterAuth()`.
|
||||||
|
6. Forgot-password navigates to account reset itself — do not add the enter-app hook there.
|
||||||
|
|
||||||
|
Rule: `.cursor/rules/post-auth-navigation.mdc`.
|
||||||
|
|
||||||
## Tasks tab interaction
|
## Tasks tab interaction
|
||||||
|
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ frontend/src/
|
|||||||
- Token on first ship → `/{locale}/lab-case/{token}` after login.
|
- Token on first ship → `/{locale}/lab-case/{token}` after login.
|
||||||
- **Lab:** view/edit tasks (assignee rules), comments + visibility toggle.
|
- **Lab:** view/edit tasks (assignee rules), comments + visibility toggle.
|
||||||
- **Clinic:** treatment **provider** only — read-only tasks, can comment.
|
- **Clinic:** treatment **provider** only — read-only tasks, can comment.
|
||||||
- Logged out → login with `?from=` → single `consumeAuthRedirect()` after org ready (not inside `useAuth.login()`).
|
- Logged out → login with `?from=` → `storeAuthRedirectFromPath` then `useEnterAppWhenAuthenticated` (`consumeAuthRedirect` once after org ready — not inside `useAuth.login()` / `registerTrial`). Trial register uses the same hook; staff/org invite accept then `login()` + `navigateIntoAppIfOrgSelected`. See `.cursor/rules/post-auth-navigation.mdc`.
|
||||||
|
|
||||||
**Today dashboard:** KPIs + charts per org type/permissions; deep links via `today-deep-links.ts` (Tasks KPIs/charts, Staff highlight, case partners). See `.cursor/skills/today-dashboard/SKILL.md`.
|
**Today dashboard:** KPIs + charts per org type/permissions; deep links via `today-deep-links.ts` (Tasks KPIs/charts, Staff highlight, case partners). See `.cursor/skills/today-dashboard/SKILL.md`.
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ const TABLES_IN_ORDER = [
|
|||||||
'staff_invitations',
|
'staff_invitations',
|
||||||
'membership_permissions',
|
'membership_permissions',
|
||||||
'sessions',
|
'sessions',
|
||||||
|
'user_notifications',
|
||||||
|
'lab_case_user_read_states',
|
||||||
|
'lab_case_user_tab_read_states',
|
||||||
'lab_case_task_status_events',
|
'lab_case_task_status_events',
|
||||||
'lab_case_comments',
|
'lab_case_comments',
|
||||||
'lab_case_attachments',
|
'lab_case_attachments',
|
||||||
@@ -49,6 +52,7 @@ const TABLES_IN_ORDER = [
|
|||||||
'lab_case_tooth_prosthesis',
|
'lab_case_tooth_prosthesis',
|
||||||
'lab_case_details',
|
'lab_case_details',
|
||||||
'lab_cases',
|
'lab_cases',
|
||||||
|
'lab_case_activities',
|
||||||
'treatment_detail_attachments',
|
'treatment_detail_attachments',
|
||||||
'treatment_details',
|
'treatment_details',
|
||||||
'treatments',
|
'treatments',
|
||||||
|
|||||||
@@ -119,7 +119,8 @@
|
|||||||
"labelCreatePassword": "Create password",
|
"labelCreatePassword": "Create password",
|
||||||
"labelConfirmPassword": "Confirm password",
|
"labelConfirmPassword": "Confirm password",
|
||||||
"activateAccount": "Activate account",
|
"activateAccount": "Activate account",
|
||||||
"invitationAcceptedRedirect": "Invitation Accepted. Redirecting to login...",
|
"invitationAcceptedRedirect": "Invitation accepted. Opening your workspace...",
|
||||||
|
"invitationAcceptedSignInFailed": "Account activated, but sign-in failed. Please log in with your password.",
|
||||||
"errorAcceptInvitation": "Could not accept invitation",
|
"errorAcceptInvitation": "Could not accept invitation",
|
||||||
"alreadyHaveAccess": "Already have access?",
|
"alreadyHaveAccess": "Already have access?",
|
||||||
"goToLogin": "Go to login",
|
"goToLogin": "Go to login",
|
||||||
@@ -128,7 +129,7 @@
|
|||||||
"invitedBy": "Invited by:",
|
"invitedBy": "Invited by:",
|
||||||
"ownerEmail": "Owner email",
|
"ownerEmail": "Owner email",
|
||||||
"activateOrganization": "Activate organization",
|
"activateOrganization": "Activate organization",
|
||||||
"organizationAcceptedRedirect": "Invitation accepted. Redirecting to login...",
|
"organizationAcceptedRedirect": "Invitation accepted. Opening your workspace...",
|
||||||
"stepAccount": "Account",
|
"stepAccount": "Account",
|
||||||
"stepOrganization": "Organization",
|
"stepOrganization": "Organization",
|
||||||
"organizationName": "Organization name",
|
"organizationName": "Organization name",
|
||||||
|
|||||||
@@ -119,7 +119,8 @@
|
|||||||
"labelCreatePassword": "ایجاد رمز عبور",
|
"labelCreatePassword": "ایجاد رمز عبور",
|
||||||
"labelConfirmPassword": "تأیید رمز عبور",
|
"labelConfirmPassword": "تأیید رمز عبور",
|
||||||
"activateAccount": "فعالسازی حساب",
|
"activateAccount": "فعالسازی حساب",
|
||||||
"invitationAcceptedRedirect": "دعوتنامه پذیرفته شد. در حال انتقال به صفحه ورود...",
|
"invitationAcceptedRedirect": "دعوتنامه پذیرفته شد. در حال ورود به فضای کاری...",
|
||||||
|
"invitationAcceptedSignInFailed": "حساب فعال شد، اما ورود انجام نشد. لطفاً با رمز عبور خود وارد شوید.",
|
||||||
"errorAcceptInvitation": "پذیرش دعوتنامه امکانپذیر نبود",
|
"errorAcceptInvitation": "پذیرش دعوتنامه امکانپذیر نبود",
|
||||||
"alreadyHaveAccess": "از قبل دسترسی دارید؟",
|
"alreadyHaveAccess": "از قبل دسترسی دارید؟",
|
||||||
"goToLogin": "رفتن به ورود",
|
"goToLogin": "رفتن به ورود",
|
||||||
@@ -128,7 +129,7 @@
|
|||||||
"invitedBy": "دعوتکننده:",
|
"invitedBy": "دعوتکننده:",
|
||||||
"ownerEmail": "ایمیل مالک",
|
"ownerEmail": "ایمیل مالک",
|
||||||
"activateOrganization": "فعالسازی سازمان",
|
"activateOrganization": "فعالسازی سازمان",
|
||||||
"organizationAcceptedRedirect": "دعوتنامه پذیرفته شد. در حال انتقال به صفحه ورود...",
|
"organizationAcceptedRedirect": "دعوتنامه پذیرفته شد. در حال ورود به فضای کاری...",
|
||||||
"stepAccount": "حساب",
|
"stepAccount": "حساب",
|
||||||
"stepOrganization": "سازمان",
|
"stepOrganization": "سازمان",
|
||||||
"organizationName": "نام سازمان",
|
"organizationName": "نام سازمان",
|
||||||
|
|||||||
@@ -119,7 +119,8 @@
|
|||||||
"labelCreatePassword": "Wachtwoord aanmaken",
|
"labelCreatePassword": "Wachtwoord aanmaken",
|
||||||
"labelConfirmPassword": "Bevestig wachtwoord",
|
"labelConfirmPassword": "Bevestig wachtwoord",
|
||||||
"activateAccount": "Account activeren",
|
"activateAccount": "Account activeren",
|
||||||
"invitationAcceptedRedirect": "Uitnodiging geaccepteerd. Doorsturen naar inloggen...",
|
"invitationAcceptedRedirect": "Uitnodiging geaccepteerd. Uw werkruimte wordt geopend...",
|
||||||
|
"invitationAcceptedSignInFailed": "Account geactiveerd, maar aanmelden is mislukt. Log in met uw wachtwoord.",
|
||||||
"errorAcceptInvitation": "Kon uitnodiging niet accepteren",
|
"errorAcceptInvitation": "Kon uitnodiging niet accepteren",
|
||||||
"alreadyHaveAccess": "Heeft u al toegang?",
|
"alreadyHaveAccess": "Heeft u al toegang?",
|
||||||
"goToLogin": "Ga naar inloggen",
|
"goToLogin": "Ga naar inloggen",
|
||||||
@@ -128,7 +129,7 @@
|
|||||||
"invitedBy": "Uitgenodigd door:",
|
"invitedBy": "Uitgenodigd door:",
|
||||||
"ownerEmail": "E-mail eigenaar",
|
"ownerEmail": "E-mail eigenaar",
|
||||||
"activateOrganization": "Organisatie activeren",
|
"activateOrganization": "Organisatie activeren",
|
||||||
"organizationAcceptedRedirect": "Uitnodiging geaccepteerd. Doorsturen naar inloggen...",
|
"organizationAcceptedRedirect": "Uitnodiging geaccepteerd. Uw werkruimte wordt geopend...",
|
||||||
"stepAccount": "Account",
|
"stepAccount": "Account",
|
||||||
"stepOrganization": "Organisatie",
|
"stepOrganization": "Organisatie",
|
||||||
"organizationName": "Organisatienaam",
|
"organizationName": "Organisatienaam",
|
||||||
|
|||||||
@@ -9,12 +9,15 @@ import { Button } from '@/components/ui/shared/Button';
|
|||||||
import { Input } from '@/components/ui/shared/Input';
|
import { Input } from '@/components/ui/shared/Input';
|
||||||
import { getUserFacingError } from '@/components/shared/formatApiError';
|
import { getUserFacingError } from '@/components/shared/formatApiError';
|
||||||
import { staffApi } from '@/lib/api/staff';
|
import { staffApi } from '@/lib/api/staff';
|
||||||
|
import { useAuth } from '@/lib/hooks/useAuth';
|
||||||
|
import { navigateIntoAppIfOrgSelected } from '@/lib/auth/postAuthRedirect';
|
||||||
|
|
||||||
function AcceptInviteContent() {
|
function AcceptInviteContent() {
|
||||||
const t = useTranslations('auth');
|
const t = useTranslations('auth');
|
||||||
const tErrors = useTranslations('errors');
|
const tErrors = useTranslations('errors');
|
||||||
const params = useSearchParams();
|
const params = useSearchParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { login } = useAuth();
|
||||||
const token = useMemo(() => params.get('token') || '', [params]);
|
const token = useMemo(() => params.get('token') || '', [params]);
|
||||||
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -76,18 +79,29 @@ function AcceptInviteContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
|
let accepted = false;
|
||||||
try {
|
try {
|
||||||
await staffApi.acceptInvite({
|
await staffApi.acceptInvite({
|
||||||
token,
|
token,
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
password,
|
password,
|
||||||
});
|
});
|
||||||
setSuccess(t('invitationAcceptedRedirect'));
|
accepted = true;
|
||||||
setTimeout(() => {
|
const email = inviteInfo?.email?.trim();
|
||||||
router.replace('/login');
|
if (!email) {
|
||||||
}, 1000);
|
throw new Error('missing-invite-email');
|
||||||
|
}
|
||||||
|
const { organizations } = await login(email, password);
|
||||||
|
navigateIntoAppIfOrgSelected((href) => router.replace(href), organizations.length);
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
setError(getUserFacingError(e, tErrors, t('errorAcceptInvitation')));
|
if (accepted) {
|
||||||
|
setSuccess(t('invitationAcceptedSignInFailed'));
|
||||||
|
setTimeout(() => {
|
||||||
|
router.replace('/login');
|
||||||
|
}, 1500);
|
||||||
|
} else {
|
||||||
|
setError(getUserFacingError(e, tErrors, t('errorAcceptInvitation')));
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import { OrganizationDetailsFields } from '@/components/ui/auth/OrganizationDeta
|
|||||||
import { RegistrationProgressSteps } from '@/components/ui/auth/RegistrationProgressSteps';
|
import { RegistrationProgressSteps } from '@/components/ui/auth/RegistrationProgressSteps';
|
||||||
import { getUserFacingError } from '@/components/shared/formatApiError';
|
import { getUserFacingError } from '@/components/shared/formatApiError';
|
||||||
import { organizationApi } from '@/lib/api/organization';
|
import { organizationApi } from '@/lib/api/organization';
|
||||||
|
import { useAuth } from '@/lib/hooks/useAuth';
|
||||||
|
import { navigateIntoAppIfOrgSelected } from '@/lib/auth/postAuthRedirect';
|
||||||
|
|
||||||
type AcceptOrganizationInviteForm = {
|
type AcceptOrganizationInviteForm = {
|
||||||
ownerName: string;
|
ownerName: string;
|
||||||
@@ -33,6 +35,7 @@ function AcceptOrganizationInviteContent() {
|
|||||||
const tValidation = useTranslations('validation');
|
const tValidation = useTranslations('validation');
|
||||||
const params = useSearchParams();
|
const params = useSearchParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { login } = useAuth();
|
||||||
const token = useMemo(() => params.get('token') || '', [params]);
|
const token = useMemo(() => params.get('token') || '', [params]);
|
||||||
|
|
||||||
const acceptOrganizationInviteSchema = useMemo(
|
const acceptOrganizationInviteSchema = useMemo(
|
||||||
@@ -137,6 +140,7 @@ function AcceptOrganizationInviteContent() {
|
|||||||
setError('');
|
setError('');
|
||||||
setSuccess('');
|
setSuccess('');
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
|
let accepted = false;
|
||||||
try {
|
try {
|
||||||
await organizationApi.acceptInvite({
|
await organizationApi.acceptInvite({
|
||||||
token,
|
token,
|
||||||
@@ -146,10 +150,20 @@ function AcceptOrganizationInviteContent() {
|
|||||||
organizationEmail: data.organizationEmail.trim(),
|
organizationEmail: data.organizationEmail.trim(),
|
||||||
organizationType: data.organizationType,
|
organizationType: data.organizationType,
|
||||||
});
|
});
|
||||||
setSuccess(t('organizationAcceptedRedirect'));
|
accepted = true;
|
||||||
setTimeout(() => router.replace('/login'), 1000);
|
const email = inviteInfo?.ownerEmail?.trim();
|
||||||
|
if (!email) {
|
||||||
|
throw new Error('missing-invite-email');
|
||||||
|
}
|
||||||
|
const { organizations } = await login(email, data.password);
|
||||||
|
navigateIntoAppIfOrgSelected((href) => router.replace(href), organizations.length);
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
setError(getUserFacingError(e, tErrors, t('errorAcceptInvitation')));
|
if (accepted) {
|
||||||
|
setSuccess(t('invitationAcceptedSignInFailed'));
|
||||||
|
setTimeout(() => router.replace('/login'), 1500);
|
||||||
|
} else {
|
||||||
|
setError(getUserFacingError(e, tErrors, t('errorAcceptInvitation')));
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import { Suspense, useState, useEffect, useMemo } from 'react';
|
import { Suspense, useState, useEffect, useMemo } from 'react';
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
import { useRouter } from '@/i18n/navigation';
|
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import * as z from 'zod';
|
import * as z from 'zod';
|
||||||
@@ -16,10 +15,8 @@ import { AuthPageShell } from '@/components/ui/auth/AuthPageShell';
|
|||||||
import { Button } from '@/components/ui/shared/Button';
|
import { Button } from '@/components/ui/shared/Button';
|
||||||
import { Checkbox } from '@/components/ui/shared/Checkbox';
|
import { Checkbox } from '@/components/ui/shared/Checkbox';
|
||||||
import { Input } from '@/components/ui/shared/Input';
|
import { Input } from '@/components/ui/shared/Input';
|
||||||
import {
|
import { storeAuthRedirectFromPath } from '@/lib/auth/postAuthRedirect';
|
||||||
consumeAuthRedirect,
|
import { useEnterAppWhenAuthenticated } from '@/lib/hooks/useEnterAppWhenAuthenticated';
|
||||||
storeAuthRedirectFromPath,
|
|
||||||
} from '@/lib/auth/postAuthRedirect';
|
|
||||||
|
|
||||||
type LoginForm = {
|
type LoginForm = {
|
||||||
email: string;
|
email: string;
|
||||||
@@ -32,8 +29,7 @@ function LoginPageContent() {
|
|||||||
const tCommon = useTranslations('common');
|
const tCommon = useTranslations('common');
|
||||||
const tValidation = useTranslations('validation');
|
const tValidation = useTranslations('validation');
|
||||||
const tErrors = useTranslations('errors');
|
const tErrors = useTranslations('errors');
|
||||||
const { login, isLoading, user, isAuthReady, organizations, currentOrganization } = useAuth();
|
const { login, isLoading, isAuthReady } = useAuth();
|
||||||
const router = useRouter();
|
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [savedEmail] = useState(() => getRememberedEmail());
|
const [savedEmail] = useState(() => getRememberedEmail());
|
||||||
@@ -55,12 +51,7 @@ function LoginPageContent() {
|
|||||||
}
|
}
|
||||||
}, [searchParams]);
|
}, [searchParams]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEnterAppWhenAuthenticated();
|
||||||
if (!isAuthReady || !user || isLoading) return;
|
|
||||||
const orgReady = organizations.length <= 1 || currentOrganization;
|
|
||||||
if (!orgReady) return;
|
|
||||||
router.push(consumeAuthRedirect() ?? '/today');
|
|
||||||
}, [isAuthReady, user, isLoading, organizations, currentOrganization, router]);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Link } from '@/i18n/navigation';
|
|||||||
import { Mail, Lock, User, Phone } from 'lucide-react';
|
import { Mail, Lock, User, Phone } from 'lucide-react';
|
||||||
import { getUserFacingError } from '@/components/shared/formatApiError';
|
import { getUserFacingError } from '@/components/shared/formatApiError';
|
||||||
import { useAuth } from '@/lib/hooks/useAuth';
|
import { useAuth } from '@/lib/hooks/useAuth';
|
||||||
|
import { useEnterAppWhenAuthenticated } from '@/lib/hooks/useEnterAppWhenAuthenticated';
|
||||||
import { AuthPageShell } from '@/components/ui/auth/AuthPageShell';
|
import { AuthPageShell } from '@/components/ui/auth/AuthPageShell';
|
||||||
import { OrganizationDetailsFields } from '@/components/ui/auth/OrganizationDetailsFields';
|
import { OrganizationDetailsFields } from '@/components/ui/auth/OrganizationDetailsFields';
|
||||||
import { RegistrationProgressSteps } from '@/components/ui/auth/RegistrationProgressSteps';
|
import { RegistrationProgressSteps } from '@/components/ui/auth/RegistrationProgressSteps';
|
||||||
@@ -38,9 +39,10 @@ export default function RegisterPage() {
|
|||||||
const tCommon = useTranslations('common');
|
const tCommon = useTranslations('common');
|
||||||
const tValidation = useTranslations('validation');
|
const tValidation = useTranslations('validation');
|
||||||
const tErrors = useTranslations('errors');
|
const tErrors = useTranslations('errors');
|
||||||
const { registerTrial, isLoading } = useAuth();
|
const { registerTrial, isLoading, isAuthReady } = useAuth();
|
||||||
const [step, setStep] = useState(1);
|
const [step, setStep] = useState(1);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
useEnterAppWhenAuthenticated();
|
||||||
|
|
||||||
const registerSchema = useMemo(
|
const registerSchema = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -121,6 +123,14 @@ export default function RegisterPage() {
|
|||||||
hide: t('hidePassword'),
|
hide: t('hidePassword'),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!isAuthReady) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-[100dvh] app-web-bg flex items-center justify-center px-4">
|
||||||
|
<p className="text-text-secondary">{tCommon('loading')}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthPageShell
|
<AuthPageShell
|
||||||
header={
|
header={
|
||||||
|
|||||||
@@ -15,3 +15,20 @@ export function consumeAuthRedirect(): string | null {
|
|||||||
if (path) sessionStorage.removeItem(AUTH_REDIRECT_KEY);
|
if (path) sessionStorage.removeItem(AUTH_REDIRECT_KEY);
|
||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Dashboard (or a stored share-link / post-auth path). Consume once — never inside useAuth.login() or registerTrial. */
|
||||||
|
export function appPathAfterAuth(): string {
|
||||||
|
return consumeAuthRedirect() ?? '/today';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* After login() on invite pages only. Multi-org already went to /select-organization.
|
||||||
|
* Do not use useEnterAppWhenAuthenticated on invite pages.
|
||||||
|
*/
|
||||||
|
export function navigateIntoAppIfOrgSelected(
|
||||||
|
replace: (href: string) => void,
|
||||||
|
organizationCount: number,
|
||||||
|
) {
|
||||||
|
if (organizationCount > 1) return;
|
||||||
|
replace(appPathAfterAuth());
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
} from '@/lib/auth/proactiveRefresh';
|
} from '@/lib/auth/proactiveRefresh';
|
||||||
import { notifyAccessTokenRefreshed } from '@/lib/auth/accessTokenEvents';
|
import { notifyAccessTokenRefreshed } from '@/lib/auth/accessTokenEvents';
|
||||||
import { asApiError, legacyStatusCode, type ApiError } from '@/types/api';
|
import { asApiError, legacyStatusCode, type ApiError } from '@/types/api';
|
||||||
import { consumeAuthRedirect } from '@/lib/auth/postAuthRedirect';
|
import { appPathAfterAuth } from '@/lib/auth/postAuthRedirect';
|
||||||
|
|
||||||
function toApiError(err: unknown): ApiError {
|
function toApiError(err: unknown): ApiError {
|
||||||
return (
|
return (
|
||||||
@@ -50,7 +50,11 @@ interface AuthContextType {
|
|||||||
organizationEmail: string,
|
organizationEmail: string,
|
||||||
organizationType: 'CLINIC' | 'LAB'
|
organizationType: 'CLINIC' | 'LAB'
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
login: (email: string, password: string, rememberMe?: boolean) => Promise<void>;
|
login: (
|
||||||
|
email: string,
|
||||||
|
password: string,
|
||||||
|
rememberMe?: boolean,
|
||||||
|
) => Promise<{ organizations: Organization[] }>;
|
||||||
logout: () => Promise<void>;
|
logout: () => Promise<void>;
|
||||||
selectOrganization: (orgId: string) => Promise<void>;
|
selectOrganization: (orgId: string) => Promise<void>;
|
||||||
createOrganization: (
|
createOrganization: (
|
||||||
@@ -337,6 +341,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
router.push('/select-organization');
|
router.push('/select-organization');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return { organizations: orgs };
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setApiError(toApiError(err));
|
setApiError(toApiError(err));
|
||||||
throw err;
|
throw err;
|
||||||
@@ -388,12 +393,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
plan: (organization as { plan?: Organization['plan'] }).plan,
|
plan: (organization as { plan?: Organization['plan'] }).plan,
|
||||||
});
|
});
|
||||||
|
|
||||||
const redirectPath = consumeAuthRedirect();
|
router.push(appPathAfterAuth());
|
||||||
if (redirectPath) {
|
|
||||||
router.push(redirectPath);
|
|
||||||
} else {
|
|
||||||
router.push('/today');
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setApiError(toApiError(err));
|
setApiError(toApiError(err));
|
||||||
|
|||||||
23
frontend/src/lib/hooks/useEnterAppWhenAuthenticated.ts
Normal file
23
frontend/src/lib/hooks/useEnterAppWhenAuthenticated.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useRouter } from '@/i18n/navigation';
|
||||||
|
import { appPathAfterAuth } from '@/lib/auth/postAuthRedirect';
|
||||||
|
import { useAuth } from '@/lib/hooks/useAuth';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When a session already exists (or was just created on this page), enter the app.
|
||||||
|
* Use on /login and /register only — not on invite pages, where a logged-in visitor
|
||||||
|
* must still be able to finish accept before navigating.
|
||||||
|
*/
|
||||||
|
export function useEnterAppWhenAuthenticated() {
|
||||||
|
const { user, isAuthReady, isLoading, organizations, currentOrganization } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthReady || !user || isLoading) return;
|
||||||
|
const orgReady = organizations.length <= 1 || currentOrganization;
|
||||||
|
if (!orgReady) return;
|
||||||
|
router.push(appPathAfterAuth());
|
||||||
|
}, [isAuthReady, user, isLoading, organizations, currentOrganization, router]);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user