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.
+
+
+
+
+
+
+ );
+}
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.
+
+
+
+
+
+
+ );
+}
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 */}
+
+ >
+ );
+}
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 */}
+
+ >
+ );
+}
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();
});
});