Merge branch 'dev'

# Conflicts:
#	locales/en/common.json
This commit is contained in:
Bernd Rodler
2026-08-07 14:21:20 +02:00
40 changed files with 1346 additions and 512 deletions
+7
View File
@@ -49,6 +49,13 @@ RUN apk upgrade --no-cache && \
COPY --from=builder /app/public ./public COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# next/dist/lib/metadata/** (get-metadata-route.js and its neighbours). A
# plain top-level require in router-utils/filesystem.js, yet Next's own
# output file tracing for `output: "standalone"` + `next build --webpack`
# drops the whole directory - the server crashes on its first line with
# "Cannot find module '../../../lib/metadata/get-metadata-route'" without
# this. Same tracing-gap class as the plugins copy below.
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/next/dist/lib/metadata ./node_modules/next/dist/lib/metadata
# Staged first-party plugin bundles. Read by path at runtime, so Next's output # Staged first-party plugin bundles. Read by path at runtime, so Next's output
# file tracing does not carry them into .next/standalone - copy explicitly or # file tracing does not carry them into .next/standalone - copy explicitly or
# the image boots with the S/MIME policy toggle on and no plugin installed. # the image boots with the S/MIME policy toggle on and no plugin installed.
+92 -2
View File
@@ -1,8 +1,8 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Save, Loader2, X, ArrowRight } from 'lucide-react'; import { Save, Loader2, X, ArrowRight, Plus, Trash2 } from 'lucide-react';
import type { AiConsoleConfig, AiClass } from '@/lib/ai/types'; import type { AiConsoleConfig, AiClass, PublicAiPreset } from '@/lib/ai/types';
import { DEFAULT_AI_CONSOLE_CONFIG } from '@/lib/ai/types'; import { DEFAULT_AI_CONSOLE_CONFIG } from '@/lib/ai/types';
import type { AiEntitlementState, MeteringEntry } from '@/lib/ai/entitlement'; import type { AiEntitlementState, MeteringEntry } from '@/lib/ai/entitlement';
import { apiFetch } from '@/lib/browser-navigation'; import { apiFetch } from '@/lib/browser-navigation';
@@ -73,6 +73,85 @@ function AllowlistEditor({
); );
} }
function newPresetId(): string {
return `preset-${Math.random().toString(36).slice(2, 10)}`;
}
/**
* The Paperclip-style env-var-key picker (decision 2026-08-07): an admin
* names a preset and an env var; the actual secret value is never entered
* here — it's whatever ops has set in the server's real environment. This is
* what lets a user in Settings pick a provider from a dropdown instead of
* pasting a key.
*/
function PublicPresetsEditor({
presets, onChange,
}: { presets: PublicAiPreset[]; onChange: (next: PublicAiPreset[]) => void }) {
const [name, setName] = useState('');
const [baseUrl, setBaseUrl] = useState('https://api.deepseek.com');
const [model, setModel] = useState('');
const [envVar, setEnvVar] = useState('');
const canAdd = name.trim() && baseUrl.trim() && model.trim() && envVar.trim();
function addPreset() {
if (!canAdd) return;
onChange([...presets, { id: newPresetId(), name: name.trim(), baseUrl: baseUrl.trim(), model: model.trim(), apiKeyEnvVar: envVar.trim() }]);
setName('');
setBaseUrl('https://api.deepseek.com');
setModel('');
setEnvVar('');
}
return (
<>
{presets.length > 0 && (
<div className="divide-y divide-border">
{presets.map((p) => (
<div key={p.id} className="px-4 py-2.5 flex items-center justify-between gap-3">
<div className="min-w-0">
<span className="text-sm font-medium">{p.name}</span>
<p className="text-xs text-muted-foreground truncate">
{p.model} · {p.baseUrl} · reads <code className="text-[11px]">{p.apiKeyEnvVar}</code>
</p>
</div>
<button
onClick={() => onChange(presets.filter((x) => x.id !== p.id))}
className="shrink-0 text-muted-foreground hover:text-destructive"
aria-label={`Remove ${p.name}`}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
)}
<div className="px-4 py-3 flex flex-col gap-2 border-t border-border">
<div className="flex gap-2 flex-wrap">
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="Name, e.g. DeepSeek (org)"
className="flex-1 min-w-[160px] h-8 rounded border border-input bg-background px-2.5 text-xs" />
<input value={model} onChange={(e) => setModel(e.target.value)} placeholder="Model, e.g. deepseek-chat"
className="flex-1 min-w-[160px] h-8 rounded border border-input bg-background px-2.5 text-xs" />
</div>
<div className="flex gap-2 flex-wrap">
<input value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} placeholder="API base URL"
className="flex-1 min-w-[200px] h-8 rounded border border-input bg-background px-2.5 text-xs" />
<input value={envVar} onChange={(e) => setEnvVar(e.target.value)} placeholder="Env var, e.g. DEEPSEEK_API_KEY"
className="flex-1 min-w-[200px] h-8 rounded border border-input bg-background px-2.5 text-xs" />
<button onClick={addPreset} disabled={!canAdd}
className="h-8 px-3 rounded border border-border bg-muted text-xs font-medium hover:bg-muted/70 disabled:opacity-50 inline-flex items-center gap-1.5">
<Plus className="w-3 h-3" /> Add
</button>
</div>
<p className="text-xs text-muted-foreground">
Only the env var <em>name</em> is stored here provision the actual key as a real environment variable on
the server (k8s secret, .env, Electron packaging). This app never sees or stores the value.
</p>
</div>
</>
);
}
export function AiPolicyTab() { export function AiPolicyTab() {
const setActiveTab = useAdminTabStore((s) => s.setActiveTab); const setActiveTab = useAdminTabStore((s) => s.setActiveTab);
const [config, setConfig] = useState<AiConsoleConfig>({ ...DEFAULT_AI_CONSOLE_CONFIG }); const [config, setConfig] = useState<AiConsoleConfig>({ ...DEFAULT_AI_CONSOLE_CONFIG });
@@ -247,6 +326,17 @@ export function AiPolicyTab() {
/> />
</div> </div>
<div className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30">
<h2 className="text-sm font-medium text-foreground">Public org-managed presets</h2>
<p className="text-xs text-muted-foreground mt-0.5">
Paperclip-style: publish a provider by name instead of making every user paste their own key. Users pick
one of these in Settings with no key field at all the server resolves the named env var at request time.
</p>
</div>
<PublicPresetsEditor presets={config.publicPresets} onChange={(v) => update({ publicPresets: v })} />
</div>
<div className="border border-border rounded-lg"> <div className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30"> <div className="px-4 py-3 border-b border-border bg-muted/30">
<h2 className="text-sm font-medium text-foreground">Entitlement &amp; seats</h2> <h2 className="text-sm font-medium text-foreground">Entitlement &amp; seats</h2>
+92 -74
View File
@@ -1,8 +1,10 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { Save, Loader2, Plus, X } from 'lucide-react'; import { Save, Loader2, Plus, X } from 'lucide-react';
import { apiFetch } from '@/lib/browser-navigation'; import { apiFetch } from '@/lib/browser-navigation';
import { toast } from '@/stores/toast-store';
interface VncDirectoryFormData { interface VncDirectoryFormData {
enabled: boolean; enabled: boolean;
@@ -49,6 +51,7 @@ const BLANK_FORM: VncDirectoryFormData = {
}; };
export function VncDirectoryTab() { export function VncDirectoryTab() {
const t = useTranslations('admin.vncdirectory');
const [config, setConfig] = useState<VncDirectoryFormData>({ ...BLANK_FORM }); const [config, setConfig] = useState<VncDirectoryFormData>({ ...BLANK_FORM });
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -105,19 +108,25 @@ export function VncDirectoryTab() {
setSaving(true); setSaving(true);
setMessage(null); setMessage(null);
const res = await apiFetch('/api/admin/vncdirectory', { try {
method: 'POST', const res = await apiFetch('/api/admin/vncdirectory', {
headers: { 'Content-Type': 'application/json' }, method: 'POST',
body: JSON.stringify(config), headers: { 'Content-Type': 'application/json' },
}); body: JSON.stringify(config),
});
if (res.ok) { if (res.ok) {
setMessage({ type: 'success', text: 'VNCdirectory configuration saved.' }); setMessage({ type: 'success', text: t('saved') });
setDirty(false); setDirty(false);
await fetchConfig(); await fetchConfig();
} else { } else {
const data = await res.json(); const data = await res.json();
setMessage({ type: 'error', text: data.error || 'Failed to save' }); setMessage({ type: 'error', text: data.error || t('save_error') });
}
} catch (err) {
const msg = err instanceof Error ? err.message : t('save_error');
setMessage({ type: 'error', text: msg });
toast.error(msg);
} }
setSaving(false); setSaving(false);
} }
@@ -125,7 +134,7 @@ export function VncDirectoryTab() {
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm"> <div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
Loading... {t('loading')}
</div> </div>
); );
} }
@@ -136,9 +145,9 @@ export function VncDirectoryTab() {
<div className="space-y-6"> <div className="space-y-6">
<div className="flex flex-wrap items-start justify-between gap-3"> <div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">
<h1 className="text-2xl font-semibold text-foreground">VNCdirectory</h1> <h1 className="text-2xl font-semibold text-foreground">{t('title')}</h1>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
Centralized identity and directory integration (SAML, LDAP, 2FA) {t('description')}
</p> </p>
</div> </div>
{dirty && ( {dirty && (
@@ -148,7 +157,7 @@ export function VncDirectoryTab() {
className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm" className="inline-flex items-center gap-2 h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-all shadow-sm"
> >
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />} {saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
Save configuration {t('save')}
</button> </button>
)} )}
</div> </div>
@@ -165,12 +174,12 @@ export function VncDirectoryTab() {
</div> </div>
)} )}
<Section title="Enable VNCdirectory Integration"> <Section title={t('enable_section')}>
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4"> <div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0"> <div className="min-w-0">
<span className="text-sm text-foreground">Enabled</span> <span className="text-sm text-foreground">{t('enabled')}</span>
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">
Turn on VNCdirectory integration for identity management, SSO, and directory services {t('enabled_description')}
</p> </p>
</div> </div>
<button <button
@@ -192,53 +201,53 @@ export function VncDirectoryTab() {
{config.enabled && ( {config.enabled && (
<> <>
<Section title="Connection"> <Section title={t('connection')}>
<div className="divide-y divide-border"> <div className="divide-y divide-border">
<TextRow <TextRow
label="VNCdirectory URL" label={t('url')}
value={config.apiUrl} value={config.apiUrl}
onChange={(v) => updateField('apiUrl', v)} onChange={(v) => updateField('apiUrl', v)}
placeholder="https://vncdirectory.example.com" placeholder={t('url_placeholder')}
/> />
<PasswordRow <PasswordRow
label="API Key" label={t('api_key')}
value={config.apiKey} value={config.apiKey}
onChange={(v) => updateField('apiKey', v)} onChange={(v) => updateField('apiKey', v)}
placeholder="Enter API key" placeholder={t('api_key_placeholder')}
/> />
</div> </div>
</Section> </Section>
<Section title="SAML / Identity Provider"> <Section title={t('saml')}>
<div className="divide-y divide-border"> <div className="divide-y divide-border">
<ToggleRow <ToggleRow
label="SAML Enabled" label={t('saml_enabled')}
description="Enable SAML single sign-on via VNCdirectory" description={t('saml_enabled_description')}
value={config.samlEnabled} value={config.samlEnabled}
onChange={() => toggleBool('samlEnabled')} onChange={() => toggleBool('samlEnabled')}
/> />
{config.samlEnabled && ( {config.samlEnabled && (
<> <>
<TextRow <TextRow
label="Identity Provider URL" label={t('idp_url')}
value={config.samlIdpUrl} value={config.samlIdpUrl}
onChange={(v) => updateField('samlIdpUrl', v)} onChange={(v) => updateField('samlIdpUrl', v)}
placeholder="https://idp.example.com/saml2/idp" placeholder={t('idp_url_placeholder')}
/> />
<TextRow <TextRow
label="Issuer Name (Entity ID)" label={t('issuer')}
value={config.samlIssuer} value={config.samlIssuer}
onChange={(v) => updateField('samlIssuer', v)} onChange={(v) => updateField('samlIssuer', v)}
placeholder="urn:example:vncmail" placeholder={t('issuer_placeholder')}
/> />
<div className="px-4 py-3 flex flex-col gap-2"> <div className="px-4 py-3 flex flex-col gap-2">
<label className="text-sm text-foreground"> <label className="text-sm text-foreground">
Service Provider Certificate (X.509) {t('sp_cert')}
</label> </label>
<textarea <textarea
value={config.samlSpCert} value={config.samlSpCert}
onChange={(e) => updateField('samlSpCert', e.target.value)} onChange={(e) => updateField('samlSpCert', e.target.value)}
placeholder="-----BEGIN CERTIFICATE-----&#10;...&#10;-----END CERTIFICATE-----" placeholder={t('sp_cert_placeholder')}
rows={4} rows={4}
className="w-full rounded-md border border-input bg-background px-2.5 py-1.5 text-sm font-mono text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring resize-vertical" className="w-full rounded-md border border-input bg-background px-2.5 py-1.5 text-sm font-mono text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring resize-vertical"
/> />
@@ -248,46 +257,46 @@ export function VncDirectoryTab() {
</div> </div>
</Section> </Section>
<Section title="LDAP Directory"> <Section title={t('ldap')}>
<div className="divide-y divide-border"> <div className="divide-y divide-border">
<ToggleRow <ToggleRow
label="LDAP Enabled" label={t('ldap_enabled')}
description="Query user directory via LDAP for contact lookups and authentication" description={t('ldap_enabled_description')}
value={config.ldapEnabled} value={config.ldapEnabled}
onChange={() => toggleBool('ldapEnabled')} onChange={() => toggleBool('ldapEnabled')}
/> />
{config.ldapEnabled && ( {config.ldapEnabled && (
<> <>
<TextRow <TextRow
label="LDAP Server URI" label={t('ldap_uri')}
value={config.ldapUri} value={config.ldapUri}
onChange={(v) => updateField('ldapUri', v)} onChange={(v) => updateField('ldapUri', v)}
placeholder="ldaps://ldap.example.com:636" placeholder={t('ldap_uri_placeholder')}
/> />
<TextRow <TextRow
label="Bind DN" label={t('bind_dn')}
value={config.ldapBindDn} value={config.ldapBindDn}
onChange={(v) => updateField('ldapBindDn', v)} onChange={(v) => updateField('ldapBindDn', v)}
placeholder="cn=readonly,dc=example,dc=com" placeholder={t('bind_dn_placeholder')}
/> />
<PasswordRow <PasswordRow
label="Bind Password" label={t('bind_password')}
value={config.ldapBindPassword} value={config.ldapBindPassword}
onChange={(v) => updateField('ldapBindPassword', v)} onChange={(v) => updateField('ldapBindPassword', v)}
placeholder="Enter LDAP bind password" placeholder={t('bind_password_placeholder')}
/> />
<TextRow <TextRow
label="Search Base" label={t('search_base')}
value={config.ldapSearchBase} value={config.ldapSearchBase}
onChange={(v) => updateField('ldapSearchBase', v)} onChange={(v) => updateField('ldapSearchBase', v)}
placeholder="ou=users,dc=example,dc=com" placeholder={t('search_base_placeholder')}
/> />
<SelectRow <SelectRow
label="LDAP Type" label={t('ldap_type')}
value={config.ldapType} value={config.ldapType}
options={[ options={[
{ value: 'openldap', label: 'OpenLDAP' }, { value: 'openldap', label: t('ldap_type_openldap') },
{ value: 'ms-ad', label: 'Microsoft Active Directory' }, { value: 'ms-ad', label: t('ldap_type_msad') },
]} ]}
onChange={(v) => updateField('ldapType', v as 'openldap' | 'ms-ad')} onChange={(v) => updateField('ldapType', v as 'openldap' | 'ms-ad')}
/> />
@@ -296,41 +305,41 @@ export function VncDirectoryTab() {
</div> </div>
</Section> </Section>
<Section title="Authentication"> <Section title={t('auth_section')}>
<div className="divide-y divide-border"> <div className="divide-y divide-border">
<ToggleRow <ToggleRow
label="Enforce 2FA/TOTP" label={t('require_2fa')}
description="Require two-factor authentication for all users" description={t('require_2fa_description')}
value={config.tfaEnabled} value={config.tfaEnabled}
onChange={() => toggleBool('tfaEnabled')} onChange={() => toggleBool('tfaEnabled')}
/> />
<ToggleRow <ToggleRow
label="OpenID Connect (OIDC)" label={t('oidc_section')}
description="Enable OIDC login alongside or instead of SAML" description={t('oidc_section_description')}
value={config.oidcEnabled} value={config.oidcEnabled}
onChange={() => toggleBool('oidcEnabled')} onChange={() => toggleBool('oidcEnabled')}
/> />
{config.oidcEnabled && ( {config.oidcEnabled && (
<> <>
<TextRow <TextRow
label="OIDC Client ID" label={t('oidc_client_id')}
value={config.oidcClientId} value={config.oidcClientId}
onChange={(v) => updateField('oidcClientId', v)} onChange={(v) => updateField('oidcClientId', v)}
placeholder="vncmail-client" placeholder={t('oidc_client_id_placeholder')}
/> />
<TextRow <TextRow
label="OIDC Discovery URL" label={t('oidc_discovery_url')}
value={config.oidcDiscoveryUrl} value={config.oidcDiscoveryUrl}
onChange={(v) => updateField('oidcDiscoveryUrl', v)} onChange={(v) => updateField('oidcDiscoveryUrl', v)}
placeholder="https://idp.example.com/.well-known/openid-configuration" placeholder={t('oidc_discovery_url_placeholder')}
/> />
</> </>
)} )}
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4"> <div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="min-w-0"> <div className="min-w-0">
<span className="text-sm text-foreground">Session TTL (seconds)</span> <span className="text-sm text-foreground">{t('session_ttl')}</span>
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">
How long SSO sessions remain valid. Default: 8 hours (28800) {t('session_ttl_description')}
</p> </p>
</div> </div>
<input <input
@@ -344,11 +353,10 @@ export function VncDirectoryTab() {
</div> </div>
</Section> </Section>
<Section title="Federated Applications"> <Section title={t('federated')}>
<div className="px-4 py-3"> <div className="px-4 py-3">
<p className="text-xs text-muted-foreground mb-3"> <p className="text-xs text-muted-foreground mb-3">
Configure SSO redirect URLs for other VNC applications. Users signed into one {t('federated_description')}
app will be transparently authenticated when navigating to another.
</p> </p>
<div className="space-y-2"> <div className="space-y-2">
{federatedAppsList.map(([appName, url]) => ( {federatedAppsList.map(([appName, url]) => (
@@ -366,13 +374,13 @@ export function VncDirectoryTab() {
type="url" type="url"
value={url} value={url}
onChange={(e) => setFederatedApp(appName, e.target.value)} onChange={(e) => setFederatedApp(appName, e.target.value)}
placeholder="https://vnc.example.com/auth/sso" placeholder={t('app_url_placeholder')}
className="h-8 w-full sm:flex-1 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" className="h-8 w-full sm:flex-1 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/> />
<button <button
onClick={() => removeFederatedApp(appName)} onClick={() => removeFederatedApp(appName)}
className="shrink-0 text-muted-foreground hover:text-destructive transition-colors" className="shrink-0 text-muted-foreground hover:text-destructive transition-colors"
title={`Remove ${appName}`} title={t('remove_app', { name: appName })}
> >
<X className="w-4 h-4" /> <X className="w-4 h-4" />
</button> </button>
@@ -398,6 +406,7 @@ function AddFederatedApp({
existingKeys: Set<string>; existingKeys: Set<string>;
onAdd: (name: string, url: string) => void; onAdd: (name: string, url: string) => void;
}) { }) {
const t = useTranslations('admin.vncdirectory');
const [adding, setAdding] = useState(false); const [adding, setAdding] = useState(false);
const [name, setName] = useState(''); const [name, setName] = useState('');
const [url, setUrl] = useState(''); const [url, setUrl] = useState('');
@@ -411,7 +420,7 @@ function AddFederatedApp({
className="inline-flex items-center gap-1.5 h-8 px-3 rounded-md border border-dashed border-input text-sm text-muted-foreground hover:bg-muted hover:text-foreground transition-colors" className="inline-flex items-center gap-1.5 h-8 px-3 rounded-md border border-dashed border-input text-sm text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
> >
<Plus className="w-3.5 h-3.5" /> <Plus className="w-3.5 h-3.5" />
Add federated app {t('add_app')}
</button> </button>
); );
} }
@@ -419,19 +428,19 @@ function AddFederatedApp({
function handleAdd() { function handleAdd() {
const trimmed = name.trim(); const trimmed = name.trim();
if (!trimmed) { if (!trimmed) {
setError('Enter an application name'); setError(t('app_name_error'));
return; return;
} }
if (!/^[a-zA-Z0-9_-]+$/.test(trimmed)) { if (!/^[a-zA-Z0-9_-]+$/.test(trimmed)) {
setError('Name must contain only letters, numbers, hyphens, and underscores'); setError(t('app_name_format_error'));
return; return;
} }
if (existingKeys.has(trimmed)) { if (existingKeys.has(trimmed)) {
setError('An app with this name already exists'); setError(t('app_exists_error'));
return; return;
} }
if (!url.trim()) { if (!url.trim()) {
setError('Enter an SSO URL'); setError(t('app_url_error'));
return; return;
} }
setError(null); setError(null);
@@ -457,7 +466,7 @@ function AddFederatedApp({
value={name} value={name}
onChange={(e) => { setName(e.target.value); setError(null); }} onChange={(e) => { setName(e.target.value); setError(null); }}
onKeyDown={(e) => { if (e.key === 'Enter') handleAdd(); }} onKeyDown={(e) => { if (e.key === 'Enter') handleAdd(); }}
placeholder="App name (e.g. vnctalk)" placeholder={t('app_name_placeholder')}
className="h-8 w-full sm:w-36 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" className="h-8 w-full sm:w-36 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/> />
<input <input
@@ -465,7 +474,7 @@ function AddFederatedApp({
value={url} value={url}
onChange={(e) => { setUrl(e.target.value); setError(null); }} onChange={(e) => { setUrl(e.target.value); setError(null); }}
onKeyDown={(e) => { if (e.key === 'Enter') handleAdd(); }} onKeyDown={(e) => { if (e.key === 'Enter') handleAdd(); }}
placeholder="https://vnctalk.example.com/auth/sso" placeholder={t('app_url_placeholder')}
className="h-8 w-full sm:flex-1 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" className="h-8 w-full sm:flex-1 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/> />
<div className="flex items-center gap-1 shrink-0"> <div className="flex items-center gap-1 shrink-0">
@@ -474,14 +483,14 @@ function AddFederatedApp({
onClick={handleAdd} onClick={handleAdd}
className="inline-flex items-center h-8 px-3 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors" className="inline-flex items-center h-8 px-3 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors"
> >
Add {t('add')}
</button> </button>
<button <button
type="button" type="button"
onClick={handleCancel} onClick={handleCancel}
className="h-8 px-2.5 rounded-md text-sm text-muted-foreground hover:text-foreground transition-colors" className="h-8 px-2.5 rounded-md text-sm text-muted-foreground hover:text-foreground transition-colors"
> >
Cancel {t('cancel')}
</button> </button>
</div> </div>
</div> </div>
@@ -537,7 +546,16 @@ function PasswordRow({
onChange: (v: string) => void; onChange: (v: string) => void;
placeholder?: string; placeholder?: string;
}) { }) {
const isMasked = value === '••••••'; const [isMasked, setIsMasked] = useState(value === '••••••');
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
if (isMasked) {
onChange(e.target.value);
setIsMasked(false);
} else {
onChange(e.target.value);
}
}
return ( return (
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4"> <div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
@@ -546,7 +564,7 @@ function PasswordRow({
<input <input
type={isMasked ? 'text' : 'password'} type={isMasked ? 'text' : 'password'}
value={value ?? ''} value={value ?? ''}
onChange={(e) => onChange(e.target.value)} onChange={handleChange}
placeholder={placeholder || (isMasked ? 'Saved - type to replace' : undefined)} placeholder={placeholder || (isMasked ? 'Saved - type to replace' : undefined)}
className="h-8 w-full sm:w-72 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" className="h-8 w-full sm:w-72 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/> />
+19
View File
@@ -48,6 +48,24 @@ function validate(body: Partial<AiConsoleConfig>): string | null {
return 'publicProviderAllowlist must be an array of strings or null'; return 'publicProviderAllowlist must be an array of strings or null';
} }
} }
if (body.publicPresets !== undefined) {
if (!Array.isArray(body.publicPresets)) return 'publicPresets must be an array';
const ids = new Set<string>();
for (const preset of body.publicPresets) {
if (
typeof preset !== 'object' || preset === null ||
typeof preset.id !== 'string' || !preset.id ||
typeof preset.name !== 'string' || !preset.name ||
typeof preset.baseUrl !== 'string' || !preset.baseUrl ||
typeof preset.model !== 'string' || !preset.model ||
typeof preset.apiKeyEnvVar !== 'string' || !preset.apiKeyEnvVar
) {
return 'each publicPresets entry needs non-empty id, name, baseUrl, model, apiKeyEnvVar';
}
if (ids.has(preset.id)) return `duplicate publicPresets id "${preset.id}"`;
ids.add(preset.id);
}
}
if (body.retrievalEnabled !== undefined && typeof body.retrievalEnabled !== 'boolean') { if (body.retrievalEnabled !== undefined && typeof body.retrievalEnabled !== 'boolean') {
return 'retrievalEnabled must be a boolean'; return 'retrievalEnabled must be a boolean';
} }
@@ -83,6 +101,7 @@ export async function PUT(request: NextRequest) {
consentVersion: next.consent?.version ?? null, consentVersion: next.consent?.version ?? null,
serverModelAllowlistCount: next.serverModelAllowlist?.length ?? null, serverModelAllowlistCount: next.serverModelAllowlist?.length ?? null,
publicProviderAllowlistCount: next.publicProviderAllowlist?.length ?? null, publicProviderAllowlistCount: next.publicProviderAllowlist?.length ?? null,
publicPresetsCount: next.publicPresets.length,
}, ip); }, ip);
return NextResponse.json(next); return NextResponse.json(next);
} catch (error) { } catch (error) {
+5
View File
@@ -43,6 +43,11 @@ export async function GET() {
retrievalEnabled: consoleConfig.retrievalEnabled, retrievalEnabled: consoleConfig.retrievalEnabled,
consent: consoleConfig.consent, consent: consoleConfig.consent,
publicProviderAllowlist: consoleConfig.publicProviderAllowlist, publicProviderAllowlist: consoleConfig.publicProviderAllowlist,
// Sanitized: {id,name,model} only. baseUrl/apiKeyEnvVar stay server-side —
// the client only ever refers to a preset by id (app/api/ai/public/chat
// resolves the rest), so there's no reason to hand a browser tab even
// an internal env var *name*, let alone a provider base URL.
publicPresets: consoleConfig.publicPresets.map((p) => ({ id: p.id, name: p.name, model: p.model })),
}; };
return NextResponse.json(aiPolicy, { return NextResponse.json(aiPolicy, {
+97
View File
@@ -0,0 +1,97 @@
import { NextRequest, NextResponse } from 'next/server';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
import { configManager } from '@/lib/admin/config-manager';
import { logger } from '@/lib/logger';
export const runtime = 'nodejs';
const MAX_BODY_BYTES = 200 * 1024;
interface ChatMessage {
role: 'system' | 'user' | 'assistant';
content: string;
}
interface OpenAiChatResponse {
choices?: Array<{ message?: { content?: string } }>;
}
/**
* POST /api/ai/public/chat — the Paperclip-style, admin-managed alternative
* to the personal-key `chatPublic` path (lib/ai/local-client.ts): the client
* sends a `presetId`, never a key. The preset (name/baseUrl/model/
* apiKeyEnvVar) lives in admin config (lib/ai/types.ts's PublicAiPreset);
* the actual secret value is read from THIS PROCESS's real environment at
* request time and never leaves this route — same custody model as
* AI_SERVER_BASE_URL, just admin-nameable per preset instead of one fixed var.
*
* Deliberately NOT entitlement-metered, same reasoning as `local`/`opencode`
* (lib/ai/entitlement.ts's header): this is still the `public` class, just
* with the org supplying the key instead of the user — no centrally-borne
* inference cost this app is billing for.
*/
export async function POST(request: NextRequest) {
const auth = await getStalwartCredentials(request);
if (!auth) {
return NextResponse.json({ error: 'not authenticated' }, { status: 401 });
}
await configManager.ensureLoaded();
const consoleConfig = configManager.getAiConsoleConfig();
if (consoleConfig.classesEnabled.public === false) {
return NextResponse.json({ error: 'the Public AI class is disabled by admin policy' }, { status: 403 });
}
const rawBody = await request.text();
if (rawBody.length > MAX_BODY_BYTES) {
return NextResponse.json({ error: 'request too large' }, { status: 413 });
}
let body: { presetId?: unknown; messages?: unknown };
try {
body = JSON.parse(rawBody);
} catch {
return NextResponse.json({ error: 'invalid JSON body' }, { status: 400 });
}
const presetId = typeof body.presetId === 'string' ? body.presetId : '';
const messages = Array.isArray(body.messages) ? (body.messages as ChatMessage[]) : null;
if (!presetId || !messages || messages.length === 0) {
return NextResponse.json({ error: 'presetId and messages are required' }, { status: 400 });
}
const preset = consoleConfig.publicPresets.find((p) => p.id === presetId);
if (!preset) {
return NextResponse.json({ error: `No such preset "${presetId}" — it may have been removed by an admin.` }, { status: 404 });
}
const apiKey = process.env[preset.apiKeyEnvVar];
if (!apiKey) {
return NextResponse.json(
{ error: `Env var "${preset.apiKeyEnvVar}" is not set on the server for preset "${preset.name}" — ask an admin to provision it.` },
{ status: 503 },
);
}
try {
const res = await fetch(`${preset.baseUrl.replace(/\/+$/, '')}/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
body: JSON.stringify({ model: preset.model, messages }),
});
if (!res.ok) {
return NextResponse.json({ error: `Provider returned ${res.status}` }, { status: 502 });
}
const data = (await res.json()) as OpenAiChatResponse;
const content = data.choices?.[0]?.message?.content;
if (!content) {
return NextResponse.json({ error: 'Provider returned no message content' }, { status: 502 });
}
return NextResponse.json({ answer: content });
} catch (cause) {
logger.error('public ai preset chat failed', {
presetId, error: cause instanceof Error ? cause.message : String(cause),
});
return NextResponse.json({ error: `Could not reach ${preset.baseUrl}` }, { status: 502 });
}
}
+2 -169
View File
@@ -1,4 +1,5 @@
import type { NextRequest } from "next/server"; import type { NextRequest } from "next/server";
import { resolveRights, type SharedResourceKind } from "@/lib/sharing-rights";
type JmapMethodCall = [string, Record<string, unknown>, string]; type JmapMethodCall = [string, Record<string, unknown>, string];
@@ -142,7 +143,7 @@ export async function POST(request: NextRequest) {
); );
} }
const patchValue = role === null ? null : buildRights(kind as string, role as string); const patchValue = role === null ? null : resolveRights(kind as SharedResourceKind, role as string);
const methodCalls: JmapMethodCall[] = [ const methodCalls: JmapMethodCall[] = [
[ [
@@ -190,172 +191,4 @@ export async function POST(request: NextRequest) {
return Response.json({ ok: true }); return Response.json({ ok: true });
} }
function buildRights(
kind: string,
role: string,
): Record<string, boolean> | null {
if (role === null) return null;
switch (kind) {
case "mailbox":
return mailboxRights(role);
case "calendar":
return calendarRights(role);
case "addressBook":
return addressBookRights(role);
case "file":
return fileRights(role);
default:
return readRights();
}
}
function mailboxRights(role: string): Record<string, boolean> {
switch (role) {
case "read":
return {
mayReadItems: true,
mayAddItems: false,
mayRemoveItems: false,
maySetSeen: false,
maySetKeywords: false,
mayCreateChild: false,
mayRename: false,
mayDelete: false,
maySubmit: false,
};
case "readWrite":
return {
mayReadItems: true,
mayAddItems: true,
mayRemoveItems: false,
maySetSeen: true,
maySetKeywords: true,
mayCreateChild: false,
mayRename: false,
mayDelete: false,
maySubmit: true,
};
case "manager":
return {
mayReadItems: true,
mayAddItems: true,
mayRemoveItems: true,
maySetSeen: true,
maySetKeywords: true,
mayCreateChild: true,
mayRename: true,
mayDelete: true,
maySubmit: true,
mayShare: true,
};
default:
return mailboxRights("read");
}
}
function calendarRights(role: string): Record<string, boolean> {
switch (role) {
case "read":
return {
mayReadFreeBusy: true,
mayReadItems: true,
mayWriteAll: false,
mayWriteOwn: false,
mayUpdatePrivate: false,
mayRSVP: false,
mayShare: false,
mayDelete: false,
};
case "readWrite":
return {
mayReadFreeBusy: true,
mayReadItems: true,
mayWriteAll: true,
mayWriteOwn: true,
mayUpdatePrivate: true,
mayRSVP: true,
mayShare: false,
mayDelete: false,
};
case "manager":
return {
mayReadFreeBusy: true,
mayReadItems: true,
mayWriteAll: true,
mayWriteOwn: true,
mayUpdatePrivate: true,
mayRSVP: true,
mayShare: true,
mayDelete: true,
};
default:
return calendarRights("read");
}
}
function addressBookRights(role: string): Record<string, boolean> {
switch (role) {
case "read":
return {
mayRead: true,
mayWrite: false,
mayShare: false,
mayDelete: false,
};
case "readWrite":
return {
mayRead: true,
mayWrite: true,
mayShare: false,
mayDelete: false,
};
case "manager":
return {
mayRead: true,
mayWrite: true,
mayShare: true,
mayDelete: true,
};
default:
return addressBookRights("read");
}
}
function fileRights(role: string): Record<string, boolean> {
switch (role) {
case "read":
return {
mayRead: true,
mayAddChildren: false,
mayRename: false,
mayDelete: false,
mayModifyContent: false,
mayShare: false,
};
case "readWrite":
return {
mayRead: true,
mayAddChildren: true,
mayRename: true,
mayDelete: true,
mayModifyContent: true,
mayShare: false,
};
case "manager":
return {
mayRead: true,
mayAddChildren: true,
mayRename: true,
mayDelete: true,
mayModifyContent: true,
mayShare: true,
};
default:
return fileRights("read");
}
}
function readRights(): Record<string, boolean> {
return { mayRead: true };
}
+20 -2
View File
@@ -22,8 +22,9 @@ import { useAccountStore } from '@/stores/account-store';
import { DEFAULT_AI_POLICY, type AiPolicy } from '@/lib/ai/types'; import { DEFAULT_AI_POLICY, type AiPolicy } from '@/lib/ai/types';
import { supportsLocalLlm } from '@/lib/platform-capabilities'; import { supportsLocalLlm } from '@/lib/platform-capabilities';
import { getAiApiKey } from '@/lib/ai/key-store'; import { getAiApiKey } from '@/lib/ai/key-store';
import { loadAiSettings, type AiLocalSettings } from '@/lib/ai/local-settings'; import { loadAiSettings, isPresetActiveId, presetIdFromActiveId, type AiLocalSettings } from '@/lib/ai/local-settings';
import { askMail, type AskResult } from '@/lib/ai/local-client'; import { askMail, type AskResult } from '@/lib/ai/local-client';
import { ensureDefaultProvider } from '@/lib/ai/auto-provision';
function useAiPolicy(): { policy: AiPolicy; loaded: boolean } { function useAiPolicy(): { policy: AiPolicy; loaded: boolean } {
const [policy, setPolicy] = useState<AiPolicy>(DEFAULT_AI_POLICY); const [policy, setPolicy] = useState<AiPolicy>(DEFAULT_AI_POLICY);
@@ -58,6 +59,9 @@ function providerConfigured(settings: AiLocalSettings, policy: AiPolicy): boolea
case 'opencode': case 'opencode':
return classes.includes('opencode') && !!settings.opencodeModel; return classes.includes('opencode') && !!settings.opencodeModel;
case 'public': { case 'public': {
if (isPresetActiveId(settings.activeProfileId)) {
return classes.includes('public') && !!presetIdFromActiveId(settings.activeProfileId) && settings.publicConsentAccepted;
}
const active = settings.publicProfiles.find((p) => p.id === settings.activeProfileId) ?? null; const active = settings.publicProfiles.find((p) => p.id === settings.activeProfileId) ?? null;
return classes.includes('public') && !!active && settings.publicConsentAccepted; return classes.includes('public') && !!active && settings.publicConsentAccepted;
} }
@@ -90,6 +94,18 @@ export function AiAskButton() {
setOpen(true); setOpen(true);
}, []); }, []);
// Zero-config default (see lib/ai/auto-provision.ts): resolves as soon as
// policy loads, so a user who never visits Settings still finds AI
// already on the first time they open this dialog, if OpenCode or Ollama
// is available.
useEffect(() => {
if (!loaded) return;
(async () => {
const next = await ensureDefaultProvider(policy);
setSettings(next);
})();
}, [loaded, policy]);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
textareaRef.current?.focus(); textareaRef.current?.focus();
@@ -109,7 +125,8 @@ export function AiAskButton() {
setAskError(null); setAskError(null);
setAskResult(null); setAskResult(null);
try { try {
const activeProfile = settings.publicProfiles.find((p) => p.id === settings.activeProfileId) ?? null; const managedPresetId = presetIdFromActiveId(settings.activeProfileId);
const activeProfile = managedPresetId ? null : settings.publicProfiles.find((p) => p.id === settings.activeProfileId) ?? null;
const key = activeProfile ? getAiApiKey(activeProfile.id) : null; const key = activeProfile ? getAiApiKey(activeProfile.id) : null;
const result = await askMail(question.trim(), { const result = await askMail(question.trim(), {
provider: settings.provider as 'local' | 'server' | 'public' | 'opencode', provider: settings.provider as 'local' | 'server' | 'public' | 'opencode',
@@ -119,6 +136,7 @@ export function AiAskButton() {
opencodeModel: settings.opencodeModel, opencodeModel: settings.opencodeModel,
slot: activeSlot, slot: activeSlot,
publicProfile: activeProfile && key ? { baseUrl: activeProfile.baseUrl, model: activeProfile.model, apiKey: key } : null, publicProfile: activeProfile && key ? { baseUrl: activeProfile.baseUrl, model: activeProfile.model, apiKey: key } : null,
publicPresetId: managedPresetId,
}); });
setAskResult(result); setAskResult(result);
} catch (err) { } catch (err) {
+11 -5
View File
@@ -89,6 +89,7 @@ export function FreeBusyView({
}: FreeBusyViewProps) { }: FreeBusyViewProps) {
const t = useTranslations("calendar"); const t = useTranslations("calendar");
const client = useAuthStore((s) => s.client); const client = useAuthStore((s) => s.client);
const activeAccountId = useAuthStore((s) => s.activeAccountId);
const [freeBusyData, setFreeBusyData] = useState<Map<string, FreeBusySlot[]> | null>(null); const [freeBusyData, setFreeBusyData] = useState<Map<string, FreeBusySlot[]> | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [hoveredSlot, setHoveredSlot] = useState<{ const [hoveredSlot, setHoveredSlot] = useState<{
@@ -114,7 +115,7 @@ export function FreeBusyView({
if (!client || participants.length === 0) return; if (!client || participants.length === 0) return;
let cancelled = false; let cancelled = false;
setLoading(true); setLoading(true);
fetchFreeBusy(client, participants, startDate, endDate) fetchFreeBusy(client, participants, startDate, endDate, activeAccountId ?? undefined)
.then((data) => { .then((data) => {
if (!cancelled) { if (!cancelled) {
setFreeBusyData(data); setFreeBusyData(data);
@@ -164,7 +165,8 @@ export function FreeBusyView({
)} )}
</div> </div>
<div className="overflow-auto border border-border rounded-lg"> <div className="relative">
<div className="overflow-auto border border-border rounded-lg">
<div className="min-w-max" style={{ minWidth: totalHalfHourSlots * 24 + 200 }}> <div className="min-w-max" style={{ minWidth: totalHalfHourSlots * 24 + 200 }}>
<table className="w-full border-collapse text-xs"> <table className="w-full border-collapse text-xs">
<thead> <thead>
@@ -242,9 +244,12 @@ export function FreeBusyView({
: "opacity-70" : "opacity-70"
)} )}
title={format(hourSlot.start, "HH:mm")} title={format(hourSlot.start, "HH:mm")}
onClick={() => onClick={() => {
isFree ? handleSlotClick(slot!) : undefined if (!isFree) return;
} const s = slot;
if (!s) return;
handleSlotClick(s);
}}
onMouseEnter={() => onMouseEnter={() =>
setHoveredSlot({ setHoveredSlot({
participant: key, participant: key,
@@ -324,6 +329,7 @@ export function FreeBusyView({
}} }}
/> />
)} )}
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground mt-1"> <div className="flex items-center gap-3 text-xs text-muted-foreground mt-1">
<span className="inline-flex items-center gap-1"> <span className="inline-flex items-center gap-1">
@@ -58,6 +58,9 @@ export function MiniCalendarDashlet({
const end = format(endOfMonth(displayMonth), "yyyy-MM-dd'T'23:59:59"); const end = format(endOfMonth(displayMonth), "yyyy-MM-dd'T'23:59:59");
const { dateRange } = useCalendarStore.getState(); const { dateRange } = useCalendarStore.getState();
if (dateRange?.start === start && dateRange?.end === end) return; if (dateRange?.start === start && dateRange?.end === end) return;
// Imperative fetch via getState() is intentional: we only need to
// trigger a data fetch, not react to its completion directly within
// this component. The store handles loading / error states internally.
useCalendarStore.getState().fetchEvents(client, start, end); useCalendarStore.getState().fetchEvents(client, start, end);
}, [displayMonth, client]); }, [displayMonth, client]);
+3 -2
View File
@@ -4,6 +4,7 @@ import { useState, useEffect, useMemo } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { apiFetch } from "@/lib/browser-navigation";
import { useResourceStore } from "@/stores/resource-store"; import { useResourceStore } from "@/stores/resource-store";
import type { Resource } from "@/lib/resources/client"; import type { Resource } from "@/lib/resources/client";
import { import {
@@ -72,7 +73,6 @@ export function ResourcePicker({ start, end, compact = false }: ResourcePickerPr
for (const resource of filtered) { for (const resource of filtered) {
try { try {
const params = new URLSearchParams({ start, end }); const params = new URLSearchParams({ start, end });
const { apiFetch } = await import("@/lib/browser-navigation");
const res = await apiFetch( const res = await apiFetch(
`/api/resources/${resource.id}/availability?${params.toString()}` `/api/resources/${resource.id}/availability?${params.toString()}`
); );
@@ -130,11 +130,12 @@ export function ResourcePicker({ start, end, compact = false }: ResourcePickerPr
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
placeholder={t("resources.search_placeholder")} placeholder={t("resources.search_placeholder")}
className="pl-8" className="pl-8"
aria-label="Search resources"
/> />
</div> </div>
{isLoading ? ( {isLoading ? (
<div className="flex items-center justify-center py-8"> <div className="flex items-center justify-center py-8" role="status" aria-label="Loading resources">
<div className="animate-spin w-5 h-5 border-2 border-primary border-t-transparent rounded-full" /> <div className="animate-spin w-5 h-5 border-2 border-primary border-t-transparent rounded-full" />
</div> </div>
) : filtered.length === 0 ? ( ) : filtered.length === 0 ? (
@@ -2,7 +2,7 @@
import { useState, useRef, useCallback } from "react"; import { useState, useRef, useCallback } from "react";
import { useTranslations } from "next-intl"; 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 { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { parseVCard, detectDuplicates } from "@/lib/vcard"; import { parseVCard, detectDuplicates } from "@/lib/vcard";
+7 -1
View File
@@ -308,8 +308,14 @@ export function EmailComposer({
getIdentityReplySignatureId, getIdentityReplySignatureId,
} = useSignatureStore(); } = 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 resolveStoreSignatureId = (): string | null => {
const perIdentityId = selectedIdentityId || initialData?.selectedIdentityId || null; const perIdentityId = initialData?.selectedIdentityId || null;
if (mode === 'compose') { if (mode === 'compose') {
if (perIdentityId) { if (perIdentityId) {
const id = getIdentityDefaultSignatureId(perIdentityId); const id = getIdentityDefaultSignatureId(perIdentityId);
+52 -9
View File
@@ -9,7 +9,11 @@ import { useAccountStore } from '@/stores/account-store';
import { DEFAULT_AI_POLICY, type AiPolicy } from '@/lib/ai/types'; import { DEFAULT_AI_POLICY, type AiPolicy } from '@/lib/ai/types';
import { supportsLocalLlm, localLlmNeedsCorsSetup } from '@/lib/platform-capabilities'; import { supportsLocalLlm, localLlmNeedsCorsSetup } from '@/lib/platform-capabilities';
import { getAiApiKey, setAiApiKey, clearAiApiKey } from '@/lib/ai/key-store'; 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 { import {
discoverLocalOllama, discoverLocalOllama,
recommendDefaultModel, recommendDefaultModel,
@@ -56,7 +60,23 @@ export function AiAssistantSettings() {
(async () => { (async () => {
try { try {
const res = await apiFetch('/api/ai/policy'); 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 { } finally {
if (!cancelled) setPolicyLoading(false); if (!cancelled) setPolicyLoading(false);
} }
@@ -289,7 +309,9 @@ export function AiAssistantSettings() {
const [askResult, setAskResult] = useState<AskResult | null>(null); const [askResult, setAskResult] = useState<AskResult | null>(null);
const [askError, setAskError] = useState<string | null>(null); const [askError, setAskError] = useState<string | null>(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 = const canAsk =
question.trim().length > 0 && question.trim().length > 0 &&
@@ -300,7 +322,7 @@ export function AiAssistantSettings() {
: settings.provider === 'opencode' : settings.provider === 'opencode'
? canUseOpencode && !!settings.opencodeModel ? canUseOpencode && !!settings.opencodeModel
: settings.provider === 'public' : settings.provider === 'public'
? canUsePublic && !!activeProfile && settings.publicConsentAccepted ? canUsePublic && activePublicSelection && settings.publicConsentAccepted
: false); : false);
const runAsk = useCallback(async () => { const runAsk = useCallback(async () => {
@@ -318,6 +340,7 @@ export function AiAssistantSettings() {
opencodeModel: settings.opencodeModel, opencodeModel: settings.opencodeModel,
slot: activeSlot, slot: activeSlot,
publicProfile: activeProfile && key ? { baseUrl: activeProfile.baseUrl, model: activeProfile.model, apiKey: key } : null, publicProfile: activeProfile && key ? { baseUrl: activeProfile.baseUrl, model: activeProfile.model, apiKey: key } : null,
publicPresetId: activePresetId,
}); });
setAskResult(result); setAskResult(result);
if (result.seatJustAssigned) { if (result.seatJustAssigned) {
@@ -328,7 +351,7 @@ export function AiAssistantSettings() {
} finally { } finally {
setAsking(false); setAsking(false);
} }
}, [question, settings, activeProfile, activeSlot]); }, [question, settings, activeProfile, activePresetId, activeSlot]);
const providerOptions = useMemo( const providerOptions = useMemo(
() => [ () => [
@@ -622,8 +645,25 @@ export function AiAssistantSettings() {
title="Public providers" 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." 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 && (
<SettingItem
label="Org-managed providers"
description="Set up by your admin. Pick one below in “Answer with” — no key to paste, it's resolved on the server."
>
<div className="flex flex-col gap-2 w-full">
{policy.publicPresets.map((p) => (
<div key={p.id} className="flex items-center gap-2 rounded-md border border-border px-3 py-2">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground truncate">{p.name}</p>
<p className="text-xs text-muted-foreground truncate">{p.model} · managed by admin</p>
</div>
</div>
))}
</div>
</SettingItem>
)}
{settings.publicProfiles.length > 0 && ( {settings.publicProfiles.length > 0 && (
<SettingItem label="Saved profiles"> <SettingItem label="Your own keys">
<div className="flex flex-col gap-2 w-full"> <div className="flex flex-col gap-2 w-full">
{settings.publicProfiles.map((p) => ( {settings.publicProfiles.map((p) => (
<div key={p.id} className="flex items-center gap-2 rounded-md border border-border px-3 py-2"> <div key={p.id} className="flex items-center gap-2 rounded-md border border-border px-3 py-2">
@@ -639,7 +679,7 @@ export function AiAssistantSettings() {
</div> </div>
</SettingItem> </SettingItem>
)} )}
<SettingItem label="Add a provider"> <SettingItem label="Add your own key" description="Prefer to bring your own instead of an org-managed provider above.">
<div className="flex flex-col gap-2 w-full"> <div className="flex flex-col gap-2 w-full">
<div className="flex gap-2 flex-wrap"> <div className="flex gap-2 flex-wrap">
<input <input
@@ -704,12 +744,15 @@ export function AiAssistantSettings() {
{settings.provider && ( {settings.provider && (
<SettingsSection title="Try it" description="Ask a question against your synced mail."> <SettingsSection title="Try it" description="Ask a question against your synced mail.">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{settings.provider === 'public' && settings.publicProfiles.length > 0 && ( {settings.provider === 'public' && (settings.publicProfiles.length > 0 || policy.publicPresets.length > 0) && (
<SettingItem label="Answer with"> <SettingItem label="Answer with">
<Select <Select
value={settings.activeProfileId ?? ''} value={settings.activeProfileId ?? ''}
onChange={(v) => update('activeProfileId', v)} onChange={(v) => 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 })),
]}
/> />
</SettingItem> </SettingItem>
)} )}
+2 -2
View File
@@ -2,7 +2,7 @@
import { useState, useRef, useCallback, useEffect } from "react"; import { useState, useRef, useCallback, useEffect } from "react";
import { useTranslations } from "next-intl"; 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 { Button } from "@/components/ui/button";
import { SettingsSection, SettingItem, RadioGroup, Select } from "./settings-section"; import { SettingsSection, SettingItem, RadioGroup, Select } from "./settings-section";
import { importEmails, type ConflictResolution, type ImportProgress, type ImportResult } from "@/lib/email-import"; import { importEmails, type ConflictResolution, type ImportProgress, type ImportResult } from "@/lib/email-import";
@@ -128,7 +128,7 @@ export function ImportSettings() {
: t("choose_files")} : t("choose_files")}
</Button> </Button>
{files.length > 0 && !importing && ( {files.length > 0 && !importing && (
<Button variant="ghost" size="sm" onClick={reset}> <Button variant="ghost" size="sm" onClick={reset} aria-label="Clear selection">
<X className="w-4 h-4" /> <X className="w-4 h-4" />
</Button> </Button>
)} )}
@@ -184,7 +184,7 @@ export function SignatureEditorModal({
<h2 className="text-lg font-semibold text-foreground"> <h2 className="text-lg font-semibold text-foreground">
{isEditing ? t('edit_signature') : t('new_signature')} {isEditing ? t('edit_signature') : t('new_signature')}
</h2> </h2>
<Button variant="ghost" size="icon" onClick={onClose} className="h-8 w-8"> <Button variant="ghost" size="icon" onClick={onClose} className="h-8 w-8" aria-label="Close">
<X className="w-4 h-4" /> <X className="w-4 h-4" />
</Button> </Button>
</div> </div>
+34 -18
View File
@@ -33,6 +33,13 @@ export function RadialMenu({
const [activeIndex, setActiveIndex] = useState<number>(-1); const [activeIndex, setActiveIndex] = useState<number>(-1);
const [animatingIn, setAnimatingIn] = useState(false); const [animatingIn, setAnimatingIn] = useState(false);
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
const activeIndexRef = useRef(activeIndex);
const itemsRef = useRef(items);
const onCloseRef = useRef(onClose);
activeIndexRef.current = activeIndex;
itemsRef.current = items;
onCloseRef.current = onClose;
useEffect(() => { useEffect(() => {
setMounted(true); setMounted(true);
@@ -51,45 +58,54 @@ export function RadialMenu({
setActiveIndex(-1); setActiveIndex(-1);
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
const items = itemsRef.current;
const currentIndex = activeIndexRef.current;
if (e.key === "Escape") { if (e.key === "Escape") {
e.preventDefault(); e.preventDefault();
onClose(); onCloseRef.current();
return; return;
} }
if (e.key === "Enter" && activeIndex >= 0 && activeIndex < items.length) { if (e.key === "Enter") {
e.preventDefault(); if (currentIndex >= 0 && currentIndex < items.length) {
const item = items[activeIndex]; e.preventDefault();
if (!item.disabled) { const item = items[currentIndex];
item.onClick(); if (!item.disabled) {
onClose(); item.onClick();
onCloseRef.current();
}
} }
return; return;
} }
if (e.key === "ArrowRight" || e.key === "ArrowDown") { if (e.key === "ArrowRight" || e.key === "ArrowDown") {
e.preventDefault(); e.preventDefault();
setActiveIndex((prev) => { setActiveIndex((prev) => {
let next = prev + 1; const hasEnabledItem = items.some((item) => !item.disabled);
if (next >= items.length) next = 0; if (!hasEnabledItem) return -1;
let next = prev;
let loops = 0; let loops = 0;
while (items[next]?.disabled && loops < items.length) { do {
next = next + 1 >= items.length ? 0 : next + 1; next = next + 1 >= items.length ? 0 : next + 1;
loops++; loops++;
} } while (items[next]?.disabled && loops < items.length);
return next; return items[next]?.disabled ? -1 : next;
}); });
return; return;
} }
if (e.key === "ArrowLeft" || e.key === "ArrowUp") { if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
e.preventDefault(); e.preventDefault();
setActiveIndex((prev) => { setActiveIndex((prev) => {
let next = prev - 1; const hasEnabledItem = items.some((item) => !item.disabled);
if (next < 0) next = items.length - 1; if (!hasEnabledItem) return -1;
let next = prev;
let loops = 0; let loops = 0;
while (items[next]?.disabled && loops < items.length) { do {
next = next - 1 < 0 ? items.length - 1 : next - 1; next = next - 1 < 0 ? items.length - 1 : next - 1;
loops++; loops++;
} } while (items[next]?.disabled && loops < items.length);
return next; return items[next]?.disabled ? -1 : next;
}); });
return; return;
} }
@@ -97,7 +113,7 @@ export function RadialMenu({
document.addEventListener("keydown", handleKeyDown); document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown);
}, [isOpen, activeIndex, items, onClose]); }, [isOpen]);
const radius = size / 2 - 28; const radius = size / 2 - 28;
const center = size / 2; const center = size / 2;
+19 -1
View File
@@ -238,13 +238,31 @@ export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: strin
extensionDirectoryUrl: { envVar: 'EXTENSION_DIRECTORY_URL', type: 'url', defaultValue: 'https://extensions.bulwarkmail.org' }, extensionDirectoryUrl: { envVar: 'EXTENSION_DIRECTORY_URL', type: 'url', defaultValue: 'https://extensions.bulwarkmail.org' },
vnctalkServerUrl: { envVar: 'VNCTALK_SERVER_URL', type: 'url', defaultValue: '' }, vnctalkServerUrl: { envVar: 'VNCTALK_SERVER_URL', type: 'url', defaultValue: '' },
collaboraServerUrl: { envVar: 'COLLABORA_SERVER_URL', type: 'url', defaultValue: '' }, collaboraServerUrl: { envVar: 'COLLABORA_SERVER_URL', type: 'url', defaultValue: '' },
appUrl: { envVar: 'NEXT_PUBLIC_APP_URL', type: 'url', defaultValue: '' },
port: { envVar: 'PORT', type: 'string', defaultValue: '3000' },
vncdirectoryEnabled: { envVar: 'VNCDIRECTORY_ENABLED', type: 'boolean', defaultValue: false }, vncdirectoryEnabled: { envVar: 'VNCDIRECTORY_ENABLED', type: 'boolean', defaultValue: false },
vncdirectoryApiUrl: { envVar: 'VNCDIRECTORY_API_URL', type: 'url', defaultValue: '' }, vncdirectoryApiUrl: { envVar: 'VNCDIRECTORY_API_URL', type: 'url', defaultValue: '' },
vncdirectorySamlEnabled: { envVar: 'VNCDIRECTORY_SAML_ENABLED', type: 'boolean', defaultValue: false }, vncdirectorySamlEnabled: { envVar: 'VNCDIRECTORY_SAML_ENABLED', type: 'boolean', defaultValue: false },
vncdirectoryApiKey: { envVar: 'VNCDIRECTORY_API_KEY', type: 'string', defaultValue: '' },
vncdirectorySamlIdpUrl: { envVar: 'VNCDIRECTORY_SAML_IDP_URL', type: 'url', defaultValue: '' },
vncdirectorySamlSpCert: { envVar: 'VNCDIRECTORY_SAML_SP_CERT', type: 'string', defaultValue: '' },
vncdirectorySamlIssuer: { envVar: 'VNCDIRECTORY_SAML_ISSUER', type: 'string', defaultValue: '' },
vncdirectoryLdapEnabled: { envVar: 'VNCDIRECTORY_LDAP_ENABLED', type: 'boolean', defaultValue: false },
vncdirectoryLdapUri: { envVar: 'VNCDIRECTORY_LDAP_URI', type: 'url', defaultValue: '' },
vncdirectoryLdapBindDn: { envVar: 'VNCDIRECTORY_LDAP_BIND_DN', type: 'string', defaultValue: '' },
vncdirectoryLdapBindPassword: { envVar: 'VNCDIRECTORY_LDAP_BIND_PASSWORD', fileEnvVar: 'VNCDIRECTORY_LDAP_BIND_PASSWORD_FILE', type: 'string', defaultValue: '' },
vncdirectoryLdapSearchBase: { envVar: 'VNCDIRECTORY_LDAP_SEARCH_BASE', type: 'string', defaultValue: '' },
vncdirectoryLdapType: { envVar: 'VNCDIRECTORY_LDAP_TYPE', type: 'enum', defaultValue: 'openldap', enumValues: ['openldap', 'ms-ad'] },
vncdirectoryTfaEnabled: { envVar: 'VNCDIRECTORY_TFA_ENABLED', type: 'boolean', defaultValue: false },
vncdirectoryOidcEnabled: { envVar: 'VNCDIRECTORY_OIDC_ENABLED', type: 'boolean', defaultValue: false },
vncdirectoryOidcClientId: { envVar: 'VNCDIRECTORY_OIDC_CLIENT_ID', type: 'string', defaultValue: '' },
vncdirectoryOidcDiscoveryUrl: { envVar: 'VNCDIRECTORY_OIDC_DISCOVERY_URL', type: 'url', defaultValue: '' },
vncdirectorySessionTtl: { envVar: 'VNCDIRECTORY_SESSION_TTL', type: 'string', defaultValue: '28800' },
vncdirectoryFederatedApps: { envVar: 'VNCDIRECTORY_FEDERATED_APPS', type: 'json', defaultValue: {} },
}; };
/** Keys that should never be exposed to the client config endpoint */ /** Keys that should never be exposed to the client config endpoint */
export const SENSITIVE_CONFIG_KEYS = new Set(['oauthClientSecret', 'sessionSecret', 'vncdirectoryApiKey', 'vncdirectoryLdapPassword']); export const SENSITIVE_CONFIG_KEYS = new Set(['oauthClientSecret', 'sessionSecret', 'vncdirectoryApiKey', 'vncdirectoryLdapBindPassword']);
/** Admin session cookie name */ /** Admin session cookie name */
export const ADMIN_SESSION_COOKIE = 'admin_session'; export const ADMIN_SESSION_COOKIE = 'admin_session';
+87
View File
@@ -0,0 +1,87 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { DEFAULT_AI_ENTITLEMENT, DEFAULT_AI_POLICY, type AiPolicy } from '../types';
import { loadAiSettings } from '../local-settings';
const { listOpencodeModels } = vi.hoisted(() => ({ 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);
});
});
+81
View File
@@ -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/);
});
});
+22
View File
@@ -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();
});
});
+66
View File
@@ -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<AiLocalSettings> {
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;
}
+57 -11
View File
@@ -135,14 +135,33 @@ export async function chatPublic(
model: string, model: string,
messages: ChatMessage[], messages: ChatMessage[],
): Promise<string> { ): Promise<string> {
const res = await fetch(`${baseUrl.replace(/\/+$/, '')}/chat/completions`, { const url = `${baseUrl.replace(/\/+$/, '')}/chat/completions`;
method: 'POST', let res: Response;
headers: { try {
'Content-Type': 'application/json', res = await fetch(url, {
Authorization: `Bearer ${apiKey}`, method: 'POST',
}, headers: {
body: JSON.stringify({ model, messages }), '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}`); if (!res.ok) throw new Error(`Provider returned ${res.status}`);
const body = (await res.json()) as OpenAiChatResponse; const body = (await res.json()) as OpenAiChatResponse;
const content = body.choices?.[0]?.message?.content; const content = body.choices?.[0]?.message?.content;
@@ -150,6 +169,24 @@ export async function chatPublic(
return content; 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<string> {
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), // ── OpenCode: a locally-running `opencode serve` (github.com/sst/opencode),
// the same agent runtime Paperclip drives as an adapter. Reached through THIS // 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 // app's own backend (app/api/ai/opencode/*) rather than directly, for the same
@@ -419,7 +456,12 @@ export interface AskConfig {
localBaseUrl: string; localBaseUrl: string;
localModel: string | null; localModel: string | null;
serverModel: 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; publicProfile: ResolvedPublicProfile | null;
/** Admin-managed preset id (see chatPublicManaged) — mutually exclusive
* with publicProfile. */
publicPresetId?: string | null;
opencodeModel?: string | null; opencodeModel?: string | null;
/** Cookie slot of the account whose local index should be searched. Omitting /** Cookie slot of the account whose local index should be searched. Omitting
* it reads whichever account the resolver finds first — see fetchLocalLeg. */ * it reads whichever account the resolver finds first — see fetchLocalLeg. */
@@ -433,7 +475,7 @@ export async function askMail(question: string, config: AskConfig): Promise<AskR
if (config.provider === 'server' && !config.serverModel) { if (config.provider === 'server' && !config.serverModel) {
throw new Error('No server model selected'); throw new Error('No server model selected');
} }
if (config.provider === 'public' && !config.publicProfile) { if (config.provider === 'public' && !config.publicProfile && !config.publicPresetId) {
throw new Error('No provider profile selected'); throw new Error('No provider profile selected');
} }
if (config.provider === 'opencode' && !config.opencodeModel) { if (config.provider === 'opencode' && !config.opencodeModel) {
@@ -448,8 +490,12 @@ export async function askMail(question: string, config: AskConfig): Promise<AskR
let answer: string; let answer: string;
let seatJustAssigned = false; let seatJustAssigned = false;
if (config.provider === 'public') { if (config.provider === 'public') {
const profile = config.publicProfile as ResolvedPublicProfile; if (config.publicPresetId) {
answer = await chatPublic(profile.baseUrl, profile.apiKey, profile.model, messages); answer = await chatPublicManaged(config.publicPresetId, messages);
} else {
const profile = config.publicProfile as ResolvedPublicProfile;
answer = await chatPublic(profile.baseUrl, profile.apiKey, profile.model, messages);
}
} else if (config.provider === 'server') { } else if (config.provider === 'server') {
const result = await chatServer(config.serverModel as string, messages); const result = await chatServer(config.serverModel as string, messages);
answer = result.answer; answer = result.answer;
+20
View File
@@ -50,6 +50,26 @@ function newProfileId(): string {
return `profile-${Math.random().toString(36).slice(2, 10)}-${Math.random().toString(36).slice(2, 10)}`; return `profile-${Math.random().toString(36).slice(2, 10)}-${Math.random().toString(36).slice(2, 10)}`;
} }
/**
* `activeProfileId` names either a personal BYOK profile (its own id, as
* always) or an admin-managed preset (see PublicAiPreset), prefixed so the
* two id spaces can never collide without adding a second field everywhere
* that reads/writes activeProfileId.
*/
const PRESET_PREFIX = 'preset:';
export function presetActiveId(presetId: string): string {
return PRESET_PREFIX + presetId;
}
export function isPresetActiveId(activeId: string | null): boolean {
return !!activeId && activeId.startsWith(PRESET_PREFIX);
}
export function presetIdFromActiveId(activeId: string | null): string | null {
return activeId && activeId.startsWith(PRESET_PREFIX) ? activeId.slice(PRESET_PREFIX.length) : null;
}
/** One-time upgrade from the earlier single-profile shape (a bare /** One-time upgrade from the earlier single-profile shape (a bare
* publicBaseUrl/publicModel pair) into the profile list, so a browser that * publicBaseUrl/publicModel pair) into the profile list, so a browser that
* already saved settings before profiles existed doesn't just lose them. */ * already saved settings before profiles existed doesn't just lose them. */
+36
View File
@@ -22,6 +22,33 @@
export type AiClass = 'local' | 'server' | 'public' | 'opencode'; export type AiClass = 'local' | 'server' | 'public' | 'opencode';
/**
* An admin-published, server-managed `public`-class provider — the
* Paperclip-style alternative to a user pasting their own key (decision
* 2026-08-07): the admin names an env var (e.g. "DEEPSEEK_API_KEY") instead
* of typing a secret value anywhere in this config. The actual value is
* whatever ops has set in the server's real environment (k8s secret, .env,
* Electron packaging) — same custody model as the existing AI_SERVER_BASE_URL
* var, just admin-nameable instead of hardcoded. Resolved server-side only,
* in app/api/ai/public/chat/route.ts; never sent to a browser.
*/
export interface PublicAiPreset {
id: string;
name: string;
baseUrl: string;
model: string;
apiKeyEnvVar: string;
}
/** What a client is allowed to know about a preset — no baseUrl/apiKeyEnvVar,
* since the client only ever refers to a preset by id and never calls the
* provider itself. */
export interface PublicAiPresetOption {
id: string;
name: string;
model: string;
}
export interface AiEntitlement { export interface AiEntitlement {
licensed: boolean; licensed: boolean;
subject: 'user' | 'tenant'; subject: 'user' | 'tenant';
@@ -45,6 +72,9 @@ export interface AiPolicy {
/** Base-URL prefixes a BYOK profile's baseUrl must match. null = unrestricted /** Base-URL prefixes a BYOK profile's baseUrl must match. null = unrestricted
* (today's behavior). Advisory/client-side only — see spec §6.1. */ * (today's behavior). Advisory/client-side only — see spec §6.1. */
publicProviderAllowlist: string[] | null; publicProviderAllowlist: string[] | null;
/** Admin-managed provider presets available to every user (see
* PublicAiPreset) — sanitized to {id,name,model} for the client. */
publicPresets: PublicAiPresetOption[];
} }
export const DEFAULT_AI_ENTITLEMENT: AiEntitlement = { export const DEFAULT_AI_ENTITLEMENT: AiEntitlement = {
@@ -63,6 +93,7 @@ export const DEFAULT_AI_POLICY: AiPolicy = {
retrievalEnabled: true, retrievalEnabled: true,
consent: null, consent: null,
publicProviderAllowlist: null, publicProviderAllowlist: null,
publicPresets: [],
}; };
// Admin-authored console config (docs/AI-ASSISTANT-CONCEPT.md §6 / // Admin-authored console config (docs/AI-ASSISTANT-CONCEPT.md §6 /
@@ -83,6 +114,10 @@ export interface AiConsoleConfig {
/** null = unrestricted BYOK base URLs (today's behavior, unchanged). /** null = unrestricted BYOK base URLs (today's behavior, unchanged).
* Non-null = base URL must start with one of these prefixes. */ * Non-null = base URL must start with one of these prefixes. */
publicProviderAllowlist: string[] | null; publicProviderAllowlist: string[] | null;
/** Server-managed `public`-class presets — the Paperclip-style env-var-key
* picker (decision 2026-08-07). Empty by default; adding one here is what
* makes it show up in every user's Settings picker and in AiPolicy.publicPresets. */
publicPresets: PublicAiPreset[];
/** Master switch for the retrieval leg (mail-content → embeddings). /** Master switch for the retrieval leg (mail-content → embeddings).
* Independent of classesEnabled.server. Defaults true. */ * Independent of classesEnabled.server. Defaults true. */
retrievalEnabled: boolean; retrievalEnabled: boolean;
@@ -93,6 +128,7 @@ export const DEFAULT_AI_CONSOLE_CONFIG: AiConsoleConfig = {
classesEnabled: {}, classesEnabled: {},
serverModelAllowlist: null, serverModelAllowlist: null,
publicProviderAllowlist: null, publicProviderAllowlist: null,
publicPresets: [],
retrievalEnabled: true, retrievalEnabled: true,
consent: null, consent: null,
}; };
+10 -2
View File
@@ -52,6 +52,11 @@ function getEventRange(event: CalendarEvent): EventRange {
}; };
} }
// NOTE: this duplicates the ISO 8601 duration parsing in
// components/calendar/event-card.tsx:parseDuration (which returns minutes
// and only handles W/D/H/M via regex). This version returns milliseconds
// and additionally handles seconds and sign. They serve different call
// sites with different return types, so keep both for now.
function parseDurationMs(duration: string): number { function parseDurationMs(duration: string): number {
let ms = 0; let ms = 0;
let sign = 1; let sign = 1;
@@ -120,7 +125,8 @@ export async function fetchFreeBusy(
client: IJMAPClient, client: IJMAPClient,
participants: { email: string }[], participants: { email: string }[],
start: Date, start: Date,
end: Date end: Date,
accountId?: string
): Promise<Map<string, FreeBusySlot[]>> { ): Promise<Map<string, FreeBusySlot[]>> {
const result = new Map<string, FreeBusySlot[]>(); const result = new Map<string, FreeBusySlot[]>();
@@ -139,7 +145,9 @@ export async function fetchFreeBusy(
try { try {
const events = await client.queryAllCalendarEvents( const events = await client.queryAllCalendarEvents(
{ after: start.toISOString(), before: end.toISOString() }, { after: start.toISOString(), before: end.toISOString() },
[{ property: "start", isAscending: true }] [{ property: "start", isAscending: true }],
undefined,
accountId
); );
for (const event of events) { for (const event of events) {
+3 -1
View File
@@ -79,8 +79,10 @@ export async function getCollaboraEditUrl(
// For now, return the base edit URL. A full WOPI implementation would // 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. // generate a WOPI src URL with an access token pointing back to this server.
const appUrl = configManager.get<string>("appUrl") || process.env.NEXT_PUBLIC_APP_URL;
const port = configManager.get<string>("port") || process.env.PORT || "3000";
const wopiSrcUrl = `${actionUrl}?WOPISrc=${encodeURIComponent( 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; return wopiSrcUrl;
+5 -4
View File
@@ -888,16 +888,17 @@ export class DemoJMAPClient implements IJMAPClient {
return { destroyed: eventIds, notDestroyed: [] }; return { destroyed: eventIds, notDestroyed: [] };
} }
async queryCalendarEvents(filter: CalendarEventFilter): Promise<CalendarEvent[]> { async queryCalendarEvents(filter: CalendarEventFilter, sort?: Array<{ property: string; isAscending: boolean }>, limit?: number): Promise<CalendarEvent[]> {
return this.data.calendarEvents.filter(e => { const events = this.data.calendarEvents.filter(e => {
if (filter.after && e.start < filter.after) return false; if (filter.after && e.start < filter.after) return false;
if (filter.before && e.start > filter.before) return false; if (filter.before && e.start > filter.before) return false;
return true; return true;
}); });
return limit ? events.slice(0, limit) : events;
} }
async queryAllCalendarEvents(filter: CalendarEventFilter): Promise<CalendarEvent[]> { async queryAllCalendarEvents(filter: CalendarEventFilter, sort?: Array<{ property: string; isAscending: boolean }>, limit?: number): Promise<CalendarEvent[]> {
return this.queryCalendarEvents(filter); return this.queryCalendarEvents(filter, sort, limit);
} }
async parseCalendarEvents(): Promise<Partial<CalendarEvent>[]> { async parseCalendarEvents(): Promise<Partial<CalendarEvent>[]> {
-10
View File
@@ -1,5 +1,4 @@
import type { IJMAPClient } from "@/lib/jmap/client-interface"; import type { IJMAPClient } from "@/lib/jmap/client-interface";
import type { Mailbox } from "@/lib/jmap/types";
import { expandImportableEmails } from "@/lib/eml-import"; import { expandImportableEmails } from "@/lib/eml-import";
export type ConflictResolution = "skip" | "replace" | "copy"; export type ConflictResolution = "skip" | "replace" | "copy";
@@ -20,15 +19,6 @@ export interface ImportResult {
errors: Array<{ file: string; error: string }>; 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 { interface ParsedEml {
messageId: string | null; messageId: string | null;
subject: string; subject: string;
-4
View File
@@ -17,10 +17,6 @@ function isTgzName(name: string): boolean {
return /\.(tgz|tar\.gz)$/i.test(name); return /\.(tgz|tar\.gz)$/i.test(name);
} }
function isArchiveName(name: string): boolean {
return isZipName(name) || isTgzName(name);
}
async function extractEmlsFromZip(file: File): Promise<ImportableEmail[]> { async function extractEmlsFromZip(file: File): Promise<ImportableEmail[]> {
const { default: JSZip } = await import("jszip"); const { default: JSZip } = await import("jszip");
const zip = await JSZip.loadAsync(await file.arrayBuffer()); const zip = await JSZip.loadAsync(await file.arrayBuffer());
+1 -1
View File
@@ -300,7 +300,7 @@ export interface IJMAPClient {
deleteCalendarEvent(eventId: string, sendSchedulingMessages?: boolean, targetAccountId?: string): Promise<void>; deleteCalendarEvent(eventId: string, sendSchedulingMessages?: boolean, targetAccountId?: string): Promise<void>;
batchDeleteCalendarEvents(eventIds: string[], targetAccountId?: string): Promise<{ destroyed: string[]; notDestroyed: string[] }>; batchDeleteCalendarEvents(eventIds: string[], targetAccountId?: string): Promise<{ destroyed: string[]; notDestroyed: string[] }>;
queryCalendarEvents(filter: CalendarEventFilter, sort?: Array<{ property: string; isAscending: boolean }>, limit?: number, targetAccountId?: string): Promise<CalendarEvent[]>; queryCalendarEvents(filter: CalendarEventFilter, sort?: Array<{ property: string; isAscending: boolean }>, limit?: number, targetAccountId?: string): Promise<CalendarEvent[]>;
queryAllCalendarEvents(filter: CalendarEventFilter, sort?: Array<{ property: string; isAscending: boolean }>, limit?: number): Promise<CalendarEvent[]>; queryAllCalendarEvents(filter: CalendarEventFilter, sort?: Array<{ property: string; isAscending: boolean }>, limit?: number, accountId?: string): Promise<CalendarEvent[]>;
parseCalendarEvents(accountId: string, blobId: string): Promise<Partial<CalendarEvent>[]>; parseCalendarEvents(accountId: string, blobId: string): Promise<Partial<CalendarEvent>[]>;
// ── Calendar Tasks ──────────────────────────────────────────── // ── Calendar Tasks ────────────────────────────────────────────
+3 -2
View File
@@ -4957,12 +4957,13 @@ export class JMAPClient implements IJMAPClient {
async queryAllCalendarEvents( async queryAllCalendarEvents(
filter: CalendarEventFilter, filter: CalendarEventFilter,
sort?: Array<{ property: string; isAscending: boolean }>, sort?: Array<{ property: string; isAscending: boolean }>,
limit?: number limit?: number,
accountId?: string
): Promise<CalendarEvent[]> { ): Promise<CalendarEvent[]> {
try { try {
const allEvents: CalendarEvent[] = []; const allEvents: CalendarEvent[] = [];
const primaryId = this.getCalendarsAccountId(); const primaryId = this.getCalendarsAccountId();
const accountIds = this.getCalendarCapableAccountIds(); const accountIds = accountId ? [accountId] : this.getCalendarCapableAccountIds();
for (const accountId of accountIds) { for (const accountId of accountIds) {
const isPrimary = accountId === primaryId; const isPrimary = accountId === primaryId;
+216
View File
@@ -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<string, MailboxRights> = {
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<string, string> = {
read: "Viewer",
readWrite: "Editor",
manager: "Manager",
};
export const CALENDAR_ROLE_LABELS: Record<string, string> = {
read: "Viewer",
readWrite: "Editor",
manager: "Manager",
};
export const ADDRESSBOOK_ROLE_LABELS: Record<string, string> = {
read: "Viewer",
readWrite: "Editor",
manager: "Manager",
};
export const FILE_ROLE_LABELS: Record<string, string> = {
read: "Viewer",
readWrite: "Editor",
manager: "Manager",
};
export const CALENDAR_RIGHTS_PRESETS: Record<string, CalendarRights> = {
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<string, AddressBookRights> = {
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<string, FileNodeRights> = {
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";
}
+6
View File
@@ -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"; import { configManager } from "@/lib/admin/config-manager";
export interface CreateVncMeetingParams { export interface CreateVncMeetingParams {
+66
View File
@@ -3424,5 +3424,71 @@
"alignment": "Alignment", "alignment": "Alignment",
"font_size": "Font Size" "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"
}
} }
} }
@@ -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)
+16
View File
@@ -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); console.log("Assembled standalone server at", standaloneDir);
+9 -4
View File
@@ -121,7 +121,11 @@ export const useResourceStore = create<ResourceState>()((set, get) => ({
cancelBooking: async (bookingId: string) => { cancelBooking: async (bookingId: string) => {
const { bookings } = get(); const { bookings } = get();
const booking = bookings.find((b) => b.id === bookingId); 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 { try {
const res = await apiFetch( const res = await apiFetch(
@@ -136,9 +140,10 @@ export const useResourceStore = create<ResourceState>()((set, get) => ({
} }
}, },
cancelEventBookings: async (_eventId: string) => { cancelEventBookings: async (eventId: string) => {
const { bookings } = get(); const { bookings } = get();
for (const booking of bookings) { const eventBookings = bookings.filter((b) => b.eventId === eventId);
for (const booking of eventBookings) {
try { try {
const res = await apiFetch( const res = await apiFetch(
`/api/resources/${booking.resourceId}/book/${booking.id}`, `/api/resources/${booking.resourceId}/book/${booking.id}`,
@@ -149,6 +154,6 @@ export const useResourceStore = create<ResourceState>()((set, get) => ({
// silently fail // silently fail
} }
} }
set({ bookings: [] }); set({ bookings: bookings.filter((b) => b.eventId !== eventId) });
}, },
})); }));
+25 -181
View File
@@ -7,13 +7,19 @@ import type {
FileNodeRights, FileNodeRights,
MailboxRights, MailboxRights,
} from "@/lib/jmap/types"; } 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 = export type { SharedResourceKind } from "@/lib/sharing-rights";
| "mailbox"
| "calendar"
| "addressBook"
| "file";
export interface SharedFolder { export interface SharedFolder {
id: string; id: string;
@@ -34,6 +40,7 @@ interface SharingState {
sharedWithMe: SharedFolder[]; sharedWithMe: SharedFolder[];
loading: boolean; loading: boolean;
principalsCache: Principal[]; principalsCache: Principal[];
lastMessage: { type: 'success' | 'error'; text: string } | null;
loadPrincipals: (client: IJMAPClient) => Promise<Principal[]>; loadPrincipals: (client: IJMAPClient) => Promise<Principal[]>;
fetchShares: (client: IJMAPClient) => Promise<void>; fetchShares: (client: IJMAPClient) => Promise<void>;
@@ -67,148 +74,17 @@ interface SharingState {
} }
function roleLabel(kind: SharedResourceKind, role: string): string { function roleLabel(kind: SharedResourceKind, role: string): string {
if (kind === "mailbox") return MAILBOX_ROLE_LABELS[role] ?? role;
return role;
}
const MAILBOX_PRESETS: Record<string, MailboxRights> = {
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<string, string> = {
read: "Viewer",
readWrite: "Editor",
manager: "Manager",
};
const CALENDAR_PRESETS: Record<string, CalendarRights> = {
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<string, AddressBookRights> = {
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<string, FileNodeRights> = {
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) { switch (kind) {
case "mailbox": case "mailbox":
return ( return MAILBOX_ROLE_LABELS[role] ?? role;
MAILBOX_PRESETS[role] ?? MAILBOX_PRESETS.read
);
case "calendar": case "calendar":
return ( return CALENDAR_ROLE_LABELS[role] ?? role;
CALENDAR_PRESETS[role] ?? CALENDAR_PRESETS.read
);
case "addressBook": case "addressBook":
return ( return ADDRESSBOOK_ROLE_LABELS[role] ?? role;
ADDRESS_BOOK_PRESETS[role] ?? ADDRESS_BOOK_PRESETS.read
);
case "file": 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<SharingState>((set, get) => ({
sharedWithMe: [], sharedWithMe: [],
loading: false, loading: false,
principalsCache: [], principalsCache: [],
lastMessage: null,
async loadPrincipals(client) { async loadPrincipals(client) {
const cached = get().principalsCache; const cached = get().principalsCache;
@@ -416,7 +293,7 @@ export const useSharingStore = create<SharingState>((set, get) => ({
entry, entry,
], ],
})); }));
toast.success(`Shared "${resourceName}"`); set({ lastMessage: { type: 'success', text: `Shared "${resourceName}"` } });
}, },
async revokeShare(client, resourceId, resourceKind, principalId, accountId) { async revokeShare(client, resourceId, resourceKind, principalId, accountId) {
@@ -440,7 +317,7 @@ export const useSharingStore = create<SharingState>((set, get) => ({
), ),
), ),
})); }));
toast.success("Access revoked"); set({ lastMessage: { type: 'success', text: "Access revoked" } });
}, },
async changeRole( async changeRole(
@@ -463,7 +340,7 @@ export const useSharingStore = create<SharingState>((set, get) => ({
: f, : f,
), ),
})); }));
toast.success("Role updated"); set({ lastMessage: { type: 'success', text: "Role updated" } });
}, },
async acceptShare(_client, share) { async acceptShare(_client, share) {
@@ -472,14 +349,14 @@ export const useSharingStore = create<SharingState>((set, get) => ({
f.id === share.id ? { ...f, pending: false } : f, 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) { async declineShare(_client, share) {
set((s) => ({ set((s) => ({
sharedWithMe: s.sharedWithMe.filter((f) => f.id !== share.id), 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";
}
+22 -5
View File
@@ -61,11 +61,28 @@ export const useSignatureStore = create<SignatureState>()(
}, },
deleteSignature: (id) => { deleteSignature: (id) => {
set((state) => ({ set((state) => {
signatures: state.signatures.filter((s) => s.id !== id), const nextMap = { ...state.identitySignatureMap };
defaultSignatureId: state.defaultSignatureId === id ? null : state.defaultSignatureId, for (const identityId of Object.keys(nextMap)) {
replySignatureId: state.replySignatureId === id ? null : state.replySignatureId, 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) => { duplicateSignature: (id) => {