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
+523
-18
No files matched your search
+64
-13
@@ -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,18 +250,34 @@ 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"
|
||||
title="Settings"
|
||||
aria-label="Open settings"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
className="text-kithara-muted hover:text-white transition-colors p-1 rounded hover:bg-white/5"
|
||||
title="Settings"
|
||||
aria-label="Open settings"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -329,4 +351,33 @@ function App() {
|
||||
)
|
||||
}
|
||||
|
||||
export default 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>
|
||||
<App />
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
)
|
||||
Reference in new issue
Block a user