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
@@ -31,6 +31,7 @@ import {
type Patient,
updatePatient,
} from "@/lib/patients";
import { hasClinicalAccess, useActiveRole } from "@/lib/roles";
import { notify } from "@/lib/toast";
type PatientFormDialogProps = {
@@ -203,6 +204,11 @@ export function PatientFormDialog({
}: PatientFormDialogProps) {
const { t } = useTranslation();
const isEdit = mode === "edit";
// Reception registers demographics only — clinical sections are hidden (the
// backend also redacts/ignores clinical data for this role). Show everything
// while the role is still loading to avoid a flash for clinical users.
const role = useActiveRole();
const showClinical = role == null || hasClinicalAccess(role);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -416,6 +422,8 @@ export function PatientFormDialog({
/>
</Field>
{showClinical && (
<>
<div className="flex flex-col gap-1.5">
<span className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
{t("patientForm.currentVitals")}
@@ -615,6 +623,8 @@ export function PatientFormDialog({
)}
rows={visits}
/>
</>
)}
</DialogPanel>
<DialogFooter className="flex-col items-stretch gap-2 sm:flex-row sm:items-center">