File
Blob: src/client/pages/account/identities.tsx
| 1 | import { useQuery, useQueryClient } from "@tanstack/react-query"; |
| 2 | import { Github, Unlink } from "lucide-react"; |
| 3 | import { useState } from "react"; |
| 4 | |
| 5 | import { BackLink } from "@/client/components/ui/back-link"; |
| 6 | import { Button } from "@/client/components/ui/button"; |
| 7 | import { Card } from "@/client/components/ui/card"; |
| 8 | import { ConfirmButton } from "@/client/components/ui/confirm-button"; |
| 9 | import { GoogleIcon } from "@/client/components/ui/google-icon"; |
| 10 | import { PageError } from "@/client/components/ui/page-error"; |
| 11 | import { PageHeader } from "@/client/components/ui/page-header"; |
| 12 | import { PanelRow } from "@/client/components/ui/row"; |
| 13 | import { Skeleton } from "@/client/components/ui/skeleton"; |
| 14 | import { useRequireSession } from "@/client/hooks/use-auth-guard"; |
| 15 | import { authClient } from "@/client/lib/auth-client"; |
| 16 | import { type SocialProvider, useClientConfig } from "@/client/lib/config"; |
| 17 | import { formatDate } from "@/client/lib/format"; |
| 18 | import { queryKeys } from "@/client/lib/query"; |
| 19 | |
| 20 | interface LinkedAccount { |
| 21 | id: string; |
| 22 | providerId: string; |
| 23 | accountId: string; |
| 24 | createdAt: string | Date; |
| 25 | } |
| 26 | |
| 27 | const PROVIDER_LABEL: Record<string, string> = { |
| 28 | credential: "Email + password", |
| 29 | github: "GitHub", |
| 30 | google: "Google", |
| 31 | }; |
| 32 | |
| 33 | const 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 | |
| 50 | const 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 | |
| 66 | export 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 | }; |