Merge branch 'dev'
# Conflicts: # locales/en/common.json
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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 & seats</h2>
|
<h2 className="text-sm font-medium text-foreground">Entitlement & seats</h2>
|
||||||
|
|||||||
@@ -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,6 +108,7 @@ export function VncDirectoryTab() {
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
|
|
||||||
|
try {
|
||||||
const res = await apiFetch('/api/admin/vncdirectory', {
|
const res = await apiFetch('/api/admin/vncdirectory', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -112,12 +116,17 @@ export function VncDirectoryTab() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
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----- ... -----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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
@@ -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
@@ -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 };
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,6 +165,7 @@ export function FreeBusyView({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
<div className="overflow-auto border border-border rounded-lg">
|
<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">
|
||||||
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,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>
|
||||||
|
|||||||
@@ -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") {
|
||||||
|
if (currentIndex >= 0 && currentIndex < items.length) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const item = items[activeIndex];
|
const item = items[currentIndex];
|
||||||
if (!item.disabled) {
|
if (!item.disabled) {
|
||||||
item.onClick();
|
item.onClick();
|
||||||
onClose();
|
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
@@ -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';
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
+48
-2
@@ -135,7 +135,10 @@ 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`;
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -143,6 +146,22 @@ export async function chatPublic(
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({ model, messages }),
|
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') {
|
||||||
|
if (config.publicPresetId) {
|
||||||
|
answer = await chatPublicManaged(config.publicPresetId, messages);
|
||||||
|
} else {
|
||||||
const profile = config.publicProfile as ResolvedPublicProfile;
|
const profile = config.publicProfile as ResolvedPublicProfile;
|
||||||
answer = await chatPublic(profile.baseUrl, profile.apiKey, profile.model, messages);
|
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;
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>[]> {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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";
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -61,11 +61,28 @@ export const useSignatureStore = create<SignatureState>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
deleteSignature: (id) => {
|
deleteSignature: (id) => {
|
||||||
set((state) => ({
|
set((state) => {
|
||||||
|
const nextMap = { ...state.identitySignatureMap };
|
||||||
|
for (const identityId of Object.keys(nextMap)) {
|
||||||
|
const entry = nextMap[identityId];
|
||||||
|
if (entry.defaultId === id || entry.replyId === id) {
|
||||||
|
const updated = { ...entry };
|
||||||
|
if (updated.defaultId === id) delete updated.defaultId;
|
||||||
|
if (updated.replyId === id) delete updated.replyId;
|
||||||
|
if (Object.keys(updated).length === 0) {
|
||||||
|
delete nextMap[identityId];
|
||||||
|
} else {
|
||||||
|
nextMap[identityId] = updated;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
signatures: state.signatures.filter((s) => s.id !== id),
|
signatures: state.signatures.filter((s) => s.id !== id),
|
||||||
defaultSignatureId: state.defaultSignatureId === id ? null : state.defaultSignatureId,
|
defaultSignatureId: state.defaultSignatureId === id ? null : state.defaultSignatureId,
|
||||||
replySignatureId: state.replySignatureId === id ? null : state.replySignatureId,
|
replySignatureId: state.replySignatureId === id ? null : state.replySignatureId,
|
||||||
}));
|
identitySignatureMap: nextMap,
|
||||||
|
};
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
duplicateSignature: (id) => {
|
duplicateSignature: (id) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user