Files
temetro/frontend/components/settings/settings-care-team.tsx
T
Claude 1ecbd16404 Wire frontend to the backend: auth, organizations, real patient API
Connects the UI-only app to the new backend over Better Auth + a small
patient API client, replacing the in-memory fixture and placeholder identity.

- Better Auth React client (lib/auth-client.ts) + shared access-control
  roles; API client (lib/api-client.ts) sending credentials cross-origin.
- Designed (auth) route group: login, signup, verify-email, forgot/reset
  password, clinic onboarding, and accept-invite.
- proxy.ts (this Next's renamed middleware) optimistic redirect + an
  authoritative client AppAuthGuard requiring a session and active clinic.
- Real identity in nav-user (useSession + sign out); the unused team
  switcher repurposed as an organization (clinic) switcher; Care-team
  settings tab manages members and invitations.
- lib/patients.ts now calls the org-scoped API (types unchanged); patients
  table and create/edit dialog updated for async create/update.
- next.config: standalone output + Dockerfile for containerized runs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 21:28:02 +03:00

248 lines
7.6 KiB
TypeScript

"use client";
import { X } from "lucide-react";
import { type FormEvent, useCallback, useEffect, useState } from "react";
import {
SettingsCard,
SettingsSection,
} from "@/components/settings/settings-parts";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ROLE_LABELS } from "@/lib/access";
import { authClient } from "@/lib/auth-client";
type Member = {
id: string;
role: string;
userId: string;
user?: { name?: string | null; email?: string | null };
};
type Invite = {
id: string;
email: string;
role?: string | null;
status: string;
};
const INVITE_ROLES = ["member", "admin", "viewer"] as const;
function roleLabel(role?: string | null): string {
if (!role) return "Member";
return (ROLE_LABELS as Record<string, string>)[role] ?? role;
}
function initials(name?: string | null, email?: string | null): string {
const source = name?.trim() || email?.trim() || "?";
return (
source
.split(/\s+/)
.map((w) => w[0])
.filter(Boolean)
.join("")
.slice(0, 2)
.toUpperCase() || "?"
);
}
export function CareTeamPanel() {
const { data: session } = authClient.useSession();
const [members, setMembers] = useState<Member[]>([]);
const [invites, setInvites] = useState<Invite[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [email, setEmail] = useState("");
const [role, setRole] = useState<(typeof INVITE_ROLES)[number]>("member");
const [inviting, setInviting] = useState(false);
const load = useCallback(async () => {
const { data, error: err } =
await authClient.organization.getFullOrganization();
if (err || !data) {
setError(err?.message ?? "Could not load the care team.");
setLoading(false);
return;
}
setMembers((data.members ?? []) as Member[]);
setInvites(
((data.invitations ?? []) as Invite[]).filter(
(i) => i.status === "pending"
)
);
setError(null);
setLoading(false);
}, []);
useEffect(() => {
void load();
}, [load]);
const myRole = members.find((m) => m.userId === session?.user?.id)?.role;
const canManage = myRole === "owner" || myRole === "admin";
const invite = async (event: FormEvent) => {
event.preventDefault();
if (!email.trim() || inviting) return;
setInviting(true);
setNotice(null);
setError(null);
const { error: err } = await authClient.organization.inviteMember({
email: email.trim(),
role,
});
setInviting(false);
if (err) {
setError(err.message ?? "Could not send the invitation.");
return;
}
setEmail("");
setNotice(`Invitation sent to ${email.trim()}.`);
void load();
};
const removeMember = async (memberId: string) => {
await authClient.organization.removeMember({ memberIdOrEmail: memberId });
void load();
};
const cancelInvite = async (invitationId: string) => {
await authClient.organization.cancelInvitation({ invitationId });
void load();
};
return (
<SettingsSection
description="Clinicians with access to this clinic"
title="Care team"
>
{error && (
<p className="rounded-2xl bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</p>
)}
{notice && (
<p className="rounded-2xl bg-primary/10 px-3 py-2 text-sm text-primary">
{notice}
</p>
)}
{canManage && (
<SettingsCard className="p-4">
<form
className="flex flex-col gap-2 sm:flex-row sm:items-center"
onSubmit={invite}
>
<Input
className="flex-1"
onChange={(e) => setEmail(e.target.value)}
placeholder="colleague@clinic.org"
required
type="email"
value={email}
/>
<select
className="h-9 rounded-3xl border border-transparent bg-input/50 px-3 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30"
onChange={(e) =>
setRole(e.target.value as (typeof INVITE_ROLES)[number])
}
value={role}
>
{INVITE_ROLES.map((r) => (
<option key={r} value={r}>
{roleLabel(r)}
</option>
))}
</select>
<Button disabled={inviting} type="submit">
{inviting ? "Sending…" : "Invite"}
</Button>
</form>
</SettingsCard>
)}
<SettingsCard className="divide-y divide-border">
{loading ? (
<p className="p-6 text-center text-sm text-muted-foreground">
Loading care team
</p>
) : (
members.map((m) => {
const isSelf = m.userId === session?.user?.id;
return (
<div className="flex items-center gap-3 px-4 py-3" key={m.id}>
<Avatar className="size-8">
<AvatarFallback>
{initials(m.user?.name, m.user?.email)}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">
{m.user?.name || m.user?.email || m.userId}
{isSelf && (
<span className="ml-1 text-xs text-muted-foreground">
(you)
</span>
)}
</p>
{m.user?.email && (
<p className="truncate text-xs text-muted-foreground">
{m.user.email}
</p>
)}
</div>
<Badge className="capitalize" variant="secondary">
{roleLabel(m.role)}
</Badge>
{canManage && !isSelf && m.role !== "owner" && (
<Button
aria-label="Remove member"
onClick={() => removeMember(m.id)}
size="icon-sm"
type="button"
variant="ghost"
>
<X className="size-4" />
</Button>
)}
</div>
);
})
)}
</SettingsCard>
{invites.length > 0 && (
<SettingsCard className="divide-y divide-border">
<p className="px-4 py-2.5 text-xs font-medium tracking-wide text-muted-foreground uppercase">
Pending invitations
</p>
{invites.map((inv) => (
<div className="flex items-center gap-3 px-4 py-3" key={inv.id}>
<div className="min-w-0 flex-1">
<p className="truncate text-sm">{inv.email}</p>
</div>
<Badge className="capitalize" variant="outline">
{roleLabel(inv.role)}
</Badge>
{canManage && (
<Button
aria-label="Cancel invitation"
onClick={() => cancelInvite(inv.id)}
size="icon-sm"
type="button"
variant="ghost"
>
<X className="size-4" />
</Button>
)}
</div>
))}
</SettingsCard>
)}
</SettingsSection>
);
}