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.

feat(dashboard): warn users before they use `transition:generic`

Trezy 7d4c44a7 8ed28e51

+204 -71
+204 -71
web/src/app/dashboard/settings/api-clients/page.tsx
··· 1 1 "use client"; 2 2 3 3 import { useCallback, useEffect, useState } from "react"; 4 - import { Copy, Check, Trash2, X, ExternalLink } from "lucide-react"; 4 + import { 5 + AlertTriangle, 6 + Copy, 7 + Check, 8 + Trash2, 9 + X, 10 + ExternalLink, 11 + } from "lucide-react"; 5 12 6 13 import { useConfig } from "@/lib/config-context"; 7 14 import { useCurrentUser } from "@/hooks/use-current-user"; ··· 11 18 updateApiClient, 12 19 deleteApiClient, 13 20 } from "@/lib/api"; 14 - import type { ApiClientSummary, CreateApiClientResponse } from "@/types/api-clients"; 21 + import type { 22 + ApiClientSummary, 23 + CreateApiClientResponse, 24 + } from "@/types/api-clients"; 15 25 import { SiteHeader } from "@/components/site-header"; 16 26 import { Badge } from "@/components/ui/badge"; 17 27 import { Button } from "@/components/ui/button"; ··· 70 80 onChange(next); 71 81 } 72 82 73 - function handleKeyDown(index: number, e: React.KeyboardEvent<HTMLInputElement>) { 83 + function handleKeyDown( 84 + index: number, 85 + e: React.KeyboardEvent<HTMLInputElement>, 86 + ) { 74 87 if (e.key === "Backspace" && values[index] === "" && values.length > 1) { 75 88 e.preventDefault(); 76 89 handleRemove(index); ··· 182 195 <TableCell className="font-medium">{client.name}</TableCell> 183 196 <TableCell> 184 197 <Badge variant="outline"> 185 - {client.client_type === "public" ? "Public" : "Confidential"} 198 + {client.client_type === "public" 199 + ? "Public" 200 + : "Confidential"} 186 201 </Badge> 187 202 </TableCell> 188 203 <TableCell className="font-mono text-sm"> ··· 193 208 </TableCell> 194 209 <TableCell> 195 210 <div className="flex flex-wrap gap-1"> 196 - {client.scopes.split(/\s+/).filter(Boolean).map((scope) => ( 197 - <Badge key={scope} variant="secondary">{scope}</Badge> 198 - ))} 211 + {client.scopes 212 + .split(/\s+/) 213 + .filter(Boolean) 214 + .map((scope) => ( 215 + <Badge key={scope} variant="secondary"> 216 + {scope} 217 + </Badge> 218 + ))} 199 219 </div> 200 220 </TableCell> 201 221 <TableCell> ··· 208 228 </TableCell> 209 229 <TableCell className="text-sm"> 210 230 {client.parent_client_id 211 - ? clients.find((c) => c.id === client.parent_client_id)?.name ?? client.parent_client_id 231 + ? (clients.find((c) => c.id === client.parent_client_id) 232 + ?.name ?? client.parent_client_id) 212 233 : "—"} 213 234 </TableCell> 214 235 <TableCell className="text-sm max-w-48 truncate"> ··· 220 241 <EditApiClientDialog client={client} onSuccess={load} /> 221 242 )} 222 243 {hasPermission("api-clients:delete") && ( 223 - <DeleteApiClientDialog client={client} onSuccess={load} /> 244 + <DeleteApiClientDialog 245 + client={client} 246 + onSuccess={load} 247 + /> 224 248 )} 225 249 </div> 226 250 </TableCell> ··· 238 262 const config = useConfig(); 239 263 const happyviewCallbackUri = `${config.public_url.replace(/\/$/, "")}/auth/callback`; 240 264 241 - const [clientType, setClientType] = useState<"confidential" | "public">("confidential"); 265 + const [clientType, setClientType] = useState<"confidential" | "public">( 266 + "confidential", 267 + ); 242 268 const [name, setName] = useState(""); 243 269 const [clientIdUrl, setClientIdUrl] = useState(""); 244 270 const [clientUri, setClientUri] = useState(""); ··· 247 273 const [scopes, setScopes] = useState<string[]>([""]); 248 274 const [rateLimitEnabled, setRateLimitEnabled] = useState(true); 249 275 const [rateLimitCapacity, setRateLimitCapacity] = useState( 250 - String(config.default_rate_limit_capacity) 276 + String(config.default_rate_limit_capacity), 251 277 ); 252 278 const [rateLimitRefillRate, setRateLimitRefillRate] = useState( 253 - String(config.default_rate_limit_refill_rate) 279 + String(config.default_rate_limit_refill_rate), 254 280 ); 255 281 const [error, setError] = useState<string | null>(null); 256 282 const [open, setOpen] = useState(false); ··· 300 326 return; 301 327 } 302 328 try { 303 - const filteredOrigins = allowedOrigins.map((o) => o.trim()).filter(Boolean); 329 + const filteredOrigins = allowedOrigins 330 + .map((o) => o.trim()) 331 + .filter(Boolean); 304 332 const result = await createApiClient({ 305 333 name: name.trim(), 306 334 client_id_url: clientIdUrl.trim(), ··· 308 336 redirect_uris: allUris, 309 337 scopes: allScopes, 310 338 client_type: clientType, 311 - allowed_origins: clientType === "public" && filteredOrigins.length > 0 ? filteredOrigins : undefined, 312 - rate_limit_capacity: rateLimitEnabled ? Number(rateLimitCapacity) : null, 313 - rate_limit_refill_rate: rateLimitEnabled ? Number(rateLimitRefillRate) : null, 339 + allowed_origins: 340 + clientType === "public" && filteredOrigins.length > 0 341 + ? filteredOrigins 342 + : undefined, 343 + rate_limit_capacity: rateLimitEnabled 344 + ? Number(rateLimitCapacity) 345 + : null, 346 + rate_limit_refill_rate: rateLimitEnabled 347 + ? Number(rateLimitRefillRate) 348 + : null, 314 349 }); 315 350 setCreated(result); 316 351 } catch (e: unknown) { ··· 361 396 </Button> 362 397 </div> 363 398 <p className="text-muted-foreground text-xs"> 364 - Public identifier. Send as the <code className="bg-muted px-1 rounded">X-Client-Key</code> header 365 - or <code className="bg-muted px-1 rounded">client_key</code> query parameter. 399 + Public identifier. Send as the{" "} 400 + <code className="bg-muted px-1 rounded">X-Client-Key</code>{" "} 401 + header or{" "} 402 + <code className="bg-muted px-1 rounded">client_key</code> query 403 + parameter. 366 404 </p> 367 405 </div> 368 406 {created.client_secret ? ( ··· 388 426 </Button> 389 427 </div> 390 428 <p className="text-muted-foreground text-xs"> 391 - Keep this secret. Send as the <code className="bg-muted px-1 rounded">X-Client-Secret</code> header 392 - for server-to-server requests. Browser requests are validated by Origin instead. 429 + Keep this secret. Send as the{" "} 430 + <code className="bg-muted px-1 rounded">X-Client-Secret</code>{" "} 431 + header for server-to-server requests. Browser requests are 432 + validated by Origin instead. 393 433 </p> 394 434 </div> 395 435 ) : ( 396 436 <div className="flex flex-col gap-2 rounded-lg border p-4 bg-muted/50"> 397 437 <p className="text-sm"> 398 - This is a public client. Authenticate using PKCE instead of a client secret. 438 + This is a public client. Authenticate using PKCE instead of a 439 + client secret. 399 440 </p> 400 441 <a 401 442 href="/docs/getting-started/authentication#pkce" ··· 410 451 )} 411 452 </div> 412 453 ) : ( 413 - <div className="flex flex-col gap-4 max-h-[60vh] overflow-y-auto"> 454 + <div className="flex flex-col gap-4 max-h-[60vh] overflow-y-auto"> 414 455 {error && <p className="text-destructive text-sm">{error}</p>} 415 456 <fieldset className="flex flex-col gap-3 rounded-lg border p-4"> 416 457 <legend className="text-sm font-medium px-1">Client Type</legend> 417 458 <RadioGroup 418 459 value={clientType} 419 - onValueChange={(v) => setClientType(v as "confidential" | "public")} 460 + onValueChange={(v) => 461 + setClientType(v as "confidential" | "public") 462 + } 420 463 className="flex flex-col gap-3" 421 464 > 422 465 <div className="flex items-start gap-3"> 423 - <RadioGroupItem value="confidential" id="type-confidential" className="mt-0.5" /> 466 + <RadioGroupItem 467 + value="confidential" 468 + id="type-confidential" 469 + className="mt-0.5" 470 + /> 424 471 <div className="flex flex-col gap-0.5"> 425 - <Label htmlFor="type-confidential" className="cursor-pointer font-medium">Confidential</Label> 472 + <Label 473 + htmlFor="type-confidential" 474 + className="cursor-pointer font-medium" 475 + > 476 + Confidential 477 + </Label> 426 478 <p className="text-muted-foreground text-xs"> 427 - Server-side applications that can securely store a client secret. 479 + Server-side applications that can securely store a client 480 + secret. 428 481 </p> 429 482 </div> 430 483 </div> 431 484 <div className="flex items-start gap-3"> 432 - <RadioGroupItem value="public" id="type-public" className="mt-0.5" /> 485 + <RadioGroupItem 486 + value="public" 487 + id="type-public" 488 + className="mt-0.5" 489 + /> 433 490 <div className="flex flex-col gap-0.5"> 434 - <Label htmlFor="type-public" className="cursor-pointer font-medium">Public</Label> 491 + <Label 492 + htmlFor="type-public" 493 + className="cursor-pointer font-medium" 494 + > 495 + Public 496 + </Label> 435 497 <p className="text-muted-foreground text-xs"> 436 - Browser or native apps that authenticate using PKCE (no secret). 498 + Browser or native apps that authenticate using PKCE (no 499 + secret). 437 500 </p> 438 501 </div> 439 502 </div> ··· 475 538 </div> 476 539 </fieldset> 477 540 <fieldset className="flex flex-col gap-3 rounded-lg border p-4"> 478 - <legend className="text-sm font-medium px-1">Redirect URIs</legend> 541 + <legend className="text-sm font-medium px-1"> 542 + Redirect URIs 543 + </legend> 479 544 <p className="text-muted-foreground text-xs"> 480 - URLs that the authorization server may redirect to after authentication. 481 - The AppView callback is always included. 545 + URLs that the authorization server may redirect to after 546 + authentication. The AppView callback is always included. 482 547 </p> 483 548 <MultiInput 484 549 id="redirect-uris" ··· 490 555 </fieldset> 491 556 {clientType === "public" && ( 492 557 <fieldset className="flex flex-col gap-3 rounded-lg border p-4"> 493 - <legend className="text-sm font-medium px-1">Allowed Origins</legend> 558 + <legend className="text-sm font-medium px-1"> 559 + Allowed Origins 560 + </legend> 494 561 <p className="text-muted-foreground text-xs"> 495 - Origins permitted to use this client. Requests from unlisted origins will be 496 - rejected. Leave empty to allow any origin. 562 + Origins permitted to use this client. Requests from unlisted 563 + origins will be rejected. Leave empty to allow any origin. 497 564 </p> 498 565 <MultiInput 499 566 id="allowed-origins" ··· 506 573 <fieldset className="flex flex-col gap-3 rounded-lg border p-4"> 507 574 <legend className="text-sm font-medium px-1">Scopes</legend> 508 575 <p className="text-muted-foreground text-xs"> 509 - OAuth scopes this client is allowed to request. The <code className="bg-muted px-1 rounded">atproto</code> scope 510 - is always required. 576 + OAuth scopes this client is allowed to request. The{" "} 577 + <code className="bg-muted px-1 rounded">atproto</code> scope is 578 + always required. 511 579 </p> 512 580 <MultiInput 513 581 id="scopes" ··· 516 584 placeholder="scope.name" 517 585 readonlyValues={["atproto"]} 518 586 /> 587 + {scopes.some((s) => s.trim() === "transition:generic") && ( 588 + <div className="flex items-start gap-3 rounded-lg border border-amber-500/50 bg-amber-500/10 p-3"> 589 + <AlertTriangle className="size-4 text-amber-500 shrink-0 mt-0.5" /> 590 + <p className="text-xs text-amber-500"> 591 + <code>transition:generic</code> grants broad write access to 592 + any collection. Prefer specific scopes or{" "} 593 + <a 594 + href="https://docs.happyview.dev/guides/features/api-clients#permission-sets" 595 + target="_blank" 596 + rel="noopener noreferrer" 597 + className="underline" 598 + > 599 + permission sets 600 + </a> 601 + . 602 + </p> 603 + </div> 604 + )} 519 605 </fieldset> 520 606 <fieldset className="flex flex-col gap-3 rounded-lg border p-4"> 521 - <legend className="text-sm font-medium px-1">Rate Limiting</legend> 607 + <legend className="text-sm font-medium px-1"> 608 + Rate Limiting 609 + </legend> 522 610 <div className="flex items-center gap-3"> 523 611 <Switch 524 612 id="rl-enabled" 525 613 checked={rateLimitEnabled} 526 614 onCheckedChange={setRateLimitEnabled} 527 615 /> 528 - <Label htmlFor="rl-enabled" className="cursor-pointer">Enabled</Label> 616 + <Label htmlFor="rl-enabled" className="cursor-pointer"> 617 + Enabled 618 + </Label> 529 619 </div> 530 620 <p className="text-muted-foreground text-xs"> 531 - Each client gets a token bucket. Requests consume tokens and the bucket 532 - refills over time. When the bucket is empty, requests are rejected until 533 - tokens replenish. 621 + Each client gets a token bucket. Requests consume tokens and the 622 + bucket refills over time. When the bucket is empty, requests are 623 + rejected until tokens replenish. 534 624 </p> 535 625 <div className="grid grid-cols-2 gap-4"> 536 626 <div className="flex flex-col gap-2"> ··· 613 703 614 704 const [name, setName] = useState(client.name); 615 705 const [redirectUris, setRedirectUris] = useState<string[]>( 616 - parseRedirectUris(client.redirect_uris) 706 + parseRedirectUris(client.redirect_uris), 617 707 ); 618 708 const [allowedOrigins, setAllowedOrigins] = useState<string[]>( 619 - parseAllowedOrigins(client.allowed_origins) 709 + parseAllowedOrigins(client.allowed_origins), 620 710 ); 621 711 const [scopes, setScopes] = useState<string[]>(parseScopes(client.scopes)); 622 712 const [isActive, setIsActive] = useState(client.is_active); 623 713 const [rateLimitEnabled, setRateLimitEnabled] = useState( 624 - client.rate_limit_capacity != null && client.rate_limit_refill_rate != null 714 + client.rate_limit_capacity != null && client.rate_limit_refill_rate != null, 625 715 ); 626 716 const [rateLimitCapacity, setRateLimitCapacity] = useState( 627 - String(client.rate_limit_capacity ?? config.default_rate_limit_capacity) 717 + String(client.rate_limit_capacity ?? config.default_rate_limit_capacity), 628 718 ); 629 719 const [rateLimitRefillRate, setRateLimitRefillRate] = useState( 630 - String(client.rate_limit_refill_rate ?? config.default_rate_limit_refill_rate) 720 + String( 721 + client.rate_limit_refill_rate ?? config.default_rate_limit_refill_rate, 722 + ), 631 723 ); 632 724 const [error, setError] = useState<string | null>(null); 633 725 const [open, setOpen] = useState(false); ··· 642 734 setScopes(parseScopes(client.scopes)); 643 735 setIsActive(client.is_active); 644 736 setRateLimitEnabled( 645 - client.rate_limit_capacity != null && client.rate_limit_refill_rate != null 737 + client.rate_limit_capacity != null && 738 + client.rate_limit_refill_rate != null, 646 739 ); 647 740 setRateLimitCapacity( 648 - String(client.rate_limit_capacity ?? config.default_rate_limit_capacity) 741 + String( 742 + client.rate_limit_capacity ?? config.default_rate_limit_capacity, 743 + ), 649 744 ); 650 745 setRateLimitRefillRate( 651 - String(client.rate_limit_refill_rate ?? config.default_rate_limit_refill_rate) 746 + String( 747 + client.rate_limit_refill_rate ?? 748 + config.default_rate_limit_refill_rate, 749 + ), 652 750 ); 653 751 setError(null); 654 752 } ··· 667 765 const extraScopes = scopes.map((s) => s.trim()).filter(Boolean); 668 766 const allScopes = ["atproto", ...extraScopes].join(" "); 669 767 670 - const filteredOrigins = allowedOrigins.map((o) => o.trim()).filter(Boolean); 768 + const filteredOrigins = allowedOrigins 769 + .map((o) => o.trim()) 770 + .filter(Boolean); 671 771 await updateApiClient(client.id, { 672 772 name: name.trim() || undefined, 673 773 redirect_uris: allUris, 674 774 scopes: allScopes, 675 - allowed_origins: client.client_type === "public" 676 - ? (filteredOrigins.length > 0 ? filteredOrigins : null) 677 - : undefined, 775 + allowed_origins: 776 + client.client_type === "public" 777 + ? filteredOrigins.length > 0 778 + ? filteredOrigins 779 + : null 780 + : undefined, 678 781 is_active: isActive, 679 - rate_limit_capacity: rateLimitEnabled ? Number(rateLimitCapacity) : null, 680 - rate_limit_refill_rate: rateLimitEnabled ? Number(rateLimitRefillRate) : null, 782 + rate_limit_capacity: rateLimitEnabled 783 + ? Number(rateLimitCapacity) 784 + : null, 785 + rate_limit_refill_rate: rateLimitEnabled 786 + ? Number(rateLimitRefillRate) 787 + : null, 681 788 }); 682 789 setOpen(false); 683 790 onSuccess(); ··· 720 827 checked={isActive} 721 828 onCheckedChange={setIsActive} 722 829 /> 723 - <Label htmlFor="edit-active" className="cursor-pointer">Active</Label> 830 + <Label htmlFor="edit-active" className="cursor-pointer"> 831 + Active 832 + </Label> 724 833 </div> 725 834 </fieldset> 726 835 <fieldset className="flex flex-col gap-3 rounded-lg border p-4"> 727 836 <legend className="text-sm font-medium px-1">Redirect URIs</legend> 728 837 <p className="text-muted-foreground text-xs"> 729 - URLs that the authorization server may redirect to after authentication. 730 - The AppView callback is always included. 838 + URLs that the authorization server may redirect to after 839 + authentication. The AppView callback is always included. 731 840 </p> 732 841 <MultiInput 733 842 id="edit-redirect-uris" ··· 739 848 </fieldset> 740 849 {client.client_type === "public" && ( 741 850 <fieldset className="flex flex-col gap-3 rounded-lg border p-4"> 742 - <legend className="text-sm font-medium px-1">Allowed Origins</legend> 851 + <legend className="text-sm font-medium px-1"> 852 + Allowed Origins 853 + </legend> 743 854 <p className="text-muted-foreground text-xs"> 744 - Origins permitted to use this client. Requests from unlisted origins will be 745 - rejected. Leave empty to allow any origin. 855 + Origins permitted to use this client. Requests from unlisted 856 + origins will be rejected. Leave empty to allow any origin. 746 857 </p> 747 858 <MultiInput 748 859 id="edit-allowed-origins" ··· 755 866 <fieldset className="flex flex-col gap-3 rounded-lg border p-4"> 756 867 <legend className="text-sm font-medium px-1">Scopes</legend> 757 868 <p className="text-muted-foreground text-xs"> 758 - OAuth scopes this client is allowed to request. The <code className="bg-muted px-1 rounded">atproto</code> scope 759 - is always required. 869 + OAuth scopes this client is allowed to request. The{" "} 870 + <code className="bg-muted px-1 rounded">atproto</code> scope is 871 + always required. 760 872 </p> 761 873 <MultiInput 762 874 id="edit-scopes" ··· 765 877 placeholder="scope.name" 766 878 readonlyValues={["atproto"]} 767 879 /> 880 + {scopes.some((s) => s.trim() === "transition:generic") && ( 881 + <div className="flex items-start gap-3 rounded-lg border border-amber-500/50 bg-amber-500/10 p-3"> 882 + <AlertTriangle className="size-4 text-amber-500 shrink-0 mt-0.5" /> 883 + <p className="text-xs text-amber-500"> 884 + <span className="font-medium">transition:generic</span> grants 885 + broad write access to any collection. Prefer specific scopes 886 + or{" "} 887 + <a 888 + href="https://docs.happyview.dev/guides/features/api-clients#permission-sets" 889 + target="_blank" 890 + rel="noopener noreferrer" 891 + className="underline" 892 + > 893 + permission sets 894 + </a>{" "} 895 + to follow the principle of least privilege. 896 + </p> 897 + </div> 898 + )} 768 899 </fieldset> 769 900 <fieldset className="flex flex-col gap-3 rounded-lg border p-4"> 770 901 <legend className="text-sm font-medium px-1">Rate Limiting</legend> ··· 774 905 checked={rateLimitEnabled} 775 906 onCheckedChange={setRateLimitEnabled} 776 907 /> 777 - <Label htmlFor="edit-rl-enabled" className="cursor-pointer">Enabled</Label> 908 + <Label htmlFor="edit-rl-enabled" className="cursor-pointer"> 909 + Enabled 910 + </Label> 778 911 </div> 779 912 <p className="text-muted-foreground text-xs"> 780 - Each client gets a token bucket. Requests consume tokens and the bucket 781 - refills over time. When the bucket is empty, requests are rejected until 782 - tokens replenish. 913 + Each client gets a token bucket. Requests consume tokens and the 914 + bucket refills over time. When the bucket is empty, requests are 915 + rejected until tokens replenish. 783 916 </p> 784 917 <div className="grid grid-cols-2 gap-4"> 785 918 <div className="flex flex-col gap-2"> ··· 865 998 <ResponsiveDialogHeader> 866 999 <ResponsiveDialogTitle>Delete API Client</ResponsiveDialogTitle> 867 1000 <ResponsiveDialogDescription> 868 - This will permanently delete &ldquo;{client.name}&rdquo; and revoke its 869 - OAuth identity. Any applications using this client will lose the ability 870 - to authenticate. 1001 + This will permanently delete &ldquo;{client.name}&rdquo; and revoke 1002 + its OAuth identity. Any applications using this client will lose the 1003 + ability to authenticate. 871 1004 </ResponsiveDialogDescription> 872 1005 </ResponsiveDialogHeader> 873 1006 <ResponsiveDialogFooter>