fix: Implement user API enhancements with masked API keys and updated invite link handling

This commit is contained in:
GitHub Actions
2026-02-25 06:14:03 +00:00
parent c156183666
commit 690480e181
8 changed files with 119 additions and 167 deletions

View File

@@ -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"

View File

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

View File

@@ -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', () => {