Skip to content
File

Blob: src/client/pages/account/identities.tsx

typescript213 lines
1import { useQuery, useQueryClient } from "@tanstack/react-query";
2import { Github, Unlink } from "lucide-react";
3import { useState } from "react";
4 
5import { BackLink } from "@/client/components/ui/back-link";
6import { Button } from "@/client/components/ui/button";
7import { Card } from "@/client/components/ui/card";
8import { ConfirmButton } from "@/client/components/ui/confirm-button";
9import { GoogleIcon } from "@/client/components/ui/google-icon";
10import { PageError } from "@/client/components/ui/page-error";
11import { PageHeader } from "@/client/components/ui/page-header";
12import { PanelRow } from "@/client/components/ui/row";
13import { Skeleton } from "@/client/components/ui/skeleton";
14import { useRequireSession } from "@/client/hooks/use-auth-guard";
15import { authClient } from "@/client/lib/auth-client";
16import { type SocialProvider, useClientConfig } from "@/client/lib/config";
17import { formatDate } from "@/client/lib/format";
18import { queryKeys } from "@/client/lib/query";
19 
20interface LinkedAccount {
21 id: string;
22 providerId: string;
23 accountId: string;
24 createdAt: string | Date;
25}
26 
27const PROVIDER_LABEL: Record<string, string> = {
28 credential: "Email + password",
29 github: "GitHub",
30 google: "Google",
31};
32 
33const IdentityRowsSkeleton = ({ count }: { count: number }) => (
34 <div className="flex flex-col gap-3" aria-busy="true">
35 {Array.from({ length: count }, (_, i) => (
36 <div
37 key={i}
38 className="flex items-center justify-between rounded-xl border border-zinc-800/60 bg-zinc-900/40 px-4 py-3"
39 >
40 <div className="space-y-2">
41 <Skeleton className="h-4 w-32" />
42 <Skeleton className="h-3 w-40" />
43 </div>
44 <Skeleton className="h-9 w-20 rounded-lg" />
45 </div>
46 ))}
47 </div>
48);
49 
50const AccountIdentitiesPageSkeleton = () => (
51 <div className="mx-auto max-w-3xl" aria-busy="true">
52 <Skeleton className="mb-6 h-4 w-24" />
53 <div className="mb-8">
54 <Skeleton className="mb-3 h-8 w-36" />
55 <Skeleton className="h-4 w-full max-w-lg" />
56 </div>
57 <div className="rounded-2xl border border-zinc-800/60 bg-zinc-900/40 p-5 sm:p-6">
58 <div className="mb-4 flex flex-wrap items-end justify-between gap-x-4 gap-y-2">
59 <Skeleton className="h-5 w-40" />
60 </div>
61 <IdentityRowsSkeleton count={3} />
62 </div>
63 </div>
64);
65 
66export const AccountIdentitiesPage = () => {
67 const session = useRequireSession("/account/identities");
68 const queryClient = useQueryClient();
69 const [error, setError] = useState<string | null>(null);
70 const [pending, setPending] = useState<string | null>(null);
71 const config = useClientConfig();
72 const socialProviders: SocialProvider[] = config?.status === "ok" ? config.socialProviders : [];
73 
74 const userId = session.data?.user.id ?? null;
75 const accountsQueryKey = queryKeys.accountIdentities(userId);
76 const accountsQuery = useQuery({
77 queryKey: accountsQueryKey,
78 queryFn: async () => {
79 const res = await authClient.listAccounts();
80 if (res.error) throw new Error(res.error.message ?? "Could not load identities.");
81 return (res.data ?? []) as unknown as LinkedAccount[];
82 },
83 enabled: Boolean(userId),
84 });
85 const accounts = accountsQuery.data ?? null;
86 
87 const credentialCount = accounts?.filter((a) => a.providerId === "credential").length ?? 0;
88 const totalAccounts = accounts?.length ?? 0;
89 const linked = (provider: string) => Boolean(accounts?.find((a) => a.providerId === provider));
90 
91 const handleLink = async (provider: "github" | "google") => {
92 setError(null);
93 setPending(provider);
94 try {
95 const res = await authClient.linkSocial({ provider, callbackURL: "/account/identities" });
96 if (res.error) {
97 setError(
98 res.error.message ??
99 `Could not link ${provider}. The provider may not be configured on this server — ask your operator.`,
100 );
101 setPending(null);
102 return;
103 }
104 if (res.data?.url) {
105 window.location.assign(res.data.url);
106 return;
107 }
108 setError(`Could not link ${provider}: no redirect URL returned.`);
109 setPending(null);
110 } catch (e) {
111 setError(e instanceof Error ? e.message : `Failed to link ${provider}.`);
112 setPending(null);
113 }
114 };
115 
116 const handleUnlink = async (account: LinkedAccount) => {
117 setError(null);
118 if (account.providerId === "credential" && credentialCount <= 1 && (accounts?.length ?? 0) <= 1) {
119 setError("Cannot unlink your only credential.");
120 return;
121 }
122 setPending(account.id);
123 try {
124 const res = await authClient.unlinkAccount({
125 accountId: account.id,
126 });
127 if (res.error) {
128 setError(res.error.message ?? `Could not unlink ${account.providerId}.`);
129 return;
130 }
131 await queryClient.invalidateQueries({ queryKey: accountsQueryKey, exact: true });
132 } catch (e) {
133 setError(e instanceof Error ? e.message : "Failed to unlink account.");
134 } finally {
135 setPending(null);
136 }
137 };
138 
139 if (session.isPending || !session.data) {
140 return <AccountIdentitiesPageSkeleton />;
141 }
142 
143 return (
144 <div className="mx-auto max-w-3xl">
145 <BackLink to="/account">Account</BackLink>
146 <PageHeader
147 title="Identities"
148 description="The credentials you can use to sign in. Link a social provider to skip the password next time."
149 />
150 <PageError message={error} onDismiss={() => setError(null)} />
151 <Card>
152 <div className="mb-4 flex flex-wrap items-end justify-between gap-x-4 gap-y-2">
153 <h2 className="text-lg font-semibold text-zinc-100">Linked identities</h2>
154 {accounts ? (
155 <div className="flex flex-wrap gap-2">
156 {socialProviders.includes("github") && !linked("github") ? (
157 <Button variant="ghost" size="sm" loading={pending === "github"} onClick={() => handleLink("github")}>
158 <Github className="h-4 w-4" aria-hidden="true" />
159 Link GitHub
160 </Button>
161 ) : null}
162 {socialProviders.includes("google") && !linked("google") ? (
163 <Button variant="ghost" size="sm" loading={pending === "google"} onClick={() => handleLink("google")}>
164 <GoogleIcon className="h-4 w-4" />
165 Link Google
166 </Button>
167 ) : null}
168 </div>
169 ) : null}
170 </div>
171 {accounts === null ? (
172 <IdentityRowsSkeleton count={3} />
173 ) : (
174 <div className="flex flex-col gap-3">
175 {accounts.map((account) => {
176 // Block the unlink that would leave the user with zero ways
177 // to sign in. That's the credential row when it's the only
178 // credential and there are no socials, OR any social row
179 // when it's the only account on the user.
180 const isLastCredential = account.providerId === "credential" && credentialCount <= 1;
181 const isLastAccount = totalAccounts <= 1;
182 const canUnlink = !isLastCredential && !isLastAccount;
183 return (
184 <PanelRow key={account.id} className="flex items-center justify-between">
185 <div>
186 <p className="text-sm font-medium text-zinc-100">
187 {PROVIDER_LABEL[account.providerId] ?? account.providerId}
188 </p>
189 <p className="text-xs text-zinc-500">Linked {formatDate(account.createdAt)}</p>
190 </div>
191 {canUnlink ? (
192 <ConfirmButton
193 variant="ghost"
194 size="sm"
195 loading={pending === account.id}
196 onConfirm={() => handleUnlink(account)}
197 >
198 <Unlink className="h-4 w-4" aria-hidden="true" />
199 Unlink
200 </ConfirmButton>
201 ) : (
202 <span className="text-xs italic text-zinc-500">Last sign-in method.</span>
203 )}
204 </PanelRow>
205 );
206 })}
207 </div>
208 )}
209 </Card>
210 </div>
211 );
212};