mirror of
https://github.com/temetro/temetro.git
synced 2026-08-28 19:37:33 +00:00
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:
+21
-1
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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" },
|
||||
];
|
||||
|
||||
@@ -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),
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user