Files
temetro/frontend/lib/roles.ts
T
Khalid Abdi 6213da9477 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>
2026-06-08 19:12:07 +03:00

103 lines
3.3 KiB
TypeScript

"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),
}));
}