diff --git a/src/App.tsx b/src/App.tsx index e540610..12723af 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,6 +11,7 @@ import { PlaylistsPage } from './pages/PlaylistsPage' import { ExtractionsPage } from './pages/ExtractionsPage' import { UsersPage } from './pages/admin/UsersPage' import { SettingsPage } from './pages/admin/SettingsPage' +import { AccountPage } from './pages/AccountPage' import { Toaster } from './components/ui/sonner' function ProtectedRoute({ children }: { children: React.ReactNode }) { @@ -73,6 +74,11 @@ function AppRoutes() { } /> + + + + } /> diff --git a/src/lib/api/config.ts b/src/lib/api/config.ts index d7b974f..9982efc 100644 --- a/src/lib/api/config.ts +++ b/src/lib/api/config.ts @@ -29,6 +29,8 @@ export const API_CONFIG = { OAUTH_AUTHORIZE: (provider: string) => `/api/v1/auth/${provider}/authorize`, OAUTH_CALLBACK: (provider: string) => `/api/v1/auth/${provider}/callback`, EXCHANGE_OAUTH_TOKEN: '/api/v1/auth/exchange-oauth-token', + API_TOKEN: '/api/v1/auth/api-token', + API_TOKEN_STATUS: '/api/v1/auth/api-token/status', }, }, } as const diff --git a/src/lib/api/services/auth.ts b/src/lib/api/services/auth.ts index 1596da5..cfd1335 100644 --- a/src/lib/api/services/auth.ts +++ b/src/lib/api/services/auth.ts @@ -36,6 +36,36 @@ export interface ExchangeOAuthTokenResponse { user_id: string } +export interface ApiTokenRequest { + expires_days?: number +} + +export interface ApiTokenResponse { + api_token: string + expires_at?: string +} + +export interface ApiTokenStatusResponse { + has_token: boolean + expires_at?: string + is_expired: boolean +} + +export interface UpdateProfileRequest { + name?: string +} + +export interface ChangePasswordRequest { + current_password?: string + new_password: string +} + +export interface UserProvider { + provider: string + display_name: string + connected_at?: string +} + export class AuthService { /** * Authenticate user with email and password @@ -150,6 +180,48 @@ export class AuthService { throw new Error('Token refresh failed') } } + + /** + * Update user profile information + */ + async updateProfile(data: UpdateProfileRequest): Promise { + return apiClient.patch(API_CONFIG.ENDPOINTS.AUTH.ME, data) + } + + /** + * Change user password + */ + async changePassword(data: ChangePasswordRequest): Promise { + return apiClient.post('/api/v1/auth/change-password', data) + } + + /** + * Generate a new API token + */ + async generateApiToken(request: ApiTokenRequest = {}): Promise { + return apiClient.post(API_CONFIG.ENDPOINTS.AUTH.API_TOKEN, request) + } + + /** + * Get API token status + */ + async getApiTokenStatus(): Promise { + return apiClient.get(API_CONFIG.ENDPOINTS.AUTH.API_TOKEN_STATUS) + } + + /** + * Delete API token + */ + async deleteApiToken(): Promise { + return apiClient.delete(API_CONFIG.ENDPOINTS.AUTH.API_TOKEN) + } + + /** + * Get user's connected authentication providers + */ + async getUserProviders(): Promise { + return apiClient.get('/api/v1/auth/user-providers') + } } export const authService = new AuthService() \ No newline at end of file diff --git a/src/pages/AccountPage.tsx b/src/pages/AccountPage.tsx new file mode 100644 index 0000000..925ac0f --- /dev/null +++ b/src/pages/AccountPage.tsx @@ -0,0 +1,652 @@ +import { useState, useEffect } from 'react' +import { AppLayout } from '@/components/AppLayout' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Label } from '@/components/ui/label' +import { Input } from '@/components/ui/input' +import { Badge } from '@/components/ui/badge' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Skeleton } from '@/components/ui/skeleton' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { toast } from 'sonner' +import { + User, + Key, + Shield, + Palette, + Eye, + EyeOff, + Copy, + Trash2, + Github, + Mail, + CheckCircle2 +} from 'lucide-react' +import { useAuth } from '@/contexts/AuthContext' +import { useTheme } from '@/hooks/use-theme' +import { authService, type ApiTokenStatusResponse, type UserProvider } from '@/lib/api/services/auth' + +export function AccountPage() { + const { user, setUser } = useAuth() + const { theme, setTheme } = useTheme() + + // Profile state + const [profileName, setProfileName] = useState('') + const [profileSaving, setProfileSaving] = useState(false) + + // Password state + const [passwordData, setPasswordData] = useState({ + current_password: '', + new_password: '', + confirm_password: '' + }) + const [passwordSaving, setPasswordSaving] = useState(false) + const [showCurrentPassword, setShowCurrentPassword] = useState(false) + const [showNewPassword, setShowNewPassword] = useState(false) + + // API Token state + const [apiTokenStatus, setApiTokenStatus] = useState(null) + const [apiTokenLoading, setApiTokenLoading] = useState(true) + const [generatedToken, setGeneratedToken] = useState('') + const [showGeneratedToken, setShowGeneratedToken] = useState(false) + const [tokenExpireDays, setTokenExpireDays] = useState('365') + + // Providers state + const [providers, setProviders] = useState([]) + const [providersLoading, setProvidersLoading] = useState(true) + + useEffect(() => { + if (user) { + setProfileName(user.name) + } + loadApiTokenStatus() + loadProviders() + }, [user]) + + const loadApiTokenStatus = async () => { + try { + const status = await authService.getApiTokenStatus() + setApiTokenStatus(status) + } catch (error) { + console.error('Failed to load API token status:', error) + } finally { + setApiTokenLoading(false) + } + } + + const loadProviders = async () => { + try { + const userProviders = await authService.getUserProviders() + setProviders(userProviders) + } catch (error) { + console.error('Failed to load providers:', error) + setProviders([]) + } finally { + setProvidersLoading(false) + } + } + + const handleProfileSave = async () => { + if (!user || !profileName.trim()) return + + setProfileSaving(true) + try { + const updatedUser = await authService.updateProfile({ name: profileName.trim() }) + setUser?.(updatedUser) + toast.success('Profile updated successfully') + } catch (error) { + toast.error('Failed to update profile') + console.error('Profile update error:', error) + } finally { + setProfileSaving(false) + } + } + + const handlePasswordChange = async () => { + // Check if user has password authentication from providers + const hasPasswordProvider = providers.some(provider => provider.provider === 'password') + + // Validate required fields + if (hasPasswordProvider && !passwordData.current_password) { + toast.error('Current password is required') + return + } + + if (!passwordData.new_password) { + toast.error('New password is required') + return + } + + if (passwordData.new_password !== passwordData.confirm_password) { + toast.error('New passwords do not match') + return + } + + if (passwordData.new_password.length < 8) { + toast.error('New password must be at least 8 characters long') + return + } + + setPasswordSaving(true) + try { + await authService.changePassword({ + current_password: hasPasswordProvider ? passwordData.current_password : undefined, + new_password: passwordData.new_password + }) + setPasswordData({ current_password: '', new_password: '', confirm_password: '' }) + toast.success(hasPasswordProvider ? 'Password changed successfully' : 'Password set successfully') + + // Reload providers since password status might have changed + loadProviders() + } catch (error) { + toast.error('Failed to change password') + console.error('Password change error:', error) + } finally { + setPasswordSaving(false) + } + } + + const handleGenerateApiToken = async () => { + try { + const response = await authService.generateApiToken({ + expires_days: parseInt(tokenExpireDays) + }) + setGeneratedToken(response.api_token) + setShowGeneratedToken(true) + await loadApiTokenStatus() + toast.success('API token generated successfully') + } catch (error) { + toast.error('Failed to generate API token') + console.error('API token generation error:', error) + } + } + + const handleDeleteApiToken = async () => { + try { + await authService.deleteApiToken() + await loadApiTokenStatus() + toast.success('API token deleted successfully') + } catch (error) { + toast.error('Failed to delete API token') + console.error('API token deletion error:', error) + } + } + + const copyToClipboard = (text: string) => { + navigator.clipboard.writeText(text) + toast.success('Copied to clipboard') + } + + const getProviderIcon = (provider: string) => { + switch (provider.toLowerCase()) { + case 'github': + return + case 'google': + return + case 'password': + return + default: + return + } + } + + if (!user) { + return ( + +
+ +
+ {Array.from({ length: 4 }).map((_, i) => ( + + + + + +
+ + +
+
+
+ ))} +
+
+
+ ) + } + + return ( + +
+
+

Account Settings

+
+ +
+ {/* Profile Information */} + + + + + Profile Information + + + +
+ + +

+ Email cannot be changed +

+
+ +
+ + setProfileName(e.target.value)} + placeholder="Enter your display name" + /> +
+ +
+ +
+
Role: {user.role}
+
Credits: {user.credits.toLocaleString()}
+
Plan: {user.plan.name}
+
Member since: {new Date(user.created_at).toLocaleDateString()}
+
+
+ + +
+
+ + {/* Theme Settings */} + + + + + Appearance + + + +
+ + +

+ Choose how the interface appears to you +

+
+ +
+
+ Current theme: {theme} +
+
+
+
+ + {/* Password Management */} + + + + + Security + + + + {providers.some(provider => provider.provider === 'password') ? ( + <> +
+ +
+ setPasswordData(prev => ({ ...prev, current_password: e.target.value }))} + placeholder="Enter current password" + /> + +
+
+ +
+ +
+ setPasswordData(prev => ({ ...prev, new_password: e.target.value }))} + placeholder="Enter new password" + /> + +
+
+ +
+ + setPasswordData(prev => ({ ...prev, confirm_password: e.target.value }))} + placeholder="Confirm new password" + /> +
+ + + + ) : ( + <> +
+

+ 💡 Set up password authentication +
+ You signed up with OAuth and don't have a password yet. Set one now to enable password login. +

+
+ +
+ +
+ setPasswordData(prev => ({ ...prev, new_password: e.target.value }))} + placeholder="Enter your new password" + /> + +
+
+ +
+ + setPasswordData(prev => ({ ...prev, confirm_password: e.target.value }))} + placeholder="Confirm your password" + /> +
+ + + + )} +
+
+ + {/* API Token Management */} + + + + + API Token + + + + {apiTokenLoading ? ( +
+ + +
+ ) : ( + <> + {apiTokenStatus?.has_token ? ( +
+
+ + API Token Active + {apiTokenStatus.expires_at && ( + + (Expires: {new Date(apiTokenStatus.expires_at).toLocaleDateString()}) + + )} +
+ +
+ ) : ( +
+
+ + +
+ +
+ )} + + )} + +
+ API tokens allow external applications to access your account programmatically +
+
+
+
+ + {/* Authentication Providers */} + + + + + Authentication Methods + +

+ Available methods to sign in to your account. Use any of these to access your account. +

+
+ + {providersLoading ? ( +
+ {Array.from({ length: 2 }).map((_, i) => ( +
+ + +
+ ))} +
+ ) : ( +
+ {/* All Authentication Providers from API */} + {providers.map((provider) => { + const isOAuth = provider.provider !== 'password' + + return ( +
+
+ {getProviderIcon(provider.provider)} + {provider.display_name} + + {isOAuth ? 'OAuth' : 'Password Authentication'} + + {provider.connected_at && ( + + Connected {new Date(provider.connected_at).toLocaleDateString()} + + )} +
+ + Available + +
+ ) + })} + + {/* API Token Provider */} + {apiTokenStatus?.has_token && ( +
+
+ + API Token + API Access + {apiTokenStatus.expires_at && ( + + Expires {new Date(apiTokenStatus.expires_at).toLocaleDateString()} + + )} +
+ + Available + +
+ )} + + {providers.length === 0 && !apiTokenStatus?.has_token && ( +
+ No authentication methods configured +
+ )} +
+ )} +
+
+
+ + {/* Generated Token Dialog */} + + + + API Token Generated + +
+
+ +
+ + +
+
+
+

+ ⚠️ Important: This token will only be shown once. + Copy it now and store it securely. +

+
+ +
+
+
+
+ ) +} \ No newline at end of file diff --git a/src/pages/admin/UsersPage.tsx b/src/pages/admin/UsersPage.tsx index b99b28e..a338dda 100644 --- a/src/pages/admin/UsersPage.tsx +++ b/src/pages/admin/UsersPage.tsx @@ -4,7 +4,7 @@ import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' import { Badge } from '@/components/ui/badge' -import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet' +import { Sheet, SheetContent } from '@/components/ui/sheet' import { Label } from '@/components/ui/label' import { Input } from '@/components/ui/input' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'