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:
rin.kagamine committed 2026-05-31 12:15:13 -04:00
1 parent b3f273942e
commit c571e28725
10 files changed
+520 -15

No files matched your search

+1
View File
@@ -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
+3
View File
@@ -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
+59 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
+63 -12
View File
@@ -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() {
)
}
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
+93
View File
@@ -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()
}
+83
View File
@@ -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>
)
}
+131
View File
@@ -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>
)
}
+78
View File
@@ -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>
)
}
+7 -1
View File
@@ -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>,
)