mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 16:07:12 +00:00
fix: follow the organisation policy for delegates and recovering deleted mail
This commit is contained in:
@@ -6,6 +6,7 @@ import { RotateCcw } from '../icons';
|
||||
import { spacing, typography, type ThemePalette } from '../../theme/tokens';
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
import { usePolicyStore } from '../../stores/policy-store';
|
||||
import { setPendingSettingsTab } from '../../navigation/pending-settings-tab';
|
||||
import { recoveryPolicy } from '../../lib/recovery/policy';
|
||||
import { useRecoveryStore } from '../../lib/recovery/recovery-store';
|
||||
@@ -28,7 +29,7 @@ export function TrashRecoveryLink({ mailbox }: { mailbox: Mailbox | undefined })
|
||||
const t = useLocaleStore((s) => s.t);
|
||||
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||
const { available, accountId, key } = useDeletedItemsRecovery();
|
||||
const { days } = recoveryPolicy();
|
||||
const days = usePolicyStore((s) => recoveryPolicy(s.effective).days);
|
||||
const recent = useRecoveryStore((s) => (key ? s.recent[key] : undefined));
|
||||
const checkRecent = useRecoveryStore((s) => s.checkRecent);
|
||||
const ownTrash = isOwnTrash(mailbox, accountId);
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useColors } from '../../theme/colors';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
import { useAuthStore } from '../../stores/auth-store';
|
||||
import { useAccountStore } from '../../stores/account-store';
|
||||
import { usePolicyStore } from '../../stores/policy-store';
|
||||
import { toast } from '../../stores/toast-store';
|
||||
import { jmapClient } from '../../api/jmap-client';
|
||||
import { listPrincipals, type PrincipalListing } from '../../api/files';
|
||||
@@ -39,7 +40,7 @@ import { delegationPolicy } from '../../lib/delegation/policy';
|
||||
// Settings > Sharing > Delegates. Give someone access to mail, calendars and
|
||||
// contacts in one step, and see and take back who has access to what. The
|
||||
// admin's hiding, rights limit and app gates come from lib/delegation/policy
|
||||
// (nothing is limited until the app has the org policy).
|
||||
// (the active account's organisation policy).
|
||||
|
||||
// Names kept for a login go with it: removed from the app, or everyone signed out.
|
||||
useAccountStore.subscribe((state, prev) => {
|
||||
@@ -55,7 +56,8 @@ export function DelegateSettings() {
|
||||
const session = useAuthStore((s) => s.session);
|
||||
const accountKey = useAuthStore((s) => s.activeAccountId) ?? '';
|
||||
const username = useAuthStore((s) => s.username);
|
||||
const policy = delegationPolicy();
|
||||
const effectivePolicy = usePolicyStore((s) => s.effective);
|
||||
const policy = useMemo(() => delegationPolicy(effectivePolicy), [effectivePolicy]);
|
||||
const levels = useMemo(() => allowedLevels(policy.allowedValues), [policy.allowedValues]);
|
||||
const { calendarsEnabled, contactsEnabled, filesEnabled } = policy;
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { spacing, radius, typography, type ThemePalette } from '../../theme/toke
|
||||
import { useColors } from '../../theme/colors';
|
||||
import { useLocaleStore } from '../../stores/locale-store';
|
||||
import { useSettingsStore } from '../../stores/settings-store';
|
||||
import { usePolicyStore } from '../../stores/policy-store';
|
||||
import { toast } from '../../stores/toast-store';
|
||||
import { jmapClient } from '../../api/jmap-client';
|
||||
import { listArchivedEmails, restoreArchivedItems } from '../../api/archived-items';
|
||||
@@ -29,7 +30,7 @@ import { useDeletedItemsRecovery } from '../../lib/recovery/use-deleted-items-re
|
||||
// "Show older" past 2000. Restoring asks the server to put them back in the
|
||||
// Inbox; it does that in the background, and the mail list picks them up
|
||||
// from the push that follows. The admin's day limit comes from
|
||||
// lib/recovery/policy (no limit until the app has the org policy).
|
||||
// lib/recovery/policy (the active account's organisation policy).
|
||||
|
||||
type ListState =
|
||||
| { status: 'loading' }
|
||||
@@ -52,7 +53,7 @@ export function RecoverDeletedSettings() {
|
||||
const locale = useLocaleStore((s) => s.locale);
|
||||
const timeFormat = useSettingsStore((s) => s.timeFormat);
|
||||
const { available, accountId, key } = useDeletedItemsRecovery();
|
||||
const { days } = recoveryPolicy();
|
||||
const days = usePolicyStore((s) => recoveryPolicy(s.effective).days);
|
||||
const record = useRecoveryStore((s) => s.record);
|
||||
const forgetRecent = useRecoveryStore((s) => s.forgetRecent);
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { delegationPolicy } from '../delegation/policy';
|
||||
import { recoveryPolicy } from '../recovery/policy';
|
||||
import { sanitizePolicy, type OrgPolicy } from '../org-policy';
|
||||
import { usePolicyStore } from '../../stores/policy-store';
|
||||
|
||||
// Delegates and "Recover deleted items" follow the organisation policy
|
||||
// (webmail components/settings/delegates/delegates-settings.tsx,
|
||||
// hooks/use-deleted-items-recovery.ts, components/email/trash-recovery-link.tsx).
|
||||
// Both used to answer fixed defaults whatever the admin set.
|
||||
|
||||
function policyWith(raw: Record<string, unknown>): OrgPolicy {
|
||||
const policy = sanitizePolicy({ features: {}, restrictions: {}, defaults: {}, ...raw });
|
||||
if (!policy) throw new Error('policy did not sanitize');
|
||||
return policy;
|
||||
}
|
||||
|
||||
const read = (rel: string) => fs.readFileSync(path.resolve(__dirname, '..', '..', rel), 'utf8');
|
||||
|
||||
afterEach(() => {
|
||||
usePolicyStore.setState({ effective: null });
|
||||
});
|
||||
|
||||
describe('delegationPolicy', () => {
|
||||
it('shows everything without a policy', () => {
|
||||
expect(delegationPolicy(null)).toEqual({
|
||||
hidden: false, allowedValues: null, calendarsEnabled: true, contactsEnabled: true, filesEnabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('reads the hidden page, the rights limit and the app gates', () => {
|
||||
const policy = policyWith({
|
||||
restrictions: { delegation: { hidden: true }, sharingRights: { allowedValues: ['read'] } },
|
||||
features: { calendarEnabled: false, contactsEnabled: true, filesEnabled: false },
|
||||
});
|
||||
expect(delegationPolicy(policy)).toEqual({
|
||||
hidden: true, allowedValues: ['read'], calendarsEnabled: false, contactsEnabled: true, filesEnabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("reads the active account's policy from the store by default", () => {
|
||||
usePolicyStore.setState({ effective: policyWith({ restrictions: { delegation: { hidden: true } } }) });
|
||||
expect(delegationPolicy().hidden).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recoveryPolicy', () => {
|
||||
it('shows everything without a policy', () => {
|
||||
expect(recoveryPolicy(null)).toEqual({ hidden: false, days: 0 });
|
||||
});
|
||||
|
||||
it('reads the hidden page and the day limit, clamped as the webmail does', () => {
|
||||
expect(recoveryPolicy(policyWith({
|
||||
restrictions: { deletedItemsRecovery: { hidden: true } },
|
||||
organisation: { recovery: { days: 30 } },
|
||||
}))).toEqual({ hidden: true, days: 30 });
|
||||
expect(recoveryPolicy(policyWith({ organisation: { recovery: { days: 99999 } } })).days).toBe(3650);
|
||||
expect(recoveryPolicy(policyWith({ organisation: { recovery: { days: -4 } } })).days).toBe(0);
|
||||
expect(recoveryPolicy(policyWith({ organisation: { recovery: { days: '30' } } })).days).toBe(0);
|
||||
});
|
||||
|
||||
it("reads the active account's policy from the store by default", () => {
|
||||
usePolicyStore.setState({ effective: policyWith({ organisation: { recovery: { days: 14 } } }) });
|
||||
expect(recoveryPolicy().days).toBe(14);
|
||||
});
|
||||
});
|
||||
|
||||
describe('policy wiring', () => {
|
||||
it('re-reads the policy when it changes', () => {
|
||||
expect(read('components/email/TrashRecoveryLink.tsx')).toMatch(/const days = usePolicyStore\(\(s\) => recoveryPolicy\(s\.effective\)\.days\);/);
|
||||
expect(read('components/settings/RecoverDeletedSettings.tsx')).toMatch(/const days = usePolicyStore\(\(s\) => recoveryPolicy\(s\.effective\)\.days\);/);
|
||||
expect(read('lib/recovery/use-deleted-items-recovery.ts')).toMatch(/const off = usePolicyStore\(\(s\) => recoveryPolicy\(s\.effective\)\.hidden\);/);
|
||||
expect(read('components/settings/DelegateSettings.tsx')).toMatch(/const policy = useMemo\(\(\) => delegationPolicy\(effectivePolicy\), \[effectivePolicy\]\);/);
|
||||
expect(read('lib/delegation/use-delegation-available.ts')).toMatch(/const policy = delegationPolicy\(effective\);/);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,13 @@
|
||||
// What the organisation policy says about Delegates. The webmail reads it
|
||||
// from its policy store: the admin can hide the page (restriction
|
||||
// `delegation`), limit the rights offered (`sharingRights.allowedValues`,
|
||||
// read and/or write) and switch calendars, contacts or files off
|
||||
// (`calendarEnabled`, `contactsEnabled`, `filesEnabled`). The app has no
|
||||
// policy route yet, so this answers "shown, every right, every app"; once
|
||||
// the policy arrives, this is the one place to plug it in.
|
||||
// What the organisation policy says about Delegates (webmail
|
||||
// components/settings/delegates/delegates-settings.tsx): the admin can hide
|
||||
// the page (restriction `delegation`), limit the rights offered
|
||||
// (`sharingRights.allowedValues`, read and/or write) and switch calendars,
|
||||
// contacts or files off (`calendarEnabled`, `contactsEnabled`,
|
||||
// `filesEnabled`). Read from the active account's policy (policy store); an
|
||||
// account without a webmail has none: shown, every right, every app.
|
||||
|
||||
import { isFeatureEnabled, type OrgPolicy } from '../org-policy';
|
||||
import { usePolicyStore } from '../../stores/policy-store';
|
||||
|
||||
export interface DelegationPolicy {
|
||||
/** The admin hid the page. */
|
||||
@@ -16,6 +19,13 @@ export interface DelegationPolicy {
|
||||
filesEnabled: boolean;
|
||||
}
|
||||
|
||||
export function delegationPolicy(): DelegationPolicy {
|
||||
return { hidden: false, allowedValues: null, calendarsEnabled: true, contactsEnabled: true, filesEnabled: true };
|
||||
export function delegationPolicy(policy: OrgPolicy | null = usePolicyStore.getState().effective): DelegationPolicy {
|
||||
const allowed = policy?.restrictions.sharingRights?.allowedValues;
|
||||
return {
|
||||
hidden: policy?.restrictions.delegation?.hidden === true,
|
||||
allowedValues: Array.isArray(allowed) ? allowed : null,
|
||||
calendarsEnabled: isFeatureEnabled(policy, 'calendarEnabled'),
|
||||
contactsEnabled: isFeatureEnabled(policy, 'contactsEnabled'),
|
||||
filesEnabled: isFeatureEnabled(policy, 'filesEnabled'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useAuthStore } from '../../stores/auth-store';
|
||||
import { supportsDelegation } from '../../api/delegation';
|
||||
import { delegationPolicy } from './policy';
|
||||
import { usePolicyStore } from '../../stores/policy-store';
|
||||
|
||||
/**
|
||||
* Whether Settings lists Delegates for the active account: not hidden by
|
||||
@@ -8,9 +9,11 @@ import { delegationPolicy } from './policy';
|
||||
* share (webmail settings-app supportsSharing). Re-read with every session.
|
||||
*/
|
||||
export function useDelegationAvailable(): boolean {
|
||||
// The organisation policy, re-read when it changes.
|
||||
const effective = usePolicyStore((s) => s.effective);
|
||||
return useAuthStore((s) => {
|
||||
if (!s.session) return false;
|
||||
const policy = delegationPolicy();
|
||||
const policy = delegationPolicy(effective);
|
||||
return !policy.hidden && supportsDelegation(policy);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
// What the organisation policy says about "Recover deleted items". The
|
||||
// webmail reads it from its policy store: the admin can hide the page
|
||||
// (restriction `deletedItemsRecovery`) and limit the list to the last N days
|
||||
// (`organisation.recovery.days`, 0 = everything the server keeps). The app
|
||||
// has no policy route yet, so this answers "shown, no limit"; once the
|
||||
// policy arrives, this is the one place to plug it in.
|
||||
// What the organisation policy says about "Recover deleted items" (webmail
|
||||
// hooks/use-deleted-items-recovery.ts, components/email/trash-recovery-link.tsx):
|
||||
// the admin can hide the page (restriction `deletedItemsRecovery`) and limit
|
||||
// the list to the last N days (`organisation.recovery.days`, 0 = everything
|
||||
// the server keeps; webmail lib/admin/organisation-policy.ts clamps it to
|
||||
// 0..3650). Read from the active account's policy (policy store); an account
|
||||
// without a webmail has none: shown, no limit.
|
||||
|
||||
import type { OrgPolicy } from '../org-policy';
|
||||
import { usePolicyStore } from '../../stores/policy-store';
|
||||
|
||||
export interface RecoveryPolicy {
|
||||
/** The admin hid the page: nothing is asked of the server. */
|
||||
@@ -12,6 +16,18 @@ export interface RecoveryPolicy {
|
||||
days: number;
|
||||
}
|
||||
|
||||
export function recoveryPolicy(): RecoveryPolicy {
|
||||
return { hidden: false, days: 0 };
|
||||
const MAX_DAYS = 3650;
|
||||
|
||||
function recoveryDays(organisation: Record<string, unknown> | undefined): number {
|
||||
const recovery = organisation?.recovery;
|
||||
const days = recovery && typeof recovery === 'object' ? (recovery as Record<string, unknown>).days : undefined;
|
||||
if (typeof days !== 'number' || !Number.isFinite(days)) return 0;
|
||||
return Math.min(MAX_DAYS, Math.max(0, Math.round(days)));
|
||||
}
|
||||
|
||||
export function recoveryPolicy(policy: OrgPolicy | null = usePolicyStore.getState().effective): RecoveryPolicy {
|
||||
return {
|
||||
hidden: policy?.restrictions.deletedItemsRecovery?.hidden === true,
|
||||
days: recoveryDays(policy?.organisation),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from '../../api/archived-items';
|
||||
import { useAuthStore } from '../../stores/auth-store';
|
||||
import { recoveryPolicy } from './policy';
|
||||
import { usePolicyStore } from '../../stores/policy-store';
|
||||
import { recoveryKey, useRecoveryStore, type RecoveryClient } from './recovery-store';
|
||||
|
||||
/** The archive calls for one JMAP account, as the detection store asks them. */
|
||||
@@ -41,7 +42,7 @@ export function useDeletedItemsRecovery(): DeletedItemsRecovery {
|
||||
// connectedAccountId, not accountId: the client is between sessions while an
|
||||
// account switch reconnects, and accountId throws then (a crash in render).
|
||||
const accountId = session ? jmapClient.connectedAccountId : null;
|
||||
const off = recoveryPolicy().hidden;
|
||||
const off = usePolicyStore((s) => recoveryPolicy(s.effective).hidden);
|
||||
const key = accountId ? recoveryKey(activeAccountId, accountId) : null;
|
||||
const check = useRecoveryStore((s) => (key ? s.checks[key] : undefined));
|
||||
const detect = useRecoveryStore((s) => s.detect);
|
||||
|
||||
Reference in New Issue
Block a user