Compare commits
6 Commits
15f522b9b1
...
388ca08724
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
388ca08724 | ||
|
|
54a14047be | ||
|
|
65f209c679 | ||
|
|
64a4b3fb11 | ||
|
|
1c7f34c078 | ||
|
|
fe5d152cee |
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,81 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
test.describe('Login Flow', () => {
|
test.describe('Login Flow', () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.describe.configure({ mode: 'serial' });
|
||||||
|
|
||||||
|
// Collect browser console logs per test for debugging
|
||||||
|
let consoleLogs: string[] = [];
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page, context }) => {
|
||||||
|
consoleLogs = [];
|
||||||
|
|
||||||
|
// Capture console logs
|
||||||
|
page.on('console', (msg) => {
|
||||||
|
try {
|
||||||
|
consoleLogs.push(`[${msg.type()}] ${msg.text()}`);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ensure clean state across parallel workers
|
||||||
|
await context.clearCookies();
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
try {
|
||||||
|
localStorage.clear();
|
||||||
|
sessionStorage.clear();
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
|
||||||
// Navigate to login page before each test
|
// Navigate to login page before each test
|
||||||
await page.goto('/login');
|
await page.goto('/login');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.afterEach(async ({ page }, testInfo) => {
|
||||||
|
if (testInfo.status !== 'passed') {
|
||||||
|
// Attach current URL
|
||||||
|
await testInfo.attach('page-url.txt', {
|
||||||
|
body: page.url(),
|
||||||
|
contentType: 'text/plain',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Attach skeleton count to see if page stuck on loading state
|
||||||
|
try {
|
||||||
|
const skeletonCount = await page.locator('.animate-pulse').count();
|
||||||
|
await testInfo.attach('skeleton-count.txt', {
|
||||||
|
body: String(skeletonCount),
|
||||||
|
contentType: 'text/plain',
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// Attach full DOM snapshot
|
||||||
|
try {
|
||||||
|
const html = await page.content();
|
||||||
|
await testInfo.attach('dom.html', {
|
||||||
|
body: html,
|
||||||
|
contentType: 'text/html',
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// Attach full page screenshot
|
||||||
|
try {
|
||||||
|
const img = await page.screenshot({ fullPage: true });
|
||||||
|
await testInfo.attach('screenshot.png', {
|
||||||
|
body: img,
|
||||||
|
contentType: 'image/png',
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// Attach console logs
|
||||||
|
try {
|
||||||
|
await testInfo.attach('console.log', {
|
||||||
|
body: consoleLogs.join('\n'),
|
||||||
|
contentType: 'text/plain',
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('should display login form', async ({ page }) => {
|
test('should display login form', async ({ page }) => {
|
||||||
// Check page title
|
// Check page title
|
||||||
await expect(page.locator('h2')).toContainText('Sign in to your account');
|
await expect(page.locator('h2')).toContainText('Sign in to your account');
|
||||||
@@ -21,18 +91,26 @@ test.describe('Login Flow', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('should show validation errors for empty form', async ({ page }) => {
|
test('should show validation errors for empty form', async ({ page }) => {
|
||||||
// Wait for React hydration to complete
|
// Ensure the dynamically loaded form is mounted and interactive
|
||||||
await page.waitForLoadState('networkidle');
|
|
||||||
|
|
||||||
// Interact with email field to ensure form is interactive
|
|
||||||
const emailInput = page.locator('input[name="email"]');
|
const emailInput = page.locator('input[name="email"]');
|
||||||
|
const passwordInput = page.locator('input[name="password"]');
|
||||||
|
const submitButton = page.locator('button[type="submit"]');
|
||||||
|
|
||||||
|
await expect(emailInput).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(passwordInput).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(submitButton).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(submitButton).toBeEnabled({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Touch fields to mimic user interaction
|
||||||
await emailInput.focus();
|
await emailInput.focus();
|
||||||
await emailInput.blur();
|
await emailInput.blur();
|
||||||
|
await passwordInput.focus();
|
||||||
|
await passwordInput.blur();
|
||||||
|
|
||||||
// Submit empty form
|
// Submit empty form
|
||||||
await page.locator('button[type="submit"]').click();
|
await submitButton.click();
|
||||||
|
|
||||||
// Wait for validation errors - Firefox may be slower
|
// Wait for validation errors - allow extra time for slower browsers
|
||||||
await expect(page.locator('#email-error')).toBeVisible({ timeout: 10000 });
|
await expect(page.locator('#email-error')).toBeVisible({ timeout: 10000 });
|
||||||
await expect(page.locator('#password-error')).toBeVisible({ timeout: 10000 });
|
await expect(page.locator('#password-error')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,81 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
test.describe('Registration Flow', () => {
|
test.describe('Registration Flow', () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.describe.configure({ mode: 'serial' });
|
||||||
|
|
||||||
|
// Collect browser console logs per test for debugging
|
||||||
|
let consoleLogs: string[] = [];
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page, context }) => {
|
||||||
|
consoleLogs = [];
|
||||||
|
|
||||||
|
// Capture console logs
|
||||||
|
page.on('console', (msg) => {
|
||||||
|
try {
|
||||||
|
consoleLogs.push(`[${msg.type()}] ${msg.text()}`);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ensure clean state across parallel workers
|
||||||
|
await context.clearCookies();
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
try {
|
||||||
|
localStorage.clear();
|
||||||
|
sessionStorage.clear();
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
|
||||||
// Navigate to register page before each test
|
// Navigate to register page before each test
|
||||||
await page.goto('/register');
|
await page.goto('/register');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.afterEach(async ({ page }, testInfo) => {
|
||||||
|
if (testInfo.status !== 'passed') {
|
||||||
|
// Attach current URL
|
||||||
|
await testInfo.attach('page-url.txt', {
|
||||||
|
body: page.url(),
|
||||||
|
contentType: 'text/plain',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Attach skeleton count to see if page stuck on loading state
|
||||||
|
try {
|
||||||
|
const skeletonCount = await page.locator('.animate-pulse').count();
|
||||||
|
await testInfo.attach('skeleton-count.txt', {
|
||||||
|
body: String(skeletonCount),
|
||||||
|
contentType: 'text/plain',
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// Attach full DOM snapshot
|
||||||
|
try {
|
||||||
|
const html = await page.content();
|
||||||
|
await testInfo.attach('dom.html', {
|
||||||
|
body: html,
|
||||||
|
contentType: 'text/html',
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// Attach full page screenshot
|
||||||
|
try {
|
||||||
|
const img = await page.screenshot({ fullPage: true });
|
||||||
|
await testInfo.attach('screenshot.png', {
|
||||||
|
body: img,
|
||||||
|
contentType: 'image/png',
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// Attach console logs
|
||||||
|
try {
|
||||||
|
await testInfo.attach('console.log', {
|
||||||
|
body: consoleLogs.join('\n'),
|
||||||
|
contentType: 'text/plain',
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('should display registration form', async ({ page }) => {
|
test('should display registration form', async ({ page }) => {
|
||||||
// Check page title
|
// Check page title
|
||||||
await expect(page.locator('h2')).toContainText('Create your account');
|
await expect(page.locator('h2')).toContainText('Create your account');
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ export default defineConfig({
|
|||||||
forbidOnly: !!process.env.CI,
|
forbidOnly: !!process.env.CI,
|
||||||
/* Retry on CI and locally to handle flaky tests */
|
/* Retry on CI and locally to handle flaky tests */
|
||||||
retries: process.env.CI ? 2 : 1,
|
retries: process.env.CI ? 2 : 1,
|
||||||
/* Limit workers to prevent test interference */
|
/* Limit workers to prevent test interference and Next dev server overload */
|
||||||
workers: process.env.CI ? 1 : 12,
|
workers: process.env.CI ? 1 : 8,
|
||||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||||
reporter: process.env.CI ? 'github' : 'list',
|
reporter: process.env.CI ? 'github' : 'list',
|
||||||
/* Suppress console output unless VERBOSE=true */
|
/* Suppress console output unless VERBOSE=true */
|
||||||
@@ -31,6 +31,8 @@ export default defineConfig({
|
|||||||
trace: 'on-first-retry',
|
trace: 'on-first-retry',
|
||||||
/* Screenshot on failure */
|
/* Screenshot on failure */
|
||||||
screenshot: 'only-on-failure',
|
screenshot: 'only-on-failure',
|
||||||
|
/* Record video for failed tests to diagnose flakiness */
|
||||||
|
video: 'retain-on-failure',
|
||||||
},
|
},
|
||||||
|
|
||||||
/* Configure projects for major browsers */
|
/* Configure projects for major browsers */
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
|
|
||||||
// Code-split LoginForm - heavy with react-hook-form + validation
|
// Code-split LoginForm - heavy with react-hook-form + validation
|
||||||
|
|||||||
@@ -3,8 +3,6 @@
|
|||||||
* Users enter their email to receive reset instructions
|
* Users enter their email to receive reset instructions
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'use client';
|
|
||||||
|
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
|
|
||||||
// Code-split PasswordResetRequestForm
|
// Code-split PasswordResetRequestForm
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
|
|
||||||
// Code-split RegisterForm (313 lines)
|
// Code-split RegisterForm (313 lines)
|
||||||
|
|||||||
@@ -1,25 +1,25 @@
|
|||||||
/**
|
/**
|
||||||
* Password Settings Page
|
* Password Settings Page
|
||||||
* Change password functionality
|
* Secure password change functionality for authenticated users
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/* istanbul ignore next - Next.js type import for metadata */
|
'use client';
|
||||||
import type { Metadata } from 'next';
|
|
||||||
|
|
||||||
/* istanbul ignore next - Next.js metadata, not executable code */
|
import { PasswordChangeForm } from '@/components/settings';
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: 'Password Settings',
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function PasswordSettingsPage() {
|
export default function PasswordSettingsPage() {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="space-y-6">
|
||||||
<h2 className="text-2xl font-semibold text-foreground mb-4">
|
<div>
|
||||||
Password Settings
|
<h2 className="text-2xl font-semibold text-foreground">
|
||||||
</h2>
|
Password Settings
|
||||||
<p className="text-muted-foreground">
|
</h2>
|
||||||
Change your password (Coming in Task 3.3)
|
<p className="text-muted-foreground mt-1">
|
||||||
</p>
|
Change your password to keep your account secure
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PasswordChangeForm />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,25 @@
|
|||||||
/**
|
/**
|
||||||
* Profile Settings Page
|
* 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 */
|
'use client';
|
||||||
import type { Metadata } from 'next';
|
|
||||||
|
|
||||||
/* istanbul ignore next - Next.js metadata, not executable code */
|
import { ProfileSettingsForm } from '@/components/settings';
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: 'Profile Settings',
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function ProfileSettingsPage() {
|
export default function ProfileSettingsPage() {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="space-y-6">
|
||||||
<h2 className="text-2xl font-semibold text-foreground mb-4">
|
<div>
|
||||||
Profile Settings
|
<h2 className="text-2xl font-semibold text-foreground">
|
||||||
</h2>
|
Profile Settings
|
||||||
<p className="text-muted-foreground">
|
</h2>
|
||||||
Manage your profile information (Coming in Task 3.2)
|
<p className="text-muted-foreground mt-1">
|
||||||
</p>
|
Manage your profile information
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ProfileSettingsForm />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,25 @@
|
|||||||
/**
|
/**
|
||||||
* Session Management Page
|
* 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 */
|
'use client';
|
||||||
import type { Metadata } from 'next';
|
|
||||||
|
|
||||||
/* istanbul ignore next - Next.js metadata, not executable code */
|
import { SessionsManager } from '@/components/settings';
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: 'Active Sessions',
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function SessionsPage() {
|
export default function SessionsPage() {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="space-y-6">
|
||||||
<h2 className="text-2xl font-semibold text-foreground mb-4">
|
<div>
|
||||||
Active Sessions
|
<h2 className="text-2xl font-semibold text-foreground">
|
||||||
</h2>
|
Active Sessions
|
||||||
<p className="text-muted-foreground">
|
</h2>
|
||||||
Manage your active sessions (Coming in Task 3.4)
|
<p className="text-muted-foreground mt-1">
|
||||||
</p>
|
View and manage devices signed in to your account
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SessionsManager />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,7 +79,9 @@ export default function FormsPage() {
|
|||||||
setSubmitSuccess(false);
|
setSubmitSuccess(false);
|
||||||
// Simulate API call
|
// Simulate API call
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||||
console.log('Login form data:', data);
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
console.log('Login form data:', data);
|
||||||
|
}
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
setSubmitSuccess(true);
|
setSubmitSuccess(true);
|
||||||
};
|
};
|
||||||
@@ -89,7 +91,9 @@ export default function FormsPage() {
|
|||||||
setSubmitSuccess(false);
|
setSubmitSuccess(false);
|
||||||
// Simulate API call
|
// Simulate API call
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||||
console.log('Contact form data:', data);
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
console.log('Contact form data:', data);
|
||||||
|
}
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
setSubmitSuccess(true);
|
setSubmitSuccess(true);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -82,6 +82,8 @@ export function LoginForm({
|
|||||||
|
|
||||||
const form = useForm<LoginFormData>({
|
const form = useForm<LoginFormData>({
|
||||||
resolver: zodResolver(loginSchema),
|
resolver: zodResolver(loginSchema),
|
||||||
|
mode: 'onBlur',
|
||||||
|
reValidateMode: 'onChange',
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
email: '',
|
email: '',
|
||||||
password: '',
|
password: '',
|
||||||
|
|||||||
@@ -98,6 +98,8 @@ export function RegisterForm({
|
|||||||
|
|
||||||
const form = useForm<RegisterFormData>({
|
const form = useForm<RegisterFormData>({
|
||||||
resolver: zodResolver(registerSchema),
|
resolver: zodResolver(registerSchema),
|
||||||
|
mode: 'onBlur',
|
||||||
|
reValidateMode: 'onChange',
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
email: '',
|
email: '',
|
||||||
first_name: '',
|
first_name: '',
|
||||||
|
|||||||
209
frontend/src/components/settings/PasswordChangeForm.tsx
Normal file
209
frontend/src/components/settings/PasswordChangeForm.tsx
Normal 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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
219
frontend/src/components/settings/ProfileSettingsForm.tsx
Normal file
219
frontend/src/components/settings/ProfileSettingsForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
181
frontend/src/components/settings/SessionCard.tsx
Normal file
181
frontend/src/components/settings/SessionCard.tsx
Normal 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'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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
242
frontend/src/components/settings/SessionsManager.tsx
Normal file
242
frontend/src/components/settings/SessionsManager.tsx
Normal 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'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'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're currently using.
|
||||||
|
You'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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
// User settings components
|
// User settings components
|
||||||
// Examples: ProfileSettings, SessionList, PasswordChangeForm, etc.
|
// Examples: ProfileSettings, SessionList, PasswordChangeForm, etc.
|
||||||
|
|
||||||
export {};
|
export { ProfileSettingsForm } from './ProfileSettingsForm';
|
||||||
|
export { PasswordChangeForm } from './PasswordChangeForm';
|
||||||
|
export { SessionCard } from './SessionCard';
|
||||||
|
export { SessionsManager } from './SessionsManager';
|
||||||
|
|||||||
@@ -19,8 +19,10 @@ import config from '@/config/app.config';
|
|||||||
/**
|
/**
|
||||||
* Token refresh state management (singleton pattern)
|
* Token refresh state management (singleton pattern)
|
||||||
* Prevents race conditions when multiple requests fail with 401 simultaneously
|
* Prevents race conditions when multiple requests fail with 401 simultaneously
|
||||||
|
*
|
||||||
|
* The refreshPromise acts as both the lock and the shared promise.
|
||||||
|
* If it exists, a refresh is in progress - all concurrent requests wait for the same promise.
|
||||||
*/
|
*/
|
||||||
let isRefreshing = false;
|
|
||||||
let refreshPromise: Promise<string> | null = null;
|
let refreshPromise: Promise<string> | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -46,11 +48,12 @@ const getAuthStore = async () => {
|
|||||||
/* istanbul ignore next */
|
/* istanbul ignore next */
|
||||||
async function refreshAccessToken(): Promise<string> {
|
async function refreshAccessToken(): Promise<string> {
|
||||||
// Singleton pattern: reuse in-flight refresh request
|
// Singleton pattern: reuse in-flight refresh request
|
||||||
if (isRefreshing && refreshPromise) {
|
// If a refresh is already in progress, return the existing promise
|
||||||
|
if (refreshPromise) {
|
||||||
return refreshPromise;
|
return refreshPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
isRefreshing = true;
|
// Create and store the refresh promise immediately to prevent race conditions
|
||||||
refreshPromise = (async () => {
|
refreshPromise = (async () => {
|
||||||
try {
|
try {
|
||||||
const authStore = await getAuthStore();
|
const authStore = await getAuthStore();
|
||||||
@@ -92,22 +95,23 @@ async function refreshAccessToken(): Promise<string> {
|
|||||||
console.error('[API Client] Token refresh failed:', error);
|
console.error('[API Client] Token refresh failed:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear auth and redirect to login
|
// Clear auth state
|
||||||
const authStore = await getAuthStore();
|
const authStore = await getAuthStore();
|
||||||
await authStore.clearAuth();
|
await authStore.clearAuth();
|
||||||
|
|
||||||
// Redirect to login if we're in browser
|
// Only redirect to login when not already on an auth route
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
const currentPath = window.location.pathname;
|
const currentPath = window.location.pathname;
|
||||||
const returnUrl = currentPath !== '/login' && currentPath !== '/register'
|
const onAuthRoute = currentPath === '/login' || currentPath === '/register' || currentPath.startsWith('/password-reset');
|
||||||
? `?returnUrl=${encodeURIComponent(currentPath)}`
|
if (!onAuthRoute) {
|
||||||
: '';
|
const returnUrl = currentPath ? `?returnUrl=${encodeURIComponent(currentPath)}` : '';
|
||||||
window.location.href = `/login${returnUrl}`;
|
window.location.href = `/login${returnUrl}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
isRefreshing = false;
|
// Clear the promise so future 401s will trigger a new refresh
|
||||||
refreshPromise = null;
|
refreshPromise = null;
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
@@ -126,8 +130,12 @@ client.instance.interceptors.request.use(
|
|||||||
const authStore = await getAuthStore();
|
const authStore = await getAuthStore();
|
||||||
const { accessToken } = authStore;
|
const { accessToken } = authStore;
|
||||||
|
|
||||||
// Add Authorization header if token exists
|
// Do not attach Authorization header for auth endpoints
|
||||||
if (accessToken && requestConfig.headers) {
|
const url = requestConfig.url || '';
|
||||||
|
const isAuthEndpoint = url.includes('/auth/login') || url.includes('/auth/register') || url.includes('/auth/refresh') || url.includes('/auth/password') || url.includes('/password');
|
||||||
|
|
||||||
|
// Add Authorization header if token exists and not hitting auth endpoints
|
||||||
|
if (accessToken && requestConfig.headers && !isAuthEndpoint) {
|
||||||
requestConfig.headers.Authorization = `Bearer ${accessToken}`;
|
requestConfig.headers.Authorization = `Bearer ${accessToken}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,13 +175,27 @@ client.instance.interceptors.response.use(
|
|||||||
|
|
||||||
// Handle 401 Unauthorized - Token expired
|
// Handle 401 Unauthorized - Token expired
|
||||||
if (error.response?.status === 401 && originalRequest && !originalRequest._retry) {
|
if (error.response?.status === 401 && originalRequest && !originalRequest._retry) {
|
||||||
|
const url = originalRequest.url || '';
|
||||||
|
const isAuthEndpoint = url.includes('/auth/login') || url.includes('/auth/register') || url.includes('/auth/password') || url.includes('/password');
|
||||||
|
|
||||||
|
// If the 401 is from auth endpoints, do not attempt refresh
|
||||||
|
if (isAuthEndpoint) {
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
|
||||||
// If refresh endpoint itself fails with 401, clear auth and reject
|
// If refresh endpoint itself fails with 401, clear auth and reject
|
||||||
if (originalRequest.url?.includes('/auth/refresh')) {
|
if (url.includes('/auth/refresh')) {
|
||||||
const authStore = await getAuthStore();
|
const authStore = await getAuthStore();
|
||||||
await authStore.clearAuth();
|
await authStore.clearAuth();
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure we have a refresh token before attempting refresh
|
||||||
|
const authStore = await getAuthStore();
|
||||||
|
if (!authStore.refreshToken) {
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
|
||||||
originalRequest._retry = true;
|
originalRequest._retry = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
177
frontend/src/lib/api/hooks/useSession.ts
Normal file
177
frontend/src/lib/api/hooks/useSession.ts
Normal file
@@ -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<Session[]> => {
|
||||||
|
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<string, unknown>).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');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
83
frontend/src/lib/api/hooks/useUser.ts
Normal file
83
frontend/src/lib/api/hooks/useUser.ts
Normal file
@@ -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');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,26 +1,40 @@
|
|||||||
/**
|
/**
|
||||||
* Tests for Password Settings Page
|
* Tests for Password Settings Page
|
||||||
* Smoke tests for placeholder page
|
* Smoke tests for page rendering
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import PasswordSettingsPage from '@/app/(authenticated)/settings/password/page';
|
import PasswordSettingsPage from '@/app/(authenticated)/settings/password/page';
|
||||||
|
|
||||||
describe('PasswordSettingsPage', () => {
|
describe('PasswordSettingsPage', () => {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: { retry: false },
|
||||||
|
mutations: { retry: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderWithProvider = (component: React.ReactElement) => {
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
{component}
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
it('renders without crashing', () => {
|
it('renders without crashing', () => {
|
||||||
render(<PasswordSettingsPage />);
|
renderWithProvider(<PasswordSettingsPage />);
|
||||||
expect(screen.getByText('Password Settings')).toBeInTheDocument();
|
expect(screen.getByText('Password Settings')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders heading', () => {
|
it('renders heading', () => {
|
||||||
render(<PasswordSettingsPage />);
|
renderWithProvider(<PasswordSettingsPage />);
|
||||||
|
|
||||||
expect(screen.getByRole('heading', { name: /password settings/i })).toBeInTheDocument();
|
expect(screen.getByRole('heading', { name: /password settings/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows placeholder text', () => {
|
it('shows description text', () => {
|
||||||
render(<PasswordSettingsPage />);
|
renderWithProvider(<PasswordSettingsPage />);
|
||||||
|
|
||||||
expect(screen.getByText(/change your password/i)).toBeInTheDocument();
|
expect(screen.getByText(/change your password/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,26 +1,66 @@
|
|||||||
/**
|
/**
|
||||||
* Tests for Profile Settings Page
|
* Tests for Profile Settings Page
|
||||||
* Smoke tests for placeholder page
|
* Smoke tests for page rendering
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import ProfileSettingsPage from '@/app/(authenticated)/settings/profile/page';
|
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<typeof useAuthStore>;
|
||||||
|
|
||||||
describe('ProfileSettingsPage', () => {
|
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(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
{component}
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
it('renders without crashing', () => {
|
it('renders without crashing', () => {
|
||||||
render(<ProfileSettingsPage />);
|
renderWithProvider(<ProfileSettingsPage />);
|
||||||
expect(screen.getByText('Profile Settings')).toBeInTheDocument();
|
expect(screen.getByText('Profile Settings')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders heading', () => {
|
it('renders heading', () => {
|
||||||
render(<ProfileSettingsPage />);
|
renderWithProvider(<ProfileSettingsPage />);
|
||||||
|
|
||||||
expect(screen.getByRole('heading', { name: /profile settings/i })).toBeInTheDocument();
|
expect(screen.getByRole('heading', { name: /profile settings/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows placeholder text', () => {
|
it('shows description text', () => {
|
||||||
render(<ProfileSettingsPage />);
|
renderWithProvider(<ProfileSettingsPage />);
|
||||||
|
|
||||||
expect(screen.getByText(/manage your profile information/i)).toBeInTheDocument();
|
expect(screen.getByText(/manage your profile information/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,26 +1,56 @@
|
|||||||
/**
|
/**
|
||||||
* Tests for Sessions Page
|
* 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';
|
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', () => {
|
describe('SessionsPage', () => {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: { retry: false },
|
||||||
|
mutations: { retry: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderWithProvider = (component: React.ReactElement) => {
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
{component}
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
it('renders without crashing', () => {
|
it('renders without crashing', () => {
|
||||||
render(<SessionsPage />);
|
renderWithProvider(<SessionsPage />);
|
||||||
expect(screen.getByText('Active Sessions')).toBeInTheDocument();
|
// Check for the main heading
|
||||||
|
expect(screen.getByRole('heading', { level: 2 })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders heading', () => {
|
it('renders heading', () => {
|
||||||
render(<SessionsPage />);
|
renderWithProvider(<SessionsPage />);
|
||||||
|
// The heading text
|
||||||
expect(screen.getByRole('heading', { name: /active sessions/i })).toBeInTheDocument();
|
expect(screen.getByRole('heading', { name: /active sessions/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows placeholder text', () => {
|
it('shows description text', () => {
|
||||||
render(<SessionsPage />);
|
renderWithProvider(<SessionsPage />);
|
||||||
|
// Description under the heading
|
||||||
expect(screen.getByText(/manage your active sessions/i)).toBeInTheDocument();
|
expect(screen.getByText(/view and manage devices/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -246,7 +246,6 @@ describe('FormField', () => {
|
|||||||
render(
|
render(
|
||||||
<FormField
|
<FormField
|
||||||
label="Email"
|
label="Email"
|
||||||
// @ts-expect-error - Testing missing name
|
|
||||||
name={undefined}
|
name={undefined}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
251
frontend/tests/components/settings/PasswordChangeForm.test.tsx
Normal file
251
frontend/tests/components/settings/PasswordChangeForm.test.tsx
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
/**
|
||||||
|
* Tests for PasswordChangeForm Component
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { PasswordChangeForm } from '@/components/settings/PasswordChangeForm';
|
||||||
|
import * as useAuthModule from '@/lib/api/hooks/useAuth';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
jest.mock('@/lib/api/hooks/useAuth');
|
||||||
|
jest.mock('sonner', () => ({ toast: { success: jest.fn(), error: jest.fn() } }));
|
||||||
|
|
||||||
|
const mockUsePasswordChange = useAuthModule.usePasswordChange as jest.Mock;
|
||||||
|
const mockToast = toast as jest.Mocked<typeof toast>;
|
||||||
|
|
||||||
|
describe('PasswordChangeForm', () => {
|
||||||
|
let queryClient: QueryClient;
|
||||||
|
let user: ReturnType<typeof userEvent.setup>;
|
||||||
|
const mockMutateAsync = jest.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
queryClient = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||||
|
});
|
||||||
|
user = userEvent.setup();
|
||||||
|
jest.clearAllMocks();
|
||||||
|
mockUsePasswordChange.mockReturnValue({
|
||||||
|
mutateAsync: mockMutateAsync,
|
||||||
|
isPending: false,
|
||||||
|
isError: false,
|
||||||
|
isSuccess: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderWithProvider = (component: React.ReactElement) =>
|
||||||
|
render(<QueryClientProvider client={queryClient}>{component}</QueryClientProvider>);
|
||||||
|
|
||||||
|
describe('Rendering', () => {
|
||||||
|
it('renders all password fields', () => {
|
||||||
|
renderWithProvider(<PasswordChangeForm />);
|
||||||
|
expect(screen.getByLabelText(/current password/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText(/^new password/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText(/confirm new password/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders change password button', () => {
|
||||||
|
renderWithProvider(<PasswordChangeForm />);
|
||||||
|
expect(screen.getByRole('button', { name: /change password/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows password strength requirements', () => {
|
||||||
|
renderWithProvider(<PasswordChangeForm />);
|
||||||
|
expect(screen.getByText(/at least 8 characters/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses usePasswordChange hook', () => {
|
||||||
|
renderWithProvider(<PasswordChangeForm />);
|
||||||
|
expect(mockUsePasswordChange).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Form State', () => {
|
||||||
|
it('disables submit when pristine', () => {
|
||||||
|
renderWithProvider(<PasswordChangeForm />);
|
||||||
|
expect(screen.getByRole('button', { name: /change password/i })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables inputs while submitting', () => {
|
||||||
|
mockUsePasswordChange.mockReturnValue({
|
||||||
|
mutateAsync: mockMutateAsync,
|
||||||
|
isPending: true,
|
||||||
|
isError: false,
|
||||||
|
isSuccess: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithProvider(<PasswordChangeForm />);
|
||||||
|
|
||||||
|
expect(screen.getByLabelText(/current password/i)).toBeDisabled();
|
||||||
|
expect(screen.getByLabelText(/^new password/i)).toBeDisabled();
|
||||||
|
expect(screen.getByLabelText(/confirm new password/i)).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows loading text while submitting', () => {
|
||||||
|
mockUsePasswordChange.mockReturnValue({
|
||||||
|
mutateAsync: mockMutateAsync,
|
||||||
|
isPending: true,
|
||||||
|
isError: false,
|
||||||
|
isSuccess: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithProvider(<PasswordChangeForm />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/changing password/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows cancel button when form is dirty', async () => {
|
||||||
|
renderWithProvider(<PasswordChangeForm />);
|
||||||
|
|
||||||
|
const currentPasswordInput = screen.getByLabelText(/current password/i);
|
||||||
|
await user.type(currentPasswordInput, 'password');
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('User Interactions', () => {
|
||||||
|
it('allows typing in current password field', async () => {
|
||||||
|
renderWithProvider(<PasswordChangeForm />);
|
||||||
|
|
||||||
|
const currentPasswordInput = screen.getByLabelText(/current password/i) as HTMLInputElement;
|
||||||
|
await user.type(currentPasswordInput, 'OldPassword123!');
|
||||||
|
|
||||||
|
expect(currentPasswordInput.value).toBe('OldPassword123!');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows typing in new password field', async () => {
|
||||||
|
renderWithProvider(<PasswordChangeForm />);
|
||||||
|
|
||||||
|
const newPasswordInput = screen.getByLabelText(/^new password/i) as HTMLInputElement;
|
||||||
|
await user.type(newPasswordInput, 'NewPassword123!');
|
||||||
|
|
||||||
|
expect(newPasswordInput.value).toBe('NewPassword123!');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows typing in confirm password field', async () => {
|
||||||
|
renderWithProvider(<PasswordChangeForm />);
|
||||||
|
|
||||||
|
const confirmPasswordInput = screen.getByLabelText(/confirm new password/i) as HTMLInputElement;
|
||||||
|
await user.type(confirmPasswordInput, 'NewPassword123!');
|
||||||
|
|
||||||
|
expect(confirmPasswordInput.value).toBe('NewPassword123!');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets form when cancel button is clicked', async () => {
|
||||||
|
renderWithProvider(<PasswordChangeForm />);
|
||||||
|
|
||||||
|
const currentPasswordInput = screen.getByLabelText(/current password/i) as HTMLInputElement;
|
||||||
|
await user.type(currentPasswordInput, 'password');
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const cancelButton = screen.getByRole('button', { name: /cancel/i });
|
||||||
|
await user.click(cancelButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(currentPasswordInput.value).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Form Submission - Success', () => {
|
||||||
|
it('calls mutateAsync with correct data on successful submission', async () => {
|
||||||
|
mockMutateAsync.mockResolvedValueOnce({});
|
||||||
|
|
||||||
|
renderWithProvider(<PasswordChangeForm />);
|
||||||
|
|
||||||
|
// Simulate the hook callback being triggered (success path)
|
||||||
|
const hookCallback = mockUsePasswordChange.mock.calls[0][0];
|
||||||
|
|
||||||
|
// Trigger the callback as if mutation succeeded
|
||||||
|
hookCallback('Password changed successfully');
|
||||||
|
|
||||||
|
expect(mockToast.success).toHaveBeenCalledWith('Password changed successfully');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onSuccess callback after successful password change', async () => {
|
||||||
|
const onSuccess = jest.fn();
|
||||||
|
mockMutateAsync.mockResolvedValueOnce({});
|
||||||
|
|
||||||
|
renderWithProvider(<PasswordChangeForm onSuccess={onSuccess} />);
|
||||||
|
|
||||||
|
// Simulate successful password change through hook callback
|
||||||
|
const hookCallback = mockUsePasswordChange.mock.calls[0][0];
|
||||||
|
hookCallback('Password changed successfully');
|
||||||
|
|
||||||
|
expect(onSuccess).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows success toast with custom message', async () => {
|
||||||
|
renderWithProvider(<PasswordChangeForm />);
|
||||||
|
|
||||||
|
const hookCallback = mockUsePasswordChange.mock.calls[0][0];
|
||||||
|
hookCallback('Your password has been updated');
|
||||||
|
|
||||||
|
expect(mockToast.success).toHaveBeenCalledWith('Your password has been updated');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Form Validation', () => {
|
||||||
|
it('validates password match', async () => {
|
||||||
|
renderWithProvider(<PasswordChangeForm />);
|
||||||
|
|
||||||
|
await user.type(screen.getByLabelText(/current password/i), 'OldPass123!');
|
||||||
|
await user.type(screen.getByLabelText(/^new password/i), 'NewPass123!');
|
||||||
|
await user.type(screen.getByLabelText(/confirm new password/i), 'DifferentPass123!');
|
||||||
|
|
||||||
|
// Try to submit the form
|
||||||
|
const form = screen.getByRole('button', { name: /change password/i }).closest('form');
|
||||||
|
if (form) {
|
||||||
|
const submitEvent = new Event('submit', { bubbles: true, cancelable: true });
|
||||||
|
form.dispatchEvent(submitEvent);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/passwords do not match/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates password strength requirements', async () => {
|
||||||
|
renderWithProvider(<PasswordChangeForm />);
|
||||||
|
|
||||||
|
await user.type(screen.getByLabelText(/current password/i), 'OldPass123!');
|
||||||
|
await user.type(screen.getByLabelText(/^new password/i), 'weak');
|
||||||
|
await user.type(screen.getByLabelText(/confirm new password/i), 'weak');
|
||||||
|
|
||||||
|
const form = screen.getByRole('button', { name: /change password/i }).closest('form');
|
||||||
|
if (form) {
|
||||||
|
const submitEvent = new Event('submit', { bubbles: true, cancelable: true });
|
||||||
|
form.dispatchEvent(submitEvent);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/password must be at least 8 characters/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires all fields to be filled', async () => {
|
||||||
|
renderWithProvider(<PasswordChangeForm />);
|
||||||
|
|
||||||
|
// Leave fields empty and try to submit
|
||||||
|
const form = screen.getByRole('button', { name: /change password/i }).closest('form');
|
||||||
|
if (form) {
|
||||||
|
const submitEvent = new Event('submit', { bubbles: true, cancelable: true });
|
||||||
|
form.dispatchEvent(submitEvent);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/current password is required/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
243
frontend/tests/components/settings/ProfileSettingsForm.test.tsx
Normal file
243
frontend/tests/components/settings/ProfileSettingsForm.test.tsx
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
/**
|
||||||
|
* Tests for ProfileSettingsForm Component
|
||||||
|
* Tests profile editing functionality
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { ProfileSettingsForm } from '@/components/settings/ProfileSettingsForm';
|
||||||
|
import * as useAuthModule from '@/lib/api/hooks/useAuth';
|
||||||
|
import * as useUserModule from '@/lib/api/hooks/useUser';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
jest.mock('@/lib/api/hooks/useAuth');
|
||||||
|
jest.mock('@/lib/api/hooks/useUser');
|
||||||
|
jest.mock('sonner', () => ({
|
||||||
|
toast: {
|
||||||
|
success: jest.fn(),
|
||||||
|
error: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockUseCurrentUser = useAuthModule.useCurrentUser as jest.Mock;
|
||||||
|
const mockUseUpdateProfile = useUserModule.useUpdateProfile as jest.Mock;
|
||||||
|
const mockToast = toast as jest.Mocked<typeof toast>;
|
||||||
|
|
||||||
|
describe('ProfileSettingsForm', () => {
|
||||||
|
let queryClient: QueryClient;
|
||||||
|
let user: ReturnType<typeof userEvent.setup>;
|
||||||
|
const mockMutateAsync = jest.fn();
|
||||||
|
|
||||||
|
const mockUser = {
|
||||||
|
id: '1',
|
||||||
|
email: 'test@example.com',
|
||||||
|
first_name: 'John',
|
||||||
|
last_name: 'Doe',
|
||||||
|
is_active: true,
|
||||||
|
is_superuser: false,
|
||||||
|
created_at: '2024-01-01T00:00:00Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: { retry: false },
|
||||||
|
mutations: { retry: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
user = userEvent.setup();
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
// Mock useCurrentUser to return user
|
||||||
|
mockUseCurrentUser.mockReturnValue(mockUser);
|
||||||
|
|
||||||
|
// Mock useUpdateProfile
|
||||||
|
mockUseUpdateProfile.mockReturnValue({
|
||||||
|
mutateAsync: mockMutateAsync,
|
||||||
|
isPending: false,
|
||||||
|
isError: false,
|
||||||
|
isSuccess: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderWithProvider = (component: React.ReactElement) => {
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
{component}
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Rendering', () => {
|
||||||
|
it('renders form with all fields', () => {
|
||||||
|
renderWithProvider(<ProfileSettingsForm />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Profile Information')).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText(/first name/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText(/last name/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: /save changes/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('populates form with current user data', async () => {
|
||||||
|
renderWithProvider(<ProfileSettingsForm />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const firstNameInput = screen.getByLabelText(/first name/i) as HTMLInputElement;
|
||||||
|
const lastNameInput = screen.getByLabelText(/last name/i) as HTMLInputElement;
|
||||||
|
const emailInput = screen.getByLabelText(/email/i) as HTMLInputElement;
|
||||||
|
|
||||||
|
expect(firstNameInput.value).toBe('John');
|
||||||
|
expect(lastNameInput.value).toBe('Doe');
|
||||||
|
expect(emailInput.value).toBe('test@example.com');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables email field', () => {
|
||||||
|
renderWithProvider(<ProfileSettingsForm />);
|
||||||
|
|
||||||
|
const emailInput = screen.getByLabelText(/email/i);
|
||||||
|
expect(emailInput).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows email cannot be changed message', () => {
|
||||||
|
renderWithProvider(<ProfileSettingsForm />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/cannot be changed from this form/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks first name as required', () => {
|
||||||
|
renderWithProvider(<ProfileSettingsForm />);
|
||||||
|
|
||||||
|
const firstNameLabel = screen.getByText(/first name/i);
|
||||||
|
expect(firstNameLabel.parentElement?.textContent).toContain('*');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('User Interactions', () => {
|
||||||
|
it('allows typing in first name field', async () => {
|
||||||
|
renderWithProvider(<ProfileSettingsForm />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect((screen.getByLabelText(/first name/i) as HTMLInputElement).value).toBe('John');
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstNameInput = screen.getByLabelText(/first name/i);
|
||||||
|
await user.clear(firstNameInput);
|
||||||
|
await user.type(firstNameInput, 'Jane');
|
||||||
|
|
||||||
|
expect((firstNameInput as HTMLInputElement).value).toBe('Jane');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows typing in last name field', async () => {
|
||||||
|
renderWithProvider(<ProfileSettingsForm />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect((screen.getByLabelText(/last name/i) as HTMLInputElement).value).toBe('Doe');
|
||||||
|
});
|
||||||
|
|
||||||
|
const lastNameInput = screen.getByLabelText(/last name/i);
|
||||||
|
await user.clear(lastNameInput);
|
||||||
|
await user.type(lastNameInput, 'Smith');
|
||||||
|
|
||||||
|
expect((lastNameInput as HTMLInputElement).value).toBe('Smith');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Form Submission', () => {
|
||||||
|
it('uses useUpdateProfile hook correctly', async () => {
|
||||||
|
renderWithProvider(<ProfileSettingsForm />);
|
||||||
|
|
||||||
|
// Verify the hook is called
|
||||||
|
expect(mockUseUpdateProfile).toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Verify the hook callback would call success handler
|
||||||
|
const hookCallback = mockUseUpdateProfile.mock.calls[0][0];
|
||||||
|
expect(typeof hookCallback).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls success toast through hook callback', async () => {
|
||||||
|
renderWithProvider(<ProfileSettingsForm />);
|
||||||
|
|
||||||
|
// Get the hook callback
|
||||||
|
const hookCallback = mockUseUpdateProfile.mock.calls[0][0];
|
||||||
|
if (hookCallback) {
|
||||||
|
hookCallback('Profile updated successfully');
|
||||||
|
expect(mockToast.success).toHaveBeenCalledWith('Profile updated successfully');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onSuccess callback when provided', async () => {
|
||||||
|
const onSuccess = jest.fn();
|
||||||
|
renderWithProvider(<ProfileSettingsForm onSuccess={onSuccess} />);
|
||||||
|
|
||||||
|
// Verify onSuccess is passed to the component
|
||||||
|
expect(onSuccess).not.toHaveBeenCalled(); // Not called on mount
|
||||||
|
|
||||||
|
// The onSuccess would be called through the useUpdateProfile hook callback
|
||||||
|
const hookCallback = mockUseUpdateProfile.mock.calls[0][0];
|
||||||
|
if (hookCallback) {
|
||||||
|
hookCallback('Profile updated successfully');
|
||||||
|
expect(onSuccess).toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Validation', () => {
|
||||||
|
it('marks first name field as required in UI', () => {
|
||||||
|
renderWithProvider(<ProfileSettingsForm />);
|
||||||
|
|
||||||
|
// First name label should have asterisk indicating it's required
|
||||||
|
const firstNameLabel = screen.getByText(/first name/i);
|
||||||
|
expect(firstNameLabel.parentElement?.textContent).toContain('*');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Form State', () => {
|
||||||
|
it('disables submit button when form is pristine', async () => {
|
||||||
|
renderWithProvider(<ProfileSettingsForm />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const submitButton = screen.getByRole('button', { name: /save changes/i });
|
||||||
|
expect(submitButton).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables inputs while submitting', async () => {
|
||||||
|
mockUseUpdateProfile.mockReturnValue({
|
||||||
|
mutateAsync: mockMutateAsync,
|
||||||
|
isPending: true,
|
||||||
|
isError: false,
|
||||||
|
isSuccess: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithProvider(<ProfileSettingsForm />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const firstNameInput = screen.getByLabelText(/first name/i);
|
||||||
|
expect(firstNameInput).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows loading text while submitting', async () => {
|
||||||
|
mockUseUpdateProfile.mockReturnValue({
|
||||||
|
mutateAsync: mockMutateAsync,
|
||||||
|
isPending: true,
|
||||||
|
isError: false,
|
||||||
|
isSuccess: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithProvider(<ProfileSettingsForm />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/saving/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
144
frontend/tests/components/settings/SessionCard.test.tsx
Normal file
144
frontend/tests/components/settings/SessionCard.test.tsx
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
/**
|
||||||
|
* Tests for SessionCard Component
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
import { SessionCard } from '@/components/settings/SessionCard';
|
||||||
|
import type { Session } from '@/lib/api/hooks/useSession';
|
||||||
|
|
||||||
|
describe('SessionCard', () => {
|
||||||
|
const mockOnRevoke = jest.fn();
|
||||||
|
|
||||||
|
const currentSession: Session = {
|
||||||
|
id: '1',
|
||||||
|
device_name: 'Chrome on Mac',
|
||||||
|
ip_address: '192.168.1.1',
|
||||||
|
location_city: 'San Francisco',
|
||||||
|
location_country: 'USA',
|
||||||
|
last_used_at: '2024-01-01T12:00:00Z',
|
||||||
|
created_at: '2024-01-01T00:00:00Z',
|
||||||
|
expires_at: '2024-01-08T00:00:00Z',
|
||||||
|
is_current: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const otherSession: Session = {
|
||||||
|
...currentSession,
|
||||||
|
id: '2',
|
||||||
|
device_name: 'Firefox on Windows',
|
||||||
|
location_city: 'New York',
|
||||||
|
is_current: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders session information', () => {
|
||||||
|
render(<SessionCard session={currentSession} onRevoke={mockOnRevoke} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Chrome on Mac')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/San Francisco.*USA/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('192.168.1.1')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows current session badge', () => {
|
||||||
|
render(<SessionCard session={currentSession} onRevoke={mockOnRevoke} />);
|
||||||
|
expect(screen.getByText('Current Session')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show revoke button for current session', () => {
|
||||||
|
render(<SessionCard session={currentSession} onRevoke={mockOnRevoke} />);
|
||||||
|
expect(screen.queryByRole('button', { name: /revoke/i })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows revoke button for other sessions', () => {
|
||||||
|
render(<SessionCard session={otherSession} onRevoke={mockOnRevoke} />);
|
||||||
|
expect(screen.getByRole('button', { name: /revoke/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens confirmation dialog on revoke click', async () => {
|
||||||
|
render(<SessionCard session={otherSession} onRevoke={mockOnRevoke} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /revoke/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Revoke Session?')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onRevoke when confirmed', async () => {
|
||||||
|
render(<SessionCard session={otherSession} onRevoke={mockOnRevoke} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /revoke/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Revoke Session?')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const confirmButton = screen.getByRole('button', { name: /revoke session/i });
|
||||||
|
fireEvent.click(confirmButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockOnRevoke).toHaveBeenCalledWith('2');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes dialog on cancel', async () => {
|
||||||
|
render(<SessionCard session={otherSession} onRevoke={mockOnRevoke} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /revoke/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Revoke Session?')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText('Revoke Session?')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockOnRevoke).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles missing location gracefully', () => {
|
||||||
|
const sessionWithoutLocation: Session = {
|
||||||
|
...otherSession,
|
||||||
|
location_city: null,
|
||||||
|
location_country: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<SessionCard session={sessionWithoutLocation} onRevoke={mockOnRevoke} />);
|
||||||
|
|
||||||
|
expect(screen.queryByText(/San Francisco/)).not.toBeInTheDocument();
|
||||||
|
// Component hides location when unknown, so it should not be displayed
|
||||||
|
expect(screen.queryByText(/location/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles missing device name gracefully', () => {
|
||||||
|
const sessionWithoutDevice: Session = {
|
||||||
|
...otherSession,
|
||||||
|
device_name: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<SessionCard session={sessionWithoutDevice} onRevoke={mockOnRevoke} />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Unknown device')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables revoke button while revoking', () => {
|
||||||
|
render(<SessionCard session={otherSession} onRevoke={mockOnRevoke} isRevoking />);
|
||||||
|
|
||||||
|
const revokeButton = screen.getByRole('button', { name: /revoke/i });
|
||||||
|
expect(revokeButton).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('highlights current session with border', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<SessionCard session={currentSession} onRevoke={mockOnRevoke} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const card = container.querySelector('.border-primary');
|
||||||
|
expect(card).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
258
frontend/tests/components/settings/SessionsManager.test.tsx
Normal file
258
frontend/tests/components/settings/SessionsManager.test.tsx
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
/**
|
||||||
|
* Tests for SessionsManager Component
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { SessionsManager } from '@/components/settings/SessionsManager';
|
||||||
|
import * as useSessionModule from '@/lib/api/hooks/useSession';
|
||||||
|
|
||||||
|
jest.mock('@/lib/api/hooks/useSession');
|
||||||
|
jest.mock('sonner', () => ({ toast: { success: jest.fn(), error: jest.fn() } }));
|
||||||
|
|
||||||
|
const mockUseListSessions = useSessionModule.useListSessions as jest.Mock;
|
||||||
|
const mockUseRevokeSession = useSessionModule.useRevokeSession as jest.Mock;
|
||||||
|
const mockUseRevokeAllOtherSessions = useSessionModule.useRevokeAllOtherSessions as jest.Mock;
|
||||||
|
|
||||||
|
describe('SessionsManager', () => {
|
||||||
|
let queryClient: QueryClient;
|
||||||
|
const mockRevokeMutate = jest.fn();
|
||||||
|
const mockRevokeAllMutate = jest.fn();
|
||||||
|
|
||||||
|
const mockSessions = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
device_name: 'Chrome on Mac',
|
||||||
|
ip_address: '192.168.1.1',
|
||||||
|
location_city: 'San Francisco',
|
||||||
|
location_country: 'USA',
|
||||||
|
last_used_at: '2024-01-01T12:00:00Z',
|
||||||
|
created_at: '2024-01-01T00:00:00Z',
|
||||||
|
expires_at: '2024-01-08T00:00:00Z',
|
||||||
|
is_current: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
device_name: 'Firefox on Windows',
|
||||||
|
ip_address: '192.168.1.2',
|
||||||
|
location_city: 'New York',
|
||||||
|
location_country: 'USA',
|
||||||
|
last_used_at: '2024-01-01T11:00:00Z',
|
||||||
|
created_at: '2023-12-31T00:00:00Z',
|
||||||
|
expires_at: '2024-01-07T00:00:00Z',
|
||||||
|
is_current: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
queryClient = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
mockUseRevokeSession.mockReturnValue({
|
||||||
|
mutate: mockRevokeMutate,
|
||||||
|
isPending: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
mockUseRevokeAllOtherSessions.mockReturnValue({
|
||||||
|
mutate: mockRevokeAllMutate,
|
||||||
|
isPending: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderWithProvider = (component: React.ReactElement) =>
|
||||||
|
render(<QueryClientProvider client={queryClient}>{component}</QueryClientProvider>);
|
||||||
|
|
||||||
|
it('shows loading state', () => {
|
||||||
|
mockUseListSessions.mockReturnValue({
|
||||||
|
data: undefined,
|
||||||
|
isLoading: true,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithProvider(<SessionsManager />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/loading your active sessions/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows error state', () => {
|
||||||
|
mockUseListSessions.mockReturnValue({
|
||||||
|
data: undefined,
|
||||||
|
isLoading: false,
|
||||||
|
error: new Error('Failed to load'),
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithProvider(<SessionsManager />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/unable to load your sessions/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders sessions list', () => {
|
||||||
|
mockUseListSessions.mockReturnValue({
|
||||||
|
data: mockSessions,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithProvider(<SessionsManager />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Chrome on Mac')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Firefox on Windows')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows "Revoke All Others" button when multiple sessions exist', () => {
|
||||||
|
mockUseListSessions.mockReturnValue({
|
||||||
|
data: mockSessions,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithProvider(<SessionsManager />);
|
||||||
|
|
||||||
|
expect(screen.getByRole('button', { name: /revoke all others/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show "Revoke All Others" when only current session', () => {
|
||||||
|
mockUseListSessions.mockReturnValue({
|
||||||
|
data: [mockSessions[0]], // Only current session
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithProvider(<SessionsManager />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.queryByRole('button', { name: /revoke all others/i })
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens bulk revoke dialog', async () => {
|
||||||
|
mockUseListSessions.mockReturnValue({
|
||||||
|
data: mockSessions,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithProvider(<SessionsManager />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /revoke all others/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Revoke All Other Sessions?')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls bulk revoke when confirmed', async () => {
|
||||||
|
mockUseListSessions.mockReturnValue({
|
||||||
|
data: mockSessions,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithProvider(<SessionsManager />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /revoke all others/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Revoke All Other Sessions?')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Find the destructive button in the dialog (not the "Cancel" button)
|
||||||
|
const buttons = screen.getAllByRole('button');
|
||||||
|
const confirmButton = buttons.find(btn => btn.textContent === 'Revoke All Others');
|
||||||
|
|
||||||
|
if (confirmButton) {
|
||||||
|
fireEvent.click(confirmButton);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(mockRevokeAllMutate).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls revoke when individual session revoke clicked', async () => {
|
||||||
|
mockUseListSessions.mockReturnValue({
|
||||||
|
data: mockSessions,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithProvider(<SessionsManager />);
|
||||||
|
|
||||||
|
// Click the individual "Revoke" button (not "Revoke All Others")
|
||||||
|
const revokeButtons = screen.getAllByRole('button', { name: /^revoke$/i });
|
||||||
|
fireEvent.click(revokeButtons[0]);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Revoke Session?')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const confirmButton = screen.getByRole('button', { name: /revoke session/i });
|
||||||
|
fireEvent.click(confirmButton);
|
||||||
|
|
||||||
|
expect(mockRevokeMutate).toHaveBeenCalledWith('2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows empty state when no sessions', () => {
|
||||||
|
mockUseListSessions.mockReturnValue({
|
||||||
|
data: [],
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithProvider(<SessionsManager />);
|
||||||
|
|
||||||
|
expect(screen.getByText('No active sessions to display')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows info message when only one session', () => {
|
||||||
|
mockUseListSessions.mockReturnValue({
|
||||||
|
data: [mockSessions[0]],
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithProvider(<SessionsManager />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText(/you're viewing your only active session/i)
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows security tip', () => {
|
||||||
|
mockUseListSessions.mockReturnValue({
|
||||||
|
data: mockSessions,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithProvider(<SessionsManager />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/security tip/i)).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText(/if you see a session you don't recognize/i)
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes bulk revoke dialog on cancel', async () => {
|
||||||
|
mockUseListSessions.mockReturnValue({
|
||||||
|
data: mockSessions,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithProvider(<SessionsManager />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /revoke all others/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Revoke All Other Sessions?')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText('Revoke All Other Sessions?')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
338
frontend/tests/lib/api/hooks/useSession.test.tsx
Normal file
338
frontend/tests/lib/api/hooks/useSession.test.tsx
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
/**
|
||||||
|
* Tests for useSession hooks
|
||||||
|
* Tests session management hooks
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { renderHook, waitFor } from '@testing-library/react';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
useListSessions,
|
||||||
|
useRevokeSession,
|
||||||
|
useRevokeAllOtherSessions,
|
||||||
|
type Session,
|
||||||
|
} from '@/lib/api/hooks/useSession';
|
||||||
|
import * as apiClient from '@/lib/api/client';
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
jest.mock('@/lib/api/client');
|
||||||
|
|
||||||
|
const mockListMySessions = apiClient.listMySessions as jest.Mock;
|
||||||
|
const mockRevokeSession = apiClient.revokeSession as jest.Mock;
|
||||||
|
|
||||||
|
describe('useSession hooks', () => {
|
||||||
|
let queryClient: QueryClient;
|
||||||
|
|
||||||
|
const mockSessions: Session[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
device_name: 'Chrome on Mac',
|
||||||
|
ip_address: '192.168.1.1',
|
||||||
|
location_city: 'San Francisco',
|
||||||
|
location_country: 'USA',
|
||||||
|
last_used_at: '2024-01-01T12:00:00Z',
|
||||||
|
created_at: '2024-01-01T00:00:00Z',
|
||||||
|
expires_at: '2024-01-08T00:00:00Z',
|
||||||
|
is_current: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
device_name: 'Firefox on Windows',
|
||||||
|
ip_address: '192.168.1.2',
|
||||||
|
location_city: 'New York',
|
||||||
|
location_country: 'USA',
|
||||||
|
last_used_at: '2024-01-01T11:00:00Z',
|
||||||
|
created_at: '2023-12-31T00:00:00Z',
|
||||||
|
expires_at: '2024-01-07T00:00:00Z',
|
||||||
|
is_current: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: { retry: false },
|
||||||
|
mutations: { retry: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
{children}
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('useListSessions', () => {
|
||||||
|
it('successfully fetches sessions list', async () => {
|
||||||
|
mockListMySessions.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
sessions: mockSessions,
|
||||||
|
total: 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useListSessions(), { wrapper });
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
|
||||||
|
expect(mockListMySessions).toHaveBeenCalledWith({
|
||||||
|
throwOnError: true,
|
||||||
|
});
|
||||||
|
expect(result.current.data).toEqual(mockSessions);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array when no sessions', async () => {
|
||||||
|
mockListMySessions.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
sessions: [],
|
||||||
|
total: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useListSessions(), { wrapper });
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
|
||||||
|
expect(result.current.data).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles undefined sessions data', async () => {
|
||||||
|
mockListMySessions.mockResolvedValueOnce({
|
||||||
|
data: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useListSessions(), { wrapper });
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
|
||||||
|
expect(result.current.data).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses correct cache key', async () => {
|
||||||
|
mockListMySessions.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
sessions: mockSessions,
|
||||||
|
total: 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useListSessions(), { wrapper });
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
|
||||||
|
const cachedData = queryClient.getQueryData(['sessions', 'list']);
|
||||||
|
expect(cachedData).toEqual(mockSessions);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useRevokeSession', () => {
|
||||||
|
it('successfully revokes a session', async () => {
|
||||||
|
mockRevokeSession.mockResolvedValueOnce({
|
||||||
|
data: { message: 'Session revoked successfully' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useRevokeSession(), { wrapper });
|
||||||
|
|
||||||
|
result.current.mutate('session-id-123');
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
|
||||||
|
expect(mockRevokeSession).toHaveBeenCalledWith({
|
||||||
|
path: { session_id: 'session-id-123' },
|
||||||
|
throwOnError: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onSuccess callback when provided', async () => {
|
||||||
|
const onSuccess = jest.fn();
|
||||||
|
|
||||||
|
mockRevokeSession.mockResolvedValueOnce({
|
||||||
|
data: { message: 'Session revoked successfully' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useRevokeSession(onSuccess), { wrapper });
|
||||||
|
|
||||||
|
result.current.mutate('session-id-123');
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
|
||||||
|
expect(onSuccess).toHaveBeenCalledWith('Session revoked successfully');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses default message when no message in response', async () => {
|
||||||
|
const onSuccess = jest.fn();
|
||||||
|
|
||||||
|
mockRevokeSession.mockResolvedValueOnce({
|
||||||
|
data: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useRevokeSession(onSuccess), { wrapper });
|
||||||
|
|
||||||
|
result.current.mutate('session-id-123');
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
|
||||||
|
expect(onSuccess).toHaveBeenCalledWith('Session revoked successfully');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles revocation errors', async () => {
|
||||||
|
const consoleError = jest.spyOn(console, 'error').mockImplementation();
|
||||||
|
|
||||||
|
mockRevokeSession.mockResolvedValueOnce({
|
||||||
|
error: {
|
||||||
|
message: 'Revocation failed',
|
||||||
|
errors: [{ field: 'general', message: 'Revocation failed' }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useRevokeSession(), { wrapper });
|
||||||
|
|
||||||
|
result.current.mutate('session-id-123');
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||||
|
|
||||||
|
expect(consoleError).toHaveBeenCalledWith(
|
||||||
|
'Session revocation failed:',
|
||||||
|
'An unexpected error occurred'
|
||||||
|
);
|
||||||
|
|
||||||
|
consoleError.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invalidates sessions query on success', async () => {
|
||||||
|
mockRevokeSession.mockResolvedValueOnce({
|
||||||
|
data: { message: 'Session revoked' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const invalidateQueriesSpy = jest.spyOn(queryClient, 'invalidateQueries');
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useRevokeSession(), { wrapper });
|
||||||
|
|
||||||
|
result.current.mutate('session-id-123');
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
|
||||||
|
expect(invalidateQueriesSpy).toHaveBeenCalledWith({
|
||||||
|
queryKey: ['sessions', 'list'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useRevokeAllOtherSessions', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Mock useListSessions data
|
||||||
|
queryClient.setQueryData(['sessions', 'list'], mockSessions);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('successfully revokes all other sessions', async () => {
|
||||||
|
mockRevokeSession.mockResolvedValue({
|
||||||
|
data: { message: 'Session revoked' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useRevokeAllOtherSessions(), { wrapper });
|
||||||
|
|
||||||
|
result.current.mutate();
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
|
||||||
|
// Should only revoke non-current sessions
|
||||||
|
expect(mockRevokeSession).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockRevokeSession).toHaveBeenCalledWith({
|
||||||
|
path: { session_id: '2' },
|
||||||
|
throwOnError: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onSuccess with count message', async () => {
|
||||||
|
const onSuccess = jest.fn();
|
||||||
|
|
||||||
|
mockRevokeSession.mockResolvedValue({
|
||||||
|
data: { message: 'Session revoked' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useRevokeAllOtherSessions(onSuccess), { wrapper });
|
||||||
|
|
||||||
|
result.current.mutate();
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
|
||||||
|
expect(onSuccess).toHaveBeenCalledWith('Successfully revoked 1 session');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles plural session count in message', async () => {
|
||||||
|
const onSuccess = jest.fn();
|
||||||
|
|
||||||
|
// Set multiple non-current sessions
|
||||||
|
const multipleSessions: Session[] = [
|
||||||
|
{ ...mockSessions[0], is_current: true },
|
||||||
|
{ ...mockSessions[1], id: '2', is_current: false },
|
||||||
|
{ ...mockSessions[1], id: '3', is_current: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
queryClient.setQueryData(['sessions', 'list'], multipleSessions);
|
||||||
|
|
||||||
|
mockRevokeSession.mockResolvedValue({
|
||||||
|
data: { message: 'Session revoked' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useRevokeAllOtherSessions(onSuccess), { wrapper });
|
||||||
|
|
||||||
|
result.current.mutate();
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
|
||||||
|
expect(mockRevokeSession).toHaveBeenCalledTimes(2);
|
||||||
|
expect(onSuccess).toHaveBeenCalledWith('Successfully revoked 2 sessions');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles no other sessions gracefully', async () => {
|
||||||
|
const onSuccess = jest.fn();
|
||||||
|
|
||||||
|
// Only current session
|
||||||
|
queryClient.setQueryData(['sessions', 'list'], [mockSessions[0]]);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useRevokeAllOtherSessions(onSuccess), { wrapper });
|
||||||
|
|
||||||
|
result.current.mutate();
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
|
||||||
|
expect(mockRevokeSession).not.toHaveBeenCalled();
|
||||||
|
expect(onSuccess).toHaveBeenCalledWith('No other sessions to revoke');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles when no sessions are available', async () => {
|
||||||
|
const onSuccess = jest.fn();
|
||||||
|
|
||||||
|
// Set empty sessions array
|
||||||
|
queryClient.setQueryData(['sessions', 'list'], []);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useRevokeAllOtherSessions(onSuccess), { wrapper });
|
||||||
|
|
||||||
|
result.current.mutate();
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
|
||||||
|
// Should succeed with message about no sessions
|
||||||
|
expect(onSuccess).toHaveBeenCalledWith('No other sessions to revoke');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles bulk revocation errors', async () => {
|
||||||
|
const consoleError = jest.spyOn(console, 'error').mockImplementation();
|
||||||
|
|
||||||
|
mockRevokeSession.mockRejectedValue(new Error('Network error'));
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useRevokeAllOtherSessions(), { wrapper });
|
||||||
|
|
||||||
|
result.current.mutate();
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||||
|
|
||||||
|
expect(consoleError).toHaveBeenCalled();
|
||||||
|
|
||||||
|
consoleError.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
195
frontend/tests/lib/api/hooks/useUser.test.tsx
Normal file
195
frontend/tests/lib/api/hooks/useUser.test.tsx
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
/**
|
||||||
|
* Tests for useUser hooks
|
||||||
|
* Tests user profile management hooks
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { renderHook, waitFor } from '@testing-library/react';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { useUpdateProfile } from '@/lib/api/hooks/useUser';
|
||||||
|
import { useAuthStore } from '@/lib/stores/authStore';
|
||||||
|
import * as apiClient from '@/lib/api/client';
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
jest.mock('@/lib/stores/authStore');
|
||||||
|
jest.mock('@/lib/api/client');
|
||||||
|
|
||||||
|
const mockUseAuthStore = useAuthStore as jest.MockedFunction<typeof useAuthStore>;
|
||||||
|
const mockUpdateCurrentUser = apiClient.updateCurrentUser as jest.Mock;
|
||||||
|
|
||||||
|
describe('useUser hooks', () => {
|
||||||
|
let queryClient: QueryClient;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: { retry: false },
|
||||||
|
mutations: { retry: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
{children}
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('useUpdateProfile', () => {
|
||||||
|
const mockSetUser = jest.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockUseAuthStore.mockImplementation((selector: unknown) => {
|
||||||
|
if (typeof selector === 'function') {
|
||||||
|
const mockState = { setUser: mockSetUser };
|
||||||
|
return selector(mockState);
|
||||||
|
}
|
||||||
|
return mockSetUser;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('successfully updates profile', async () => {
|
||||||
|
const updatedUser = {
|
||||||
|
id: '1',
|
||||||
|
email: 'test@example.com',
|
||||||
|
first_name: 'Updated',
|
||||||
|
last_name: 'Name',
|
||||||
|
is_active: true,
|
||||||
|
is_superuser: false,
|
||||||
|
created_at: '2024-01-01T00:00:00Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
mockUpdateCurrentUser.mockResolvedValueOnce({
|
||||||
|
data: updatedUser,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useUpdateProfile(), { wrapper });
|
||||||
|
|
||||||
|
result.current.mutate({
|
||||||
|
first_name: 'Updated',
|
||||||
|
last_name: 'Name',
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
|
||||||
|
expect(mockUpdateCurrentUser).toHaveBeenCalledWith({
|
||||||
|
body: { first_name: 'Updated', last_name: 'Name' },
|
||||||
|
throwOnError: false,
|
||||||
|
});
|
||||||
|
expect(mockSetUser).toHaveBeenCalledWith(updatedUser);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onSuccess callback when provided', async () => {
|
||||||
|
const onSuccess = jest.fn();
|
||||||
|
const updatedUser = {
|
||||||
|
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',
|
||||||
|
};
|
||||||
|
|
||||||
|
mockUpdateCurrentUser.mockResolvedValueOnce({
|
||||||
|
data: updatedUser,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useUpdateProfile(onSuccess), { wrapper });
|
||||||
|
|
||||||
|
result.current.mutate({
|
||||||
|
first_name: 'Test',
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
|
||||||
|
expect(onSuccess).toHaveBeenCalledWith('Profile updated successfully');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles update errors', async () => {
|
||||||
|
const consoleError = jest.spyOn(console, 'error').mockImplementation();
|
||||||
|
|
||||||
|
mockUpdateCurrentUser.mockResolvedValueOnce({
|
||||||
|
error: {
|
||||||
|
message: 'Update failed',
|
||||||
|
errors: [{ field: 'general', message: 'Update failed' }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useUpdateProfile(), { wrapper });
|
||||||
|
|
||||||
|
result.current.mutate({
|
||||||
|
first_name: 'Test',
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||||
|
|
||||||
|
expect(consoleError).toHaveBeenCalledWith(
|
||||||
|
'Profile update failed:',
|
||||||
|
'An unexpected error occurred'
|
||||||
|
);
|
||||||
|
|
||||||
|
consoleError.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invalidates auth queries on success', async () => {
|
||||||
|
const updatedUser = {
|
||||||
|
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',
|
||||||
|
};
|
||||||
|
|
||||||
|
mockUpdateCurrentUser.mockResolvedValueOnce({
|
||||||
|
data: updatedUser,
|
||||||
|
});
|
||||||
|
|
||||||
|
const invalidateQueriesSpy = jest.spyOn(queryClient, 'invalidateQueries');
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useUpdateProfile(), { wrapper });
|
||||||
|
|
||||||
|
result.current.mutate({
|
||||||
|
first_name: 'Test',
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
|
||||||
|
expect(invalidateQueriesSpy).toHaveBeenCalledWith({
|
||||||
|
queryKey: ['auth', 'me'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates only first_name when last_name is not provided', async () => {
|
||||||
|
const updatedUser = {
|
||||||
|
id: '1',
|
||||||
|
email: 'test@example.com',
|
||||||
|
first_name: 'Test',
|
||||||
|
last_name: '',
|
||||||
|
is_active: true,
|
||||||
|
is_superuser: false,
|
||||||
|
created_at: '2024-01-01T00:00:00Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
mockUpdateCurrentUser.mockResolvedValueOnce({
|
||||||
|
data: updatedUser,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useUpdateProfile(), { wrapper });
|
||||||
|
|
||||||
|
result.current.mutate({
|
||||||
|
first_name: 'Test',
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
|
||||||
|
expect(mockUpdateCurrentUser).toHaveBeenCalledWith({
|
||||||
|
body: { first_name: 'Test' },
|
||||||
|
throwOnError: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user