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:
1 parent
3d72c53262
commit
473e8e01fa
57 files changed
+3888
-281
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 <name> --env <env>
|
||||
<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 <N></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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
|
||||
Vendored
+4
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user