feat(12.2-04): add the fileupload field with deferred uploads on the form session key

- sessionKey.ts: one 32-byte base64url key per form mount, sent only in headers
- api/files.ts: FileRoutes over the record and child file routes, XHR upload with progress, 401 refresh and retry
- FileuploadField and FileCaptionModal per UI-SPEC section 3: dropzone, image grid, rows, per-item states, client pre-checks, reorder, protected previews
- FormView provides FORM_SESSION, counts pending changes as dirty and sends X-Session-Key on create and update
- fileupload lang keys in en and pl, admin-spa docs note, deferred smoke test, rebuilt dist
This commit is contained in:
Jakub Zych
2026-10-02 19:22:22 +02:00
parent 1ebfe691a2
commit ea33296799
22 changed files with 2338 additions and 15 deletions

View File

@@ -0,0 +1,28 @@
// Form session keys (D-02). A record form makes one key when it mounts and a
// relation child modal makes its own when it opens. Uploads, file removals,
// deferred relation calls and the final save carry the key in a header, so
// the server can hold the work against it and commit it with the save. A key
// never appears in a URL: it would end up in logs and the browser history.
/** Header of the record form's key. */
export const SESSION_HEADER = 'X-Session-Key'
/** Header of a relation child modal's own key. */
export const CHILD_SESSION_HEADER = 'X-Child-Session-Key'
/** Random bytes per key: 256 bits, above D-02's 128-bit floor. */
const KEY_BYTES = 32
/**
* A new key: 32 bytes from crypto.getRandomValues encoded as unpadded
* base64url, 43 characters of A-Z a-z 0-9 _ - (the server's key pattern).
*/
export function newSessionKey(): string {
const bytes = new Uint8Array(KEY_BYTES)
globalThis.crypto.getRandomValues(bytes)
let binary = ''
for (const byte of bytes) {
binary += String.fromCharCode(byte)
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}