Skip to content
File

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

typescript166 lines
1import { useQuery, useQueryClient } from "@tanstack/react-query";
2import { ExternalLink, Trash2 } from "lucide-react";
3import { useState } from "react";
4 
5import { BackLink } from "@/client/components/ui/back-link";
6import { Card } from "@/client/components/ui/card";
7import { ConfirmButton } from "@/client/components/ui/confirm-button";
8import { PageError } from "@/client/components/ui/page-error";
9import { PageHeader } from "@/client/components/ui/page-header";
10import { PanelRow } from "@/client/components/ui/row";
11import { Skeleton } from "@/client/components/ui/skeleton";
12import { useRequireSession } from "@/client/hooks/use-auth-guard";
13import { formatDate } from "@/client/lib/format";
14import { apiRequest, responseMessage } from "@/client/lib/http";
15import { fetchJson, queryKeys } from "@/client/lib/query";
16 
17interface ConnectedApp {
18 id: string;
19 client_id: string;
20 client_name: string | null;
21 client_uri: string | null;
22 scopes: string[];
23 granted_at: string | null;
24 updated_at: string | null;
25}
26 
27const AppRowsSkeleton = ({ count }: { count: number }) => (
28 <div className="flex flex-col gap-3" aria-busy="true">
29 {Array.from({ length: count }, (_, i) => (
30 <div
31 key={i}
32 className="flex flex-col gap-3 rounded-xl border border-zinc-800/60 bg-zinc-900/40 px-4 py-3 sm:flex-row sm:items-start sm:justify-between"
33 >
34 <div className="min-w-0 flex-1 space-y-2">
35 <Skeleton className="h-4 w-40" />
36 <Skeleton className="h-3 w-56" />
37 <div className="flex gap-1 pt-1">
38 <Skeleton className="h-4 w-12 rounded" />
39 <Skeleton className="h-4 w-16 rounded" />
40 <Skeleton className="h-4 w-10 rounded" />
41 </div>
42 <Skeleton className="h-3 w-32" />
43 </div>
44 <Skeleton className="h-9 w-20 rounded-lg" />
45 </div>
46 ))}
47 </div>
48);
49 
50const AccountAppsPageSkeleton = () => (
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-48" />
55 <Skeleton className="h-4 w-full max-w-xl" />
56 </div>
57 <div className="rounded-2xl border border-zinc-800/60 bg-zinc-900/40 p-5 sm:p-6">
58 <Skeleton className="mb-4 h-5 w-28" />
59 <AppRowsSkeleton count={2} />
60 </div>
61 </div>
62);
63 
64export const AccountAppsPage = () => {
65 const session = useRequireSession("/account/apps");
66 const queryClient = useQueryClient();
67 const userId = session.data?.user.id ?? null;
68 const appsQueryKey = queryKeys.accountApps(userId);
69 const { data: apps = null } = useQuery({
70 queryKey: appsQueryKey,
71 queryFn: () => fetchJson<ConnectedApp[]>("/api/account/connected-apps"),
72 enabled: Boolean(userId),
73 });
74 const [error, setError] = useState<string | null>(null);
75 const [pending, setPending] = useState<string | null>(null);
76 
77 const handleRevoke = async (app: ConnectedApp) => {
78 setError(null);
79 setPending(app.id);
80 try {
81 const res = await apiRequest(`/api/account/connected-apps/${encodeURIComponent(app.id)}`, {
82 method: "DELETE",
83 });
84 if (!res.ok && res.status !== 204) {
85 setError(await responseMessage(res, "Could not revoke access."));
86 return;
87 }
88 await queryClient.invalidateQueries({ queryKey: appsQueryKey, exact: true });
89 } catch (e) {
90 setError(e instanceof Error ? e.message : "Failed to revoke access.");
91 } finally {
92 setPending(null);
93 }
94 };
95 
96 if (session.isPending || !session.data) {
97 return <AccountAppsPageSkeleton />;
98 }
99 
100 return (
101 <div className="mx-auto max-w-3xl">
102 <BackLink to="/account">Account</BackLink>
103 <PageHeader
104 title="Connected apps"
105 description="Third-party apps you've authorized to sign you in. Revoking removes the grant and clears any active tokens."
106 />
107 <PageError message={error} onDismiss={() => setError(null)} />
108 <Card>
109 <h2 className="mb-4 text-lg font-semibold text-zinc-100">Authorized</h2>
110 {!apps ? (
111 <AppRowsSkeleton count={2} />
112 ) : (
113 <div className="flex flex-col gap-3">
114 {apps.map((app) => (
115 <PanelRow key={app.id} className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
116 <div className="min-w-0 flex-1">
117 <div className="flex flex-wrap items-center gap-2">
118 <p className="text-sm font-medium text-zinc-100">{app.client_name ?? app.client_id}</p>
119 {app.client_uri ? (
120 <a
121 href={app.client_uri}
122 target="_blank"
123 rel="noopener noreferrer"
124 className="inline-flex items-center gap-0.5 text-xs text-zinc-500 hover:text-zinc-300"
125 >
126 <ExternalLink className="h-3 w-3" aria-hidden="true" />
127 visit
128 </a>
129 ) : null}
130 </div>
131 <code className="mt-1 block truncate text-xs text-zinc-500">{app.client_id}</code>
132 {Array.isArray(app.scopes) && app.scopes.length > 0 ? (
133 <div className="mt-2 flex flex-wrap gap-1">
134 {app.scopes.map((s) => (
135 <code
136 key={s}
137 className="rounded bg-zinc-800/60 px-1.5 py-0.5 font-mono text-[0.7rem] text-zinc-300"
138 >
139 {s}
140 </code>
141 ))}
142 </div>
143 ) : null}
144 {app.granted_at ? (
145 <p className="mt-2 text-xs text-zinc-500">Authorized {formatDate(app.granted_at)}</p>
146 ) : null}
147 </div>
148 <ConfirmButton
149 variant="ghost"
150 size="sm"
151 loading={pending === app.id}
152 onConfirm={() => handleRevoke(app)}
153 >
154 <Trash2 className="h-4 w-4" aria-hidden="true" />
155 Revoke
156 </ConfirmButton>
157 </PanelRow>
158 ))}
159 {apps.length === 0 ? <p className="text-sm text-zinc-500">No third-party apps authorized yet.</p> : null}
160 </div>
161 )}
162 </Card>
163 </div>
164 );
165};