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.'
)}
-
+ {
+ 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}