mirror of
https://github.com/temetro/temetro.git
synced 2026-08-16 21:38:26 +00:00
1ecbd16404
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>
248 lines
7.6 KiB
TypeScript
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>
|
|
);
|
|
}
|