Spaces:
Sleeping
Sleeping
| import React, { useEffect, useState, useMemo } from 'react' | |
| import { adminApi } from '../../api/client' | |
| import { useTranslation } from '../../i18n' | |
| import { usePermissionsStore, PermissionLevel } from '../../store/permissionsStore' | |
| import { useToast } from '../shared/Toast' | |
| import { Save, Loader2, RotateCcw } from 'lucide-react' | |
| import CustomSelect from '../shared/CustomSelect' | |
| 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(() => { | |
| 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]) | |
| if (loading) { | |
| return ( | |
| <div className="p-8 text-center"> | |
| <div className="w-8 h-8 border-2 border-slate-200 border-t-slate-900 rounded-full animate-spin mx-auto" /> | |
| </div> | |
| ) | |
| } | |
| return ( | |
| <div className="space-y-6"> | |
| <div className="bg-white rounded-xl border border-slate-200 overflow-hidden"> | |
| <div className="px-6 py-4 border-b border-slate-100 flex items-center justify-between"> | |
| <div> | |
| <h2 className="font-semibold text-slate-900">{t('perm.title')}</h2> | |
| <p className="text-xs text-slate-400 mt-0.5">{t('perm.subtitle')}</p> | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| <button | |
| onClick={handleReset} | |
| disabled={saving} | |
| title={t('perm.resetDefaults')} | |
| aria-label={t('perm.resetDefaults')} | |
| className="flex items-center justify-center gap-1.5 px-0 sm:px-3 py-1.5 text-sm w-8 sm:w-auto border border-slate-300 rounded-lg hover:bg-slate-50 disabled:opacity-40 transition-colors" | |
| > | |
| <RotateCcw className="w-3.5 h-3.5" /> | |
| <span className="hidden sm:inline">{t('perm.resetDefaults')}</span> | |
| </button> | |
| <button | |
| onClick={handleSave} | |
| disabled={saving || !dirty} | |
| className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-slate-900 text-white rounded-lg hover:bg-slate-700 disabled:bg-slate-400 transition-colors" | |
| > | |
| {saving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />} | |
| {t('common.save')} | |
| </button> | |
| </div> | |
| </div> | |
| <div className="divide-y divide-slate-100"> | |
| {CATEGORIES.map(cat => ( | |
| <div key={cat.id} className="px-6 py-4"> | |
| <h3 className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-3"> | |
| {t(`perm.cat.${cat.id}`)} | |
| </h3> | |
| <div className="space-y-3"> | |
| {cat.keys.map(key => { | |
| const entry = entryMap.get(key) | |
| if (!entry) return null | |
| const currentLevel = values[key] || entry.defaultLevel | |
| const isDefault = currentLevel === entry.defaultLevel | |
| return ( | |
| <div key={key} className="flex items-center justify-between gap-4"> | |
| <div className="flex-1 min-w-0"> | |
| <p className="text-sm font-medium text-slate-700">{t(`perm.action.${key}`)}</p> | |
| <p className="text-xs text-slate-400 mt-0.5">{t(`perm.actionHint.${key}`)}</p> | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| {!isDefault && ( | |
| <span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-amber-100 text-amber-700"> | |
| {t('perm.customized')} | |
| </span> | |
| )} | |
| <CustomSelect | |
| value={currentLevel} | |
| onChange={(val) => handleChange(key, val as PermissionLevel)} | |
| options={entry.allowedLevels.map(l => ({ | |
| value: l, | |
| label: t(LEVEL_LABELS[l] || l), | |
| }))} | |
| style={{ minWidth: 160 }} | |
| /> | |
| </div> | |
| </div> | |
| ) | |
| })} | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| </div> | |
| ) | |
| } | |