refactor(admin): share the permission levels editor between desktop and phone

Loading, editing, resetting and saving the permission levels now live in one usePermissionsAdmin hook next to the category and level tables. Both panels keep their own markup.
This commit is contained in:
Maurice
2026-10-09 10:03:25 +02:00
parent a52179f685
commit 0de89b86fa
4 changed files with 206 additions and 164 deletions
@@ -1,93 +1,15 @@
import React, { useEffect, useState, useMemo } from 'react'
import { adminApi } from '../../api/client'
import React from 'react'
import { useTranslation } from '../../i18n'
import { usePermissionsStore, PermissionLevel } from '../../store/permissionsStore'
import { useToast } from '../shared/Toast'
import { PermissionLevel } from '../../store/permissionsStore'
import { CATEGORIES, LEVEL_LABELS, usePermissionsAdmin } from './usePermissionsAdmin'
import { Save, Loader2, RotateCcw, ShieldCheck } from 'lucide-react'
import CustomSelect from '../shared/CustomSelect'
import { DialogSection, fs } from '../shared/DialogShell'
import { SETTINGS_BUTTON, SETTINGS_BUTTON_PRIMARY, SettingRow, SettingRows, SettingsCard, StatusPill } from '../Settings/settingsKit'
interface PermissionEntry {
key: string
level: PermissionLevel
defaultLevel: PermissionLevel
allowedLevels: PermissionLevel[]
}
const LEVEL_LABELS: Record<string, string> = {
admin: 'perm.level.admin',
trip_owner: 'perm.level.tripOwner',
trip_member: 'perm.level.tripMember',
everybody: 'perm.level.everybody',
}
const CATEGORIES = [
{ id: 'trip', keys: ['trip_create', 'trip_edit', 'trip_delete', 'trip_archive', 'trip_cover_upload'] },
{ id: 'members', keys: ['member_manage'] },
{ id: 'files', keys: ['file_upload', 'file_edit', 'file_delete'] },
{ id: 'content', keys: ['place_edit', 'day_edit', 'reservation_edit'] },
{ id: 'extras', keys: ['budget_edit', 'packing_edit', 'collab_edit', 'share_manage'] },
]
export default function PermissionsPanel(): React.ReactElement {
const { t } = useTranslation()
const toast = useToast()
const [entries, setEntries] = useState<PermissionEntry[]>([])
const [values, setValues] = useState<Record<string, PermissionLevel>>({})
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [dirty, setDirty] = useState(false)
useEffect(() => {
void loadPermissions()
}, [])
const loadPermissions = async () => {
setLoading(true)
try {
const data = await adminApi.getPermissions()
setEntries(data.permissions)
const vals: Record<string, PermissionLevel> = {}
for (const p of data.permissions) vals[p.key] = p.level
setValues(vals)
setDirty(false)
} catch {
toast.error(t('common.error'))
} finally {
setLoading(false)
}
}
const handleChange = (key: string, level: PermissionLevel) => {
setValues(prev => ({ ...prev, [key]: level }))
setDirty(true)
}
const handleSave = async () => {
setSaving(true)
try {
const data = await adminApi.updatePermissions(values)
if (data.permissions) {
usePermissionsStore.getState().setPermissions(data.permissions)
}
setDirty(false)
toast.success(t('perm.saved'))
} catch {
toast.error(t('common.error'))
} finally {
setSaving(false)
}
}
const handleReset = () => {
const defaults: Record<string, PermissionLevel> = {}
for (const p of entries) defaults[p.key] = p.defaultLevel
setValues(defaults)
setDirty(true)
}
const entryMap = useMemo(() => new Map(entries.map(e => [e.key, e])), [entries])
const { entryMap, values, loading, saving, dirty, handleChange, handleSave, handleReset } = usePermissionsAdmin()
if (loading) {
return (
@@ -0,0 +1,100 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { adminApi } from '../../api/client';
import { usePermissionsStore } from '../../store/permissionsStore';
import { usePermissionsAdmin } from './usePermissionsAdmin';
// FE-HOOK-PERMADMIN-001 to FE-HOOK-PERMADMIN-007
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
vi.mock('../shared/Toast', () => ({ useToast: () => toast }));
vi.mock('../../i18n', () => ({ useTranslation: () => ({ t: (k: string) => k }) }));
const PERMISSIONS = [
{ key: 'trip_create', level: 'admin', defaultLevel: 'everybody', allowedLevels: ['admin', 'everybody'] },
{ key: 'trip_edit', level: 'trip_member', defaultLevel: 'trip_member', allowedLevels: ['trip_owner', 'trip_member'] },
];
beforeEach(() => {
toast.success.mockReset();
toast.error.mockReset();
vi.spyOn(adminApi, 'getPermissions').mockResolvedValue({ permissions: PERMISSIONS });
vi.spyOn(adminApi, 'updatePermissions').mockResolvedValue({ permissions: { trip_create: 'everybody' } });
});
// A store write replaces the state object, carrying a spy on one of its functions into the
// next test, so every test starts from the store as it was loaded.
const initialPermissions = usePermissionsStore.getState();
afterEach(() => {
vi.restoreAllMocks();
usePermissionsStore.setState(initialPermissions, true);
});
async function loaded() {
const hook = renderHook(() => usePermissionsAdmin());
await waitFor(() => expect(hook.result.current.loading).toBe(false));
return hook;
}
describe('usePermissionsAdmin', () => {
it('FE-HOOK-PERMADMIN-001: loads once on mount into values and a lookup by key, clean', async () => {
const { result, rerender } = await loaded();
rerender();
expect(adminApi.getPermissions).toHaveBeenCalledTimes(1);
expect(result.current.values).toEqual({ trip_create: 'admin', trip_edit: 'trip_member' });
expect(result.current.entryMap.get('trip_edit')?.defaultLevel).toBe('trip_member');
expect(result.current.dirty).toBe(false);
});
it('FE-HOOK-PERMADMIN-002: a failed load toasts the generic error and stops loading', async () => {
vi.mocked(adminApi.getPermissions).mockRejectedValue(new Error('down'));
const { result } = await loaded();
expect(toast.error).toHaveBeenCalledWith('common.error');
expect(result.current.entryMap.size).toBe(0);
});
it('FE-HOOK-PERMADMIN-003: a change sets the value and marks the form dirty', async () => {
const { result } = await loaded();
act(() => result.current.handleChange('trip_edit', 'trip_owner'));
expect(result.current.values.trip_edit).toBe('trip_owner');
expect(result.current.dirty).toBe(true);
});
it('FE-HOOK-PERMADMIN-004: reset puts every entry back on its default and stays dirty until saved', async () => {
const { result } = await loaded();
act(() => result.current.handleReset());
expect(result.current.values).toEqual({ trip_create: 'everybody', trip_edit: 'trip_member' });
expect(result.current.dirty).toBe(true);
});
it('FE-HOOK-PERMADMIN-005: save sends the values, updates the permissions store and toasts', async () => {
const setPermissions = vi.spyOn(usePermissionsStore.getState(), 'setPermissions');
const { result } = await loaded();
act(() => result.current.handleChange('trip_create', 'everybody'));
await act(() => result.current.handleSave());
expect(adminApi.updatePermissions).toHaveBeenCalledWith({ trip_create: 'everybody', trip_edit: 'trip_member' });
expect(setPermissions).toHaveBeenCalledWith({ trip_create: 'everybody' });
expect(toast.success).toHaveBeenCalledWith('perm.saved');
expect(result.current.dirty).toBe(false);
expect(result.current.saving).toBe(false);
});
it('FE-HOOK-PERMADMIN-006: a save answer without permissions leaves the store alone', async () => {
vi.mocked(adminApi.updatePermissions).mockResolvedValue({});
const setPermissions = vi.spyOn(usePermissionsStore.getState(), 'setPermissions');
const { result } = await loaded();
await act(() => result.current.handleSave());
expect(setPermissions).not.toHaveBeenCalled();
expect(toast.success).toHaveBeenCalledWith('perm.saved');
});
it('FE-HOOK-PERMADMIN-007: a failed save toasts the error and keeps the form dirty', async () => {
vi.mocked(adminApi.updatePermissions).mockRejectedValue(new Error('nope'));
const { result } = await loaded();
act(() => result.current.handleChange('trip_create', 'everybody'));
await act(() => result.current.handleSave());
expect(toast.error).toHaveBeenCalledWith('common.error');
expect(result.current.dirty).toBe(true);
expect(result.current.saving).toBe(false);
});
});
@@ -0,0 +1,98 @@
import { useEffect, useEffectEvent, useMemo, useState } from 'react';
import { adminApi } from '../../api/client';
import { useTranslation } from '../../i18n';
import { type PermissionLevel, usePermissionsStore } from '../../store/permissionsStore';
import { useToast } from '../shared/Toast';
interface PermissionEntry {
key: string;
level: PermissionLevel;
defaultLevel: PermissionLevel;
allowedLevels: PermissionLevel[];
}
export const LEVEL_LABELS: Record<string, string> = {
admin: 'perm.level.admin',
trip_owner: 'perm.level.tripOwner',
trip_member: 'perm.level.tripMember',
everybody: 'perm.level.everybody',
};
export const CATEGORIES = [
{ id: 'trip', keys: ['trip_create', 'trip_edit', 'trip_delete', 'trip_archive', 'trip_cover_upload'] },
{ id: 'members', keys: ['member_manage'] },
{ id: 'files', keys: ['file_upload', 'file_edit', 'file_delete'] },
{ id: 'content', keys: ['place_edit', 'day_edit', 'reservation_edit'] },
{ id: 'extras', keys: ['budget_edit', 'packing_edit', 'collab_edit', 'share_manage'] },
];
/**
* The permission levels behind both admin shells: loaded once on mount, edited locally
* until saved, and resettable to the server's defaults. A save pushes the result into the
* permissions store so the rest of the app follows straight away.
*/
export function usePermissionsAdmin() {
const { t } = useTranslation();
const toast = useToast();
const [entries, setEntries] = useState<PermissionEntry[]>([]);
const [values, setValues] = useState<Record<string, PermissionLevel>>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false);
const loadPermissions = async () => {
setLoading(true);
try {
const data = await adminApi.getPermissions();
setEntries(data.permissions);
const vals: Record<string, PermissionLevel> = {};
for (const p of data.permissions) vals[p.key] = p.level;
setValues(vals);
setDirty(false);
} catch {
toast.error(t('common.error'));
} finally {
setLoading(false);
}
};
const loadOnMount = useEffectEvent(() => {
void loadPermissions();
});
useEffect(() => {
loadOnMount();
}, []);
const handleChange = (key: string, level: PermissionLevel) => {
setValues((prev) => ({ ...prev, [key]: level }));
setDirty(true);
};
const handleSave = async () => {
setSaving(true);
try {
const data = await adminApi.updatePermissions(values);
if (data.permissions) {
usePermissionsStore.getState().setPermissions(data.permissions);
}
setDirty(false);
toast.success(t('perm.saved'));
} catch {
toast.error(t('common.error'));
} finally {
setSaving(false);
}
};
const handleReset = () => {
const defaults: Record<string, PermissionLevel> = {};
for (const p of entries) defaults[p.key] = p.defaultLevel;
setValues(defaults);
setDirty(true);
};
const entryMap = useMemo(() => new Map(entries.map((e) => [e.key, e])), [entries]);
return { entryMap, values, loading, saving, dirty, handleChange, handleSave, handleReset };
}
@@ -1,91 +1,13 @@
import React, { useEffect, useState, useMemo } from 'react'
import { adminApi } from '../../../api/client'
import React from 'react'
import { useTranslation } from '../../../i18n'
import { usePermissionsStore, PermissionLevel } from '../../../store/permissionsStore'
import { useToast } from '../../../components/shared/Toast'
import { PermissionLevel } from '../../../store/permissionsStore'
import { CATEGORIES, LEVEL_LABELS, usePermissionsAdmin } from '../../../components/Admin/usePermissionsAdmin'
import { Save, RotateCcw } from 'lucide-react'
import { MAdminButton, MAdminCard } from './MAdminUi'
interface PermissionEntry {
key: string
level: PermissionLevel
defaultLevel: PermissionLevel
allowedLevels: PermissionLevel[]
}
const LEVEL_LABELS: Record<string, string> = {
admin: 'perm.level.admin',
trip_owner: 'perm.level.tripOwner',
trip_member: 'perm.level.tripMember',
everybody: 'perm.level.everybody',
}
const CATEGORIES = [
{ id: 'trip', keys: ['trip_create', 'trip_edit', 'trip_delete', 'trip_archive', 'trip_cover_upload'] },
{ id: 'members', keys: ['member_manage'] },
{ id: 'files', keys: ['file_upload', 'file_edit', 'file_delete'] },
{ id: 'content', keys: ['place_edit', 'day_edit', 'reservation_edit'] },
{ id: 'extras', keys: ['budget_edit', 'packing_edit', 'collab_edit', 'share_manage'] },
]
export default function MAdminPermissionsPanel(): React.ReactElement {
const { t } = useTranslation()
const toast = useToast()
const [entries, setEntries] = useState<PermissionEntry[]>([])
const [values, setValues] = useState<Record<string, PermissionLevel>>({})
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [dirty, setDirty] = useState(false)
useEffect(() => {
void loadPermissions()
}, [])
const loadPermissions = async () => {
setLoading(true)
try {
const data = await adminApi.getPermissions()
setEntries(data.permissions)
const vals: Record<string, PermissionLevel> = {}
for (const p of data.permissions) vals[p.key] = p.level
setValues(vals)
setDirty(false)
} catch {
toast.error(t('common.error'))
} finally {
setLoading(false)
}
}
const handleChange = (key: string, level: PermissionLevel) => {
setValues(prev => ({ ...prev, [key]: level }))
setDirty(true)
}
const handleSave = async () => {
setSaving(true)
try {
const data = await adminApi.updatePermissions(values)
if (data.permissions) {
usePermissionsStore.getState().setPermissions(data.permissions)
}
setDirty(false)
toast.success(t('perm.saved'))
} catch {
toast.error(t('common.error'))
} finally {
setSaving(false)
}
}
const handleReset = () => {
const defaults: Record<string, PermissionLevel> = {}
for (const p of entries) defaults[p.key] = p.defaultLevel
setValues(defaults)
setDirty(true)
}
const entryMap = useMemo(() => new Map(entries.map(e => [e.key, e])), [entries])
const { entryMap, values, loading, saving, dirty, handleChange, handleSave, handleReset } = usePermissionsAdmin()
if (loading) {
return (