diff --git a/components/admin/org/__tests__/shared-tags-section.test.tsx b/components/admin/org/__tests__/shared-tags-section.test.tsx new file mode 100644 index 000000000..621994ded --- /dev/null +++ b/components/admin/org/__tests__/shared-tags-section.test.tsx @@ -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> = []; +let stored: Record = {}; +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 | null = null; +function Harness() { + const draft = useOrgPolicyDraft('test'); + latest = draft; + if (draft.loading) return null; + return ; +} + +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(); + 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: {} } } }], + }); + }); +}); diff --git a/components/admin/org/sections/shared-tags-section.tsx b/components/admin/org/sections/shared-tags-section.tsx index 978c1a56d..847748d3c 100644 --- a/components/admin/org/sections/shared-tags-section.tsx +++ b/components/admin/org/sections/shared-tags-section.tsx @@ -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 ( - -

Nothing to set here yet.

+ +
+ {restrictionKeysForPage('org-sharing') + .filter((def) => def.key === 'sharedTags') + .map((def) => )} +
); } diff --git a/lib/admin/restriction-keys.ts b/lib/admin/restriction-keys.ts index 7d00d4103..92b21d081 100644 --- a/lib/admin/restriction-keys.ts +++ b/lib/admin/restriction-keys.ts @@ -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[] {