Directory and Domains: cards by default, the list one press away #94
No files matched your search
@@ -64,13 +64,16 @@ import { toast } from '@/hooks/use-toast';
|
||||
import { deleteWithUndo } from '@/components/common/undoableDelete';
|
||||
import { useShortcut } from '@/lib/shortcuts';
|
||||
import { PersonCard } from '@/features/people/PersonCard';
|
||||
import { personOf } from '@/features/people/person';
|
||||
import { ObjectCard } from '@/features/cards/ObjectCard';
|
||||
import { cardOf } from '@/features/cards/objectCard';
|
||||
import {
|
||||
PERSON_CARD_PROPERTIES,
|
||||
personOf,
|
||||
readPeopleLayout,
|
||||
writePeopleLayout,
|
||||
type PeopleLayout,
|
||||
} from '@/features/people/person';
|
||||
cardKindOf,
|
||||
cardPropertiesFor,
|
||||
readListLayout,
|
||||
writeListLayout,
|
||||
type ListLayout,
|
||||
} from '@/features/cards/layout';
|
||||
import { isPendingDelete, usePendingDeletes } from '@/lib/pendingDeletes';
|
||||
import { friendlySetError } from '@/lib/jmapErrors';
|
||||
import { coerceLabel } from '@/lib/objectOptions';
|
||||
@@ -384,9 +387,6 @@ interface DynamicListProps {
|
||||
viewName: string;
|
||||
}
|
||||
|
||||
/** inbuxa: the People list, which can show cards (roadmap item 10). */
|
||||
const PEOPLE_VIEW = 'x:Account/User';
|
||||
|
||||
export function DynamicList({ viewName }: DynamicListProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
@@ -410,10 +410,11 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
||||
}, [schema, viewName]);
|
||||
// inbuxa: lock and unlock people from the account list (AL-1)
|
||||
const directoryLocks = useDirectoryLocks(resolved?.obj.objectName === 'x:Account');
|
||||
// inbuxa: People can be cards or the plain table (roadmap item 10)
|
||||
const peopleView = viewName === PEOPLE_VIEW;
|
||||
const [peopleLayout, setPeopleLayout] = useState<PeopleLayout>(readPeopleLayout);
|
||||
const showCards = peopleView && peopleLayout === 'cards';
|
||||
// inbuxa: Directory and Domains lists can be cards or the plain table (roadmap item 10)
|
||||
const cardKind = cardKindOf(viewName);
|
||||
const [layouts, setLayouts] = useState<Record<string, ListLayout>>({});
|
||||
const layout = layouts[viewName] ?? readListLayout(viewName);
|
||||
const showCards = cardKind !== undefined && layout === 'cards';
|
||||
|
||||
const [items, setItems] = useState<Record<string, unknown>[]>([]);
|
||||
const [total, setTotal] = useState<number | null>(null);
|
||||
@@ -484,7 +485,7 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
||||
const properties = [
|
||||
'id',
|
||||
...list.columns.map((c) => c.name),
|
||||
...(viewName === PEOPLE_VIEW ? PERSON_CARD_PROPERTIES : []),
|
||||
...cardPropertiesFor(viewName).filter((p) => !list.columns.some((c) => c.name === p)),
|
||||
];
|
||||
const filter = buildFilter();
|
||||
const sortArr = buildSort();
|
||||
@@ -1230,29 +1231,25 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<HelpPanel viewName={viewName} title={list.title} />
|
||||
{peopleView && (
|
||||
<div
|
||||
className="flex rounded-md border p-0.5"
|
||||
role="group"
|
||||
aria-label={t('people.layout', 'Show people as')}
|
||||
>
|
||||
{cardKind && (
|
||||
<div className="flex rounded-md border p-0.5" role="group" aria-label={t('list.layout', 'Show as')}>
|
||||
{(['cards', 'table'] as const).map((mode) => (
|
||||
<Button
|
||||
key={mode}
|
||||
type="button"
|
||||
variant={peopleLayout === mode ? 'secondary' : 'ghost'}
|
||||
variant={layout === mode ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-7 px-2"
|
||||
aria-pressed={peopleLayout === mode}
|
||||
title={mode === 'cards' ? t('people.cards', 'Cards') : t('people.table', 'Table')}
|
||||
aria-pressed={layout === mode}
|
||||
title={mode === 'cards' ? t('list.cards', 'Cards') : t('list.table', 'List')}
|
||||
onClick={() => {
|
||||
setPeopleLayout(mode);
|
||||
writePeopleLayout(mode);
|
||||
setLayouts((all) => ({ ...all, [viewName]: mode }));
|
||||
writeListLayout(viewName, mode);
|
||||
}}
|
||||
>
|
||||
{mode === 'cards' ? <LayoutGrid className="h-4 w-4" /> : <Rows3 className="h-4 w-4" />}
|
||||
<span className="sr-only">
|
||||
{mode === 'cards' ? t('people.cards', 'Cards') : t('people.table', 'Table')}
|
||||
{mode === 'cards' ? t('list.cards', 'Cards') : t('list.table', 'List')}
|
||||
</span>
|
||||
</Button>
|
||||
))}
|
||||
@@ -1403,9 +1400,21 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
||||
{t('list.selectAll', 'Select all')}
|
||||
</label>
|
||||
)}
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{items.map((item) => {
|
||||
const itemId = item.id as string;
|
||||
if (cardKind !== 'person' && cardKind !== 'group') {
|
||||
return (
|
||||
<ObjectCard
|
||||
key={itemId}
|
||||
card={cardOf(cardKind!, item)}
|
||||
selected={selectedIds.has(itemId)}
|
||||
onToggleSelect={hasMassActions ? () => toggleSelectItem(itemId) : undefined}
|
||||
onOpen={() => handleRowClick(item)}
|
||||
actions={renderItemActions(item)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<PersonCard
|
||||
key={itemId}
|
||||
@@ -1415,6 +1424,7 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
||||
onOpen={() => handleRowClick(item)}
|
||||
actions={renderItemActions(item)}
|
||||
badge={directoryLocks.badge(item)}
|
||||
hideEmptyStorage={cardKind === 'group'}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* inbuxa: what every list card shares: a mark, the name and a line under
|
||||
* it, the row's own quick actions, selection, and opening like a row does.
|
||||
*/
|
||||
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { formatSize } from '@/lib/durationFormat';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/** Initials on a circle whose hue follows the name, the same every time. */
|
||||
export function InitialsMark({ initials, hue }: { initials: string; hue: number }) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{ '--h': hue } as CSSProperties}
|
||||
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-[hsl(var(--h)_60%_90%)] text-sm font-semibold text-[hsl(var(--h)_55%_24%)] dark:bg-[hsl(var(--h)_35%_24%)] dark:text-[hsl(var(--h)_70%_86%)]"
|
||||
>
|
||||
{initials}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** An icon on a tinted square, for things that aren't people. */
|
||||
export function IconMark({ children, className }: { children: ReactNode; className: string }) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn('flex h-11 w-11 shrink-0 items-center justify-center rounded-xl', className)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function StorageRing({ fill }: { fill: number | null }) {
|
||||
const r = 15;
|
||||
const circumference = 2 * Math.PI * r;
|
||||
const tone =
|
||||
fill === null
|
||||
? 'text-muted-foreground/40'
|
||||
: fill >= 0.9
|
||||
? 'text-destructive'
|
||||
: fill >= 0.75
|
||||
? 'text-highlight'
|
||||
: 'text-primary';
|
||||
return (
|
||||
<svg viewBox="0 0 36 36" className="h-9 w-9 shrink-0 -rotate-90" aria-hidden="true">
|
||||
<circle cx="18" cy="18" r={r} fill="none" strokeWidth="4" className="stroke-muted" />
|
||||
{fill !== null && fill > 0 && (
|
||||
<circle
|
||||
cx="18"
|
||||
cy="18"
|
||||
r={r}
|
||||
fill="none"
|
||||
strokeWidth="4"
|
||||
strokeLinecap="round"
|
||||
className={cn('stroke-current', tone)}
|
||||
strokeDasharray={`${Math.max(fill, 0.02) * circumference} ${circumference}`}
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Storage used, against the limit when there is one. */
|
||||
export function StorageSummary({ used, quota, fill }: { used: number; quota: number | null; fill: number | null }) {
|
||||
const { t } = useTranslation();
|
||||
const storage =
|
||||
quota === null
|
||||
? t('people.usedNoLimit', '{{used}} used, no limit', { used: formatSize(used) })
|
||||
: t('people.usedOf', '{{used}} of {{quota}}', { used: formatSize(used), quota: formatSize(quota) });
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<StorageRing fill={fill} />
|
||||
<div className="min-w-0 text-sm">
|
||||
<p className="truncate">{storage}</p>
|
||||
{fill !== null && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('people.percentFull', '{{percent}}% full', { percent: Math.round(fill * 100) })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A small rounded label in the card's footer. */
|
||||
export function Pill({ children, tone = 'plain' }: { children: ReactNode; tone?: 'plain' | 'good' | 'muted' | 'bad' }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'rounded-full px-2 py-0.5 font-medium',
|
||||
tone === 'plain' && 'border text-foreground',
|
||||
tone === 'good' && 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-300',
|
||||
tone === 'muted' && 'bg-muted text-muted-foreground',
|
||||
tone === 'bad' && 'bg-destructive/15 text-destructive',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardShell({
|
||||
mark,
|
||||
title,
|
||||
subtitle,
|
||||
badge,
|
||||
selected,
|
||||
onToggleSelect,
|
||||
onOpen,
|
||||
actions,
|
||||
children,
|
||||
}: {
|
||||
mark: ReactNode;
|
||||
title: string;
|
||||
subtitle?: ReactNode;
|
||||
badge?: ReactNode;
|
||||
/** Shown only when the list allows mass actions. */
|
||||
selected?: boolean;
|
||||
onToggleSelect?: () => void;
|
||||
onOpen: () => void;
|
||||
actions?: ReactNode;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const stop = (e: { stopPropagation: () => void }) => e.stopPropagation();
|
||||
return (
|
||||
<article
|
||||
tabIndex={0}
|
||||
onClick={onOpen}
|
||||
onKeyDown={(e) => {
|
||||
if (e.target === e.currentTarget && (e.key === 'Enter' || e.key === ' ')) {
|
||||
e.preventDefault();
|
||||
onOpen();
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'flex min-w-0 cursor-pointer flex-col gap-3 rounded-xl border bg-card p-4 shadow-soft transition-colors hover:border-primary/40 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring',
|
||||
selected && 'border-primary/60 bg-accent/40',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{onToggleSelect && (
|
||||
<div className="pt-2.5" onClick={stop}>
|
||||
<Checkbox
|
||||
checked={selected}
|
||||
onCheckedChange={onToggleSelect}
|
||||
aria-label={t('people.select', 'Select {{name}}', { name: title })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{mark}
|
||||
<div className={cn('min-w-0 flex-1', !subtitle && !badge && 'self-center')}>
|
||||
<p className="truncate font-medium">{title}</p>
|
||||
{subtitle && <p className="truncate text-sm text-muted-foreground">{subtitle}</p>}
|
||||
{/* A list's badges sit beside the name; here they go under it */}
|
||||
{badge && <div className="mt-1 [&>*]:ml-0">{badge}</div>}
|
||||
</div>
|
||||
{actions && (
|
||||
<div onClick={stop} onKeyDown={stop}>
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{children}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* inbuxa: a card for a mailing list, tenant, role, OAuth client or domain,
|
||||
* on the same frame as a person's card, showing what the table hides in
|
||||
* columns or behind a click.
|
||||
*/
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AppWindow, Globe, ShieldCheck } from 'lucide-react';
|
||||
import { CardShell, IconMark, InitialsMark, Pill, StorageSummary } from './CardShell';
|
||||
import type { ObjectCardModel } from './objectCard';
|
||||
|
||||
function Footer({ children }: { children: ReactNode }) {
|
||||
return <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">{children}</div>;
|
||||
}
|
||||
|
||||
export function ObjectCard({
|
||||
card,
|
||||
selected,
|
||||
onToggleSelect,
|
||||
onOpen,
|
||||
actions,
|
||||
}: {
|
||||
card: ObjectCardModel;
|
||||
selected?: boolean;
|
||||
onToggleSelect?: () => void;
|
||||
onOpen: () => void;
|
||||
actions?: ReactNode;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const date = (iso: string) => new Date(iso).toLocaleDateString(i18n.language, { dateStyle: 'medium' });
|
||||
const since = (iso?: string) => iso && <span>{t('people.since', 'Since {{date}}', { date: date(iso) })}</span>;
|
||||
const shell = { title: card.title, selected, onToggleSelect, onOpen, actions };
|
||||
|
||||
switch (card.kind) {
|
||||
case 'mailingList':
|
||||
return (
|
||||
<CardShell {...shell} mark={<InitialsMark initials={card.initials} hue={card.hue} />} subtitle={card.address}>
|
||||
<Footer>
|
||||
<Pill tone={card.recipients > 0 ? 'plain' : 'muted'}>
|
||||
{t('cards.recipients', {
|
||||
count: card.recipients,
|
||||
defaultValue_one: '{{count}} recipient',
|
||||
defaultValue_other: '{{count}} recipients',
|
||||
})}
|
||||
</Pill>
|
||||
{card.aliases > 0 && (
|
||||
<span>
|
||||
{t('cards.aliases', {
|
||||
count: card.aliases,
|
||||
defaultValue_one: '{{count}} other address',
|
||||
defaultValue_other: '{{count}} other addresses',
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</Footer>
|
||||
</CardShell>
|
||||
);
|
||||
|
||||
case 'tenant':
|
||||
return (
|
||||
<CardShell {...shell} mark={<InitialsMark initials={card.initials} hue={card.hue} />}>
|
||||
<StorageSummary used={card.used} quota={card.quota} fill={card.fill} />
|
||||
<Footer>{since(card.createdAt)}</Footer>
|
||||
</CardShell>
|
||||
);
|
||||
|
||||
case 'role':
|
||||
return (
|
||||
<CardShell
|
||||
{...shell}
|
||||
mark={
|
||||
<IconMark className="bg-violet-500/15 text-violet-700 dark:text-violet-300">
|
||||
<ShieldCheck className="h-5 w-5" />
|
||||
</IconMark>
|
||||
}
|
||||
subtitle={t('cards.granted', {
|
||||
count: card.granted,
|
||||
defaultValue_one: '{{count}} permission granted',
|
||||
defaultValue_other: '{{count}} permissions granted',
|
||||
})}
|
||||
>
|
||||
{(card.removed > 0 || card.includes > 0) && (
|
||||
<Footer>
|
||||
{card.includes > 0 && (
|
||||
<Pill>
|
||||
{t('cards.includes', {
|
||||
count: card.includes,
|
||||
defaultValue_one: 'Includes {{count}} role',
|
||||
defaultValue_other: 'Includes {{count}} roles',
|
||||
})}
|
||||
</Pill>
|
||||
)}
|
||||
{card.removed > 0 && (
|
||||
<Pill tone="bad">
|
||||
{t('cards.removed', {
|
||||
count: card.removed,
|
||||
defaultValue_one: '{{count}} taken away',
|
||||
defaultValue_other: '{{count}} taken away',
|
||||
})}
|
||||
</Pill>
|
||||
)}
|
||||
</Footer>
|
||||
)}
|
||||
</CardShell>
|
||||
);
|
||||
|
||||
case 'oauthClient':
|
||||
return (
|
||||
<CardShell
|
||||
{...shell}
|
||||
mark={
|
||||
<IconMark className="bg-amber-500/15 text-amber-700 dark:text-amber-300">
|
||||
<AppWindow className="h-5 w-5" />
|
||||
</IconMark>
|
||||
}
|
||||
subtitle={
|
||||
card.clientId !== card.title ? <span className="font-mono text-xs">{card.clientId}</span> : undefined
|
||||
}
|
||||
>
|
||||
<Footer>
|
||||
<Pill tone={card.redirects > 0 ? 'plain' : 'muted'}>
|
||||
{t('cards.redirects', {
|
||||
count: card.redirects,
|
||||
defaultValue_one: '{{count}} sign-in return address',
|
||||
defaultValue_other: '{{count}} sign-in return addresses',
|
||||
})}
|
||||
</Pill>
|
||||
{card.expiresAt && (
|
||||
<Pill tone={card.expired ? 'bad' : 'muted'}>
|
||||
{card.expired
|
||||
? t('cards.expired', 'Expired {{date}}', { date: date(card.expiresAt) })
|
||||
: t('cards.expires', 'Expires {{date}}', { date: date(card.expiresAt) })}
|
||||
</Pill>
|
||||
)}
|
||||
{since(card.createdAt)}
|
||||
</Footer>
|
||||
</CardShell>
|
||||
);
|
||||
|
||||
case 'domain': {
|
||||
const mode = (on: boolean) => (
|
||||
<Pill tone={on ? 'good' : 'muted'}>{on ? t('hover.automatic', 'Automatic') : t('hover.manual', 'Manual')}</Pill>
|
||||
);
|
||||
return (
|
||||
<CardShell
|
||||
{...shell}
|
||||
mark={
|
||||
<IconMark className="bg-sky-500/15 text-sky-700 dark:text-sky-300">
|
||||
<Globe className="h-5 w-5" />
|
||||
</IconMark>
|
||||
}
|
||||
subtitle={card.description}
|
||||
badge={
|
||||
// On is the usual state: only say so when a domain is turned off
|
||||
!card.enabled && <Pill tone="bad">{t('hover.disabled', 'Turned off')}</Pill>
|
||||
}
|
||||
>
|
||||
<div className="space-y-1.5 text-sm">
|
||||
{(
|
||||
[
|
||||
[t('hover.dns', 'DNS records'), card.dns],
|
||||
[t('hover.dkim', 'Signing keys'), card.dkim],
|
||||
[t('hover.certs', 'Certificates'), card.certs],
|
||||
] as const
|
||||
).map(([label, on]) => (
|
||||
<div key={label} className="flex items-center justify-between gap-3 text-xs">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
{mode(on)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{(card.aliases > 0 || card.createdAt) && (
|
||||
<Footer>
|
||||
{card.aliases > 0 && (
|
||||
<span>
|
||||
{t('cards.domainAliases', {
|
||||
count: card.aliases,
|
||||
defaultValue_one: '{{count}} alias domain',
|
||||
defaultValue_other: '{{count}} alias domains',
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
{since(card.createdAt)}
|
||||
</Footer>
|
||||
)}
|
||||
</CardShell>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* inbuxa: which lists show cards (admin UX roadmap, item 10, carried from
|
||||
* People to the rest of Directory and to Domains). Cards are the default;
|
||||
* the plain list is one press away, and the choice is remembered per list.
|
||||
*/
|
||||
|
||||
import { PERSON_CARD_PROPERTIES } from '@/features/people/person';
|
||||
|
||||
export type CardKind = 'person' | 'group' | 'mailingList' | 'tenant' | 'role' | 'oauthClient' | 'domain';
|
||||
|
||||
/** The lists that can show cards, and what each card is. */
|
||||
export const CARD_VIEWS: Record<string, CardKind> = {
|
||||
'x:Account/User': 'person',
|
||||
'x:Account/Group': 'group',
|
||||
'x:MailingList': 'mailingList',
|
||||
'x:Tenant': 'tenant',
|
||||
'x:Role': 'role',
|
||||
'x:OAuthClient': 'oauthClient',
|
||||
'x:Domain': 'domain',
|
||||
};
|
||||
|
||||
/** Properties a card needs beyond the list's own columns. */
|
||||
const CARD_PROPERTIES: Record<CardKind, string[]> = {
|
||||
person: PERSON_CARD_PROPERTIES,
|
||||
// A group is in no groups: it has no memberGroupIds to ask for
|
||||
group: ['usedDiskQuota', 'quotas', 'roles'],
|
||||
mailingList: ['name', 'description', 'emailAddress', 'recipients', 'aliases'],
|
||||
tenant: ['name', 'usedDiskQuota', 'quotas', 'createdAt'],
|
||||
role: ['description', 'enabledPermissions', 'disabledPermissions', 'roleIds'],
|
||||
oauthClient: ['clientId', 'description', 'redirectUris', 'expiresAt', 'createdAt'],
|
||||
domain: [
|
||||
'name',
|
||||
'description',
|
||||
'isEnabled',
|
||||
'dnsManagement',
|
||||
'dkimManagement',
|
||||
'certificateManagement',
|
||||
'aliases',
|
||||
'createdAt',
|
||||
],
|
||||
};
|
||||
|
||||
export function cardKindOf(viewName: string): CardKind | undefined {
|
||||
return CARD_VIEWS[viewName];
|
||||
}
|
||||
|
||||
export function cardPropertiesFor(viewName: string): string[] {
|
||||
const kind = cardKindOf(viewName);
|
||||
return kind ? CARD_PROPERTIES[kind] : [];
|
||||
}
|
||||
|
||||
/** Cards or the plain list, remembered per browser and per list. */
|
||||
export type ListLayout = 'cards' | 'table';
|
||||
|
||||
const keyOf = (viewName: string) => `inbuxa-list-layout:${viewName}`;
|
||||
/** Where People kept its choice before every list had one. */
|
||||
const PEOPLE_KEY = 'inbuxa-people-layout';
|
||||
|
||||
export function readListLayout(viewName: string): ListLayout {
|
||||
try {
|
||||
const stored =
|
||||
localStorage.getItem(keyOf(viewName)) ??
|
||||
(viewName === 'x:Account/User' ? localStorage.getItem(PEOPLE_KEY) : null);
|
||||
return stored === 'table' ? 'table' : 'cards';
|
||||
} catch {
|
||||
return 'cards';
|
||||
}
|
||||
}
|
||||
|
||||
export function writeListLayout(viewName: string, layout: ListLayout) {
|
||||
try {
|
||||
localStorage.setItem(keyOf(viewName), layout);
|
||||
} catch {
|
||||
// Private windows can refuse storage; the choice just isn't remembered.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { cardOf, countOf } from './objectCard';
|
||||
import { cardKindOf, cardPropertiesFor, readListLayout, writeListLayout } from './layout';
|
||||
|
||||
describe('countOf', () => {
|
||||
it('counts a set or object list in either wire shape', () => {
|
||||
expect(countOf(['a', 'b'])).toBe(2);
|
||||
expect(countOf({ '[email protected]': true, '[email protected]': true, '[email protected]': true })).toBe(3);
|
||||
expect(countOf({ 0: { name: 'x' } })).toBe(1);
|
||||
expect(countOf(undefined)).toBe(0);
|
||||
expect(countOf('nope')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cardOf', () => {
|
||||
it('names a mailing list by its description, with the address under it', () => {
|
||||
const c = cardOf('mailingList', {
|
||||
id: 'l1',
|
||||
emailAddress: '[email protected]',
|
||||
description: 'The whole team',
|
||||
recipients: { '[email protected]': true, '[email protected]': true },
|
||||
});
|
||||
expect(c).toMatchObject({ title: 'The whole team', address: '[email protected]', recipients: 2, aliases: 0 });
|
||||
});
|
||||
|
||||
it('shows a mailing list with no description by its address, once', () => {
|
||||
const c = cardOf('mailingList', { id: 'l1', emailAddress: '[email protected]' });
|
||||
expect(c).toMatchObject({ title: '[email protected]', address: undefined });
|
||||
});
|
||||
|
||||
it('reads a tenant’s storage against its limit', () => {
|
||||
const c = cardOf('tenant', { id: 't1', name: 'Acme', usedDiskQuota: 50, quotas: { maxDiskQuota: 200 } });
|
||||
expect(c).toMatchObject({ title: 'Acme', initials: 'AC', used: 50, quota: 200, fill: 0.25 });
|
||||
expect(cardOf('tenant', { id: 't2', name: 'Open', quotas: { maxDiskQuota: 0 } })).toMatchObject({
|
||||
quota: null,
|
||||
fill: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('counts a role’s permissions', () => {
|
||||
const c = cardOf('role', {
|
||||
id: 'r1',
|
||||
description: 'Help desk',
|
||||
enabledPermissions: { sysAccountGet: true, sysAccountUpdate: true },
|
||||
disabledPermissions: { sysAccountDestroy: true },
|
||||
roleIds: {},
|
||||
});
|
||||
expect(c).toMatchObject({ title: 'Help desk', granted: 2, removed: 1, includes: 0 });
|
||||
});
|
||||
|
||||
it('falls back to an OAuth client’s id for its name', () => {
|
||||
expect(
|
||||
cardOf('oauthClient', { id: 'o1', clientId: 'thunderbird', redirectUris: ['http://localhost'] }),
|
||||
).toMatchObject({ title: 'thunderbird', clientId: 'thunderbird', redirects: 1, expired: false });
|
||||
});
|
||||
|
||||
it('marks an OAuth client whose registration has run out', () => {
|
||||
const now = Date.parse('2026-10-07T00:00:00Z');
|
||||
expect(cardOf('oauthClient', { id: 'o1', clientId: 'x', expiresAt: '2026-10-01T00:00:00Z' }, now)).toMatchObject({
|
||||
expired: true,
|
||||
});
|
||||
expect(cardOf('oauthClient', { id: 'o1', clientId: 'x', expiresAt: '2027-01-01T00:00:00Z' }, now)).toMatchObject({
|
||||
expired: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('reads which parts of a domain run themselves', () => {
|
||||
const c = cardOf('domain', {
|
||||
id: 'd1',
|
||||
name: 'example.org',
|
||||
isEnabled: false,
|
||||
dnsManagement: { '@type': 'Automatic' },
|
||||
dkimManagement: { '@type': 'Manual' },
|
||||
aliases: ['example.net'],
|
||||
});
|
||||
expect(c).toMatchObject({ title: 'example.org', enabled: false, dns: true, dkim: false, certs: false, aliases: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('layout', () => {
|
||||
// In memory: Node's own `localStorage` global, empty without a storage file, hides happy-dom's.
|
||||
beforeAll(() => {
|
||||
const store = new Map<string, string>();
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: (k: string) => store.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => void store.set(k, v),
|
||||
clear: () => store.clear(),
|
||||
});
|
||||
});
|
||||
afterEach(() => localStorage.clear());
|
||||
|
||||
it('offers cards on Directory and Domains lists, not elsewhere', () => {
|
||||
expect(cardKindOf('x:Account/Group')).toBe('group');
|
||||
expect(cardKindOf('x:Domain')).toBe('domain');
|
||||
expect(cardKindOf('x:DkimSignature')).toBeUndefined();
|
||||
expect(cardPropertiesFor('x:Account/Group')).not.toContain('memberGroupIds');
|
||||
expect(cardPropertiesFor('x:Log')).toEqual([]);
|
||||
});
|
||||
|
||||
it('defaults to cards and remembers a choice per list', () => {
|
||||
expect(readListLayout('x:Domain')).toBe('cards');
|
||||
writeListLayout('x:Domain', 'table');
|
||||
expect(readListLayout('x:Domain')).toBe('table');
|
||||
expect(readListLayout('x:Tenant')).toBe('cards');
|
||||
});
|
||||
|
||||
it('keeps the choice People made before every list had one', () => {
|
||||
localStorage.setItem('inbuxa-people-layout', 'table');
|
||||
expect(readListLayout('x:Account/User')).toBe('table');
|
||||
expect(readListLayout('x:Account/Group')).toBe('cards');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* inbuxa: what a card shows for the lists beyond People (mailing lists,
|
||||
* tenants, roles, OAuth clients, domains), read from the list's row.
|
||||
*/
|
||||
|
||||
import { hueOf, initialsOf } from '@/features/people/person';
|
||||
import type { CardKind } from './layout';
|
||||
|
||||
/** How many entries a JMAP set or object list holds, whichever shape the wire used. */
|
||||
export function countOf(value: unknown): number {
|
||||
if (Array.isArray(value)) return value.length;
|
||||
if (value && typeof value === 'object') return Object.keys(value).length;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function text(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
/** A domain's DNS, signing keys or certificates: run for it, or by hand. */
|
||||
function managed(value: unknown): boolean {
|
||||
return (value as { '@type'?: string } | undefined)?.['@type'] === 'Automatic';
|
||||
}
|
||||
|
||||
export interface MailingListCard {
|
||||
kind: 'mailingList';
|
||||
title: string;
|
||||
address?: string;
|
||||
initials: string;
|
||||
hue: number;
|
||||
recipients: number;
|
||||
aliases: number;
|
||||
}
|
||||
|
||||
export interface TenantCard {
|
||||
kind: 'tenant';
|
||||
title: string;
|
||||
initials: string;
|
||||
hue: number;
|
||||
used: number;
|
||||
quota: number | null;
|
||||
fill: number | null;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export interface RoleCard {
|
||||
kind: 'role';
|
||||
title: string;
|
||||
granted: number;
|
||||
removed: number;
|
||||
includes: number;
|
||||
}
|
||||
|
||||
export interface OAuthClientCard {
|
||||
kind: 'oauthClient';
|
||||
title: string;
|
||||
clientId: string;
|
||||
redirects: number;
|
||||
expiresAt?: string;
|
||||
expired: boolean;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export interface DomainCard {
|
||||
kind: 'domain';
|
||||
title: string;
|
||||
description?: string;
|
||||
enabled: boolean;
|
||||
dns: boolean;
|
||||
dkim: boolean;
|
||||
certs: boolean;
|
||||
aliases: number;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export type ObjectCardModel = MailingListCard | TenantCard | RoleCard | OAuthClientCard | DomainCard;
|
||||
|
||||
export function cardOf(
|
||||
kind: Exclude<CardKind, 'person' | 'group'>,
|
||||
item: Record<string, unknown>,
|
||||
now = Date.now(),
|
||||
): ObjectCardModel {
|
||||
const id = String(item.id ?? '');
|
||||
const createdAt = text(item.createdAt);
|
||||
switch (kind) {
|
||||
case 'mailingList': {
|
||||
const address = text(item.emailAddress);
|
||||
const title = text(item.description) ?? text(item.name) ?? address ?? id;
|
||||
return {
|
||||
kind,
|
||||
title,
|
||||
address: address !== title ? address : undefined,
|
||||
initials: initialsOf(text(item.description), address ?? title),
|
||||
hue: hueOf(address ?? title),
|
||||
recipients: countOf(item.recipients),
|
||||
aliases: countOf(item.aliases),
|
||||
};
|
||||
}
|
||||
case 'tenant': {
|
||||
const title = text(item.name) ?? id;
|
||||
const quotas = (item.quotas ?? {}) as Record<string, unknown>;
|
||||
const quota = typeof quotas.maxDiskQuota === 'number' && quotas.maxDiskQuota > 0 ? quotas.maxDiskQuota : null;
|
||||
const used = typeof item.usedDiskQuota === 'number' ? item.usedDiskQuota : 0;
|
||||
return {
|
||||
kind,
|
||||
title,
|
||||
initials: initialsOf(title, title),
|
||||
hue: hueOf(title),
|
||||
used,
|
||||
quota,
|
||||
fill: quota ? Math.min(1, used / quota) : null,
|
||||
createdAt,
|
||||
};
|
||||
}
|
||||
case 'role':
|
||||
return {
|
||||
kind,
|
||||
title: text(item.description) ?? id,
|
||||
granted: countOf(item.enabledPermissions),
|
||||
removed: countOf(item.disabledPermissions),
|
||||
includes: countOf(item.roleIds),
|
||||
};
|
||||
case 'oauthClient': {
|
||||
const clientId = text(item.clientId) ?? id;
|
||||
const expiresAt = text(item.expiresAt);
|
||||
return {
|
||||
kind,
|
||||
title: text(item.description) ?? clientId,
|
||||
clientId,
|
||||
redirects: countOf(item.redirectUris),
|
||||
expiresAt,
|
||||
expired: expiresAt ? new Date(expiresAt).getTime() < now : false,
|
||||
createdAt,
|
||||
};
|
||||
}
|
||||
case 'domain':
|
||||
return {
|
||||
kind,
|
||||
title: text(item.name) ?? id,
|
||||
description: text(item.description),
|
||||
enabled: item.isEnabled !== false,
|
||||
dns: managed(item.dnsManagement),
|
||||
dkim: managed(item.dkimManagement),
|
||||
certs: managed(item.certificateManagement),
|
||||
aliases: countOf(item.aliases),
|
||||
createdAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
import { useCallback, useEffect, useState, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Lock, Unlock } from 'lucide-react';
|
||||
import { Lock, Unlock, Users } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { DropdownMenuItem, DropdownMenuSeparator } from '@/components/ui/dropdown-menu';
|
||||
import { useAccountStore } from '@/stores/accountStore';
|
||||
@@ -100,9 +100,19 @@ export function useDirectoryLocks(active: boolean) {
|
||||
);
|
||||
};
|
||||
|
||||
/** A badge beside a locked account's name. */
|
||||
/** A badge beside a locked account's name, or a shared mailbox's. */
|
||||
const badge = (item: Record<string, unknown>): ReactNode => {
|
||||
if (!enabled || !locks.has(String(item.id))) return null;
|
||||
const lock = enabled ? locks.get(String(item.id)) : undefined;
|
||||
if (!lock) return null;
|
||||
// A shared mailbox is locked on purpose: say what it is, not that it's shut (MA-S)
|
||||
if (lock.kind === 'sharedMailbox') {
|
||||
return (
|
||||
<Badge variant="secondary" className="ml-2 gap-1 align-middle">
|
||||
<Users className="h-3 w-3" />
|
||||
{t('lock.sharedBadge', 'Shared mailbox')}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge variant="destructive" className="ml-2 gap-1 align-middle">
|
||||
<Lock className="h-3 w-3" />
|
||||
|
||||
@@ -7,46 +7,14 @@
|
||||
/**
|
||||
* inbuxa: one person on the People list, as a card (admin UX roadmap, item
|
||||
* 10): initials, name and address, a storage ring, role, groups and since
|
||||
* when, and the row's own quick actions. It opens like a row does.
|
||||
* when, and the row's own quick actions. Groups use it too.
|
||||
*/
|
||||
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { formatSize } from '@/lib/durationFormat';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { CardShell, InitialsMark, Pill, StorageSummary } from '@/features/cards/CardShell';
|
||||
import type { Person } from './person';
|
||||
|
||||
function StorageRing({ fill }: { fill: number | null }) {
|
||||
const r = 15;
|
||||
const circumference = 2 * Math.PI * r;
|
||||
const tone =
|
||||
fill === null
|
||||
? 'text-muted-foreground/40'
|
||||
: fill >= 0.9
|
||||
? 'text-destructive'
|
||||
: fill >= 0.75
|
||||
? 'text-highlight'
|
||||
: 'text-primary';
|
||||
return (
|
||||
<svg viewBox="0 0 36 36" className="h-9 w-9 shrink-0 -rotate-90" aria-hidden="true">
|
||||
<circle cx="18" cy="18" r={r} fill="none" strokeWidth="4" className="stroke-muted" />
|
||||
{fill !== null && fill > 0 && (
|
||||
<circle
|
||||
cx="18"
|
||||
cy="18"
|
||||
r={r}
|
||||
fill="none"
|
||||
strokeWidth="4"
|
||||
strokeLinecap="round"
|
||||
className={cn('stroke-current', tone)}
|
||||
strokeDasharray={`${Math.max(fill, 0.02) * circumference} ${circumference}`}
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function PersonCard({
|
||||
person,
|
||||
selected,
|
||||
@@ -54,6 +22,7 @@ export function PersonCard({
|
||||
onOpen,
|
||||
actions,
|
||||
badge,
|
||||
hideEmptyStorage,
|
||||
}: {
|
||||
person: Person;
|
||||
/** Shown only when the list allows mass actions. */
|
||||
@@ -62,75 +31,30 @@ export function PersonCard({
|
||||
onOpen: () => void;
|
||||
actions?: ReactNode;
|
||||
badge?: ReactNode;
|
||||
hideEmptyStorage?: boolean;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const stop = (e: { stopPropagation: () => void }) => e.stopPropagation();
|
||||
const storage =
|
||||
person.quota === null
|
||||
? t('people.usedNoLimit', '{{used}} used, no limit', { used: formatSize(person.used) })
|
||||
: t('people.usedOf', '{{used}} of {{quota}}', { used: formatSize(person.used), quota: formatSize(person.quota) });
|
||||
|
||||
return (
|
||||
<article
|
||||
tabIndex={0}
|
||||
onClick={onOpen}
|
||||
onKeyDown={(e) => {
|
||||
if (e.target === e.currentTarget && (e.key === 'Enter' || e.key === ' ')) {
|
||||
e.preventDefault();
|
||||
onOpen();
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'flex cursor-pointer flex-col gap-3 rounded-xl border bg-card p-4 shadow-soft transition-colors hover:border-primary/40 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring',
|
||||
selected && 'border-primary/60 bg-accent/40',
|
||||
)}
|
||||
<CardShell
|
||||
mark={<InitialsMark initials={person.initials} hue={person.hue} />}
|
||||
title={person.name ?? person.address}
|
||||
subtitle={person.name ? person.address : undefined}
|
||||
badge={badge}
|
||||
selected={selected}
|
||||
onToggleSelect={onToggleSelect}
|
||||
onOpen={onOpen}
|
||||
actions={actions}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{onToggleSelect && (
|
||||
<div className="pt-2.5" onClick={stop}>
|
||||
<Checkbox
|
||||
checked={selected}
|
||||
onCheckedChange={onToggleSelect}
|
||||
aria-label={t('people.select', 'Select {{name}}', { name: person.name ?? person.address })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{ '--h': person.hue } as CSSProperties}
|
||||
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-[hsl(var(--h)_60%_90%)] text-sm font-semibold text-[hsl(var(--h)_55%_24%)] dark:bg-[hsl(var(--h)_35%_24%)] dark:text-[hsl(var(--h)_70%_86%)]"
|
||||
>
|
||||
{person.initials}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium">{person.name ?? person.address}</p>
|
||||
{person.name && <p className="truncate text-sm text-muted-foreground">{person.address}</p>}
|
||||
{badge}
|
||||
</div>
|
||||
{actions && (
|
||||
<div onClick={stop} onKeyDown={stop}>
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<StorageRing fill={person.fill} />
|
||||
<div className="min-w-0 text-sm">
|
||||
<p className="truncate">{storage}</p>
|
||||
{person.fill !== null && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('people.percentFull', '{{percent}}% full', { percent: Math.round(person.fill * 100) })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* A group that holds no mail and has no limit has nothing to show here */}
|
||||
{(!hideEmptyStorage || person.used > 0 || person.quota !== null) && (
|
||||
<StorageSummary used={person.used} quota={person.quota} fill={person.fill} />
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||
{person.role !== 'user' && (
|
||||
<span className="rounded-full border px-2 py-0.5 font-medium text-foreground">
|
||||
<Pill>
|
||||
{person.role === 'admin' ? t('hover.roleAdmin', 'Administrator') : t('hover.roleCustom', 'Custom role')}
|
||||
</span>
|
||||
</Pill>
|
||||
)}
|
||||
{person.groups > 0 && (
|
||||
<span>
|
||||
@@ -149,6 +73,6 @@ export function PersonCard({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
</CardShell>
|
||||
);
|
||||
}
|
||||
@@ -66,23 +66,3 @@ export function personOf(item: Record<string, unknown>): Person {
|
||||
createdAt: typeof item.createdAt === 'string' ? item.createdAt : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** Cards or the plain table, remembered per browser. */
|
||||
export type PeopleLayout = 'cards' | 'table';
|
||||
const LAYOUT_KEY = 'inbuxa-people-layout';
|
||||
|
||||
export function readPeopleLayout(): PeopleLayout {
|
||||
try {
|
||||
return localStorage.getItem(LAYOUT_KEY) === 'table' ? 'table' : 'cards';
|
||||
} catch {
|
||||
return 'cards';
|
||||
}
|
||||
}
|
||||
|
||||
export function writePeopleLayout(layout: PeopleLayout) {
|
||||
try {
|
||||
localStorage.setItem(LAYOUT_KEY, layout);
|
||||
} catch {
|
||||
// Private windows can refuse storage; the choice just isn't remembered.
|
||||
}
|
||||
}
|
||||
+24
-5
@@ -309,7 +309,10 @@
|
||||
"unknownError": "Unknown error",
|
||||
"emptyTitle": "Nothing here yet",
|
||||
"showingRange": "Showing {{from}}-{{to}} {{name}}",
|
||||
"rowActions": "Actions for {{name}}"
|
||||
"rowActions": "Actions for {{name}}",
|
||||
"layout": "Show as",
|
||||
"cards": "Cards",
|
||||
"table": "List"
|
||||
},
|
||||
"login": {
|
||||
"continue": "Continue",
|
||||
@@ -1392,7 +1395,8 @@
|
||||
"unlockTitle": "Unlock {{name}}",
|
||||
"unlockBody": "The account can sign in again and its forwarding and vacation reply resume. Delegates lose it, and any shares they had before get back what they were. The password is unchanged: if someone else may know it, set a new one on the account’s page too.",
|
||||
"unlockGo": "Unlock",
|
||||
"lockedBadge": "Locked"
|
||||
"lockedBadge": "Locked",
|
||||
"sharedBadge": "Shared mailbox"
|
||||
},
|
||||
"metricsConnect": {
|
||||
"copied": "Copied",
|
||||
@@ -2181,9 +2185,6 @@
|
||||
"skip": "Skip to content"
|
||||
},
|
||||
"people": {
|
||||
"layout": "Show people as",
|
||||
"cards": "Cards",
|
||||
"table": "Table",
|
||||
"usedNoLimit": "{{used}} used, no limit",
|
||||
"usedOf": "{{used}} of {{quota}}",
|
||||
"select": "Select {{name}}",
|
||||
@@ -2312,5 +2313,23 @@
|
||||
"daily": "Every day at {{time}} ({{zone}})",
|
||||
"monthly": "Day {{day}} of each month at {{time}} ({{zone}})",
|
||||
"weekly": "Every {{day}} at {{time}} ({{zone}})"
|
||||
},
|
||||
"cards": {
|
||||
"recipients_one": "{{count}} recipient",
|
||||
"recipients_other": "{{count}} recipients",
|
||||
"aliases_one": "{{count}} other address",
|
||||
"aliases_other": "{{count}} other addresses",
|
||||
"granted_one": "{{count}} permission granted",
|
||||
"granted_other": "{{count}} permissions granted",
|
||||
"includes_one": "Includes {{count}} role",
|
||||
"includes_other": "Includes {{count}} roles",
|
||||
"removed_one": "{{count}} taken away",
|
||||
"removed_other": "{{count}} taken away",
|
||||
"redirects_one": "{{count}} sign-in return address",
|
||||
"redirects_other": "{{count}} sign-in return addresses",
|
||||
"expired": "Expired {{date}}",
|
||||
"expires": "Expires {{date}}",
|
||||
"domainAliases_one": "{{count}} alias domain",
|
||||
"domainAliases_other": "{{count}} alias domains"
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user