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

+5
View File
@@ -0,0 +1,5 @@
node_modules
.next
out
.git
*.md
+17
View File
@@ -0,0 +1,17 @@
node_modules/
dist/
.next/
out/
.wrangler/
.open-next/
.dev.vars
.env
.env.local
.env.production
*.log
.DS_Store
Thumbs.db
.turbo/
coverage/
*.tsbuildinfo
next-env.d.ts
+25
View File
@@ -0,0 +1,25 @@
# Multi-stage build
# Stage 1: Install deps and build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8787
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
RUN npm run build
# Stage 2: Production
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
CMD ["node", "server.js"]
+124
View File
@@ -0,0 +1,124 @@
# Elydora Console
Web dashboard for the Elydora Responsibility Protocol. Provides agent management, operation inspection, epoch visualization, audit log querying, and compliance export tools.
## Tech Stack
- **Framework:** Next.js 16.1 (App Router)
- **Language:** TypeScript 5.7
- **UI:** React 19, Tailwind CSS 4
- **Data Fetching:** SWR 2.3
- **Deployment:** Cloudflare Pages (static export)
## Project Structure
```
src/
├── app/
│ ├── layout.tsx # Root layout with AppShell
│ ├── page.tsx # Dashboard — stats, recent operations, quick actions
│ ├── globals.css # Theme variables, global styles
│ ├── agents/
│ │ ├── page.tsx # Agent list, search, registration
│ │ └── [agent_id]/page.tsx # Agent details, keys, freeze/unfreeze
│ ├── operations/
│ │ ├── page.tsx # Operations log with filtering
│ │ └── [operation_id]/page.tsx # Operation details and verification
│ ├── epochs/
│ │ ├── page.tsx # Epoch list with root hashes
│ │ └── [epoch_id]/page.tsx # Epoch details, Merkle proof visualization
│ ├── exports/
│ │ └── page.tsx # Create and download compliance exports
│ ├── audit/
│ │ └── page.tsx # Audit log query builder
│ ├── login/page.tsx # Login form
│ ├── register/page.tsx # Registration form
│ └── jwks/page.tsx # Public key display
├── components/
│ ├── AppShell.tsx # Main layout wrapper with sidebar
│ ├── AgentDetailClient.tsx # Agent info and key management
│ ├── AgentRegistrationForm.tsx # Register new agent form
│ ├── OperationDetailClient.tsx # Operation inspection and verification
│ ├── OperationDetailCard.tsx # Operation data display
│ ├── EpochDetailClient.tsx # Epoch proof and verification
│ ├── ChainVisualization.tsx # Merkle chain visualization
│ ├── VerificationChecklist.tsx # Proof verification UI
│ └── ui/
│ ├── PageHeader.tsx # Page title and breadcrumbs
│ ├── DataTable.tsx # Sortable, paginated table
│ ├── Modal.tsx # Dialog component
│ ├── SearchInput.tsx # Search/filter input
│ ├── StatusBadge.tsx # Status indicator
│ ├── CopyButton.tsx # Copy-to-clipboard
│ └── Sidebar.tsx # Navigation sidebar
├── lib/
│ ├── api.ts # Typed REST client for all endpoints
│ ├── auth.tsx # Auth context and hooks
│ └── hooks.ts # SWR data-fetching hooks
└── shared/ # Shared types (@elydora/shared)
```
## Getting Started
### Prerequisites
- Node.js 18+
### Installation
```bash
npm install
```
### Environment
| Variable | Purpose | Default |
|----------|---------|---------|
| `NEXT_PUBLIC_API_BASE_URL` | Backend API URL | `http://localhost:8787` |
### Development
```bash
npm run dev
```
Runs at `http://localhost:3000` with Turbopack.
### Build
```bash
npm run build
```
Produces a static export in `out/`. A post-build script removes stale `_redirects` files.
### Deployment
```bash
npm run deploy
```
Deploys the `out/` directory to Cloudflare Pages.
### Type Checking
```bash
npm run typecheck
```
## Pages
| Path | Description |
|------|-------------|
| `/` | Dashboard with stats, recent operations, and quick actions |
| `/agents` | List, search, and register agents |
| `/agents/[agent_id]` | Agent details, key management, freeze/unfreeze |
| `/operations` | Browse operations with agent and type filters |
| `/operations/[operation_id]` | Operation details, chain hash verification |
| `/epochs` | List epochs with root hashes and leaf counts |
| `/epochs/[epoch_id]` | Epoch details with Merkle proof visualization |
| `/exports` | Create and download compliance exports (JSON/PDF) |
| `/audit` | Query builder for audit log with time range and agent filters |
| `/login` | Login |
| `/register` | Register account and organization |
| `/jwks` | Display server public keys in JWK format |
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: 'standalone',
};
export default nextConfig;
+1794
View File
File diff suppressed because it is too large. Load diff
+29
View File
@@ -0,0 +1,29 @@
{
"name": "elydora-console",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"i18next": "^25.8.13",
"i18next-browser-languagedetector": "^8.2.1",
"next": "^16.1.6",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-i18next": "^16.5.4",
"swr": "^2.3.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.0.0",
"@types/node": "25.3.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"postcss": "^8.0.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.0"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+4
View File
@@ -0,0 +1,4 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="32" height="32" rx="7" fill="#121212"/>
<path d="M9 8H23V12H13V14H19V18H13V20H23V24H9Z" fill="#EAEAE5"/>
</svg>

After

Width:  |  Height:  |  Size: 225 B

@@ -0,0 +1,13 @@
'use client';
import { useState, useEffect } from 'react';
import AgentDetailClient from '@/components/AgentDetailClient';
export default function AgentDetailShell() {
const [agentId, setAgentId] = useState('');
useEffect(() => {
setAgentId(window.location.pathname.split('/')[2] ?? '');
}, []);
if (!agentId) return null;
return <AgentDetailClient agentId={agentId} />;
}
@@ -0,0 +1,9 @@
import AgentDetailShell from './client';
export async function generateStaticParams() {
return [{ agent_id: 'detail' }];
}
export default function AgentDetailPage() {
return <AgentDetailShell />;
}
+248
View File
@@ -0,0 +1,248 @@
'use client';
import { Suspense, useState, useCallback, useMemo } from 'react';
import { useRouter } from 'next/navigation';
import { useTranslation } from 'react-i18next';
import { useAgentsList, useAudit, formatRelativeTime } from '@/lib/hooks';
import { api } from '@/lib/api';
import PageHeader from '@/components/ui/PageHeader';
import DataTable from '@/components/ui/DataTable';
import type { Column } from '@/components/ui/DataTable';
import SearchInput from '@/components/ui/SearchInput';
import Modal from '@/components/ui/Modal';
import AgentRegistrationForm from '@/components/AgentRegistrationForm';
interface AgentRow {
agent_id: string;
display_name: string;
status: string;
created_at: number;
[key: string]: unknown;
}
/** Fetches the real total_count for a single agent via the audit API. */
function AgentOpCount({ agentId }: { agentId: string }) {
const { data, isLoading } = useAudit({ agent_id: agentId });
if (isLoading) return <span className="font-mono text-[13px] text-ink-dim">&mdash;</span>;
return <span className="font-mono text-[13px] text-ink-dim">{data?.total_count ?? 0}</span>;
}
/** Fetches the latest operation timestamp for a single agent via the audit API. */
function AgentLatestActivity({ agentId, fallback }: { agentId: string; fallback: number }) {
const { data, isLoading } = useAudit({ agent_id: agentId });
if (isLoading) return <span className="font-mono text-[12px] text-ink-dim">&mdash;</span>;
const latest = data?.operations?.[0]?.issued_at ?? fallback;
return <span className="font-mono text-[12px] text-ink-dim">{formatRelativeTime(latest)}</span>;
}
function AgentsContent() {
const { t } = useTranslation();
const router = useRouter();
const [search, setSearch] = useState('');
const [showRegister, setShowRegister] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<AgentRow | null>(null);
const [deleting, setDeleting] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
const { data: agentsData, isLoading: agentsLoading, error: agentsError, mutate } = useAgentsList();
const agents: AgentRow[] = useMemo(() => {
if (!agentsData?.agents) return [];
return agentsData.agents.map((agent) => ({
agent_id: agent.agent_id,
display_name: agent.display_name,
status: agent.status,
created_at: agent.created_at,
}));
}, [agentsData]);
const filteredAgents = useMemo(() => {
if (!search.trim()) return agents;
const q = search.toLowerCase();
return agents.filter(
(a) => a.agent_id.toLowerCase().includes(q) || a.display_name.toLowerCase().includes(q),
);
}, [agents, search]);
const columns: Column<AgentRow>[] = [
{
key: 'agent_id',
label: t('agents.colAgent'),
sortable: true,
render: (row) => (
<div>
<span className="font-mono text-[13px] text-ink font-medium">{row.agent_id}</span>
{row.display_name && row.display_name !== row.agent_id && (
<span className="ml-2 font-mono text-[11px] text-ink-dim">{row.display_name}</span>
)}
</div>
),
},
{
key: 'status',
label: t('agents.colStatus'),
sortable: true,
width: '100px',
render: (row) => (
<span
className={`font-mono text-[11px] uppercase tracking-wider ${
row.status === 'active'
? 'text-ink'
: row.status === 'frozen'
? 'text-amber-600'
: 'text-red-600'
}`}
>
{row.status}
</span>
),
},
{
key: 'operation_count',
label: t('agents.colOperations'),
width: '120px',
render: (row) => <AgentOpCount agentId={row.agent_id} />,
},
{
key: 'latest_activity',
label: t('agents.colLatestActivity'),
render: (row) => <AgentLatestActivity agentId={row.agent_id} fallback={row.created_at} />,
},
{
key: 'actions',
label: '',
width: '80px',
render: (row) => (
<button
className="btn-ghost text-[10px] py-1 px-2 text-red-600 hover:text-red-700"
onClick={(e) => {
e.stopPropagation();
setDeleteTarget(row);
}}
>
{t('common.delete')}
</button>
),
},
];
const handleRowClick = useCallback(
(row: AgentRow) => {
router.push(`/agents/${row.agent_id}`);
},
[router],
);
const handleRegisterSuccess = useCallback(() => {
setShowRegister(false);
mutate();
}, [mutate]);
const handleDelete = useCallback(async () => {
if (!deleteTarget) return;
setDeleting(true);
setDeleteError(null);
try {
await api.agents.delete(deleteTarget.agent_id);
setDeleteTarget(null);
mutate();
} catch (err) {
setDeleteError(err instanceof Error ? err.message : t('agents.failedToDelete'));
} finally {
setDeleting(false);
}
}, [deleteTarget, mutate]);
return (
<div className="fade-in">
<PageHeader
title={t('agents.title')}
subtitle={t('agents.subtitle')}
breadcrumbs={[
{ label: t('common.dashboard'), href: '/' },
{ label: t('agents.title') },
]}
actions={
<button className="btn-brutalist" onClick={() => setShowRegister(true)}>
{t('agents.registerAgent')}
</button>
}
/>
{agentsError && (
<div className="mb-6 px-4 py-3 border border-red-300 bg-red-50 font-mono text-[12px] text-red-700">
{t('agents.failedToLoad')} {agentsError instanceof Error ? agentsError.message : t('common.unknownError')}
</div>
)}
<div className="mb-4">
<SearchInput
value={search}
onChange={setSearch}
placeholder={t('agents.searchPlaceholder')}
className="max-w-md"
/>
</div>
<DataTable
columns={columns}
data={filteredAgents}
keyExtractor={(row) => row.agent_id}
onRowClick={handleRowClick}
isLoading={agentsLoading}
emptyMessage={t('agents.emptyMessage')}
/>
<Modal
isOpen={showRegister}
onClose={() => setShowRegister(false)}
title={t('agents.registerAgent')}
width="max-w-2xl"
>
<AgentRegistrationForm
onSuccess={handleRegisterSuccess}
onCancel={() => setShowRegister(false)}
/>
</Modal>
<Modal
isOpen={deleteTarget !== null}
onClose={() => { setDeleteTarget(null); setDeleteError(null); }}
title={t('agents.deleteAgent')}
>
<div className="space-y-4">
<p className="font-sans text-sm text-ink-dim">
{t('agents.deleteConfirmPre')}<strong className="text-ink font-mono text-[13px]">{deleteTarget?.agent_id}</strong>{t('agents.deleteConfirmPost')}
</p>
{deleteError && (
<div className="px-4 py-3 border border-red-300 bg-red-50 font-mono text-[12px] text-red-700">
{deleteError}
</div>
)}
<div className="flex items-center justify-end gap-3 pt-2">
<button
className="btn-ghost"
onClick={() => { setDeleteTarget(null); setDeleteError(null); }}
>
{t('common.cancel')}
</button>
<button
className="btn-brutalist bg-red-600 text-white border-red-700 hover:bg-red-700"
onClick={handleDelete}
disabled={deleting}
>
{deleting ? t('agents.deleting') : t('agents.deleteAgent')}
</button>
</div>
</div>
</Modal>
</div>
);
}
export default function AgentsPage() {
return (
<Suspense fallback={<div className="fade-in"><div className="border border-border p-6"><div className="space-y-3"><div className="skeleton h-10 w-full max-w-md" /><div className="skeleton h-6 w-full" /><div className="skeleton h-6 w-full" /><div className="skeleton h-6 w-3/4" /></div></div></div>}>
<AgentsContent />
</Suspense>
);
}
+238
View File
@@ -0,0 +1,238 @@
'use client';
import { useState, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { useTranslation } from 'react-i18next';
import { useAudit, formatTimestamp, formatRelativeTime } from '@/lib/hooks';
import PageHeader from '@/components/ui/PageHeader';
import DataTable from '@/components/ui/DataTable';
import type { Column } from '@/components/ui/DataTable';
import type { Operation, AuditQueryRequest } from '@elydora/shared';
import Link from 'next/link';
export default function AuditPage() {
const { t } = useTranslation();
const router = useRouter();
const [agentId, setAgentId] = useState('');
const [operationType, setOperationType] = useState('');
const [startTime, setStartTime] = useState('');
const [endTime, setEndTime] = useState('');
const [cursor, setCursor] = useState<string | undefined>(undefined);
const [limit] = useState(50);
const queryParams: AuditQueryRequest = {
agent_id: agentId || undefined,
operation_type: operationType || undefined,
start_time: startTime ? new Date(startTime).getTime() : undefined,
end_time: endTime ? new Date(endTime).getTime() : undefined,
cursor,
limit,
};
const { data, isLoading, error } = useAudit(queryParams);
const operations = data?.operations ?? [];
const columns: Column<Operation & Record<string, unknown>>[] = [
{
key: 'operation_id',
label: t('audit.colOperationId'),
render: (row) => (
<Link
href={`/operations/${row.operation_id}`}
className="font-mono text-[13px] text-ink hover:underline no-underline"
>
{row.operation_id.slice(0, 18)}...
</Link>
),
},
{
key: 'agent_id',
label: t('audit.colAgent'),
sortable: true,
render: (row) => (
<Link
href={`/agents/${row.agent_id}`}
className="font-mono text-[13px] text-ink-dim hover:text-ink no-underline transition-colors"
>
{row.agent_id}
</Link>
),
},
{
key: 'operation_type',
label: t('audit.colType'),
sortable: true,
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('audit.colIssuedAt'),
sortable: true,
render: (row) => (
<span className="font-mono text-[12px] text-ink-dim" title={formatTimestamp(row.issued_at)}>
{formatRelativeTime(row.issued_at)}
</span>
),
},
{
key: 'seq_no',
label: t('audit.colSeq'),
sortable: true,
width: '80px',
render: (row) => (
<span className="font-mono text-[13px] text-ink-dim">#{row.seq_no}</span>
),
},
{
key: 'chain_hash',
label: t('audit.colChainHash'),
render: (row) => (
<span className="font-mono text-[11px] text-ink-dim">
{row.chain_hash.slice(0, 12)}...
</span>
),
},
];
const handleClearFilters = useCallback(() => {
setAgentId('');
setOperationType('');
setStartTime('');
setEndTime('');
setCursor(undefined);
}, []);
return (
<div className="fade-in">
<PageHeader
title={t('audit.title')}
subtitle={t('audit.subtitle')}
breadcrumbs={[
{ label: t('common.dashboard'), href: '/' },
{ label: t('audit.title') },
]}
actions={
<Link href="/exports" className="btn-ghost inline-block no-underline">
{t('audit.exportData')}
</Link>
}
/>
{error && (
<div className="mb-6 px-4 py-3 border border-red-300 bg-red-50 font-mono text-[12px] text-red-700">
{t('audit.failedToLoad')} {error instanceof Error ? error.message : t('common.unknownError')}
</div>
)}
{/* Query Builder */}
<div className="border border-border bg-surface p-4 sm:p-6 mb-6">
<div className="flex items-center justify-between mb-4">
<h3 className="section-label">{t('audit.queryBuilder')}</h3>
<button className="btn-ghost text-[10px] py-1 px-3" onClick={handleClearFilters}>
{t('audit.clearFilters')}
</button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<label className="font-mono text-[10px] text-ink-dim uppercase tracking-wider block mb-1">
{t('audit.agentId')}
</label>
<input
type="text"
value={agentId}
onChange={(e) => {
setAgentId(e.target.value);
setCursor(undefined);
}}
placeholder={t('audit.filterByAgent')}
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"
/>
</div>
<div>
<label className="font-mono text-[10px] text-ink-dim uppercase tracking-wider block mb-1">
{t('audit.operationType')}
</label>
<input
type="text"
value={operationType}
onChange={(e) => {
setOperationType(e.target.value);
setCursor(undefined);
}}
placeholder={t('audit.filterByType')}
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"
/>
</div>
<div>
<label className="font-mono text-[10px] text-ink-dim uppercase tracking-wider block mb-1">
{t('audit.startTime')}
</label>
<input
type="datetime-local"
value={startTime}
onChange={(e) => {
setStartTime(e.target.value);
setCursor(undefined);
}}
className="w-full px-3 py-2 bg-transparent border border-border font-mono text-[13px] text-ink focus:outline-none focus:border-ink transition-colors"
/>
</div>
<div>
<label className="font-mono text-[10px] text-ink-dim uppercase tracking-wider block mb-1">
{t('audit.endTime')}
</label>
<input
type="datetime-local"
value={endTime}
onChange={(e) => {
setEndTime(e.target.value);
setCursor(undefined);
}}
className="w-full px-3 py-2 bg-transparent border border-border font-mono text-[13px] text-ink focus:outline-none focus:border-ink transition-colors"
/>
</div>
</div>
{data && (
<div className="mt-4 pt-4 border-t border-border flex items-center gap-6">
<span className="font-mono text-[11px] text-ink-dim">
{t('audit.showing', { shown: operations.length, total: data.total_count })}
</span>
{data.cursor && (
<span className="font-mono text-[11px] text-ink-dim">
{t('audit.moreResults')}
</span>
)}
</div>
)}
</div>
{/* Results Table */}
<DataTable
columns={columns}
data={operations as (Operation & Record<string, unknown>)[]}
keyExtractor={(row) => row.operation_id as string}
onRowClick={(row) => { router.push(`/operations/${row.operation_id}`); }}
isLoading={isLoading}
emptyMessage={t('audit.emptyMessage')}
pagination={
data?.cursor
? {
cursor: data.cursor,
hasMore: true,
onLoadMore: () => setCursor(data.cursor),
}
: undefined
}
/>
</div>
);
}
@@ -0,0 +1,13 @@
'use client';
import { useState, useEffect } from 'react';
import EpochDetailClient from '@/components/EpochDetailClient';
export default function EpochDetailShell() {
const [epochId, setEpochId] = useState('');
useEffect(() => {
setEpochId(window.location.pathname.split('/')[2] ?? '');
}, []);
if (!epochId) return null;
return <EpochDetailClient epochId={epochId} />;
}
@@ -0,0 +1,9 @@
import EpochDetailShell from './client';
export async function generateStaticParams() {
return [{ epoch_id: 'detail' }];
}
export default function EpochDetailPage() {
return <EpochDetailShell />;
}
+126
View File
@@ -0,0 +1,126 @@
'use client';
import { Suspense, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { useTranslation } from 'react-i18next';
import { useEpochs, formatTimestamp, truncateHash } from '@/lib/hooks';
import PageHeader from '@/components/ui/PageHeader';
import DataTable from '@/components/ui/DataTable';
import type { Column } from '@/components/ui/DataTable';
import CopyButton from '@/components/ui/CopyButton';
interface EpochRow {
epoch_id: string;
org_id: string;
start_time: number;
end_time: number;
root_hash: string;
leaf_count: number;
r2_epoch_key: string;
created_at: number;
[key: string]: unknown;
}
function EpochsContent() {
const { t } = useTranslation();
const router = useRouter();
const { data, isLoading, error } = useEpochs();
const epochs = (data?.epochs ?? []) as EpochRow[];
const columns: Column<EpochRow>[] = [
{
key: 'epoch_id',
label: t('epochs.colEpochId'),
render: (row) => (
<span className="font-mono text-[13px] text-ink font-medium">
{row.epoch_id.slice(0, 16)}...
</span>
),
},
{
key: 'start_time',
label: t('epochs.colTimeRange'),
sortable: true,
render: (row) => (
<div className="font-mono text-[11px] text-ink-dim">
<div>{formatTimestamp(row.start_time)}</div>
<div className="text-[10px]">to {formatTimestamp(row.end_time)}</div>
</div>
),
},
{
key: 'leaf_count',
label: t('epochs.colLeaves'),
sortable: true,
width: '100px',
render: (row) => (
<span className="font-mono text-[13px] text-ink">{row.leaf_count}</span>
),
},
{
key: 'root_hash',
label: t('epochs.colRootHash'),
render: (row) => (
<div className="flex items-center gap-1.5">
<span className="font-mono text-[12px] text-ink-dim">
{truncateHash(row.root_hash, 10)}
</span>
<CopyButton text={row.root_hash} />
</div>
),
},
{
key: 'created_at',
label: t('epochs.colCreated'),
sortable: true,
render: (row) => (
<span className="font-mono text-[12px] text-ink-dim">
{formatTimestamp(row.created_at)}
</span>
),
},
];
const handleRowClick = useCallback(
(row: EpochRow) => {
router.push(`/epochs/${row.epoch_id}`);
},
[router],
);
return (
<div className="fade-in">
<PageHeader
title={t('epochs.title')}
subtitle={t('epochs.subtitle')}
breadcrumbs={[
{ label: t('common.dashboard'), href: '/' },
{ label: t('epochs.title') },
]}
/>
{error && (
<div className="mb-6 px-4 py-3 border border-red-300 bg-red-50 font-mono text-[12px] text-red-700">
{t('epochs.failedToLoad')} {error instanceof Error ? error.message : t('common.unknownError')}
</div>
)}
<DataTable
columns={columns}
data={epochs}
keyExtractor={(row) => row.epoch_id}
onRowClick={handleRowClick}
isLoading={isLoading}
emptyMessage={t('epochs.emptyMessage')}
/>
</div>
);
}
export default function EpochsPage() {
return (
<Suspense fallback={<div className="fade-in"><div className="border border-border p-6"><div className="space-y-3"><div className="skeleton h-10 w-full max-w-md" /><div className="skeleton h-6 w-full" /><div className="skeleton h-6 w-full" /><div className="skeleton h-6 w-3/4" /></div></div></div>}>
<EpochsContent />
</Suspense>
);
}
+296
View File
@@ -0,0 +1,296 @@
'use client';
import { useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useExports, formatTimestamp } from '@/lib/hooks';
import { api } from '@/lib/api';
import PageHeader from '@/components/ui/PageHeader';
import DataTable from '@/components/ui/DataTable';
import type { Column } from '@/components/ui/DataTable';
import StatusBadge from '@/components/ui/StatusBadge';
import Modal from '@/components/ui/Modal';
import type { Export, CreateExportRequest, ExportStatus } from '@elydora/shared';
interface ExportRow extends Export {
[key: string]: unknown;
}
export default function ExportsPage() {
const { t } = useTranslation();
const { data, isLoading, error, mutate } = useExports();
const exports = (data?.exports ?? []) as ExportRow[];
const [showCreate, setShowCreate] = useState(false);
const [startTime, setStartTime] = useState('');
const [endTime, setEndTime] = useState('');
const [agentId, setAgentId] = useState('');
const [operationType, setOperationType] = useState('');
const [format, setFormat] = useState<'json' | 'pdf'>('json');
const [creating, setCreating] = useState(false);
const [createError, setCreateError] = useState<string | null>(null);
const handleCreate = useCallback(async () => {
if (!startTime || !endTime) {
setCreateError(t('exports.startEndRequired'));
return;
}
setCreating(true);
setCreateError(null);
try {
const body: CreateExportRequest = {
start_time: new Date(startTime).getTime(),
end_time: new Date(endTime).getTime(),
agent_id: agentId || undefined,
operation_type: operationType || undefined,
format,
};
await api.exports.create(body);
setShowCreate(false);
setStartTime('');
setEndTime('');
setAgentId('');
setOperationType('');
mutate();
} catch (err) {
setCreateError(err instanceof Error ? err.message : t('exports.failedToCreate'));
} finally {
setCreating(false);
}
}, [startTime, endTime, agentId, operationType, format, mutate]);
const handleDownload = useCallback(async (exportId: string, queryParams: string) => {
try {
let ext = '';
try {
const params = JSON.parse(queryParams);
ext = params.format === 'pdf' ? '.pdf' : '.json';
} catch { ext = ''; }
const blob = await api.exports.download(exportId);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `export-${exportId}${ext}`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch {
// Download error is non-critical
}
}, []);
const columns: Column<ExportRow>[] = [
{
key: 'export_id',
label: t('exports.colExportId'),
render: (row) => (
<span className="font-mono text-[13px] text-ink">
{row.export_id.slice(0, 16)}...
</span>
),
},
{
key: 'status',
label: t('exports.colStatus'),
width: '120px',
render: (row) => <StatusBadge status={row.status as ExportStatus} size="sm" />,
},
{
key: 'query_params',
label: t('exports.colFilters'),
render: (row) => {
let params: Record<string, unknown> = {};
try {
params = JSON.parse(row.query_params);
} catch {
// Not valid JSON
}
const entries = Object.entries(params).filter(([, v]) => v != null);
if (entries.length === 0) {
return <span className="font-mono text-[11px] text-ink-dim">{t('common.allData')}</span>;
}
return (
<span className="font-mono text-[11px] text-ink-dim">
{entries.map(([k, v]) => `${k}=${v}`).join(', ')}
</span>
);
},
},
{
key: 'created_at',
label: t('exports.colCreated'),
sortable: true,
render: (row) => (
<span className="font-mono text-[12px] text-ink-dim">
{formatTimestamp(row.created_at)}
</span>
),
},
{
key: 'completed_at',
label: t('exports.colCompleted'),
render: (row) => (
<span className="font-mono text-[12px] text-ink-dim">
{row.completed_at ? formatTimestamp(row.completed_at) : '\u2014'}
</span>
),
},
{
key: 'actions',
label: '',
width: '100px',
render: (row) =>
row.status === 'done' ? (
<button
className="btn-ghost text-[10px] py-1 px-3"
onClick={(e) => {
e.stopPropagation();
handleDownload(row.export_id, row.query_params);
}}
>
{t('common.download')}
</button>
) : null,
},
];
return (
<div className="fade-in">
<PageHeader
title={t('exports.title')}
subtitle={t('exports.subtitle')}
breadcrumbs={[
{ label: t('common.dashboard'), href: '/' },
{ label: t('exports.title') },
]}
actions={
<button className="btn-brutalist" onClick={() => setShowCreate(true)}>
{t('exports.createExport')}
</button>
}
/>
{error && (
<div className="mb-6 px-4 py-3 border border-red-300 bg-red-50 font-mono text-[12px] text-red-700">
{t('exports.failedToLoad')} {error instanceof Error ? error.message : t('common.unknownError')}
</div>
)}
<DataTable
columns={columns}
data={exports}
keyExtractor={(row) => row.export_id}
isLoading={isLoading}
emptyMessage={t('exports.emptyMessage')}
/>
{/* Create Export Modal */}
<Modal
isOpen={showCreate}
onClose={() => {
setShowCreate(false);
setCreateError(null);
}}
title={t('exports.createExport')}
width="max-w-xl"
>
<div className="space-y-4">
{createError && (
<div className="px-4 py-3 border border-red-300 bg-red-50 font-mono text-[12px] text-red-700">
{createError}
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="section-label block mb-1.5">{t('exports.startTime')}</label>
<input
type="datetime-local"
value={startTime}
onChange={(e) => setStartTime(e.target.value)}
className="w-full px-3 py-2 bg-transparent border border-border font-mono text-[13px] text-ink focus:outline-none focus:border-ink transition-colors"
required
/>
</div>
<div>
<label className="section-label block mb-1.5">{t('exports.endTime')}</label>
<input
type="datetime-local"
value={endTime}
onChange={(e) => setEndTime(e.target.value)}
className="w-full px-3 py-2 bg-transparent border border-border font-mono text-[13px] text-ink focus:outline-none focus:border-ink transition-colors"
required
/>
</div>
</div>
<div>
<label className="section-label block mb-1.5">{t('exports.agentIdOptional')}</label>
<input
type="text"
value={agentId}
onChange={(e) => setAgentId(e.target.value)}
placeholder={t('exports.filterByAgent')}
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"
/>
</div>
<div>
<label className="section-label block mb-1.5">{t('exports.operationTypeOptional')}</label>
<input
type="text"
value={operationType}
onChange={(e) => setOperationType(e.target.value)}
placeholder={t('exports.filterByOperationType')}
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"
/>
</div>
<div>
<label className="section-label block mb-1.5">{t('exports.format')}</label>
<div className="flex gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="format"
value="json"
checked={format === 'json'}
onChange={() => setFormat('json')}
className="accent-ink"
/>
<span className="font-mono text-[13px] text-ink">JSON</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="format"
value="pdf"
checked={format === 'pdf'}
onChange={() => setFormat('pdf')}
className="accent-ink"
/>
<span className="font-mono text-[13px] text-ink">PDF</span>
</label>
</div>
</div>
<div className="flex items-center justify-end gap-3 pt-2">
<button
className="btn-ghost"
onClick={() => {
setShowCreate(false);
setCreateError(null);
}}
>
{t('common.cancel')}
</button>
<button className="btn-brutalist" onClick={handleCreate} disabled={creating}>
{creating ? t('exports.creating') : t('exports.createExport')}
</button>
</div>
</div>
</Modal>
</div>
);
}
+19
View File
@@ -0,0 +1,19 @@
'use client';
export default function GlobalError({
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<html lang="en">
<body>
<div style={{ padding: '2rem', fontFamily: 'sans-serif' }}>
<h2>Something went wrong</h2>
<button onClick={() => reset()}>Try again</button>
</div>
</body>
</html>
);
}
+355
View File
@@ -0,0 +1,355 @@
@import url('https://fonts.googleapis.com/css2?family=Manrope:wght@200;300;400;500;600;700;800&family=JetBrains+Mono:wght@300;400;500&family=Noto+Sans+SC:wght@300;400;500;600;700;800&display=swap');
@import "tailwindcss";
:root {
--bg-color: #EAEAE5;
--surface-color: #F2F2EE;
--ink-color: #121212;
--ink-dim: #666666;
--border-color: #C0C0BA;
--grid-line: rgba(18, 18, 18, 0.08);
--white: #FFFFFF;
--sidebar-width: 260px;
}
@theme inline {
--color-bg: var(--bg-color);
--color-surface: var(--surface-color);
--color-ink: var(--ink-color);
--color-ink-dim: var(--ink-dim);
--color-border: var(--border-color);
--color-grid-line: var(--grid-line);
--font-sans: 'Manrope', 'Noto Sans SC', sans-serif;
--font-mono: 'JetBrains Mono', 'Noto Sans SC', monospace;
}
* {
box-sizing: border-box;
}
body {
background-color: var(--bg-color);
color: var(--ink-color);
font-family: 'Manrope', sans-serif;
margin: 0;
overflow-x: hidden;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
:lang(zh) body,
:lang(zh-CN) body,
:lang(zh-TW) body {
font-family: 'Noto Sans SC', 'Manrope', sans-serif;
}
/* Scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: var(--bg-color);
}
::-webkit-scrollbar-thumb {
background: var(--ink-color);
}
/* Grid background */
.grid-bg {
background-image:
linear-gradient(var(--grid-line) 1px, transparent 1px),
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
background-size: 40px 40px;
}
/* Crosshair markers */
.crosshair {
position: absolute;
width: 11px;
height: 11px;
z-index: 10;
pointer-events: none;
}
.crosshair::before, .crosshair::after {
content: '';
position: absolute;
background-color: var(--ink-color);
}
.crosshair::before { width: 1px; height: 100%; left: 5px; }
.crosshair::after { height: 1px; width: 100%; top: 5px; }
.ch-tl { top: -12px; left: -12px; }
.ch-tr { top: -12px; right: -12px; }
.ch-bl { bottom: -12px; left: -12px; }
.ch-br { bottom: -12px; right: -12px; }
@media (max-width: 640px) {
.crosshair { display: none; }
}
/* Display text */
.display-text {
font-size: clamp(2.5rem, 5vw, 4.5rem);
line-height: 1.1;
letter-spacing: -0.04em;
}
/* Console display - smaller variant */
.console-display {
font-size: clamp(1.5rem, 3vw, 2.5rem);
line-height: 1.1;
letter-spacing: -0.03em;
}
/* Navigation item */
.nav-item {
position: relative;
padding-left: 1rem;
transition: all 0.3s ease;
}
.nav-item::before {
content: '';
position: absolute;
left: 0;
top: 50%;
width: 4px;
height: 4px;
background: var(--ink-color);
transform: translateY(-50%) scale(0);
transition: transform 0.2s ease;
}
.nav-item:hover {
padding-left: 1.5rem;
color: var(--ink-color);
}
.nav-item:hover::before {
transform: translateY(-50%) scale(1);
}
/* Sidebar navigation item */
.sidebar-nav-item {
position: relative;
padding: 8px 16px;
font-family: 'JetBrains Mono', 'Noto Sans SC', monospace;
font-size: 12px;
font-weight: 400;
letter-spacing: 0.02em;
color: rgba(234, 234, 229, 0.6);
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
text-decoration: none;
}
.sidebar-nav-item:hover {
color: #EAEAE5;
background: rgba(234, 234, 229, 0.05);
}
.sidebar-nav-item.active {
color: #EAEAE5;
background: rgba(234, 234, 229, 0.08);
}
.sidebar-nav-item.active::before {
content: '';
position: absolute;
left: 0;
top: 4px;
bottom: 4px;
width: 2px;
background: #EAEAE5;
}
/* Marquee */
.marquee-container {
overflow: hidden;
white-space: nowrap;
}
.marquee-content {
display: inline-block;
animation: marquee 40s linear infinite;
}
@keyframes marquee {
0% { transform: translateX(0); }
100% { transform: translateX(-50%); }
}
/* Brutalist button */
.btn-brutalist {
background: var(--ink-color);
color: var(--bg-color);
border: 1px solid var(--ink-color);
transition: all 0.2s cubic-bezier(0.25, 1, 0.5, 1);
position: relative;
overflow: hidden;
cursor: pointer;
font-family: 'JetBrains Mono', 'Noto Sans SC', monospace;
font-weight: 700;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.1em;
padding: 10px 24px;
}
.btn-brutalist:hover {
background: transparent;
color: var(--ink-color);
}
.btn-brutalist:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.btn-brutalist:disabled:hover {
background: var(--ink-color);
color: var(--bg-color);
}
/* Ghost button */
.btn-ghost {
background: transparent;
color: var(--ink-color);
border: 1px solid var(--border-color);
transition: all 0.2s cubic-bezier(0.25, 1, 0.5, 1);
cursor: pointer;
font-family: 'JetBrains Mono', 'Noto Sans SC', monospace;
font-weight: 700;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.1em;
padding: 10px 24px;
}
.btn-ghost:hover {
background: var(--bg-color);
border-color: var(--ink-color);
}
.btn-ghost:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Data row */
.data-row {
transition: background-color 0.2s ease;
}
.data-row:hover {
background-color: #FFFFFF;
}
/* Feature card */
.feature-card {
transition: all 0.3s ease;
}
.feature-card:hover {
background-color: #FFFFFF;
}
/* Stat card */
.stat-card {
background: var(--surface-color);
border: 1px solid var(--border-color);
padding: 24px;
position: relative;
transition: all 0.3s ease;
}
.stat-card:hover {
background: #FFFFFF;
}
/* Loading bar animation */
@keyframes load {
0% { transform: scaleX(0); }
50% { transform: scaleX(0.7); }
100% { transform: scaleX(0); transform-origin: right; }
}
/* Fade in animation */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.fade-in {
animation: fadeIn 0.6s ease forwards;
}
/* Stagger children */
.stagger-children > * {
opacity: 0;
animation: fadeIn 0.5s ease forwards;
}
.stagger-children > *:nth-child(1) { animation-delay: 0.1s; }
.stagger-children > *:nth-child(2) { animation-delay: 0.2s; }
.stagger-children > *:nth-child(3) { animation-delay: 0.3s; }
.stagger-children > *:nth-child(4) { animation-delay: 0.4s; }
.stagger-children > *:nth-child(5) { animation-delay: 0.5s; }
.stagger-children > *:nth-child(6) { animation-delay: 0.6s; }
/* Skeleton loading */
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.skeleton {
background: linear-gradient(90deg, var(--border-color) 25%, var(--surface-color) 50%, var(--border-color) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s ease-in-out infinite;
border-radius: 2px;
}
/* Pulse dot animation */
@keyframes pulse-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.pulse-dot {
animation: pulse-dot 2s ease-in-out infinite;
}
/* Modal overlay */
.modal-overlay {
background: transparent;
}
/* Section label */
.section-label {
font-family: 'JetBrains Mono', 'Noto Sans SC', monospace;
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.15em;
color: var(--ink-dim);
}
/* Table header */
.table-header {
font-family: 'JetBrains Mono', 'Noto Sans SC', monospace;
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--ink-dim);
}
/* Monospace data */
.mono-data {
font-family: 'JetBrains Mono', 'Noto Sans SC', monospace;
font-size: 13px;
font-weight: 400;
overflow-wrap: break-word;
word-break: break-all;
}
/* Mobile-friendly table cells */
@media (max-width: 640px) {
.stat-card {
padding: 16px;
}
}
/* Checkbox/check icon */
.check-pass {
color: #16a34a;
}
.check-fail {
color: #dc2626;
}
+125
View File
@@ -0,0 +1,125 @@
'use client';
import { useJWKS } from '@/lib/hooks';
import { useTranslation } from 'react-i18next';
import PageHeader from '@/components/ui/PageHeader';
import CopyButton from '@/components/ui/CopyButton';
import DataTable from '@/components/ui/DataTable';
import type { Column } from '@/components/ui/DataTable';
import type { JWK } from '@elydora/shared';
interface JWKRow extends JWK {
[key: string]: unknown;
}
export default function JWKSPage() {
const { t } = useTranslation();
const { data: jwksData, isLoading: jwksLoading, error: jwksError } = useJWKS();
const keys = (jwksData?.keys ?? []) as JWKRow[];
const jwkColumns: Column<JWKRow>[] = [
{
key: 'kid',
label: t('jwks.colKeyId'),
render: (row) => (
<div className="flex items-center gap-1.5">
<span className="font-mono text-[13px] text-ink font-medium">{row.kid}</span>
<CopyButton text={row.kid} />
</div>
),
},
{
key: 'kty',
label: t('jwks.colKeyType'),
width: '100px',
render: (row) => (
<span className="font-mono text-[11px] uppercase tracking-wider px-2 py-1 bg-surface border border-border">
{row.kty}
</span>
),
},
{
key: 'alg',
label: t('jwks.colAlgorithm'),
width: '120px',
render: (row) => (
<span className="font-mono text-[13px] text-ink">{row.alg}</span>
),
},
{
key: 'crv',
label: t('jwks.colCurve'),
width: '100px',
render: (row) => (
<span className="font-mono text-[13px] text-ink-dim">{row.crv ?? '\u2014'}</span>
),
},
{
key: 'use',
label: t('jwks.colUse'),
width: '80px',
render: (row) => (
<span className="font-mono text-[11px] text-ink-dim uppercase">{row.use}</span>
),
},
{
key: 'x',
label: t('jwks.colPublicKey'),
render: (row) =>
row.x ? (
<div className="flex items-center gap-1.5">
<span className="font-mono text-[11px] text-ink-dim">
{row.x.slice(0, 20)}...
</span>
<CopyButton text={row.x} />
</div>
) : (
<span className="font-mono text-[11px] text-ink-dim">{'\u2014'}</span>
),
},
];
return (
<div className="fade-in">
<PageHeader
title={t('jwks.title')}
subtitle={t('jwks.subtitle')}
breadcrumbs={[
{ label: t('common.dashboard'), href: '/' },
{ label: t('jwks.title') },
]}
/>
<div className="mb-8">
<div className="flex items-center justify-between mb-3">
<div className="section-label">{t('jwks.publicKeys')}</div>
{jwksData && (
<CopyButton
text={JSON.stringify(jwksData, null, 2)}
label={t('common.copyJwks')}
className="text-[11px]"
/>
)}
</div>
{jwksError && (
<div className="mb-4 px-4 py-3 border border-red-300 bg-red-50 font-mono text-[12px] text-red-700">
{t('jwks.failedToLoad')} {jwksError instanceof Error ? jwksError.message : t('common.unknownError')}
</div>
)}
<DataTable
columns={jwkColumns}
data={keys}
keyExtractor={(row) => row.kid}
isLoading={jwksLoading}
emptyMessage={t('jwks.emptyMessage')}
/>
<div className="mt-3 font-mono text-[11px] text-ink-dim">
{t('jwks.endpoint')} <span className="text-ink">/.well-known/elydora/jwks.json</span>
</div>
</div>
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
import type { Metadata } from 'next';
import './globals.css';
import AppShell from '@/components/AppShell';
import I18nProvider from '@/components/I18nProvider';
export const metadata: Metadata = {
title: 'Elydora Console',
description: 'Enterprise AI accountability and compliance console',
icons: { icon: '/favicon.svg' },
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className="bg-bg text-ink font-sans antialiased">
<I18nProvider>
<AppShell>{children}</AppShell>
</I18nProvider>
</body>
</html>
);
}
+102
View File
@@ -0,0 +1,102 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { api } from '@/lib/api';
import { useAuth } from '@/lib/auth';
import { useTranslation } from 'react-i18next';
export default function LoginPage() {
const { t } = useTranslation();
const router = useRouter();
const { login } = useAuth();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError('');
setIsSubmitting(true);
try {
const result = await api.auth.login(email, password);
login(result.token, { display_name: result.user.display_name, email: result.user.email });
router.push('/');
} catch (err) {
setError(err instanceof Error ? err.message : t('login.loginFailed'));
} finally {
setIsSubmitting(false);
}
}
return (
<div className="min-h-screen bg-bg flex items-center justify-center px-6">
<div className="w-full max-w-[400px]">
{/* Logo */}
<div className="mb-12 text-center">
<div className="inline-flex items-center gap-3">
<div className="w-10 h-10 border border-ink flex items-center justify-center">
<span className="font-mono text-lg font-bold text-ink">E</span>
</div>
<div className="text-left">
<div className="font-sans text-base font-semibold tracking-wide text-ink">ELYDORA</div>
<div className="font-mono text-[10px] text-ink-dim tracking-widest uppercase">Console</div>
</div>
</div>
</div>
{/* Form */}
<div className="border border-border bg-surface p-6 sm:p-8">
<h1 className="font-sans text-xl font-semibold tracking-tight text-ink mb-6">{t('login.signIn')}</h1>
{error && (
<div className="mb-4 p-3 border border-red-300 bg-red-50 text-red-700 text-sm font-mono">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block font-mono text-[11px] uppercase tracking-wider text-ink-dim mb-1.5">{t('login.email')}</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full px-3 py-2.5 border border-border bg-bg font-mono text-sm text-ink focus:outline-none focus:border-ink transition-colors"
placeholder={t('login.emailPlaceholder')}
/>
</div>
<div>
<label className="block font-mono text-[11px] uppercase tracking-wider text-ink-dim mb-1.5">{t('login.password')}</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
className="w-full px-3 py-2.5 border border-border bg-bg font-mono text-sm text-ink focus:outline-none focus:border-ink transition-colors"
/>
</div>
<button
type="submit"
disabled={isSubmitting}
className="w-full btn-brutalist py-3 text-xs uppercase font-bold tracking-wider disabled:opacity-50"
>
{isSubmitting ? t('login.signingIn') : t('login.submitSignIn')}
</button>
</form>
<div className="mt-6 text-center">
<span className="font-mono text-xs text-ink-dim">{t('login.noAccount')}</span>
<Link href="/register" className="font-mono text-xs text-ink underline hover:no-underline">
{t('login.createOne')}
</Link>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,13 @@
'use client';
import { useState, useEffect } from 'react';
import OperationDetailClient from '@/components/OperationDetailClient';
export default function OperationDetailShell() {
const [operationId, setOperationId] = useState('');
useEffect(() => {
setOperationId(window.location.pathname.split('/')[2] ?? '');
}, []);
if (!operationId) return null;
return <OperationDetailClient operationId={operationId} />;
}
@@ -0,0 +1,9 @@
import OperationDetailShell from './client';
export async function generateStaticParams() {
return [{ operation_id: 'detail' }];
}
export default function OperationDetailPage() {
return <OperationDetailShell />;
}
@@ -0,0 +1,189 @@
'use client';
import { Suspense, useState, useCallback, useMemo } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useTranslation } from 'react-i18next';
import { useAudit, formatRelativeTime } from '@/lib/hooks';
import PageHeader from '@/components/ui/PageHeader';
import DataTable from '@/components/ui/DataTable';
import type { Column } from '@/components/ui/DataTable';
import SearchInput from '@/components/ui/SearchInput';
import type { Operation } from '@elydora/shared';
function OperationsContent() {
const { t } = useTranslation();
const router = useRouter();
const searchParams = useSearchParams();
const initialAgentId = searchParams.get('agent_id') ?? '';
const [search, setSearch] = useState('');
const [agentFilter, setAgentFilter] = useState(initialAgentId);
const [typeFilter, setTypeFilter] = useState('');
const [cursor, setCursor] = useState<string | undefined>(undefined);
const { data, isLoading, error } = useAudit({
agent_id: agentFilter || undefined,
operation_type: typeFilter || undefined,
cursor,
limit: 50,
});
const operations = data?.operations ?? [];
const filteredOps = useMemo(() => {
if (!search.trim()) return operations;
const q = search.toLowerCase();
return operations.filter(
(op) =>
op.operation_id.toLowerCase().includes(q) ||
op.agent_id.toLowerCase().includes(q) ||
op.operation_type.toLowerCase().includes(q),
);
}, [operations, search]);
const columns: Column<Operation & Record<string, unknown>>[] = [
{
key: 'operation_id',
label: t('operations.colOperationId'),
render: (row) => (
<span className="font-mono text-[13px] text-ink">
{row.operation_id.slice(0, 20)}...
</span>
),
},
{
key: 'agent_id',
label: t('operations.colAgent'),
sortable: true,
render: (row) => (
<span className="font-mono text-[13px] text-ink-dim">{row.agent_id}</span>
),
},
{
key: 'operation_type',
label: t('operations.colType'),
sortable: true,
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('operations.colIssuedAt'),
sortable: true,
render: (row) => (
<span className="font-mono text-[12px] text-ink-dim">
{formatRelativeTime(row.issued_at)}
</span>
),
},
{
key: 'seq_no',
label: t('operations.colSeq'),
sortable: true,
width: '80px',
render: (row) => (
<span className="font-mono text-[13px] text-ink-dim">#{row.seq_no}</span>
),
},
];
const handleRowClick = useCallback(
(row: Operation & Record<string, unknown>) => {
router.push(`/operations/${row.operation_id}`);
},
[router],
);
return (
<>
{error && (
<div className="mb-6 px-4 py-3 border border-red-300 bg-red-50 font-mono text-[12px] text-red-700">
{t('operations.failedToLoad')} {error instanceof Error ? error.message : t('common.unknownError')}
</div>
)}
{/* Filter Bar */}
<div className="mb-4 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<SearchInput
value={search}
onChange={setSearch}
placeholder={t('operations.searchPlaceholder')}
className="sm:col-span-2"
/>
<div>
<label className="font-mono text-[10px] text-ink-dim uppercase tracking-wider block mb-1">
{t('operations.agentIdFilter')}
</label>
<input
type="text"
value={agentFilter}
onChange={(e) => setAgentFilter(e.target.value)}
placeholder={t('operations.filterByAgent')}
className="w-full px-3 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>
<div>
<label className="font-mono text-[10px] text-ink-dim uppercase tracking-wider block mb-1">
{t('operations.typeFilter')}
</label>
<input
type="text"
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
placeholder={t('operations.filterByType')}
className="w-full px-3 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>
</div>
<DataTable
columns={columns}
data={filteredOps as (Operation & Record<string, unknown>)[]}
keyExtractor={(row) => row.operation_id as string}
onRowClick={handleRowClick}
isLoading={isLoading}
emptyMessage={t('operations.emptyMessage')}
pagination={
data?.cursor
? {
cursor: data.cursor,
hasMore: true,
onLoadMore: () => setCursor(data.cursor),
}
: undefined
}
/>
</>
);
}
export default function OperationsPage() {
const { t } = useTranslation();
return (
<div className="fade-in">
<PageHeader
title={t('operations.title')}
subtitle={t('operations.subtitle')}
breadcrumbs={[
{ label: t('common.dashboard'), href: '/' },
{ label: t('operations.title') },
]}
/>
<Suspense fallback={
<div className="border border-border p-6">
<div className="space-y-3">
<div className="skeleton h-10 w-full max-w-md" />
<div className="skeleton h-6 w-full" />
<div className="skeleton h-6 w-full" />
<div className="skeleton h-6 w-3/4" />
</div>
</div>
}>
<OperationsContent />
</Suspense>
</div>
);
}
+232
View File
@@ -0,0 +1,232 @@
'use client';
import { useAgentsList, useAudit, useEpochs, useExports, formatTimestamp, formatRelativeTime } from '@/lib/hooks';
import { useTranslation } from 'react-i18next';
import PageHeader from '@/components/ui/PageHeader';
import Link from 'next/link';
function StatCard({
label,
value,
subtitle,
isLoading,
}: {
label: string;
value: string | number;
subtitle?: string;
isLoading?: boolean;
}) {
return (
<div className="stat-card">
<div className="section-label mb-3">{label}</div>
{isLoading ? (
<div className="skeleton h-8 w-20 mb-1" />
) : (
<div className="font-sans text-3xl font-semibold tracking-tight text-ink leading-none">
{value}
</div>
)}
{subtitle && (
<div className="font-mono text-[11px] text-ink-dim mt-2">{subtitle}</div>
)}
</div>
);
}
export default function DashboardPage() {
const { t } = useTranslation();
const { data: agentsData, isLoading: agentsLoading } = useAgentsList();
const { data: auditData, isLoading: auditLoading, error: auditError } = useAudit({});
const { data: epochsData, isLoading: epochsLoading } = useEpochs();
const { data: exportsData, isLoading: exportsLoading } = useExports();
const agentsList = agentsData?.agents ?? [];
const operations = auditData?.operations ?? [];
const recentOps = operations.slice(0, 10);
const totalOps = auditData?.total_count ?? 0;
const epochs = epochsData?.epochs ?? [];
const exports = exportsData?.exports ?? [];
const pendingExports = exports.filter((e) => e.status === 'queued' || e.status === 'running');
return (
<div className="fade-in">
<PageHeader
title={t('dashboard.title')}
subtitle={t('dashboard.subtitle')}
/>
{auditError && (
<div className="mb-6 px-4 py-3 border border-red-300 bg-red-50 font-mono text-[12px] text-red-700">
{t('dashboard.failedToLoad')}
</div>
)}
{/* Stats Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8 stagger-children">
<StatCard
label={t('dashboard.totalAgents')}
value={agentsList.length}
subtitle={t('dashboard.registeredAgents')}
isLoading={agentsLoading}
/>
<StatCard
label={t('dashboard.totalOperations')}
value={totalOps}
subtitle={t('dashboard.allRecordedOps')}
isLoading={auditLoading}
/>
<StatCard
label={t('dashboard.activeEpochs')}
value={epochs.length}
subtitle={t('dashboard.merkleRollups')}
isLoading={epochsLoading}
/>
<StatCard
label={t('dashboard.pendingExports')}
value={pendingExports.length}
subtitle={t('dashboard.queuedOrRunning')}
isLoading={exportsLoading}
/>
</div>
{/* Quick Actions */}
<div className="mb-8">
<div className="section-label mb-3">{t('dashboard.quickActions')}</div>
<div className="flex flex-wrap gap-3">
<Link href="/agents" className="btn-brutalist inline-block no-underline">
{t('dashboard.registerAgent')}
</Link>
<Link href="/operations" className="btn-ghost inline-block no-underline">
{t('dashboard.viewOperations')}
</Link>
<Link href="/exports" className="btn-ghost inline-block no-underline">
{t('dashboard.createExport')}
</Link>
</div>
</div>
{/* Recent Operations */}
<div className="mb-8">
<div className="flex items-center justify-between mb-3">
<div className="section-label">{t('dashboard.recentOperations')}</div>
<Link
href="/operations"
className="font-mono text-[11px] text-ink-dim hover:text-ink transition-colors no-underline uppercase tracking-wider"
>
{t('common.viewAll')}
</Link>
</div>
<div className="border border-border overflow-hidden">
{auditLoading ? (
<div className="p-4 space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex gap-4">
<div className="skeleton h-4 w-40" />
<div className="skeleton h-4 w-24" />
<div className="skeleton h-4 w-32" />
<div className="skeleton h-4 w-20" />
</div>
))}
</div>
) : recentOps.length === 0 ? (
<div className="py-12 text-center">
<p className="font-mono text-sm text-ink-dim">
{t('dashboard.noOperations')}
</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full border-collapse min-w-[600px]">
<thead>
<tr className="border-b border-border bg-surface">
<th className="table-header text-left px-4 py-3">{t('dashboard.colOperationId')}</th>
<th className="table-header text-left px-4 py-3">{t('dashboard.colAgent')}</th>
<th className="table-header text-left px-4 py-3">{t('dashboard.colType')}</th>
<th className="table-header text-left px-4 py-3">{t('dashboard.colIssued')}</th>
<th className="table-header text-left px-4 py-3">{t('dashboard.colSeq')}</th>
</tr>
</thead>
<tbody>
{recentOps.map((op) => (
<tr key={op.operation_id} className="data-row border-b border-border last:border-b-0">
<td className="px-4 py-3">
<Link
href={`/operations/${op.operation_id}`}
className="font-mono text-[13px] text-ink hover:underline no-underline"
>
{op.operation_id.slice(0, 16)}...
</Link>
</td>
<td className="px-4 py-3 font-mono text-[13px] text-ink-dim">
{op.agent_id}
</td>
<td className="px-4 py-3">
<span className="font-mono text-[11px] uppercase tracking-wider px-2 py-1 bg-surface border border-border">
{op.operation_type}
</span>
</td>
<td className="px-4 py-3 font-mono text-[12px] text-ink-dim">
{formatRelativeTime(op.issued_at)}
</td>
<td className="px-4 py-3 font-mono text-[13px] text-ink-dim">
#{op.seq_no}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
{/* Agent Status Summary */}
<div>
<div className="section-label mb-3">{t('dashboard.agentSummary')}</div>
<div className="border border-border bg-surface p-4 sm:p-6">
{agentsLoading ? (
<div className="flex gap-8">
<div className="skeleton h-4 w-32" />
<div className="skeleton h-4 w-32" />
<div className="skeleton h-4 w-32" />
</div>
) : agentsList.length === 0 ? (
<p className="font-mono text-sm text-ink-dim">
{t('dashboard.noAgentsRegistered')}
</p>
) : (
<div className="flex flex-wrap gap-8">
<div>
<div className="font-mono text-[11px] text-ink-dim uppercase tracking-wider mb-1">
{t('dashboard.totalAgents')}
</div>
<div className="font-sans text-xl font-semibold text-ink">
{agentsList.length}
</div>
</div>
<div>
<div className="font-mono text-[11px] text-ink-dim uppercase tracking-wider mb-1">
{t('dashboard.totalOperations')}
</div>
<div className="font-sans text-xl font-semibold text-ink">
{totalOps}
</div>
</div>
{operations.length > 0 && operations[0] != null && (
<div>
<div className="font-mono text-[11px] text-ink-dim uppercase tracking-wider mb-1">
{t('dashboard.latestActivity')}
</div>
<div className="font-mono text-sm text-ink">
{formatTimestamp(operations[0].issued_at)}
</div>
</div>
)}
</div>
)}
</div>
</div>
</div>
);
}
+127
View File
@@ -0,0 +1,127 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { api } from '@/lib/api';
import { useAuth } from '@/lib/auth';
import { useTranslation } from 'react-i18next';
export default function RegisterPage() {
const { t } = useTranslation();
const router = useRouter();
const { login } = useAuth();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [displayName, setDisplayName] = useState('');
const [orgName, setOrgName] = useState('');
const [error, setError] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError('');
setIsSubmitting(true);
try {
const result = await api.auth.register(email, password, displayName, orgName);
login(result.token, { display_name: result.user.display_name, email: result.user.email });
router.push('/');
} catch (err) {
setError(err instanceof Error ? err.message : t('register.registrationFailed'));
} finally {
setIsSubmitting(false);
}
}
return (
<div className="min-h-screen bg-bg flex items-center justify-center px-6">
<div className="w-full max-w-[400px]">
{/* Logo */}
<div className="mb-12 text-center">
<div className="inline-flex items-center gap-3">
<div className="w-10 h-10 border border-ink flex items-center justify-center">
<span className="font-mono text-lg font-bold text-ink">E</span>
</div>
<div className="text-left">
<div className="font-sans text-base font-semibold tracking-wide text-ink">ELYDORA</div>
<div className="font-mono text-[10px] text-ink-dim tracking-widest uppercase">Console</div>
</div>
</div>
</div>
{/* Form */}
<div className="border border-border bg-surface p-8">
<h1 className="font-sans text-xl font-semibold tracking-tight text-ink mb-6">{t('register.createAccount')}</h1>
{error && (
<div className="mb-4 p-3 border border-red-300 bg-red-50 text-red-700 text-sm font-mono">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block font-mono text-[11px] uppercase tracking-wider text-ink-dim mb-1.5">{t('register.orgName')}</label>
<input
type="text"
value={orgName}
onChange={(e) => setOrgName(e.target.value)}
required
className="w-full px-3 py-2.5 border border-border bg-bg font-mono text-sm text-ink focus:outline-none focus:border-ink transition-colors"
placeholder={t('register.orgPlaceholder')}
/>
</div>
<div>
<label className="block font-mono text-[11px] uppercase tracking-wider text-ink-dim mb-1.5">{t('register.displayName')}</label>
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
required
className="w-full px-3 py-2.5 border border-border bg-bg font-mono text-sm text-ink focus:outline-none focus:border-ink transition-colors"
placeholder={t('register.displayNamePlaceholder')}
/>
</div>
<div>
<label className="block font-mono text-[11px] uppercase tracking-wider text-ink-dim mb-1.5">{t('register.email')}</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full px-3 py-2.5 border border-border bg-bg font-mono text-sm text-ink focus:outline-none focus:border-ink transition-colors"
placeholder={t('register.emailPlaceholder')}
/>
</div>
<div>
<label className="block font-mono text-[11px] uppercase tracking-wider text-ink-dim mb-1.5">{t('register.password')}</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
className="w-full px-3 py-2.5 border border-border bg-bg font-mono text-sm text-ink focus:outline-none focus:border-ink transition-colors"
placeholder={t('register.passwordPlaceholder')}
/>
</div>
<button
type="submit"
disabled={isSubmitting}
className="w-full btn-brutalist py-3 text-xs uppercase font-bold tracking-wider disabled:opacity-50"
>
{isSubmitting ? t('register.creatingAccount') : t('register.submitCreateAccount')}
</button>
</form>
<div className="mt-6 text-center">
<span className="font-mono text-xs text-ink-dim">{t('register.hasAccount')}</span>
<Link href="/login" className="font-mono text-xs text-ink underline hover:no-underline">
{t('register.signIn')}
</Link>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,92 @@
'use client';
import { useAuth } from '@/lib/auth';
import { useTranslation } from 'react-i18next';
import PageHeader from '@/components/ui/PageHeader';
import CopyButton from '@/components/ui/CopyButton';
export default function SettingsPage() {
const { t } = useTranslation();
const { user, logout } = useAuth();
return (
<div className="fade-in">
<PageHeader
title={t('settings.title')}
subtitle={t('settings.subtitle')}
breadcrumbs={[
{ label: t('common.dashboard'), href: '/' },
{ label: t('settings.title') },
]}
/>
{/* User Information */}
<div className="mb-8">
<div className="section-label mb-3">{t('settings.user')}</div>
<div className="border border-border bg-surface p-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<div className="font-mono text-[10px] text-ink-dim uppercase tracking-wider mb-1">
{t('settings.displayName')}
</div>
<div className="font-sans text-sm text-ink">
{user?.display_name ?? '\u2014'}
</div>
</div>
<div>
<div className="font-mono text-[10px] text-ink-dim uppercase tracking-wider mb-1">
{t('settings.email')}
</div>
<div className="font-sans text-sm text-ink">
{user?.email ?? '\u2014'}
</div>
</div>
<div>
<div className="font-mono text-[10px] text-ink-dim uppercase tracking-wider mb-1">
{t('settings.subject')}
</div>
<div className="flex items-center gap-1.5">
<span className="font-mono text-[13px] text-ink">{user?.sub ?? '\u2014'}</span>
{user?.sub && <CopyButton text={user.sub} />}
</div>
</div>
<div>
<div className="font-mono text-[10px] text-ink-dim uppercase tracking-wider mb-1">
{t('settings.role')}
</div>
<span className="font-mono text-[11px] uppercase tracking-wider px-2 py-1 bg-bg border border-border text-ink">
{user?.role ?? '\u2014'}
</span>
</div>
</div>
</div>
</div>
{/* Organization */}
<div className="mb-8">
<div className="section-label mb-3">{t('settings.organization')}</div>
<div className="border border-border bg-surface p-6">
<div>
<div className="font-mono text-[10px] text-ink-dim uppercase tracking-wider mb-1">
{t('settings.organizationId')}
</div>
<div className="flex items-center gap-1.5">
<span className="font-mono text-[13px] text-ink">{user?.org_id ?? '\u2014'}</span>
{user?.org_id && <CopyButton text={user.org_id} />}
</div>
</div>
</div>
</div>
{/* Sign Out */}
<div>
<button
onClick={logout}
className="px-4 py-2 font-mono text-[12px] uppercase tracking-wider border border-border bg-surface text-ink hover:bg-bg transition-colors"
>
{t('common.signOut')}
</button>
</div>
</div>
);
}
@@ -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>
);
}
+28
View File
@@ -0,0 +1,28 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import en from './en.json';
import zh from './zh.json';
const resources = {
en: { translation: en },
zh: { translation: zh },
};
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources,
fallbackLng: 'en',
interpolation: {
escapeValue: false,
},
detection: {
order: ['localStorage', 'navigator'],
caches: ['localStorage'],
},
});
export default i18n;
+390
View File
@@ -0,0 +1,390 @@
{
"common": {
"dashboard": "Dashboard",
"agents": "Agents",
"operations": "Operations",
"epochs": "Epochs",
"auditTrail": "Audit Trail",
"exports": "Exports",
"jwks": "JWKS",
"settings": "Settings",
"cancel": "Cancel",
"delete": "Delete",
"download": "Download",
"continue": "Continue",
"signOut": "Sign Out",
"copy": "Copy",
"copied": "Copied",
"clickToCopy": "Click to copy",
"viewAll": "View All",
"home": "Home",
"docs": "Docs",
"console": "Console",
"loading": "Loading...",
"unknownError": "Unknown error.",
"allData": "All data",
"copyToClipboard": "Copy to clipboard",
"copyJwks": "Copy JWKS",
"links": "LINKS"
},
"sidebar": {
"overview": "OVERVIEW",
"management": "MANAGEMENT",
"compliance": "COMPLIANCE",
"system": "SYSTEM"
},
"dashboard": {
"title": "Dashboard",
"subtitle": "Elydora accountability console overview",
"totalAgents": "Total Agents",
"registeredAgents": "Registered agents",
"totalOperations": "Total Operations",
"allRecordedOps": "All recorded operations",
"activeEpochs": "Active Epochs",
"merkleRollups": "Merkle rollup epochs",
"pendingExports": "Pending Exports",
"queuedOrRunning": "Queued or running",
"quickActions": "Quick Actions",
"registerAgent": "Register Agent",
"viewOperations": "View Operations",
"createExport": "Create Export",
"recentOperations": "Recent Operations",
"noOperations": "No operations recorded yet.",
"agentSummary": "Agent Summary",
"noAgentsRegistered": "No agents registered yet. Register an agent to start recording operations.",
"latestActivity": "Latest Activity",
"failedToLoad": "Failed to load dashboard data. Ensure the API is reachable.",
"colOperationId": "Operation ID",
"colAgent": "Agent",
"colType": "Type",
"colIssued": "Issued",
"colSeq": "Seq"
},
"agents": {
"title": "Agents",
"subtitle": "Manage registered AI agents",
"registerAgent": "Register Agent",
"searchPlaceholder": "Search agents by ID...",
"colAgent": "Agent",
"colStatus": "Status",
"colOperations": "Operations",
"colLatestActivity": "Latest Activity",
"emptyMessage": "No agents found. Register an agent to get started.",
"failedToLoad": "Failed to load agents.",
"deleteAgent": "Delete Agent",
"deleteConfirmPre": "Are you sure you want to delete agent ",
"deleteConfirmPost": "? This will permanently remove the agent and all its keys. This action cannot be undone.",
"deleting": "Deleting...",
"failedToDelete": "Failed to delete agent."
},
"agentDetail": {
"title": "Agent Details",
"agentId": "Agent ID",
"status": "Status",
"organization": "Organization",
"displayName": "Display Name",
"responsibleEntity": "Responsible Entity",
"created": "Created",
"keys": "Keys",
"noKeys": "No keys registered for this agent.",
"colKeyId": "Key ID",
"colAlgorithm": "Algorithm",
"colStatus": "Status",
"colCreated": "Created",
"revoke": "Revoke",
"operations": "Operations",
"freezeAgent": "Freeze Agent",
"unfreezeAgent": "Unfreeze Agent",
"freezeDescPre": "Freezing agent ",
"freezeDescPost": " will prevent it from submitting new operations. This action is recorded in the audit trail.",
"unfreezeDescPre": "Unfreezing agent ",
"unfreezeDescPost": " will allow it to resume submitting operations. This action is recorded in the audit trail.",
"reason": "Reason",
"freezePlaceholder": "Provide a reason for freezing this agent...",
"unfreezePlaceholder": "Provide a reason for unfreezing this agent...",
"freezing": "Freezing...",
"confirmFreeze": "Confirm Freeze",
"unfreezing": "Unfreezing...",
"confirmUnfreeze": "Confirm Unfreeze",
"failedToFreeze": "Failed to freeze agent.",
"failedToUnfreeze": "Failed to unfreeze agent.",
"reasonRequired": "A reason is required.",
"revokeKey": "Revoke Key",
"revokeKeyDescPre": "Revoking key ",
"revokeKeyDescPost": " will prevent it from being used to sign operations. This action cannot be undone.",
"revokePlaceholder": "Provide a reason for revoking this key...",
"revoking": "Revoking...",
"confirmRevoke": "Confirm Revoke",
"failedToRevoke": "Failed to revoke key.",
"failedToLoad": "Failed to load agent.",
"mayNotExist": "The agent may not exist.",
"noOperations": "No operations recorded for this agent."
},
"operationDetail": {
"title": "Operation Details",
"failedToLoad": "Failed to load operation.",
"mayNotExist": "The operation may not exist."
},
"operationCard": {
"operationRecord": "Operation Record",
"formatted": "Formatted",
"rawJson": "Raw JSON",
"operationId": "Operation ID",
"agentId": "Agent ID",
"orgId": "Org ID",
"type": "Type",
"issuedAt": "Issued At",
"createdAt": "Created At",
"ttlMs": "TTL (ms)",
"nonce": "Nonce",
"seqNo": "Seq No",
"signingKey": "Signing Key",
"payloadHash": "Payload Hash",
"prevChainHash": "Prev Chain Hash",
"chainHash": "Chain Hash",
"r2PayloadKey": "R2 Payload Key",
"payload": "Payload",
"receipt": "Receipt (EAR)",
"receiptId": "Receipt ID",
"r2ReceiptKey": "R2 Receipt Key"
},
"operations": {
"title": "Operations",
"subtitle": "Browse and inspect recorded operations",
"searchPlaceholder": "Search by ID, agent, or type...",
"colOperationId": "Operation ID",
"colAgent": "Agent",
"colType": "Type",
"colIssuedAt": "Issued At",
"colSeq": "Seq",
"agentIdFilter": "Agent ID",
"filterByAgent": "Filter by agent",
"typeFilter": "Type",
"filterByType": "Filter by type",
"emptyMessage": "No operations found matching your filters.",
"failedToLoad": "Failed to load operations."
},
"epochs": {
"title": "Epochs",
"subtitle": "Periodic Merkle-tree rollups anchoring operations",
"colEpochId": "Epoch ID",
"colTimeRange": "Time Range",
"colLeaves": "Leaves",
"colRootHash": "Root Hash",
"colCreated": "Created",
"emptyMessage": "No epochs have been created yet. Epochs are generated periodically as operations are recorded.",
"failedToLoad": "Failed to load epochs."
},
"epochDetail": {
"title": "Epoch Details",
"viewOperations": "View Operations",
"epochMetadata": "Epoch Metadata",
"epochId": "Epoch ID",
"organization": "Organization",
"startTime": "Start Time",
"endTime": "End Time",
"duration": "Duration",
"durationMinutes": "{{count}} minutes",
"createdAt": "Created At",
"merkleTree": "Merkle Tree",
"rootHash": "Root Hash",
"leafCount": "Leaf Count",
"operationsUnit": "operations",
"tsaAnchor": "TSA Anchor",
"tsaToken": "TSA Token",
"tsaUrl": "TSA URL",
"anchoredAt": "Anchored At",
"noTsaAnchor": "No TSA anchor information available for this epoch.",
"storage": "Storage",
"r2EpochKey": "R2 Epoch Key",
"failedToLoad": "Failed to load epoch.",
"mayNotExist": "The epoch may not exist."
},
"audit": {
"title": "Audit Trail",
"subtitle": "Query and inspect the full audit log of operations",
"exportData": "Export Data",
"queryBuilder": "Query Builder",
"clearFilters": "Clear Filters",
"agentId": "Agent ID",
"filterByAgent": "Filter by agent",
"operationType": "Operation Type",
"filterByType": "Filter by type",
"startTime": "Start Time",
"endTime": "End Time",
"showing": "Showing {{shown}} of {{total}} results",
"moreResults": "More results available",
"colOperationId": "Operation ID",
"colAgent": "Agent",
"colType": "Type",
"colIssuedAt": "Issued At",
"colSeq": "Seq",
"colChainHash": "Chain Hash",
"emptyMessage": "No audit records match your query. Adjust your filters or clear them to see all records.",
"failedToLoad": "Failed to load audit data."
},
"exports": {
"title": "Exports",
"subtitle": "Create and download compliance data exports",
"createExport": "Create Export",
"colExportId": "Export ID",
"colStatus": "Status",
"colFilters": "Filters",
"colCreated": "Created",
"colCompleted": "Completed",
"emptyMessage": "No exports have been created yet. Create an export to download compliance data.",
"failedToLoad": "Failed to load exports.",
"startTime": "Start Time",
"endTime": "End Time",
"agentIdOptional": "Agent ID (optional)",
"filterByAgent": "Filter by specific agent",
"operationTypeOptional": "Operation Type (optional)",
"filterByOperationType": "Filter by operation type",
"format": "Format",
"creating": "Creating...",
"startEndRequired": "Start time and end time are required.",
"failedToCreate": "Failed to create export."
},
"jwks": {
"title": "JWKS",
"subtitle": "JSON Web Key Set for token verification",
"publicKeys": "Public Keys",
"colKeyId": "Key ID",
"colKeyType": "Key Type",
"colAlgorithm": "Algorithm",
"colCurve": "Curve",
"colUse": "Use",
"colPublicKey": "Public Key",
"emptyMessage": "No JWKS keys available. The server may not have published any public keys.",
"failedToLoad": "Failed to load JWKS.",
"endpoint": "JWKS endpoint:"
},
"settings": {
"title": "Settings",
"subtitle": "Account and organization details",
"user": "User",
"displayName": "Display Name",
"email": "Email",
"subject": "Subject",
"role": "Role",
"organization": "Organization",
"organizationId": "Organization ID"
},
"login": {
"signIn": "Sign in",
"email": "Email",
"emailPlaceholder": "you@company.com",
"password": "Password",
"signingIn": "Signing in...",
"submitSignIn": "Sign In",
"noAccount": "No account? ",
"createOne": "Create one",
"loginFailed": "Login failed"
},
"register": {
"createAccount": "Create account",
"orgName": "Organization Name",
"orgPlaceholder": "Acme Corp",
"displayName": "Display Name",
"displayNamePlaceholder": "Jane Smith",
"email": "Email",
"emailPlaceholder": "you@company.com",
"password": "Password",
"passwordPlaceholder": "Minimum 8 characters",
"creatingAccount": "Creating account...",
"submitCreateAccount": "Create Account",
"hasAccount": "Already have an account? ",
"signIn": "Sign in",
"registrationFailed": "Registration failed"
},
"agentRegistration": {
"displayName": "Display Name",
"displayNamePlaceholder": "My AI Agent",
"displayNameRequired": "Display Name is required.",
"responsibleEntity": "Responsible Entity (optional)",
"responsibleEntityPlaceholder": "Person or team responsible",
"submitting": "Registering...",
"submit": "Register Agent",
"failedToRegister": "Failed to register agent.",
"agentRegistered": "Agent Registered",
"privateKeySaveNow": "Private Key — Save This Now",
"privateKeyWarning": "This will not be shown again. Store it as an environment variable or in a secrets manager.",
"apiToken": "API Token",
"apiTokenDesc": "Use this as your ELYDORA_TOKEN environment variable. This is your bearer token for API authentication.",
"tokenExpiration": "Token Expiration",
"customDays": "Number of days",
"days": "days",
"enterValidDays": "Enter a valid number of days (minimum 1).",
"issuing": "Issuing...",
"issueApiToken": "Issue API Token",
"failedToIssueToken": "Failed to issue token.",
"credAgentId": "Agent ID",
"credKeyId": "Key ID",
"credOrgId": "Org ID",
"credAlgorithm": "Algorithm",
"chooseIntegration": "Choose Integration",
"chooseIntegrationDesc": "How will this agent connect to Elydora?",
"supported": "Supported (one-command install)",
"sdkIntegration": "SDK Integration",
"install": "Install",
"backToList": "Back",
"sdkTutorial": "SDK Tutorial",
"aiReadyPrompt": "AI-Ready Prompt",
"aiReadyPromptDesc": "Copy this prompt and paste it into your AI agent to set up Elydora automatically.",
"copyPrompt": "Copy Prompt",
"done": "Done",
"step": "Step {{current}} of {{total}}"
},
"chain": {
"title": "Chain Visualization",
"previous": "Previous",
"current": "Current ({{id}})",
"next": "Next",
"pending": "Pending...",
"chainBlock": "Chain block",
"currentOperation": "Current operation"
},
"verification": {
"title": "Verification",
"verify": "Verify",
"reVerify": "Re-verify",
"verifying": "Verifying...",
"verifiedSuccess": "Operation verified successfully",
"verificationFailed": "Verification failed",
"signature": "Signature",
"signatureDesc": "Ed25519 signature is valid for the given public key",
"chainCheck": "Chain",
"chainCheckDesc": "Previous chain hash matches the expected value",
"receiptCheck": "Receipt",
"receiptCheckDesc": "Server receipt (EAR) is valid and matches the operation",
"merkle": "Merkle",
"merkleDesc": "Operation is included in the epoch Merkle tree",
"pending": "Pending",
"passed": "Passed",
"failed": "Failed",
"errors": "Errors"
},
"tokenExpiration": {
"24hours": "24 hours",
"7days": "7 days",
"1month": "1 month",
"1year": "1 year",
"custom": "Custom",
"neverExpire": "Never expire"
},
"integrations": {
"claudecode": "Anthropic CLI agent",
"cursor": "AI-powered code editor",
"gemini": "Google AI CLI agent",
"kirocli": "AWS AI CLI agent",
"kiroide": "AWS spec-driven AI IDE",
"opencode": "Open-source TUI agent",
"copilot": "GitHub Copilot CLI agent",
"letta": "Letta AI CLI agent",
"codex": "OpenAI CLI agent",
"kimi": "Moonshot AI assistant",
"enterprise": "Your own internal agent",
"gui": "Desktop or web-based agent",
"other": "Any custom integration"
}
}
+390
View File
@@ -0,0 +1,390 @@
{
"common": {
"dashboard": "仪表盘",
"agents": "代理",
"operations": "操作",
"epochs": "纪元",
"auditTrail": "审计追踪",
"exports": "导出",
"jwks": "JWKS",
"settings": "设置",
"cancel": "取消",
"delete": "删除",
"download": "下载",
"continue": "继续",
"signOut": "退出登录",
"copy": "复制",
"copied": "已复制",
"clickToCopy": "点击复制",
"viewAll": "查看全部",
"home": "首页",
"docs": "文档",
"console": "控制台",
"loading": "加载中...",
"unknownError": "未知错误。",
"allData": "全部数据",
"copyToClipboard": "复制到剪贴板",
"copyJwks": "复制 JWKS",
"links": "链接"
},
"sidebar": {
"overview": "概览",
"management": "管理",
"compliance": "合规",
"system": "系统"
},
"dashboard": {
"title": "仪表盘",
"subtitle": "Elydora 责任追溯控制台概览",
"totalAgents": "代理总数",
"registeredAgents": "已注册代理",
"totalOperations": "操作总数",
"allRecordedOps": "所有已记录操作",
"activeEpochs": "活跃纪元",
"merkleRollups": "Merkle 树汇总纪元",
"pendingExports": "待处理导出",
"queuedOrRunning": "排队中或运行中",
"quickActions": "快捷操作",
"registerAgent": "注册代理",
"viewOperations": "查看操作",
"createExport": "创建导出",
"recentOperations": "最近操作",
"noOperations": "尚无操作记录。",
"agentSummary": "代理概要",
"noAgentsRegistered": "尚未注册代理。注册一个代理以开始记录操作。",
"latestActivity": "最近活动",
"failedToLoad": "加载仪表盘数据失败。请确保 API 可访问。",
"colOperationId": "操作 ID",
"colAgent": "代理",
"colType": "类型",
"colIssued": "签发时间",
"colSeq": "序号"
},
"agents": {
"title": "代理",
"subtitle": "管理已注册的 AI 代理",
"registerAgent": "注册代理",
"searchPlaceholder": "按 ID 搜索代理...",
"colAgent": "代理",
"colStatus": "状态",
"colOperations": "操作数",
"colLatestActivity": "最近活动",
"emptyMessage": "未找到代理。注册一个代理以开始使用。",
"failedToLoad": "加载代理列表失败。",
"deleteAgent": "删除代理",
"deleteConfirmPre": "确定要删除代理 ",
"deleteConfirmPost": " 吗?这将永久移除该代理及其所有密钥。此操作无法撤销。",
"deleting": "删除中...",
"failedToDelete": "删除代理失败。"
},
"agentDetail": {
"title": "代理详情",
"agentId": "代理 ID",
"status": "状态",
"organization": "组织",
"displayName": "显示名称",
"responsibleEntity": "责任实体",
"created": "创建时间",
"keys": "密钥",
"noKeys": "该代理没有已注册的密钥。",
"colKeyId": "密钥 ID",
"colAlgorithm": "算法",
"colStatus": "状态",
"colCreated": "创建时间",
"revoke": "吊销",
"operations": "操作",
"freezeAgent": "冻结代理",
"unfreezeAgent": "解冻代理",
"reason": "原因",
"freezePlaceholder": "请提供冻结此代理的原因...",
"unfreezePlaceholder": "请提供解冻此代理的原因...",
"freezing": "冻结中...",
"confirmFreeze": "确认冻结",
"unfreezing": "解冻中...",
"confirmUnfreeze": "确认解冻",
"failedToFreeze": "冻结代理失败。",
"failedToUnfreeze": "解冻代理失败。",
"reasonRequired": "原因为必填项。",
"revokeKey": "吊销密钥",
"revokePlaceholder": "请提供吊销此密钥的原因...",
"revoking": "吊销中...",
"confirmRevoke": "确认吊销",
"failedToRevoke": "吊销密钥失败。",
"freezeDescPre": "冻结代理 ",
"freezeDescPost": " 将阻止其提交新操作。此操作会记录在审计追踪中。",
"unfreezeDescPre": "解冻代理 ",
"unfreezeDescPost": " 将允许其恢复提交操作。此操作会记录在审计追踪中。",
"revokeKeyDescPre": "吊销密钥 ",
"revokeKeyDescPost": " 将阻止其用于签署操作。此操作无法撤销。",
"failedToLoad": "加载代理信息失败。",
"mayNotExist": "该代理可能不存在。",
"noOperations": "该代理尚无操作记录。"
},
"operationDetail": {
"title": "操作详情",
"failedToLoad": "加载操作信息失败。",
"mayNotExist": "该操作可能不存在。"
},
"operationCard": {
"operationRecord": "操作记录",
"formatted": "格式化",
"rawJson": "原始 JSON",
"operationId": "操作 ID",
"agentId": "代理 ID",
"orgId": "组织 ID",
"type": "类型",
"issuedAt": "签发时间",
"createdAt": "创建时间",
"ttlMs": "TTL (ms)",
"nonce": "Nonce",
"seqNo": "序号",
"signingKey": "签名密钥",
"payloadHash": "载荷哈希",
"prevChainHash": "上一链哈希",
"chainHash": "链哈希",
"r2PayloadKey": "R2 载荷键",
"payload": "载荷",
"receipt": "回执 (EAR)",
"receiptId": "回执 ID",
"r2ReceiptKey": "R2 回执键"
},
"operations": {
"title": "操作",
"subtitle": "浏览和检查已记录的操作",
"searchPlaceholder": "按 ID、代理或类型搜索...",
"colOperationId": "操作 ID",
"colAgent": "代理",
"colType": "类型",
"colIssuedAt": "签发时间",
"colSeq": "序号",
"agentIdFilter": "代理 ID",
"filterByAgent": "按代理筛选",
"typeFilter": "类型",
"filterByType": "按类型筛选",
"emptyMessage": "没有符合筛选条件的操作。",
"failedToLoad": "加载操作列表失败。"
},
"epochs": {
"title": "纪元",
"subtitle": "定期 Merkle 树汇总锚定操作",
"colEpochId": "纪元 ID",
"colTimeRange": "时间范围",
"colLeaves": "叶子数",
"colRootHash": "根哈希",
"colCreated": "创建时间",
"emptyMessage": "尚未创建纪元。纪元会随着操作的记录定期生成。",
"failedToLoad": "加载纪元列表失败。"
},
"epochDetail": {
"title": "纪元详情",
"viewOperations": "查看操作",
"epochMetadata": "纪元元数据",
"epochId": "纪元 ID",
"organization": "组织",
"startTime": "开始时间",
"endTime": "结束时间",
"duration": "持续时间",
"durationMinutes": "{{count}} 分钟",
"createdAt": "创建时间",
"merkleTree": "Merkle 树",
"rootHash": "根哈希",
"leafCount": "叶子数量",
"operationsUnit": "个操作",
"tsaAnchor": "TSA 锚点",
"tsaToken": "TSA 令牌",
"tsaUrl": "TSA URL",
"anchoredAt": "锚定时间",
"noTsaAnchor": "此纪元暂无 TSA 锚点信息。",
"storage": "存储",
"r2EpochKey": "R2 纪元键",
"failedToLoad": "加载纪元信息失败。",
"mayNotExist": "该纪元可能不存在。"
},
"audit": {
"title": "审计追踪",
"subtitle": "查询和检查完整的操作审计日志",
"exportData": "导出数据",
"queryBuilder": "查询构建器",
"clearFilters": "清除筛选",
"agentId": "代理 ID",
"filterByAgent": "按代理筛选",
"operationType": "操作类型",
"filterByType": "按类型筛选",
"startTime": "开始时间",
"endTime": "结束时间",
"showing": "显示 {{shown}} / {{total}} 条结果",
"moreResults": "还有更多结果",
"colOperationId": "操作 ID",
"colAgent": "代理",
"colType": "类型",
"colIssuedAt": "签发时间",
"colSeq": "序号",
"colChainHash": "链哈希",
"emptyMessage": "没有匹配的审计记录。请调整筛选条件或清除筛选以查看所有记录。",
"failedToLoad": "加载审计数据失败。"
},
"exports": {
"title": "导出",
"subtitle": "创建和下载合规数据导出",
"createExport": "创建导出",
"colExportId": "导出 ID",
"colStatus": "状态",
"colFilters": "筛选条件",
"colCreated": "创建时间",
"colCompleted": "完成时间",
"emptyMessage": "尚未创建导出。创建导出以下载合规数据。",
"failedToLoad": "加载导出列表失败。",
"startTime": "开始时间",
"endTime": "结束时间",
"agentIdOptional": "代理 ID(可选)",
"filterByAgent": "按特定代理筛选",
"operationTypeOptional": "操作类型(可选)",
"filterByOperationType": "按操作类型筛选",
"format": "格式",
"creating": "创建中...",
"startEndRequired": "开始时间和结束时间为必填项。",
"failedToCreate": "创建导出失败。"
},
"jwks": {
"title": "JWKS",
"subtitle": "用于令牌验证的 JSON Web 密钥集",
"publicKeys": "公钥",
"colKeyId": "密钥 ID",
"colKeyType": "密钥类型",
"colAlgorithm": "算法",
"colCurve": "曲线",
"colUse": "用途",
"colPublicKey": "公钥",
"emptyMessage": "没有可用的 JWKS 密钥。服务器可能尚未发布公钥。",
"failedToLoad": "加载 JWKS 失败。",
"endpoint": "JWKS 端点:"
},
"settings": {
"title": "设置",
"subtitle": "账户和组织详情",
"user": "用户",
"displayName": "显示名称",
"email": "邮箱",
"subject": "主体",
"role": "角色",
"organization": "组织",
"organizationId": "组织 ID"
},
"login": {
"signIn": "登录",
"email": "邮箱",
"emailPlaceholder": "you@company.com",
"password": "密码",
"signingIn": "登录中...",
"submitSignIn": "登录",
"noAccount": "还没有账户?",
"createOne": "创建一个",
"loginFailed": "登录失败"
},
"register": {
"createAccount": "创建账户",
"orgName": "组织名称",
"orgPlaceholder": "示例公司",
"displayName": "显示名称",
"displayNamePlaceholder": "张三",
"email": "邮箱",
"emailPlaceholder": "you@company.com",
"password": "密码",
"passwordPlaceholder": "至少 8 个字符",
"creatingAccount": "创建账户中...",
"submitCreateAccount": "创建账户",
"hasAccount": "已有账户?",
"signIn": "登录",
"registrationFailed": "注册失败"
},
"agentRegistration": {
"displayName": "显示名称",
"displayNamePlaceholder": "我的 AI 代理",
"displayNameRequired": "显示名称为必填项。",
"responsibleEntity": "责任实体(可选)",
"responsibleEntityPlaceholder": "负责人或团队",
"submitting": "注册中...",
"submit": "注册代理",
"failedToRegister": "注册代理失败。",
"agentRegistered": "代理注册成功",
"privateKeySaveNow": "私钥 — 请立即保存",
"privateKeyWarning": "此私钥不会再次显示。请将其存储为环境变量或保存在密钥管理器中。",
"apiToken": "API 令牌",
"apiTokenDesc": "将其用作 ELYDORA_TOKEN 环境变量。这是您的 API 认证令牌。",
"tokenExpiration": "令牌有效期",
"customDays": "天数",
"days": "天",
"enterValidDays": "请输入有效的天数(最少 1 天)。",
"issuing": "签发中...",
"issueApiToken": "签发 API 令牌",
"failedToIssueToken": "签发令牌失败。",
"credAgentId": "代理 ID",
"credKeyId": "密钥 ID",
"credOrgId": "组织 ID",
"credAlgorithm": "算法",
"chooseIntegration": "选择集成方式",
"chooseIntegrationDesc": "此代理将如何连接到 Elydora?",
"supported": "支持的(一键安装)",
"sdkIntegration": "SDK 集成",
"install": "安装",
"backToList": "返回",
"sdkTutorial": "SDK 教程",
"aiReadyPrompt": "AI 就绪提示",
"aiReadyPromptDesc": "复制此提示并粘贴到您的 AI 代理中以自动设置 Elydora。",
"copyPrompt": "复制提示",
"done": "完成",
"step": "第 {{current}} 步,共 {{total}} 步"
},
"chain": {
"title": "链可视化",
"previous": "上一个",
"current": "当前 ({{id}})",
"next": "下一个",
"pending": "待处理...",
"chainBlock": "链块",
"currentOperation": "当前操作"
},
"verification": {
"title": "验证",
"verify": "验证",
"reVerify": "重新验证",
"verifying": "验证中...",
"verifiedSuccess": "操作验证成功",
"verificationFailed": "验证失败",
"signature": "签名",
"signatureDesc": "Ed25519 签名对给定公钥有效",
"chainCheck": "链",
"chainCheckDesc": "上一链哈希与预期值匹配",
"receiptCheck": "回执",
"receiptCheckDesc": "服务器回执 (EAR) 有效且与操作匹配",
"merkle": "Merkle",
"merkleDesc": "操作包含在纪元 Merkle 树中",
"pending": "待验证",
"passed": "通过",
"failed": "失败",
"errors": "错误"
},
"tokenExpiration": {
"24hours": "24 小时",
"7days": "7 天",
"1month": "1 个月",
"1year": "1 年",
"custom": "自定义",
"neverExpire": "永不过期"
},
"integrations": {
"claudecode": "Anthropic CLI 代理",
"cursor": "AI 驱动的代码编辑器",
"gemini": "Google AI CLI 代理",
"kirocli": "AWS AI CLI 代理",
"kiroide": "AWS 规格驱动的 AI IDE",
"opencode": "开源 TUI 代理",
"copilot": "GitHub Copilot CLI 代理",
"letta": "Letta AI CLI 代理",
"codex": "OpenAI CLI 代理",
"kimi": "Moonshot AI 助手",
"enterprise": "您的内部代理",
"gui": "桌面或 Web 代理",
"other": "任何自定义集成"
}
}
+263
View File
@@ -0,0 +1,263 @@
import type {
Agent,
RegisterAgentRequest,
RegisterAgentResponse,
GetAgentResponse,
ListAgentsResponse,
FreezeAgentRequest,
UnfreezeAgentRequest,
RevokeAgentRequest,
UpdateAgentRequest,
SubmitOperationRequest,
SubmitOperationResponse,
GetOperationResponse,
VerifyOperationResponse,
AuditQueryRequest,
AuditQueryResponse,
GetEpochResponse,
CreateExportRequest,
CreateExportResponse,
GetExportResponse,
AuthLoginResponse,
AuthRegisterResponse,
AuthMeResponse,
AuthRefreshResponse,
IssueTokenResponse,
JWKSResponse,
ErrorResponse,
} from '@elydora/shared';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? 'http://localhost:8787';
class ApiError extends Error {
constructor(
public readonly status: number,
public readonly code: string,
public readonly requestId: string,
message: string,
public readonly details?: Record<string, unknown>,
) {
super(message);
this.name = 'ApiError';
}
}
function getAuthToken(): string | null {
if (typeof window === 'undefined') return null;
return localStorage.getItem('elydora_token');
}
async function request<T>(
path: string,
options: RequestInit = {},
): Promise<T> {
const token = getAuthToken();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(options.headers as Record<string, string> ?? {}),
};
const response = await fetch(`${API_BASE_URL}${path}`, {
...options,
headers,
});
if (!response.ok) {
let errorBody: ErrorResponse | null = null;
try {
errorBody = await response.json() as ErrorResponse;
} catch {
// Response body is not JSON
}
if (errorBody?.error) {
throw new ApiError(
response.status,
errorBody.error.code,
errorBody.error.request_id,
errorBody.error.message,
errorBody.error.details,
);
}
throw new ApiError(
response.status,
'INTERNAL_ERROR',
'unknown',
`Request failed with status ${response.status}`,
);
}
return response.json() as Promise<T>;
}
export const api = {
agents: {
list(): Promise<ListAgentsResponse> {
return request<ListAgentsResponse>('/v1/agents');
},
register(body: RegisterAgentRequest): Promise<RegisterAgentResponse> {
return request<RegisterAgentResponse>('/v1/agents/register', {
method: 'POST',
body: JSON.stringify(body),
});
},
get(agentId: string): Promise<GetAgentResponse> {
return request<GetAgentResponse>(`/v1/agents/${encodeURIComponent(agentId)}`);
},
update(agentId: string, body: UpdateAgentRequest): Promise<{ agent: Agent }> {
return request<{ agent: Agent }>(`/v1/agents/${encodeURIComponent(agentId)}`, {
method: 'PATCH',
body: JSON.stringify(body),
});
},
freeze(agentId: string, reason: string): Promise<void> {
const body: FreezeAgentRequest = { reason };
return request<void>(`/v1/agents/${encodeURIComponent(agentId)}/freeze`, {
method: 'POST',
body: JSON.stringify(body),
});
},
unfreeze(agentId: string, reason: string): Promise<void> {
const body: UnfreezeAgentRequest = { reason };
return request<void>(`/v1/agents/${encodeURIComponent(agentId)}/unfreeze`, {
method: 'POST',
body: JSON.stringify(body),
});
},
revokeKey(agentId: string, kid: string, reason: string): Promise<void> {
const body: RevokeAgentRequest = { kid, reason };
return request<void>(`/v1/agents/${encodeURIComponent(agentId)}/revoke`, {
method: 'POST',
body: JSON.stringify(body),
});
},
delete(agentId: string): Promise<void> {
return request<void>(`/v1/agents/${encodeURIComponent(agentId)}`, {
method: 'DELETE',
});
},
},
operations: {
submit(eor: SubmitOperationRequest): Promise<SubmitOperationResponse> {
return request<SubmitOperationResponse>('/v1/operations', {
method: 'POST',
body: JSON.stringify(eor),
});
},
get(operationId: string): Promise<GetOperationResponse> {
return request<GetOperationResponse>(
`/v1/operations/${encodeURIComponent(operationId)}`,
);
},
verify(operationId: string): Promise<VerifyOperationResponse> {
return request<VerifyOperationResponse>(
`/v1/operations/${encodeURIComponent(operationId)}/verify`,
{ method: 'POST' },
);
},
},
audit: {
query(params: AuditQueryRequest): Promise<AuditQueryResponse> {
return request<AuditQueryResponse>(`/v1/audit/query`, {
method: 'POST',
body: JSON.stringify(params),
});
},
},
epochs: {
list(): Promise<{ epochs: Array<{ epoch_id: string; org_id: string; start_time: number; end_time: number; root_hash: string; leaf_count: number; r2_epoch_key: string; created_at: number }> }> {
return request(`/v1/epochs`);
},
get(epochId: string): Promise<GetEpochResponse> {
return request<GetEpochResponse>(`/v1/epochs/${encodeURIComponent(epochId)}`);
},
},
exports: {
create(body: CreateExportRequest): Promise<CreateExportResponse> {
return request<CreateExportResponse>('/v1/exports', {
method: 'POST',
body: JSON.stringify(body),
});
},
get(exportId: string): Promise<GetExportResponse> {
return request<GetExportResponse>(`/v1/exports/${encodeURIComponent(exportId)}`);
},
list(): Promise<{ exports: Array<import('@elydora/shared').Export> }> {
return request(`/v1/exports`);
},
async download(exportId: string): Promise<Blob> {
const token = getAuthToken();
const response = await fetch(
`${API_BASE_URL}/v1/exports/${encodeURIComponent(exportId)}/download`,
{
headers: token ? { Authorization: `Bearer ${token}` } : {},
},
);
if (!response.ok) {
throw new ApiError(response.status, 'DOWNLOAD_ERROR', 'unknown', `Download failed with status ${response.status}`);
}
return response.blob();
},
},
jwks: {
get(): Promise<JWKSResponse> {
return request<JWKSResponse>('/.well-known/elydora/jwks.json');
},
},
auth: {
login(email: string, password: string): Promise<AuthLoginResponse> {
return request<AuthLoginResponse>('/v1/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
});
},
register(email: string, password: string, displayName: string, orgName: string): Promise<AuthRegisterResponse> {
return request<AuthRegisterResponse>('/v1/auth/register', {
method: 'POST',
body: JSON.stringify({ email, password, display_name: displayName, org_name: orgName }),
});
},
me(): Promise<AuthMeResponse> {
return request<AuthMeResponse>('/v1/auth/me');
},
refresh(): Promise<AuthRefreshResponse> {
return request<AuthRefreshResponse>('/v1/auth/refresh', {
method: 'POST',
});
},
issueToken(ttlSeconds: number | null): Promise<IssueTokenResponse> {
return request<IssueTokenResponse>('/v1/auth/token', {
method: 'POST',
body: JSON.stringify({ ttl_seconds: ttlSeconds }),
});
},
},
} as const;
export { ApiError };
export type { ErrorResponse };
+108
View File
@@ -0,0 +1,108 @@
'use client';
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
import type { ReactNode } from 'react';
import type { RbacRole } from '@elydora/shared';
interface AuthUser {
sub: string;
org_id: string;
role: RbacRole;
display_name?: string;
email?: string;
}
interface AuthContextValue {
user: AuthUser | null;
token: string | null;
isAuthenticated: boolean;
isLoading: boolean;
login: (token: string, user?: { display_name?: string; email?: string }) => void;
logout: () => void;
}
const AuthContext = createContext<AuthContextValue | null>(null);
function decodeJWTPayload(token: string): AuthUser | null {
try {
const parts = token.split('.');
if (parts.length !== 3) return null;
// base64url decode
let base64 = parts[1]!.replace(/-/g, '+').replace(/_/g, '/');
while (base64.length % 4 !== 0) base64 += '=';
const json = atob(base64);
const payload = JSON.parse(json);
// Check expiration
const nowSeconds = Math.floor(Date.now() / 1000);
if (typeof payload.exp === 'number' && payload.exp < nowSeconds) return null;
if (!payload.sub || !payload.org_id || !payload.role) return null;
return { sub: payload.sub, org_id: payload.org_id, role: payload.role };
} catch {
return null;
}
}
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthUser | null>(null);
const [token, setToken] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const stored = localStorage.getItem('elydora_token');
if (stored) {
const decoded = decodeJWTPayload(stored);
if (decoded) {
setToken(stored);
// Try to fetch full user profile from localStorage
const displayName = localStorage.getItem('elydora_display_name');
const email = localStorage.getItem('elydora_email');
if (displayName) decoded.display_name = displayName;
if (email) decoded.email = email;
setUser({ ...decoded });
} else {
// Token expired or invalid, clear it
localStorage.removeItem('elydora_token');
localStorage.removeItem('elydora_display_name');
localStorage.removeItem('elydora_email');
}
}
setIsLoading(false);
}, []);
const login = useCallback((newToken: string, userInfo?: { display_name?: string; email?: string }) => {
localStorage.setItem('elydora_token', newToken);
const decoded = decodeJWTPayload(newToken);
if (decoded) {
if (userInfo?.display_name) {
decoded.display_name = userInfo.display_name;
localStorage.setItem('elydora_display_name', userInfo.display_name);
}
if (userInfo?.email) {
decoded.email = userInfo.email;
localStorage.setItem('elydora_email', userInfo.email);
}
setUser(decoded);
}
setToken(newToken);
}, []);
const logout = useCallback(() => {
localStorage.removeItem('elydora_token');
localStorage.removeItem('elydora_display_name');
localStorage.removeItem('elydora_email');
setUser(null);
setToken(null);
}, []);
return (
<AuthContext.Provider value={{ user, token, isAuthenticated: !!user, isLoading, login, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
return ctx;
}
+177
View File
@@ -0,0 +1,177 @@
'use client';
import useSWR from 'swr';
import { useEffect, useRef, useState, useCallback } from 'react';
import { api } from './api';
import type {
AuditQueryRequest,
GetAgentResponse,
ListAgentsResponse,
GetOperationResponse,
GetEpochResponse,
GetExportResponse,
AuditQueryResponse,
JWKSResponse,
} from '@elydora/shared';
export function useAgent(agentId: string | undefined) {
return useSWR<GetAgentResponse>(
agentId ? `/agents/${agentId}` : null,
agentId ? () => api.agents.get(agentId) : null,
{ revalidateOnFocus: false },
);
}
export function useAgentsList() {
return useSWR<ListAgentsResponse>(
'/agents',
() => api.agents.list(),
{ revalidateOnFocus: false },
);
}
export function useOperation(operationId: string | undefined) {
return useSWR<GetOperationResponse>(
operationId ? `/operations/${operationId}` : null,
operationId ? () => api.operations.get(operationId) : null,
{ revalidateOnFocus: false },
);
}
export function useAudit(params: AuditQueryRequest) {
return useSWR<AuditQueryResponse>(
['/audit', params],
() => api.audit.query(params),
{ revalidateOnFocus: false },
);
}
export function useEpoch(epochId: string | undefined) {
return useSWR<GetEpochResponse>(
epochId ? `/epochs/${epochId}` : null,
epochId ? () => api.epochs.get(epochId) : null,
{ revalidateOnFocus: false },
);
}
export function useEpochs() {
return useSWR<{ epochs: Array<{ epoch_id: string; org_id: string; start_time: number; end_time: number; root_hash: string; leaf_count: number; r2_epoch_key: string; created_at: number }> }>(
'/epochs',
() => api.epochs.list(),
{ revalidateOnFocus: false },
);
}
export function useExport(exportId: string | undefined) {
return useSWR<GetExportResponse>(
exportId ? `/exports/${exportId}` : null,
exportId ? () => api.exports.get(exportId) : null,
{
revalidateOnFocus: false,
refreshInterval: (data) => {
if (data?.export?.status === 'queued' || data?.export?.status === 'running') {
return 3000;
}
return 0;
},
},
);
}
export function useExports() {
return useSWR<{ exports: Array<import('@elydora/shared').Export> }>(
'/exports',
() => api.exports.list(),
{ revalidateOnFocus: false },
);
}
export function useJWKS() {
return useSWR<JWKSResponse>(
'/jwks',
() => api.jwks.get(),
{ revalidateOnFocus: false },
);
}
export function useFadeIn() {
const ref = useRef<HTMLDivElement>(null);
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
const element = ref.current;
if (!element) return;
const observer = new IntersectionObserver(
(entries) => {
const entry = entries[0];
if (entry?.isIntersecting) {
setIsVisible(true);
observer.unobserve(element);
}
},
{ threshold: 0.1 },
);
observer.observe(element);
return () => observer.disconnect();
}, []);
return { ref, isVisible };
}
export function useClipboard() {
const [copied, setCopied] = useState(false);
const copy = useCallback(async (text: string) => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Fallback for older browsers
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
}, []);
return { copied, copy };
}
export function formatTimestamp(ms: number): string {
return new Date(ms).toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}
export function formatRelativeTime(ms: number): string {
const now = Date.now();
const diff = now - ms;
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) return `${days}d ago`;
if (hours > 0) return `${hours}h ago`;
if (minutes > 0) return `${minutes}m ago`;
return `${seconds}s ago`;
}
export function truncateHash(hash: string, length = 8): string {
if (hash.length <= length * 2) return hash;
return `${hash.slice(0, length)}...${hash.slice(-length)}`;
}
+51
View File
@@ -0,0 +1,51 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": [
"DOM",
"DOM.Iterable",
"ESNext"
],
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"noEmit": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./src/*"
],
"@elydora/shared": [
"../server/src/shared"
],
"@elydora/shared/*": [
"../server/src/shared/*"
]
},
"allowJs": true
},
"include": [
"next-env.d.ts",
"src/**/*.ts",
"src/**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}