feat(drive): UI: show policiesto drive's members

and add tests
This commit is contained in:
Edouard Vanbelle
2026-07-01 22:15:09 +02:00
parent 01ff7dab0b
commit 09339ea63f
21 changed files with 557 additions and 206 deletions
@@ -0,0 +1,145 @@
<script lang="ts">
/**
* Reusable list-of-policy-toggles.
*
* Consumed by:
* - Admin "Manage policies" modal — `readonly=false`, admin edits the
* bound `values` in place.
* - Drive settings page (`/config/drive/{uuid}`) — `readonly=true`,
* drive members read the currently-in-effect state.
*
* The shared `policyDefs` in `$lib/utils/drivePolicies` is the single
* source of truth for label + help text + implied-by relations. Adding
* a policy is one push there + one row in `DrivePolicies` in
* `types.ts`; the two consuming surfaces update automatically.
*/
import type { DrivePoliciesPartial } from '$lib/api/types';
import { isPolicyImplied, policyDefs, type PolicyDef } from '$lib/utils/drivePolicies';
interface Props {
/** Current values displayed on each row. */
values: Required<DrivePoliciesPartial>;
/** `true` = display only, disables the checkboxes so members can see the
* live state without a mutation affordance. When `true`, `onchange`
* is ignored — the component never emits. */
readonly?: boolean;
/** Additional disable signal (used by the admin modal during save). */
busy?: boolean;
/** Prefix for the `data-testid` on each checkbox
* (e.g. `admin-policy-…` on the admin page, `drive-policy-…` on
* the config page). Keeps test selectors stable per surface. */
testIdPrefix?: string;
/** Fired when the user toggles a checkbox (mutable surface only).
* The parent owns the storage and applies the change. Not called
* in `readonly` mode. */
onchange?: (key: PolicyDef['key'], next: boolean) => void;
}
let {
values,
readonly = false,
busy = false,
testIdPrefix = 'policy',
onchange
}: Props = $props();
</script>
<ul class="policy-list">
{#each policyDefs as def (def.key)}
{@const implied = isPolicyImplied(def, values)}
<li class="policy-row" class:policy-row--implied={implied}>
<label class="policy-row__label">
<span class="policy-row__head">
<input
type="checkbox"
data-testid={`${testIdPrefix}-${def.key}`}
checked={values[def.key]}
disabled={readonly || busy || implied}
onchange={(e) => onchange?.(def.key, (e.currentTarget as HTMLInputElement).checked)}
/>
<span class="policy-row__title">{def.label()}</span>
</span>
<span class="policy-row__help muted">
{def.help()}
{#if implied && def.impliedHint}
<span class="policy-row__implied">{def.impliedHint()}</span>
{/if}
</span>
</label>
</li>
{/each}
</ul>
<style>
/* Ported from the admin modal's original block so the visual stays
identical when the modal switches to this component; the read-only
surface on `/config/drive/{uuid}` gets the same look for free. */
.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. Same treatment used on the read-only
surface so subordinate rules read as visually secondary. */
.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;
}
</style>
+148
View File
@@ -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<DrivePoliciesPartial>;
label: () => string;
help: () => string;
impliedBy?: keyof Required<DrivePoliciesPartial>;
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<DrivePoliciesPartial>): boolean {
return def.impliedBy != null && values[def.impliedBy];
}
/**
* JSONB reader — the backend may hold a raw `Record<string, unknown>` 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<string, unknown>, key: string): boolean {
const v = p[key];
return typeof v === 'boolean' ? v : false;
}
/**
* Populate a full `Required<DrivePoliciesPartial>` 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<string, unknown>): Required<DrivePoliciesPartial> {
const out = {} as Required<DrivePoliciesPartial>;
for (const def of policyDefs) {
out[def.key] = readPolicyBool(p, def.key);
}
return out;
}