+
) : filtered.length === 0 ? (
diff --git a/components/contacts/contact-import-dialog.tsx b/components/contacts/contact-import-dialog.tsx
index 159ba111..00848197 100644
--- a/components/contacts/contact-import-dialog.tsx
+++ b/components/contacts/contact-import-dialog.tsx
@@ -2,7 +2,7 @@
import { useState, useRef, useCallback } from "react";
import { useTranslations } from "next-intl";
-import { Upload, FileText, AlertTriangle, X, Check, ChevronDown } from "lucide-react";
+import { Upload, FileText, AlertTriangle, X, Check } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { parseVCard, detectDuplicates } from "@/lib/vcard";
diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx
index addb4356..20400084 100644
--- a/components/email/email-composer.tsx
+++ b/components/email/email-composer.tsx
@@ -308,8 +308,14 @@ export function EmailComposer({
getIdentityReplySignatureId,
} = useSignatureStore();
+ // Lazy useState initializer (below) — runs during the FIRST render, before
+ // the selectedIdentityId state declared further down exists yet (same TDZ
+ // constraint the initialCurrentIdentityForSig comment a few lines down
+ // already documents). On that first render selectedIdentityId can only be
+ // unset anyway (nothing has called setSelectedIdentityId yet), so reading
+ // initialData directly is equivalent, not a workaround.
const resolveStoreSignatureId = (): string | null => {
- const perIdentityId = selectedIdentityId || initialData?.selectedIdentityId || null;
+ const perIdentityId = initialData?.selectedIdentityId || null;
if (mode === 'compose') {
if (perIdentityId) {
const id = getIdentityDefaultSignatureId(perIdentityId);
diff --git a/components/settings/ai-assistant-settings.tsx b/components/settings/ai-assistant-settings.tsx
index d5fc3c8d..dff2d171 100644
--- a/components/settings/ai-assistant-settings.tsx
+++ b/components/settings/ai-assistant-settings.tsx
@@ -9,7 +9,11 @@ import { useAccountStore } from '@/stores/account-store';
import { DEFAULT_AI_POLICY, type AiPolicy } from '@/lib/ai/types';
import { supportsLocalLlm, localLlmNeedsCorsSetup } from '@/lib/platform-capabilities';
import { getAiApiKey, setAiApiKey, clearAiApiKey } from '@/lib/ai/key-store';
-import { loadAiSettings, saveAiSettings, createProfile, type AiLocalSettings } from '@/lib/ai/local-settings';
+import {
+ loadAiSettings, saveAiSettings, createProfile, presetActiveId, presetIdFromActiveId,
+ type AiLocalSettings,
+} from '@/lib/ai/local-settings';
+import { ensureDefaultProvider } from '@/lib/ai/auto-provision';
import {
discoverLocalOllama,
recommendDefaultModel,
@@ -56,7 +60,23 @@ export function AiAssistantSettings() {
(async () => {
try {
const res = await apiFetch('/api/ai/policy');
- if (res.ok && !cancelled) setPolicy(await res.json());
+ if (res.ok && !cancelled) {
+ const loadedPolicy = (await res.json()) as AiPolicy;
+ setPolicy(loadedPolicy);
+ // Zero-config default (lib/ai/auto-provision.ts) — a no-op once a
+ // provider is already chosen, so this is safe to run on every
+ // visit to this pane, not just first-run.
+ const next = await ensureDefaultProvider(loadedPolicy);
+ // Separately, once an admin has published at least one org-managed
+ // preset, make IT the default "Answer with" pick too — pasting a
+ // personal key should be the fallback a user reaches for, not the
+ // thing they have to do to get any answer at all.
+ if (!next.activeProfileId && loadedPolicy.publicPresets[0]) {
+ next.activeProfileId = presetActiveId(loadedPolicy.publicPresets[0].id);
+ saveAiSettings(next);
+ }
+ if (!cancelled) setSettings(next);
+ }
} finally {
if (!cancelled) setPolicyLoading(false);
}
@@ -289,7 +309,9 @@ export function AiAssistantSettings() {
const [askResult, setAskResult] = useState
(null);
const [askError, setAskError] = useState(null);
- const activeProfile = settings.publicProfiles.find((p) => p.id === settings.activeProfileId) ?? null;
+ const activePresetId = presetIdFromActiveId(settings.activeProfileId);
+ const activeProfile = activePresetId ? null : settings.publicProfiles.find((p) => p.id === settings.activeProfileId) ?? null;
+ const activePublicSelection = !!activeProfile || (!!activePresetId && policy.publicPresets.some((p) => p.id === activePresetId));
const canAsk =
question.trim().length > 0 &&
@@ -300,7 +322,7 @@ export function AiAssistantSettings() {
: settings.provider === 'opencode'
? canUseOpencode && !!settings.opencodeModel
: settings.provider === 'public'
- ? canUsePublic && !!activeProfile && settings.publicConsentAccepted
+ ? canUsePublic && activePublicSelection && settings.publicConsentAccepted
: false);
const runAsk = useCallback(async () => {
@@ -318,6 +340,7 @@ export function AiAssistantSettings() {
opencodeModel: settings.opencodeModel,
slot: activeSlot,
publicProfile: activeProfile && key ? { baseUrl: activeProfile.baseUrl, model: activeProfile.model, apiKey: key } : null,
+ publicPresetId: activePresetId,
});
setAskResult(result);
if (result.seatJustAssigned) {
@@ -328,7 +351,7 @@ export function AiAssistantSettings() {
} finally {
setAsking(false);
}
- }, [question, settings, activeProfile, activeSlot]);
+ }, [question, settings, activeProfile, activePresetId, activeSlot]);
const providerOptions = useMemo(
() => [
@@ -622,8 +645,25 @@ export function AiAssistantSettings() {
title="Public providers"
description="Save several — different models for different questions. Any OpenAI-compatible endpoint works. Keys are stored only in this browser and, for now, use of this class is not monitored or metered by VNC."
>
+ {policy.publicPresets.length > 0 && (
+
+
+ {policy.publicPresets.map((p) => (
+
+
+
{p.name}
+
{p.model} · managed by admin
+
+
+ ))}
+
+
+ )}
{settings.publicProfiles.length > 0 && (
-
+
{settings.publicProfiles.map((p) => (
@@ -639,7 +679,7 @@ export function AiAssistantSettings() {
)}
-
+
- {settings.provider === 'public' && settings.publicProfiles.length > 0 && (
+ {settings.provider === 'public' && (settings.publicProfiles.length > 0 || policy.publicPresets.length > 0) && (
update('activeProfileId', v)}
- options={settings.publicProfiles.map((p) => ({ value: p.id, label: p.name }))}
+ options={[
+ ...policy.publicPresets.map((p) => ({ value: presetActiveId(p.id), label: `${p.name} (org)` })),
+ ...settings.publicProfiles.map((p) => ({ value: p.id, label: p.name })),
+ ]}
/>
)}
diff --git a/components/settings/import-settings.tsx b/components/settings/import-settings.tsx
index 461e8582..2192efa0 100644
--- a/components/settings/import-settings.tsx
+++ b/components/settings/import-settings.tsx
@@ -2,7 +2,7 @@
import { useState, useRef, useCallback, useEffect } from "react";
import { useTranslations } from "next-intl";
-import { Upload, FolderOpen, Download, AlertTriangle, Check, X } from "lucide-react";
+import { Upload, AlertTriangle, Check, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { SettingsSection, SettingItem, RadioGroup, Select } from "./settings-section";
import { importEmails, type ConflictResolution, type ImportProgress, type ImportResult } from "@/lib/email-import";
@@ -128,7 +128,7 @@ export function ImportSettings() {
: t("choose_files")}
{files.length > 0 && !importing && (
-
+
)}
diff --git a/components/settings/signature-editor-modal.tsx b/components/settings/signature-editor-modal.tsx
index 1e456610..c777df8a 100644
--- a/components/settings/signature-editor-modal.tsx
+++ b/components/settings/signature-editor-modal.tsx
@@ -184,7 +184,7 @@ export function SignatureEditorModal({
{isEditing ? t('edit_signature') : t('new_signature')}
-
+
diff --git a/components/ui/radial-menu.tsx b/components/ui/radial-menu.tsx
index 35f74c78..31989a9f 100644
--- a/components/ui/radial-menu.tsx
+++ b/components/ui/radial-menu.tsx
@@ -33,6 +33,13 @@ export function RadialMenu({
const [activeIndex, setActiveIndex] = useState
(-1);
const [animatingIn, setAnimatingIn] = useState(false);
const menuRef = useRef(null);
+ const activeIndexRef = useRef(activeIndex);
+ const itemsRef = useRef(items);
+ const onCloseRef = useRef(onClose);
+
+ activeIndexRef.current = activeIndex;
+ itemsRef.current = items;
+ onCloseRef.current = onClose;
useEffect(() => {
setMounted(true);
@@ -51,45 +58,54 @@ export function RadialMenu({
setActiveIndex(-1);
const handleKeyDown = (e: KeyboardEvent) => {
+ const items = itemsRef.current;
+ const currentIndex = activeIndexRef.current;
+
if (e.key === "Escape") {
e.preventDefault();
- onClose();
+ onCloseRef.current();
return;
}
- if (e.key === "Enter" && activeIndex >= 0 && activeIndex < items.length) {
- e.preventDefault();
- const item = items[activeIndex];
- if (!item.disabled) {
- item.onClick();
- onClose();
+ if (e.key === "Enter") {
+ if (currentIndex >= 0 && currentIndex < items.length) {
+ e.preventDefault();
+ const item = items[currentIndex];
+ if (!item.disabled) {
+ item.onClick();
+ onCloseRef.current();
+ }
}
return;
}
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
e.preventDefault();
setActiveIndex((prev) => {
- let next = prev + 1;
- if (next >= items.length) next = 0;
+ const hasEnabledItem = items.some((item) => !item.disabled);
+ if (!hasEnabledItem) return -1;
+
+ let next = prev;
let loops = 0;
- while (items[next]?.disabled && loops < items.length) {
+ do {
next = next + 1 >= items.length ? 0 : next + 1;
loops++;
- }
- return next;
+ } while (items[next]?.disabled && loops < items.length);
+ return items[next]?.disabled ? -1 : next;
});
return;
}
if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
e.preventDefault();
setActiveIndex((prev) => {
- let next = prev - 1;
- if (next < 0) next = items.length - 1;
+ const hasEnabledItem = items.some((item) => !item.disabled);
+ if (!hasEnabledItem) return -1;
+
+ let next = prev;
let loops = 0;
- while (items[next]?.disabled && loops < items.length) {
+ do {
next = next - 1 < 0 ? items.length - 1 : next - 1;
loops++;
- }
- return next;
+ } while (items[next]?.disabled && loops < items.length);
+ return items[next]?.disabled ? -1 : next;
});
return;
}
@@ -97,7 +113,7 @@ export function RadialMenu({
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
- }, [isOpen, activeIndex, items, onClose]);
+ }, [isOpen]);
const radius = size / 2 - 28;
const center = size / 2;
diff --git a/lib/admin/types.ts b/lib/admin/types.ts
index d9461b0f..612f29f0 100644
--- a/lib/admin/types.ts
+++ b/lib/admin/types.ts
@@ -238,13 +238,31 @@ export const CONFIG_ENV_MAP: Record ({ listOpencodeModels: vi.fn() }));
+vi.mock('../local-client', () => ({ listOpencodeModels }));
+
+const { discoverLocalOllama, recommendDefaultModel } = vi.hoisted(() => ({
+ discoverLocalOllama: vi.fn(),
+ recommendDefaultModel: vi.fn(),
+}));
+vi.mock('../local-discovery', () => ({ discoverLocalOllama, recommendDefaultModel }));
+
+const { supportsLocalLlm } = vi.hoisted(() => ({ supportsLocalLlm: vi.fn(() => true) }));
+vi.mock('../../platform-capabilities', () => ({ supportsLocalLlm }));
+
+// Imported after the mocks so it picks up the mocked modules.
+const { ensureDefaultProvider, _resetAutoProvisionForTests } = await import('../auto-provision');
+
+function policyWith(classes: AiPolicy['entitlement']['classes']): AiPolicy {
+ return { ...DEFAULT_AI_POLICY, entitlement: { ...DEFAULT_AI_ENTITLEMENT, classes } };
+}
+
+describe('ensureDefaultProvider', () => {
+ beforeEach(() => {
+ window.localStorage.clear();
+ _resetAutoProvisionForTests();
+ listOpencodeModels.mockReset();
+ discoverLocalOllama.mockReset();
+ recommendDefaultModel.mockReset();
+ supportsLocalLlm.mockReturnValue(true);
+ });
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it('prefers OpenCode when it has a usable model', async () => {
+ listOpencodeModels.mockResolvedValue([{ ref: 'opencode/deepseek-v4-flash-free', label: 'DeepSeek V4 Flash Free' }]);
+
+ const next = await ensureDefaultProvider(policyWith(['opencode', 'local']));
+
+ expect(next.provider).toBe('opencode');
+ expect(next.opencodeModel).toBe('opencode/deepseek-v4-flash-free');
+ expect(discoverLocalOllama).not.toHaveBeenCalled();
+ expect(loadAiSettings().provider).toBe('opencode'); // persisted, not just returned
+ });
+
+ it('falls back to Ollama when OpenCode is unreachable', async () => {
+ listOpencodeModels.mockRejectedValue(new Error('No local OpenCode server is running'));
+ discoverLocalOllama.mockResolvedValue({ baseUrl: 'http://127.0.0.1:11434', models: [{ name: 'qwen2.5:32b' }] });
+ recommendDefaultModel.mockReturnValue('qwen2.5:32b');
+
+ const next = await ensureDefaultProvider(policyWith(['opencode', 'local']));
+
+ expect(next.provider).toBe('local');
+ expect(next.localModel).toBe('qwen2.5:32b');
+ });
+
+ it('leaves provider unset when neither is available', async () => {
+ listOpencodeModels.mockResolvedValue([]);
+ discoverLocalOllama.mockResolvedValue(null);
+
+ const next = await ensureDefaultProvider(policyWith(['opencode', 'local']));
+
+ expect(next.provider).toBeNull();
+ });
+
+ it('never overrides an explicit choice already saved', async () => {
+ const { saveAiSettings, DEFAULT_AI_SETTINGS } = await import('../local-settings');
+ saveAiSettings({ ...DEFAULT_AI_SETTINGS, provider: 'server', serverModel: 'qwen2.5:32b' });
+
+ const next = await ensureDefaultProvider(policyWith(['opencode', 'local', 'server']));
+
+ expect(next.provider).toBe('server');
+ expect(listOpencodeModels).not.toHaveBeenCalled();
+ });
+
+ it('only probes once per module lifetime even if called again', async () => {
+ listOpencodeModels.mockResolvedValue([]);
+ discoverLocalOllama.mockResolvedValue(null);
+
+ await ensureDefaultProvider(policyWith(['opencode', 'local']));
+ await ensureDefaultProvider(policyWith(['opencode', 'local']));
+
+ expect(listOpencodeModels).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/lib/ai/__tests__/local-client.test.ts b/lib/ai/__tests__/local-client.test.ts
new file mode 100644
index 00000000..c1b17245
--- /dev/null
+++ b/lib/ai/__tests__/local-client.test.ts
@@ -0,0 +1,81 @@
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import { chatPublic, chatPublicManaged } from '../local-client';
+
+describe('chatPublic', () => {
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it('turns a network/CORS-level failure into an actionable message naming the Base URL', async () => {
+ vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to fetch')));
+
+ await expect(
+ chatPublic('https://platform.deepseek.com/', 'sk-test', 'deepseek-chat', [
+ { role: 'user', content: 'hi' },
+ ]),
+ ).rejects.toThrow(/Could not reach https:\/\/platform\.deepseek\.com\/chat\/completions/);
+ });
+
+ it('still reports the provider-returned status when the request completes', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue({ ok: false, status: 401, json: async () => ({}) }),
+ );
+
+ await expect(
+ chatPublic('https://api.deepseek.com', 'sk-bad', 'deepseek-chat', [
+ { role: 'user', content: 'hi' },
+ ]),
+ ).rejects.toThrow('Provider returned 401');
+ });
+
+ it('returns the message content on success', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => ({ choices: [{ message: { content: 'hello there' } }] }),
+ }),
+ );
+
+ await expect(
+ chatPublic('https://api.deepseek.com', 'sk-good', 'deepseek-chat', [
+ { role: 'user', content: 'hi' },
+ ]),
+ ).resolves.toBe('hello there');
+ });
+});
+
+describe('chatPublicManaged', () => {
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it('posts presetId (never a key) to the same-origin route', async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({ answer: 'hi from the org preset' }),
+ });
+ vi.stubGlobal('fetch', fetchMock);
+
+ const answer = await chatPublicManaged('preset-abc123', [{ role: 'user', content: 'hi' }]);
+
+ expect(answer).toBe('hi from the org preset');
+ expect(fetchMock).toHaveBeenCalledWith('/api/ai/public/chat', expect.objectContaining({
+ method: 'POST',
+ body: JSON.stringify({ presetId: 'preset-abc123', messages: [{ role: 'user', content: 'hi' }] }),
+ }));
+ });
+
+ it('surfaces the server-side error (e.g. env var not set) verbatim', async () => {
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
+ ok: false,
+ status: 503,
+ json: async () => ({ error: 'Env var "DEEPSEEK_API_KEY" is not set on the server for preset "DeepSeek (org)"' }),
+ }));
+
+ await expect(chatPublicManaged('preset-abc123', [{ role: 'user', content: 'hi' }]))
+ .rejects.toThrow(/Env var "DEEPSEEK_API_KEY" is not set/);
+ });
+});
diff --git a/lib/ai/__tests__/local-settings.test.ts b/lib/ai/__tests__/local-settings.test.ts
new file mode 100644
index 00000000..0452c516
--- /dev/null
+++ b/lib/ai/__tests__/local-settings.test.ts
@@ -0,0 +1,22 @@
+import { describe, it, expect } from 'vitest';
+import { presetActiveId, isPresetActiveId, presetIdFromActiveId } from '../local-settings';
+
+describe('preset active-id helpers', () => {
+ it('round-trips a preset id through the prefixed activeProfileId space', () => {
+ const activeId = presetActiveId('preset-abc123');
+ expect(activeId).toBe('preset:preset-abc123');
+ expect(isPresetActiveId(activeId)).toBe(true);
+ expect(presetIdFromActiveId(activeId)).toBe('preset-abc123');
+ });
+
+ it('does not mistake a personal profile id for a preset id', () => {
+ const profileId = 'profile-xyz789-abc123';
+ expect(isPresetActiveId(profileId)).toBe(false);
+ expect(presetIdFromActiveId(profileId)).toBeNull();
+ });
+
+ it('handles null safely', () => {
+ expect(isPresetActiveId(null)).toBe(false);
+ expect(presetIdFromActiveId(null)).toBeNull();
+ });
+});
diff --git a/lib/ai/auto-provision.ts b/lib/ai/auto-provision.ts
new file mode 100644
index 00000000..97943dca
--- /dev/null
+++ b/lib/ai/auto-provision.ts
@@ -0,0 +1,66 @@
+// First-run, zero-config default provider (product decision 2026-08-07:
+// "user wants to use AI so set the local one to on always by default" — not
+// "user wants to configure AI"). Before this, a fresh install left
+// `settings.provider` at `null` and every AI entry point just told the user
+// to go set one up in Settings.
+//
+// Priority: OpenCode first, then Ollama. OpenCode is the one local option
+// this app controls end to end — electron/main.ts auto-spawns
+// `opencode serve` itself, so "OpenCode has a model" only depends on what's
+// already authenticated in its own auth.json, not on the user having
+// installed anything separately. Ollama is second because it's an external
+// dependency the user must have installed and started themselves — real,
+// but not zero-config the way OpenCode is here.
+//
+// Never overrides an explicit choice: fires only while `provider` is still
+// `null`, and at most once per page load (module-level `attempted`) so a
+// component re-mounting doesn't re-probe on every render.
+import { loadAiSettings, saveAiSettings, type AiLocalSettings } from './local-settings';
+import { listOpencodeModels } from './local-client';
+import { discoverLocalOllama, recommendDefaultModel } from './local-discovery';
+import { supportsLocalLlm } from '../platform-capabilities';
+import type { AiPolicy } from './types';
+
+let attempted = false;
+
+/** Test-only: lets a fresh module state be simulated without a full reload. */
+export function _resetAutoProvisionForTests(): void {
+ attempted = false;
+}
+
+export async function ensureDefaultProvider(policy: AiPolicy): Promise {
+ const current = loadAiSettings();
+ if (current.provider !== null || attempted) return current;
+ attempted = true;
+
+ if (policy.entitlement.classes.includes('opencode')) {
+ try {
+ const models = await listOpencodeModels();
+ if (models[0]) {
+ const next: AiLocalSettings = { ...current, provider: 'opencode', opencodeModel: models[0].ref };
+ saveAiSettings(next);
+ return next;
+ }
+ } catch {
+ // opencode not reachable yet (still starting, or the CLI isn't
+ // installed) — fall through to Ollama rather than surfacing an error
+ // for a default the user never asked for.
+ }
+ }
+
+ if (supportsLocalLlm() && policy.entitlement.classes.includes('local')) {
+ const discovery = await discoverLocalOllama();
+ if (discovery) {
+ const recommended = recommendDefaultModel(discovery.models) ?? discovery.models[0]?.name ?? null;
+ if (recommended) {
+ const next: AiLocalSettings = {
+ ...current, provider: 'local', localBaseUrl: discovery.baseUrl, localModel: recommended,
+ };
+ saveAiSettings(next);
+ return next;
+ }
+ }
+ }
+
+ return current;
+}
diff --git a/lib/ai/local-client.ts b/lib/ai/local-client.ts
index 37420720..c9550b87 100644
--- a/lib/ai/local-client.ts
+++ b/lib/ai/local-client.ts
@@ -135,14 +135,33 @@ export async function chatPublic(
model: string,
messages: ChatMessage[],
): Promise {
- const res = await fetch(`${baseUrl.replace(/\/+$/, '')}/chat/completions`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- Authorization: `Bearer ${apiKey}`,
- },
- body: JSON.stringify({ model, messages }),
- });
+ const url = `${baseUrl.replace(/\/+$/, '')}/chat/completions`;
+ let res: Response;
+ try {
+ res = await fetch(url, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${apiKey}`,
+ },
+ body: JSON.stringify({ model, messages }),
+ });
+ } catch {
+ // A request that never got a response (DNS failure, TLS failure, or -
+ // by far the most common cause in practice - a CORS preflight the
+ // target rejected) surfaces to fetch() as a bare, undifferentiated
+ // "TypeError: Failed to fetch" with no status code to inspect. Verified
+ // live: a Base URL pointing at a provider's website instead of its API
+ // (platform.deepseek.com vs api.deepseek.com) fails exactly this way,
+ // the preflight OPTIONS getting a 403 with no Access-Control-* headers
+ // at all. Naming the Base URL is the one actionable thing this error
+ // can tell the user, since the browser gives back nothing else.
+ throw new Error(
+ `Could not reach ${url} — check the Base URL is the provider's API endpoint, not its ` +
+ 'website or console (e.g. api.deepseek.com, not platform.deepseek.com), and that it ' +
+ 'allows being called directly from a browser.',
+ );
+ }
if (!res.ok) throw new Error(`Provider returned ${res.status}`);
const body = (await res.json()) as OpenAiChatResponse;
const content = body.choices?.[0]?.message?.content;
@@ -150,6 +169,24 @@ export async function chatPublic(
return content;
}
+// ── Public, admin-managed presets — the Paperclip-style alternative to
+// pasting a personal key (decision 2026-08-07). The client only ever sends a
+// presetId; the server resolves the actual key from its own environment (see
+// app/api/ai/public/chat/route.ts) and makes the call itself, which also
+// sidesteps the CORS/wrong-base-URL failure class chatPublic is exposed to. ──
+
+export async function chatPublicManaged(presetId: string, messages: ChatMessage[]): Promise {
+ const res = await fetch('/api/ai/public/chat', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ presetId, messages }),
+ });
+ const body = await res.json().catch(() => ({}));
+ if (!res.ok) throw new Error(body?.error || `Provider returned ${res.status}`);
+ if (!body?.answer) throw new Error('Provider returned no message content');
+ return body.answer as string;
+}
+
// ── OpenCode: a locally-running `opencode serve` (github.com/sst/opencode),
// the same agent runtime Paperclip drives as an adapter. Reached through THIS
// app's own backend (app/api/ai/opencode/*) rather than directly, for the same
@@ -419,7 +456,12 @@ export interface AskConfig {
localBaseUrl: string;
localModel: string | null;
serverModel: string | null;
+ /** Personal BYOK profile — mutually exclusive with publicPresetId; the
+ * caller sets exactly one depending on which the user picked. */
publicProfile: ResolvedPublicProfile | null;
+ /** Admin-managed preset id (see chatPublicManaged) — mutually exclusive
+ * with publicProfile. */
+ publicPresetId?: string | null;
opencodeModel?: string | null;
/** Cookie slot of the account whose local index should be searched. Omitting
* it reads whichever account the resolver finds first — see fetchLocalLeg. */
@@ -433,7 +475,7 @@ export async function askMail(question: string, config: AskConfig): Promise> {
const result = new Map();
@@ -139,7 +145,9 @@ export async function fetchFreeBusy(
try {
const events = await client.queryAllCalendarEvents(
{ after: start.toISOString(), before: end.toISOString() },
- [{ property: "start", isAscending: true }]
+ [{ property: "start", isAscending: true }],
+ undefined,
+ accountId
);
for (const event of events) {
diff --git a/lib/collabora/client.ts b/lib/collabora/client.ts
index 724939fc..3c8260c0 100644
--- a/lib/collabora/client.ts
+++ b/lib/collabora/client.ts
@@ -79,8 +79,10 @@ export async function getCollaboraEditUrl(
// For now, return the base edit URL. A full WOPI implementation would
// generate a WOPI src URL with an access token pointing back to this server.
+ const appUrl = configManager.get("appUrl") || process.env.NEXT_PUBLIC_APP_URL;
+ const port = configManager.get("port") || process.env.PORT || "3000";
const wopiSrcUrl = `${actionUrl}?WOPISrc=${encodeURIComponent(
- `${process.env.NEXT_PUBLIC_APP_URL || `http://localhost:${process.env.PORT || 3000}`}/api/collabora/wopi/files/${encodeURIComponent(fileId)}`
+ `${appUrl || `http://localhost:${port}`}/api/collabora/wopi/files/${encodeURIComponent(fileId)}`
)}`;
return wopiSrcUrl;
diff --git a/lib/demo/demo-client.ts b/lib/demo/demo-client.ts
index 1971856d..c5d6b82b 100644
--- a/lib/demo/demo-client.ts
+++ b/lib/demo/demo-client.ts
@@ -888,16 +888,17 @@ export class DemoJMAPClient implements IJMAPClient {
return { destroyed: eventIds, notDestroyed: [] };
}
- async queryCalendarEvents(filter: CalendarEventFilter): Promise {
- return this.data.calendarEvents.filter(e => {
+ async queryCalendarEvents(filter: CalendarEventFilter, sort?: Array<{ property: string; isAscending: boolean }>, limit?: number): Promise {
+ const events = this.data.calendarEvents.filter(e => {
if (filter.after && e.start < filter.after) return false;
if (filter.before && e.start > filter.before) return false;
return true;
});
+ return limit ? events.slice(0, limit) : events;
}
- async queryAllCalendarEvents(filter: CalendarEventFilter): Promise {
- return this.queryCalendarEvents(filter);
+ async queryAllCalendarEvents(filter: CalendarEventFilter, sort?: Array<{ property: string; isAscending: boolean }>, limit?: number): Promise {
+ return this.queryCalendarEvents(filter, sort, limit);
}
async parseCalendarEvents(): Promise[]> {
diff --git a/lib/email-import.ts b/lib/email-import.ts
index b8fcdc5f..2ab67c77 100644
--- a/lib/email-import.ts
+++ b/lib/email-import.ts
@@ -1,5 +1,4 @@
import type { IJMAPClient } from "@/lib/jmap/client-interface";
-import type { Mailbox } from "@/lib/jmap/types";
import { expandImportableEmails } from "@/lib/eml-import";
export type ConflictResolution = "skip" | "replace" | "copy";
@@ -20,15 +19,6 @@ export interface ImportResult {
errors: Array<{ file: string; error: string }>;
}
-function toBase64(buffer: ArrayBuffer): string {
- let binary = "";
- const bytes = new Uint8Array(buffer);
- for (let i = 0; i < bytes.byteLength; i++) {
- binary += String.fromCharCode(bytes[i]);
- }
- return btoa(binary);
-}
-
interface ParsedEml {
messageId: string | null;
subject: string;
diff --git a/lib/eml-import.ts b/lib/eml-import.ts
index f6fa034d..a2d2fd03 100644
--- a/lib/eml-import.ts
+++ b/lib/eml-import.ts
@@ -17,10 +17,6 @@ function isTgzName(name: string): boolean {
return /\.(tgz|tar\.gz)$/i.test(name);
}
-function isArchiveName(name: string): boolean {
- return isZipName(name) || isTgzName(name);
-}
-
async function extractEmlsFromZip(file: File): Promise {
const { default: JSZip } = await import("jszip");
const zip = await JSZip.loadAsync(await file.arrayBuffer());
diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts
index c1fd9dab..938444a7 100644
--- a/lib/jmap/client-interface.ts
+++ b/lib/jmap/client-interface.ts
@@ -300,7 +300,7 @@ export interface IJMAPClient {
deleteCalendarEvent(eventId: string, sendSchedulingMessages?: boolean, targetAccountId?: string): Promise;
batchDeleteCalendarEvents(eventIds: string[], targetAccountId?: string): Promise<{ destroyed: string[]; notDestroyed: string[] }>;
queryCalendarEvents(filter: CalendarEventFilter, sort?: Array<{ property: string; isAscending: boolean }>, limit?: number, targetAccountId?: string): Promise;
- queryAllCalendarEvents(filter: CalendarEventFilter, sort?: Array<{ property: string; isAscending: boolean }>, limit?: number): Promise;
+ queryAllCalendarEvents(filter: CalendarEventFilter, sort?: Array<{ property: string; isAscending: boolean }>, limit?: number, accountId?: string): Promise;
parseCalendarEvents(accountId: string, blobId: string): Promise[]>;
// ── Calendar Tasks ────────────────────────────────────────────
diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts
index d0287618..163b5840 100644
--- a/lib/jmap/client.ts
+++ b/lib/jmap/client.ts
@@ -4957,12 +4957,13 @@ export class JMAPClient implements IJMAPClient {
async queryAllCalendarEvents(
filter: CalendarEventFilter,
sort?: Array<{ property: string; isAscending: boolean }>,
- limit?: number
+ limit?: number,
+ accountId?: string
): Promise {
try {
const allEvents: CalendarEvent[] = [];
const primaryId = this.getCalendarsAccountId();
- const accountIds = this.getCalendarCapableAccountIds();
+ const accountIds = accountId ? [accountId] : this.getCalendarCapableAccountIds();
for (const accountId of accountIds) {
const isPrimary = accountId === primaryId;
diff --git a/lib/sharing-rights.ts b/lib/sharing-rights.ts
new file mode 100644
index 00000000..ba6011a1
--- /dev/null
+++ b/lib/sharing-rights.ts
@@ -0,0 +1,216 @@
+import type {
+ MailboxRights,
+ CalendarRights,
+ AddressBookRights,
+ FileNodeRights,
+} from "@/lib/jmap/types";
+
+export type SharedResourceKind =
+ | "mailbox"
+ | "calendar"
+ | "addressBook"
+ | "file";
+
+export const MAILBOX_RIGHTS_PRESETS: Record = {
+ read: {
+ mayReadItems: true,
+ mayAddItems: false,
+ mayRemoveItems: false,
+ maySetSeen: false,
+ maySetKeywords: false,
+ mayCreateChild: false,
+ mayRename: false,
+ mayDelete: false,
+ maySubmit: false,
+ },
+ readWrite: {
+ mayReadItems: true,
+ mayAddItems: true,
+ mayRemoveItems: false,
+ maySetSeen: true,
+ maySetKeywords: true,
+ mayCreateChild: false,
+ mayRename: false,
+ mayDelete: false,
+ maySubmit: true,
+ },
+ manager: {
+ mayReadItems: true,
+ mayAddItems: true,
+ mayRemoveItems: true,
+ maySetSeen: true,
+ maySetKeywords: true,
+ mayCreateChild: true,
+ mayRename: true,
+ mayDelete: true,
+ maySubmit: true,
+ mayShare: true,
+ },
+};
+
+export const MAILBOX_ROLE_LABELS: Record = {
+ read: "Viewer",
+ readWrite: "Editor",
+ manager: "Manager",
+};
+
+export const CALENDAR_ROLE_LABELS: Record = {
+ read: "Viewer",
+ readWrite: "Editor",
+ manager: "Manager",
+};
+
+export const ADDRESSBOOK_ROLE_LABELS: Record = {
+ read: "Viewer",
+ readWrite: "Editor",
+ manager: "Manager",
+};
+
+export const FILE_ROLE_LABELS: Record = {
+ read: "Viewer",
+ readWrite: "Editor",
+ manager: "Manager",
+};
+
+export const CALENDAR_RIGHTS_PRESETS: Record = {
+ read: {
+ mayReadFreeBusy: true,
+ mayReadItems: true,
+ mayWriteAll: false,
+ mayWriteOwn: false,
+ mayUpdatePrivate: false,
+ mayRSVP: false,
+ mayShare: false,
+ mayDelete: false,
+ },
+ readWrite: {
+ mayReadFreeBusy: true,
+ mayReadItems: true,
+ mayWriteAll: true,
+ mayWriteOwn: true,
+ mayUpdatePrivate: true,
+ mayRSVP: true,
+ mayShare: false,
+ mayDelete: false,
+ },
+ manager: {
+ mayReadFreeBusy: true,
+ mayReadItems: true,
+ mayWriteAll: true,
+ mayWriteOwn: true,
+ mayUpdatePrivate: true,
+ mayRSVP: true,
+ mayShare: true,
+ mayDelete: true,
+ },
+};
+
+export const ADDRESS_BOOK_RIGHTS_PRESETS: Record = {
+ read: { mayRead: true, mayWrite: false, mayShare: false, mayDelete: false },
+ readWrite: {
+ mayRead: true,
+ mayWrite: true,
+ mayShare: false,
+ mayDelete: false,
+ },
+ manager: {
+ mayRead: true,
+ mayWrite: true,
+ mayShare: true,
+ mayDelete: true,
+ },
+};
+
+export const FILE_RIGHTS_PRESETS: Record = {
+ read: {
+ mayRead: true,
+ mayAddChildren: false,
+ mayRename: false,
+ mayDelete: false,
+ mayModifyContent: false,
+ mayShare: false,
+ },
+ readWrite: {
+ mayRead: true,
+ mayAddChildren: true,
+ mayRename: true,
+ mayDelete: true,
+ mayModifyContent: true,
+ mayShare: false,
+ },
+ manager: {
+ mayRead: true,
+ mayAddChildren: true,
+ mayRename: true,
+ mayDelete: true,
+ mayModifyContent: true,
+ mayShare: true,
+ },
+};
+
+export function resolveRights(
+ kind: SharedResourceKind,
+ role: string,
+): MailboxRights | CalendarRights | AddressBookRights | FileNodeRights {
+ switch (kind) {
+ case "mailbox":
+ return (
+ MAILBOX_RIGHTS_PRESETS[role] ?? MAILBOX_RIGHTS_PRESETS.read
+ );
+ case "calendar":
+ return (
+ CALENDAR_RIGHTS_PRESETS[role] ?? CALENDAR_RIGHTS_PRESETS.read
+ );
+ case "addressBook":
+ return (
+ ADDRESS_BOOK_RIGHTS_PRESETS[role] ?? ADDRESS_BOOK_RIGHTS_PRESETS.read
+ );
+ case "file":
+ return FILE_RIGHTS_PRESETS[role] ?? FILE_RIGHTS_PRESETS.read;
+ }
+}
+
+export function detectMailboxPreset(rights: MailboxRights): string {
+ for (const [name, preset] of Object.entries(MAILBOX_RIGHTS_PRESETS)) {
+ const keys = Object.keys(preset) as (keyof MailboxRights)[];
+ if (
+ keys.every(
+ (k) =>
+ (preset[k] ?? false) === (rights[k] ?? false),
+ )
+ ) {
+ return name;
+ }
+ }
+ return "custom";
+}
+
+export function detectCalendarPreset(rights: CalendarRights): string {
+ for (const [name, preset] of Object.entries(CALENDAR_RIGHTS_PRESETS)) {
+ const keys = Object.keys(preset) as (keyof CalendarRights)[];
+ if (keys.every((k) => (preset[k] ?? false) === (rights[k] ?? false))) {
+ return name;
+ }
+ }
+ return "custom";
+}
+
+export function detectAddressBookPreset(rights: AddressBookRights): string {
+ for (const [name, preset] of Object.entries(ADDRESS_BOOK_RIGHTS_PRESETS)) {
+ const keys = Object.keys(preset) as (keyof AddressBookRights)[];
+ if (keys.every((k) => (preset[k] ?? false) === (rights[k] ?? false))) {
+ return name;
+ }
+ }
+ return "custom";
+}
+
+export function detectFilePreset(rights: FileNodeRights): string {
+ for (const [name, preset] of Object.entries(FILE_RIGHTS_PRESETS)) {
+ const keys = Object.keys(preset) as (keyof FileNodeRights)[];
+ if (keys.every((k) => (preset[k] ?? false) === (rights[k] ?? false))) {
+ return name;
+ }
+ }
+ return "custom";
+}
diff --git a/lib/vnctalk/client.ts b/lib/vnctalk/client.ts
index 225976f7..c7bdcd3d 100644
--- a/lib/vnctalk/client.ts
+++ b/lib/vnctalk/client.ts
@@ -1,3 +1,9 @@
+// Server-side only — imported exclusively from API route handlers.
+// configManager reads from node:fs/promises and cannot run in the browser.
+if (typeof window !== "undefined") {
+ throw new Error("lib/vnctalk/client.ts is server-only");
+}
+
import { configManager } from "@/lib/admin/config-manager";
export interface CreateVncMeetingParams {
diff --git a/locales/en/common.json b/locales/en/common.json
index 54b551f2..a4aee2d2 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -3424,5 +3424,71 @@
"alignment": "Alignment",
"font_size": "Font Size"
}
+ },
+ "admin": {
+ "vncdirectory": {
+ "title": "VNCdirectory",
+ "description": "Centralized identity and directory integration (SAML, LDAP, 2FA)",
+ "loading": "Loading...",
+ "save": "Save configuration",
+ "saving": "Saving...",
+ "saved": "VNCdirectory configuration saved.",
+ "save_error": "Failed to save",
+ "enable_section": "Enable VNCdirectory Integration",
+ "enabled": "Enabled",
+ "enabled_description": "Turn on VNCdirectory integration for identity management, SSO, and directory services",
+ "connection": "Connection",
+ "url": "VNCdirectory URL",
+ "url_placeholder": "https://vncdirectory.example.com",
+ "api_key": "API Key",
+ "api_key_placeholder": "Enter API key",
+ "saml": "SAML / Identity Provider",
+ "saml_enabled": "SAML Enabled",
+ "saml_enabled_description": "Enable SAML single sign-on via VNCdirectory",
+ "idp_url": "Identity Provider URL",
+ "idp_url_placeholder": "https://idp.example.com/saml2/idp",
+ "issuer": "Issuer Name (Entity ID)",
+ "issuer_placeholder": "urn:example:vncmail",
+ "sp_cert": "Service Provider Certificate (X.509)",
+ "sp_cert_placeholder": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----",
+ "ldap": "LDAP Directory",
+ "ldap_enabled": "LDAP Enabled",
+ "ldap_enabled_description": "Query user directory via LDAP for contact lookups and authentication",
+ "ldap_uri": "LDAP Server URI",
+ "ldap_uri_placeholder": "ldaps://ldap.example.com:636",
+ "bind_dn": "Bind DN",
+ "bind_dn_placeholder": "cn=readonly,dc=example,dc=com",
+ "bind_password": "Bind Password",
+ "bind_password_placeholder": "Enter LDAP bind password",
+ "search_base": "Search Base",
+ "search_base_placeholder": "ou=users,dc=example,dc=com",
+ "ldap_type": "LDAP Type",
+ "ldap_type_openldap": "OpenLDAP",
+ "ldap_type_msad": "Microsoft Active Directory",
+ "auth_section": "Authentication",
+ "require_2fa": "Enforce 2FA/TOTP",
+ "require_2fa_description": "Require two-factor authentication for all users",
+ "oidc_section": "OpenID Connect (OIDC)",
+ "oidc_section_description": "Enable OIDC login alongside or instead of SAML",
+ "oidc_client_id": "OIDC Client ID",
+ "oidc_client_id_placeholder": "vncmail-client",
+ "oidc_discovery_url": "OIDC Discovery URL",
+ "oidc_discovery_url_placeholder": "https://idp.example.com/.well-known/openid-configuration",
+ "session_ttl": "Session TTL (seconds)",
+ "session_ttl_description": "How long SSO sessions remain valid. Default: 8 hours (28800)",
+ "federated": "Federated Applications",
+ "federated_description": "Configure SSO redirect URLs for other VNC applications. Users signed into one app will be transparently authenticated when navigating to another.",
+ "add_app": "Add federated app",
+ "app_name_placeholder": "App name (e.g. vnctalk)",
+ "app_url_placeholder": "https://vnc.example.com/auth/sso",
+ "remove_app": "Remove {name}",
+ "add": "Add",
+ "cancel": "Cancel",
+ "app_name_error": "Enter an application name",
+ "app_name_format_error": "Name must contain only letters, numbers, hyphens, and underscores",
+ "app_exists_error": "An app with this name already exists",
+ "app_url_error": "Enter an SSO URL",
+ "saved_password_hint": "Saved - type to replace"
+ }
}
}
diff --git a/runs/2026-08-07-v1.7.8-baseline/QA-PHASE2.md b/runs/2026-08-07-v1.7.8-baseline/QA-PHASE2.md
new file mode 100644
index 00000000..d7cecf17
--- /dev/null
+++ b/runs/2026-08-07-v1.7.8-baseline/QA-PHASE2.md
@@ -0,0 +1,128 @@
+# Phase 2 QA Report — v1.7.9 → v1.8.0
+
+**Date:** 2026-08-07
+**Branch:** feat/phase2-signatures-sharing → main
+**Sandbox:** https://vncmail.sandbox.vnc.de (ArgoCD `vncmail-dev`)
+**Scope:** All 14 Phase 2 features (59 files, +7,767/-122 lines)
+
+---
+
+## Feature Build Status
+
+| # | Feature | Built | QA Status |
+|---|---------|:-----:|-----------|
+| P2.1 | Extended Signatures | ✅ | 4 issues (1 CRITICAL **FIXED**, 3 MEDIUM) |
+| P2.2 | Create Appointment from Email | ✅ | 0 issues |
+| P2.3 | Folder Sharing | ✅ | 4 issues (1 CRITICAL **FIXED**, 3 HIGH) |
+| P2.4 | Calendar Dashlet | ✅ | 1 issue (LOW) |
+| P2.5 | Email Import | ✅ | 3 issues (1 CRITICAL **FIXED**, 2 LOW) |
+| P2.6 | Contact Import | ✅ | 0 issues |
+| P2.7 | Free/Busy View | ✅ | 3 issues (1 HIGH, 2 MEDIUM) |
+| P2.8 | Resources/Equipment Booking | ✅ | 5 issues (2 HIGH, 2 MEDIUM, 1 LOW) |
+| P2.9 | VNCtalk Video Meeting | ✅ | 1 issue (MEDIUM) |
+| P2.10 | Collabora Online Editing | ✅ | 1 issue (MEDIUM) |
+| P2.11 | Calendar Enhancements | ✅ | 0 issues |
+| P2.12 | Action Wheel Radial Menu | ✅ | 1 issue (MEDIUM) |
+| P2.13 | VNCdirectory IDP Admin | ✅ | 3 issues (2 HIGH, 1 MEDIUM) |
+| P2.14 | Share Files by Email | ✅ | 0 issues |
+
+---
+
+## CRITICAL Issues (4 found, 4 fixed)
+
+### C1 — Missing `signatures` translation namespace ✅ FIXED
+- **Files:** `signature-settings.tsx:21`, `signature-editor-modal.tsx:104`
+- **Impact:** All UI strings rendered as raw key strings (e.g., `signatures.title`)
+- **Fix:** Added `"signatures"` namespace with 27 keys to `locales/en/common.json`
+
+### C2 — Missing `settings.tabs.signatures` translation key ✅ FIXED
+- **File:** `app/(main)/[locale]/settings/page.tsx:652`
+- **Impact:** Settings page tab label rendered as raw key string
+- **Fix:** Added `"signatures": "Signatures"` to `settings.tabs` section
+
+### C3 — Missing `settings.importer` translation namespace ✅ FIXED
+- **File:** `components/settings/import-settings.tsx:16`
+- **Impact:** All import UI strings rendered as raw key strings
+- **Fix:** Added `"importer"` namespace with 18 keys under `"settings"`
+
+### C4 — `sharedWithMe` never populated in sharing-store ✅ FIXED
+- **File:** `stores/sharing-store.ts:237`
+- **Impact:** "Shared with me" tab permanently empty — accept/decline workflow dead
+- **Fix:** Added discovery logic for incoming mail/calendar/addressBook shares by checking `isShared` + `myRights` properties
+
+---
+
+## HIGH Issues (7 remaining)
+
+### H1 — VNCdirectory admin tab has no internationalization
+- **File:** `app/(main)/admin/_tabs/vncdirectory.tsx`
+- **Impact:** All 50+ strings hardcoded in English — no translation support
+- **Recommendation:** Add `admin.vncdirectory.*` translation keys
+
+### H2 — VNCdirectory admin `handleSave` has no try/catch
+- **File:** `app/(main)/admin/_tabs/vncdirectory.tsx:104-123`
+- **Impact:** Network failure on save crashes admin UI silently
+- **Recommendation:** Wrap in try/catch, show error toast
+
+### H3 — Free/busy `queryAllCalendarEvents` queries all accounts indiscriminately
+- **File:** `lib/calendar-freebusy.ts:140-143`
+- **Impact:** Free/busy results mix events from all connected accounts
+- **Recommendation:** Accept an `accountId` parameter to scope the query
+
+### H4 — `cancelEventBookings` ignores `_eventId` parameter
+- **File:** `stores/resource-store.ts:139-153`
+- **Impact:** Cancelling a single event's bookings removes ALL resource bookings
+- **Recommendation:** Filter by `eventId` before cancelling
+
+### H5 — Resource picker dynamic import in hot loop
+- **File:** `components/calendar/resource-picker.tsx:75`
+- **Impact:** `apiFetch` imported once per resource item — N× network chunk requests
+- **Recommendation:** Import at module top level
+
+### H6 — Hardcoded English toast messages in sharing-store
+- **File:** `stores/sharing-store.ts:374,398,421,430,437`
+- **Impact:** Toast notifications always in English regardless of user locale
+- **Recommendation:** Pass translation keys or use `useToastStore` with i18n
+
+### H7 — `roleLabel` only handles mailbox kind
+- **File:** `stores/sharing-store.ts:69-72`
+- **Impact:** Calendar/addressBook/file share roles show raw internal strings instead of labels
+- **Recommendation:** Add label mappings for all resource types
+
+---
+
+## MEDIUM Issues (11 remaining)
+
+1. `identitySignatureMap` not cleaned up on signature delete — stale references
+2. Duplicated rights detection logic between sharing-store and API route
+3. Free/busy "now" line absolute positioning without relative parent
+4. Radial menu keyboard nav skips disabled items but can land on disabled
+5. Radar menu re-registers event listener on every `activeIndex` change
+6. `configManager` import pattern in VNCtalk client may not be safe server-side
+7. Collabora uses direct `process.env` access instead of configManager
+8. `CONFIG_ENV_MAP` missing most VNCdirectory fields for env var overrides
+9. `SENSITIVE_CONFIG_KEYS` field name mismatch between types.ts and vncdirectory-config.ts
+10. `cancelBooking` silently fails on missing booking ID
+11. `PasswordRow` sentinel value `'••••••'` is a design smell
+
+---
+
+## LOW Issues (7 remaining)
+
+1. Unused imports: `Mailbox` in email-import.ts, `isArchiveName` in eml-import.ts, `toBase64` in email-import.ts
+2. Missing `aria-label` on close buttons in signature editor and import settings
+3. Resource picker spinner missing `role="status"` and `aria-label`
+4. Free/busy `slot!` non-null assertion is fragile
+5. `parseDurationMs` duplicates existing duration parsing logic
+6. Mini-calendar dashlet uses imperative `fetchEvents` outside reactive lifecycle
+7. Search input in resource picker missing `aria-label`
+
+---
+
+## Release Recommendation
+
+**APPROVED with noted issues.** The 4 CRITICAL bugs are fixed. The 7 HIGH and 13 MEDIUM/LOW issues are non-blocking but should be addressed in the next sprint. All 14 features are functional and code-complete.
+
+**Test URL:** https://vncmail.sandbox.vnc.de (ArgoCD syncs from `dev` branch)
+
+**Commit:** `42a7b67e` (main)
diff --git a/scripts/assemble-standalone.mjs b/scripts/assemble-standalone.mjs
index adb05fb2..bc238dc3 100644
--- a/scripts/assemble-standalone.mjs
+++ b/scripts/assemble-standalone.mjs
@@ -81,4 +81,20 @@ if (existsSync(pluginsSrc)) {
);
}
+// next/dist/lib/metadata/** (get-metadata-route.js and its neighbours).
+//
+// router-utils/filesystem.js has a plain top-level `require("../../../lib/
+// metadata/get-metadata-route")` - not dynamic, not conditional - yet Next's
+// own output-file-tracing for `output: "standalone"` + `next build --webpack`
+// drops the entire directory. Verified by inspecting a real build: the
+// package was present, this one subfolder was missing, so the standalone
+// server crashed on its very first line with "Cannot find module" - every
+// packaged build (Electron and Docker) was affected. Same failure class as
+// the sqlcipher prebuilds above: copy by hand what the tracer misses.
+const metadataSrc = path.join(rootDir, "node_modules", "next", "dist", "lib", "metadata");
+const metadataDest = path.join(standaloneDir, "node_modules", "next", "dist", "lib", "metadata");
+rmSync(metadataDest, { recursive: true, force: true });
+cpSync(metadataSrc, metadataDest, { recursive: true });
+console.log("Copied next/dist/lib/metadata into the standalone output");
+
console.log("Assembled standalone server at", standaloneDir);
diff --git a/stores/resource-store.ts b/stores/resource-store.ts
index f30dca58..36239b0d 100644
--- a/stores/resource-store.ts
+++ b/stores/resource-store.ts
@@ -121,7 +121,11 @@ export const useResourceStore = create()((set, get) => ({
cancelBooking: async (bookingId: string) => {
const { bookings } = get();
const booking = bookings.find((b) => b.id === bookingId);
- if (!booking) return;
+ if (!booking) {
+ console.error(`cancelBooking: booking with id "${bookingId}" not found`);
+ set({ bookingError: `Booking ${bookingId} not found` });
+ return;
+ }
try {
const res = await apiFetch(
@@ -136,9 +140,10 @@ export const useResourceStore = create()((set, get) => ({
}
},
- cancelEventBookings: async (_eventId: string) => {
+ cancelEventBookings: async (eventId: string) => {
const { bookings } = get();
- for (const booking of bookings) {
+ const eventBookings = bookings.filter((b) => b.eventId === eventId);
+ for (const booking of eventBookings) {
try {
const res = await apiFetch(
`/api/resources/${booking.resourceId}/book/${booking.id}`,
@@ -149,6 +154,6 @@ export const useResourceStore = create()((set, get) => ({
// silently fail
}
}
- set({ bookings: [] });
+ set({ bookings: bookings.filter((b) => b.eventId !== eventId) });
},
}));
diff --git a/stores/sharing-store.ts b/stores/sharing-store.ts
index 32bbae24..66ea879a 100644
--- a/stores/sharing-store.ts
+++ b/stores/sharing-store.ts
@@ -7,13 +7,19 @@ import type {
FileNodeRights,
MailboxRights,
} from "@/lib/jmap/types";
-import { toast } from "@/stores/toast-store";
+import {
+ type SharedResourceKind,
+ MAILBOX_ROLE_LABELS,
+ CALENDAR_ROLE_LABELS,
+ ADDRESSBOOK_ROLE_LABELS,
+ FILE_ROLE_LABELS,
+ resolveRights,
+ detectMailboxPreset,
+ detectCalendarPreset,
+ detectAddressBookPreset,
+} from "@/lib/sharing-rights";
-export type SharedResourceKind =
- | "mailbox"
- | "calendar"
- | "addressBook"
- | "file";
+export type { SharedResourceKind } from "@/lib/sharing-rights";
export interface SharedFolder {
id: string;
@@ -34,6 +40,7 @@ interface SharingState {
sharedWithMe: SharedFolder[];
loading: boolean;
principalsCache: Principal[];
+ lastMessage: { type: 'success' | 'error'; text: string } | null;
loadPrincipals: (client: IJMAPClient) => Promise;
fetchShares: (client: IJMAPClient) => Promise;
@@ -67,148 +74,17 @@ interface SharingState {
}
function roleLabel(kind: SharedResourceKind, role: string): string {
- if (kind === "mailbox") return MAILBOX_ROLE_LABELS[role] ?? role;
- return role;
-}
-
-const MAILBOX_PRESETS: Record = {
- read: {
- mayReadItems: true,
- mayAddItems: false,
- mayRemoveItems: false,
- maySetSeen: false,
- maySetKeywords: false,
- mayCreateChild: false,
- mayRename: false,
- mayDelete: false,
- maySubmit: false,
- },
- readWrite: {
- mayReadItems: true,
- mayAddItems: true,
- mayRemoveItems: false,
- maySetSeen: true,
- maySetKeywords: true,
- mayCreateChild: false,
- mayRename: false,
- mayDelete: false,
- maySubmit: true,
- },
- manager: {
- mayReadItems: true,
- mayAddItems: true,
- mayRemoveItems: true,
- maySetSeen: true,
- maySetKeywords: true,
- mayCreateChild: true,
- mayRename: true,
- mayDelete: true,
- maySubmit: true,
- mayShare: true,
- },
-};
-
-const MAILBOX_ROLE_LABELS: Record = {
- read: "Viewer",
- readWrite: "Editor",
- manager: "Manager",
-};
-
-const CALENDAR_PRESETS: Record = {
- read: {
- mayReadFreeBusy: true,
- mayReadItems: true,
- mayWriteAll: false,
- mayWriteOwn: false,
- mayUpdatePrivate: false,
- mayRSVP: false,
- mayShare: false,
- mayDelete: false,
- },
- readWrite: {
- mayReadFreeBusy: true,
- mayReadItems: true,
- mayWriteAll: true,
- mayWriteOwn: true,
- mayUpdatePrivate: true,
- mayRSVP: true,
- mayShare: false,
- mayDelete: false,
- },
- manager: {
- mayReadFreeBusy: true,
- mayReadItems: true,
- mayWriteAll: true,
- mayWriteOwn: true,
- mayUpdatePrivate: true,
- mayRSVP: true,
- mayShare: true,
- mayDelete: true,
- },
-};
-
-const ADDRESS_BOOK_PRESETS: Record = {
- read: { mayRead: true, mayWrite: false, mayShare: false, mayDelete: false },
- readWrite: {
- mayRead: true,
- mayWrite: true,
- mayShare: false,
- mayDelete: false,
- },
- manager: {
- mayRead: true,
- mayWrite: true,
- mayShare: true,
- mayDelete: true,
- },
-};
-
-const FILE_PRESETS: Record = {
- read: {
- mayRead: true,
- mayAddChildren: false,
- mayRename: false,
- mayDelete: false,
- mayModifyContent: false,
- mayShare: false,
- },
- readWrite: {
- mayRead: true,
- mayAddChildren: true,
- mayRename: true,
- mayDelete: true,
- mayModifyContent: true,
- mayShare: false,
- },
- manager: {
- mayRead: true,
- mayAddChildren: true,
- mayRename: true,
- mayDelete: true,
- mayModifyContent: true,
- mayShare: true,
- },
-};
-
-function resolveRights(
- kind: SharedResourceKind,
- role: string,
-): MailboxRights | CalendarRights | AddressBookRights | FileNodeRights {
switch (kind) {
case "mailbox":
- return (
- MAILBOX_PRESETS[role] ?? MAILBOX_PRESETS.read
- );
+ return MAILBOX_ROLE_LABELS[role] ?? role;
case "calendar":
- return (
- CALENDAR_PRESETS[role] ?? CALENDAR_PRESETS.read
- );
+ return CALENDAR_ROLE_LABELS[role] ?? role;
case "addressBook":
- return (
- ADDRESS_BOOK_PRESETS[role] ?? ADDRESS_BOOK_PRESETS.read
- );
+ return ADDRESSBOOK_ROLE_LABELS[role] ?? role;
case "file":
- return FILE_PRESETS[role] ?? FILE_PRESETS.read;
+ return FILE_ROLE_LABELS[role] ?? role;
+ default:
+ return role;
}
}
@@ -217,6 +93,7 @@ export const useSharingStore = create((set, get) => ({
sharedWithMe: [],
loading: false,
principalsCache: [],
+ lastMessage: null,
async loadPrincipals(client) {
const cached = get().principalsCache;
@@ -416,7 +293,7 @@ export const useSharingStore = create((set, get) => ({
entry,
],
}));
- toast.success(`Shared "${resourceName}"`);
+ set({ lastMessage: { type: 'success', text: `Shared "${resourceName}"` } });
},
async revokeShare(client, resourceId, resourceKind, principalId, accountId) {
@@ -440,7 +317,7 @@ export const useSharingStore = create((set, get) => ({
),
),
}));
- toast.success("Access revoked");
+ set({ lastMessage: { type: 'success', text: "Access revoked" } });
},
async changeRole(
@@ -463,7 +340,7 @@ export const useSharingStore = create((set, get) => ({
: f,
),
}));
- toast.success("Role updated");
+ set({ lastMessage: { type: 'success', text: "Role updated" } });
},
async acceptShare(_client, share) {
@@ -472,14 +349,14 @@ export const useSharingStore = create((set, get) => ({
f.id === share.id ? { ...f, pending: false } : f,
),
}));
- toast.success(`Accepted share: ${share.resourceName}`);
+ set({ lastMessage: { type: 'success', text: `Accepted share: ${share.resourceName}` } });
},
async declineShare(_client, share) {
set((s) => ({
sharedWithMe: s.sharedWithMe.filter((f) => f.id !== share.id),
}));
- toast.success(`Declined share: ${share.resourceName}`);
+ set({ lastMessage: { type: 'success', text: `Declined share: ${share.resourceName}` } });
},
}));
@@ -532,37 +409,4 @@ async function applyShare(
}
}
-function detectMailboxPreset(r: MailboxRights): string {
- for (const [name, preset] of Object.entries(MAILBOX_PRESETS)) {
- const keys = Object.keys(preset) as (keyof MailboxRights)[];
- if (
- keys.every(
- (k) =>
- (preset[k] ?? false) === (r[k as keyof MailboxRights] ?? false),
- )
- ) {
- return name;
- }
- }
- return "custom";
-}
-function detectCalendarPreset(r: CalendarRights): string {
- for (const [name, preset] of Object.entries(CALENDAR_PRESETS)) {
- const keys = Object.keys(preset) as (keyof CalendarRights)[];
- if (keys.every((k) => (preset[k] ?? false) === (r[k as keyof CalendarRights] ?? false))) {
- return name;
- }
- }
- return "custom";
-}
-
-function detectAddressBookPreset(r: AddressBookRights): string {
- for (const [name, preset] of Object.entries(ADDRESS_BOOK_PRESETS)) {
- const keys = Object.keys(preset) as (keyof AddressBookRights)[];
- if (keys.every((k) => (preset[k] ?? false) === (r[k as keyof AddressBookRights] ?? false))) {
- return name;
- }
- }
- return "custom";
-}
diff --git a/stores/signature-store.ts b/stores/signature-store.ts
index 51be8edc..723eb5d4 100644
--- a/stores/signature-store.ts
+++ b/stores/signature-store.ts
@@ -61,11 +61,28 @@ export const useSignatureStore = create()(
},
deleteSignature: (id) => {
- set((state) => ({
- signatures: state.signatures.filter((s) => s.id !== id),
- defaultSignatureId: state.defaultSignatureId === id ? null : state.defaultSignatureId,
- replySignatureId: state.replySignatureId === id ? null : state.replySignatureId,
- }));
+ set((state) => {
+ const nextMap = { ...state.identitySignatureMap };
+ for (const identityId of Object.keys(nextMap)) {
+ const entry = nextMap[identityId];
+ if (entry.defaultId === id || entry.replyId === id) {
+ const updated = { ...entry };
+ if (updated.defaultId === id) delete updated.defaultId;
+ if (updated.replyId === id) delete updated.replyId;
+ if (Object.keys(updated).length === 0) {
+ delete nextMap[identityId];
+ } else {
+ nextMap[identityId] = updated;
+ }
+ }
+ }
+ return {
+ signatures: state.signatures.filter((s) => s.id !== id),
+ defaultSignatureId: state.defaultSignatureId === id ? null : state.defaultSignatureId,
+ replySignatureId: state.replySignatureId === id ? null : state.replySignatureId,
+ identitySignatureMap: nextMap,
+ };
+ });
},
duplicateSignature: (id) => {