feat: implement auth, projects, and frontend foundation
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
import { Link, NavLink, Outlet } from "react-router-dom";
|
||||
|
||||
import { useAuth } from "../state/auth";
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ to: "/", label: "Dashboard" },
|
||||
{ to: "/projects", label: "Projects" },
|
||||
{ to: "/repositories", label: "Repositories" },
|
||||
{ to: "/ssh-keys", label: "SSH Keys" },
|
||||
{ to: "/settings", label: "Settings" }
|
||||
];
|
||||
|
||||
export const AppShell = () => {
|
||||
const { user, logout } = useAuth();
|
||||
|
||||
return (
|
||||
<div className="shell">
|
||||
<header className="shell-header">
|
||||
<Link className="brand" to="/">
|
||||
Headquarter
|
||||
</Link>
|
||||
<div className="header-actions">
|
||||
<div className="user-chip">{user?.name ?? "User"}</div>
|
||||
<button
|
||||
className="ghost-button"
|
||||
onClick={() => {
|
||||
void logout();
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="shell-body">
|
||||
<aside className="shell-nav" aria-label="Primary navigation">
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={({ isActive }) => (isActive ? "nav-item nav-item-active" : "nav-item")}
|
||||
end={item.to === "/"}
|
||||
>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</aside>
|
||||
|
||||
<main className="shell-content">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ProtectedRoute } from "./protected-route";
|
||||
|
||||
const mockUseAuth = vi.fn();
|
||||
|
||||
vi.mock("../state/auth", () => ({
|
||||
useAuth: () => mockUseAuth()
|
||||
}));
|
||||
|
||||
describe("ProtectedRoute", () => {
|
||||
it("shows loading while session is resolving", () => {
|
||||
mockUseAuth.mockReturnValue({ state: "loading" });
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/"]}>
|
||||
<ProtectedRoute>
|
||||
<div>private content</div>
|
||||
</ProtectedRoute>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Checking session...")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("redirects unauthenticated users to login", () => {
|
||||
mockUseAuth.mockReturnValue({ state: "unauthenticated" });
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/settings"]}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/settings"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<div>private content</div>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="/login" element={<div>login page</div>} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByText("login page")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Navigate, useLocation } from "react-router-dom";
|
||||
|
||||
import { useAuth } from "../state/auth";
|
||||
|
||||
export const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
||||
const { state } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
if (state === "loading") {
|
||||
return <div className="center-screen">Checking session...</div>;
|
||||
}
|
||||
|
||||
if (state === "unauthenticated") {
|
||||
const nextPath = encodeURIComponent(location.pathname);
|
||||
return <Navigate to={`/login?next=${nextPath}`} replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
Reference in New Issue
Block a user