diff --git a/src/external_auth/routes.rs b/src/external_auth/routes.rs index 86f59c2..601c217 100644 --- a/src/external_auth/routes.rs +++ b/src/external_auth/routes.rs @@ -18,8 +18,10 @@ use crate::plugin::sync::SyncProcessor; pub fn routes() -> Router { Router::new() .route("/providers", get(list_providers)) + .route("/accounts", get(list_accounts)) .route("/{plugin_id}/authorize", get(authorize)) .route("/{plugin_id}/callback", get(callback)) + .route("/{plugin_id}/connect", post(connect_with_config)) .route("/{plugin_id}/sync", post(sync)) .route("/{plugin_id}/unlink", post(unlink)) } @@ -29,6 +31,9 @@ struct ProviderInfo { id: String, name: String, icon_url: Option, + auth_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + config_schema: Option, } async fn list_providers( @@ -42,12 +47,25 @@ async fn list_providers( id: p.info.id.clone(), name: p.info.name.clone(), icon_url: p.info.icon_url.clone(), + auth_type: p.info.auth_type.clone(), + config_schema: p.info.config_schema.clone(), }) .collect(); Ok(Json(providers)) } +async fn list_accounts( + State(app_state): State, + claims: Claims, +) -> Result>, AppError> { + let accounts = tokens::list_linked_accounts(&app_state.db, app_state.db_backend, claims.did()) + .await + .map_err(|e| AppError::Internal(e.to_string()))?; + + Ok(Json(accounts)) +} + #[derive(Deserialize)] struct AuthorizeQuery { redirect_uri: String, @@ -195,6 +213,92 @@ async fn callback( Ok(Redirect::to(&stored_state.redirect_uri)) } +/// Connect with user-provided config (for API key auth type) +#[derive(Deserialize)] +struct ConnectConfigBody { + /// User-provided configuration matching the plugin's config_schema + config: serde_json::Value, +} + +async fn connect_with_config( + State(app_state): State, + Path(plugin_id): Path, + claims: Claims, + Json(body): Json, +) -> Result, AppError> { + let user_did = claims.did(); + + let plugin = app_state + .plugin_registry + .get(&plugin_id) + .await + .ok_or_else(|| AppError::NotFound(format!("Plugin not found: {}", plugin_id)))?; + + // Verify this is an API key plugin + if plugin.info.auth_type != "api_key" { + return Err(AppError::BadRequest( + "This endpoint is only for API key authentication".into(), + )); + } + + let secrets = load_plugin_secrets(&plugin_id); + + let executor = PluginExecutor::new( + app_state.wasm_runtime.clone(), + app_state.plugin_registry.clone(), + app_state.db.clone(), + app_state.db_backend, + app_state.http.clone(), + Arc::new(app_state.lexicons.clone()), + ); + + // For API key auth, we pass the user's config to handle_callback + // The "code" is empty since there's no OAuth flow + let mut instance = executor + .instantiate(&plugin_id, user_did, secrets, body.config.clone()) + .await + .map_err(|e| AppError::Internal(e.to_string()))?; + + // Call handle_callback with the config as the callback params + // The plugin will extract the api_key from the config + let token_set = instance + .call_handle_callback("", "", &body.config) + .await + .map_err(|e| AppError::Internal(e.to_string()))?; + + // Get profile to get the account_id + let profile = instance + .call_get_profile(&token_set.access_token, &body.config) + .await + .map_err(|e| AppError::Internal(e.to_string()))?; + + // Format expires_at as RFC3339 string + let expires_at = token_set.expires_at.map(|dt| dt.to_rfc3339()); + + // Store encrypted tokens + tokens::store_tokens( + &app_state.db, + app_state.db_backend, + app_state.config.token_encryption_key.as_ref(), + user_did, + &plugin_id, + &profile.account_id, + &token_set.access_token, + token_set.refresh_token.as_deref(), + Some(&token_set.token_type), + None, + expires_at.as_deref(), + ) + .await + .map_err(|e| AppError::Internal(e.to_string()))?; + + Ok(Json(serde_json::json!({ + "status": "connected", + "account_id": profile.account_id, + "display_name": profile.display_name + }))) +} + async fn sync( State(app_state): State, Path(plugin_id): Path, diff --git a/src/external_auth/tokens.rs b/src/external_auth/tokens.rs index fbb5b62..4ce15ae 100644 --- a/src/external_auth/tokens.rs +++ b/src/external_auth/tokens.rs @@ -195,4 +195,40 @@ pub async fn get_account_id( Ok(row.map(|(id,)| id)) } +/// Summary of a linked external account (without tokens) +#[derive(Debug, Clone, serde::Serialize)] +pub struct LinkedAccountSummary { + pub plugin_id: String, + pub account_id: String, + pub created_at: String, + pub updated_at: String, +} + +/// List all linked external accounts for a user +pub async fn list_linked_accounts( + db: &sqlx::AnyPool, + backend: DatabaseBackend, + did: &str, +) -> Result, TokenError> { + let sql = adapt_sql( + "SELECT plugin_id, account_id, created_at, updated_at FROM external_account_tokens WHERE did = ? ORDER BY created_at DESC", + backend, + ); + + let rows: Vec<(String, String, String, String)> = + sqlx::query_as(&sql).bind(did).fetch_all(db).await?; + + Ok(rows + .into_iter() + .map( + |(plugin_id, account_id, created_at, updated_at)| LinkedAccountSummary { + plugin_id, + account_id, + created_at, + updated_at, + }, + ) + .collect()) +} + // Integration tests for token storage are in tests/e2e_external_auth.rs diff --git a/src/main.rs b/src/main.rs index d019fa7..cb4df4b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -173,8 +173,11 @@ async fn main() { ); } - // Initialize plugin registry - let plugin_registry = Arc::new(happyview::plugin::PluginRegistry::new()); + // Initialize plugin registry (with DB for persistence) + let plugin_registry = Arc::new(happyview::plugin::PluginRegistry::with_db( + db_pool.clone(), + db_backend, + )); // Initialize WASM runtime let wasm_runtime = diff --git a/src/plugin/mod.rs b/src/plugin/mod.rs index ce5e4a2..a19e564 100644 --- a/src/plugin/mod.rs +++ b/src/plugin/mod.rs @@ -13,14 +13,26 @@ pub use memory::{MemoryError, PluginEnvelopeError, PluginResponse}; pub use runtime::WasmRuntime; pub use types::*; +use crate::db::{DatabaseBackend, adapt_sql, now_rfc3339}; use std::collections::HashMap; use std::sync::Arc; use tokio::sync::RwLock; /// Registry of loaded plugins -#[derive(Default)] pub struct PluginRegistry { plugins: RwLock>>, + db: Option, + db_backend: DatabaseBackend, +} + +impl Default for PluginRegistry { + fn default() -> Self { + Self { + plugins: RwLock::new(HashMap::new()), + db: None, + db_backend: DatabaseBackend::Sqlite, + } + } } impl PluginRegistry { @@ -28,11 +40,64 @@ impl PluginRegistry { Self::default() } + /// Create a registry backed by a database for persistence + pub fn with_db(db: sqlx::AnyPool, db_backend: DatabaseBackend) -> Self { + Self { + plugins: RwLock::new(HashMap::new()), + db: Some(db), + db_backend, + } + } + pub async fn register(&self, plugin: LoadedPlugin) { let id = plugin.info.id.clone(); + + // Persist to database if configured + if let Some(db) = &self.db + && let Err(e) = self.persist_plugin(db, &plugin).await + { + tracing::error!(plugin_id = %id, error = %e, "Failed to persist plugin to database"); + } + self.plugins.write().await.insert(id, Arc::new(plugin)); } + async fn persist_plugin( + &self, + db: &sqlx::AnyPool, + plugin: &LoadedPlugin, + ) -> Result<(), sqlx::Error> { + let (source, url, sha256) = match &plugin.source { + PluginSource::File { path } => ("file", Some(path.display().to_string()), None), + PluginSource::Url { url, sha256 } => ("url", Some(url.clone()), sha256.clone()), + }; + + let now = now_rfc3339(); + let sql = adapt_sql( + "INSERT INTO plugins (id, source, url, sha256, enabled, loaded_at, api_version) + VALUES (?, ?, ?, ?, 1, ?, ?) + ON CONFLICT (id) DO UPDATE SET + source = excluded.source, + url = excluded.url, + sha256 = excluded.sha256, + loaded_at = excluded.loaded_at, + api_version = excluded.api_version", + self.db_backend, + ); + + sqlx::query(&sql) + .bind(&plugin.info.id) + .bind(source) + .bind(url) + .bind(sha256) + .bind(&now) + .bind(&plugin.info.api_version) + .execute(db) + .await?; + + Ok(()) + } + pub async fn get(&self, id: &str) -> Option> { self.plugins.read().await.get(id).cloned() } diff --git a/src/plugin/types.rs b/src/plugin/types.rs index f4debaf..0062f67 100644 --- a/src/plugin/types.rs +++ b/src/plugin/types.rs @@ -11,10 +11,18 @@ pub struct PluginInfo { pub icon_url: Option, #[serde(default)] pub required_secrets: Vec, + /// Authentication type: "oauth2", "openid", "api_key" + #[serde(default = "default_auth_type")] + pub auth_type: String, + /// JSON Schema describing user-provided configuration (e.g., API keys) #[serde(skip_serializing_if = "Option::is_none")] pub config_schema: Option, } +fn default_auth_type() -> String { + "oauth2".to_string() +} + /// OAuth callback parameters passed to handle_callback() #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CallbackParams { diff --git a/web/next.config.ts b/web/next.config.ts index 0afe59b..93e2742 100644 --- a/web/next.config.ts +++ b/web/next.config.ts @@ -21,6 +21,7 @@ if (process.env.NODE_ENV === "production") { { source: "/health", destination: `${apiBase}/health` }, { source: "/config", destination: `${apiBase}/config` }, { source: "/oauth/:path*", destination: `${apiBase}/oauth/:path*` }, + { source: "/external-auth/:path*", destination: `${apiBase}/external-auth/:path*` }, ], afterFiles: [], fallback: [], diff --git a/web/src/app/dashboard/settings/accounts/page.tsx b/web/src/app/dashboard/settings/accounts/page.tsx new file mode 100644 index 0000000..65c3400 --- /dev/null +++ b/web/src/app/dashboard/settings/accounts/page.tsx @@ -0,0 +1,435 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { Link2, Unlink, RefreshCw, ExternalLink, Key } from "lucide-react"; + +import { + getExternalProviders, + getLinkedAccounts, + authorizeExternal, + syncExternal, + unlinkExternal, + connectWithConfig, +} from "@/lib/api"; +import type { ExternalProvider, LinkedAccount } from "@/types/external-accounts"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { SiteHeader } from "@/components/site-header"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + ResponsiveDialog, + ResponsiveDialogClose, + ResponsiveDialogContent, + ResponsiveDialogDescription, + ResponsiveDialogFooter, + ResponsiveDialogHeader, + ResponsiveDialogTitle, +} from "@/components/ui/responsive-dialog"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; + +export default function LinkedAccountsPage() { + const [providers, setProviders] = useState([]); + const [accounts, setAccounts] = useState([]); + const [error, setError] = useState(null); + const [unlinkId, setUnlinkId] = useState(null); + const [unlinking, setUnlinking] = useState(false); + const [syncing, setSyncing] = useState(null); + const [syncResult, setSyncResult] = useState<{ pluginId: string; written: number } | null>(null); + // API key config dialog state + const [configProvider, setConfigProvider] = useState(null); + const [configValues, setConfigValues] = useState>({}); + const [connecting, setConnecting] = useState(false); + + const load = useCallback(async () => { + try { + const [providerList, accountList] = await Promise.all([ + getExternalProviders(), + getLinkedAccounts(), + ]); + setProviders(providerList); + setAccounts(accountList); + setError(null); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } + }, []); + + useEffect(() => { + load(); + }, [load]); + + async function handleConnect(pluginId: string) { + const provider = providers.find((p) => p.id === pluginId); + if (!provider) return; + + // For API key auth, show config dialog instead of redirecting + if (provider.auth_type === "api_key" && provider.config_schema) { + setConfigProvider(provider); + setConfigValues({}); + return; + } + + // For OAuth/OpenID, redirect to provider + try { + const redirectUri = window.location.href; + const result = await authorizeExternal(pluginId, redirectUri); + window.location.href = result.authorize_url; + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } + } + + async function handleConfigSubmit() { + if (!configProvider) return; + + setConnecting(true); + setError(null); + try { + await connectWithConfig(configProvider.id, configValues); + setConfigProvider(null); + setConfigValues({}); + load(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setConnecting(false); + } + } + + async function handleSync(pluginId: string) { + setSyncing(pluginId); + setSyncResult(null); + try { + const result = await syncExternal(pluginId); + setSyncResult({ pluginId, written: result.written }); + setError(null); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setSyncing(null); + } + } + + async function handleUnlink(pluginId: string) { + setUnlinking(true); + try { + await unlinkExternal(pluginId); + setUnlinkId(null); + load(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setUnlinking(false); + } + } + + // Build a map of linked accounts by plugin_id + const linkedByPlugin = new Map(accounts.map((a) => [a.plugin_id, a])); + + return ( + <> + +
+ {error &&

{error}

} + + {syncResult && ( +
+

+ Sync complete: {syncResult.written} records written to your PDS +

+
+ )} + +
+

External Account Providers

+

+ Connect external platforms to sync data to your AT Protocol repository. +

+
+ + {providers.length === 0 ? ( + + + No Providers Available + + No external account plugins are currently loaded. Contact your + administrator to install plugins. + + + + ) : ( +
+ {providers.map((provider) => { + const linked = linkedByPlugin.get(provider.id); + const isSyncing = syncing === provider.id; + + return ( + + +
+ + {provider.icon_url && ( + + )} + {provider.name} + + {linked && ( + + Connected + + )} +
+
+ + {linked ? ( +
+
+ Account ID:{" "} + {linked.account_id} +
+
+ Connected {new Date(linked.created_at).toLocaleDateString()} +
+
+ + +
+
+ ) : ( + + )} +
+
+ ); + })} +
+ )} + + {accounts.length > 0 && ( + <> +
+

Connected Accounts

+

+ Your linked external accounts and their sync status. +

+
+ +
+ + + + Provider + Account ID + Connected + Last Updated + + + + + {accounts.map((account) => { + const provider = providers.find( + (p) => p.id === account.plugin_id + ); + const isSyncing = syncing === account.plugin_id; + + return ( + + + {provider?.name ?? account.plugin_id} + + + {account.account_id} + + + {new Date(account.created_at).toLocaleString()} + + + {new Date(account.updated_at).toLocaleString()} + + +
+ + +
+
+
+ ); + })} +
+
+
+ + )} +
+ + { + if (!open) setUnlinkId(null); + }} + > + + + Unlink account? + + This will disconnect your external account and remove the stored + credentials. You can reconnect at any time. + + + {unlinkId && ( +

+ Provider:{" "} + + {providers.find((p) => p.id === unlinkId)?.name ?? unlinkId} + +

+ )} + + + + + + +
+
+ + {/* API Key / Config Dialog */} + { + if (!open) { + setConfigProvider(null); + setConfigValues({}); + } + }} + > + + + + {configProvider?.icon_url && ( + + )} + Connect {configProvider?.name} + + + Enter your credentials to connect this account. + + + + {configProvider?.config_schema && ( +
+ {Object.entries(configProvider.config_schema.properties).map( + ([key, prop]) => ( +
+ + + setConfigValues((prev) => ({ + ...prev, + [key]: e.target.value, + })) + } + /> + {prop.description && ( +

+ {prop.description} +

+ )} +
+ ) + )} +
+ )} + + + + + + + +
+
+ + ); +} diff --git a/web/src/components/app-sidebar.tsx b/web/src/components/app-sidebar.tsx index 19f5a05..4aa842f 100644 --- a/web/src/components/app-sidebar.tsx +++ b/web/src/components/app-sidebar.tsx @@ -14,6 +14,7 @@ import { IconTag, IconChevronRight, IconShield, + IconLink, } from "@tabler/icons-react" import Image from "next/image" import Link from "next/link" @@ -51,6 +52,7 @@ const navItems = [ const settingsSubItems = [ { title: "Users", url: "/dashboard/settings/users", icon: IconUsers, requiredPermissions: ["users:read"] }, + { title: "Linked Accounts", url: "/dashboard/settings/accounts", icon: IconLink, requiredPermissions: [] as string[] }, { title: "ENV Variables", url: "/dashboard/settings/env-variables", icon: IconVariable, requiredPermissions: ["script-variables:read"] }, { title: "API Keys", url: "/dashboard/settings/api-keys", icon: IconKey, requiredPermissions: ["api-keys:read"] }, { title: "Labelers", url: "/dashboard/settings/labelers", icon: IconTag, requiredPermissions: ["labelers:read"] }, @@ -70,6 +72,7 @@ export function AppSidebar({ }) const visibleSettingsItems = settingsSubItems.filter((item) => + item.requiredPermissions.length === 0 || item.requiredPermissions.some((perm) => hasPermission(perm)) ) diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 4c9822b..1a6eafe 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -10,6 +10,14 @@ import type { EventsListResponse } from "@/types/events" import type { ScriptVariableSummary } from "@/types/script-variables" import type { LabelerSummary } from "@/types/labelers" import type { RateLimitsResponse } from "@/types/rate-limits" +import type { + ExternalProvider, + LinkedAccount, + AuthorizeResponse, + SyncResponse, + UnlinkResponse, + ConnectResponse, +} from "@/types/external-accounts" export type { ApiKeySummary, CreateApiKeyResponse } from "@/types/api-keys" export type { CollectionStat, StatsResponse } from "@/types/stats" @@ -24,6 +32,16 @@ export type { ScriptVariableSummary } from "@/types/script-variables" export type { LabelerSummary } from "@/types/labelers" export type { RecordLabel } from "@/types/records" export type { AllowlistEntry, RateLimitsResponse } from "@/types/rate-limits" +export type { + ExternalProvider, + LinkedAccount, + AuthorizeResponse, + SyncResponse, + UnlinkResponse, + ConnectResponse, + ConfigSchema, + ConfigProperty, +} from "@/types/external-accounts" export class ApiError extends Error { status: number @@ -374,3 +392,40 @@ export function getEvents( `/admin/events${qs ? `?${qs}` : ""}`, ) } + +// External Accounts +export function getExternalProviders() { + return apiFetch("/external-auth/providers") +} + +export function getLinkedAccounts() { + return apiFetch("/external-auth/accounts") +} + +export function authorizeExternal(pluginId: string, redirectUri: string) { + const params = new URLSearchParams({ redirect_uri: redirectUri }) + return apiFetch( + `/external-auth/${encodeURIComponent(pluginId)}/authorize?${params}`, + ) +} + +export function syncExternal(pluginId: string) { + return apiFetch( + `/external-auth/${encodeURIComponent(pluginId)}/sync`, + { method: "POST" }, + ) +} + +export function unlinkExternal(pluginId: string) { + return apiFetch( + `/external-auth/${encodeURIComponent(pluginId)}/unlink`, + { method: "POST" }, + ) +} + +export function connectWithConfig(pluginId: string, config: Record) { + return apiFetch( + `/external-auth/${encodeURIComponent(pluginId)}/connect`, + { method: "POST", body: JSON.stringify({ config }) }, + ) +} diff --git a/web/src/types/external-accounts.ts b/web/src/types/external-accounts.ts new file mode 100644 index 0000000..0e1de5d --- /dev/null +++ b/web/src/types/external-accounts.ts @@ -0,0 +1,51 @@ +export interface ExternalProvider { + id: string + name: string + icon_url: string | null + auth_type: "oauth2" | "openid" | "api_key" + config_schema?: ConfigSchema +} + +/** JSON Schema for plugin configuration */ +export interface ConfigSchema { + type: "object" + required?: string[] + properties: Record +} + +export interface ConfigProperty { + type: "string" | "number" | "boolean" + title?: string + description?: string + format?: "password" | "uri" | "email" + default?: unknown +} + +export interface LinkedAccount { + plugin_id: string + account_id: string + created_at: string + updated_at: string +} + +export interface AuthorizeResponse { + authorize_url: string + state: string +} + +export interface SyncResponse { + status: string + processed: number + written: number +} + +export interface UnlinkResponse { + status: string + was_linked: boolean +} + +export interface ConnectResponse { + status: string + account_id: string + display_name: string | null +}