From 0de89b86fa1182e711af403baec35778fd7b0292 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 9 Oct 2026 10:03:25 +0200 Subject: [PATCH] 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. --- .../src/components/Admin/PermissionsPanel.tsx | 86 +-------------- .../Admin/usePermissionsAdmin.test.ts | 100 ++++++++++++++++++ .../components/Admin/usePermissionsAdmin.ts | 98 +++++++++++++++++ .../screens/admin/MAdminPermissionsPanel.tsx | 86 +-------------- 4 files changed, 206 insertions(+), 164 deletions(-) create mode 100644 client/src/components/Admin/usePermissionsAdmin.test.ts create mode 100644 client/src/components/Admin/usePermissionsAdmin.ts diff --git a/client/src/components/Admin/PermissionsPanel.tsx b/client/src/components/Admin/PermissionsPanel.tsx index adabb3dd0..105bf5873 100644 --- a/client/src/components/Admin/PermissionsPanel.tsx +++ b/client/src/components/Admin/PermissionsPanel.tsx @@ -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 = { - 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([]) - const [values, setValues] = useState>({}) - 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 = {} - 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 = {} - 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 ( diff --git a/client/src/components/Admin/usePermissionsAdmin.test.ts b/client/src/components/Admin/usePermissionsAdmin.test.ts new file mode 100644 index 000000000..8820eda86 --- /dev/null +++ b/client/src/components/Admin/usePermissionsAdmin.test.ts @@ -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); + }); +}); diff --git a/client/src/components/Admin/usePermissionsAdmin.ts b/client/src/components/Admin/usePermissionsAdmin.ts new file mode 100644 index 000000000..c8920a5eb --- /dev/null +++ b/client/src/components/Admin/usePermissionsAdmin.ts @@ -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 = { + 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([]); + const [values, setValues] = useState>({}); + 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 = {}; + 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 = {}; + 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 }; +} diff --git a/client/src/mobile/screens/admin/MAdminPermissionsPanel.tsx b/client/src/mobile/screens/admin/MAdminPermissionsPanel.tsx index 4a6a98299..7a85a3222 100644 --- a/client/src/mobile/screens/admin/MAdminPermissionsPanel.tsx +++ b/client/src/mobile/screens/admin/MAdminPermissionsPanel.tsx @@ -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 = { - 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([]) - const [values, setValues] = useState>({}) - 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 = {} - 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 = {} - 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 (