first commit
Agent Release / build (push) Has been cancelled
Server Deploy / deploy (push) Has been cancelled

This commit is contained in:
domrichardson
2026-06-15 13:58:45 +01:00
commit c9868b2108
55 changed files with 11076 additions and 0 deletions
+43
View File
@@ -0,0 +1,43 @@
# Dependencies stage
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ARG NEXT_PUBLIC_API_URL=http://localhost:8080
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
RUN npm run build
# Runtime stage
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
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]
+38
View File
@@ -0,0 +1,38 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
color-scheme: dark;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
background-color: #0f1117;
color: #e8eaf0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
-webkit-font-smoothing: antialiased;
}
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: #1a1d27;
}
::-webkit-scrollbar-thumb {
background: #2e3147;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #3e4160;
}
+346
View File
@@ -0,0 +1,346 @@
"use client";
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import { api, Server } from "@/lib/api";
import { Badge, Button, Card, CardHeader, CardTitle } from "@/components/ui";
import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
function AssignModal({
keyId,
assignedServerIds,
onClose,
}: {
keyId: string;
assignedServerIds: string[];
onClose: () => void;
}) {
const queryClient = useQueryClient();
const [selectedServer, setSelectedServer] = useState("");
const { data: servers } = useQuery({
queryKey: ["servers"],
queryFn: api.listServers,
});
const { mutate: assign, isPending, error } = useMutation({
mutationFn: () => api.assignKey(keyId, selectedServer),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["keys", keyId] });
queryClient.invalidateQueries({ queryKey: ["servers"] });
onClose();
},
});
const availableServers = servers?.filter(
(s: Server) => !assignedServerIds.includes(s.server_id)
);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
<div className="w-full max-w-md rounded-xl border border-border bg-surface p-6">
<h2 className="mb-4 text-lg font-semibold text-text-primary">Assign Key to Server</h2>
{error && (
<div className="mb-4 rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">
{(error as Error).message}
</div>
)}
<div className="mb-4">
<label className="mb-1.5 block text-sm font-medium text-text-secondary">
Select Server
</label>
{!availableServers || availableServers.length === 0 ? (
<p className="text-sm text-text-secondary">
All servers already have this key assigned.
</p>
) : (
<select
value={selectedServer}
onChange={(e) => setSelectedServer(e.target.value)}
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent"
>
<option value="">Choose a server...</option>
{availableServers.map((s: Server) => (
<option key={s.server_id} value={s.server_id}>
{s.hostname} ({s.ip_address})
</option>
))}
</select>
)}
</div>
<div className="flex justify-end gap-3">
<Button variant="ghost" onClick={onClose}>Cancel</Button>
<Button
variant="primary"
loading={isPending}
disabled={!selectedServer}
onClick={() => assign()}
>
Assign Key
</Button>
</div>
</div>
</div>
);
}
export default function KeyDetailPage() {
const params = useParams();
const router = useRouter();
const queryClient = useQueryClient();
const keyId = params.id as string;
const [showAssign, setShowAssign] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const [copiedKey, setCopiedKey] = useState(false);
const { data: key, isLoading, error } = useQuery({
queryKey: ["keys", keyId],
queryFn: () => api.getKey(keyId),
});
const { mutate: revokeKey } = useMutation({
mutationFn: (serverId: string) => api.revokeKey(keyId, serverId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["keys", keyId] });
queryClient.invalidateQueries({ queryKey: ["servers"] });
},
});
const { mutate: deleteKey, isPending: isDeleting } = useMutation({
mutationFn: () => api.deleteKey(keyId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["keys"] });
router.push("/keys");
},
});
const handleCopyKey = async () => {
if (!key?.public_key) return;
await navigator.clipboard.writeText(key.public_key);
setCopiedKey(true);
setTimeout(() => setCopiedKey(false), 2000);
};
if (isLoading) {
return (
<div className="flex h-full items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
</div>
);
}
if (error || !key) {
return (
<div className="p-8">
<div className="rounded-lg border border-danger/30 bg-danger/10 p-4 text-danger">
Key not found or failed to load.
</div>
</div>
);
}
const activeAssignments = key.assignments?.filter((a) => !a.revoked_at) ?? [];
const assignedServerIds = activeAssignments.map((a) => a.server_id);
return (
<div className="p-8">
{showAssign && (
<AssignModal
keyId={keyId}
assignedServerIds={assignedServerIds}
onClose={() => setShowAssign(false)}
/>
)}
<div className="mb-6 flex items-start justify-between">
<div>
<Link href="/keys" className="text-text-secondary hover:text-text-primary text-sm">
SSH Keys
</Link>
<div className="mt-2 flex items-center gap-3">
<h1 className="text-2xl font-bold text-text-primary">{key.label}</h1>
<Badge variant={key.source === "generated" ? "accent" : "neutral"}>
{key.source}
</Badge>
</div>
<p className="mt-1 font-mono text-xs text-text-secondary">{key.fingerprint}</p>
</div>
<div className="flex gap-2">
<Button variant="secondary" onClick={() => setShowAssign(true)}>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
Assign to Server
</Button>
{!confirmDelete ? (
<Button variant="danger" onClick={() => setConfirmDelete(true)}>
Delete Key
</Button>
) : (
<div className="flex items-center gap-2">
<span className="text-sm text-danger">Delete permanently?</span>
<Button variant="danger" loading={isDeleting} onClick={() => deleteKey()}>
Confirm
</Button>
<Button variant="ghost" onClick={() => setConfirmDelete(false)}>
Cancel
</Button>
</div>
)}
</div>
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div className="space-y-6 lg:col-span-1">
<Card>
<CardHeader>
<CardTitle>Details</CardTitle>
</CardHeader>
<dl className="space-y-3 text-sm">
<div>
<dt className="text-text-secondary">Key ID</dt>
<dd className="mt-0.5 font-mono text-xs text-text-primary break-all">{key.key_id}</dd>
</div>
<div className="border-t border-border pt-3">
<dt className="text-text-secondary">Source</dt>
<dd className="mt-0.5 text-text-primary capitalize">{key.source}</dd>
</div>
{key.generated_by_server_id && (
<div className="border-t border-border pt-3">
<dt className="text-text-secondary">Generated By</dt>
<dd className="mt-0.5">
<Link
href={`/servers/${key.generated_by_server_id}`}
className="font-mono text-xs text-accent hover:underline"
>
{key.generated_by_server_id}
</Link>
</dd>
</div>
)}
<div className="border-t border-border pt-3">
<dt className="text-text-secondary">Active Assignments</dt>
<dd className="mt-0.5 text-text-primary">{activeAssignments.length}</dd>
</div>
<div className="border-t border-border pt-3">
<dt className="text-text-secondary">Created</dt>
<dd className="mt-0.5 text-text-primary">
{new Date(key.created_at).toLocaleString()}
</dd>
</div>
</dl>
</Card>
<Card>
<CardHeader>
<CardTitle>Public Key</CardTitle>
<button
onClick={handleCopyKey}
className="rounded-md border border-border bg-surface-2 px-2.5 py-1 text-xs font-medium text-text-secondary transition-colors hover:border-accent/50 hover:text-text-primary"
>
{copiedKey ? <span className="text-success">Copied!</span> : "Copy"}
</button>
</CardHeader>
<div className="rounded-lg border border-border bg-[#0a0c14] p-3">
<pre className="overflow-x-auto whitespace-pre-wrap break-all font-mono text-xs text-text-secondary leading-relaxed">
{key.public_key}
</pre>
</div>
</Card>
</div>
<div className="lg:col-span-2">
<Card padding={false}>
<div className="flex items-center justify-between border-b border-border px-6 py-4">
<h2 className="text-lg font-semibold text-text-primary">
Server Assignments
<span className="ml-2 rounded-full bg-surface-2 px-2 py-0.5 text-xs text-text-secondary">
{activeAssignments.length} active
</span>
</h2>
</div>
{!key.assignments || key.assignments.length === 0 ? (
<div className="py-16 text-center">
<p className="text-text-secondary text-sm">Not assigned to any servers.</p>
<Button
variant="secondary"
size="sm"
className="mt-3"
onClick={() => setShowAssign(true)}
>
Assign to a server
</Button>
</div>
) : (
<Table>
<Thead>
<Tr>
<Th>Server</Th>
<Th>IP Address</Th>
<Th>Status</Th>
<Th>Assigned</Th>
<Th>Revoked</Th>
<Th />
</Tr>
</Thead>
<Tbody>
{key.assignments.map((assignment) => (
<Tr key={`${assignment.key_id}-${assignment.server_id}`}>
<Td>
<Link
href={`/servers/${assignment.server_id}`}
className="font-medium text-text-primary hover:text-accent"
>
{assignment.server?.hostname ?? assignment.server_id}
</Link>
</Td>
<Td>
<span className="font-mono text-xs text-text-secondary">
{assignment.server?.ip_address ?? "—"}
</span>
</Td>
<Td>
<Badge variant={assignment.revoked_at ? "danger" : "success"}>
{assignment.revoked_at ? "revoked" : "active"}
</Badge>
</Td>
<Td>
<span className="text-text-secondary text-xs">
{new Date(assignment.assigned_at).toLocaleDateString()}
</span>
</Td>
<Td>
<span className="text-text-secondary text-xs">
{assignment.revoked_at
? new Date(assignment.revoked_at).toLocaleDateString()
: "—"}
</span>
</Td>
<Td>
{!assignment.revoked_at && (
<Button
variant="danger"
size="sm"
onClick={() => revokeKey(assignment.server_id)}
>
Revoke
</Button>
)}
</Td>
</Tr>
))}
</Tbody>
</Table>
)}
</Card>
</div>
</div>
</div>
);
}
+181
View File
@@ -0,0 +1,181 @@
"use client";
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import { api, Key } from "@/lib/api";
import { Badge, Button, Card, CardHeader, CardTitle } from "@/components/ui";
import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
function UploadKeyModal({ onClose }: { onClose: () => void }) {
const queryClient = useQueryClient();
const [label, setLabel] = useState("");
const [publicKey, setPublicKey] = useState("");
const { mutate: upload, isPending, error } = useMutation({
mutationFn: () => api.uploadKey(label.trim(), publicKey.trim()),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["keys"] });
onClose();
},
});
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
<div className="w-full max-w-lg rounded-xl border border-border bg-surface p-6">
<h2 className="mb-4 text-lg font-semibold text-text-primary">Upload SSH Key</h2>
{error && (
<div className="mb-4 rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">
{(error as Error).message}
</div>
)}
<div className="space-y-4">
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">
Label
</label>
<input
type="text"
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder="e.g. dom-macbook"
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary placeholder-text-secondary/50 focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent"
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">
Public Key
</label>
<textarea
value={publicKey}
onChange={(e) => setPublicKey(e.target.value)}
placeholder="ssh-ed25519 AAAA..."
rows={4}
className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 font-mono text-xs text-text-primary placeholder-text-secondary/50 focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent resize-none"
/>
</div>
</div>
<div className="mt-6 flex justify-end gap-3">
<Button variant="ghost" onClick={onClose}>Cancel</Button>
<Button
variant="primary"
loading={isPending}
disabled={!label.trim() || !publicKey.trim()}
onClick={() => upload()}
>
Upload Key
</Button>
</div>
</div>
</div>
);
}
export default function KeysPage() {
const [showUpload, setShowUpload] = useState(false);
const { data: keys, isLoading, error } = useQuery({
queryKey: ["keys"],
queryFn: api.listKeys,
});
return (
<div className="p-8">
{showUpload && <UploadKeyModal onClose={() => setShowUpload(false)} />}
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-text-primary">SSH Keys</h1>
<p className="mt-1 text-sm text-text-secondary">
{keys?.length ?? 0} key{keys?.length !== 1 ? "s" : ""} managed
</p>
</div>
<Button variant="primary" onClick={() => setShowUpload(true)}>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
</svg>
Upload Key
</Button>
</div>
<Card padding={false}>
{isLoading ? (
<div className="flex items-center justify-center py-20">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
</div>
) : error ? (
<div className="py-20 text-center text-danger">
Failed to load keys. Is the backend running?
</div>
) : keys && keys.length > 0 ? (
<Table>
<Thead>
<Tr>
<Th>Label</Th>
<Th>Fingerprint</Th>
<Th>Source</Th>
<Th>Assignments</Th>
<Th>Created</Th>
<Th />
</Tr>
</Thead>
<Tbody>
{keys.map((key: Key) => (
<Tr key={key.key_id}>
<Td>
<span className="font-medium text-text-primary">{key.label}</span>
</Td>
<Td>
<span className="font-mono text-xs text-text-secondary">
{key.fingerprint}
</span>
</Td>
<Td>
<Badge variant={key.source === "generated" ? "accent" : "neutral"}>
{key.source}
</Badge>
</Td>
<Td>
<span className="text-text-secondary">
{key.assigned_count ?? 0} server{(key.assigned_count ?? 0) !== 1 ? "s" : ""}
</span>
</Td>
<Td>
<span className="text-text-secondary text-xs">
{new Date(key.created_at).toLocaleDateString()}
</span>
</Td>
<Td>
<Link href={`/keys/${key.key_id}`}>
<Button variant="ghost" size="sm">View </Button>
</Link>
</Td>
</Tr>
))}
</Tbody>
</Table>
) : (
<div className="py-20 text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-surface-2">
<svg className="h-6 w-6 text-text-secondary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" />
</svg>
</div>
<p className="text-text-secondary">No SSH keys yet.</p>
<Button
variant="primary"
size="sm"
className="mt-4"
onClick={() => setShowUpload(true)}
>
Upload your first key
</Button>
</div>
)}
</Card>
</div>
);
}
+30
View File
@@ -0,0 +1,30 @@
import type { Metadata } from "next";
import "./globals.css";
import { Providers } from "@/components/Providers";
import { Sidebar } from "@/components/Sidebar";
export const metadata: Metadata = {
title: "KeyManager",
description: "Self-hosted SSH key management",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" className="dark">
<body className="bg-background text-text-primary">
<Providers>
<div className="flex h-screen overflow-hidden">
<Sidebar />
<main className="flex-1 overflow-y-auto">
{children}
</main>
</div>
</Providers>
</body>
</html>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function Home() {
redirect("/servers");
}
+219
View File
@@ -0,0 +1,219 @@
"use client";
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import { api, ServerStatus } from "@/lib/api";
import { Badge, Button, Card, CardHeader, CardTitle } from "@/components/ui";
import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
function statusVariant(status: ServerStatus) {
switch (status) {
case "active": return "success";
case "pending": return "warning";
case "offline": return "danger";
}
}
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleString();
}
export default function ServerDetailPage() {
const params = useParams();
const router = useRouter();
const queryClient = useQueryClient();
const serverId = params.id as string;
const [confirmDelete, setConfirmDelete] = useState(false);
const { data: server, isLoading, error } = useQuery({
queryKey: ["servers", serverId],
queryFn: () => api.getServer(serverId),
refetchInterval: 30_000,
});
const { mutate: generateKey, isPending: isGenerating } = useMutation({
mutationFn: () => api.generateKeyForServer(serverId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["servers", serverId] });
queryClient.invalidateQueries({ queryKey: ["keys"] });
},
});
const { mutate: deleteServer, isPending: isDeleting } = useMutation({
mutationFn: () => api.deleteServer(serverId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["servers"] });
router.push("/servers");
},
});
if (isLoading) {
return (
<div className="flex h-full items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
</div>
);
}
if (error || !server) {
return (
<div className="p-8">
<div className="rounded-lg border border-danger/30 bg-danger/10 p-4 text-danger">
Server not found or failed to load.
</div>
</div>
);
}
return (
<div className="p-8">
<div className="mb-6 flex items-start justify-between">
<div>
<div className="flex items-center gap-3">
<Link href="/servers" className="text-text-secondary hover:text-text-primary text-sm">
Servers
</Link>
</div>
<div className="mt-2 flex items-center gap-3">
<h1 className="text-2xl font-bold text-text-primary">{server.hostname}</h1>
<Badge variant={statusVariant(server.status)}>{server.status}</Badge>
</div>
<p className="mt-1 font-mono text-sm text-text-secondary">{server.ip_address}</p>
</div>
<div className="flex gap-2">
<Button
variant="secondary"
loading={isGenerating}
onClick={() => generateKey()}
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" />
</svg>
Generate SSH Key
</Button>
{!confirmDelete ? (
<Button variant="danger" onClick={() => setConfirmDelete(true)}>
Remove Server
</Button>
) : (
<div className="flex items-center gap-2">
<span className="text-sm text-danger">Are you sure?</span>
<Button
variant="danger"
loading={isDeleting}
onClick={() => deleteServer()}
>
Confirm
</Button>
<Button variant="ghost" onClick={() => setConfirmDelete(false)}>
Cancel
</Button>
</div>
)}
</div>
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<Card className="lg:col-span-1">
<CardHeader>
<CardTitle>Details</CardTitle>
</CardHeader>
<dl className="space-y-3 text-sm">
<div>
<dt className="text-text-secondary">Server ID</dt>
<dd className="mt-0.5 font-mono text-xs text-text-primary break-all">{server.server_id}</dd>
</div>
<div className="border-t border-border pt-3">
<dt className="text-text-secondary">OS</dt>
<dd className="mt-0.5 text-text-primary">{server.os_info}</dd>
</div>
<div className="border-t border-border pt-3">
<dt className="text-text-secondary">Last Seen</dt>
<dd className="mt-0.5 text-text-primary">{server.last_seen ? formatDate(server.last_seen) : "Never"}</dd>
</div>
<div className="border-t border-border pt-3">
<dt className="text-text-secondary">Registered</dt>
<dd className="mt-0.5 text-text-primary">{formatDate(server.created_at)}</dd>
</div>
</dl>
</Card>
<div className="lg:col-span-2">
<Card padding={false}>
<div className="flex items-center justify-between border-b border-border px-6 py-4">
<h2 className="text-lg font-semibold text-text-primary">
Installed Keys
<span className="ml-2 rounded-full bg-surface-2 px-2 py-0.5 text-xs text-text-secondary">
{server.keys?.filter(k => !k.revoked_at).length ?? 0} active
</span>
</h2>
<Link href="/keys">
<Button variant="ghost" size="sm">Manage Keys </Button>
</Link>
</div>
{!server.keys || server.keys.length === 0 ? (
<div className="py-16 text-center">
<p className="text-text-secondary text-sm">No keys assigned to this server.</p>
<Link href="/keys">
<Button variant="secondary" size="sm" className="mt-3">
Assign a key
</Button>
</Link>
</div>
) : (
<Table>
<Thead>
<Tr>
<Th>Label</Th>
<Th>Fingerprint</Th>
<Th>Source</Th>
<Th>Status</Th>
<Th>Assigned</Th>
<Th />
</Tr>
</Thead>
<Tbody>
{server.keys.map((assignment) => (
<Tr key={assignment.key_id}>
<Td>
<span className="font-medium">{assignment.key.label}</span>
</Td>
<Td>
<span className="font-mono text-xs text-text-secondary">
{assignment.key.fingerprint}
</span>
</Td>
<Td>
<Badge variant={assignment.key.source === "generated" ? "accent" : "neutral"}>
{assignment.key.source}
</Badge>
</Td>
<Td>
<Badge variant={assignment.revoked_at ? "danger" : "success"}>
{assignment.revoked_at ? "revoked" : "active"}
</Badge>
</Td>
<Td>
<span className="text-text-secondary text-xs">
{formatDate(assignment.assigned_at)}
</span>
</Td>
<Td>
<Link href={`/keys/${assignment.key_id}`}>
<Button variant="ghost" size="sm">View</Button>
</Link>
</Td>
</Tr>
))}
</Tbody>
</Table>
)}
</Card>
</div>
</div>
</div>
);
}
+139
View File
@@ -0,0 +1,139 @@
"use client";
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { api, NewServerResponse } from "@/lib/api";
import { Button, Card, CardHeader, CardTitle } from "@/components/ui";
export default function NewServerPage() {
const [result, setResult] = useState<NewServerResponse | null>(null);
const [copied, setCopied] = useState(false);
const { mutate: createServer, isPending, error } = useMutation({
mutationFn: api.createServer,
onSuccess: (data) => setResult(data),
});
const handleCopy = async () => {
if (!result?.install_command) return;
await navigator.clipboard.writeText(result.install_command);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="p-8">
<div className="mb-6">
<h1 className="text-2xl font-bold text-text-primary">Add Server</h1>
<p className="mt-1 text-sm text-text-secondary">
Generate an install command to register a new server with the KeyManager agent.
</p>
</div>
<div className="max-w-2xl space-y-6">
{!result ? (
<Card>
<CardHeader>
<CardTitle>Generate Install Command</CardTitle>
</CardHeader>
<p className="mb-6 text-sm text-text-secondary leading-relaxed">
Click the button below to generate a one-time install command. The command
contains a short-lived token (valid for 1 hour) that registers your server
and installs the KeyManager agent automatically.
</p>
{error && (
<div className="mb-4 rounded-lg border border-danger/30 bg-danger/10 px-4 py-3 text-sm text-danger">
Failed to generate install command. Make sure the backend is running.
</div>
)}
<Button
variant="primary"
loading={isPending}
onClick={() => createServer()}
>
Generate Install Command
</Button>
</Card>
) : (
<>
<Card>
<CardHeader>
<CardTitle>Install Command</CardTitle>
<span className="rounded-full bg-success/15 px-2.5 py-0.5 text-xs font-medium text-success border border-success/30">
Valid for 1 hour
</span>
</CardHeader>
<p className="mb-4 text-sm text-text-secondary">
Run this command on the target server as <code className="rounded bg-surface-2 px-1 py-0.5 text-xs font-mono text-text-primary">root</code>:
</p>
<div className="relative rounded-lg border border-border bg-[#0a0c14] p-4 font-mono text-sm">
<pre className="overflow-x-auto whitespace-pre-wrap break-all text-text-secondary leading-relaxed">
<span className="text-accent">$</span>{" "}
<span className="text-text-primary">{result.install_command}</span>
</pre>
<button
onClick={handleCopy}
className="absolute right-3 top-3 rounded-md border border-border bg-surface-2 px-2.5 py-1 text-xs font-medium text-text-secondary transition-colors hover:border-accent/50 hover:text-text-primary"
>
{copied ? (
<span className="text-success">Copied!</span>
) : (
"Copy"
)}
</button>
</div>
</Card>
<Card>
<CardHeader>
<CardTitle>Server Details</CardTitle>
</CardHeader>
<dl className="space-y-3 text-sm">
<div className="flex items-center justify-between">
<dt className="text-text-secondary">Server ID</dt>
<dd className="font-mono text-text-primary">{result.server_id}</dd>
</div>
<div className="flex items-center justify-between border-t border-border pt-3">
<dt className="text-text-secondary">Status</dt>
<dd className="text-warning">Pending registration</dd>
</div>
</dl>
</Card>
<Card>
<CardHeader>
<CardTitle>What happens next?</CardTitle>
</CardHeader>
<ol className="space-y-3 text-sm text-text-secondary">
{[
"The install script detects your CPU architecture (amd64 / arm64)",
"Downloads and verifies the latest agent binary from the Gitea release",
"Writes /etc/keymanager/config.yaml with the server ID and token",
"Installs and starts the keymanager-agent systemd service",
"The agent calls Register() to obtain a persistent auth token",
"The server status changes to active on the first successful sync",
].map((step, i) => (
<li key={i} className="flex gap-3">
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-accent/20 text-xs font-semibold text-accent">
{i + 1}
</span>
{step}
</li>
))}
</ol>
</Card>
<div className="flex gap-3">
<Button variant="secondary" onClick={() => { setResult(null); setCopied(false); }}>
Generate Another
</Button>
</div>
</>
)}
</div>
</div>
);
}
+139
View File
@@ -0,0 +1,139 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import Link from "next/link";
import { api, Server, ServerStatus } from "@/lib/api";
import { Badge, Button, Card } from "@/components/ui";
import { Table, Thead, Tbody, Tr, Th, Td } from "@/components/ui";
function statusVariant(status: ServerStatus) {
switch (status) {
case "active":
return "success";
case "pending":
return "warning";
case "offline":
return "danger";
}
}
function formatLastSeen(dateStr: string): string {
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24);
if (diffSec < 60) return `${diffSec}s ago`;
if (diffMin < 60) return `${diffMin}m ago`;
if (diffHour < 24) return `${diffHour}h ago`;
return `${diffDay}d ago`;
}
export default function ServersPage() {
const { data: servers, isLoading, error } = useQuery({
queryKey: ["servers"],
queryFn: api.listServers,
refetchInterval: 30_000,
});
return (
<div className="p-8">
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-text-primary">Servers</h1>
<p className="mt-1 text-sm text-text-secondary">
{servers?.length ?? 0} registered server{servers?.length !== 1 ? "s" : ""}
</p>
</div>
<Link href="/servers/new">
<Button variant="primary">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
Add Server
</Button>
</Link>
</div>
<Card padding={false}>
{isLoading ? (
<div className="flex items-center justify-center py-20">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
</div>
) : error ? (
<div className="py-20 text-center text-danger">
Failed to load servers. Is the backend running?
</div>
) : servers && servers.length > 0 ? (
<Table>
<Thead>
<Tr>
<Th>Hostname</Th>
<Th>IP Address</Th>
<Th>OS</Th>
<Th>Status</Th>
<Th>Last Seen</Th>
<Th />
</Tr>
</Thead>
<Tbody>
{servers.map((server: Server) => (
<Tr key={server.server_id}>
<Td>
<span className="font-medium text-text-primary">
{server.hostname}
</span>
</Td>
<Td>
<span className="font-mono text-text-secondary">
{server.ip_address}
</span>
</Td>
<Td>
<span className="text-text-secondary">{server.os_info}</span>
</Td>
<Td>
<Badge variant={statusVariant(server.status)}>
{server.status}
</Badge>
</Td>
<Td>
<span className="text-text-secondary">
{server.last_seen
? formatLastSeen(server.last_seen)
: "Never"}
</span>
</Td>
<Td>
<Link href={`/servers/${server.server_id}`}>
<Button variant="ghost" size="sm">
View
</Button>
</Link>
</Td>
</Tr>
))}
</Tbody>
</Table>
) : (
<div className="py-20 text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-surface-2">
<svg className="h-6 w-6 text-text-secondary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z" />
</svg>
</div>
<p className="text-text-secondary">No servers registered yet.</p>
<Link href="/servers/new">
<Button variant="primary" size="sm" className="mt-4">
Add your first server
</Button>
</Link>
</div>
)}
</Card>
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
"use client";
import { QueryClientProvider } from "@tanstack/react-query";
import { queryClient } from "@/lib/query-client";
export function Providers({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}
+78
View File
@@ -0,0 +1,78 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { clsx } from "clsx";
interface NavItem {
href: string;
label: string;
icon: React.ReactNode;
}
function ServerIcon() {
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z" />
</svg>
);
}
function KeyIcon() {
return (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" />
</svg>
);
}
const navItems: NavItem[] = [
{ href: "/servers", label: "Servers", icon: <ServerIcon /> },
{ href: "/keys", label: "SSH Keys", icon: <KeyIcon /> },
];
export function Sidebar() {
const pathname = usePathname();
return (
<aside className="flex h-screen w-60 flex-col border-r border-border bg-surface">
<div className="flex h-16 items-center gap-3 border-b border-border px-5">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-accent">
<svg className="h-4 w-4 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" />
</svg>
</div>
<span className="text-base font-semibold text-text-primary">KeyManager</span>
</div>
<nav className="flex-1 overflow-y-auto px-3 py-4">
<ul className="space-y-1">
{navItems.map((item) => {
const isActive =
pathname === item.href || pathname.startsWith(item.href + "/");
return (
<li key={item.href}>
<Link
href={item.href}
className={clsx(
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
isActive
? "bg-accent/15 text-accent"
: "text-text-secondary hover:bg-surface-2 hover:text-text-primary"
)}
>
{item.icon}
{item.label}
</Link>
</li>
);
})}
</ul>
</nav>
<div className="border-t border-border px-4 py-3">
<p className="text-xs text-text-secondary">KeyManager v1.0</p>
</div>
</aside>
);
}
+40
View File
@@ -0,0 +1,40 @@
import { clsx } from "clsx";
type Variant = "success" | "warning" | "danger" | "neutral" | "accent";
interface BadgeProps {
variant?: Variant;
children: React.ReactNode;
className?: string;
}
const variantClasses: Record<Variant, string> = {
success: "bg-success/15 text-success border-success/30",
warning: "bg-warning/15 text-warning border-warning/30",
danger: "bg-danger/15 text-danger border-danger/30",
neutral: "bg-surface-2 text-text-secondary border-border",
accent: "bg-accent/15 text-accent border-accent/30",
};
export function Badge({ variant = "neutral", children, className }: BadgeProps) {
return (
<span
className={clsx(
"inline-flex items-center gap-1 rounded-full border px-2.5 py-0.5 text-xs font-medium",
variantClasses[variant],
className
)}
>
{(variant === "success" || variant === "warning" || variant === "danger") && (
<span
className={clsx("h-1.5 w-1.5 rounded-full", {
"bg-success": variant === "success",
"bg-warning": variant === "warning",
"bg-danger": variant === "danger",
})}
/>
)}
{children}
</span>
);
}
+75
View File
@@ -0,0 +1,75 @@
import { ButtonHTMLAttributes, forwardRef } from "react";
import { clsx } from "clsx";
type Variant = "primary" | "secondary" | "danger" | "ghost";
type Size = "sm" | "md" | "lg";
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: Variant;
size?: Size;
loading?: boolean;
}
const variantClasses: Record<Variant, string> = {
primary:
"bg-accent hover:bg-accent-hover text-white border-transparent",
secondary:
"bg-surface-2 hover:bg-[#2d3048] text-text-primary border-border",
danger:
"bg-danger hover:bg-danger-hover text-white border-transparent",
ghost:
"bg-transparent hover:bg-surface-2 text-text-secondary hover:text-text-primary border-transparent",
};
const sizeClasses: Record<Size, string> = {
sm: "px-3 py-1.5 text-sm",
md: "px-4 py-2 text-sm",
lg: "px-5 py-2.5 text-base",
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
(
{ variant = "primary", size = "md", loading, className, children, disabled, ...props },
ref
) => {
return (
<button
ref={ref}
disabled={disabled || loading}
className={clsx(
"inline-flex items-center gap-2 rounded-lg border font-medium transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2 focus:ring-offset-background disabled:opacity-50 disabled:cursor-not-allowed",
variantClasses[variant],
sizeClasses[size],
className
)}
{...props}
>
{loading && (
<svg
className="animate-spin h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
)}
{children}
</button>
);
}
);
Button.displayName = "Button";
+37
View File
@@ -0,0 +1,37 @@
import { clsx } from "clsx";
import { HTMLAttributes } from "react";
interface CardProps extends HTMLAttributes<HTMLDivElement> {
padding?: boolean;
}
export function Card({ className, padding = true, children, ...props }: CardProps) {
return (
<div
className={clsx(
"rounded-xl border border-border bg-surface",
padding && "p-6",
className
)}
{...props}
>
{children}
</div>
);
}
export function CardHeader({ className, children, ...props }: HTMLAttributes<HTMLDivElement>) {
return (
<div className={clsx("mb-4 flex items-center justify-between", className)} {...props}>
{children}
</div>
);
}
export function CardTitle({ className, children, ...props }: HTMLAttributes<HTMLHeadingElement>) {
return (
<h2 className={clsx("text-lg font-semibold text-text-primary", className)} {...props}>
{children}
</h2>
);
}
+67
View File
@@ -0,0 +1,67 @@
import { clsx } from "clsx";
import { HTMLAttributes, TdHTMLAttributes, ThHTMLAttributes } from "react";
export function Table({ className, children, ...props }: HTMLAttributes<HTMLTableElement>) {
return (
<div className="overflow-x-auto">
<table
className={clsx("w-full border-collapse text-sm", className)}
{...props}
>
{children}
</table>
</div>
);
}
export function Thead({ className, children, ...props }: HTMLAttributes<HTMLTableSectionElement>) {
return (
<thead className={clsx("border-b border-border", className)} {...props}>
{children}
</thead>
);
}
export function Tbody({ className, children, ...props }: HTMLAttributes<HTMLTableSectionElement>) {
return (
<tbody className={clsx("divide-y divide-border", className)} {...props}>
{children}
</tbody>
);
}
export function Tr({ className, children, ...props }: HTMLAttributes<HTMLTableRowElement>) {
return (
<tr
className={clsx("transition-colors hover:bg-surface-2/50", className)}
{...props}
>
{children}
</tr>
);
}
export function Th({ className, children, ...props }: ThHTMLAttributes<HTMLTableCellElement>) {
return (
<th
className={clsx(
"px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-text-secondary",
className
)}
{...props}
>
{children}
</th>
);
}
export function Td({ className, children, ...props }: TdHTMLAttributes<HTMLTableCellElement>) {
return (
<td
className={clsx("px-4 py-3 text-text-primary", className)}
{...props}
>
{children}
</td>
);
}
+4
View File
@@ -0,0 +1,4 @@
export { Button } from "./Button";
export { Badge } from "./Badge";
export { Card, CardHeader, CardTitle } from "./Card";
export { Table, Thead, Tbody, Tr, Th, Td } from "./Table";
+137
View File
@@ -0,0 +1,137 @@
export type ServerStatus = "pending" | "active" | "offline";
export type KeySource = "uploaded" | "generated";
export interface Server {
id: string;
server_id: string;
hostname: string;
ip_address: string;
os_info: string;
status: ServerStatus;
last_seen: string;
created_at: string;
}
export interface Key {
id: string;
key_id: string;
label: string;
public_key: string;
fingerprint: string;
source: KeySource;
generated_by_server_id?: string;
created_at: string;
assigned_count?: number;
}
export interface Assignment {
id: string;
key_id: string;
server_id: string;
assigned_at: string;
revoked_at: string | null;
}
export interface NewServerResponse {
server_id: string;
pre_reg_token: string;
install_command: string;
}
export interface KeyWithAssignments extends Key {
assignments: (Assignment & { server: Server })[];
}
export interface ServerWithKeys extends Server {
keys: (Assignment & { key: Key })[];
}
class ApiError extends Error {
constructor(
public status: number,
message: string
) {
super(message);
this.name = "ApiError";
}
}
async function request<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(`/api${path}`, {
headers: {
"Content-Type": "application/json",
...options?.headers,
},
...options,
});
if (!res.ok) {
const text = await res.text().catch(() => res.statusText);
throw new ApiError(res.status, text || `HTTP ${res.status}`);
}
if (res.status === 204) {
return undefined as T;
}
return res.json();
}
export const api = {
// Servers
listServers(): Promise<Server[]> {
return request<Server[]>("/servers");
},
getServer(serverId: string): Promise<ServerWithKeys> {
return request<ServerWithKeys>(`/servers/${serverId}`);
},
createServer(): Promise<NewServerResponse> {
return request<NewServerResponse>("/servers/new", { method: "POST" });
},
deleteServer(serverId: string): Promise<void> {
return request<void>(`/servers/${serverId}`, { method: "DELETE" });
},
generateKeyForServer(serverId: string): Promise<{ key_id: string }> {
return request<{ key_id: string }>(`/servers/${serverId}/generate-key`, {
method: "POST",
});
},
// Keys
listKeys(): Promise<Key[]> {
return request<Key[]>("/keys");
},
getKey(keyId: string): Promise<KeyWithAssignments> {
return request<KeyWithAssignments>(`/keys/${keyId}`);
},
uploadKey(label: string, public_key: string): Promise<Key> {
return request<Key>("/keys", {
method: "POST",
body: JSON.stringify({ label, public_key }),
});
},
deleteKey(keyId: string): Promise<void> {
return request<void>(`/keys/${keyId}`, { method: "DELETE" });
},
// Assignments
assignKey(keyId: string, serverId: string): Promise<Assignment> {
return request<Assignment>(`/keys/${keyId}/assign`, {
method: "POST",
body: JSON.stringify({ server_id: serverId }),
});
},
revokeKey(keyId: string, serverId: string): Promise<void> {
return request<void>(`/keys/${keyId}/assign/${serverId}`, {
method: "DELETE",
});
},
};
+12
View File
@@ -0,0 +1,12 @@
"use client";
import { QueryClient } from "@tanstack/react-query";
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
retry: 1,
},
},
});
+21
View File
@@ -0,0 +1,21 @@
import type { NextConfig } from "next";
const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8080";
const nextConfig: NextConfig = {
output: "standalone",
async rewrites() {
return [
{
source: "/api/:path*",
destination: `${apiUrl}/api/:path*`,
},
{
source: "/install",
destination: `${apiUrl}/install`,
},
];
},
};
export default nextConfig;
+6856
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
{
"name": "keymanager-web",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"next": "16.2.9",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"@tanstack/react-query": "^5.51.1",
"clsx": "^2.1.1",
"tailwind-merge": "^2.4.0"
},
"devDependencies": {
"@types/node": "^20.14.11",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"autoprefixer": "^10.4.19",
"postcss": "^8.4.39",
"tailwindcss": "^3.4.6",
"typescript": "^5.5.3",
"eslint": "^9.0.0",
"eslint-config-next": "16.2.9"
}
}
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+30
View File
@@ -0,0 +1,30 @@
import type { Config } from "tailwindcss";
const config: Config = {
content: [
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
"./app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
background: "#0f1117",
surface: "#1a1d27",
"surface-2": "#232635",
border: "#2e3147",
"text-primary": "#e8eaf0",
"text-secondary": "#9095a8",
accent: "#6366f1",
"accent-hover": "#4f52d4",
success: "#22c55e",
warning: "#f59e0b",
danger: "#ef4444",
"danger-hover": "#dc2626",
},
},
},
plugins: [],
};
export default config;
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}