feat: let admins lock shared tags per domain or group

This commit is contained in:
Linus Rath
2026-10-28 06:46:04 +01:00
parent b049b6f470
commit 3d8a0cfd0b
3 changed files with 104 additions and 4 deletions
@@ -0,0 +1,78 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
// Organisation > Sharing > Shared tags: one sentence and the lock for the
// `sharedTags` restriction, written at the scope chosen in the scope bar.
const puts: Array<Record<string, unknown>> = [];
let stored: Record<string, unknown> = {};
vi.mock('@/lib/browser-navigation', () => ({
apiFetch: vi.fn(async (_url: string, init?: RequestInit) => {
if (init?.method === 'PUT') {
const body = JSON.parse(String(init.body));
puts.push(body);
stored = body;
return new Response('{"ok":true}');
}
return new Response(JSON.stringify(stored));
}),
withBasePath: (p: string) => p,
getPathPrefix: () => '',
}));
import { SharedTagsSection } from '../sections/shared-tags-section';
import { useOrgPolicyDraft, useOrgScopeStore, EVERYONE } from '../use-org-policy-draft';
import { RESTRICTION_KEYS } from '@/lib/admin/restriction-keys';
import { resolvePolicy } from '@/lib/admin/policy-scopes';
const POLICY = {
restrictions: {}, features: {}, defaults: {}, themePolicy: {},
forceEnabledPlugins: [], approvedPlugins: [], forceEnabledThemes: [],
scopes: [{ id: 'leads', label: 'Leads', match: { groups: ['leads@example.org'] }, policy: {} }],
};
let latest: ReturnType<typeof useOrgPolicyDraft> | null = null;
function Harness() {
const draft = useOrgPolicyDraft('test');
latest = draft;
if (draft.loading) return null;
return <SharedTagsSection draft={draft} />;
}
beforeEach(() => {
puts.length = 0;
stored = JSON.parse(JSON.stringify(POLICY));
latest = null;
useOrgScopeStore.setState({ scopeId: EVERYONE });
});
describe('Shared tags section', () => {
it('registers sharedTags on the Sharing page, lock only', () => {
expect(RESTRICTION_KEYS.find((def) => def.key === 'sharedTags')).toMatchObject({ page: 'org-sharing', modes: ['locked'] });
});
it('locks shared tags for everyone and unlocks them in a group scope', async () => {
render(<Harness />);
const section = await screen.findByTestId('org-shared-tags-section');
expect(within(section).queryByText('Hide')).not.toBeInTheDocument();
fireEvent.click(within(section).getByRole('checkbox'));
expect(latest!.policy?.restrictions.sharedTags).toEqual({ locked: true });
act(() => latest!.setScopeId('leads'));
await waitFor(() => expect(within(screen.getByTestId('org-shared-tags-section')).getByRole('checkbox')).toBeChecked());
fireEvent.click(within(screen.getByTestId('org-shared-tags-section')).getByRole('checkbox'));
// In a scope an empty restriction is a real override: it unlocks.
expect(latest!.policy?.scopes?.[0].policy.restrictions?.sharedTags).toEqual({});
const lead = resolvePolicy(latest!.policy!, { email: 'anna@example.org', groups: ['leads@example.org'] }).policy;
const member = resolvePolicy(latest!.policy!, { email: 'bob@example.org', groups: ['team@example.org'] }).policy;
expect(lead.restrictions.sharedTags?.locked).not.toBe(true);
expect(member.restrictions.sharedTags?.locked).toBe(true);
await act(async () => { await latest!.save(); });
expect(puts[0]).toMatchObject({
restrictions: { sharedTags: { locked: true } },
scopes: [{ id: 'leads', policy: { restrictions: { sharedTags: {} } } }],
});
});
});
@@ -1,14 +1,27 @@
'use client';
import { SettingsSection } from '@/components/settings/settings-section';
import { restrictionKeysForPage } from '@/lib/admin/restriction-keys';
import { RestrictionRow } from '../org-page';
import type { OrgPolicyDraft } from '../use-org-policy-draft';
/** Organisation feature 6; filled in by its own step (local-data/org-features-plan.md). */
/**
* Organisation feature 6: shared tags in group mailboxes. The tags live in
* the group's Inbox on the mail server (Stalwart 1.0 metadata), so the only
* thing to set here is who may change them.
*/
export function SharedTagsSection({ draft }: { draft: OrgPolicyDraft }) {
void draft;
return (
<SettingsSection id="shared-tags" title="Shared tags" description="Tag names and colours that every member of a shared mailbox sees.">
<p className="text-sm text-muted-foreground">Nothing to set here yet.</p>
<SettingsSection
id="shared-tags"
title="Shared tags"
description="Members of a group mailbox see the same tag names and colours on its mail, and members who may change the mailbox edit them (Stalwart 1.0 or later)."
>
<div data-testid="org-shared-tags-section">
{restrictionKeysForPage('org-sharing')
.filter((def) => def.key === 'sharedTags')
.map((def) => <RestrictionRow key={def.key} draft={draft} def={def} />)}
</div>
</SettingsSection>
);
}
+9
View File
@@ -51,6 +51,15 @@ export const RESTRICTION_KEYS: readonly RestrictionKeyDef[] = [
page: 'org-signatures',
modes: ['locked', 'hidden'],
},
{
key: 'sharedTags',
label: 'Shared tags',
description: "Lock: users can tag mail with the shared tags of their group mailboxes but can't add, rename or delete them. Unlock it in a group scope for the people who look after the tags.",
category: 'Sharing',
type: 'boolean',
page: 'org-sharing',
modes: ['locked'],
},
];
export function restrictionKeysForPage(page: AdminPageId): RestrictionKeyDef[] {