diff --git a/frontend/src/api/__tests__/user.test.ts b/frontend/src/api/__tests__/user.test.ts new file mode 100644 index 00000000..ee43f501 --- /dev/null +++ b/frontend/src/api/__tests__/user.test.ts @@ -0,0 +1,69 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import client from '../client' +import { getProfile, regenerateApiKey, updateProfile } from '../user' + +vi.mock('../client', () => ({ + default: { + get: vi.fn(), + post: vi.fn(), + }, +})) + +describe('user api', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('fetches profile using masked API key fields', async () => { + vi.mocked(client.get).mockResolvedValueOnce({ + data: { + id: 1, + email: 'admin@example.com', + name: 'Admin', + role: 'admin', + has_api_key: true, + api_key_masked: '********', + }, + }) + + const profile = await getProfile() + + expect(client.get).toHaveBeenCalledWith('/user/profile') + expect(profile.has_api_key).toBe(true) + expect(profile.api_key_masked).toBe('********') + }) + + it('regenerates API key and returns metadata-only response', async () => { + vi.mocked(client.post).mockResolvedValueOnce({ + data: { + message: 'API key regenerated successfully', + has_api_key: true, + api_key_masked: '********', + api_key_updated: '2026-02-25T00:00:00Z', + }, + }) + + const result = await regenerateApiKey() + + expect(client.post).toHaveBeenCalledWith('/user/api-key') + expect(result.has_api_key).toBe(true) + expect(result.api_key_masked).toBe('********') + expect(result.api_key_updated).toBe('2026-02-25T00:00:00Z') + }) + + it('updates profile with optional current password', async () => { + vi.mocked(client.post).mockResolvedValueOnce({ data: { message: 'ok' } }) + + await updateProfile({ + name: 'Updated Name', + email: 'updated@example.com', + current_password: 'current-password', + }) + + expect(client.post).toHaveBeenCalledWith('/user/profile', { + name: 'Updated Name', + email: 'updated@example.com', + current_password: 'current-password', + }) + }) +}) diff --git a/frontend/src/api/__tests__/users.test.ts b/frontend/src/api/__tests__/users.test.ts index ab4b3f81..bab06a01 100644 --- a/frontend/src/api/__tests__/users.test.ts +++ b/frontend/src/api/__tests__/users.test.ts @@ -50,7 +50,7 @@ describe('users api', () => { }) it('invites users and updates permissions', async () => { - vi.mocked(client.post).mockResolvedValueOnce({ data: { invite_token: 't', invite_url: 'https://charon.example.com/accept-invite?token=t' } }) + vi.mocked(client.post).mockResolvedValueOnce({ data: { invite_token_masked: '********', invite_url: '[REDACTED]' } }) await inviteUser({ email: 'i', permission_mode: 'allow_all' }) expect(client.post).toHaveBeenCalledWith('/users/invite', { email: 'i', permission_mode: 'allow_all' }) diff --git a/frontend/src/api/user.ts b/frontend/src/api/user.ts index d3cd3f11..0477d6c5 100644 --- a/frontend/src/api/user.ts +++ b/frontend/src/api/user.ts @@ -6,7 +6,8 @@ export interface UserProfile { email: string name: string role: string - api_key: string + has_api_key: boolean + api_key_masked: string } /** @@ -24,8 +25,15 @@ export const getProfile = async (): Promise => { * @returns Promise resolving to object containing the new API key * @throws {AxiosError} If regeneration fails */ -export const regenerateApiKey = async (): Promise<{ api_key: string }> => { - const response = await client.post('/user/api-key') +export interface RegenerateApiKeyResponse { + message: string + has_api_key: boolean + api_key_masked: string + api_key_updated: string +} + +export const regenerateApiKey = async (): Promise => { + const response = await client.post('/user/api-key') return response.data } diff --git a/frontend/src/api/users.test.ts b/frontend/src/api/users.test.ts index 6ff9baa8..09f014de 100644 --- a/frontend/src/api/users.test.ts +++ b/frontend/src/api/users.test.ts @@ -50,7 +50,7 @@ describe('users api', () => { it('creates, invites, updates, and deletes users', async () => { mockedClient.post .mockResolvedValueOnce({ data: { id: 3, uuid: 'u3', email: 'c@example.com', name: 'C', role: 'user', enabled: true, permission_mode: 'allow_all', created_at: '', updated_at: '' } }) - .mockResolvedValueOnce({ data: { id: 4, uuid: 'u4', email: 'invite@example.com', role: 'user', invite_token: 'token', invite_url: 'https://charon.example.com/accept-invite?token=token', email_sent: true, expires_at: '' } }) + .mockResolvedValueOnce({ data: { id: 4, uuid: 'u4', email: 'invite@example.com', role: 'user', invite_token_masked: '********', invite_url: '[REDACTED]', email_sent: true, expires_at: '' } }) mockedClient.put.mockResolvedValueOnce({ data: { message: 'updated' } }) mockedClient.delete.mockResolvedValueOnce({ data: { message: 'deleted' } }) @@ -61,7 +61,7 @@ describe('users api', () => { const invite = await inviteUser({ email: 'invite@example.com', role: 'user' }) expect(mockedClient.post).toHaveBeenCalledWith('/users/invite', { email: 'invite@example.com', role: 'user' }) - expect(invite.invite_token).toBe('token') + expect(invite.invite_token_masked).toBe('********') await updateUser(3, { enabled: false }) expect(mockedClient.put).toHaveBeenCalledWith('/users/3', { enabled: false }) diff --git a/frontend/src/api/users.ts b/frontend/src/api/users.ts index 12d708e7..e9aebc27 100644 --- a/frontend/src/api/users.ts +++ b/frontend/src/api/users.ts @@ -44,8 +44,8 @@ export interface InviteUserResponse { uuid: string email: string role: string - invite_token: string - invite_url: string + invite_token_masked: string + invite_url?: string email_sent: boolean expires_at: string } diff --git a/frontend/src/pages/Account.tsx b/frontend/src/pages/Account.tsx index fa621ee3..571dde00 100644 --- a/frontend/src/pages/Account.tsx +++ b/frontend/src/pages/Account.tsx @@ -11,7 +11,7 @@ import { Skeleton } from '../components/ui/Skeleton' import { toast } from '../utils/toast' import { getProfile, regenerateApiKey, updateProfile } from '../api/user' import { getSettings, updateSetting } from '../api/settings' -import { Copy, RefreshCw, Shield, Mail, User, AlertTriangle, Key } from 'lucide-react' +import { RefreshCw, Shield, Mail, User, AlertTriangle, Key } from 'lucide-react' import { PasswordStrengthMeter } from '../components/PasswordStrengthMeter' import { isValidEmail } from '../utils/validation' import { useAuth } from '../hooks/useAuth' @@ -242,13 +242,6 @@ export default function Account() { } } - const copyApiKey = () => { - if (profile?.api_key) { - navigator.clipboard.writeText(profile.api_key) - toast.success(t('account.apiKeyCopied')) - } - } - if (isLoadingProfile) { return (
@@ -444,13 +437,10 @@ export default function Account() {
- -
+ {hasUsableInviteUrl(inviteResult.inviteUrl) ? ( +
+ + +
+ ) : ( +

+ {t('users.inviteLinkHiddenForSecurity', { defaultValue: 'Invite link is hidden for security. Share the invite through configured email delivery.' })} +

+ )}

{t('users.expires')}: {new Date(inviteResult.expiresAt).toLocaleString()}

diff --git a/frontend/src/pages/__tests__/UsersPage.test.tsx b/frontend/src/pages/__tests__/UsersPage.test.tsx index 1fe5b284..5a6ed98f 100644 --- a/frontend/src/pages/__tests__/UsersPage.test.tsx +++ b/frontend/src/pages/__tests__/UsersPage.test.tsx @@ -216,8 +216,8 @@ describe('UsersPage', () => { uuid: 'new-user', email: 'new@example.com', role: 'user', - invite_token: 'test-token-123', - invite_url: 'https://charon.example.com/accept-invite?token=test-token-123', + invite_token_masked: '********', + invite_url: '[REDACTED]', email_sent: false, expires_at: '2024-01-03T00:00:00Z', }) @@ -319,26 +319,19 @@ describe('UsersPage', () => { }) }) - it('shows manual invite link flow when email is not sent and allows copy', async () => { + it('hides invite link when backend returns a redacted URL', async () => { vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers) vi.mocked(usersApi.inviteUser).mockResolvedValue({ id: 5, uuid: 'invitee', email: 'manual@example.com', role: 'user', - invite_token: 'token-123', - invite_url: 'https://charon.example.com/accept-invite?token=token-123', + invite_token_masked: '********', + invite_url: '[REDACTED]', email_sent: false, expires_at: '2025-01-01T00:00:00Z', }) - const writeText = vi.fn().mockResolvedValue(undefined) - const originalDescriptor = Object.getOwnPropertyDescriptor(navigator, 'clipboard') - Object.defineProperty(navigator, 'clipboard', { - get: () => ({ writeText }), - configurable: true, - }) - renderWithQueryClient() const user = userEvent.setup() @@ -347,127 +340,10 @@ describe('UsersPage', () => { await user.type(screen.getByPlaceholderText('user@example.com'), 'manual@example.com') await user.click(screen.getByRole('button', { name: /^Send Invite$/i })) - await screen.findByDisplayValue(/accept-invite\?token=token-123/) - const copyButton = await screen.findByRole('button', { name: /copy invite link/i }) - - await user.click(copyButton) - await waitFor(() => { - expect(toast.success).toHaveBeenCalledWith('Invite link copied to clipboard') + expect(screen.queryByRole('button', { name: /copy invite link/i })).not.toBeInTheDocument() + expect(screen.queryByDisplayValue('[REDACTED]')).not.toBeInTheDocument() }) - - if (originalDescriptor) { - Object.defineProperty(navigator, 'clipboard', originalDescriptor) - } else { - delete (navigator as unknown as { clipboard?: unknown }).clipboard - } - }) - - it('uses textarea fallback copy when clipboard API fails', async () => { - vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers) - vi.mocked(usersApi.inviteUser).mockResolvedValue({ - id: 6, - uuid: 'invitee-fallback', - email: 'fallback@example.com', - role: 'user', - invite_token: 'token-fallback', - invite_url: 'https://charon.example.com/accept-invite?token=token-fallback', - email_sent: false, - expires_at: '2025-01-01T00:00:00Z', - }) - - const originalDescriptor = Object.getOwnPropertyDescriptor(navigator, 'clipboard') - Object.defineProperty(navigator, 'clipboard', { - get: () => undefined, - configurable: true, - }) - - const appendSpy = vi.spyOn(document.body, 'appendChild') - const removeSpy = vi.spyOn(document.body, 'removeChild') - Object.defineProperty(document, 'execCommand', { - value: vi.fn(), - configurable: true, - writable: true, - }) - - renderWithQueryClient() - - const user = userEvent.setup() - await waitFor(() => expect(screen.getByText('Invite User')).toBeInTheDocument()) - await user.click(screen.getByRole('button', { name: /Invite User/i })) - await user.type(screen.getByPlaceholderText('user@example.com'), 'fallback@example.com') - await user.click(screen.getByRole('button', { name: /^Send Invite$/i })) - - await screen.findByDisplayValue(/accept-invite\?token=token-fallback/) - await user.click(screen.getByRole('button', { name: /copy invite link/i })) - - await waitFor(() => { - expect(appendSpy).toHaveBeenCalled() - expect(toast.success).toHaveBeenCalledWith('Invite link copied to clipboard') - }) - - appendSpy.mockRestore() - removeSpy.mockRestore() - - if (originalDescriptor) { - Object.defineProperty(navigator, 'clipboard', originalDescriptor) - } else { - delete (navigator as unknown as { clipboard?: unknown }).clipboard - } - }) - - it('uses textarea fallback copy when clipboard writeText rejects', async () => { - vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers) - vi.mocked(usersApi.inviteUser).mockResolvedValue({ - id: 7, - uuid: 'invitee-reject', - email: 'reject@example.com', - role: 'user', - invite_token: 'token-reject', - invite_url: 'https://charon.example.com/accept-invite?token=token-reject', - email_sent: false, - expires_at: '2025-01-01T00:00:00Z', - }) - - const writeText = vi.fn().mockRejectedValue(new Error('clipboard denied')) - const originalDescriptor = Object.getOwnPropertyDescriptor(navigator, 'clipboard') - Object.defineProperty(navigator, 'clipboard', { - get: () => ({ writeText }), - configurable: true, - }) - - const appendSpy = vi.spyOn(document.body, 'appendChild') - const removeSpy = vi.spyOn(document.body, 'removeChild') - Object.defineProperty(document, 'execCommand', { - value: vi.fn().mockReturnValue(true), - configurable: true, - writable: true, - }) - - renderWithQueryClient() - - const user = userEvent.setup() - await waitFor(() => expect(screen.getByText('Invite User')).toBeInTheDocument()) - await user.click(screen.getByRole('button', { name: /Invite User/i })) - await user.type(screen.getByPlaceholderText('user@example.com'), 'reject@example.com') - await user.click(screen.getByRole('button', { name: /^Send Invite$/i })) - - await screen.findByDisplayValue(/accept-invite\?token=token-reject/) - await user.click(screen.getByRole('button', { name: /copy invite link/i })) - - await waitFor(() => { - expect(appendSpy).toHaveBeenCalled() - expect(toast.success).toHaveBeenCalledWith('Invite link copied to clipboard') - }) - - appendSpy.mockRestore() - removeSpy.mockRestore() - - if (originalDescriptor) { - Object.defineProperty(navigator, 'clipboard', originalDescriptor) - } else { - delete (navigator as unknown as { clipboard?: unknown }).clipboard - } }) describe('URL Preview in InviteModal', () => {