diff --git a/src/components/lists/DynamicList.tsx b/src/components/lists/DynamicList.tsx index 22425e9..4d6018f 100644 --- a/src/components/lists/DynamicList.tsx +++ b/src/components/lists/DynamicList.tsx @@ -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(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>({}); + const layout = layouts[viewName] ?? readListLayout(viewName); + const showCards = cardKind !== undefined && layout === 'cards'; const [items, setItems] = useState[]>([]); const [total, setTotal] = useState(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) { />
- {peopleView && ( -
+ {cardKind && ( +
{(['cards', 'table'] as const).map((mode) => ( ))} @@ -1403,9 +1400,21 @@ export function DynamicList({ viewName }: DynamicListProps) { {t('list.selectAll', 'Select all')} )} -
+
{items.map((item) => { const itemId = item.id as string; + if (cardKind !== 'person' && cardKind !== 'group') { + return ( + toggleSelectItem(itemId) : undefined} + onOpen={() => handleRowClick(item)} + actions={renderItemActions(item)} + /> + ); + } return ( handleRowClick(item)} actions={renderItemActions(item)} badge={directoryLocks.badge(item)} + hideEmptyStorage={cardKind === 'group'} /> ); })} diff --git a/src/features/cards/CardShell.tsx b/src/features/cards/CardShell.tsx new file mode 100644 index 0000000..b7e9a02 --- /dev/null +++ b/src/features/cards/CardShell.tsx @@ -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 ( + + ); +} + +/** An icon on a tinted square, for things that aren't people. */ +export function IconMark({ children, className }: { children: ReactNode; className: string }) { + return ( + + ); +} + +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 ( + + ); +} + +/** 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 ( +
+ +
+

{storage}

+ {fill !== null && ( +

+ {t('people.percentFull', '{{percent}}% full', { percent: Math.round(fill * 100) })} +

+ )} +
+
+ ); +} + +/** A small rounded label in the card's footer. */ +export function Pill({ children, tone = 'plain' }: { children: ReactNode; tone?: 'plain' | 'good' | 'muted' | 'bad' }) { + return ( + + {children} + + ); +} + +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 ( +
{ + 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', + )} + > +
+ {onToggleSelect && ( +
+ +
+ )} + {mark} +
+

{title}

+ {subtitle &&

{subtitle}

} + {/* A list's badges sit beside the name; here they go under it */} + {badge &&
{badge}
} +
+ {actions && ( +
+ {actions} +
+ )} +
+ {children} +
+ ); +} diff --git a/src/features/cards/ObjectCard.tsx b/src/features/cards/ObjectCard.tsx new file mode 100644 index 0000000..b018964 --- /dev/null +++ b/src/features/cards/ObjectCard.tsx @@ -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
{children}
; +} + +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 && {t('people.since', 'Since {{date}}', { date: date(iso) })}; + const shell = { title: card.title, selected, onToggleSelect, onOpen, actions }; + + switch (card.kind) { + case 'mailingList': + return ( + } subtitle={card.address}> +
+ 0 ? 'plain' : 'muted'}> + {t('cards.recipients', { + count: card.recipients, + defaultValue_one: '{{count}} recipient', + defaultValue_other: '{{count}} recipients', + })} + + {card.aliases > 0 && ( + + {t('cards.aliases', { + count: card.aliases, + defaultValue_one: '{{count}} other address', + defaultValue_other: '{{count}} other addresses', + })} + + )} +
+
+ ); + + case 'tenant': + return ( + }> + +
{since(card.createdAt)}
+
+ ); + + case 'role': + return ( + + + + } + subtitle={t('cards.granted', { + count: card.granted, + defaultValue_one: '{{count}} permission granted', + defaultValue_other: '{{count}} permissions granted', + })} + > + {(card.removed > 0 || card.includes > 0) && ( +
+ {card.includes > 0 && ( + + {t('cards.includes', { + count: card.includes, + defaultValue_one: 'Includes {{count}} role', + defaultValue_other: 'Includes {{count}} roles', + })} + + )} + {card.removed > 0 && ( + + {t('cards.removed', { + count: card.removed, + defaultValue_one: '{{count}} taken away', + defaultValue_other: '{{count}} taken away', + })} + + )} +
+ )} +
+ ); + + case 'oauthClient': + return ( + + + + } + subtitle={ + card.clientId !== card.title ? {card.clientId} : undefined + } + > +
+ 0 ? 'plain' : 'muted'}> + {t('cards.redirects', { + count: card.redirects, + defaultValue_one: '{{count}} sign-in return address', + defaultValue_other: '{{count}} sign-in return addresses', + })} + + {card.expiresAt && ( + + {card.expired + ? t('cards.expired', 'Expired {{date}}', { date: date(card.expiresAt) }) + : t('cards.expires', 'Expires {{date}}', { date: date(card.expiresAt) })} + + )} + {since(card.createdAt)} +
+
+ ); + + case 'domain': { + const mode = (on: boolean) => ( + {on ? t('hover.automatic', 'Automatic') : t('hover.manual', 'Manual')} + ); + return ( + + + + } + subtitle={card.description} + badge={ + // On is the usual state: only say so when a domain is turned off + !card.enabled && {t('hover.disabled', 'Turned off')} + } + > +
+ {( + [ + [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]) => ( +
+ {label} + {mode(on)} +
+ ))} +
+ {(card.aliases > 0 || card.createdAt) && ( +
+ {card.aliases > 0 && ( + + {t('cards.domainAliases', { + count: card.aliases, + defaultValue_one: '{{count}} alias domain', + defaultValue_other: '{{count}} alias domains', + })} + + )} + {since(card.createdAt)} +
+ )} +
+ ); + } + } +} diff --git a/src/features/cards/layout.ts b/src/features/cards/layout.ts new file mode 100644 index 0000000..b3d6597 --- /dev/null +++ b/src/features/cards/layout.ts @@ -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 = { + '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 = { + 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. + } +} diff --git a/src/features/cards/objectCard.test.ts b/src/features/cards/objectCard.test.ts new file mode 100644 index 0000000..c4031b7 --- /dev/null +++ b/src/features/cards/objectCard.test.ts @@ -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({ 'a@example.org': true, 'b@example.org': true, 'c@example.org': 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: 'team@example.org', + description: 'The whole team', + recipients: { 'a@example.org': true, 'b@example.org': true }, + }); + expect(c).toMatchObject({ title: 'The whole team', address: 'team@example.org', recipients: 2, aliases: 0 }); + }); + + it('shows a mailing list with no description by its address, once', () => { + const c = cardOf('mailingList', { id: 'l1', emailAddress: 'team@example.org' }); + expect(c).toMatchObject({ title: 'team@example.org', 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(); + 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'); + }); +}); diff --git a/src/features/cards/objectCard.ts b/src/features/cards/objectCard.ts new file mode 100644 index 0000000..ed4690d --- /dev/null +++ b/src/features/cards/objectCard.ts @@ -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, + item: Record, + 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; + 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, + }; + } +} diff --git a/src/features/lock/useDirectoryLocks.tsx b/src/features/lock/useDirectoryLocks.tsx index 4fd67d4..72218be 100644 --- a/src/features/lock/useDirectoryLocks.tsx +++ b/src/features/lock/useDirectoryLocks.tsx @@ -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): 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 ( + + + {t('lock.sharedBadge', 'Shared mailbox')} + + ); + } return ( diff --git a/src/features/people/PersonCard.tsx b/src/features/people/PersonCard.tsx index 1d4ca2f..4f9e3b6 100644 --- a/src/features/people/PersonCard.tsx +++ b/src/features/people/PersonCard.tsx @@ -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 ( - - ); -} - 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 ( -
{ - 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', - )} + } + title={person.name ?? person.address} + subtitle={person.name ? person.address : undefined} + badge={badge} + selected={selected} + onToggleSelect={onToggleSelect} + onOpen={onOpen} + actions={actions} > -
- {onToggleSelect && ( -
- -
- )} - -
-

{person.name ?? person.address}

- {person.name &&

{person.address}

} - {badge} -
- {actions && ( -
- {actions} -
- )} -
- -
- -
-

{storage}

- {person.fill !== null && ( -

- {t('people.percentFull', '{{percent}}% full', { percent: Math.round(person.fill * 100) })} -

- )} -
-
+ {/* A group that holds no mail and has no limit has nothing to show here */} + {(!hideEmptyStorage || person.used > 0 || person.quota !== null) && ( + + )}
{person.role !== 'user' && ( - + {person.role === 'admin' ? t('hover.roleAdmin', 'Administrator') : t('hover.roleCustom', 'Custom role')} - + )} {person.groups > 0 && ( @@ -149,6 +73,6 @@ export function PersonCard({ )}
-
+ ); } diff --git a/src/features/people/person.ts b/src/features/people/person.ts index 0b1315c..98c11a5 100644 --- a/src/features/people/person.ts +++ b/src/features/people/person.ts @@ -66,23 +66,3 @@ export function personOf(item: Record): 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. - } -} diff --git a/src/i18n/en.json b/src/i18n/en.json index 089cda9..090a3af 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -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" } }