From d6958b2e48f7addeea92d39eeffe3544ba0ed49c Mon Sep 17 00:00:00 2001 From: Admin Date: Wed, 19 Aug 2026 00:39:31 +0330 Subject: [PATCH] bugfix: the flow for trial and accept invitation (org + staff)is now unified. all navigate to dshboard if succesfull. --- .cursor/rules/dyolink-overview.mdc | 2 +- .cursor/rules/lab-case-share-link.mdc | 2 +- .cursor/rules/post-auth-navigation.mdc | 16 +++++++++++++ .cursor/skills/lab-case-share-link/SKILL.md | 11 +++++---- AGENTS.md | 2 +- backend/prisma/wipe-app-data.ts | 4 ++++ frontend/messages/en.json | 5 ++-- frontend/messages/fa.json | 5 ++-- frontend/messages/nl.json | 5 ++-- .../[locale]/(public)/accept-invite/page.tsx | 24 +++++++++++++++---- .../accept-organization-invite/page.tsx | 20 +++++++++++++--- .../src/app/[locale]/(public)/login/page.tsx | 17 ++++--------- .../app/[locale]/(public)/register/page.tsx | 12 +++++++++- frontend/src/lib/auth/postAuthRedirect.ts | 17 +++++++++++++ frontend/src/lib/hooks/useAuth.tsx | 16 ++++++------- .../lib/hooks/useEnterAppWhenAuthenticated.ts | 23 ++++++++++++++++++ 16 files changed, 138 insertions(+), 43 deletions(-) create mode 100644 .cursor/rules/post-auth-navigation.mdc create mode 100644 frontend/src/lib/hooks/useEnterAppWhenAuthenticated.ts diff --git a/.cursor/rules/dyolink-overview.mdc b/.cursor/rules/dyolink-overview.mdc index d3d47c2..c484b78 100644 --- a/.cursor/rules/dyolink-overview.mdc +++ b/.cursor/rules/dyolink-overview.mdc @@ -36,7 +36,7 @@ CLINIC + LAB KPIs/charts in `modules/today/today.service.ts`. Deep links: `compo ## 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) diff --git a/.cursor/rules/lab-case-share-link.mdc b/.cursor/rules/lab-case-share-link.mdc index 918f56c..95a5247 100644 --- a/.cursor/rules/lab-case-share-link.mdc +++ b/.cursor/rules/lab-case-share-link.mdc @@ -12,7 +12,7 @@ alwaysApply: false - **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`. - **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 `` for `next build`. Skill: `.cursor/skills/lab-case-share-link/SKILL.md` diff --git a/.cursor/rules/post-auth-navigation.mdc b/.cursor/rules/post-auth-navigation.mdc new file mode 100644 index 0000000..8980e4e --- /dev/null +++ b/.cursor/rules/post-auth-navigation.mdc @@ -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`. diff --git a/.cursor/skills/lab-case-share-link/SKILL.md b/.cursor/skills/lab-case-share-link/SKILL.md index fe237c2..844dfc5 100644 --- a/.cursor/skills/lab-case-share-link/SKILL.md +++ b/.cursor/skills/lab-case-share-link/SKILL.md @@ -53,10 +53,13 @@ Task status updates use **`PATCH /tasks/:id`** (not token routes) — same assig Helpers: `lib/auth/postAuthRedirect.ts` (`sessionStorage` key `authRedirect`). 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)`. -3. After login + org ready: **one** `consumeAuthRedirect()` on login page (wait for `!isLoading` and org selected). -4. **Do not** `consumeAuthRedirect()` inside `useAuth.login()` — double consume sends user to `/today`. -5. Multi-org: redirect stays in storage until `selectOrganization()` consumes it. +2. Login page `useSearchParams` (inside **Suspense**) calls `storeAuthRedirectFromPath(from)` **before** `useEnterAppWhenAuthenticated`. +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()` or `registerTrial` — double consume sends user to `/today`. +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 diff --git a/AGENTS.md b/AGENTS.md index f84d92d..0b93f86 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -68,7 +68,7 @@ frontend/src/ - Token on first ship → `/{locale}/lab-case/{token}` after login. - **Lab:** view/edit tasks (assignee rules), comments + visibility toggle. - **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`. diff --git a/backend/prisma/wipe-app-data.ts b/backend/prisma/wipe-app-data.ts index e3680b5..8cc2166 100644 --- a/backend/prisma/wipe-app-data.ts +++ b/backend/prisma/wipe-app-data.ts @@ -41,6 +41,9 @@ const TABLES_IN_ORDER = [ 'staff_invitations', 'membership_permissions', 'sessions', + 'user_notifications', + 'lab_case_user_read_states', + 'lab_case_user_tab_read_states', 'lab_case_task_status_events', 'lab_case_comments', 'lab_case_attachments', @@ -49,6 +52,7 @@ const TABLES_IN_ORDER = [ 'lab_case_tooth_prosthesis', 'lab_case_details', 'lab_cases', + 'lab_case_activities', 'treatment_detail_attachments', 'treatment_details', 'treatments', diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 7370ecd..26ab69e 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -119,7 +119,8 @@ "labelCreatePassword": "Create password", "labelConfirmPassword": "Confirm password", "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", "alreadyHaveAccess": "Already have access?", "goToLogin": "Go to login", @@ -128,7 +129,7 @@ "invitedBy": "Invited by:", "ownerEmail": "Owner email", "activateOrganization": "Activate organization", - "organizationAcceptedRedirect": "Invitation accepted. Redirecting to login...", + "organizationAcceptedRedirect": "Invitation accepted. Opening your workspace...", "stepAccount": "Account", "stepOrganization": "Organization", "organizationName": "Organization name", diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index 0de9a92..c4cb5fa 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -119,7 +119,8 @@ "labelCreatePassword": "ایجاد رمز عبور", "labelConfirmPassword": "تأیید رمز عبور", "activateAccount": "فعال‌سازی حساب", - "invitationAcceptedRedirect": "دعوتنامه پذیرفته شد. در حال انتقال به صفحه ورود...", + "invitationAcceptedRedirect": "دعوتنامه پذیرفته شد. در حال ورود به فضای کاری...", + "invitationAcceptedSignInFailed": "حساب فعال شد، اما ورود انجام نشد. لطفاً با رمز عبور خود وارد شوید.", "errorAcceptInvitation": "پذیرش دعوتنامه امکان‌پذیر نبود", "alreadyHaveAccess": "از قبل دسترسی دارید؟", "goToLogin": "رفتن به ورود", @@ -128,7 +129,7 @@ "invitedBy": "دعوت‌کننده:", "ownerEmail": "ایمیل مالک", "activateOrganization": "فعال‌سازی سازمان", - "organizationAcceptedRedirect": "دعوتنامه پذیرفته شد. در حال انتقال به صفحه ورود...", + "organizationAcceptedRedirect": "دعوتنامه پذیرفته شد. در حال ورود به فضای کاری...", "stepAccount": "حساب", "stepOrganization": "سازمان", "organizationName": "نام سازمان", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index a7ee63f..d58539e 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -119,7 +119,8 @@ "labelCreatePassword": "Wachtwoord aanmaken", "labelConfirmPassword": "Bevestig wachtwoord", "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", "alreadyHaveAccess": "Heeft u al toegang?", "goToLogin": "Ga naar inloggen", @@ -128,7 +129,7 @@ "invitedBy": "Uitgenodigd door:", "ownerEmail": "E-mail eigenaar", "activateOrganization": "Organisatie activeren", - "organizationAcceptedRedirect": "Uitnodiging geaccepteerd. Doorsturen naar inloggen...", + "organizationAcceptedRedirect": "Uitnodiging geaccepteerd. Uw werkruimte wordt geopend...", "stepAccount": "Account", "stepOrganization": "Organisatie", "organizationName": "Organisatienaam", diff --git a/frontend/src/app/[locale]/(public)/accept-invite/page.tsx b/frontend/src/app/[locale]/(public)/accept-invite/page.tsx index 6ad568e..cf86a72 100644 --- a/frontend/src/app/[locale]/(public)/accept-invite/page.tsx +++ b/frontend/src/app/[locale]/(public)/accept-invite/page.tsx @@ -9,12 +9,15 @@ import { Button } from '@/components/ui/shared/Button'; import { Input } from '@/components/ui/shared/Input'; import { getUserFacingError } from '@/components/shared/formatApiError'; import { staffApi } from '@/lib/api/staff'; +import { useAuth } from '@/lib/hooks/useAuth'; +import { navigateIntoAppIfOrgSelected } from '@/lib/auth/postAuthRedirect'; function AcceptInviteContent() { const t = useTranslations('auth'); const tErrors = useTranslations('errors'); const params = useSearchParams(); const router = useRouter(); + const { login } = useAuth(); const token = useMemo(() => params.get('token') || '', [params]); const [loading, setLoading] = useState(true); @@ -76,18 +79,29 @@ function AcceptInviteContent() { } setSubmitting(true); + let accepted = false; try { await staffApi.acceptInvite({ token, name: name.trim(), password, }); - setSuccess(t('invitationAcceptedRedirect')); - setTimeout(() => { - router.replace('/login'); - }, 1000); + accepted = true; + const email = inviteInfo?.email?.trim(); + if (!email) { + throw new Error('missing-invite-email'); + } + const { organizations } = await login(email, password); + navigateIntoAppIfOrgSelected((href) => router.replace(href), organizations.length); } 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 { setSubmitting(false); } diff --git a/frontend/src/app/[locale]/(public)/accept-organization-invite/page.tsx b/frontend/src/app/[locale]/(public)/accept-organization-invite/page.tsx index 213af59..7745e07 100644 --- a/frontend/src/app/[locale]/(public)/accept-organization-invite/page.tsx +++ b/frontend/src/app/[locale]/(public)/accept-organization-invite/page.tsx @@ -16,6 +16,8 @@ import { OrganizationDetailsFields } from '@/components/ui/auth/OrganizationDeta import { RegistrationProgressSteps } from '@/components/ui/auth/RegistrationProgressSteps'; import { getUserFacingError } from '@/components/shared/formatApiError'; import { organizationApi } from '@/lib/api/organization'; +import { useAuth } from '@/lib/hooks/useAuth'; +import { navigateIntoAppIfOrgSelected } from '@/lib/auth/postAuthRedirect'; type AcceptOrganizationInviteForm = { ownerName: string; @@ -33,6 +35,7 @@ function AcceptOrganizationInviteContent() { const tValidation = useTranslations('validation'); const params = useSearchParams(); const router = useRouter(); + const { login } = useAuth(); const token = useMemo(() => params.get('token') || '', [params]); const acceptOrganizationInviteSchema = useMemo( @@ -137,6 +140,7 @@ function AcceptOrganizationInviteContent() { setError(''); setSuccess(''); setSubmitting(true); + let accepted = false; try { await organizationApi.acceptInvite({ token, @@ -146,10 +150,20 @@ function AcceptOrganizationInviteContent() { organizationEmail: data.organizationEmail.trim(), organizationType: data.organizationType, }); - setSuccess(t('organizationAcceptedRedirect')); - setTimeout(() => router.replace('/login'), 1000); + accepted = true; + 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) { - setError(getUserFacingError(e, tErrors, t('errorAcceptInvitation'))); + if (accepted) { + setSuccess(t('invitationAcceptedSignInFailed')); + setTimeout(() => router.replace('/login'), 1500); + } else { + setError(getUserFacingError(e, tErrors, t('errorAcceptInvitation'))); + } } finally { setSubmitting(false); } diff --git a/frontend/src/app/[locale]/(public)/login/page.tsx b/frontend/src/app/[locale]/(public)/login/page.tsx index 1cd8f93..35a6cc1 100644 --- a/frontend/src/app/[locale]/(public)/login/page.tsx +++ b/frontend/src/app/[locale]/(public)/login/page.tsx @@ -2,7 +2,6 @@ import { Suspense, useState, useEffect, useMemo } from 'react'; import { useSearchParams } from 'next/navigation'; -import { useRouter } from '@/i18n/navigation'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/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 { Checkbox } from '@/components/ui/shared/Checkbox'; import { Input } from '@/components/ui/shared/Input'; -import { - consumeAuthRedirect, - storeAuthRedirectFromPath, -} from '@/lib/auth/postAuthRedirect'; +import { storeAuthRedirectFromPath } from '@/lib/auth/postAuthRedirect'; +import { useEnterAppWhenAuthenticated } from '@/lib/hooks/useEnterAppWhenAuthenticated'; type LoginForm = { email: string; @@ -32,8 +29,7 @@ function LoginPageContent() { const tCommon = useTranslations('common'); const tValidation = useTranslations('validation'); const tErrors = useTranslations('errors'); - const { login, isLoading, user, isAuthReady, organizations, currentOrganization } = useAuth(); - const router = useRouter(); + const { login, isLoading, isAuthReady } = useAuth(); const searchParams = useSearchParams(); const [error, setError] = useState(null); const [savedEmail] = useState(() => getRememberedEmail()); @@ -55,12 +51,7 @@ function LoginPageContent() { } }, [searchParams]); - useEffect(() => { - if (!isAuthReady || !user || isLoading) return; - const orgReady = organizations.length <= 1 || currentOrganization; - if (!orgReady) return; - router.push(consumeAuthRedirect() ?? '/today'); - }, [isAuthReady, user, isLoading, organizations, currentOrganization, router]); + useEnterAppWhenAuthenticated(); const { register, diff --git a/frontend/src/app/[locale]/(public)/register/page.tsx b/frontend/src/app/[locale]/(public)/register/page.tsx index 36ec6ae..6d17c13 100644 --- a/frontend/src/app/[locale]/(public)/register/page.tsx +++ b/frontend/src/app/[locale]/(public)/register/page.tsx @@ -9,6 +9,7 @@ import { Link } from '@/i18n/navigation'; import { Mail, Lock, User, Phone } from 'lucide-react'; import { getUserFacingError } from '@/components/shared/formatApiError'; import { useAuth } from '@/lib/hooks/useAuth'; +import { useEnterAppWhenAuthenticated } from '@/lib/hooks/useEnterAppWhenAuthenticated'; import { AuthPageShell } from '@/components/ui/auth/AuthPageShell'; import { OrganizationDetailsFields } from '@/components/ui/auth/OrganizationDetailsFields'; import { RegistrationProgressSteps } from '@/components/ui/auth/RegistrationProgressSteps'; @@ -38,9 +39,10 @@ export default function RegisterPage() { const tCommon = useTranslations('common'); const tValidation = useTranslations('validation'); const tErrors = useTranslations('errors'); - const { registerTrial, isLoading } = useAuth(); + const { registerTrial, isLoading, isAuthReady } = useAuth(); const [step, setStep] = useState(1); const [error, setError] = useState(null); + useEnterAppWhenAuthenticated(); const registerSchema = useMemo( () => @@ -121,6 +123,14 @@ export default function RegisterPage() { hide: t('hidePassword'), }; + if (!isAuthReady) { + return ( +
+

{tCommon('loading')}

+
+ ); + } + return ( void, + organizationCount: number, +) { + if (organizationCount > 1) return; + replace(appPathAfterAuth()); +} diff --git a/frontend/src/lib/hooks/useAuth.tsx b/frontend/src/lib/hooks/useAuth.tsx index a014829..d29cd61 100644 --- a/frontend/src/lib/hooks/useAuth.tsx +++ b/frontend/src/lib/hooks/useAuth.tsx @@ -18,7 +18,7 @@ import { } from '@/lib/auth/proactiveRefresh'; import { notifyAccessTokenRefreshed } from '@/lib/auth/accessTokenEvents'; 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 { return ( @@ -50,7 +50,11 @@ interface AuthContextType { organizationEmail: string, organizationType: 'CLINIC' | 'LAB' ) => Promise; - login: (email: string, password: string, rememberMe?: boolean) => Promise; + login: ( + email: string, + password: string, + rememberMe?: boolean, + ) => Promise<{ organizations: Organization[] }>; logout: () => Promise; selectOrganization: (orgId: string) => Promise; createOrganization: ( @@ -337,6 +341,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { router.push('/select-organization'); } + return { organizations: orgs }; } catch (err: any) { setApiError(toApiError(err)); throw err; @@ -388,12 +393,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { plan: (organization as { plan?: Organization['plan'] }).plan, }); - const redirectPath = consumeAuthRedirect(); - if (redirectPath) { - router.push(redirectPath); - } else { - router.push('/today'); - } + router.push(appPathAfterAuth()); } catch (err: any) { setApiError(toApiError(err)); diff --git a/frontend/src/lib/hooks/useEnterAppWhenAuthenticated.ts b/frontend/src/lib/hooks/useEnterAppWhenAuthenticated.ts new file mode 100644 index 0000000..3377318 --- /dev/null +++ b/frontend/src/lib/hooks/useEnterAppWhenAuthenticated.ts @@ -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]); +}