feat: manage shared/group account settings from Accounts page

This commit is contained in:
Linus Rath
2026-06-14 17:05:29 +02:00
parent 848ed9774d
commit 4c6c1aab60
16 changed files with 577 additions and 103 deletions
+71 -3
View File
@@ -1,11 +1,13 @@
"use client";
import { useState, useRef, useCallback } from 'react';
import { useState, useRef, useCallback, useMemo } from 'react';
import { useTranslations } from 'next-intl';
import { Check, GripVertical, Plus, Star, AlertCircle } from 'lucide-react';
import { Check, GripVertical, Plus, Star, AlertCircle, ChevronRight } from 'lucide-react';
import { useAuthStore } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store';
import { useAccountStore, type AccountEntry } from '@/stores/account-store';
import { useManagedAccountStore } from '@/stores/managed-account-store';
import type { SharedAccount } from '@/lib/jmap/types';
import { SettingsSection, SettingItem } from './settings-section';
import { Avatar } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
@@ -17,12 +19,30 @@ function hostnameOf(serverUrl: string): string {
try { return new URL(serverUrl).hostname; } catch { return serverUrl; }
}
// First scoped settings tab to land on for a shared account, by capability.
// Mirrors the scoped-tab gating in the settings page. null = nothing editable.
function firstScopedTab(caps: SharedAccount['capabilities']): string | null {
if (caps.sieve) return 'filters';
if (caps.mail) return 'vacation';
if (caps.calendars) return 'calendar';
if (caps.contacts) return 'contacts';
return null;
}
export function AccountSettings() {
const t = useTranslations('settings.account');
const router = useRouter();
const { username, serverUrl, isDemoMode, primaryIdentity, authMode } = useAuthStore();
const { username, serverUrl, isDemoMode, primaryIdentity, authMode, client } = useAuthStore();
const activeAccountId = useAuthStore((s) => s.activeAccountId);
const switchAccount = useAuthStore((s) => s.switchAccount);
const setManagedAccount = useManagedAccountStore((s) => s.setManagedAccount);
// Shared/group accounts delegated to this session (excludes the user's own
// primary account). These can be drilled into for scoped settings editing.
const sharedAccounts = useMemo<SharedAccount[]>(
() => (client?.getSharedAccounts() ?? []).filter((a) => !a.isPrimary),
[client],
);
const { quota } = useEmailStore();
const accounts = useAccountStore((s) => s.accounts);
const setDefaultAccount = useAccountStore((s) => s.setDefaultAccount);
@@ -82,6 +102,16 @@ export function AccountSettings() {
router.push(`/login?mode=add-account` as never);
}, [router]);
// Enter scoped settings mode for a shared/group account: set the managed
// account, then steer the settings panel to its first editable tab via the
// existing 'settings-tab-change' event the page already listens for.
const handleManageShared = useCallback((account: SharedAccount) => {
const tab = firstScopedTab(account.capabilities);
if (!tab) return;
setManagedAccount(account);
window.dispatchEvent(new CustomEvent('settings-tab-change', { detail: tab }));
}, [setManagedAccount]);
return (
<div className="space-y-8">
<SettingsSection title={t('title')} description={t('description')}>
@@ -197,6 +227,44 @@ export function AccountSettings() {
</div>
</SettingsSection>
)}
{/* Shared / group accounts delegated to this session. Clicking one drills
into a scoped settings view (filters, vacation, calendars, contacts). */}
{sharedAccounts.length > 0 && (
<SettingsSection title={t('shared_accounts.title')} description={t('shared_accounts.description')}>
<div className="space-y-2">
{sharedAccounts.map((acc) => {
const editable = firstScopedTab(acc.capabilities) !== null;
return (
<button
key={acc.id}
type="button"
onClick={() => handleManageShared(acc)}
disabled={!editable}
className={cn(
'flex items-center gap-3 w-full p-3 border border-border rounded-lg text-left transition-colors',
editable ? 'hover:bg-muted/50 cursor-pointer' : 'opacity-60 cursor-not-allowed',
)}
>
<Avatar
name={acc.name}
size="sm"
className="w-9 h-9 text-sm flex-shrink-0"
disableFavicon
/>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{acc.name}</p>
<p className="text-xs text-muted-foreground truncate">
{t('shared_accounts.shared_label')}
</p>
</div>
<ChevronRight className="w-4 h-4 text-muted-foreground flex-shrink-0" />
</button>
);
})}
</div>
</SettingsSection>
)}
</div>
);
}
@@ -5,6 +5,7 @@ import { useTranslations } from "next-intl";
import { Book, Pencil, Share2, Tag, Users } from "lucide-react";
import { useContactStore } from "@/stores/contact-store";
import { useAuthStore } from "@/stores/auth-store";
import { useManagedAccountStore } from "@/stores/managed-account-store";
import { toast } from "@/stores/toast-store";
import { SettingsSection } from "./settings-section";
import { cn } from "@/lib/utils";
@@ -71,6 +72,7 @@ export function AddressBookManagementSettings() {
const tContacts = useTranslations("contacts");
const tSettings = useTranslations("settings.contacts");
const { client } = useAuthStore();
const managedAccountId = useManagedAccountStore((s) => s.managedAccountId);
const { addressBooks, contacts, supportsSync, fetchAddressBooks, renameAddressBook, shareAddressBook, renameKeyword } = useContactStore();
const [editingId, setEditingId] = useState<string | null>(null);
const [editingKeyword, setEditingKeyword] = useState<string | null>(null);
@@ -205,9 +207,13 @@ export function AddressBookManagementSettings() {
<>
<SettingsSection title={tSettings("manage_title")} description={tSettings("manage_description")}>
<div className="space-y-2">
{personal.map(renderBook)}
{/* In scoped mode (managing a shared account) hide the user's own
address books and show only the managed account's group. */}
{(managedAccountId ? [] : personal).map(renderBook)}
{Array.from(sharedGroups.entries()).map(([accountId, group]) => (
{Array.from(sharedGroups.entries())
.filter(([accountId]) => !managedAccountId || accountId === managedAccountId)
.map(([accountId, group]) => (
<div key={accountId} className="mt-4 space-y-2">
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wider flex items-center gap-1.5">
<Share2 className="w-3 h-3" />
@@ -223,6 +229,9 @@ export function AddressBookManagementSettings() {
</div>
</SettingsSection>
{/* Contact categories come from the active account's contacts, not the
shared account, so hide them while scoped to a shared account. */}
{!managedAccountId && (
<div className="mt-8">
<SettingsSection title={tSettings("categories_title")} description={tSettings("categories_description")}>
<div className="space-y-2">
@@ -267,6 +276,7 @@ export function AddressBookManagementSettings() {
</div>
</SettingsSection>
</div>
)}
{sharingId && client && (() => {
const book = addressBooks.find((b) => b.id === sharingId);
@@ -14,6 +14,7 @@ import { cn, formatDateTime, redactUrlCredentials } from '@/lib/utils';
import { ICalImportModal } from '@/components/calendar/ical-import-modal';
import { ICalSubscriptionModal } from '@/components/calendar/ical-subscription-modal';
import { useSettingsStore } from '@/stores/settings-store';
import { useManagedAccountStore } from '@/stores/managed-account-store';
import { apiFetch } from '@/lib/browser-navigation';
import { CALENDAR_COLORS, sharedCalendarColorKey } from '@/lib/shared-calendar-colors';
@@ -137,6 +138,7 @@ export { CalendarColorPicker, CALENDAR_COLORS };
export function CalendarManagementSettings() {
const t = useTranslations('calendar.management');
const { client, serverUrl, username } = useAuthStore();
const managedAccountId = useManagedAccountStore((s) => s.managedAccountId);
const { calendars, updateCalendar, shareCalendar, createCalendar, removeCalendar, clearCalendarEvents, fetchCalendars, icalSubscriptions: allSubs, removeICalSubscription, refreshICalSubscription, isSubscriptionCalendar } = useCalendarStore();
// Subscriptions are persisted globally but scoped per JMAP account via
// accountId. Legacy entries with no accountId show in the active account.
@@ -388,7 +390,10 @@ export function CalendarManagementSettings() {
return (
<SettingsSection title={t('title')} description={t('description')}>
<div className="space-y-2">
{calendars.filter(cal => !isSubscriptionCalendar(cal.id)).map((cal) => {
{calendars
.filter(cal => !isSubscriptionCalendar(cal.id))
.filter(cal => !managedAccountId || cal.accountId === managedAccountId)
.map((cal) => {
const color = (cal.isShared && sharedCalendarColors[sharedCalendarColorKey(cal)]) || cal.color || '#3b82f6';
if (editingId === cal.id) {
+44 -5
View File
@@ -12,7 +12,9 @@ import { useEmailStore } from "@/stores/email-store";
import { useSettingsStore } from "@/stores/settings-store";
import { toast } from "@/stores/toast-store";
import type { FilterRule } from "@/lib/jmap/sieve-types";
import type { Mailbox } from "@/lib/jmap/types";
import { useVacationStore } from "@/stores/vacation-store";
import { useManagedAccountStore } from "@/stores/managed-account-store";
import {
Plus,
GripVertical,
@@ -157,7 +159,7 @@ export function FilterSettings() {
const t = useTranslations("settings.filters");
const tNotifications = useTranslations("notifications");
const { client } = useAuthStore();
const mailboxes = useEmailStore((s) => s.mailboxes);
const storeMailboxes = useEmailStore((s) => s.mailboxes);
const expandedFilterView = useSettingsStore((s) => s.expandedFilterView);
const updateSetting = useSettingsStore((s) => s.updateSetting);
@@ -170,7 +172,7 @@ export function FilterSettings() {
isOpaque,
rawScript,
vacationSettings,
fetchFilters,
selectAccount,
saveFilters,
addRule,
updateRule,
@@ -182,7 +184,41 @@ export function FilterSettings() {
validateScript,
} = useFilterStore();
const vacationEnabled = useVacationStore((s) => s.isEnabled) || vacationSettings?.isEnabled;
// Scoped to a shared/group account when the settings panel is managing one.
const managedAccountId = useManagedAccountStore((s) => s.managedAccountId);
const isPrimaryAccount = !managedAccountId;
// Folders offered as "move to" targets in the rule editor must belong to the
// account whose filters we're editing. For a shared account, fetch that
// account's mailboxes (the email store only holds the active account's). They
// are this account's own folders within its Sieve context, so present them as
// non-shared — otherwise the rule modal filters them out (it drops isShared
// mailboxes, which are normally other accounts' folders merged into the view).
const [scopedMailboxes, setScopedMailboxes] = useState<Mailbox[]>([]);
useEffect(() => {
if (!client || !managedAccountId) {
setScopedMailboxes([]);
return;
}
let cancelled = false;
void client
.getMailboxes(managedAccountId)
.then((mbs) => {
if (!cancelled) setScopedMailboxes(mbs.map((mb) => ({ ...mb, isShared: false })));
})
.catch(() => {
if (!cancelled) setScopedMailboxes([]);
});
return () => { cancelled = true; };
}, [client, managedAccountId]);
const mailboxes = managedAccountId ? scopedMailboxes : storeMailboxes;
const vacationStoreEnabled = useVacationStore((s) => s.isEnabled);
// Vacation uses a separate per-(primary)-account mechanism (RFC 9661), so the
// "vacation active" banner only applies when editing the personal account.
const vacationEnabled =
isPrimaryAccount && (vacationStoreEnabled || vacationSettings?.isEnabled);
const [editingRule, setEditingRule] = useState<FilterRule | undefined>();
const [showRuleModal, setShowRuleModal] = useState(false);
@@ -194,9 +230,12 @@ export function FilterSettings() {
useEffect(() => {
if (client && isSupported) {
void fetchFilters(client);
// Always pass a concrete account id (managed shared account, or the
// primary Sieve account) so switching never falls back to a stale
// previously-selected account.
void selectAccount(client, managedAccountId ?? client.getSieveAccountId());
}
}, [client, isSupported, fetchFilters]);
}, [client, isSupported, managedAccountId, selectAccount]);
const handleToggle = useCallback(
async (ruleId: string) => {
+5 -3
View File
@@ -6,6 +6,7 @@ import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section';
import { Button } from '@/components/ui/button';
import { useVacationStore } from '@/stores/vacation-store';
import { useAuthStore } from '@/stores/auth-store';
import { useManagedAccountStore } from '@/stores/managed-account-store';
import { Loader2, AlertTriangle, Eye, EyeOff } from 'lucide-react';
import { toast } from '@/stores/toast-store';
@@ -19,6 +20,7 @@ export function VacationSettings() {
const t = useTranslations('settings.vacation');
const tNotifications = useTranslations('notifications');
const { client } = useAuthStore();
const managedAccountId = useManagedAccountStore((s) => s.managedAccountId);
const {
isEnabled,
fromDate,
@@ -43,9 +45,9 @@ export function VacationSettings() {
useEffect(() => {
if (client && isSupported) {
void fetchVacationResponse(client);
void fetchVacationResponse(client, managedAccountId ?? undefined);
}
}, [client, isSupported, fetchVacationResponse]);
}, [client, isSupported, managedAccountId, fetchVacationResponse]);
useEffect(() => {
setLocalEnabled(isEnabled);
@@ -101,7 +103,7 @@ export function VacationSettings() {
toDate: localToDate || null,
subject: localSubject,
textBody: localTextBody,
});
}, managedAccountId ?? undefined);
toast.success(tNotifications('vacation_saved'));
} catch (error) {