Skip to content
File

Blob: src/client/components/header.tsx

typescript197 lines
1import { ArrowRight, LogOut, ShieldCheck } from "lucide-react";
2import { useState } from "react";
3import { Link, NavLink, useLocation, useNavigate } from "react-router";
4 
5import { authClient, isAdminSession, useSession } from "@/client/lib/auth-client";
6import { signInPath } from "@/client/lib/navigation";
7import { Skeleton } from "@/client/components/ui/skeleton";
8 
9const navLinkClass = ({ isActive }: { isActive: boolean }): string =>
10 [
11 // Stay reachable on small screens by letting the nav row scroll
12 // horizontally instead of disappearing behind a hamburger menu โ€”
13 // tessera's nav is short enough to stay visible.
14 "shrink-0 rounded-lg px-3 py-1.5 text-sm font-medium transition-colors",
15 isActive ? "bg-accent-500/10 text-accent-400" : "text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200",
16 ].join(" ");
17 
18const HeaderBrand = ({ href }: { href: string }) => (
19 <Link
20 to={href}
21 className="group flex shrink-0 items-center gap-3 transition-opacity hover:opacity-80"
22 aria-label="tessera"
23 >
24 <span className="inline-grid h-9 w-9 place-items-center">
25 <ShieldCheck
26 className="h-6 w-6 text-accent-400 transition-transform duration-200 group-hover:-rotate-6"
27 strokeWidth={2}
28 aria-hidden="true"
29 />
30 </span>
31 <span className="hidden leading-tight sm:block">
32 <strong className="block text-sm font-semibold text-zinc-100">tessera</strong>
33 <small className="block text-xs text-zinc-400">An identification mark.</small>
34 </span>
35 </Link>
36);
37 
38const SignInPill = () => {
39 const { pathname, search } = useLocation();
40 if (pathname === "/sign-in") return null;
41 const here = `${pathname}${search}`;
42 return (
43 <Link
44 to={signInPath(here)}
45 className="inline-flex shrink-0 items-center gap-1.5 rounded-lg border border-zinc-800/60 bg-zinc-900/70 px-2.5 py-1.5 text-xs font-medium text-accent-400 transition-colors hover:border-accent-500/40 hover:bg-zinc-800/60 hover:text-accent-300"
46 >
47 Sign in
48 <ArrowRight className="h-3.5 w-3.5" aria-hidden="true" />
49 </Link>
50 );
51};
52 
53interface SignedInUserProps {
54 name: string;
55 email: string;
56 refetch: () => void;
57}
58 
59const SignedInUser = ({ name, email, refetch }: SignedInUserProps) => {
60 const navigate = useNavigate();
61 const [signingOut, setSigningOut] = useState(false);
62 const [failed, setFailed] = useState(false);
63 const handleSignOut = async () => {
64 if (signingOut) return;
65 setSigningOut(true);
66 setFailed(false);
67 try {
68 await authClient.signOut();
69 // Better Auth's signOut clears the cookie server-side but doesn't push
70 // the cleared state into the nanostore. Refetch so the header (and any
71 // other useSession consumer) sees the user is gone before we navigate.
72 await refetch();
73 navigate("/");
74 } catch {
75 // Server may have cleared the session even if our response path tripped.
76 // Refetch first so the header reflects server truth โ€” if the session is
77 // actually gone, this component unmounts before the failed label paints.
78 try {
79 await refetch();
80 } catch {
81 // ignore: the failed label still surfaces the sign-out failure even
82 // if refetch itself errors.
83 }
84 setFailed(true);
85 } finally {
86 setSigningOut(false);
87 }
88 };
89 const display = name.trim() || email;
90 const buttonClass = failed
91 ? "inline-flex items-center gap-1.5 rounded-lg border border-red-500/30 bg-red-500/10 px-2.5 py-1.5 text-xs text-red-300 transition-colors hover:border-red-500/50 hover:bg-red-500/20 hover:text-red-200 disabled:cursor-not-allowed disabled:opacity-60"
92 : "inline-flex items-center gap-1.5 rounded-lg border border-zinc-800/60 bg-zinc-900/70 px-2.5 py-1.5 text-xs text-zinc-300 transition-colors hover:border-zinc-700/60 hover:bg-zinc-800/60 hover:text-zinc-100 disabled:cursor-not-allowed disabled:opacity-60";
93 const label = signingOut ? "Signing out..." : failed ? "Retry sign-out" : "Sign out";
94 return (
95 <div className="flex shrink-0 items-center gap-2">
96 <span className="hidden max-w-[200px] truncate text-xs text-zinc-500 lg:block" title={email}>
97 {display}
98 </span>
99 <button
100 type="button"
101 onClick={handleSignOut}
102 disabled={signingOut}
103 aria-busy={signingOut || undefined}
104 className={buttonClass}
105 aria-label={failed ? "Sign-out failed. Press to retry." : "Sign out"}
106 title={failed ? "Sign-out failed. Press to retry." : undefined}
107 >
108 <LogOut className="h-3.5 w-3.5" aria-hidden="true" />
109 <span className="hidden sm:inline">{label}</span>
110 </button>
111 </div>
112 );
113};
114 
115interface HeaderProps {
116 variant: "public" | "app" | "loading";
117}
118 
119export const Header = ({ variant }: HeaderProps) => {
120 const session = useSession();
121 const isAdmin = isAdminSession(session);
122 const isAuthenticated = Boolean(session.data);
123 // Brand always points home โ€” `/` is the launcher when signed in and
124 // the marketing page when not. Routing signed-in users to /account
125 // would strand them with no path back to the launcher except the URL
126 // bar.
127 const brandHref = "/";
128 
129 if (variant === "loading") {
130 return (
131 <header className="sticky top-0 z-50 w-full border-b border-zinc-800/60 bg-zinc-900/95" aria-busy="true">
132 <div className="mx-auto flex max-w-7xl items-center justify-between gap-3 px-4 py-3 sm:px-6">
133 <HeaderBrand href={brandHref} />
134 <Skeleton className="h-8 w-20 rounded-lg" />
135 </div>
136 </header>
137 );
138 }
139 
140 if (variant === "public") {
141 return (
142 <header className="sticky top-0 z-50 w-full border-b border-zinc-800/60 bg-zinc-900/95">
143 <div className="mx-auto flex max-w-7xl items-center justify-between gap-3 px-4 py-3 sm:px-6">
144 <HeaderBrand href={brandHref} />
145 {isAuthenticated && session.data ? (
146 <SignedInUser name={session.data.user.name} email={session.data.user.email} refetch={session.refetch} />
147 ) : (
148 <SignInPill />
149 )}
150 </div>
151 </header>
152 );
153 }
154 
155 return (
156 <header className="sticky top-0 z-50 w-full border-b border-zinc-800/60 bg-zinc-900/95">
157 <div className="mx-auto flex max-w-7xl items-center gap-4 px-4 py-3 sm:px-6">
158 <HeaderBrand href={brandHref} />
159
160 {isAuthenticated ? (
161 <nav
162 aria-label="Main navigation"
163 className="-mx-2 flex flex-1 items-center gap-1 overflow-x-auto px-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
164 >
165 <NavLink to="/account" className={navLinkClass}>
166 Account
167 </NavLink>
168 {isAdmin ? (
169 <>
170 <span aria-hidden="true" className="mx-1 h-4 w-px shrink-0 bg-zinc-800" />
171 <NavLink to="/admin/invites" className={navLinkClass}>
172 Invites
173 </NavLink>
174 <NavLink to="/admin/clients" className={navLinkClass}>
175 Clients
176 </NavLink>
177 <NavLink to="/admin/launcher-apps" className={navLinkClass}>
178 Launcher
179 </NavLink>
180 <NavLink to="/admin/users" className={navLinkClass}>
181 Users
182 </NavLink>
183 </>
184 ) : null}
185 </nav>
186 ) : (
187 <span className="flex-1" />
188 )}
189
190 {isAuthenticated && session.data ? (
191 <SignedInUser name={session.data.user.name} email={session.data.user.email} refetch={session.refetch} />
192 ) : null}
193 </div>
194 </header>
195 );
196};