diff --git a/web/public/locales/en/config/validation.json b/web/public/locales/en/config/validation.json index af0599733..2824f6147 100644 --- a/web/public/locales/en/config/validation.json +++ b/web/public/locales/en/config/validation.json @@ -17,5 +17,6 @@ "additionalProperties": "Unknown property is not allowed", "oneOf": "Must match exactly one of the allowed schemas", "anyOf": "Must match at least one of the allowed schemas", + "proxy.header_map.roleHeaderRequired": "Role header is required when role mappings are configured.", "ffmpeg.inputs.rolesUnique": "Each role can only be assigned to one input stream." } diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index f80e12d83..30f8edc6b 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -1218,6 +1218,13 @@ "timezone": { "defaultOption": "Use browser timezone" }, + "roleMap": { + "empty": "No role mappings", + "roleLabel": "Role", + "groupsLabel": "Groups", + "addMapping": "Add role mapping", + "remove": "Remove" + }, "ffmpegArgs": { "preset": "Preset", "manual": "Manual arguments", diff --git a/web/src/components/config-form/section-configs/proxy.ts b/web/src/components/config-form/section-configs/proxy.ts index 8b245cf52..323dd9845 100644 --- a/web/src/components/config-form/section-configs/proxy.ts +++ b/web/src/components/config-form/section-configs/proxy.ts @@ -13,6 +13,14 @@ const proxy: SectionConfigOverrides = { ], advancedFields: ["header_map", "auth_secret", "separator"], liveValidate: true, + uiSchema: { + header_map: { + "ui:after": { render: "ProxyRoleMap" }, + }, + "header_map.role_map": { + "ui:widget": "hidden", + }, + }, }, }; diff --git a/web/src/components/config-form/section-validations/index.ts b/web/src/components/config-form/section-validations/index.ts index 1be445648..31a02a1d1 100644 --- a/web/src/components/config-form/section-validations/index.ts +++ b/web/src/components/config-form/section-validations/index.ts @@ -1,6 +1,7 @@ import type { FormValidation } from "@rjsf/utils"; import type { TFunction } from "i18next"; import { validateFfmpegInputRoles } from "./ffmpeg"; +import { validateProxyRoleHeader } from "./proxy"; export type SectionValidation = ( formData: unknown, @@ -22,5 +23,9 @@ export function getSectionValidation({ return (formData, errors) => validateFfmpegInputRoles(formData, errors, t); } + if (sectionPath === "proxy" && level === "global") { + return (formData, errors) => validateProxyRoleHeader(formData, errors, t); + } + return undefined; } diff --git a/web/src/components/config-form/section-validations/proxy.ts b/web/src/components/config-form/section-validations/proxy.ts new file mode 100644 index 000000000..340e17222 --- /dev/null +++ b/web/src/components/config-form/section-validations/proxy.ts @@ -0,0 +1,37 @@ +import type { FormValidation } from "@rjsf/utils"; +import type { TFunction } from "i18next"; +import { isJsonObject } from "@/lib/utils"; +import type { JsonObject } from "@/types/configForm"; + +export function validateProxyRoleHeader( + formData: unknown, + errors: FormValidation, + t: TFunction, +): FormValidation { + if (!isJsonObject(formData as JsonObject)) { + return errors; + } + + const headerMap = (formData as JsonObject).header_map; + if (!isJsonObject(headerMap)) { + return errors; + } + + const roleHeader = headerMap.role; + const roleHeaderDefined = + typeof roleHeader === "string" && roleHeader.trim().length > 0; + const roleMap = headerMap.role_map; + const roleMapHasEntries = + isJsonObject(roleMap) && Object.keys(roleMap).length > 0; + + if (roleMapHasEntries && !roleHeaderDefined) { + const headerMapErrors = errors.header_map as { + role?: { addError?: (message: string) => void }; + }; + headerMapErrors?.role?.addError?.( + t("proxy.header_map.roleHeaderRequired", { ns: "config/validation" }), + ); + } + + return errors; +} diff --git a/web/src/components/config-form/sectionExtras/ProxyRoleMap.tsx b/web/src/components/config-form/sectionExtras/ProxyRoleMap.tsx new file mode 100644 index 000000000..2846b0500 --- /dev/null +++ b/web/src/components/config-form/sectionExtras/ProxyRoleMap.tsx @@ -0,0 +1,201 @@ +import { useMemo } from "react"; +import type { ComponentType } from "react"; +import { useTranslation } from "react-i18next"; +import cloneDeep from "lodash/cloneDeep"; +import get from "lodash/get"; +import set from "lodash/set"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { LuPlus, LuTrash2 } from "react-icons/lu"; +import { TagsWidget } from "@/components/config-form/theme/widgets/TagsWidget"; +import { isJsonObject } from "@/lib/utils"; +import type { ConfigSectionData, JsonObject } from "@/types/configForm"; +import type { SectionRendererProps } from "./registry"; + +const EMPTY_FORM_DATA: JsonObject = {}; +const RoleMapTags = TagsWidget as unknown as ComponentType<{ + id: string; + value: string[]; + onChange: (value: unknown) => void; + schema: { title: string }; +}>; + +export default function ProxyRoleMap({ formContext }: SectionRendererProps) { + const { t } = useTranslation(["views/settings", "config/global"]); + + const fullFormData = + (formContext?.formData as JsonObject | undefined) ?? EMPTY_FORM_DATA; + const onFormDataChange = formContext?.onFormDataChange; + + const roleHeader = get(fullFormData, "header_map.role"); + const hasRoleHeader = + typeof roleHeader === "string" && roleHeader.trim().length > 0; + + const roleMap = useMemo(() => { + const roleMapValue = get(fullFormData, "header_map.role_map"); + return isJsonObject(roleMapValue) + ? (roleMapValue as Record) + : {}; + }, [fullFormData]); + + const roleOptions = useMemo(() => { + const rolesFromConfig = formContext?.fullConfig?.auth?.roles + ? Object.keys(formContext.fullConfig.auth.roles) + : []; + const roles = + rolesFromConfig.length > 0 ? rolesFromConfig : ["admin", "viewer"]; + + return Array.from(new Set([...roles, ...Object.keys(roleMap)])).sort(); + }, [formContext?.fullConfig, roleMap]); + + if (!onFormDataChange || !formContext?.formData) { + return null; + } + + if (!hasRoleHeader) { + return null; + } + + const usedRoles = new Set(Object.keys(roleMap)); + const nextRole = roleOptions.find((role) => !usedRoles.has(role)); + + const updateRoleMap = (nextRoleMap: Record) => { + const nextFormData = cloneDeep(fullFormData) as JsonObject; + set(nextFormData, "header_map.role_map", nextRoleMap); + onFormDataChange(nextFormData as ConfigSectionData); + }; + + const handleAdd = () => { + if (!nextRole) return; + updateRoleMap({ + ...roleMap, + [nextRole]: [], + }); + }; + + const handleRemove = (role: string) => { + const next = { ...roleMap }; + delete next[role]; + updateRoleMap(next); + }; + + const handleRoleChange = (currentRole: string, newRole: string) => { + if (currentRole === newRole) return; + const next = { ...roleMap } as Record; + const groups = next[currentRole] ?? []; + delete next[currentRole]; + next[newRole] = groups; + updateRoleMap(next); + }; + + const handleGroupsChange = (role: string, groups: unknown) => { + updateRoleMap({ + ...roleMap, + [role]: Array.isArray(groups) ? groups : [], + }); + }; + + const roleMapLabel = t("proxy.header_map.role_map.label", { + ns: "config/global", + }); + const roleMapDescription = t("proxy.header_map.role_map.description", { + ns: "config/global", + }); + + return ( +
+
+ +

{roleMapDescription}

+
+ + {Object.keys(roleMap).length === 0 && ( +

+ {t("configForm.roleMap.empty", { ns: "views/settings" })} +

+ )} + + {Object.entries(roleMap).map(([role, groups], index) => { + const rowId = `role-map-${role}-${index}`; + const roleLabel = t("configForm.roleMap.roleLabel", { + ns: "views/settings", + }); + const groupsLabel = t("configForm.roleMap.groupsLabel", { + ns: "views/settings", + }); + const normalizedGroups = Array.isArray(groups) ? groups : []; + + return ( +
+
+ + +
+ +
+ + handleGroupsChange(role, next)} + schema={{ title: groupsLabel }} + /> +
+ +
+ +
+
+ ); + })} + + +
+ ); +} diff --git a/web/src/components/config-form/sectionExtras/registry.ts b/web/src/components/config-form/sectionExtras/registry.ts index 6057cd066..f37e97b3b 100644 --- a/web/src/components/config-form/sectionExtras/registry.ts +++ b/web/src/components/config-form/sectionExtras/registry.ts @@ -1,6 +1,7 @@ import type { ComponentType } from "react"; import SemanticSearchReindex from "./SemanticSearchReindex.tsx"; import CameraReviewStatusToggles from "./CameraReviewStatusToggles"; +import ProxyRoleMap from "./ProxyRoleMap"; import type { ConfigFormContext } from "@/types/configForm"; // Props that will be injected into all section renderers @@ -44,6 +45,9 @@ export const sectionRenderers: SectionRenderers = { review: { CameraReviewStatusToggles, }, + proxy: { + ProxyRoleMap, + }, }; export default sectionRenderers;