mirror of
https://github.com/temetro/temetro.git
synced 2026-08-19 14:46:29 +00:00
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>
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { type ReactNode, useEffect } from "react";
|
||||
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
// Authoritative client-side gate for the app shell: requires a session and an
|
||||
// active clinic, otherwise redirects to login / onboarding. The API enforces
|
||||
// the same rules server-side.
|
||||
export function AppAuthGuard({ children }: { children: ReactNode }) {
|
||||
const router = useRouter();
|
||||
const { data, isPending } = authClient.useSession();
|
||||
|
||||
const ready = Boolean(data?.user && data.session?.activeOrganizationId);
|
||||
|
||||
useEffect(() => {
|
||||
if (isPending) return;
|
||||
if (!data?.user) {
|
||||
router.replace("/login");
|
||||
} else if (!data.session?.activeOrganizationId) {
|
||||
router.replace("/onboarding");
|
||||
}
|
||||
}, [data, isPending, router]);
|
||||
|
||||
if (!ready) {
|
||||
return (
|
||||
<div className="flex h-dvh w-full items-center justify-center text-sm text-muted-foreground">
|
||||
Loading…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import Image from "next/image";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Centered, branded shell shared by every auth page.
|
||||
export function AuthShell({
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
footer,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: ReactNode;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-h-full w-full flex-col items-center justify-center px-4 py-12">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="mb-8 flex flex-col items-center gap-3 text-center">
|
||||
<Image
|
||||
alt="temetro"
|
||||
className="size-10"
|
||||
height={40}
|
||||
priority
|
||||
src="/temetro-logo.png"
|
||||
width={40}
|
||||
/>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold tracking-tight">{title}</h1>
|
||||
{subtitle && (
|
||||
<p className="mt-1.5 text-sm text-muted-foreground">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-3xl border border-border bg-card/40 p-6 shadow-sm">
|
||||
{children}
|
||||
</div>
|
||||
{footer && (
|
||||
<div className="mt-6 text-center text-sm text-muted-foreground">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Field({
|
||||
label,
|
||||
htmlFor,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
htmlFor: string;
|
||||
hint?: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium text-foreground" htmlFor={htmlFor}>
|
||||
{label}
|
||||
</label>
|
||||
{children}
|
||||
{hint && <p className="text-xs text-muted-foreground">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FormAlert({
|
||||
tone = "error",
|
||||
children,
|
||||
}: {
|
||||
tone?: "error" | "success";
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<p
|
||||
className={cn(
|
||||
"rounded-2xl px-3 py-2 text-sm",
|
||||
tone === "error"
|
||||
? "bg-destructive/10 text-destructive"
|
||||
: "bg-primary/10 text-primary"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -70,7 +70,14 @@ export function ChatPanel() {
|
||||
},
|
||||
]);
|
||||
|
||||
const patient = await getPatient(fileNumber);
|
||||
let patient: Patient | null = null;
|
||||
try {
|
||||
patient = await getPatient(fileNumber);
|
||||
} catch {
|
||||
// Network / auth errors fall through as "not found"; a 401 will have
|
||||
// already redirected to /login via the API client.
|
||||
patient = null;
|
||||
}
|
||||
setMessages((prev) =>
|
||||
prev.map((message) =>
|
||||
message.id === resultId &&
|
||||
|
||||
@@ -16,11 +16,12 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
addPatient,
|
||||
type AllergySeverity,
|
||||
createPatient,
|
||||
generateFileNumber,
|
||||
type LabFlag,
|
||||
type Patient,
|
||||
updatePatient,
|
||||
} from "@/lib/patients";
|
||||
|
||||
type PatientFormDialogProps = {
|
||||
@@ -127,6 +128,9 @@ export function PatientFormDialog({
|
||||
}: PatientFormDialogProps) {
|
||||
const isEdit = mode === "edit";
|
||||
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [fileNumber, setFileNumber] = useState(() =>
|
||||
isEdit && patient ? patient.fileNumber : generateFileNumber()
|
||||
);
|
||||
@@ -163,9 +167,9 @@ export function PatientFormDialog({
|
||||
})) ?? []
|
||||
);
|
||||
|
||||
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (!name.trim()) {
|
||||
if (!name.trim() || submitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -207,13 +211,25 @@ export function PatientFormDialog({
|
||||
})),
|
||||
};
|
||||
|
||||
addPatient(built);
|
||||
if (isEdit) {
|
||||
onSaved?.(built);
|
||||
} else {
|
||||
onCreated?.(fileNumber);
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const saved = isEdit
|
||||
? await updatePatient(built)
|
||||
: await createPatient(built);
|
||||
if (isEdit) {
|
||||
onSaved?.(saved);
|
||||
} else {
|
||||
onCreated?.(saved.fileNumber);
|
||||
}
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Could not save the patient.",
|
||||
);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -497,12 +513,15 @@ export function PatientFormDialog({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogFooter className="flex-col items-stretch gap-2 sm:flex-row sm:items-center">
|
||||
{error && (
|
||||
<p className="text-sm text-destructive sm:mr-auto">{error}</p>
|
||||
)}
|
||||
<DialogClose render={<Button type="button" variant="outline" />}>
|
||||
Cancel
|
||||
</DialogClose>
|
||||
<Button disabled={!name.trim()} type="submit">
|
||||
{isEdit ? "Save changes" : "Save patient"}
|
||||
<Button disabled={!name.trim() || submitting} type="submit">
|
||||
{submitting ? "Saving…" : isEdit ? "Save changes" : "Save patient"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { Plus, Search } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { PatientFormDialog } from "@/components/chat/patient-form-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -25,8 +25,35 @@ export function PatientsView() {
|
||||
// Bumped on open so the create dialog remounts with a fresh file # / form.
|
||||
const [addKey, setAddKey] = useState(0);
|
||||
|
||||
const [allPatients, setAllPatients] = useState<Patient[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setLoading(true);
|
||||
listPatients()
|
||||
.then((data) => {
|
||||
if (!active) return;
|
||||
setAllPatients(data);
|
||||
setLoadError(null);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!active) return;
|
||||
setLoadError(
|
||||
err instanceof Error ? err.message : "Failed to load patients."
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const q = query.trim().toLowerCase();
|
||||
const patients = listPatients().filter(
|
||||
const patients = allPatients.filter(
|
||||
(p) => !q || p.name.toLowerCase().includes(q) || p.fileNumber.includes(q)
|
||||
);
|
||||
|
||||
@@ -73,7 +100,22 @@ export function PatientsView() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{patients.length === 0 ? (
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td
|
||||
className="px-4 py-10 text-center text-muted-foreground"
|
||||
colSpan={6}
|
||||
>
|
||||
Loading patients…
|
||||
</td>
|
||||
</tr>
|
||||
) : loadError ? (
|
||||
<tr>
|
||||
<td className="px-4 py-10 text-center text-destructive" colSpan={6}>
|
||||
{loadError}
|
||||
</td>
|
||||
</tr>
|
||||
) : patients.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
className="px-4 py-10 text-center text-muted-foreground"
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
SettingsSection,
|
||||
} from "@/components/settings/settings-parts";
|
||||
import { SigningPanel } from "@/components/settings/settings-billing";
|
||||
import { CareTeamPanel } from "@/components/settings/settings-care-team";
|
||||
import { ProfilePanel } from "@/components/settings/settings-preferences";
|
||||
|
||||
const TABS = [
|
||||
@@ -71,12 +72,7 @@ export function SettingsView() {
|
||||
/>
|
||||
)}
|
||||
{tab === "Signing" && <SigningPanel />}
|
||||
{tab === "Care team" && (
|
||||
<PlaceholderPanel
|
||||
description="Clinicians with access to this workspace"
|
||||
title="Care team"
|
||||
/>
|
||||
)}
|
||||
{tab === "Care team" && <CareTeamPanel />}
|
||||
{tab === "Developers" && (
|
||||
<PlaceholderPanel
|
||||
description="Access tokens for the temetro API"
|
||||
|
||||
@@ -16,6 +16,7 @@ import type { Route } from "./nav-main";
|
||||
import DashboardNavigation from "@/components/sidebar-02/nav-main";
|
||||
import { NotificationsPopover } from "@/components/sidebar-02/nav-notifications";
|
||||
import { NavUser } from "@/components/sidebar-02/nav-user";
|
||||
import { OrgSwitcher } from "@/components/sidebar-02/team-switcher";
|
||||
|
||||
const sampleNotifications = [
|
||||
{
|
||||
@@ -107,6 +108,7 @@ export function DashboardSidebar() {
|
||||
</motion.div>
|
||||
</SidebarHeader>
|
||||
<SidebarContent className="gap-4 px-2 py-4">
|
||||
<OrgSwitcher />
|
||||
<DashboardNavigation routes={dashboardRoutes} />
|
||||
</SidebarContent>
|
||||
<SidebarFooter className="px-2">
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { ChevronsUpDown, LogOut, Settings as SettingsIcon, Sun } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import {
|
||||
@@ -19,12 +20,21 @@ import {
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
// Placeholder identity — there is no auth backend yet.
|
||||
const user = { name: "Dr. Khalid", role: "Clinician", initials: "K" };
|
||||
// Open-source repo (placeholder).
|
||||
const REPO_URL = "https://github.com/temetro/temetro";
|
||||
|
||||
function initialsFromName(name: string): string {
|
||||
const letters = name
|
||||
.split(/\s+/)
|
||||
.map((w) => w[0])
|
||||
.filter(Boolean)
|
||||
.join("")
|
||||
.slice(0, 2);
|
||||
return (letters || "?").toUpperCase();
|
||||
}
|
||||
|
||||
function GitHubIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
@@ -41,6 +51,17 @@ function GitHubIcon({ className }: { className?: string }) {
|
||||
export function NavUser() {
|
||||
const { isMobile, state } = useSidebar();
|
||||
const isCollapsed = state === "collapsed";
|
||||
const router = useRouter();
|
||||
const { data } = authClient.useSession();
|
||||
|
||||
const name = data?.user?.name ?? "Clinician";
|
||||
const email = data?.user?.email ?? "";
|
||||
const initials = initialsFromName(name);
|
||||
|
||||
const signOut = async () => {
|
||||
await authClient.signOut();
|
||||
router.push("/login");
|
||||
};
|
||||
|
||||
return (
|
||||
<SidebarMenu>
|
||||
@@ -51,19 +72,19 @@ export function NavUser() {
|
||||
<SidebarMenuButton
|
||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
size="lg"
|
||||
tooltip={user.name}
|
||||
tooltip={name}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Avatar className="size-8">
|
||||
<AvatarFallback>{user.initials}</AvatarFallback>
|
||||
<AvatarFallback>{initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
{!isCollapsed && (
|
||||
<>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-medium">{user.name}</span>
|
||||
<span className="truncate font-medium">{name}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{user.role}
|
||||
{email}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronsUpDown className="ml-auto size-4" />
|
||||
@@ -78,12 +99,12 @@ export function NavUser() {
|
||||
>
|
||||
<DropdownMenuLabel className="flex items-center gap-2 py-2 text-foreground">
|
||||
<Avatar className="size-8">
|
||||
<AvatarFallback>{user.initials}</AvatarFallback>
|
||||
<AvatarFallback>{initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-medium">{user.name}</span>
|
||||
<span className="truncate font-medium">{name}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{user.role}
|
||||
{email}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
@@ -104,7 +125,7 @@ export function NavUser() {
|
||||
<DropdownMenuShortcut>Dark</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive">
|
||||
<DropdownMenuItem onClick={signOut} variant="destructive">
|
||||
<LogOut />
|
||||
Log out
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { Building2, ChevronsUpDown, Plus } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
@@ -15,63 +17,84 @@ import {
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { ChevronsUpDown, Plus } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
type Team = {
|
||||
name: string;
|
||||
logo: React.ElementType;
|
||||
plan: string;
|
||||
};
|
||||
// Switches the active clinic (organization). Scopes every subsequent patient
|
||||
// API call. Replaces the old static "team switcher".
|
||||
export function OrgSwitcher() {
|
||||
const { isMobile, state } = useSidebar();
|
||||
const isCollapsed = state === "collapsed";
|
||||
const router = useRouter();
|
||||
const { data: orgs } = authClient.useListOrganizations();
|
||||
const { data: activeOrg } = authClient.useActiveOrganization();
|
||||
|
||||
export function TeamSwitcher({ teams }: { teams: Team[] }) {
|
||||
const { isMobile } = useSidebar();
|
||||
const [activeTeam, setActiveTeam] = React.useState(teams[0]);
|
||||
const setActive = async (organizationId: string) => {
|
||||
if (organizationId === activeOrg?.id) return;
|
||||
await authClient.organization.setActive({ organizationId });
|
||||
};
|
||||
|
||||
if (!activeTeam) return null;
|
||||
|
||||
const Logo = activeTeam.logo;
|
||||
const activeName = activeOrg?.name ?? "Select clinic";
|
||||
|
||||
return (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={<SidebarMenuButton size="lg" className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground" />}><div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-background text-foreground">
|
||||
<Logo className="size-4" />
|
||||
</div><div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">
|
||||
{activeTeam.name}
|
||||
</span>
|
||||
<span className="truncate text-xs">{activeTeam.plan}</span>
|
||||
</div><ChevronsUpDown className="ml-auto" /></DropdownMenuTrigger>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<SidebarMenuButton
|
||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
size="lg"
|
||||
tooltip={activeName}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-background text-foreground">
|
||||
<Building2 className="size-4" />
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">{activeName}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
Clinic
|
||||
</span>
|
||||
</div>
|
||||
<ChevronsUpDown className="ml-auto size-4" />
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg mb-4"
|
||||
align="start"
|
||||
side={isMobile ? "bottom" : "right"}
|
||||
className="min-w-56 rounded-lg"
|
||||
side={isMobile ? "bottom" : isCollapsed ? "right" : "bottom"}
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
||||
Teams
|
||||
Clinics
|
||||
</DropdownMenuLabel>
|
||||
{teams.map((team, index) => (
|
||||
{(orgs ?? []).map((org) => (
|
||||
<DropdownMenuItem
|
||||
key={team.name}
|
||||
onClick={() => setActiveTeam(team)}
|
||||
className="gap-2 p-2"
|
||||
key={org.id}
|
||||
onClick={() => setActive(org.id)}
|
||||
>
|
||||
<div className="flex size-6 items-center justify-center rounded-sm border">
|
||||
<team.logo className="size-4 shrink-0" />
|
||||
<Building2 className="size-4 shrink-0" />
|
||||
</div>
|
||||
{team.name}
|
||||
<DropdownMenuShortcut>⌘{index + 1}</DropdownMenuShortcut>
|
||||
<span className="truncate">{org.name}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="gap-2 p-2">
|
||||
<DropdownMenuItem
|
||||
className="gap-2 p-2"
|
||||
onClick={() => router.push("/onboarding")}
|
||||
>
|
||||
<div className="flex size-6 items-center justify-center rounded-md border bg-background">
|
||||
<Plus className="size-4" />
|
||||
</div>
|
||||
<div className="font-medium text-muted-foreground">Add team</div>
|
||||
<div className="font-medium text-muted-foreground">
|
||||
Create clinic
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
Reference in New Issue
Block a user