From 26d3c692ba3513a3de5e47c09aed0a3d27a26399 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Fri, 26 Jun 2026 18:08:34 +0200 Subject: [PATCH] feat(drive): policies management from UI --- frontend/src/lib/api/endpoints/drives.ts | 37 ++ frontend/src/lib/api/types.ts | 27 ++ frontend/src/routes/admin/+page.svelte | 353 +++++++++++++++++- .../routes/config/drive/[uuid]/+page.svelte | 47 +-- frontend/static/locales/ar.json | 21 +- frontend/static/locales/de.json | 21 +- frontend/static/locales/en.json | 21 +- frontend/static/locales/es.json | 21 +- frontend/static/locales/fa.json | 21 +- frontend/static/locales/fr.json | 21 +- frontend/static/locales/hi.json | 21 +- frontend/static/locales/it.json | 21 +- frontend/static/locales/ja.json | 21 +- frontend/static/locales/ko.json | 21 +- frontend/static/locales/nl.json | 21 +- frontend/static/locales/pl.json | 21 +- frontend/static/locales/pt.json | 21 +- frontend/static/locales/ru.json | 21 +- frontend/static/locales/zh-TW.json | 21 +- frontend/static/locales/zh.json | 21 +- 20 files changed, 720 insertions(+), 80 deletions(-) diff --git a/frontend/src/lib/api/endpoints/drives.ts b/frontend/src/lib/api/endpoints/drives.ts index 1a8ee9f5..53469462 100644 --- a/frontend/src/lib/api/endpoints/drives.ts +++ b/frontend/src/lib/api/endpoints/drives.ts @@ -13,6 +13,8 @@ import type { Drive, DriveMember, DriveMemberSubject, + DrivePolicies, + DrivePoliciesPartial, DriveRole } from '$lib/api/types'; @@ -130,6 +132,41 @@ export async function deleteDrive(driveId: string): Promise { } } +/** + * `PATCH /api/drives/{id}/policies` — update drive policies (D5). + * + * **OxiCloud-admin only.** Owners cannot mutate policies — the carve-out + * exists because policies are a compliance surface (an owner who could + * flip them would defeat the gates by disabling, sharing, re-enabling). + * Non-admin callers receive 404 (anti-enum). The frontend only surfaces + * this from the admin panel. + * + * Body is a partial — keys not present are left untouched at the JSONB + * merge layer. Returns the post-merge typed view. + */ +export async function updateDrivePolicies( + driveId: string, + partial: DrivePoliciesPartial +): Promise { + const res = await apiFetch(`/api/drives/${encodeURIComponent(driveId)}/policies`, { + method: 'PATCH', + headers: { ...JSON_HEADERS, ...getCsrfHeaders() }, + credentials: 'same-origin', + body: JSON.stringify(partial) + }); + if (!res.ok) { + let detail = ''; + try { + const parsed = (await res.json()) as { error?: string; message?: string }; + detail = parsed.error ?? parsed.message ?? ''; + } catch { + /* response body wasn't JSON */ + } + throw new Error(detail || `update policies failed: ${res.status}`); + } + return (await res.json()) as DrivePolicies; +} + /** * `DELETE /api/drives/{id}/members/{kind}/{sid}` — remove a member. * Idempotent (removing a non-member returns 204). Refused with 400 if it diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index 3d8fb9dd..1eed62c0 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -237,12 +237,39 @@ export interface Drive { root_folder_id: string; quota_bytes?: number | null; used_bytes: number; + /** + * Drive policies — raw JSONB bag from the backend. Unknown keys are + * preserved verbatim. For the typed view used by the admin policy + * editor, see [`DrivePolicies`]. + */ policies: Record; created_at: string; updated_at: string; caller_role?: DriveRole | null; } +/** + * Typed mirror of the five known D5 policy keys. Every field defaults to + * `false` (= allowed). The wire shape returned by + * `PATCH /api/drives/{id}/policies` carries all five keys; the request + * body uses [`DrivePoliciesPartial`] so unsupplied keys aren't disturbed. + * + * See `docs/plan/drive.md` §8 for what each key gates. + */ +export interface DrivePolicies { + forbid_sharing: boolean; + forbid_external_sharing: boolean; + forbid_public_links: boolean; + forbid_cross_drive_move: boolean; + forbid_owner_role_change: boolean; +} + +/** + * Body shape for the admin policy editor — every key optional so omitting + * a field leaves that policy untouched (the backend uses a JSONB merge). + */ +export type DrivePoliciesPartial = Partial; + /** * Request body for `POST /api/drives` (D3a). Mirrors `CreateDriveDto` in * `src/interfaces/api/handlers/drive_handler.rs`. `kind: 'personal'` is a diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte index fb3bbe4d..510a9f04 100644 --- a/frontend/src/routes/admin/+page.svelte +++ b/frontend/src/routes/admin/+page.svelte @@ -52,14 +52,20 @@ type StorageSettings, type StorageTestResult } from '$lib/api/endpoints/admin'; - import { createDrive } from '$lib/api/endpoints/drives'; + import { createDrive, updateDrivePolicies } from '$lib/api/endpoints/drives'; import { ensureResolvers, resolveRecipient, searchRecipients, type Recipient } from '$lib/api/endpoints/recipients'; - import type { Drive, DriveMember, User } from '$lib/api/types'; + import type { + Drive, + DriveMember, + DrivePolicies, + DrivePoliciesPartial, + User + } from '$lib/api/types'; import Icon from '$lib/icons/Icon.svelte'; import Modal from '$lib/components/Modal.svelte'; import OwnerAvatarStack from '$lib/components/OwnerAvatarStack.svelte'; @@ -1062,6 +1068,154 @@ : [] ); + // ── Manage-policies modal (D5 admin-only mutation) ───────────────────── + // Policies were owner-mutable in the original D5 design; the carve-out + // to admin-only fixed the self-policing-soft-cap hole (an owner could + // disable forbid_external_sharing, share, re-enable — net zero + // enforcement). The owner UI no longer surfaces policies at all; this + // modal is the only editor. See `docs/plan/drive.md` §8. + let managePoliciesDrive = $state(null); + let managePoliciesDraft = $state>({ + forbid_sharing: false, + forbid_external_sharing: false, + forbid_public_links: false, + forbid_cross_drive_move: false, + forbid_owner_role_change: false + }); + let managePoliciesError = $state(null); + let managePoliciesBusy = $state(false); + + function readPolicyBool(p: Record, key: string): boolean { + // JSONB returns unknown keys verbatim; default missing/non-bool to + // `false` so a freshly-created drive (empty `{}` bag) shows every + // toggle off without ad-hoc nullish handling per row. + const v = p[key]; + return typeof v === 'boolean' ? v : false; + } + + function openManagePolicies(d: Drive) { + managePoliciesDrive = d; + managePoliciesError = null; + const p = (d.policies ?? {}) as Record; + managePoliciesDraft = { + forbid_sharing: readPolicyBool(p, 'forbid_sharing'), + forbid_external_sharing: readPolicyBool(p, 'forbid_external_sharing'), + forbid_public_links: readPolicyBool(p, 'forbid_public_links'), + forbid_cross_drive_move: readPolicyBool(p, 'forbid_cross_drive_move'), + forbid_owner_role_change: readPolicyBool(p, 'forbid_owner_role_change') + }; + } + + function closeManagePolicies() { + managePoliciesDrive = null; + managePoliciesError = null; + } + + async function saveManagePolicies() { + if (!managePoliciesDrive) return; + managePoliciesBusy = true; + managePoliciesError = null; + try { + const merged: DrivePolicies = await updateDrivePolicies( + managePoliciesDrive.id, + managePoliciesDraft + ); + // Refresh the drive row's policies in place so the next time + // the admin opens this modal they see the persisted state. + const driveId = managePoliciesDrive.id; + drivesList = drivesList.map((d) => + d.id === driveId ? { ...d, policies: { ...d.policies, ...merged } } : d + ); + closeManagePolicies(); + } catch (e) { + managePoliciesError = errorMessage(e); + } finally { + managePoliciesBusy = false; + } + } + + // Policy keys + labels for the toggle list. Mirrors the entity field + // order in `src/domain/entities/drive.rs` so a future 6th policy lands + // here as one literal-array push. + // + // `impliedBy` captures the semantic dependency between policies: when + // the named parent policy is on, this subordinate gate is moot + // (its enforcement is already covered by the broader rule). The UI + // disables the toggle and shows a hint so the admin understands the + // hierarchy without our having to actually mutate the stored value — + // their preference is preserved for the moment they relax the parent. + const policyDefs: Array<{ + key: keyof Required; + label: () => string; + help: () => string; + impliedBy?: keyof Required; + impliedHint?: () => string; + }> = [ + { + key: 'forbid_sharing', + label: () => t('admin.drive_policy.forbid_sharing', 'Forbid per-resource sharing'), + help: () => + t( + 'admin.drive_policy.forbid_sharing_help', + 'Block per-file / per-folder grants (covers public links and external sharing as well). Drive-level membership still works.' + ) + }, + { + key: 'forbid_public_links', + label: () => t('admin.drive_policy.forbid_public_links', 'Forbid public links'), + help: () => + t( + 'admin.drive_policy.forbid_public_links_help', + 'Block anonymous share links on resources in this drive.' + ), + impliedBy: 'forbid_sharing', + impliedHint: () => + t( + 'admin.drive_policy.implied_by_forbid_sharing', + 'Already enforced by Forbid per-resource sharing.' + ) + }, + { + key: 'forbid_external_sharing', + label: () => t('admin.drive_policy.forbid_external_sharing', 'Forbid external sharing'), + help: () => + t( + 'admin.drive_policy.forbid_external_sharing_help', + 'Block grants to external users (email invitations and pre-existing external accounts).' + ), + impliedBy: 'forbid_sharing', + impliedHint: () => + t( + 'admin.drive_policy.implied_by_forbid_sharing', + 'Already enforced by Forbid per-resource sharing.' + ) + }, + { + key: 'forbid_cross_drive_move', + label: () => t('admin.drive_policy.forbid_cross_drive_move', 'Forbid cross-drive move'), + help: () => + t( + 'admin.drive_policy.forbid_cross_drive_move_help', + 'Block moving files or folders out to another drive. Does not stop download + re-upload.' + ) + }, + { + key: 'forbid_owner_role_change', + label: () => t('admin.drive_policy.forbid_owner_role_change', 'Lock Owner roster'), + help: () => + t( + 'admin.drive_policy.forbid_owner_role_change_help', + 'Only admin can add, remove, or demote drive Owners while this is on.' + ) + } + ]; + + // Reactive helper for the template: is this policy currently + // disabled because its parent policy implies it? + function isPolicyImplied(def: (typeof policyDefs)[number]): boolean { + return def.impliedBy !== undefined && managePoliciesDraft[def.impliedBy]; + } + // Admin-driven delete-drive flow (D3b). Guarded by the confirm modal // because the action is destructive and irreversible. The backend // refuses the default Personal drive (405) and any non-empty drive @@ -2205,7 +2359,13 @@ stays a plain table cell so its baseline + bottom-border align with the rest of the row even on personal-drive rows where the wrapper is empty. --> -
+
+ {#if d.kind === 'shared'} + {:else} + {/if} + + + (backend returns 405). Render an invisible + placeholder so the row's columns still line up + with the deletable rows above and below. --> {#if !d.default_for_user} + {:else} + {/if}
@@ -2641,6 +2820,78 @@ {/snippet} + + + {#if managePoliciesDrive} +
+

+ {t( + 'admin.drive_manage_policies_help', + 'Policies are admin-only — drive owners cannot mutate them. Each toggle controls one enforcement gate.' + )} +

+
    + {#each policyDefs as def (def.key)} + {@const implied = isPolicyImplied(def)} +
  • + +
  • + {/each} +
+ {#if managePoliciesError} +

{managePoliciesError}

+ {/if} +
+ {/if} + {#snippet footer()} + + + {/snippet} +
+ diff --git a/frontend/src/routes/config/drive/[uuid]/+page.svelte b/frontend/src/routes/config/drive/[uuid]/+page.svelte index bf233026..426e5ab8 100644 --- a/frontend/src/routes/config/drive/[uuid]/+page.svelte +++ b/frontend/src/routes/config/drive/[uuid]/+page.svelte @@ -179,37 +179,10 @@ return Math.min(100, (drive.used_bytes / drive.quota_bytes) * 100); }); - const policyEntries = $derived.by(() => { - if (!drive) return []; - return Object.entries(drive.policies).map(([key, value]) => ({ key, value })); - }); - - function policyLabel(key: string): string { - // Known policy keys get a friendlier translated label; unknown keys - // surface verbatim so operators still see them (forward-compat). - switch (key) { - case 'forbid_public_links': - return t('drive.policy.forbid_public_links', 'Forbid public links'); - case 'forbid_external_sharing': - return t('drive.policy.forbid_external_sharing', 'Forbid external sharing'); - case 'forbid_sharing': - return t('drive.policy.forbid_sharing', 'Forbid sharing'); - case 'forbid_cross_drive_move': - return t('drive.policy.forbid_cross_drive_move', 'Forbid cross-drive move'); - case 'include_in_photo_index': - return t('drive.policy.include_in_photo_index', 'Include in photo index'); - case 'forbid_music_index': - return t('drive.policy.forbid_music_index', 'Forbid music index'); - default: - return key; - } - } - - function policyValueDisplay(value: unknown): string { - if (value === true) return t('drive.policy.on', 'On'); - if (value === false) return t('drive.policy.off', 'Off'); - return String(value); - } + // Drive policies are OxiCloud-admin-only post-D5 — owners can no + // longer mutate them, so this page no longer surfaces them at all + // (the admin panel hosts the policy editor). See + // `docs/plan/drive.md` §8. onMount(() => { void drivesStore.load(); @@ -413,18 +386,6 @@ {/if}
- {#if policyEntries.length > 0} -
-

{t('drive.policies', 'Policies')}

-
- {#each policyEntries as p (p.key)} -
{policyLabel(p.key)}
-
{policyValueDisplay(p.value)}
- {/each} -
-
- {/if} - {#if canDelete}