Add forms for profile and password settings; improve tests for settings pages

- Implement `ProfileSettingsForm` and `PasswordChangeForm` components to manage user profile and password updates.
- Add `SessionCard` for session management and related API hooks (`useSession`).
- Update settings page tests to include user state mock and React Query provider for better test reliability.
- Enhance `PasswordSettingsPage` and `ProfileSettingsPage` tests to verify component rendering and user interaction.
- Improve API hook structure with dedicated hooks for session and user profile management.
This commit is contained in:
2025-11-02 23:24:29 +01:00
parent 64a4b3fb11
commit 65f209c679
14 changed files with 1513 additions and 77 deletions

View File

@@ -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<typeof passwordChangeSchema>;
// ============================================================================
// 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
* <PasswordChangeForm onSuccess={() => console.log('Password changed')} />
* ```
*/
export function PasswordChangeForm({
onSuccess,
className,
}: PasswordChangeFormProps) {
const [serverError, setServerError] = useState<string | null>(null);
const passwordChangeMutation = usePasswordChange((message) => {
toast.success(message);
form.reset();
onSuccess?.();
});
const form = useForm<PasswordChangeFormData>({
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 (
<Card className={className}>
<CardHeader>
<CardTitle>Change Password</CardTitle>
<CardDescription>
Update your password to keep your account secure. Make sure it&apos;s strong and unique.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
{/* Server Error Alert */}
{serverError && (
<Alert variant="destructive">
<p className="text-sm">{serverError}</p>
</Alert>
)}
{/* Current Password Field */}
<FormField
label="Current Password"
type="password"
placeholder="Enter your current password"
autoComplete="current-password"
disabled={isSubmitting}
required
error={form.formState.errors.current_password}
{...form.register('current_password')}
/>
{/* New Password Field */}
<FormField
label="New Password"
type="password"
placeholder="Enter your new password"
autoComplete="new-password"
disabled={isSubmitting}
required
description="At least 8 characters with uppercase, lowercase, number, and special character"
error={form.formState.errors.new_password}
{...form.register('new_password')}
/>
{/* Confirm Password Field */}
<FormField
label="Confirm New Password"
type="password"
placeholder="Confirm your new password"
autoComplete="new-password"
disabled={isSubmitting}
required
error={form.formState.errors.confirm_password}
{...form.register('confirm_password')}
/>
{/* Submit Button */}
<div className="flex items-center gap-4">
<Button
type="submit"
disabled={isSubmitting || !isDirty}
>
{isSubmitting ? 'Changing Password...' : 'Change Password'}
</Button>
{isDirty && !isSubmitting && (
<Button
type="button"
variant="outline"
onClick={() => form.reset()}
>
Cancel
</Button>
)}
</div>
</form>
</CardContent>
</Card>
);
}

View File

@@ -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<typeof profileSchema>;
// ============================================================================
// 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
* <ProfileSettingsForm onSuccess={() => console.log('Profile updated')} />
* ```
*/
export function ProfileSettingsForm({
onSuccess,
className,
}: ProfileSettingsFormProps) {
const [serverError, setServerError] = useState<string | null>(null);
const currentUser = useCurrentUser();
const updateProfileMutation = useUpdateProfile((message) => {
toast.success(message);
onSuccess?.();
});
const form = useForm<ProfileFormData>({
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 (
<Card className={className}>
<CardHeader>
<CardTitle>Profile Information</CardTitle>
<CardDescription>
Update your personal information. Your email address is read-only.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
{/* Server Error Alert */}
{serverError && (
<Alert variant="destructive">
<p className="text-sm">{serverError}</p>
</Alert>
)}
{/* First Name Field */}
<FormField
label="First Name"
type="text"
placeholder="John"
autoComplete="given-name"
disabled={isSubmitting}
required
error={form.formState.errors.first_name}
{...form.register('first_name')}
/>
{/* Last Name Field */}
<FormField
label="Last Name"
type="text"
placeholder="Doe"
autoComplete="family-name"
disabled={isSubmitting}
error={form.formState.errors.last_name}
{...form.register('last_name')}
/>
{/* Email Field (Read-only) */}
<FormField
label="Email"
type="email"
autoComplete="email"
disabled
description="Your email address cannot be changed from this form"
error={form.formState.errors.email}
{...form.register('email')}
/>
{/* Submit Button */}
<div className="flex items-center gap-4">
<Button
type="submit"
disabled={isSubmitting || !isDirty}
>
{isSubmitting ? 'Saving...' : 'Save Changes'}
</Button>
{isDirty && !isSubmitting && (
<Button
type="button"
variant="outline"
onClick={() => form.reset()}
>
Reset
</Button>
)}
</div>
</form>
</CardContent>
</Card>
);
}

View File

@@ -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
* <SessionCard
* session={sessionData}
* onRevoke={(id) => 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 (
<>
<Card className={session.is_current ? 'border-primary' : ''}>
<CardContent className="p-4">
<div className="flex items-start gap-4">
{/* Device Icon */}
<div className="flex-shrink-0">
<div className="w-10 h-10 rounded-full bg-muted flex items-center justify-center">
<DeviceIcon className="w-5 h-5 text-muted-foreground" />
</div>
</div>
{/* Session Info */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2 mb-2">
<div className="flex-1">
<h3 className="font-medium text-sm truncate">{deviceInfo}</h3>
{session.is_current && (
<Badge variant="default" className="mt-1">
Current Session
</Badge>
)}
</div>
{!session.is_current && (
<Button
variant="ghost"
size="sm"
onClick={() => setShowConfirmDialog(true)}
disabled={isRevoking}
>
Revoke
</Button>
)}
</div>
<div className="space-y-1 text-sm text-muted-foreground">
{/* Location */}
{location !== 'Unknown location' && (
<div className="flex items-center gap-1.5">
<MapPin className="w-3.5 h-3.5 flex-shrink-0" />
<span className="truncate">{location}</span>
</div>
)}
{/* IP Address */}
{session.ip_address && (
<div className="flex items-center gap-1.5">
<AlertCircle className="w-3.5 h-3.5 flex-shrink-0" />
<span className="truncate">{session.ip_address}</span>
</div>
)}
{/* Last Used */}
<div className="flex items-center gap-1.5">
<Clock className="w-3.5 h-3.5 flex-shrink-0" />
<span className="truncate">Last active {lastUsedTime}</span>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Revoke Confirmation Dialog */}
<Dialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Revoke Session?</DialogTitle>
<DialogDescription>
This will sign out this device and you&apos;ll need to sign in again to access your account
from it. This action cannot be undone.
</DialogDescription>
</DialogHeader>
<div className="py-4">
<div className="text-sm space-y-1">
<p>
<strong>Device:</strong> {deviceInfo}
</p>
{location !== 'Unknown location' && (
<p>
<strong>Location:</strong> {location}
</p>
)}
{session.ip_address && (
<p>
<strong>IP:</strong> {session.ip_address}
</p>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowConfirmDialog(false)}>
Cancel
</Button>
<Button variant="destructive" onClick={handleRevoke} disabled={isRevoking}>
{isRevoking ? 'Revoking...' : 'Revoke Session'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -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
* <SessionsManager />
* ```
*/
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 (
<Card className={className}>
<CardHeader>
<CardTitle>Active Sessions</CardTitle>
<CardDescription>Loading your active sessions...</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Skeleton className="h-24 w-full" />
<Skeleton className="h-24 w-full" />
<Skeleton className="h-24 w-full" />
</CardContent>
</Card>
);
}
// Error state
if (error) {
return (
<Card className={className}>
<CardHeader>
<CardTitle>Active Sessions</CardTitle>
<CardDescription>Failed to load sessions</CardDescription>
</CardHeader>
<CardContent>
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<p className="text-sm ml-2">
Unable to load your sessions. Please try refreshing the page.
</p>
</Alert>
</CardContent>
</Card>
);
}
// Empty state (should never happen, but handle gracefully)
if (!sessions || sessions.length === 0) {
return (
<Card className={className}>
<CardHeader>
<CardTitle>Active Sessions</CardTitle>
<CardDescription>No active sessions found</CardDescription>
</CardHeader>
<CardContent>
<div className="text-center py-8 text-muted-foreground">
<Shield className="w-12 h-12 mx-auto mb-4 opacity-20" />
<p>No active sessions to display</p>
</div>
</CardContent>
</Card>
);
}
return (
<>
<Card className={className}>
<CardHeader>
<div className="flex items-start justify-between">
<div>
<CardTitle>Active Sessions</CardTitle>
<CardDescription>
Manage devices that are currently signed in to your account
</CardDescription>
</div>
{otherSessions.length > 0 && (
<Button
variant="outline"
size="sm"
onClick={() => setShowBulkRevokeDialog(true)}
disabled={revokeAllMutation.isPending}
>
Revoke All Others
</Button>
)}
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Current Session */}
{currentSession && (
<div>
<SessionCard
session={currentSession}
onRevoke={handleRevoke}
isRevoking={revokeMutation.isPending}
/>
</div>
)}
{/* Other Sessions */}
{otherSessions.length > 0 && (
<div className="space-y-4">
{otherSessions.map((session) => (
<SessionCard
key={session.id}
session={session}
onRevoke={handleRevoke}
isRevoking={revokeMutation.isPending}
/>
))}
</div>
)}
{/* Info Alert */}
{sessions.length === 1 && (
<Alert>
<Shield className="h-4 w-4" />
<div className="ml-2">
<p className="text-sm font-medium">You&apos;re viewing your only active session</p>
<p className="text-sm text-muted-foreground mt-1">
Sign in from another device to see it listed here.
</p>
</div>
</Alert>
)}
{/* Security Tip */}
<div className="pt-4 border-t">
<p className="text-sm text-muted-foreground">
<strong className="text-foreground">Security tip:</strong> If you see a session you don&apos;t
recognize, revoke it immediately and change your password.
</p>
</div>
</CardContent>
</Card>
{/* Bulk Revoke Confirmation Dialog */}
<Dialog open={showBulkRevokeDialog} onOpenChange={setShowBulkRevokeDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Revoke All Other Sessions?</DialogTitle>
<DialogDescription>
This will sign out all devices except the one you&apos;re currently using.
You&apos;ll need to sign in again on those devices.
</DialogDescription>
</DialogHeader>
<div className="py-4">
<Alert>
<AlertCircle className="h-4 w-4" />
<div className="ml-2">
<p className="text-sm">
<strong>{otherSessions.length}</strong> session
{otherSessions.length > 1 ? 's' : ''} will be revoked
</p>
</div>
</Alert>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowBulkRevokeDialog(false)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleBulkRevoke}
disabled={revokeAllMutation.isPending}
>
{revokeAllMutation.isPending ? 'Revoking...' : 'Revoke All Others'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -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';