diff --git a/src/admin/settings.rs b/src/admin/settings.rs --- a/src/admin/settings.rs +++ b/src/admin/settings.rs @@ -20,7 +20,6 @@ ("client_uri", "CLIENT_URI"), ("logo_uri", "LOGO_URI"), ("tos_uri", "TOS_URI"), ("policy_uri", "POLICY_URI"), - ("oauth_scopes", "OAUTH_SCOPES"), ]; /// Resolve a setting value: check the DB first, then fall back to env var. diff --git a/src/auth/routes.rs b/src/auth/routes.rs --- a/src/auth/routes.rs +++ b/src/auth/routes.rs @@ -72,18 +72,7 @@ } else { parsed } } else { - match crate::admin::settings::get_setting(&state.db, "oauth_scopes", state.db_backend).await - { - Some(s) => { - let parsed = parse_scope_string(&s); - if parsed.is_empty() { - vec![Scope::Known(KnownScope::Atproto)] - } else { - parsed - } - } - None => vec![Scope::Known(KnownScope::Atproto)], - } + vec![Scope::Known(KnownScope::Atproto)] }; tracing::debug!(scopes = ?scopes, client_id = ?query.client_id, "resolved oauth scopes"); diff --git a/src/main.rs b/src/main.rs --- a/src/main.rs +++ b/src/main.rs @@ -331,20 +331,9 @@ }; let oauth_state_store = DbStateStore::new(db_pool.clone(), db_backend); - // Load OAuth scopes from the settings DB so they can be managed at runtime - // without restarting HappyView. Falls back to just `atproto` if unset. - let oauth_scopes = - match happyview::admin::settings::get_setting(&db_pool, "oauth_scopes", db_backend).await { - Some(s) => { - let parsed = happyview::auth::parse_scope_string(&s); - if parsed.is_empty() { - vec![Scope::Known(KnownScope::Atproto)] - } else { - parsed - } - } - None => vec![Scope::Known(KnownScope::Atproto)], - }; + // HappyView's own default OAuth client always uses the `atproto` scope. + // API clients configure their own scopes via the API Clients settings page. + let oauth_scopes = vec![Scope::Known(KnownScope::Atproto)]; let oauth_client = if is_loopback { info!("Using loopback OAuth client metadata (local development)"); diff --git a/src/server.rs b/src/server.rs --- a/src/server.rs +++ b/src/server.rs @@ -99,6 +99,27 @@ "ok" } async fn config_endpoint(State(state): State) -> Json { + let pool = &state.db; + let backend = state.db_backend; + + let app_name = crate::admin::settings::get_setting(pool, "app_name", backend) + .await + .or_else(|| state.config.app_name.clone()); + + let has_logo_data = crate::admin::settings::get_setting(pool, "logo_data", backend) + .await + .is_some(); + let logo_url = if has_logo_data { + Some(format!( + "{}/settings/logo", + state.config.public_url.trim_end_matches('/') + )) + } else { + crate::admin::settings::get_setting(pool, "logo_uri", backend) + .await + .or_else(|| state.config.logo_uri.clone()) + }; + Json(serde_json::json!({ "public_url": state.config.public_url, "version": env!("CARGO_PKG_VERSION"), @@ -108,6 +129,8 @@ "relay_url": state.config.relay_url, "plc_url": state.config.plc_url, "default_rate_limit_capacity": state.config.default_rate_limit_capacity, "default_rate_limit_refill_rate": state.config.default_rate_limit_refill_rate, + "app_name": app_name, + "logo_url": logo_url, })) } @@ -153,16 +176,6 @@ } if let Some(uri) = crate::admin::settings::get_setting(pool, "policy_uri", backend).await { metadata["policy_uri"] = serde_json::Value::String(uri); - } - - // OAuth scopes: override from the settings DB so admins can manage scopes without - // restarting HappyView. The authorization server fetches this endpoint to validate - // scope requests at PAR time, so this value is authoritative for non-loopback clients. - if let Some(scopes) = crate::admin::settings::get_setting(pool, "oauth_scopes", backend).await { - let normalized = scopes.split_whitespace().collect::>().join(" "); - if !normalized.is_empty() { - metadata["scope"] = serde_json::Value::String(normalized); - } } Json(metadata) diff --git a/web/src/app/dashboard/layout.tsx b/web/src/app/dashboard/layout.tsx --- a/web/src/app/dashboard/layout.tsx +++ b/web/src/app/dashboard/layout.tsx @@ -4,6 +4,7 @@ import { useEffect } from "react" import { useRouter } from "next/navigation" import { useAuth } from "@/lib/auth-context" +import { useConfig } from "@/lib/config-context" import { AppSidebar } from "@/components/app-sidebar" import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar" @@ -13,6 +14,7 @@ }: { children: React.ReactNode }) { const { did } = useAuth() + const { app_name } = useConfig() const router = useRouter() useEffect(() => { @@ -20,6 +22,10 @@ if (!did) { router.replace("/login") } }, [did, router]) + + useEffect(() => { + document.title = app_name ? `${app_name} Admin` : "HappyView Admin" + }, [app_name]) if (!did) return null diff --git a/web/src/app/dashboard/settings/oauth/page.tsx b/web/src/app/dashboard/settings/general/page.tsx rename from web/src/app/dashboard/settings/oauth/page.tsx rename to web/src/app/dashboard/settings/general/page.tsx --- a/web/src/app/dashboard/settings/oauth/page.tsx +++ b/web/src/app/dashboard/settings/general/page.tsx @@ -1,6 +1,6 @@ "use client" -import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { useCallback, useEffect, useRef, useState } from "react" import { Upload, Trash2 } from "lucide-react" import { useCurrentUser } from "@/hooks/use-current-user" @@ -10,39 +10,44 @@ upsertSetting, deleteSetting, uploadLogo, deleteLogo, - OAUTH_SETTING_KEYS, type SettingEntry, } from "@/lib/api" import { SiteHeader } from "@/components/site-header" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" -import { Textarea } from "@/components/ui/textarea" -type FieldKey = (typeof OAUTH_SETTING_KEYS)[number] +const SETTING_KEYS = [ + "app_name", + "client_uri", + "logo_uri", + "tos_uri", + "policy_uri", +] as const + +type FieldKey = (typeof SETTING_KEYS)[number] type FieldConfig = { key: FieldKey label: string placeholder: string description: string - multiline?: boolean } const FIELDS: FieldConfig[] = [ { key: "app_name", - label: "Client Name", + label: "Instance Name", placeholder: "My HappyView Instance", description: - "Shown to users on the OAuth consent screen.", + "Display name for this instance. Shown in the sidebar and on the OAuth consent screen.", }, { key: "client_uri", - label: "Client URI", + label: "Instance URI", placeholder: "https://example.com", description: - "The homepage for this application, linked from the consent screen.", + "The public URL for this instance, linked from the OAuth consent screen.", }, { key: "logo_uri", @@ -55,25 +60,17 @@ { key: "tos_uri", label: "Terms of Service URI", placeholder: "https://example.com/terms", - description: "Link to your terms of service.", + description: "Link to your terms of service. Optional.", }, { key: "policy_uri", label: "Privacy Policy URI", placeholder: "https://example.com/privacy", - description: "Link to your privacy policy.", - }, - { - key: "oauth_scopes", - label: "OAuth Scopes", - placeholder: "atproto\ninclude:com.example.authBasic", - description: - "One scope per line (or space-separated). Must include `atproto`. Use `include:` to reference lexicon permission sets.", - multiline: true, + description: "Link to your privacy policy. Optional.", }, ] -export default function OAuthSettingsPage() { +export default function GeneralSettingsPage() { const { hasPermission } = useCurrentUser() const canManage = hasPermission("settings:manage") @@ -83,7 +80,6 @@ client_uri: "", logo_uri: "", tos_uri: "", policy_uri: "", - oauth_scopes: "", }) const [sources, setSources] = useState>({ app_name: "unset", @@ -91,7 +87,6 @@ client_uri: "unset", logo_uri: "unset", tos_uri: "unset", policy_uri: "unset", - oauth_scopes: "unset", }) const [logoUploaded, setLogoUploaded] = useState(false) const [error, setError] = useState(null) @@ -109,7 +104,6 @@ client_uri: byKey.get("client_uri")?.value ?? "", logo_uri: byKey.get("logo_uri")?.value ?? "", tos_uri: byKey.get("tos_uri")?.value ?? "", policy_uri: byKey.get("policy_uri")?.value ?? "", - oauth_scopes: byKey.get("oauth_scopes")?.value ?? "", }) setSources({ app_name: (byKey.get("app_name")?.source as "database" | "env" | undefined) ?? "unset", @@ -117,8 +111,6 @@ client_uri: (byKey.get("client_uri")?.source as "database" | "env" | undefined) ?? "unset", logo_uri: (byKey.get("logo_uri")?.source as "database" | "env" | undefined) ?? "unset", tos_uri: (byKey.get("tos_uri")?.source as "database" | "env" | undefined) ?? "unset", policy_uri: (byKey.get("policy_uri")?.source as "database" | "env" | undefined) ?? "unset", - oauth_scopes: - (byKey.get("oauth_scopes")?.source as "database" | "env" | undefined) ?? "unset", }) setLogoUploaded(byKey.has("logo_data")) } catch (e: unknown) { @@ -130,29 +122,17 @@ useEffect(() => { load() }, [load]) - const scopesMissingAtproto = useMemo(() => { - const tokens = values.oauth_scopes.split(/\s+/).filter(Boolean) - return tokens.length > 0 && !tokens.includes("atproto") - }, [values.oauth_scopes]) - async function handleSave() { setError(null) setNotice(null) setSaving(true) try { - // Normalize scopes to space-separated - const normalizedScopes = values.oauth_scopes - .split(/\s+/) - .filter(Boolean) - .join(" ") - for (const field of FIELDS) { - const value = field.key === "oauth_scopes" ? normalizedScopes : values[field.key] + const value = values[field.key] if (value === "") { if (sources[field.key] === "database") { await deleteSetting(field.key) } - // If source is env or unset and value is empty, nothing to persist } else { await upsertSetting(field.key, value) } @@ -194,17 +174,16 @@ } return ( <> - +
{error &&

{error}

} {notice &&

{notice}

}
-

Client Metadata

+

Instance Identity

- These values are served from{" "} - /oauth-client-metadata.json and shown - on the OAuth consent screen. + Configure your HappyView instance. These values are used in the + dashboard sidebar and on the OAuth consent screen.

@@ -218,35 +197,16 @@ from env var )}
- {field.multiline ? ( -