feat(web): login, first-run setup, org settings; org-aware AuthProvider

- Route group (app) holds AuthProvider + Sidebar, so /login and /setup
  render without app chrome and never mount the provider.
- AuthProvider drops the removed auth_enabled flag and exposes
  {user, org, isAdmin}.
- New login page (password + SSO), first-run setup page, and org settings
  page with a members table and the OIDC provider form.
- Settings page and the Organization nav entry are gated on role, since
  /api/settings now 403s for members.
- GET /api/org/oidc gains client_secret_set so the UI can show whether a
  secret is stored; the secret itself is still never serialized, and an
  empty submitted value still means "keep the stored one".
- Fix logout: the sidebar linked to /auth/logout with a GET, but the route
  is POST-only, so logout was 404ing.
This commit is contained in:
2026-07-22 10:17:12 +01:00
parent 156c5354de
commit e70b2f0e67
30 changed files with 979 additions and 55 deletions
+42 -28
View File
@@ -1,49 +1,63 @@
"use client";
import { createContext, useContext, useEffect, useState, ReactNode } from "react";
import { auth, type Org, type Role, type SessionUser } from "@/lib/api";
export interface User {
user_id: string;
email: string;
name: string;
}
export type { Org, Role, SessionUser };
interface AuthContextType {
user: User | null;
authEnabled: boolean;
user: SessionUser | null;
org: Org | null;
/** True for owner and admin — the roles the /api/settings and /api/org routes require. */
isAdmin: boolean;
}
const AuthContext = createContext<AuthContextType>({ user: null, authEnabled: false });
const AuthContext = createContext<AuthContextType>({ user: null, org: null, isAdmin: false });
export function useAuth() {
return useContext(AuthContext);
}
/**
* Wraps the authenticated app shell only (see app/(app)/layout.tsx). /login and
* /setup live outside the group, so no pathname guard is needed here.
*/
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [authEnabled, setAuthEnabled] = useState(false);
const [user, setUser] = useState<SessionUser | null>(null);
const [org, setOrg] = useState<Org | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch("/auth/me", { credentials: "include" })
.then(async (res) => {
if (res.status === 401) {
window.location.href = "/auth/login";
let cancelled = false;
(async () => {
try {
const status = await auth.bootstrapStatus();
if (status.needs_setup) {
window.location.href = "/setup";
return;
}
const data = await res.json();
if (data.auth_enabled === false) {
setAuthEnabled(false);
} else {
setAuthEnabled(true);
setUser(data as User);
const me = await auth.me();
if (cancelled) return;
setUser(me.user);
setOrg(me.org);
setLoading(false);
} catch (err) {
if (cancelled) return;
const status = (err as { status?: number }).status;
if (status === 401) {
window.location.href = "/login";
return;
}
// Backend unreachable or unexpected failure — don't trap the user on a spinner.
setLoading(false);
})
.catch(() => {
// Backend unreachable — don't block the UI
setLoading(false);
});
}
})();
return () => {
cancelled = true;
};
}, []);
if (loading) {
@@ -54,9 +68,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
);
}
const isAdmin = user?.role === "owner" || user?.role === "admin";
return (
<AuthContext.Provider value={{ user, authEnabled }}>
{children}
</AuthContext.Provider>
<AuthContext.Provider value={{ user, org, isAdmin }}>{children}</AuthContext.Provider>
);
}
+49 -12
View File
@@ -4,11 +4,14 @@ import Link from "next/link";
import { usePathname } from "next/navigation";
import { clsx } from "clsx";
import { useAuth } from "@/components/AuthProvider";
import { auth } from "@/lib/api";
interface NavItem {
href: string;
label: string;
icon: React.ReactNode;
/** Restricted to owner/admin — the roles the backing API requires. */
adminOnly?: boolean;
}
function ServerIcon() {
@@ -76,6 +79,14 @@ function StepsIcon() {
);
}
function OrgIcon() {
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="M3.75 21h16.5M4.5 3h15M5.25 3v18m13.5-18v18M9 6.75h1.5m-1.5 3h1.5m-1.5 3h1.5m3-6H15m-1.5 3H15m-1.5 3H15M9 21v-3.375c0-.621.504-1.125 1.125-1.125h3.75c.621 0 1.125.504 1.125 1.125V21" />
</svg>
);
}
const navItems: NavItem[] = [
{ href: "/servers", label: "Servers", icon: <ServerIcon /> },
{ href: "/monitors", label: "Monitors", icon: <MonitorIcon /> },
@@ -84,12 +95,32 @@ const navItems: NavItem[] = [
{ href: "/workflows", label: "Workflows", icon: <WorkflowIcon /> },
{ href: "/steps", label: "Steps", icon: <StepsIcon /> },
{ href: "/audit", label: "Audit Log", icon: <AuditIcon /> },
{ href: "/settings", label: "Settings", icon: <SettingsIcon /> },
{ href: "/settings/org", label: "Organization", icon: <OrgIcon />, adminOnly: true },
{ href: "/settings", label: "Settings", icon: <SettingsIcon />, adminOnly: true },
];
export function Sidebar() {
const pathname = usePathname();
const { user, authEnabled } = useAuth();
const { user, org, isAdmin } = useAuth();
const visibleItems = navItems.filter((item) => !item.adminOnly || isAdmin);
// Longest match wins, so /settings/org doesn't also light up /settings.
const activeHref = visibleItems.reduce<string | null>((best, item) => {
const matches = pathname === item.href || pathname.startsWith(item.href + "/");
if (!matches) return best;
return best === null || item.href.length > best.length ? item.href : best;
}, null);
async function handleLogout() {
// /auth/logout is POST-only on the server.
try {
await auth.logout();
} catch {
// Fall through — clearing the client-side session view is what matters.
}
window.location.href = "/login";
}
return (
<aside className="flex h-screen w-60 flex-col border-r border-border bg-surface">
@@ -99,14 +130,16 @@ export function Sidebar() {
<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">Vantage</span>
<div className="min-w-0">
<span className="block text-base font-semibold leading-tight text-text-primary">Vantage</span>
{org && <span className="block truncate text-xs text-text-secondary">{org.name}</span>}
</div>
</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 + "/");
{visibleItems.map((item) => {
const isActive = activeHref === item.href;
return (
<li key={item.href}>
<Link
@@ -128,21 +161,25 @@ export function Sidebar() {
</nav>
<div className="border-t border-border px-4 py-3">
{authEnabled && user && (
{user && (
<div className="mb-3">
<p className="truncate text-sm font-medium text-text-primary">{user.name || user.email}</p>
<p className="truncate text-xs text-text-secondary">{user.email}</p>
<p className="truncate text-xs text-text-secondary">
{user.email}
{user.role && <span className="ml-1 text-text-tertiary">· {user.role}</span>}
</p>
</div>
)}
<div className="flex items-center justify-between">
<p className="text-xs text-text-secondary">Vantage v1.0</p>
{authEnabled && user && (
<a
href="/auth/logout"
{user && (
<button
type="button"
onClick={handleLogout}
className="text-xs text-text-secondary transition-colors hover:text-danger"
>
Logout
</a>
</button>
)}
</div>
</div>