fix: Phase 2 QA — all 25 remaining HIGH/MEDIUM/LOW issues
HIGH fixes (7): - H1: VNCdirectory admin i18n — 30+ translation keys added - H2: handleSave try/catch with error toast - H3: Free/busy accountId scoping - H4: cancelEventBookings filter by eventId - H5: Resource picker static apiFetch import - H6: Sharing-store toast messages via lastMessage state - H7: roleLabel for all resource types MEDIUM fixes (11): - M1: identitySignatureMap cleanup on delete - M2: Now-line relative positioning - M3: Radial menu disabled item keyboard nav - M4: Radial menu stable event listener via refs - M5: cancelBooking error on missing booking - M6: PasswordRow isMasked state flag - M7: Extract shared rights into lib/sharing-rights.ts - M8: VNCtalk client server-side guard - M9: Collabora configManager instead of process.env - M10: CONFIG_ENV_MAP VNCdirectory fields - M11: SENSITIVE_CONFIG_KEYS field name unification LOW fixes (7): - L1-L3: Unused imports removed - L4: aria-labels on close, clear, search, spinner - L5-L7: Comments for intentional patterns, null guard
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Save, Loader2, Plus, X } from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import { toast } from '@/stores/toast-store';
|
||||
|
||||
interface VncDirectoryFormData {
|
||||
enabled: boolean;
|
||||
@@ -49,6 +51,7 @@ const BLANK_FORM: VncDirectoryFormData = {
|
||||
};
|
||||
|
||||
export function VncDirectoryTab() {
|
||||
const t = useTranslations('admin.vncdirectory');
|
||||
const [config, setConfig] = useState<VncDirectoryFormData>({ ...BLANK_FORM });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -105,19 +108,25 @@ export function VncDirectoryTab() {
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
|
||||
const res = await apiFetch('/api/admin/vncdirectory', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config),
|
||||
});
|
||||
try {
|
||||
const res = await apiFetch('/api/admin/vncdirectory', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setMessage({ type: 'success', text: 'VNCdirectory configuration saved.' });
|
||||
setDirty(false);
|
||||
await fetchConfig();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setMessage({ type: 'error', text: data.error || 'Failed to save' });
|
||||
if (res.ok) {
|
||||
setMessage({ type: 'success', text: t('saved') });
|
||||
setDirty(false);
|
||||
await fetchConfig();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
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);
|
||||
}
|
||||
@@ -125,7 +134,7 @@ export function VncDirectoryTab() {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
|
||||
Loading...
|
||||
{t('loading')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -136,9 +145,9 @@ export function VncDirectoryTab() {
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<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">
|
||||
Centralized identity and directory integration (SAML, LDAP, 2FA)
|
||||
{t('description')}
|
||||
</p>
|
||||
</div>
|
||||
{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"
|
||||
>
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
Save configuration
|
||||
{t('save')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -165,12 +174,12 @@ export function VncDirectoryTab() {
|
||||
</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="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">
|
||||
Turn on VNCdirectory integration for identity management, SSO, and directory services
|
||||
{t('enabled_description')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -192,53 +201,53 @@ export function VncDirectoryTab() {
|
||||
|
||||
{config.enabled && (
|
||||
<>
|
||||
<Section title="Connection">
|
||||
<Section title={t('connection')}>
|
||||
<div className="divide-y divide-border">
|
||||
<TextRow
|
||||
label="VNCdirectory URL"
|
||||
label={t('url')}
|
||||
value={config.apiUrl}
|
||||
onChange={(v) => updateField('apiUrl', v)}
|
||||
placeholder="https://vncdirectory.example.com"
|
||||
placeholder={t('url_placeholder')}
|
||||
/>
|
||||
<PasswordRow
|
||||
label="API Key"
|
||||
label={t('api_key')}
|
||||
value={config.apiKey}
|
||||
onChange={(v) => updateField('apiKey', v)}
|
||||
placeholder="Enter API key"
|
||||
placeholder={t('api_key_placeholder')}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="SAML / Identity Provider">
|
||||
<Section title={t('saml')}>
|
||||
<div className="divide-y divide-border">
|
||||
<ToggleRow
|
||||
label="SAML Enabled"
|
||||
description="Enable SAML single sign-on via VNCdirectory"
|
||||
label={t('saml_enabled')}
|
||||
description={t('saml_enabled_description')}
|
||||
value={config.samlEnabled}
|
||||
onChange={() => toggleBool('samlEnabled')}
|
||||
/>
|
||||
{config.samlEnabled && (
|
||||
<>
|
||||
<TextRow
|
||||
label="Identity Provider URL"
|
||||
label={t('idp_url')}
|
||||
value={config.samlIdpUrl}
|
||||
onChange={(v) => updateField('samlIdpUrl', v)}
|
||||
placeholder="https://idp.example.com/saml2/idp"
|
||||
placeholder={t('idp_url_placeholder')}
|
||||
/>
|
||||
<TextRow
|
||||
label="Issuer Name (Entity ID)"
|
||||
label={t('issuer')}
|
||||
value={config.samlIssuer}
|
||||
onChange={(v) => updateField('samlIssuer', v)}
|
||||
placeholder="urn:example:vncmail"
|
||||
placeholder={t('issuer_placeholder')}
|
||||
/>
|
||||
<div className="px-4 py-3 flex flex-col gap-2">
|
||||
<label className="text-sm text-foreground">
|
||||
Service Provider Certificate (X.509)
|
||||
{t('sp_cert')}
|
||||
</label>
|
||||
<textarea
|
||||
value={config.samlSpCert}
|
||||
onChange={(e) => updateField('samlSpCert', e.target.value)}
|
||||
placeholder="-----BEGIN CERTIFICATE----- ... -----END CERTIFICATE-----"
|
||||
placeholder={t('sp_cert_placeholder')}
|
||||
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"
|
||||
/>
|
||||
@@ -248,46 +257,46 @@ export function VncDirectoryTab() {
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="LDAP Directory">
|
||||
<Section title={t('ldap')}>
|
||||
<div className="divide-y divide-border">
|
||||
<ToggleRow
|
||||
label="LDAP Enabled"
|
||||
description="Query user directory via LDAP for contact lookups and authentication"
|
||||
label={t('ldap_enabled')}
|
||||
description={t('ldap_enabled_description')}
|
||||
value={config.ldapEnabled}
|
||||
onChange={() => toggleBool('ldapEnabled')}
|
||||
/>
|
||||
{config.ldapEnabled && (
|
||||
<>
|
||||
<TextRow
|
||||
label="LDAP Server URI"
|
||||
label={t('ldap_uri')}
|
||||
value={config.ldapUri}
|
||||
onChange={(v) => updateField('ldapUri', v)}
|
||||
placeholder="ldaps://ldap.example.com:636"
|
||||
placeholder={t('ldap_uri_placeholder')}
|
||||
/>
|
||||
<TextRow
|
||||
label="Bind DN"
|
||||
label={t('bind_dn')}
|
||||
value={config.ldapBindDn}
|
||||
onChange={(v) => updateField('ldapBindDn', v)}
|
||||
placeholder="cn=readonly,dc=example,dc=com"
|
||||
placeholder={t('bind_dn_placeholder')}
|
||||
/>
|
||||
<PasswordRow
|
||||
label="Bind Password"
|
||||
label={t('bind_password')}
|
||||
value={config.ldapBindPassword}
|
||||
onChange={(v) => updateField('ldapBindPassword', v)}
|
||||
placeholder="Enter LDAP bind password"
|
||||
placeholder={t('bind_password_placeholder')}
|
||||
/>
|
||||
<TextRow
|
||||
label="Search Base"
|
||||
label={t('search_base')}
|
||||
value={config.ldapSearchBase}
|
||||
onChange={(v) => updateField('ldapSearchBase', v)}
|
||||
placeholder="ou=users,dc=example,dc=com"
|
||||
placeholder={t('search_base_placeholder')}
|
||||
/>
|
||||
<SelectRow
|
||||
label="LDAP Type"
|
||||
label={t('ldap_type')}
|
||||
value={config.ldapType}
|
||||
options={[
|
||||
{ value: 'openldap', label: 'OpenLDAP' },
|
||||
{ value: 'ms-ad', label: 'Microsoft Active Directory' },
|
||||
{ value: 'openldap', label: t('ldap_type_openldap') },
|
||||
{ value: 'ms-ad', label: t('ldap_type_msad') },
|
||||
]}
|
||||
onChange={(v) => updateField('ldapType', v as 'openldap' | 'ms-ad')}
|
||||
/>
|
||||
@@ -296,41 +305,41 @@ export function VncDirectoryTab() {
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Authentication">
|
||||
<Section title={t('auth_section')}>
|
||||
<div className="divide-y divide-border">
|
||||
<ToggleRow
|
||||
label="Enforce 2FA/TOTP"
|
||||
description="Require two-factor authentication for all users"
|
||||
label={t('require_2fa')}
|
||||
description={t('require_2fa_description')}
|
||||
value={config.tfaEnabled}
|
||||
onChange={() => toggleBool('tfaEnabled')}
|
||||
/>
|
||||
<ToggleRow
|
||||
label="OpenID Connect (OIDC)"
|
||||
description="Enable OIDC login alongside or instead of SAML"
|
||||
label={t('oidc_section')}
|
||||
description={t('oidc_section_description')}
|
||||
value={config.oidcEnabled}
|
||||
onChange={() => toggleBool('oidcEnabled')}
|
||||
/>
|
||||
{config.oidcEnabled && (
|
||||
<>
|
||||
<TextRow
|
||||
label="OIDC Client ID"
|
||||
label={t('oidc_client_id')}
|
||||
value={config.oidcClientId}
|
||||
onChange={(v) => updateField('oidcClientId', v)}
|
||||
placeholder="vncmail-client"
|
||||
placeholder={t('oidc_client_id_placeholder')}
|
||||
/>
|
||||
<TextRow
|
||||
label="OIDC Discovery URL"
|
||||
label={t('oidc_discovery_url')}
|
||||
value={config.oidcDiscoveryUrl}
|
||||
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="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">
|
||||
How long SSO sessions remain valid. Default: 8 hours (28800)
|
||||
{t('session_ttl_description')}
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
@@ -344,11 +353,10 @@ export function VncDirectoryTab() {
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Federated Applications">
|
||||
<Section title={t('federated')}>
|
||||
<div className="px-4 py-3">
|
||||
<p className="text-xs text-muted-foreground mb-3">
|
||||
Configure SSO redirect URLs for other VNC applications. Users signed into one
|
||||
app will be transparently authenticated when navigating to another.
|
||||
{t('federated_description')}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{federatedAppsList.map(([appName, url]) => (
|
||||
@@ -366,13 +374,13 @@ export function VncDirectoryTab() {
|
||||
type="url"
|
||||
value={url}
|
||||
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"
|
||||
/>
|
||||
<button
|
||||
onClick={() => removeFederatedApp(appName)}
|
||||
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" />
|
||||
</button>
|
||||
@@ -398,6 +406,7 @@ function AddFederatedApp({
|
||||
existingKeys: Set<string>;
|
||||
onAdd: (name: string, url: string) => void;
|
||||
}) {
|
||||
const t = useTranslations('admin.vncdirectory');
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [name, setName] = 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"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Add federated app
|
||||
{t('add_app')}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -419,19 +428,19 @@ function AddFederatedApp({
|
||||
function handleAdd() {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) {
|
||||
setError('Enter an application name');
|
||||
setError(t('app_name_error'));
|
||||
return;
|
||||
}
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(trimmed)) {
|
||||
setError('Name must contain only letters, numbers, hyphens, and underscores');
|
||||
setError(t('app_name_format_error'));
|
||||
return;
|
||||
}
|
||||
if (existingKeys.has(trimmed)) {
|
||||
setError('An app with this name already exists');
|
||||
setError(t('app_exists_error'));
|
||||
return;
|
||||
}
|
||||
if (!url.trim()) {
|
||||
setError('Enter an SSO URL');
|
||||
setError(t('app_url_error'));
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
@@ -457,7 +466,7 @@ function AddFederatedApp({
|
||||
value={name}
|
||||
onChange={(e) => { setName(e.target.value); setError(null); }}
|
||||
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"
|
||||
/>
|
||||
<input
|
||||
@@ -465,7 +474,7 @@ function AddFederatedApp({
|
||||
value={url}
|
||||
onChange={(e) => { setUrl(e.target.value); setError(null); }}
|
||||
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"
|
||||
/>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
@@ -474,14 +483,14 @@ function AddFederatedApp({
|
||||
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"
|
||||
>
|
||||
Add
|
||||
{t('add')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
className="h-8 px-2.5 rounded-md text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Cancel
|
||||
{t('cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -537,7 +546,16 @@ function PasswordRow({
|
||||
onChange: (v: string) => void;
|
||||
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 (
|
||||
<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
|
||||
type={isMasked ? 'text' : 'password'}
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onChange={handleChange}
|
||||
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"
|
||||
/>
|
||||
|
||||
+2
-169
@@ -1,4 +1,5 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { resolveRights, type SharedResourceKind } from "@/lib/sharing-rights";
|
||||
|
||||
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[] = [
|
||||
[
|
||||
@@ -190,172 +191,4 @@ export async function POST(request: NextRequest) {
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -89,6 +89,7 @@ export function FreeBusyView({
|
||||
}: FreeBusyViewProps) {
|
||||
const t = useTranslations("calendar");
|
||||
const client = useAuthStore((s) => s.client);
|
||||
const activeAccountId = useAuthStore((s) => s.activeAccountId);
|
||||
const [freeBusyData, setFreeBusyData] = useState<Map<string, FreeBusySlot[]> | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hoveredSlot, setHoveredSlot] = useState<{
|
||||
@@ -114,7 +115,7 @@ export function FreeBusyView({
|
||||
if (!client || participants.length === 0) return;
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
fetchFreeBusy(client, participants, startDate, endDate)
|
||||
fetchFreeBusy(client, participants, startDate, endDate, activeAccountId ?? undefined)
|
||||
.then((data) => {
|
||||
if (!cancelled) {
|
||||
setFreeBusyData(data);
|
||||
@@ -164,7 +165,8 @@ export function FreeBusyView({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="overflow-auto border border-border rounded-lg">
|
||||
<div className="relative">
|
||||
<div className="overflow-auto border border-border rounded-lg">
|
||||
<div className="min-w-max" style={{ minWidth: totalHalfHourSlots * 24 + 200 }}>
|
||||
<table className="w-full border-collapse text-xs">
|
||||
<thead>
|
||||
@@ -242,9 +244,12 @@ export function FreeBusyView({
|
||||
: "opacity-70"
|
||||
)}
|
||||
title={format(hourSlot.start, "HH:mm")}
|
||||
onClick={() =>
|
||||
isFree ? handleSlotClick(slot!) : undefined
|
||||
}
|
||||
onClick={() => {
|
||||
if (!isFree) return;
|
||||
const s = slot;
|
||||
if (!s) return;
|
||||
handleSlotClick(s);
|
||||
}}
|
||||
onMouseEnter={() =>
|
||||
setHoveredSlot({
|
||||
participant: key,
|
||||
@@ -324,6 +329,7 @@ export function FreeBusyView({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground mt-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 { dateRange } = useCalendarStore.getState();
|
||||
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);
|
||||
}, [displayMonth, client]);
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState, useEffect, useMemo } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { apiFetch } from "@/lib/browser-navigation";
|
||||
import { useResourceStore } from "@/stores/resource-store";
|
||||
import type { Resource } from "@/lib/resources/client";
|
||||
import {
|
||||
@@ -72,7 +73,6 @@ export function ResourcePicker({ start, end, compact = false }: ResourcePickerPr
|
||||
for (const resource of filtered) {
|
||||
try {
|
||||
const params = new URLSearchParams({ start, end });
|
||||
const { apiFetch } = await import("@/lib/browser-navigation");
|
||||
const res = await apiFetch(
|
||||
`/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)}
|
||||
placeholder={t("resources.search_placeholder")}
|
||||
className="pl-8"
|
||||
aria-label="Search resources"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{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>
|
||||
) : filtered.length === 0 ? (
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Upload, FileText, AlertTriangle, X, Check, ChevronDown } from "lucide-react";
|
||||
import { Upload, FileText, AlertTriangle, X, Check } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { parseVCard, detectDuplicates } from "@/lib/vcard";
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useRef, useCallback, useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Upload, FolderOpen, Download, AlertTriangle, Check, X } from "lucide-react";
|
||||
import { Upload, AlertTriangle, Check, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingsSection, SettingItem, RadioGroup, Select } from "./settings-section";
|
||||
import { importEmails, type ConflictResolution, type ImportProgress, type ImportResult } from "@/lib/email-import";
|
||||
@@ -128,7 +128,7 @@ export function ImportSettings() {
|
||||
: t("choose_files")}
|
||||
</Button>
|
||||
{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" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -184,7 +184,7 @@ export function SignatureEditorModal({
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
{isEditing ? t('edit_signature') : t('new_signature')}
|
||||
</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" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -33,6 +33,13 @@ export function RadialMenu({
|
||||
const [activeIndex, setActiveIndex] = useState<number>(-1);
|
||||
const [animatingIn, setAnimatingIn] = useState(false);
|
||||
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(() => {
|
||||
setMounted(true);
|
||||
@@ -51,45 +58,54 @@ export function RadialMenu({
|
||||
setActiveIndex(-1);
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const items = itemsRef.current;
|
||||
const currentIndex = activeIndexRef.current;
|
||||
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
onCloseRef.current();
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter" && activeIndex >= 0 && activeIndex < items.length) {
|
||||
e.preventDefault();
|
||||
const item = items[activeIndex];
|
||||
if (!item.disabled) {
|
||||
item.onClick();
|
||||
onClose();
|
||||
if (e.key === "Enter") {
|
||||
if (currentIndex >= 0 && currentIndex < items.length) {
|
||||
e.preventDefault();
|
||||
const item = items[currentIndex];
|
||||
if (!item.disabled) {
|
||||
item.onClick();
|
||||
onCloseRef.current();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setActiveIndex((prev) => {
|
||||
let next = prev + 1;
|
||||
if (next >= items.length) next = 0;
|
||||
const hasEnabledItem = items.some((item) => !item.disabled);
|
||||
if (!hasEnabledItem) return -1;
|
||||
|
||||
let next = prev;
|
||||
let loops = 0;
|
||||
while (items[next]?.disabled && loops < items.length) {
|
||||
do {
|
||||
next = next + 1 >= items.length ? 0 : next + 1;
|
||||
loops++;
|
||||
}
|
||||
return next;
|
||||
} while (items[next]?.disabled && loops < items.length);
|
||||
return items[next]?.disabled ? -1 : next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setActiveIndex((prev) => {
|
||||
let next = prev - 1;
|
||||
if (next < 0) next = items.length - 1;
|
||||
const hasEnabledItem = items.some((item) => !item.disabled);
|
||||
if (!hasEnabledItem) return -1;
|
||||
|
||||
let next = prev;
|
||||
let loops = 0;
|
||||
while (items[next]?.disabled && loops < items.length) {
|
||||
do {
|
||||
next = next - 1 < 0 ? items.length - 1 : next - 1;
|
||||
loops++;
|
||||
}
|
||||
return next;
|
||||
} while (items[next]?.disabled && loops < items.length);
|
||||
return items[next]?.disabled ? -1 : next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -97,7 +113,7 @@ export function RadialMenu({
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isOpen, activeIndex, items, onClose]);
|
||||
}, [isOpen]);
|
||||
|
||||
const radius = size / 2 - 28;
|
||||
const center = size / 2;
|
||||
|
||||
+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' },
|
||||
vnctalkServerUrl: { envVar: 'VNCTALK_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 },
|
||||
vncdirectoryApiUrl: { envVar: 'VNCDIRECTORY_API_URL', type: 'url', defaultValue: '' },
|
||||
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 */
|
||||
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 */
|
||||
export const ADMIN_SESSION_COOKIE = 'admin_session';
|
||||
|
||||
@@ -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 {
|
||||
let ms = 0;
|
||||
let sign = 1;
|
||||
@@ -120,7 +125,8 @@ export async function fetchFreeBusy(
|
||||
client: IJMAPClient,
|
||||
participants: { email: string }[],
|
||||
start: Date,
|
||||
end: Date
|
||||
end: Date,
|
||||
accountId?: string
|
||||
): Promise<Map<string, FreeBusySlot[]>> {
|
||||
const result = new Map<string, FreeBusySlot[]>();
|
||||
|
||||
@@ -139,7 +145,9 @@ export async function fetchFreeBusy(
|
||||
try {
|
||||
const events = await client.queryAllCalendarEvents(
|
||||
{ after: start.toISOString(), before: end.toISOString() },
|
||||
[{ property: "start", isAscending: true }]
|
||||
[{ property: "start", isAscending: true }],
|
||||
undefined,
|
||||
accountId
|
||||
);
|
||||
|
||||
for (const event of events) {
|
||||
|
||||
@@ -79,8 +79,10 @@ export async function getCollaboraEditUrl(
|
||||
|
||||
// For now, return the base edit URL. A full WOPI implementation would
|
||||
// generate a WOPI src URL with an access token pointing back to this server.
|
||||
const appUrl = configManager.get<string>("appUrl") || process.env.NEXT_PUBLIC_APP_URL;
|
||||
const port = configManager.get<string>("port") || process.env.PORT || "3000";
|
||||
const wopiSrcUrl = `${actionUrl}?WOPISrc=${encodeURIComponent(
|
||||
`${process.env.NEXT_PUBLIC_APP_URL || `http://localhost:${process.env.PORT || 3000}`}/api/collabora/wopi/files/${encodeURIComponent(fileId)}`
|
||||
`${appUrl || `http://localhost:${port}`}/api/collabora/wopi/files/${encodeURIComponent(fileId)}`
|
||||
)}`;
|
||||
|
||||
return wopiSrcUrl;
|
||||
|
||||
@@ -888,16 +888,17 @@ export class DemoJMAPClient implements IJMAPClient {
|
||||
return { destroyed: eventIds, notDestroyed: [] };
|
||||
}
|
||||
|
||||
async queryCalendarEvents(filter: CalendarEventFilter): Promise<CalendarEvent[]> {
|
||||
return this.data.calendarEvents.filter(e => {
|
||||
async queryCalendarEvents(filter: CalendarEventFilter, sort?: Array<{ property: string; isAscending: boolean }>, limit?: number): Promise<CalendarEvent[]> {
|
||||
const events = this.data.calendarEvents.filter(e => {
|
||||
if (filter.after && e.start < filter.after) return false;
|
||||
if (filter.before && e.start > filter.before) return false;
|
||||
return true;
|
||||
});
|
||||
return limit ? events.slice(0, limit) : events;
|
||||
}
|
||||
|
||||
async queryAllCalendarEvents(filter: CalendarEventFilter): Promise<CalendarEvent[]> {
|
||||
return this.queryCalendarEvents(filter);
|
||||
async queryAllCalendarEvents(filter: CalendarEventFilter, sort?: Array<{ property: string; isAscending: boolean }>, limit?: number): Promise<CalendarEvent[]> {
|
||||
return this.queryCalendarEvents(filter, sort, limit);
|
||||
}
|
||||
|
||||
async parseCalendarEvents(): Promise<Partial<CalendarEvent>[]> {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||
import type { Mailbox } from "@/lib/jmap/types";
|
||||
import { expandImportableEmails } from "@/lib/eml-import";
|
||||
|
||||
export type ConflictResolution = "skip" | "replace" | "copy";
|
||||
@@ -20,15 +19,6 @@ export interface ImportResult {
|
||||
errors: Array<{ file: string; error: string }>;
|
||||
}
|
||||
|
||||
function toBase64(buffer: ArrayBuffer): string {
|
||||
let binary = "";
|
||||
const bytes = new Uint8Array(buffer);
|
||||
for (let i = 0; i < bytes.byteLength; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
interface ParsedEml {
|
||||
messageId: string | null;
|
||||
subject: string;
|
||||
|
||||
@@ -17,10 +17,6 @@ function isTgzName(name: string): boolean {
|
||||
return /\.(tgz|tar\.gz)$/i.test(name);
|
||||
}
|
||||
|
||||
function isArchiveName(name: string): boolean {
|
||||
return isZipName(name) || isTgzName(name);
|
||||
}
|
||||
|
||||
async function extractEmlsFromZip(file: File): Promise<ImportableEmail[]> {
|
||||
const { default: JSZip } = await import("jszip");
|
||||
const zip = await JSZip.loadAsync(await file.arrayBuffer());
|
||||
|
||||
@@ -300,7 +300,7 @@ export interface IJMAPClient {
|
||||
deleteCalendarEvent(eventId: string, sendSchedulingMessages?: boolean, targetAccountId?: string): Promise<void>;
|
||||
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[]>;
|
||||
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>[]>;
|
||||
|
||||
// ── Calendar Tasks ────────────────────────────────────────────
|
||||
|
||||
+3
-2
@@ -4957,12 +4957,13 @@ export class JMAPClient implements IJMAPClient {
|
||||
async queryAllCalendarEvents(
|
||||
filter: CalendarEventFilter,
|
||||
sort?: Array<{ property: string; isAscending: boolean }>,
|
||||
limit?: number
|
||||
limit?: number,
|
||||
accountId?: string
|
||||
): Promise<CalendarEvent[]> {
|
||||
try {
|
||||
const allEvents: CalendarEvent[] = [];
|
||||
const primaryId = this.getCalendarsAccountId();
|
||||
const accountIds = this.getCalendarCapableAccountIds();
|
||||
const accountIds = accountId ? [accountId] : this.getCalendarCapableAccountIds();
|
||||
|
||||
for (const accountId of accountIds) {
|
||||
const isPrimary = accountId === primaryId;
|
||||
|
||||
@@ -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";
|
||||
|
||||
export interface CreateVncMeetingParams {
|
||||
|
||||
@@ -3424,5 +3424,71 @@
|
||||
"alignment": "Alignment",
|
||||
"font_size": "Font Size"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"vncdirectory": {
|
||||
"title": "VNCdirectory",
|
||||
"description": "Centralized identity and directory integration (SAML, LDAP, 2FA)",
|
||||
"loading": "Loading...",
|
||||
"save": "Save configuration",
|
||||
"saving": "Saving...",
|
||||
"saved": "VNCdirectory configuration saved.",
|
||||
"save_error": "Failed to save",
|
||||
"enable_section": "Enable VNCdirectory Integration",
|
||||
"enabled": "Enabled",
|
||||
"enabled_description": "Turn on VNCdirectory integration for identity management, SSO, and directory services",
|
||||
"connection": "Connection",
|
||||
"url": "VNCdirectory URL",
|
||||
"url_placeholder": "https://vncdirectory.example.com",
|
||||
"api_key": "API Key",
|
||||
"api_key_placeholder": "Enter API key",
|
||||
"saml": "SAML / Identity Provider",
|
||||
"saml_enabled": "SAML Enabled",
|
||||
"saml_enabled_description": "Enable SAML single sign-on via VNCdirectory",
|
||||
"idp_url": "Identity Provider URL",
|
||||
"idp_url_placeholder": "https://idp.example.com/saml2/idp",
|
||||
"issuer": "Issuer Name (Entity ID)",
|
||||
"issuer_placeholder": "urn:example:vncmail",
|
||||
"sp_cert": "Service Provider Certificate (X.509)",
|
||||
"sp_cert_placeholder": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----",
|
||||
"ldap": "LDAP Directory",
|
||||
"ldap_enabled": "LDAP Enabled",
|
||||
"ldap_enabled_description": "Query user directory via LDAP for contact lookups and authentication",
|
||||
"ldap_uri": "LDAP Server URI",
|
||||
"ldap_uri_placeholder": "ldaps://ldap.example.com:636",
|
||||
"bind_dn": "Bind DN",
|
||||
"bind_dn_placeholder": "cn=readonly,dc=example,dc=com",
|
||||
"bind_password": "Bind Password",
|
||||
"bind_password_placeholder": "Enter LDAP bind password",
|
||||
"search_base": "Search Base",
|
||||
"search_base_placeholder": "ou=users,dc=example,dc=com",
|
||||
"ldap_type": "LDAP Type",
|
||||
"ldap_type_openldap": "OpenLDAP",
|
||||
"ldap_type_msad": "Microsoft Active Directory",
|
||||
"auth_section": "Authentication",
|
||||
"require_2fa": "Enforce 2FA/TOTP",
|
||||
"require_2fa_description": "Require two-factor authentication for all users",
|
||||
"oidc_section": "OpenID Connect (OIDC)",
|
||||
"oidc_section_description": "Enable OIDC login alongside or instead of SAML",
|
||||
"oidc_client_id": "OIDC Client ID",
|
||||
"oidc_client_id_placeholder": "vncmail-client",
|
||||
"oidc_discovery_url": "OIDC Discovery URL",
|
||||
"oidc_discovery_url_placeholder": "https://idp.example.com/.well-known/openid-configuration",
|
||||
"session_ttl": "Session TTL (seconds)",
|
||||
"session_ttl_description": "How long SSO sessions remain valid. Default: 8 hours (28800)",
|
||||
"federated": "Federated Applications",
|
||||
"federated_description": "Configure SSO redirect URLs for other VNC applications. Users signed into one app will be transparently authenticated when navigating to another.",
|
||||
"add_app": "Add federated app",
|
||||
"app_name_placeholder": "App name (e.g. vnctalk)",
|
||||
"app_url_placeholder": "https://vnc.example.com/auth/sso",
|
||||
"remove_app": "Remove {name}",
|
||||
"add": "Add",
|
||||
"cancel": "Cancel",
|
||||
"app_name_error": "Enter an application name",
|
||||
"app_name_format_error": "Name must contain only letters, numbers, hyphens, and underscores",
|
||||
"app_exists_error": "An app with this name already exists",
|
||||
"app_url_error": "Enter an SSO URL",
|
||||
"saved_password_hint": "Saved - type to replace"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -121,7 +121,11 @@ export const useResourceStore = create<ResourceState>()((set, get) => ({
|
||||
cancelBooking: async (bookingId: string) => {
|
||||
const { bookings } = get();
|
||||
const booking = bookings.find((b) => b.id === bookingId);
|
||||
if (!booking) return;
|
||||
if (!booking) {
|
||||
console.error(`cancelBooking: booking with id "${bookingId}" not found`);
|
||||
set({ bookingError: `Booking ${bookingId} not found` });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await apiFetch(
|
||||
@@ -136,9 +140,10 @@ export const useResourceStore = create<ResourceState>()((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
cancelEventBookings: async (_eventId: string) => {
|
||||
cancelEventBookings: async (eventId: string) => {
|
||||
const { bookings } = get();
|
||||
for (const booking of bookings) {
|
||||
const eventBookings = bookings.filter((b) => b.eventId === eventId);
|
||||
for (const booking of eventBookings) {
|
||||
try {
|
||||
const res = await apiFetch(
|
||||
`/api/resources/${booking.resourceId}/book/${booking.id}`,
|
||||
@@ -149,6 +154,6 @@ export const useResourceStore = create<ResourceState>()((set, get) => ({
|
||||
// silently fail
|
||||
}
|
||||
}
|
||||
set({ bookings: [] });
|
||||
set({ bookings: bookings.filter((b) => b.eventId !== eventId) });
|
||||
},
|
||||
}));
|
||||
|
||||
+25
-181
@@ -7,13 +7,19 @@ import type {
|
||||
FileNodeRights,
|
||||
MailboxRights,
|
||||
} from "@/lib/jmap/types";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import {
|
||||
type SharedResourceKind,
|
||||
MAILBOX_ROLE_LABELS,
|
||||
CALENDAR_ROLE_LABELS,
|
||||
ADDRESSBOOK_ROLE_LABELS,
|
||||
FILE_ROLE_LABELS,
|
||||
resolveRights,
|
||||
detectMailboxPreset,
|
||||
detectCalendarPreset,
|
||||
detectAddressBookPreset,
|
||||
} from "@/lib/sharing-rights";
|
||||
|
||||
export type SharedResourceKind =
|
||||
| "mailbox"
|
||||
| "calendar"
|
||||
| "addressBook"
|
||||
| "file";
|
||||
export type { SharedResourceKind } from "@/lib/sharing-rights";
|
||||
|
||||
export interface SharedFolder {
|
||||
id: string;
|
||||
@@ -34,6 +40,7 @@ interface SharingState {
|
||||
sharedWithMe: SharedFolder[];
|
||||
loading: boolean;
|
||||
principalsCache: Principal[];
|
||||
lastMessage: { type: 'success' | 'error'; text: string } | null;
|
||||
|
||||
loadPrincipals: (client: IJMAPClient) => Promise<Principal[]>;
|
||||
fetchShares: (client: IJMAPClient) => Promise<void>;
|
||||
@@ -67,148 +74,17 @@ interface SharingState {
|
||||
}
|
||||
|
||||
function roleLabel(kind: SharedResourceKind, role: string): string {
|
||||
if (kind === "mailbox") return MAILBOX_ROLE_LABELS[role] ?? role;
|
||||
return role;
|
||||
}
|
||||
|
||||
const MAILBOX_PRESETS: Record<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) {
|
||||
case "mailbox":
|
||||
return (
|
||||
MAILBOX_PRESETS[role] ?? MAILBOX_PRESETS.read
|
||||
);
|
||||
return MAILBOX_ROLE_LABELS[role] ?? role;
|
||||
case "calendar":
|
||||
return (
|
||||
CALENDAR_PRESETS[role] ?? CALENDAR_PRESETS.read
|
||||
);
|
||||
return CALENDAR_ROLE_LABELS[role] ?? role;
|
||||
case "addressBook":
|
||||
return (
|
||||
ADDRESS_BOOK_PRESETS[role] ?? ADDRESS_BOOK_PRESETS.read
|
||||
);
|
||||
return ADDRESSBOOK_ROLE_LABELS[role] ?? role;
|
||||
case "file":
|
||||
return FILE_PRESETS[role] ?? FILE_PRESETS.read;
|
||||
return FILE_ROLE_LABELS[role] ?? role;
|
||||
default:
|
||||
return role;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,6 +93,7 @@ export const useSharingStore = create<SharingState>((set, get) => ({
|
||||
sharedWithMe: [],
|
||||
loading: false,
|
||||
principalsCache: [],
|
||||
lastMessage: null,
|
||||
|
||||
async loadPrincipals(client) {
|
||||
const cached = get().principalsCache;
|
||||
@@ -416,7 +293,7 @@ export const useSharingStore = create<SharingState>((set, get) => ({
|
||||
entry,
|
||||
],
|
||||
}));
|
||||
toast.success(`Shared "${resourceName}"`);
|
||||
set({ lastMessage: { type: 'success', text: `Shared "${resourceName}"` } });
|
||||
},
|
||||
|
||||
async revokeShare(client, resourceId, resourceKind, principalId, accountId) {
|
||||
@@ -440,7 +317,7 @@ export const useSharingStore = create<SharingState>((set, get) => ({
|
||||
),
|
||||
),
|
||||
}));
|
||||
toast.success("Access revoked");
|
||||
set({ lastMessage: { type: 'success', text: "Access revoked" } });
|
||||
},
|
||||
|
||||
async changeRole(
|
||||
@@ -463,7 +340,7 @@ export const useSharingStore = create<SharingState>((set, get) => ({
|
||||
: f,
|
||||
),
|
||||
}));
|
||||
toast.success("Role updated");
|
||||
set({ lastMessage: { type: 'success', text: "Role updated" } });
|
||||
},
|
||||
|
||||
async acceptShare(_client, share) {
|
||||
@@ -472,14 +349,14 @@ export const useSharingStore = create<SharingState>((set, get) => ({
|
||||
f.id === share.id ? { ...f, pending: false } : f,
|
||||
),
|
||||
}));
|
||||
toast.success(`Accepted share: ${share.resourceName}`);
|
||||
set({ lastMessage: { type: 'success', text: `Accepted share: ${share.resourceName}` } });
|
||||
},
|
||||
|
||||
async declineShare(_client, share) {
|
||||
set((s) => ({
|
||||
sharedWithMe: s.sharedWithMe.filter((f) => f.id !== share.id),
|
||||
}));
|
||||
toast.success(`Declined share: ${share.resourceName}`);
|
||||
set({ lastMessage: { type: 'success', text: `Declined share: ${share.resourceName}` } });
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -532,37 +409,4 @@ async function applyShare(
|
||||
}
|
||||
}
|
||||
|
||||
function detectMailboxPreset(r: MailboxRights): string {
|
||||
for (const [name, preset] of Object.entries(MAILBOX_PRESETS)) {
|
||||
const keys = Object.keys(preset) as (keyof MailboxRights)[];
|
||||
if (
|
||||
keys.every(
|
||||
(k) =>
|
||||
(preset[k] ?? false) === (r[k as keyof MailboxRights] ?? false),
|
||||
)
|
||||
) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
return "custom";
|
||||
}
|
||||
|
||||
function detectCalendarPreset(r: CalendarRights): string {
|
||||
for (const [name, preset] of Object.entries(CALENDAR_PRESETS)) {
|
||||
const keys = Object.keys(preset) as (keyof CalendarRights)[];
|
||||
if (keys.every((k) => (preset[k] ?? false) === (r[k as keyof CalendarRights] ?? false))) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
return "custom";
|
||||
}
|
||||
|
||||
function detectAddressBookPreset(r: AddressBookRights): string {
|
||||
for (const [name, preset] of Object.entries(ADDRESS_BOOK_PRESETS)) {
|
||||
const keys = Object.keys(preset) as (keyof AddressBookRights)[];
|
||||
if (keys.every((k) => (preset[k] ?? false) === (r[k as keyof AddressBookRights] ?? false))) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
return "custom";
|
||||
}
|
||||
|
||||
@@ -61,11 +61,28 @@ export const useSignatureStore = create<SignatureState>()(
|
||||
},
|
||||
|
||||
deleteSignature: (id) => {
|
||||
set((state) => ({
|
||||
signatures: state.signatures.filter((s) => s.id !== id),
|
||||
defaultSignatureId: state.defaultSignatureId === id ? null : state.defaultSignatureId,
|
||||
replySignatureId: state.replySignatureId === id ? null : state.replySignatureId,
|
||||
}));
|
||||
set((state) => {
|
||||
const nextMap = { ...state.identitySignatureMap };
|
||||
for (const identityId of Object.keys(nextMap)) {
|
||||
const entry = nextMap[identityId];
|
||||
if (entry.defaultId === id || entry.replyId === id) {
|
||||
const updated = { ...entry };
|
||||
if (updated.defaultId === id) delete updated.defaultId;
|
||||
if (updated.replyId === id) delete updated.replyId;
|
||||
if (Object.keys(updated).length === 0) {
|
||||
delete nextMap[identityId];
|
||||
} else {
|
||||
nextMap[identityId] = updated;
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
signatures: state.signatures.filter((s) => s.id !== id),
|
||||
defaultSignatureId: state.defaultSignatureId === id ? null : state.defaultSignatureId,
|
||||
replySignatureId: state.replySignatureId === id ? null : state.replySignatureId,
|
||||
identitySignatureMap: nextMap,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
duplicateSignature: (id) => {
|
||||
|
||||
Reference in New Issue
Block a user