fix: Implement user API enhancements with masked API keys and updated invite link handling
This commit is contained in:
69
frontend/src/api/__tests__/user.test.ts
Normal file
69
frontend/src/api/__tests__/user.test.ts
Normal file
@@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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' })
|
||||
|
||||
|
||||
@@ -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<UserProfile> => {
|
||||
* @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<RegenerateApiKeyResponse> => {
|
||||
const response = await client.post<RegenerateApiKeyResponse>('/user/api-key')
|
||||
return response.data
|
||||
}
|
||||
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-6">
|
||||
@@ -444,13 +437,10 @@ export default function Account() {
|
||||
<CardContent>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={profile?.api_key || ''}
|
||||
value={profile?.api_key_masked || ''}
|
||||
readOnly
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<Button type="button" variant="secondary" onClick={copyApiKey} title={t('account.copyToClipboard')}>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
|
||||
@@ -53,11 +53,15 @@ function InviteModal({ isOpen, onClose, proxyHosts }: InviteModalProps) {
|
||||
const [permissionMode, setPermissionMode] = useState<PermissionMode>('allow_all')
|
||||
const [selectedHosts, setSelectedHosts] = useState<number[]>([])
|
||||
const [inviteResult, setInviteResult] = useState<{
|
||||
token: string
|
||||
inviteUrl: string
|
||||
emailSent: boolean
|
||||
expiresAt: string
|
||||
} | null>(null)
|
||||
const hasUsableInviteUrl = (inviteUrl?: string): inviteUrl is string => {
|
||||
const normalized = (inviteUrl ?? '').trim()
|
||||
return normalized.length > 0 && normalized !== '[REDACTED]'
|
||||
}
|
||||
|
||||
const [urlPreview, setUrlPreview] = useState<{
|
||||
preview_url: string
|
||||
base_url: string
|
||||
@@ -125,8 +129,7 @@ function InviteModal({ isOpen, onClose, proxyHosts }: InviteModalProps) {
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] })
|
||||
setInviteResult({
|
||||
token: data.invite_token,
|
||||
inviteUrl: data.invite_url,
|
||||
inviteUrl: data.invite_url ?? '',
|
||||
emailSent: data.email_sent,
|
||||
expiresAt: data.expires_at,
|
||||
})
|
||||
@@ -143,8 +146,8 @@ function InviteModal({ isOpen, onClose, proxyHosts }: InviteModalProps) {
|
||||
})
|
||||
|
||||
const copyInviteLink = async () => {
|
||||
if (inviteResult?.token) {
|
||||
const link = inviteResult.inviteUrl || `${window.location.origin}/accept-invite?token=${inviteResult.token}`
|
||||
if (inviteResult?.inviteUrl && hasUsableInviteUrl(inviteResult.inviteUrl)) {
|
||||
const link = inviteResult.inviteUrl
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(link)
|
||||
@@ -231,17 +234,23 @@ function InviteModal({ isOpen, onClose, proxyHosts }: InviteModalProps) {
|
||||
<label className="block text-sm font-medium text-gray-300">
|
||||
{t('users.inviteLink')}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
value={inviteResult.inviteUrl || `${window.location.origin}/accept-invite?token=${inviteResult.token}`}
|
||||
readOnly
|
||||
className="flex-1 text-sm"
|
||||
/>
|
||||
<Button onClick={copyInviteLink} aria-label={t('users.copyInviteLink')} title={t('users.copyInviteLink')}>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{hasUsableInviteUrl(inviteResult.inviteUrl) ? (
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
value={inviteResult.inviteUrl}
|
||||
readOnly
|
||||
className="flex-1 text-sm"
|
||||
/>
|
||||
<Button onClick={copyInviteLink} aria-label={t('users.copyInviteLink')} title={t('users.copyInviteLink')}>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-300">
|
||||
{t('users.inviteLinkHiddenForSecurity', { defaultValue: 'Invite link is hidden for security. Share the invite through configured email delivery.' })}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-500">
|
||||
{t('users.expires')}: {new Date(inviteResult.expiresAt).toLocaleString()}
|
||||
</p>
|
||||
|
||||
@@ -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(<UsersPage />)
|
||||
|
||||
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(<UsersPage />)
|
||||
|
||||
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(<UsersPage />)
|
||||
|
||||
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', () => {
|
||||
|
||||
Reference in New Issue
Block a user