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: allow admin to change logo/document title for the dashboard

Trezy b44cb3a4 e4040924

+107 -132
-1
src/admin/settings.rs
··· 20 20 ("logo_uri", "LOGO_URI"), 21 21 ("tos_uri", "TOS_URI"), 22 22 ("policy_uri", "POLICY_URI"), 23 - ("oauth_scopes", "OAUTH_SCOPES"), 24 23 ]; 25 24 26 25 /// Resolve a setting value: check the DB first, then fall back to env var.
+1 -12
src/auth/routes.rs
··· 72 72 parsed 73 73 } 74 74 } else { 75 - match crate::admin::settings::get_setting(&state.db, "oauth_scopes", state.db_backend).await 76 - { 77 - Some(s) => { 78 - let parsed = parse_scope_string(&s); 79 - if parsed.is_empty() { 80 - vec![Scope::Known(KnownScope::Atproto)] 81 - } else { 82 - parsed 83 - } 84 - } 85 - None => vec![Scope::Known(KnownScope::Atproto)], 86 - } 75 + vec![Scope::Known(KnownScope::Atproto)] 87 76 }; 88 77 89 78 tracing::debug!(scopes = ?scopes, client_id = ?query.client_id, "resolved oauth scopes");
+3 -14
src/main.rs
··· 331 331 332 332 let oauth_state_store = DbStateStore::new(db_pool.clone(), db_backend); 333 333 334 - // Load OAuth scopes from the settings DB so they can be managed at runtime 335 - // without restarting HappyView. Falls back to just `atproto` if unset. 336 - let oauth_scopes = 337 - match happyview::admin::settings::get_setting(&db_pool, "oauth_scopes", db_backend).await { 338 - Some(s) => { 339 - let parsed = happyview::auth::parse_scope_string(&s); 340 - if parsed.is_empty() { 341 - vec![Scope::Known(KnownScope::Atproto)] 342 - } else { 343 - parsed 344 - } 345 - } 346 - None => vec![Scope::Known(KnownScope::Atproto)], 347 - }; 334 + // HappyView's own default OAuth client always uses the `atproto` scope. 335 + // API clients configure their own scopes via the API Clients settings page. 336 + let oauth_scopes = vec![Scope::Known(KnownScope::Atproto)]; 348 337 349 338 let oauth_client = if is_loopback { 350 339 info!("Using loopback OAuth client metadata (local development)");
+23 -10
src/server.rs
··· 99 99 } 100 100 101 101 async fn config_endpoint(State(state): State<AppState>) -> Json<serde_json::Value> { 102 + let pool = &state.db; 103 + let backend = state.db_backend; 104 + 105 + let app_name = crate::admin::settings::get_setting(pool, "app_name", backend) 106 + .await 107 + .or_else(|| state.config.app_name.clone()); 108 + 109 + let has_logo_data = crate::admin::settings::get_setting(pool, "logo_data", backend) 110 + .await 111 + .is_some(); 112 + let logo_url = if has_logo_data { 113 + Some(format!( 114 + "{}/settings/logo", 115 + state.config.public_url.trim_end_matches('/') 116 + )) 117 + } else { 118 + crate::admin::settings::get_setting(pool, "logo_uri", backend) 119 + .await 120 + .or_else(|| state.config.logo_uri.clone()) 121 + }; 122 + 102 123 Json(serde_json::json!({ 103 124 "public_url": state.config.public_url, 104 125 "version": env!("CARGO_PKG_VERSION"), ··· 108 129 "plc_url": state.config.plc_url, 109 130 "default_rate_limit_capacity": state.config.default_rate_limit_capacity, 110 131 "default_rate_limit_refill_rate": state.config.default_rate_limit_refill_rate, 132 + "app_name": app_name, 133 + "logo_url": logo_url, 111 134 })) 112 135 } 113 136 ··· 153 176 154 177 if let Some(uri) = crate::admin::settings::get_setting(pool, "policy_uri", backend).await { 155 178 metadata["policy_uri"] = serde_json::Value::String(uri); 156 - } 157 - 158 - // OAuth scopes: override from the settings DB so admins can manage scopes without 159 - // restarting HappyView. The authorization server fetches this endpoint to validate 160 - // scope requests at PAR time, so this value is authoritative for non-loopback clients. 161 - if let Some(scopes) = crate::admin::settings::get_setting(pool, "oauth_scopes", backend).await { 162 - let normalized = scopes.split_whitespace().collect::<Vec<_>>().join(" "); 163 - if !normalized.is_empty() { 164 - metadata["scope"] = serde_json::Value::String(normalized); 165 - } 166 179 } 167 180 168 181 Json(metadata)
+6
web/src/app/dashboard/layout.tsx
··· 4 4 import { useRouter } from "next/navigation" 5 5 6 6 import { useAuth } from "@/lib/auth-context" 7 + import { useConfig } from "@/lib/config-context" 7 8 import { AppSidebar } from "@/components/app-sidebar" 8 9 import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar" 9 10 ··· 13 14 children: React.ReactNode 14 15 }) { 15 16 const { did } = useAuth() 17 + const { app_name } = useConfig() 16 18 const router = useRouter() 17 19 18 20 useEffect(() => { ··· 20 22 router.replace("/login") 21 23 } 22 24 }, [did, router]) 25 + 26 + useEffect(() => { 27 + document.title = app_name ? `${app_name} Admin` : "HappyView Admin" 28 + }, [app_name]) 23 29 24 30 if (!did) return null 25 31
+32 -72
web/src/app/dashboard/settings/oauth/page.tsx web/src/app/dashboard/settings/general/page.tsx
··· 1 1 "use client" 2 2 3 - import { useCallback, useEffect, useMemo, useRef, useState } from "react" 3 + import { useCallback, useEffect, useRef, useState } from "react" 4 4 import { Upload, Trash2 } from "lucide-react" 5 5 6 6 import { useCurrentUser } from "@/hooks/use-current-user" ··· 10 10 deleteSetting, 11 11 uploadLogo, 12 12 deleteLogo, 13 - OAUTH_SETTING_KEYS, 14 13 type SettingEntry, 15 14 } from "@/lib/api" 16 15 import { SiteHeader } from "@/components/site-header" 17 16 import { Button } from "@/components/ui/button" 18 17 import { Input } from "@/components/ui/input" 19 18 import { Label } from "@/components/ui/label" 20 - import { Textarea } from "@/components/ui/textarea" 21 19 22 - type FieldKey = (typeof OAUTH_SETTING_KEYS)[number] 20 + const SETTING_KEYS = [ 21 + "app_name", 22 + "client_uri", 23 + "logo_uri", 24 + "tos_uri", 25 + "policy_uri", 26 + ] as const 27 + 28 + type FieldKey = (typeof SETTING_KEYS)[number] 23 29 24 30 type FieldConfig = { 25 31 key: FieldKey 26 32 label: string 27 33 placeholder: string 28 34 description: string 29 - multiline?: boolean 30 35 } 31 36 32 37 const FIELDS: FieldConfig[] = [ 33 38 { 34 39 key: "app_name", 35 - label: "Client Name", 40 + label: "Instance Name", 36 41 placeholder: "My HappyView Instance", 37 42 description: 38 - "Shown to users on the OAuth consent screen.", 43 + "Display name for this instance. Shown in the sidebar and on the OAuth consent screen.", 39 44 }, 40 45 { 41 46 key: "client_uri", 42 - label: "Client URI", 47 + label: "Instance URI", 43 48 placeholder: "https://example.com", 44 49 description: 45 - "The homepage for this application, linked from the consent screen.", 50 + "The public URL for this instance, linked from the OAuth consent screen.", 46 51 }, 47 52 { 48 53 key: "logo_uri", ··· 55 60 key: "tos_uri", 56 61 label: "Terms of Service URI", 57 62 placeholder: "https://example.com/terms", 58 - description: "Link to your terms of service.", 63 + description: "Link to your terms of service. Optional.", 59 64 }, 60 65 { 61 66 key: "policy_uri", 62 67 label: "Privacy Policy URI", 63 68 placeholder: "https://example.com/privacy", 64 - description: "Link to your privacy policy.", 65 - }, 66 - { 67 - key: "oauth_scopes", 68 - label: "OAuth Scopes", 69 - placeholder: "atproto\ninclude:com.example.authBasic", 70 - description: 71 - "One scope per line (or space-separated). Must include `atproto`. Use `include:<permission-set-nsid>` to reference lexicon permission sets.", 72 - multiline: true, 69 + description: "Link to your privacy policy. Optional.", 73 70 }, 74 71 ] 75 72 76 - export default function OAuthSettingsPage() { 73 + export default function GeneralSettingsPage() { 77 74 const { hasPermission } = useCurrentUser() 78 75 const canManage = hasPermission("settings:manage") 79 76 ··· 83 80 logo_uri: "", 84 81 tos_uri: "", 85 82 policy_uri: "", 86 - oauth_scopes: "", 87 83 }) 88 84 const [sources, setSources] = useState<Record<FieldKey, "database" | "env" | "unset">>({ 89 85 app_name: "unset", ··· 91 87 logo_uri: "unset", 92 88 tos_uri: "unset", 93 89 policy_uri: "unset", 94 - oauth_scopes: "unset", 95 90 }) 96 91 const [logoUploaded, setLogoUploaded] = useState(false) 97 92 const [error, setError] = useState<string | null>(null) ··· 109 104 logo_uri: byKey.get("logo_uri")?.value ?? "", 110 105 tos_uri: byKey.get("tos_uri")?.value ?? "", 111 106 policy_uri: byKey.get("policy_uri")?.value ?? "", 112 - oauth_scopes: byKey.get("oauth_scopes")?.value ?? "", 113 107 }) 114 108 setSources({ 115 109 app_name: (byKey.get("app_name")?.source as "database" | "env" | undefined) ?? "unset", ··· 117 111 logo_uri: (byKey.get("logo_uri")?.source as "database" | "env" | undefined) ?? "unset", 118 112 tos_uri: (byKey.get("tos_uri")?.source as "database" | "env" | undefined) ?? "unset", 119 113 policy_uri: (byKey.get("policy_uri")?.source as "database" | "env" | undefined) ?? "unset", 120 - oauth_scopes: 121 - (byKey.get("oauth_scopes")?.source as "database" | "env" | undefined) ?? "unset", 122 114 }) 123 115 setLogoUploaded(byKey.has("logo_data")) 124 116 } catch (e: unknown) { ··· 130 122 load() 131 123 }, [load]) 132 124 133 - const scopesMissingAtproto = useMemo(() => { 134 - const tokens = values.oauth_scopes.split(/\s+/).filter(Boolean) 135 - return tokens.length > 0 && !tokens.includes("atproto") 136 - }, [values.oauth_scopes]) 137 - 138 125 async function handleSave() { 139 126 setError(null) 140 127 setNotice(null) 141 128 setSaving(true) 142 129 try { 143 - // Normalize scopes to space-separated 144 - const normalizedScopes = values.oauth_scopes 145 - .split(/\s+/) 146 - .filter(Boolean) 147 - .join(" ") 148 - 149 130 for (const field of FIELDS) { 150 - const value = field.key === "oauth_scopes" ? normalizedScopes : values[field.key] 131 + const value = values[field.key] 151 132 if (value === "") { 152 133 if (sources[field.key] === "database") { 153 134 await deleteSetting(field.key) 154 135 } 155 - // If source is env or unset and value is empty, nothing to persist 156 136 } else { 157 137 await upsertSetting(field.key, value) 158 138 } ··· 194 174 195 175 return ( 196 176 <> 197 - <SiteHeader title="OAuth Settings" /> 177 + <SiteHeader title="General Settings" /> 198 178 <div className="flex flex-1 flex-col gap-6 p-4 md:p-6 max-w-3xl"> 199 179 {error && <p className="text-destructive text-sm">{error}</p>} 200 180 {notice && <p className="text-sm text-green-600 dark:text-green-400">{notice}</p>} 201 181 202 182 <div> 203 - <h2 className="text-lg font-semibold">Client Metadata</h2> 183 + <h2 className="text-lg font-semibold">Instance Identity</h2> 204 184 <p className="text-muted-foreground text-sm"> 205 - These values are served from{" "} 206 - <code className="text-xs">/oauth-client-metadata.json</code> and shown 207 - on the OAuth consent screen. 185 + Configure your HappyView instance. These values are used in the 186 + dashboard sidebar and on the OAuth consent screen. 208 187 </p> 209 188 </div> 210 189 ··· 218 197 </span> 219 198 )} 220 199 </div> 221 - {field.multiline ? ( 222 - <Textarea 223 - id={field.key} 224 - value={values[field.key]} 225 - onChange={(e) => 226 - setValues((v) => ({ ...v, [field.key]: e.target.value })) 227 - } 228 - placeholder={field.placeholder} 229 - rows={6} 230 - className="font-mono text-sm" 231 - disabled={!canManage} 232 - /> 233 - ) : ( 234 - <Input 235 - id={field.key} 236 - value={values[field.key]} 237 - onChange={(e) => 238 - setValues((v) => ({ ...v, [field.key]: e.target.value })) 239 - } 240 - placeholder={field.placeholder} 241 - disabled={!canManage} 242 - /> 243 - )} 200 + <Input 201 + id={field.key} 202 + value={values[field.key]} 203 + onChange={(e) => 204 + setValues((v) => ({ ...v, [field.key]: e.target.value })) 205 + } 206 + placeholder={field.placeholder} 207 + disabled={!canManage} 208 + /> 244 209 <p className="text-muted-foreground text-xs">{field.description}</p> 245 - {field.key === "oauth_scopes" && scopesMissingAtproto && ( 246 - <p className="text-destructive text-xs"> 247 - The scope list must include <code>atproto</code>. 248 - </p> 249 - )} 250 210 </div> 251 211 ))} 252 212 ··· 295 255 <div className="flex justify-end pt-2"> 296 256 <Button 297 257 onClick={handleSave} 298 - disabled={!canManage || saving || scopesMissingAtproto} 258 + disabled={!canManage || saving} 299 259 > 300 260 {saving ? "Saving..." : "Save changes"} 301 261 </Button>
+31 -16
web/src/components/app-sidebar.tsx
··· 24 24 import { usePathname } from "next/navigation" 25 25 26 26 import { useAuth } from "@/lib/auth-context" 27 + import { useConfig } from "@/lib/config-context" 27 28 import { useCurrentUser } from "@/hooks/use-current-user" 28 29 import { 29 30 Collapsible, ··· 61 62 { title: "API Keys", url: "/dashboard/settings/api-keys", icon: IconKey, requiredPermissions: ["api-keys:read"] }, 62 63 { title: "API Clients", url: "/dashboard/settings/api-clients", icon: IconApps, requiredPermissions: ["api-clients:view"] }, 63 64 { title: "Labelers", url: "/dashboard/settings/labelers", icon: IconTag, requiredPermissions: ["labelers:read"] }, 64 - { title: "OAuth", url: "/dashboard/settings/oauth", icon: IconLockAccess, requiredPermissions: ["settings:manage"] }, 65 + { title: "General", url: "/dashboard/settings/general", icon: IconLockAccess, requiredPermissions: ["settings:manage"] }, 65 66 ] as const 66 67 67 68 export function AppSidebar({ ··· 69 70 }: React.ComponentProps<typeof Sidebar>) { 70 71 const pathname = usePathname() 71 72 const { logout } = useAuth() 73 + const { app_name, logo_url } = useConfig() 72 74 const { hasPermission } = useCurrentUser() 73 75 74 76 const visibleNavItems = navItems.filter((item) => { ··· 85 87 86 88 return ( 87 89 <Sidebar collapsible="offcanvas" {...props}> 88 - <SidebarHeader className="p-4"> 89 - <Image 90 - src="/logo.light.png" 91 - alt="HappyView" 92 - width={140} 93 - height={48} 94 - className="block dark:hidden" 95 - /> 96 - <Image 97 - src="/logo.dark.png" 98 - alt="HappyView" 99 - width={140} 100 - height={48} 101 - className="hidden dark:block" 102 - /> 90 + <SidebarHeader className="flex items-center justify-center p-4"> 91 + {logo_url ? ( 92 + <Image 93 + src={logo_url} 94 + alt={app_name ?? "HappyView"} 95 + width={140} 96 + height={48} 97 + className="object-contain" 98 + unoptimized 99 + /> 100 + ) : ( 101 + <> 102 + <Image 103 + src="/logo.light.png" 104 + alt="HappyView" 105 + width={140} 106 + height={48} 107 + className="block dark:hidden" 108 + /> 109 + <Image 110 + src="/logo.dark.png" 111 + alt="HappyView" 112 + width={140} 113 + height={48} 114 + className="hidden dark:block" 115 + /> 116 + </> 117 + )} 103 118 </SidebarHeader> 104 119 <SidebarContent> 105 120 <SidebarGroup>
+2 -2
web/src/lib/api.ts
··· 33 33 export type { LabelerSummary } from "@/types/labelers" 34 34 export type { RecordLabel } from "@/types/records" 35 35 export type { ApiClientSummary, CreateApiClientResponse } from "@/types/api-clients" 36 - export type { SettingEntry, OAuthSettings } from "@/types/settings" 37 - export { OAUTH_SETTING_KEYS } from "@/types/settings" 36 + export type { SettingEntry, InstanceSettings } from "@/types/settings" 37 + export { INSTANCE_SETTING_KEYS } from "@/types/settings" 38 38 export type { 39 39 ExternalProvider, 40 40 LinkedAccount,
+6
web/src/lib/config-context.tsx
··· 6 6 public_url: string 7 7 default_rate_limit_capacity: number 8 8 default_rate_limit_refill_rate: number 9 + app_name: string | null 10 + logo_url: string | null 9 11 } 10 12 11 13 const ConfigContext = createContext<ConfigContextType>({ 12 14 public_url: "", 13 15 default_rate_limit_capacity: 100, 14 16 default_rate_limit_refill_rate: 2.0, 17 + app_name: null, 18 + logo_url: null, 15 19 }) 16 20 17 21 export function ConfigProvider({ children }: { children: React.ReactNode }) { ··· 29 33 public_url: data.public_url, 30 34 default_rate_limit_capacity: data.default_rate_limit_capacity, 31 35 default_rate_limit_refill_rate: data.default_rate_limit_refill_rate, 36 + app_name: data.app_name ?? null, 37 + logo_url: data.logo_url ?? null, 32 38 }) 33 39 }) 34 40 .catch((e) => setError(e.message))
+3 -5
web/src/types/settings.ts
··· 4 4 source: "database" | "env" 5 5 } 6 6 7 - export type OAuthSettings = { 7 + export type InstanceSettings = { 8 8 app_name: string 9 9 client_uri: string 10 10 logo_uri: string 11 11 tos_uri: string 12 12 policy_uri: string 13 - oauth_scopes: string 14 13 } 15 14 16 - export const OAUTH_SETTING_KEYS = [ 15 + export const INSTANCE_SETTING_KEYS = [ 17 16 "app_name", 18 17 "client_uri", 19 18 "logo_uri", 20 19 "tos_uri", 21 20 "policy_uri", 22 - "oauth_scopes", 23 - ] as const satisfies readonly (keyof OAuthSettings)[] 21 + ] as const satisfies readonly (keyof InstanceSettings)[]