feat: Phase 1 frontend - auth UI with KITHARA_MODE switch
- Auth API client (src/api/auth.ts): login, register, me, api-key endpoints - AuthContext: React context managing auth state, mode detection, JWT tokens - LoginPage: clean dark-themed login form with error handling - RegisterPage: invite code + registration with password validation - App.tsx: BrowserRouter + AuthProvider wrapper, mode-conditional routing - Single mode: straight to app, no auth required - Multi mode: redirect to /login, auth-gated access - main.tsx: BrowserRouter + AuthProvider initialization - react-router-dom added as dependency - docker-compose.yml: KITHARA_MODE, KITHARA_ADMIN_USER, KITHARA_ADMIN_PASS env vars - requirements.txt: pinned bcrypt==4.0.1 for passlib compatibility - Tested: multi-mode admin bootstrap, JWT login, auth/me round-trip all confirmed
This commit is contained in:
1 parent
b3f273942e
commit
c571e28725
10 files changed
+508
-3
No files matched your search
@@ -12,3 +12,4 @@ google-genai==1.0.0
|
||||
mutagen==1.47.0
|
||||
PyJWT==2.8.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
bcrypt==4.0.1
|
||||
@@ -10,6 +10,9 @@ services:
|
||||
environment:
|
||||
- LYRIA_API_KEY=${LYRIA_API_KEY}
|
||||
- LYRIA_DEFAULT_MODEL=${LYRIA_DEFAULT_MODEL:-lyria-3-clip-preview}
|
||||
- KITHARA_MODE=${KITHARA_MODE:-single}
|
||||
- KITHARA_ADMIN_USER=${KITHARA_ADMIN_USER:-admin}
|
||||
- KITHARA_ADMIN_PASS=${KITHARA_ADMIN_PASS:-admin}
|
||||
- TZ=${TZ:-America/New_York}
|
||||
restart: unless-stopped
|
||||
|
||||
|
||||
Generated
+59
-1
@@ -10,7 +10,8 @@
|
||||
"dependencies": {
|
||||
"fuse.js": "^7.0.0",
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0"
|
||||
"react-dom": "^18.3.0",
|
||||
"react-router-dom": "^7.16.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.0",
|
||||
@@ -1518,6 +1519,19 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/cssesc": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||
@@ -2287,6 +2301,44 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.16.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.16.0.tgz",
|
||||
"integrity": "sha512-wArC8lVyJb3+jM9OpDyW6hLCizACWkvQR/sSGqSs+o5uEXEtGlqdZ4v8hENR3Jad6i+LRkK93q/+bQAcvl6V1A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "^1.0.1",
|
||||
"set-cookie-parser": "^2.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "7.16.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.16.0.tgz",
|
||||
"integrity": "sha512-kMUAbimWB5FVbF4Bce4bJsiKJWLIUHq/mEG8+CFDnCSgltptBiG5nguducmsJeGKytlCvQud9Qhzpn49iduTlA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-router": "7.16.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/read-cache": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||
@@ -2431,6 +2483,12 @@
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/set-cookie-parser": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
"dependencies": {
|
||||
"fuse.js": "^7.0.0",
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0"
|
||||
"react-dom": "^18.3.0",
|
||||
"react-router-dom": "^7.16.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.0",
|
||||
|
||||
+52
-1
@@ -1,10 +1,14 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||
import Generator from './components/Generator'
|
||||
import Gallery from './components/Gallery'
|
||||
import Player from './components/Player'
|
||||
import DetailPanel from './components/DetailPanel'
|
||||
import SettingsModal from './components/SettingsModal'
|
||||
import ArtworkGenerationModal from './components/ArtworkGenerationModal'
|
||||
import LoginPage from './components/LoginPage'
|
||||
import RegisterPage from './components/RegisterPage'
|
||||
import { useAuth } from './contexts/AuthContext'
|
||||
|
||||
interface Clip {
|
||||
id: string
|
||||
@@ -22,10 +26,12 @@ interface Clip {
|
||||
artist?: string | null
|
||||
album?: string | null
|
||||
comment?: string | null
|
||||
genre?: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
function App() {
|
||||
function AuthenticatedApp() {
|
||||
const { mode, user, logout } = useAuth()
|
||||
const [clips, setClips] = useState<Clip[]>([])
|
||||
const [currentClip, setCurrentClip] = useState<Clip | null>(null)
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
@@ -244,7 +250,22 @@ function App() {
|
||||
<img src="/logo.png" alt="Kithara Studio" className="w-8 h-8 invert" />
|
||||
<h1 className="text-xl font-bold text-white">Kithara Studio</h1>
|
||||
<span className="text-xs text-kithara-muted bg-white/5 px-2 py-0.5 rounded">v0.1</span>
|
||||
{mode === 'multi' && user && (
|
||||
<span className="text-xs text-orange-400 bg-orange-500/10 px-2 py-0.5 rounded ml-1">
|
||||
{user.display_name || user.username}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{mode === 'multi' && user && (
|
||||
<button
|
||||
onClick={logout}
|
||||
className="text-kithara-muted hover:text-white transition-colors text-sm"
|
||||
title="Sign out"
|
||||
>
|
||||
Sign Out
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
className="text-kithara-muted hover:text-white transition-colors p-1 rounded hover:bg-white/5"
|
||||
@@ -257,6 +278,7 @@ function App() {
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-6xl mx-auto p-6 pb-24 grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
@@ -329,4 +351,33 @@ function App() {
|
||||
)
|
||||
}
|
||||
|
||||
function App() {
|
||||
const { mode, user, loading } = useAuth()
|
||||
|
||||
// Show loading spinner while checking auth
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-kithara-dark flex items-center justify-center">
|
||||
<div className="text-kithara-muted animate-pulse">Loading...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Single-user mode: no auth needed, go straight to app
|
||||
if (mode === 'single') {
|
||||
return <AuthenticatedApp />
|
||||
}
|
||||
|
||||
// Multi-user mode: check auth state
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/register" element={<RegisterPage />} />
|
||||
<Route path="/*" element={
|
||||
user ? <AuthenticatedApp /> : <Navigate to="/login" replace />
|
||||
} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
@@ -0,0 +1,93 @@
|
||||
const API_BASE = '/api/auth'
|
||||
|
||||
export interface AuthUser {
|
||||
id: string
|
||||
username: string
|
||||
display_name: string | null
|
||||
role: string
|
||||
has_api_key: boolean
|
||||
}
|
||||
|
||||
export interface AuthMeResponse {
|
||||
mode: 'single' | 'multi'
|
||||
user: AuthUser | null
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
access_token: string
|
||||
token_type: string
|
||||
user: AuthUser
|
||||
}
|
||||
|
||||
export interface RegisterResponse {
|
||||
access_token: string
|
||||
token_type: string
|
||||
user: AuthUser
|
||||
}
|
||||
|
||||
const TOKEN_KEY = 'kithara_token'
|
||||
|
||||
export function getToken(): string | null {
|
||||
return localStorage.getItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
export function setToken(token: string): void {
|
||||
localStorage.setItem(TOKEN_KEY, token)
|
||||
}
|
||||
|
||||
export function clearToken(): void {
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
export async function fetchMe(): Promise<AuthMeResponse> {
|
||||
const token = getToken()
|
||||
const headers: Record<string, string> = {}
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
const res = await fetch(`${API_BASE}/me`, { headers })
|
||||
if (!res.ok) throw new Error('Failed to fetch auth status')
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function login(username: string, password: string): Promise<LoginResponse> {
|
||||
const res = await fetch(`${API_BASE}/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json()
|
||||
throw new Error(err.detail || 'Login failed')
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function register(username: string, password: string, invite_code: string, display_name?: string): Promise<RegisterResponse> {
|
||||
const res = await fetch(`${API_BASE}/register`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password, invite_code, display_name }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json()
|
||||
throw new Error(err.detail || 'Registration failed')
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function updateApiKey(apiKey: string, token: string): Promise<{ message: string }> {
|
||||
const res = await fetch(`${API_BASE}/api-key`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ api_key: apiKey }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json()
|
||||
throw new Error(err.detail || 'Failed to update API key')
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useState, FormEvent } from 'react'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
|
||||
export default function LoginPage() {
|
||||
const { login } = useAuth()
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
setLoading(true)
|
||||
try {
|
||||
await login(username, password)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Login failed')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-kithara-dark flex items-center justify-center px-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="text-center mb-8">
|
||||
<img src="/logo.png" alt="Kithara Studio" className="w-12 h-12 invert mx-auto mb-3" />
|
||||
<h1 className="text-2xl font-bold text-white">Kithara Studio</h1>
|
||||
<p className="text-kithara-muted text-sm mt-1">Sign in to continue</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/30 rounded-lg px-4 py-3 text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-kithara-muted mb-1">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-2.5 text-white placeholder-kithara-muted/50 focus:outline-none focus:border-orange-500/50 focus:ring-1 focus:ring-orange-500/50 transition-colors"
|
||||
placeholder="Enter your username"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-kithara-muted mb-1">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-2.5 text-white placeholder-kithara-muted/50 focus:outline-none focus:border-orange-500/50 focus:ring-1 focus:ring-orange-500/50 transition-colors"
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-orange-500 hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed text-white font-medium py-2.5 rounded-lg transition-colors"
|
||||
>
|
||||
{loading ? 'Signing in...' : 'Sign In'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-kithara-muted text-sm mt-6">
|
||||
Don't have an account?{' '}
|
||||
<a href="/register" className="text-orange-400 hover:text-orange-300 transition-colors">
|
||||
Register
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useState, FormEvent } from 'react'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
|
||||
export default function RegisterPage() {
|
||||
const { register } = useAuth()
|
||||
const [username, setUsername] = useState('')
|
||||
const [displayName, setDisplayName] = useState('')
|
||||
const [inviteCode, setInviteCode] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match')
|
||||
return
|
||||
}
|
||||
if (password.length < 6) {
|
||||
setError('Password must be at least 6 characters')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
await register(username, password, inviteCode, displayName || undefined)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Registration failed')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-kithara-dark flex items-center justify-center px-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="text-center mb-8">
|
||||
<img src="/logo.png" alt="Kithara Studio" className="w-12 h-12 invert mx-auto mb-3" />
|
||||
<h1 className="text-2xl font-bold text-white">Create Account</h1>
|
||||
<p className="text-kithara-muted text-sm mt-1">Join Kithara Studio</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/30 rounded-lg px-4 py-3 text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-kithara-muted mb-1">Invite Code</label>
|
||||
<input
|
||||
type="text"
|
||||
value={inviteCode}
|
||||
onChange={e => setInviteCode(e.target.value)}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-2.5 text-white placeholder-kithara-muted/50 focus:outline-none focus:border-orange-500/50 focus:ring-1 focus:ring-orange-500/50 transition-colors font-mono"
|
||||
placeholder="Enter your invite code"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-kithara-muted mb-1">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-2.5 text-white placeholder-kithara-muted/50 focus:outline-none focus:border-orange-500/50 focus:ring-1 focus:ring-orange-500/50 transition-colors"
|
||||
placeholder="Choose a username"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-kithara-muted mb-1">Display Name <span className="text-kithara-muted/50">(optional)</span></label>
|
||||
<input
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={e => setDisplayName(e.target.value)}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-2.5 text-white placeholder-kithara-muted/50 focus:outline-none focus:border-orange-500/50 focus:ring-1 focus:ring-orange-500/50 transition-colors"
|
||||
placeholder="How others see you"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-kithara-muted mb-1">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-2.5 text-white placeholder-kithara-muted/50 focus:outline-none focus:border-orange-500/50 focus:ring-1 focus:ring-orange-500/50 transition-colors"
|
||||
placeholder="At least 6 characters"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-kithara-muted mb-1">Confirm Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={e => setConfirmPassword(e.target.value)}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-2.5 text-white placeholder-kithara-muted/50 focus:outline-none focus:border-orange-500/50 focus:ring-1 focus:ring-orange-500/50 transition-colors"
|
||||
placeholder="Repeat your password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-orange-500 hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed text-white font-medium py-2.5 rounded-lg transition-colors"
|
||||
>
|
||||
{loading ? 'Creating Account...' : 'Create Account'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-kithara-muted text-sm mt-6">
|
||||
Already have an account?{' '}
|
||||
<a href="/login" className="text-orange-400 hover:text-orange-300 transition-colors">
|
||||
Sign In
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react'
|
||||
import { AuthUser, fetchMe, login as apiLogin, register as apiRegister, setToken, clearToken } from '../api/auth'
|
||||
|
||||
interface AuthState {
|
||||
mode: 'single' | 'multi' | null
|
||||
user: AuthUser | null
|
||||
loading: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
interface AuthContextType extends AuthState {
|
||||
login: (username: string, password: string) => Promise<void>
|
||||
register: (username: string, password: string, inviteCode: string, displayName?: string) => Promise<void>
|
||||
logout: () => void
|
||||
refresh: () => Promise<void>
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | null>(null)
|
||||
|
||||
export function useAuth(): AuthContextType {
|
||||
const ctx = useContext(AuthContext)
|
||||
if (!ctx) throw new Error('useAuth must be used within AuthProvider')
|
||||
return ctx
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [state, setState] = useState<AuthState>({
|
||||
mode: null,
|
||||
user: null,
|
||||
loading: true,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const me = await fetchMe()
|
||||
setState({
|
||||
mode: me.mode,
|
||||
user: me.user,
|
||||
loading: false,
|
||||
error: null,
|
||||
})
|
||||
} catch (err) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
loading: false,
|
||||
error: err instanceof Error ? err.message : 'Auth check failed',
|
||||
}))
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
refresh()
|
||||
}, [refresh])
|
||||
|
||||
const login = useCallback(async (username: string, password: string) => {
|
||||
const res = await apiLogin(username, password)
|
||||
setToken(res.access_token)
|
||||
await refresh()
|
||||
}, [refresh])
|
||||
|
||||
const register = useCallback(async (username: string, password: string, inviteCode: string, displayName?: string) => {
|
||||
const res = await apiRegister(username, password, inviteCode, displayName)
|
||||
setToken(res.access_token)
|
||||
await refresh()
|
||||
}, [refresh])
|
||||
|
||||
const logout = useCallback(() => {
|
||||
clearToken()
|
||||
setState(prev => ({ ...prev, user: null }))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ ...state, login, register, logout, refresh }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,16 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { AuthProvider } from './contexts/AuthContext'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
Reference in new issue
Block a user