feat: admin-provisioned staff, username login & role-based access

Replace the email-invitation flow with admin-provisioned staff accounts and
add role-based access that changes what each member sees.

Backend:
- Enable Better Auth `username` plugin (staff sign in by username); regenerate
  auth schema (+ username/displayUsername on user) and migration 0007.
- Add `doctor` and `reception` roles to the access-control RBAC. `reception` is
  scoped to scheduling + registration (no `prescription` statement).
- New `/api/staff` route: POST creates a user (auth.api.signUpEmail) and adds
  them to the active clinic (auth.api.addMember); GET lists members + usernames.
  Gated by requirePermission({ member: ["create"] }).
- Redact clinical PHI for the reception role in the patients service (read,
  create and update) so demographics-only is enforced server-side.

Frontend:
- usernameClient + Email|Username tabs on the login form.
- lib/roles.ts: useActiveRole + Better-Auth-permission-driven nav visibility,
  default landing, and a route guard (reception -> /appointments, blocked from
  clinical routes). Applied to the sidebar, command palette and auth guard.
- Care team page now provisions staff via a two-step Add-team-member dialog
  (details -> username/password) hitting /api/staff; removes the email-invite
  and pending-invitation UI. New members are contactable from Messages
  automatically (they become org members).
- Hide clinical sections of the patient form and the admin-only settings tabs
  for non-clinical/non-admin roles.

All permission management stays in Better Auth (per the better-auth skills now
referenced in backend/CLAUDE.md).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-06-08 19:12:07 +03:00
parent ab2f10bffc
commit 6213da9477
24 changed files with 3338 additions and 180 deletions
+21 -1
View File
@@ -42,6 +42,24 @@ export const member = ac.newRole({
task: ["read", "write", "delete"],
});
// doctor (clinician): mirrors backend/src/lib/access.ts — same clinical access
// as `member`.
export const doctor = ac.newRole({
...memberAc.statements,
patient: ["read", "write"],
appointment: ["read", "write", "delete"],
prescription: ["read", "write", "delete"],
task: ["read", "write", "delete"],
});
// reception (front desk): scheduling + registration only, no clinical records.
export const reception = ac.newRole({
...memberAc.statements,
patient: ["read", "write"],
appointment: ["read", "write", "delete"],
task: ["read", "write"],
});
export const viewer = ac.newRole({
patient: ["read"],
appointment: ["read"],
@@ -49,12 +67,14 @@ export const viewer = ac.newRole({
task: ["read"],
});
export const roles = { owner, admin, member, viewer };
export const roles = { owner, admin, doctor, reception, member, viewer };
// Human-readable labels for the role keys used in the UI.
export const ROLE_LABELS: Record<keyof typeof roles, string> = {
owner: "Owner",
admin: "Admin",
doctor: "Doctor",
reception: "Reception",
member: "Clinician",
viewer: "Viewer",
};
+7 -2
View File
@@ -1,4 +1,7 @@
import { organizationClient } from "better-auth/client/plugins";
import {
organizationClient,
usernameClient,
} from "better-auth/client/plugins";
import { createAuthClient } from "better-auth/react";
import { ac, roles } from "@/lib/access";
@@ -8,7 +11,9 @@ import { ac, roles } from "@/lib/access";
// cookie set by the backend is included on every request.
export const authClient = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000",
plugins: [organizationClient({ ac, roles })],
// usernameClient enables signIn.username(...) so admin-provisioned staff can
// log in with a username; organizationClient powers clinics + RBAC.
plugins: [usernameClient(), organizationClient({ ac, roles })],
});
export const {
+30 -8
View File
@@ -11,8 +11,12 @@
"login": {
"title": "Welcome back",
"subtitle": "Sign in to your clinician account",
"tabEmail": "Email",
"tabUsername": "Username",
"emailLabel": "Email",
"emailPlaceholder": "you@clinic.org",
"usernameLabel": "Username",
"usernamePlaceholder": "jdoe",
"passwordLabel": "Password",
"forgotPassword": "Forgot your password?",
"submit": "Sign in",
@@ -695,18 +699,36 @@
},
"careTeam": {
"title": "Care team",
"description": "Clinicians with access to this clinic",
"description": "Staff with access to this clinic",
"loadError": "Could not load the care team.",
"invitePlaceholder": "colleague@clinic.org",
"inviting": "Sending…",
"invite": "Invite",
"inviteError": "Could not send the invitation.",
"inviteSent": "Invitation sent to {{email}}.",
"loading": "Loading care team…",
"you": "(you)",
"pendingInvitations": "Pending invitations",
"addMember": "Add team member",
"removeMember": "Remove member",
"cancelInvitation": "Cancel invitation"
"add": {
"title": "Add team member",
"step1Description": "Who are you adding, and what can they do?",
"step2Description": "Set the username and password they'll sign in with.",
"nameLabel": "Full name",
"namePlaceholder": "Jane Okafor",
"nameRequired": "Enter the person's name.",
"roleLabel": "Role",
"usernameLabel": "Username",
"usernamePlaceholder": "jokafor",
"usernameTooShort": "Username must be at least {{count}} characters.",
"passwordLabel": "Password",
"passwordHint": "Must be at least {{count}} characters long.",
"passwordTooShort": "Password must be at least {{count}} characters.",
"back": "Back",
"cancel": "Cancel",
"next": "Next",
"create": "Create account",
"creating": "Creating…",
"createdTitle": "Account created",
"createdBody": "{{name}} can now sign in with the username {{username}}.",
"error": "Could not create the account.",
"errorTitle": "Could not add member"
}
},
"signing": {
"keyTitle": "Signing key",
+27 -3
View File
@@ -18,6 +18,8 @@ export type NavSubItem = {
labelKey: string;
icon?: LucideIcon;
link: string;
// Hidden from non-clinical roles (e.g. reception). See lib/roles.ts.
requiresClinical?: boolean;
};
export type NavItem = {
@@ -28,13 +30,21 @@ export type NavItem = {
link: string;
// Optional sub-pages revealed under this item in the sidebar.
subs?: NavSubItem[];
// Hidden from non-clinical roles (e.g. reception). See lib/roles.ts.
requiresClinical?: boolean;
};
// Single source of truth for the primary navigation. Consumed by the sidebar
// (components/sidebar-02/app-sidebar.tsx) and the command palette
// (components/command-palette.tsx) so the two never drift.
export const navItems: NavItem[] = [
{ id: "new-chat", labelKey: "nav.newChat", icon: Plus, link: "/" },
{
id: "new-chat",
labelKey: "nav.newChat",
icon: Plus,
link: "/",
requiresClinical: true,
},
{
id: "patients",
labelKey: "nav.patients",
@@ -53,6 +63,7 @@ export const navItems: NavItem[] = [
labelKey: "nav.prescriptions",
icon: Pill,
link: "/prescriptions",
requiresClinical: true,
},
],
},
@@ -61,10 +72,23 @@ export const navItems: NavItem[] = [
labelKey: "nav.analysis",
icon: BarChart3,
link: "/analysis",
requiresClinical: true,
},
{ id: "messages", labelKey: "nav.messages", icon: Mail, link: "/messages" },
{ id: "notes", labelKey: "nav.notes", icon: NotebookPen, link: "/notes" },
{
id: "notes",
labelKey: "nav.notes",
icon: NotebookPen,
link: "/notes",
requiresClinical: true,
},
{ id: "tasks", labelKey: "nav.tasks", icon: ListTodo, link: "/tasks" },
{ id: "activity", labelKey: "nav.activity", icon: History, link: "/activity" },
{
id: "activity",
labelKey: "nav.activity",
icon: History,
link: "/activity",
requiresClinical: true,
},
{ id: "settings", labelKey: "nav.settings", icon: Settings, link: "/settings" },
];
+102
View File
@@ -0,0 +1,102 @@
"use client";
import { useEffect, useState } from "react";
import { type roles } from "@/lib/access";
import { authClient } from "@/lib/auth-client";
import { type NavItem, navItems } from "@/lib/nav";
export type RoleKey = keyof typeof roles;
// Roles an admin can assign when provisioning staff (owner is excluded — the
// clinic creator is the sole owner). Mirrors the backend's PROVISIONABLE_ROLES.
export const PROVISIONABLE_ROLES: RoleKey[] = [
"admin",
"doctor",
"reception",
"viewer",
];
// The current user's role in the active clinic (null while loading or if they
// aren't a member). Re-fetches when the active organization changes.
export function useActiveRole(): string | null {
const { data: activeOrg } = authClient.useActiveOrganization();
const [role, setRole] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
authClient.organization
.getActiveMember()
.then(({ data }) => {
if (!cancelled) setRole(data?.role ?? null);
})
.catch(() => {
if (!cancelled) setRole(null);
});
return () => {
cancelled = true;
};
}, [activeOrg?.id]);
return role;
}
// Whether a role may see clinical records (AI lookup, prescriptions, notes,
// analysis). Driven by Better Auth permissions so it stays in lock-step with
// lib/access.ts: the `reception` role has no `prescription` statement, so this
// is false for them and true for every clinical role.
export function hasClinicalAccess(role: string | null | undefined): boolean {
if (!role) return false;
try {
return authClient.organization.checkRolePermission({
role: role as RoleKey,
permissions: { prescription: ["read"] },
});
} catch {
return false;
}
}
// Where a role lands after sign-in. Reception has no AI chat, so they start on
// the appointments board; clinical roles start on the chat home.
export function defaultLandingFor(role: string | null | undefined): string {
return hasClinicalAccess(role) ? "/" : "/appointments";
}
// Clinical-only routes — a non-clinical role (reception) is redirected away.
// Keyed by path; "/" matches exactly, others match themselves + nested paths.
const CLINICAL_ROUTES = [
"/",
"/prescriptions",
"/analysis",
"/notes",
"/activity",
];
// Whether `path` is reachable by `role`. Returns true while the role is still
// loading to avoid redirect flicker; the authoritative check is the backend's
// per-route RBAC (which returns 403 regardless).
export function canAccessRoute(
path: string,
role: string | null | undefined,
): boolean {
if (role == null) return true;
if (hasClinicalAccess(role)) return true;
return !CLINICAL_ROUTES.some((r) =>
r === "/" ? path === "/" : path === r || path.startsWith(`${r}/`),
);
}
// Nav items visible to a role, with clinical-only items (and sub-items) removed
// for non-clinical roles. While the role is loading we optimistically show
// everything (clinical users are the common case) — the flash is sub-second.
export function visibleNavItems(role: string | null | undefined): NavItem[] {
if (role == null) return navItems;
const clinical = hasClinicalAccess(role);
return navItems
.filter((item) => !item.requiresClinical || clinical)
.map((item) => ({
...item,
subs: item.subs?.filter((sub) => !sub.requiresClinical || clinical),
}));
}