Adopt shared WebUI layout primitives

This commit is contained in:
2026-08-18 10:42:50 +02:00
parent aa390a69b9
commit e6165b4d26
@@ -1,6 +1,6 @@
import { Download, Edit3, GitMerge, History, Link2, Network, Plus, RefreshCw, RotateCcw, Save, Search, ShieldCheck, Trash2, Upload, UserPlus, X } from "lucide-react"; import { Download, Edit3, GitMerge, History, Link2, Network, Plus, RefreshCw, RotateCcw, Save, Search, ShieldCheck, Trash2, Upload, UserPlus, X } from "lucide-react";
import { useCallback, useEffect, useMemo, useState, type DragEvent as ReactDragEvent, type FormEvent } from "react"; import { useCallback, useEffect, useMemo, useState, type DragEvent as ReactDragEvent, type FormEvent } from "react";
import { import { DialogSection, DialogForm, FormGrid, ActionToolbar,
ApiError, ApiError,
ActionBlockerHint, ActionBlockerHint,
Button, Button,
@@ -13,6 +13,7 @@ import {
fetchAuthGroups, fetchAuthGroups,
formatDateTime, formatDateTime,
FormField, FormField,
FormSection,
LoadingFrame, LoadingFrame,
MetricCard, MetricCard,
PasswordField, PasswordField,
@@ -3059,7 +3060,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<Button type="button" variant="primary" onClick={openCreateContactDialog} disabledReason={createContactReason}><Plus size={16} /> Contact</Button> <Button type="button" variant="primary" onClick={openCreateContactDialog} disabledReason={createContactReason}><Plus size={16} /> Contact</Button>
</div> </div>
</header> </header>
<div className="address-contact-toolbar"> <ActionToolbar className="address-contact-toolbar">
<input <input
value={query} value={query}
onChange={(event) => { onChange={(event) => {
@@ -3069,7 +3070,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
placeholder="Search contacts" /> placeholder="Search contacts" />
{selectedBook?.read_only && <StatusBadge status="read-only" />} {selectedBook?.read_only && <StatusBadge status="read-only" />}
{selectedBook?.deleted_at && <StatusBadge status="archived" />} {selectedBook?.deleted_at && <StatusBadge status="archived" />}
</div> </ActionToolbar>
<div className="address-contact-list"> <div className="address-contact-list">
{visibleContacts.length === 0 ? {visibleContacts.length === 0 ?
<div className="address-empty-note">{selectedBook ? "No contacts found." : "Select an address book."}</div> : <div className="address-empty-note">{selectedBook ? "No contacts found." : "Select an address book."}</div> :
@@ -3111,8 +3112,8 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<Button type="submit" form="address-book-form" variant="primary" disabledReason={bookSaveReason}><Save size={16} /> Save</Button> <Button type="submit" form="address-book-form" variant="primary" disabledReason={bookSaveReason}><Save size={16} /> Save</Button>
</> </>
}> }>
<form id="address-book-form" className="address-dialog-form" onSubmit={(event) => void submitBook(event)}> <DialogForm id="address-book-form" className="address-dialog-form" onSubmit={(event) => void submitBook(event)}>
<div className="form-grid two"> <FormGrid columns={2} collapseAt="standard" className="">
<FormField label="Scope" documentation={ADDRESS_FIELDS_DOCUMENTATION}> <FormField label="Scope" documentation={ADDRESS_FIELDS_DOCUMENTATION}>
<select value={bookForm.scope_type} disabled={bookDialog?.mode === "edit"} onChange={(event) => setBookForm((current) => ({ ...current, scope_type: event.target.value as AddressBookScope }))}> <select value={bookForm.scope_type} disabled={bookDialog?.mode === "edit"} onChange={(event) => setBookForm((current) => ({ ...current, scope_type: event.target.value as AddressBookScope }))}>
<option value="user">Personal</option> <option value="user">Personal</option>
@@ -3129,10 +3130,10 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
</select> </select>
</FormField> </FormField>
} }
</div> </FormGrid>
<FormField label="Name" documentation={ADDRESS_FIELDS_DOCUMENTATION}><input value={bookForm.name} onChange={(event) => setBookForm((current) => ({ ...current, name: event.target.value }))} autoFocus /></FormField> <FormField label="Name" documentation={ADDRESS_FIELDS_DOCUMENTATION}><input value={bookForm.name} onChange={(event) => setBookForm((current) => ({ ...current, name: event.target.value }))} autoFocus /></FormField>
<FormField label="Description"><textarea value={bookForm.description} onChange={(event) => setBookForm((current) => ({ ...current, description: event.target.value }))} rows={3} /></FormField> <FormField label="Description"><textarea value={bookForm.description} onChange={(event) => setBookForm((current) => ({ ...current, description: event.target.value }))} rows={3} /></FormField>
</form> </DialogForm>
</Dialog> </Dialog>
<Dialog <Dialog
@@ -3147,7 +3148,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<Button type="submit" form="address-list-form" variant="primary" disabledReason={listSaveReason}><Save size={16} /> Save</Button> <Button type="submit" form="address-list-form" variant="primary" disabledReason={listSaveReason}><Save size={16} /> Save</Button>
</> </>
}> }>
<form id="address-list-form" className="address-dialog-form" onSubmit={(event) => void submitList(event)}> <DialogForm id="address-list-form" className="address-dialog-form" onSubmit={(event) => void submitList(event)}>
<p className="muted">Lists group contacts inside the selected address book.</p> <p className="muted">Lists group contacts inside the selected address book.</p>
<FormField label="Address book" documentation={ADDRESS_FIELDS_DOCUMENTATION}> <FormField label="Address book" documentation={ADDRESS_FIELDS_DOCUMENTATION}>
<input value={selectedBook?.name ?? ""} disabled readOnly /> <input value={selectedBook?.name ?? ""} disabled readOnly />
@@ -3158,7 +3159,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<FormField label="Description"> <FormField label="Description">
<textarea value={listForm.description} onChange={(event) => setListForm((current) => ({ ...current, description: event.target.value }))} rows={3} /> <textarea value={listForm.description} onChange={(event) => setListForm((current) => ({ ...current, description: event.target.value }))} rows={3} />
</FormField> </FormField>
</form> </DialogForm>
</Dialog> </Dialog>
<Dialog <Dialog
@@ -3174,22 +3175,21 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<Button type="submit" form="address-contact-form" variant="primary" disabledReason={contactSaveReason}><Save size={16} /> Save</Button> <Button type="submit" form="address-contact-form" variant="primary" disabledReason={contactSaveReason}><Save size={16} /> Save</Button>
</> </>
}> }>
<form id="address-contact-form" className="address-dialog-form" onSubmit={(event) => void submitContact(event)}> <DialogForm id="address-contact-form" className="address-dialog-form" onSubmit={(event) => void submitContact(event)}>
<div className="form-grid three"> <FormGrid columns={3} collapseAt="standard" className="">
<FormField label="Display name" documentation={ADDRESS_FIELDS_DOCUMENTATION}><input value={contactForm.display_name} onChange={(event) => setContactForm((current) => ({ ...current, display_name: event.target.value }))} autoFocus /></FormField> <FormField label="Display name" documentation={ADDRESS_FIELDS_DOCUMENTATION}><input value={contactForm.display_name} onChange={(event) => setContactForm((current) => ({ ...current, display_name: event.target.value }))} autoFocus /></FormField>
<FormField label="Given name"><input value={contactForm.given_name} onChange={(event) => setContactForm((current) => ({ ...current, given_name: event.target.value }))} /></FormField> <FormField label="Given name"><input value={contactForm.given_name} onChange={(event) => setContactForm((current) => ({ ...current, given_name: event.target.value }))} /></FormField>
<FormField label="Family name"><input value={contactForm.family_name} onChange={(event) => setContactForm((current) => ({ ...current, family_name: event.target.value }))} /></FormField> <FormField label="Family name"><input value={contactForm.family_name} onChange={(event) => setContactForm((current) => ({ ...current, family_name: event.target.value }))} /></FormField>
</div> </FormGrid>
<div className="form-grid two"> <FormGrid columns={2} collapseAt="standard" className="">
<FormField label="Organization" documentation={ADDRESS_FIELDS_DOCUMENTATION}><input value={contactForm.organization} onChange={(event) => setContactForm((current) => ({ ...current, organization: event.target.value }))} /></FormField> <FormField label="Organization" documentation={ADDRESS_FIELDS_DOCUMENTATION}><input value={contactForm.organization} onChange={(event) => setContactForm((current) => ({ ...current, organization: event.target.value }))} /></FormField>
<FormField label="Role title"><input value={contactForm.role_title} onChange={(event) => setContactForm((current) => ({ ...current, role_title: event.target.value }))} /></FormField> <FormField label="Role title"><input value={contactForm.role_title} onChange={(event) => setContactForm((current) => ({ ...current, role_title: event.target.value }))} /></FormField>
</div> </FormGrid>
<section className="address-form-section"> <FormSection
<div className="address-form-section-heading"> className="address-form-section"
<strong>Email addresses</strong> title="Email addresses"
<Button type="button" onClick={addEmailRow} disabledReason={addContactRowReason}><Plus size={15} /> Email</Button> actions={<Button type="button" onClick={addEmailRow} disabledReason={addContactRowReason}><Plus size={15} /> Email</Button>}
</div> contentClassName="address-form-list">
<div className="address-form-list">
{contactForm.emails.map((email) => ( {contactForm.emails.map((email) => (
<div className="address-form-row address-form-row-email" key={email.rowId}> <div className="address-form-row address-form-row-email" key={email.rowId}>
<label className="address-primary-choice"><input type="radio" name="address-primary-email" checked={email.is_primary} onChange={() => setPrimaryEmailRow(email.rowId)} /> Primary</label> <label className="address-primary-choice"><input type="radio" name="address-primary-email" checked={email.is_primary} onChange={() => setPrimaryEmailRow(email.rowId)} /> Primary</label>
@@ -3198,15 +3198,13 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<Button type="button" variant="danger" title="Remove email" aria-label="Remove email" disabledReason={removeEmailRowReason} onClick={() => removeEmailRow(email.rowId)}><Trash2 size={15} /></Button> <Button type="button" variant="danger" title="Remove email" aria-label="Remove email" disabledReason={removeEmailRowReason} onClick={() => removeEmailRow(email.rowId)}><Trash2 size={15} /></Button>
</div> </div>
))} ))}
</div> </FormSection>
</section>
<section className="address-form-section"> <FormSection
<div className="address-form-section-heading"> className="address-form-section"
<strong>Phone numbers</strong> title="Phone numbers"
<Button type="button" onClick={addPhoneRow} disabledReason={addContactRowReason}><Plus size={15} /> Phone</Button> actions={<Button type="button" onClick={addPhoneRow} disabledReason={addContactRowReason}><Plus size={15} /> Phone</Button>}
</div> contentClassName="address-form-list">
<div className="address-form-list">
{contactForm.phones.map((phone) => ( {contactForm.phones.map((phone) => (
<div className="address-form-row address-form-row-phone" key={phone.rowId}> <div className="address-form-row address-form-row-phone" key={phone.rowId}>
<label className="address-primary-choice"><input type="radio" name="address-primary-phone" checked={phone.is_primary} onChange={() => setPrimaryPhoneRow(phone.rowId)} /> Primary</label> <label className="address-primary-choice"><input type="radio" name="address-primary-phone" checked={phone.is_primary} onChange={() => setPrimaryPhoneRow(phone.rowId)} /> Primary</label>
@@ -3215,15 +3213,13 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<Button type="button" variant="danger" title="Remove phone" aria-label="Remove phone" disabledReason={removePhoneRowReason} onClick={() => removePhoneRow(phone.rowId)}><Trash2 size={15} /></Button> <Button type="button" variant="danger" title="Remove phone" aria-label="Remove phone" disabledReason={removePhoneRowReason} onClick={() => removePhoneRow(phone.rowId)}><Trash2 size={15} /></Button>
</div> </div>
))} ))}
</div> </FormSection>
</section>
<section className="address-form-section"> <FormSection
<div className="address-form-section-heading"> className="address-form-section"
<strong>Postal addresses</strong> title="Postal addresses"
<Button type="button" onClick={addPostalAddressRow} disabledReason={addContactRowReason}><Plus size={15} /> Address</Button> actions={<Button type="button" onClick={addPostalAddressRow} disabledReason={addContactRowReason}><Plus size={15} /> Address</Button>}
</div> contentClassName="address-form-list">
<div className="address-form-list">
{contactForm.postal_addresses.map((address) => ( {contactForm.postal_addresses.map((address) => (
<div className="address-form-row address-form-row-postal" key={address.rowId}> <div className="address-form-row address-form-row-postal" key={address.rowId}>
<label className="address-primary-choice"><input type="radio" name="address-primary-postal" checked={address.is_primary} onChange={() => setPrimaryPostalAddressRow(address.rowId)} /> Primary</label> <label className="address-primary-choice"><input type="radio" name="address-primary-postal" checked={address.is_primary} onChange={() => setPrimaryPostalAddressRow(address.rowId)} /> Primary</label>
@@ -3236,11 +3232,10 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<Button type="button" variant="danger" title="Remove postal address" aria-label="Remove postal address" disabledReason={removePostalAddressRowReason} onClick={() => removePostalAddressRow(address.rowId)}><Trash2 size={15} /></Button> <Button type="button" variant="danger" title="Remove postal address" aria-label="Remove postal address" disabledReason={removePostalAddressRowReason} onClick={() => removePostalAddressRow(address.rowId)}><Trash2 size={15} /></Button>
</div> </div>
))} ))}
</div> </FormSection>
</section>
<FormField label="Tags"><input value={contactForm.tags} placeholder="Comma-separated tags" onChange={(event) => setContactForm((current) => ({ ...current, tags: event.target.value }))} /></FormField> <FormField label="Tags"><input value={contactForm.tags} placeholder="Comma-separated tags" onChange={(event) => setContactForm((current) => ({ ...current, tags: event.target.value }))} /></FormField>
<FormField label="Note"><textarea rows={3} value={contactForm.note} onChange={(event) => setContactForm((current) => ({ ...current, note: event.target.value }))} /></FormField> <FormField label="Note"><textarea rows={3} value={contactForm.note} onChange={(event) => setContactForm((current) => ({ ...current, note: event.target.value }))} /></FormField>
</form> </DialogForm>
</Dialog> </Dialog>
<Dialog <Dialog
@@ -3299,7 +3294,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
</section> </section>
{canWriteGovernance && {canWriteGovernance &&
<form className="address-dialog-form address-form-section" onSubmit={(event) => void submitChannelRule(event)}> <DialogForm className="address-dialog-form address-form-section" onSubmit={(event) => void submitChannelRule(event)}>
<div className="address-form-section-heading"> <div className="address-form-section-heading">
<div> <div>
<strong>Record a fact</strong> <strong>Record a fact</strong>
@@ -3307,7 +3302,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
</div> </div>
<Button type="submit" variant="primary" disabledReason={channelRuleSaveReason}><Plus size={15} /> Add</Button> <Button type="submit" variant="primary" disabledReason={channelRuleSaveReason}><Plus size={15} /> Add</Button>
</div> </div>
<div className="form-grid two"> <FormGrid columns={2} collapseAt="standard" className="">
<FormField label="Channel" documentation={ADDRESS_GOVERNANCE_DOCUMENTATION}> <FormField label="Channel" documentation={ADDRESS_GOVERNANCE_DOCUMENTATION}>
<select <select
value={channelRuleForm.channel} value={channelRuleForm.channel}
@@ -3331,8 +3326,8 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
{governancePointOptions.map((item) => <option value={item.id} key={item.id}>{item.label}</option>)} {governancePointOptions.map((item) => <option value={item.id} key={item.id}>{item.label}</option>)}
</select> </select>
</FormField> </FormField>
</div> </FormGrid>
<div className="form-grid two"> <FormGrid columns={2} collapseAt="standard" className="">
<FormField label="Decision" documentation={ADDRESS_GOVERNANCE_DOCUMENTATION}> <FormField label="Decision" documentation={ADDRESS_GOVERNANCE_DOCUMENTATION}>
<select value={channelRuleForm.decision} onChange={(event) => setChannelRuleForm((current) => ({ ...current, decision: event.target.value as AddressChannelDecision }))}> <select value={channelRuleForm.decision} onChange={(event) => setChannelRuleForm((current) => ({ ...current, decision: event.target.value as AddressChannelDecision }))}>
<option value="allowed">Allowed</option> <option value="allowed">Allowed</option>
@@ -3348,19 +3343,19 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<FormField label="Communication purpose" documentation={ADDRESS_GOVERNANCE_DOCUMENTATION}> <FormField label="Communication purpose" documentation={ADDRESS_GOVERNANCE_DOCUMENTATION}>
<input value={channelRuleForm.purpose} placeholder="All purposes" onChange={(event) => setChannelRuleForm((current) => ({ ...current, purpose: event.target.value }))} /> <input value={channelRuleForm.purpose} placeholder="All purposes" onChange={(event) => setChannelRuleForm((current) => ({ ...current, purpose: event.target.value }))} />
</FormField> </FormField>
</div> </FormGrid>
<div className="form-grid three"> <FormGrid columns={3} collapseAt="standard" className="">
<FormField label="Legal basis"><input value={channelRuleForm.legal_basis} onChange={(event) => setChannelRuleForm((current) => ({ ...current, legal_basis: event.target.value }))} /></FormField> <FormField label="Legal basis"><input value={channelRuleForm.legal_basis} onChange={(event) => setChannelRuleForm((current) => ({ ...current, legal_basis: event.target.value }))} /></FormField>
<FormField label="Evidence reference"><input value={channelRuleForm.evidence_ref} onChange={(event) => setChannelRuleForm((current) => ({ ...current, evidence_ref: event.target.value }))} /></FormField> <FormField label="Evidence reference"><input value={channelRuleForm.evidence_ref} onChange={(event) => setChannelRuleForm((current) => ({ ...current, evidence_ref: event.target.value }))} /></FormField>
<FormField label="Locale"><input value={channelRuleForm.locale} placeholder="de-DE" onChange={(event) => setChannelRuleForm((current) => ({ ...current, locale: event.target.value }))} /></FormField> <FormField label="Locale"><input value={channelRuleForm.locale} placeholder="de-DE" onChange={(event) => setChannelRuleForm((current) => ({ ...current, locale: event.target.value }))} /></FormField>
</div> </FormGrid>
<div className="form-grid three"> <FormGrid columns={3} collapseAt="standard" className="">
<FormField label="Effective from"><input type="datetime-local" value={channelRuleForm.effective_from} onChange={(event) => setChannelRuleForm((current) => ({ ...current, effective_from: event.target.value }))} /></FormField> <FormField label="Effective from"><input type="datetime-local" value={channelRuleForm.effective_from} onChange={(event) => setChannelRuleForm((current) => ({ ...current, effective_from: event.target.value }))} /></FormField>
<FormField label="Effective until"><input type="datetime-local" value={channelRuleForm.effective_until} onChange={(event) => setChannelRuleForm((current) => ({ ...current, effective_until: event.target.value }))} /></FormField> <FormField label="Effective until"><input type="datetime-local" value={channelRuleForm.effective_until} onChange={(event) => setChannelRuleForm((current) => ({ ...current, effective_until: event.target.value }))} /></FormField>
<FormField label="Preference rank"><input type="number" min="0" max="10000" value={channelRuleForm.preference_rank} onChange={(event) => setChannelRuleForm((current) => ({ ...current, preference_rank: event.target.value }))} /></FormField> <FormField label="Preference rank"><input type="number" min="0" max="10000" value={channelRuleForm.preference_rank} onChange={(event) => setChannelRuleForm((current) => ({ ...current, preference_rank: event.target.value }))} /></FormField>
</div> </FormGrid>
<FormField label="Reason" documentation={ADDRESS_GOVERNANCE_DOCUMENTATION}><textarea rows={2} value={channelRuleForm.reason} onChange={(event) => setChannelRuleForm((current) => ({ ...current, reason: event.target.value }))} /></FormField> <FormField label="Reason" documentation={ADDRESS_GOVERNANCE_DOCUMENTATION}><textarea rows={2} value={channelRuleForm.reason} onChange={(event) => setChannelRuleForm((current) => ({ ...current, reason: event.target.value }))} /></FormField>
</form> </DialogForm>
} }
</div> </div>
</Dialog> </Dialog>
@@ -3531,9 +3526,9 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<Button type="submit" form="address-quality-point-form" variant="primary" disabledReason={disabledReason([!qualityPointTarget, "Select a contact point."], [!canWriteGovernance, "You need permission to manage address quality."], [saving, savingReason])}><Save size={15} /> Record</Button> <Button type="submit" form="address-quality-point-form" variant="primary" disabledReason={disabledReason([!qualityPointTarget, "Select a contact point."], [!canWriteGovernance, "You need permission to manage address quality."], [saving, savingReason])}><Save size={15} /> Record</Button>
</> </>
}> }>
<form id="address-quality-point-form" className="address-dialog-form" onSubmit={(event) => void submitQualityDecision(event)}> <DialogForm id="address-quality-point-form" className="address-dialog-form" onSubmit={(event) => void submitQualityDecision(event)}>
<FormField label="Contact point"><input value={qualityPointTarget?.label ?? ""} readOnly disabled /></FormField> <FormField label="Contact point"><input value={qualityPointTarget?.label ?? ""} readOnly disabled /></FormField>
<div className="form-grid two"> <FormGrid columns={2} collapseAt="standard" className="">
<FormField label="Quality state"> <FormField label="Quality state">
<select value={qualityForm.state} onChange={(event) => setQualityForm((current) => ({ ...current, state: event.target.value as ContactPointQualityState }))}> <select value={qualityForm.state} onChange={(event) => setQualityForm((current) => ({ ...current, state: event.target.value as ContactPointQualityState }))}>
<option value="valid">Valid</option> <option value="valid">Valid</option>
@@ -3544,10 +3539,10 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
</select> </select>
</FormField> </FormField>
<FormField label="Reason code"><input value={qualityForm.reason_code} placeholder={`addresses.quality.${qualityForm.state}`} onChange={(event) => setQualityForm((current) => ({ ...current, reason_code: event.target.value }))} /></FormField> <FormField label="Reason code"><input value={qualityForm.reason_code} placeholder={`addresses.quality.${qualityForm.state}`} onChange={(event) => setQualityForm((current) => ({ ...current, reason_code: event.target.value }))} /></FormField>
</div> </FormGrid>
<FormField label="Reason"><textarea rows={3} value={qualityForm.reason} onChange={(event) => setQualityForm((current) => ({ ...current, reason: event.target.value }))} /></FormField> <FormField label="Reason"><textarea rows={3} value={qualityForm.reason} onChange={(event) => setQualityForm((current) => ({ ...current, reason: event.target.value }))} /></FormField>
<FormField label="Evidence reference"><input value={qualityForm.evidence_ref} placeholder="mail:delivery:..." onChange={(event) => setQualityForm((current) => ({ ...current, evidence_ref: event.target.value }))} /></FormField> <FormField label="Evidence reference"><input value={qualityForm.evidence_ref} placeholder="mail:delivery:..." onChange={(event) => setQualityForm((current) => ({ ...current, evidence_ref: event.target.value }))} /></FormField>
</form> </DialogForm>
</Dialog> </Dialog>
<Dialog <Dialog
@@ -3562,7 +3557,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<Button type="submit" form="address-merge-form" variant="primary" disabledReason={disabledReason([!mergeDialog || mergeDialog.reason.trim().length < 3, "Record why these contacts are being merged."], [saving, savingReason])}><GitMerge size={15} /> Merge</Button> <Button type="submit" form="address-merge-form" variant="primary" disabledReason={disabledReason([!mergeDialog || mergeDialog.reason.trim().length < 3, "Record why these contacts are being merged."], [saving, savingReason])}><GitMerge size={15} /> Merge</Button>
</> </>
}> }>
{mergeDialog && <form id="address-merge-form" className="address-dialog-form" onSubmit={(event) => void submitContactMerge(event)}> {mergeDialog && <DialogForm id="address-merge-form" className="address-dialog-form" onSubmit={(event) => void submitContactMerge(event)}>
<DismissibleAlert tone="warning" dismissible={false}>The other contact is archived and redirected to the survivor. Address-list memberships and matching contact points are repaired transactionally.</DismissibleAlert> <DismissibleAlert tone="warning" dismissible={false}>The other contact is archived and redirected to the survivor. Address-list memberships and matching contact points are repaired transactionally.</DismissibleAlert>
<FormField label="Surviving contact"> <FormField label="Surviving contact">
<select value={mergeDialog.winnerId} onChange={(event) => changeMergeWinner(event.target.value)}> <select value={mergeDialog.winnerId} onChange={(event) => changeMergeWinner(event.target.value)}>
@@ -3570,7 +3565,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<option value={mergeDialog.suggestion.right.id}>{mergeDialog.suggestion.right.display_name} · {primaryEmail(mergeDialog.suggestion.right) || "no email"}</option> <option value={mergeDialog.suggestion.right.id}>{mergeDialog.suggestion.right.display_name} · {primaryEmail(mergeDialog.suggestion.right) || "no email"}</option>
</select> </select>
</FormField> </FormField>
<div className="form-grid two address-merge-field-sources"> <FormGrid columns={2} collapseAt="standard" className="address-merge-field-sources">
{MERGE_SCALAR_FIELDS.map((field) => ( {MERGE_SCALAR_FIELDS.map((field) => (
<FormField label={`${field.label} source`} key={field.id}> <FormField label={`${field.label} source`} key={field.id}>
<select <select
@@ -3584,7 +3579,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
</select> </select>
</FormField> </FormField>
))} ))}
</div> </FormGrid>
<FormField label="Contact points"> <FormField label="Contact points">
<SegmentedControl<"union" | "winner_only"> <SegmentedControl<"union" | "winner_only">
role="group" role="group"
@@ -3596,7 +3591,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
/> />
</FormField> </FormField>
<FormField label="Reason"><textarea rows={3} value={mergeDialog.reason} onChange={(event) => setMergeDialog((current) => current ? { ...current, reason: event.target.value } : null)} /></FormField> <FormField label="Reason"><textarea rows={3} value={mergeDialog.reason} onChange={(event) => setMergeDialog((current) => current ? { ...current, reason: event.target.value } : null)} /></FormField>
</form>} </DialogForm>}
</Dialog> </Dialog>
<Dialog <Dialog
@@ -3611,10 +3606,10 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<Button type="submit" form="address-merge-recovery-form" variant="primary" disabledReason={disabledReason([!mergeRecoveryDialog || mergeRecoveryDialog.reason.trim().length < 3, "Record why the merge is being recovered."], [saving, savingReason])}><RotateCcw size={15} /> {mergeRecoveryDialog?.action === "split" ? "Split" : "Undo"}</Button> <Button type="submit" form="address-merge-recovery-form" variant="primary" disabledReason={disabledReason([!mergeRecoveryDialog || mergeRecoveryDialog.reason.trim().length < 3, "Record why the merge is being recovered."], [saving, savingReason])}><RotateCcw size={15} /> {mergeRecoveryDialog?.action === "split" ? "Split" : "Undo"}</Button>
</> </>
}> }>
{mergeRecoveryDialog && <form id="address-merge-recovery-form" className="address-dialog-form" onSubmit={(event) => void submitMergeRecovery(event)}> {mergeRecoveryDialog && <DialogForm id="address-merge-recovery-form" className="address-dialog-form" onSubmit={(event) => void submitMergeRecovery(event)}>
<p className="muted">Recovery restores the recorded pre-merge values and list memberships. It is rejected if either contact changed after the merge.</p> <p className="muted">Recovery restores the recorded pre-merge values and list memberships. It is rejected if either contact changed after the merge.</p>
<FormField label="Reason"><textarea rows={3} value={mergeRecoveryDialog.reason} onChange={(event) => setMergeRecoveryDialog((current) => current ? { ...current, reason: event.target.value } : null)} autoFocus /></FormField> <FormField label="Reason"><textarea rows={3} value={mergeRecoveryDialog.reason} onChange={(event) => setMergeRecoveryDialog((current) => current ? { ...current, reason: event.target.value } : null)} autoFocus /></FormField>
</form>} </DialogForm>}
</Dialog> </Dialog>
<Dialog <Dialog
@@ -3626,9 +3621,9 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
footerClassName="button-row compact-actions" footerClassName="button-row compact-actions"
footer={<Button type="button" onClick={() => setMemberDialogOpen(false)} disabledReason={dialogCancelReason}>Close</Button>}> footer={<Button type="button" onClick={() => setMemberDialogOpen(false)} disabledReason={dialogCancelReason}>Close</Button>}>
<div className="address-member-picker"> <div className="address-member-picker">
<div className="address-contact-toolbar address-member-search"> <ActionToolbar className="address-contact-toolbar address-member-search">
<input value={memberQuery} onChange={(event) => setMemberQuery(event.target.value)} placeholder="Search contacts to add" autoFocus /> <input value={memberQuery} onChange={(event) => setMemberQuery(event.target.value)} placeholder="Search contacts to add" autoFocus />
</div> </ActionToolbar>
<div className="address-member-candidate-list" role="list"> <div className="address-member-candidate-list" role="list">
{memberCandidateContacts.length === 0 ? {memberCandidateContacts.length === 0 ?
<div className="address-empty-note">No contacts available to add.</div> : <div className="address-empty-note">No contacts available to add.</div> :
@@ -3683,7 +3678,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<Button type="button" variant="primary" onClick={() => void applyTabularImport()} disabledReason={tabularApplyReason}><Upload size={16} /> Apply import</Button>} <Button type="button" variant="primary" onClick={() => void applyTabularImport()} disabledReason={tabularApplyReason}><Upload size={16} /> Apply import</Button>}
</> </>
}> }>
<div className="address-dialog-form"> <DialogSection className="address-dialog-form">
<SegmentedControl<ImportMode> <SegmentedControl<ImportMode>
role="group" role="group"
size="equal" size="equal"
@@ -3693,7 +3688,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
onChange={(mode) => { setImportMode(mode); setImportRun(null); }} onChange={(mode) => { setImportMode(mode); setImportRun(null); }}
/> />
{importMode === "vcard" && {importMode === "vcard" &&
<form id="address-vcard-import-form" className="address-dialog-form" onSubmit={(event) => void submitVcardImport(event)}> <DialogForm id="address-vcard-import-form" className="address-dialog-form" onSubmit={(event) => void submitVcardImport(event)}>
<p className="muted">Paste one or more vCard entries into the selected address book.</p> <p className="muted">Paste one or more vCard entries into the selected address book.</p>
<FormField label="vCard content"> <FormField label="vCard content">
<textarea <textarea
@@ -3704,10 +3699,10 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
placeholder={"BEGIN:VCARD\nVERSION:4.0\nFN:Ada Lovelace\nEMAIL;TYPE=work:ada@example.local\nEND:VCARD"} placeholder={"BEGIN:VCARD\nVERSION:4.0\nFN:Ada Lovelace\nEMAIL;TYPE=work:ada@example.local\nEND:VCARD"}
/> />
</FormField> </FormField>
</form>} </DialogForm>}
{importMode === "tabular" && {importMode === "tabular" &&
<div className="address-import-workspace"> <div className="address-import-workspace">
<div className="form-grid two"> <FormGrid columns={2} collapseAt="standard" className="">
<FormField label="Mapping profile"> <FormField label="Mapping profile">
<select <select
value={selectedImportProfileId} value={selectedImportProfileId}
@@ -3728,10 +3723,10 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
{creatingImportProfile ? "Cancel editor" : "New mapping"} {creatingImportProfile ? "Cancel editor" : "New mapping"}
</Button> </Button>
</div> </div>
</div> </FormGrid>
{creatingImportProfile ? {creatingImportProfile ?
<div className="address-import-profile-editor"> <div className="address-import-profile-editor">
<div className="form-grid two"> <FormGrid columns={2} collapseAt="standard" className="">
<FormField label="Profile name"><input value={importProfileForm.name} onChange={(event) => setImportProfileForm((current) => ({ ...current, name: event.target.value }))} /></FormField> <FormField label="Profile name"><input value={importProfileForm.name} onChange={(event) => setImportProfileForm((current) => ({ ...current, name: event.target.value }))} /></FormField>
<FormField label="Format"> <FormField label="Format">
<select value={importProfileForm.source_format} disabled={Boolean(editingImportProfileId)} onChange={(event) => setImportProfileForm((current) => ({ ...current, source_format: event.target.value as "csv" | "xlsx" }))}> <select value={importProfileForm.source_format} disabled={Boolean(editingImportProfileId)} onChange={(event) => setImportProfileForm((current) => ({ ...current, source_format: event.target.value as "csv" | "xlsx" }))}>
@@ -3771,7 +3766,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<FormField label="Locale"><input value={importProfileForm.locale} onChange={(event) => setImportProfileForm((current) => ({ ...current, locale: event.target.value }))} placeholder="de-DE" /></FormField> <FormField label="Locale"><input value={importProfileForm.locale} onChange={(event) => setImportProfileForm((current) => ({ ...current, locale: event.target.value }))} placeholder="de-DE" /></FormField>
<FormField label="Default tags"><input value={importProfileForm.default_tags} onChange={(event) => setImportProfileForm((current) => ({ ...current, default_tags: event.target.value }))} placeholder="monthly, imported" /></FormField> <FormField label="Default tags"><input value={importProfileForm.default_tags} onChange={(event) => setImportProfileForm((current) => ({ ...current, default_tags: event.target.value }))} placeholder="monthly, imported" /></FormField>
<FormField label="Maximum rows"><input type="number" min="1" max="10000" value={importProfileForm.max_rows} onChange={(event) => setImportProfileForm((current) => ({ ...current, max_rows: event.target.value }))} /></FormField> <FormField label="Maximum rows"><input type="number" min="1" max="10000" value={importProfileForm.max_rows} onChange={(event) => setImportProfileForm((current) => ({ ...current, max_rows: event.target.value }))} /></FormField>
</div> </FormGrid>
<div className="address-import-mapping-grid"> <div className="address-import-mapping-grid">
{IMPORT_MAPPING_FIELDS.map(([target, label]) => {IMPORT_MAPPING_FIELDS.map(([target, label]) =>
<FormField label={label} key={target}> <FormField label={label} key={target}>
@@ -3813,7 +3808,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
</div>} </div>}
</>} </>}
</div>} </div>}
</div> </DialogSection>
</Dialog> </Dialog>
<Dialog <Dialog
@@ -3830,14 +3825,14 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<Button type="submit" form="address-carddav-form" variant="primary" disabledReason={disabledReason([saving, savingReason], [!cardDavForm.collection_url.trim(), "Enter a CardDAV address-book URL before saving."])}><Save size={16} /> Connect</Button> <Button type="submit" form="address-carddav-form" variant="primary" disabledReason={disabledReason([saving, savingReason], [!cardDavForm.collection_url.trim(), "Enter a CardDAV address-book URL before saving."])}><Save size={16} /> Connect</Button>
</> </>
}> }>
<form id="address-carddav-form" className="address-dialog-form" onSubmit={(event) => void submitCardDavSource(event)}> <DialogForm id="address-carddav-form" className="address-dialog-form" onSubmit={(event) => void submitCardDavSource(event)}>
<FormField label="Address book"> <FormField label="Address book">
<input value={selectedBook?.name ?? ""} disabled readOnly /> <input value={selectedBook?.name ?? ""} disabled readOnly />
</FormField> </FormField>
<FormField label="CardDAV URL"> <FormField label="CardDAV URL">
<input value={cardDavForm.collection_url} onChange={(event) => setCardDavForm((current) => ({ ...current, collection_url: event.target.value }))} autoFocus /> <input value={cardDavForm.collection_url} onChange={(event) => setCardDavForm((current) => ({ ...current, collection_url: event.target.value }))} autoFocus />
</FormField> </FormField>
<div className="form-grid three"> <FormGrid columns={3} collapseAt="standard" className="">
<FormField label="Authentication"> <FormField label="Authentication">
<select value={cardDavForm.auth_type} onChange={(event) => setCardDavForm((current) => ({ ...current, auth_type: event.target.value as CardDavFormState["auth_type"] }))}> <select value={cardDavForm.auth_type} onChange={(event) => setCardDavForm((current) => ({ ...current, auth_type: event.target.value as CardDavFormState["auth_type"] }))}>
<option value="none">None</option> <option value="none">None</option>
@@ -3855,7 +3850,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<FormField label="Display name"> <FormField label="Display name">
<input value={cardDavForm.display_name} onChange={(event) => setCardDavForm((current) => ({ ...current, display_name: event.target.value }))} placeholder={selectedBook?.name ?? "CardDAV"} /> <input value={cardDavForm.display_name} onChange={(event) => setCardDavForm((current) => ({ ...current, display_name: event.target.value }))} placeholder={selectedBook?.name ?? "CardDAV"} />
</FormField> </FormField>
</div> </FormGrid>
{cardDavForm.auth_type !== "none" && {cardDavForm.auth_type !== "none" &&
<FormField label="Reusable credential"> <FormField label="Reusable credential">
<select <select
@@ -3883,7 +3878,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
} }
{cardDavCredentialsError && <DismissibleAlert tone="danger" resetKey={cardDavCredentialsError}>{cardDavCredentialsError}</DismissibleAlert>} {cardDavCredentialsError && <DismissibleAlert tone="danger" resetKey={cardDavCredentialsError}>{cardDavCredentialsError}</DismissibleAlert>}
{cardDavForm.auth_type === "basic" && {cardDavForm.auth_type === "basic" &&
<div className="form-grid two"> <FormGrid columns={2} collapseAt="standard" className="">
<FormField label="Username"> <FormField label="Username">
<input value={cardDavForm.username} onChange={(event) => setCardDavForm((current) => ({ ...current, username: event.target.value }))} disabled={Boolean(cardDavForm.credential_envelope_id)} /> <input value={cardDavForm.username} onChange={(event) => setCardDavForm((current) => ({ ...current, username: event.target.value }))} disabled={Boolean(cardDavForm.credential_envelope_id)} />
</FormField> </FormField>
@@ -3892,7 +3887,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<PasswordField value={cardDavForm.password} onValueChange={(value) => setCardDavForm((current) => ({ ...current, password: value }))} autoComplete="new-password" /> <PasswordField value={cardDavForm.password} onValueChange={(value) => setCardDavForm((current) => ({ ...current, password: value }))} autoComplete="new-password" />
</FormField> </FormField>
} }
</div> </FormGrid>
} }
{cardDavForm.auth_type === "bearer" && !cardDavForm.credential_envelope_id && {cardDavForm.auth_type === "bearer" && !cardDavForm.credential_envelope_id &&
<FormField label="Bearer token"> <FormField label="Bearer token">
@@ -3913,7 +3908,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
))} ))}
</SelectionList> </SelectionList>
} }
</form> </DialogForm>
</Dialog> </Dialog>
<Dialog <Dialog
@@ -3930,8 +3925,8 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<Button type="submit" form="address-ldap-form" variant="primary" disabledReason={disabledReason([saving, savingReason], [!ldapForm.url.trim(), "Enter an LDAP URL."], [!ldapForm.base_dn.trim(), "Select or enter a base DN."], [!ldapForm.attribute_map.source_key?.trim(), "Map a stable source-key attribute."])}><Save size={16} /> Connect</Button> <Button type="submit" form="address-ldap-form" variant="primary" disabledReason={disabledReason([saving, savingReason], [!ldapForm.url.trim(), "Enter an LDAP URL."], [!ldapForm.base_dn.trim(), "Select or enter a base DN."], [!ldapForm.attribute_map.source_key?.trim(), "Map a stable source-key attribute."])}><Save size={16} /> Connect</Button>
</> </>
}> }>
<form id="address-ldap-form" className="address-dialog-form" onSubmit={(event) => void submitLdapSource(event)}> <DialogForm id="address-ldap-form" className="address-dialog-form" onSubmit={(event) => void submitLdapSource(event)}>
<div className="form-grid two"> <FormGrid columns={2} collapseAt="standard" className="">
<FormField label="Directory URL"> <FormField label="Directory URL">
<input value={ldapForm.url} onChange={(event) => setLdapForm((current) => ({ ...current, url: event.target.value }))} placeholder="ldaps://directory.example.org" autoFocus /> <input value={ldapForm.url} onChange={(event) => setLdapForm((current) => ({ ...current, url: event.target.value }))} placeholder="ldaps://directory.example.org" autoFocus />
</FormField> </FormField>
@@ -3954,7 +3949,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
<FormField label="Bind DN / username"> <FormField label="Bind DN / username">
<input value={ldapForm.bind_dn} onChange={(event) => setLdapForm((current) => ({ ...current, bind_dn: event.target.value }))} /> <input value={ldapForm.bind_dn} onChange={(event) => setLdapForm((current) => ({ ...current, bind_dn: event.target.value }))} />
</FormField> </FormField>
</div> </FormGrid>
<ToggleSwitch <ToggleSwitch
label="Require StartTLS for ldap://" label="Require StartTLS for ldap://"
checked={ldapForm.start_tls} checked={ldapForm.start_tls}
@@ -3962,7 +3957,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
help="LDAPS always uses TLS. Plain ldap:// endpoints are accepted only when StartTLS is enabled." help="LDAPS always uses TLS. Plain ldap:// endpoints are accepted only when StartTLS is enabled."
/> />
{cardDavCredentialsError && <DismissibleAlert tone="danger" resetKey={cardDavCredentialsError}>{cardDavCredentialsError}</DismissibleAlert>} {cardDavCredentialsError && <DismissibleAlert tone="danger" resetKey={cardDavCredentialsError}>{cardDavCredentialsError}</DismissibleAlert>}
<div className="form-grid two"> <FormGrid columns={2} collapseAt="standard" className="">
<FormField label="Base DN"> <FormField label="Base DN">
<input list="address-ldap-base-dns" value={ldapForm.base_dn} onChange={(event) => setLdapForm((current) => ({ ...current, base_dn: event.target.value }))} placeholder="ou=people,dc=example,dc=org" /> <input list="address-ldap-base-dns" value={ldapForm.base_dn} onChange={(event) => setLdapForm((current) => ({ ...current, base_dn: event.target.value }))} placeholder="ou=people,dc=example,dc=org" />
<datalist id="address-ldap-base-dns">{ldapBaseDns.map((baseDn) => <option value={baseDn} key={baseDn} />)}</datalist> <datalist id="address-ldap-base-dns">{ldapBaseDns.map((baseDn) => <option value={baseDn} key={baseDn} />)}</datalist>
@@ -3972,7 +3967,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
</FormField> </FormField>
<FormField label="Page size"><input type="number" min="1" max="1000" value={ldapForm.page_size} onChange={(event) => setLdapForm((current) => ({ ...current, page_size: event.target.value }))} /></FormField> <FormField label="Page size"><input type="number" min="1" max="1000" value={ldapForm.page_size} onChange={(event) => setLdapForm((current) => ({ ...current, page_size: event.target.value }))} /></FormField>
<FormField label="Maximum entries"><input type="number" min="1" max="10000" value={ldapForm.max_entries} onChange={(event) => setLdapForm((current) => ({ ...current, max_entries: event.target.value }))} /></FormField> <FormField label="Maximum entries"><input type="number" min="1" max="10000" value={ldapForm.max_entries} onChange={(event) => setLdapForm((current) => ({ ...current, max_entries: event.target.value }))} /></FormField>
</div> </FormGrid>
<div className="address-import-mapping-grid"> <div className="address-import-mapping-grid">
{LDAP_MAPPING_FIELDS.map(([target, label]) => {LDAP_MAPPING_FIELDS.map(([target, label]) =>
<FormField label={label} key={target}> <FormField label={label} key={target}>
@@ -3980,7 +3975,7 @@ export default function AddressBookPage({ settings, auth, onAuthChange }: Props)
</FormField>)} </FormField>)}
</div> </div>
<p className="muted">The directory remains authoritative and read-only. Preview the first refresh before applying it.</p> <p className="muted">The directory remains authoritative and read-only. Preview the first refresh before applying it.</p>
</form> </DialogForm>
</Dialog> </Dialog>
<Dialog <Dialog