diff --git a/src/App.tsx b/src/App.tsx index ebcd676..fc8915b 100644 --- a/src/App.tsx +++ b/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 ( - - - - } /> - } /> + + + + + } /> + } /> - {/* Protected routes with layout */} - - - - - - } - /> - - - - - - } - /> - - - - - - } - /> - - Add User - } - > - - - - } - /> + {/* Protected routes with layout */} + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + Add User} + > + + + + } + /> - } /> - } /> - - - + } /> + } /> + + + + ) } diff --git a/src/components/ThemeProvider.tsx b/src/components/ThemeProvider.tsx new file mode 100644 index 0000000..ee5f2db --- /dev/null +++ b/src/components/ThemeProvider.tsx @@ -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(initialState) + +export function ThemeProvider({ + children, + defaultTheme = "system", + storageKey = "vite-ui-theme", + ...props +}: ThemeProviderProps) { + const [theme, setTheme] = useState( + () => (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 ( + + {children} + + ) +} + +export const useTheme = () => { + const context = useContext(ThemeProviderContext) + + if (context === undefined) + throw new Error("useTheme must be used within a ThemeProvider") + + return context +} \ No newline at end of file diff --git a/src/components/sidebar/AppSidebar.tsx b/src/components/sidebar/AppSidebar.tsx index 52ffef3..fa45ba2 100644 --- a/src/components/sidebar/AppSidebar.tsx +++ b/src/components/sidebar/AppSidebar.tsx @@ -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 = [ diff --git a/src/components/sidebar/NavUser.tsx b/src/components/sidebar/NavUser.tsx index 435cd06..c5e05de 100644 --- a/src/components/sidebar/NavUser.tsx +++ b/src/components/sidebar/NavUser.tsx @@ -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) { - + Account - + diff --git a/src/components/ui/sidebar.tsx b/src/components/ui/sidebar.tsx index 1ee5a45..30638ac 100644 --- a/src/components/ui/sidebar.tsx +++ b/src/components/ui/sidebar.tsx @@ -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" diff --git a/src/pages/AccountPage.tsx b/src/pages/AccountPage.tsx new file mode 100644 index 0000000..212f41f --- /dev/null +++ b/src/pages/AccountPage.tsx @@ -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(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 ( +
+
+
+

+ Loading latest account information... +

+
+
+ ) + } + + return ( +
+ {/* Success/Error Messages */} + {message && ( +
+

{message}

+
+ )} + {error && ( +
+

{error}

+
+ )} + +
+ + + Profile Information + Update your personal information + + +
+ + setName(e.target.value)} + placeholder="Enter your full name" + /> +
+ +
+ + +

+ Email cannot be changed directly. Contact support if needed. +

+
+ + +
+
+ + + + Password Management + + {user?.provider === 'password' + ? 'Change your account password' + : 'Set or update your account password' + } + + + + {!showPasswordForm ? ( + + ) : ( +
+ {user?.provider === 'password' && ( +
+ + setCurrentPassword(e.target.value)} + placeholder="Enter your current password" + /> +
+ )} + + {user?.provider !== 'password' && ( +
+

+ 💡 Since you're logged in via {user?.provider}, you can set or update your password without entering the current one. +

+
+ )} + +
+ + setNewPassword(e.target.value)} + placeholder="Enter your new password" + /> +

+ Password must be at least 6 characters long +

+
+ +
+ + setConfirmPassword(e.target.value)} + placeholder="Confirm your new password" + /> +
+ +
+ + +
+
+ )} +
+
+ + + + Account Information + View your account details + + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ {user.providers.map(provider => ( + + {provider} + + ))} +
+
+
+
+ + + + Plan & Credits + + View your current plan and credit usage + + + + {user.plan ? ( + <> +
+ +
+ + + {user.plan.code.toUpperCase()} + +
+
+ +
+ +

+ {user.plan.description} +

+
+ +
+
+ + +
+
+ + +
+
+ +
+ +
+
+
+

+ {user.credits ?? 0} of {user.plan.max_credits} credits + remaining +

+
+ +
+ +

+ Plan upgrades coming soon +

+
+ + ) : ( +
+

+ No plan information available +

+
+ )} + + + + + + API Access + + Manage your API token for programmatic access + + + +
+ +
+ + {showFullToken && apiToken && ( + <> + + + + )} +
+

+ {showFullToken && apiToken + ? "Copy this token now - it won't be displayed again once hidden." + : 'Use this token for API authentication. Keep it secure.'} +

+
+ + + + {showFullToken && apiToken && ( +
+

+ ⚠️ Security Warning +

+

+ Store this token securely. Anyone with this token can access + your account via API. +

+
+ )} +
+
+ + + + Preferences + Customize your experience + + +
+ + +

+ Choose your preferred theme or use system setting +

+
+
+
+
+
+ ) +} diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx deleted file mode 100644 index 7702f87..0000000 --- a/src/pages/SettingsPage.tsx +++ /dev/null @@ -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 ( -
-
- - - Profile Information - Update your personal information - - -
- - -
- -
- - -

- Email cannot be changed directly. Contact support if needed. -

-
- - -
-
- - - - Account Information - View your account details - - -
- - -
- -
- - -
- -
- - -
- -
- -
- {user.providers.map((provider) => ( - - {provider} - - ))} -
-
-
-
- - - - API Access - Manage your API token for programmatic access - - -
- - -

- Use this token for API authentication. Keep it secure. -

-
- - -
-
- - - - Preferences - Customize your experience - - -
- - -
- -
- - -
- - -
-
-
-
- ) -} \ No newline at end of file diff --git a/src/services/auth.ts b/src/services/auth.ts index e1bf433..1998e8f 100644 --- a/src/services/auth.ts +++ b/src/services/auth.ts @@ -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 }