diff --git a/frontend/src/lib/components/PolicyList.svelte b/frontend/src/lib/components/PolicyList.svelte new file mode 100644 index 00000000..1d7853ff --- /dev/null +++ b/frontend/src/lib/components/PolicyList.svelte @@ -0,0 +1,145 @@ + + + + + diff --git a/frontend/src/lib/utils/drivePolicies.ts b/frontend/src/lib/utils/drivePolicies.ts new file mode 100644 index 00000000..c0c03c7a --- /dev/null +++ b/frontend/src/lib/utils/drivePolicies.ts @@ -0,0 +1,148 @@ +/** + * Shared drive-policy definitions. + * + * Consumed by two surfaces: + * - Admin "Manage policies" modal (`routes/admin/+page.svelte`) — read+write. + * - Drive settings page (`routes/config/drive/[uuid]/+page.svelte`) — read-only, + * so drive members can see which policies an admin has set. + * + * Kept in a plain `.ts` module (not a component) so both consumers import the + * same array and the definition of "one policy" lives in exactly one place. + * Adding a sixth policy is a single push here + one migration + the + * `DrivePolicies` interface extension in `types.ts`. See + * `docs/plan/drive.md` §8 (forbid_* gates) + §15 (include_in_*_index scope). + */ +import { t } from '$lib/i18n/index.svelte'; +import type { DrivePoliciesPartial } from '$lib/api/types'; + +/** + * `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 admin modal disables the + * child toggle and shows `impliedHint` so the admin understands the + * hierarchy without our having to mutate the stored value — their + * preference is preserved for the moment they relax the parent. The + * read-only config surface uses the same signal to dim implied rows. + */ +export interface PolicyDef { + key: keyof Required; + label: () => string; + help: () => string; + impliedBy?: keyof Required; + impliedHint?: () => string; +} + +/** + * Mirrors the entity field order in `src/domain/entities/drive.rs` so a + * future policy lands here as one literal-array push. + */ +export const policyDefs: PolicyDef[] = [ + { + 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.' + ) + }, + { + key: 'include_in_photo_index', + label: () => t('admin.drive_policy.include_in_photo_index', 'Include in Photos'), + help: () => + t( + 'admin.drive_policy.include_in_photo_index_help', + 'Show image and video files from this drive in the Photos timeline and on the Places map. Default personal drives are opted in automatically; turn on for shared drives that genuinely hold photos (e.g. "Family Photos").' + ) + }, + { + key: 'include_in_music_index', + label: () => t('admin.drive_policy.include_in_music_index', 'Include in Music'), + help: () => + t( + 'admin.drive_policy.include_in_music_index_help', + 'Include audio files from this drive in the Music library. Default personal drives are opted in automatically; turn on for shared drives that genuinely hold a music collection (e.g. "Family Music", "Band Collaboration").' + ) + } +]; + +/** + * True when `def` is subordinate to another policy whose value is currently + * `true` in `values`. Both surfaces use this to gray out implied rows. + */ +export function isPolicyImplied(def: PolicyDef, values: Required): boolean { + return def.impliedBy != null && values[def.impliedBy]; +} + +/** + * JSONB reader — the backend may hold a raw `Record` bag + * (unknown keys preserved verbatim), so any missing / non-bool key resolves + * to `false`. Shared between the admin modal (initialising the edit draft) + * and the config/drive page (reading the current state for display). + */ +export function readPolicyBool(p: Record, key: string): boolean { + const v = p[key]; + return typeof v === 'boolean' ? v : false; +} + +/** + * Populate a full `Required` from the JSONB bag by + * reading each known key with `readPolicyBool`. Both admin and config + * surfaces call this on load; the admin edits the returned object in + * place while the config surface renders it read-only. + */ +export function readAllPolicies(p: Record): Required { + const out = {} as Required; + for (const def of policyDefs) { + out[def.key] = readPolicyBool(p, def.key); + } + return out; +} diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte index 62ef4263..7e667e70 100644 --- a/frontend/src/routes/admin/+page.svelte +++ b/frontend/src/routes/admin/+page.svelte @@ -69,8 +69,10 @@ import Icon from '$lib/icons/Icon.svelte'; import Modal from '$lib/components/Modal.svelte'; import OwnerAvatarStack from '$lib/components/OwnerAvatarStack.svelte'; + import PolicyList from '$lib/components/PolicyList.svelte'; import UserVignette from '$lib/components/UserVignette.svelte'; import { t } from '$lib/i18n/index.svelte'; + import { readPolicyBool } from '$lib/utils/drivePolicies'; import { session } from '$lib/stores/session.svelte'; import { drives as drivesStore } from '$lib/stores/drives.svelte'; import { ui } from '$lib/stores/ui.svelte'; @@ -1091,14 +1093,6 @@ 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; @@ -1142,105 +1136,10 @@ } } - // 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.' - ) - }, - { - key: 'include_in_photo_index', - label: () => t('admin.drive_policy.include_in_photo_index', 'Include in Photos'), - help: () => - t( - 'admin.drive_policy.include_in_photo_index_help', - 'Show image and video files from this drive in the Photos timeline and on the Places map. Default personal drives are opted in automatically; turn on for shared drives that genuinely hold photos (e.g. "Family Photos").' - ) - }, - { - key: 'include_in_music_index', - label: () => t('admin.drive_policy.include_in_music_index', 'Include in Music'), - help: () => - t( - 'admin.drive_policy.include_in_music_index_help', - 'Include audio files from this drive in the Music library. Default personal drives are opted in automatically; turn on for shared drives that genuinely hold a music collection (e.g. "Family Music", "Band Collaboration").' - ) - } - ]; - - // 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]; - } + // Policy definitions live in `$lib/utils/drivePolicies` so the same + // list drives the admin "Manage policies" modal AND the read-only + // summary on `/config/drive/{uuid}`. Adding a policy is one literal- + // array push there + one field in `DrivePolicies` in `types.ts`. // Admin-driven delete-drive flow (D3b). Guarded by the confirm modal // because the action is destructive and irreversible. The backend @@ -2869,30 +2768,14 @@ '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} -
+ { + managePoliciesDraft[key] = next; + }} + /> {#if managePoliciesError}

{managePoliciesError}

{/if} @@ -3904,76 +3787,10 @@ white-space: nowrap; } - /* D5 policy editor (admin-only). Same row shape as `.owners-list__row` - so the modal feels consistent; the label inside is a flex row so the - checkbox sits beside the text instead of stacking vertically. */ - .policy-list { - list-style: none; - margin: 0; - padding: 0; - display: flex; - flex-direction: column; - gap: var(--space-2); - } - - .policy-row { - padding: var(--space-2); - border: 1px solid var(--color-border); - border-radius: var(--radius-md); - } - - .policy-row__label { - /* Column layout: head (checkbox + title inline) on top, help - text underneath. The checkbox + title share a row via - `.policy-row__head` so the title sits beside the checkbox - instead of wrapping to its own line. */ - display: flex; - flex-direction: column; - gap: var(--space-1); - cursor: pointer; - margin: 0; - } - - .policy-row__head { - display: flex; - align-items: center; - gap: var(--space-2); - min-width: 0; - } - - .policy-row__head input[type='checkbox'] { - margin: 0; - flex-shrink: 0; - } - - .policy-row__title { - font-weight: 600; - } - - .policy-row__help { - /* Indent the help text under the title so the relationship is - visually obvious. Width = checkbox width + the head's gap. */ - padding-left: calc(1rem + var(--space-2)); - } - - /* Implied state — the row's gate is already covered by a broader - policy (e.g. forbid_public_links when forbid_sharing is on). - Visually dimmed so the admin understands they don't need to - toggle it; the stored value is preserved for the moment they - relax the parent policy. */ - .policy-row--implied { - opacity: 0.55; - } - - .policy-row--implied .policy-row__label { - cursor: not-allowed; - } - - .policy-row__implied { - display: block; - margin-top: var(--space-1); - font-style: italic; - } + /* Policy list styles moved to `PolicyList.svelte`. The modal now + embeds `` and the + read-only summary on `/config/drive/{uuid}` reuses the same + component. */ /* Drives table action cell — same shape as `.actions` plus a fixed 3-column grid so the [users] [policies] [delete] icons line up diff --git a/frontend/src/routes/config/drive/[uuid]/+page.svelte b/frontend/src/routes/config/drive/[uuid]/+page.svelte index 426e5ab8..88373d55 100644 --- a/frontend/src/routes/config/drive/[uuid]/+page.svelte +++ b/frontend/src/routes/config/drive/[uuid]/+page.svelte @@ -9,7 +9,8 @@ import { renameFolder } from '$lib/api/endpoints/folders'; import { errorToast } from '$lib/utils/errors'; import { ui } from '$lib/stores/ui.svelte'; - import type { Drive, DriveMember, DriveRole } from '$lib/api/types'; + import type { Drive, DriveMember, DriveRole, DrivePoliciesPartial } from '$lib/api/types'; + import PolicyList from '$lib/components/PolicyList.svelte'; import ShareDialog from '$lib/components/ShareDialog.svelte'; import UserVignette from '$lib/components/UserVignette.svelte'; import Icon from '$lib/icons/Icon.svelte'; @@ -17,6 +18,7 @@ import { drives as drivesStore, driveIcon } from '$lib/stores/drives.svelte'; import { formatDate } from '$lib/utils/display'; import { formatBytes } from '$lib/utils/format'; + import { readAllPolicies } from '$lib/utils/drivePolicies'; const uuid = $derived(page.params.uuid ?? ''); const drive = $derived(drivesStore.findById(uuid)); @@ -179,10 +181,15 @@ return Math.min(100, (drive.used_bytes / drive.quota_bytes) * 100); }); - // 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. + // Drive policies are OxiCloud-admin-only for mutation (§8), but + // visible read-only here so members understand what rules apply to + // the drive they're on. The admin's "Manage policies" modal on + // `/admin` is the only editor. `readAllPolicies` normalises the raw + // JSONB bag into a `Required` — unknown keys + // (or missing ones) resolve to `false`. + const drivePoliciesView = $derived>( + readAllPolicies((drive?.policies ?? {}) as Record) + ); onMount(() => { void drivesStore.load(); @@ -386,6 +393,26 @@ {/if} + +
+ +

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

+ +
+

+ {t( + 'drive.policies_help', + "Rules an OxiCloud admin has set for this drive. Only admins can change them; you're seeing the current state." + )} +

+ +
+ {#if canDelete}