= (props) => {
}
footer={
{
- children: © {new Date().getFullYear()} {import.meta.env.VITE_COMPANY}
+ children: © {new Date().getFullYear()} {import.meta.env.VITE_COMPANY}
}
}
>{children}
@@ -147,7 +147,9 @@ const AppMap: AppMapType[] = [
enum ApiRoutes {
- ACCOUNTS = 'GET|/accounts',
+
+ // Accounts
+ ACCOUNTS_GET = 'GET|/accounts',
ACCOUNT_POST = 'POST|/account',
ACCOUNT_GET = 'GET|/account/{accountId}',
@@ -160,14 +162,23 @@ enum ApiRoutes {
// ACCOUNT_ID_HOSTNAMES = 'GET|/account/{accountId}/hostnames',
// ACCOUNT_ID_HOSTNAME_ID = 'GET|/account/{accountId}/hostname/{index}',
- // Agents
- AGENT_TEST = 'GET|/agent/test',
+
// Certs flow
CERTS_FLOW_CONFIGURE_CLIENT = 'POST|/certs/configure-client',
CERTS_FLOW_TERMS_OF_SERVICE = 'GET|/certs/{sessionId}/terms-of-service',
CERTS_FLOW_CERTIFICATES_APPLY = 'POST|/certs/{accountId}/certificates/apply',
+ // Caches
+ FULL_CACHE_DOWNLOAD_GET = 'GET|/cache/download',
+ FULL_CACHE_UPLOAD_POST = 'POST|/cache/upload',
+ FULL_CACHE_DELETE = 'DELETE|/cache',
+
+ CACHE_DOWNLOAD_GET = 'GET|/cache/{accountId}/download/',
+ CACHE_UPLOAD_POST = 'POST|/cache/{accountId}/upload/',
+
+ // Agents
+ AGENT_TEST = 'GET|/agent/test',
// Secrets
generateSecret = 'GET|/secret/generatesecret',
diff --git a/src/MaksIT.WebUI/src/axiosConfig.ts b/src/MaksIT.WebUI/src/axiosConfig.ts
index 12fc327..9339e34 100644
--- a/src/MaksIT.WebUI/src/axiosConfig.ts
+++ b/src/MaksIT.WebUI/src/axiosConfig.ts
@@ -5,10 +5,8 @@ import { store } from './redux/store'
import { refreshJwt } from './redux/slices/identitySlice'
import { hideLoader, showLoader } from './redux/slices/loaderSlice'
import { addToast } from './components/Toast/addToast'
-import { de } from 'zod/v4/locales'
-import { deepPatternMatch } from './functions'
-import { ProblemDetails, ProblemDetailsProto } from './models/ProblemDetails'
-import { add } from 'lodash'
+import { ProblemDetails } from './models/ProblemDetails'
+
// Create an Axios instance
const axiosInstance = axios.create({
@@ -99,6 +97,7 @@ axiosInstance.interceptors.response.use(
* Performs a GET request and returns the response data.
* @param url The endpoint URL.
* @param timeout Optional timeout in milliseconds to override the default.
+ * @returns The response data, or undefined if an error occurs.
*/
const getData = async (url: string, timeout?: number): Promise => {
try {
@@ -120,6 +119,7 @@ const getData = async (url: string, timeout?: number): Promise(url: string, data?: TRequest, timeout?: number): Promise => {
try {
@@ -142,6 +142,7 @@ const postData = async (url: string, data?: TRequest, timeo
* @param url The endpoint URL.
* @param data The request payload.
* @param timeout Optional timeout in milliseconds to override the default.
+ * @returns The response data, or undefined if an error occurs.
*/
const patchData = async (url: string, data: TRequest, timeout?: number): Promise => {
try {
@@ -163,6 +164,7 @@ const patchData = async (url: string, data: TRequest, timeo
* @param url The endpoint URL.
* @param data The request payload.
* @param timeout Optional timeout in milliseconds to override the default.
+ * @returns The response data, or undefined if an error occurs.
*/
const putData = async (url: string, data: TRequest, timeout?: number): Promise => {
try {
@@ -183,6 +185,7 @@ const putData = async (url: string, data: TRequest, timeout
* Performs a DELETE request and returns the response data.
* @param url The endpoint URL.
* @param timeout Optional timeout in milliseconds to override the default.
+ * @returns The response data, or undefined if an error occurs.
*/
const deleteData = async (url: string, timeout?: number): Promise => {
try {
@@ -199,11 +202,141 @@ const deleteData = async (url: string, timeout?: number): Promise(
+ url: string,
+ data: Blob | ArrayBuffer | Uint8Array,
+ timeout?: number
+): Promise => {
+ try {
+ const response = await axiosInstance.post(url, data, {
+ headers: {
+ 'Content-Type': 'application/octet-stream'
+ },
+ ...(timeout ? { timeout } : {})
+ })
+ return response.data
+ } catch {
+ // Error is already handled by interceptors, so just return undefined
+ return undefined
+ }
+}
+
+/**
+ * Performs a GET request to retrieve binary data (e.g., file download).
+ * @param url The endpoint URL.
+ * @param timeout Optional timeout in milliseconds to override the default.
+ * @param as The format to retrieve the binary data as ('arraybuffer' or 'blob').
+ * @returns The binary data and headers, or undefined if an error occurs.
+ */
+const getBinary = async (
+ url: string,
+ timeout?: number,
+ as: 'arraybuffer' | 'blob' = 'arraybuffer'
+): Promise<{ data: ArrayBuffer | Blob, headers: Record } | undefined> => {
+ try {
+ const response = await axiosInstance.get(url, {
+ responseType: as,
+ ...(timeout ? { timeout } : {})
+ })
+
+ return {
+ data: response.data,
+ headers: response.headers as Record
+ }
+ } catch {
+ // Error is already handled by interceptors, so just return undefined
+ return undefined
+ }
+}
+
+/**
+ * Performs a POST request using multipart/form-data.
+ * Accepts either a ready FormData or a record of fields to be converted into FormData.
+ * Note: Do NOT set the Content-Type header manually; the browser will include the boundary.
+ * @param url The endpoint URL.
+ * @param form The FormData instance or a record of fields.
+ * Values can be string | Blob | File | (string | Blob | File)[]
+ * @param timeout Optional timeout in milliseconds to override the default.
+ * @returns The response data, or undefined if an error occurs.
+ */
+const postFormData = async (
+ url: string,
+ form: FormData | Record,
+ timeout?: number
+): Promise => {
+ try {
+ const formData =
+ form instanceof FormData
+ ? form
+ : (() => {
+ const fd = new FormData()
+ Object.entries(form).forEach(([key, value]) => {
+ if (Array.isArray(value)) {
+ value.forEach(v => fd.append(key, v))
+ } else {
+ fd.append(key, value)
+ }
+ })
+ return fd
+ })()
+
+ const response = await axiosInstance.post(url, formData, {
+ // Do NOT set Content-Type; the browser will set the correct multipart boundary
+ ...(timeout ? { timeout } : {})
+ })
+
+ return response.data
+ } catch {
+ // Error is already handled by interceptors, so just return undefined
+ return undefined
+ }
+}
+
+/**
+ * Convenience helper for uploading a single file via multipart/form-data.
+ * @param url The endpoint URL.
+ * @param file The file/blob to upload.
+ * @param fieldName The form field name for the file (default: "file").
+ * @param filename Optional filename; if omitted and "file" is a File, the File.name is used.
+ * @param extraFields Optional extra key/value fields to include in the form.
+ * @param timeout Optional timeout in milliseconds to override the default.
+ * @returns The response data, or undefined if an error occurs.
+ */
+const postFile = async (
+ url: string,
+ file: Blob | File,
+ fieldName: string = 'file',
+ filename?: string,
+ extraFields?: Record,
+ timeout?: number
+): Promise => {
+ const fd = new FormData()
+ const inferredName = filename ?? (file instanceof File ? file.name : 'file')
+ fd.append(fieldName, file, inferredName)
+
+ if (extraFields) {
+ Object.entries(extraFields).forEach(([k, v]) => fd.append(k, v))
+ }
+
+ return postFormData(url, fd, timeout)
+}
+
export {
axiosInstance,
getData,
postData,
patchData,
putData,
- deleteData
+ deleteData,
+ postBinary,
+ getBinary,
+ postFormData,
+ postFile
}
\ No newline at end of file
diff --git a/src/MaksIT.WebUI/src/forms/EditAccount.tsx b/src/MaksIT.WebUI/src/forms/EditAccount.tsx
index 6f3046e..2f5df37 100644
--- a/src/MaksIT.WebUI/src/forms/EditAccount.tsx
+++ b/src/MaksIT.WebUI/src/forms/EditAccount.tsx
@@ -24,8 +24,8 @@ const EditAccountHostnameFormProto = (): EditAccountHostnameFormProps => ({
})
const EditAccountHostnameFormSchema: Schema = object({
- hostname: string(),
- isDisabled: boolean()
+ isDisabled: boolean(),
+ hostname: string()
})
interface EditAccountFormProps {
@@ -95,7 +95,7 @@ const EditAccount: FC = (props) => {
...RegisterFormProto(),
isDisabled: response.isDisabled,
description: response.description,
- contacts: response.contacts,
+ contacts: [...response.contacts],
hostnames: (response.hostnames ?? []).map(h => ({
...EditAccountHostnameFormProto(),
isDisabled: h.isDisabled,
@@ -124,9 +124,10 @@ const EditAccount: FC = (props) => {
const patchRequest: PatchAccountRequest = {
isDisabled: formStateCopy.isDisabled,
description: formStateCopy.description,
- contacts: formStateCopy.contacts,
+ contacts: [...formStateCopy.contacts],
hostnames: formStateCopy.hostnames.map(h => ({
- hostname: h.hostname
+ hostname: h.hostname,
+ isDisabled: h.isDisabled
}))
}
@@ -139,7 +140,11 @@ const EditAccount: FC = (props) => {
const fromFormState = mapFormStateToPatchRequest(formState)
const fromBackupState = mapFormStateToPatchRequest(backupState)
- const delta = deepDelta(fromBackupState, fromFormState)
+ const delta = deepDelta(fromFormState, fromBackupState, {
+ arrays: {
+ hostnames: { identityKey: 'hostname' }
+ }
+ })
if (!deltaHasOperations(delta)) {
addToast('No changes detected', 'info')
@@ -147,6 +152,7 @@ const EditAccount: FC = (props) => {
}
const request = PatchAccountRequestSchema.safeParse(delta)
+
if (!request.success) {
request.error.issues.forEach(error => {
addToast(error.message, 'error')
@@ -156,7 +162,7 @@ const EditAccount: FC = (props) => {
}
patchData(GetApiRoute(ApiRoutes.ACCOUNT_PATCH).route
- .replace('{accountId}', accountId), delta
+ .replace('{accountId}', accountId), delta, 120000
).then((response) => {
if (!response) return
@@ -215,9 +221,6 @@ const EditAccount: FC = (props) => {
label={'New Contact'}
value={formState.contact}
onChange={(e) => {
- if (formState.contacts.includes(e.target.value))
- return
-
handleInputChange('contact', e.target.value)
}}
placeholder={'Add contact'}
@@ -227,6 +230,9 @@ const EditAccount: FC = (props) => {
{
+ if (formState.contacts.includes(formState.contact))
+ return
+
handleInputChange('contacts', [...formState.contacts, formState.contact])
handleInputChange('contact', '')
}}
@@ -238,12 +244,31 @@ const EditAccount: FC = (props) => {
Hostnames:
{formState.hostnames.map((hostname) => (
- -
- {hostname.hostname}
+
-
+ {hostname.hostname}
+
+
+ {
+ const updatedHostnames = formState.hostnames.map(h => {
+ if (h.hostname === hostname.hostname) {
+ return {
+ ...h,
+ isDisabled: e.target.checked
+ }
+ }
+ return h
+ })
+ handleInputChange('hostnames', updatedHostnames)
+ }}
+ />
+
{
- const updatedHostnames = formState.hostnames.filter(h => h !== hostname)
+ const updatedHostnames = formState.hostnames.filter(h => h.hostname !== hostname.hostname)
handleInputChange('hostnames', updatedHostnames)
}}
>
@@ -258,9 +283,6 @@ const EditAccount: FC = (props) => {
label={'New Hostname'}
value={formState.hostname}
onChange={(e) => {
- if (formState.hostnames.find(h => h.hostname === e.target.value))
- return
-
handleInputChange('hostname', e.target.value)
}}
placeholder={'Add hostname'}
@@ -270,7 +292,15 @@ const EditAccount: FC = (props) => {
{
- handleInputChange('hostnames', [...formState.hostnames, formState.hostname])
+ if (formState.hostnames.find(h => h.hostname === formState.hostname))
+ return
+
+ handleInputChange('hostnames', [ ...formState.hostnames, {
+ ...EditAccountHostnameFormProto(),
+ hostname: formState.hostname
+ }
+ ])
+
handleInputChange('hostname', '')
}}
disabled={formState.hostname.trim() === ''}
diff --git a/src/MaksIT.WebUI/src/forms/Home.tsx b/src/MaksIT.WebUI/src/forms/Home.tsx
index 47f7e3f..068c281 100644
--- a/src/MaksIT.WebUI/src/forms/Home.tsx
+++ b/src/MaksIT.WebUI/src/forms/Home.tsx
@@ -16,7 +16,7 @@ const Home: FC = () => {
const [accountId, setAccountId] = useState(undefined)
const loadData = useCallback(() => {
- getData(GetApiRoute(ApiRoutes.ACCOUNTS).route).then((response) => {
+ getData(GetApiRoute(ApiRoutes.ACCOUNTS_GET).route).then((response) => {
if (!response) return
setRawd(response)
})
diff --git a/src/MaksIT.WebUI/src/forms/Utilities.tsx b/src/MaksIT.WebUI/src/forms/Utilities.tsx
index 497d531..00d9927 100644
--- a/src/MaksIT.WebUI/src/forms/Utilities.tsx
+++ b/src/MaksIT.WebUI/src/forms/Utilities.tsx
@@ -1,9 +1,12 @@
import { FC, useState } from 'react'
import { FormContainer, FormContent, FormFooter, FormHeader } from '../components/FormLayout'
-import { ButtonComponent, DateTimePickerComponent, FileUploadComponent } from '../components/editors'
+import { ButtonComponent, FileUploadComponent } from '../components/editors'
import { ApiRoutes, GetApiRoute } from '../AppMap'
-import { getData } from '../axiosConfig'
+import { deleteData, getBinary, getData, postFile } from '../axiosConfig'
import { addToast } from '../components/Toast/addToast'
+import { extractFilenameFromHeaders, saveBinaryToDisk } from '../functions'
+import { downloadZip } from 'client-zip'
+
const Utilities: FC = () => {
@@ -18,6 +21,40 @@ const Utilities: FC = () => {
})
}
+ const handleUploadFiles = async () => {
+ if (files.length === 0) {
+ addToast('No files selected for upload', 'error')
+ return
+ }
+
+ const zipBlob = await downloadZip(files).blob()
+ // Option A: direct file helper
+ postFile(GetApiRoute(ApiRoutes.FULL_CACHE_UPLOAD_POST).route, zipBlob, 'file', 'cache.zip')
+ .then((_) => {
+ setFiles([])
+ addToast('Files uploaded successfully', 'success')
+ })
+ }
+
+ const handleDownloadFiles = () => {
+ getBinary(GetApiRoute(ApiRoutes.FULL_CACHE_DOWNLOAD_GET).route
+ ).then((response) => {
+ if (!response) return
+
+ const { data, headers } = response
+ const filename = extractFilenameFromHeaders(headers, 'cache.zip')
+ saveBinaryToDisk(data, filename)
+ })
+ }
+
+ const handleDestroyFiles = () => {
+ deleteData(GetApiRoute(ApiRoutes.FULL_CACHE_DELETE).route)
+ .then((_) => {
+ addToast('Cache files destroyed successfully', 'success')
+ })
+ }
+
+
return
Utilities
@@ -29,25 +66,37 @@ const Utilities: FC = () => {
onClick={hadnleTestAgent}
/>
+
+
+
+
+
+
+
{}}
+ onClick={handleDownloadFiles}
/>
{}}
+ onClick={handleDestroyFiles}
/>
diff --git a/src/MaksIT.WebUI/src/functions/deep/deepDelta.ts b/src/MaksIT.WebUI/src/functions/deep/deepDelta.ts
index 7cc1fbb..2ead8d1 100644
--- a/src/MaksIT.WebUI/src/functions/deep/deepDelta.ts
+++ b/src/MaksIT.WebUI/src/functions/deep/deepDelta.ts
@@ -1,4 +1,4 @@
-import { PatchOperation } from '../../models/PatchOperation'
+import { PatchOperation } from '../../models/PatchOperation.js'
import { deepCopy } from './deepCopy.js'
import { deepEqual } from './deepEqual.js'
@@ -18,37 +18,71 @@ type PlainObject = Record
type DeltaArrayItem = Partial & EnsureId & OperationBag
-/** Policy non-generica: chiavi sempre stringhe */
+/**
+ * Policy that controls how object arrays behave.
+ *
+ * - Arrays with identifiable items (id or identityKey) get per-item Add/Remove/Update logic.
+ * - Arrays without identity fall back to "full replace" semantics.
+ */
export type ArrayPolicy = {
- /** Nome del campo “radice” che implica re-parenting (es. 'organizationId') */
+ /** Name of the "root" field that implies re-parenting (e.g. 'organizationId') */
rootKey?: string
- /** Nomi degli array figli da trattare in caso di re-parenting (es. ['applicationRoles']) */
+
+ /** Child array field names to process on re-parenting (e.g. ['applicationRoles']) */
childArrayKeys?: string[]
- /** Se true, in re-parenting i figli vengono azzerati (default TRUE) */
+
+ /** If true, children are cleared on root change (default TRUE) */
dropChildrenOnRootChange?: boolean
- /** Nome del campo ruolo (default 'role') */
+
+ /** Name of the role field (default 'role') */
roleFieldKey?: string
- /** Se true, quando role diventa null si rimuove l’intero item (default TRUE) */
+
+ /** If true, when role becomes null the entire item is removed (default TRUE) */
deleteItemWhenRoleRemoved?: boolean
+
+ /**
+ * Stable identity for items that do not have an `id`.
+ * Can be:
+ * - a property name (e.g. "hostname")
+ * - a function that extracts a unique value
+ *
+ * Without identityKey AND without item.id, the array falls back to full replace.
+ */
+ identityKey?: string | ((item: Record) => string | number)
}
export type DeepDeltaOptions = {
- /** Policy per i campi array del payload (mappati per nome chiave) */
+ /**
+ * Optional per-array rules.
+ * Example:
+ * {
+ * hostnames: { identityKey: "hostname" }
+ * }
+ */
arrays?: Partial, ArrayPolicy>>
}
+/**
+ * Delta represents:
+ * - T fields that changed (primitives, objects, arrays)
+ * - "operations" dictionary describing what type of change (SetField, RemoveField, AddToCollection, etc.)
+ * - For primitive arrays: delta contains the full new array + SetField.
+ * - For identifiable object arrays: delta contains per-item changes.
+ */
export type Delta =
Partial<{
[K in keyof T]:
T[K] extends (infer U)[]
- ? DeltaArrayItem<(U & Identifiable)>[]
+ ? (U extends object
+ ? DeltaArrayItem<(U & Identifiable)>[] // object arrays → itemized
+ : U[]) // primitive arrays → full array
: T[K] extends object
? Delta>>
: T[K]
}> & OperationBag>
-/** Safe index per evitare TS2536 quando si indicizza su chiavi dinamiche */
-const getArrayPolicy = (options: DeepDeltaOptions | undefined, key: string): ArrayPolicy | undefined =>{
+/** Safe index to avoid TS2536 when addressing dynamic keys */
+const getArrayPolicy = (options: DeepDeltaOptions | undefined, key: string): ArrayPolicy | undefined => {
const arrays = options?.arrays as Partial> | undefined
return arrays?.[key]
}
@@ -56,6 +90,16 @@ const getArrayPolicy = (options: DeepDeltaOptions | undefined, key: string
const isPlainObject = (value: unknown): value is PlainObject =>
typeof value === 'object' && value !== null && !Array.isArray(value)
+/**
+ * Computes a deep "delta" object between formState and backupState.
+ *
+ * Rules:
+ * - Primitive fields → SetField / RemoveField
+ * - Primitive arrays → full replace (SetField)
+ * - Object arrays:
+ * * if items have id or identityKey → itemized collection diff
+ * * otherwise → full replace (SetField)
+ */
export const deepDelta = >(
formState: T,
backupState: T,
@@ -63,11 +107,20 @@ export const deepDelta = >(
): Delta => {
const delta = {} as Delta
+ // Sets an operation flag into the provided bag for a given key
const setOp = (bag: OperationBag, key: string, op: PatchOperation) => {
const ops = (bag.operations ??= {} as Record)
ops[key] = op
}
+ /**
+ * Recursive object diffing.
+ *
+ * Handles:
+ * - primitives
+ * - nested objects
+ * - arrays (delegates to array logic)
+ */
const calculateDelta = (
form: PlainObject,
backup: PlainObject,
@@ -82,18 +135,59 @@ export const deepDelta = >(
// --- ARRAY ---
if (Array.isArray(formValue) && Array.isArray(backupValue)) {
+ const bothPrimitive =
+ (formValue as unknown[]).every(v => typeof v !== 'object' || v === null) &&
+ (backupValue as unknown[]).every(v => typeof v !== 'object' || v === null)
+
+ /**
+ * Detect primitive arrays (string[], number[], primitive unions).
+ * Primitive arrays have no identity → always full replace.
+ */
+ if (bothPrimitive) {
+ if (!deepEqual(formValue, backupValue)) {
+ ;(parentDelta as Delta)[key] = deepCopy(formValue) as unknown as Delta[typeof key]
+ setOp(parentDelta, key, PatchOperation.SetField)
+ }
+ continue
+ }
+
+ // Object collections
const policy = getArrayPolicy(options, key)
+
+ /**
+ * If items have neither `id` nor `identityKey`, they cannot be diffed.
+ * => treat array as a scalar and replace entirely.
+ */
+ const lacksIdentity =
+ !(policy?.identityKey) &&
+ (formValue as Identifiable[]).every(x => (x?.id ?? null) == null) &&
+ (backupValue as Identifiable[]).every(x => (x?.id ?? null) == null)
+
+ if (lacksIdentity) {
+ if (!deepEqual(formValue, backupValue)) {
+ ;(parentDelta as Delta)[key] = deepCopy(formValue) as unknown as Delta[typeof key]
+ setOp(parentDelta, key, PatchOperation.SetField)
+ }
+ continue
+ }
+
+ /**
+ * Identifiable arrays => itemized delta with Add/Remove/Update
+ */
const arrayDelta = calculateArrayDelta(
formValue as Identifiable[],
backupValue as Identifiable[],
policy
)
+
if (arrayDelta.length > 0) {
;(parentDelta as Delta)[key] = arrayDelta as unknown as Delta[typeof key]
}
+
continue
}
+
// --- OBJECT ---
if (isPlainObject(formValue) && isPlainObject(backupValue)) {
if (!deepEqual(formValue, backupValue)) {
@@ -118,6 +212,16 @@ export const deepDelta = >(
}
}
+ /**
+ * Computes itemized delta for identifiable object arrays.
+ *
+ * Handles:
+ * - Add: item without id or identity
+ * - Remove: item missing in formArray
+ * - Update: fields changed inside item
+ * - Re-parenting: rootKey changed
+ * - Role: if policy.deleteItemWhenRoleRemoved is true
+ */
const calculateArrayDelta = (
formArray: U[],
backupArray: U[],
@@ -125,7 +229,28 @@ export const deepDelta = >(
): DeltaArrayItem[] => {
const arrayDelta: DeltaArrayItem[] = []
- const getId = (item?: U): IdLike => (item ? item.id ?? null : null)
+ /**
+ * Identity resolution order:
+ * 1. If item has `.id` → use it.
+ * 2. Else if identityKey is provided → use that to extract a unique key.
+ * 3. Else: return null → item will be treated as “new”.
+ */
+ const resolveId = (item?: U): IdLike => {
+ if (!item) return null
+ const directId = (item as Identifiable).id
+ if (directId !== null && directId !== undefined) return directId
+ if (!policy?.identityKey) return null
+
+ if (typeof policy.identityKey === 'function') {
+ try { return policy.identityKey(item as unknown as Record) }
+ catch { return null }
+ }
+
+ const k = policy.identityKey as string
+ const v = (item as unknown as Record)[k]
+ return (typeof v === 'string' || typeof v === 'number') ? v : null
+ }
+
const childrenKeys = policy?.childArrayKeys ?? []
const dropChildren = policy?.dropChildrenOnRootChange ?? true
const roleKey = (policy?.roleFieldKey ?? 'role') as keyof U & string
@@ -136,29 +261,29 @@ export const deepDelta = >(
return (f as PlainObject)[rootKey] === (b as PlainObject)[rootKey]
}
- // Mappe id → item per lookup veloce
+ // id → item maps for O(1) lookups
const formMap = new Map()
const backupMap = new Map()
for (const item of formArray) {
- const id = getId(item)
+ const id = resolveId(item)
if (id !== null && id !== undefined) formMap.set(id as string | number, item)
}
for (const item of backupArray) {
- const id = getId(item)
+ const id = resolveId(item)
if (id !== null && id !== undefined) backupMap.set(id as string | number, item)
}
- // 1) Gestione elementi presenti nel form
+ // 1) Items present in the form array
for (const formItem of formArray) {
- const fid = getId(formItem)
+ const fid = resolveId(formItem)
- // 1.a) Nuovo item (senza id)
+ // 1.a) New item (no identity)
if (fid === null || fid === undefined) {
const addItem = {} as DeltaArrayItem
Object.assign(addItem, formItem as Partial)
addItem.operations = { collectionItemOperation: PatchOperation.AddToCollection }
- // ⬇️ NON droppiamo i figli su "add": li normalizziamo come AddToCollection
+ // normalize children as AddToCollection
for (const ck of childrenKeys) {
const v = (addItem as PlainObject)[ck]
if (Array.isArray(v)) {
@@ -168,7 +293,7 @@ export const deepDelta = >(
c.operations = { collectionItemOperation: PatchOperation.AddToCollection }
return c
})
- ;(addItem as PlainObject)[ck] = normalized
+ ;(addItem as PlainObject)[ck] = normalized
}
}
@@ -176,15 +301,14 @@ export const deepDelta = >(
continue
}
- // 1.b) Ha id ma non esiste nel backup ⇒ AddToCollection
+ // 1.b) Has identity but not in backup ⇒ AddToCollection
const backupItem = backupMap.get(fid as string | number)
if (!backupItem) {
const addItem = {} as DeltaArrayItem
Object.assign(addItem, formItem as Partial)
- addItem.id = fid as U['id']
+ addItem.id = fid as U['id'] // store identity for server convenience
addItem.operations = { collectionItemOperation: PatchOperation.AddToCollection }
- // ⬇️ Anche qui: manteniamo i figli, marcandoli come AddToCollection
for (const ck of childrenKeys) {
const v = (addItem as PlainObject)[ck]
if (Array.isArray(v)) {
@@ -194,7 +318,7 @@ export const deepDelta = >(
c.operations = { collectionItemOperation: PatchOperation.AddToCollection }
return c
})
- ;(addItem as PlainObject)[ck] = normalized
+ ;(addItem as PlainObject)[ck] = normalized
}
}
@@ -202,28 +326,24 @@ export const deepDelta = >(
continue
}
- // 1.c) Re-parenting: root cambiata
+ // 1.c) Re-parenting: root changed
if (!sameRoot(formItem, backupItem)) {
- // REMOVE vecchio
const removeItem = {} as DeltaArrayItem
removeItem.id = fid as U['id']
removeItem.operations = { collectionItemOperation: PatchOperation.RemoveFromCollection }
arrayDelta.push(removeItem)
- // ADD nuovo
const addItem = {} as DeltaArrayItem
Object.assign(addItem, formItem as Partial)
addItem.operations = { collectionItemOperation: PatchOperation.AddToCollection }
if (dropChildren) {
- // ⬇️ SOLO qui, in caso di re-parenting e se richiesto, azzera i figli
for (const ck of childrenKeys) {
if (ck in (addItem as PlainObject)) {
;(addItem as PlainObject)[ck] = []
}
}
} else {
- // Mantieni i figli marcandoli come AddToCollection
for (const ck of childrenKeys) {
const v = (addItem as PlainObject)[ck]
if (Array.isArray(v)) {
@@ -232,7 +352,8 @@ export const deepDelta = >(
Object.assign(c, child as Partial)
c.operations = { collectionItemOperation: PatchOperation.AddToCollection }
return c
- }); (addItem as PlainObject)[ck] = normalized
+ })
+ ;(addItem as PlainObject)[ck] = normalized
}
}
}
@@ -241,8 +362,7 @@ export const deepDelta = >(
continue
}
-
- // 1.d) Ruolo → null ⇒ rimozione item (se abilitato)
+ // 1.d) Role → null ⇒ remove item (if enabled)
const deleteOnRoleNull = policy?.deleteItemWhenRoleRemoved ?? true
if (deleteOnRoleNull) {
const formRole = (formItem as PlainObject)[roleKey]
@@ -257,14 +377,14 @@ export const deepDelta = >(
}
}
- // 1.e) Diff puntuale su campi
+ // 1.e) Field-level diff
const itemDeltaBase = {} as (PlainObject & OperationBag & { id?: U['id'] })
itemDeltaBase.id = fid as U['id']
calculateDelta(
- formItem as PlainObject,
- backupItem as PlainObject,
- itemDeltaBase
+ formItem as PlainObject,
+ backupItem as PlainObject,
+ itemDeltaBase
)
const hasMeaningfulChanges = Object.keys(itemDeltaBase).some(k => k !== 'id')
@@ -273,9 +393,9 @@ export const deepDelta = >(
}
}
- // 2) Elementi rimossi
+ // 2) Items removed
for (const backupItem of backupArray) {
- const bid = getId(backupItem)
+ const bid = resolveId(backupItem)
if (bid === null || bid === undefined) continue
if (!formMap.has(bid as string | number)) {
const removeItem = {} as DeltaArrayItem
@@ -297,6 +417,14 @@ export const deepDelta = >(
return delta
}
+/**
+ * Checks whether any operations exist inside the delta.
+ *
+ * A delta has operations if:
+ * - parent-level operations exist, or
+ * - nested object deltas contain operations, or
+ * - any array item contains operations.
+ */
export const deltaHasOperations = >(delta: Delta): boolean => {
if (!isPlainObject(delta)) return false
if ('operations' in delta && isPlainObject(delta.operations)) return true
diff --git a/src/MaksIT.WebUI/src/functions/file/index.ts b/src/MaksIT.WebUI/src/functions/file/index.ts
new file mode 100644
index 0000000..7d2aa18
--- /dev/null
+++ b/src/MaksIT.WebUI/src/functions/file/index.ts
@@ -0,0 +1,7 @@
+import {
+ saveBinaryToDisk
+} from './saveBinaryToDisk'
+
+export {
+ saveBinaryToDisk
+}
\ No newline at end of file
diff --git a/src/MaksIT.WebUI/src/functions/file/saveBinaryToDisk.ts b/src/MaksIT.WebUI/src/functions/file/saveBinaryToDisk.ts
new file mode 100644
index 0000000..32a48ab
--- /dev/null
+++ b/src/MaksIT.WebUI/src/functions/file/saveBinaryToDisk.ts
@@ -0,0 +1,23 @@
+/**
+ * Saves binary data to disk by creating a downloadable link.
+ * @param data The binary data to save (ArrayBuffer or Blob).
+ * @param filename The desired filename for the saved file.
+ */
+const saveBinaryToDisk = (data: ArrayBuffer | Blob, filename: string) => {
+ const blob = data instanceof Blob ? data : new Blob([data])
+ const url = URL.createObjectURL(blob)
+
+ const a = document.createElement('a')
+ a.href = url
+ a.download = filename
+
+ document.body.appendChild(a)
+ a.click()
+ a.remove()
+
+ setTimeout(() => URL.revokeObjectURL(url), 1000)
+}
+
+export {
+ saveBinaryToDisk
+}
\ No newline at end of file
diff --git a/src/MaksIT.WebUI/src/functions/headers/extractFilenameFromHeaders.ts b/src/MaksIT.WebUI/src/functions/headers/extractFilenameFromHeaders.ts
new file mode 100644
index 0000000..ec1cc2a
--- /dev/null
+++ b/src/MaksIT.WebUI/src/functions/headers/extractFilenameFromHeaders.ts
@@ -0,0 +1,44 @@
+/**
+ * Extracts filename from HTTP headers.
+ * @param headers The HTTP headers object.
+ * @param fallbackName The fallback filename if none found in headers.
+ * @return The extracted filename or the fallback name.
+ */
+const extractFilenameFromHeaders = (
+ headers: Record,
+ fallbackName: string = 'download.bin'
+): string => {
+
+ const cd = headers['content-disposition']
+ if (!cd) {
+ return fallbackName
+ }
+
+ // RFC 5987 — filename*=UTF-8''encoded-name
+ const matchEncoded = /filename\*=\s*UTF-8''([^;]+)/i.exec(cd)
+ if (matchEncoded && matchEncoded[1]) {
+ try {
+ return decodeURIComponent(matchEncoded[1])
+ } catch {
+ return matchEncoded[1]
+ }
+ }
+
+ // Standard — filename="quoted"
+ const matchQuoted = /filename="([^"]+)"/i.exec(cd)
+ if (matchQuoted && matchQuoted[1]) {
+ return matchQuoted[1]
+ }
+
+ // Standard — filename=plain
+ const matchPlain = /filename=([^;]+)/i.exec(cd)
+ if (matchPlain && matchPlain[1]) {
+ return matchPlain[1].trim()
+ }
+
+ return fallbackName
+}
+
+export {
+ extractFilenameFromHeaders
+}
\ No newline at end of file
diff --git a/src/MaksIT.WebUI/src/functions/headers/index.ts b/src/MaksIT.WebUI/src/functions/headers/index.ts
new file mode 100644
index 0000000..bdabbb9
--- /dev/null
+++ b/src/MaksIT.WebUI/src/functions/headers/index.ts
@@ -0,0 +1,7 @@
+import {
+ extractFilenameFromHeaders
+} from './extractFilenameFromHeaders'
+
+export {
+ extractFilenameFromHeaders
+}
\ No newline at end of file
diff --git a/src/MaksIT.WebUI/src/functions/index.ts b/src/MaksIT.WebUI/src/functions/index.ts
index c46b784..e1caaef 100644
--- a/src/MaksIT.WebUI/src/functions/index.ts
+++ b/src/MaksIT.WebUI/src/functions/index.ts
@@ -31,10 +31,20 @@ import {
parseAclEntries
} from './acl'
+import {
+ saveBinaryToDisk
+} from './file'
+
+import {
+ extractFilenameFromHeaders
+} from './headers'
+
export {
+ // date
isValidISODateString,
formatISODateString,
+ // deep
deepCopy,
deepDelta,
deltaHasOperations,
@@ -42,6 +52,7 @@ export {
deepMerge,
deepPatternMatch,
+ // enum
enumToArr,
enumToObj,
enumToString,
@@ -50,8 +61,16 @@ export {
hasFlag,
hasAnyFlag,
+ // isGuid
isGuid,
+ // acl
parseAclEntry,
- parseAclEntries
+ parseAclEntries,
+
+ // file
+ saveBinaryToDisk,
+
+ // headers
+ extractFilenameFromHeaders
}
\ No newline at end of file
diff --git a/src/Models/LetsEncryptServer/Account/Requests/PatchAccountRequest.cs b/src/Models/LetsEncryptServer/Account/Requests/PatchAccountRequest.cs
index 4650bd1..a322f6c 100644
--- a/src/Models/LetsEncryptServer/Account/Requests/PatchAccountRequest.cs
+++ b/src/Models/LetsEncryptServer/Account/Requests/PatchAccountRequest.cs
@@ -5,7 +5,7 @@ namespace MaksIT.Models.LetsEncryptServer.Account.Requests;
public class PatchAccountRequest : PatchRequestModelBase {
- public string Description { get; set; }
+ public string? Description { get; set; }
public bool? IsDisabled { get; set; }
diff --git a/src/Models/Models.csproj b/src/Models/Models.csproj
index ce34962..dd499c8 100644
--- a/src/Models/Models.csproj
+++ b/src/Models/Models.csproj
@@ -11,7 +11,7 @@