Hover cards for domains and people, and defaults in option tooltips
- A domain's name in any list opens a card on hover: whether it's taking mail, how many people it has, which of its key records (mail routing, SPF, DKIM, DMARC) are live in public DNS, and whether DNS, signing keys and certificates are automatic. - A person's shows their name, storage against their quota, role, groups and when they joined. The server keeps no last sign-in on the account, so the card doesn't claim one. - Cards load on open and are cached for a minute. - Option tooltips end with the option's default, and an option set away from its default gets a small "changed" mark.
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CheckCircle2, CircleDashed, Globe, Loader2, UserRound } from 'lucide-react';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { formatSize } from '@/lib/durationFormat';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { CARD_OBJECTS, loadCardFacts, type CardFacts, type DomainFacts, type PersonFacts } from './facts';
|
||||
|
||||
const KIND_LABEL: Record<string, string> = { mx: 'Mail routing', spf: 'SPF', dkim: 'DKIM', dmarc: 'DMARC' };
|
||||
|
||||
function Row({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span className="text-right font-medium">{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DomainCard({ f }: { f: DomainFacts }) {
|
||||
const { t } = useTranslation();
|
||||
const auto = (m: string) => (m === 'Automatic' ? t('hover.automatic', 'Automatic') : t('hover.manual', 'Manual'));
|
||||
const live = f.checks.filter((c) => c.live).length;
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-sky-500/15 text-sky-700 dark:text-sky-300">
|
||||
<Globe className="h-4 w-4" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-semibold">{f.name}</p>
|
||||
<p className="text-muted-foreground">
|
||||
{f.enabled ? t('hover.enabled', 'Receiving mail') : t('hover.disabled', 'Turned off')}
|
||||
{f.people !== undefined &&
|
||||
` · ${t('hover.people', { count: f.people, defaultValue_one: '{{count}} person', defaultValue_other: '{{count}} people' })}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{f.checks.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<p className="font-medium">
|
||||
{live === f.checks.length
|
||||
? t('hover.dnsAllLive', 'DNS is in place')
|
||||
: t('hover.dnsSome', '{{live}} of {{total}} key records live', { live, total: f.checks.length })}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{f.checks.map((c) => (
|
||||
<span
|
||||
key={c.kind}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded-full px-2 py-0.5',
|
||||
c.live
|
||||
? 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-300'
|
||||
: 'bg-muted text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{c.live ? <CheckCircle2 className="h-3 w-3" /> : <CircleDashed className="h-3 w-3" />}
|
||||
{KIND_LABEL[c.kind] ?? c.kind}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1 border-t pt-2">
|
||||
<Row label={t('hover.dns', 'DNS records')}>{auto(f.dns)}</Row>
|
||||
<Row label={t('hover.dkim', 'Signing keys')}>{auto(f.dkim)}</Row>
|
||||
<Row label={t('hover.certs', 'Certificates')}>{auto(f.certs)}</Row>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PersonCard({ f }: { f: PersonFacts }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const pct = f.quota ? Math.min(100, Math.round((f.used / f.quota) * 100)) : null;
|
||||
const roleLabel =
|
||||
f.role === 'Admin'
|
||||
? t('hover.roleAdmin', 'Administrator')
|
||||
: f.role === 'Custom'
|
||||
? t('hover.roleCustom', 'Custom role')
|
||||
: t('hover.roleUser', 'User');
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-violet-500/15 text-violet-700 dark:text-violet-300">
|
||||
<UserRound className="h-4 w-4" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-semibold">{f.name ?? f.address}</p>
|
||||
{f.name && <p className="truncate text-muted-foreground">{f.address}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">{t('hover.storage', 'Storage')}</span>
|
||||
<span className="font-medium">
|
||||
{formatSize(f.used)}
|
||||
{f.quota ? ` / ${formatSize(f.quota)}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
{pct !== null && (
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full',
|
||||
pct >= 90 ? 'bg-rose-500' : pct >= 75 ? 'bg-amber-500' : 'bg-primary',
|
||||
)}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1 border-t pt-2">
|
||||
<Row label={t('hover.role', 'Role')}>{roleLabel}</Row>
|
||||
{f.groups > 0 && <Row label={t('hover.groups', 'Groups')}>{f.groups}</Row>}
|
||||
{f.createdAt && (
|
||||
<Row label={t('hover.since', 'Here since')}>
|
||||
{new Date(f.createdAt).toLocaleDateString(i18n.language, { dateStyle: 'medium' })}
|
||||
</Row>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CardBody({ objectName, id }: { objectName: string; id: string }) {
|
||||
const { t } = useTranslation();
|
||||
const [facts, setFacts] = useState<CardFacts | null | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
loadCardFacts(objectName, id).then((f) => {
|
||||
if (live) setFacts(f);
|
||||
});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [objectName, id]);
|
||||
if (facts === undefined)
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
{t('hover.loading', 'Looking…')}
|
||||
</div>
|
||||
);
|
||||
if (!facts) return <p className="text-muted-foreground">{t('hover.unavailable', 'No details available.')}</p>;
|
||||
return facts.kind === 'domain' ? <DomainCard f={facts} /> : <PersonCard f={facts} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* A card with the essentials of a domain or person, on hover or focus of its
|
||||
* name in a list, so a glance answers "is this one healthy?" without
|
||||
* opening it. Other objects render their name unchanged.
|
||||
*/
|
||||
export function ObjectHoverCard({ objectName, id, children }: { objectName: string; id: string; children: ReactNode }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
if (!CARD_OBJECTS.has(objectName) || !id) return <>{children}</>;
|
||||
return (
|
||||
<TooltipProvider delayDuration={450}>
|
||||
<Tooltip open={open} onOpenChange={setOpen}>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="cursor-default underline decoration-dotted decoration-muted-foreground/40 underline-offset-4">
|
||||
{children}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
className="w-72 border bg-popover p-4 text-xs text-popover-foreground shadow-soft"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{open && <CardBody objectName={objectName} id={id} />}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* What a hover card shows, fetched when the card opens and kept for a
|
||||
* minute, so moving across a list doesn't ask the server twice.
|
||||
*/
|
||||
import { getAccountId, jmapRequest } from '@/services/jmap/client';
|
||||
import { parseZone, type RecordKind, type ZoneRecord } from '@/features/dns/zone';
|
||||
import { checkRecords } from '@/features/dns/liveCheck';
|
||||
|
||||
export interface DomainFacts {
|
||||
kind: 'domain';
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
people?: number;
|
||||
dns: string;
|
||||
dkim: string;
|
||||
certs: string;
|
||||
/** The core records, and which are live in public DNS. */
|
||||
checks: { kind: RecordKind; live: boolean }[];
|
||||
}
|
||||
|
||||
export interface PersonFacts {
|
||||
kind: 'person';
|
||||
address: string;
|
||||
name?: string;
|
||||
used: number;
|
||||
quota: number | null;
|
||||
role?: string;
|
||||
groups: number;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export type CardFacts = DomainFacts | PersonFacts;
|
||||
|
||||
/** The records a domain can't work without, in the order the card lists them. */
|
||||
export const CORE_KINDS: RecordKind[] = ['mx', 'spf', 'dkim', 'dmarc'];
|
||||
|
||||
const TTL_MS = 60_000;
|
||||
const cache = new Map<string, { at: number; facts: Promise<CardFacts | null> }>();
|
||||
|
||||
function mode(value: unknown): string {
|
||||
return ((value as { '@type'?: string } | undefined)?.['@type'] ?? 'Manual').toString();
|
||||
}
|
||||
|
||||
/** Of the domain's core records, which are live. Kinds with no records (DKIM before keys exist) are left out. */
|
||||
export async function coreChecks(zone: ZoneRecord[], domain: string): Promise<{ kind: RecordKind; live: boolean }[]> {
|
||||
const apex = domain.toLowerCase();
|
||||
const core = zone.filter((r) => CORE_KINDS.includes(r.kind) && (r.kind !== 'spf' || r.name.toLowerCase() === apex));
|
||||
const states = await checkRecords(core);
|
||||
return CORE_KINDS.filter((k) => core.some((r) => r.kind === k)).map((kind) => ({
|
||||
kind,
|
||||
live: core.filter((r) => r.kind === kind).every((r) => states.get(r) === 'live'),
|
||||
}));
|
||||
}
|
||||
|
||||
async function domainFacts(id: string): Promise<DomainFacts | null> {
|
||||
const accountId = getAccountId('x:Domain');
|
||||
const responses = await jmapRequest([
|
||||
[
|
||||
'x:Domain/get',
|
||||
{
|
||||
accountId,
|
||||
ids: [id],
|
||||
properties: ['name', 'isEnabled', 'dnsManagement', 'dkimManagement', 'certificateManagement', 'dnsZoneFile'],
|
||||
},
|
||||
'd',
|
||||
],
|
||||
[
|
||||
'x:Account/query',
|
||||
{ accountId: getAccountId('x:Account'), filter: { domainId: id }, limit: 1, calculateTotal: true },
|
||||
'n',
|
||||
],
|
||||
]);
|
||||
const d = (responses.find((r) => r[2] === 'd')?.[1] as { list?: Record<string, unknown>[] })?.list?.[0];
|
||||
if (!d) return null;
|
||||
const count = responses.find((r) => r[2] === 'n' && r[0] !== 'error')?.[1] as { total?: number } | undefined;
|
||||
const name = String(d.name);
|
||||
const checks = await coreChecks(parseZone(d.dnsZoneFile as string | undefined), name).catch(
|
||||
(): DomainFacts['checks'] => [],
|
||||
);
|
||||
return {
|
||||
kind: 'domain',
|
||||
name,
|
||||
enabled: d.isEnabled !== false,
|
||||
people: count?.total,
|
||||
dns: mode(d.dnsManagement),
|
||||
dkim: mode(d.dkimManagement),
|
||||
certs: mode(d.certificateManagement),
|
||||
checks,
|
||||
};
|
||||
}
|
||||
|
||||
async function personFacts(id: string): Promise<PersonFacts | null> {
|
||||
const responses = await jmapRequest([
|
||||
[
|
||||
'x:Account/get',
|
||||
{
|
||||
accountId: getAccountId('x:Account'),
|
||||
ids: [id],
|
||||
properties: [
|
||||
'@type',
|
||||
'emailAddress',
|
||||
'name',
|
||||
'description',
|
||||
'usedDiskQuota',
|
||||
'quotas',
|
||||
'roles',
|
||||
'memberGroupIds',
|
||||
'createdAt',
|
||||
],
|
||||
},
|
||||
'a',
|
||||
],
|
||||
]);
|
||||
const a = (responses[0]?.[1] as { list?: Record<string, unknown>[] })?.list?.[0];
|
||||
if (!a) return null;
|
||||
const quotas = (a.quotas ?? {}) as Record<string, unknown>;
|
||||
const quota = typeof quotas.maxDiskQuota === 'number' && quotas.maxDiskQuota > 0 ? quotas.maxDiskQuota : null;
|
||||
const groups = a.memberGroupIds && typeof a.memberGroupIds === 'object' ? Object.keys(a.memberGroupIds).length : 0;
|
||||
return {
|
||||
kind: 'person',
|
||||
address: String(a.emailAddress ?? a.name ?? id),
|
||||
name: typeof a.description === 'string' && a.description.trim() ? a.description.trim() : undefined,
|
||||
used: typeof a.usedDiskQuota === 'number' ? a.usedDiskQuota : 0,
|
||||
quota,
|
||||
role: (a.roles as { '@type'?: string } | undefined)?.['@type'],
|
||||
groups,
|
||||
createdAt: typeof a.createdAt === 'string' ? a.createdAt : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** Object types that have a hover card. */
|
||||
export const CARD_OBJECTS = new Set(['x:Domain', 'x:Account']);
|
||||
|
||||
export function loadCardFacts(objectName: string, id: string): Promise<CardFacts | null> {
|
||||
const key = `${objectName}|${id}`;
|
||||
const hit = cache.get(key);
|
||||
if (hit && Date.now() - hit.at < TTL_MS) return hit.facts;
|
||||
const facts = (objectName === 'x:Domain' ? domainFacts(id) : personFacts(id)).catch(() => null);
|
||||
cache.set(key, { at: Date.now(), facts });
|
||||
return facts;
|
||||
}
|
||||
Reference in New Issue
Block a user