Add backend API for projects & snapshots

Introduce full backend API surface for projects, snapshots and user pubkeys: controllers, DTOs, JPA entities, repositories, services, and tests. Add SecurityConfig improvements (JWT decoder, audience/issuer/timestamp validation and CORS), a GlobalExceptionHandler for consistent error responses, and remove a stray test call from BackendApplication. Also update README (architecture, docs, CLI/Frontend/Backend notes), add CLAUDE.md, VSCode settings, and multiple frontend/CLI Rust changes. Small housekeeping: add .DS_Store and test adjustments.
This commit is contained in:
Aryan Singh committed 2026-03-14 18:30:10 -05:00
1 parent 3d72c53262
commit 473e8e01fa
57 files changed
+3888 -281

No files matched your search

+73
View File
@@ -0,0 +1,73 @@
import Link from "next/link";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { redirect } from "next/navigation";
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await getServerSession(authOptions);
if (!session) redirect("/api/auth/signin/keycloak");
const username = session.preferredUsername ?? session.user?.email ?? "user";
return (
<div className="flex min-h-screen bg-[#050505] text-white">
{/* Sidebar */}
<aside className="sticky top-0 h-screen w-60 shrink-0 border-r border-white/5 bg-zinc-950 flex flex-col">
<div className="flex items-center gap-2 px-5 py-5 border-b border-white/5">
<Link href="/" className="flex items-center gap-2 text-base font-bold tracking-tight">
<span className="flex h-7 w-7 items-center justify-center rounded-md bg-emerald-500 text-xs font-black text-black">
E
</span>
EnvSync
</Link>
</div>
<nav className="flex-1 p-3 space-y-0.5 overflow-y-auto">
<Link
href="/dashbaord"
className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-zinc-400 hover:bg-white/[0.04] hover:text-white transition-colors"
>
<GridIcon />
Projects
</Link>
<Link
href="/api/auth/signout"
className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-zinc-400 hover:bg-white/[0.04] hover:text-white transition-colors mt-auto"
>
<LogoutIcon />
Sign out
</Link>
</nav>
<div className="border-t border-white/5 px-4 py-3">
<p className="truncate text-xs text-zinc-500">{username}</p>
</div>
</aside>
{/* Main content */}
<main className="flex-1 overflow-auto">
{children}
</main>
</div>
);
}
function GridIcon() {
return (
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth={1.75} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25V6ZM3.75 15.75A2.25 2.25 0 0 1 6 13.5h2.25a2.25 2.25 0 0 1 2.25 2.25V18a2.25 2.25 0 0 1-2.25 2.25H6A2.25 2.25 0 0 1 3.75 18v-2.25ZM13.5 6a2.25 2.25 0 0 1 2.25-2.25H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25a2.25 2.25 0 0 1-2.25-2.25V6ZM13.5 15.75a2.25 2.25 0 0 1 2.25-2.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-2.25A2.25 2.25 0 0 1 13.5 18v-2.25Z" />
</svg>
);
}
function LogoutIcon() {
return (
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth={1.75} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0 0 13.5 3h-6a2.25 2.25 0 0 0-2.25 2.25v13.5A2.25 2.25 0 0 0 7.5 21h6a2.25 2.25 0 0 0 2.25-2.25V15m3 0 3-3m0 0-3-3m3 3H9" />
</svg>
);
}
+398
View File
@@ -0,0 +1,398 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useSession } from "next-auth/react";
import Link from "next/link";
import { createApiClient, type Project } from "@/lib/api";
const ENV_COLORS: Record<string, string> = {
production: "bg-red-500/10 text-red-400 border-red-500/20",
staging: "bg-yellow-500/10 text-yellow-400 border-yellow-500/20",
development: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20",
};
function EnvBadge({ env }: { env: string }) {
const cls = ENV_COLORS[env.toLowerCase()] ?? "bg-zinc-800 text-zinc-400 border-zinc-700";
return (
<span className={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium ${cls}`}>
{env}
</span>
);
}
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
}
// ── Create Project Modal ───────────────────────────────────────────────────
const ENVIRONMENTS = ["development", "staging", "production", "test"];
function CreateProjectModal({
onClose,
onCreate,
}: {
onClose: () => void;
onCreate: (name: string, env: string) => Promise<void>;
}) {
const [name, setName] = useState("");
const [env, setEnv] = useState("development");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!name.trim()) return;
setLoading(true);
setError(null);
try {
await onCreate(name.trim(), env);
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to create project");
} finally {
setLoading(false);
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="w-full max-w-md rounded-2xl border border-white/10 bg-zinc-950 p-6 shadow-2xl">
<h2 className="text-lg font-semibold">New Project</h2>
<p className="mt-1 text-sm text-zinc-500">
Create a new encrypted vault for your team.
</p>
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1.5">
Project name
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="my-app"
className="w-full rounded-lg border border-white/10 bg-zinc-900 px-3 py-2 text-sm text-white placeholder-zinc-600 outline-none focus:border-emerald-500/50 focus:ring-1 focus:ring-emerald-500/50"
autoFocus
required
maxLength={100}
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1.5">
Environment
</label>
<select
value={env}
onChange={(e) => setEnv(e.target.value)}
className="w-full rounded-lg border border-white/10 bg-zinc-900 px-3 py-2 text-sm text-white outline-none focus:border-emerald-500/50 focus:ring-1 focus:ring-emerald-500/50"
>
{ENVIRONMENTS.map((e) => (
<option key={e} value={e}>{e}</option>
))}
</select>
</div>
{error && (
<p className="rounded-lg border border-red-500/20 bg-red-500/10 px-3 py-2 text-sm text-red-400">
{error}
</p>
)}
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={onClose}
className="flex-1 rounded-lg border border-white/10 px-4 py-2 text-sm text-zinc-400 hover:text-white transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={loading || !name.trim()}
className="flex-1 rounded-lg bg-emerald-500 px-4 py-2 text-sm font-semibold text-black hover:bg-emerald-400 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{loading ? "Creating…" : "Create"}
</button>
</div>
</form>
</div>
</div>
);
}
// ── Delete Confirmation ────────────────────────────────────────────────────
function DeleteConfirmModal({
project,
onClose,
onDelete,
}: {
project: Project;
onClose: () => void;
onDelete: () => Promise<void>;
}) {
const [loading, setLoading] = useState(false);
async function handleDelete() {
setLoading(true);
try {
await onDelete();
onClose();
} finally {
setLoading(false);
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="w-full max-w-md rounded-2xl border border-red-500/20 bg-zinc-950 p-6 shadow-2xl">
<h2 className="text-lg font-semibold text-red-400">Delete project?</h2>
<p className="mt-2 text-sm text-zinc-400">
This will permanently delete{" "}
<span className="font-medium text-white">{project.name}</span> and all
its snapshots. This action cannot be undone.
</p>
<div className="mt-6 flex gap-3">
<button
onClick={onClose}
className="flex-1 rounded-lg border border-white/10 px-4 py-2 text-sm text-zinc-400 hover:text-white transition-colors"
>
Cancel
</button>
<button
onClick={handleDelete}
disabled={loading}
className="flex-1 rounded-lg bg-red-500 px-4 py-2 text-sm font-semibold text-white hover:bg-red-400 disabled:opacity-50 transition-colors"
>
{loading ? "Deleting…" : "Delete"}
</button>
</div>
</div>
</div>
);
}
// ── Project Card ───────────────────────────────────────────────────────────
function ProjectCard({
project,
onDelete,
}: {
project: Project;
onDelete: (p: Project) => void;
}) {
return (
<div className="group relative rounded-2xl border border-white/[0.06] bg-zinc-900/40 p-5 hover:border-emerald-500/20 hover:bg-emerald-500/[0.03] transition-colors">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<Link
href={`/dashbaord/projects/${project.id}`}
className="block truncate font-semibold tracking-tight hover:text-emerald-400 transition-colors"
>
{project.name}
</Link>
<p className="mt-1 text-xs text-zinc-500">
Created {formatDate(project.created_at)}
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<EnvBadge env={project.environment} />
<button
onClick={() => onDelete(project)}
className="opacity-0 group-hover:opacity-100 rounded-lg p-1.5 text-zinc-600 hover:text-red-400 hover:bg-red-500/10 transition-all"
title="Delete project"
>
<TrashIcon />
</button>
</div>
</div>
<div className="mt-4 flex items-center gap-2">
<Link
href={`/dashbaord/projects/${project.id}`}
className="inline-flex items-center gap-1.5 rounded-lg border border-white/[0.06] px-3 py-1.5 text-xs text-zinc-400 hover:text-white hover:border-white/10 transition-colors"
>
<HistoryIcon />
View snapshots
</Link>
</div>
</div>
);
}
function TrashIcon() {
return (
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth={1.75} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
</svg>
);
}
function HistoryIcon() {
return (
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth={1.75} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
);
}
// ── Page ───────────────────────────────────────────────────────────────────
export default function DashboardPage() {
const { data: session, status } = useSession();
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showCreate, setShowCreate] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<Project | null>(null);
const fetchProjects = useCallback(async () => {
if (!session?.accessToken) return;
setLoading(true);
setError(null);
try {
const api = createApiClient(session.accessToken);
const list = await api.listProjects();
setProjects(list);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load projects");
} finally {
setLoading(false);
}
}, [session?.accessToken]);
useEffect(() => {
if (status === "authenticated") {
fetchProjects();
}
}, [status, fetchProjects]);
async function handleCreate(name: string, env: string) {
if (!session?.accessToken) return;
const api = createApiClient(session.accessToken);
await api.createProject(name, env);
await fetchProjects();
}
async function handleDelete() {
if (!session?.accessToken || !deleteTarget) return;
const api = createApiClient(session.accessToken);
await api.deleteProject(deleteTarget.id);
setDeleteTarget(null);
await fetchProjects();
}
if (status === "loading" || loading) {
return (
<div className="flex h-full items-center justify-center p-20">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-emerald-500 border-t-transparent" />
</div>
);
}
return (
<div className="p-8 max-w-5xl mx-auto">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Projects</h1>
<p className="mt-1 text-sm text-zinc-500">
{projects.length} project{projects.length !== 1 ? "s" : ""} in your account
</p>
</div>
<button
onClick={() => setShowCreate(true)}
className="inline-flex items-center gap-2 rounded-xl bg-emerald-500 px-4 py-2 text-sm font-semibold text-black hover:bg-emerald-400 transition-colors"
>
<PlusIcon />
New project
</button>
</div>
{/* CLI hint */}
<div className="mt-6 rounded-xl border border-white/[0.06] bg-zinc-900/40 px-4 py-3 font-mono text-xs text-zinc-500">
<span className="text-emerald-400">$</span>{" "}
envsync init --project &lt;name&gt; --env &lt;env&gt;
<span className="ml-4 not-italic font-sans text-zinc-600">← or create via this dashboard</span>
</div>
{/* Error */}
{error && (
<div className="mt-6 rounded-xl border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-400">
{error}
<button onClick={fetchProjects} className="ml-3 underline hover:no-underline">
Retry
</button>
</div>
)}
{/* Projects grid */}
{projects.length === 0 && !error ? (
<div className="mt-16 flex flex-col items-center text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl border border-white/[0.06] bg-zinc-900/60">
<FolderIcon />
</div>
<h3 className="mt-4 font-semibold">No projects yet</h3>
<p className="mt-2 max-w-sm text-sm text-zinc-500">
Create your first project to start syncing encrypted environment variables across your team.
</p>
<button
onClick={() => setShowCreate(true)}
className="mt-6 inline-flex items-center gap-2 rounded-xl bg-emerald-500 px-5 py-2.5 text-sm font-semibold text-black hover:bg-emerald-400 transition-colors"
>
<PlusIcon />
Create first project
</button>
</div>
) : (
<div className="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{projects.map((p) => (
<ProjectCard
key={p.id}
project={p}
onDelete={setDeleteTarget}
/>
))}
</div>
)}
{/* Modals */}
{showCreate && (
<CreateProjectModal
onClose={() => setShowCreate(false)}
onCreate={handleCreate}
/>
)}
{deleteTarget && (
<DeleteConfirmModal
project={deleteTarget}
onClose={() => setDeleteTarget(null)}
onDelete={handleDelete}
/>
)}
</div>
);
}
function PlusIcon() {
return (
<svg className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
);
}
function FolderIcon() {
return (
<svg className="h-8 w-8 text-zinc-600" fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 12.75V12A2.25 2.25 0 0 1 4.5 9.75h15A2.25 2.25 0 0 1 21.75 12v.75m-8.69-6.44-2.12-2.12a1.5 1.5 0 0 0-1.061-.44H4.5A2.25 2.25 0 0 0 2.25 6v8.25m19.5 0A2.25 2.25 0 0 1 19.5 16.5h-15a2.25 2.25 0 0 1-2.25-2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 16.91a2.25 2.25 0 0 1-1.07-1.916V14.25" />
</svg>
);
}
@@ -0,0 +1,322 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useSession } from "next-auth/react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { createApiClient, type Project, type SnapshotMeta } from "@/lib/api";
function formatDate(iso: string) {
return new Date(iso).toLocaleString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
function formatRelative(iso: string) {
const diff = Date.now() - new Date(iso).getTime();
const minutes = Math.floor(diff / 60_000);
if (minutes < 1) return "just now";
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
const ENV_BADGE: Record<string, string> = {
production: "bg-red-500/10 text-red-400 border-red-500/20",
staging: "bg-yellow-500/10 text-yellow-400 border-yellow-500/20",
development: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20",
};
// ── Snapshot Row ────────────────────────────────────────────────────────────
function SnapshotRow({
snap,
isLatest,
}: {
snap: SnapshotMeta;
isLatest: boolean;
}) {
return (
<div className="flex items-start gap-4 py-4 first:pt-0">
{/* Version bubble */}
<div className="flex flex-col items-center">
<div
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-full border text-xs font-bold ${
isLatest
? "border-emerald-500/40 bg-emerald-500/10 text-emerald-400"
: "border-white/10 bg-zinc-800 text-zinc-500"
}`}
>
v{snap.version}
</div>
{/* connector line — handled by parent spacing */}
</div>
<div className="flex-1 min-w-0 pt-0.5">
<div className="flex items-center gap-2 flex-wrap">
{isLatest && (
<span className="inline-flex items-center rounded-full bg-emerald-500/10 border border-emerald-500/20 px-2 py-0.5 text-xs font-medium text-emerald-400">
latest
</span>
)}
<p className="text-sm font-medium truncate">
{snap.message ?? <span className="italic text-zinc-500">No message</span>}
</p>
</div>
<p className="mt-1 text-xs text-zinc-500">
{snap.pushed_by ? (
<>
<span className="font-mono text-zinc-400">{snap.pushed_by.split("-")[0]}…</span>
{" · "}
</>
) : null}
<span title={formatDate(snap.created_at)}>{formatRelative(snap.created_at)}</span>
</p>
</div>
<div className="shrink-0 text-xs text-zinc-600" title={formatDate(snap.created_at)}>
{formatDate(snap.created_at)}
</div>
</div>
);
}
// ── CLI Reference Panel ─────────────────────────────────────────────────────
function CliPanel({ projectId }: { projectId: string }) {
const [copied, setCopied] = useState<string | null>(null);
function copy(text: string, key: string) {
navigator.clipboard.writeText(text).then(() => {
setCopied(key);
setTimeout(() => setCopied(null), 1500);
});
}
const commands = [
{ key: "push", cmd: "envsync push -m \"describe changes\"", desc: "Encrypt & push local .env" },
{ key: "pull", cmd: "envsync pull", desc: "Pull latest snapshot" },
{ key: "diff", cmd: "envsync diff", desc: "Compare local vs remote" },
{ key: "log", cmd: "envsync log", desc: "View snapshot history" },
];
return (
<div className="rounded-2xl border border-white/[0.06] bg-zinc-900/40 p-5">
<h2 className="text-sm font-semibold text-zinc-300">CLI Quick Reference</h2>
<p className="mt-1 text-xs text-zinc-600">
Project ID: <span className="font-mono text-zinc-500">{projectId}</span>
</p>
<div className="mt-4 space-y-2">
{commands.map(({ key, cmd, desc }) => (
<div
key={key}
className="group flex items-center justify-between gap-3 rounded-lg border border-white/[0.04] bg-zinc-900/60 px-3 py-2"
>
<div className="min-w-0">
<code className="text-xs text-emerald-400">{cmd}</code>
<p className="text-xs text-zinc-600 mt-0.5">{desc}</p>
</div>
<button
onClick={() => copy(cmd, key)}
className="shrink-0 rounded p-1 text-zinc-600 opacity-0 group-hover:opacity-100 hover:text-zinc-300 hover:bg-white/5 transition-all"
title="Copy"
>
{copied === key ? <CheckIcon /> : <CopyIcon />}
</button>
</div>
))}
</div>
</div>
);
}
// ── Page ───────────────────────────────────────────────────────────────────
export default function ProjectDetailPage() {
const { id } = useParams<{ id: string }>();
const { data: session, status } = useSession();
const [project, setProject] = useState<Project | null>(null);
const [snapshots, setSnapshots] = useState<SnapshotMeta[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchData = useCallback(async () => {
if (!session?.accessToken || !id) return;
setLoading(true);
setError(null);
try {
const api = createApiClient(session.accessToken);
const [proj, snaps] = await Promise.all([
api.getProject(id),
api.listSnapshots(id, 50),
]);
setProject(proj);
setSnapshots(snaps);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load project");
} finally {
setLoading(false);
}
}, [session?.accessToken, id]);
useEffect(() => {
if (status === "authenticated") fetchData();
}, [status, fetchData]);
if (status === "loading" || loading) {
return (
<div className="flex h-full items-center justify-center p-20">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-emerald-500 border-t-transparent" />
</div>
);
}
if (error) {
return (
<div className="p-8 max-w-5xl mx-auto">
<div className="rounded-xl border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-400">
{error}
<button onClick={fetchData} className="ml-3 underline hover:no-underline">
Retry
</button>
</div>
</div>
);
}
if (!project) return null;
const envClass = ENV_BADGE[project.environment.toLowerCase()] ?? "bg-zinc-800 text-zinc-400 border-zinc-700";
return (
<div className="p-8 max-w-5xl mx-auto">
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-sm text-zinc-500 mb-6">
<Link href="/dashbaord" className="hover:text-white transition-colors">
Projects
</Link>
<span>/</span>
<span className="text-white">{project.name}</span>
</div>
{/* Project header */}
<div className="flex items-start justify-between gap-4">
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold tracking-tight">{project.name}</h1>
<span className={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium ${envClass}`}>
{project.environment}
</span>
</div>
<p className="mt-1 text-sm text-zinc-500">
{snapshots.length} snapshot{snapshots.length !== 1 ? "s" : ""} · Created {formatDate(project.created_at)}
</p>
</div>
</div>
<div className="mt-8 grid gap-6 lg:grid-cols-3">
{/* Snapshot history */}
<div className="lg:col-span-2">
<div className="rounded-2xl border border-white/[0.06] bg-zinc-900/40">
<div className="border-b border-white/[0.06] px-5 py-4 flex items-center justify-between">
<h2 className="text-sm font-semibold text-zinc-300">Snapshot History</h2>
<span className="text-xs text-zinc-600">{snapshots.length} total</span>
</div>
{snapshots.length === 0 ? (
<div className="px-5 py-12 text-center">
<p className="text-sm text-zinc-500">No snapshots yet.</p>
<p className="mt-2 text-xs text-zinc-600">
Run{" "}
<code className="text-emerald-400">envsync push</code>{" "}
to create the first one.
</p>
</div>
) : (
<div className="divide-y divide-white/[0.04] px-5">
{snapshots.map((snap, i) => (
<SnapshotRow
key={snap.id}
snap={snap}
isLatest={i === 0}
/>
))}
</div>
)}
</div>
{/* Rollback note */}
{snapshots.length > 1 && (
<div className="mt-3 flex items-start gap-2 rounded-xl border border-white/[0.04] bg-zinc-900/20 px-4 py-3">
<InfoIcon />
<p className="text-xs text-zinc-600">
To roll back:{" "}
<code className="text-emerald-500">envsync rollback --version &lt;N&gt;</code>
</p>
</div>
)}
</div>
{/* Sidebar */}
<div className="space-y-4">
<CliPanel projectId={id} />
{/* Stats */}
<div className="rounded-2xl border border-white/[0.06] bg-zinc-900/40 p-5 space-y-3">
<h2 className="text-sm font-semibold text-zinc-300">Stats</h2>
<div className="space-y-2">
<Stat label="Total snapshots" value={snapshots.length.toString()} />
<Stat
label="Latest version"
value={snapshots[0] ? `v${snapshots[0].version}` : "—"}
/>
<Stat
label="Last push"
value={snapshots[0] ? formatRelative(snapshots[0].created_at) : "—"}
/>
</div>
</div>
</div>
</div>
</div>
);
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-center justify-between">
<span className="text-xs text-zinc-500">{label}</span>
<span className="text-xs font-medium text-zinc-300">{value}</span>
</div>
);
}
function CopyIcon() {
return (
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth={1.75} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 0 1-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 0 1 1.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 0 0-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 0 1-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 0 0-3.375-3.375h-1.5a1.125 1.125 0 0 1-1.125-1.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H9.75" />
</svg>
);
}
function CheckIcon() {
return (
<svg className="h-3.5 w-3.5 text-emerald-400" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
);
}
function InfoIcon() {
return (
<svg className="h-4 w-4 shrink-0 text-zinc-600" fill="none" stroke="currentColor" strokeWidth={1.75} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z" />
</svg>
);
}
+113
View File
@@ -0,0 +1,113 @@
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8081";
export interface Project {
id: string;
name: string;
environment: string;
created_at: string;
}
export interface SnapshotMeta {
id: string;
version: number;
message: string | null;
pushed_by: string | null;
created_at: string;
}
export interface SnapshotFull extends SnapshotMeta {
ciphertext: string;
nonce: string;
}
export interface ApiError {
status: number;
error: string;
fields?: Record<string, string>;
}
class ApiClient {
private token: string;
constructor(token: string) {
this.token = token;
}
private async request<T>(
path: string,
init: RequestInit = {}
): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
...init,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.token}`,
...init.headers,
},
});
if (!res.ok) {
let body: ApiError;
try {
body = await res.json();
} catch {
body = { status: res.status, error: res.statusText };
}
const err = new Error(body.error ?? `HTTP ${res.status}`);
(err as Error & { apiError: ApiError }).apiError = body;
throw err;
}
if (res.status === 204) return undefined as unknown as T;
return res.json() as Promise<T>;
}
// ── Projects ──────────────────────────────────────────────────────
listProjects(): Promise<Project[]> {
return this.request<Project[]>("/api/projects");
}
getProject(id: string): Promise<Project> {
return this.request<Project>(`/api/projects/${id}`);
}
createProject(name: string, environment: string): Promise<Project> {
return this.request<Project>("/api/projects", {
method: "POST",
body: JSON.stringify({ name, environment }),
});
}
deleteProject(id: string): Promise<void> {
return this.request<void>(`/api/projects/${id}`, { method: "DELETE" });
}
// ── Snapshots ─────────────────────────────────────────────────────
listSnapshots(projectId: string, limit = 50): Promise<SnapshotMeta[]> {
return this.request<SnapshotMeta[]>(
`/api/projects/${projectId}/snapshots?limit=${limit}`
);
}
getLatestSnapshot(projectId: string): Promise<SnapshotFull | null> {
return this.request<SnapshotFull>(
`/api/projects/${projectId}/snapshots/latest`
).catch((err) => {
if ((err as Error & { apiError?: ApiError }).apiError?.status === 404)
return null;
throw err;
});
}
getSnapshot(projectId: string, snapId: string): Promise<SnapshotFull> {
return this.request<SnapshotFull>(
`/api/projects/${projectId}/snapshots/${snapId}`
);
}
}
export function createApiClient(token: string): ApiClient {
return new ApiClient(token);
}
+59 -7
View File
@@ -1,14 +1,44 @@
import type { NextAuthOptions } from "next-auth";
import type { JWT } from "next-auth/jwt";
import KeycloakProvider from "next-auth/providers/keycloak";
function requiredEnv(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`Missing required environment variable: ${name}`);
return value;
}
if (!value) {
throw new Error(`Missing required environment variable: ${name}`);
async function refreshAccessToken(token: JWT): Promise<JWT> {
const issuer = requiredEnv("KEYCLOAK_ISSUER");
const tokenUrl = `${issuer}/protocol/openid-connect/token`;
const res = await fetch(tokenUrl, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
client_id: requiredEnv("KEYCLOAK_CLIENT_ID"),
client_secret: requiredEnv("KEYCLOAK_CLIENT_SECRET"),
refresh_token: String(token.refreshToken ?? ""),
}),
});
const refreshed = await res.json();
if (!res.ok) {
// Refresh failed — force re-login by returning an error token
return { ...token, error: "RefreshAccessTokenError" };
}
return value;
return {
...token,
accessToken: refreshed.access_token,
idToken: refreshed.id_token ?? token.idToken,
refreshToken: refreshed.refresh_token ?? token.refreshToken,
// expires_in is seconds from now
accessTokenExpiresAt: Math.floor(Date.now() / 1000) + refreshed.expires_in,
error: undefined,
};
}
export const authOptions: NextAuthOptions = {
@@ -17,6 +47,7 @@ export const authOptions: NextAuthOptions = {
clientId: requiredEnv("KEYCLOAK_CLIENT_ID"),
clientSecret: requiredEnv("KEYCLOAK_CLIENT_SECRET"),
issuer: requiredEnv("KEYCLOAK_ISSUER"),
authorization: { params: { scope: "openid email profile offline_access" } },
}),
],
session: {
@@ -24,20 +55,41 @@ export const authOptions: NextAuthOptions = {
},
callbacks: {
async jwt({ token, account, profile }) {
// Initial sign-in — store everything from the provider
if (account) {
token.accessToken = account.access_token;
token.idToken = account.id_token;
token.refreshToken = account.refresh_token;
token.accessTokenExpiresAt = account.expires_at; // unix seconds
}
if (profile && typeof profile === "object" && "preferred_username" in profile) {
token.preferredUsername = String(profile.preferred_username);
}
return token;
},
async session({ session, token }) {
session.preferredUsername = typeof token.preferredUsername === "string" ? token.preferredUsername : undefined;
// Token still valid (with 60-second buffer)
const expiresAt = typeof token.accessTokenExpiresAt === "number"
? token.accessTokenExpiresAt
: 0;
if (Math.floor(Date.now() / 1000) < expiresAt - 60) {
return token;
}
// Access token expired — try to refresh
return refreshAccessToken(token);
},
async session({ session, token }) {
session.preferredUsername = typeof token.preferredUsername === "string"
? token.preferredUsername
: undefined;
session.accessToken = typeof token.accessToken === "string"
? token.accessToken
: undefined;
// Expose refresh errors to the client so it can redirect to sign-in
if (token.error) {
(session as typeof session & { error?: string }).error = token.error as string;
}
return session;
},
},
+4
View File
@@ -3,6 +3,7 @@ import "next-auth/jwt";
declare module "next-auth" {
interface Session {
accessToken?: string;
preferredUsername?: string;
}
}
@@ -11,6 +12,9 @@ declare module "next-auth/jwt" {
interface JWT {
accessToken?: string;
idToken?: string;
refreshToken?: string;
accessTokenExpiresAt?: number; // unix seconds
preferredUsername?: string;
error?: string;
}
}