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
6 changes: 3 additions & 3 deletions frontend/src/app/routes/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import { LanguagePage } from '@/features/settings/pages/LanguagePage'
import { AboutPage } from '@/features/settings/pages/AboutPage'
import { LicensePage } from '@/features/billing'
import { NotificationsPage } from '@/features/notifications'
import { ApiKeysPage } from '@/features/api-keys'
import { DashboardLayout } from '@/shared/layouts/DashboardLayout'

export function AppRoutes() {
Expand Down Expand Up @@ -154,9 +155,8 @@ export function AppRoutes() {
<Route path="settings/index-patterns" element={<Navigate to="/settings/indices" replace />} />
<Route path="settings/branding" element={<BrandingPage />} />
<Route path="settings/theme" element={<Navigate to="/settings/branding" replace />} />
{/* API keys are per-user (a key authenticates AS its owner), so they live
embedded in the profile page, not system settings. */}
<Route path="settings/api-keys" element={<Navigate to="/profile" replace />} />
{/* API keys are per-user (a key authenticates AS its owner). Not available in federation. */}
{!IS_FEDERATION && <Route path="settings/api-keys" element={<ApiKeysPage />} />}
<Route path="settings/identity-providers" element={<IdentityProvidersPage />} />
{/* Federation serves email config from its own admin chrome (top-level route). */}
{!IS_FEDERATION && <Route path="settings/email" element={<EmailConfigurationPage />} />}
Expand Down
53 changes: 53 additions & 0 deletions frontend/src/features/api-keys/components/ConfirmDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { useTranslation } from 'react-i18next'
import { RefreshCw, Trash2 } from 'lucide-react'
import { toast } from 'sonner'
import { ConfirmDialog as SharedConfirmDialog } from '@/shared/components/ui/confirm-dialog'
import { apiKeysHttpService } from '../services/api-keys-http.service'
import type { ApiKey } from '../types/api-key.types'
import { apiKeyError } from './api-key-error'

export function ConfirmDialog({
state,
onClose,
onDone,
}: {
state: { kind: 'rotate' | 'delete'; key: ApiKey }
onClose: () => void
onDone: (reveal?: { name: string; token: string }) => void
}) {
const { t } = useTranslation()
const isDelete = state.kind === 'delete'

const run = async () => {
try {
if (isDelete) {
await apiKeysHttpService.remove(state.key.id)
toast.success(t('apiKeys.toast.deleted'))
onDone()
} else {
const { api_key } = await apiKeysHttpService.generate(state.key.id)
onDone({ name: state.key.name, token: api_key })
}
} catch (err) {
toast.error(apiKeyError(err, t))
}
}

return (
<SharedConfirmDialog
open
icon={isDelete ? Trash2 : RefreshCw}
title={isDelete ? t('apiKeys.confirm.deleteTitle') : t('apiKeys.confirm.rotateTitle')}
body={
isDelete
? t('apiKeys.confirm.deleteBody', { name: state.key.name })
: t('apiKeys.confirm.rotateBody', { name: state.key.name })
}
confirmLabel={isDelete ? t('apiKeys.confirm.delete') : t('apiKeys.confirm.rotate')}
cancelLabel={t('apiKeys.confirm.cancel')}
danger={isDelete}
onClose={onClose}
onConfirm={run}
/>
)
}
17 changes: 17 additions & 0 deletions frontend/src/features/api-keys/components/Field.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
export function Field({
label,
hint,
children,
}: {
label: string
hint?: string
children: React.ReactNode
}) {
return (
<div className="space-y-1.5">
<label className="block text-xs font-medium text-foreground/80">{label}</label>
{children}
{hint && <p className="text-[11px] text-muted-foreground">{hint}</p>}
</div>
)
}
27 changes: 27 additions & 0 deletions frontend/src/features/api-keys/components/IconAction.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { cn } from '@/shared/lib/utils'

export function IconAction({
label,
onClick,
danger,
children,
}: {
label: string
onClick: () => void
danger?: boolean
children: React.ReactNode
}) {
return (
<button
title={label}
aria-label={label}
onClick={onClick}
className={cn(
'flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground',
danger && 'hover:bg-red-500/10 hover:text-red-500',
)}
>
{children}
</button>
)
}
70 changes: 70 additions & 0 deletions frontend/src/features/api-keys/components/KeyRow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { useTranslation } from 'react-i18next'
import { Globe2, KeyRound, Pencil, RefreshCw, Trash2 } from 'lucide-react'
import type { ApiKey } from '../types/api-key.types'
import { IconAction } from './IconAction'
import { StatusBadge } from './StatusBadge'

export const COLS = '1.4fr 1.2fr 110px 110px 110px 100px 110px'

function formatDate(iso: string): string {
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return '—'
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: '2-digit' })
}

export function KeyRow({
apiKey: k,
onEdit,
onRotate,
onDelete,
}: {
apiKey: ApiKey
onEdit: () => void
onRotate: () => void
onDelete: () => void
}) {
const { t } = useTranslation()
return (
<div
className="grid items-center gap-3 border-b border-border px-4 py-3 text-xs last:border-b-0 hover:bg-muted/30"
style={{ gridTemplateColumns: COLS }}
>
<div className="flex min-w-0 items-center gap-2">
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
<KeyRound size={13} />
</span>
<span className="truncate font-medium">{k.name}</span>
</div>
<div className="min-w-0 truncate text-[11px] text-muted-foreground">
{k.allowed_ip.length === 0 ? (
<span className="inline-flex items-center gap-1">
<Globe2 size={11} /> {t('apiKeys.anyIp')}
</span>
) : (
<span className="font-mono" title={k.allowed_ip.join(', ')}>
{k.allowed_ip.join(', ')}
</span>
)}
</div>
<div className="text-[11px] text-muted-foreground">{formatDate(k.created_at)}</div>
<div className="text-[11px] text-muted-foreground">{formatDate(k.generated_at)}</div>
<div className="text-[11px] text-muted-foreground">
{k.expires_at ? formatDate(k.expires_at) : <span className="text-muted-foreground/60">{t('apiKeys.never')}</span>}
</div>
<div>
<StatusBadge expiresAt={k.expires_at} />
</div>
<div className="flex items-center justify-end gap-1">
<IconAction label={t('apiKeys.action.rotate')} onClick={onRotate}>
<RefreshCw size={13} />
</IconAction>
<IconAction label={t('apiKeys.action.edit')} onClick={onEdit}>
<Pencil size={13} />
</IconAction>
<IconAction label={t('apiKeys.action.delete')} danger onClick={onDelete}>
<Trash2 size={13} />
</IconAction>
</div>
</div>
)
}
36 changes: 36 additions & 0 deletions frontend/src/features/api-keys/components/Modal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { X, type LucideIcon } from 'lucide-react'

export function Modal({
title,
icon: Icon,
onClose,
children,
}: {
title: string
icon: LucideIcon
onClose: () => void
children: React.ReactNode
}) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4 backdrop-blur-sm" onClick={onClose}>
<div
className="flex w-full max-w-lg flex-col overflow-hidden rounded-xl border border-border bg-card shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<header className="flex items-center justify-between gap-4 border-b border-border px-6 py-4">
<h2 className="flex items-center gap-2 text-base font-semibold">
<Icon size={17} strokeWidth={1.75} />
{title}
</h2>
<button
onClick={onClose}
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"
>
<X size={16} />
</button>
</header>
{children}
</div>
</div>
)
}
40 changes: 40 additions & 0 deletions frontend/src/features/api-keys/components/RevealModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Check, Copy, KeyRound, TriangleAlert } from 'lucide-react'
import { Button } from '@/shared/components/ui/button'
import { Modal } from './Modal'

export function RevealModal({ name, token, onClose }: { name: string; token: string; onClose: () => void }) {
const { t } = useTranslation()
const [copied, setCopied] = useState(false)
const copy = () => {
navigator.clipboard.writeText(token).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 1500)
})
}
return (
<Modal onClose={onClose} title={t('apiKeys.reveal.title')} icon={KeyRound}>
<div className="space-y-4 px-6 py-5">
<p className="flex items-start gap-2 rounded-md border border-amber-500/30 bg-amber-500/5 px-3 py-2 text-xs text-amber-700 dark:text-amber-300">
<TriangleAlert size={14} className="mt-0.5 shrink-0" />
{t('apiKeys.reveal.note', { name })}
</p>
<div className="flex items-stretch gap-2">
<code className="min-w-0 flex-1 truncate rounded-md border border-border bg-muted/40 px-3 py-2 font-mono text-xs">
{token}
</code>
<Button variant="outline" size="sm" onClick={copy}>
{copied ? <Check size={14} className="text-emerald-500" /> : <Copy size={14} />}
<span className="ml-1.5">{copied ? t('apiKeys.reveal.copied') : t('apiKeys.reveal.copy')}</span>
</Button>
</div>
</div>
<footer className="flex items-center justify-end border-t border-border px-6 py-3">
<Button size="sm" onClick={onClose}>
{t('apiKeys.reveal.done')}
</Button>
</footer>
</Modal>
)
}
29 changes: 29 additions & 0 deletions frontend/src/features/api-keys/components/StatusBadge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { useTranslation } from 'react-i18next'
import { cn } from '@/shared/lib/utils'

const EXPIRING_WINDOW_MS = 7 * 86_400_000

function keyStatus(expiresAt: string | null): 'active' | 'expiring' | 'expired' {
if (!expiresAt) return 'active'
const exp = new Date(expiresAt).getTime()
const now = Date.now()
if (exp <= now) return 'expired'
if (exp - now <= EXPIRING_WINDOW_MS) return 'expiring'
return 'active'
}

export function StatusBadge({ expiresAt }: { expiresAt: string | null }) {
const { t } = useTranslation()
const status = keyStatus(expiresAt)
const meta = {
active: { label: t('apiKeys.status.active'), cls: 'bg-emerald-500/15 text-emerald-600 ring-emerald-500/30 dark:text-emerald-300', dot: 'bg-emerald-500' },
expiring: { label: t('apiKeys.status.expiring'), cls: 'bg-amber-500/15 text-amber-600 ring-amber-500/30 dark:text-amber-300', dot: 'bg-amber-500' },
expired: { label: t('apiKeys.status.expired'), cls: 'bg-red-500/15 text-red-500 ring-red-500/30 dark:text-red-300', dot: 'bg-red-500' },
}[status]
return (
<span className={cn('inline-flex items-center gap-1.5 rounded-md px-1.5 py-0.5 text-[10px] font-medium ring-1 ring-inset', meta.cls)}>
<span className={cn('h-1.5 w-1.5 rounded-full', meta.dot)} />
{meta.label}
</span>
)
}
Loading
Loading