feat: implement auth, projects, and frontend foundation

This commit is contained in:
2026-05-17 20:21:55 +00:00
parent e7819bfc82
commit 71d9fe6406
88 changed files with 10936 additions and 47 deletions
+54
View File
@@ -0,0 +1,54 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { DashboardPage } from "./dashboard";
const mockGet = vi.fn();
vi.mock("../api/dashboard", () => ({
getDashboardSummary: (...args: unknown[]) => mockGet(...args)
}));
describe("DashboardPage", () => {
beforeEach(() => {
mockGet.mockReset();
});
it("shows loading then empty state when summary has no data", async () => {
mockGet.mockResolvedValue({
projects: 0,
repositories: 0,
sshKeys: 0,
recentActivity: []
});
render(<DashboardPage />);
expect(screen.getByText("Loading dashboard...")).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText("No activity yet")).toBeInTheDocument();
});
});
it("shows retry action when summary request fails", async () => {
mockGet.mockRejectedValueOnce(new Error("failed"));
mockGet.mockResolvedValueOnce({
projects: 2,
repositories: 5,
sshKeys: 1,
recentActivity: ["Created repo"]
});
render(<DashboardPage />);
await waitFor(() => {
expect(screen.getByText("Dashboard is unavailable")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
await waitFor(() => {
expect(screen.getByText("2")).toBeInTheDocument();
});
});
});
+79
View File
@@ -0,0 +1,79 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { getDashboardSummary, type DashboardSummary } from "../api/dashboard";
const CARDS = [
{ label: "Projects", key: "projects" },
{ label: "Repositories", key: "repositories" },
{ label: "SSH Keys", key: "sshKeys" }
] as const;
type DashboardStatus = "loading" | "ready" | "error";
export const DashboardPage = () => {
const [status, setStatus] = useState<DashboardStatus>("loading");
const [summary, setSummary] = useState<DashboardSummary | null>(null);
const loadSummary = useCallback(async () => {
setStatus("loading");
try {
const data = await getDashboardSummary();
setSummary(data);
setStatus("ready");
} catch {
setSummary(null);
setStatus("error");
}
}, []);
useEffect(() => {
void loadSummary();
}, [loadSummary]);
const cards = useMemo(() => CARDS, []);
const isEmpty =
status === "ready" &&
summary !== null &&
summary.projects === 0 &&
summary.repositories === 0 &&
summary.sshKeys === 0 &&
summary.recentActivity.length === 0;
return (
<section className="stack">
<h1>Dashboard</h1>
<p className="muted">Your workspace overview will appear here.</p>
{status === "loading" && <p className="muted">Loading dashboard...</p>}
{status === "error" && (
<div className="card stack">
<p>Dashboard is unavailable</p>
<button className="secondary-button" onClick={() => void loadSummary()} type="button">
Retry
</button>
</div>
)}
<div className="card-grid">
{cards.map((card) => (
<article className="card" key={card.label}>
<p className="card-label">{card.label}</p>
<p className="card-value">{summary ? String(summary[card.key]) : "-"}</p>
</article>
))}
</div>
{isEmpty && <p className="muted">No activity yet</p>}
<div className="quick-actions">
<button className="primary-button" type="button">
New Project
</button>
<button className="secondary-button" type="button">
Add Repository
</button>
</div>
</section>
);
};
+32
View File
@@ -0,0 +1,32 @@
export const PlaceholderPage = ({ title }: { title: string }) => {
return (
<section className="stack">
<h1>{title}</h1>
<p className="muted">This page is part of the frontend foundation scaffold.</p>
</section>
);
};
export const NotFoundPage = () => {
return (
<section className="stack center-screen">
<h1>404</h1>
<p className="muted">The page you requested does not exist.</p>
</section>
);
};
export const LoginRedirectPage = () => {
const nextPath = new URLSearchParams(window.location.search).get("next") ?? "/";
const encodedNext = encodeURIComponent(nextPath);
return (
<section className="stack center-screen">
<h1>Sign in required</h1>
<p className="muted">You need to authenticate to access this section.</p>
<a className="primary-button" href={`/auth/login?next=${encodedNext}`}>
Continue to login
</a>
</section>
);
};
+163
View File
@@ -0,0 +1,163 @@
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ProjectsPage } from "./projects";
import * as projectsApi from "../api/projects";
const mockProjects = [
{
id: "proj-1",
name: "Alpha Project",
description: "First project",
owner_id: "user-1",
default_ssh_key_id: null,
},
{
id: "proj-2",
name: "Beta Project",
description: null,
owner_id: "user-1",
default_ssh_key_id: null,
},
];
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
describe("ProjectsPage", () => {
it("renders loading state initially", () => {
vi.spyOn(projectsApi, "listProjects").mockImplementation(() => new Promise(() => {}));
render(<ProjectsPage />);
expect(screen.getByText(/loading projects/i)).toBeInTheDocument();
});
it("renders project list after loading", async () => {
vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
render(<ProjectsPage />);
await waitFor(() => {
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
});
expect(screen.getByText("Beta Project")).toBeInTheDocument();
expect(screen.getByText("First project")).toBeInTheDocument();
});
it("renders empty state when no projects", async () => {
vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
render(<ProjectsPage />);
await waitFor(() => {
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
});
});
it("renders error state with retry button", async () => {
vi.spyOn(projectsApi, "listProjects").mockRejectedValue(new Error("fail"));
render(<ProjectsPage />);
await waitFor(() => {
expect(screen.getByText(/failed to load projects/i)).toBeInTheDocument();
});
expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument();
});
it("opens create dialog and submits new project", async () => {
const listMock = vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
const createMock = vi.spyOn(projectsApi, "createProject").mockResolvedValue(mockProjects[0]);
render(<ProjectsPage />);
await waitFor(() => {
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /new project/i }));
expect(screen.getByRole("dialog")).toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText(/project name/i), {
target: { value: "Gamma Project" },
});
fireEvent.change(screen.getByPlaceholderText(/optional description/i), {
target: { value: "A new project" },
});
fireEvent.click(screen.getByRole("button", { name: /create/i }));
await waitFor(() => {
expect(createMock).toHaveBeenCalledWith({
name: "Gamma Project",
description: "A new project",
});
});
expect(listMock).toHaveBeenCalledTimes(2);
});
it("shows validation error when name is empty", async () => {
vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
render(<ProjectsPage />);
await waitFor(() => {
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /new project/i }));
fireEvent.click(screen.getByRole("button", { name: /create/i }));
expect(screen.getByText(/project name is required/i)).toBeInTheDocument();
});
it("opens edit dialog and saves changes", async () => {
const listMock = vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
const updateMock = vi.spyOn(projectsApi, "updateProject").mockResolvedValue(mockProjects[0]);
render(<ProjectsPage />);
await waitFor(() => {
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
});
const alphaCard = screen.getByText("Alpha Project").closest(".project-card") as HTMLElement | null;
if (!alphaCard) throw new Error("Card not found");
fireEvent.click(within(alphaCard).getByRole("button", { name: /edit/i }));
expect(screen.getByRole("dialog")).toBeInTheDocument();
const nameInput = screen.getByDisplayValue("Alpha Project");
fireEvent.change(nameInput, { target: { value: "Alpha Updated" } });
fireEvent.click(screen.getByRole("button", { name: /save/i }));
await waitFor(() => {
expect(updateMock).toHaveBeenCalledWith("proj-1", {
name: "Alpha Updated",
description: "First project",
});
});
expect(listMock).toHaveBeenCalledTimes(2);
});
it("shows delete confirmation and deletes project", async () => {
const listMock = vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
const deleteMock = vi.spyOn(projectsApi, "deleteProject").mockResolvedValue(undefined);
render(<ProjectsPage />);
await waitFor(() => {
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
});
const alphaCard = screen.getByText("Alpha Project").closest(".project-card") as HTMLElement | null;
if (!alphaCard) throw new Error("Card not found");
fireEvent.click(within(alphaCard).getByRole("button", { name: /delete/i }));
expect(within(alphaCard).getByText(/are you sure/i)).toBeInTheDocument();
fireEvent.click(within(alphaCard).getByRole("button", { name: /delete/i }));
await waitFor(() => {
expect(deleteMock).toHaveBeenCalledWith("proj-1");
});
expect(listMock).toHaveBeenCalledTimes(2);
});
});
+215
View File
@@ -0,0 +1,215 @@
import { useCallback, useEffect, useState } from "react";
import {
createProject,
deleteProject,
listProjects,
updateProject,
type ProjectCreateInput,
type ProjectUpdateInput,
} from "../api/projects";
import type { Project } from "../types";
type ProjectsStatus = "loading" | "ready" | "error";
type DialogMode = "none" | "create" | "edit";
export const ProjectsPage = () => {
const [status, setStatus] = useState<ProjectsStatus>("loading");
const [projects, setProjects] = useState<Project[]>([]);
const [dialogMode, setDialogMode] = useState<DialogMode>("none");
const [editingProject, setEditingProject] = useState<Project | null>(null);
const [formName, setFormName] = useState("");
const [formDescription, setFormDescription] = useState("");
const [formError, setFormError] = useState<string | null>(null);
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const loadProjects = useCallback(async () => {
setStatus("loading");
try {
const data = await listProjects();
setProjects(data);
setStatus("ready");
} catch {
setProjects([]);
setStatus("error");
}
}, []);
useEffect(() => {
void loadProjects();
}, [loadProjects]);
const openCreate = () => {
setFormName("");
setFormDescription("");
setFormError(null);
setEditingProject(null);
setDialogMode("create");
};
const openEdit = (project: Project) => {
setFormName(project.name);
setFormDescription(project.description ?? "");
setFormError(null);
setEditingProject(project);
setDialogMode("edit");
};
const closeDialog = () => {
setDialogMode("none");
setEditingProject(null);
setFormError(null);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setFormError(null);
if (!formName.trim()) {
setFormError("Project name is required");
return;
}
try {
if (dialogMode === "create") {
const input: ProjectCreateInput = {
name: formName.trim(),
description: formDescription.trim() || null,
};
await createProject(input);
} else if (dialogMode === "edit" && editingProject) {
const input: ProjectUpdateInput = {
name: formName.trim(),
description: formDescription.trim() || null,
};
await updateProject(editingProject.id, input);
}
closeDialog();
await loadProjects();
} catch {
setFormError("Failed to save project");
}
};
const handleDelete = async (projectId: string) => {
try {
await deleteProject(projectId);
setDeleteConfirmId(null);
await loadProjects();
} catch {
setDeleteConfirmId(null);
}
};
const isEmpty = status === "ready" && projects.length === 0;
return (
<section className="stack">
<div className="page-header">
<h1>Projects</h1>
<button className="primary-button" onClick={openCreate} type="button">
New Project
</button>
</div>
{status === "loading" && <p className="muted">Loading projects...</p>}
{status === "error" && (
<div className="card stack">
<p>Failed to load projects</p>
<button className="secondary-button" onClick={() => void loadProjects()} type="button">
Retry
</button>
</div>
)}
{isEmpty && <p className="muted">No projects yet. Create your first project above.</p>}
{status === "ready" && projects.length > 0 && (
<div className="project-list">
{projects.map((project) => (
<article className="card project-card" key={project.id}>
<div className="project-info">
<h3>{project.name}</h3>
{project.description && <p className="muted">{project.description}</p>}
</div>
<div className="project-actions">
<button
className="ghost-button"
onClick={() => openEdit(project)}
type="button"
>
Edit
</button>
{deleteConfirmId === project.id ? (
<div className="delete-confirm">
<span>Are you sure?</span>
<button
className="danger-button"
onClick={() => void handleDelete(project.id)}
type="button"
>
Delete
</button>
<button
className="ghost-button"
onClick={() => setDeleteConfirmId(null)}
type="button"
>
Cancel
</button>
</div>
) : (
<button
className="ghost-button danger-text"
onClick={() => setDeleteConfirmId(project.id)}
type="button"
>
Delete
</button>
)}
</div>
</article>
))}
</div>
)}
{dialogMode !== "none" && (
<div className="dialog-overlay" role="dialog" aria-modal="true">
<div className="dialog">
<h2>{dialogMode === "create" ? "Create Project" : "Edit Project"}</h2>
<form onSubmit={handleSubmit} className="stack">
<label className="form-field">
Name
<input
type="text"
value={formName}
onChange={(e) => setFormName(e.target.value)}
placeholder="Project name"
/>
</label>
<label className="form-field">
Description
<textarea
value={formDescription}
onChange={(e) => setFormDescription(e.target.value)}
placeholder="Optional description"
rows={3}
/>
</label>
{formError && <p className="error-text">{formError}</p>}
<div className="dialog-actions">
<button className="secondary-button" onClick={closeDialog} type="button">
Cancel
</button>
<button className="primary-button" type="submit">
{dialogMode === "create" ? "Create" : "Save"}
</button>
</div>
</form>
</div>
</div>
)}
</section>
);
};