diff --git a/web/src/app/dashboard/settings/api-clients/page.tsx b/web/src/app/dashboard/settings/api-clients/page.tsx index e3232fd..086abe8 100644 --- a/web/src/app/dashboard/settings/api-clients/page.tsx +++ b/web/src/app/dashboard/settings/api-clients/page.tsx @@ -1,7 +1,14 @@ "use client"; import { useCallback, useEffect, useState } from "react"; -import { Copy, Check, Trash2, X, ExternalLink } from "lucide-react"; +import { + AlertTriangle, + Copy, + Check, + Trash2, + X, + ExternalLink, +} from "lucide-react"; import { useConfig } from "@/lib/config-context"; import { useCurrentUser } from "@/hooks/use-current-user"; @@ -11,7 +18,10 @@ import { updateApiClient, deleteApiClient, } from "@/lib/api"; -import type { ApiClientSummary, CreateApiClientResponse } from "@/types/api-clients"; +import type { + ApiClientSummary, + CreateApiClientResponse, +} from "@/types/api-clients"; import { SiteHeader } from "@/components/site-header"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -70,7 +80,10 @@ function MultiInput({ onChange(next); } - function handleKeyDown(index: number, e: React.KeyboardEvent) { + function handleKeyDown( + index: number, + e: React.KeyboardEvent, + ) { if (e.key === "Backspace" && values[index] === "" && values.length > 1) { e.preventDefault(); handleRemove(index); @@ -182,7 +195,9 @@ export default function ApiClientsPage() { {client.name} - {client.client_type === "public" ? "Public" : "Confidential"} + {client.client_type === "public" + ? "Public" + : "Confidential"} @@ -193,9 +208,14 @@ export default function ApiClientsPage() {
- {client.scopes.split(/\s+/).filter(Boolean).map((scope) => ( - {scope} - ))} + {client.scopes + .split(/\s+/) + .filter(Boolean) + .map((scope) => ( + + {scope} + + ))}
@@ -208,7 +228,8 @@ export default function ApiClientsPage() { {client.parent_client_id - ? clients.find((c) => c.id === client.parent_client_id)?.name ?? client.parent_client_id + ? (clients.find((c) => c.id === client.parent_client_id) + ?.name ?? client.parent_client_id) : "—"} @@ -220,7 +241,10 @@ export default function ApiClientsPage() { )} {hasPermission("api-clients:delete") && ( - + )} @@ -238,7 +262,9 @@ function CreateApiClientDialog({ onSuccess }: { onSuccess: () => void }) { const config = useConfig(); const happyviewCallbackUri = `${config.public_url.replace(/\/$/, "")}/auth/callback`; - const [clientType, setClientType] = useState<"confidential" | "public">("confidential"); + const [clientType, setClientType] = useState<"confidential" | "public">( + "confidential", + ); const [name, setName] = useState(""); const [clientIdUrl, setClientIdUrl] = useState(""); const [clientUri, setClientUri] = useState(""); @@ -247,10 +273,10 @@ function CreateApiClientDialog({ onSuccess }: { onSuccess: () => void }) { const [scopes, setScopes] = useState([""]); const [rateLimitEnabled, setRateLimitEnabled] = useState(true); const [rateLimitCapacity, setRateLimitCapacity] = useState( - String(config.default_rate_limit_capacity) + String(config.default_rate_limit_capacity), ); const [rateLimitRefillRate, setRateLimitRefillRate] = useState( - String(config.default_rate_limit_refill_rate) + String(config.default_rate_limit_refill_rate), ); const [error, setError] = useState(null); const [open, setOpen] = useState(false); @@ -300,7 +326,9 @@ function CreateApiClientDialog({ onSuccess }: { onSuccess: () => void }) { return; } try { - const filteredOrigins = allowedOrigins.map((o) => o.trim()).filter(Boolean); + const filteredOrigins = allowedOrigins + .map((o) => o.trim()) + .filter(Boolean); const result = await createApiClient({ name: name.trim(), client_id_url: clientIdUrl.trim(), @@ -308,9 +336,16 @@ function CreateApiClientDialog({ onSuccess }: { onSuccess: () => void }) { redirect_uris: allUris, scopes: allScopes, client_type: clientType, - allowed_origins: clientType === "public" && filteredOrigins.length > 0 ? filteredOrigins : undefined, - rate_limit_capacity: rateLimitEnabled ? Number(rateLimitCapacity) : null, - rate_limit_refill_rate: rateLimitEnabled ? Number(rateLimitRefillRate) : null, + allowed_origins: + clientType === "public" && filteredOrigins.length > 0 + ? filteredOrigins + : undefined, + rate_limit_capacity: rateLimitEnabled + ? Number(rateLimitCapacity) + : null, + rate_limit_refill_rate: rateLimitEnabled + ? Number(rateLimitRefillRate) + : null, }); setCreated(result); } catch (e: unknown) { @@ -361,8 +396,11 @@ function CreateApiClientDialog({ onSuccess }: { onSuccess: () => void }) {

- Public identifier. Send as the X-Client-Key header - or client_key query parameter. + Public identifier. Send as the{" "} + X-Client-Key{" "} + header or{" "} + client_key query + parameter.

{created.client_secret ? ( @@ -388,14 +426,17 @@ function CreateApiClientDialog({ onSuccess }: { onSuccess: () => void }) {

- Keep this secret. Send as the X-Client-Secret header - for server-to-server requests. Browser requests are validated by Origin instead. + Keep this secret. Send as the{" "} + X-Client-Secret{" "} + header for server-to-server requests. Browser requests are + validated by Origin instead.

) : (

- This is a public client. Authenticate using PKCE instead of a client secret. + This is a public client. Authenticate using PKCE instead of a + client secret.

void }) { )}
) : ( -
+
{error &&

{error}

}
Client Type setClientType(v as "confidential" | "public")} + onValueChange={(v) => + setClientType(v as "confidential" | "public") + } className="flex flex-col gap-3" >
- +
- +

- Server-side applications that can securely store a client secret. + Server-side applications that can securely store a client + secret.

- +
- +

- Browser or native apps that authenticate using PKCE (no secret). + Browser or native apps that authenticate using PKCE (no + secret).

@@ -475,10 +538,12 @@ function CreateApiClientDialog({ onSuccess }: { onSuccess: () => void }) {
- Redirect URIs + + Redirect URIs +

- URLs that the authorization server may redirect to after authentication. - The AppView callback is always included. + URLs that the authorization server may redirect to after + authentication. The AppView callback is always included.

void }) {
{clientType === "public" && (
- Allowed Origins + + Allowed Origins +

- Origins permitted to use this client. Requests from unlisted origins will be - rejected. Leave empty to allow any origin. + Origins permitted to use this client. Requests from unlisted + origins will be rejected. Leave empty to allow any origin.

void }) {
Scopes

- OAuth scopes this client is allowed to request. The atproto scope - is always required. + OAuth scopes this client is allowed to request. The{" "} + atproto scope is + always required.

void }) { placeholder="scope.name" readonlyValues={["atproto"]} /> + {scopes.some((s) => s.trim() === "transition:generic") && ( +
+ )}
- Rate Limiting + + Rate Limiting +
- +

- Each client gets a token bucket. Requests consume tokens and the bucket - refills over time. When the bucket is empty, requests are rejected until - tokens replenish. + Each client gets a token bucket. Requests consume tokens and the + bucket refills over time. When the bucket is empty, requests are + rejected until tokens replenish.

@@ -613,21 +703,23 @@ function EditApiClientDialog({ const [name, setName] = useState(client.name); const [redirectUris, setRedirectUris] = useState( - parseRedirectUris(client.redirect_uris) + parseRedirectUris(client.redirect_uris), ); const [allowedOrigins, setAllowedOrigins] = useState( - parseAllowedOrigins(client.allowed_origins) + parseAllowedOrigins(client.allowed_origins), ); const [scopes, setScopes] = useState(parseScopes(client.scopes)); const [isActive, setIsActive] = useState(client.is_active); const [rateLimitEnabled, setRateLimitEnabled] = useState( - client.rate_limit_capacity != null && client.rate_limit_refill_rate != null + client.rate_limit_capacity != null && client.rate_limit_refill_rate != null, ); const [rateLimitCapacity, setRateLimitCapacity] = useState( - String(client.rate_limit_capacity ?? config.default_rate_limit_capacity) + String(client.rate_limit_capacity ?? config.default_rate_limit_capacity), ); const [rateLimitRefillRate, setRateLimitRefillRate] = useState( - String(client.rate_limit_refill_rate ?? config.default_rate_limit_refill_rate) + String( + client.rate_limit_refill_rate ?? config.default_rate_limit_refill_rate, + ), ); const [error, setError] = useState(null); const [open, setOpen] = useState(false); @@ -642,13 +734,19 @@ function EditApiClientDialog({ setScopes(parseScopes(client.scopes)); setIsActive(client.is_active); setRateLimitEnabled( - client.rate_limit_capacity != null && client.rate_limit_refill_rate != null + client.rate_limit_capacity != null && + client.rate_limit_refill_rate != null, ); setRateLimitCapacity( - String(client.rate_limit_capacity ?? config.default_rate_limit_capacity) + String( + client.rate_limit_capacity ?? config.default_rate_limit_capacity, + ), ); setRateLimitRefillRate( - String(client.rate_limit_refill_rate ?? config.default_rate_limit_refill_rate) + String( + client.rate_limit_refill_rate ?? + config.default_rate_limit_refill_rate, + ), ); setError(null); } @@ -667,17 +765,26 @@ function EditApiClientDialog({ const extraScopes = scopes.map((s) => s.trim()).filter(Boolean); const allScopes = ["atproto", ...extraScopes].join(" "); - const filteredOrigins = allowedOrigins.map((o) => o.trim()).filter(Boolean); + const filteredOrigins = allowedOrigins + .map((o) => o.trim()) + .filter(Boolean); await updateApiClient(client.id, { name: name.trim() || undefined, redirect_uris: allUris, scopes: allScopes, - allowed_origins: client.client_type === "public" - ? (filteredOrigins.length > 0 ? filteredOrigins : null) - : undefined, + allowed_origins: + client.client_type === "public" + ? filteredOrigins.length > 0 + ? filteredOrigins + : null + : undefined, is_active: isActive, - rate_limit_capacity: rateLimitEnabled ? Number(rateLimitCapacity) : null, - rate_limit_refill_rate: rateLimitEnabled ? Number(rateLimitRefillRate) : null, + rate_limit_capacity: rateLimitEnabled + ? Number(rateLimitCapacity) + : null, + rate_limit_refill_rate: rateLimitEnabled + ? Number(rateLimitRefillRate) + : null, }); setOpen(false); onSuccess(); @@ -720,14 +827,16 @@ function EditApiClientDialog({ checked={isActive} onCheckedChange={setIsActive} /> - +
Redirect URIs

- URLs that the authorization server may redirect to after authentication. - The AppView callback is always included. + URLs that the authorization server may redirect to after + authentication. The AppView callback is always included.

{client.client_type === "public" && (
- Allowed Origins + + Allowed Origins +

- Origins permitted to use this client. Requests from unlisted origins will be - rejected. Leave empty to allow any origin. + Origins permitted to use this client. Requests from unlisted + origins will be rejected. Leave empty to allow any origin.

Scopes

- OAuth scopes this client is allowed to request. The atproto scope - is always required. + OAuth scopes this client is allowed to request. The{" "} + atproto scope is + always required.

+ {scopes.some((s) => s.trim() === "transition:generic") && ( +
+ +

+ transition:generic grants + broad write access to any collection. Prefer specific scopes + or{" "} + + permission sets + {" "} + to follow the principle of least privilege. +

+
+ )}
Rate Limiting @@ -774,12 +905,14 @@ function EditApiClientDialog({ checked={rateLimitEnabled} onCheckedChange={setRateLimitEnabled} /> - +

- Each client gets a token bucket. Requests consume tokens and the bucket - refills over time. When the bucket is empty, requests are rejected until - tokens replenish. + Each client gets a token bucket. Requests consume tokens and the + bucket refills over time. When the bucket is empty, requests are + rejected until tokens replenish.

@@ -865,9 +998,9 @@ function DeleteApiClientDialog({ Delete API Client - This will permanently delete “{client.name}” and revoke its - OAuth identity. Any applications using this client will lose the ability - to authenticate. + This will permanently delete “{client.name}” and revoke + its OAuth identity. Any applications using this client will lose the + ability to authenticate.