feat: implement Account page and ThemeProvider; refactor App component and sidebar navigation
This commit is contained in:
128
src/App.tsx
128
src/App.tsx
@@ -1,76 +1,86 @@
|
||||
import { AppLayout } from '@/components/AppLayout'
|
||||
import { ProtectedRoute } from '@/components/ProtectedRoute'
|
||||
import { AuthProvider } from '@/contexts/AuthContext'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { AuthProvider } from '@/contexts/AuthContext'
|
||||
import { AccountPage } from '@/pages/AccountPage'
|
||||
import { ActivityPage } from '@/pages/ActivityPage'
|
||||
import { AdminUsersPage } from '@/pages/AdminUsersPage'
|
||||
import { DashboardPage } from '@/pages/DashboardPage'
|
||||
import { LoginPage } from '@/pages/LoginPage'
|
||||
import { RegisterPage } from '@/pages/RegisterPage'
|
||||
import { SettingsPage } from '@/pages/SettingsPage'
|
||||
import { Navigate, Route, BrowserRouter as Router, Routes } from 'react-router'
|
||||
import { ThemeProvider } from './components/ThemeProvider'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/register" element={<RegisterPage />} />
|
||||
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
|
||||
<AuthProvider>
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/register" element={<RegisterPage />} />
|
||||
|
||||
{/* Protected routes with layout */}
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AppLayout title="Dashboard" description="Welcome to your dashboard">
|
||||
<DashboardPage />
|
||||
</AppLayout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/activity"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AppLayout title="Activity" description="View recent activity and logs">
|
||||
<ActivityPage />
|
||||
</AppLayout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AppLayout title="Settings" description="Manage your account settings and preferences">
|
||||
<SettingsPage />
|
||||
</AppLayout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/users"
|
||||
element={
|
||||
<ProtectedRoute requireAdmin>
|
||||
<AppLayout
|
||||
title="User Management"
|
||||
description="Manage users and their permissions"
|
||||
headerActions={
|
||||
<Button>Add User</Button>
|
||||
}
|
||||
>
|
||||
<AdminUsersPage />
|
||||
</AppLayout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
{/* Protected routes with layout */}
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AppLayout
|
||||
title="Dashboard"
|
||||
description="Welcome to your dashboard"
|
||||
>
|
||||
<DashboardPage />
|
||||
</AppLayout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/activity"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AppLayout
|
||||
title="Activity"
|
||||
description="View recent activity and logs"
|
||||
>
|
||||
<ActivityPage />
|
||||
</AppLayout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/account"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AppLayout
|
||||
title="Account"
|
||||
description="Manage your account settings and preferences"
|
||||
>
|
||||
<AccountPage />
|
||||
</AppLayout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/users"
|
||||
element={
|
||||
<ProtectedRoute requireAdmin>
|
||||
<AppLayout
|
||||
title="User Management"
|
||||
description="Manage users and their permissions"
|
||||
headerActions={<Button>Add User</Button>}
|
||||
>
|
||||
<AdminUsersPage />
|
||||
</AppLayout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
</AuthProvider>
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
73
src/components/ThemeProvider.tsx
Normal file
73
src/components/ThemeProvider.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { createContext, useContext, useEffect, useState } from "react"
|
||||
|
||||
type Theme = "dark" | "light" | "system"
|
||||
|
||||
type ThemeProviderProps = {
|
||||
children: React.ReactNode
|
||||
defaultTheme?: Theme
|
||||
storageKey?: string
|
||||
}
|
||||
|
||||
type ThemeProviderState = {
|
||||
theme: Theme
|
||||
setTheme: (theme: Theme) => void
|
||||
}
|
||||
|
||||
const initialState: ThemeProviderState = {
|
||||
theme: "system",
|
||||
setTheme: () => null,
|
||||
}
|
||||
|
||||
const ThemeProviderContext = createContext<ThemeProviderState>(initialState)
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = "system",
|
||||
storageKey = "vite-ui-theme",
|
||||
...props
|
||||
}: ThemeProviderProps) {
|
||||
const [theme, setTheme] = useState<Theme>(
|
||||
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement
|
||||
|
||||
root.classList.remove("light", "dark")
|
||||
|
||||
if (theme === "system") {
|
||||
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
.matches
|
||||
? "dark"
|
||||
: "light"
|
||||
|
||||
root.classList.add(systemTheme)
|
||||
return
|
||||
}
|
||||
|
||||
root.classList.add(theme)
|
||||
}, [theme])
|
||||
|
||||
const value = {
|
||||
theme,
|
||||
setTheme: (theme: Theme) => {
|
||||
localStorage.setItem(storageKey, theme)
|
||||
setTheme(theme)
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeProviderContext.Provider {...props} value={value}>
|
||||
{children}
|
||||
</ThemeProviderContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeProviderContext)
|
||||
|
||||
if (context === undefined)
|
||||
throw new Error("useTheme must be used within a ThemeProvider")
|
||||
|
||||
return context
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
@@ -7,10 +6,10 @@ import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from '@/components/ui/sidebar'
|
||||
import { useSidebar } from '@/components/ui/sidebar'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import { Activity, Home, LogOut, Settings, Users } from 'lucide-react'
|
||||
import { Activity, Home, Users } from 'lucide-react'
|
||||
import { Link, useLocation } from 'react-router'
|
||||
import { NavUser } from './NavUser'
|
||||
|
||||
@@ -25,11 +24,6 @@ const navigationItems = [
|
||||
href: '/activity',
|
||||
icon: Activity,
|
||||
},
|
||||
{
|
||||
title: 'Settings',
|
||||
href: '/settings',
|
||||
icon: Settings,
|
||||
},
|
||||
]
|
||||
|
||||
const adminNavigationItems = [
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from '../ui/sidebar'
|
||||
import { Link } from 'react-router'
|
||||
|
||||
interface NavUserProps {
|
||||
user: User
|
||||
@@ -65,10 +66,10 @@ export function NavUser({ user, logout }: NavUserProps) {
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem asChild>
|
||||
<a href="/account">
|
||||
<Link to="/account">
|
||||
<UserIcon />
|
||||
Account
|
||||
</a>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, VariantProps } from "class-variance-authority"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { PanelLeftIcon } from "lucide-react"
|
||||
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
|
||||
595
src/pages/AccountPage.tsx
Normal file
595
src/pages/AccountPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,3 +1,12 @@
|
||||
interface Plan {
|
||||
id: number
|
||||
code: string
|
||||
name: string
|
||||
description: string
|
||||
credits: number
|
||||
max_credits: number
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: string
|
||||
email: string
|
||||
@@ -9,6 +18,8 @@ interface User {
|
||||
providers: string[]
|
||||
api_token?: string
|
||||
api_token_expires_at?: string | null
|
||||
plan?: Plan
|
||||
credits?: number
|
||||
}
|
||||
|
||||
interface AuthResponse {
|
||||
@@ -125,4 +136,4 @@ class AuthService {
|
||||
}
|
||||
|
||||
export const authService = new AuthService()
|
||||
export type { User }
|
||||
export type { User, Plan }
|
||||
|
||||
Reference in New Issue
Block a user