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 @@ } 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 @@ "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/src/admin/settings.rs b/src/admin/settings.rs --- a/src/admin/settings.rs +++ b/src/admin/settings.rs @@ -20,7 +20,6 @@ ("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 @@ 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/web/src/components/app-sidebar.tsx b/web/src/components/app-sidebar.tsx --- a/web/src/components/app-sidebar.tsx +++ b/web/src/components/app-sidebar.tsx @@ -24,6 +24,7 @@ import { usePathname } from "next/navigation" import { useAuth } from "@/lib/auth-context" +import { useConfig } from "@/lib/config-context" import { useCurrentUser } from "@/hooks/use-current-user" import { Collapsible, @@ -61,7 +62,7 @@ { title: "API Keys", url: "/dashboard/settings/api-keys", icon: IconKey, requiredPermissions: ["api-keys:read"] }, { title: "API Clients", url: "/dashboard/settings/api-clients", icon: IconApps, requiredPermissions: ["api-clients:view"] }, { title: "Labelers", url: "/dashboard/settings/labelers", icon: IconTag, requiredPermissions: ["labelers:read"] }, - { title: "OAuth", url: "/dashboard/settings/oauth", icon: IconLockAccess, requiredPermissions: ["settings:manage"] }, + { title: "General", url: "/dashboard/settings/general", icon: IconLockAccess, requiredPermissions: ["settings:manage"] }, ] as const export function AppSidebar({ @@ -69,6 +70,7 @@ }: React.ComponentProps) { const pathname = usePathname() const { logout } = useAuth() + const { app_name, logo_url } = useConfig() const { hasPermission } = useCurrentUser() const visibleNavItems = navItems.filter((item) => { @@ -85,21 +87,34 @@ return ( - - HappyView - HappyView + + {logo_url ? ( + {app_name + ) : ( + <> + HappyView + HappyView + + )} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -33,8 +33,8 @@ export type { LabelerSummary } from "@/types/labelers" export type { RecordLabel } from "@/types/records" export type { ApiClientSummary, CreateApiClientResponse } from "@/types/api-clients" -export type { SettingEntry, OAuthSettings } from "@/types/settings" -export { OAUTH_SETTING_KEYS } from "@/types/settings" +export type { SettingEntry, InstanceSettings } from "@/types/settings" +export { INSTANCE_SETTING_KEYS } from "@/types/settings" export type { ExternalProvider, LinkedAccount, diff --git a/web/src/lib/config-context.tsx b/web/src/lib/config-context.tsx --- a/web/src/lib/config-context.tsx +++ b/web/src/lib/config-context.tsx @@ -6,12 +6,16 @@ public_url: string default_rate_limit_capacity: number default_rate_limit_refill_rate: number + app_name: string | null + logo_url: string | null } const ConfigContext = createContext({ public_url: "", default_rate_limit_capacity: 100, default_rate_limit_refill_rate: 2.0, + app_name: null, + logo_url: null, }) export function ConfigProvider({ children }: { children: React.ReactNode }) { @@ -29,6 +33,8 @@ public_url: data.public_url, default_rate_limit_capacity: data.default_rate_limit_capacity, default_rate_limit_refill_rate: data.default_rate_limit_refill_rate, + app_name: data.app_name ?? null, + logo_url: data.logo_url ?? null, }) }) .catch((e) => setError(e.message)) diff --git a/web/src/types/settings.ts b/web/src/types/settings.ts --- a/web/src/types/settings.ts +++ b/web/src/types/settings.ts @@ -4,20 +4,18 @@ source: "database" | "env" } -export type OAuthSettings = { +export type InstanceSettings = { app_name: string client_uri: string logo_uri: string tos_uri: string policy_uri: string - oauth_scopes: string } -export const OAUTH_SETTING_KEYS = [ +export const INSTANCE_SETTING_KEYS = [ "app_name", "client_uri", "logo_uri", "tos_uri", "policy_uri", - "oauth_scopes", -] as const satisfies readonly (keyof OAuthSettings)[] +] as const satisfies readonly (keyof InstanceSettings)[] 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 { 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 @@ 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/general/page.tsx b/web/src/app/dashboard/settings/general/page.tsx new file mode 100644 --- /dev/null +++ b/web/src/app/dashboard/settings/general/page.tsx @@ -0,0 +1,266 @@ +"use client" + +import { useCallback, useEffect, useRef, useState } from "react" +import { Upload, Trash2 } from "lucide-react" + +import { useCurrentUser } from "@/hooks/use-current-user" +import { + getSettings, + upsertSetting, + deleteSetting, + uploadLogo, + deleteLogo, + 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" + +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 +} + +const FIELDS: FieldConfig[] = [ + { + key: "app_name", + label: "Instance Name", + placeholder: "My HappyView Instance", + description: + "Display name for this instance. Shown in the sidebar and on the OAuth consent screen.", + }, + { + key: "client_uri", + label: "Instance URI", + placeholder: "https://example.com", + description: + "The public URL for this instance, linked from the OAuth consent screen.", + }, + { + key: "logo_uri", + label: "Logo URI", + placeholder: "https://example.com/logo.png", + description: + "External URL to a logo image. Overridden by an uploaded logo below.", + }, + { + key: "tos_uri", + label: "Terms of Service URI", + placeholder: "https://example.com/terms", + 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. Optional.", + }, +] + +export default function GeneralSettingsPage() { + const { hasPermission } = useCurrentUser() + const canManage = hasPermission("settings:manage") + + const [values, setValues] = useState>({ + app_name: "", + client_uri: "", + logo_uri: "", + tos_uri: "", + policy_uri: "", + }) + const [sources, setSources] = useState>({ + app_name: "unset", + client_uri: "unset", + logo_uri: "unset", + tos_uri: "unset", + policy_uri: "unset", + }) + const [logoUploaded, setLogoUploaded] = useState(false) + const [error, setError] = useState(null) + const [saving, setSaving] = useState(false) + const [notice, setNotice] = useState(null) + const fileInputRef = useRef(null) + + const load = useCallback(async () => { + try { + const entries = await getSettings() + const byKey = new Map(entries.map((e) => [e.key, e])) + setValues({ + app_name: byKey.get("app_name")?.value ?? "", + 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 ?? "", + }) + setSources({ + app_name: (byKey.get("app_name")?.source as "database" | "env" | undefined) ?? "unset", + 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", + }) + setLogoUploaded(byKey.has("logo_data")) + } catch (e: unknown) { + setError(e instanceof Error ? e.message : String(e)) + } + }, []) + + useEffect(() => { + load() + }, [load]) + + async function handleSave() { + setError(null) + setNotice(null) + setSaving(true) + try { + for (const field of FIELDS) { + const value = values[field.key] + if (value === "") { + if (sources[field.key] === "database") { + await deleteSetting(field.key) + } + } else { + await upsertSetting(field.key, value) + } + } + setNotice("Settings saved.") + await load() + } catch (e: unknown) { + setError(e instanceof Error ? e.message : String(e)) + } finally { + setSaving(false) + } + } + + async function handleLogoUpload(e: React.ChangeEvent) { + const file = e.target.files?.[0] + if (!file) return + setError(null) + try { + await uploadLogo(file) + setNotice("Logo uploaded.") + await load() + } catch (err: unknown) { + setError(err instanceof Error ? err.message : String(err)) + } finally { + if (fileInputRef.current) fileInputRef.current.value = "" + } + } + + async function handleLogoDelete() { + setError(null) + try { + await deleteLogo() + setNotice("Logo removed.") + await load() + } catch (err: unknown) { + setError(err instanceof Error ? err.message : String(err)) + } + } + + return ( + <> + +
+ {error &&

{error}

} + {notice &&

{notice}

} + +
+

Instance Identity

+

+ Configure your HappyView instance. These values are used in the + dashboard sidebar and on the OAuth consent screen. +

+
+ + {FIELDS.map((field) => ( +
+
+ + {sources[field.key] === "env" && ( + + from env var + + )} +
+ + setValues((v) => ({ ...v, [field.key]: e.target.value })) + } + placeholder={field.placeholder} + disabled={!canManage} + /> +

{field.description}

+
+ ))} + +
+ +

+ Upload a logo (max 5MB). Overrides the Logo URI above when set. +

+
+ + + {logoUploaded && ( + + )} + {logoUploaded && ( + + Current logo served at /settings/logo + + )} +
+
+ +
+ +
+
+ + ) +} diff --git a/web/src/app/dashboard/settings/oauth/page.tsx b/web/src/app/dashboard/settings/oauth/page.tsx deleted file mode 100644 --- a/web/src/app/dashboard/settings/oauth/page.tsx +++ /dev/null @@ -1,306 +0,0 @@ -"use client" - -import { useCallback, useEffect, useMemo, useRef, useState } from "react" -import { Upload, Trash2 } from "lucide-react" - -import { useCurrentUser } from "@/hooks/use-current-user" -import { - getSettings, - 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] - -type FieldConfig = { - key: FieldKey - label: string - placeholder: string - description: string - multiline?: boolean -} - -const FIELDS: FieldConfig[] = [ - { - key: "app_name", - label: "Client Name", - placeholder: "My HappyView Instance", - description: - "Shown to users on the OAuth consent screen.", - }, - { - key: "client_uri", - label: "Client URI", - placeholder: "https://example.com", - description: - "The homepage for this application, linked from the consent screen.", - }, - { - key: "logo_uri", - label: "Logo URI", - placeholder: "https://example.com/logo.png", - description: - "External URL to a logo image. Overridden by an uploaded logo below.", - }, - { - key: "tos_uri", - label: "Terms of Service URI", - placeholder: "https://example.com/terms", - description: "Link to your terms of service.", - }, - { - 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, - }, -] - -export default function OAuthSettingsPage() { - const { hasPermission } = useCurrentUser() - const canManage = hasPermission("settings:manage") - - const [values, setValues] = useState>({ - app_name: "", - client_uri: "", - logo_uri: "", - tos_uri: "", - policy_uri: "", - oauth_scopes: "", - }) - const [sources, setSources] = useState>({ - app_name: "unset", - 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) - const [saving, setSaving] = useState(false) - const [notice, setNotice] = useState(null) - const fileInputRef = useRef(null) - - const load = useCallback(async () => { - try { - const entries = await getSettings() - const byKey = new Map(entries.map((e) => [e.key, e])) - setValues({ - app_name: byKey.get("app_name")?.value ?? "", - 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", - 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) { - setError(e instanceof Error ? e.message : String(e)) - } - }, []) - - 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] - 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) - } - } - setNotice("Settings saved.") - await load() - } catch (e: unknown) { - setError(e instanceof Error ? e.message : String(e)) - } finally { - setSaving(false) - } - } - - async function handleLogoUpload(e: React.ChangeEvent) { - const file = e.target.files?.[0] - if (!file) return - setError(null) - try { - await uploadLogo(file) - setNotice("Logo uploaded.") - await load() - } catch (err: unknown) { - setError(err instanceof Error ? err.message : String(err)) - } finally { - if (fileInputRef.current) fileInputRef.current.value = "" - } - } - - async function handleLogoDelete() { - setError(null) - try { - await deleteLogo() - setNotice("Logo removed.") - await load() - } catch (err: unknown) { - setError(err instanceof Error ? err.message : String(err)) - } - } - - return ( - <> - -
- {error &&

{error}

} - {notice &&

{notice}

} - -
-

Client Metadata

-

- These values are served from{" "} - /oauth-client-metadata.json and shown - on the OAuth consent screen. -

-
- - {FIELDS.map((field) => ( -
-
- - {sources[field.key] === "env" && ( - - from env var - - )} -
- {field.multiline ? ( -