@@ -678,10 +986,14 @@ export default function UsersPage() {
className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
user.role === 'admin'
? 'bg-purple-900/30 text-purple-400'
- : 'bg-blue-900/30 text-blue-400'
+ : user.role === 'passthrough'
+ ? 'bg-gray-900/30 text-gray-400'
+ : 'bg-blue-900/30 text-blue-400'
}`}
>
- {user.role}
+ {user.role === 'admin' && t('users.roleAdmin')}
+ {user.role === 'user' && t('users.roleUser')}
+ {user.role === 'passthrough' && t('users.rolePassthrough')}
|
@@ -721,6 +1033,14 @@ export default function UsersPage() {
|
+
{user.invite_status === 'pending' && (
)
}
diff --git a/frontend/src/pages/__tests__/Settings.test.tsx b/frontend/src/pages/__tests__/Settings.test.tsx
index 947235fe..86b6d176 100644
--- a/frontend/src/pages/__tests__/Settings.test.tsx
+++ b/frontend/src/pages/__tests__/Settings.test.tsx
@@ -10,7 +10,7 @@ const translations: Record = {
'settings.system': 'System',
'navigation.notifications': 'Notifications',
'settings.smtp': 'Email (SMTP)',
- 'settings.account': 'Account',
+ 'navigation.users': 'Users',
}
const t = (key: string) => translations[key] ?? key
@@ -19,6 +19,10 @@ vi.mock('react-i18next', () => ({
useTranslation: () => ({ t }),
}))
+vi.mock('../../hooks/useAuth', () => ({
+ useAuth: () => ({ user: { user_id: 1, role: 'admin', name: 'Admin' } }),
+}))
+
const renderWithRoute = (route: string) =>
render(
@@ -27,7 +31,7 @@ const renderWithRoute = (route: string) =>
System Page} />
Notifications Page} />
SMTP Page} />
- Account Page} />
+ Users Page} />
@@ -46,12 +50,12 @@ describe('Settings page', () => {
expect(screen.getByText('System Page')).toBeInTheDocument()
})
- it('keeps navigation order consistent', () => {
+ it('keeps navigation order consistent for admin', () => {
renderWithRoute('/settings/notifications')
const links = screen.getAllByRole('link')
const labels = links.map(link => link.textContent)
- expect(labels).toEqual(['System', 'Notifications', 'Email (SMTP)', 'Account'])
+ expect(labels).toEqual(['System', 'Notifications', 'Email (SMTP)', 'Users'])
})
})
diff --git a/frontend/src/pages/__tests__/UsersPage.test.tsx b/frontend/src/pages/__tests__/UsersPage.test.tsx
index 5a6ed98f..aca11ad5 100644
--- a/frontend/src/pages/__tests__/UsersPage.test.tsx
+++ b/frontend/src/pages/__tests__/UsersPage.test.tsx
@@ -22,6 +22,20 @@ vi.mock('../../api/users', () => ({
acceptInvite: vi.fn(),
previewInviteURL: vi.fn(),
resendInvite: vi.fn(),
+ getProfile: vi.fn(),
+ updateProfile: vi.fn(),
+ regenerateApiKey: vi.fn(),
+}))
+
+vi.mock('../../hooks/useAuth', () => ({
+ useAuth: vi.fn().mockReturnValue({
+ user: { user_id: 1, role: 'admin', name: 'Admin User', email: 'admin@example.com' },
+ changePassword: vi.fn().mockResolvedValue(undefined),
+ isAuthenticated: true,
+ isLoading: false,
+ login: vi.fn(),
+ logout: vi.fn(),
+ }),
}))
vi.mock('../../api/proxyHosts', () => ({
@@ -78,6 +92,18 @@ const mockUsers = [
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
},
+ {
+ id: 4,
+ uuid: '999-000',
+ email: 'passthrough@example.com',
+ name: 'Passthrough User',
+ role: 'passthrough' as const,
+ enabled: true,
+ invite_status: 'accepted' as const,
+ permission_mode: 'allow_all' as const,
+ created_at: '2024-01-01T00:00:00Z',
+ updated_at: '2024-01-01T00:00:00Z',
+ },
]
const mockProxyHosts = [
@@ -127,8 +153,8 @@ describe('UsersPage', () => {
expect(screen.getByText('User Management')).toBeTruthy()
})
- expect(screen.getByText('Admin User')).toBeTruthy()
- expect(screen.getByText('admin@example.com')).toBeTruthy()
+ expect(screen.getAllByText('Admin User').length).toBeGreaterThan(0)
+ expect(screen.getAllByText('admin@example.com').length).toBeGreaterThan(0)
expect(screen.getByText('Regular User')).toBeTruthy()
expect(screen.getByText('user@example.com')).toBeTruthy()
})
@@ -346,6 +372,58 @@ describe('UsersPage', () => {
})
})
+ it('renders passthrough role badge', async () => {
+ vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
+
+ renderWithQueryClient()
+
+ await waitFor(() => {
+ expect(screen.getByText('Passthrough')).toBeTruthy()
+ })
+ })
+
+ it('renders My Profile card for current user', async () => {
+ vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
+
+ renderWithQueryClient()
+
+ await waitFor(() => {
+ expect(screen.getByText('My Profile')).toBeTruthy()
+ })
+ })
+
+ it('shows passthrough option in invite role select', async () => {
+ vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
+
+ renderWithQueryClient()
+
+ const user = userEvent.setup()
+ await waitFor(() => expect(screen.getByText('Invite User')).toBeTruthy())
+ await user.click(screen.getByRole('button', { name: /Invite User/i }))
+
+ await waitFor(() => {
+ const roleSelect = screen.getByLabelText('Role') as HTMLSelectElement
+ const options = Array.from(roleSelect.options).map(o => o.value)
+ expect(options).toContain('passthrough')
+ })
+ })
+
+ it('opens detail modal when edit button is clicked', async () => {
+ vi.mocked(usersApi.listUsers).mockResolvedValue(mockUsers)
+
+ renderWithQueryClient()
+
+ await waitFor(() => expect(screen.getByText('Regular User')).toBeTruthy())
+
+ const user = userEvent.setup()
+ const editButtons = screen.getAllByTitle('Edit User')
+ await user.click(editButtons[1])
+
+ await waitFor(() => {
+ expect(screen.getByRole('dialog', { name: /Edit User/i })).toBeTruthy()
+ })
+ })
+
describe('URL Preview in InviteModal', () => {
afterEach(() => {
vi.useRealTimers()
|