Initial commit: Elydora Open Source — self-hosted audit platform for AI agents

Full self-hosted port of the Elydora tamper-evident audit protocol:
- Hono API server with PostgreSQL, Redis, MinIO adapters
- Ed25519 signed operations, chain hashing, Merkle epoch rollups
- PBKDF2 password hashing, HMAC-SHA256 JWT, 5-role RBAC
- Next.js management console
- Node.js, Python, and Go SDKs
- Docker Compose for local deployment
- Helm chart for Kubernetes
- Comprehensive security hardening and audit fixes applied

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 4.6 committed 2026-03-02 13:41:42 -05:00
1 parent 15547a8c35
commit e390e68594
207 files changed
+32790 -63

No files matched your search

@@ -0,0 +1,484 @@
'use client';
import { useState, useCallback } from 'react';
import { useAgent, useAudit, formatTimestamp } from '@/lib/hooks';
import { api } from '@/lib/api';
import PageHeader from '@/components/ui/PageHeader';
import StatusBadge from '@/components/ui/StatusBadge';
import CopyButton from '@/components/ui/CopyButton';
import DataTable from '@/components/ui/DataTable';
import type { Column } from '@/components/ui/DataTable';
import Modal from '@/components/ui/Modal';
import Link from 'next/link';
import type { AgentKey, Operation } from '@elydora/shared';
import { useTranslation } from 'react-i18next';
const HOOKS_INTEGRATIONS = new Set([
'claudecode', 'cursor', 'gemini', 'kirocli', 'kiroide', 'opencode', 'copilot', 'letta',
]);
export default function AgentDetailClient({ agentId }: { agentId: string }) {
const { t } = useTranslation();
const { data: agentData, isLoading: agentLoading, error: agentError, mutate: mutateAgent } = useAgent(agentId);
const { data: opsData, isLoading: opsLoading } = useAudit({ agent_id: agentId });
const [showFreezeModal, setShowFreezeModal] = useState(false);
const [freezeReason, setFreezeReason] = useState('');
const [freezing, setFreezing] = useState(false);
const [freezeError, setFreezeError] = useState<string | null>(null);
const [showUnfreezeModal, setShowUnfreezeModal] = useState(false);
const [unfreezeReason, setUnfreezeReason] = useState('');
const [unfreezing, setUnfreezing] = useState(false);
const [unfreezeError, setUnfreezeError] = useState<string | null>(null);
const [revokeKeyId, setRevokeKeyId] = useState<string | null>(null);
const [revokeReason, setRevokeReason] = useState('');
const [revoking, setRevoking] = useState(false);
const [revokeError, setRevokeError] = useState<string | null>(null);
const agent = agentData?.agent;
const supportsFreeze = !agent?.integration_type || HOOKS_INTEGRATIONS.has(agent.integration_type);
const keys = agentData?.keys ?? [];
const operations = opsData?.operations ?? [];
const handleFreeze = useCallback(async () => {
if (!freezeReason.trim()) {
setFreezeError(t('agentDetail.reasonRequired'));
return;
}
setFreezing(true);
setFreezeError(null);
try {
await api.agents.freeze(agentId, freezeReason.trim());
setShowFreezeModal(false);
setFreezeReason('');
mutateAgent();
} catch (err) {
setFreezeError(err instanceof Error ? err.message : t('agentDetail.failedToFreeze'));
} finally {
setFreezing(false);
}
}, [agentId, freezeReason, mutateAgent, t]);
const handleUnfreeze = useCallback(async () => {
if (!unfreezeReason.trim()) {
setUnfreezeError(t('agentDetail.reasonRequired'));
return;
}
setUnfreezing(true);
setUnfreezeError(null);
try {
await api.agents.unfreeze(agentId, unfreezeReason.trim());
setShowUnfreezeModal(false);
setUnfreezeReason('');
mutateAgent();
} catch (err) {
setUnfreezeError(err instanceof Error ? err.message : t('agentDetail.failedToUnfreeze'));
} finally {
setUnfreezing(false);
}
}, [agentId, unfreezeReason, mutateAgent, t]);
const handleRevokeKey = useCallback(async () => {
if (!revokeKeyId || !revokeReason.trim()) {
setRevokeError(t('agentDetail.reasonRequired'));
return;
}
setRevoking(true);
setRevokeError(null);
try {
await api.agents.revokeKey(agentId, revokeKeyId, revokeReason.trim());
setRevokeKeyId(null);
setRevokeReason('');
mutateAgent();
} catch (err) {
setRevokeError(err instanceof Error ? err.message : t('agentDetail.failedToRevoke'));
} finally {
setRevoking(false);
}
}, [agentId, revokeKeyId, revokeReason, mutateAgent, t]);
const keyColumns: Column<AgentKey & Record<string, unknown>>[] = [
{
key: 'kid',
label: t('agentDetail.colKeyId'),
render: (row) => (
<div className="flex items-center gap-1.5">
<span className="font-mono text-[13px] text-ink">{row.kid}</span>
<CopyButton text={row.kid} />
</div>
),
},
{
key: 'algorithm',
label: t('agentDetail.colAlgorithm'),
width: '120px',
render: (row) => (
<span className="font-mono text-[11px] uppercase tracking-wider px-2 py-1 bg-surface border border-border">
{row.algorithm}
</span>
),
},
{
key: 'status',
label: t('agentDetail.colStatus'),
width: '120px',
render: (row) => <StatusBadge status={row.status} size="sm" />,
},
{
key: 'created_at',
label: t('agentDetail.colCreated'),
render: (row) => (
<span className="font-mono text-[12px] text-ink-dim">
{formatTimestamp(row.created_at)}
</span>
),
},
{
key: 'actions',
label: '',
width: '80px',
render: (row) =>
row.status === 'active' ? (
<button
className="btn-ghost text-[10px] py-1 px-2"
onClick={(e) => {
e.stopPropagation();
setRevokeKeyId(row.kid);
}}
>
{t('agentDetail.revoke')}
</button>
) : null,
},
];
const opColumns: Column<Operation & Record<string, unknown>>[] = [
{
key: 'operation_id',
label: t('operationCard.operationId'),
render: (row) => (
<Link
href={`/operations/${row.operation_id}`}
className="font-mono text-[13px] text-ink hover:underline no-underline"
>
{row.operation_id.slice(0, 16)}...
</Link>
),
},
{
key: 'operation_type',
label: t('operationCard.type'),
render: (row) => (
<span className="font-mono text-[11px] uppercase tracking-wider px-2 py-1 bg-surface border border-border">
{row.operation_type}
</span>
),
},
{
key: 'issued_at',
label: t('operationCard.issuedAt'),
sortable: true,
render: (row) => (
<span className="font-mono text-[12px] text-ink-dim">
{formatTimestamp(row.issued_at)}
</span>
),
},
{
key: 'seq_no',
label: t('operationCard.seqNo'),
width: '80px',
render: (row) => (
<span className="font-mono text-[13px] text-ink-dim">#{row.seq_no}</span>
),
},
];
if (agentLoading) {
return (
<div className="fade-in">
<PageHeader
title={t('agentDetail.title')}
breadcrumbs={[
{ label: t('common.dashboard'), href: '/' },
{ label: t('common.agents'), href: '/agents' },
{ label: agentId },
]}
/>
<div className="space-y-4">
<div className="border border-border bg-surface p-6">
<div className="space-y-3">
<div className="skeleton h-6 w-64" />
<div className="skeleton h-4 w-48" />
<div className="skeleton h-4 w-32" />
</div>
</div>
</div>
</div>
);
}
if (agentError) {
return (
<div className="fade-in">
<PageHeader
title={t('agentDetail.title')}
breadcrumbs={[
{ label: t('common.dashboard'), href: '/' },
{ label: t('common.agents'), href: '/agents' },
{ label: agentId },
]}
/>
<div className="px-4 py-3 border border-red-300 bg-red-50 font-mono text-[12px] text-red-700">
{t('agentDetail.failedToLoad')} {agentError instanceof Error ? agentError.message : t('agentDetail.mayNotExist')}
</div>
</div>
);
}
return (
<div className="fade-in">
<PageHeader
title={agent?.display_name ?? agentId}
subtitle={agent?.display_name ? agentId : undefined}
breadcrumbs={[
{ label: t('common.dashboard'), href: '/' },
{ label: t('common.agents'), href: '/agents' },
{ label: agent?.display_name ?? agentId },
]}
actions={
supportsFreeze && agent?.status === 'active' ? (
<button
className="btn-brutalist"
onClick={() => setShowFreezeModal(true)}
>
{t('agentDetail.freezeAgent')}
</button>
) : supportsFreeze && agent?.status === 'frozen' ? (
<button
className="btn-brutalist"
onClick={() => setShowUnfreezeModal(true)}
>
{t('agentDetail.unfreezeAgent')}
</button>
) : undefined
}
/>
{/* Agent Info Card */}
<div className="border border-border bg-surface p-4 sm:p-6 mb-6 relative">
<span className="crosshair ch-tl" />
<span className="crosshair ch-tr" />
<span className="crosshair ch-bl" />
<span className="crosshair ch-br" />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div>
<div className="section-label mb-1">{t('agentDetail.agentId')}</div>
<div className="flex items-center gap-1.5">
<span className="font-mono text-[14px] text-ink break-all">{agent?.agent_id}</span>
{agent?.agent_id && <CopyButton text={agent.agent_id} />}
</div>
</div>
<div>
<div className="section-label mb-1">{t('agentDetail.status')}</div>
{agent?.status && <StatusBadge status={agent.status} />}
</div>
<div>
<div className="section-label mb-1">{t('agentDetail.organization')}</div>
<span className="font-mono text-[13px] text-ink break-all">{agent?.org_id}</span>
</div>
<div>
<div className="section-label mb-1">{t('agentDetail.displayName')}</div>
<span className="font-sans text-sm text-ink">
{agent?.display_name ?? '\u2014'}
</span>
</div>
<div>
<div className="section-label mb-1">{t('agentDetail.responsibleEntity')}</div>
<span className="font-sans text-sm text-ink">
{agent?.responsible_entity ?? '\u2014'}
</span>
</div>
<div>
<div className="section-label mb-1">{t('agentDetail.created')}</div>
<span className="font-mono text-[12px] text-ink-dim">
{agent?.created_at ? formatTimestamp(agent.created_at) : '\u2014'}
</span>
</div>
</div>
</div>
{/* Keys */}
<div className="mb-6">
<div className="section-label mb-3">{t('agentDetail.keys')} ({keys.length})</div>
<DataTable
columns={keyColumns}
data={keys as (AgentKey & Record<string, unknown>)[]}
keyExtractor={(row) => row.kid}
emptyMessage={t('agentDetail.noKeys')}
/>
</div>
{/* Recent Operations */}
<div>
<div className="flex items-center justify-between mb-3">
<div className="section-label">
{t('agentDetail.operations')} ({opsData?.total_count ?? 0})
</div>
<Link
href={`/operations?agent_id=${agentId}`}
className="font-mono text-[11px] text-ink-dim hover:text-ink transition-colors no-underline uppercase tracking-wider"
>
{t('common.viewAll')}
</Link>
</div>
<DataTable
columns={opColumns}
data={(operations.slice(0, 10)) as (Operation & Record<string, unknown>)[]}
keyExtractor={(row) => row.operation_id}
isLoading={opsLoading}
emptyMessage={t('agentDetail.noOperations')}
/>
</div>
{/* Freeze Modal */}
<Modal
isOpen={showFreezeModal}
onClose={() => {
setShowFreezeModal(false);
setFreezeError(null);
}}
title={t('agentDetail.freezeAgent')}
>
<div className="space-y-4">
<p className="font-sans text-sm text-ink-dim">
{t('agentDetail.freezeDescPre')}<strong className="text-ink">{agentId}</strong>{t('agentDetail.freezeDescPost')}
</p>
{freezeError && (
<div className="px-4 py-3 border border-red-300 bg-red-50 font-mono text-[12px] text-red-700">
{freezeError}
</div>
)}
<div>
<label className="section-label block mb-1.5">{t('agentDetail.reason')}</label>
<textarea
value={freezeReason}
onChange={(e) => setFreezeReason(e.target.value)}
placeholder={t('agentDetail.freezePlaceholder')}
rows={3}
className="w-full px-3 py-2 bg-transparent border border-border font-mono text-[13px] text-ink placeholder:text-ink-dim focus:outline-none focus:border-ink transition-colors resize-none"
/>
</div>
<div className="flex items-center justify-end gap-3 pt-2">
<button
className="btn-ghost"
onClick={() => {
setShowFreezeModal(false);
setFreezeError(null);
}}
>
{t('common.cancel')}
</button>
<button className="btn-brutalist" onClick={handleFreeze} disabled={freezing}>
{freezing ? t('agentDetail.freezing') : t('agentDetail.confirmFreeze')}
</button>
</div>
</div>
</Modal>
{/* Unfreeze Modal */}
<Modal
isOpen={showUnfreezeModal}
onClose={() => {
setShowUnfreezeModal(false);
setUnfreezeError(null);
}}
title={t('agentDetail.unfreezeAgent')}
>
<div className="space-y-4">
<p className="font-sans text-sm text-ink-dim">
{t('agentDetail.unfreezeDescPre')}<strong className="text-ink">{agentId}</strong>{t('agentDetail.unfreezeDescPost')}
</p>
{unfreezeError && (
<div className="px-4 py-3 border border-red-300 bg-red-50 font-mono text-[12px] text-red-700">
{unfreezeError}
</div>
)}
<div>
<label className="section-label block mb-1.5">{t('agentDetail.reason')}</label>
<textarea
value={unfreezeReason}
onChange={(e) => setUnfreezeReason(e.target.value)}
placeholder={t('agentDetail.unfreezePlaceholder')}
rows={3}
className="w-full px-3 py-2 bg-transparent border border-border font-mono text-[13px] text-ink placeholder:text-ink-dim focus:outline-none focus:border-ink transition-colors resize-none"
/>
</div>
<div className="flex items-center justify-end gap-3 pt-2">
<button
className="btn-ghost"
onClick={() => {
setShowUnfreezeModal(false);
setUnfreezeError(null);
}}
>
{t('common.cancel')}
</button>
<button className="btn-brutalist" onClick={handleUnfreeze} disabled={unfreezing}>
{unfreezing ? t('agentDetail.unfreezing') : t('agentDetail.confirmUnfreeze')}
</button>
</div>
</div>
</Modal>
{/* Revoke Key Modal */}
<Modal
isOpen={revokeKeyId !== null}
onClose={() => {
setRevokeKeyId(null);
setRevokeError(null);
}}
title={t('agentDetail.revokeKey')}
>
<div className="space-y-4">
<p className="font-sans text-sm text-ink-dim">
{t('agentDetail.revokeKeyDescPre')}<strong className="text-ink font-mono text-[13px]">{revokeKeyId}</strong>{t('agentDetail.revokeKeyDescPost')}
</p>
{revokeError && (
<div className="px-4 py-3 border border-red-300 bg-red-50 font-mono text-[12px] text-red-700">
{revokeError}
</div>
)}
<div>
<label className="section-label block mb-1.5">{t('agentDetail.reason')}</label>
<textarea
value={revokeReason}
onChange={(e) => setRevokeReason(e.target.value)}
placeholder={t('agentDetail.revokePlaceholder')}
rows={3}
className="w-full px-3 py-2 bg-transparent border border-border font-mono text-[13px] text-ink placeholder:text-ink-dim focus:outline-none focus:border-ink transition-colors resize-none"
/>
</div>
<div className="flex items-center justify-end gap-3 pt-2">
<button
className="btn-ghost"
onClick={() => {
setRevokeKeyId(null);
setRevokeError(null);
}}
>
{t('common.cancel')}
</button>
<button className="btn-brutalist" onClick={handleRevokeKey} disabled={revoking}>
{revoking ? t('agentDetail.revoking') : t('agentDetail.confirmRevoke')}
</button>
</div>
</div>
</Modal>
</div>
);
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,74 @@
'use client';
import { usePathname, useRouter } from 'next/navigation';
import { useEffect, useState, useCallback } from 'react';
import { AuthProvider, useAuth } from '@/lib/auth';
import Sidebar from '@/components/ui/Sidebar';
function AuthGuard({ children }: { children: React.ReactNode }) {
const { isAuthenticated, isLoading } = useAuth();
const pathname = usePathname();
const router = useRouter();
const [sidebarOpen, setSidebarOpen] = useState(false);
const closeSidebar = useCallback(() => setSidebarOpen(false), []);
// Close sidebar on route change (mobile)
useEffect(() => {
setSidebarOpen(false);
}, [pathname]);
const isPublicRoute = pathname === '/login' || pathname === '/register';
useEffect(() => {
if (!isLoading && !isAuthenticated && !isPublicRoute) {
router.push('/login');
}
}, [isLoading, isAuthenticated, isPublicRoute, router]);
if (isLoading) {
return <div className="min-h-screen bg-bg" />;
}
if (isPublicRoute) {
return <>{children}</>;
}
if (!isAuthenticated) {
return <div className="min-h-screen bg-bg" />;
}
return (
<div className="min-h-screen">
<Sidebar isOpen={sidebarOpen} onClose={closeSidebar} />
{/* Mobile header bar */}
<div className="fixed top-0 left-0 right-0 z-30 flex items-center gap-3 px-4 h-14 bg-ink md:hidden">
<button
onClick={() => setSidebarOpen(true)}
className="p-2 -ml-2 text-[#EAEAE5]"
aria-label="Open menu"
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M3 5h14M3 10h14M3 15h14" />
</svg>
</button>
<span className="font-sans text-sm font-semibold tracking-wide text-[#EAEAE5]">ELYDORA</span>
</div>
<main className="min-h-screen md:ml-[260px]">
<div className="max-w-[1400px] mx-auto px-4 md:px-6 lg:px-12 pt-20 md:pt-8 pb-8 overflow-hidden">
{children}
</div>
</main>
</div>
);
}
export default function AppShell({ children }: { children: React.ReactNode }) {
return (
<AuthProvider>
<AuthGuard>{children}</AuthGuard>
</AuthProvider>
);
}
@@ -0,0 +1,89 @@
'use client';
import CopyButton from '@/components/ui/CopyButton';
import { truncateHash } from '@/lib/hooks';
import { useTranslation } from 'react-i18next';
interface ChainBlock {
label: string;
hash: string;
isCurrent?: boolean;
}
interface ChainVisualizationProps {
prevHash: string;
currentHash: string;
operationId: string;
}
function Block({ label, hash, isCurrent }: ChainBlock) {
return (
<div
className={`border ${
isCurrent ? 'border-ink bg-white' : 'border-border bg-surface'
} p-3 sm:p-4 min-w-[140px] sm:min-w-[180px] shrink-0`}
>
<div className="section-label mb-2 truncate">{label}</div>
<div className="flex items-center gap-1.5">
<span className="font-mono text-[11px] sm:text-[12px] text-ink">
{truncateHash(hash, 8)}
</span>
<CopyButton text={hash} />
</div>
</div>
);
}
function Arrow() {
return (
<div className="flex items-center px-1 sm:px-2 shrink-0">
<svg width="24" height="12" viewBox="0 0 24 12" fill="none" className="text-ink-dim sm:w-8">
<path d="M0 6h20M16 1l5 5-5 5" stroke="currentColor" strokeWidth="1.5" />
</svg>
</div>
);
}
export default function ChainVisualization({
prevHash,
currentHash,
operationId,
}: ChainVisualizationProps) {
const { t } = useTranslation();
return (
<div className="border border-border bg-surface p-4 sm:p-6 overflow-hidden">
<h3 className="section-label mb-4">{t('chain.title')}</h3>
<div className="flex items-center overflow-x-auto pb-2 -mx-1 px-1">
<Block label={t('chain.previous')} hash={prevHash} />
<Arrow />
<Block label={t('chain.current', { id: truncateHash(operationId, 6) })} hash={currentHash} isCurrent />
<Arrow />
<div className="border border-border border-dashed p-3 sm:p-4 min-w-[140px] sm:min-w-[180px] bg-surface shrink-0">
<div className="section-label mb-2">{t('chain.next')}</div>
<span className="font-mono text-[12px] text-ink-dim italic">
{t('chain.pending')}
</span>
</div>
</div>
<div className="mt-4 pt-4 border-t border-border">
<div className="flex items-center gap-4 sm:gap-6 text-[11px] font-mono text-ink-dim flex-wrap">
<span className="flex items-center gap-2">
<span className="w-3 h-3 border border-border bg-surface inline-block" />
{t('chain.chainBlock')}
</span>
<span className="flex items-center gap-2">
<span className="w-3 h-3 border border-ink bg-white inline-block" />
{t('chain.currentOperation')}
</span>
<span className="flex items-center gap-2">
<span className="w-3 h-3 border border-dashed border-border bg-surface inline-block" />
{t('chain.pending')}
</span>
</div>
</div>
</div>
);
}
@@ -0,0 +1,217 @@
'use client';
import { useEpoch, formatTimestamp, truncateHash } from '@/lib/hooks';
import PageHeader from '@/components/ui/PageHeader';
import CopyButton from '@/components/ui/CopyButton';
import Link from 'next/link';
import { useTranslation } from 'react-i18next';
export default function EpochDetailClient({ epochId }: { epochId: string }) {
const { t } = useTranslation();
const { data, isLoading, error } = useEpoch(epochId);
const epoch = data?.epoch;
const anchor = data?.anchor;
if (isLoading) {
return (
<div className="fade-in">
<PageHeader
title={t('epochDetail.title')}
breadcrumbs={[
{ label: t('common.dashboard'), href: '/' },
{ label: t('common.epochs'), href: '/epochs' },
{ label: epochId.slice(0, 16) + '...' },
]}
/>
<div className="border border-border bg-surface p-4 sm:p-6">
<div className="space-y-3">
<div className="skeleton h-6 w-64" />
<div className="skeleton h-4 w-full" />
<div className="skeleton h-4 w-3/4" />
</div>
</div>
</div>
);
}
if (error || !epoch) {
return (
<div className="fade-in">
<PageHeader
title={t('epochDetail.title')}
breadcrumbs={[
{ label: t('common.dashboard'), href: '/' },
{ label: t('common.epochs'), href: '/epochs' },
{ label: epochId.slice(0, 16) + '...' },
]}
/>
<div className="px-4 py-3 border border-red-300 bg-red-50 font-mono text-[12px] text-red-700">
{t('epochDetail.failedToLoad')} {error instanceof Error ? error.message : t('epochDetail.mayNotExist')}
</div>
</div>
);
}
const durationMs = epoch.end_time - epoch.start_time;
const durationMinutes = Math.round(durationMs / 60000);
return (
<div className="fade-in">
<PageHeader
title={t('epochDetail.title')}
subtitle={epoch.epoch_id}
breadcrumbs={[
{ label: t('common.dashboard'), href: '/' },
{ label: t('common.epochs'), href: '/epochs' },
{ label: epoch.epoch_id.slice(0, 16) + '...' },
]}
actions={
<Link
href={`/audit?start_time=${new Date(epoch.start_time).toISOString().slice(0, 16)}&end_time=${new Date(epoch.end_time).toISOString().slice(0, 16)}`}
className="btn-ghost inline-block no-underline"
>
{t('epochDetail.viewOperations')}
</Link>
}
/>
<div className="space-y-6">
{/* Epoch Metadata */}
<div className="border border-border bg-surface p-4 sm:p-6 relative">
<span className="crosshair ch-tl" />
<span className="crosshair ch-tr" />
<span className="crosshair ch-bl" />
<span className="crosshair ch-br" />
<h3 className="section-label mb-4">{t('epochDetail.epochMetadata')}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<div className="section-label mb-1">{t('epochDetail.epochId')}</div>
<div className="flex items-center gap-1.5">
<span className="font-mono text-[13px] text-ink break-all">{epoch.epoch_id}</span>
<CopyButton text={epoch.epoch_id} />
</div>
</div>
<div>
<div className="section-label mb-1">{t('epochDetail.organization')}</div>
<span className="font-mono text-[13px] text-ink">{epoch.org_id}</span>
</div>
<div>
<div className="section-label mb-1">{t('epochDetail.startTime')}</div>
<span className="font-mono text-[12px] text-ink">
{formatTimestamp(epoch.start_time)}
</span>
</div>
<div>
<div className="section-label mb-1">{t('epochDetail.endTime')}</div>
<span className="font-mono text-[12px] text-ink">
{formatTimestamp(epoch.end_time)}
</span>
</div>
<div>
<div className="section-label mb-1">{t('epochDetail.duration')}</div>
<span className="font-mono text-[13px] text-ink">
{t('epochDetail.durationMinutes', { count: durationMinutes })}
</span>
</div>
<div>
<div className="section-label mb-1">{t('epochDetail.createdAt')}</div>
<span className="font-mono text-[12px] text-ink-dim">
{formatTimestamp(epoch.created_at)}
</span>
</div>
</div>
</div>
{/* Merkle Root */}
<div className="border border-border bg-surface p-4 sm:p-6">
<h3 className="section-label mb-4">{t('epochDetail.merkleTree')}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<div className="section-label mb-1">{t('epochDetail.rootHash')}</div>
<div className="flex items-center gap-1.5">
<span className="font-mono text-[13px] text-ink break-all">
{epoch.root_hash}
</span>
<CopyButton text={epoch.root_hash} className="shrink-0" />
</div>
</div>
<div>
<div className="section-label mb-1">{t('epochDetail.leafCount')}</div>
<span className="font-sans text-2xl font-semibold text-ink">
{epoch.leaf_count}
</span>
<span className="font-mono text-[11px] text-ink-dim ml-2">
{t('epochDetail.operationsUnit')}
</span>
</div>
</div>
</div>
{/* TSA Anchor */}
<div className="border border-border bg-surface p-4 sm:p-6">
<h3 className="section-label mb-4">{t('epochDetail.tsaAnchor')}</h3>
{anchor?.tsa_token ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="md:col-span-2">
<div className="section-label mb-1">{t('epochDetail.tsaToken')}</div>
<div className="flex items-center gap-1.5">
<span className="font-mono text-[12px] text-ink break-all">
{truncateHash(anchor.tsa_token, 20)}
</span>
<CopyButton text={anchor.tsa_token} className="shrink-0" />
</div>
</div>
{anchor.tsa_url && (
<div>
<div className="section-label mb-1">{t('epochDetail.tsaUrl')}</div>
<span className="font-mono text-[12px] text-ink">
{anchor.tsa_url}
</span>
</div>
)}
{anchor.anchored_at && (
<div>
<div className="section-label mb-1">{t('epochDetail.anchoredAt')}</div>
<span className="font-mono text-[12px] text-ink">
{formatTimestamp(anchor.anchored_at)}
</span>
</div>
)}
</div>
) : (
<p className="font-mono text-sm text-ink-dim">
{t('epochDetail.noTsaAnchor')}
</p>
)}
</div>
{/* R2 Storage */}
<div className="border border-border bg-surface p-4 sm:p-6">
<h3 className="section-label mb-4">{t('epochDetail.storage')}</h3>
<div>
<div className="section-label mb-1">{t('epochDetail.r2EpochKey')}</div>
<div className="flex items-center gap-1.5">
<span className="font-mono text-[13px] text-ink break-all">
{epoch.r2_epoch_key}
</span>
<CopyButton text={epoch.r2_epoch_key} className="shrink-0" />
</div>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,20 @@
'use client';
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import '@/i18n/config';
export default function I18nProvider({ children }: { children: React.ReactNode }) {
const { i18n } = useTranslation();
useEffect(() => {
document.documentElement.lang = i18n.language;
const handleChange = (lng: string) => {
document.documentElement.lang = lng;
};
i18n.on('languageChanged', handleChange);
return () => { i18n.off('languageChanged', handleChange); };
}, [i18n]);
return <>{children}</>;
}
@@ -0,0 +1,143 @@
'use client';
import { useState } from 'react';
import CopyButton from '@/components/ui/CopyButton';
import { formatTimestamp, truncateHash } from '@/lib/hooks';
import type { Operation, Receipt } from '@elydora/shared';
import { useTranslation } from 'react-i18next';
interface OperationDetailCardProps {
operation: Operation;
receipt?: Receipt;
payload?: Record<string, unknown>;
}
interface FieldRowProps {
label: string;
value: string | number | null | undefined;
mono?: boolean;
copyable?: boolean;
}
function FieldRow({ label, value, mono = true, copyable = false }: FieldRowProps) {
const displayValue = value == null ? '\u2014' : String(value);
return (
<div className="flex flex-col sm:flex-row sm:items-start py-2.5 border-b border-border last:border-b-0 gap-1 sm:gap-0">
<div className="sm:w-44 sm:shrink-0">
<span className="section-label">{label}</span>
</div>
<div className="flex-1 flex items-center gap-2 min-w-0">
<span
className={`${mono ? 'font-mono text-[13px]' : 'font-sans text-sm'} text-ink break-all`}
>
{displayValue}
</span>
{copyable && value != null && (
<CopyButton text={String(value)} className="shrink-0" />
)}
</div>
</div>
);
}
export default function OperationDetailCard({ operation, receipt, payload }: OperationDetailCardProps) {
const [showRaw, setShowRaw] = useState(false);
const { t } = useTranslation();
return (
<div className="space-y-6">
{/* Operation record */}
<div className="border border-border bg-surface p-4 sm:p-6 relative">
<span className="crosshair ch-tl" />
<span className="crosshair ch-tr" />
<span className="crosshair ch-bl" />
<span className="crosshair ch-br" />
<div className="flex items-center justify-between mb-4">
<h3 className="section-label">{t('operationCard.operationRecord')}</h3>
<button
onClick={() => setShowRaw(!showRaw)}
className="btn-ghost text-[10px] py-1 px-3"
>
{showRaw ? t('operationCard.formatted') : t('operationCard.rawJson')}
</button>
</div>
{showRaw ? (
<div className="relative">
<pre className="font-mono text-[12px] text-ink bg-bg p-4 border border-border overflow-x-auto">
{JSON.stringify(operation, null, 2)}
</pre>
<div className="absolute top-2 right-2">
<CopyButton text={JSON.stringify(operation, null, 2)} />
</div>
</div>
) : (
<div>
<FieldRow label={t('operationCard.operationId')} value={operation.operation_id} copyable />
<FieldRow label={t('operationCard.agentId')} value={operation.agent_id} copyable />
<FieldRow label={t('operationCard.orgId')} value={operation.org_id} copyable />
<FieldRow label={t('operationCard.type')} value={operation.operation_type} />
<FieldRow label={t('operationCard.issuedAt')} value={formatTimestamp(operation.issued_at)} mono={false} />
<FieldRow label={t('operationCard.createdAt')} value={formatTimestamp(operation.created_at)} mono={false} />
<FieldRow label={t('operationCard.ttlMs')} value={operation.ttl_ms} />
<FieldRow label={t('operationCard.nonce')} value={operation.nonce} copyable />
<FieldRow label={t('operationCard.seqNo')} value={operation.seq_no} />
<FieldRow label={t('operationCard.signingKey')} value={operation.agent_pubkey_kid} copyable />
<FieldRow label={t('operationCard.payloadHash')} value={truncateHash(operation.payload_hash, 12)} copyable />
<FieldRow label={t('operationCard.prevChainHash')} value={truncateHash(operation.prev_chain_hash, 12)} copyable />
<FieldRow label={t('operationCard.chainHash')} value={truncateHash(operation.chain_hash, 12)} copyable />
<FieldRow label={t('operationCard.r2PayloadKey')} value={operation.r2_payload_key ?? '—'} copyable />
</div>
)}
</div>
{/* Payload — what the operation actually did */}
{payload && (
<div className="border border-border bg-surface p-4 sm:p-6 relative">
<span className="crosshair ch-tl" />
<span className="crosshair ch-tr" />
<span className="crosshair ch-bl" />
<span className="crosshair ch-br" />
<div className="flex items-center justify-between mb-4">
<h3 className="section-label">{t('operationCard.payload')}</h3>
<div className="flex-1" />
<CopyButton text={JSON.stringify(payload, null, 2)} />
</div>
{Object.entries(payload).map(([key, value]) => {
if (value == null) return null;
const isComplex = typeof value === 'object';
if (isComplex) {
return (
<div key={key} className="py-2.5 border-b border-border last:border-b-0">
<div className="mb-1">
<span className="section-label">{key.replace(/_/g, ' ')}</span>
</div>
<pre className="font-mono text-[12px] text-ink bg-bg p-3 border border-border max-h-64 overflow-y-auto whitespace-pre-wrap break-all">
{JSON.stringify(value, null, 2)}
</pre>
</div>
);
}
return (
<FieldRow key={key} label={key.replace(/_/g, ' ')} value={String(value)} copyable />
);
})}
</div>
)}
{/* Receipt */}
{receipt && (
<div className="border border-border bg-surface p-4 sm:p-6">
<h3 className="section-label mb-4">{t('operationCard.receipt')}</h3>
<FieldRow label={t('operationCard.receiptId')} value={receipt.receipt_id} copyable />
<FieldRow label={t('operationCard.operationId')} value={receipt.operation_id} copyable />
<FieldRow label={t('operationCard.r2ReceiptKey')} value={receipt.r2_receipt_key} copyable />
<FieldRow label={t('operationCard.createdAt')} value={formatTimestamp(receipt.created_at)} mono={false} />
</div>
)}
</div>
);
}
@@ -0,0 +1,114 @@
'use client';
import { useState, useCallback } from 'react';
import { useOperation } from '@/lib/hooks';
import { api } from '@/lib/api';
import PageHeader from '@/components/ui/PageHeader';
import OperationDetailCard from '@/components/OperationDetailCard';
import ChainVisualization from '@/components/ChainVisualization';
import VerificationChecklist from '@/components/VerificationChecklist';
import type { VerifyOperationResponse } from '@elydora/shared';
import { useTranslation } from 'react-i18next';
export default function OperationDetailClient({ operationId }: { operationId: string }) {
const { t } = useTranslation();
const { data, isLoading, error } = useOperation(operationId);
const [verifyResult, setVerifyResult] = useState<VerifyOperationResponse | null>(null);
const [verifying, setVerifying] = useState(false);
const operation = data?.operation;
const receipt = data?.receipt;
const payload = data?.payload;
const handleVerify = useCallback(async () => {
setVerifying(true);
try {
const result = await api.operations.verify(operationId);
setVerifyResult(result);
} catch (err) {
setVerifyResult({
valid: false,
checks: {
signature: false,
chain: false,
receipt: false,
},
errors: [err instanceof Error ? err.message : 'Verification request failed.'],
});
} finally {
setVerifying(false);
}
}, [operationId]);
if (isLoading) {
return (
<div className="fade-in">
<PageHeader
title={t('operationDetail.title')}
breadcrumbs={[
{ label: t('common.dashboard'), href: '/' },
{ label: t('common.operations'), href: '/operations' },
{ label: operationId.slice(0, 16) + '...' },
]}
/>
<div className="space-y-4">
<div className="border border-border bg-surface p-6">
<div className="space-y-3">
<div className="skeleton h-6 w-64" />
<div className="skeleton h-4 w-full" />
<div className="skeleton h-4 w-3/4" />
<div className="skeleton h-4 w-1/2" />
</div>
</div>
</div>
</div>
);
}
if (error || !operation) {
return (
<div className="fade-in">
<PageHeader
title={t('operationDetail.title')}
breadcrumbs={[
{ label: t('common.dashboard'), href: '/' },
{ label: t('common.operations'), href: '/operations' },
{ label: operationId.slice(0, 16) + '...' },
]}
/>
<div className="px-4 py-3 border border-red-300 bg-red-50 font-mono text-[12px] text-red-700">
{t('operationDetail.failedToLoad')} {error instanceof Error ? error.message : t('operationDetail.mayNotExist')}
</div>
</div>
);
}
return (
<div className="fade-in">
<PageHeader
title={t('operationDetail.title')}
subtitle={operation.operation_id}
breadcrumbs={[
{ label: t('common.dashboard'), href: '/' },
{ label: t('common.operations'), href: '/operations' },
{ label: operation.operation_id.slice(0, 16) + '...' },
]}
/>
<div className="space-y-6">
<OperationDetailCard operation={operation} receipt={receipt} payload={payload} />
<ChainVisualization
prevHash={operation.prev_chain_hash}
currentHash={operation.chain_hash}
operationId={operation.operation_id}
/>
<VerificationChecklist
result={verifyResult}
isLoading={verifying}
onVerify={handleVerify}
/>
</div>
</div>
);
}
@@ -0,0 +1,156 @@
'use client';
import type { VerifyOperationResponse } from '@elydora/shared';
import { useTranslation } from 'react-i18next';
interface VerificationChecklistProps {
result: VerifyOperationResponse | null;
isLoading: boolean;
onVerify: () => void;
}
interface CheckItemProps {
label: string;
description: string;
passed: boolean | undefined;
pendingText: string;
passedText: string;
failedText: string;
}
function CheckItem({ label, description, passed, pendingText, passedText, failedText }: CheckItemProps) {
return (
<div className="flex items-start gap-3 py-3 border-b border-border last:border-b-0">
<div className="mt-0.5 shrink-0">
{passed === undefined ? (
<div className="w-5 h-5 border border-border rounded-full" />
) : passed ? (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" className="check-pass">
<circle cx="10" cy="10" r="9" stroke="currentColor" strokeWidth="1.5" />
<path d="M6 10l3 3 5-6" stroke="currentColor" strokeWidth="1.5" fill="none" />
</svg>
) : (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" className="check-fail">
<circle cx="10" cy="10" r="9" stroke="currentColor" strokeWidth="1.5" />
<path d="M7 7l6 6M13 7l-6 6" stroke="currentColor" strokeWidth="1.5" />
</svg>
)}
</div>
<div className="flex-1">
<div className="font-mono text-[12px] font-medium uppercase tracking-wider text-ink">
{label}
</div>
<div className="font-sans text-[12px] text-ink-dim mt-0.5">
{description}
</div>
</div>
<div className="shrink-0">
{passed === undefined ? (
<span className="font-mono text-[11px] text-ink-dim uppercase">{pendingText}</span>
) : passed ? (
<span className="font-mono text-[11px] text-green-600 uppercase">{passedText}</span>
) : (
<span className="font-mono text-[11px] text-red-600 uppercase">{failedText}</span>
)}
</div>
</div>
);
}
export default function VerificationChecklist({
result,
isLoading,
onVerify,
}: VerificationChecklistProps) {
const { t } = useTranslation();
const checks = result?.checks;
return (
<div className="border border-border bg-surface p-4 sm:p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="section-label">{t('verification.title')}</h3>
<button
onClick={onVerify}
className="btn-brutalist text-[10px] py-1.5 px-4"
disabled={isLoading}
>
{isLoading ? t('verification.verifying') : result ? t('verification.reVerify') : t('verification.verify')}
</button>
</div>
{result && (
<div className="mb-4 px-4 py-3 border font-mono text-[12px] flex items-center gap-2"
style={{
borderColor: result.valid ? '#16a34a' : '#dc2626',
backgroundColor: result.valid ? 'rgba(22, 163, 74, 0.05)' : 'rgba(220, 38, 38, 0.05)',
color: result.valid ? '#16a34a' : '#dc2626',
}}
>
{result.valid ? (
<>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M2 7.5l3 3 7-7" />
</svg>
{t('verification.verifiedSuccess')}
</>
) : (
<>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M1 1l12 12M13 1L1 13" />
</svg>
{t('verification.verificationFailed')}
</>
)}
</div>
)}
<div>
<CheckItem
label={t('verification.signature')}
description={t('verification.signatureDesc')}
passed={checks?.signature}
pendingText={t('verification.pending')}
passedText={t('verification.passed')}
failedText={t('verification.failed')}
/>
<CheckItem
label={t('verification.chainCheck')}
description={t('verification.chainCheckDesc')}
passed={checks?.chain}
pendingText={t('verification.pending')}
passedText={t('verification.passed')}
failedText={t('verification.failed')}
/>
<CheckItem
label={t('verification.receiptCheck')}
description={t('verification.receiptCheckDesc')}
passed={checks?.receipt}
pendingText={t('verification.pending')}
passedText={t('verification.passed')}
failedText={t('verification.failed')}
/>
<CheckItem
label={t('verification.merkle')}
description={t('verification.merkleDesc')}
passed={checks?.merkle}
pendingText={t('verification.pending')}
passedText={t('verification.passed')}
failedText={t('verification.failed')}
/>
</div>
{result?.errors && result.errors.length > 0 && (
<div className="mt-4 pt-4 border-t border-border">
<div className="section-label mb-2">{t('verification.errors')}</div>
<ul className="space-y-1">
{result.errors.map((err, i) => (
<li key={i} className="font-mono text-[12px] text-red-600">
{err}
</li>
))}
</ul>
</div>
)}
</div>
);
}
@@ -0,0 +1,35 @@
'use client';
import { useClipboard } from '@/lib/hooks';
import { useTranslation } from 'react-i18next';
interface CopyButtonProps {
text: string;
label?: string;
className?: string;
}
export default function CopyButton({ text, label, className = '' }: CopyButtonProps) {
const { copied, copy } = useClipboard();
const { t } = useTranslation();
return (
<button
onClick={() => copy(text)}
className={`inline-flex items-center gap-1.5 font-mono text-[12px] text-ink-dim hover:text-ink transition-colors ${className}`}
title={t('common.copyToClipboard')}
>
{label && <span>{label}</span>}
{copied ? (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-green-600">
<path d="M2 7.5l3 3 7-7" />
</svg>
) : (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5">
<rect x="4" y="4" width="9" height="9" rx="1" />
<path d="M10 4V2a1 1 0 00-1-1H2a1 1 0 00-1 1v7a1 1 0 001 1h2" />
</svg>
)}
</button>
);
}
@@ -0,0 +1,190 @@
'use client';
import { useState, useCallback } from 'react';
export interface Column<T> {
key: string;
label: string;
sortable?: boolean;
render?: (row: T) => React.ReactNode;
width?: string;
}
interface DataTableProps<T> {
columns: Column<T>[];
data: T[];
keyExtractor: (row: T) => string;
onRowClick?: (row: T) => void;
isLoading?: boolean;
emptyMessage?: string;
pagination?: {
cursor?: string;
hasMore: boolean;
onLoadMore: () => void;
loadingMore?: boolean;
};
}
type SortDirection = 'asc' | 'desc';
export default function DataTable<T extends Record<string, unknown>>({
columns,
data,
keyExtractor,
onRowClick,
isLoading,
emptyMessage = 'No data available.',
pagination,
}: DataTableProps<T>) {
const [sortKey, setSortKey] = useState<string | null>(null);
const [sortDir, setSortDir] = useState<SortDirection>('asc');
const handleSort = useCallback((key: string) => {
if (sortKey === key) {
setSortDir((prev) => (prev === 'asc' ? 'desc' : 'asc'));
} else {
setSortKey(key);
setSortDir('asc');
}
}, [sortKey]);
const sortedData = sortKey
? [...data].sort((a, b) => {
const aVal = a[sortKey];
const bVal = b[sortKey];
if (aVal == null && bVal == null) return 0;
if (aVal == null) return sortDir === 'asc' ? -1 : 1;
if (bVal == null) return sortDir === 'asc' ? 1 : -1;
if (typeof aVal === 'string' && typeof bVal === 'string') {
return sortDir === 'asc'
? aVal.localeCompare(bVal)
: bVal.localeCompare(aVal);
}
if (typeof aVal === 'number' && typeof bVal === 'number') {
return sortDir === 'asc' ? aVal - bVal : bVal - aVal;
}
return 0;
})
: data;
if (isLoading) {
return (
<div className="border border-border">
<table className="w-full border-collapse">
<thead>
<tr className="border-b border-border">
{columns.map((col) => (
<th
key={col.key}
className="table-header text-left px-4 py-3"
style={col.width ? { width: col.width } : undefined}
>
{col.label}
</th>
))}
</tr>
</thead>
<tbody>
{Array.from({ length: 5 }).map((_, i) => (
<tr key={i} className="border-b border-border last:border-b-0">
{columns.map((col) => (
<td key={col.key} className="px-4 py-3">
<div className="skeleton h-4 w-3/4" />
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
if (data.length === 0) {
return (
<div className="border border-border bg-surface">
<table className="w-full border-collapse">
<thead>
<tr className="border-b border-border">
{columns.map((col) => (
<th
key={col.key}
className="table-header text-left px-4 py-3"
style={col.width ? { width: col.width } : undefined}
>
{col.label}
</th>
))}
</tr>
</thead>
</table>
<div className="py-16 text-center">
<p className="font-mono text-sm text-ink-dim">{emptyMessage}</p>
</div>
</div>
);
}
return (
<div className="border border-border overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full border-collapse min-w-[600px]">
<thead>
<tr className="border-b border-border bg-surface">
{columns.map((col) => (
<th
key={col.key}
className={`table-header text-left px-4 py-3 ${
col.sortable ? 'cursor-pointer select-none hover:text-ink' : ''
}`}
style={col.width ? { width: col.width } : undefined}
onClick={col.sortable ? () => handleSort(col.key) : undefined}
>
<span className="inline-flex items-center gap-1">
{col.label}
{col.sortable && sortKey === col.key && (
<span className="text-ink">
{sortDir === 'asc' ? '\u2191' : '\u2193'}
</span>
)}
</span>
</th>
))}
</tr>
</thead>
<tbody>
{sortedData.map((row) => (
<tr
key={keyExtractor(row)}
className={`data-row border-b border-border last:border-b-0 ${
onRowClick ? 'cursor-pointer' : ''
}`}
onClick={onRowClick ? () => onRowClick(row) : undefined}
>
{columns.map((col) => (
<td key={col.key} className="px-4 py-3 font-mono text-[13px]">
{col.render
? col.render(row)
: String(row[col.key] ?? '')}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{pagination && pagination.hasMore && (
<div className="border-t border-border px-4 py-3 flex justify-center">
<button
className="btn-ghost text-[11px]"
onClick={pagination.onLoadMore}
disabled={pagination.loadingMore}
>
{pagination.loadingMore ? 'Loading...' : 'Load More'}
</button>
</div>
)}
</div>
);
}
@@ -0,0 +1,120 @@
'use client';
import { useState, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
function USFlag() {
return (
<svg width="20" height="14" viewBox="0 0 20 14" style={{ borderRadius: 2, flexShrink: 0 }}>
<rect width="20" height="14" fill="#B22234" />
<rect y="2" width="20" height="2" fill="#fff" />
<rect y="6" width="20" height="2" fill="#fff" />
<rect y="10" width="20" height="2" fill="#fff" />
<rect width="8" height="8" fill="#3C3B6E" />
<g fill="#fff">
<circle cx="2" cy="1.5" r="0.5" />
<circle cx="4" cy="1.5" r="0.5" />
<circle cx="6" cy="1.5" r="0.5" />
<circle cx="3" cy="3" r="0.5" />
<circle cx="5" cy="3" r="0.5" />
<circle cx="2" cy="4.5" r="0.5" />
<circle cx="4" cy="4.5" r="0.5" />
<circle cx="6" cy="4.5" r="0.5" />
<circle cx="3" cy="6" r="0.5" />
<circle cx="5" cy="6" r="0.5" />
</g>
</svg>
);
}
function CNFlag() {
return (
<svg width="20" height="14" viewBox="0 0 20 14" style={{ borderRadius: 2, flexShrink: 0 }}>
<rect width="20" height="14" fill="#DE2910" />
<g fill="#FFDE00">
<polygon points="4,1.5 4.7,3.6 6.9,3.6 5.1,5 5.8,7 4,5.7 2.2,7 2.9,5 1.1,3.6 3.3,3.6" />
<polygon points="8.5,1 9,1.8 8.5,1.6 8,1.8" />
<polygon points="10,2.5 10.3,3.3 9.7,2.9 9.3,3.3 9.5,2.5" />
<polygon points="10,4.5 10.3,5.3 9.7,4.9 9.3,5.3 9.5,4.5" />
<polygon points="8.5,6 9,6.8 8.5,6.6 8,6.8" />
</g>
</svg>
);
}
const languages = [
{ code: 'en', label: 'EN', flag: <USFlag /> },
{ code: 'zh', label: '中文', flag: <CNFlag /> },
];
export function LanguageDropdown() {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const { i18n } = useTranslation();
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const current = i18n.language.startsWith('zh') ? languages[1]! : languages[0]!;
return (
<div ref={ref} className="relative">
<button
onClick={() => setOpen(!open)}
className="flex items-center gap-2 px-2 py-1.5 font-mono text-[11px] rounded transition-colors w-full"
style={{
color: '#EAEAE5',
background: 'rgba(234,234,229,0.08)',
border: '1px solid rgba(234,234,229,0.15)',
cursor: 'pointer',
}}
>
{current.flag}
<span>{current.label}</span>
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1.5" style={{ marginLeft: 'auto', transform: open ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s' }}>
<path d="M2 4l3 3 3-3" />
</svg>
</button>
{open && (
<div
className="absolute left-0 right-0 z-50 rounded overflow-hidden"
style={{
bottom: '100%',
marginBottom: 4,
backgroundColor: '#1a1a18',
border: '1px solid rgba(234,234,229,0.15)',
}}
>
{languages.map((lang) => {
const isActive = lang.code === 'en' ? i18n.language === 'en' : i18n.language.startsWith('zh');
return (
<button
key={lang.code}
onClick={() => { i18n.changeLanguage(lang.code); setOpen(false); }}
className="flex items-center gap-2 w-full px-2 py-1.5 font-mono text-[11px] transition-colors"
style={{
color: isActive ? '#EAEAE5' : 'rgba(234,234,229,0.5)',
background: isActive ? 'rgba(234,234,229,0.1)' : 'transparent',
border: 'none',
cursor: 'pointer',
}}
onMouseEnter={(e) => { if (!isActive) e.currentTarget.style.backgroundColor = 'rgba(234,234,229,0.06)'; }}
onMouseLeave={(e) => { if (!isActive) e.currentTarget.style.backgroundColor = 'transparent'; }}
>
{lang.flag}
{lang.label}
</button>
);
})}
</div>
)}
</div>
);
}
@@ -0,0 +1,78 @@
'use client';
import { useEffect, useCallback, useRef } from 'react';
import { createPortal } from 'react-dom';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
width?: string;
}
export default function Modal({ isOpen, onClose, title, children, width = 'max-w-lg' }: ModalProps) {
const overlayRef = useRef<HTMLDivElement>(null);
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
},
[onClose],
);
useEffect(() => {
if (isOpen) {
document.addEventListener('keydown', handleKeyDown);
document.body.style.overflow = 'hidden';
}
return () => {
document.removeEventListener('keydown', handleKeyDown);
document.body.style.overflow = '';
};
}, [isOpen, handleKeyDown]);
if (!isOpen) return null;
return createPortal(
<div
ref={overlayRef}
className="fixed inset-0 md:left-[260px] z-[100] flex items-center justify-center modal-overlay"
onClick={(e) => {
if (e.target === overlayRef.current) onClose();
}}
>
<div
className={`${width} w-full mx-4 bg-bg border border-ink relative fade-in`}
>
{/* Crosshair corners */}
<span className="crosshair ch-tl" />
<span className="crosshair ch-tr" />
<span className="crosshair ch-bl" />
<span className="crosshair ch-br" />
{/* Header */}
<div className="flex items-center justify-between px-4 sm:px-6 py-4 border-b border-border">
<h2 className="font-sans text-lg font-semibold tracking-tight text-ink min-w-0 truncate">
{title}
</h2>
<button
onClick={onClose}
className="w-8 h-8 flex items-center justify-center text-ink-dim hover:text-ink transition-colors"
aria-label="Close"
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M1 1l12 12M13 1L1 13" />
</svg>
</button>
</div>
{/* Content */}
<div className="px-4 sm:px-6 py-5 max-h-[calc(100vh-8rem)] overflow-y-auto">
{children}
</div>
</div>
</div>,
document.body,
);
}
@@ -0,0 +1,62 @@
import Link from 'next/link';
interface Breadcrumb {
label: string;
href?: string;
}
interface PageHeaderProps {
title: string;
subtitle?: string;
breadcrumbs?: Breadcrumb[];
actions?: React.ReactNode;
}
export default function PageHeader({ title, subtitle, breadcrumbs, actions }: PageHeaderProps) {
return (
<div className="mb-8">
{breadcrumbs && breadcrumbs.length > 0 && (
<nav className="mb-3 flex items-center gap-2 flex-wrap overflow-hidden">
{breadcrumbs.map((crumb, index) => (
<span key={index} className="flex items-center gap-2 min-w-0">
{index > 0 && (
<span className="font-mono text-[11px] text-ink-dim shrink-0">/</span>
)}
{crumb.href ? (
<Link
href={crumb.href}
className="font-mono text-[11px] uppercase tracking-wider text-ink-dim hover:text-ink transition-colors no-underline whitespace-nowrap"
>
{crumb.label}
</Link>
) : (
<span className="font-mono text-[11px] uppercase tracking-wider text-ink truncate">
{crumb.label}
</span>
)}
</span>
))}
</nav>
)}
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-6">
<div className="min-w-0">
<h1 className="font-sans text-2xl font-semibold tracking-tight text-ink leading-tight">
{title}
</h1>
{subtitle && (
<p className="mt-1 font-mono text-[13px] text-ink-dim break-all">
{subtitle}
</p>
)}
</div>
{actions && (
<div className="flex items-center gap-3 shrink-0">
{actions}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,39 @@
'use client';
interface SearchInputProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
className?: string;
}
export default function SearchInput({
value,
onChange,
placeholder = 'Search...',
className = '',
}: SearchInputProps) {
return (
<div className={`relative ${className}`}>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
className="absolute left-3 top-1/2 -translate-y-1/2 text-ink-dim"
>
<circle cx="7" cy="7" r="5" />
<path d="M11 11l4 4" />
</svg>
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="w-full pl-10 pr-4 py-2.5 bg-transparent border border-border font-mono text-[13px] text-ink placeholder:text-ink-dim focus:outline-none focus:border-ink transition-colors"
/>
</div>
);
}
@@ -0,0 +1,253 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useAuth } from '@/lib/auth';
import { useTranslation } from 'react-i18next';
import { LanguageDropdown } from './LanguageDropdown';
interface NavItem {
label: string;
href: string;
icon: React.ReactNode;
}
interface NavSection {
title: string;
items: NavItem[];
}
interface SidebarProps {
isOpen: boolean;
onClose: () => void;
}
export default function Sidebar({ isOpen, onClose }: SidebarProps) {
const pathname = usePathname();
const { user, logout } = useAuth();
const { t } = useTranslation();
const sections: NavSection[] = [
{
title: t('sidebar.overview'),
items: [
{
label: t('common.dashboard'),
href: '/',
icon: (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
<rect x="1" y="1" width="6" height="6" rx="1" />
<rect x="9" y="1" width="6" height="6" rx="1" />
<rect x="1" y="9" width="6" height="6" rx="1" />
<rect x="9" y="9" width="6" height="6" rx="1" />
</svg>
),
},
],
},
{
title: t('sidebar.management'),
items: [
{
label: t('common.agents'),
href: '/agents',
icon: (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
<circle cx="8" cy="5" r="3" />
<path d="M2 14c0-3.3 2.7-6 6-6s6 2.7 6 6" />
</svg>
),
},
{
label: t('common.operations'),
href: '/operations',
icon: (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M2 4h12M2 8h12M2 12h12" />
</svg>
),
},
],
},
{
title: t('sidebar.compliance'),
items: [
{
label: t('common.auditTrail'),
href: '/audit',
icon: (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M4 1v14M12 1v14M1 4h14M1 12h14" />
</svg>
),
},
{
label: t('common.epochs'),
href: '/epochs',
icon: (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
<circle cx="8" cy="8" r="6" />
<path d="M8 4v4l3 3" />
</svg>
),
},
{
label: t('common.exports'),
href: '/exports',
icon: (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M8 2v8M5 7l3 3 3-3M3 12v2h10v-2" />
</svg>
),
},
],
},
{
title: t('sidebar.system'),
items: [
{
label: t('common.jwks'),
href: '/jwks',
icon: (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M8 1l2 3h3l-2.5 3L12 11l-4-2-4 2 1.5-4L3 4h3z" />
</svg>
),
},
{
label: t('common.settings'),
href: '/settings',
icon: (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
<circle cx="8" cy="8" r="2" />
<path d="M8 1v2M8 13v2M1 8h2M13 8h2M3.05 3.05l1.41 1.41M11.54 11.54l1.41 1.41M3.05 12.95l1.41-1.41M11.54 4.46l1.41-1.41" />
</svg>
),
},
],
},
];
function isActive(href: string): boolean {
if (href === '/') return pathname === '/';
return pathname.startsWith(href);
}
return (
<>
{/* Backdrop overlay — mobile only */}
{isOpen && (
<div
className="fixed inset-0 bg-black/50 z-40 md:hidden"
onClick={onClose}
/>
)}
<aside
className={`fixed left-0 top-0 bottom-0 bg-ink text-[#EAEAE5] flex flex-col z-50 transition-transform duration-300 ease-in-out md:translate-x-0 ${isOpen ? 'translate-x-0' : '-translate-x-full'}`}
style={{ width: 'var(--sidebar-width)' }}
>
{/* Logo */}
<div className="px-5 py-6 border-b border-[rgba(234,234,229,0.1)]">
<Link href="/" className="flex items-center gap-3 no-underline" onClick={onClose}>
<div className="w-8 h-8 border border-[#EAEAE5] flex items-center justify-center">
<span className="font-mono text-sm font-bold text-[#EAEAE5]">E</span>
</div>
<div>
<div className="font-sans text-sm font-semibold tracking-wide text-[#EAEAE5]">
ELYDORA
</div>
<div className="font-mono text-[10px] text-[rgba(234,234,229,0.4)] tracking-widest uppercase">
{t('common.console')}
</div>
</div>
</Link>
</div>
{/* Navigation */}
<nav className="flex-1 overflow-y-auto py-4">
{sections.map((section) => (
<div key={section.title} className="mb-4">
<div className="px-5 py-2 font-mono text-[10px] font-medium tracking-[0.15em] uppercase text-[rgba(234,234,229,0.3)]">
{section.title}
</div>
{section.items.map((item) => (
<Link
key={item.href + item.label}
href={item.href}
onClick={onClose}
className={`sidebar-nav-item mx-2 rounded-sm ${isActive(item.href) ? 'active' : ''}`}
>
{item.icon}
{item.label}
</Link>
))}
</div>
))}
</nav>
{/* External links */}
<div className="px-5 py-3 border-t border-[rgba(234,234,229,0.1)]">
<div className="font-mono text-[10px] font-medium tracking-[0.15em] uppercase text-[rgba(234,234,229,0.3)] mb-2">
{t('common.links')}
</div>
<a
href="https://elydora.com"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-3 py-1.5 text-[rgba(234,234,229,0.6)] hover:text-[#EAEAE5] transition-colors no-underline font-mono text-[12px]"
>
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M6 3H3v10h10v-3M9 2h5v5M14 2L7 9" />
</svg>
{t('common.home')}
</a>
<a
href="https://docs.elydora.com"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-3 py-1.5 text-[rgba(234,234,229,0.6)] hover:text-[#EAEAE5] transition-colors no-underline font-mono text-[12px]"
>
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M6 3H3v10h10v-3M9 2h5v5M14 2L7 9" />
</svg>
{t('common.docs')}
</a>
</div>
{/* Language dropdown */}
<div className="px-5 py-3 border-t border-[rgba(234,234,229,0.1)]">
<LanguageDropdown />
</div>
{/* Bottom user info */}
<div className="px-5 py-4 border-t border-[rgba(234,234,229,0.1)]">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-[rgba(234,234,229,0.1)] flex items-center justify-center">
<span className="font-mono text-xs text-[rgba(234,234,229,0.6)]">
{user?.display_name?.[0]?.toUpperCase() ?? user?.email?.[0]?.toUpperCase() ?? 'U'}
</span>
</div>
<div className="flex-1 min-w-0">
<div className="font-mono text-xs text-[#EAEAE5] truncate">
{user?.display_name ?? user?.email ?? 'User'}
</div>
<div className="font-mono text-[10px] text-[rgba(234,234,229,0.4)] uppercase tracking-wider">
{user?.role ?? 'unknown'}
</div>
</div>
<button
onClick={logout}
className="p-1.5 rounded transition-colors hover:bg-[rgba(234,234,229,0.1)]"
title={t('common.signOut')}
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="rgba(234,234,229,0.4)" strokeWidth="1.5">
<path d="M6 2H3v12h3M11 4l4 4-4 4M7 8h8" />
</svg>
</button>
</div>
</div>
</aside>
</>
);
}
@@ -0,0 +1,85 @@
import type { AgentStatus, KeyStatus, ExportStatus } from '@elydora/shared';
type Status = AgentStatus | KeyStatus | ExportStatus | 'running' | 'queued' | 'done' | 'failed' | 'verified' | 'invalid';
interface StatusBadgeProps {
status: Status;
size?: 'sm' | 'md';
}
const statusConfig: Record<Status, { color: string; dotColor: string; label: string }> = {
active: {
color: 'text-ink',
dotColor: 'bg-ink',
label: 'Active',
},
frozen: {
color: 'text-blue-600',
dotColor: 'bg-blue-500',
label: 'Frozen',
},
revoked: {
color: 'text-red-600',
dotColor: 'bg-red-500',
label: 'Revoked',
},
retired: {
color: 'text-ink-dim',
dotColor: 'bg-ink-dim',
label: 'Retired',
},
queued: {
color: 'text-ink-dim',
dotColor: 'bg-ink-dim',
label: 'Queued',
},
running: {
color: 'text-amber-600',
dotColor: 'bg-amber-500',
label: 'Running',
},
done: {
color: 'text-green-600',
dotColor: 'bg-green-500',
label: 'Done',
},
failed: {
color: 'text-red-600',
dotColor: 'bg-red-500',
label: 'Failed',
},
verified: {
color: 'text-green-600',
dotColor: 'bg-green-500',
label: 'Verified',
},
invalid: {
color: 'text-red-600',
dotColor: 'bg-red-500',
label: 'Invalid',
},
};
export default function StatusBadge({ status, size = 'md' }: StatusBadgeProps) {
const config = statusConfig[status] ?? {
color: 'text-ink-dim',
dotColor: 'bg-ink-dim',
label: status,
};
const dotSize = size === 'sm' ? 'w-1.5 h-1.5' : 'w-2 h-2';
const textSize = size === 'sm' ? 'text-[10px]' : 'text-[11px]';
return (
<span className={`inline-flex items-center gap-1.5 ${config.color}`}>
<span
className={`${dotSize} rounded-full ${config.dotColor} ${
status === 'running' ? 'pulse-dot' : ''
}`}
/>
<span className={`font-mono ${textSize} font-medium uppercase tracking-wider`}>
{config.label}
</span>
</span>
);
}