From c571e287258eeaa49ee03475beacb65c6981701a Mon Sep 17 00:00:00 2001 From: Kagamine Rin Date: Sun, 31 May 2026 12:15:13 -0400 Subject: [PATCH] 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 --- backend/requirements.txt | 1 + docker-compose.yml | 5 +- frontend/package-lock.json | 60 ++++++++++- frontend/package.json | 3 +- frontend/src/App.tsx | 77 ++++++++++--- frontend/src/api/auth.ts | 93 ++++++++++++++++ frontend/src/components/LoginPage.tsx | 83 ++++++++++++++ frontend/src/components/RegisterPage.tsx | 131 +++++++++++++++++++++++ frontend/src/contexts/AuthContext.tsx | 78 ++++++++++++++ frontend/src/main.tsx | 10 +- 10 files changed, 523 insertions(+), 18 deletions(-) create mode 100644 frontend/src/api/auth.ts create mode 100644 frontend/src/components/LoginPage.tsx create mode 100644 frontend/src/components/RegisterPage.tsx create mode 100644 frontend/src/contexts/AuthContext.tsx diff --git a/backend/requirements.txt b/backend/requirements.txt index ce87314..bd5d4c7 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 35541e6..7bbb394 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 @@ -21,4 +24,4 @@ services: - "3100:3000" depends_on: - backend - restart: unless-stopped + restart: unless-stopped \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 50cc99d..2cba7bf 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index ddf79ec..48de119 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fb53a93..9dff8be 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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([]) const [currentClip, setCurrentClip] = useState(null) const [isPlaying, setIsPlaying] = useState(false) @@ -244,18 +250,34 @@ function App() { Kithara Studio

Kithara Studio

v0.1 + {mode === 'multi' && user && ( + + {user.display_name || user.username} + + )} + +
+ {mode === 'multi' && user && ( + + )} +
- @@ -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 ( +
+
Loading...
+
+ ) + } + + // Single-user mode: no auth needed, go straight to app + if (mode === 'single') { + return + } + + // Multi-user mode: check auth state + return ( + + } /> + } /> + : + } /> + + ) +} + +export default App \ No newline at end of file diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts new file mode 100644 index 0000000..2a1c97b --- /dev/null +++ b/frontend/src/api/auth.ts @@ -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 { + const token = getToken() + const headers: Record = {} + 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 { + 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 { + 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() +} \ No newline at end of file diff --git a/frontend/src/components/LoginPage.tsx b/frontend/src/components/LoginPage.tsx new file mode 100644 index 0000000..8569b71 --- /dev/null +++ b/frontend/src/components/LoginPage.tsx @@ -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(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 ( +
+
+
+ Kithara Studio +

Kithara Studio

+

Sign in to continue

+
+ +
+ {error && ( +
+ {error} +
+ )} + +
+ + 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 + /> +
+ +
+ + 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 + /> +
+ + +
+ +

+ Don't have an account?{' '} + + Register + +

+
+
+ ) +} \ No newline at end of file diff --git a/frontend/src/components/RegisterPage.tsx b/frontend/src/components/RegisterPage.tsx new file mode 100644 index 0000000..1bed871 --- /dev/null +++ b/frontend/src/components/RegisterPage.tsx @@ -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(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 ( +
+
+
+ Kithara Studio +

Create Account

+

Join Kithara Studio

+
+ +
+ {error && ( +
+ {error} +
+ )} + +
+ + 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 + /> +
+ +
+ + 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 + /> +
+ +
+ + 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" + /> +
+ +
+ + 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 + /> +
+ +
+ + 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 + /> +
+ + +
+ +

+ Already have an account?{' '} + + Sign In + +

+
+
+ ) +} \ No newline at end of file diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx new file mode 100644 index 0000000..a47061a --- /dev/null +++ b/frontend/src/contexts/AuthContext.tsx @@ -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 + register: (username: string, password: string, inviteCode: string, displayName?: string) => Promise + logout: () => void + refresh: () => Promise +} + +const AuthContext = createContext(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({ + 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 ( + + {children} + + ) +} \ No newline at end of file diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 964aeb4..bc35ac6 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -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( - + + + + + , -) +) \ No newline at end of file