first commit
This commit is contained in:
@@ -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"]
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function Home() {
|
||||
redirect("/servers");
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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",
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30_000,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
Generated
+6856
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user