Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4501b3894b | ||
|
|
2edf2fab89 | ||
|
|
d493bb17dc | ||
|
|
234129397d | ||
|
|
9b3a47f9be | ||
|
|
0fcc932e66 |
@@ -1,5 +1,21 @@
|
||||
# Changelog
|
||||
|
||||
## 1.4.3 (2026-03-19)
|
||||
|
||||
### Features
|
||||
|
||||
- **Auth**: Implement multi-account support with up to 5 simultaneous accounts and instant switching
|
||||
- **Auth**: Add account switcher component with connection status, default account selection, and per-account logout
|
||||
- **Auth**: Support multi-account OAuth and basic auth with per-account session persistence
|
||||
- **Contacts**: Enhance contacts sidebar with collapsible sections, bulk operations, and address book grouping
|
||||
- **Contacts**: Add contact import functionality and keyword filtering
|
||||
- **Settings**: Add per-account encrypted settings storage with server-side sync support
|
||||
|
||||
### Fixes
|
||||
|
||||
- **UI**: Adjust popover alignment in sub-address helper component
|
||||
- **Settings**: Improve error logging in settings sync functionality
|
||||
|
||||
## 1.4.2 (2026-03-19)
|
||||
|
||||
### Features
|
||||
|
||||
@@ -12,7 +12,7 @@ A modern, self-hosted webmail client for [Stalwart Mail Server](https://stalw.ar
|
||||
Built with Next.js and the JMAP protocol.
|
||||
|
||||
[](LICENSE)
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](https://ghcr.io/bulwarkmail/webmail)
|
||||
|
||||
</div>
|
||||
|
||||
@@ -53,6 +53,7 @@ function OAuthCallbackInner() {
|
||||
sessionStorage.removeItem("oauth_state");
|
||||
sessionStorage.removeItem("oauth_code_verifier");
|
||||
sessionStorage.removeItem("oauth_server_url");
|
||||
sessionStorage.removeItem("oauth_add_account_mode");
|
||||
let redirectTo = `/${params.locale}`;
|
||||
try {
|
||||
const saved = sessionStorage.getItem('redirect_after_login');
|
||||
|
||||
@@ -611,6 +611,7 @@ export default function CalendarPage() {
|
||||
|
||||
const visibleEvents = useMemo(() =>
|
||||
events.filter((e) => {
|
||||
if (!e.calendarIds) return false;
|
||||
const calIds = Object.keys(e.calendarIds);
|
||||
return calIds.some((id) => selectedCalendarIds.includes(id));
|
||||
}),
|
||||
@@ -711,7 +712,7 @@ export default function CalendarPage() {
|
||||
collapsed
|
||||
quota={quota}
|
||||
isPushConnected={isPushConnected}
|
||||
onLogout={() => { logout(); router.push('/login'); }}
|
||||
onLogout={() => { logout(); if (!useAuthStore.getState().isAuthenticated) router.push('/login'); }}
|
||||
onManageApps={handleManageApps}
|
||||
onInlineApp={handleInlineApp}
|
||||
onCloseInlineApp={closeInlineApp}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { ContactForm } from "@/components/contacts/contact-form";
|
||||
import { ContactGroupForm } from "@/components/contacts/contact-group-form";
|
||||
import { ContactGroupDetail } from "@/components/contacts/contact-group-detail";
|
||||
import { ContactsSidebar, type ContactCategory } from "@/components/contacts/contacts-sidebar";
|
||||
import { ContactImportDialog } from "@/components/contacts/contact-import-dialog";
|
||||
import { exportContacts } from "@/components/contacts/contact-export";
|
||||
import { useContactStore, getContactDisplayName } from "@/stores/contact-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
@@ -73,10 +74,12 @@ export default function ContactsPage() {
|
||||
bulkDeleteContacts,
|
||||
bulkAddToGroup,
|
||||
moveContactToAddressBook,
|
||||
importContacts,
|
||||
} = useContactStore();
|
||||
|
||||
const [view, setView] = useState<View>("list");
|
||||
const [activeCategory, setActiveCategory] = useState<ContactCategory>("all");
|
||||
const [showImportDialog, setShowImportDialog] = useState(false);
|
||||
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
|
||||
const hasFetched = useRef(false);
|
||||
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
||||
@@ -84,10 +87,10 @@ export default function ContactsPage() {
|
||||
|
||||
// Panel resize state - sidebar (categories)
|
||||
const [sidebarWidth, setSidebarWidth] = useState(() => {
|
||||
try { const v = localStorage.getItem("contacts-sidebar-width"); return v ? Number(v) : 180; } catch { return 180; }
|
||||
try { const v = localStorage.getItem("contacts-sidebar-width"); return v ? Number(v) : 256; } catch { return 256; }
|
||||
});
|
||||
const [isSidebarResizing, setIsSidebarResizing] = useState(false);
|
||||
const sidebarDragStartWidth = useRef(180);
|
||||
const sidebarDragStartWidth = useRef(256);
|
||||
|
||||
// Panel resize state - contact list
|
||||
const [listWidth, setListWidth] = useState(() => {
|
||||
@@ -125,21 +128,17 @@ export default function ContactsPage() {
|
||||
|
||||
// Contacts to display based on active category
|
||||
const displayedContacts = useMemo(() => {
|
||||
if (activeCategory === "all") return individuals.filter(c => !c.isShared);
|
||||
if (activeCategory === "all") return individuals;
|
||||
if ("addressBookId" in activeCategory) {
|
||||
const bookId = activeCategory.addressBookId;
|
||||
return individuals.filter(c => {
|
||||
if (!c.addressBookIds) return false;
|
||||
// Check both namespaced (accountId:bookId) and raw bookId
|
||||
if (c.addressBookIds[bookId]) return true;
|
||||
// For shared contacts, match namespaced id
|
||||
if (c.isShared && c.accountId) {
|
||||
const namespacedId = `${c.accountId}:${Object.keys(c.addressBookIds).find(k => c.addressBookIds[k])}`;
|
||||
return namespacedId === bookId;
|
||||
}
|
||||
return false;
|
||||
return c.addressBookIds[bookId] === true;
|
||||
});
|
||||
}
|
||||
if ("keyword" in activeCategory) {
|
||||
return individuals.filter(c => c.keywords?.[activeCategory.keyword]);
|
||||
}
|
||||
// Show members of the selected group
|
||||
return getGroupMembers(activeCategory.groupId);
|
||||
}, [activeCategory, individuals, getGroupMembers]);
|
||||
@@ -151,6 +150,9 @@ export default function ContactsPage() {
|
||||
const book = addressBooks.find(b => b.id === activeCategory.addressBookId);
|
||||
return book?.name || t("tabs.all");
|
||||
}
|
||||
if ("keyword" in activeCategory) {
|
||||
return activeCategory.keyword;
|
||||
}
|
||||
const group = contacts.find(c => c.id === activeCategory.groupId);
|
||||
return group ? getContactDisplayName(group) : t("tabs.all");
|
||||
}, [activeCategory, contacts, addressBooks, t]);
|
||||
@@ -160,6 +162,7 @@ export default function ContactsPage() {
|
||||
clearSelection();
|
||||
if (typeof category === "object" && "groupId" in category) {
|
||||
setSelectedGroupId(category.groupId);
|
||||
setView("group-detail");
|
||||
} else {
|
||||
setSelectedGroupId(null);
|
||||
}
|
||||
@@ -179,6 +182,13 @@ export default function ContactsPage() {
|
||||
}
|
||||
}, [client, moveContactToAddressBook, t]);
|
||||
|
||||
const handleImportContacts = useCallback(async (importedContacts: ContactCard[]) => {
|
||||
return importContacts(
|
||||
supportsSync && client ? client : null,
|
||||
importedContacts
|
||||
);
|
||||
}, [supportsSync, client, importContacts]);
|
||||
|
||||
const handleSelectContact = (id: string) => {
|
||||
setSelectedContact(id);
|
||||
clearSelection();
|
||||
@@ -273,6 +283,35 @@ export default function ContactsPage() {
|
||||
setView("group-edit");
|
||||
};
|
||||
|
||||
const handleEditGroupFromSidebar = useCallback((groupId: string) => {
|
||||
setSelectedGroupId(groupId);
|
||||
setActiveCategory({ groupId });
|
||||
setView("group-edit");
|
||||
}, []);
|
||||
|
||||
const handleDeleteGroupFromSidebar = useCallback(async (groupId: string) => {
|
||||
const confirmed = await confirmDialog({
|
||||
title: t("groups.delete_confirm_title"),
|
||||
message: t("groups.delete_confirm"),
|
||||
confirmText: t("form.delete"),
|
||||
variant: "destructive",
|
||||
});
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
await deleteGroup(supportsSync && client ? client : null, groupId);
|
||||
toast.success(t("toast.deleted"));
|
||||
if (selectedGroupId === groupId) {
|
||||
setSelectedGroupId(null);
|
||||
setActiveCategory("all");
|
||||
setView("list");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete group:', error);
|
||||
toast.error(t("toast.error_delete"));
|
||||
}
|
||||
}, [confirmDialog, deleteGroup, supportsSync, client, selectedGroupId, t]);
|
||||
|
||||
const handleDeleteGroup = async () => {
|
||||
if (!selectedGroup) return;
|
||||
|
||||
@@ -416,7 +455,6 @@ export default function ContactsPage() {
|
||||
isMobile={isMobile}
|
||||
onSelectMember={(id) => {
|
||||
setSelectedContact(id);
|
||||
setActiveCategory("all");
|
||||
setView("detail");
|
||||
}}
|
||||
/>
|
||||
@@ -514,7 +552,7 @@ export default function ContactsPage() {
|
||||
collapsed
|
||||
quota={quota}
|
||||
isPushConnected={isPushConnected}
|
||||
onLogout={() => { logout(); router.push('/login'); }}
|
||||
onLogout={() => { logout(); if (!useAuthStore.getState().isAuthenticated) router.push('/login'); }}
|
||||
onManageApps={handleManageApps}
|
||||
onInlineApp={handleInlineApp}
|
||||
onCloseInlineApp={closeInlineApp}
|
||||
@@ -548,17 +586,20 @@ export default function ContactsPage() {
|
||||
onSelectCategory={handleSelectCategory}
|
||||
onCreateGroup={handleCreateGroup}
|
||||
onCreateContact={handleCreateNew}
|
||||
onImport={() => setShowImportDialog(true)}
|
||||
onEditGroup={handleEditGroupFromSidebar}
|
||||
onDeleteGroup={handleDeleteGroupFromSidebar}
|
||||
onDropContacts={handleDropContacts}
|
||||
/>
|
||||
</div>
|
||||
<ResizeHandle
|
||||
onResizeStart={() => { sidebarDragStartWidth.current = sidebarWidth; setIsSidebarResizing(true); }}
|
||||
onResize={(delta) => setSidebarWidth(Math.max(140, Math.min(300, sidebarDragStartWidth.current + delta)))}
|
||||
onResize={(delta) => setSidebarWidth(Math.max(180, Math.min(400, sidebarDragStartWidth.current + delta)))}
|
||||
onResizeEnd={() => {
|
||||
setIsSidebarResizing(false);
|
||||
localStorage.setItem("contacts-sidebar-width", String(sidebarWidth));
|
||||
}}
|
||||
onDoubleClick={() => { setSidebarWidth(180); localStorage.setItem("contacts-sidebar-width", "180"); }}
|
||||
onDoubleClick={() => { setSidebarWidth(256); localStorage.setItem("contacts-sidebar-width", "256"); }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -642,6 +683,17 @@ export default function ContactsPage() {
|
||||
|
||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||
<ConfirmDialog {...confirmDialogProps} />
|
||||
{showImportDialog && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-background rounded-lg border border-border shadow-xl w-full max-w-2xl max-h-[80vh] overflow-hidden">
|
||||
<ContactImportDialog
|
||||
existingContacts={contacts}
|
||||
onImport={handleImportContacts}
|
||||
onClose={() => setShowImportDialog(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -357,7 +357,7 @@ export default function FilesPage() {
|
||||
collapsed
|
||||
quota={quota}
|
||||
isPushConnected={isPushConnected}
|
||||
onLogout={() => { logout(); router.push('/login'); }}
|
||||
onLogout={() => { logout(); if (!useAuthStore.getState().isAuthenticated) router.push('/login'); }}
|
||||
onManageApps={handleManageApps}
|
||||
onInlineApp={handleInlineApp}
|
||||
onCloseInlineApp={closeInlineApp}
|
||||
|
||||
+25
-15
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useParams, useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -16,7 +16,7 @@ import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery";
|
||||
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
|
||||
import { OAUTH_SCOPES } from "@/lib/oauth/tokens";
|
||||
|
||||
const APP_VERSION = "1.4.2";
|
||||
const APP_VERSION = "1.4.3";
|
||||
|
||||
const THEME_OPTIONS = [
|
||||
{ value: "light" as const, icon: Sun, label: "Light" },
|
||||
@@ -28,6 +28,8 @@ export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const t = useTranslations("login");
|
||||
const params = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
const isAddAccountMode = searchParams.get("mode") === "add-account";
|
||||
const { login, isLoading, error, clearError, isAuthenticated } = useAuthStore();
|
||||
const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme })));
|
||||
const { appName, jmapServerUrl: serverUrl, oauthEnabled, oauthOnly, oauthClientId, oauthIssuerUrl, rememberMeEnabled, devMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError } = useConfig();
|
||||
@@ -102,7 +104,7 @@ export default function LoginPage() {
|
||||
}, [serverUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
if (isAuthenticated && !isAddAccountMode) {
|
||||
let redirectTo = '/';
|
||||
try {
|
||||
const saved = sessionStorage.getItem('redirect_after_login');
|
||||
@@ -113,7 +115,7 @@ export default function LoginPage() {
|
||||
} catch { /* ignore */ }
|
||||
router.push(redirectTo);
|
||||
}
|
||||
}, [isAuthenticated, router]);
|
||||
}, [isAuthenticated, router, isAddAccountMode]);
|
||||
|
||||
useEffect(() => {
|
||||
clearError();
|
||||
@@ -303,6 +305,9 @@ export default function LoginPage() {
|
||||
sessionStorage.setItem("oauth_code_verifier", verifier);
|
||||
sessionStorage.setItem("oauth_state", state);
|
||||
sessionStorage.setItem("oauth_server_url", serverUrl!);
|
||||
if (isAddAccountMode) {
|
||||
sessionStorage.setItem("oauth_add_account_mode", "true");
|
||||
}
|
||||
|
||||
const authUrl = new URL(oauthMetadata.authorization_endpoint);
|
||||
authUrl.searchParams.set("response_type", "code");
|
||||
@@ -329,15 +334,7 @@ export default function LoginPage() {
|
||||
|
||||
if (success) {
|
||||
saveUsername(formData.username);
|
||||
let redirectTo = '/';
|
||||
try {
|
||||
const saved = sessionStorage.getItem('redirect_after_login');
|
||||
if (saved) {
|
||||
sessionStorage.removeItem('redirect_after_login');
|
||||
redirectTo = saved;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
router.push(redirectTo);
|
||||
router.push('/');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -426,10 +423,10 @@ export default function LoginPage() {
|
||||
/>
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-foreground tracking-tight">
|
||||
{appName}
|
||||
{isAddAccountMode ? t("add_account_title") : appName}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1.5">
|
||||
{t("title") !== appName ? t("title") : "Sign in to your account"}
|
||||
{isAddAccountMode ? t("add_account_subtitle") : (t("title") !== appName ? t("title") : "Sign in to your account")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -737,6 +734,19 @@ export default function LoginPage() {
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
|
||||
{isAddAccountMode && (
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="w-full h-10 text-sm text-muted-foreground hover:text-foreground"
|
||||
onClick={() => router.push('/')}
|
||||
>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -769,7 +769,9 @@ export default function Home() {
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
router.push('/login');
|
||||
if (!useAuthStore.getState().isAuthenticated) {
|
||||
router.push('/login');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = async (query: string) => {
|
||||
|
||||
@@ -286,7 +286,7 @@ export default function SettingsPage() {
|
||||
{/* Logout */}
|
||||
<div className="border-t border-border px-5 py-3">
|
||||
<button
|
||||
onClick={() => { logout(); router.push('/login'); }}
|
||||
onClick={() => { logout(); if (!useAuthStore.getState().isAuthenticated) router.push('/login'); }}
|
||||
className="w-full flex items-center gap-3 py-2.5 text-sm text-destructive hover:bg-muted rounded-md px-2 transition-colors duration-150"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
@@ -317,7 +317,7 @@ export default function SettingsPage() {
|
||||
collapsed
|
||||
quota={quota}
|
||||
isPushConnected={isPushConnected}
|
||||
onLogout={() => { logout(); router.push('/login'); }}
|
||||
onLogout={() => { logout(); if (!useAuthStore.getState().isAuthenticated) router.push('/login'); }}
|
||||
onManageApps={handleManageApps}
|
||||
onInlineApp={handleInlineApp}
|
||||
onCloseInlineApp={closeInlineApp}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { encryptSession, decryptSession } from '@/lib/auth/crypto';
|
||||
import { SESSION_COOKIE, SESSION_COOKIE_MAX_AGE } from '@/lib/auth/session-cookie';
|
||||
import { SESSION_COOKIE_MAX_AGE, sessionCookieName } from '@/lib/auth/session-cookie';
|
||||
|
||||
const COOKIE_OPTIONS = {
|
||||
httpOnly: true,
|
||||
@@ -12,20 +12,30 @@ const COOKIE_OPTIONS = {
|
||||
maxAge: SESSION_COOKIE_MAX_AGE,
|
||||
};
|
||||
|
||||
function getSlot(request: NextRequest): number {
|
||||
const raw = request.nextUrl.searchParams.get('slot');
|
||||
if (raw === null) return 0;
|
||||
const slot = parseInt(raw, 10);
|
||||
if (isNaN(slot) || slot < 0 || slot > 4) return 0;
|
||||
return slot;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
if (process.env.OAUTH_ENABLED === 'true' && process.env.OAUTH_ONLY === 'true') {
|
||||
return NextResponse.json({ error: 'Basic authentication is disabled' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { serverUrl, username, password } = await request.json();
|
||||
const { serverUrl, username, password, slot: bodySlot } = await request.json();
|
||||
if (!serverUrl || !username || !password) {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : getSlot(request);
|
||||
const cookieName = sessionCookieName(slot);
|
||||
const token = encryptSession(serverUrl, username, password);
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(SESSION_COOKIE, token, COOKIE_OPTIONS);
|
||||
cookieStore.set(cookieName, token, COOKIE_OPTIONS);
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
@@ -34,10 +44,12 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const slot = getSlot(request);
|
||||
const cookieName = sessionCookieName(slot);
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get(SESSION_COOKIE)?.value;
|
||||
const token = cookieStore.get(cookieName)?.value;
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: 'No session' }, { status: 401 });
|
||||
@@ -45,7 +57,7 @@ export async function GET() {
|
||||
|
||||
const credentials = decryptSession(token);
|
||||
if (!credentials) {
|
||||
cookieStore.delete(SESSION_COOKIE);
|
||||
cookieStore.delete(cookieName);
|
||||
return NextResponse.json({ error: 'Invalid session' }, { status: 401 });
|
||||
}
|
||||
|
||||
@@ -58,10 +70,21 @@ export async function GET() {
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE() {
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.delete(SESSION_COOKIE);
|
||||
const all = request.nextUrl.searchParams.get('all') === 'true';
|
||||
|
||||
if (all) {
|
||||
// Delete all session cookies (slots 0-4)
|
||||
for (let i = 0; i <= 4; i++) {
|
||||
cookieStore.delete(sessionCookieName(i));
|
||||
}
|
||||
} else {
|
||||
const slot = getSlot(request);
|
||||
cookieStore.delete(sessionCookieName(slot));
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
logger.error('Session clear error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
|
||||
+52
-10
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { discoverOAuth } from '@/lib/oauth/discovery';
|
||||
import { REFRESH_TOKEN_COOKIE } from '@/lib/oauth/tokens';
|
||||
import { refreshTokenCookieName } from '@/lib/oauth/tokens';
|
||||
|
||||
const CLIENT_SECRET = process.env.OAUTH_CLIENT_SECRET || '';
|
||||
|
||||
@@ -14,6 +14,15 @@ const COOKIE_OPTIONS = {
|
||||
maxAge: 30 * 24 * 60 * 60,
|
||||
};
|
||||
|
||||
function getSlot(request: NextRequest): number {
|
||||
const raw = request.nextUrl.searchParams.get('slot');
|
||||
if (raw === null) return 0;
|
||||
const slot = parseInt(raw, 10);
|
||||
if (isNaN(slot) || slot < 0 || slot > 4) return 0;
|
||||
return slot;
|
||||
}
|
||||
|
||||
|
||||
function getRequiredConfig() {
|
||||
const clientId = process.env.OAUTH_CLIENT_ID;
|
||||
const serverUrl = process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL;
|
||||
@@ -53,12 +62,13 @@ function buildOAuthParams(base: Record<string, string>): URLSearchParams {
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { code, code_verifier, redirect_uri } = await request.json();
|
||||
const { code, code_verifier, redirect_uri, slot: bodySlot } = await request.json();
|
||||
|
||||
if (!code || !code_verifier || !redirect_uri) {
|
||||
return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 });
|
||||
}
|
||||
|
||||
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : getSlot(request);
|
||||
const tokenEndpoint = await getTokenEndpoint();
|
||||
|
||||
const params = buildOAuthParams({
|
||||
@@ -93,8 +103,9 @@ export async function POST(request: NextRequest) {
|
||||
});
|
||||
|
||||
if (tokens.refresh_token) {
|
||||
const cookieName = refreshTokenCookieName(slot);
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(REFRESH_TOKEN_COOKIE, tokens.refresh_token, COOKIE_OPTIONS);
|
||||
cookieStore.set(cookieName, tokens.refresh_token, COOKIE_OPTIONS);
|
||||
}
|
||||
|
||||
return response;
|
||||
@@ -104,10 +115,12 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT() {
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const slot = getSlot(request);
|
||||
const cookieName = refreshTokenCookieName(slot);
|
||||
const cookieStore = await cookies();
|
||||
const refreshToken = cookieStore.get(REFRESH_TOKEN_COOKIE)?.value;
|
||||
const refreshToken = cookieStore.get(cookieName)?.value;
|
||||
|
||||
if (!refreshToken) {
|
||||
return NextResponse.json({ error: 'No refresh token' }, { status: 401 });
|
||||
@@ -129,7 +142,7 @@ export async function PUT() {
|
||||
if (!tokenResponse.ok) {
|
||||
const errorText = await tokenResponse.text();
|
||||
logger.error('Token refresh failed', { status: tokenResponse.status, error: errorText });
|
||||
cookieStore.delete(REFRESH_TOKEN_COOKIE);
|
||||
cookieStore.delete(cookieName);
|
||||
return NextResponse.json({ error: 'Refresh failed' }, { status: 401 });
|
||||
}
|
||||
|
||||
@@ -141,7 +154,7 @@ export async function PUT() {
|
||||
}
|
||||
|
||||
if (tokens.refresh_token) {
|
||||
cookieStore.set(REFRESH_TOKEN_COOKIE, tokens.refresh_token, COOKIE_OPTIONS);
|
||||
cookieStore.set(cookieName, tokens.refresh_token, COOKIE_OPTIONS);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
@@ -154,10 +167,39 @@ export async function PUT() {
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE() {
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const all = request.nextUrl.searchParams.get('all') === 'true';
|
||||
|
||||
if (all) {
|
||||
// Revoke and delete all refresh token cookies (slots 0-4)
|
||||
const cookieStore = await cookies();
|
||||
for (let i = 0; i <= 4; i++) {
|
||||
const name = refreshTokenCookieName(i);
|
||||
const token = cookieStore.get(name)?.value;
|
||||
if (token) {
|
||||
// Best-effort revocation
|
||||
try {
|
||||
const metadata = await getMetadata().catch(() => null);
|
||||
if (metadata?.revocation_endpoint) {
|
||||
const params = buildOAuthParams({ token, token_type_hint: 'refresh_token' });
|
||||
await fetch(metadata.revocation_endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: params.toString(),
|
||||
}).catch(() => {});
|
||||
}
|
||||
} catch { /* best effort */ }
|
||||
cookieStore.delete(name);
|
||||
}
|
||||
}
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
const slot = getSlot(request);
|
||||
const cookieName = refreshTokenCookieName(slot);
|
||||
const cookieStore = await cookies();
|
||||
const refreshToken = cookieStore.get(REFRESH_TOKEN_COOKIE)?.value;
|
||||
const refreshToken = cookieStore.get(cookieName)?.value;
|
||||
const metadata = await getMetadata().catch((err) => {
|
||||
logger.warn('Failed to discover OAuth metadata during logout', {
|
||||
error: err instanceof Error ? err.message : 'Unknown error',
|
||||
@@ -186,7 +228,7 @@ export async function DELETE() {
|
||||
}
|
||||
}
|
||||
|
||||
cookieStore.delete(REFRESH_TOKEN_COOKIE);
|
||||
cookieStore.delete(cookieName);
|
||||
}
|
||||
|
||||
let end_session_url: string | undefined;
|
||||
|
||||
@@ -47,7 +47,9 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
return NextResponse.json({ settings });
|
||||
} catch (error) {
|
||||
logger.error('Settings load error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
logger.error('Settings load error', { error: message, code });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -74,7 +76,9 @@ export async function POST(request: NextRequest) {
|
||||
await saveUserSettings(username, serverUrl, settings);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
logger.error('Settings save error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
logger.error('Settings save error', { error: message, code });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState, useCallback, type DragEvent } from "react";
|
||||
import { useMemo, useState, useCallback, useEffect, useRef, type DragEvent } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { BookUser, Users, Plus, UserPlus, Share2, Book } from "lucide-react";
|
||||
import { BookUser, Users, Plus, Share2, Book, ChevronRight, ChevronDown, UserPlus, UsersRound, Upload, Tag, Pencil, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ContactCard, AddressBook } from "@/lib/jmap/types";
|
||||
import { getContactDisplayName } from "@/stores/contact-store";
|
||||
|
||||
export type ContactCategory = "all" | { groupId: string } | { addressBookId: string };
|
||||
export type ContactCategory = "all" | { groupId: string } | { addressBookId: string } | { keyword: string };
|
||||
|
||||
interface ContactsSidebarProps {
|
||||
groups: ContactCard[];
|
||||
@@ -18,10 +20,30 @@ interface ContactsSidebarProps {
|
||||
onSelectCategory: (category: ContactCategory) => void;
|
||||
onCreateGroup: () => void;
|
||||
onCreateContact: () => void;
|
||||
onImport?: () => void;
|
||||
onEditGroup?: (groupId: string) => void;
|
||||
onDeleteGroup?: (groupId: string) => void;
|
||||
onDropContacts?: (contactIds: string[], addressBook: AddressBook) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const COLLAPSED_KEY = "contacts-sidebar-collapsed";
|
||||
|
||||
function loadCollapsed(): Record<string, boolean> {
|
||||
try {
|
||||
const v = localStorage.getItem(COLLAPSED_KEY);
|
||||
return v ? JSON.parse(v) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function saveCollapsed(state: Record<string, boolean>) {
|
||||
try {
|
||||
localStorage.setItem(COLLAPSED_KEY, JSON.stringify(state));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
export function ContactsSidebar({
|
||||
groups,
|
||||
individuals,
|
||||
@@ -30,10 +52,42 @@ export function ContactsSidebar({
|
||||
onSelectCategory,
|
||||
onCreateGroup,
|
||||
onCreateContact,
|
||||
onImport,
|
||||
onEditGroup,
|
||||
onDeleteGroup,
|
||||
onDropContacts,
|
||||
className,
|
||||
}: ContactsSidebarProps) {
|
||||
const t = useTranslations("contacts");
|
||||
const { contextMenu: groupContextMenu, openContextMenu: openGroupContextMenu, closeContextMenu: closeGroupContextMenu, menuRef: groupMenuRef } = useContextMenu<ContactCard>();
|
||||
|
||||
const [collapsed, setCollapsed] = useState<Record<string, boolean>>(loadCollapsed);
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const menuBtnRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const toggleSection = useCallback((key: string) => {
|
||||
setCollapsed(prev => {
|
||||
const next = { ...prev, [key]: !prev[key] };
|
||||
saveCollapsed(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
if (!showMenu) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (
|
||||
menuRef.current && !menuRef.current.contains(e.target as Node) &&
|
||||
menuBtnRef.current && !menuBtnRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setShowMenu(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [showMenu]);
|
||||
|
||||
const sortedGroups = useMemo(() => {
|
||||
return [...groups].sort((a, b) =>
|
||||
@@ -73,25 +127,96 @@ export function ContactsSidebar({
|
||||
if (!contact.addressBookIds) continue;
|
||||
for (const bookId of Object.keys(contact.addressBookIds)) {
|
||||
if (!contact.addressBookIds[bookId]) continue;
|
||||
// Build the full namespaced key
|
||||
const key = contact.isShared && contact.accountId ? `${contact.accountId}:${bookId}` : bookId;
|
||||
counts[key] = (counts[key] || 0) + 1;
|
||||
counts[bookId] = (counts[bookId] || 0) + 1;
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}, [individuals]);
|
||||
|
||||
// Auto-collect keywords from all contacts
|
||||
const allKeywords = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const contact of individuals) {
|
||||
if (!contact.keywords) continue;
|
||||
for (const [kw, active] of Object.entries(contact.keywords)) {
|
||||
if (!active) continue;
|
||||
counts[kw] = (counts[kw] || 0) + 1;
|
||||
}
|
||||
}
|
||||
return Object.entries(counts).sort(([a], [b]) => a.localeCompare(b));
|
||||
}, [individuals]);
|
||||
|
||||
// Resolve actual group member counts against living contacts
|
||||
const memberCountByGroup = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const group of groups) {
|
||||
if (!group.members) {
|
||||
counts[group.id] = 0;
|
||||
continue;
|
||||
}
|
||||
const memberKeys = Object.keys(group.members).filter(k => group.members![k]);
|
||||
const normalizedKeys = memberKeys.map(k => k.startsWith('urn:uuid:') ? k.slice(9) : k);
|
||||
counts[group.id] = individuals.filter(c => {
|
||||
if (memberKeys.includes(c.id) || normalizedKeys.includes(c.id)) return true;
|
||||
if (c.uid) {
|
||||
const bareUid = c.uid.startsWith('urn:uuid:') ? c.uid.slice(9) : c.uid;
|
||||
return memberKeys.includes(c.uid) || normalizedKeys.includes(bareUid);
|
||||
}
|
||||
return false;
|
||||
}).length;
|
||||
}
|
||||
return counts;
|
||||
}, [groups, individuals]);
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col h-full bg-secondary", className)}>
|
||||
{/* Header */}
|
||||
<div className="px-3 border-b border-border flex items-center justify-between" style={{ paddingBlock: 'var(--density-header-py)' }}>
|
||||
<span className="text-sm font-semibold truncate">{t("title")}</span>
|
||||
<Button size="icon" variant="ghost" onClick={onCreateContact} className="h-7 w-7 flex-shrink-0">
|
||||
<UserPlus className="w-4 h-4" />
|
||||
</Button>
|
||||
<div className="relative flex-shrink-0">
|
||||
<Button
|
||||
ref={menuBtnRef}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => setShowMenu(v => !v)}
|
||||
className="h-7 w-7"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</Button>
|
||||
{showMenu && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="absolute right-0 top-full mt-1 w-44 rounded-md border border-border bg-background text-foreground shadow-md z-50 py-1"
|
||||
>
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-left"
|
||||
onClick={() => { setShowMenu(false); onCreateContact(); }}
|
||||
>
|
||||
<UserPlus className="w-4 h-4" />
|
||||
{t("create_new")}
|
||||
</button>
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-left"
|
||||
onClick={() => { setShowMenu(false); onCreateGroup(); }}
|
||||
>
|
||||
<UsersRound className="w-4 h-4" />
|
||||
{t("groups.create")}
|
||||
</button>
|
||||
{onImport && (
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-accent transition-colors text-left"
|
||||
onClick={() => { setShowMenu(false); onImport(); }}
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
{t("import.title")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Categories */}
|
||||
{/* Navigation */}
|
||||
<div className="flex-1 overflow-y-auto py-1">
|
||||
{/* All contacts */}
|
||||
<button
|
||||
@@ -107,19 +232,27 @@ export function ContactsSidebar({
|
||||
<BookUser className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="truncate">{t("tabs.all")}</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
|
||||
{individuals.filter(c => !c.isShared).length}
|
||||
{individuals.length}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Personal address books */}
|
||||
{/* My Address Books */}
|
||||
{personalBooks.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="flex items-center justify-between px-3 py-1">
|
||||
<button
|
||||
onClick={() => toggleSection("addressBooks")}
|
||||
className="flex items-center gap-1 px-3 py-1 w-full text-left group"
|
||||
>
|
||||
{collapsed.addressBooks ? (
|
||||
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="w-3 h-3 text-muted-foreground" />
|
||||
)}
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
{t("address_books.title")}
|
||||
</span>
|
||||
</div>
|
||||
{personalBooks.map((book) => (
|
||||
</button>
|
||||
{!collapsed.addressBooks && personalBooks.map((book) => (
|
||||
<AddressBookItem
|
||||
key={book.id}
|
||||
book={book}
|
||||
@@ -133,29 +266,33 @@ export function ContactsSidebar({
|
||||
)}
|
||||
|
||||
{/* Groups section */}
|
||||
{(sortedGroups.length > 0) && (
|
||||
{sortedGroups.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="flex items-center justify-between px-3 py-1">
|
||||
<button
|
||||
onClick={() => toggleSection("groups")}
|
||||
className="flex items-center gap-1 px-3 py-1 w-full text-left group"
|
||||
>
|
||||
{collapsed.groups ? (
|
||||
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="w-3 h-3 text-muted-foreground" />
|
||||
)}
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
{t("tabs.groups")}
|
||||
</span>
|
||||
<Button size="icon" variant="ghost" onClick={onCreateGroup} className="h-5 w-5">
|
||||
<Plus className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{sortedGroups.map((group) => {
|
||||
{!collapsed.groups && sortedGroups.map((group) => {
|
||||
const isActive = typeof activeCategory === "object" && "groupId" in activeCategory && activeCategory.groupId === group.id;
|
||||
const memberCount = group.members
|
||||
? Object.values(group.members).filter(Boolean).length
|
||||
: 0;
|
||||
const memberCount = memberCountByGroup[group.id] || 0;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={group.id}
|
||||
onClick={() => onSelectCategory({ groupId: group.id })}
|
||||
onContextMenu={(e) => openGroupContextMenu(e, group)}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 px-3 text-sm transition-colors",
|
||||
"w-full flex items-center gap-2 pl-5 pr-3 text-sm transition-colors",
|
||||
isActive
|
||||
? "bg-accent text-accent-foreground font-medium"
|
||||
: "text-foreground/80 hover:bg-muted"
|
||||
@@ -173,35 +310,66 @@ export function ContactsSidebar({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sortedGroups.length === 0 && (
|
||||
<div className="mt-2 px-3">
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
{t("tabs.groups")}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onCreateGroup}
|
||||
className="w-full justify-start text-xs text-muted-foreground h-7"
|
||||
{/* Categories section (from contact keywords) */}
|
||||
{allKeywords.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<button
|
||||
onClick={() => toggleSection("categories")}
|
||||
className="flex items-center gap-1 px-3 py-1 w-full text-left group"
|
||||
>
|
||||
<Plus className="w-3 h-3 mr-1.5" />
|
||||
{t("groups.create")}
|
||||
</Button>
|
||||
{collapsed.categories ? (
|
||||
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="w-3 h-3 text-muted-foreground" />
|
||||
)}
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
{t("detail.categories")}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{!collapsed.categories && allKeywords.map(([keyword, count]) => {
|
||||
const isActive = typeof activeCategory === "object" && "keyword" in activeCategory && activeCategory.keyword === keyword;
|
||||
return (
|
||||
<button
|
||||
key={keyword}
|
||||
onClick={() => onSelectCategory({ keyword })}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 pl-5 pr-3 text-sm transition-colors",
|
||||
isActive
|
||||
? "bg-accent text-accent-foreground font-medium"
|
||||
: "text-foreground/80 hover:bg-muted"
|
||||
)}
|
||||
style={{ paddingBlock: 'var(--density-sidebar-py, 4px)', minHeight: '32px' }}
|
||||
>
|
||||
<Tag className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span className="truncate">{keyword}</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Shared accounts with address books */}
|
||||
{sharedBookGroups.map((group) => (
|
||||
<div key={group.accountId} className="mt-2">
|
||||
<div className="flex items-center justify-between px-3 py-1">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider flex items-center gap-1">
|
||||
<Share2 className="w-3 h-3" />
|
||||
{group.accountName}
|
||||
<button
|
||||
onClick={() => toggleSection(`shared-${group.accountId}`)}
|
||||
className="flex items-center gap-1 px-3 py-1 w-full text-left group"
|
||||
>
|
||||
{collapsed[`shared-${group.accountId}`] ? (
|
||||
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="w-3 h-3 text-muted-foreground" />
|
||||
)}
|
||||
<Share2 className="w-3 h-3 text-muted-foreground" />
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider truncate">
|
||||
{t("address_books.shared_prefix", { name: group.accountName })}
|
||||
</span>
|
||||
</div>
|
||||
{group.books.map((book) => (
|
||||
</button>
|
||||
{!collapsed[`shared-${group.accountId}`] && group.books.map((book) => (
|
||||
<AddressBookItem
|
||||
key={book.id}
|
||||
book={book}
|
||||
@@ -214,6 +382,35 @@ export function ContactsSidebar({
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Group context menu */}
|
||||
{groupContextMenu.data && (
|
||||
<ContextMenu
|
||||
ref={groupMenuRef}
|
||||
isOpen={groupContextMenu.isOpen}
|
||||
position={groupContextMenu.position}
|
||||
onClose={closeGroupContextMenu}
|
||||
>
|
||||
<ContextMenuItem
|
||||
icon={Pencil}
|
||||
label={t("groups.edit")}
|
||||
onClick={() => {
|
||||
closeGroupContextMenu();
|
||||
onEditGroup?.(groupContextMenu.data!.id);
|
||||
}}
|
||||
/>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
icon={Trash2}
|
||||
label={t("form.delete")}
|
||||
onClick={() => {
|
||||
closeGroupContextMenu();
|
||||
onDeleteGroup?.(groupContextMenu.data!.id);
|
||||
}}
|
||||
destructive
|
||||
/>
|
||||
</ContextMenu>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -266,7 +463,7 @@ function AddressBookItem({
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 px-3 text-sm transition-colors",
|
||||
"w-full flex items-center gap-2 pl-5 pr-3 text-sm transition-colors",
|
||||
isActive
|
||||
? "bg-accent text-accent-foreground font-medium"
|
||||
: "text-foreground/80 hover:bg-muted",
|
||||
|
||||
@@ -135,7 +135,7 @@ export function SubAddressHelper({
|
||||
<div
|
||||
ref={popoverRef}
|
||||
className={cn(
|
||||
'absolute top-full left-0 mt-1 z-50',
|
||||
'absolute top-full right-0 mt-1 z-50',
|
||||
'bg-background border border-border rounded-lg shadow-lg',
|
||||
'w-80 p-4 animate-in fade-in zoom-in-95 duration-150'
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Check, Plus, LogOut, Star, ChevronDown, AlertCircle } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useAccountStore, type AccountEntry } from "@/stores/account-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { getInitials, MAX_ACCOUNTS } from "@/lib/account-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
|
||||
interface AccountSwitcherProps {
|
||||
/** "rail" = small avatar only (NavigationRail), "expanded" = avatar + name + email (Sidebar) */
|
||||
variant?: "rail" | "expanded";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function AccountAvatar({ account, size = "sm" }: { account: AccountEntry; size?: "sm" | "md" }) {
|
||||
const initials = getInitials(account.displayName || account.label, account.email || account.username);
|
||||
const sizeClasses = size === "sm" ? "w-8 h-8 text-xs" : "w-9 h-9 text-sm";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("rounded-full flex items-center justify-center text-white font-medium flex-shrink-0", sizeClasses)}
|
||||
style={{ backgroundColor: account.avatarColor }}
|
||||
title={account.label}
|
||||
>
|
||||
{initials}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AccountSwitcher({ variant = "rail", className }: AccountSwitcherProps) {
|
||||
const t = useTranslations("sidebar");
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
const [popoverStyle, setPopoverStyle] = useState<React.CSSProperties>({});
|
||||
|
||||
const accounts = useAccountStore((s) => s.accounts);
|
||||
const activeAccountId = useAccountStore((s) => s.activeAccountId);
|
||||
const setDefaultAccount = useAccountStore((s) => s.setDefaultAccount);
|
||||
const activeAccount = accounts.find((a) => a.id === activeAccountId);
|
||||
const switchAccount = useAuthStore((s) => s.switchAccount);
|
||||
const logout = useAuthStore((s) => s.logout);
|
||||
const logoutAll = useAuthStore((s) => s.logoutAll);
|
||||
const primaryIdentity = useAuthStore((s) => s.primaryIdentity);
|
||||
|
||||
const updatePosition = useCallback(() => {
|
||||
if (!buttonRef.current) return;
|
||||
const rect = buttonRef.current.getBoundingClientRect();
|
||||
if (variant === "rail") {
|
||||
setPopoverStyle({
|
||||
position: "fixed",
|
||||
left: rect.right + 8,
|
||||
bottom: Math.max(8, window.innerHeight - rect.bottom),
|
||||
});
|
||||
} else {
|
||||
setPopoverStyle({
|
||||
position: "fixed",
|
||||
left: rect.left,
|
||||
top: rect.bottom + 4,
|
||||
});
|
||||
}
|
||||
}, [variant]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
updatePosition();
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (
|
||||
buttonRef.current?.contains(e.target as Node) ||
|
||||
popoverRef.current?.contains(e.target as Node)
|
||||
) return;
|
||||
setOpen(false);
|
||||
};
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
document.removeEventListener("keydown", handleEscape);
|
||||
};
|
||||
}, [open, updatePosition]);
|
||||
|
||||
const handleSwitch = async (accountId: string) => {
|
||||
if (accountId === activeAccountId) return;
|
||||
setOpen(false);
|
||||
await switchAccount(accountId);
|
||||
};
|
||||
|
||||
const handleAddAccount = () => {
|
||||
setOpen(false);
|
||||
router.push(`/login?mode=add-account` as never);
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
setOpen(false);
|
||||
logout();
|
||||
if (useAccountStore.getState().accounts.length === 0) {
|
||||
router.push("/login" as never);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogoutAll = () => {
|
||||
setOpen(false);
|
||||
logoutAll();
|
||||
router.push("/login" as never);
|
||||
};
|
||||
|
||||
const handleSetDefault = (accountId: string) => {
|
||||
setDefaultAccount(accountId);
|
||||
};
|
||||
|
||||
// Display name for the active account
|
||||
const displayName = primaryIdentity?.name || activeAccount?.displayName || activeAccount?.label || "";
|
||||
const displayEmail = primaryIdentity?.email || activeAccount?.email || activeAccount?.username || "";
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
onClick={() => setOpen(!open)}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md transition-colors",
|
||||
variant === "rail"
|
||||
? "justify-center w-10 h-10 hover:bg-muted"
|
||||
: "w-full px-2 py-1.5 hover:bg-muted text-left min-w-0",
|
||||
className
|
||||
)}
|
||||
title={variant === "rail" ? (displayName || displayEmail) : undefined}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="true"
|
||||
>
|
||||
{activeAccount ? (
|
||||
<>
|
||||
<AccountAvatar account={activeAccount} size={variant === "rail" ? "sm" : "md"} />
|
||||
{variant === "expanded" && (
|
||||
<>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-foreground truncate">{displayName}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{displayEmail}</p>
|
||||
</div>
|
||||
<ChevronDown className={cn("w-3.5 h-3.5 text-muted-foreground flex-shrink-0 transition-transform", open && "rotate-180")} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className={cn(
|
||||
"rounded-full bg-muted flex items-center justify-center text-muted-foreground",
|
||||
variant === "rail" ? "w-8 h-8 text-xs" : "w-9 h-9 text-sm"
|
||||
)}>
|
||||
?
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && createPortal(
|
||||
<div
|
||||
ref={popoverRef}
|
||||
style={popoverStyle}
|
||||
className="w-72 rounded-lg border border-border bg-background text-foreground shadow-lg z-50 overflow-hidden"
|
||||
role="menu"
|
||||
>
|
||||
{/* Account List */}
|
||||
<div className="py-1 max-h-64 overflow-y-auto">
|
||||
{accounts.map((account) => {
|
||||
const isActive = account.id === activeAccountId;
|
||||
return (
|
||||
<button
|
||||
key={account.id}
|
||||
onClick={() => handleSwitch(account.id)}
|
||||
className={cn(
|
||||
"w-full flex items-start gap-3 px-3 py-2.5 text-left transition-colors",
|
||||
isActive ? "bg-accent/50" : "hover:bg-muted"
|
||||
)}
|
||||
role="menuitem"
|
||||
disabled={isActive}
|
||||
>
|
||||
<div className="relative flex-shrink-0">
|
||||
<AccountAvatar account={account} size="md" />
|
||||
{isActive && (
|
||||
<div className="absolute -bottom-0.5 -right-0.5 w-4 h-4 rounded-full bg-primary flex items-center justify-center">
|
||||
<Check className="w-2.5 h-2.5 text-primary-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-sm font-medium truncate">
|
||||
{account.displayName || account.label}
|
||||
</span>
|
||||
{account.isDefault && (
|
||||
<Star className="w-3 h-3 text-amber-500 flex-shrink-0 fill-amber-500" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{account.email || account.username}
|
||||
</p>
|
||||
<div className="flex items-center gap-1 mt-0.5">
|
||||
{account.hasError ? (
|
||||
<AlertCircle className="w-3 h-3 text-destructive" />
|
||||
) : (
|
||||
<span className={cn(
|
||||
"w-1.5 h-1.5 rounded-full",
|
||||
account.isConnected ? "bg-green-500" : "bg-muted-foreground/40"
|
||||
)} />
|
||||
)}
|
||||
<span className="text-[10px] text-muted-foreground truncate">
|
||||
{new URL(account.serverUrl).hostname}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Separator + Add Account */}
|
||||
{accounts.length < MAX_ACCOUNTS && (
|
||||
<div className="border-t border-border">
|
||||
<button
|
||||
onClick={handleAddAccount}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-foreground hover:bg-muted transition-colors"
|
||||
role="menuitem"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
{t("add_account")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Separator + Actions */}
|
||||
<div className="border-t border-border">
|
||||
{activeAccount && !activeAccount.isDefault && accounts.length > 1 && (
|
||||
<button
|
||||
onClick={() => handleSetDefault(activeAccount.id)}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-foreground hover:bg-muted transition-colors"
|
||||
role="menuitem"
|
||||
>
|
||||
<Star className="w-4 h-4" />
|
||||
{t("set_as_default")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-foreground hover:bg-muted transition-colors"
|
||||
role="menuitem"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
{t("sign_out_of", { account: displayEmail })}
|
||||
</button>
|
||||
{accounts.length > 1 && (
|
||||
<button
|
||||
onClick={handleLogoutAll}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-destructive hover:bg-muted transition-colors"
|
||||
role="menuitem"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
{t("sign_out_all")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Mail, Calendar, BookUser, HardDrive, Settings, LogOut, Keyboard, Plus } from "lucide-react";
|
||||
import { AccountSwitcher } from "./account-switcher";
|
||||
import { icons as lucideIcons, type LucideIcon } from "lucide-react";
|
||||
import { usePathname, Link } from "@/i18n/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
@@ -432,13 +433,7 @@ export function NavigationRail({
|
||||
)}
|
||||
|
||||
{onLogout && (
|
||||
<button
|
||||
onClick={onLogout}
|
||||
className="flex items-center justify-center w-10 h-10 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
title={t("sign_out")}
|
||||
>
|
||||
<LogOut className="w-[18px] h-[18px]" />
|
||||
</button>
|
||||
<AccountSwitcher variant="rail" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -39,6 +39,7 @@ import { toast } from "@/stores/toast-store";
|
||||
import { debug } from "@/lib/debug";
|
||||
import { useConfig } from "@/hooks/use-config";
|
||||
import { useThemeStore } from "@/stores/theme-store";
|
||||
import { AccountSwitcher } from "./account-switcher";
|
||||
|
||||
interface SidebarProps {
|
||||
mailboxes: Mailbox[];
|
||||
@@ -485,15 +486,8 @@ export function Sidebar({
|
||||
{isCollapsed ? <ChevronsRight className="w-4 h-4" /> : <ChevronsLeft className="w-4 h-4" />}
|
||||
</Button>
|
||||
|
||||
{!isCollapsed && primaryIdentity && (
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-foreground truncate" title={primaryIdentity.name}>
|
||||
{primaryIdentity.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate" title={primaryIdentity.email}>
|
||||
{primaryIdentity.email}
|
||||
</p>
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<AccountSwitcher variant="expanded" className="flex-1" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -296,7 +296,7 @@ export function CalendarManagementSettings() {
|
||||
const buildCalDavUrl = (calendarId: string) => {
|
||||
if (!serverUrl || !username) return null;
|
||||
const base = serverUrl.replace(/\/$/, '');
|
||||
return `${base}/dav/calendars/user/${encodeURIComponent(username)}/${encodeURIComponent(calendarId)}/`;
|
||||
return `${base}/dav/cal/${encodeURIComponent(username)}/${encodeURIComponent(calendarId)}/`;
|
||||
};
|
||||
|
||||
const handleCopyUrl = async (url: string) => {
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Manages per-account state snapshots for fast switching.
|
||||
* When user switches from Account A → B, we snapshot A's store state
|
||||
* into memory, clear stores, then restore B's cached state.
|
||||
*/
|
||||
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { useContactStore } from '@/stores/contact-store';
|
||||
import { useCalendarStore } from '@/stores/calendar-store';
|
||||
import { useFilterStore } from '@/stores/filter-store';
|
||||
import { useIdentityStore } from '@/stores/identity-store';
|
||||
import { useVacationStore } from '@/stores/vacation-store';
|
||||
|
||||
// Minimal snapshot shapes — we only capture what we need
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type StoreSnapshot = Record<string, any>;
|
||||
|
||||
interface AccountSnapshot {
|
||||
email: StoreSnapshot;
|
||||
contact: StoreSnapshot;
|
||||
calendar: StoreSnapshot;
|
||||
filter: StoreSnapshot;
|
||||
identity: StoreSnapshot;
|
||||
vacation: StoreSnapshot;
|
||||
}
|
||||
|
||||
const cache = new Map<string, AccountSnapshot>();
|
||||
|
||||
/** Capture current store states for the given account */
|
||||
export function snapshotAccount(accountId: string): void {
|
||||
const emailState = useEmailStore.getState();
|
||||
const contactState = useContactStore.getState();
|
||||
const calendarState = useCalendarStore.getState();
|
||||
const filterState = useFilterStore.getState();
|
||||
const identityState = useIdentityStore.getState();
|
||||
const vacationState = useVacationStore.getState();
|
||||
|
||||
cache.set(accountId, {
|
||||
email: {
|
||||
emails: emailState.emails,
|
||||
mailboxes: emailState.mailboxes,
|
||||
selectedEmail: emailState.selectedEmail,
|
||||
selectedMailbox: emailState.selectedMailbox,
|
||||
searchQuery: emailState.searchQuery,
|
||||
quota: emailState.quota,
|
||||
},
|
||||
contact: {
|
||||
contacts: contactState.contacts,
|
||||
addressBooks: contactState.addressBooks,
|
||||
supportsSync: contactState.supportsSync,
|
||||
},
|
||||
calendar: {
|
||||
calendars: calendarState.calendars,
|
||||
events: calendarState.events,
|
||||
selectedCalendarIds: calendarState.selectedCalendarIds,
|
||||
viewMode: calendarState.viewMode,
|
||||
supportsCalendar: calendarState.supportsCalendar,
|
||||
},
|
||||
filter: {
|
||||
rules: filterState.rules,
|
||||
isSupported: filterState.isSupported,
|
||||
},
|
||||
identity: {
|
||||
identities: identityState.identities,
|
||||
preferredPrimaryId: identityState.preferredPrimaryId,
|
||||
},
|
||||
vacation: {
|
||||
isEnabled: vacationState.isEnabled,
|
||||
isSupported: vacationState.isSupported,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Restore cached store states for the given account. Returns false if no cache exists. */
|
||||
export function restoreAccount(accountId: string): boolean {
|
||||
const snapshot = cache.get(accountId);
|
||||
if (!snapshot) return false;
|
||||
|
||||
useEmailStore.setState(snapshot.email);
|
||||
useContactStore.setState(snapshot.contact);
|
||||
useCalendarStore.setState(snapshot.calendar);
|
||||
useFilterStore.setState(snapshot.filter);
|
||||
useIdentityStore.setState(snapshot.identity);
|
||||
useVacationStore.setState(snapshot.vacation);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Clear all stores (used before restoring a different account) */
|
||||
export function clearAllStores(): void {
|
||||
useEmailStore.setState({
|
||||
emails: [],
|
||||
mailboxes: [],
|
||||
selectedEmail: null,
|
||||
selectedMailbox: '',
|
||||
isLoading: false,
|
||||
error: null,
|
||||
searchQuery: '',
|
||||
quota: null,
|
||||
});
|
||||
useIdentityStore.getState().clearIdentities();
|
||||
useContactStore.getState().clearContacts();
|
||||
useVacationStore.getState().clearState();
|
||||
useCalendarStore.getState().clearState();
|
||||
useFilterStore.getState().clearState();
|
||||
}
|
||||
|
||||
/** Evict cached state for one account */
|
||||
export function evictAccount(accountId: string): void {
|
||||
cache.delete(accountId);
|
||||
}
|
||||
|
||||
/** Evict all cached states */
|
||||
export function evictAll(): void {
|
||||
cache.clear();
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Utilities for multi-account support:
|
||||
* - Account ID generation
|
||||
* - Deterministic avatar colors
|
||||
* - Account-scoped localStorage keys
|
||||
*/
|
||||
|
||||
/** Generate a unique, deterministic account ID from username and server URL */
|
||||
export function generateAccountId(username: string, serverUrl: string): string {
|
||||
const host = new URL(serverUrl).hostname;
|
||||
return `${username}@${host}`;
|
||||
}
|
||||
|
||||
/** Deterministic avatar/accent color from an email string */
|
||||
export function generateAvatarColor(email: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < email.length; i++) {
|
||||
hash = ((hash << 5) - hash + email.charCodeAt(i)) | 0;
|
||||
}
|
||||
// 12 distinct, accessible hues
|
||||
const colors = [
|
||||
'#2563eb', // blue
|
||||
'#7c3aed', // violet
|
||||
'#db2777', // pink
|
||||
'#dc2626', // red
|
||||
'#ea580c', // orange
|
||||
'#d97706', // amber
|
||||
'#65a30d', // lime
|
||||
'#16a34a', // green
|
||||
'#0d9488', // teal
|
||||
'#0891b2', // cyan
|
||||
'#6366f1', // indigo
|
||||
'#9333ea', // purple
|
||||
];
|
||||
return colors[Math.abs(hash) % colors.length];
|
||||
}
|
||||
|
||||
/** Get initials for an avatar from a display name or email */
|
||||
export function getInitials(name: string, email?: string): string {
|
||||
if (name) {
|
||||
const parts = name.trim().split(/\s+/);
|
||||
if (parts.length >= 2) {
|
||||
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
||||
}
|
||||
return parts[0][0]?.toUpperCase() ?? '?';
|
||||
}
|
||||
if (email) {
|
||||
return email[0]?.toUpperCase() ?? '?';
|
||||
}
|
||||
return '?';
|
||||
}
|
||||
|
||||
/** Build an account-scoped localStorage key */
|
||||
export function getAccountScopedKey(baseKey: string, accountId: string): string {
|
||||
return `${baseKey}::${accountId}`;
|
||||
}
|
||||
|
||||
/** Maximum number of accounts allowed */
|
||||
export const MAX_ACCOUNTS = 5;
|
||||
@@ -1,2 +1,7 @@
|
||||
export const SESSION_COOKIE = 'jmap_session';
|
||||
export const SESSION_COOKIE_MAX_AGE = 30 * 24 * 60 * 60;
|
||||
|
||||
/** Get the cookie name for a given account slot (0-4). Slot 0 uses the legacy name. */
|
||||
export function sessionCookieName(slot: number): string {
|
||||
return slot === 0 ? SESSION_COOKIE : `${SESSION_COOKIE}_${slot}`;
|
||||
}
|
||||
|
||||
+5
-2
@@ -2479,6 +2479,9 @@ export class JMAPClient {
|
||||
...contact,
|
||||
id: isPrimary ? contact.id : `${accountId}:${contact.id}`,
|
||||
originalId: contact.id,
|
||||
addressBookIds: isPrimary ? contact.addressBookIds : (contact.addressBookIds ? Object.fromEntries(
|
||||
Object.entries(contact.addressBookIds).map(([bookId, v]) => [`${accountId}:${bookId}`, v])
|
||||
) : contact.addressBookIds),
|
||||
accountId,
|
||||
accountName: account?.name || (isPrimary ? this.username : accountId),
|
||||
isShared: !isPrimary,
|
||||
@@ -2851,8 +2854,8 @@ export class JMAPClient {
|
||||
id: isPrimary ? event.id : `${accountId}:${event.id}`,
|
||||
originalId: event.id,
|
||||
originalCalendarIds: event.calendarIds,
|
||||
calendarIds: isPrimary ? event.calendarIds : Object.fromEntries(
|
||||
Object.entries(event.calendarIds).map(([calId, v]) => [`${accountId}:${calId}`, v])
|
||||
calendarIds: isPrimary ? (event.calendarIds || {}) : Object.fromEntries(
|
||||
Object.entries(event.calendarIds || {}).map(([calId, v]) => [`${accountId}:${calId}`, v])
|
||||
),
|
||||
accountId,
|
||||
accountName: account?.name || (isPrimary ? this.username : accountId),
|
||||
|
||||
@@ -1,2 +1,7 @@
|
||||
export const OAUTH_SCOPES = 'openid email profile';
|
||||
export const REFRESH_TOKEN_COOKIE = 'jmap_rt';
|
||||
|
||||
/** Get the cookie name for a given account slot (0-4). Slot 0 uses the legacy name. */
|
||||
export function refreshTokenCookieName(slot: number): string {
|
||||
return slot === 0 ? REFRESH_TOKEN_COOKIE : `${REFRESH_TOKEN_COOKIE}_${slot}`;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createHash, createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
|
||||
import { readFile, writeFile, unlink, mkdir } from 'node:fs/promises';
|
||||
import { readFile, writeFile, unlink, mkdir, rename } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { logger } from '@/lib/logger';
|
||||
@@ -45,7 +45,10 @@ export async function saveUserSettings(username: string, serverUrl: string, sett
|
||||
const tag = cipher.getAuthTag();
|
||||
|
||||
const data = Buffer.concat([iv, tag, encrypted]);
|
||||
await writeFile(getSettingsPath(username, serverUrl), data);
|
||||
const targetPath = getSettingsPath(username, serverUrl);
|
||||
const tmpPath = targetPath + '.tmp';
|
||||
await writeFile(tmpPath, data);
|
||||
await rename(tmpPath, targetPath);
|
||||
}
|
||||
|
||||
export async function loadUserSettings(username: string, serverUrl: string): Promise<Record<string, unknown> | null> {
|
||||
|
||||
+11
-2
@@ -34,6 +34,9 @@
|
||||
"dismiss": "Schließen",
|
||||
"or": "oder",
|
||||
"sign_in_sso": "Mit SSO anmelden",
|
||||
"add_account_title": "Konto hinzufügen",
|
||||
"add_account_subtitle": "Mit einem anderen Konto anmelden",
|
||||
"cancel": "Abbrechen",
|
||||
"website": "Webseite",
|
||||
"imprint": "Impressum",
|
||||
"privacy_policy": "Datenschutz",
|
||||
@@ -58,6 +61,11 @@
|
||||
"storage_free": "Frei",
|
||||
"storage_total": "Gesamt",
|
||||
"sign_out": "Abmelden",
|
||||
"sign_out_of": "Von {account} abmelden",
|
||||
"sign_out_all": "Von allen Konten abmelden",
|
||||
"add_account": "Konto hinzufügen",
|
||||
"set_as_default": "Als Standard festlegen",
|
||||
"switch_account": "Konto wechseln",
|
||||
"contacts": "Kontakte",
|
||||
"calendar": "Kalender",
|
||||
"settings": "Einstellungen",
|
||||
@@ -1473,11 +1481,12 @@
|
||||
"title": "Geteilt"
|
||||
},
|
||||
"address_books": {
|
||||
"title": "Verzeichnisse",
|
||||
"title": "Meine Adressbücher",
|
||||
"shared_prefix": "Geteilt: {name}",
|
||||
"moved": "Kontakt verschoben nach {name}",
|
||||
"moved_plural": "{count} Kontakte verschoben nach {name}",
|
||||
"move_failed": "Kontakt konnte nicht verschoben werden",
|
||||
"address_book": "Verzeichnis"
|
||||
"address_book": "Adressbuch"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "E-Mail-Adressen",
|
||||
|
||||
+11
-2
@@ -34,6 +34,9 @@
|
||||
"dismiss": "Dismiss",
|
||||
"or": "or",
|
||||
"sign_in_sso": "Sign in with SSO",
|
||||
"add_account_title": "Add Account",
|
||||
"add_account_subtitle": "Sign in with another account",
|
||||
"cancel": "Cancel",
|
||||
"website": "Website",
|
||||
"imprint": "Imprint",
|
||||
"privacy_policy": "Privacy Policy",
|
||||
@@ -58,6 +61,11 @@
|
||||
"storage_free": "Free",
|
||||
"storage_total": "Total",
|
||||
"sign_out": "Sign out",
|
||||
"sign_out_of": "Sign out of {account}",
|
||||
"sign_out_all": "Sign out of all accounts",
|
||||
"add_account": "Add account",
|
||||
"set_as_default": "Set as default",
|
||||
"switch_account": "Switch account",
|
||||
"contacts": "Contacts",
|
||||
"calendar": "Calendar",
|
||||
"settings": "Settings",
|
||||
@@ -1473,11 +1481,12 @@
|
||||
"title": "Shared"
|
||||
},
|
||||
"address_books": {
|
||||
"title": "Directories",
|
||||
"title": "My Address Books",
|
||||
"shared_prefix": "Shared: {name}",
|
||||
"moved": "Contact moved to {name}",
|
||||
"moved_plural": "{count} contacts moved to {name}",
|
||||
"move_failed": "Failed to move contact",
|
||||
"address_book": "Directory"
|
||||
"address_book": "Address Book"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "Email Addresses",
|
||||
|
||||
+11
-2
@@ -34,6 +34,9 @@
|
||||
"dismiss": "Cerrar",
|
||||
"or": "o",
|
||||
"sign_in_sso": "Iniciar sesión con SSO",
|
||||
"add_account_title": "Agregar cuenta",
|
||||
"add_account_subtitle": "Iniciar sesión con otra cuenta",
|
||||
"cancel": "Cancelar",
|
||||
"website": "Sitio web",
|
||||
"imprint": "Aviso legal",
|
||||
"privacy_policy": "Política de privacidad",
|
||||
@@ -58,6 +61,11 @@
|
||||
"storage_free": "Libre",
|
||||
"storage_total": "Total",
|
||||
"sign_out": "Cerrar sesión",
|
||||
"sign_out_of": "Cerrar sesión de {account}",
|
||||
"sign_out_all": "Cerrar sesión de todas las cuentas",
|
||||
"add_account": "Agregar cuenta",
|
||||
"set_as_default": "Establecer como predeterminada",
|
||||
"switch_account": "Cambiar cuenta",
|
||||
"contacts": "Contactos",
|
||||
"calendar": "Calendario",
|
||||
"settings": "Configuración",
|
||||
@@ -1473,11 +1481,12 @@
|
||||
"title": "Compartidos"
|
||||
},
|
||||
"address_books": {
|
||||
"title": "Directorios",
|
||||
"title": "Mis Libretas de Direcciones",
|
||||
"shared_prefix": "Compartido: {name}",
|
||||
"moved": "Contacto movido a {name}",
|
||||
"moved_plural": "{count} contactos movidos a {name}",
|
||||
"move_failed": "Error al mover el contacto",
|
||||
"address_book": "Directorio"
|
||||
"address_book": "Libreta de direcciones"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "Direcciones de correo",
|
||||
|
||||
+11
-2
@@ -34,6 +34,9 @@
|
||||
"dismiss": "Fermer",
|
||||
"or": "ou",
|
||||
"sign_in_sso": "Se connecter avec SSO",
|
||||
"add_account_title": "Ajouter un compte",
|
||||
"add_account_subtitle": "Se connecter avec un autre compte",
|
||||
"cancel": "Annuler",
|
||||
"website": "Site web",
|
||||
"imprint": "Mentions légales",
|
||||
"privacy_policy": "Politique de confidentialité",
|
||||
@@ -58,6 +61,11 @@
|
||||
"storage_free": "Libre",
|
||||
"storage_total": "Total",
|
||||
"sign_out": "Se déconnecter",
|
||||
"sign_out_of": "Se déconnecter de {account}",
|
||||
"sign_out_all": "Se déconnecter de tous les comptes",
|
||||
"add_account": "Ajouter un compte",
|
||||
"set_as_default": "Définir par défaut",
|
||||
"switch_account": "Changer de compte",
|
||||
"contacts": "Contacts",
|
||||
"calendar": "Calendrier",
|
||||
"settings": "Paramètres",
|
||||
@@ -1473,11 +1481,12 @@
|
||||
"title": "Partagés"
|
||||
},
|
||||
"address_books": {
|
||||
"title": "Répertoires",
|
||||
"title": "Mes Carnets d'adresses",
|
||||
"shared_prefix": "Partagé : {name}",
|
||||
"moved": "Contact déplacé vers {name}",
|
||||
"moved_plural": "{count} contacts déplacés vers {name}",
|
||||
"move_failed": "Échec du déplacement du contact",
|
||||
"address_book": "Répertoire"
|
||||
"address_book": "Carnet d'adresses"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "Adresses e-mail",
|
||||
|
||||
+10
-1
@@ -34,6 +34,9 @@
|
||||
"dismiss": "Chiudi",
|
||||
"or": "o",
|
||||
"sign_in_sso": "Accedi con SSO",
|
||||
"add_account_title": "Aggiungi account",
|
||||
"add_account_subtitle": "Accedi con un altro account",
|
||||
"cancel": "Annulla",
|
||||
"website": "Sito web",
|
||||
"imprint": "Note legali",
|
||||
"privacy_policy": "Informativa sulla privacy",
|
||||
@@ -58,6 +61,11 @@
|
||||
"storage_free": "Libero",
|
||||
"storage_total": "Totale",
|
||||
"sign_out": "Esci",
|
||||
"sign_out_of": "Disconnetti da {account}",
|
||||
"sign_out_all": "Disconnetti da tutti gli account",
|
||||
"add_account": "Aggiungi account",
|
||||
"set_as_default": "Imposta come predefinito",
|
||||
"switch_account": "Cambia account",
|
||||
"contacts": "Contatti",
|
||||
"calendar": "Calendario",
|
||||
"settings": "Impostazioni",
|
||||
@@ -1473,7 +1481,8 @@
|
||||
"title": "Condivisi"
|
||||
},
|
||||
"address_books": {
|
||||
"title": "Rubriche",
|
||||
"title": "Le mie Rubriche",
|
||||
"shared_prefix": "Condiviso: {name}",
|
||||
"moved": "Contatto spostato in {name}",
|
||||
"moved_plural": "{count} contatti spostati in {name}",
|
||||
"move_failed": "Impossibile spostare il contatto",
|
||||
|
||||
+11
-2
@@ -34,6 +34,9 @@
|
||||
"dismiss": "閉じる",
|
||||
"or": "または",
|
||||
"sign_in_sso": "SSOでサインイン",
|
||||
"add_account_title": "アカウントを追加",
|
||||
"add_account_subtitle": "別のアカウントでサインイン",
|
||||
"cancel": "キャンセル",
|
||||
"website": "ウェブサイト",
|
||||
"imprint": "サイト運営者情報",
|
||||
"privacy_policy": "プライバシーポリシー",
|
||||
@@ -58,6 +61,11 @@
|
||||
"storage_free": "空き",
|
||||
"storage_total": "合計",
|
||||
"sign_out": "サインアウト",
|
||||
"sign_out_of": "{account} からサインアウト",
|
||||
"sign_out_all": "すべてのアカウントからサインアウト",
|
||||
"add_account": "アカウントを追加",
|
||||
"set_as_default": "デフォルトに設定",
|
||||
"switch_account": "アカウントを切り替え",
|
||||
"contacts": "連絡先",
|
||||
"calendar": "カレンダー",
|
||||
"settings": "設定",
|
||||
@@ -1473,11 +1481,12 @@
|
||||
"title": "共有"
|
||||
},
|
||||
"address_books": {
|
||||
"title": "ディレクトリ",
|
||||
"title": "マイアドレス帳",
|
||||
"shared_prefix": "共有: {name}",
|
||||
"moved": "連絡先を {name} に移動しました",
|
||||
"moved_plural": "{count} 件の連絡先を {name} に移動しました",
|
||||
"move_failed": "連絡先の移動に失敗しました",
|
||||
"address_book": "ディレクトリ"
|
||||
"address_book": "アドレス帳"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "メールアドレス",
|
||||
|
||||
+10
-1
@@ -34,6 +34,9 @@
|
||||
"dismiss": "Sluiten",
|
||||
"or": "of",
|
||||
"sign_in_sso": "Inloggen met SSO",
|
||||
"add_account_title": "Account toevoegen",
|
||||
"add_account_subtitle": "Inloggen met een ander account",
|
||||
"cancel": "Annuleren",
|
||||
"website": "Website",
|
||||
"imprint": "Colofon",
|
||||
"privacy_policy": "Privacybeleid",
|
||||
@@ -58,6 +61,11 @@
|
||||
"storage_free": "Vrij",
|
||||
"storage_total": "Totaal",
|
||||
"sign_out": "Afmelden",
|
||||
"sign_out_of": "Uitloggen van {account}",
|
||||
"sign_out_all": "Uitloggen van alle accounts",
|
||||
"add_account": "Account toevoegen",
|
||||
"set_as_default": "Als standaard instellen",
|
||||
"switch_account": "Account wisselen",
|
||||
"contacts": "Contacten",
|
||||
"calendar": "Agenda",
|
||||
"settings": "Instellingen",
|
||||
@@ -1473,7 +1481,8 @@
|
||||
"title": "Gedeeld"
|
||||
},
|
||||
"address_books": {
|
||||
"title": "Adresboeken",
|
||||
"title": "Mijn Adresboeken",
|
||||
"shared_prefix": "Gedeeld: {name}",
|
||||
"moved": "Contact verplaatst naar {name}",
|
||||
"moved_plural": "{count} contacten verplaatst naar {name}",
|
||||
"move_failed": "Verplaatsen van contact mislukt",
|
||||
|
||||
+11
-2
@@ -34,6 +34,9 @@
|
||||
"dismiss": "Fechar",
|
||||
"or": "ou",
|
||||
"sign_in_sso": "Entrar com SSO",
|
||||
"add_account_title": "Adicionar conta",
|
||||
"add_account_subtitle": "Entrar com outra conta",
|
||||
"cancel": "Cancelar",
|
||||
"website": "Site",
|
||||
"imprint": "Informações legais",
|
||||
"privacy_policy": "Política de privacidade",
|
||||
@@ -58,6 +61,11 @@
|
||||
"storage_free": "Livre",
|
||||
"storage_total": "Total",
|
||||
"sign_out": "Sair",
|
||||
"sign_out_of": "Sair de {account}",
|
||||
"sign_out_all": "Sair de todas as contas",
|
||||
"add_account": "Adicionar conta",
|
||||
"set_as_default": "Definir como padrão",
|
||||
"switch_account": "Trocar conta",
|
||||
"contacts": "Contatos",
|
||||
"calendar": "Calendário",
|
||||
"settings": "Configurações",
|
||||
@@ -1473,11 +1481,12 @@
|
||||
"title": "Compartilhados"
|
||||
},
|
||||
"address_books": {
|
||||
"title": "Diretórios",
|
||||
"title": "Meus Catálogos de Endereços",
|
||||
"shared_prefix": "Compartilhado: {name}",
|
||||
"moved": "Contato movido para {name}",
|
||||
"moved_plural": "{count} contatos movidos para {name}",
|
||||
"move_failed": "Falha ao mover o contato",
|
||||
"address_book": "Diretório"
|
||||
"address_book": "Catálogo de endereços"
|
||||
},
|
||||
"detail": {
|
||||
"emails": "Endereços de e-mail",
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "bulwark-webmail",
|
||||
"version": "1.4.2",
|
||||
"version": "1.4.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bulwark-webmail",
|
||||
"version": "1.4.2",
|
||||
"version": "1.4.3",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@tanstack/react-virtual": "^3.13.18",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bulwark-webmail",
|
||||
"version": "1.4.2",
|
||||
"version": "1.4.3",
|
||||
"description": "Bulwark Webmail — a modern webmail client built for Stalwart Mail Server",
|
||||
"author": "Bulwark Webmail <bulwark@rbm.systems>",
|
||||
"license": "AGPL-3.0-only",
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { generateAccountId, generateAvatarColor, MAX_ACCOUNTS } from '@/lib/account-utils';
|
||||
|
||||
export interface AccountEntry {
|
||||
/** Unique key: `${username}@${serverHostname}` */
|
||||
id: string;
|
||||
/** Display label (defaults to email, user-editable) */
|
||||
label: string;
|
||||
/** Full server URL */
|
||||
serverUrl: string;
|
||||
/** Username / email used to authenticate */
|
||||
username: string;
|
||||
/** Authentication mode */
|
||||
authMode: 'basic' | 'oauth';
|
||||
/** Cookie slot index (0–4) for session/token cookies */
|
||||
cookieSlot: number;
|
||||
/** Whether "Remember Me" was checked (basic auth only) */
|
||||
rememberMe: boolean;
|
||||
/** Cached display info */
|
||||
displayName: string;
|
||||
email: string;
|
||||
avatarColor: string;
|
||||
/** Timestamp of last successful login */
|
||||
lastLoginAt: number;
|
||||
/** Whether this account is currently connected */
|
||||
isConnected: boolean;
|
||||
/** Whether this account had a connection error */
|
||||
hasError: boolean;
|
||||
errorMessage?: string;
|
||||
/** Whether this is the default account (loaded on app start) */
|
||||
isDefault: boolean;
|
||||
}
|
||||
|
||||
interface AccountState {
|
||||
accounts: AccountEntry[];
|
||||
activeAccountId: string | null;
|
||||
defaultAccountId: string | null;
|
||||
|
||||
addAccount: (entry: Omit<AccountEntry, 'id' | 'cookieSlot' | 'avatarColor'>) => string;
|
||||
removeAccount: (accountId: string) => void;
|
||||
setActiveAccount: (accountId: string) => void;
|
||||
setDefaultAccount: (accountId: string) => void;
|
||||
getDefaultAccount: () => AccountEntry | null;
|
||||
updateAccount: (accountId: string, updates: Partial<AccountEntry>) => void;
|
||||
getActiveAccount: () => AccountEntry | null;
|
||||
getAccountById: (accountId: string) => AccountEntry | undefined;
|
||||
getNextCookieSlot: () => number;
|
||||
hasAccount: (username: string, serverUrl: string) => boolean;
|
||||
}
|
||||
|
||||
export const useAccountStore = create<AccountState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
accounts: [],
|
||||
activeAccountId: null,
|
||||
defaultAccountId: null,
|
||||
|
||||
addAccount: (entry) => {
|
||||
const state = get();
|
||||
if (state.accounts.length >= MAX_ACCOUNTS) {
|
||||
throw new Error(`Maximum of ${MAX_ACCOUNTS} accounts reached`);
|
||||
}
|
||||
|
||||
const id = generateAccountId(entry.username, entry.serverUrl);
|
||||
if (state.accounts.some((a) => a.id === id)) {
|
||||
return id; // already exists, return existing id
|
||||
}
|
||||
|
||||
const cookieSlot = state.getNextCookieSlot();
|
||||
const avatarColor = generateAvatarColor(entry.email || entry.username);
|
||||
const isDefault = state.accounts.length === 0; // first account is default
|
||||
|
||||
const account: AccountEntry = {
|
||||
...entry,
|
||||
id,
|
||||
cookieSlot,
|
||||
avatarColor,
|
||||
isDefault,
|
||||
};
|
||||
|
||||
set((s) => ({
|
||||
accounts: [...s.accounts, account],
|
||||
// If there is no active account, activate this one
|
||||
activeAccountId: s.activeAccountId ?? id,
|
||||
defaultAccountId: isDefault ? id : s.defaultAccountId,
|
||||
}));
|
||||
|
||||
return id;
|
||||
},
|
||||
|
||||
removeAccount: (accountId) => {
|
||||
set((s) => {
|
||||
const remaining = s.accounts.filter((a) => a.id !== accountId);
|
||||
const wasDefault = s.defaultAccountId === accountId;
|
||||
const wasActive = s.activeAccountId === accountId;
|
||||
|
||||
let newDefault = s.defaultAccountId;
|
||||
if (wasDefault) {
|
||||
newDefault = remaining[0]?.id ?? null;
|
||||
// Mark new default
|
||||
if (newDefault) {
|
||||
const idx = remaining.findIndex((a) => a.id === newDefault);
|
||||
if (idx >= 0) {
|
||||
remaining[idx] = { ...remaining[idx], isDefault: true };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
accounts: remaining,
|
||||
activeAccountId: wasActive ? (remaining[0]?.id ?? null) : s.activeAccountId,
|
||||
defaultAccountId: newDefault,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
setActiveAccount: (accountId) => {
|
||||
const account = get().accounts.find((a) => a.id === accountId);
|
||||
if (!account) return;
|
||||
set({ activeAccountId: accountId });
|
||||
},
|
||||
|
||||
setDefaultAccount: (accountId) => {
|
||||
const account = get().accounts.find((a) => a.id === accountId);
|
||||
if (!account) return;
|
||||
set((s) => ({
|
||||
defaultAccountId: accountId,
|
||||
accounts: s.accounts.map((a) => ({
|
||||
...a,
|
||||
isDefault: a.id === accountId,
|
||||
})),
|
||||
}));
|
||||
},
|
||||
|
||||
getDefaultAccount: () => {
|
||||
const state = get();
|
||||
if (state.defaultAccountId) {
|
||||
const account = state.accounts.find((a) => a.id === state.defaultAccountId);
|
||||
if (account) return account;
|
||||
}
|
||||
return state.accounts[0] ?? null;
|
||||
},
|
||||
|
||||
updateAccount: (accountId, updates) => {
|
||||
set((s) => ({
|
||||
accounts: s.accounts.map((a) =>
|
||||
a.id === accountId ? { ...a, ...updates } : a
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
getActiveAccount: () => {
|
||||
const state = get();
|
||||
return state.accounts.find((a) => a.id === state.activeAccountId) ?? null;
|
||||
},
|
||||
|
||||
getAccountById: (accountId) => {
|
||||
return get().accounts.find((a) => a.id === accountId);
|
||||
},
|
||||
|
||||
getNextCookieSlot: () => {
|
||||
const used = new Set(get().accounts.map((a) => a.cookieSlot));
|
||||
for (let i = 0; i < MAX_ACCOUNTS; i++) {
|
||||
if (!used.has(i)) return i;
|
||||
}
|
||||
return 0; // fallback, shouldn't happen if max is enforced
|
||||
},
|
||||
|
||||
hasAccount: (username, serverUrl) => {
|
||||
const id = generateAccountId(username, serverUrl);
|
||||
return get().accounts.some((a) => a.id === id);
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'account-registry',
|
||||
partialize: (state) => ({
|
||||
accounts: state.accounts,
|
||||
activeAccountId: state.activeAccountId,
|
||||
defaultAccountId: state.defaultAccountId,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
+571
-65
@@ -1,15 +1,17 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { JMAPClient } from '@/lib/jmap/client';
|
||||
import { useEmailStore } from './email-store';
|
||||
import { useIdentityStore } from './identity-store';
|
||||
import { useContactStore } from './contact-store';
|
||||
import { useVacationStore } from './vacation-store';
|
||||
import { useCalendarStore } from './calendar-store';
|
||||
import { useFilterStore } from './filter-store';
|
||||
import { useSettingsStore } from './settings-store';
|
||||
import { useAccountStore } from './account-store';
|
||||
import { fetchConfig } from '@/hooks/use-config';
|
||||
import { debug } from '@/lib/debug';
|
||||
import { generateAccountId } from '@/lib/account-utils';
|
||||
import { snapshotAccount, restoreAccount, clearAllStores, evictAccount, evictAll } from '@/lib/account-state-manager';
|
||||
import type { Identity } from '@/lib/jmap/types';
|
||||
|
||||
interface AuthState {
|
||||
@@ -26,14 +28,18 @@ interface AuthState {
|
||||
accessToken: string | null;
|
||||
tokenExpiresAt: number | null;
|
||||
connectionLost: boolean;
|
||||
activeAccountId: string | null;
|
||||
|
||||
login: (serverUrl: string, username: string, password: string, totp?: string, rememberMe?: boolean) => Promise<boolean>;
|
||||
loginWithOAuth: (serverUrl: string, code: string, codeVerifier: string, redirectUri: string) => Promise<boolean>;
|
||||
refreshAccessToken: () => Promise<string | null>;
|
||||
logout: () => void;
|
||||
logoutAll: () => void;
|
||||
switchAccount: (accountId: string) => Promise<void>;
|
||||
checkAuth: () => Promise<void>;
|
||||
clearError: () => void;
|
||||
syncIdentities: () => void;
|
||||
getClientForAccount: (accountId: string) => JMAPClient | undefined;
|
||||
}
|
||||
|
||||
const ERROR_PATTERNS: Array<{ key: string; matches: string[] }> = [
|
||||
@@ -130,22 +136,55 @@ function initializeFeatureStores(client: JMAPClient): void {
|
||||
let refreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let refreshPromise: Promise<string | null> | null = null;
|
||||
|
||||
function scheduleRefresh(expiresIn: number, refreshFn: () => Promise<string | null>): void {
|
||||
if (refreshTimer) clearTimeout(refreshTimer);
|
||||
const refreshAt = Math.max((expiresIn - 60) * 1000, 10_000);
|
||||
refreshTimer = setTimeout(() => {
|
||||
refreshFn().catch((err) => {
|
||||
debug.error('Scheduled token refresh failed:', err);
|
||||
});
|
||||
}, refreshAt);
|
||||
// Multi-account state: per-account JMAP clients and refresh timers
|
||||
const clients = new Map<string, JMAPClient>();
|
||||
const refreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
const refreshPromises = new Map<string, Promise<string | null>>();
|
||||
|
||||
function scheduleRefresh(expiresIn: number, refreshFn: () => Promise<string | null>, accountId?: string): void {
|
||||
if (accountId) {
|
||||
const existing = refreshTimers.get(accountId);
|
||||
if (existing) clearTimeout(existing);
|
||||
const refreshAt = Math.max((expiresIn - 60) * 1000, 10_000);
|
||||
refreshTimers.set(accountId, setTimeout(() => {
|
||||
refreshFn().catch((err) => {
|
||||
debug.error(`Scheduled token refresh failed for ${accountId}:`, err);
|
||||
});
|
||||
}, refreshAt));
|
||||
} else {
|
||||
if (refreshTimer) clearTimeout(refreshTimer);
|
||||
const refreshAt = Math.max((expiresIn - 60) * 1000, 10_000);
|
||||
refreshTimer = setTimeout(() => {
|
||||
refreshFn().catch((err) => {
|
||||
debug.error('Scheduled token refresh failed:', err);
|
||||
});
|
||||
}, refreshAt);
|
||||
}
|
||||
}
|
||||
|
||||
function clearRefreshTimer(): void {
|
||||
if (refreshTimer) {
|
||||
clearTimeout(refreshTimer);
|
||||
refreshTimer = null;
|
||||
function clearRefreshTimer(accountId?: string): void {
|
||||
if (accountId) {
|
||||
const timer = refreshTimers.get(accountId);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
refreshTimers.delete(accountId);
|
||||
}
|
||||
refreshPromises.delete(accountId);
|
||||
} else {
|
||||
if (refreshTimer) {
|
||||
clearTimeout(refreshTimer);
|
||||
refreshTimer = null;
|
||||
}
|
||||
refreshPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
function clearAllRefreshTimers(): void {
|
||||
if (refreshTimer) { clearTimeout(refreshTimer); refreshTimer = null; }
|
||||
refreshPromise = null;
|
||||
for (const timer of refreshTimers.values()) clearTimeout(timer);
|
||||
refreshTimers.clear();
|
||||
refreshPromises.clear();
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
@@ -164,6 +203,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
accessToken: null,
|
||||
tokenExpiresAt: null,
|
||||
connectionLost: false,
|
||||
activeAccountId: null,
|
||||
|
||||
login: async (serverUrl, username, password, totp, rememberMe) => {
|
||||
const effectivePassword = totp ? `${password}$${totp}` : password;
|
||||
@@ -179,6 +219,37 @@ export const useAuthStore = create<AuthState>()(
|
||||
const { identities, primaryIdentity } = loadIdentities(await client.getIdentities(), username);
|
||||
initializeFeatureStores(client);
|
||||
|
||||
// Register in account store
|
||||
const accountStore = useAccountStore.getState();
|
||||
const accountId = generateAccountId(username, serverUrl);
|
||||
const cookieSlot = accountStore.hasAccount(username, serverUrl)
|
||||
? (accountStore.getAccountById(accountId)?.cookieSlot ?? accountStore.getNextCookieSlot())
|
||||
: accountStore.getNextCookieSlot();
|
||||
|
||||
// Snapshot current account if switching away
|
||||
const prevAccountId = get().activeAccountId;
|
||||
if (prevAccountId && prevAccountId !== accountId) {
|
||||
snapshotAccount(prevAccountId);
|
||||
}
|
||||
|
||||
// Store client in multi-account map
|
||||
clients.set(accountId, client);
|
||||
|
||||
accountStore.addAccount({
|
||||
label: primaryIdentity?.name || username,
|
||||
serverUrl,
|
||||
username,
|
||||
authMode: 'basic',
|
||||
rememberMe: !!rememberMe,
|
||||
displayName: primaryIdentity?.name || username,
|
||||
email: primaryIdentity?.email || username,
|
||||
lastLoginAt: Date.now(),
|
||||
isConnected: true,
|
||||
hasError: false,
|
||||
isDefault: accountStore.accounts.length === 0,
|
||||
});
|
||||
accountStore.setActiveAccount(accountId);
|
||||
|
||||
set({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
@@ -192,6 +263,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
tokenExpiresAt: null,
|
||||
connectionLost: false,
|
||||
error: null,
|
||||
activeAccountId: accountId,
|
||||
});
|
||||
|
||||
// Sync settings from server (only if enabled)
|
||||
@@ -204,10 +276,10 @@ export const useAuthStore = create<AuthState>()(
|
||||
|
||||
if (rememberMe) {
|
||||
try {
|
||||
const res = await fetch('/api/auth/session', {
|
||||
const res = await fetch(`/api/auth/session?slot=${cookieSlot}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ serverUrl, username, password: effectivePassword }),
|
||||
body: JSON.stringify({ serverUrl, username, password: effectivePassword, slot: cookieSlot }),
|
||||
});
|
||||
if (res.ok) {
|
||||
set({ rememberMe: true });
|
||||
@@ -236,10 +308,17 @@ export const useAuthStore = create<AuthState>()(
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
try {
|
||||
const tokenRes = await fetch('/api/auth/token', {
|
||||
// Determine slot for this account (use slot from sessionStorage if re-adding)
|
||||
const accountStore = useAccountStore.getState();
|
||||
const pendingSlot = typeof window !== 'undefined'
|
||||
? parseInt(sessionStorage.getItem('oauth_cookie_slot') || '0', 10)
|
||||
: 0;
|
||||
const slot = pendingSlot >= 0 && pendingSlot <= 4 ? pendingSlot : accountStore.getNextCookieSlot();
|
||||
|
||||
const tokenRes = await fetch(`/api/auth/token?slot=${slot}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code, code_verifier: codeVerifier, redirect_uri: redirectUri }),
|
||||
body: JSON.stringify({ code, code_verifier: codeVerifier, redirect_uri: redirectUri, slot }),
|
||||
});
|
||||
|
||||
if (!tokenRes.ok) {
|
||||
@@ -259,6 +338,32 @@ export const useAuthStore = create<AuthState>()(
|
||||
const { identities, primaryIdentity } = loadIdentities(await client.getIdentities(), username);
|
||||
initializeFeatureStores(client);
|
||||
|
||||
// Register in account store
|
||||
const accountId = generateAccountId(username, serverUrl);
|
||||
|
||||
// Snapshot current account if switching away
|
||||
const prevAccountId = get().activeAccountId;
|
||||
if (prevAccountId && prevAccountId !== accountId) {
|
||||
snapshotAccount(prevAccountId);
|
||||
}
|
||||
|
||||
clients.set(accountId, client);
|
||||
|
||||
accountStore.addAccount({
|
||||
label: primaryIdentity?.name || username,
|
||||
serverUrl,
|
||||
username,
|
||||
authMode: 'oauth',
|
||||
rememberMe: true,
|
||||
displayName: primaryIdentity?.name || username,
|
||||
email: primaryIdentity?.email || username,
|
||||
lastLoginAt: Date.now(),
|
||||
isConnected: true,
|
||||
hasError: false,
|
||||
isDefault: accountStore.accounts.length === 0,
|
||||
});
|
||||
accountStore.setActiveAccount(accountId);
|
||||
|
||||
set({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
@@ -272,9 +377,10 @@ export const useAuthStore = create<AuthState>()(
|
||||
tokenExpiresAt: Date.now() + expires_in * 1000,
|
||||
connectionLost: false,
|
||||
error: null,
|
||||
activeAccountId: accountId,
|
||||
});
|
||||
|
||||
scheduleRefresh(expires_in, get().refreshAccessToken);
|
||||
scheduleRefresh(expires_in, get().refreshAccessToken, accountId);
|
||||
|
||||
// Sync settings from server (only if enabled)
|
||||
fetchConfig().then(config => {
|
||||
@@ -284,6 +390,11 @@ export const useAuthStore = create<AuthState>()(
|
||||
});
|
||||
}).catch(() => {});
|
||||
|
||||
// Clean up sessionStorage
|
||||
if (typeof window !== 'undefined') {
|
||||
sessionStorage.removeItem('oauth_cookie_slot');
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
debug.error('OAuth login error:', error);
|
||||
@@ -300,9 +411,17 @@ export const useAuthStore = create<AuthState>()(
|
||||
refreshAccessToken: async () => {
|
||||
if (refreshPromise) return refreshPromise;
|
||||
|
||||
refreshPromise = (async () => {
|
||||
const accountId = get().activeAccountId;
|
||||
if (accountId && refreshPromises.has(accountId)) {
|
||||
return refreshPromises.get(accountId)!;
|
||||
}
|
||||
|
||||
const account = accountId ? useAccountStore.getState().getAccountById(accountId) : null;
|
||||
const slot = account?.cookieSlot ?? 0;
|
||||
|
||||
const promise = (async () => {
|
||||
try {
|
||||
const res = await fetch('/api/auth/token', { method: 'PUT' });
|
||||
const res = await fetch(`/api/auth/token?slot=${slot}`, { method: 'PUT' });
|
||||
|
||||
if (!res.ok) {
|
||||
markSessionExpired();
|
||||
@@ -319,7 +438,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
tokenExpiresAt: Date.now() + expires_in * 1000,
|
||||
});
|
||||
|
||||
scheduleRefresh(expires_in, get().refreshAccessToken);
|
||||
scheduleRefresh(expires_in, get().refreshAccessToken, accountId ?? undefined);
|
||||
return access_token;
|
||||
} catch (error) {
|
||||
debug.error('Token refresh failed:', error);
|
||||
@@ -328,21 +447,136 @@ export const useAuthStore = create<AuthState>()(
|
||||
return null;
|
||||
} finally {
|
||||
refreshPromise = null;
|
||||
if (accountId) refreshPromises.delete(accountId);
|
||||
}
|
||||
})();
|
||||
|
||||
return refreshPromise;
|
||||
refreshPromise = promise;
|
||||
if (accountId) refreshPromises.set(accountId, promise);
|
||||
|
||||
return promise;
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
const state = get();
|
||||
const wasOAuth = state.authMode === 'oauth';
|
||||
const accountId = state.activeAccountId;
|
||||
const accountStore = useAccountStore.getState();
|
||||
const account = accountId ? accountStore.getAccountById(accountId) : null;
|
||||
const slot = account?.cookieSlot ?? 0;
|
||||
|
||||
clearRefreshTimer();
|
||||
clearRefreshTimer(accountId ?? undefined);
|
||||
state.client?.disconnect();
|
||||
|
||||
// Remove client from multi-account map
|
||||
if (accountId) {
|
||||
clients.delete(accountId);
|
||||
evictAccount(accountId);
|
||||
accountStore.removeAccount(accountId);
|
||||
}
|
||||
|
||||
useSettingsStore.getState().disableSync();
|
||||
|
||||
// Check if there are remaining accounts to switch to
|
||||
const remainingAccounts = accountStore.accounts;
|
||||
if (remainingAccounts.length > 0) {
|
||||
// Switch to the next account
|
||||
const nextAccount = remainingAccounts[0];
|
||||
// Clean current stores, then switch
|
||||
clearAllStores();
|
||||
|
||||
// Restore next account
|
||||
const nextClient = clients.get(nextAccount.id);
|
||||
if (nextClient) {
|
||||
const restored = restoreAccount(nextAccount.id);
|
||||
accountStore.setActiveAccount(nextAccount.id);
|
||||
|
||||
set({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
serverUrl: nextAccount.serverUrl,
|
||||
username: nextAccount.username,
|
||||
client: nextClient,
|
||||
authMode: nextAccount.authMode,
|
||||
connectionLost: false,
|
||||
error: null,
|
||||
activeAccountId: nextAccount.id,
|
||||
});
|
||||
|
||||
if (!restored) {
|
||||
initializeFeatureStores(nextClient);
|
||||
nextClient.getIdentities().then((rawIds) => {
|
||||
const { identities, primaryIdentity } = loadIdentities(rawIds, nextAccount.username);
|
||||
set({ identities, primaryIdentity });
|
||||
}).catch((err) => debug.error('Failed to load identities after switch:', err));
|
||||
} else {
|
||||
const identityState = useIdentityStore.getState();
|
||||
set({
|
||||
identities: identityState.identities,
|
||||
primaryIdentity: identityState.identities[0] ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No accounts remaining — full logout
|
||||
set({
|
||||
isAuthenticated: false,
|
||||
serverUrl: null,
|
||||
username: null,
|
||||
client: null,
|
||||
identities: [],
|
||||
primaryIdentity: null,
|
||||
authMode: 'basic',
|
||||
rememberMe: false,
|
||||
accessToken: null,
|
||||
tokenExpiresAt: null,
|
||||
connectionLost: false,
|
||||
error: null,
|
||||
activeAccountId: null,
|
||||
});
|
||||
|
||||
localStorage.removeItem('auth-storage');
|
||||
clearAllStores();
|
||||
}
|
||||
|
||||
// Clean up cookies for the removed account
|
||||
fetch(`/api/auth/session?slot=${slot}`, { method: 'DELETE' }).catch((err) => {
|
||||
debug.error('Failed to clear session cookie:', err);
|
||||
});
|
||||
|
||||
if (wasOAuth) {
|
||||
fetch(`/api/auth/token?slot=${slot}`, { method: 'DELETE' })
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`Revocation failed: ${res.status}`);
|
||||
return res.json();
|
||||
})
|
||||
.then((data) => {
|
||||
if (data.end_session_url && remainingAccounts.length === 0) {
|
||||
const locale = window.location.pathname.split('/')[1] || 'en';
|
||||
const redirectUri = `${window.location.origin}/${locale}/login`;
|
||||
const url = new URL(data.end_session_url);
|
||||
url.searchParams.set('post_logout_redirect_uri', redirectUri);
|
||||
window.location.href = url.toString();
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
debug.error('OAuth logout cleanup failed:', err);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
logoutAll: () => {
|
||||
// Disconnect all clients
|
||||
for (const client of clients.values()) {
|
||||
client.disconnect();
|
||||
}
|
||||
clients.clear();
|
||||
clearAllRefreshTimers();
|
||||
evictAll();
|
||||
|
||||
useSettingsStore.getState().disableSync();
|
||||
useAccountStore.getState().accounts.forEach(() => {});
|
||||
|
||||
set({
|
||||
isAuthenticated: false,
|
||||
serverUrl: null,
|
||||
@@ -356,55 +590,283 @@ export const useAuthStore = create<AuthState>()(
|
||||
tokenExpiresAt: null,
|
||||
connectionLost: false,
|
||||
error: null,
|
||||
activeAccountId: null,
|
||||
});
|
||||
|
||||
localStorage.removeItem('auth-storage');
|
||||
clearAllStores();
|
||||
|
||||
useEmailStore.setState({
|
||||
emails: [],
|
||||
mailboxes: [],
|
||||
selectedEmail: null,
|
||||
selectedMailbox: "",
|
||||
isLoading: false,
|
||||
error: null,
|
||||
searchQuery: "",
|
||||
quota: null,
|
||||
});
|
||||
|
||||
useIdentityStore.getState().clearIdentities();
|
||||
useContactStore.getState().clearContacts();
|
||||
useVacationStore.getState().clearState();
|
||||
useCalendarStore.getState().clearState();
|
||||
useFilterStore.getState().clearState();
|
||||
|
||||
fetch('/api/auth/session', { method: 'DELETE' }).catch((err) => {
|
||||
debug.error('Failed to clear session cookie:', err);
|
||||
});
|
||||
|
||||
if (wasOAuth) {
|
||||
fetch('/api/auth/token', { method: 'DELETE' })
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`Revocation failed: ${res.status}`);
|
||||
return res.json();
|
||||
})
|
||||
.then((data) => {
|
||||
if (data.end_session_url) {
|
||||
const locale = window.location.pathname.split('/')[1] || 'en';
|
||||
const redirectUri = `${window.location.origin}/${locale}/login`;
|
||||
const url = new URL(data.end_session_url);
|
||||
url.searchParams.set('post_logout_redirect_uri', redirectUri);
|
||||
window.location.href = url.toString();
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
debug.error('OAuth logout cleanup failed:', err);
|
||||
});
|
||||
// Clear all accounts from registry
|
||||
const accountStore = useAccountStore.getState();
|
||||
const allAccounts = [...accountStore.accounts];
|
||||
for (const account of allAccounts) {
|
||||
accountStore.removeAccount(account.id);
|
||||
}
|
||||
|
||||
// Delete all cookies
|
||||
fetch('/api/auth/session?all=true', { method: 'DELETE' }).catch(() => {});
|
||||
fetch('/api/auth/token?all=true', { method: 'DELETE' }).catch(() => {});
|
||||
},
|
||||
|
||||
switchAccount: async (accountId: string) => {
|
||||
const state = get();
|
||||
if (state.activeAccountId === accountId) return;
|
||||
|
||||
const accountStore = useAccountStore.getState();
|
||||
const targetAccount = accountStore.getAccountById(accountId);
|
||||
if (!targetAccount) return;
|
||||
|
||||
set({ isLoading: true });
|
||||
|
||||
// Snapshot current account
|
||||
if (state.activeAccountId) {
|
||||
snapshotAccount(state.activeAccountId);
|
||||
}
|
||||
|
||||
// Clear current stores
|
||||
clearAllStores();
|
||||
useSettingsStore.getState().disableSync();
|
||||
|
||||
// Get or create client for target account
|
||||
let targetClient = clients.get(accountId);
|
||||
|
||||
if (!targetClient) {
|
||||
// Client not connected — try to restore
|
||||
try {
|
||||
if (targetAccount.authMode === 'oauth') {
|
||||
const res = await fetch(`/api/auth/token?slot=${targetAccount.cookieSlot}`, { method: 'PUT' });
|
||||
if (res.ok) {
|
||||
const { access_token, expires_in } = await res.json();
|
||||
const refreshFn = get().refreshAccessToken;
|
||||
targetClient = JMAPClient.withBearer(targetAccount.serverUrl, access_token, targetAccount.username, () => refreshFn());
|
||||
targetClient.onConnectionChange((connected) => {
|
||||
if (get().activeAccountId === accountId) {
|
||||
set({ connectionLost: !connected });
|
||||
}
|
||||
accountStore.updateAccount(accountId, { isConnected: connected });
|
||||
});
|
||||
await targetClient.connect();
|
||||
clients.set(accountId, targetClient);
|
||||
scheduleRefresh(expires_in, get().refreshAccessToken, accountId);
|
||||
}
|
||||
} else if (targetAccount.authMode === 'basic' && targetAccount.rememberMe) {
|
||||
const res = await fetch(`/api/auth/session?slot=${targetAccount.cookieSlot}`);
|
||||
if (res.ok) {
|
||||
const { serverUrl, username, password } = await res.json();
|
||||
targetClient = new JMAPClient(serverUrl, username, password);
|
||||
targetClient.onConnectionChange((connected) => {
|
||||
if (get().activeAccountId === accountId) {
|
||||
set({ connectionLost: !connected });
|
||||
}
|
||||
accountStore.updateAccount(accountId, { isConnected: connected });
|
||||
});
|
||||
await targetClient.connect();
|
||||
clients.set(accountId, targetClient);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
debug.error(`Failed to restore client for ${accountId}:`, err);
|
||||
accountStore.updateAccount(accountId, {
|
||||
isConnected: false,
|
||||
hasError: true,
|
||||
errorMessage: err instanceof Error ? err.message : 'Connection failed',
|
||||
});
|
||||
set({ isLoading: false });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetClient) {
|
||||
set({ isLoading: false });
|
||||
return;
|
||||
}
|
||||
|
||||
// Restore cached state or fetch fresh
|
||||
const restored = restoreAccount(accountId);
|
||||
accountStore.setActiveAccount(accountId);
|
||||
accountStore.updateAccount(accountId, { isConnected: true, hasError: false, errorMessage: undefined });
|
||||
|
||||
set({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
serverUrl: targetAccount.serverUrl,
|
||||
username: targetAccount.username,
|
||||
client: targetClient,
|
||||
authMode: targetAccount.authMode,
|
||||
connectionLost: false,
|
||||
error: null,
|
||||
activeAccountId: accountId,
|
||||
});
|
||||
|
||||
if (!restored) {
|
||||
// Fetch fresh data
|
||||
try {
|
||||
const { identities, primaryIdentity } = loadIdentities(await targetClient.getIdentities(), targetAccount.username);
|
||||
set({ identities, primaryIdentity });
|
||||
initializeFeatureStores(targetClient);
|
||||
} catch (err) {
|
||||
debug.error(`Failed to load data for ${accountId}:`, err);
|
||||
}
|
||||
} else {
|
||||
const identityState = useIdentityStore.getState();
|
||||
set({
|
||||
identities: identityState.identities,
|
||||
primaryIdentity: identityState.identities[0] ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
// Sync settings
|
||||
fetchConfig().then(config => {
|
||||
if (!config.settingsSyncEnabled) return;
|
||||
useSettingsStore.getState().loadFromServer(targetAccount.username, targetAccount.serverUrl).finally(() => {
|
||||
useSettingsStore.getState().enableSync(targetAccount.username, targetAccount.serverUrl);
|
||||
});
|
||||
}).catch(() => {});
|
||||
},
|
||||
|
||||
checkAuth: async () => {
|
||||
const state = get();
|
||||
const accountStore = useAccountStore.getState();
|
||||
const accounts = accountStore.accounts;
|
||||
|
||||
// Multi-account restoration: restore all registered accounts
|
||||
if (accounts.length > 0) {
|
||||
set({ isLoading: true });
|
||||
|
||||
// Determine which account to activate first
|
||||
const defaultAccount = accountStore.getDefaultAccount();
|
||||
const activeId = get().activeAccountId;
|
||||
const targetId = activeId || defaultAccount?.id || accounts[0].id;
|
||||
|
||||
// Try to connect all accounts
|
||||
for (const account of accounts) {
|
||||
if (clients.has(account.id)) continue; // Already connected
|
||||
|
||||
try {
|
||||
if (account.authMode === 'oauth') {
|
||||
const res = await fetch(`/api/auth/token?slot=${account.cookieSlot}`, { method: 'PUT' });
|
||||
if (res.ok) {
|
||||
const { access_token, expires_in } = await res.json();
|
||||
const refreshFn = get().refreshAccessToken;
|
||||
const client = JMAPClient.withBearer(account.serverUrl, access_token, account.username, () => refreshFn());
|
||||
client.onConnectionChange((connected) => {
|
||||
if (get().activeAccountId === account.id) {
|
||||
set({ connectionLost: !connected });
|
||||
}
|
||||
accountStore.updateAccount(account.id, { isConnected: connected });
|
||||
});
|
||||
await client.connect();
|
||||
clients.set(account.id, client);
|
||||
scheduleRefresh(expires_in, get().refreshAccessToken, account.id);
|
||||
accountStore.updateAccount(account.id, { isConnected: true, hasError: false });
|
||||
} else {
|
||||
throw new Error(`Token refresh failed: ${res.status}`);
|
||||
}
|
||||
} else if (account.authMode === 'basic' && account.rememberMe) {
|
||||
const res = await fetch(`/api/auth/session?slot=${account.cookieSlot}`);
|
||||
if (res.ok) {
|
||||
const { serverUrl, username, password } = await res.json();
|
||||
const client = new JMAPClient(serverUrl, username, password);
|
||||
client.onConnectionChange((connected) => {
|
||||
if (get().activeAccountId === account.id) {
|
||||
set({ connectionLost: !connected });
|
||||
}
|
||||
accountStore.updateAccount(account.id, { isConnected: connected });
|
||||
});
|
||||
await client.connect();
|
||||
clients.set(account.id, client);
|
||||
accountStore.updateAccount(account.id, { isConnected: true, hasError: false });
|
||||
} else {
|
||||
throw new Error(`Session cookie missing: ${res.status}`);
|
||||
}
|
||||
} else {
|
||||
// Basic auth without rememberMe — can't restore
|
||||
throw new Error('No saved session');
|
||||
}
|
||||
} catch (err) {
|
||||
debug.error(`Failed to restore account ${account.id}:`, err);
|
||||
accountStore.updateAccount(account.id, {
|
||||
isConnected: false,
|
||||
hasError: true,
|
||||
errorMessage: err instanceof Error ? err.message : 'Restore failed',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Activate the target account
|
||||
const targetClient = clients.get(targetId);
|
||||
const targetAccount = accountStore.getAccountById(targetId);
|
||||
if (targetClient && targetAccount) {
|
||||
accountStore.setActiveAccount(targetId);
|
||||
const { identities, primaryIdentity } = loadIdentities(await targetClient.getIdentities(), targetAccount.username);
|
||||
initializeFeatureStores(targetClient);
|
||||
|
||||
set({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
serverUrl: targetAccount.serverUrl,
|
||||
username: targetAccount.username,
|
||||
client: targetClient,
|
||||
identities,
|
||||
primaryIdentity,
|
||||
authMode: targetAccount.authMode,
|
||||
connectionLost: false,
|
||||
error: null,
|
||||
activeAccountId: targetId,
|
||||
});
|
||||
|
||||
fetchConfig().then(config => {
|
||||
if (!config.settingsSyncEnabled) return;
|
||||
useSettingsStore.getState().loadFromServer(targetAccount.username, targetAccount.serverUrl).finally(() => {
|
||||
useSettingsStore.getState().enableSync(targetAccount.username, targetAccount.serverUrl);
|
||||
});
|
||||
}).catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
// If target didn't connect, try any connected account
|
||||
for (const [id, client] of clients.entries()) {
|
||||
const acc = accountStore.getAccountById(id);
|
||||
if (acc) {
|
||||
accountStore.setActiveAccount(id);
|
||||
const { identities, primaryIdentity } = loadIdentities(await client.getIdentities(), acc.username);
|
||||
initializeFeatureStores(client);
|
||||
|
||||
set({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
serverUrl: acc.serverUrl,
|
||||
username: acc.username,
|
||||
client,
|
||||
identities,
|
||||
primaryIdentity,
|
||||
authMode: acc.authMode,
|
||||
connectionLost: false,
|
||||
error: null,
|
||||
activeAccountId: id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// No accounts could be restored
|
||||
markSessionExpired();
|
||||
set({
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
client: null,
|
||||
serverUrl: null,
|
||||
username: null,
|
||||
authMode: 'basic',
|
||||
rememberMe: false,
|
||||
accessToken: null,
|
||||
tokenExpiresAt: null,
|
||||
activeAccountId: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Legacy single-account fallback (for accounts not yet in registry)
|
||||
const state = get();
|
||||
if (state.isAuthenticated && !state.client) {
|
||||
if (state.authMode === 'oauth' && state.serverUrl) {
|
||||
set({ isLoading: true });
|
||||
@@ -418,6 +880,25 @@ export const useAuthStore = create<AuthState>()(
|
||||
});
|
||||
await client.connect();
|
||||
|
||||
const accountId = generateAccountId(state.username || '', state.serverUrl);
|
||||
clients.set(accountId, client);
|
||||
|
||||
// Migrate to account registry
|
||||
accountStore.addAccount({
|
||||
label: state.username || '',
|
||||
serverUrl: state.serverUrl,
|
||||
username: state.username || '',
|
||||
authMode: 'oauth',
|
||||
rememberMe: true,
|
||||
displayName: state.username || '',
|
||||
email: state.username || '',
|
||||
lastLoginAt: Date.now(),
|
||||
isConnected: true,
|
||||
hasError: false,
|
||||
isDefault: accountStore.accounts.length === 0,
|
||||
});
|
||||
accountStore.setActiveAccount(accountId);
|
||||
|
||||
const { identities, primaryIdentity } = loadIdentities(await client.getIdentities(), state.username || '');
|
||||
initializeFeatureStores(client);
|
||||
|
||||
@@ -428,9 +909,9 @@ export const useAuthStore = create<AuthState>()(
|
||||
identities,
|
||||
primaryIdentity,
|
||||
accessToken: token,
|
||||
activeAccountId: accountId,
|
||||
});
|
||||
|
||||
// Sync settings from server (only if enabled)
|
||||
fetchConfig().then(config => {
|
||||
if (!config.settingsSyncEnabled) return;
|
||||
useSettingsStore.getState().loadFromServer(state.username || '', state.serverUrl!).finally(() => {
|
||||
@@ -462,6 +943,25 @@ export const useAuthStore = create<AuthState>()(
|
||||
});
|
||||
await client.connect();
|
||||
|
||||
const accountId = generateAccountId(username, serverUrl);
|
||||
clients.set(accountId, client);
|
||||
|
||||
// Migrate to account registry
|
||||
accountStore.addAccount({
|
||||
label: username,
|
||||
serverUrl,
|
||||
username,
|
||||
authMode: 'basic',
|
||||
rememberMe: state.rememberMe,
|
||||
displayName: username,
|
||||
email: username,
|
||||
lastLoginAt: Date.now(),
|
||||
isConnected: true,
|
||||
hasError: false,
|
||||
isDefault: accountStore.accounts.length === 0,
|
||||
});
|
||||
accountStore.setActiveAccount(accountId);
|
||||
|
||||
const { identities, primaryIdentity } = loadIdentities(await client.getIdentities(), username);
|
||||
initializeFeatureStores(client);
|
||||
|
||||
@@ -474,9 +974,9 @@ export const useAuthStore = create<AuthState>()(
|
||||
identities,
|
||||
primaryIdentity,
|
||||
authMode: 'basic',
|
||||
activeAccountId: accountId,
|
||||
});
|
||||
|
||||
// Sync settings from server (only if enabled)
|
||||
fetchConfig().then(config => {
|
||||
if (!config.settingsSyncEnabled) return;
|
||||
useSettingsStore.getState().loadFromServer(username, serverUrl).finally(() => {
|
||||
@@ -502,6 +1002,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
rememberMe: false,
|
||||
accessToken: null,
|
||||
tokenExpiresAt: null,
|
||||
activeAccountId: null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -516,6 +1017,10 @@ export const useAuthStore = create<AuthState>()(
|
||||
const primaryIdentity = identities[0] ?? null;
|
||||
set({ identities, primaryIdentity });
|
||||
},
|
||||
|
||||
getClientForAccount: (accountId: string) => {
|
||||
return clients.get(accountId);
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'auth-storage',
|
||||
@@ -527,6 +1032,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
? state.isAuthenticated
|
||||
: undefined,
|
||||
rememberMe: state.rememberMe,
|
||||
activeAccountId: state.activeAccountId,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
+64
-17
@@ -87,7 +87,39 @@ interface ContactStore {
|
||||
|
||||
export const useContactStore = create<ContactStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
(set, get) => {
|
||||
|
||||
// Clean group member references when contacts are removed
|
||||
function cleanGroupMembers(contacts: ContactCard[], removedIds: Set<string>): ContactCard[] {
|
||||
// Collect uid/id variants of removed contacts for matching
|
||||
const removedKeys = new Set<string>();
|
||||
for (const c of contacts) {
|
||||
if (!removedIds.has(c.id)) continue;
|
||||
removedKeys.add(c.id);
|
||||
if (c.uid) {
|
||||
removedKeys.add(c.uid);
|
||||
const bare = c.uid.startsWith('urn:uuid:') ? c.uid.slice(9) : c.uid;
|
||||
removedKeys.add(bare);
|
||||
}
|
||||
if (c.originalId) removedKeys.add(c.originalId);
|
||||
}
|
||||
return contacts.map(c => {
|
||||
if (c.kind !== 'group' || !c.members) return c;
|
||||
let changed = false;
|
||||
const newMembers: Record<string, boolean> = {};
|
||||
for (const [key, val] of Object.entries(c.members)) {
|
||||
const bareKey = key.startsWith('urn:uuid:') ? key.slice(9) : key;
|
||||
if (removedKeys.has(key) || removedKeys.has(bareKey)) {
|
||||
changed = true;
|
||||
} else {
|
||||
newMembers[key] = val;
|
||||
}
|
||||
}
|
||||
return changed ? { ...c, members: newMembers } : c;
|
||||
});
|
||||
}
|
||||
|
||||
return ({
|
||||
contacts: [],
|
||||
addressBooks: [],
|
||||
selectedContactId: null,
|
||||
@@ -170,10 +202,14 @@ export const useContactStore = create<ContactStore>()(
|
||||
const originalId = contact?.originalId || id;
|
||||
const accountId = contact?.isShared ? contact.accountId : undefined;
|
||||
await client.deleteContact(originalId, accountId);
|
||||
set((state) => ({
|
||||
contacts: state.contacts.filter(c => c.id !== id),
|
||||
selectedContactId: state.selectedContactId === id ? null : state.selectedContactId,
|
||||
}));
|
||||
set((state) => {
|
||||
const removedIds = new Set([id]);
|
||||
const cleaned = cleanGroupMembers(state.contacts, removedIds);
|
||||
return {
|
||||
contacts: cleaned.filter(c => c.id !== id),
|
||||
selectedContactId: state.selectedContactId === id ? null : state.selectedContactId,
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : 'Failed to delete contact';
|
||||
set({ error: msg });
|
||||
@@ -191,10 +227,14 @@ export const useContactStore = create<ContactStore>()(
|
||||
),
|
||||
})),
|
||||
|
||||
deleteLocalContact: (id) => set((state) => ({
|
||||
contacts: state.contacts.filter(c => c.id !== id),
|
||||
selectedContactId: state.selectedContactId === id ? null : state.selectedContactId,
|
||||
})),
|
||||
deleteLocalContact: (id) => set((state) => {
|
||||
const removedIds = new Set([id]);
|
||||
const cleaned = cleanGroupMembers(state.contacts, removedIds);
|
||||
return {
|
||||
contacts: cleaned.filter(c => c.id !== id),
|
||||
selectedContactId: state.selectedContactId === id ? null : state.selectedContactId,
|
||||
};
|
||||
}),
|
||||
|
||||
setSelectedContact: (id) => set({ selectedContactId: id }),
|
||||
setSearchQuery: (query) => set({ searchQuery: query }),
|
||||
@@ -456,11 +496,14 @@ export const useContactStore = create<ContactStore>()(
|
||||
}
|
||||
}
|
||||
|
||||
set((state) => ({
|
||||
contacts: state.contacts.filter(c => !deletedIds.has(c.id)),
|
||||
selectedContactId: deletedIds.has(state.selectedContactId || '') ? null : state.selectedContactId,
|
||||
selectedContactIds: new Set<string>(),
|
||||
}));
|
||||
set((state) => {
|
||||
const cleaned = cleanGroupMembers(state.contacts, deletedIds);
|
||||
return {
|
||||
contacts: cleaned.filter(c => !deletedIds.has(c.id)),
|
||||
selectedContactId: deletedIds.has(state.selectedContactId || '') ? null : state.selectedContactId,
|
||||
selectedContactIds: new Set<string>(),
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
bulkAddToGroup: async (client, groupId, contactIds) => {
|
||||
@@ -485,9 +528,11 @@ export const useContactStore = create<ContactStore>()(
|
||||
// Same account: just update the addressBookIds
|
||||
if ((sourceAccountId || primaryAccountId) === (targetAccountId || primaryAccountId)) {
|
||||
await client.updateContact(originalId, { addressBookIds: { [targetBookOriginalId]: true } }, sourceAccountId);
|
||||
const isTargetPrimary = !targetAccountId || targetAccountId === primaryAccountId;
|
||||
const localBookId = isTargetPrimary ? targetBookOriginalId : `${targetAccountId}:${targetBookOriginalId}`;
|
||||
set((state) => ({
|
||||
contacts: state.contacts.map(c =>
|
||||
c.id === id ? { ...c, addressBookIds: { [targetBookOriginalId]: true } } : c
|
||||
c.id === id ? { ...c, addressBookIds: { [localBookId]: true } } : c
|
||||
),
|
||||
}));
|
||||
} else {
|
||||
@@ -501,6 +546,7 @@ export const useContactStore = create<ContactStore>()(
|
||||
|
||||
// Update local state
|
||||
const isPrimary = !targetAccountId || targetAccountId === primaryAccountId;
|
||||
const localBookId = isPrimary ? targetBookOriginalId : `${targetAccountId}:${targetBookOriginalId}`;
|
||||
set((state) => ({
|
||||
contacts: state.contacts.map(c => {
|
||||
if (c.id !== id) return c;
|
||||
@@ -511,7 +557,7 @@ export const useContactStore = create<ContactStore>()(
|
||||
accountId: targetAccountId,
|
||||
accountName: addressBook.accountName || targetAccountId,
|
||||
isShared: !isPrimary,
|
||||
addressBookIds: { [targetBookOriginalId]: true },
|
||||
addressBookIds: { [localBookId]: true },
|
||||
};
|
||||
}),
|
||||
}));
|
||||
@@ -544,7 +590,8 @@ export const useContactStore = create<ContactStore>()(
|
||||
|
||||
return imported;
|
||||
},
|
||||
}),
|
||||
});
|
||||
},
|
||||
{
|
||||
name: 'contact-storage',
|
||||
partialize: (state) => ({
|
||||
|
||||
+23
-15
@@ -562,26 +562,34 @@ if (typeof window !== 'undefined') {
|
||||
applyAnimations(store.animationsEnabled);
|
||||
|
||||
// Shared sync function used by all store subscribers
|
||||
const syncToServer = async (retries = 1): Promise<void> => {
|
||||
const settings = JSON.parse(useSettingsStore.getState().exportSettings());
|
||||
syncLog('Syncing settings to server...');
|
||||
const res = await fetch('/api/settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: syncUsername, serverUrl: syncServerUrl, settings }),
|
||||
});
|
||||
if (res.status === 404) {
|
||||
syncWarn('Settings sync endpoint returned 404, disabling sync');
|
||||
syncEnabled = false;
|
||||
} else if (res.status >= 500 && retries > 0) {
|
||||
syncWarn('Settings sync got server error, retrying...');
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
return syncToServer(retries - 1);
|
||||
} else if (!res.ok) {
|
||||
syncError('Settings sync failed with status', res.status);
|
||||
} else {
|
||||
syncLog('Settings synced to server successfully');
|
||||
}
|
||||
};
|
||||
|
||||
const triggerSync = () => {
|
||||
if (!syncEnabled || !syncUsername || !syncServerUrl || isLoadingFromServer) return;
|
||||
if (syncTimeout) clearTimeout(syncTimeout);
|
||||
syncTimeout = setTimeout(async () => {
|
||||
try {
|
||||
const settings = JSON.parse(useSettingsStore.getState().exportSettings());
|
||||
syncLog('Syncing settings to server...');
|
||||
const res = await fetch('/api/settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: syncUsername, serverUrl: syncServerUrl, settings }),
|
||||
});
|
||||
if (res.status === 404) {
|
||||
syncWarn('Settings sync endpoint returned 404, disabling sync');
|
||||
syncEnabled = false;
|
||||
} else if (!res.ok) {
|
||||
syncError('Settings sync failed with status', res.status);
|
||||
} else {
|
||||
syncLog('Settings synced to server successfully');
|
||||
}
|
||||
await syncToServer();
|
||||
} catch (error) {
|
||||
syncError('Settings sync error:', error);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user