feat: multi-server JMAP support
This commit is contained in:
@@ -0,0 +1,272 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Plus, Trash2, RotateCcw, ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import type { JmapServerEntry } from '@/lib/admin/jmap-servers';
|
||||
|
||||
interface Props {
|
||||
value: JmapServerEntry[];
|
||||
source?: string;
|
||||
onChange: (next: JmapServerEntry[]) => void;
|
||||
onRevert: () => void;
|
||||
}
|
||||
|
||||
interface RowDraft {
|
||||
id: string;
|
||||
label: string;
|
||||
url: string;
|
||||
domains: string;
|
||||
oauthClientId: string;
|
||||
oauthIssuerUrl: string;
|
||||
oauthClientSecret: string;
|
||||
oauthExpanded: boolean;
|
||||
}
|
||||
|
||||
function entryToDraft(e: JmapServerEntry): RowDraft {
|
||||
return {
|
||||
id: e.id,
|
||||
label: e.label,
|
||||
url: e.url,
|
||||
domains: (e.domains ?? []).join(', '),
|
||||
oauthClientId: e.oauth?.clientId ?? '',
|
||||
oauthIssuerUrl: e.oauth?.issuerUrl ?? '',
|
||||
oauthClientSecret: e.oauth?.clientSecret ?? '',
|
||||
oauthExpanded: !!(e.oauth && (e.oauth.clientId || e.oauth.issuerUrl || e.oauth.clientSecret)),
|
||||
};
|
||||
}
|
||||
|
||||
function draftToEntry(d: RowDraft): JmapServerEntry | null {
|
||||
const id = d.id.trim();
|
||||
const url = d.url.trim().replace(/\/+$/, '');
|
||||
if (!id || !url) return null;
|
||||
const domains = d.domains
|
||||
.split(/[,\s]+/)
|
||||
.map((s) => s.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
const clientId = d.oauthClientId.trim();
|
||||
const issuerUrl = d.oauthIssuerUrl.trim().replace(/\/+$/, '');
|
||||
const clientSecret = d.oauthClientSecret;
|
||||
const oauth = clientId || issuerUrl || clientSecret
|
||||
? {
|
||||
...(clientId ? { clientId } : {}),
|
||||
...(issuerUrl ? { issuerUrl } : {}),
|
||||
...(clientSecret ? { clientSecret } : {}),
|
||||
}
|
||||
: undefined;
|
||||
return {
|
||||
id,
|
||||
label: d.label.trim() || id,
|
||||
url,
|
||||
...(domains.length > 0 ? { domains } : {}),
|
||||
...(oauth ? { oauth } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function emptyDraft(): RowDraft {
|
||||
return {
|
||||
id: '',
|
||||
label: '',
|
||||
url: '',
|
||||
domains: '',
|
||||
oauthClientId: '',
|
||||
oauthIssuerUrl: '',
|
||||
oauthClientSecret: '',
|
||||
oauthExpanded: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function JmapServersSection({ value, source, onChange, onRevert }: Props) {
|
||||
const [drafts, setDrafts] = useState<RowDraft[]>(() => value.map(entryToDraft));
|
||||
|
||||
useEffect(() => {
|
||||
// Re-sync from props when the underlying config value changes (e.g. revert,
|
||||
// initial load). Skip when drafts already represent the same array to avoid
|
||||
// clobbering in-progress edits.
|
||||
setDrafts((prev) => {
|
||||
if (prev.length === value.length) {
|
||||
const same = prev.every((d, i) => {
|
||||
const e = value[i];
|
||||
return d.id === e.id && d.url === e.url && d.label === e.label;
|
||||
});
|
||||
if (same) return prev;
|
||||
}
|
||||
return value.map(entryToDraft);
|
||||
});
|
||||
}, [value]);
|
||||
|
||||
function commit(next: RowDraft[]) {
|
||||
setDrafts(next);
|
||||
const entries: JmapServerEntry[] = [];
|
||||
for (const d of next) {
|
||||
const e = draftToEntry(d);
|
||||
if (e) entries.push(e);
|
||||
}
|
||||
onChange(entries);
|
||||
}
|
||||
|
||||
function update(idx: number, patch: Partial<RowDraft>) {
|
||||
commit(drafts.map((d, i) => (i === idx ? { ...d, ...patch } : d)));
|
||||
}
|
||||
|
||||
function remove(idx: number) {
|
||||
commit(drafts.filter((_, i) => i !== idx));
|
||||
}
|
||||
|
||||
function add() {
|
||||
setDrafts((prev) => [...prev, emptyDraft()]);
|
||||
// Don't commit yet - new row needs id+url before it counts.
|
||||
}
|
||||
|
||||
const ids = new Set<string>();
|
||||
const duplicateIdx = new Set<number>();
|
||||
drafts.forEach((d, i) => {
|
||||
const id = d.id.trim();
|
||||
if (!id) return;
|
||||
if (ids.has(id)) duplicateIdx.add(i);
|
||||
ids.add(id);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="px-4 py-3 space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-foreground">Servers</span>
|
||||
{source && source !== 'default' && (
|
||||
<span className={`text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded ${source === 'admin' ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'}`}>
|
||||
{source}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Each entry appears as an option on the login dropdown. Leave the list empty to fall back to the single <code className="text-[11px]">JMAP Server URL</code> above.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{source === 'admin' && (
|
||||
<button
|
||||
onClick={onRevert}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
title="Revert to default"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={add}
|
||||
className="inline-flex items-center gap-1.5 h-8 px-2.5 rounded-md border border-input bg-background text-xs text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Add server
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{drafts.length === 0 && (
|
||||
<div className="text-xs text-muted-foreground italic">No servers configured.</div>
|
||||
)}
|
||||
|
||||
{drafts.map((d, i) => {
|
||||
const isDuplicate = duplicateIdx.has(i);
|
||||
return (
|
||||
<div key={i} className="rounded-md border border-border bg-muted/20 p-3 space-y-2">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-12 gap-2 items-start">
|
||||
<div className="sm:col-span-3">
|
||||
<label className="block text-[11px] font-medium text-muted-foreground mb-1">ID</label>
|
||||
<input
|
||||
type="text"
|
||||
value={d.id}
|
||||
onChange={(e) => update(i, { id: e.target.value })}
|
||||
placeholder="main"
|
||||
className={`h-8 w-full rounded-md border bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${isDuplicate ? 'border-destructive' : 'border-input'}`}
|
||||
/>
|
||||
{isDuplicate && <p className="text-[10px] text-destructive mt-0.5">Duplicate id</p>}
|
||||
</div>
|
||||
<div className="sm:col-span-4">
|
||||
<label className="block text-[11px] font-medium text-muted-foreground mb-1">Label</label>
|
||||
<input
|
||||
type="text"
|
||||
value={d.label}
|
||||
onChange={(e) => update(i, { label: e.target.value })}
|
||||
placeholder="Main server"
|
||||
className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-5">
|
||||
<label className="block text-[11px] font-medium text-muted-foreground mb-1">JMAP URL</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="url"
|
||||
value={d.url}
|
||||
onChange={(e) => update(i, { url: e.target.value })}
|
||||
placeholder="https://mail.example.com"
|
||||
className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
<button
|
||||
onClick={() => remove(i)}
|
||||
className="shrink-0 text-muted-foreground hover:text-destructive"
|
||||
title="Remove server"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-muted-foreground mb-1">
|
||||
Email domains (comma-separated, used for auto-pick)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={d.domains}
|
||||
onChange={(e) => update(i, { domains: e.target.value })}
|
||||
placeholder="example.com, example.org"
|
||||
className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => update(i, { oauthExpanded: !d.oauthExpanded })}
|
||||
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
type="button"
|
||||
>
|
||||
{d.oauthExpanded ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
|
||||
Per-server OAuth (optional, overrides global)
|
||||
</button>
|
||||
{d.oauthExpanded && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2 pl-4 border-l border-border">
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-muted-foreground mb-1">OAuth Client ID</label>
|
||||
<input
|
||||
type="text"
|
||||
value={d.oauthClientId}
|
||||
onChange={(e) => update(i, { oauthClientId: e.target.value })}
|
||||
className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-muted-foreground mb-1">OAuth Issuer URL</label>
|
||||
<input
|
||||
type="url"
|
||||
value={d.oauthIssuerUrl}
|
||||
onChange={(e) => update(i, { oauthIssuerUrl: e.target.value })}
|
||||
placeholder="https://auth.example.com"
|
||||
className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-muted-foreground mb-1">OAuth Client Secret</label>
|
||||
<input
|
||||
type="password"
|
||||
value={d.oauthClientSecret}
|
||||
onChange={(e) => update(i, { oauthClientSecret: e.target.value })}
|
||||
className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user