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: external auth infra

Trezy 1677721f b0d9af35

+764 -3
+104
src/external_auth/routes.rs
··· 18 18 pub fn routes() -> Router<AppState> { 19 19 Router::new() 20 20 .route("/providers", get(list_providers)) 21 + .route("/accounts", get(list_accounts)) 21 22 .route("/{plugin_id}/authorize", get(authorize)) 22 23 .route("/{plugin_id}/callback", get(callback)) 24 + .route("/{plugin_id}/connect", post(connect_with_config)) 23 25 .route("/{plugin_id}/sync", post(sync)) 24 26 .route("/{plugin_id}/unlink", post(unlink)) 25 27 } ··· 29 31 id: String, 30 32 name: String, 31 33 icon_url: Option<String>, 34 + auth_type: String, 35 + #[serde(skip_serializing_if = "Option::is_none")] 36 + config_schema: Option<serde_json::Value>, 32 37 } 33 38 34 39 async fn list_providers( ··· 42 47 id: p.info.id.clone(), 43 48 name: p.info.name.clone(), 44 49 icon_url: p.info.icon_url.clone(), 50 + auth_type: p.info.auth_type.clone(), 51 + config_schema: p.info.config_schema.clone(), 45 52 }) 46 53 .collect(); 47 54 48 55 Ok(Json(providers)) 56 + } 57 + 58 + async fn list_accounts( 59 + State(app_state): State<AppState>, 60 + claims: Claims, 61 + ) -> Result<Json<Vec<tokens::LinkedAccountSummary>>, AppError> { 62 + let accounts = tokens::list_linked_accounts(&app_state.db, app_state.db_backend, claims.did()) 63 + .await 64 + .map_err(|e| AppError::Internal(e.to_string()))?; 65 + 66 + Ok(Json(accounts)) 49 67 } 50 68 51 69 #[derive(Deserialize)] ··· 193 211 194 212 // Redirect to the original redirect_uri 195 213 Ok(Redirect::to(&stored_state.redirect_uri)) 214 + } 215 + 216 + /// Connect with user-provided config (for API key auth type) 217 + #[derive(Deserialize)] 218 + struct ConnectConfigBody { 219 + /// User-provided configuration matching the plugin's config_schema 220 + config: serde_json::Value, 221 + } 222 + 223 + async fn connect_with_config( 224 + State(app_state): State<AppState>, 225 + Path(plugin_id): Path<String>, 226 + claims: Claims, 227 + Json(body): Json<ConnectConfigBody>, 228 + ) -> Result<Json<serde_json::Value>, AppError> { 229 + let user_did = claims.did(); 230 + 231 + let plugin = app_state 232 + .plugin_registry 233 + .get(&plugin_id) 234 + .await 235 + .ok_or_else(|| AppError::NotFound(format!("Plugin not found: {}", plugin_id)))?; 236 + 237 + // Verify this is an API key plugin 238 + if plugin.info.auth_type != "api_key" { 239 + return Err(AppError::BadRequest( 240 + "This endpoint is only for API key authentication".into(), 241 + )); 242 + } 243 + 244 + let secrets = load_plugin_secrets(&plugin_id); 245 + 246 + let executor = PluginExecutor::new( 247 + app_state.wasm_runtime.clone(), 248 + app_state.plugin_registry.clone(), 249 + app_state.db.clone(), 250 + app_state.db_backend, 251 + app_state.http.clone(), 252 + Arc::new(app_state.lexicons.clone()), 253 + ); 254 + 255 + // For API key auth, we pass the user's config to handle_callback 256 + // The "code" is empty since there's no OAuth flow 257 + let mut instance = executor 258 + .instantiate(&plugin_id, user_did, secrets, body.config.clone()) 259 + .await 260 + .map_err(|e| AppError::Internal(e.to_string()))?; 261 + 262 + // Call handle_callback with the config as the callback params 263 + // The plugin will extract the api_key from the config 264 + let token_set = instance 265 + .call_handle_callback("", "", &body.config) 266 + .await 267 + .map_err(|e| AppError::Internal(e.to_string()))?; 268 + 269 + // Get profile to get the account_id 270 + let profile = instance 271 + .call_get_profile(&token_set.access_token, &body.config) 272 + .await 273 + .map_err(|e| AppError::Internal(e.to_string()))?; 274 + 275 + // Format expires_at as RFC3339 string 276 + let expires_at = token_set.expires_at.map(|dt| dt.to_rfc3339()); 277 + 278 + // Store encrypted tokens 279 + tokens::store_tokens( 280 + &app_state.db, 281 + app_state.db_backend, 282 + app_state.config.token_encryption_key.as_ref(), 283 + user_did, 284 + &plugin_id, 285 + &profile.account_id, 286 + &token_set.access_token, 287 + token_set.refresh_token.as_deref(), 288 + Some(&token_set.token_type), 289 + None, 290 + expires_at.as_deref(), 291 + ) 292 + .await 293 + .map_err(|e| AppError::Internal(e.to_string()))?; 294 + 295 + Ok(Json(serde_json::json!({ 296 + "status": "connected", 297 + "account_id": profile.account_id, 298 + "display_name": profile.display_name 299 + }))) 196 300 } 197 301 198 302 async fn sync(
+36
src/external_auth/tokens.rs
··· 195 195 Ok(row.map(|(id,)| id)) 196 196 } 197 197 198 + /// Summary of a linked external account (without tokens) 199 + #[derive(Debug, Clone, serde::Serialize)] 200 + pub struct LinkedAccountSummary { 201 + pub plugin_id: String, 202 + pub account_id: String, 203 + pub created_at: String, 204 + pub updated_at: String, 205 + } 206 + 207 + /// List all linked external accounts for a user 208 + pub async fn list_linked_accounts( 209 + db: &sqlx::AnyPool, 210 + backend: DatabaseBackend, 211 + did: &str, 212 + ) -> Result<Vec<LinkedAccountSummary>, TokenError> { 213 + let sql = adapt_sql( 214 + "SELECT plugin_id, account_id, created_at, updated_at FROM external_account_tokens WHERE did = ? ORDER BY created_at DESC", 215 + backend, 216 + ); 217 + 218 + let rows: Vec<(String, String, String, String)> = 219 + sqlx::query_as(&sql).bind(did).fetch_all(db).await?; 220 + 221 + Ok(rows 222 + .into_iter() 223 + .map( 224 + |(plugin_id, account_id, created_at, updated_at)| LinkedAccountSummary { 225 + plugin_id, 226 + account_id, 227 + created_at, 228 + updated_at, 229 + }, 230 + ) 231 + .collect()) 232 + } 233 + 198 234 // Integration tests for token storage are in tests/e2e_external_auth.rs
+5 -2
src/main.rs
··· 173 173 ); 174 174 } 175 175 176 - // Initialize plugin registry 177 - let plugin_registry = Arc::new(happyview::plugin::PluginRegistry::new()); 176 + // Initialize plugin registry (with DB for persistence) 177 + let plugin_registry = Arc::new(happyview::plugin::PluginRegistry::with_db( 178 + db_pool.clone(), 179 + db_backend, 180 + )); 178 181 179 182 // Initialize WASM runtime 180 183 let wasm_runtime =
+66 -1
src/plugin/mod.rs
··· 13 13 pub use runtime::WasmRuntime; 14 14 pub use types::*; 15 15 16 + use crate::db::{DatabaseBackend, adapt_sql, now_rfc3339}; 16 17 use std::collections::HashMap; 17 18 use std::sync::Arc; 18 19 use tokio::sync::RwLock; 19 20 20 21 /// Registry of loaded plugins 21 - #[derive(Default)] 22 22 pub struct PluginRegistry { 23 23 plugins: RwLock<HashMap<String, Arc<LoadedPlugin>>>, 24 + db: Option<sqlx::AnyPool>, 25 + db_backend: DatabaseBackend, 26 + } 27 + 28 + impl Default for PluginRegistry { 29 + fn default() -> Self { 30 + Self { 31 + plugins: RwLock::new(HashMap::new()), 32 + db: None, 33 + db_backend: DatabaseBackend::Sqlite, 34 + } 35 + } 24 36 } 25 37 26 38 impl PluginRegistry { ··· 28 40 Self::default() 29 41 } 30 42 43 + /// Create a registry backed by a database for persistence 44 + pub fn with_db(db: sqlx::AnyPool, db_backend: DatabaseBackend) -> Self { 45 + Self { 46 + plugins: RwLock::new(HashMap::new()), 47 + db: Some(db), 48 + db_backend, 49 + } 50 + } 51 + 31 52 pub async fn register(&self, plugin: LoadedPlugin) { 32 53 let id = plugin.info.id.clone(); 54 + 55 + // Persist to database if configured 56 + if let Some(db) = &self.db 57 + && let Err(e) = self.persist_plugin(db, &plugin).await 58 + { 59 + tracing::error!(plugin_id = %id, error = %e, "Failed to persist plugin to database"); 60 + } 61 + 33 62 self.plugins.write().await.insert(id, Arc::new(plugin)); 63 + } 64 + 65 + async fn persist_plugin( 66 + &self, 67 + db: &sqlx::AnyPool, 68 + plugin: &LoadedPlugin, 69 + ) -> Result<(), sqlx::Error> { 70 + let (source, url, sha256) = match &plugin.source { 71 + PluginSource::File { path } => ("file", Some(path.display().to_string()), None), 72 + PluginSource::Url { url, sha256 } => ("url", Some(url.clone()), sha256.clone()), 73 + }; 74 + 75 + let now = now_rfc3339(); 76 + let sql = adapt_sql( 77 + "INSERT INTO plugins (id, source, url, sha256, enabled, loaded_at, api_version) 78 + VALUES (?, ?, ?, ?, 1, ?, ?) 79 + ON CONFLICT (id) DO UPDATE SET 80 + source = excluded.source, 81 + url = excluded.url, 82 + sha256 = excluded.sha256, 83 + loaded_at = excluded.loaded_at, 84 + api_version = excluded.api_version", 85 + self.db_backend, 86 + ); 87 + 88 + sqlx::query(&sql) 89 + .bind(&plugin.info.id) 90 + .bind(source) 91 + .bind(url) 92 + .bind(sha256) 93 + .bind(&now) 94 + .bind(&plugin.info.api_version) 95 + .execute(db) 96 + .await?; 97 + 98 + Ok(()) 34 99 } 35 100 36 101 pub async fn get(&self, id: &str) -> Option<Arc<LoadedPlugin>> {
+8
src/plugin/types.rs
··· 11 11 pub icon_url: Option<String>, 12 12 #[serde(default)] 13 13 pub required_secrets: Vec<String>, 14 + /// Authentication type: "oauth2", "openid", "api_key" 15 + #[serde(default = "default_auth_type")] 16 + pub auth_type: String, 17 + /// JSON Schema describing user-provided configuration (e.g., API keys) 14 18 #[serde(skip_serializing_if = "Option::is_none")] 15 19 pub config_schema: Option<serde_json::Value>, 20 + } 21 + 22 + fn default_auth_type() -> String { 23 + "oauth2".to_string() 16 24 } 17 25 18 26 /// OAuth callback parameters passed to handle_callback()
+1
web/next.config.ts
··· 21 21 { source: "/health", destination: `${apiBase}/health` }, 22 22 { source: "/config", destination: `${apiBase}/config` }, 23 23 { source: "/oauth/:path*", destination: `${apiBase}/oauth/:path*` }, 24 + { source: "/external-auth/:path*", destination: `${apiBase}/external-auth/:path*` }, 24 25 ], 25 26 afterFiles: [], 26 27 fallback: [],
+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 + }
+3
web/src/components/app-sidebar.tsx
··· 14 14 IconTag, 15 15 IconChevronRight, 16 16 IconShield, 17 + IconLink, 17 18 } from "@tabler/icons-react" 18 19 import Image from "next/image" 19 20 import Link from "next/link" ··· 51 52 52 53 const settingsSubItems = [ 53 54 { title: "Users", url: "/dashboard/settings/users", icon: IconUsers, requiredPermissions: ["users:read"] }, 55 + { title: "Linked Accounts", url: "/dashboard/settings/accounts", icon: IconLink, requiredPermissions: [] as string[] }, 54 56 { title: "ENV Variables", url: "/dashboard/settings/env-variables", icon: IconVariable, requiredPermissions: ["script-variables:read"] }, 55 57 { title: "API Keys", url: "/dashboard/settings/api-keys", icon: IconKey, requiredPermissions: ["api-keys:read"] }, 56 58 { title: "Labelers", url: "/dashboard/settings/labelers", icon: IconTag, requiredPermissions: ["labelers:read"] }, ··· 70 72 }) 71 73 72 74 const visibleSettingsItems = settingsSubItems.filter((item) => 75 + item.requiredPermissions.length === 0 || 73 76 item.requiredPermissions.some((perm) => hasPermission(perm)) 74 77 ) 75 78
+55
web/src/lib/api.ts
··· 10 10 import type { ScriptVariableSummary } from "@/types/script-variables" 11 11 import type { LabelerSummary } from "@/types/labelers" 12 12 import type { RateLimitsResponse } from "@/types/rate-limits" 13 + import type { 14 + ExternalProvider, 15 + LinkedAccount, 16 + AuthorizeResponse, 17 + SyncResponse, 18 + UnlinkResponse, 19 + ConnectResponse, 20 + } from "@/types/external-accounts" 13 21 14 22 export type { ApiKeySummary, CreateApiKeyResponse } from "@/types/api-keys" 15 23 export type { CollectionStat, StatsResponse } from "@/types/stats" ··· 24 32 export type { LabelerSummary } from "@/types/labelers" 25 33 export type { RecordLabel } from "@/types/records" 26 34 export type { AllowlistEntry, RateLimitsResponse } from "@/types/rate-limits" 35 + export type { 36 + ExternalProvider, 37 + LinkedAccount, 38 + AuthorizeResponse, 39 + SyncResponse, 40 + UnlinkResponse, 41 + ConnectResponse, 42 + ConfigSchema, 43 + ConfigProperty, 44 + } from "@/types/external-accounts" 27 45 28 46 export class ApiError extends Error { 29 47 status: number ··· 374 392 `/admin/events${qs ? `?${qs}` : ""}`, 375 393 ) 376 394 } 395 + 396 + // External Accounts 397 + export function getExternalProviders() { 398 + return apiFetch<ExternalProvider[]>("/external-auth/providers") 399 + } 400 + 401 + export function getLinkedAccounts() { 402 + return apiFetch<LinkedAccount[]>("/external-auth/accounts") 403 + } 404 + 405 + export function authorizeExternal(pluginId: string, redirectUri: string) { 406 + const params = new URLSearchParams({ redirect_uri: redirectUri }) 407 + return apiFetch<AuthorizeResponse>( 408 + `/external-auth/${encodeURIComponent(pluginId)}/authorize?${params}`, 409 + ) 410 + } 411 + 412 + export function syncExternal(pluginId: string) { 413 + return apiFetch<SyncResponse>( 414 + `/external-auth/${encodeURIComponent(pluginId)}/sync`, 415 + { method: "POST" }, 416 + ) 417 + } 418 + 419 + export function unlinkExternal(pluginId: string) { 420 + return apiFetch<UnlinkResponse>( 421 + `/external-auth/${encodeURIComponent(pluginId)}/unlink`, 422 + { method: "POST" }, 423 + ) 424 + } 425 + 426 + export function connectWithConfig(pluginId: string, config: Record<string, unknown>) { 427 + return apiFetch<ConnectResponse>( 428 + `/external-auth/${encodeURIComponent(pluginId)}/connect`, 429 + { method: "POST", body: JSON.stringify({ config }) }, 430 + ) 431 + }
+51
web/src/types/external-accounts.ts
··· 1 + export interface ExternalProvider { 2 + id: string 3 + name: string 4 + icon_url: string | null 5 + auth_type: "oauth2" | "openid" | "api_key" 6 + config_schema?: ConfigSchema 7 + } 8 + 9 + /** JSON Schema for plugin configuration */ 10 + export interface ConfigSchema { 11 + type: "object" 12 + required?: string[] 13 + properties: Record<string, ConfigProperty> 14 + } 15 + 16 + export interface ConfigProperty { 17 + type: "string" | "number" | "boolean" 18 + title?: string 19 + description?: string 20 + format?: "password" | "uri" | "email" 21 + default?: unknown 22 + } 23 + 24 + export interface LinkedAccount { 25 + plugin_id: string 26 + account_id: string 27 + created_at: string 28 + updated_at: string 29 + } 30 + 31 + export interface AuthorizeResponse { 32 + authorize_url: string 33 + state: string 34 + } 35 + 36 + export interface SyncResponse { 37 + status: string 38 + processed: number 39 + written: number 40 + } 41 + 42 + export interface UnlinkResponse { 43 + status: string 44 + was_linked: boolean 45 + } 46 + 47 + export interface ConnectResponse { 48 + status: string 49 + account_id: string 50 + display_name: string | null 51 + }