diff --git a/.cursor/rules/appointments.mdc b/.cursor/rules/appointments.mdc
index 96fecf1..133bf7a 100644
--- a/.cursor/rules/appointments.mdc
+++ b/.cursor/rules/appointments.mdc
@@ -7,6 +7,7 @@ alwaysApply: false
# Appointments
- List includes `hasTreatment` when a `Treatment` row is linked (`appointmentId`).
+- **Patient:** create/update must use a **named** patient of this org (`createdByOrganizationId`, not walk-in). Helper `ensurePatientInOrg`. Do not book another clinic’s patient UUID.
- **Patient change** blocked while linked → `APPOINTMENT_PATIENT_LOCKED` (UI: `patientLockedHint`).
- **Delete** blocked while linked → `APPOINTMENT_HAS_TREATMENT` (hide delete + `deleteBlockedHint`). Empty appointments (no treatment yet) remain deletable.
- **Past days:** new bookings stay blocked. Existing appointments **without** treatment can be edited/deleted; with treatment → toast `infoEditBlockedHasTreatment` (no modal). Banner clicks are not gated by `canBook` (slots still are).
diff --git a/.cursor/rules/dyolink-overview.mdc b/.cursor/rules/dyolink-overview.mdc
index 8bfbad4..a573b36 100644
--- a/.cursor/rules/dyolink-overview.mdc
+++ b/.cursor/rules/dyolink-overview.mdc
@@ -11,6 +11,7 @@ Monorepo: `backend/` (NestJS + Prisma), `frontend/` (Next.js + next-intl), `infr
- **CLINIC** orgs: patients, appointments, treatment, staff.
- **LAB** orgs: cases, tasks, lab workflows.
+- Named **patients** are scoped to `createdByOrganizationId`. `mobile` stays globally unique — other-org / walk-in hit `PATIENT_MOBILE_UNAVAILABLE` (no shared row).
- Tab access: `TAB_*_READ` / `TAB_*_EDIT` in `backend/src/common/permissions.ts`. EDIT implies READ.
## Agent behavior
diff --git a/.cursor/rules/frontend-assets.mdc b/.cursor/rules/frontend-assets.mdc
index 2596c52..fb08521 100644
--- a/.cursor/rules/frontend-assets.mdc
+++ b/.cursor/rules/frontend-assets.mdc
@@ -1,6 +1,6 @@
---
description: Brand and FDI SVG sources live under frontend/src/assets, not public/
-globs: frontend/src/assets/**,frontend/src/components/ui/shared/Brand*.tsx,frontend/public/**,frontend/scripts/**
+globs: frontend/src/assets/**,frontend/src/components/ui/shared/Brand*.tsx,frontend/public/**,frontend/scripts/**,frontend/Dockerfile
alwaysApply: false
---
@@ -11,3 +11,4 @@ alwaysApply: false
- **FDI tooth sources:** `frontend/src/assets/fdi/`. The chart uses inlined paths in `realisticToothAssets.ts`; regenerate with `frontend/scripts/extract-tooth-svgs.mjs`.
- **Prosthesis catalog illustrations:** source `frontend/src/assets/prosthesis-catalog/*.svg` (painted navy, not `currentColor`). Serve the same filenames from `frontend/public/prosthesis-catalog/` as `` — Next cannot import these as URLs under Turbopack 16.1, and denture/veneer are too large to inline. Map codes in `prosthesisCatalogIcons.ts`. Copy into `public/` when adding a file.
- **`public/`** is only for files that must be fetched by URL (e.g. og images, prosthesis catalog icons). Do not put themeable brand/FDI SVGs there — `` cannot inherit `currentColor`.
+- **Docker:** Next `output: 'standalone'` does **not** include `public/`. `frontend/Dockerfile` must `COPY` builder `/app/public` to `./public` next to `server.js` (after the standalone copy). Missing this 404s `/prosthesis-catalog/*.svg` in staging/prod.
diff --git a/.cursor/rules/lab-case-share-link.mdc b/.cursor/rules/lab-case-share-link.mdc
index 18a3a8d..e66ad8b 100644
--- a/.cursor/rules/lab-case-share-link.mdc
+++ b/.cursor/rules/lab-case-share-link.mdc
@@ -13,7 +13,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 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).
+- **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 (join + `password_setup`): `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/patients.mdc b/.cursor/rules/patients.mdc
new file mode 100644
index 0000000..8bc239c
--- /dev/null
+++ b/.cursor/rules/patients.mdc
@@ -0,0 +1,12 @@
+---
+description: Clinic patients — org-scoped named records, globally unique mobile
+globs: backend/src/modules/patients/**,backend/src/modules/appointments/appointments.service.ts,backend/src/modules/treatments/treatments.service.ts,frontend/src/components/ui/patient/**,frontend/src/lib/api/patients.ts
+alwaysApply: false
+---
+
+# Patients
+
+- List / get / update / create: **this org** + `isWalkIn: false` (`createdByOrganizationId`).
+- `Patient.mobile` stays **globally unique**. Same-org named create returns `{ existing: true }`. Other org, walk-in, or null creator → `PATIENT_MOBILE_UNAVAILABLE` (409). Do **not** return or mention the other clinic’s row.
+- Appointment create/update and `POST /treatments` named `patientId`: `ensurePatientInOrg` (named + this org). Walk-in sentinel is per clinic (`walk-in-patient.ts`), hidden from Patients/search/booking.
+- History / lab-case lists still query treatments for **this** `organizationId` even if the patient UUID is guessed.
diff --git a/.cursor/rules/post-auth-navigation.mdc b/.cursor/rules/post-auth-navigation.mdc
index 8980e4e..4c0e8bc 100644
--- a/.cursor/rules/post-auth-navigation.mdc
+++ b/.cursor/rules/post-auth-navigation.mdc
@@ -10,7 +10,7 @@ alwaysApply: false
- **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).
+- **Staff / org invite:** accept → `login(email, password)` → `navigateIntoAppIfOrgSelected`. Same for `/accept-invite` `mode: password_setup` (password fields only). ❌ Do not put the hook on invite pages (logged-in visitors must finish accept). Login does not special-case `passwordHash: null` — those users cannot sign in until they set a password via the setup link.
- **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/rules/staff.mdc b/.cursor/rules/staff.mdc
new file mode 100644
index 0000000..aad1d77
--- /dev/null
+++ b/.cursor/rules/staff.mdc
@@ -0,0 +1,13 @@
+---
+description: Staff passwords — setup link only; never set another user’s password
+globs: backend/src/modules/staff/**,frontend/src/components/ui/staff/**,frontend/src/app/**/accept-invite/**,frontend/src/lib/api/staff.ts
+alwaysApply: false
+---
+
+# Staff passwords
+
+- Owner / `TAB_STAFF_EDIT` may **clear** a password, never set one for someone else.
+- `POST /staff/members/:membershipId/clear-password`: `passwordHash: null`, delete sessions, revoke unused invites on that membership, mint a 7-day `/accept-invite` URL. Refuse owner, self, pending, disabled (`STAFF_CANNOT_CLEAR_OWN_PASSWORD`, `STAFF_PASSWORD_CLEAR_ACTIVE_ONLY`).
+- List DTO: `hasPassword` boolean only (never the hash). `previewInvite` `mode`: `join` | `password_setup` from `membership.isActive`.
+- Login page unchanged — null hash is invalid credentials until they set a password on the setup link.
+- `/accept-invite` `password_setup`: password fields only; then `login()` + `navigateIntoAppIfOrgSelected` (no enter-app hook).
diff --git a/.cursor/skills/lab-case-share-link/SKILL.md b/.cursor/skills/lab-case-share-link/SKILL.md
index 268f8f8..4514f2d 100644
--- a/.cursor/skills/lab-case-share-link/SKILL.md
+++ b/.cursor/skills/lab-case-share-link/SKILL.md
@@ -56,7 +56,7 @@ 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)` **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).
+3. After login/register + org ready: **one** consume via `appPathAfterAuth()` in that hook. Staff/org invite (join + `password_setup`): `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.
diff --git a/.cursor/skills/treatment-workspace/SKILL.md b/.cursor/skills/treatment-workspace/SKILL.md
index d02602b..1ee0e48 100644
--- a/.cursor/skills/treatment-workspace/SKILL.md
+++ b/.cursor/skills/treatment-workspace/SKILL.md
@@ -43,7 +43,7 @@ Right-column entry is **not** a three-step wizard. Type dropdown + `TreatmentDet
| **Treatment** | Type dropdown + `TreatmentDetailAttachmentsStrip`, `FdiToothChart` / `ProsthesisAssignChart`, full-width Notes | Default |
| **Lab** | `LabCasesDispatchPanel` in the chart slot | Lab-dependent type + user clicks **Lab dispatch** (or rail / Go to dispatch). Auto-ensures a shipment draft (teeth/arch not required to create the draft). **No default lab or prosthesis type** on a new detail (including siblings in the same plan). Last **3 sent** labs appear as chips under search — pick is explicit. Comments stay on the dispatch panel. After send, the same case QR as lab Cases is shown (`shareUrl`): dest/jobs share a row with the thumb; tracker + comments are full width below. |
-- Prosthesis types are assigned on the chart (`ProsthesisAssignChart` + `ProsthesisJobPopover` in `prosthesisTree.ts`). The picker is two columns (`4fr` wrapping category grid / `1fr` add-ons) with a vertical `border-e` divider. Category and subcategory tiles (and matching leaves) show SVGs from `src/assets/prosthesis-catalog` (served from `public/prosthesis-catalog`) via `prosthesisCatalogIcons.ts`. Category tiles keep the wrapping `minmax(8rem, 1fr)` grid and stretch to fill the overlay; expanded children use `minmax(10.2rem, 1fr)` with a parent-colored **L** rail sized to the first child card (not a per-card tree). Parent-bar back arrow is black. Child labels stay one line and ellipsize (`…`) when they overflow. **Add detail**, Lab dispatch, and Chart share one control width (`WorkspaceActionLabel` in `TreatmentDetailsEditor`). Indirect children are **Veneer → Inlay → Onlay → Overlay** (same order as the category title). Crown leaves without a dedicated SVG use the monolithic zirconia drawing, not the Crown parent icon. Empty crown suggestion: plus + dashed chip (`addonCrownCanBeAdded`) inside a full-height slot; after a crown is picked the slot stays as the filled type chip. Arch Upper/Lower/Both is a compact `h-8` segmented control at half the tree column width — it **is** the assignment (`retargetArchJobs`): Both→Upper/Lower drops the other jaw; Upper/Lower→Both copies the type onto the empty jaw; Upper↔Lower moves the job. Chart Upper/Lower arch labels are dashed outline buttons with plus (open the picker; control shows current jobs, or the clicked jaw if none). Category parents use the heaviest family pastel; children only lighten. One **restoration** per tooth (crown / veneer-inlay-onlay-overlay). **Screw-retained** is implant (`stackGroup: implant`, paints the crown) and is itself the restoration — no crown suggestion slot, and Crown / Indirect are disabled. Implant or post & core (without a non-crown restoration) shows a **crown** suggestion slot. A veneer/inlay/onlay/overlay hides the suggestion slot. **Post & core** category is visible but disabled when a restoration or implant is on the tooth. **Implant** category is disabled when post & core is on the tooth. Complete denture / overdenture / appliances / digital use **Upper arch / Lower arch** (`UA`/`LA`). **Partial denture** is tooth-level (select FDI teeth, Removable in the tooth picker); after send it is **one lab job** for all those teeth. Picker leaves are filtered by `chartRegion` so Removable appears in both tooth and arch pickers. Prosthesis FDI teeth **must** have jobs — never persist selected teeth without `toothProsthesis` (`pruneDetailTeethToJobs`). Catalog has no `addonKind` — stacking uses `stackGroup` plus the crown suggestion slot.
+- Prosthesis types are assigned on the chart (`ProsthesisAssignChart` + `ProsthesisJobPopover` in `prosthesisTree.ts`). The picker is two columns (`4fr` wrapping category grid / `1fr` add-ons) with a vertical `border-e` divider. Category and subcategory tiles (and matching leaves) show SVGs from `src/assets/prosthesis-catalog` (served from `public/prosthesis-catalog`) via `prosthesisCatalogIcons.ts`. Production Docker must copy `public/` into the standalone image (see `.cursor/rules/frontend-assets.mdc`). Category tiles keep the wrapping `minmax(8rem, 1fr)` grid and stretch to fill the overlay; expanded children use `minmax(10.2rem, 1fr)` with a parent-colored **L** rail sized to the first child card (not a per-card tree). Parent-bar back arrow is black. Child labels stay one line and ellipsize (`…`) when they overflow. **Add detail**, Lab dispatch, and Chart share one control width (`WorkspaceActionLabel` in `TreatmentDetailsEditor`). Indirect children are **Veneer → Inlay → Onlay → Overlay** (same order as the category title). Crown leaves without a dedicated SVG use the monolithic zirconia drawing, not the Crown parent icon. Empty crown suggestion: plus + dashed chip (`addonCrownCanBeAdded`) inside a full-height slot; after a crown is picked the slot stays as the filled type chip. Arch Upper/Lower/Both is a compact `h-8` segmented control at half the tree column width — it **is** the assignment (`retargetArchJobs`): Both→Upper/Lower drops the other jaw; Upper/Lower→Both copies the type onto the empty jaw; Upper↔Lower moves the job. Chart Upper/Lower arch labels are dashed outline buttons with plus (open the picker; control shows current jobs, or the clicked jaw if none). Category parents use the heaviest family pastel; children only lighten. One **restoration** per tooth (crown / veneer-inlay-onlay-overlay). **Screw-retained** is implant (`stackGroup: implant`, paints the crown) and is itself the restoration — no crown suggestion slot, and Crown / Indirect are disabled. Implant or post & core (without a non-crown restoration) shows a **crown** suggestion slot. A veneer/inlay/onlay/overlay hides the suggestion slot. **Post & core** category is visible but disabled when a restoration or implant is on the tooth. **Implant** category is disabled when post & core is on the tooth. Complete denture / overdenture / appliances / digital use **Upper arch / Lower arch** (`UA`/`LA`). **Partial denture** is tooth-level (select FDI teeth, Removable in the tooth picker); after send it is **one lab job** for all those teeth. Picker leaves are filtered by `chartRegion` so Removable appears in both tooth and arch pickers. Prosthesis FDI teeth **must** have jobs — never persist selected teeth without `toothProsthesis` (`pruneDetailTeethToJobs`). Catalog has no `addonKind` — stacking uses `stackGroup` plus the crown suggestion slot.
- Detail chips show **type + teeth**, not “Detail N”. Lab-dependent chips use colored sent/unsent text (same size as the label); sent date stays on Lab dispatch.
- Detail type may differ from appointment purpose. Purpose seeds the first line of an empty **appointment** draft (first open, and **Add detail** when the plan is `[]`). Later **Add detail** starts with an empty type. Unscheduled / New treatment still seeds a blank first line.
- Lab shipments rail / “Go to dispatch” / load-with-focus **opens the dispatch view** in the chart slot (`pendingScrollToLabRef` + `labPanelRef`).
@@ -188,7 +188,7 @@ Use shared `Checkbox` (not native ``) to avoid focus-driv
| `GET /treatments/day?from&to` | Standalone (unscheduled) strip cards |
-| `POST /treatments` | Create standalone `{ patientId?, walkIn?, treatmentAt }` |
+| `POST /treatments` | Create standalone `{ patientId?, walkIn?, treatmentAt }`. Named `patientId` must be this org (`ensurePatientInOrg`). |
| `DELETE /treatments/:id` | Empty standalone only (`appointmentId` null, no detail rows). UI may `PUT` `{ details: [] }` first when the strip looks blank but autosave has not finished. |
@@ -202,7 +202,7 @@ Use shared `Checkbox` (not native ``) to avoid focus-driv
-Walk-in uses one sentinel `Patient` per clinic (`isWalkIn`, hidden from Patients/search/booking). Display via i18n, never the stored name. Patient search: same workspace patient with a live visit → no-op; else open today’s strip visit if any; else load latest history into the editor; **no history and no strip visit → do not auto-create**. Detach the previous visit, keep the searched patient, and show an inline editor empty state (`noTreatmentFoundTitle` / `noTreatmentFoundBody`) that points to **New treatment** in the rail (Walk-in, current named patient card, or search).
+Walk-in uses one sentinel `Patient` per clinic (`isWalkIn`, hidden from Patients/search/booking). Display via i18n, never the stored name. Named patients are this-org only (`createdByOrganizationId`); another clinic’s mobile is `PATIENT_MOBILE_UNAVAILABLE`, not a shared row. Patient search: same workspace patient with a live visit → no-op; else open today’s strip visit if any; else load latest history into the editor; **no history and no strip visit → do not auto-create**. Detach the previous visit, keep the searched patient, and show an inline editor empty state (`noTreatmentFoundTitle` / `noTreatmentFoundBody`) that points to **New treatment** in the rail (Walk-in, current named patient card, or search).
Draft writes for appointments require provider match (`ensureAppointmentProvider`). Standalone requires `treatment.providerUserId === actor`.
diff --git a/AGENTS.md b/AGENTS.md
index 950ec5d..31e1cb6 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -62,7 +62,11 @@ frontend/src/
- **Live lab rail**: `notification.created` → `notifyTabBadgesChanged()` silently refreshes patient lab cases + unread rail (does **not** clear draft/form state).
- **Lab shipment progress + comments**: shown in **Lab dispatch panel** for the active shipment; expanding activity / opening comments marks that case read. Shared UI: `LabCaseCommentsPanel` — newest first; sent = start / received = end (`text-start`/`justify-start`, RTL-safe); pass `viewerSide`.
-**Appointments (quick ref):** Do not delete (or change patient) when `hasTreatment`; codes `APPOINTMENT_HAS_TREATMENT` / `APPOINTMENT_PATIENT_LOCKED`. Past days: no new bookings; edit/delete OK without treatment; with treatment → toast. Appointment delete does not cascade-delete treatments. Working hours: client IANA `timeZone` on create/update — never `Date#getHours()`/`getDay()` on the UTC server. Logical API errors: `AppException` + `errors.*` (never Nest English throws). See `.cursor/rules/appointments.mdc`, `.cursor/skills/api-errors/SKILL.md`.
+**Appointments (quick ref):** Do not delete (or change patient) when `hasTreatment`; codes `APPOINTMENT_HAS_TREATMENT` / `APPOINTMENT_PATIENT_LOCKED`. Past days: no new bookings; edit/delete OK without treatment; with treatment → toast. Appointment delete does not cascade-delete treatments. Working hours: client IANA `timeZone` on create/update — never `Date#getHours()`/`getDay()` on the UTC server. Logical API errors: `AppException` + `errors.*` (never Nest English throws). Patient must belong to this org (`ensurePatientInOrg`). See `.cursor/rules/appointments.mdc`, `.cursor/skills/api-errors/SKILL.md`.
+
+**Patients (quick ref):** List/get/update/create are this-org named patients (`createdByOrganizationId`, `isWalkIn: false`). Mobile stays globally unique. Same-org mobile create returns `existing: true`; other org / walk-in / null creator → `PATIENT_MOBILE_UNAVAILABLE` (409, no leak). See `.cursor/rules/patients.mdc`.
+
+**Staff (quick ref):** Owner / `TAB_STAFF_EDIT` can **remove** a password (`POST /staff/members/:id/clear-password`) and copy a setup link — never set one for someone else. Login page unchanged (`passwordHash: null` cannot sign in). `/accept-invite` `password_setup` is password-only. See `.cursor/rules/staff.mdc`.
**Lab Tasks tab:** Newest case first; steps ordered 1→N; case grouping when sorted by date; `stepCompleted` filter; filter by case source (`origin`: received vs generated); prosthesis colors from catalog; job titles show the picker path to the leaf (`prosthesisJobPath.ts`); task assignment in **Cases** (compact row: status + assignee + last update); on **Tasks**, all staff see every task but only assignee (or unassigned pool) can change status — others see “Assigned to {name}” instead of the status dropdown; **case due dates** set/edited in clinic Treatment lab dispatch, shown on lab Cases/Tasks with overdue filter + sort; completing **`intraoral_scan`** completes every scan task in that case (case-scoped; catalog first step for all prosthesis types); **mobile:** larger task status controls, sticky case header when grouped; **tab badges:** `LabCaseActivity` + `GET /notifications/tab-counts` (lab Cases/Tasks split, clinic Treatment) — live via inbox Socket.IO → `notifyTabBadgesChanged()` + soft list refresh — see `.cursor/skills/lab-tasks/SKILL.md`, `.cursor/skills/tab-badges/SKILL.md`, `.cursor/skills/notifications-inbox/SKILL.md`.
@@ -72,7 +76,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** with `TAB_TREATMENT_EDIT` — read-only tasks, can comment. Same QR as lab Cases appears on Treatment **Lab dispatch** after send (dest/jobs beside the thumb; tracker + comments full width below).
-- 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`.
+- 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 (including `password_setup`) 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/CLAUDE.md b/CLAUDE.md
index 31c68dc..4584416 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -107,4 +107,4 @@ Jest covers pure logic only — permission normalization, phone/timezone helpers
## Deployment
-Images are built on a dev machine and pulled by the server; Compose files and scripts are in `infrastructure/` (`docker-compose.{prod,staging,registry}.yml`). Full guide: `infrastructure/DEPLOY.md`. Root `README.md` covers the Docker Hub + Let's Encrypt path and the Gitea registry path. Frontend `NEXT_PUBLIC_*` are **build args** — changing the public domain requires rebuilding the frontend image.
+Images are built on a dev machine and pulled by the server; Compose files and scripts are in `infrastructure/` (`docker-compose.{prod,staging,registry}.yml`). Full guide: `infrastructure/DEPLOY.md`. Root `README.md` covers the Docker Hub + Let's Encrypt path and the Gitea registry path. Frontend `NEXT_PUBLIC_*` are **build args** — changing the public domain requires rebuilding the frontend image. Next `output: 'standalone'` does **not** include `public/`; `frontend/Dockerfile` copies `/app/public` next to `server.js` (catalog icons at `/prosthesis-catalog/*.svg`). Production tags are immutable — CI clones `--branch $tag`; cut a new `v*` instead of moving an existing tag.
diff --git a/README.md b/README.md
index a5c49a7..265c8f9 100644
--- a/README.md
+++ b/README.md
@@ -33,7 +33,7 @@ Workflow: [`.gitea/workflows/registry-build-deploy.yml`](.gitea/workflows/regist
| Path | Role |
|------|------|
| `backend/Dockerfile` | API image |
-| `frontend/Dockerfile` | Web image |
+| `frontend/Dockerfile` | Web image (`standalone` + copy `public/` for catalog icons) |
| `infrastructure/STAGING-DEPLOY.md` | Staging setup, CI variables, testing |
| `infrastructure/docker-compose.registry.yml` | Pull-only staging stack (registry images + nginx + postgres) |
| `infrastructure/deploy.registry.env.example` | Template for `deploy.registry.env` |
diff --git a/backend/src/common/errors/error-codes.ts b/backend/src/common/errors/error-codes.ts
index 79dfe99..42f4fe5 100644
--- a/backend/src/common/errors/error-codes.ts
+++ b/backend/src/common/errors/error-codes.ts
@@ -84,6 +84,7 @@ export const ErrorCode = {
APPOINTMENT_NOT_PROVIDER: 'APPOINTMENT_NOT_PROVIDER',
PATIENT_NOT_FOUND: 'PATIENT_NOT_FOUND',
+ PATIENT_MOBILE_UNAVAILABLE: 'PATIENT_MOBILE_UNAVAILABLE',
WORKING_HOURS_INVALID: 'WORKING_HOURS_INVALID',
WORKING_HOURS_OWNER_NOT_ALLOWED: 'WORKING_HOURS_OWNER_NOT_ALLOWED',
@@ -110,6 +111,8 @@ export const ErrorCode = {
STAFF_CANNOT_ENABLE_OWNER: 'STAFF_CANNOT_ENABLE_OWNER',
STAFF_CANNOT_DISABLE_OWNER: 'STAFF_CANNOT_DISABLE_OWNER',
STAFF_CANNOT_REMOVE_OWNER: 'STAFF_CANNOT_REMOVE_OWNER',
+ STAFF_CANNOT_CLEAR_OWN_PASSWORD: 'STAFF_CANNOT_CLEAR_OWN_PASSWORD',
+ STAFF_PASSWORD_CLEAR_ACTIVE_ONLY: 'STAFF_PASSWORD_CLEAR_ACTIVE_ONLY',
ORG_CANNOT_LINK_SELF: 'ORG_CANNOT_LINK_SELF',
ORG_LINK_WRONG_TYPE: 'ORG_LINK_WRONG_TYPE',
diff --git a/backend/src/modules/appointments/appointments.service.ts b/backend/src/modules/appointments/appointments.service.ts
index 2b760df..adc1c86 100644
--- a/backend/src/modules/appointments/appointments.service.ts
+++ b/backend/src/modules/appointments/appointments.service.ts
@@ -341,12 +341,16 @@ export class AppointmentsService {
}
}
- private async ensurePatientInOrg(patientId: string, _organizationId: string) {
- const patient = await this.prisma.patient.findUnique({
- where: { id: patientId },
- select: { id: true, isWalkIn: true },
+ private async ensurePatientInOrg(patientId: string, organizationId: string) {
+ const patient = await this.prisma.patient.findFirst({
+ where: {
+ id: patientId,
+ isWalkIn: false,
+ createdByOrganizationId: organizationId,
+ },
+ select: { id: true },
});
- if (!patient || patient.isWalkIn) {
+ if (!patient) {
throw new AppException(ErrorCode.PATIENT_NOT_FOUND, HttpStatus.NOT_FOUND);
}
}
diff --git a/backend/src/modules/patients/patients.controller.ts b/backend/src/modules/patients/patients.controller.ts
index b40b2f0..ff4007a 100644
--- a/backend/src/modules/patients/patients.controller.ts
+++ b/backend/src/modules/patients/patients.controller.ts
@@ -25,16 +25,17 @@ export class PatientsController {
constructor(private readonly patientsService: PatientsService) {}
@Post()
- @ApiOperation({ summary: 'Create or return existing global patient by mobile' })
+ @ApiOperation({ summary: 'Create or return this clinic’s patient by mobile' })
create(@Body() createPatientDto: CreatePatientDto, @Req() req) {
const organizationId = this.patientsService.getOrganizationIdFromUser(req.user);
return this.patientsService.create(createPatientDto, organizationId);
}
@Get()
- @ApiOperation({ summary: 'Search all patients globally' })
- findAll(@Query() query: ListPatientsDto) {
- return this.patientsService.findAll(query);
+ @ApiOperation({ summary: 'Search patients created by the current clinic' })
+ findAll(@Query() query: ListPatientsDto, @Req() req) {
+ const organizationId = this.patientsService.getOrganizationIdFromUser(req.user);
+ return this.patientsService.findAll(query, organizationId);
}
@Get(':id/appointments')
@@ -48,14 +49,20 @@ export class PatientsController {
}
@Get(':id')
- @ApiOperation({ summary: 'Get one patient by id' })
- findOne(@Param('id') id: string) {
- return this.patientsService.findOne(id);
+ @ApiOperation({ summary: 'Get one patient created by the current clinic' })
+ findOne(@Param('id') id: string, @Req() req) {
+ const organizationId = this.patientsService.getOrganizationIdFromUser(req.user);
+ return this.patientsService.findOne(id, organizationId);
}
@Patch(':id')
- @ApiOperation({ summary: 'Update global patient record' })
- update(@Param('id') id: string, @Body() updatePatientDto: UpdatePatientDto) {
- return this.patientsService.update(id, updatePatientDto);
+ @ApiOperation({ summary: 'Update a patient created by the current clinic' })
+ update(
+ @Param('id') id: string,
+ @Body() updatePatientDto: UpdatePatientDto,
+ @Req() req,
+ ) {
+ const organizationId = this.patientsService.getOrganizationIdFromUser(req.user);
+ return this.patientsService.update(id, updatePatientDto, organizationId);
}
}
diff --git a/backend/src/modules/patients/patients.service.ts b/backend/src/modules/patients/patients.service.ts
index 050da08..cdd78f5 100644
--- a/backend/src/modules/patients/patients.service.ts
+++ b/backend/src/modules/patients/patients.service.ts
@@ -21,7 +21,13 @@ export class PatientsService {
});
if (existing) {
- return { success: true, data: existing, existing: true as const };
+ if (
+ !existing.isWalkIn &&
+ existing.createdByOrganizationId === organizationId
+ ) {
+ return { success: true, data: existing, existing: true as const };
+ }
+ throw new AppException(ErrorCode.PATIENT_MOBILE_UNAVAILABLE, HttpStatus.CONFLICT);
}
const patient = await this.prisma.patient.create({
@@ -39,12 +45,13 @@ export class PatientsService {
return { success: true, data: patient, existing: false as const };
}
- async findAll(query: ListPatientsDto) {
+ async findAll(query: ListPatientsDto, organizationId: string) {
const { page = 1, limit = 10, q } = query;
const skip = (page - 1) * limit;
const where = {
isWalkIn: false,
+ createdByOrganizationId: organizationId,
...(q?.trim() ? this.buildSearchWhere(q.trim()) : {}),
};
@@ -72,27 +79,13 @@ export class PatientsService {
};
}
- async findOne(id: string) {
- const patient = await this.prisma.patient.findUnique({
- where: { id },
- });
-
- if (!patient || patient.isWalkIn) {
- throw new AppException(ErrorCode.PATIENT_NOT_FOUND, HttpStatus.NOT_FOUND);
- }
-
+ async findOne(id: string, organizationId: string) {
+ const patient = await this.findNamedPatientInOrg(id, organizationId);
return { success: true, data: patient };
}
- async update(id: string, updatePatientDto: UpdatePatientDto) {
- await this.ensurePatient(id);
- const patient = await this.prisma.patient.findUnique({
- where: { id },
- select: { isWalkIn: true },
- });
- if (patient?.isWalkIn) {
- throw new AppException(ErrorCode.PATIENT_NOT_FOUND, HttpStatus.NOT_FOUND);
- }
+ async update(id: string, updatePatientDto: UpdatePatientDto, organizationId: string) {
+ await this.findNamedPatientInOrg(id, organizationId);
const data: {
firstName?: string;
@@ -110,7 +103,15 @@ export class PatientsService {
data.lastName = this.requireNonEmptyName(updatePatientDto.lastName, 'lastName');
}
if (updatePatientDto.mobile !== undefined) {
- data.mobile = this.resolveMobile(updatePatientDto.mobile);
+ const mobile = this.resolveMobile(updatePatientDto.mobile);
+ const taken = await this.prisma.patient.findUnique({
+ where: { mobile },
+ select: { id: true, createdByOrganizationId: true, isWalkIn: true },
+ });
+ if (taken && taken.id !== id) {
+ throw new AppException(ErrorCode.PATIENT_MOBILE_UNAVAILABLE, HttpStatus.CONFLICT);
+ }
+ data.mobile = mobile;
}
if (updatePatientDto.email !== undefined) {
data.email = updatePatientDto.email?.trim() || null;
@@ -138,7 +139,7 @@ export class PatientsService {
actorUserId: string,
) {
await this.assertCanViewPatients(actorUserId, organizationId);
- await this.ensurePatient(patientId);
+ await this.findNamedPatientInOrg(patientId, organizationId);
const items = await this.prisma.appointment.findMany({
where: { organizationId, patientId },
@@ -241,14 +242,18 @@ export class PatientsService {
}
}
- private async ensurePatient(id: string) {
- const patient = await this.prisma.patient.findUnique({
- where: { id },
- select: { id: true },
+ private async findNamedPatientInOrg(id: string, organizationId: string) {
+ const patient = await this.prisma.patient.findFirst({
+ where: {
+ id,
+ isWalkIn: false,
+ createdByOrganizationId: organizationId,
+ },
});
if (!patient) {
throw new AppException(ErrorCode.PATIENT_NOT_FOUND, HttpStatus.NOT_FOUND);
}
+ return patient;
}
}
diff --git a/backend/src/modules/staff/staff.controller.ts b/backend/src/modules/staff/staff.controller.ts
index a6fc1a8..1f54804 100644
--- a/backend/src/modules/staff/staff.controller.ts
+++ b/backend/src/modules/staff/staff.controller.ts
@@ -61,6 +61,20 @@ export class StaffController {
return this.staffService.invite(req.user.id, organizationId, dto);
}
+ @Post('members/:membershipId/clear-password')
+ @UseGuards(JwtAuthGuard)
+ @ApiOperation({
+ summary:
+ 'Clear a staff member password and return a setup link (owner or TAB_STAFF_EDIT; active members only)',
+ })
+ clearPassword(
+ @Req() req: { user: { id: string; organizationId?: string } },
+ @Param('membershipId') membershipId: string,
+ ) {
+ const organizationId = this.staffService.getOrganizationIdFromUser(req.user);
+ return this.staffService.clearPassword(req.user.id, organizationId, membershipId);
+ }
+
@Post('members/:membershipId/invitation-link')
@UseGuards(JwtAuthGuard)
@ApiOperation({
diff --git a/backend/src/modules/staff/staff.service.ts b/backend/src/modules/staff/staff.service.ts
index 09e4399..aa9fc59 100644
--- a/backend/src/modules/staff/staff.service.ts
+++ b/backend/src/modules/staff/staff.service.ts
@@ -49,7 +49,7 @@ export class StaffService {
this.prisma.membership.findMany({
where: { organizationId },
include: {
- user: { select: { id: true, email: true, name: true } },
+ user: { select: { id: true, email: true, name: true, passwordHash: true } },
permissions: { include: { permission: true } },
invitations: {
orderBy: { createdAt: 'desc' },
@@ -80,6 +80,7 @@ export class StaffService {
isOwner: m.isOwner,
isActive: m.isOwner ? true : m.isActive,
invitationStatus: this.getInvitationStatus(m),
+ hasPassword: Boolean(m.user.passwordHash),
invitedAt: m.invitations[0]?.createdAt?.toISOString() || null,
acceptedAt: m.invitations[0]?.acceptedAt?.toISOString() || null,
permissions: m.isOwner
@@ -310,6 +311,77 @@ export class StaffService {
organizationName: org.name,
expiresAt: invitation.expiresAt.toISOString(),
status: invitation.acceptedAt ? 'ACCEPTED' : 'PENDING',
+ mode: invitation.membership.isActive ? 'password_setup' : 'join',
+ },
+ };
+ }
+
+ async clearPassword(
+ actorUserId: string,
+ organizationId: string,
+ membershipId: string,
+ ) {
+ const actor = await this.getActorMembership(actorUserId, organizationId);
+ if (!actor || !this.canEditStaff(actor)) {
+ throw new AppException(ErrorCode.PERMISSION_EDIT_STAFF, HttpStatus.FORBIDDEN);
+ }
+
+ const membership = await this.prisma.membership.findFirst({
+ where: { id: membershipId, organizationId },
+ include: {
+ user: { select: { id: true, email: true } },
+ },
+ });
+
+ if (!membership) {
+ throw new AppException(ErrorCode.STAFF_MEMBER_NOT_FOUND, HttpStatus.NOT_FOUND);
+ }
+ if (membership.isOwner) {
+ throw new AppException(ErrorCode.STAFF_CANNOT_EDIT_OWNER, HttpStatus.FORBIDDEN);
+ }
+ if (membership.userId === actorUserId) {
+ throw new AppException(ErrorCode.STAFF_CANNOT_CLEAR_OWN_PASSWORD, HttpStatus.BAD_REQUEST);
+ }
+ if (!membership.isActive) {
+ throw new AppException(ErrorCode.STAFF_PASSWORD_CLEAR_ACTIVE_ONLY, HttpStatus.BAD_REQUEST);
+ }
+
+ const plainToken = this.generateInviteToken();
+ const tokenHash = this.hashInviteToken(plainToken);
+
+ const invitation = await this.prisma.$transaction(async (tx) => {
+ await tx.user.update({
+ where: { id: membership.userId },
+ data: { passwordHash: null },
+ });
+ await tx.session.deleteMany({
+ where: { userId: membership.userId },
+ });
+ await tx.staffInvitation.updateMany({
+ where: {
+ membershipId: membership.id,
+ acceptedAt: null,
+ revokedAt: null,
+ },
+ data: { revokedAt: new Date() },
+ });
+ return tx.staffInvitation.create({
+ data: {
+ membershipId: membership.id,
+ invitedById: actorUserId,
+ tokenHash,
+ expiresAt: this.getInviteExpiryDate(),
+ },
+ });
+ });
+
+ return {
+ success: true,
+ data: {
+ membershipId: membership.id,
+ invitationId: invitation.id,
+ email: membership.user.email,
+ invitationUrl: this.buildInviteUrl(plainToken),
},
};
}
diff --git a/backend/src/modules/treatments/treatments.service.ts b/backend/src/modules/treatments/treatments.service.ts
index 72792c7..0af3c7a 100644
--- a/backend/src/modules/treatments/treatments.service.ts
+++ b/backend/src/modules/treatments/treatments.service.ts
@@ -222,14 +222,7 @@ export class TreatmentsService {
const sentinel = await ensureWalkInPatient(this.prisma, organizationId);
patientId = sentinel.id;
} else {
- await this.ensurePatientExists(dto.patientId!);
- const patient = await this.prisma.patient.findUnique({
- where: { id: dto.patientId! },
- select: { isWalkIn: true },
- });
- if (patient?.isWalkIn) {
- throw new AppException(ErrorCode.TREATMENT_PATIENT_OR_WALK_IN, HttpStatus.BAD_REQUEST);
- }
+ await this.ensurePatientInOrg(dto.patientId!, organizationId);
patientId = dto.patientId!;
}
@@ -1670,6 +1663,20 @@ export class TreatmentsService {
}
}
+ private async ensurePatientInOrg(patientId: string, organizationId: string) {
+ const patient = await this.prisma.patient.findFirst({
+ where: {
+ id: patientId,
+ isWalkIn: false,
+ createdByOrganizationId: organizationId,
+ },
+ select: { id: true },
+ });
+ if (!patient) {
+ throw new AppException(ErrorCode.PATIENT_NOT_FOUND, HttpStatus.NOT_FOUND);
+ }
+ }
+
private async ensureTreatmentProvider(
treatmentId: string,
organizationId: string,
diff --git a/frontend/Dockerfile b/frontend/Dockerfile
index f5b50e0..77e9fa3 100644
--- a/frontend/Dockerfile
+++ b/frontend/Dockerfile
@@ -50,6 +50,8 @@ ENV HOSTNAME=0.0.0.0
COPY --from=builder --chown=dyolink:nodejs /app/.next/standalone ./
COPY --from=builder --chown=dyolink:nodejs /app/.next/static ./.next/static
+# Standalone does not include public/; catalog icons are .
+COPY --from=builder --chown=dyolink:nodejs /app/public ./public
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
# Windows git/build context may use CRLF; strip before chmod (fixes dumb-init "No such file or directory").
diff --git a/frontend/README.md b/frontend/README.md
index dfc623d..e7b5349 100644
--- a/frontend/README.md
+++ b/frontend/README.md
@@ -60,4 +60,4 @@ npm install
## Docker
-Image build and build-args (`NEXT_PUBLIC_*`) are documented in the **repository root `README.md`**.
+Image build and build-args (`NEXT_PUBLIC_*`) are documented in the **repository root `README.md`**. Next `standalone` does not include `public/` — `frontend/Dockerfile` copies it so `/prosthesis-catalog/*.svg` is served in production.
diff --git a/frontend/messages/en.json b/frontend/messages/en.json
index 0e05950..961fcbb 100644
--- a/frontend/messages/en.json
+++ b/frontend/messages/en.json
@@ -122,6 +122,9 @@
"labelCreatePassword": "Create password",
"labelConfirmPassword": "Confirm password",
"activateAccount": "Activate account",
+ "setPasswordTitle": "Set your password",
+ "setPasswordSubmit": "Set password",
+ "passwordSetupAlreadyDone": "This password setup link is no longer valid. You can log in now.",
"invitationAcceptedRedirect": "Invitation accepted. Opening your workspace...",
"invitationAcceptedSignInFailed": "Account activated, but sign-in failed. Please log in with your password.",
"errorAcceptInvitation": "Could not accept invitation",
@@ -156,9 +159,8 @@
"verifyFailed": "Invalid or expired verification code."
},
"landing": {
- "heroTitle": "Connect Dental Clinics & Labs",
- "heroHighlight": "Seamlessly",
- "heroSubtitle": "Streamline communication between dental professionals. Start with a 30-day free trial, no credit card required.",
+ "heroTitle": "Nudentic is a digital workflow platform for modern dentistry.",
+ "heroSubtitle": "It brings clinical and laboratory workflows, case information, communication, and patient records into one structured environment.",
"featureClinicsTitle": "For Clinics",
"featureClinicsDescription": "Manage patients, appointments, and send cases to labs instantly.",
"featureLabsTitle": "For Labs",
@@ -322,6 +324,18 @@
"disableBullet3": "Disabling frees one seat on your plan so you can invite someone else.",
"disableMemberButton": "Disable member",
"editModalTitle": "Edit member",
+ "removePassword": "Remove password",
+ "copyPasswordSetupLink": "Copy password setup link",
+ "removePasswordModalTitle": "Remove password",
+ "removePasswordConfirm": "Remove the password for {name} ({email})?",
+ "removePasswordBullet1": "They will not be able to sign in until they set a new password with the setup link.",
+ "removePasswordBullet2": "You cannot choose their new password. Share the setup link with them.",
+ "removePasswordBullet3": "This signs them out of every organization they belong to.",
+ "removePasswordButton": "Remove password and copy link",
+ "passwordSetupLinkHeading": "Password setup link",
+ "passwordSetupShareHint": "Share this link so they can set a new password. Login will fail until they finish.",
+ "successPasswordCleared": "Password removed for {name}. Share the setup link with them.",
+ "errorClearPassword": "Could not remove the password.",
"loadingWorkingHours": "Loading working hours…",
"errorLoadStaff": "Failed to load staff.",
"errorCopyInvite": "Could not copy invitation link.",
@@ -1214,6 +1228,7 @@
"APPOINTMENT_NOT_FOUND": "Appointment not found.",
"APPOINTMENT_NOT_PROVIDER": "You are not the provider for this appointment.",
"PATIENT_NOT_FOUND": "Patient not found.",
+ "PATIENT_MOBILE_UNAVAILABLE": "This mobile number cannot be added for this clinic.",
"WORKING_HOURS_INVALID": "Working hours are invalid. Check that shifts do not overlap.",
"WORKING_HOURS_OWNER_NOT_ALLOWED": "Set owner working hours from account settings.",
"WORKING_HOURS_CONFLICTS_WITH_APPOINTMENTS": "These hours conflict with upcoming appointments. Reschedule or remove those appointments first.",
@@ -1237,6 +1252,8 @@
"STAFF_CANNOT_ENABLE_OWNER": "The organization owner cannot be enabled this way.",
"STAFF_CANNOT_DISABLE_OWNER": "The organization owner cannot be disabled.",
"STAFF_CANNOT_REMOVE_OWNER": "The organization owner cannot be removed.",
+ "STAFF_CANNOT_CLEAR_OWN_PASSWORD": "You cannot remove your own password here. Use account settings or forgot password.",
+ "STAFF_PASSWORD_CLEAR_ACTIVE_ONLY": "Password can only be removed for active members. Pending members use the invitation link.",
"ORG_CANNOT_LINK_SELF": "You cannot link an organization to itself.",
"ORG_LINK_WRONG_TYPE": "You can only link to the matching organization type (clinic or lab).",
"ORG_TARGET_NO_SUBSCRIPTION": "The other organization does not have an active subscription.",
diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json
index 23565fe..9023f7b 100644
--- a/frontend/messages/fa.json
+++ b/frontend/messages/fa.json
@@ -122,6 +122,9 @@
"labelCreatePassword": "ایجاد رمز عبور",
"labelConfirmPassword": "تأیید رمز عبور",
"activateAccount": "فعالسازی حساب",
+ "setPasswordTitle": "رمز عبور خود را تنظیم کنید",
+ "setPasswordSubmit": "تنظیم رمز عبور",
+ "passwordSetupAlreadyDone": "این لینک تنظیم رمز دیگر معتبر نیست. اکنون میتوانید وارد شوید.",
"invitationAcceptedRedirect": "دعوتنامه پذیرفته شد. در حال ورود به فضای کاری...",
"invitationAcceptedSignInFailed": "حساب فعال شد، اما ورود انجام نشد. لطفاً با رمز عبور خود وارد شوید.",
"errorAcceptInvitation": "پذیرش دعوتنامه امکانپذیر نبود",
@@ -156,9 +159,8 @@
"verifyFailed": "کد تأیید نامعتبر یا منقضی شده است."
},
"landing": {
- "heroTitle": "اتصال کلینیکها و لابراتوارهای دندانپزشکی",
- "heroHighlight": "بهصورت یکپارچه",
- "heroSubtitle": "ارتباط بین متخصصان دندانپزشکی را ساده و سریع کنید. با یک دوره آزمایشی رایگان ۳۰ روزه، بدون نیاز به کارت اعتباری، شروع کنید.",
+ "heroTitle": "Nudentic یک پلتفرم گردشکار دیجیتال برای دندانپزشکی مدرن است.",
+ "heroSubtitle": "این پلتفرم گردشکارهای بالینی و لابراتواری، اطلاعات پرونده، ارتباط و سوابق بیمار را در یک محیط ساختاریافته کنار هم میآورد.",
"featureClinicsTitle": "برای کلینیکها",
"featureClinicsDescription": "بیماران و نوبتها را مدیریت کنید و پروندهها را فوراً به لابراتوارها ارسال کنید.",
"featureLabsTitle": "برای لابراتوارها",
@@ -322,6 +324,18 @@
"disableBullet3": "غیرفعالسازی یک مجوز در طرح شما را آزاد میکند تا بتوانید شخص دیگری را دعوت کنید.",
"disableMemberButton": "غیرفعالسازی عضو",
"editModalTitle": "ویرایش عضو",
+ "removePassword": "حذف رمز عبور",
+ "copyPasswordSetupLink": "کپی لینک تنظیم رمز",
+ "removePasswordModalTitle": "حذف رمز عبور",
+ "removePasswordConfirm": "رمز عبور {name} ({email}) حذف شود؟",
+ "removePasswordBullet1": "تا وقتی با لینک تنظیم، رمز جدید نگذارند، نمیتوانند وارد شوند.",
+ "removePasswordBullet2": "شما رمز جدید را انتخاب نمیکنید. لینک تنظیم را برایشان بفرستید.",
+ "removePasswordBullet3": "از همه سازمانهایی که عضو آن هستند خارج میشوند.",
+ "removePasswordButton": "حذف رمز و کپی لینک",
+ "passwordSetupLinkHeading": "لینک تنظیم رمز عبور",
+ "passwordSetupShareHint": "این لینک را به اشتراک بگذارید تا رمز جدید بگذارند. تا تکمیل این کار ورود ناموفق است.",
+ "successPasswordCleared": "رمز {name} حذف شد. لینک تنظیم را برایشان بفرستید.",
+ "errorClearPassword": "حذف رمز عبور امکانپذیر نبود.",
"loadingWorkingHours": "در حال بارگذاری ساعات کاری...",
"errorLoadStaff": "بارگذاری کارکنان ناموفق بود.",
"errorCopyInvite": "کپی لینک دعوتنامه امکانپذیر نبود.",
@@ -1215,6 +1229,7 @@
"APPOINTMENT_NOT_FOUND": "نوبت یافت نشد.",
"APPOINTMENT_NOT_PROVIDER": "شما ارائهدهنده این نوبت نیستید.",
"PATIENT_NOT_FOUND": "بیمار یافت نشد.",
+ "PATIENT_MOBILE_UNAVAILABLE": "این شماره موبایل را نمیتوان برای این کلینیک ثبت کرد.",
"WORKING_HOURS_INVALID": "ساعات کاری نامعتبر است. همپوشانی شیفتها را بررسی کنید.",
"WORKING_HOURS_OWNER_NOT_ALLOWED": "ساعات کاری مالک را از تنظیمات حساب تنظیم کنید.",
"WORKING_HOURS_CONFLICTS_WITH_APPOINTMENTS": "این ساعات با نوبتهای آینده تداخل دارد. ابتدا آن نوبتها را تغییر دهید یا حذف کنید.",
@@ -1238,6 +1253,8 @@
"STAFF_CANNOT_ENABLE_OWNER": "مالک سازمان را نمیتوان اینگونه فعال کرد.",
"STAFF_CANNOT_DISABLE_OWNER": "مالک سازمان را نمیتوان غیرفعال کرد.",
"STAFF_CANNOT_REMOVE_OWNER": "مالک سازمان را نمیتوان حذف کرد.",
+ "STAFF_CANNOT_CLEAR_OWN_PASSWORD": "نمیتوانید رمز عبور خود را از اینجا حذف کنید. از تنظیمات حساب یا فراموشی رمز استفاده کنید.",
+ "STAFF_PASSWORD_CLEAR_ACTIVE_ONLY": "رمز عبور را فقط برای اعضای فعال میتوان حذف کرد. اعضای در انتظار از لینک دعوت استفاده میکنند.",
"ORG_CANNOT_LINK_SELF": "نمیتوانید سازمان را به خودش متصل کنید.",
"ORG_LINK_WRONG_TYPE": "فقط میتوانید به نوع سازمان متناظر (کلینیک یا لابراتوار) متصل شوید.",
"ORG_TARGET_NO_SUBSCRIPTION": "سازمان مقابل اشتراک فعال ندارد.",
diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json
index 16f4b43..f3cc81e 100644
--- a/frontend/messages/nl.json
+++ b/frontend/messages/nl.json
@@ -122,6 +122,9 @@
"labelCreatePassword": "Wachtwoord aanmaken",
"labelConfirmPassword": "Bevestig wachtwoord",
"activateAccount": "Account activeren",
+ "setPasswordTitle": "Stel uw wachtwoord in",
+ "setPasswordSubmit": "Wachtwoord instellen",
+ "passwordSetupAlreadyDone": "Deze wachtwoordlink is niet meer geldig. U kunt nu 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",
@@ -156,9 +159,8 @@
"verifyFailed": "Ongeldige of verlopen verificatiecode."
},
"landing": {
- "heroTitle": "Verbind Tandheelkundige Klinieken & Laboratoria",
- "heroHighlight": "Naadloos",
- "heroSubtitle": "Stroomlijn de communicatie tussen tandheelkundige professionals. Start met een gratis proefperiode van 30 dagen, zonder creditcard.",
+ "heroTitle": "Nudentic is een digitaal workflowplatform voor de moderne tandheelkunde.",
+ "heroSubtitle": "Het brengt klinische en laboratoriumworkflows, casusinformatie, communicatie en patiëntendossiers samen in één gestructureerde omgeving.",
"featureClinicsTitle": "Voor Klinieken",
"featureClinicsDescription": "Beheer patiënten, afspraken en stuur casussen direct naar laboratoria.",
"featureLabsTitle": "Voor Laboratoria",
@@ -322,6 +324,18 @@
"disableBullet3": "Uitschakelen maakt één plaats vrij in uw abonnement, zodat u iemand anders kunt uitnodigen.",
"disableMemberButton": "Lid uitschakelen",
"editModalTitle": "Lid bewerken",
+ "removePassword": "Wachtwoord verwijderen",
+ "copyPasswordSetupLink": "Wachtwoordlink kopiëren",
+ "removePasswordModalTitle": "Wachtwoord verwijderen",
+ "removePasswordConfirm": "Wachtwoord van {name} ({email}) verwijderen?",
+ "removePasswordBullet1": "Zij kunnen niet inloggen tot ze via de instellink een nieuw wachtwoord kiezen.",
+ "removePasswordBullet2": "U kunt hun nieuwe wachtwoord niet kiezen. Deel de instellink met hen.",
+ "removePasswordBullet3": "Dit meldt hen af bij elke organisatie waar zij lid van zijn.",
+ "removePasswordButton": "Wachtwoord verwijderen en link kopiëren",
+ "passwordSetupLinkHeading": "Wachtwoord-instellink",
+ "passwordSetupShareHint": "Deel deze link zodat zij een nieuw wachtwoord kunnen instellen. Inloggen mislukt tot dat is afgerond.",
+ "successPasswordCleared": "Wachtwoord van {name} is verwijderd. Deel de instellink met hen.",
+ "errorClearPassword": "Kon het wachtwoord niet verwijderen.",
"loadingWorkingHours": "Werktijden laden...",
"errorLoadStaff": "Medewerkers laden mislukt.",
"errorCopyInvite": "Kon uitnodigingslink niet kopiëren.",
@@ -1214,6 +1228,7 @@
"APPOINTMENT_NOT_FOUND": "Afspraak niet gevonden.",
"APPOINTMENT_NOT_PROVIDER": "U bent niet de zorgverlener van deze afspraak.",
"PATIENT_NOT_FOUND": "Patiënt niet gevonden.",
+ "PATIENT_MOBILE_UNAVAILABLE": "Dit mobiele nummer kan niet voor deze kliniek worden toegevoegd.",
"WORKING_HOURS_INVALID": "De werktijden zijn ongeldig. Controleer of diensten niet overlappen.",
"WORKING_HOURS_OWNER_NOT_ALLOWED": "Stel werktijden van de eigenaar in via accountinstellingen.",
"WORKING_HOURS_CONFLICTS_WITH_APPOINTMENTS": "Deze tijden conflicteren met aankomende afspraken. Plan die eerst om of verwijder ze.",
@@ -1237,6 +1252,8 @@
"STAFF_CANNOT_ENABLE_OWNER": "De eigenaar kan op deze manier niet worden ingeschakeld.",
"STAFF_CANNOT_DISABLE_OWNER": "De eigenaar kan niet worden uitgeschakeld.",
"STAFF_CANNOT_REMOVE_OWNER": "De eigenaar kan niet worden verwijderd.",
+ "STAFF_CANNOT_CLEAR_OWN_PASSWORD": "U kunt hier uw eigen wachtwoord niet verwijderen. Gebruik accountinstellingen of wachtwoord vergeten.",
+ "STAFF_PASSWORD_CLEAR_ACTIVE_ONLY": "Het wachtwoord kan alleen voor actieve leden worden verwijderd. Leden in afwachting gebruiken de uitnodigingslink.",
"ORG_CANNOT_LINK_SELF": "U kunt een organisatie niet aan zichzelf koppelen.",
"ORG_LINK_WRONG_TYPE": "U kunt alleen koppelen aan het bijbehorende type (kliniek of lab).",
"ORG_TARGET_NO_SUBSCRIPTION": "De andere organisatie heeft geen actief abonnement.",
diff --git a/frontend/src/app/[locale]/(public)/accept-invite/page.tsx b/frontend/src/app/[locale]/(public)/accept-invite/page.tsx
index cf86a72..f69f07b 100644
--- a/frontend/src/app/[locale]/(public)/accept-invite/page.tsx
+++ b/frontend/src/app/[locale]/(public)/accept-invite/page.tsx
@@ -30,6 +30,7 @@ function AcceptInviteContent() {
organizationName: string;
expiresAt: string;
status: 'PENDING' | 'ACCEPTED';
+ mode: 'join' | 'password_setup';
} | null>(null);
const [name, setName] = useState('');
@@ -48,10 +49,17 @@ function AcceptInviteContent() {
setError('');
try {
const res = await staffApi.previewInvite(token);
- setInviteInfo(res.data);
+ setInviteInfo({
+ ...res.data,
+ mode: res.data.mode === 'password_setup' ? 'password_setup' : 'join',
+ });
setName(res.data.name || '');
if (res.data.status === 'ACCEPTED') {
- setSuccess(t('invitationAlreadyAccepted'));
+ setSuccess(
+ res.data.mode === 'password_setup'
+ ? t('passwordSetupAlreadyDone')
+ : t('invitationAlreadyAccepted'),
+ );
}
} catch (e: unknown) {
setError(getUserFacingError(e, tErrors, t('errorLoadInvitation')));
@@ -65,7 +73,9 @@ function AcceptInviteContent() {
if (!token) return;
setError('');
setSuccess('');
- if (!name.trim()) {
+ const isPasswordSetup = inviteInfo?.mode === 'password_setup';
+ const nameToSubmit = isPasswordSetup ? (inviteInfo?.name || '').trim() : name.trim();
+ if (!isPasswordSetup && !nameToSubmit) {
setError(t('nameRequired'));
return;
}
@@ -83,7 +93,7 @@ function AcceptInviteContent() {
try {
await staffApi.acceptInvite({
token,
- name: name.trim(),
+ name: nameToSubmit || inviteInfo?.name || '',
password,
});
accepted = true;
@@ -115,7 +125,9 @@ function AcceptInviteContent() {
return (