feat: P2.9 VNCtalk + P2.10 Collabora + P2.11 Calendar Enhancements + P2.13 VNCdirectory Admin
- P2.9: VNCtalk video meeting — create/update meeting from event modal, 'Join Meeting' link in event detail. Admin config vnctalkServerUrl. - P2.10: Collabora online editing — 'Edit with Collabora' for office files, WOPI discovery + edit URL. Admin config collaboraServerUrl. - P2.11: Calendar enhancements — clickable links in descriptions, participant contact popover, Reply/Reply All from event, timezone picker, map links for locations. - P2.13: VNCdirectory IDP admin panel — Connection, SAML/IDP, LDAP, Authentication, Federated Apps configuration. Secret masking on display.
This commit is contained in:
@@ -0,0 +1,620 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Save, Loader2, Plus, X } from 'lucide-react';
|
||||||
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
|
||||||
|
interface VncDirectoryFormData {
|
||||||
|
enabled: boolean;
|
||||||
|
apiUrl: string;
|
||||||
|
apiKey: string;
|
||||||
|
samlEnabled: boolean;
|
||||||
|
samlIdpUrl: string;
|
||||||
|
samlSpCert: string;
|
||||||
|
samlIssuer: string;
|
||||||
|
ldapEnabled: boolean;
|
||||||
|
ldapUri: string;
|
||||||
|
ldapBindDn: string;
|
||||||
|
ldapBindPassword: string;
|
||||||
|
ldapSearchBase: string;
|
||||||
|
ldapType: 'openldap' | 'ms-ad';
|
||||||
|
tfaEnabled: boolean;
|
||||||
|
oidcEnabled: boolean;
|
||||||
|
oidcClientId: string;
|
||||||
|
oidcDiscoveryUrl: string;
|
||||||
|
sessionTtl: number;
|
||||||
|
federatedApps: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BLANK_FORM: VncDirectoryFormData = {
|
||||||
|
enabled: false,
|
||||||
|
apiUrl: '',
|
||||||
|
apiKey: '',
|
||||||
|
samlEnabled: false,
|
||||||
|
samlIdpUrl: '',
|
||||||
|
samlSpCert: '',
|
||||||
|
samlIssuer: '',
|
||||||
|
ldapEnabled: false,
|
||||||
|
ldapUri: '',
|
||||||
|
ldapBindDn: '',
|
||||||
|
ldapBindPassword: '',
|
||||||
|
ldapSearchBase: '',
|
||||||
|
ldapType: 'openldap',
|
||||||
|
tfaEnabled: false,
|
||||||
|
oidcEnabled: false,
|
||||||
|
oidcClientId: '',
|
||||||
|
oidcDiscoveryUrl: '',
|
||||||
|
sessionTtl: 28800,
|
||||||
|
federatedApps: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function VncDirectoryTab() {
|
||||||
|
const [config, setConfig] = useState<VncDirectoryFormData>({ ...BLANK_FORM });
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
const [dirty, setDirty] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => { fetchConfig(); }, []);
|
||||||
|
|
||||||
|
async function fetchConfig() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await apiFetch('/api/admin/vncdirectory');
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setConfig(data);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateField<K extends keyof VncDirectoryFormData>(key: K, value: VncDirectoryFormData[K]) {
|
||||||
|
setConfig((prev) => ({ ...prev, [key]: value }));
|
||||||
|
setDirty(true);
|
||||||
|
setMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleBool(key: keyof VncDirectoryFormData) {
|
||||||
|
setConfig((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||||
|
setDirty(true);
|
||||||
|
setMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFederatedApp(name: string, url: string) {
|
||||||
|
setConfig((prev) => ({
|
||||||
|
...prev,
|
||||||
|
federatedApps: { ...prev.federatedApps, [name]: url },
|
||||||
|
}));
|
||||||
|
setDirty(true);
|
||||||
|
setMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFederatedApp(name: string) {
|
||||||
|
setConfig((prev) => {
|
||||||
|
const next = { ...prev.federatedApps };
|
||||||
|
delete next[name];
|
||||||
|
return { ...prev, federatedApps: next };
|
||||||
|
});
|
||||||
|
setDirty(true);
|
||||||
|
setMessage(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
setSaving(true);
|
||||||
|
setMessage(null);
|
||||||
|
|
||||||
|
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' });
|
||||||
|
}
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const federatedAppsList = Object.entries(config.federatedApps);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Centralized identity and directory integration (SAML, LDAP, 2FA)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{dirty && (
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
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
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{message && (
|
||||||
|
<div
|
||||||
|
className={`text-sm rounded-md px-3 py-2 ${
|
||||||
|
message.type === 'success'
|
||||||
|
? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300'
|
||||||
|
: 'bg-destructive/10 text-destructive'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{message.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Section title="Enable VNCdirectory Integration">
|
||||||
|
<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>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
Turn on VNCdirectory integration for identity management, SSO, and directory services
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => toggleBool('enabled')}
|
||||||
|
className={`shrink-0 relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
|
||||||
|
config.enabled
|
||||||
|
? 'bg-primary'
|
||||||
|
: 'bg-muted-foreground/25 dark:bg-muted-foreground/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${
|
||||||
|
config.enabled ? 'translate-x-[18px]' : 'translate-x-[3px]'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{config.enabled && (
|
||||||
|
<>
|
||||||
|
<Section title="Connection">
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
<TextRow
|
||||||
|
label="VNCdirectory URL"
|
||||||
|
value={config.apiUrl}
|
||||||
|
onChange={(v) => updateField('apiUrl', v)}
|
||||||
|
placeholder="https://vncdirectory.example.com"
|
||||||
|
/>
|
||||||
|
<PasswordRow
|
||||||
|
label="API Key"
|
||||||
|
value={config.apiKey}
|
||||||
|
onChange={(v) => updateField('apiKey', v)}
|
||||||
|
placeholder="Enter API key"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="SAML / Identity Provider">
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
<ToggleRow
|
||||||
|
label="SAML Enabled"
|
||||||
|
description="Enable SAML single sign-on via VNCdirectory"
|
||||||
|
value={config.samlEnabled}
|
||||||
|
onChange={() => toggleBool('samlEnabled')}
|
||||||
|
/>
|
||||||
|
{config.samlEnabled && (
|
||||||
|
<>
|
||||||
|
<TextRow
|
||||||
|
label="Identity Provider URL"
|
||||||
|
value={config.samlIdpUrl}
|
||||||
|
onChange={(v) => updateField('samlIdpUrl', v)}
|
||||||
|
placeholder="https://idp.example.com/saml2/idp"
|
||||||
|
/>
|
||||||
|
<TextRow
|
||||||
|
label="Issuer Name (Entity ID)"
|
||||||
|
value={config.samlIssuer}
|
||||||
|
onChange={(v) => updateField('samlIssuer', v)}
|
||||||
|
placeholder="urn:example:vncmail"
|
||||||
|
/>
|
||||||
|
<div className="px-4 py-3 flex flex-col gap-2">
|
||||||
|
<label className="text-sm text-foreground">
|
||||||
|
Service Provider Certificate (X.509)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={config.samlSpCert}
|
||||||
|
onChange={(e) => updateField('samlSpCert', e.target.value)}
|
||||||
|
placeholder="-----BEGIN CERTIFICATE----- ... -----END CERTIFICATE-----"
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="LDAP Directory">
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
<ToggleRow
|
||||||
|
label="LDAP Enabled"
|
||||||
|
description="Query user directory via LDAP for contact lookups and authentication"
|
||||||
|
value={config.ldapEnabled}
|
||||||
|
onChange={() => toggleBool('ldapEnabled')}
|
||||||
|
/>
|
||||||
|
{config.ldapEnabled && (
|
||||||
|
<>
|
||||||
|
<TextRow
|
||||||
|
label="LDAP Server URI"
|
||||||
|
value={config.ldapUri}
|
||||||
|
onChange={(v) => updateField('ldapUri', v)}
|
||||||
|
placeholder="ldaps://ldap.example.com:636"
|
||||||
|
/>
|
||||||
|
<TextRow
|
||||||
|
label="Bind DN"
|
||||||
|
value={config.ldapBindDn}
|
||||||
|
onChange={(v) => updateField('ldapBindDn', v)}
|
||||||
|
placeholder="cn=readonly,dc=example,dc=com"
|
||||||
|
/>
|
||||||
|
<PasswordRow
|
||||||
|
label="Bind Password"
|
||||||
|
value={config.ldapBindPassword}
|
||||||
|
onChange={(v) => updateField('ldapBindPassword', v)}
|
||||||
|
placeholder="Enter LDAP bind password"
|
||||||
|
/>
|
||||||
|
<TextRow
|
||||||
|
label="Search Base"
|
||||||
|
value={config.ldapSearchBase}
|
||||||
|
onChange={(v) => updateField('ldapSearchBase', v)}
|
||||||
|
placeholder="ou=users,dc=example,dc=com"
|
||||||
|
/>
|
||||||
|
<SelectRow
|
||||||
|
label="LDAP Type"
|
||||||
|
value={config.ldapType}
|
||||||
|
options={[
|
||||||
|
{ value: 'openldap', label: 'OpenLDAP' },
|
||||||
|
{ value: 'ms-ad', label: 'Microsoft Active Directory' },
|
||||||
|
]}
|
||||||
|
onChange={(v) => updateField('ldapType', v as 'openldap' | 'ms-ad')}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Authentication">
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
<ToggleRow
|
||||||
|
label="Enforce 2FA/TOTP"
|
||||||
|
description="Require two-factor authentication for all users"
|
||||||
|
value={config.tfaEnabled}
|
||||||
|
onChange={() => toggleBool('tfaEnabled')}
|
||||||
|
/>
|
||||||
|
<ToggleRow
|
||||||
|
label="OpenID Connect (OIDC)"
|
||||||
|
description="Enable OIDC login alongside or instead of SAML"
|
||||||
|
value={config.oidcEnabled}
|
||||||
|
onChange={() => toggleBool('oidcEnabled')}
|
||||||
|
/>
|
||||||
|
{config.oidcEnabled && (
|
||||||
|
<>
|
||||||
|
<TextRow
|
||||||
|
label="OIDC Client ID"
|
||||||
|
value={config.oidcClientId}
|
||||||
|
onChange={(v) => updateField('oidcClientId', v)}
|
||||||
|
placeholder="vncmail-client"
|
||||||
|
/>
|
||||||
|
<TextRow
|
||||||
|
label="OIDC Discovery URL"
|
||||||
|
value={config.oidcDiscoveryUrl}
|
||||||
|
onChange={(v) => updateField('oidcDiscoveryUrl', v)}
|
||||||
|
placeholder="https://idp.example.com/.well-known/openid-configuration"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<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>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
How long SSO sessions remain valid. Default: 8 hours (28800)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={config.sessionTtl}
|
||||||
|
onChange={(e) => updateField('sessionTtl', Number(e.target.value))}
|
||||||
|
className="h-8 w-full sm:w-32 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>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Federated Applications">
|
||||||
|
<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.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{federatedAppsList.map(([appName, url]) => (
|
||||||
|
<div
|
||||||
|
key={appName}
|
||||||
|
className="flex flex-col sm:flex-row items-start sm:items-center gap-2"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={appName}
|
||||||
|
readOnly
|
||||||
|
className="h-8 w-full sm:w-36 rounded-md border border-input bg-muted/50 px-2.5 text-sm text-muted-foreground"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => setFederatedApp(appName, e.target.value)}
|
||||||
|
placeholder="https://vnc.example.com/auth/sso"
|
||||||
|
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}`}
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<AddFederatedApp
|
||||||
|
existingKeys={new Set(Object.keys(config.federatedApps))}
|
||||||
|
onAdd={(name, url) => setFederatedApp(name, url)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddFederatedApp({
|
||||||
|
existingKeys,
|
||||||
|
onAdd,
|
||||||
|
}: {
|
||||||
|
existingKeys: Set<string>;
|
||||||
|
onAdd: (name: string, url: string) => void;
|
||||||
|
}) {
|
||||||
|
const [adding, setAdding] = useState(false);
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [url, setUrl] = useState('');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
if (!adding) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAdding(true)}
|
||||||
|
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
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAdd() {
|
||||||
|
const trimmed = name.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
setError('Enter an application name');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!/^[a-zA-Z0-9_-]+$/.test(trimmed)) {
|
||||||
|
setError('Name must contain only letters, numbers, hyphens, and underscores');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (existingKeys.has(trimmed)) {
|
||||||
|
setError('An app with this name already exists');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!url.trim()) {
|
||||||
|
setError('Enter an SSO URL');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
onAdd(trimmed, url.trim());
|
||||||
|
setName('');
|
||||||
|
setUrl('');
|
||||||
|
setAdding(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
setAdding(false);
|
||||||
|
setName('');
|
||||||
|
setUrl('');
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
autoFocus
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => { setName(e.target.value); setError(null); }}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') handleAdd(); }}
|
||||||
|
placeholder="App name (e.g. vnctalk)"
|
||||||
|
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
|
||||||
|
type="url"
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => { setUrl(e.target.value); setError(null); }}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') handleAdd(); }}
|
||||||
|
placeholder="https://vnctalk.example.com/auth/sso"
|
||||||
|
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">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
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
|
||||||
|
</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
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error && <span className="text-xs text-destructive">{error}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<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">{title}</h2>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TextRow({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||||
|
<span className="text-sm text-foreground">{label}</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={value ?? ''}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PasswordRow({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
}) {
|
||||||
|
const isMasked = 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">
|
||||||
|
<span className="text-sm text-foreground">{label}</span>
|
||||||
|
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||||
|
<input
|
||||||
|
type={isMasked ? 'text' : 'password'}
|
||||||
|
value={value ?? ''}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToggleRow({
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
value: boolean;
|
||||||
|
onChange: () => void;
|
||||||
|
}) {
|
||||||
|
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="min-w-0">
|
||||||
|
<span className="text-sm text-foreground">{label}</span>
|
||||||
|
{description && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">{description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onChange}
|
||||||
|
className={`shrink-0 relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
|
||||||
|
value ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${
|
||||||
|
value ? 'translate-x-[18px]' : 'translate-x-[3px]'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectRow({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
options: { value: string; label: string }[];
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="px-4 py-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||||
|
<span className="text-sm text-foreground">{label}</span>
|
||||||
|
<select
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
className="h-8 rounded-md border border-input bg-background px-2.5 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
{options.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
Scale,
|
Scale,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
LogOut,
|
LogOut,
|
||||||
|
Key,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
Bot,
|
Bot,
|
||||||
Puzzle,
|
Puzzle,
|
||||||
@@ -55,6 +56,7 @@ const NAV_GROUPS: ReadonlyArray<{
|
|||||||
{ tab: 'settings', label: 'Settings', icon: Settings },
|
{ tab: 'settings', label: 'Settings', icon: Settings },
|
||||||
{ tab: 'branding', label: 'Branding', icon: Palette },
|
{ tab: 'branding', label: 'Branding', icon: Palette },
|
||||||
{ tab: 'auth', label: 'Authentication', icon: Shield },
|
{ tab: 'auth', label: 'Authentication', icon: Shield },
|
||||||
|
{ tab: 'vncdirectory', label: 'VNCdirectory', icon: Key },
|
||||||
{ tab: 'policy', label: 'Policy', icon: Scale },
|
{ tab: 'policy', label: 'Policy', icon: Scale },
|
||||||
{ tab: 'ai-policy', label: 'AI', icon: Bot },
|
{ tab: 'ai-policy', label: 'AI', icon: Bot },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { MarketplaceTab } from './_tabs/marketplace';
|
|||||||
import { VersionTab } from './_tabs/version';
|
import { VersionTab } from './_tabs/version';
|
||||||
import { TelemetryTab } from './_tabs/telemetry';
|
import { TelemetryTab } from './_tabs/telemetry';
|
||||||
import { LogsTab } from './_tabs/logs';
|
import { LogsTab } from './_tabs/logs';
|
||||||
|
import { VncDirectoryTab } from './_tabs/vncdirectory';
|
||||||
|
|
||||||
export default function AdminPage() {
|
export default function AdminPage() {
|
||||||
const activeTab = useAdminTabStore((s) => s.activeTab);
|
const activeTab = useAdminTabStore((s) => s.activeTab);
|
||||||
@@ -47,5 +48,6 @@ export default function AdminPage() {
|
|||||||
case 'version': return <VersionTab />;
|
case 'version': return <VersionTab />;
|
||||||
case 'telemetry': return <TelemetryTab />;
|
case 'telemetry': return <TelemetryTab />;
|
||||||
case 'logs': return <LogsTab />;
|
case 'logs': return <LogsTab />;
|
||||||
|
case 'vncdirectory': return <VncDirectoryTab />;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
redirect('/admin?tab=vncdirectory');
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
||||||
|
import { auditLog } from '@/lib/admin/audit';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
import {
|
||||||
|
getVncDirectoryConfig,
|
||||||
|
saveVncDirectoryConfig,
|
||||||
|
DEFAULT_VNCDIRECTORY_CONFIG,
|
||||||
|
VNCDIRECTORY_SENSITIVE_KEYS,
|
||||||
|
type VncDirectoryConfig,
|
||||||
|
} from '@/lib/admin/vncdirectory-config';
|
||||||
|
|
||||||
|
const VALID_LDAP_TYPES = new Set(['openldap', 'ms-ad']);
|
||||||
|
const KNOWN_KEYS = new Set(Object.keys(DEFAULT_VNCDIRECTORY_CONFIG));
|
||||||
|
|
||||||
|
function maskConfigForClient(config: VncDirectoryConfig): Record<string, unknown> {
|
||||||
|
const result: Record<string, unknown> = {};
|
||||||
|
for (const [key, value] of Object.entries(config)) {
|
||||||
|
if (VNCDIRECTORY_SENSITIVE_KEYS.has(key)) {
|
||||||
|
result[key] = typeof value === 'string' && value.length > 0 ? '••••••' : '';
|
||||||
|
} else {
|
||||||
|
result[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const result = await requireAdminAuth(request);
|
||||||
|
if ('error' in result) return result.error;
|
||||||
|
|
||||||
|
const config = await getVncDirectoryConfig();
|
||||||
|
return NextResponse.json(maskConfigForClient(config), {
|
||||||
|
headers: { 'Cache-Control': 'no-store' },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('VNCdirectory config read error', {
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
});
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const authResult = await requireAdminAuth(request);
|
||||||
|
if ('error' in authResult) return authResult.error;
|
||||||
|
|
||||||
|
const ip = getClientIP(request);
|
||||||
|
const body = await request.json();
|
||||||
|
|
||||||
|
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
||||||
|
return NextResponse.json({ error: 'Request body must be an object' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate known keys only
|
||||||
|
const unknownKeys = Object.keys(body).filter((k) => !KNOWN_KEYS.has(k));
|
||||||
|
if (unknownKeys.length > 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Unknown config keys: ${unknownKeys.join(', ')}` },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate boolean fields
|
||||||
|
const boolFields = ['enabled', 'samlEnabled', 'ldapEnabled', 'tfaEnabled', 'oidcEnabled'];
|
||||||
|
for (const key of boolFields) {
|
||||||
|
if (key in body && typeof body[key] !== 'boolean') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `${key} must be a boolean` },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate sessionTtl
|
||||||
|
if ('sessionTtl' in body) {
|
||||||
|
const ttl = Number(body.sessionTtl);
|
||||||
|
if (!Number.isFinite(ttl) || ttl < 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'sessionTtl must be a non-negative number' },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
body.sessionTtl = ttl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate ldapType
|
||||||
|
if ('ldapType' in body && !VALID_LDAP_TYPES.has(body.ldapType)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Invalid ldapType: ${body.ldapType}. Must be 'openldap' or 'ms-ad'.` },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate federatedApps
|
||||||
|
if ('federatedApps' in body) {
|
||||||
|
if (!body.federatedApps || typeof body.federatedApps !== 'object' || Array.isArray(body.federatedApps)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'federatedApps must be an object mapping app names to URLs' },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const [appName, url] of Object.entries(body.federatedApps as Record<string, unknown>)) {
|
||||||
|
if (typeof url !== 'string') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `federatedApps.${appName} must be a string URL` },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If apiKey or ldapBindPassword are "••••••", preserve existing value
|
||||||
|
const currentConfig = await getVncDirectoryConfig();
|
||||||
|
if (body.apiKey === '••••••') {
|
||||||
|
body.apiKey = currentConfig.apiKey;
|
||||||
|
}
|
||||||
|
if (body.ldapBindPassword === '••••••') {
|
||||||
|
body.ldapBindPassword = currentConfig.ldapBindPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
const changedKeys = Object.keys(body).filter((k) => {
|
||||||
|
const currentVal = currentConfig[k as keyof VncDirectoryConfig];
|
||||||
|
const newVal = body[k];
|
||||||
|
if (k === 'federatedApps') {
|
||||||
|
return JSON.stringify(currentVal) !== JSON.stringify(newVal);
|
||||||
|
}
|
||||||
|
return String(currentVal ?? '') !== String(newVal ?? '');
|
||||||
|
});
|
||||||
|
|
||||||
|
await saveVncDirectoryConfig(body as Partial<VncDirectoryConfig>);
|
||||||
|
|
||||||
|
if (changedKeys.length > 0) {
|
||||||
|
await auditLog('vncdirectory.update', { changedKeys }, ip);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('VNCdirectory config update error', {
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
});
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,17 @@ import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
|||||||
import { hasSessionSecret } from '@/lib/auth/session-secret';
|
import { hasSessionSecret } from '@/lib/auth/session-secret';
|
||||||
import { configManager } from '@/lib/admin/config-manager';
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
|
|
||||||
|
// TODO(P2.13): Wire SAML IDP integration once VNCdirectory is configured.
|
||||||
|
// When VNCdirectory is enabled and SAML is configured (see
|
||||||
|
// lib/admin/vncdirectory-config.ts), the SSO start flow should:
|
||||||
|
// 1. Check isVncDirectoryEnabled() — if false, fall through to existing OAuth flow.
|
||||||
|
// 2. Read getVncDirectoryConfig() for samlIdpUrl, samlIssuer, samlSpCert.
|
||||||
|
// 3. Build a SAML AuthnRequest and redirect to the IdP instead of OAuth.
|
||||||
|
// 4. The /sso/complete handler should process the SAML Response assertion,
|
||||||
|
// validate the signature against the SP certificate, extract the subject,
|
||||||
|
// and create a session.
|
||||||
|
// Reference: docs/admin/VNCDIRECTORY.md in the VNCmail+ plan (P2.13).
|
||||||
|
|
||||||
const SSO_PENDING_COOKIE = 'sso_pending';
|
const SSO_PENDING_COOKIE = 'sso_pending';
|
||||||
const SSO_PENDING_MAX_AGE = 300; // 5 minutes
|
const SSO_PENDING_MAX_AGE = 300; // 5 minutes
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getCollaboraEditUrl } from "@/lib/collabora/client";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
|
||||||
|
if (!body.fileId || !body.fileName) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Missing required fields: fileId, fileName" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = await getCollaboraEditUrl(
|
||||||
|
String(body.fileId),
|
||||||
|
String(body.fileName)
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({ url });
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
logger.error("Collabora edit URL failed", { error: message });
|
||||||
|
|
||||||
|
if (message.includes("not configured")) {
|
||||||
|
return NextResponse.json({ error: message }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { createVncMeeting } from "@/lib/vnctalk/client";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
|
||||||
|
function getClientIP(request: NextRequest): string {
|
||||||
|
const forwarded = request.headers.get("x-forwarded-for");
|
||||||
|
if (forwarded) return forwarded.split(",")[0].trim();
|
||||||
|
return "127.0.0.1";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
|
||||||
|
if (!body.name || !body.start || !body.end) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Missing required fields: name, start, end" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const invitees: string[] = Array.isArray(body.invitees) ? body.invitees : [];
|
||||||
|
|
||||||
|
const result = await createVncMeeting({
|
||||||
|
name: String(body.name),
|
||||||
|
start: String(body.start),
|
||||||
|
end: String(body.end),
|
||||||
|
invitees,
|
||||||
|
password: body.password ? String(body.password) : undefined,
|
||||||
|
description: body.description ? String(body.description) : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info("VNCtalk meeting created", {
|
||||||
|
meetingId: result.meetingId,
|
||||||
|
ip: getClientIP(request),
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(result, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
logger.error("VNCtalk meeting creation failed", { error: message });
|
||||||
|
|
||||||
|
if (message.includes("not configured")) {
|
||||||
|
return NextResponse.json({ error: message }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import { createPortal } from "react-dom";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
X, Clock, MapPin, Video, Users, Repeat, Bell, AlignLeft,
|
X, Clock, MapPin, Video, Users, Repeat, Bell, AlignLeft,
|
||||||
Pencil, Trash2, Copy, Send, Check,
|
Pencil, Trash2, Copy, Send, Check, ExternalLink, Globe,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { format, isSameDay } from "date-fns";
|
import { format, isSameDay } from "date-fns";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -107,6 +107,25 @@ function getRecurrenceLabel(event: CalendarEvent, t: ReturnType<typeof useTransl
|
|||||||
return buildRecurrenceSummary(event.recurrenceRules[0], t, locale);
|
return buildRecurrenceSummary(event.recurrenceRules[0], t, locale);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const URL_REGEX = /(https?:\/\/[^\s<]+[^\s<.,;:!?'")\]}>])/g;
|
||||||
|
|
||||||
|
function linkifyText(text: string): (string | { url: string })[] {
|
||||||
|
const parts: (string | { url: string })[] = [];
|
||||||
|
let lastIndex = 0;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
while ((match = URL_REGEX.exec(text)) !== null) {
|
||||||
|
if (match.index > lastIndex) {
|
||||||
|
parts.push(text.slice(lastIndex, match.index));
|
||||||
|
}
|
||||||
|
parts.push({ url: match[1] });
|
||||||
|
lastIndex = match.index + match[1].length;
|
||||||
|
}
|
||||||
|
if (lastIndex < text.length) {
|
||||||
|
parts.push(text.slice(lastIndex));
|
||||||
|
}
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
export function EventDetailPopover({
|
export function EventDetailPopover({
|
||||||
event,
|
event,
|
||||||
calendar,
|
calendar,
|
||||||
@@ -383,21 +402,34 @@ export function EventDetailPopover({
|
|||||||
{locationName && (
|
{locationName && (
|
||||||
<div className="flex items-start gap-2.5">
|
<div className="flex items-start gap-2.5">
|
||||||
<MapPin className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
<MapPin className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||||
{/^https?:\/\//i.test(locationName) ? (
|
<div className="min-w-0">
|
||||||
<a
|
{/^https?:\/\//i.test(locationName) ? (
|
||||||
href={locationName}
|
<a
|
||||||
target="_blank"
|
href={locationName}
|
||||||
rel="noreferrer"
|
target="_blank"
|
||||||
className="text-sm text-primary hover:underline truncate"
|
rel="noreferrer"
|
||||||
title={locationName}
|
className="text-sm text-primary hover:underline truncate block"
|
||||||
>
|
title={locationName}
|
||||||
{(() => {
|
>
|
||||||
try { return new URL(locationName).hostname; } catch { return locationName; }
|
{(() => {
|
||||||
})()}
|
try { return new URL(locationName).hostname; } catch { return locationName; }
|
||||||
</a>
|
})()}
|
||||||
) : (
|
</a>
|
||||||
<span className="text-sm text-foreground">{locationName}</span>
|
) : (
|
||||||
)}
|
<>
|
||||||
|
<span className="text-sm text-foreground">{locationName}</span>
|
||||||
|
<a
|
||||||
|
href={`https://maps.google.com/?q=${encodeURIComponent(locationName)}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-xs text-primary hover:underline mt-0.5 inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-3 h-3" />
|
||||||
|
View on Map
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -413,6 +445,8 @@ export function EventDetailPopover({
|
|||||||
title={virtualLocation}
|
title={virtualLocation}
|
||||||
>
|
>
|
||||||
{(() => {
|
{(() => {
|
||||||
|
const isVncMeeting = event.links?.["vnctalk-meeting"];
|
||||||
|
if (isVncMeeting) return "Join VNCtalk Meeting";
|
||||||
try {
|
try {
|
||||||
return new URL(virtualLocation).hostname;
|
return new URL(virtualLocation).hostname;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -423,6 +457,30 @@ export function EventDetailPopover({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* VNCtalk Meeting "Join" button (when meeting via links) */}
|
||||||
|
{!virtualLocation && event.links?.["vnctalk-meeting"] && (
|
||||||
|
<div className="flex items-start gap-2.5">
|
||||||
|
<Video className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||||
|
<a
|
||||||
|
href={event.links["vnctalk-meeting"].href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-sm text-primary hover:underline inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-3.5 h-3.5" />
|
||||||
|
Join VNCtalk Meeting
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Timezone */}
|
||||||
|
{!event.showWithoutTime && event.timeZone && (
|
||||||
|
<div className="flex items-start gap-2.5">
|
||||||
|
<Globe className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||||
|
<span className="text-sm text-muted-foreground">{event.timeZone}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Participants */}
|
{/* Participants */}
|
||||||
{hasParticipants && (
|
{hasParticipants && (
|
||||||
<div className="flex items-start gap-2.5">
|
<div className="flex items-start gap-2.5">
|
||||||
@@ -476,7 +534,21 @@ export function EventDetailPopover({
|
|||||||
<div className="flex items-start gap-2.5">
|
<div className="flex items-start gap-2.5">
|
||||||
<AlignLeft className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
<AlignLeft className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||||
<p className="text-sm text-muted-foreground whitespace-pre-line line-clamp-3">
|
<p className="text-sm text-muted-foreground whitespace-pre-line line-clamp-3">
|
||||||
{event.description}
|
{linkifyText(event.description).map((part, i) =>
|
||||||
|
typeof part === "string" ? (
|
||||||
|
<span key={i}>{part}</span>
|
||||||
|
) : (
|
||||||
|
<a
|
||||||
|
key={i}
|
||||||
|
href={part.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{part.url}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
|||||||
import { useTranslations, useLocale } from "next-intl";
|
import { useTranslations, useLocale } from "next-intl";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft, Plus, Eye, EyeOff } from "lucide-react";
|
import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft, Plus, Eye, EyeOff, ExternalLink, Reply, ReplyAll, Globe } from "lucide-react";
|
||||||
import { format, parseISO, addHours, addDays, isSameDay } from "date-fns";
|
import { format, parseISO, addHours, addDays, isSameDay } from "date-fns";
|
||||||
import type { CalendarEvent, Calendar, CalendarParticipant, CalendarEventAlert, CalendarRecurrenceRule } from "@/lib/jmap/types";
|
import type { CalendarEvent, Calendar, CalendarParticipant, CalendarEventAlert, CalendarRecurrenceRule } from "@/lib/jmap/types";
|
||||||
import { RecurrenceEditor, buildRecurrenceSummary, isSimpleRecurrenceRule } from "./recurrence-editor";
|
import { RecurrenceEditor, buildRecurrenceSummary, isSimpleRecurrenceRule } from "./recurrence-editor";
|
||||||
@@ -26,6 +26,8 @@ import { generateUUID } from "@/lib/utils";
|
|||||||
import { useFormatEventDate } from "@/hooks/use-format-event-date";
|
import { useFormatEventDate } from "@/hooks/use-format-event-date";
|
||||||
import { calendarHooks } from "@/lib/plugin-hooks";
|
import { calendarHooks } from "@/lib/plugin-hooks";
|
||||||
import type { ConflictWarning } from "@/lib/plugin-types";
|
import type { ConflictWarning } from "@/lib/plugin-types";
|
||||||
|
import { RecipientPopover } from "@/components/email/recipient-popover";
|
||||||
|
import { useProTabStore } from "@/stores/pro-tab-store";
|
||||||
|
|
||||||
export interface PendingEventPreview {
|
export interface PendingEventPreview {
|
||||||
start: Date;
|
start: Date;
|
||||||
@@ -64,6 +66,25 @@ function formatTimeInput(d: Date): string {
|
|||||||
return format(d, "HH:mm");
|
return format(d, "HH:mm");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const URL_REGEX = /(https?:\/\/[^\s<]+[^\s<.,;:!?'")\]}>])/g;
|
||||||
|
|
||||||
|
function linkifyText(text: string): (string | { url: string })[] {
|
||||||
|
const parts: (string | { url: string })[] = [];
|
||||||
|
let lastIndex = 0;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
while ((match = URL_REGEX.exec(text)) !== null) {
|
||||||
|
if (match.index > lastIndex) {
|
||||||
|
parts.push(text.slice(lastIndex, match.index));
|
||||||
|
}
|
||||||
|
parts.push({ url: match[1] });
|
||||||
|
lastIndex = match.index + match[1].length;
|
||||||
|
}
|
||||||
|
if (lastIndex < text.length) {
|
||||||
|
parts.push(text.slice(lastIndex));
|
||||||
|
}
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
function buildDuration(startDate: Date, endDate: Date): string {
|
function buildDuration(startDate: Date, endDate: Date): string {
|
||||||
const diffMs = endDate.getTime() - startDate.getTime();
|
const diffMs = endDate.getTime() - startDate.getTime();
|
||||||
const totalMinutes = Math.max(0, Math.floor(diffMs / 60000));
|
const totalMinutes = Math.max(0, Math.floor(diffMs / 60000));
|
||||||
@@ -354,6 +375,13 @@ export function EventModal({
|
|||||||
const [sendInvitations, setSendInvitations] = useState(true);
|
const [sendInvitations, setSendInvitations] = useState(true);
|
||||||
const [showFreeBusy, setShowFreeBusy] = useState(false);
|
const [showFreeBusy, setShowFreeBusy] = useState(false);
|
||||||
const participantInputRef = useRef<ParticipantInputHandle>(null);
|
const participantInputRef = useRef<ParticipantInputHandle>(null);
|
||||||
|
const [createVncMeeting, setCreateVncMeeting] = useState(false);
|
||||||
|
const [meetingCreating, setMeetingCreating] = useState(false);
|
||||||
|
const [timezone, setTimezone] = useState(() => {
|
||||||
|
if (event?.timeZone) return event.timeZone;
|
||||||
|
try { return Intl.DateTimeFormat().resolvedOptions().timeZone; } catch { return "UTC"; }
|
||||||
|
});
|
||||||
|
const openComposeTab = useProTabStore((s) => s.openComposeTab);
|
||||||
|
|
||||||
// Plugin transform: collect conflict warnings for the current event form.
|
// Plugin transform: collect conflict warnings for the current event form.
|
||||||
// Re-runs (debounced) whenever fields that affect scheduling change.
|
// Re-runs (debounced) whenever fields that affect scheduling change.
|
||||||
@@ -435,7 +463,7 @@ export function EventModal({
|
|||||||
duration = buildDuration(start, end);
|
duration = buildDuration(start, end);
|
||||||
}
|
}
|
||||||
|
|
||||||
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
const timeZone = timezone;
|
||||||
|
|
||||||
const data: Partial<CalendarEvent> = {
|
const data: Partial<CalendarEvent> = {
|
||||||
title: trimmedTitle,
|
title: trimmedTitle,
|
||||||
@@ -553,6 +581,54 @@ export function EventModal({
|
|||||||
data.organizerCalendarAddress = null;
|
data.organizerCalendarAddress = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// VNCtalk meeting creation
|
||||||
|
if (createVncMeeting && effectiveAttendees.length > 0 && !allDay) {
|
||||||
|
setMeetingCreating(true);
|
||||||
|
try {
|
||||||
|
const vncRes = await fetch("/api/vnctalk/meeting", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: trimmedTitle,
|
||||||
|
start: startStr,
|
||||||
|
end: allDay
|
||||||
|
? `${endDate}T23:59:59`
|
||||||
|
: `${endDate}T${endTime}:00`,
|
||||||
|
invitees: effectiveAttendees.map((a: { email: string }) => a.email),
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (vncRes.ok) {
|
||||||
|
const { meetingUrl, meetingId } = await vncRes.json();
|
||||||
|
data.virtualLocations = {
|
||||||
|
vl1: {
|
||||||
|
"@type": "VirtualLocation",
|
||||||
|
name: "VNCtalk Meeting",
|
||||||
|
description: `Meeting ID: ${meetingId}`,
|
||||||
|
uri: meetingUrl,
|
||||||
|
features: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
data.links = {
|
||||||
|
"vnctalk-meeting": {
|
||||||
|
"@type": "Link",
|
||||||
|
href: meetingUrl,
|
||||||
|
cid: meetingId,
|
||||||
|
contentType: null,
|
||||||
|
size: null,
|
||||||
|
rel: "vnctalk-meeting",
|
||||||
|
display: null,
|
||||||
|
title: "VNCtalk Meeting",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to create VNCtalk meeting:", err);
|
||||||
|
} finally {
|
||||||
|
setMeetingCreating(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const shouldSendScheduling = effectiveAttendees.length > 0 && sendInvitations;
|
const shouldSendScheduling = effectiveAttendees.length > 0 && sendInvitations;
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
@@ -560,7 +636,7 @@ export function EventModal({
|
|||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
}, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, customRule, alertRows, attendees, sendInvitations, currentUserEmails, existingParticipants, event, onSave, isSaving]);
|
}, [title, description, location, virtualLocation, startDate, startTime, endDate, endTime, allDay, calendarId, recurrence, customRule, alertRows, attendees, sendInvitations, currentUserEmails, existingParticipants, event, onSave, isSaving, createVncMeeting, timezone]);
|
||||||
|
|
||||||
const handleRsvp = useCallback((status: CalendarParticipant['participationStatus']) => {
|
const handleRsvp = useCallback((status: CalendarParticipant['participationStatus']) => {
|
||||||
if (!event || !userParticipantId || !onRsvp) return;
|
if (!event || !userParticipantId || !onRsvp) return;
|
||||||
@@ -594,6 +670,30 @@ export function EventModal({
|
|||||||
onDuplicate(data);
|
onDuplicate(data);
|
||||||
}, [event, onDuplicate]);
|
}, [event, onDuplicate]);
|
||||||
|
|
||||||
|
const handleReply = useCallback((replyAll: boolean) => {
|
||||||
|
if (!event) return;
|
||||||
|
const participants = getParticipantList(event);
|
||||||
|
const recipientEmails = replyAll
|
||||||
|
? participants.map((p) => ({ email: p.email, name: p.name }))
|
||||||
|
: (() => {
|
||||||
|
const org = participants.find((p) => p.isOrganizer);
|
||||||
|
return org ? [{ email: org.email, name: org.name }] : [];
|
||||||
|
})();
|
||||||
|
if (recipientEmails.length === 0) return;
|
||||||
|
openComposeTab({
|
||||||
|
sessionId: Date.now(),
|
||||||
|
mode: replyAll ? "replyAll" : "reply",
|
||||||
|
title: `Re: ${event.title}`,
|
||||||
|
replyTo: {
|
||||||
|
subject: `Re: ${event.title}`,
|
||||||
|
to: recipientEmails,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [event, openComposeTab]);
|
||||||
|
|
||||||
|
const handleReplyAll = useCallback(() => handleReply(true), [handleReply]);
|
||||||
|
const handleReplySingle = useCallback(() => handleReply(false), [handleReply]);
|
||||||
|
|
||||||
const modalRef = useRef<HTMLDivElement>(null);
|
const modalRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -874,22 +974,57 @@ export function EventModal({
|
|||||||
{locationName && (
|
{locationName && (
|
||||||
<div className="flex items-start gap-2.5">
|
<div className="flex items-start gap-2.5">
|
||||||
<MapPin className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
<MapPin className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||||
{/^https?:\/\//i.test(locationName) ? (
|
<div className="min-w-0">
|
||||||
<a href={locationName} target="_blank" rel="noreferrer" className="text-sm text-primary hover:underline truncate" title={locationName}>
|
{/^https?:\/\//i.test(locationName) ? (
|
||||||
{(() => { try { return new URL(locationName).hostname; } catch { return locationName; } })()}
|
<a href={locationName} target="_blank" rel="noreferrer" className="text-sm text-primary hover:underline truncate block" title={locationName}>
|
||||||
</a>
|
{(() => { try { return new URL(locationName).hostname; } catch { return locationName; } })()}
|
||||||
) : (
|
</a>
|
||||||
<span className="text-sm text-foreground">{locationName}</span>
|
) : (
|
||||||
)}
|
<>
|
||||||
|
<span className="text-sm text-foreground">{locationName}</span>
|
||||||
|
<a
|
||||||
|
href={`https://maps.google.com/?q=${encodeURIComponent(locationName)}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-xs text-primary hover:underline mt-0.5 inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-3 h-3" />
|
||||||
|
View on Map
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Virtual Location */}
|
{/* Virtual Location / Meeting Link */}
|
||||||
{virtualLoc && (
|
{virtualLoc && (
|
||||||
<div className="flex items-start gap-2.5">
|
<div className="flex items-start gap-2.5">
|
||||||
<Video className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
<Video className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||||
<a href={virtualLoc} target="_blank" rel="noreferrer" className="text-sm text-primary hover:underline truncate" title={virtualLoc}>
|
<div className="min-w-0">
|
||||||
{(() => { try { return new URL(virtualLoc).hostname; } catch { return virtualLoc; } })()}
|
<a href={virtualLoc} target="_blank" rel="noreferrer" className="text-sm text-primary hover:underline truncate block" title={virtualLoc}>
|
||||||
|
{(() => {
|
||||||
|
const isVncMeeting = event.links?.["vnctalk-meeting"];
|
||||||
|
if (isVncMeeting) return "Join VNCtalk Meeting";
|
||||||
|
try { return new URL(virtualLoc).hostname; } catch { return virtualLoc; }
|
||||||
|
})()}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* VNCtalk Meeting "Join" button (when meeting via links) */}
|
||||||
|
{!virtualLoc && event.links?.["vnctalk-meeting"] && (
|
||||||
|
<div className="flex items-start gap-2.5">
|
||||||
|
<Video className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||||
|
<a
|
||||||
|
href={event.links["vnctalk-meeting"].href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-sm text-primary hover:underline inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-3.5 h-3.5" />
|
||||||
|
Join VNCtalk Meeting
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -906,7 +1041,7 @@ export function EventModal({
|
|||||||
{viewParticipants.map((p) => (
|
{viewParticipants.map((p) => (
|
||||||
<div key={p.id} className="flex items-center justify-between gap-2 text-xs">
|
<div key={p.id} className="flex items-center justify-between gap-2 text-xs">
|
||||||
<span className="truncate text-foreground">
|
<span className="truncate text-foreground">
|
||||||
{p.name || p.email}
|
<RecipientPopover name={p.name} email={p.email} />
|
||||||
{p.isOrganizer && (
|
{p.isOrganizer && (
|
||||||
<span className="text-muted-foreground ms-1">({t("participants.organizer").toLowerCase()})</span>
|
<span className="text-muted-foreground ms-1">({t("participants.organizer").toLowerCase()})</span>
|
||||||
)}
|
)}
|
||||||
@@ -919,6 +1054,14 @@ export function EventModal({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Timezone */}
|
||||||
|
{!event.showWithoutTime && event.timeZone && (
|
||||||
|
<div className="flex items-start gap-2.5">
|
||||||
|
<Globe className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||||
|
<span className="text-sm text-muted-foreground">{event.timeZone}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Recurrence */}
|
{/* Recurrence */}
|
||||||
{recurrenceLabel && (
|
{recurrenceLabel && (
|
||||||
<div className="flex items-start gap-2.5">
|
<div className="flex items-start gap-2.5">
|
||||||
@@ -939,7 +1082,23 @@ export function EventModal({
|
|||||||
{event.description && (
|
{event.description && (
|
||||||
<div className="flex items-start gap-2.5">
|
<div className="flex items-start gap-2.5">
|
||||||
<AlignLeft className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
<AlignLeft className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||||
<p className="text-sm text-muted-foreground whitespace-pre-line">{event.description}</p>
|
<p className="text-sm text-muted-foreground whitespace-pre-line">
|
||||||
|
{linkifyText(event.description).map((part, i) =>
|
||||||
|
typeof part === "string" ? (
|
||||||
|
<span key={i}>{part}</span>
|
||||||
|
) : (
|
||||||
|
<a
|
||||||
|
key={i}
|
||||||
|
href={part.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{part.url}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -972,6 +1131,18 @@ export function EventModal({
|
|||||||
{t("events.duplicate")}
|
{t("events.duplicate")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{hasParticipants && !showDeleteConfirm && (
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" size="sm" onClick={handleReplySingle} aria-label="Reply to organizer">
|
||||||
|
<Reply className="w-4 h-4 me-1" />
|
||||||
|
Reply
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={handleReplyAll} aria-label="Reply All">
|
||||||
|
<ReplyAll className="w-4 h-4 me-1" />
|
||||||
|
Reply All
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!showDeleteConfirm && (
|
{!showDeleteConfirm && (
|
||||||
<Button onClick={() => setMode("edit")}>
|
<Button onClick={() => setMode("edit")}>
|
||||||
@@ -1061,6 +1232,21 @@ export function EventModal({
|
|||||||
setVirtualLocation,
|
setVirtualLocation,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{attendees.length > 0 && !allDay && (
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="createVncMeeting"
|
||||||
|
checked={createVncMeeting}
|
||||||
|
onChange={(e) => setCreateVncMeeting(e.target.checked)}
|
||||||
|
className="rounded border-input"
|
||||||
|
disabled={meetingCreating}
|
||||||
|
/>
|
||||||
|
<label htmlFor="createVncMeeting" className="text-sm">
|
||||||
|
{meetingCreating ? "Creating meeting..." : "Create VNCtalk Meeting"}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -1178,6 +1364,32 @@ export function EventModal({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!allDay && (
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium mb-1 block">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<Globe className="w-4 h-4" />
|
||||||
|
Timezone
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={timezone}
|
||||||
|
onChange={(e) => setTimezone(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
>
|
||||||
|
{(() => {
|
||||||
|
try {
|
||||||
|
return Intl.supportedValuesOf("timeZone");
|
||||||
|
} catch {
|
||||||
|
return [timezone || "UTC"];
|
||||||
|
}
|
||||||
|
})().map((tz: string) => (
|
||||||
|
<option key={tz} value={tz}>{tz}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{pluginConflictWarnings.length > 0 && (
|
{pluginConflictWarnings.length > 0 && (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{pluginConflictWarnings.map(w => (
|
{pluginConflictWarnings.map(w => (
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
AlertCircle, Star, Clock, FolderUp,
|
AlertCircle, Star, Clock, FolderUp,
|
||||||
FileArchive, FileSpreadsheet, Presentation, FileCode,
|
FileArchive, FileSpreadsheet, Presentation, FileCode,
|
||||||
Box, PenTool, Terminal as TerminalIcon, Database, Type as TypeIcon,
|
Box, PenTool, Terminal as TerminalIcon, Database, Type as TypeIcon,
|
||||||
Menu, Users, Share2, MailPlus, Paperclip,
|
Menu, Users, Share2, MailPlus, Paperclip, ExternalLink,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useIsDesktop } from "@/hooks/use-media-query";
|
import { useIsDesktop } from "@/hooks/use-media-query";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -208,6 +208,14 @@ function isDatabaseFile(name: string): boolean {
|
|||||||
return DATABASE_EXTENSIONS.has(ext);
|
return DATABASE_EXTENSIONS.has(ext);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const OFFICE_EXTENSIONS = new Set([
|
||||||
|
"docx", "xlsx", "pptx", "odt", "ods", "odp", "doc", "xls", "ppt",
|
||||||
|
]);
|
||||||
|
function isOfficeFile(name: string): boolean {
|
||||||
|
const ext = name.split(".").pop()?.toLowerCase() || "";
|
||||||
|
return OFFICE_EXTENSIONS.has(ext);
|
||||||
|
}
|
||||||
|
|
||||||
function isPreviewable(name: string): boolean {
|
function isPreviewable(name: string): boolean {
|
||||||
return isImageFile(name) || isTextFile(name) || isPdfFile(name) || isAudioFile(name) || isVideoFile(name);
|
return isImageFile(name) || isTextFile(name) || isPdfFile(name) || isAudioFile(name) || isVideoFile(name);
|
||||||
}
|
}
|
||||||
@@ -1075,6 +1083,33 @@ export function FileBrowser({
|
|||||||
{t("send_as_attachment")} {fileNames.length > 1 && `(${fileNames.length})`}
|
{t("send_as_attachment")} {fileNames.length > 1 && `(${fileNames.length})`}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{!showBatch && hasFiles && fileNames.length === 1 && isOfficeFile(fileNames[0]) && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8"
|
||||||
|
onClick={async () => {
|
||||||
|
const file = resources.find((r) => r.name === fileNames[0]);
|
||||||
|
if (!file) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/collabora/edit", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ fileId: file.id, fileName: file.name }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const { url } = await res.json();
|
||||||
|
window.open(url, "_blank", "noopener,noreferrer");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Collabora edit failed:", err);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil className="w-4 h-4 me-1" />
|
||||||
|
Edit with Collabora
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
@@ -1797,6 +1832,32 @@ export function FileBrowser({
|
|||||||
{t("download")}
|
{t("download")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{!resources.find(r => r.name === contextMenu.name)?.isDirectory && isOfficeFile(contextMenu.name) && (
|
||||||
|
<button
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
|
||||||
|
onClick={async () => {
|
||||||
|
const file = resources.find((r) => r.name === contextMenu.name);
|
||||||
|
if (!file) { setContextMenu(null); return; }
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/collabora/edit", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ fileId: file.id, fileName: file.name }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const { url } = await res.json();
|
||||||
|
window.open(url, "_blank", "noopener,noreferrer");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Collabora edit failed:", err);
|
||||||
|
}
|
||||||
|
setContextMenu(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-4 h-4" />
|
||||||
|
Edit with Collabora
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
+6
-1
@@ -236,10 +236,15 @@ export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: strin
|
|||||||
logLevel: { envVar: 'LOG_LEVEL', type: 'enum', defaultValue: 'info', enumValues: ['error', 'warn', 'info', 'debug'] },
|
logLevel: { envVar: 'LOG_LEVEL', type: 'enum', defaultValue: 'info', enumValues: ['error', 'warn', 'info', 'debug'] },
|
||||||
sessionSecret: { envVar: 'SESSION_SECRET', fileEnvVar: 'SESSION_SECRET_FILE', type: 'string', defaultValue: '' },
|
sessionSecret: { envVar: 'SESSION_SECRET', fileEnvVar: 'SESSION_SECRET_FILE', type: 'string', defaultValue: '' },
|
||||||
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: '' },
|
||||||
|
collaboraServerUrl: { envVar: 'COLLABORA_SERVER_URL', type: 'url', defaultValue: '' },
|
||||||
|
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 },
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 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']);
|
export const SENSITIVE_CONFIG_KEYS = new Set(['oauthClientSecret', 'sessionSecret', 'vncdirectoryApiKey', 'vncdirectoryLdapPassword']);
|
||||||
|
|
||||||
/** 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,117 @@
|
|||||||
|
import { readFile, writeFile, rename } from 'node:fs/promises';
|
||||||
|
import { ensureStateDir, getStatePath } from '@/lib/admin/paths';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
|
export interface VncDirectoryConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
apiUrl: string;
|
||||||
|
apiKey: string;
|
||||||
|
samlEnabled: boolean;
|
||||||
|
samlIdpUrl: string;
|
||||||
|
samlSpCert: string;
|
||||||
|
samlIssuer: string;
|
||||||
|
ldapEnabled: boolean;
|
||||||
|
ldapUri: string;
|
||||||
|
ldapBindDn: string;
|
||||||
|
ldapBindPassword: string;
|
||||||
|
ldapSearchBase: string;
|
||||||
|
ldapType: 'openldap' | 'ms-ad';
|
||||||
|
tfaEnabled: boolean;
|
||||||
|
oidcEnabled: boolean;
|
||||||
|
oidcClientId: string;
|
||||||
|
oidcDiscoveryUrl: string;
|
||||||
|
sessionTtl: number;
|
||||||
|
federatedApps: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_VNCDIRECTORY_CONFIG: VncDirectoryConfig = {
|
||||||
|
enabled: false,
|
||||||
|
apiUrl: '',
|
||||||
|
apiKey: '',
|
||||||
|
samlEnabled: false,
|
||||||
|
samlIdpUrl: '',
|
||||||
|
samlSpCert: '',
|
||||||
|
samlIssuer: '',
|
||||||
|
ldapEnabled: false,
|
||||||
|
ldapUri: '',
|
||||||
|
ldapBindDn: '',
|
||||||
|
ldapBindPassword: '',
|
||||||
|
ldapSearchBase: '',
|
||||||
|
ldapType: 'openldap',
|
||||||
|
tfaEnabled: false,
|
||||||
|
oidcEnabled: false,
|
||||||
|
oidcClientId: '',
|
||||||
|
oidcDiscoveryUrl: '',
|
||||||
|
sessionTtl: 28800,
|
||||||
|
federatedApps: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Keys that should be masked when returning config to clients */
|
||||||
|
export const VNCDIRECTORY_SENSITIVE_KEYS = new Set(['apiKey', 'ldapBindPassword']);
|
||||||
|
|
||||||
|
function applyEnvOverrides(config: VncDirectoryConfig): VncDirectoryConfig {
|
||||||
|
const envEnabled = process.env.VNCDIRECTORY_ENABLED;
|
||||||
|
if (envEnabled !== undefined) {
|
||||||
|
config.enabled = envEnabled === 'true' || envEnabled === '1';
|
||||||
|
}
|
||||||
|
const envApiUrl = process.env.VNCDIRECTORY_API_URL;
|
||||||
|
if (envApiUrl !== undefined) {
|
||||||
|
config.apiUrl = envApiUrl;
|
||||||
|
}
|
||||||
|
const envSamlEnabled = process.env.VNCDIRECTORY_SAML_ENABLED;
|
||||||
|
if (envSamlEnabled !== undefined) {
|
||||||
|
config.samlEnabled = envSamlEnabled === 'true' || envSamlEnabled === '1';
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJsonFile(filename: string): Promise<Record<string, unknown> | null> {
|
||||||
|
const filePath = getStatePath(filename);
|
||||||
|
try {
|
||||||
|
const raw = await readFile(filePath, 'utf-8');
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
|
||||||
|
logger.warn(`Failed to read ${filename} from state dir`, {
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeJsonFile(filename: string, data: Record<string, unknown>): Promise<void> {
|
||||||
|
await ensureStateDir();
|
||||||
|
const targetPath = getStatePath(filename);
|
||||||
|
const tmpPath = targetPath + '.tmp';
|
||||||
|
await writeFile(tmpPath, JSON.stringify(data, null, 2), 'utf-8');
|
||||||
|
await rename(tmpPath, targetPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getVncDirectoryConfig(): Promise<VncDirectoryConfig> {
|
||||||
|
const fileConfig = await readJsonFile('vncdirectory.json');
|
||||||
|
const base = fileConfig
|
||||||
|
? { ...DEFAULT_VNCDIRECTORY_CONFIG, ...fileConfig }
|
||||||
|
: { ...DEFAULT_VNCDIRECTORY_CONFIG };
|
||||||
|
return applyEnvOverrides(base);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveVncDirectoryConfig(
|
||||||
|
config: Partial<VncDirectoryConfig>,
|
||||||
|
): Promise<void> {
|
||||||
|
const current = await getVncDirectoryConfig();
|
||||||
|
const merged: Record<string, unknown> = {};
|
||||||
|
for (const key of Object.keys(DEFAULT_VNCDIRECTORY_CONFIG)) {
|
||||||
|
const k = key as keyof VncDirectoryConfig;
|
||||||
|
if (k in config) {
|
||||||
|
merged[key] = config[k];
|
||||||
|
} else {
|
||||||
|
merged[key] = current[k];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await writeJsonFile('vncdirectory.json', merged);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isVncDirectoryEnabled(): Promise<boolean> {
|
||||||
|
const cfg = await getVncDirectoryConfig();
|
||||||
|
return cfg.enabled;
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { configManager } from "@/lib/admin/config-manager";
|
||||||
|
|
||||||
|
export async function getCollaboraEditUrl(
|
||||||
|
fileId: string,
|
||||||
|
fileName: string
|
||||||
|
): Promise<string> {
|
||||||
|
const serverUrl =
|
||||||
|
configManager.get<string>("collaboraServerUrl") ||
|
||||||
|
process.env.COLLABORA_SERVER_URL ||
|
||||||
|
"";
|
||||||
|
|
||||||
|
if (!serverUrl) {
|
||||||
|
throw new Error("COLLABORA_SERVER_URL is not configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = serverUrl.replace(/\/+$/, "");
|
||||||
|
const fileExt = fileName.split(".").pop()?.toLowerCase() || "";
|
||||||
|
|
||||||
|
// Collabora WOPI host discovery endpoint
|
||||||
|
const response = await fetch(`${base}/hosting/discovery`, {
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Collabora discovery failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const discovery = await response.json();
|
||||||
|
|
||||||
|
// Find the WOPI action URL for the file extension
|
||||||
|
let actionUrl: string | null = null;
|
||||||
|
const mimeMap: Record<string, string> = {
|
||||||
|
docx: "text",
|
||||||
|
doc: "text",
|
||||||
|
odt: "text",
|
||||||
|
xlsx: "spreadsheet",
|
||||||
|
xls: "spreadsheet",
|
||||||
|
ods: "spreadsheet",
|
||||||
|
pptx: "presentation",
|
||||||
|
ppt: "presentation",
|
||||||
|
odp: "presentation",
|
||||||
|
};
|
||||||
|
const docType = mimeMap[fileExt] || "text";
|
||||||
|
|
||||||
|
if (discovery.net?.zone) {
|
||||||
|
const zones = Array.isArray(discovery.net.zone)
|
||||||
|
? discovery.net.zone
|
||||||
|
: [discovery.net.zone];
|
||||||
|
for (const zone of zones) {
|
||||||
|
const apps = Array.isArray(zone.app) ? zone.app : zone.app ? [zone.app] : [];
|
||||||
|
for (const app of apps) {
|
||||||
|
if (
|
||||||
|
app.name &&
|
||||||
|
docType &&
|
||||||
|
app.name.toLowerCase().includes(docType.toLowerCase())
|
||||||
|
) {
|
||||||
|
const actions = Array.isArray(app.action)
|
||||||
|
? app.action
|
||||||
|
: app.action
|
||||||
|
? [app.action]
|
||||||
|
: [];
|
||||||
|
for (const action of actions) {
|
||||||
|
if (action.name === "edit" && action.urlsrc) {
|
||||||
|
actionUrl = action.urlsrc;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (actionUrl) break;
|
||||||
|
}
|
||||||
|
if (actionUrl) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!actionUrl) {
|
||||||
|
// Fallback: construct URL manually
|
||||||
|
actionUrl = `${base}/loleaflet/dist/loleaflet.html`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 wopiSrcUrl = `${actionUrl}?WOPISrc=${encodeURIComponent(
|
||||||
|
`${process.env.NEXT_PUBLIC_APP_URL || `http://localhost:${process.env.PORT || 3000}`}/api/collabora/wopi/files/${encodeURIComponent(fileId)}`
|
||||||
|
)}`;
|
||||||
|
|
||||||
|
return wopiSrcUrl;
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { configManager } from "@/lib/admin/config-manager";
|
||||||
|
|
||||||
|
export interface CreateVncMeetingParams {
|
||||||
|
name: string;
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
invitees: string[];
|
||||||
|
password?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateVncMeetingResult {
|
||||||
|
meetingUrl: string;
|
||||||
|
meetingId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createVncMeeting(
|
||||||
|
params: CreateVncMeetingParams
|
||||||
|
): Promise<CreateVncMeetingResult> {
|
||||||
|
const serverUrl = configManager.get<string>("vnctalkServerUrl") || process.env.VNCTALK_SERVER_URL || "";
|
||||||
|
|
||||||
|
if (!serverUrl) {
|
||||||
|
throw new Error("VNCTALK_SERVER_URL is not configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
const endpoint = `${serverUrl.replace(/\/+$/, "")}/api/createnewmeeting`;
|
||||||
|
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: params.name,
|
||||||
|
start: params.start,
|
||||||
|
end: params.end,
|
||||||
|
invitees: params.invitees,
|
||||||
|
password: params.password,
|
||||||
|
description: params.description,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text().catch(() => "");
|
||||||
|
throw new Error(`VNCtalk API error ${response.status}: ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
const meetingUrl: string = data.meetingUrl || data.meeting_url || data.url || "";
|
||||||
|
const meetingId: string = data.meetingId || data.meeting_id || data.id || "";
|
||||||
|
|
||||||
|
if (!meetingUrl) {
|
||||||
|
throw new Error("VNCtalk API did not return a meeting URL");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { meetingUrl, meetingId };
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ export const ADMIN_TABS = [
|
|||||||
'settings',
|
'settings',
|
||||||
'branding',
|
'branding',
|
||||||
'auth',
|
'auth',
|
||||||
|
'vncdirectory',
|
||||||
'policy',
|
'policy',
|
||||||
'ai-policy',
|
'ai-policy',
|
||||||
'plugins',
|
'plugins',
|
||||||
|
|||||||
Reference in New Issue
Block a user