Public Access
feat(admin): add the admin users page and view as
This commit is contained in:
1 parent
46587dfb5c
commit
afe00ceae9
14 files changed
+649
-8
No files matched your search
@@ -1,13 +1,18 @@
|
||||
import {Link, NavLink, useLocation} from 'react-router-dom';
|
||||
import {Link, NavLink, useLocation, useNavigate} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {ChartColumn, Dices, Languages, LayoutDashboard, Library, LogOut, Moon, Settings, Sun} from 'lucide-react';
|
||||
import {ChartColumn, Dices, Eye, Languages, LayoutDashboard, Library, LogOut, Moon, Settings, Shield, Sun} from 'lucide-react';
|
||||
import {toast} from 'sonner';
|
||||
|
||||
import {Badge} from '@/components/ui/badge';
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {usePreferences} from '@/context/PreferencesContext.jsx';
|
||||
@@ -15,7 +20,11 @@ import {cn} from '@/lib/utils';
|
||||
import Logo from './Logo.jsx';
|
||||
import PageContainer from './PageContainer.jsx';
|
||||
import SteamAvatar from './SteamAvatar.jsx';
|
||||
import useCurrentUser from '../hooks/useCurrentUser.js';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import useSteamProfile from '../hooks/useSteamProfile.js';
|
||||
import {ROLES, viewAs} from '../utils/admin.js';
|
||||
import {markSession} from '../utils/auth.js';
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{to: '/dashboard', labelKey: 'NAV.DASHBOARD', Icon: LayoutDashboard},
|
||||
@@ -37,6 +46,30 @@ export default function AppHeader({isAuthenticated, onLogout}) {
|
||||
const {isDarkMode, setDarkMode, setLanguage} = usePreferences();
|
||||
const {pathname} = useLocation();
|
||||
const steamProfile = useSteamProfile(isAuthenticated);
|
||||
const currentUser = useCurrentUser(isAuthenticated);
|
||||
const navigate = useNavigate();
|
||||
const expireSession = useSessionExpired();
|
||||
const isAdmin = currentUser?.role === 'PLATFORM_ADMIN';
|
||||
const viewingAs = currentUser && currentUser.activeRole !== currentUser.role ? currentUser.activeRole : null;
|
||||
|
||||
const switchRole = async (role) => {
|
||||
try {
|
||||
const response = await viewAs(role === currentUser.role ? null : role);
|
||||
if (response.ok) {
|
||||
// New cookies with the other role; the new stamp makes the header and pages load again.
|
||||
markSession();
|
||||
navigate('/dashboard');
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('View as error:', err);
|
||||
}
|
||||
toast.error(t('NAV.VIEW_AS.ERROR'));
|
||||
};
|
||||
|
||||
const toggleLanguage = () => setLanguage(i18n.language === 'de' ? 'en' : 'de');
|
||||
const toggleTheme = () => setDarkMode(!isDarkMode);
|
||||
@@ -64,6 +97,9 @@ export default function AppHeader({isAuthenticated, onLogout}) {
|
||||
)}
|
||||
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
{isAuthenticated && viewingAs && (
|
||||
<Badge variant="secondary" className="mr-1">{t('NAV.VIEW_AS.BADGE', {role: t(`ROLES.${viewingAs}`)})}</Badge>
|
||||
)}
|
||||
{isAuthenticated ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -75,6 +111,24 @@ export default function AppHeader({isAuthenticated, onLogout}) {
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/settings"><Settings/>{t('NAV.SETTINGS')}</Link>
|
||||
</DropdownMenuItem>
|
||||
{isAdmin && !viewingAs && (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/admin/users"><Shield/>{t('NAV.ADMIN')}</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger><Eye/>{t('NAV.VIEW_AS.MENU')}</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
{ROLES.map((role) => (
|
||||
<DropdownMenuItem key={role} disabled={role === currentUser.activeRole}
|
||||
onSelect={() => switchRole(role)}>
|
||||
{t(`ROLES.${role}`)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)}
|
||||
<DropdownMenuItem onSelect={toggleLanguage}>
|
||||
<Languages/>{t('NAV.LANGUAGE')}: {i18n.language.toUpperCase()}
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -1,13 +1,30 @@
|
||||
// @vitest-environment jsdom
|
||||
import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||
import {cleanup, render, screen} from '@testing-library/react';
|
||||
import {cleanup, fireEvent, render, screen} from '@testing-library/react';
|
||||
import {MemoryRouter} from 'react-router-dom';
|
||||
import AppHeader from './AppHeader.jsx';
|
||||
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
||||
|
||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||
|
||||
afterEach(cleanup);
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
const loggedInAs = (me) => {
|
||||
localStorage.setItem('tbp_session', '1');
|
||||
globalThis.fetch = vi.fn(async (url) => ({
|
||||
ok: true, status: 200, json: async () => (url.includes('/api/users/me') ? me : {}),
|
||||
}));
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<PreferencesProvider><AppHeader isAuthenticated onLogout={() => {}}/></PreferencesProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
};
|
||||
|
||||
const openMenu = () => fireEvent.keyDown(screen.getByRole('button', {name: 'NAV.MENU'}), {key: 'Enter'});
|
||||
|
||||
describe('AppHeader', () => {
|
||||
// jsdom applies no media queries, so the test hides the name the way the 380px breakpoint does.
|
||||
@@ -21,4 +38,36 @@ describe('AppHeader', () => {
|
||||
|
||||
expect(screen.getByRole('link', {name: 'ToBePlayed'})).toBeTruthy();
|
||||
});
|
||||
|
||||
it('offers the admin page and "view as" to an admin', async () => {
|
||||
loggedInAs({role: 'PLATFORM_ADMIN', activeRole: 'PLATFORM_ADMIN'});
|
||||
await screen.findByRole('button', {name: 'NAV.MENU'});
|
||||
await vi.waitFor(() => expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/api/users/me'), expect.anything()));
|
||||
|
||||
openMenu();
|
||||
|
||||
expect(await screen.findByText('NAV.ADMIN')).toBeTruthy();
|
||||
expect(screen.getByText('NAV.VIEW_AS.MENU')).toBeTruthy();
|
||||
expect(screen.queryByText('NAV.VIEW_AS.BADGE')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows the badge and hides the admin page while an admin views as a user', async () => {
|
||||
loggedInAs({role: 'PLATFORM_ADMIN', activeRole: 'USER'});
|
||||
|
||||
expect(await screen.findByText('NAV.VIEW_AS.BADGE')).toBeTruthy();
|
||||
openMenu();
|
||||
expect(await screen.findByText('NAV.VIEW_AS.MENU')).toBeTruthy();
|
||||
expect(screen.queryByText('NAV.ADMIN')).toBeNull();
|
||||
});
|
||||
|
||||
it('offers nothing of that to a user', async () => {
|
||||
loggedInAs({role: 'USER', activeRole: 'USER'});
|
||||
await vi.waitFor(() => expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/api/users/me'), expect.anything()));
|
||||
|
||||
openMenu();
|
||||
|
||||
expect(await screen.findByText('NAV.SETTINGS')).toBeTruthy();
|
||||
expect(screen.queryByText('NAV.ADMIN')).toBeNull();
|
||||
expect(screen.queryByText('NAV.VIEW_AS.MENU')).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user