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: add admin login page with token auth

Trezy 4958ec25 a10cf73d

+321 -1
+5 -1
web/src/app/layout.tsx
··· 1 1 import type { Metadata } from "next" 2 2 import { Geist, Geist_Mono } from "next/font/google" 3 3 import "./globals.css" 4 + import { AuthProvider } from "@/lib/auth-context" 5 + import { TooltipProvider } from "@/components/ui/tooltip" 4 6 5 7 const geistSans = Geist({ 6 8 variable: "--font-geist-sans", ··· 27 29 <body 28 30 className={`${geistSans.variable} ${geistMono.variable} antialiased`} 29 31 > 30 - {children} 32 + <AuthProvider> 33 + <TooltipProvider>{children}</TooltipProvider> 34 + </AuthProvider> 31 35 </body> 32 36 </html> 33 37 )
+25
web/src/app/login/page.tsx
··· 1 + "use client" 2 + 3 + import { useEffect } from "react" 4 + import { useRouter } from "next/navigation" 5 + import { LoginForm } from "@/components/login-form" 6 + import { useAuth } from "@/lib/auth-context" 7 + 8 + export default function LoginPage() { 9 + const { token } = useAuth() 10 + const router = useRouter() 11 + 12 + useEffect(() => { 13 + if (token) router.replace("/") 14 + }, [token, router]) 15 + 16 + if (token) return null 17 + 18 + return ( 19 + <div className="bg-background flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10"> 20 + <div className="w-full max-w-sm"> 21 + <LoginForm /> 22 + </div> 23 + </div> 24 + ) 25 + }
+62
web/src/components/login-form.tsx
··· 1 + "use client" 2 + 3 + import { useState } from "react" 4 + import { useRouter } from "next/navigation" 5 + 6 + import { cn } from "@/lib/utils" 7 + import { useAuth } from "@/lib/auth-context" 8 + import { Button } from "@/components/ui/button" 9 + import { 10 + Field, 11 + FieldDescription, 12 + FieldGroup, 13 + FieldLabel, 14 + } from "@/components/ui/field" 15 + import { Input } from "@/components/ui/input" 16 + 17 + export function LoginForm({ 18 + className, 19 + ...props 20 + }: React.ComponentProps<"div">) { 21 + const [token, setToken] = useState("") 22 + const { login } = useAuth() 23 + const router = useRouter() 24 + 25 + function handleSubmit(e: React.FormEvent) { 26 + e.preventDefault() 27 + if (!token.trim()) return 28 + login(token.trim()) 29 + router.push("/") 30 + } 31 + 32 + return ( 33 + <div className={cn("flex flex-col gap-6", className)} {...props}> 34 + <form onSubmit={handleSubmit}> 35 + <FieldGroup> 36 + <div className="flex flex-col items-center gap-2 text-center"> 37 + <h1 className="text-xl font-bold">HappyView Admin</h1> 38 + <FieldDescription> 39 + Enter your access token to manage your AppView. 40 + </FieldDescription> 41 + </div> 42 + <Field> 43 + <FieldLabel htmlFor="token">Access Token</FieldLabel> 44 + <Input 45 + id="token" 46 + type="password" 47 + placeholder="eyJ..." 48 + value={token} 49 + onChange={(e) => setToken(e.target.value)} 50 + required 51 + /> 52 + </Field> 53 + <Field> 54 + <Button type="submit" className="w-full"> 55 + Sign in 56 + </Button> 57 + </Field> 58 + </FieldGroup> 59 + </form> 60 + </div> 61 + ) 62 + }
+181
web/src/lib/api.ts
··· 1 + export class ApiError extends Error { 2 + status: number 3 + constructor(status: number, message: string) { 4 + super(message) 5 + this.status = status 6 + } 7 + } 8 + 9 + async function apiFetch<T = unknown>( 10 + path: string, 11 + token: string, 12 + options?: RequestInit 13 + ): Promise<T> { 14 + const headers: Record<string, string> = { 15 + Authorization: `Bearer ${token}`, 16 + } 17 + if ( 18 + options?.method === "POST" || 19 + options?.method === "PUT" || 20 + options?.method === "PATCH" 21 + ) { 22 + headers["Content-Type"] = "application/json" 23 + } 24 + 25 + const res = await fetch(path, { 26 + ...options, 27 + headers: { ...headers, ...options?.headers }, 28 + }) 29 + 30 + if (!res.ok) { 31 + const text = await res.text().catch(() => res.statusText) 32 + throw new ApiError(res.status, text) 33 + } 34 + if (res.status === 204) return null as T 35 + return res.json() 36 + } 37 + 38 + // Stats 39 + export interface CollectionStat { 40 + collection: string 41 + count: number 42 + } 43 + 44 + export interface StatsResponse { 45 + total_records: number 46 + collections: CollectionStat[] 47 + } 48 + 49 + export function getStats(token: string) { 50 + return apiFetch<StatsResponse>("/admin/stats", token) 51 + } 52 + 53 + // Lexicons 54 + export interface LexiconSummary { 55 + id: string 56 + revision: number 57 + lexicon_type: string 58 + backfill: boolean 59 + action: string | null 60 + created_at: string 61 + updated_at: string 62 + } 63 + 64 + export interface LexiconDetail extends LexiconSummary { 65 + lexicon_json: Record<string, unknown> 66 + } 67 + 68 + export function getLexicons(token: string) { 69 + return apiFetch<LexiconSummary[]>("/admin/lexicons", token) 70 + } 71 + 72 + export function getLexicon(token: string, id: string) { 73 + return apiFetch<LexiconDetail>(`/admin/lexicons/${encodeURIComponent(id)}`, token) 74 + } 75 + 76 + export function uploadLexicon( 77 + token: string, 78 + body: { 79 + lexicon_json: unknown 80 + backfill?: boolean 81 + target_collection?: string 82 + action?: string 83 + } 84 + ) { 85 + return apiFetch<{ id: string; revision: number }>("/admin/lexicons", token, { 86 + method: "POST", 87 + body: JSON.stringify(body), 88 + }) 89 + } 90 + 91 + export function deleteLexicon(token: string, id: string) { 92 + return apiFetch(`/admin/lexicons/${encodeURIComponent(id)}`, token, { 93 + method: "DELETE", 94 + }) 95 + } 96 + 97 + // Network Lexicons 98 + export interface NetworkLexiconSummary { 99 + nsid: string 100 + authority_did: string 101 + target_collection: string | null 102 + last_fetched_at: string | null 103 + created_at: string 104 + } 105 + 106 + export function getNetworkLexicons(token: string) { 107 + return apiFetch<NetworkLexiconSummary[]>("/admin/network-lexicons", token) 108 + } 109 + 110 + export function addNetworkLexicon( 111 + token: string, 112 + body: { nsid: string; target_collection?: string } 113 + ) { 114 + return apiFetch<{ nsid: string; authority_did: string; revision: number }>( 115 + "/admin/network-lexicons", 116 + token, 117 + { method: "POST", body: JSON.stringify(body) } 118 + ) 119 + } 120 + 121 + export function deleteNetworkLexicon(token: string, nsid: string) { 122 + return apiFetch( 123 + `/admin/network-lexicons/${encodeURIComponent(nsid)}`, 124 + token, 125 + { method: "DELETE" } 126 + ) 127 + } 128 + 129 + // Backfill 130 + export interface BackfillJob { 131 + id: string 132 + collection: string | null 133 + did: string | null 134 + status: string 135 + total_repos: number | null 136 + processed_repos: number | null 137 + total_records: number | null 138 + error: string | null 139 + started_at: string | null 140 + completed_at: string | null 141 + created_at: string 142 + } 143 + 144 + export function getBackfillJobs(token: string) { 145 + return apiFetch<BackfillJob[]>("/admin/backfill/status", token) 146 + } 147 + 148 + export function createBackfillJob( 149 + token: string, 150 + body: { collection?: string; did?: string } 151 + ) { 152 + return apiFetch<{ id: string; status: string }>("/admin/backfill", token, { 153 + method: "POST", 154 + body: JSON.stringify(body), 155 + }) 156 + } 157 + 158 + // Admins 159 + export interface AdminSummary { 160 + id: string 161 + did: string 162 + created_at: string 163 + last_used_at: string | null 164 + } 165 + 166 + export function getAdmins(token: string) { 167 + return apiFetch<AdminSummary[]>("/admin/admins", token) 168 + } 169 + 170 + export function addAdmin(token: string, body: { did: string }) { 171 + return apiFetch<{ id: string; did: string }>("/admin/admins", token, { 172 + method: "POST", 173 + body: JSON.stringify(body), 174 + }) 175 + } 176 + 177 + export function deleteAdmin(token: string, id: string) { 178 + return apiFetch(`/admin/admins/${encodeURIComponent(id)}`, token, { 179 + method: "DELETE", 180 + }) 181 + }
+48
web/src/lib/auth-context.tsx
··· 1 + "use client" 2 + 3 + import { createContext, useCallback, useContext, useEffect, useState } from "react" 4 + 5 + interface AuthContextType { 6 + token: string | null 7 + login: (token: string) => void 8 + logout: () => void 9 + } 10 + 11 + const AuthContext = createContext<AuthContextType>({ 12 + token: null, 13 + login: () => {}, 14 + logout: () => {}, 15 + }) 16 + 17 + export function AuthProvider({ children }: { children: React.ReactNode }) { 18 + const [token, setToken] = useState<string | null>(null) 19 + const [loaded, setLoaded] = useState(false) 20 + 21 + useEffect(() => { 22 + const stored = localStorage.getItem("happyview_token") 23 + if (stored) setToken(stored) 24 + setLoaded(true) 25 + }, []) 26 + 27 + const login = useCallback((t: string) => { 28 + localStorage.setItem("happyview_token", t) 29 + setToken(t) 30 + }, []) 31 + 32 + const logout = useCallback(() => { 33 + localStorage.removeItem("happyview_token") 34 + setToken(null) 35 + }, []) 36 + 37 + if (!loaded) return null 38 + 39 + return ( 40 + <AuthContext.Provider value={{ token, login, logout }}> 41 + {children} 42 + </AuthContext.Provider> 43 + ) 44 + } 45 + 46 + export function useAuth() { 47 + return useContext(AuthContext) 48 + }