'use client' import * as React from 'react' import { z } from 'zod' import { useT } from '@open-mercato/shared/lib/i18n/context' import { OrganizationSelect } from '@open-mercato/core/modules/directory/components/OrganizationSelect' import { TenantSelect } from '@open-mercato/core/modules/directory/components/TenantSelect' import { CrudForm, type CrudCustomFieldRenderProps, type CrudField } from '@open-mercato/ui/backend/CrudForm' import { Button } from '@open-mercato/ui/primitives/button' import { Checkbox } from '@open-mercato/ui/primitives/checkbox' export type EnforcementScope = 'platform' | 'tenant' | 'organisation' export type EnforcementPolicyFormValue = { id?: string scope: EnforcementScope tenantId: string organizationId: string isEnforced: boolean allowedMethods: string[] enforcementDeadline: string } type EnforcementPolicyFormProps = { value?: Partial | null submitting: boolean onSubmit: (value: EnforcementPolicyFormValue) => Promise | void onCancel: () => void backHref: string } const METHOD_OPTIONS = ['totp', 'passkey', 'otp_email'] as const const METHOD_OPTION_VALUES = [...METHOD_OPTIONS] as [typeof METHOD_OPTIONS[number], ...typeof METHOD_OPTIONS[number][]] const METHOD_DESCRIPTION_KEYS: Record = { totp: { key: 'security.admin.enforcement.methods.totpDescription', fallback: 'Authenticator app code (TOTP).', }, passkey: { key: 'security.admin.enforcement.methods.passkeyDescription', fallback: 'Biometric or security key passkey.', }, otp_email: { key: 'security.admin.enforcement.methods.otpEmailDescription', fallback: 'One-time verification code sent by email.', }, } function toDatetimeLocal(value: string | null | undefined): string { if (!value) return '' const parsed = new Date(value) if (Number.isNaN(parsed.getTime())) return '' const year = parsed.getFullYear() const month = String(parsed.getMonth() + 1).padStart(2, '0') const day = String(parsed.getDate()).padStart(2, '0') const hours = String(parsed.getHours()).padStart(2, '0') const minutes = String(parsed.getMinutes()).padStart(2, '0') return `${year}-${month}-${day}T${hours}:${minutes}` } function ScopeTenantField({ value, setValue, values, disabled }: CrudCustomFieldRenderProps) { const t = useT() const scope = values?.scope if (scope !== 'tenant' && scope !== 'organisation') return null const normalizedValue = typeof value === 'string' && value.trim().length > 0 ? value : null return ( setValue(next ?? '')} disabled={disabled} includeEmptyOption emptyOptionLabel={t('security.admin.enforcement.form.tenantPlaceholder', 'Select tenant')} className="h-9 w-full rounded-md border bg-background px-3 text-sm" /> ) } function ScopeOrganizationField({ value, setValue, values, disabled }: CrudCustomFieldRenderProps) { const t = useT() const scope = values?.scope const organizationValue = typeof value === 'string' && value.trim().length > 0 ? value : null const tenantId = typeof values?.tenantId === 'string' && values?.tenantId?.trim().length > 0 ? values?.tenantId : null const previousTenantRef = React.useRef(tenantId) const hydratedRef = React.useRef(false) React.useEffect(() => { if (!hydratedRef.current) { hydratedRef.current = true previousTenantRef.current = tenantId return } if (previousTenantRef.current !== tenantId) { previousTenantRef.current = tenantId setValue('') } }, [setValue, tenantId]) if (scope !== 'organisation') return null return ( setValue(next ?? '')} disabled={disabled || !tenantId} includeEmptyOption emptyOptionLabel={t('security.admin.enforcement.form.organizationPlaceholder', 'Select organization')} tenantId={tenantId} className="h-9 w-full rounded-md border bg-background px-3 text-sm" /> ) } function AllowedMethodsField({ value, setValue, disabled }: CrudCustomFieldRenderProps) { const t = useT() const selectedMethods = React.useMemo(() => { if (!Array.isArray(value)) return [] return value .filter((item): item is typeof METHOD_OPTIONS[number] => typeof item === 'string' && METHOD_OPTIONS.includes(item as typeof METHOD_OPTIONS[number])) }, [value]) const [mode, setMode] = React.useState<'all' | 'restricted'>(() => (selectedMethods.length > 0 ? 'restricted' : 'all')) React.useEffect(() => { setMode(selectedMethods.length > 0 ? 'restricted' : 'all') }, [selectedMethods.length]) const setAllMethods = React.useCallback(() => { if (disabled) return setMode('all') setValue([]) }, [disabled, setValue]) const setRestrictedMethods = React.useCallback(() => { if (disabled) return setMode('restricted') if (selectedMethods.length === 0) { setValue([METHOD_OPTIONS[0]]) } }, [disabled, selectedMethods.length, setValue]) const toggleMethod = React.useCallback((method: typeof METHOD_OPTIONS[number], checked: boolean) => { if (disabled) return if (!checked) { if (selectedMethods.length <= 1) return setValue(selectedMethods.filter((item) => item !== method)) return } if (selectedMethods.includes(method)) return setValue([...selectedMethods, method]) }, [disabled, selectedMethods, setValue]) return (
{mode === 'restricted' ? (
{METHOD_OPTIONS.map((method) => { const isChecked = selectedMethods.includes(method) const isLastSelected = isChecked && selectedMethods.length === 1 return ( ) })}
) : (

{t( 'security.admin.enforcement.form.allowedMethodsHint', 'If no methods are selected, all available methods are allowed.', )}

)}
) } export default function EnforcementPolicyForm({ value, backHref, submitting = false, onSubmit, onCancel, }: EnforcementPolicyFormProps) { const t = useT() const formId = 'security-enforcement-policy-form' const initialValues = React.useMemo( () => ({ id: value?.id, scope: value?.scope === 'tenant' || value?.scope === 'organisation' ? value.scope : 'platform', tenantId: value?.tenantId ?? '', organizationId: value?.organizationId ?? '', isEnforced: value?.isEnforced ?? true, enforcementDeadline: toDatetimeLocal(value?.enforcementDeadline), allowedMethods: Array.isArray(value?.allowedMethods) ? value.allowedMethods : [], }), [value], ) const [selectedScope, setSelectedScope] = React.useState(initialValues.scope) React.useEffect(() => { setSelectedScope(initialValues.scope) }, [initialValues.scope]) const schema = React.useMemo( () => z .object({ scope: z.enum(['platform', 'tenant', 'organisation']), tenantId: z.string().optional().default(''), organizationId: z.string().optional().default(''), isEnforced: z.boolean().default(true), allowedMethods: z.array(z.enum(METHOD_OPTION_VALUES)).default([]), enforcementDeadline: z.string().optional().default(''), }) .superRefine((values, context) => { const tenantId = values.tenantId.trim() const organizationId = values.organizationId.trim() if (values.scope !== 'platform' && !tenantId) { context.addIssue({ code: z.ZodIssueCode.custom, message: t( 'security.admin.enforcement.form.errors.tenantRequired', 'Tenant ID is required for tenant and organisation scopes.', ), path: ['tenantId'], }) } if (values.scope === 'organisation' && !organizationId) { context.addIssue({ code: z.ZodIssueCode.custom, message: t( 'security.admin.enforcement.form.errors.organizationRequired', 'Organization ID is required for organisation scope.', ), path: ['organizationId'], }) } }), [t], ) type EnforcementPolicyCrudValues = z.infer const fields = React.useMemo( () => { const baseFields: CrudField[] = [ { id: 'scope', label: t('security.admin.enforcement.form.scope', 'Scope'), type: 'custom', required: true, disabled: submitting, layout: 'half', component: ({ value, setValue, disabled, autoFocus }) => { const resolvedScope = value === 'tenant' || value === 'organisation' ? value : 'platform' return ( ) }, }, { id: 'enforcementDeadline', label: t('security.admin.enforcement.form.deadline', 'Enforcement deadline'), type: 'datetime-local', disabled: submitting, layout: 'half', }, ] if (selectedScope !== 'platform') { baseFields.push({ id: 'tenantId', label: t('security.admin.enforcement.form.tenant', 'Tenant'), type: 'custom', component: ScopeTenantField, disabled: submitting, }) } if (selectedScope === 'organisation') { baseFields.push({ id: 'organizationId', label: t('security.admin.enforcement.form.organization', 'Organization'), type: 'custom', component: ScopeOrganizationField, disabled: submitting, }) } baseFields.push( { id: 'isEnforced', label: t('security.admin.enforcement.form.enabled', 'Policy enforced'), type: 'checkbox', disabled: submitting, }, { id: 'allowedMethods', label: t('security.admin.enforcement.form.allowedMethods', 'Allowed methods'), type: 'custom', component: AllowedMethodsField, disabled: submitting, }, ) return baseFields }, [selectedScope, submitting, t], ) const handleSubmit = React.useCallback( async (values: EnforcementPolicyCrudValues) => { const tenantId = values.tenantId.trim() const organizationId = values.organizationId.trim() await onSubmit({ id: value?.id, scope: values.scope as EnforcementScope, tenantId, organizationId, isEnforced: values.isEnforced, enforcementDeadline: values.enforcementDeadline, allowedMethods: values.allowedMethods, }) }, [onSubmit, value?.id], ) return ( title={ value?.id ? t('security.admin.enforcement.form.titleEdit', 'Edit enforcement policy') : t('security.admin.enforcement.form.titleCreate', 'Create enforcement policy') } titleHeadingLevel={1} backHref={backHref} schema={schema} formId={formId} fields={fields} initialValues={initialValues as Partial} hideFooterActions embedded={false} onSubmit={handleSubmit} extraActions={( )} /> ) }