import { useState, useCallback, useMemo } from 'react'; import { useNavigate, useParams } from 'react-router'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { rbacApi, AccessPolicy, CreatePolicyPayload, UpdatePolicyPayload } from '@/api/rbac'; import { AdminBackButton } from '@/components/admin'; // === Types === interface PolicyConditions { time_range?: { start: string; end: string }; ip_whitelist?: string[]; rate_limit?: number; weekdays?: number[]; } interface PolicyFormData { name: string; description: string; effect: 'allow' | 'deny'; resource: string; actions: string[]; role_id: number | null; priority: number; conditions: PolicyConditions; conditionsEnabled: { time_range: boolean; ip_whitelist: boolean; rate_limit: boolean; weekdays: boolean; }; } const INITIAL_FORM: PolicyFormData = { name: '', description: '', effect: 'allow', resource: '', actions: [], role_id: null, priority: 0, conditions: { time_range: { start: '09:00', end: '18:00' }, ip_whitelist: [], rate_limit: 100, weekdays: [1, 2, 3, 4, 5], }, conditionsEnabled: { time_range: false, ip_whitelist: false, rate_limit: false, weekdays: false, }, }; // === Helpers === function parseConditions(raw: Record): PolicyConditions { const result: PolicyConditions = {}; if (raw.time_range && typeof raw.time_range === 'object') { const tr = raw.time_range as Record; if (typeof tr.start === 'string' && typeof tr.end === 'string') { result.time_range = { start: tr.start, end: tr.end }; } } if (Array.isArray(raw.ip_whitelist)) { result.ip_whitelist = raw.ip_whitelist.filter((ip): ip is string => typeof ip === 'string'); } if (typeof raw.rate_limit === 'number') { result.rate_limit = raw.rate_limit; } if (Array.isArray(raw.weekdays)) { result.weekdays = raw.weekdays.filter((d): d is number => typeof d === 'number'); } return result; } function buildConditionsPayload( conditions: PolicyConditions, enabled: PolicyFormData['conditionsEnabled'], ): Record { const result: Record = {}; if (enabled.time_range && conditions.time_range) { result.time_range = conditions.time_range; } if (enabled.ip_whitelist && conditions.ip_whitelist && conditions.ip_whitelist.length > 0) { result.ip_whitelist = conditions.ip_whitelist; } if (enabled.rate_limit && conditions.rate_limit !== undefined) { result.rate_limit = conditions.rate_limit; } if (enabled.weekdays && conditions.weekdays && conditions.weekdays.length > 0) { result.weekdays = conditions.weekdays; } return result; } // === Sub-components === interface IpTagInputProps { values: string[]; onChange: (values: string[]) => void; } function IpTagInput({ values, onChange }: IpTagInputProps) { const { t } = useTranslation(); const [inputValue, setInputValue] = useState(''); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); const trimmed = inputValue.trim().replace(/,+$/, ''); if (trimmed && !values.includes(trimmed)) { onChange([...values, trimmed]); } setInputValue(''); } else if (e.key === 'Backspace' && !inputValue && values.length > 0) { onChange(values.slice(0, -1)); } }, [inputValue, values, onChange], ); const removeIp = useCallback( (ip: string) => { onChange(values.filter((v) => v !== ip)); }, [values, onChange], ); return (
{values.map((ip) => ( {ip} ))} setInputValue(e.target.value)} onKeyDown={handleKeyDown} className="min-w-[120px] flex-1 bg-transparent text-sm text-dark-100 placeholder-dark-500 outline-none" placeholder={values.length === 0 ? t('admin.policies.conditions.ipPlaceholder') : ''} />
); } interface ConditionToggleProps { label: string; enabled: boolean; onToggle: () => void; children: React.ReactNode; } function ConditionToggle({ label, enabled, onToggle, children }: ConditionToggleProps) { return (
{enabled &&
{children}
}
); } // === Main Page === export default function AdminPolicyEdit() { const { t } = useTranslation(); const navigate = useNavigate(); const { id } = useParams<{ id: string }>(); const queryClient = useQueryClient(); const isEdit = !!id; const [formData, setFormData] = useState(INITIAL_FORM); const [formError, setFormError] = useState(null); // Fetch policy for editing const { isLoading: isLoadingPolicy } = useQuery({ queryKey: ['admin-policy', id], queryFn: () => rbacApi.getPolicies(), enabled: isEdit, select: useCallback( (policies: AccessPolicy[]) => { const policy = policies.find((p) => p.id === Number(id)); if (policy) { const parsed = parseConditions(policy.conditions); const actions = Array.isArray(policy.actions) ? policy.actions : []; setFormData({ name: policy.name, description: policy.description ?? '', effect: policy.effect, resource: policy.resource, actions, role_id: policy.role_id ?? null, priority: policy.priority, conditions: { time_range: parsed.time_range ?? { start: '09:00', end: '18:00' }, ip_whitelist: parsed.ip_whitelist ?? [], rate_limit: parsed.rate_limit ?? 100, weekdays: parsed.weekdays ?? [1, 2, 3, 4, 5], }, conditionsEnabled: { time_range: !!parsed.time_range, ip_whitelist: !!parsed.ip_whitelist && parsed.ip_whitelist.length > 0, rate_limit: parsed.rate_limit !== undefined, weekdays: !!parsed.weekdays && parsed.weekdays.length > 0, }, }); } return policy; }, [id], ), }); const { data: roles } = useQuery({ queryKey: ['admin-roles'], queryFn: rbacApi.getRoles, }); const { data: permissionRegistry } = useQuery({ queryKey: ['admin-permission-registry'], queryFn: rbacApi.getPermissionRegistry, }); // Mutations const createMutation = useMutation({ mutationFn: (payload: CreatePolicyPayload) => rbacApi.createPolicy(payload), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin-policies'] }); navigate('/admin/policies'); }, onError: () => { setFormError(t('admin.policies.errors.createFailed')); }, }); const updateMutation = useMutation({ mutationFn: ({ policyId, payload }: { policyId: number; payload: UpdatePolicyPayload }) => rbacApi.updatePolicy(policyId, payload), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin-policies'] }); navigate('/admin/policies'); }, onError: () => { setFormError(t('admin.policies.errors.updateFailed')); }, }); // Derived data const selectedResourceActions = useMemo(() => { if (!formData.resource || !permissionRegistry) return []; const section = permissionRegistry.find((s) => s.section === formData.resource); return section?.actions ?? []; }, [formData.resource, permissionRegistry]); // Handlers const handleToggleAction = useCallback((action: string) => { setFormData((prev) => { const has = prev.actions.includes(action); return { ...prev, actions: has ? prev.actions.filter((a) => a !== action) : [...prev.actions, action], }; }); }, []); const handleResourceChange = useCallback((resource: string) => { setFormData((prev) => ({ ...prev, resource, actions: [], })); }, []); const handleSubmit = useCallback( (e: React.FormEvent) => { e.preventDefault(); setFormError(null); if (!formData.name.trim()) { setFormError(t('admin.policies.errors.nameRequired')); return; } if (!formData.resource) { setFormError(t('admin.policies.errors.resourceRequired')); return; } if (formData.actions.length === 0) { setFormError(t('admin.policies.errors.actionsRequired')); return; } const conditionsPayload = buildConditionsPayload( formData.conditions, formData.conditionsEnabled, ); if (isEdit) { const payload: UpdatePolicyPayload = { name: formData.name.trim(), description: formData.description.trim() || null, effect: formData.effect, resource: formData.resource, actions: formData.actions, conditions: conditionsPayload, priority: formData.priority, role_id: formData.role_id, }; updateMutation.mutate({ policyId: Number(id), payload }); } else { const payload: CreatePolicyPayload = { name: formData.name.trim(), description: formData.description.trim() || null, effect: formData.effect, resource: formData.resource, actions: formData.actions, conditions: conditionsPayload, priority: formData.priority, role_id: formData.role_id, }; createMutation.mutate(payload); } }, [formData, isEdit, id, createMutation, updateMutation, t], ); const isSaving = createMutation.isPending || updateMutation.isPending; // Loading state if (isEdit && isLoadingPolicy) { return (
); } return (
{/* Header */}

{isEdit ? t('admin.policies.modal.editTitle') : t('admin.policies.modal.createTitle')}

{/* Form */}
{/* Basic info */}
{/* Name */}
setFormData((prev) => ({ ...prev, name: e.target.value }))} className="w-full rounded-lg border border-dark-600 bg-dark-900 px-3 py-2 text-dark-100 placeholder-dark-500 outline-none transition-colors focus:border-accent-500" placeholder={t('admin.policies.form.namePlaceholder')} autoFocus />
{/* Description */}