A lexicon-driven AppView for ATProto. happyview.dev
backfill firehose jetstream atproto appview oauth lexicon
8
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: remove linked accounts page

Trezy 84eb1dce e33a9892

-441
-435
web/src/app/dashboard/settings/accounts/page.tsx
··· 1 - "use client"; 2 - 3 - import { useCallback, useEffect, useState } from "react"; 4 - import { Link2, Unlink, RefreshCw, ExternalLink, Key } from "lucide-react"; 5 - 6 - import { 7 - getExternalProviders, 8 - getLinkedAccounts, 9 - authorizeExternal, 10 - syncExternal, 11 - unlinkExternal, 12 - connectWithConfig, 13 - } from "@/lib/api"; 14 - import type { ExternalProvider, LinkedAccount } from "@/types/external-accounts"; 15 - import { Input } from "@/components/ui/input"; 16 - import { Label } from "@/components/ui/label"; 17 - import { SiteHeader } from "@/components/site-header"; 18 - import { Button } from "@/components/ui/button"; 19 - import { Badge } from "@/components/ui/badge"; 20 - import { 21 - ResponsiveDialog, 22 - ResponsiveDialogClose, 23 - ResponsiveDialogContent, 24 - ResponsiveDialogDescription, 25 - ResponsiveDialogFooter, 26 - ResponsiveDialogHeader, 27 - ResponsiveDialogTitle, 28 - } from "@/components/ui/responsive-dialog"; 29 - import { 30 - Table, 31 - TableBody, 32 - TableCell, 33 - TableHead, 34 - TableHeader, 35 - TableRow, 36 - } from "@/components/ui/table"; 37 - import { 38 - Card, 39 - CardContent, 40 - CardDescription, 41 - CardHeader, 42 - CardTitle, 43 - } from "@/components/ui/card"; 44 - 45 - export default function LinkedAccountsPage() { 46 - const [providers, setProviders] = useState<ExternalProvider[]>([]); 47 - const [accounts, setAccounts] = useState<LinkedAccount[]>([]); 48 - const [error, setError] = useState<string | null>(null); 49 - const [unlinkId, setUnlinkId] = useState<string | null>(null); 50 - const [unlinking, setUnlinking] = useState(false); 51 - const [syncing, setSyncing] = useState<string | null>(null); 52 - const [syncResult, setSyncResult] = useState<{ pluginId: string; written: number } | null>(null); 53 - // API key config dialog state 54 - const [configProvider, setConfigProvider] = useState<ExternalProvider | null>(null); 55 - const [configValues, setConfigValues] = useState<Record<string, string>>({}); 56 - const [connecting, setConnecting] = useState(false); 57 - 58 - const load = useCallback(async () => { 59 - try { 60 - const [providerList, accountList] = await Promise.all([ 61 - getExternalProviders(), 62 - getLinkedAccounts(), 63 - ]); 64 - setProviders(providerList); 65 - setAccounts(accountList); 66 - setError(null); 67 - } catch (e) { 68 - setError(e instanceof Error ? e.message : String(e)); 69 - } 70 - }, []); 71 - 72 - useEffect(() => { 73 - load(); 74 - }, [load]); 75 - 76 - async function handleConnect(pluginId: string) { 77 - const provider = providers.find((p) => p.id === pluginId); 78 - if (!provider) return; 79 - 80 - // For API key auth, show config dialog instead of redirecting 81 - if (provider.auth_type === "api_key" && provider.config_schema) { 82 - setConfigProvider(provider); 83 - setConfigValues({}); 84 - return; 85 - } 86 - 87 - // For OAuth/OpenID, redirect to provider 88 - try { 89 - const redirectUri = window.location.href; 90 - const result = await authorizeExternal(pluginId, redirectUri); 91 - window.location.href = result.authorize_url; 92 - } catch (e) { 93 - setError(e instanceof Error ? e.message : String(e)); 94 - } 95 - } 96 - 97 - async function handleConfigSubmit() { 98 - if (!configProvider) return; 99 - 100 - setConnecting(true); 101 - setError(null); 102 - try { 103 - await connectWithConfig(configProvider.id, configValues); 104 - setConfigProvider(null); 105 - setConfigValues({}); 106 - load(); 107 - } catch (e) { 108 - setError(e instanceof Error ? e.message : String(e)); 109 - } finally { 110 - setConnecting(false); 111 - } 112 - } 113 - 114 - async function handleSync(pluginId: string) { 115 - setSyncing(pluginId); 116 - setSyncResult(null); 117 - try { 118 - const result = await syncExternal(pluginId); 119 - setSyncResult({ pluginId, written: result.written }); 120 - setError(null); 121 - } catch (e) { 122 - setError(e instanceof Error ? e.message : String(e)); 123 - } finally { 124 - setSyncing(null); 125 - } 126 - } 127 - 128 - async function handleUnlink(pluginId: string) { 129 - setUnlinking(true); 130 - try { 131 - await unlinkExternal(pluginId); 132 - setUnlinkId(null); 133 - load(); 134 - } catch (e) { 135 - setError(e instanceof Error ? e.message : String(e)); 136 - } finally { 137 - setUnlinking(false); 138 - } 139 - } 140 - 141 - // Build a map of linked accounts by plugin_id 142 - const linkedByPlugin = new Map(accounts.map((a) => [a.plugin_id, a])); 143 - 144 - return ( 145 - <> 146 - <SiteHeader title="Linked Accounts" /> 147 - <div className="flex flex-1 flex-col gap-6 p-4 md:p-6"> 148 - {error && <p className="text-destructive text-sm">{error}</p>} 149 - 150 - {syncResult && ( 151 - <div className="rounded-lg border border-green-200 bg-green-50 p-4 dark:border-green-900 dark:bg-green-950"> 152 - <p className="text-green-800 dark:text-green-200"> 153 - Sync complete: {syncResult.written} records written to your PDS 154 - </p> 155 - </div> 156 - )} 157 - 158 - <div> 159 - <h2 className="text-lg font-semibold">External Account Providers</h2> 160 - <p className="text-muted-foreground text-sm"> 161 - Connect external platforms to sync data to your AT Protocol repository. 162 - </p> 163 - </div> 164 - 165 - {providers.length === 0 ? ( 166 - <Card> 167 - <CardHeader> 168 - <CardTitle>No Providers Available</CardTitle> 169 - <CardDescription> 170 - No external account plugins are currently loaded. Contact your 171 - administrator to install plugins. 172 - </CardDescription> 173 - </CardHeader> 174 - </Card> 175 - ) : ( 176 - <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> 177 - {providers.map((provider) => { 178 - const linked = linkedByPlugin.get(provider.id); 179 - const isSyncing = syncing === provider.id; 180 - 181 - return ( 182 - <Card key={provider.id}> 183 - <CardHeader className="pb-3"> 184 - <div className="flex items-center justify-between"> 185 - <CardTitle className="flex items-center gap-2"> 186 - {provider.icon_url && ( 187 - <img 188 - src={provider.icon_url} 189 - alt="" 190 - className="size-6" 191 - /> 192 - )} 193 - {provider.name} 194 - </CardTitle> 195 - {linked && ( 196 - <Badge className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"> 197 - Connected 198 - </Badge> 199 - )} 200 - </div> 201 - </CardHeader> 202 - <CardContent> 203 - {linked ? ( 204 - <div className="flex flex-col gap-3"> 205 - <div className="text-muted-foreground text-sm"> 206 - <span className="font-medium">Account ID:</span>{" "} 207 - <code className="text-xs">{linked.account_id}</code> 208 - </div> 209 - <div className="text-muted-foreground text-xs"> 210 - Connected {new Date(linked.created_at).toLocaleDateString()} 211 - </div> 212 - <div className="flex gap-2"> 213 - <Button 214 - size="sm" 215 - variant="outline" 216 - onClick={() => handleSync(provider.id)} 217 - disabled={isSyncing} 218 - > 219 - <RefreshCw 220 - className={`mr-1 size-4 ${isSyncing ? "animate-spin" : ""}`} 221 - /> 222 - {isSyncing ? "Syncing..." : "Sync Now"} 223 - </Button> 224 - <Button 225 - size="sm" 226 - variant="ghost" 227 - className="text-destructive hover:text-destructive" 228 - onClick={() => setUnlinkId(provider.id)} 229 - > 230 - <Unlink className="mr-1 size-4" /> 231 - Unlink 232 - </Button> 233 - </div> 234 - </div> 235 - ) : ( 236 - <Button 237 - size="sm" 238 - onClick={() => handleConnect(provider.id)} 239 - > 240 - <Link2 className="mr-1 size-4" /> 241 - Connect {provider.name} 242 - </Button> 243 - )} 244 - </CardContent> 245 - </Card> 246 - ); 247 - })} 248 - </div> 249 - )} 250 - 251 - {accounts.length > 0 && ( 252 - <> 253 - <div className="mt-4"> 254 - <h2 className="text-lg font-semibold">Connected Accounts</h2> 255 - <p className="text-muted-foreground text-sm"> 256 - Your linked external accounts and their sync status. 257 - </p> 258 - </div> 259 - 260 - <div className="overflow-clip rounded-lg border"> 261 - <Table> 262 - <TableHeader> 263 - <TableRow> 264 - <TableHead>Provider</TableHead> 265 - <TableHead>Account ID</TableHead> 266 - <TableHead>Connected</TableHead> 267 - <TableHead>Last Updated</TableHead> 268 - <TableHead className="w-32" /> 269 - </TableRow> 270 - </TableHeader> 271 - <TableBody> 272 - {accounts.map((account) => { 273 - const provider = providers.find( 274 - (p) => p.id === account.plugin_id 275 - ); 276 - const isSyncing = syncing === account.plugin_id; 277 - 278 - return ( 279 - <TableRow key={account.plugin_id}> 280 - <TableCell className="font-medium"> 281 - {provider?.name ?? account.plugin_id} 282 - </TableCell> 283 - <TableCell> 284 - <code className="text-xs">{account.account_id}</code> 285 - </TableCell> 286 - <TableCell> 287 - {new Date(account.created_at).toLocaleString()} 288 - </TableCell> 289 - <TableCell> 290 - {new Date(account.updated_at).toLocaleString()} 291 - </TableCell> 292 - <TableCell> 293 - <div className="flex gap-1"> 294 - <Button 295 - variant="ghost" 296 - size="icon" 297 - className="size-8" 298 - title="Sync account" 299 - onClick={() => handleSync(account.plugin_id)} 300 - disabled={isSyncing} 301 - > 302 - <RefreshCw 303 - className={`size-4 ${isSyncing ? "animate-spin" : ""}`} 304 - /> 305 - </Button> 306 - <Button 307 - variant="ghost" 308 - size="icon" 309 - className="size-8 text-destructive hover:text-destructive" 310 - title="Unlink account" 311 - onClick={() => setUnlinkId(account.plugin_id)} 312 - > 313 - <Unlink className="size-4" /> 314 - </Button> 315 - </div> 316 - </TableCell> 317 - </TableRow> 318 - ); 319 - })} 320 - </TableBody> 321 - </Table> 322 - </div> 323 - </> 324 - )} 325 - </div> 326 - 327 - <ResponsiveDialog 328 - open={!!unlinkId} 329 - onOpenChange={(open) => { 330 - if (!open) setUnlinkId(null); 331 - }} 332 - > 333 - <ResponsiveDialogContent> 334 - <ResponsiveDialogHeader> 335 - <ResponsiveDialogTitle>Unlink account?</ResponsiveDialogTitle> 336 - <ResponsiveDialogDescription> 337 - This will disconnect your external account and remove the stored 338 - credentials. You can reconnect at any time. 339 - </ResponsiveDialogDescription> 340 - </ResponsiveDialogHeader> 341 - {unlinkId && ( 342 - <p className="text-muted-foreground text-sm"> 343 - Provider:{" "} 344 - <span className="font-medium"> 345 - {providers.find((p) => p.id === unlinkId)?.name ?? unlinkId} 346 - </span> 347 - </p> 348 - )} 349 - <ResponsiveDialogFooter> 350 - <ResponsiveDialogClose asChild> 351 - <Button variant="outline" disabled={unlinking}> 352 - Cancel 353 - </Button> 354 - </ResponsiveDialogClose> 355 - <Button 356 - variant="destructive" 357 - disabled={unlinking} 358 - onClick={() => { 359 - if (unlinkId) handleUnlink(unlinkId); 360 - }} 361 - > 362 - {unlinking ? "Unlinking..." : "Unlink"} 363 - </Button> 364 - </ResponsiveDialogFooter> 365 - </ResponsiveDialogContent> 366 - </ResponsiveDialog> 367 - 368 - {/* API Key / Config Dialog */} 369 - <ResponsiveDialog 370 - open={!!configProvider} 371 - onOpenChange={(open) => { 372 - if (!open) { 373 - setConfigProvider(null); 374 - setConfigValues({}); 375 - } 376 - }} 377 - > 378 - <ResponsiveDialogContent> 379 - <ResponsiveDialogHeader> 380 - <ResponsiveDialogTitle className="flex items-center gap-2"> 381 - {configProvider?.icon_url && ( 382 - <img src={configProvider.icon_url} alt="" className="size-5" /> 383 - )} 384 - Connect {configProvider?.name} 385 - </ResponsiveDialogTitle> 386 - <ResponsiveDialogDescription> 387 - Enter your credentials to connect this account. 388 - </ResponsiveDialogDescription> 389 - </ResponsiveDialogHeader> 390 - 391 - {configProvider?.config_schema && ( 392 - <div className="grid gap-4 py-4"> 393 - {Object.entries(configProvider.config_schema.properties).map( 394 - ([key, prop]) => ( 395 - <div key={key} className="grid gap-2"> 396 - <Label htmlFor={key}>{prop.title ?? key}</Label> 397 - <Input 398 - id={key} 399 - type={prop.format === "password" ? "password" : "text"} 400 - placeholder={prop.description} 401 - value={configValues[key] ?? ""} 402 - onChange={(e) => 403 - setConfigValues((prev) => ({ 404 - ...prev, 405 - [key]: e.target.value, 406 - })) 407 - } 408 - /> 409 - {prop.description && ( 410 - <p className="text-muted-foreground text-xs"> 411 - {prop.description} 412 - </p> 413 - )} 414 - </div> 415 - ) 416 - )} 417 - </div> 418 - )} 419 - 420 - <ResponsiveDialogFooter> 421 - <ResponsiveDialogClose asChild> 422 - <Button variant="outline" disabled={connecting}> 423 - Cancel 424 - </Button> 425 - </ResponsiveDialogClose> 426 - <Button disabled={connecting} onClick={handleConfigSubmit}> 427 - <Key className="mr-1 size-4" /> 428 - {connecting ? "Connecting..." : "Connect"} 429 - </Button> 430 - </ResponsiveDialogFooter> 431 - </ResponsiveDialogContent> 432 - </ResponsiveDialog> 433 - </> 434 - ); 435 - }
-6
web/src/components/app-sidebar.tsx
··· 12 12 IconKey, 13 13 IconVariable, 14 14 IconTag, 15 - IconLink, 16 15 IconPuzzle, 17 16 IconSettings, 18 17 IconInfoCircle, ··· 89 88 url: "/dashboard/settings/plugins", 90 89 icon: IconPuzzle, 91 90 requiredPermissions: ["plugins:read"], 92 - }, 93 - { 94 - title: "Linked Accounts", 95 - url: "/dashboard/settings/accounts", 96 - icon: IconLink, 97 91 }, 98 92 { 99 93 title: "Labelers",