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 (