Merge remote-tracking branch 'origin/main' into feature/scheduled-send
# Conflicts: # app/[locale]/page.tsx # components/email/email-composer.tsx # components/email/email-viewer.tsx
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { generateAccountId, generateAvatarColor, MAX_ACCOUNTS } from '@/lib/account-utils';
|
||||
import { generateAccountId, generateAvatarColor, getMaxAccounts } from '@/lib/account-utils';
|
||||
|
||||
export interface AccountEntry {
|
||||
/** Unique key: `${username}@${serverHostname}` */
|
||||
@@ -13,7 +13,7 @@ export interface AccountEntry {
|
||||
username: string;
|
||||
/** Authentication mode */
|
||||
authMode: 'basic' | 'oauth';
|
||||
/** Cookie slot index (0–4) for session/token cookies */
|
||||
/** Cookie slot index for session/token cookies (0 ≤ slot < MAX_ACCOUNT_SLOTS) */
|
||||
cookieSlot: number;
|
||||
/** Whether "Remember Me" was checked (basic auth only) */
|
||||
rememberMe: boolean;
|
||||
@@ -80,8 +80,9 @@ export const useAccountStore = create<AccountState>()(
|
||||
return id;
|
||||
}
|
||||
|
||||
if (state.accounts.length >= MAX_ACCOUNTS) {
|
||||
throw new Error(`Maximum of ${MAX_ACCOUNTS} accounts reached`);
|
||||
const max = getMaxAccounts();
|
||||
if (state.accounts.length >= max) {
|
||||
throw new Error(`Maximum of ${max} accounts reached`);
|
||||
}
|
||||
|
||||
const cookieSlot = state.getNextCookieSlot();
|
||||
@@ -178,10 +179,9 @@ export const useAccountStore = create<AccountState>()(
|
||||
|
||||
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
|
||||
let i = 0;
|
||||
while (used.has(i)) i++;
|
||||
return i;
|
||||
},
|
||||
|
||||
hasAccount: (username, serverUrl) => {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
export const ADMIN_TABS = [
|
||||
'dashboard',
|
||||
'settings',
|
||||
'branding',
|
||||
'auth',
|
||||
'policy',
|
||||
'plugins',
|
||||
'themes',
|
||||
'marketplace',
|
||||
'version',
|
||||
'telemetry',
|
||||
'logs',
|
||||
] as const;
|
||||
|
||||
export type AdminTabId = typeof ADMIN_TABS[number];
|
||||
|
||||
export function isAdminTab(value: string | null | undefined): value is AdminTabId {
|
||||
return typeof value === 'string' && (ADMIN_TABS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
interface AdminTabState {
|
||||
activeTab: AdminTabId;
|
||||
setActiveTab: (tab: AdminTabId) => void;
|
||||
}
|
||||
|
||||
// Tab state lives in client memory + localStorage. Sidebar clicks update
|
||||
// state (no URL navigation) so React can commit the transition immediately,
|
||||
// avoiding the dev-mode "Rendering…" hang we saw when each tab was its own
|
||||
// route or distinguished by ?tab= search param.
|
||||
export const useAdminTabStore = create<AdminTabState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
activeTab: 'dashboard',
|
||||
setActiveTab: (tab) => set({ activeTab: tab }),
|
||||
}),
|
||||
{ name: 'admin_active_tab' },
|
||||
),
|
||||
);
|
||||
+15
-6
@@ -37,7 +37,7 @@ interface AuthState {
|
||||
isDemoMode: boolean;
|
||||
|
||||
login: (serverUrl: string, username: string, password: string, totp?: string, rememberMe?: boolean) => Promise<boolean>;
|
||||
loginWithOAuth: (serverUrl: string, code: string, codeVerifier: string, redirectUri: string) => Promise<boolean>;
|
||||
loginWithOAuth: (serverUrl: string, code: string, codeVerifier: string, redirectUri: string, serverId?: string) => Promise<boolean>;
|
||||
loginWithServerSso: (code: string, state: string) => Promise<boolean>;
|
||||
loginDemo: () => Promise<boolean>;
|
||||
refreshAccessToken: () => Promise<string | null>;
|
||||
@@ -408,6 +408,9 @@ export const useAuthStore = create<AuthState>()(
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ serverUrl, username, password: effectivePassword, slot: cookieSlot }),
|
||||
// Note: server_id isn't passed here - the route looks up the
|
||||
// server entry by serverUrl, so per-server OAuth still applies
|
||||
// for password+TOTP logins through the dropdown.
|
||||
});
|
||||
if (tokenRes.ok) {
|
||||
const { access_token, expires_in, has_refresh_token } = await tokenRes.json();
|
||||
@@ -597,14 +600,14 @@ export const useAuthStore = create<AuthState>()(
|
||||
}
|
||||
},
|
||||
|
||||
loginWithOAuth: async (serverUrl, code, codeVerifier, redirectUri) => {
|
||||
loginWithOAuth: async (serverUrl, code, codeVerifier, redirectUri, serverId) => {
|
||||
set({ isLoading: true, error: null, isRateLimited: false, rateLimitUntil: null });
|
||||
|
||||
try {
|
||||
// Determine slot for this account (use slot from sessionStorage if re-adding).
|
||||
// Note: `parseInt(getItem(...) || '0')` collapses "no value set" and
|
||||
// "value is 0" into the same case, so the fallback to getNextCookieSlot()
|
||||
// never fired for the common "+ Add Account" path — every OAuth account
|
||||
// never fired for the common "+ Add Account" path - every OAuth account
|
||||
// ended up on slot 0 and overwrote earlier accounts' refresh-token cookies.
|
||||
// Distinguishing rawSlot === null from a parsed 0 fixes that. The page
|
||||
// also writes oauth_cookie_slot before redirecting to the IdP.
|
||||
@@ -620,7 +623,13 @@ export const useAuthStore = create<AuthState>()(
|
||||
const tokenRes = await apiFetch(`/api/auth/token?slot=${slot}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code, code_verifier: codeVerifier, redirect_uri: redirectUri, slot }),
|
||||
body: JSON.stringify({
|
||||
code,
|
||||
code_verifier: codeVerifier,
|
||||
redirect_uri: redirectUri,
|
||||
slot,
|
||||
...(serverId ? { server_id: serverId } : {}),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!tokenRes.ok) {
|
||||
@@ -800,7 +809,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
isDefault: accountStore.accounts.length === 0,
|
||||
});
|
||||
// The refresh-token cookie was written to `slot` by /api/auth/sso/complete.
|
||||
// Force the stored cookieSlot to match — see loginWithOAuth above for the
|
||||
// Force the stored cookieSlot to match - see loginWithOAuth above for the
|
||||
// re-add and concurrent-tab cases this guards against.
|
||||
accountStore.updateAccount(accountId, { cookieSlot: slot });
|
||||
accountStore.setActiveAccount(accountId);
|
||||
@@ -1232,7 +1241,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
for (const account of accounts) {
|
||||
if (clients.has(account.id)) continue; // Already connected
|
||||
|
||||
// Basic auth without rememberMe leaves nothing to restore — the
|
||||
// Basic auth without rememberMe leaves nothing to restore - the
|
||||
// user logged in without persisting credentials. Evict silently
|
||||
// so the login screen is shown without flagging a fake error.
|
||||
if (account.authMode === 'basic' && !account.rememberMe) {
|
||||
|
||||
+47
-6
@@ -98,6 +98,9 @@ export const usePluginStore = create<PluginStoreState>()(
|
||||
adminApproved: false, // Requires admin approval before it can be enabled
|
||||
settings: existing?.settings ?? {},
|
||||
settingsSchema: manifest.settingsSchema,
|
||||
...(manifest.httpOrigins && manifest.httpOrigins.length > 0
|
||||
? { httpOrigins: manifest.httpOrigins }
|
||||
: {}),
|
||||
};
|
||||
|
||||
// Save code to IndexedDB
|
||||
@@ -303,6 +306,15 @@ interface ServerPluginInfo {
|
||||
permissions: string[];
|
||||
entrypoint: string;
|
||||
forceEnabled: boolean;
|
||||
/** Content hash of the bundle - changes whenever code changes, even if the version doesn't */
|
||||
bundleHash?: string;
|
||||
updatedAt?: string;
|
||||
/** True when the plugin was loaded from the server's PLUGIN_DEV_DIR */
|
||||
dev?: boolean;
|
||||
/** Allowlist of origins this plugin may target via api.http.fetch(). */
|
||||
httpOrigins?: string[];
|
||||
/** Per-user settings schema, captured from the manifest server-side. */
|
||||
settingsSchema?: InstalledPlugin['settingsSchema'];
|
||||
}
|
||||
|
||||
const SERVER_MANAGED_KEY = 'server-managed-plugin-ids';
|
||||
@@ -382,7 +394,7 @@ async function syncServerPlugins(
|
||||
|
||||
if (!local) {
|
||||
// New server plugin - download and install
|
||||
const code = await downloadPluginBundle(sp.id);
|
||||
const code = await downloadPluginBundle(sp.id, sp.bundleHash);
|
||||
if (!code) continue;
|
||||
|
||||
await pluginStorage.saveCode(sp.id, code);
|
||||
@@ -402,6 +414,11 @@ async function syncServerPlugins(
|
||||
forceEnabled: sp.forceEnabled,
|
||||
adminApproved: true, // Server-managed plugins are always approved
|
||||
settings: {},
|
||||
settingsSchema: sp.settingsSchema,
|
||||
bundleHash: sp.bundleHash,
|
||||
...(sp.httpOrigins && sp.httpOrigins.length > 0
|
||||
? { httpOrigins: sp.httpOrigins }
|
||||
: {}),
|
||||
};
|
||||
|
||||
set(state => {
|
||||
@@ -410,9 +427,15 @@ async function syncServerPlugins(
|
||||
}
|
||||
return { plugins: [...state.plugins, plugin] };
|
||||
});
|
||||
} else if (local.version !== sp.version) {
|
||||
// Version changed - re-download bundle
|
||||
const code = await downloadPluginBundle(sp.id);
|
||||
} else if (
|
||||
local.version !== sp.version ||
|
||||
// bundleHash mismatch covers re-uploads of the same version with new
|
||||
// code. Falsy local hash (older installs that never carried one) also
|
||||
// forces a refresh so we capture the hash on the next sync.
|
||||
(sp.bundleHash && local.bundleHash !== sp.bundleHash)
|
||||
) {
|
||||
// Version or content changed - re-download bundle
|
||||
const code = await downloadPluginBundle(sp.id, sp.bundleHash);
|
||||
if (!code) continue;
|
||||
|
||||
await pluginStorage.saveCode(sp.id, code);
|
||||
@@ -430,6 +453,9 @@ async function syncServerPlugins(
|
||||
entrypoint: sp.entrypoint,
|
||||
managed: true,
|
||||
forceEnabled: sp.forceEnabled,
|
||||
bundleHash: sp.bundleHash,
|
||||
httpOrigins: sp.httpOrigins,
|
||||
settingsSchema: sp.settingsSchema,
|
||||
}
|
||||
: p
|
||||
),
|
||||
@@ -442,10 +468,22 @@ async function syncServerPlugins(
|
||||
...p,
|
||||
managed: true,
|
||||
forceEnabled: sp.forceEnabled,
|
||||
settingsSchema: sp.settingsSchema,
|
||||
}
|
||||
: p
|
||||
),
|
||||
}));
|
||||
} else if (
|
||||
JSON.stringify(local.settingsSchema ?? null) !== JSON.stringify(sp.settingsSchema ?? null)
|
||||
) {
|
||||
// Schema drift: the bundle is current but the persisted plugin record
|
||||
// pre-dates the server passing settingsSchema through, so the per-user
|
||||
// settings UI was rendering empty. Patch the schema in place.
|
||||
set(state => ({
|
||||
plugins: state.plugins.map(p =>
|
||||
p.id === sp.id ? { ...p, settingsSchema: sp.settingsSchema } : p
|
||||
),
|
||||
}));
|
||||
} else if (sp.forceEnabled && !local.enabled) {
|
||||
// Force-enable if the server says so but client has it disabled
|
||||
set(state => ({
|
||||
@@ -483,9 +521,12 @@ async function syncServerPlugins(
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadPluginBundle(pluginId: string): Promise<string | null> {
|
||||
async function downloadPluginBundle(pluginId: string, bundleHash?: string): Promise<string | null> {
|
||||
try {
|
||||
const res = await apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/bundle`);
|
||||
// Append the hash as a query string so any intermediary HTTP cache
|
||||
// (browser, service worker, CDN) treats each version as a distinct URL.
|
||||
const suffix = bundleHash ? `?v=${encodeURIComponent(bundleHash)}` : '';
|
||||
const res = await apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/bundle${suffix}`);
|
||||
if (!res.ok) return null;
|
||||
return await res.text();
|
||||
} catch {
|
||||
|
||||
@@ -192,7 +192,6 @@ interface SettingsState {
|
||||
// Email Display
|
||||
disableThreading: boolean; // Show emails as individual messages instead of grouped by conversation
|
||||
|
||||
// Experimental
|
||||
senderFavicons: boolean;
|
||||
showAvatarsInJunk: boolean; // Show profile images/favicons in the junk folder
|
||||
|
||||
@@ -346,7 +345,6 @@ const DEFAULT_SETTINGS = {
|
||||
// Email Display
|
||||
disableThreading: false,
|
||||
|
||||
// Experimental
|
||||
senderFavicons: true,
|
||||
showAvatarsInJunk: false,
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ export const useUpdateStore = create<UpdateState>()((set, get) => ({
|
||||
lastFetchedAt: Date.now(),
|
||||
});
|
||||
} catch {
|
||||
// Silent — banner just won't appear, no need to disrupt the UI.
|
||||
// Silent - banner just won't appear, no need to disrupt the UI.
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
inFlight = null;
|
||||
|
||||
Reference in New Issue
Block a user