From bff987628efea446d77736f4b7175d91b6ec6768 Mon Sep 17 00:00:00 2001 From: Trezy Date: Sat, 14 Feb 2026 10:59:16 -0600 Subject: [PATCH] feat: add admin login page with token auth --- web/src/app/layout.tsx | 6 +- web/src/app/login/page.tsx | 25 +++++ web/src/components/login-form.tsx | 62 ++++++++++ web/src/lib/api.ts | 181 ++++++++++++++++++++++++++++++ web/src/lib/auth-context.tsx | 48 ++++++++ 5 files changed, 321 insertions(+), 1 deletion(-) create mode 100644 web/src/app/login/page.tsx create mode 100644 web/src/components/login-form.tsx create mode 100644 web/src/lib/api.ts create mode 100644 web/src/lib/auth-context.tsx diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index 6c8c81d..efd7349 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -1,6 +1,8 @@ import type { Metadata } from "next" import { Geist, Geist_Mono } from "next/font/google" import "./globals.css" +import { AuthProvider } from "@/lib/auth-context" +import { TooltipProvider } from "@/components/ui/tooltip" const geistSans = Geist({ variable: "--font-geist-sans", @@ -27,7 +29,9 @@ export default function RootLayout({ - {children} + + {children} + ) diff --git a/web/src/app/login/page.tsx b/web/src/app/login/page.tsx new file mode 100644 index 0000000..9d4a617 --- /dev/null +++ b/web/src/app/login/page.tsx @@ -0,0 +1,25 @@ +"use client" + +import { useEffect } from "react" +import { useRouter } from "next/navigation" +import { LoginForm } from "@/components/login-form" +import { useAuth } from "@/lib/auth-context" + +export default function LoginPage() { + const { token } = useAuth() + const router = useRouter() + + useEffect(() => { + if (token) router.replace("/") + }, [token, router]) + + if (token) return null + + return ( +
+
+ +
+
+ ) +} diff --git a/web/src/components/login-form.tsx b/web/src/components/login-form.tsx new file mode 100644 index 0000000..2428f4e --- /dev/null +++ b/web/src/components/login-form.tsx @@ -0,0 +1,62 @@ +"use client" + +import { useState } from "react" +import { useRouter } from "next/navigation" + +import { cn } from "@/lib/utils" +import { useAuth } from "@/lib/auth-context" +import { Button } from "@/components/ui/button" +import { + Field, + FieldDescription, + FieldGroup, + FieldLabel, +} from "@/components/ui/field" +import { Input } from "@/components/ui/input" + +export function LoginForm({ + className, + ...props +}: React.ComponentProps<"div">) { + const [token, setToken] = useState("") + const { login } = useAuth() + const router = useRouter() + + function handleSubmit(e: React.FormEvent) { + e.preventDefault() + if (!token.trim()) return + login(token.trim()) + router.push("/") + } + + return ( +
+
+ +
+

HappyView Admin

+ + Enter your access token to manage your AppView. + +
+ + Access Token + setToken(e.target.value)} + required + /> + + + + +
+
+
+ ) +} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts new file mode 100644 index 0000000..8028e0f --- /dev/null +++ b/web/src/lib/api.ts @@ -0,0 +1,181 @@ +export class ApiError extends Error { + status: number + constructor(status: number, message: string) { + super(message) + this.status = status + } +} + +async function apiFetch( + path: string, + token: string, + options?: RequestInit +): Promise { + const headers: Record = { + Authorization: `Bearer ${token}`, + } + if ( + options?.method === "POST" || + options?.method === "PUT" || + options?.method === "PATCH" + ) { + headers["Content-Type"] = "application/json" + } + + const res = await fetch(path, { + ...options, + headers: { ...headers, ...options?.headers }, + }) + + if (!res.ok) { + const text = await res.text().catch(() => res.statusText) + throw new ApiError(res.status, text) + } + if (res.status === 204) return null as T + return res.json() +} + +// Stats +export interface CollectionStat { + collection: string + count: number +} + +export interface StatsResponse { + total_records: number + collections: CollectionStat[] +} + +export function getStats(token: string) { + return apiFetch("/admin/stats", token) +} + +// Lexicons +export interface LexiconSummary { + id: string + revision: number + lexicon_type: string + backfill: boolean + action: string | null + created_at: string + updated_at: string +} + +export interface LexiconDetail extends LexiconSummary { + lexicon_json: Record +} + +export function getLexicons(token: string) { + return apiFetch("/admin/lexicons", token) +} + +export function getLexicon(token: string, id: string) { + return apiFetch(`/admin/lexicons/${encodeURIComponent(id)}`, token) +} + +export function uploadLexicon( + token: string, + body: { + lexicon_json: unknown + backfill?: boolean + target_collection?: string + action?: string + } +) { + return apiFetch<{ id: string; revision: number }>("/admin/lexicons", token, { + method: "POST", + body: JSON.stringify(body), + }) +} + +export function deleteLexicon(token: string, id: string) { + return apiFetch(`/admin/lexicons/${encodeURIComponent(id)}`, token, { + method: "DELETE", + }) +} + +// Network Lexicons +export interface NetworkLexiconSummary { + nsid: string + authority_did: string + target_collection: string | null + last_fetched_at: string | null + created_at: string +} + +export function getNetworkLexicons(token: string) { + return apiFetch("/admin/network-lexicons", token) +} + +export function addNetworkLexicon( + token: string, + body: { nsid: string; target_collection?: string } +) { + return apiFetch<{ nsid: string; authority_did: string; revision: number }>( + "/admin/network-lexicons", + token, + { method: "POST", body: JSON.stringify(body) } + ) +} + +export function deleteNetworkLexicon(token: string, nsid: string) { + return apiFetch( + `/admin/network-lexicons/${encodeURIComponent(nsid)}`, + token, + { method: "DELETE" } + ) +} + +// Backfill +export interface BackfillJob { + id: string + collection: string | null + did: string | null + status: string + total_repos: number | null + processed_repos: number | null + total_records: number | null + error: string | null + started_at: string | null + completed_at: string | null + created_at: string +} + +export function getBackfillJobs(token: string) { + return apiFetch("/admin/backfill/status", token) +} + +export function createBackfillJob( + token: string, + body: { collection?: string; did?: string } +) { + return apiFetch<{ id: string; status: string }>("/admin/backfill", token, { + method: "POST", + body: JSON.stringify(body), + }) +} + +// Admins +export interface AdminSummary { + id: string + did: string + created_at: string + last_used_at: string | null +} + +export function getAdmins(token: string) { + return apiFetch("/admin/admins", token) +} + +export function addAdmin(token: string, body: { did: string }) { + return apiFetch<{ id: string; did: string }>("/admin/admins", token, { + method: "POST", + body: JSON.stringify(body), + }) +} + +export function deleteAdmin(token: string, id: string) { + return apiFetch(`/admin/admins/${encodeURIComponent(id)}`, token, { + method: "DELETE", + }) +} diff --git a/web/src/lib/auth-context.tsx b/web/src/lib/auth-context.tsx new file mode 100644 index 0000000..0bede04 --- /dev/null +++ b/web/src/lib/auth-context.tsx @@ -0,0 +1,48 @@ +"use client" + +import { createContext, useCallback, useContext, useEffect, useState } from "react" + +interface AuthContextType { + token: string | null + login: (token: string) => void + logout: () => void +} + +const AuthContext = createContext({ + token: null, + login: () => {}, + logout: () => {}, +}) + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [token, setToken] = useState(null) + const [loaded, setLoaded] = useState(false) + + useEffect(() => { + const stored = localStorage.getItem("happyview_token") + if (stored) setToken(stored) + setLoaded(true) + }, []) + + const login = useCallback((t: string) => { + localStorage.setItem("happyview_token", t) + setToken(t) + }, []) + + const logout = useCallback(() => { + localStorage.removeItem("happyview_token") + setToken(null) + }, []) + + if (!loaded) return null + + return ( + + {children} + + ) +} + +export function useAuth() { + return useContext(AuthContext) +} -- 2.51.2