feat: manage shared/group account settings from Accounts page
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user