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:
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user