feat: implement Account page and ThemeProvider; refactor App component and sidebar navigation

This commit is contained in:
JSC
2025-06-29 22:01:13 +02:00
parent d0f8f13c86
commit e484251787
8 changed files with 755 additions and 231 deletions

595
src/pages/AccountPage.tsx Normal file
View File

@@ -0,0 +1,595 @@
import { useTheme } from '@/components/ThemeProvider'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useAuth } from '@/contexts/AuthContext'
import { useEffect, useRef, useState } from 'react'
export function AccountPage() {
const { user, refreshUser } = useAuth()
const { theme, setTheme } = useTheme()
const [isLoading, setIsLoading] = useState(false)
const [isRefreshing, setIsRefreshing] = useState(true)
const [name, setName] = useState(user?.name || '')
const [message, setMessage] = useState('')
const [error, setError] = useState('')
const [apiToken, setApiToken] = useState('')
const [showFullToken, setShowFullToken] = useState(false)
const tokenInputRef = useRef<HTMLInputElement>(null)
// Password management state
const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [showPasswordForm, setShowPasswordForm] = useState(false)
// Refresh user data when component mounts (run only once)
useEffect(() => {
const refreshOnMount = async () => {
try {
setIsRefreshing(true)
await refreshUser()
} catch (error) {
console.error(
'Failed to refresh user data on account page load:',
error,
)
setError('Failed to load latest user data')
} finally {
setIsRefreshing(false)
}
}
refreshOnMount()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []) // Intentionally empty dependency array to run only once on mount
// Update local name state when user data changes
useEffect(() => {
if (user?.name) {
setName(user.name)
}
}, [user?.name])
const handleUpdateProfile = async () => {
if (!name.trim()) {
setError('Name cannot be empty')
return
}
setIsLoading(true)
setError('')
setMessage('')
try {
const response = await fetch('http://localhost:5000/api/auth/profile', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({ name: name.trim() }),
})
const data = await response.json()
if (response.ok) {
setMessage('Profile updated successfully!')
await refreshUser() // Refresh user data in context
} else {
setError(data.error || 'Failed to update profile')
}
} catch (error) {
console.error('Failed to update profile:', error)
setError('Failed to update profile')
} finally {
setIsLoading(false)
}
}
const handleRegenerateApiToken = async () => {
setIsLoading(true)
setError('')
setMessage('')
try {
const response = await fetch(
'http://localhost:5000/api/auth/regenerate-api-token',
{
method: 'POST',
credentials: 'include',
},
)
const data = await response.json()
if (response.ok) {
setApiToken(data.api_token)
setShowFullToken(true)
setMessage(
"New API token generated successfully! Copy it now - it won't be shown again.",
)
await refreshUser() // Refresh user data
// Auto-select the token text for easy copying
setTimeout(() => {
if (tokenInputRef.current) {
tokenInputRef.current.select()
tokenInputRef.current.focus()
}
}, 100)
} else {
setError(data.error || 'Failed to regenerate API token')
}
} catch (error) {
console.error('Failed to regenerate API token:', error)
setError('Failed to regenerate API token')
} finally {
setIsLoading(false)
}
}
const handleCopyToken = async () => {
if (!apiToken) return
try {
await navigator.clipboard.writeText(apiToken)
setMessage('API token copied to clipboard!')
} catch (error) {
console.error('Failed to copy token:', error)
setError('Failed to copy token to clipboard')
}
}
const handleHideToken = () => {
setApiToken('')
setShowFullToken(false)
setMessage('')
}
const handleThemeChange = (newTheme: string) => {
setTheme(newTheme as 'light' | 'dark' | 'system')
setMessage('Theme updated successfully!')
}
const handlePasswordUpdate = async () => {
if (!newPassword.trim()) {
setError('New password is required')
return
}
if (newPassword.length < 6) {
setError('Password must be at least 6 characters long')
return
}
if (newPassword !== confirmPassword) {
setError('New password and confirmation do not match')
return
}
// Check if user logged in via password (requires current password)
// If logged in via OAuth, they can change password without current password
const loggedInViaPassword = user?.provider === 'password'
if (loggedInViaPassword && !currentPassword.trim()) {
setError('Current password is required')
return
}
setIsLoading(true)
setError('')
setMessage('')
try {
const requestBody: any = { new_password: newPassword }
if (loggedInViaPassword) {
requestBody.current_password = currentPassword
}
const response = await fetch('http://localhost:5000/api/auth/password', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify(requestBody),
})
const data = await response.json()
if (response.ok) {
setMessage(loggedInViaPassword ? 'Password changed successfully!' : 'Password set successfully!')
setCurrentPassword('')
setNewPassword('')
setConfirmPassword('')
setShowPasswordForm(false)
await refreshUser() // Refresh user data
} else {
setError(data.error || 'Failed to update password')
}
} catch (error) {
console.error('Failed to update password:', error)
setError('Failed to update password')
} finally {
setIsLoading(false)
}
}
const resetPasswordForm = () => {
setCurrentPassword('')
setNewPassword('')
setConfirmPassword('')
setShowPasswordForm(false)
setError('')
}
if (!user) return null
if (isRefreshing) {
return (
<div className="flex items-center justify-center py-8">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div>
<p className="text-muted-foreground">
Loading latest account information...
</p>
</div>
</div>
)
}
return (
<div className="space-y-6">
{/* Success/Error Messages */}
{message && (
<div className="p-4 rounded-md bg-green-50 border border-green-200">
<p className="text-green-800 text-sm">{message}</p>
</div>
)}
{error && (
<div className="p-4 rounded-md bg-red-50 border border-red-200">
<p className="text-red-800 text-sm">{error}</p>
</div>
)}
<div className="grid gap-6 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Profile Information</CardTitle>
<CardDescription>Update your personal information</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Full Name</Label>
<Input
id="name"
value={name}
onChange={e => setName(e.target.value)}
placeholder="Enter your full name"
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input id="email" value={user.email} disabled />
<p className="text-xs text-muted-foreground">
Email cannot be changed directly. Contact support if needed.
</p>
</div>
<Button onClick={handleUpdateProfile} disabled={isLoading}>
{isLoading ? 'Saving...' : 'Save Changes'}
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Password Management</CardTitle>
<CardDescription>
{user?.provider === 'password'
? 'Change your account password'
: 'Set or update your account password'
}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{!showPasswordForm ? (
<Button onClick={() => setShowPasswordForm(true)} variant="outline">
{user?.provider === 'password'
? 'Change Password'
: 'Set/Update Password'
}
</Button>
) : (
<div className="space-y-4">
{user?.provider === 'password' && (
<div className="space-y-2">
<Label htmlFor="current-password">Current Password</Label>
<Input
id="current-password"
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
placeholder="Enter your current password"
/>
</div>
)}
{user?.provider !== 'password' && (
<div className="p-3 bg-blue-50 border border-blue-200 rounded-md">
<p className="text-blue-800 text-sm">
💡 Since you're logged in via {user?.provider}, you can set or update your password without entering the current one.
</p>
</div>
)}
<div className="space-y-2">
<Label htmlFor="new-password">New Password</Label>
<Input
id="new-password"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Enter your new password"
/>
<p className="text-xs text-muted-foreground">
Password must be at least 6 characters long
</p>
</div>
<div className="space-y-2">
<Label htmlFor="confirm-password">Confirm New Password</Label>
<Input
id="confirm-password"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Confirm your new password"
/>
</div>
<div className="flex gap-2">
<Button
onClick={handlePasswordUpdate}
disabled={isLoading}
>
{isLoading ? 'Updating...' : 'Update Password'}
</Button>
<Button
onClick={resetPasswordForm}
variant="outline"
disabled={isLoading}
>
Cancel
</Button>
</div>
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Account Information</CardTitle>
<CardDescription>View your account details</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>User ID</Label>
<Input value={user.id} disabled />
</div>
<div className="space-y-2">
<Label>Role</Label>
<Input value={user.role} disabled />
</div>
<div className="space-y-2">
<Label>Status</Label>
<Input value={user.is_active ? 'Active' : 'Disabled'} disabled />
</div>
<div className="space-y-2">
<Label>Authentication Methods</Label>
<div className="flex gap-2 flex-wrap">
{user.providers.map(provider => (
<span
key={provider}
className="px-2 py-1 bg-secondary rounded-md text-xs font-medium"
>
{provider}
</span>
))}
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Plan & Credits</CardTitle>
<CardDescription>
View your current plan and credit usage
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{user.plan ? (
<>
<div className="space-y-2">
<Label>Current Plan</Label>
<div className="flex items-center gap-2">
<Input value={user.plan.name} disabled />
<span
className={`px-2 py-1 rounded-md text-xs font-medium ${
user.plan.code === 'pro'
? 'bg-purple-100 text-purple-800'
: user.plan.code === 'premium'
? 'bg-blue-100 text-blue-800'
: 'bg-gray-100 text-gray-800'
}`}
>
{user.plan.code.toUpperCase()}
</span>
</div>
</div>
<div className="space-y-2">
<Label>Plan Description</Label>
<p className="text-sm text-muted-foreground">
{user.plan.description}
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Current Credits</Label>
<Input value={user.credits ?? 0} disabled />
</div>
<div className="space-y-2">
<Label>Plan Credit Limit</Label>
<Input value={user.plan.max_credits} disabled />
</div>
</div>
<div className="space-y-2">
<Label>Credit Usage</Label>
<div className="w-full bg-secondary rounded-full h-2">
<div
className="bg-primary h-2 rounded-full transition-all duration-300"
style={{
width: `${Math.min(100, ((user.credits ?? 0) / user.plan.max_credits) * 100)}%`,
}}
/>
</div>
<p className="text-xs text-muted-foreground">
{user.credits ?? 0} of {user.plan.max_credits} credits
remaining
</p>
</div>
<div className="pt-2">
<Button variant="outline" disabled>
Upgrade Plan
</Button>
<p className="text-xs text-muted-foreground mt-1">
Plan upgrades coming soon
</p>
</div>
</>
) : (
<div className="text-center py-4">
<p className="text-muted-foreground">
No plan information available
</p>
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>API Access</CardTitle>
<CardDescription>
Manage your API token for programmatic access
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>API Token</Label>
<div className="flex gap-2">
<Input
ref={tokenInputRef}
value={
showFullToken && apiToken
? apiToken
: user.api_token
? `${user.api_token.substring(0, 8)}...`
: 'No token'
}
readOnly
className="font-mono text-sm"
placeholder="Generate a new token to see it here"
/>
{showFullToken && apiToken && (
<>
<Button
onClick={handleCopyToken}
variant="outline"
size="sm"
>
Copy
</Button>
<Button
onClick={handleHideToken}
variant="outline"
size="sm"
>
Hide
</Button>
</>
)}
</div>
<p className="text-xs text-muted-foreground">
{showFullToken && apiToken
? "Copy this token now - it won't be displayed again once hidden."
: 'Use this token for API authentication. Keep it secure.'}
</p>
</div>
<Button
onClick={handleRegenerateApiToken}
disabled={isLoading}
variant="outline"
>
{isLoading ? 'Generating...' : 'Regenerate API Token'}
</Button>
{showFullToken && apiToken && (
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-md">
<p className="text-yellow-800 text-sm font-medium">
⚠️ Security Warning
</p>
<p className="text-yellow-700 text-xs mt-1">
Store this token securely. Anyone with this token can access
your account via API.
</p>
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Preferences</CardTitle>
<CardDescription>Customize your experience</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Theme</Label>
<select
className="w-full h-9 px-3 rounded-md border border-input bg-background"
value={theme}
onChange={e => handleThemeChange(e.target.value)}
>
<option value="light">Light</option>
<option value="dark">Dark</option>
<option value="system">System</option>
</select>
<p className="text-xs text-muted-foreground">
Choose your preferred theme or use system setting
</p>
</div>
</CardContent>
</Card>
</div>
</div>
)
}

View File

@@ -1,160 +0,0 @@
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { useAuth } from '@/contexts/AuthContext'
export function SettingsPage() {
const { user } = useAuth()
const [isLoading, setIsLoading] = useState(false)
const handleSave = async () => {
setIsLoading(true)
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000))
setIsLoading(false)
}
const handleRegenerateApiToken = async () => {
setIsLoading(true)
try {
const response = await fetch('/api/auth/regenerate-api-token', {
method: 'POST',
credentials: 'include',
})
if (response.ok) {
const data = await response.json()
alert(`New API token: ${data.api_token}`)
}
} catch (error) {
console.error('Failed to regenerate API token:', error)
} finally {
setIsLoading(false)
}
}
if (!user) return null
return (
<div className="space-y-6">
<div className="grid gap-6 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Profile Information</CardTitle>
<CardDescription>Update your personal information</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Full Name</Label>
<Input id="name" defaultValue={user.name} />
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input id="email" defaultValue={user.email} disabled />
<p className="text-xs text-muted-foreground">
Email cannot be changed directly. Contact support if needed.
</p>
</div>
<Button onClick={handleSave} disabled={isLoading}>
{isLoading ? 'Saving...' : 'Save Changes'}
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Account Information</CardTitle>
<CardDescription>View your account details</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>User ID</Label>
<Input value={user.id} disabled />
</div>
<div className="space-y-2">
<Label>Role</Label>
<Input value={user.role} disabled />
</div>
<div className="space-y-2">
<Label>Status</Label>
<Input value={user.is_active ? 'Active' : 'Disabled'} disabled />
</div>
<div className="space-y-2">
<Label>Authentication Methods</Label>
<div className="flex gap-2 flex-wrap">
{user.providers.map((provider) => (
<span
key={provider}
className="px-2 py-1 bg-secondary rounded-md text-xs font-medium"
>
{provider}
</span>
))}
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>API Access</CardTitle>
<CardDescription>Manage your API token for programmatic access</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>API Token</Label>
<Input
value={user.api_token ? `${user.api_token.substring(0, 8)}...` : 'No token'}
disabled
/>
<p className="text-xs text-muted-foreground">
Use this token for API authentication. Keep it secure.
</p>
</div>
<Button onClick={handleRegenerateApiToken} disabled={isLoading} variant="outline">
{isLoading ? 'Generating...' : 'Regenerate API Token'}
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Preferences</CardTitle>
<CardDescription>Customize your experience</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Theme</Label>
<select className="w-full h-9 px-3 rounded-md border border-input bg-background">
<option>Light</option>
<option>Dark</option>
<option>System</option>
</select>
</div>
<div className="space-y-2">
<Label>Language</Label>
<select className="w-full h-9 px-3 rounded-md border border-input bg-background">
<option>English</option>
<option>French</option>
<option>Spanish</option>
</select>
</div>
<Button onClick={handleSave} disabled={isLoading}>
{isLoading ? 'Saving...' : 'Save Preferences'}
</Button>
</CardContent>
</Card>
</div>
</div>
)
}