diff --git a/frontend/IMPLEMENTATION_PLAN.md b/frontend/IMPLEMENTATION_PLAN.md index 861ddee..51ee6b1 100644 --- a/frontend/IMPLEMENTATION_PLAN.md +++ b/frontend/IMPLEMENTATION_PLAN.md @@ -1356,20 +1356,258 @@ if (process.env.NODE_ENV === 'development') { --- -## Phase 4: User Profile & Settings +## Phase 4: User Profile & Settings ⚙️ + +**Status:** IN PROGRESS ⚙️ +**Started:** November 2, 2025 +**Duration:** Estimated 2-3 days +**Prerequisites:** Phase 3 complete ✅ + +**Summary:** +Implement complete user settings functionality including profile management, password changes, and session management. Build upon existing authenticated layout with tabbed navigation. All features fully tested with maintained 98.63%+ coverage. + +**Available SDK Functions:** +- `getCurrentUserProfile` - GET /users/me +- `updateCurrentUser` - PATCH /users/me +- `changeCurrentUserPassword` - POST /users/me/password +- `listMySessions` - GET /sessions/me +- `revokeSession` - DELETE /sessions/{id} + +**Existing Infrastructure:** +- ✅ Settings layout with tabbed navigation (`/settings/layout.tsx`) +- ✅ Placeholder pages for profile, password, sessions, preferences +- ✅ `usePasswordChange` hook already exists +- ✅ `useCurrentUser` hook already exists +- ✅ FormField and useFormError shared components available + +### Task 4.1: User Profile Management (Priority 1) **Status:** TODO 📋 -**Duration:** 3-4 days -**Prerequisites:** Phase 3 complete (optimization work) +**Estimated Duration:** 4-6 hours +**Complexity:** Medium +**Risk:** Low -**Detailed tasks will be added here after Phase 2 is complete.** +**Implementation:** -**High-level Overview:** -- Authenticated layout with navigation -- User profile management -- Password change -- Session management UI -- User preferences (optional) +**Step 1: Create `useUpdateProfile` hook** (`src/lib/api/hooks/useUser.ts`) +```typescript +export function useUpdateProfile(onSuccess?: () => void) { + const setUser = useAuthStore((state) => state.setUser); + + return useMutation({ + mutationFn: (data: UpdateCurrentUserData) => + updateCurrentUser({ body: data, throwOnError: true }), + onSuccess: (response) => { + setUser(response.data); + if (onSuccess) onSuccess(); + }, + }); +} +``` + +**Step 2: Create `ProfileSettingsForm` component** (`src/components/settings/ProfileSettingsForm.tsx`) +- Fields: first_name, last_name, email (read-only with info tooltip) +- Validation: Zod schema matching backend rules +- Loading states, error handling +- Success toast on update +- Pre-populate with current user data + +**Step 3: Update profile page** (`src/app/(authenticated)/settings/profile/page.tsx`) +- Import and render ProfileSettingsForm +- Handle auth guard (authenticated users only) + +**Testing:** +- [ ] Unit test useUpdateProfile hook +- [ ] Unit test ProfileSettingsForm component (validation, submission, errors) +- [ ] E2E test profile update flow + +**Files to Create:** +- `src/lib/api/hooks/useUser.ts` - User management hooks +- `src/components/settings/ProfileSettingsForm.tsx` - Profile form +- `tests/lib/api/hooks/useUser.test.tsx` - Hook tests +- `tests/components/settings/ProfileSettingsForm.test.tsx` - Component tests + +**Files to Modify:** +- `src/app/(authenticated)/settings/profile/page.tsx` - Replace placeholder + +### Task 4.2: Password Change (Priority 1) + +**Status:** TODO 📋 +**Estimated Duration:** 2-3 hours +**Complexity:** Low (hook already exists) +**Risk:** Low + +**Implementation:** + +**Step 1: Create `PasswordChangeForm` component** (`src/components/settings/PasswordChangeForm.tsx`) +- Fields: current_password, new_password, confirm_password +- Validation: Password strength requirements +- Success toast + clear form +- Error handling for wrong current password + +**Step 2: Update password page** (`src/app/(authenticated)/settings/password/page.tsx`) +- Import and render PasswordChangeForm +- Use existing `usePasswordChange` hook + +**Testing:** +- [ ] Unit test PasswordChangeForm component +- [ ] E2E test password change flow + +**Files to Create:** +- `src/components/settings/PasswordChangeForm.tsx` - Password form +- `tests/components/settings/PasswordChangeForm.test.tsx` - Component tests + +**Files to Modify:** +- `src/app/(authenticated)/settings/password/page.tsx` - Replace placeholder + +### Task 4.3: Session Management (Priority 1) + +**Status:** TODO 📋 +**Estimated Duration:** 5-7 hours +**Complexity:** Medium-High +**Risk:** Low + +**Implementation:** + +**Step 1: Create session hooks** (`src/lib/api/hooks/useSession.ts`) +```typescript +export function useListSessions() { + return useQuery({ + queryKey: ['sessions', 'me'], + queryFn: () => listMySessions({ throwOnError: true }), + }); +} + +export function useRevokeSession(onSuccess?: () => void) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (sessionId: string) => + revokeSession({ path: { session_id: sessionId }, throwOnError: true }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['sessions', 'me'] }); + if (onSuccess) onSuccess(); + }, + }); +} +``` + +**Step 2: Create `SessionCard` component** (`src/components/settings/SessionCard.tsx`) +- Display: device info, IP, location, last used timestamp +- "Current Session" badge +- Revoke button (disabled for current session) +- Confirmation dialog before revoke + +**Step 3: Create `SessionsManager` component** (`src/components/settings/SessionsManager.tsx`) +- List all active sessions +- Render SessionCard for each +- Loading skeleton +- Empty state (no other sessions) +- "Revoke All Other Sessions" button + +**Step 4: Update sessions page** (`src/app/(authenticated)/settings/sessions/page.tsx`) +- Import and render SessionsManager + +**Testing:** +- [ ] Unit test useListSessions hook +- [ ] Unit test useRevokeSession hook +- [ ] Unit test SessionCard component +- [ ] Unit test SessionsManager component +- [ ] E2E test session revocation flow + +**Files to Create:** +- `src/lib/api/hooks/useSession.ts` - Session hooks +- `src/components/settings/SessionCard.tsx` - Session display +- `src/components/settings/SessionsManager.tsx` - Sessions list +- `tests/lib/api/hooks/useSession.test.tsx` - Hook tests +- `tests/components/settings/SessionCard.test.tsx` - Component tests +- `tests/components/settings/SessionsManager.test.tsx` - Component tests +- `e2e/settings-sessions.spec.ts` - E2E tests + +**Files to Modify:** +- `src/app/(authenticated)/settings/sessions/page.tsx` - Replace placeholder + +### Task 4.4: Preferences (Optional - Deferred) + +**Status:** DEFERRED ⏸️ +**Reason:** Not critical for MVP, theme already working + +**Future Implementation:** +- Theme preference (already working via ThemeProvider) +- Email notification preferences +- Timezone selection +- Language selection + +**Current State:** +- Placeholder page exists +- Can be implemented in future phase if needed + +### Task 4.5: Testing & Quality Assurance (Priority 1) + +**Status:** TODO 📋 +**Estimated Duration:** 3-4 hours +**Complexity:** Medium +**Risk:** Low + +**Requirements:** +- [ ] All unit tests passing (target: 450+ tests) +- [ ] All E2E tests passing (target: 100+ tests) +- [ ] Coverage maintained at 98.63%+ +- [ ] TypeScript: 0 errors +- [ ] ESLint: 0 warnings +- [ ] Build: PASSING +- [ ] Manual testing of all settings flows + +**Test Coverage Targets:** +- ProfileSettingsForm: 100% coverage +- PasswordChangeForm: 100% coverage +- SessionsManager: 100% coverage +- SessionCard: 100% coverage +- All hooks: 100% coverage + +**E2E Test Scenarios:** +1. Update profile (first name, last name) +2. Change password (success and error cases) +3. View active sessions +4. Revoke a session +5. Navigation between settings tabs + +### Success Criteria + +**Task 4.1 Complete When:** +- [ ] useUpdateProfile hook implemented and tested +- [ ] ProfileSettingsForm component complete with validation +- [ ] Profile page functional (view + edit) +- [ ] Unit tests passing +- [ ] User can update first_name and last_name + +**Task 4.2 Complete When:** +- [ ] PasswordChangeForm component complete +- [ ] Password page functional +- [ ] Unit tests passing +- [ ] User can change password successfully +- [ ] Error handling for wrong current password + +**Task 4.3 Complete When:** +- [ ] useListSessions and useRevokeSession hooks implemented +- [ ] SessionsManager component displays all sessions +- [ ] Session revocation works correctly +- [ ] Unit and E2E tests passing +- [ ] Current session cannot be revoked + +**Phase 4 Complete When:** +- [ ] All tasks 4.1, 4.2, 4.3, 4.5 complete +- [ ] Tests: 450+ passing (100%) +- [ ] E2E: 100+ passing (100%) +- [ ] Coverage: ≥98.63% +- [ ] TypeScript: 0 errors +- [ ] ESLint: 0 warnings +- [ ] Build: PASSING +- [ ] All settings features functional +- [ ] Documentation updated +- [ ] Ready for Phase 5 (Admin Dashboard) + +**Final Verdict:** Phase 4 provides complete user settings experience, building on Phase 3's solid foundation --- diff --git a/frontend/src/app/(authenticated)/settings/password/page.tsx b/frontend/src/app/(authenticated)/settings/password/page.tsx index f793dd4..590dc1d 100644 --- a/frontend/src/app/(authenticated)/settings/password/page.tsx +++ b/frontend/src/app/(authenticated)/settings/password/page.tsx @@ -1,25 +1,25 @@ /** * Password Settings Page - * Change password functionality + * Secure password change functionality for authenticated users */ -/* istanbul ignore next - Next.js type import for metadata */ -import type { Metadata } from 'next'; +'use client'; -/* istanbul ignore next - Next.js metadata, not executable code */ -export const metadata: Metadata = { - title: 'Password Settings', -}; +import { PasswordChangeForm } from '@/components/settings'; export default function PasswordSettingsPage() { return ( -
-

- Password Settings -

-

- Change your password (Coming in Task 3.3) -

+
+
+

+ Password Settings +

+

+ Change your password to keep your account secure +

+
+ +
); } diff --git a/frontend/src/app/(authenticated)/settings/profile/page.tsx b/frontend/src/app/(authenticated)/settings/profile/page.tsx index ab7806f..e068814 100644 --- a/frontend/src/app/(authenticated)/settings/profile/page.tsx +++ b/frontend/src/app/(authenticated)/settings/profile/page.tsx @@ -1,25 +1,25 @@ /** * Profile Settings Page - * User profile management - edit name, email, phone, preferences + * User profile management - edit name, email, and other profile information */ -/* istanbul ignore next - Next.js type import for metadata */ -import type { Metadata } from 'next'; +'use client'; -/* istanbul ignore next - Next.js metadata, not executable code */ -export const metadata: Metadata = { - title: 'Profile Settings', -}; +import { ProfileSettingsForm } from '@/components/settings'; export default function ProfileSettingsPage() { return ( -
-

- Profile Settings -

-

- Manage your profile information (Coming in Task 3.2) -

+
+
+

+ Profile Settings +

+

+ Manage your profile information +

+
+ +
); } diff --git a/frontend/src/app/(authenticated)/settings/sessions/page.tsx b/frontend/src/app/(authenticated)/settings/sessions/page.tsx index 49bec6c..19e0197 100644 --- a/frontend/src/app/(authenticated)/settings/sessions/page.tsx +++ b/frontend/src/app/(authenticated)/settings/sessions/page.tsx @@ -1,25 +1,25 @@ /** * Session Management Page - * View and manage active sessions across devices + * View and manage active sessions across all devices */ -/* istanbul ignore next - Next.js type import for metadata */ -import type { Metadata } from 'next'; +'use client'; -/* istanbul ignore next - Next.js metadata, not executable code */ -export const metadata: Metadata = { - title: 'Active Sessions', -}; +import { SessionsManager } from '@/components/settings'; export default function SessionsPage() { return ( -
-

- Active Sessions -

-

- Manage your active sessions (Coming in Task 3.4) -

+
+
+

+ Active Sessions +

+

+ View and manage devices signed in to your account +

+
+ +
); } diff --git a/frontend/src/components/settings/PasswordChangeForm.tsx b/frontend/src/components/settings/PasswordChangeForm.tsx new file mode 100644 index 0000000..e8d7a2f --- /dev/null +++ b/frontend/src/components/settings/PasswordChangeForm.tsx @@ -0,0 +1,209 @@ +/** + * PasswordChangeForm Component + * Allows authenticated users to change their password + * Requires current password for security verification + */ + +'use client'; + +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { toast } from 'sonner'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Alert } from '@/components/ui/alert'; +import { FormField } from '@/components/forms/FormField'; +import { usePasswordChange } from '@/lib/api/hooks/useAuth'; +import { getGeneralError, getFieldErrors, isAPIErrorArray } from '@/lib/api/errors'; + +// ============================================================================ +// Validation Schema +// ============================================================================ + +const passwordChangeSchema = z.object({ + current_password: z + .string() + .min(1, 'Current password is required'), + new_password: z + .string() + .min(1, 'New password is required') + .min(8, 'Password must be at least 8 characters') + .regex(/[0-9]/, 'Password must contain at least one number') + .regex(/[A-Z]/, 'Password must contain at least one uppercase letter') + .regex(/[a-z]/, 'Password must contain at least one lowercase letter') + .regex(/[^A-Za-z0-9]/, 'Password must contain at least one special character'), + confirm_password: z + .string() + .min(1, 'Please confirm your new password'), +}).refine((data) => data.new_password === data.confirm_password, { + message: 'Passwords do not match', + path: ['confirm_password'], +}); + +type PasswordChangeFormData = z.infer; + +// ============================================================================ +// Component +// ============================================================================ + +interface PasswordChangeFormProps { + /** Optional callback after successful password change */ + onSuccess?: () => void; + /** Custom className for card container */ + className?: string; +} + +/** + * PasswordChangeForm - Secure password update form + * + * Features: + * - Current password verification + * - Strong password validation + * - Password confirmation matching + * - Form validation with Zod + * - Loading states + * - Server error display + * - Success toast notification + * - Auto-reset form on success + * + * @example + * ```tsx + * console.log('Password changed')} /> + * ``` + */ +export function PasswordChangeForm({ + onSuccess, + className, +}: PasswordChangeFormProps) { + const [serverError, setServerError] = useState(null); + const passwordChangeMutation = usePasswordChange((message) => { + toast.success(message); + form.reset(); + onSuccess?.(); + }); + + const form = useForm({ + resolver: zodResolver(passwordChangeSchema), + defaultValues: { + current_password: '', + new_password: '', + confirm_password: '', + }, + }); + + const onSubmit = async (data: PasswordChangeFormData) => { + try { + // Clear previous errors + setServerError(null); + form.clearErrors(); + + // Attempt password change (only send required fields to API) + await passwordChangeMutation.mutateAsync({ + current_password: data.current_password, + new_password: data.new_password, + }); + } catch (error) { + // Handle API errors with type guard + if (isAPIErrorArray(error)) { + // Set general error message + const generalError = getGeneralError(error); + if (generalError) { + setServerError(generalError); + } + + // Set field-specific errors + const fieldErrors = getFieldErrors(error); + Object.entries(fieldErrors).forEach(([field, message]) => { + if (field === 'current_password' || field === 'new_password') { + form.setError(field, { message }); + } + }); + } else { + // Unexpected error format + setServerError('An unexpected error occurred. Please try again.'); + } + } + }; + + const isSubmitting = form.formState.isSubmitting || passwordChangeMutation.isPending; + const isDirty = form.formState.isDirty; + + return ( + + + Change Password + + Update your password to keep your account secure. Make sure it's strong and unique. + + + +
+ {/* Server Error Alert */} + {serverError && ( + +

{serverError}

+
+ )} + + {/* Current Password Field */} + + + {/* New Password Field */} + + + {/* Confirm Password Field */} + + + {/* Submit Button */} +
+ + {isDirty && !isSubmitting && ( + + )} +
+ +
+
+ ); +} diff --git a/frontend/src/components/settings/ProfileSettingsForm.tsx b/frontend/src/components/settings/ProfileSettingsForm.tsx new file mode 100644 index 0000000..4a1fc85 --- /dev/null +++ b/frontend/src/components/settings/ProfileSettingsForm.tsx @@ -0,0 +1,219 @@ +/** + * ProfileSettingsForm Component + * Allows users to update their profile information (name fields) + * Email is read-only as it requires separate verification flow + */ + +'use client'; + +import { useState, useEffect } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { toast } from 'sonner'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Alert } from '@/components/ui/alert'; +import { FormField } from '@/components/forms/FormField'; +import { useUpdateProfile } from '@/lib/api/hooks/useUser'; +import { useCurrentUser } from '@/lib/api/hooks/useAuth'; +import { getGeneralError, getFieldErrors, isAPIErrorArray } from '@/lib/api/errors'; + +// ============================================================================ +// Validation Schema +// ============================================================================ + +const profileSchema = z.object({ + first_name: z + .string() + .min(1, 'First name is required') + .min(2, 'First name must be at least 2 characters') + .max(50, 'First name must not exceed 50 characters'), + last_name: z + .string() + .max(50, 'Last name must not exceed 50 characters') + .optional() + .or(z.literal('')), + email: z + .string() + .email('Invalid email address'), +}); + +type ProfileFormData = z.infer; + +// ============================================================================ +// Component +// ============================================================================ + +interface ProfileSettingsFormProps { + /** Optional callback after successful update */ + onSuccess?: () => void; + /** Custom className for card container */ + className?: string; +} + +/** + * ProfileSettingsForm - User profile update form + * + * Features: + * - First name and last name editing + * - Email display (read-only) + * - Form validation with Zod + * - Loading states + * - Server error display + * - Success toast notification + * + * @example + * ```tsx + * console.log('Profile updated')} /> + * ``` + */ +export function ProfileSettingsForm({ + onSuccess, + className, +}: ProfileSettingsFormProps) { + const [serverError, setServerError] = useState(null); + const currentUser = useCurrentUser(); + const updateProfileMutation = useUpdateProfile((message) => { + toast.success(message); + onSuccess?.(); + }); + + const form = useForm({ + resolver: zodResolver(profileSchema), + defaultValues: { + first_name: '', + last_name: '', + email: '', + }, + }); + + // Populate form with current user data + useEffect(() => { + if (currentUser) { + form.reset({ + first_name: currentUser.first_name || '', + last_name: currentUser.last_name || '', + email: currentUser.email, + }); + } + }, [currentUser, form]); + + const onSubmit = async (data: ProfileFormData) => { + try { + // Clear previous errors + setServerError(null); + form.clearErrors(); + + // Only send fields that can be updated (not email) + const updateData: { first_name?: string; last_name?: string } = { + first_name: data.first_name, + }; + + // Only include last_name if it's not empty + if (data.last_name && data.last_name.trim() !== '') { + updateData.last_name = data.last_name; + } + + // Attempt profile update + await updateProfileMutation.mutateAsync(updateData); + } catch (error) { + // Handle API errors with type guard + if (isAPIErrorArray(error)) { + // Set general error message + const generalError = getGeneralError(error); + if (generalError) { + setServerError(generalError); + } + + // Set field-specific errors + const fieldErrors = getFieldErrors(error); + Object.entries(fieldErrors).forEach(([field, message]) => { + if (field === 'first_name' || field === 'last_name') { + form.setError(field, { message }); + } + }); + } else { + // Unexpected error format + setServerError('An unexpected error occurred. Please try again.'); + } + } + }; + + const isSubmitting = form.formState.isSubmitting || updateProfileMutation.isPending; + const isDirty = form.formState.isDirty; + + return ( + + + Profile Information + + Update your personal information. Your email address is read-only. + + + +
+ {/* Server Error Alert */} + {serverError && ( + +

{serverError}

+
+ )} + + {/* First Name Field */} + + + {/* Last Name Field */} + + + {/* Email Field (Read-only) */} + + + {/* Submit Button */} +
+ + {isDirty && !isSubmitting && ( + + )} +
+ +
+
+ ); +} diff --git a/frontend/src/components/settings/SessionCard.tsx b/frontend/src/components/settings/SessionCard.tsx new file mode 100644 index 0000000..3f8e4ed --- /dev/null +++ b/frontend/src/components/settings/SessionCard.tsx @@ -0,0 +1,181 @@ +/** + * SessionCard Component + * Displays individual session information with device details and revoke action + */ + +'use client'; + +import { useState } from 'react'; +import { formatDistanceToNow } from 'date-fns'; +import { Monitor, Smartphone, Tablet, MapPin, Clock, AlertCircle } from 'lucide-react'; +import { Card, CardContent } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import type { Session } from '@/lib/api/hooks/useSession'; + +// ============================================================================ +// Component +// ============================================================================ + +interface SessionCardProps { + /** Session data */ + session: Session; + /** Callback when session is revoked */ + onRevoke: (sessionId: string) => void; + /** Loading state during revocation */ + isRevoking?: boolean; +} + +/** + * SessionCard - Display session with device info and revoke action + * + * Features: + * - Device type icon (desktop, mobile, tablet) + * - Device and browser information + * - Location display (city, country) + * - Last activity timestamp + * - Current session badge + * - Revoke confirmation dialog + * + * @example + * ```tsx + * revokeSession(id)} + * isRevoking={isPending} + * /> + * ``` + */ +export function SessionCard({ session, onRevoke, isRevoking = false }: SessionCardProps) { + const [showConfirmDialog, setShowConfirmDialog] = useState(false); + + // Determine device icon (default to Monitor as device_type is not in API response) + const DeviceIcon = Monitor; + + // Format location string + const location = [session.location_city, session.location_country].filter(Boolean).join(', ') || 'Unknown location'; + + // Format device string + const deviceInfo = session.device_name || 'Unknown device'; + + // Format last used time + const lastUsedTime = session.last_used_at + ? formatDistanceToNow(new Date(session.last_used_at), { addSuffix: true }) + : 'Unknown'; + + const handleRevoke = () => { + onRevoke(session.id); + setShowConfirmDialog(false); + }; + + return ( + <> + + +
+ {/* Device Icon */} +
+
+ +
+
+ + {/* Session Info */} +
+
+
+

{deviceInfo}

+ {session.is_current && ( + + Current Session + + )} +
+ {!session.is_current && ( + + )} +
+ +
+ {/* Location */} + {location !== 'Unknown location' && ( +
+ + {location} +
+ )} + + {/* IP Address */} + {session.ip_address && ( +
+ + {session.ip_address} +
+ )} + + {/* Last Used */} +
+ + Last active {lastUsedTime} +
+
+
+
+
+
+ + {/* Revoke Confirmation Dialog */} + + + + Revoke Session? + + This will sign out this device and you'll need to sign in again to access your account + from it. This action cannot be undone. + + +
+
+

+ Device: {deviceInfo} +

+ {location !== 'Unknown location' && ( +

+ Location: {location} +

+ )} + {session.ip_address && ( +

+ IP: {session.ip_address} +

+ )} +
+
+ + + + +
+
+ + ); +} diff --git a/frontend/src/components/settings/SessionsManager.tsx b/frontend/src/components/settings/SessionsManager.tsx new file mode 100644 index 0000000..d17ccc7 --- /dev/null +++ b/frontend/src/components/settings/SessionsManager.tsx @@ -0,0 +1,242 @@ +/** + * SessionsManager Component + * Manages and displays all active user sessions + * Allows individual and bulk session revocation + */ + +'use client'; + +import { toast } from 'sonner'; +import { Shield, AlertCircle } from 'lucide-react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Alert } from '@/components/ui/alert'; +import { Skeleton } from '@/components/ui/skeleton'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { SessionCard } from './SessionCard'; +import { useListSessions, useRevokeSession, useRevokeAllOtherSessions } from '@/lib/api/hooks/useSession'; +import { useState } from 'react'; + +// ============================================================================ +// Component +// ============================================================================ + +interface SessionsManagerProps { + /** Custom className for container */ + className?: string; +} + +/** + * SessionsManager - Session list and management interface + * + * Features: + * - Lists all active sessions + * - Shows current session prominently + * - Individual session revocation + * - Bulk "Revoke All Others" action + * - Loading skeletons + * - Empty state + * - Error handling + * + * @example + * ```tsx + * + * ``` + */ +export function SessionsManager({ className }: SessionsManagerProps) { + const [showBulkRevokeDialog, setShowBulkRevokeDialog] = useState(false); + const { data: sessions, isLoading, error } = useListSessions(); + + const revokeMutation = useRevokeSession((message) => { + toast.success(message); + }); + + const revokeAllMutation = useRevokeAllOtherSessions((message) => { + toast.success(message); + setShowBulkRevokeDialog(false); + }); + + const handleRevoke = (sessionId: string) => { + revokeMutation.mutate(sessionId); + }; + + const handleBulkRevoke = () => { + revokeAllMutation.mutate(); + }; + + // Separate current session from others + const currentSession = sessions?.find((s) => s.is_current); + const otherSessions = sessions?.filter((s) => !s.is_current) || []; + + // Loading state + if (isLoading) { + return ( + + + Active Sessions + Loading your active sessions... + + + + + + + + ); + } + + // Error state + if (error) { + return ( + + + Active Sessions + Failed to load sessions + + + + +

+ Unable to load your sessions. Please try refreshing the page. +

+
+
+
+ ); + } + + // Empty state (should never happen, but handle gracefully) + if (!sessions || sessions.length === 0) { + return ( + + + Active Sessions + No active sessions found + + +
+ +

No active sessions to display

+
+
+
+ ); + } + + return ( + <> + + +
+
+ Active Sessions + + Manage devices that are currently signed in to your account + +
+ {otherSessions.length > 0 && ( + + )} +
+
+ + {/* Current Session */} + {currentSession && ( +
+ +
+ )} + + {/* Other Sessions */} + {otherSessions.length > 0 && ( +
+ {otherSessions.map((session) => ( + + ))} +
+ )} + + {/* Info Alert */} + {sessions.length === 1 && ( + + +
+

You're viewing your only active session

+

+ Sign in from another device to see it listed here. +

+
+
+ )} + + {/* Security Tip */} +
+

+ Security tip: If you see a session you don't + recognize, revoke it immediately and change your password. +

+
+
+
+ + {/* Bulk Revoke Confirmation Dialog */} + + + + Revoke All Other Sessions? + + This will sign out all devices except the one you're currently using. + You'll need to sign in again on those devices. + + +
+ + +
+

+ {otherSessions.length} session + {otherSessions.length > 1 ? 's' : ''} will be revoked +

+
+
+
+ + + + +
+
+ + ); +} diff --git a/frontend/src/components/settings/index.ts b/frontend/src/components/settings/index.ts index 15c83c2..0e7bb9c 100755 --- a/frontend/src/components/settings/index.ts +++ b/frontend/src/components/settings/index.ts @@ -1,4 +1,7 @@ // User settings components // Examples: ProfileSettings, SessionList, PasswordChangeForm, etc. -export {}; +export { ProfileSettingsForm } from './ProfileSettingsForm'; +export { PasswordChangeForm } from './PasswordChangeForm'; +export { SessionCard } from './SessionCard'; +export { SessionsManager } from './SessionsManager'; diff --git a/frontend/src/lib/api/hooks/useSession.ts b/frontend/src/lib/api/hooks/useSession.ts new file mode 100644 index 0000000..20c5c23 --- /dev/null +++ b/frontend/src/lib/api/hooks/useSession.ts @@ -0,0 +1,177 @@ +/** + * Session Management React Query Hooks + * + * Integrates with auto-generated API client for session management. + * All hooks use generated SDK functions for type safety and OpenAPI compliance. + * + * @module lib/api/hooks/useSession + */ + +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { listMySessions, revokeSession } from '../client'; +import { parseAPIError, getGeneralError } from '../errors'; +import type { SessionResponse } from '../generated/types.gen'; + +// ============================================================================ +// Query Keys +// ============================================================================ + +export const sessionKeys = { + all: ['sessions'] as const, + lists: () => [...sessionKeys.all, 'list'] as const, + list: () => [...sessionKeys.lists()] as const, +}; + +// ============================================================================ +// Types +// ============================================================================ + +/** + * Session data type (re-export from generated types) + */ +export type Session = SessionResponse; + +// ============================================================================ +// Queries +// ============================================================================ + +/** + * Get all active sessions for current user + * GET /api/v1/sessions/me + * + * Returns list of active sessions with device and location info. + * + * @returns React Query result with sessions array + */ +export function useListSessions() { + return useQuery({ + queryKey: sessionKeys.list(), + queryFn: async (): Promise => { + const response = await listMySessions({ + throwOnError: true, + }); + + // Extract sessions array from SessionListResponse + return response.data?.sessions || []; + }, + staleTime: 30 * 1000, // 30 seconds - sessions change infrequently + retry: 2, + }); +} + +// ============================================================================ +// Mutations +// ============================================================================ + +/** + * Revoke a specific session + * DELETE /api/v1/sessions/{id} + * + * On success: + * - Removes session from database + * - Invalidates session queries to refetch list + * + * Note: Cannot revoke current session (use logout instead) + * + * @param onSuccess Optional callback after successful revocation + * @returns React Query mutation + */ +export function useRevokeSession(onSuccess?: (message: string) => void) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (sessionId: string) => { + const response = await revokeSession({ + path: { session_id: sessionId }, + throwOnError: false, + }); + + if ('error' in response) { + throw response.error; + } + + // Type assertion: if no error, response has data + return (response as { data: unknown }).data; + }, + onSuccess: (data) => { + // Extract message from response + const message = + typeof data === 'object' && + data !== null && + 'message' in data && + typeof (data as Record).message === 'string' + ? (data as { message: string }).message + : 'Session revoked successfully'; + + // Invalidate sessions list to trigger refetch + queryClient.invalidateQueries({ queryKey: sessionKeys.list() }); + + // Call custom success callback if provided + if (onSuccess) { + onSuccess(message); + } + }, + onError: (error: unknown) => { + const errors = parseAPIError(error); + const generalError = getGeneralError(errors); + console.error('Session revocation failed:', generalError || 'Unknown error'); + }, + }); +} + +/** + * Revoke all sessions except the current one + * Convenience hook that revokes multiple sessions + * + * @param onSuccess Optional callback after all sessions revoked + * @returns React Query mutation + */ +export function useRevokeAllOtherSessions(onSuccess?: (message: string) => void) { + const queryClient = useQueryClient(); + const { data: sessions } = useListSessions(); + + return useMutation({ + mutationFn: async () => { + if (!sessions) { + throw new Error('No sessions loaded'); + } + + // Get all non-current sessions + const otherSessions = sessions.filter((s) => !s.is_current); + + if (otherSessions.length === 0) { + return { message: 'No other sessions to revoke' }; + } + + // Revoke all other sessions + const revokePromises = otherSessions.map((session) => + revokeSession({ + path: { session_id: session.id }, + throwOnError: false, + }) + ); + + await Promise.all(revokePromises); + + return { + message: `Successfully revoked ${otherSessions.length} session${ + otherSessions.length > 1 ? 's' : '' + }`, + }; + }, + onSuccess: (data) => { + // Invalidate sessions list to trigger refetch + queryClient.invalidateQueries({ queryKey: sessionKeys.list() }); + + // Call custom success callback if provided + if (onSuccess) { + onSuccess(data.message); + } + }, + onError: (error: unknown) => { + const errors = parseAPIError(error); + const generalError = getGeneralError(errors); + console.error('Bulk session revocation failed:', generalError || 'Unknown error'); + }, + }); +} diff --git a/frontend/src/lib/api/hooks/useUser.ts b/frontend/src/lib/api/hooks/useUser.ts new file mode 100644 index 0000000..8295501 --- /dev/null +++ b/frontend/src/lib/api/hooks/useUser.ts @@ -0,0 +1,83 @@ +/** + * User Management React Query Hooks + * + * Integrates with auto-generated API client and authStore for user profile management. + * All hooks use generated SDK functions for type safety and OpenAPI compliance. + * + * @module lib/api/hooks/useUser + */ + +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { updateCurrentUser } from '../client'; +import { useAuthStore } from '@/lib/stores/authStore'; +import type { User } from '@/lib/stores/authStore'; +import { parseAPIError, getGeneralError } from '../errors'; +import { authKeys } from './useAuth'; + +// ============================================================================ +// Mutations +// ============================================================================ + +/** + * Update current user profile mutation + * PATCH /api/v1/users/me + * + * On success: + * - Updates user in auth store + * - Invalidates auth queries + * + * @param onSuccess Optional callback after successful update + * @returns React Query mutation + */ +export function useUpdateProfile(onSuccess?: (message: string) => void) { + const queryClient = useQueryClient(); + const setUser = useAuthStore((state) => state.setUser); + + return useMutation({ + mutationFn: async (data: { + first_name?: string; + last_name?: string; + email?: string; + }) => { + const response = await updateCurrentUser({ + body: data, + throwOnError: false, + }); + + if ('error' in response) { + throw response.error; + } + + // Type assertion: if no error, response has data + const responseData = (response as { data: unknown }).data; + + // Validate response is a user object + if ( + typeof responseData !== 'object' || + responseData === null || + !('id' in responseData) + ) { + throw new Error('Invalid profile update response: missing user data'); + } + + return responseData as User; + }, + onSuccess: (data) => { + // Update auth store with new user data + setUser(data); + + // Invalidate auth queries to refetch user data + queryClient.invalidateQueries({ queryKey: authKeys.me }); + + // Call custom success callback if provided + if (onSuccess) { + onSuccess('Profile updated successfully'); + } + }, + onError: (error: unknown) => { + const errors = parseAPIError(error); + const generalError = getGeneralError(errors); + console.error('Profile update failed:', generalError || 'Unknown error'); + }, + }); +} diff --git a/frontend/tests/app/(authenticated)/settings/password/page.test.tsx b/frontend/tests/app/(authenticated)/settings/password/page.test.tsx index 528348b..6e081ed 100644 --- a/frontend/tests/app/(authenticated)/settings/password/page.test.tsx +++ b/frontend/tests/app/(authenticated)/settings/password/page.test.tsx @@ -1,26 +1,40 @@ /** * Tests for Password Settings Page - * Smoke tests for placeholder page + * Smoke tests for page rendering */ import { render, screen } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import PasswordSettingsPage from '@/app/(authenticated)/settings/password/page'; describe('PasswordSettingsPage', () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + const renderWithProvider = (component: React.ReactElement) => { + return render( + + {component} + + ); + }; + it('renders without crashing', () => { - render(); + renderWithProvider(); expect(screen.getByText('Password Settings')).toBeInTheDocument(); }); it('renders heading', () => { - render(); - + renderWithProvider(); expect(screen.getByRole('heading', { name: /password settings/i })).toBeInTheDocument(); }); - it('shows placeholder text', () => { - render(); - + it('shows description text', () => { + renderWithProvider(); expect(screen.getByText(/change your password/i)).toBeInTheDocument(); }); }); diff --git a/frontend/tests/app/(authenticated)/settings/profile/page.test.tsx b/frontend/tests/app/(authenticated)/settings/profile/page.test.tsx index 5d6f36e..7077f2d 100644 --- a/frontend/tests/app/(authenticated)/settings/profile/page.test.tsx +++ b/frontend/tests/app/(authenticated)/settings/profile/page.test.tsx @@ -1,26 +1,66 @@ /** * Tests for Profile Settings Page - * Smoke tests for placeholder page + * Smoke tests for page rendering */ import { render, screen } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import ProfileSettingsPage from '@/app/(authenticated)/settings/profile/page'; +import { useAuthStore } from '@/lib/stores/authStore'; + +// Mock authStore +jest.mock('@/lib/stores/authStore'); +const mockUseAuthStore = useAuthStore as jest.MockedFunction; describe('ProfileSettingsPage', () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + const mockUser = { + id: '1', + email: 'test@example.com', + first_name: 'Test', + last_name: 'User', + is_active: true, + is_superuser: false, + created_at: '2024-01-01T00:00:00Z', + }; + + beforeEach(() => { + // Mock useAuthStore to return user data + mockUseAuthStore.mockImplementation((selector: unknown) => { + if (typeof selector === 'function') { + const mockState = { user: mockUser }; + return selector(mockState); + } + return mockUser; + }); + }); + + const renderWithProvider = (component: React.ReactElement) => { + return render( + + {component} + + ); + }; + it('renders without crashing', () => { - render(); + renderWithProvider(); expect(screen.getByText('Profile Settings')).toBeInTheDocument(); }); it('renders heading', () => { - render(); - + renderWithProvider(); expect(screen.getByRole('heading', { name: /profile settings/i })).toBeInTheDocument(); }); - it('shows placeholder text', () => { - render(); - + it('shows description text', () => { + renderWithProvider(); expect(screen.getByText(/manage your profile information/i)).toBeInTheDocument(); }); }); diff --git a/frontend/tests/app/(authenticated)/settings/sessions/page.test.tsx b/frontend/tests/app/(authenticated)/settings/sessions/page.test.tsx index 81ad23b..39a1cf0 100644 --- a/frontend/tests/app/(authenticated)/settings/sessions/page.test.tsx +++ b/frontend/tests/app/(authenticated)/settings/sessions/page.test.tsx @@ -1,26 +1,56 @@ /** * Tests for Sessions Page - * Smoke tests for placeholder page + * Smoke tests for page rendering */ -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import SessionsPage from '@/app/(authenticated)/settings/sessions/page'; +// Mock the API client +jest.mock('@/lib/api/client', () => ({ + ...jest.requireActual('@/lib/api/client'), + listMySessions: jest.fn(() => + Promise.resolve({ + data: { + sessions: [], + total: 0, + }, + }) + ), +})); + describe('SessionsPage', () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + const renderWithProvider = (component: React.ReactElement) => { + return render( + + {component} + + ); + }; + it('renders without crashing', () => { - render(); - expect(screen.getByText('Active Sessions')).toBeInTheDocument(); + renderWithProvider(); + // Check for the main heading + expect(screen.getByRole('heading', { level: 2 })).toBeInTheDocument(); }); it('renders heading', () => { - render(); - + renderWithProvider(); + // The heading text expect(screen.getByRole('heading', { name: /active sessions/i })).toBeInTheDocument(); }); - it('shows placeholder text', () => { - render(); - - expect(screen.getByText(/manage your active sessions/i)).toBeInTheDocument(); + it('shows description text', () => { + renderWithProvider(); + // Description under the heading + expect(screen.getByText(/view and manage devices/i)).toBeInTheDocument(); }); });