Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions frontend/src/features/user-auditor/components/account-avatar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { cn } from '@/shared/lib/utils'
import { UserCog, UserX } from 'lucide-react'
import type { ADUser } from '../types/ad-user.types'
import { isService, STATUS_DOT, statusOf } from '../helpers/user-auditor-display'

export function AccountAvatar({ user, large }: { user: ADUser; large?: boolean }) {
const status = statusOf(user)
const Icon = status === 'deleted' ? UserX : UserCog
return (
<div className="relative">
<div
className={cn(
'flex shrink-0 items-center justify-center rounded-lg bg-muted text-foreground/70 ring-2 ring-border',
large ? 'h-11 w-11' : 'h-7 w-7',
)}
>
<Icon size={large ? 18 : 13} className={isService(user) ? 'text-violet-500' : 'text-sky-500'} />
</div>
<span className={cn('absolute -right-0.5 -top-0.5 h-2.5 w-2.5 rounded-full ring-2 ring-card', STATUS_DOT[status])} />
</div>
)
}
52 changes: 52 additions & 0 deletions frontend/src/features/user-auditor/components/overview-card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type { TFunction } from 'i18next'
import { Clock } from 'lucide-react'
import type { ADUserStats } from '../types/ad-user.types'

export function OverviewCard({ stats, t }: { stats: ADUserStats | null; t: TFunction }) {
const byDomain = stats?.by_domain ?? []
const maxDomain = Math.max(...byDomain.map((d) => d.count), 1)
return (
<div className="grid grid-cols-1 gap-px overflow-hidden rounded-xl border border-border bg-border md:grid-cols-[1.4fr_1fr]">
<div className="bg-card p-6">
<div className="text-[11px] uppercase tracking-wider text-muted-foreground">
{t('userAuditor.overview.title')}
</div>
<div className="mt-1 flex items-baseline gap-3">
<span className="text-3xl font-semibold tabular-nums">{stats?.total ?? 0}</span>
<span className="text-sm text-muted-foreground">
<span className="text-emerald-600 dark:text-emerald-400">{stats?.active ?? 0}</span>{' '}
{t('userAuditor.overview.active')}
<span className="mx-2 text-border">·</span>
<span className="text-amber-600 dark:text-amber-400">{stats?.disabled ?? 0}</span>{' '}
{t('userAuditor.overview.disabled')}
<span className="mx-2 text-border">·</span>
<span className="text-red-600 dark:text-red-400">{stats?.deleted ?? 0}</span>{' '}
{t('userAuditor.overview.deleted')}
</span>
</div>
<div className="mt-4 flex items-center gap-2 text-xs text-muted-foreground">
<Clock size={13} />
{t('userAuditor.overview.seen24h', { count: stats?.seen_24h ?? 0 })}
</div>
</div>

<div className="bg-card p-6">
<div className="text-[11px] uppercase tracking-wider text-muted-foreground">
{t('userAuditor.overview.byDomain')}
</div>
<ul className="mt-3 space-y-2">
{byDomain.map((d) => (
<li key={d.domain} className="flex items-center gap-3 text-xs">
<span className="w-28 shrink-0 truncate font-mono text-muted-foreground">{d.domain || '—'}</span>
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-muted">
<div className="h-full rounded-full bg-sky-500/70" style={{ width: `${(d.count / maxDomain) * 100}%` }} />
</div>
<span className="w-6 text-right font-mono tabular-nums text-muted-foreground">{d.count}</span>
</li>
))}
{byDomain.length === 0 && <li className="text-xs text-muted-foreground">—</li>}
</ul>
</div>
</div>
)
}
44 changes: 44 additions & 0 deletions frontend/src/features/user-auditor/components/saved-view-tabs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type { TFunction } from 'i18next'
import { cn } from '@/shared/lib/utils'
import { VIEW_IDS, type ViewId } from '../helpers/user-auditor-display'

export function SavedViewTabs({
current,
counts,
onChange,
t,
}: {
current: ViewId
counts: Record<ViewId, number>
onChange: (id: ViewId) => void
t: TFunction
}) {
return (
<div className="flex flex-wrap items-center gap-1 border-b border-border">
{VIEW_IDS.map((id) => {
const active = current === id
return (
<button
key={id}
onClick={() => onChange(id)}
className={cn(
'group relative flex items-center gap-2 px-3 py-2 text-xs transition-colors',
active ? 'text-foreground' : 'text-muted-foreground hover:text-foreground',
)}
>
<span>{t(`userAuditor.views.${id}`)}</span>
<span
className={cn(
'rounded-md px-1.5 py-0.5 font-mono text-[10px] tabular-nums',
active ? 'bg-primary/15 text-primary' : 'bg-muted text-muted-foreground',
)}
>
{counts[id] ?? 0}
</span>
{active && <span className="absolute inset-x-2 -bottom-px h-0.5 rounded-full bg-primary" />}
</button>
)
})}
</div>
)
}
19 changes: 19 additions & 0 deletions frontend/src/features/user-auditor/components/section-row.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { ReactNode } from 'react'

export function Section({ title, children }: { title: string; children: ReactNode }) {
return (
<div className="rounded-lg border border-border bg-card p-4">
<h4 className="mb-3 text-sm font-semibold">{title}</h4>
{children}
</div>
)
}

export function Row({ k, children }: { k: string; children: ReactNode }) {
return (
<>
<dt className="text-muted-foreground">{k}</dt>
<dd className="break-words">{children}</dd>
</>
)
}
36 changes: 36 additions & 0 deletions frontend/src/features/user-auditor/components/timeline.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type { TFunction } from 'i18next'
import type { LucideIcon } from 'lucide-react'
import { Clock, Fingerprint, UserPlus, UserX } from 'lucide-react'
import type { ADUser } from '../types/ad-user.types'
import { absTimestamp, relativeTime } from '../helpers/user-auditor-display'

export function Timeline({ user, t }: { user: ADUser; t: TFunction }) {
const events: { icon: LucideIcon; label: string; ts?: string; tone: string }[] = [
{ icon: UserPlus, label: t('userAuditor.drawer.created'), ts: user.accountCreatedAt, tone: 'text-sky-500' },
{ icon: Fingerprint, label: t('userAuditor.drawer.lastLogon'), ts: user.lastLogon, tone: 'text-emerald-500' },
{ icon: Clock, label: t('userAuditor.drawer.lastSeen'), ts: user.lastSeen, tone: 'text-muted-foreground' },
{ icon: UserX, label: t('userAuditor.drawer.deleted'), ts: user.accountDeletedAt, tone: 'text-red-500' },
].filter((e) => e.ts)

if (events.length === 0) {
return <div className="text-xs text-muted-foreground">{t('userAuditor.drawer.noLifecycle')}</div>
}

return (
<ol className="relative space-y-4 pl-6">
<span className="absolute bottom-1 left-[9px] top-1 w-px bg-border" />
{events.map((e) => (
<li key={e.label} className="relative">
<span className="absolute -left-6 flex h-[18px] w-[18px] items-center justify-center rounded-full bg-card ring-1 ring-border">
<e.icon size={11} className={e.tone} />
</span>
<div className="flex items-baseline justify-between gap-3">
<span className="text-xs font-medium">{e.label}</span>
<span className="font-mono text-[11px] text-muted-foreground">{relativeTime(e.ts, t)}</span>
</div>
<div className="font-mono text-[11px] text-muted-foreground">{absTimestamp(e.ts!)}</div>
</li>
))}
</ol>
)
}
89 changes: 89 additions & 0 deletions frontend/src/features/user-auditor/components/toolbar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import type { TFunction } from 'i18next'
import { ChevronDown, LayoutGrid, ListIcon, RefreshCw, Search, Server } from 'lucide-react'
import { cn } from '@/shared/lib/utils'
import { Input } from '@/shared/components/ui/input'

export function Toolbar({
search,
onSearch,
tenant,
tenants,
onTenant,
layout,
onLayoutChange,
onRefresh,
loading,
t,
}: {
search: string
onSearch: (s: string) => void
tenant: string
tenants: string[]
onTenant: (t: string) => void
layout: 'list' | 'cards'
onLayoutChange: (l: 'list' | 'cards') => void
onRefresh: () => void
loading: boolean
t: TFunction
}) {
return (
<div className="mt-3 flex flex-wrap items-center gap-2">
<div className="relative min-w-[280px] flex-1">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder={t('userAuditor.searchPlaceholder')}
value={search}
onChange={(e) => onSearch(e.target.value)}
className="h-9 pl-9"
/>
</div>
{tenants.length > 0 && (
<div className="relative">
<Server size={13} className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
<select
value={tenant}
onChange={(e) => onTenant(e.target.value)}
className="h-9 appearance-none rounded-md border border-border bg-background pl-8 pr-7 text-xs text-foreground"
>
<option value="">{t('userAuditor.allTenants')}</option>
{tenants.map((tn) => (
<option key={tn} value={tn}>
{tn}
</option>
))}
</select>
<ChevronDown size={12} className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 opacity-60" />
</div>
)}
<div className="ml-auto flex items-center gap-1 rounded-md border border-border p-0.5">
<button
onClick={() => onLayoutChange('list')}
title={t('userAuditor.layout.list')}
className={cn(
'flex h-7 w-7 items-center justify-center rounded transition-colors',
layout === 'list' ? 'bg-muted text-foreground' : 'text-muted-foreground hover:text-foreground',
)}
>
<ListIcon size={13} />
</button>
<button
onClick={() => onLayoutChange('cards')}
title={t('userAuditor.layout.cards')}
className={cn(
'flex h-7 w-7 items-center justify-center rounded transition-colors',
layout === 'cards' ? 'bg-muted text-foreground' : 'text-muted-foreground hover:text-foreground',
)}
>
<LayoutGrid size={13} />
</button>
</div>
<button
title={t('userAuditor.refresh')}
onClick={onRefresh}
className="flex h-8 w-8 items-center justify-center rounded-md border border-border text-muted-foreground hover:bg-muted hover:text-foreground"
>
<RefreshCw size={14} className={cn(loading && 'animate-spin')} />
</button>
</div>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { TFunction } from 'i18next'
import { Download } from 'lucide-react'
import { Button } from '@/shared/components/ui/button'

export function UserAuditorHeader({ total, t }: { total: number; t: TFunction }) {
return (
<header className="flex flex-wrap items-center justify-between gap-3">
<div className="text-xs text-muted-foreground">
<span className="font-medium text-foreground">{t('userAuditor.accountsChip', { count: total })}</span>
</div>
<div className="flex items-center gap-2">
<Button variant="default" size="sm">
<Download size={14} className="mr-2" />
{t('userAuditor.export')}
</Button>
</div>
</header>
)
}
53 changes: 53 additions & 0 deletions frontend/src/features/user-auditor/components/user-card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import type { TFunction } from 'i18next'
import { cn } from '@/shared/lib/utils'
import type { ADUser } from '../types/ad-user.types'
import {
accountIdentity,
accountName,
accountScope,
relativeTime,
STATUS_DOT,
STATUS_TEXT,
statusOf,
} from '../helpers/user-auditor-display'
import { AccountAvatar } from './account-avatar'

export function UserCard({ user, onOpen, t }: { user: ADUser; onOpen: () => void; t: TFunction }) {
const status = statusOf(user)
const identity = accountIdentity(user)
return (
<button
onClick={onOpen}
className="group flex flex-col gap-3 rounded-xl border border-border bg-card p-4 text-left transition-all hover:shadow-md"
>
<div className="flex items-start gap-3">
<AccountAvatar user={user} large />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-semibold">{accountName(user)}</span>
<span className="rounded bg-muted px-1.5 py-0.5 text-[9px] font-medium uppercase text-muted-foreground ring-1 ring-border">
{user.source}
</span>
</div>
<div className="truncate font-mono text-[11px] text-muted-foreground">{accountScope(user)}</div>
<div className="mt-1">
<span className={cn('inline-flex items-center gap-1.5 text-[11px]', STATUS_TEXT[status])}>
<span className={cn('h-1.5 w-1.5 rounded-full', STATUS_DOT[status])} />
{t(`userAuditor.status.${status}`)}
</span>
</div>
</div>
</div>
<div className="truncate border-t border-border pt-2 font-mono text-[10px] text-muted-foreground" title={identity}>
{identity}
</div>
<div className="flex items-center justify-between text-[11px] text-muted-foreground">
<span>
{t('userAuditor.card.lastLogon')}{' '}
<span className="font-mono text-foreground">{relativeTime(user.lastLogon, t)}</span>
</span>
<span className="font-mono">{user.tenantId}</span>
</div>
</button>
)
}
Loading