From e70b2f0e675ffd42c846ddceb1a30ff47ce5b353 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Wed, 22 Jul 2026 10:17:12 +0100 Subject: [PATCH] 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. --- server/internal/api/org.go | 13 +- web/app/{ => (app)}/audit/page.tsx | 0 web/app/{ => (app)}/keys/[id]/page.tsx | 0 web/app/{ => (app)}/keys/page.tsx | 0 web/app/(app)/layout.tsx | 17 + .../{ => (app)}/monitors/[id]/edit/page.tsx | 0 web/app/{ => (app)}/monitors/[id]/page.tsx | 0 web/app/{ => (app)}/monitors/new/page.tsx | 0 web/app/{ => (app)}/monitors/page.tsx | 0 web/app/{ => (app)}/page.tsx | 0 web/app/{ => (app)}/secrets/[group]/page.tsx | 0 web/app/{ => (app)}/secrets/page.tsx | 0 .../{ => (app)}/servers/[id]/console/page.tsx | 0 web/app/{ => (app)}/servers/[id]/page.tsx | 0 web/app/{ => (app)}/servers/new/page.tsx | 0 web/app/{ => (app)}/servers/page.tsx | 0 .../settings/notifications/page.tsx | 0 web/app/(app)/settings/org/page.tsx | 400 ++++++++++++++++++ web/app/{ => (app)}/settings/page.tsx | 22 +- web/app/{ => (app)}/steps/page.tsx | 0 web/app/{ => (app)}/workflows/[id]/page.tsx | 0 .../workflows/[id]/runs/[runId]/page.tsx | 0 .../{ => (app)}/workflows/[id]/runs/page.tsx | 0 web/app/{ => (app)}/workflows/page.tsx | 0 web/app/layout.tsx | 13 +- web/app/login/page.tsx | 109 +++++ web/app/setup/page.tsx | 169 ++++++++ web/components/AuthProvider.tsx | 70 +-- web/components/Sidebar.tsx | 61 ++- web/lib/api.ts | 160 +++++++ 30 files changed, 979 insertions(+), 55 deletions(-) rename web/app/{ => (app)}/audit/page.tsx (100%) rename web/app/{ => (app)}/keys/[id]/page.tsx (100%) rename web/app/{ => (app)}/keys/page.tsx (100%) create mode 100644 web/app/(app)/layout.tsx rename web/app/{ => (app)}/monitors/[id]/edit/page.tsx (100%) rename web/app/{ => (app)}/monitors/[id]/page.tsx (100%) rename web/app/{ => (app)}/monitors/new/page.tsx (100%) rename web/app/{ => (app)}/monitors/page.tsx (100%) rename web/app/{ => (app)}/page.tsx (100%) rename web/app/{ => (app)}/secrets/[group]/page.tsx (100%) rename web/app/{ => (app)}/secrets/page.tsx (100%) rename web/app/{ => (app)}/servers/[id]/console/page.tsx (100%) rename web/app/{ => (app)}/servers/[id]/page.tsx (100%) rename web/app/{ => (app)}/servers/new/page.tsx (100%) rename web/app/{ => (app)}/servers/page.tsx (100%) rename web/app/{ => (app)}/settings/notifications/page.tsx (100%) create mode 100644 web/app/(app)/settings/org/page.tsx rename web/app/{ => (app)}/settings/page.tsx (93%) rename web/app/{ => (app)}/steps/page.tsx (100%) rename web/app/{ => (app)}/workflows/[id]/page.tsx (100%) rename web/app/{ => (app)}/workflows/[id]/runs/[runId]/page.tsx (100%) rename web/app/{ => (app)}/workflows/[id]/runs/page.tsx (100%) rename web/app/{ => (app)}/workflows/page.tsx (100%) create mode 100644 web/app/login/page.tsx create mode 100644 web/app/setup/page.tsx diff --git a/server/internal/api/org.go b/server/internal/api/org.go index 0cf374f..448e966 100644 --- a/server/internal/api/org.go +++ b/server/internal/api/org.go @@ -64,10 +64,19 @@ func deleteOrgUser(c *gin.Context) { func getOrgOIDC(c *gin.Context) { cfg, err := services.GetOrgOIDC(auth.OrgID(c)) if err != nil { - c.JSON(http.StatusOK, gin.H{"enabled": false}) + c.JSON(http.StatusOK, gin.H{"enabled": false, "client_secret_set": false}) return } - c.JSON(http.StatusOK, cfg) + // The client secret itself is write-only (never serialized); expose only + // whether one is stored so the UI can say so without leaking it. + c.JSON(http.StatusOK, gin.H{ + "org_id": cfg.OrgID, + "issuer": cfg.Issuer, + "client_id": cfg.ClientID, + "enabled": cfg.Enabled, + "updated_at": cfg.UpdatedAt, + "client_secret_set": cfg.ClientSecretEnc != "", + }) } func putOrgOIDC(c *gin.Context) { diff --git a/web/app/audit/page.tsx b/web/app/(app)/audit/page.tsx similarity index 100% rename from web/app/audit/page.tsx rename to web/app/(app)/audit/page.tsx diff --git a/web/app/keys/[id]/page.tsx b/web/app/(app)/keys/[id]/page.tsx similarity index 100% rename from web/app/keys/[id]/page.tsx rename to web/app/(app)/keys/[id]/page.tsx diff --git a/web/app/keys/page.tsx b/web/app/(app)/keys/page.tsx similarity index 100% rename from web/app/keys/page.tsx rename to web/app/(app)/keys/page.tsx diff --git a/web/app/(app)/layout.tsx b/web/app/(app)/layout.tsx new file mode 100644 index 0000000..f03b1bc --- /dev/null +++ b/web/app/(app)/layout.tsx @@ -0,0 +1,17 @@ +import { AuthProvider } from "@/components/AuthProvider"; +import { Sidebar } from "@/components/Sidebar"; + +export default function AppLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + +
+ +
{children}
+
+
+ ); +} diff --git a/web/app/monitors/[id]/edit/page.tsx b/web/app/(app)/monitors/[id]/edit/page.tsx similarity index 100% rename from web/app/monitors/[id]/edit/page.tsx rename to web/app/(app)/monitors/[id]/edit/page.tsx diff --git a/web/app/monitors/[id]/page.tsx b/web/app/(app)/monitors/[id]/page.tsx similarity index 100% rename from web/app/monitors/[id]/page.tsx rename to web/app/(app)/monitors/[id]/page.tsx diff --git a/web/app/monitors/new/page.tsx b/web/app/(app)/monitors/new/page.tsx similarity index 100% rename from web/app/monitors/new/page.tsx rename to web/app/(app)/monitors/new/page.tsx diff --git a/web/app/monitors/page.tsx b/web/app/(app)/monitors/page.tsx similarity index 100% rename from web/app/monitors/page.tsx rename to web/app/(app)/monitors/page.tsx diff --git a/web/app/page.tsx b/web/app/(app)/page.tsx similarity index 100% rename from web/app/page.tsx rename to web/app/(app)/page.tsx diff --git a/web/app/secrets/[group]/page.tsx b/web/app/(app)/secrets/[group]/page.tsx similarity index 100% rename from web/app/secrets/[group]/page.tsx rename to web/app/(app)/secrets/[group]/page.tsx diff --git a/web/app/secrets/page.tsx b/web/app/(app)/secrets/page.tsx similarity index 100% rename from web/app/secrets/page.tsx rename to web/app/(app)/secrets/page.tsx diff --git a/web/app/servers/[id]/console/page.tsx b/web/app/(app)/servers/[id]/console/page.tsx similarity index 100% rename from web/app/servers/[id]/console/page.tsx rename to web/app/(app)/servers/[id]/console/page.tsx diff --git a/web/app/servers/[id]/page.tsx b/web/app/(app)/servers/[id]/page.tsx similarity index 100% rename from web/app/servers/[id]/page.tsx rename to web/app/(app)/servers/[id]/page.tsx diff --git a/web/app/servers/new/page.tsx b/web/app/(app)/servers/new/page.tsx similarity index 100% rename from web/app/servers/new/page.tsx rename to web/app/(app)/servers/new/page.tsx diff --git a/web/app/servers/page.tsx b/web/app/(app)/servers/page.tsx similarity index 100% rename from web/app/servers/page.tsx rename to web/app/(app)/servers/page.tsx diff --git a/web/app/settings/notifications/page.tsx b/web/app/(app)/settings/notifications/page.tsx similarity index 100% rename from web/app/settings/notifications/page.tsx rename to web/app/(app)/settings/notifications/page.tsx diff --git a/web/app/(app)/settings/org/page.tsx b/web/app/(app)/settings/org/page.tsx new file mode 100644 index 0000000..31e87b2 --- /dev/null +++ b/web/app/(app)/settings/org/page.tsx @@ -0,0 +1,400 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { api, auth as authApi, type OrgUser, type Role } from "@/lib/api"; +import { useAuth } from "@/components/AuthProvider"; +import { Badge, Button, Card, Modal, Table, Tbody, Td, Th, Thead, Tr } from "@/components/ui"; + +const ROLES: Role[] = ["owner", "admin", "member"]; + +const inputClass = + "w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"; + +function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) { + return ( +
+ + {children} + {hint &&

{hint}

} +
+ ); +} + +function roleVariant(role: Role) { + if (role === "owner") return "accent" as const; + if (role === "admin") return "warning" as const; + return "neutral" as const; +} + +function MembersCard() { + const queryClient = useQueryClient(); + const { user } = useAuth(); + const [addOpen, setAddOpen] = useState(false); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [role, setRole] = useState("member"); + + const { data: users, isLoading, error } = useQuery({ queryKey: ["org-users"], queryFn: api.listOrgUsers }); + + const invalidate = () => queryClient.invalidateQueries({ queryKey: ["org-users"] }); + + const { mutate: createUser, isPending: creating, error: createError } = useMutation({ + mutationFn: () => api.createOrgUser({ email, password, role }), + onSuccess: () => { + invalidate(); + setAddOpen(false); + setEmail(""); + setPassword(""); + setRole("member"); + }, + }); + + const { mutate: changeRole } = useMutation({ + mutationFn: ({ userId, next }: { userId: string; next: Role }) => api.updateOrgUserRole(userId, next), + onSuccess: invalidate, + }); + + const { mutate: removeUser } = useMutation({ + mutationFn: (userId: string) => api.deleteOrgUser(userId), + onSuccess: invalidate, + }); + + return ( + +
+
+

Members

+

+ People with access to this organization. Owners and admins can manage settings. +

+
+ +
+ + {isLoading ? ( +
+
+
+ ) : error ? ( +

{(error as Error).message}

+ ) : !users || users.length === 0 ? ( +

No members yet.

+ ) : ( + + + + + + + + + + + + {users.map((u: OrgUser) => { + const isSelf = u.user_id === user?.user_id; + return ( + + + + + + + + ); + })} + +
EmailRoleSign-inLast loginActions
+ {u.email} + {isSelf && (you)} + + {isSelf ? ( + {u.role} + ) : ( + + )} + + {u.auth_source === "oidc" ? "SSO" : "Password"} + + {u.last_login ? new Date(u.last_login).toLocaleString() : "Never"} + + {!isSelf && ( + + )} +
+ )} + + setAddOpen(false)}> +
{ + e.preventDefault(); + createUser(); + }} + className="space-y-4" + > + + setEmail(e.target.value)} + className={inputClass} + /> + + + + setPassword(e.target.value)} + className={inputClass} + /> + + + + + + + {createError && ( +
+ {(createError as Error).message} +
+ )} + +
+ + +
+
+
+ + ); +} + +function OIDCCard() { + const queryClient = useQueryClient(); + const { data: cfg, isLoading } = useQuery({ queryKey: ["org-oidc"], queryFn: api.getOrgOIDC }); + + const [issuer, setIssuer] = useState(""); + const [clientId, setClientId] = useState(""); + const [clientSecret, setClientSecret] = useState(""); + const [enabled, setEnabled] = useState(false); + const [saved, setSaved] = useState(false); + const [copied, setCopied] = useState(false); + + const redirectUrl = authApi.oidcRedirectUrl(); + + useEffect(() => { + if (!cfg) return; + setIssuer(cfg.issuer ?? ""); + setClientId(cfg.client_id ?? ""); + setEnabled(cfg.enabled); + // The secret is never returned; leave the field blank to mean "unchanged". + setClientSecret(""); + }, [cfg]); + + const { mutate: save, isPending, error } = useMutation({ + mutationFn: () => api.saveOrgOIDC({ issuer, client_id: clientId, client_secret: clientSecret, enabled }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["org-oidc"] }); + setClientSecret(""); + setSaved(true); + setTimeout(() => setSaved(false), 3000); + }, + }); + + async function copyRedirect() { + await navigator.clipboard.writeText(redirectUrl); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + + if (isLoading) { + return ( + +
+
+
+ + ); + } + + const secretSet = cfg?.client_secret_set ?? false; + + return ( + +
+

Single Sign-On (OIDC)

+

+ Let members sign in with your identity provider. Users are provisioned into this organization on + first sign-in. +

+
+ +
+

+ Register this redirect URL with your provider: +

+
+ + {redirectUrl} + + +
+
+ +
{ + e.preventDefault(); + save(); + }} + className="space-y-4" + > + + setIssuer(e.target.value)} + className={inputClass} + /> + + + + setClientId(e.target.value)} + className={inputClass} + /> + + + + setClientSecret(e.target.value)} + className={inputClass} + /> + + +
+ + + {secretSet ? "Client secret is configured" : "No client secret configured"} + +
+ + + + {enabled && !secretSet && !clientSecret && ( +
+ SSO cannot complete sign-in without a client secret. +
+ )} + + {error && ( +
+ {(error as Error).message} +
+ )} + +
+ + {saved && SSO settings saved.} +
+
+
+ ); +} + +export default function OrgSettingsPage() { + const { org, isAdmin } = useAuth(); + + if (!isAdmin) { + return ( +
+ +

You don't have access

+

+ Organization settings are available to owners and admins only. Ask an administrator if you need + access. +

+
+
+ ); + } + + return ( +
+
+

Organization

+

+ {org ? `Manage members and sign-in for ${org.name}.` : "Manage members and sign-in."} +

+
+ +
+ + +
+
+ ); +} diff --git a/web/app/settings/page.tsx b/web/app/(app)/settings/page.tsx similarity index 93% rename from web/app/settings/page.tsx rename to web/app/(app)/settings/page.tsx index 4fd7cc6..4b253e9 100644 --- a/web/app/settings/page.tsx +++ b/web/app/(app)/settings/page.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import Link from "next/link"; import { api } from "@/lib/api"; +import { useAuth } from "@/components/AuthProvider"; import { Button, Card } from "@/components/ui"; function SectionCard({ title, description, icon, children, className }: { title: string; description?: string; icon: React.ReactNode; children: React.ReactNode; className?: string }) { @@ -136,8 +137,14 @@ function SecretsTokenCard({ tokenSet, rotatedAt }: { tokenSet: boolean; rotatedA export default function SettingsPage() { const queryClient = useQueryClient(); + const { isAdmin } = useAuth(); - const { data: settings, isLoading } = useQuery({ queryKey: ["settings"], queryFn: api.getSettings }); + // /api/settings requires owner|admin and 403s for members, so don't even ask. + const { data: settings, isLoading } = useQuery({ + queryKey: ["settings"], + queryFn: api.getSettings, + enabled: isAdmin, + }); const [thresholdMinutes, setThresholdMinutes] = useState(5); const [logRetentionDays, setLogRetentionDays] = useState(30); @@ -170,6 +177,19 @@ export default function SettingsPage() { }); } + if (!isAdmin) { + return ( +
+ +

You don't have access

+

+ Settings are available to owners and admins only. Ask an administrator if you need access. +

+
+
+ ); + } + if (isLoading) { return (
diff --git a/web/app/steps/page.tsx b/web/app/(app)/steps/page.tsx similarity index 100% rename from web/app/steps/page.tsx rename to web/app/(app)/steps/page.tsx diff --git a/web/app/workflows/[id]/page.tsx b/web/app/(app)/workflows/[id]/page.tsx similarity index 100% rename from web/app/workflows/[id]/page.tsx rename to web/app/(app)/workflows/[id]/page.tsx diff --git a/web/app/workflows/[id]/runs/[runId]/page.tsx b/web/app/(app)/workflows/[id]/runs/[runId]/page.tsx similarity index 100% rename from web/app/workflows/[id]/runs/[runId]/page.tsx rename to web/app/(app)/workflows/[id]/runs/[runId]/page.tsx diff --git a/web/app/workflows/[id]/runs/page.tsx b/web/app/(app)/workflows/[id]/runs/page.tsx similarity index 100% rename from web/app/workflows/[id]/runs/page.tsx rename to web/app/(app)/workflows/[id]/runs/page.tsx diff --git a/web/app/workflows/page.tsx b/web/app/(app)/workflows/page.tsx similarity index 100% rename from web/app/workflows/page.tsx rename to web/app/(app)/workflows/page.tsx diff --git a/web/app/layout.tsx b/web/app/layout.tsx index c181f0d..eabf01e 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -1,8 +1,6 @@ import type { Metadata } from "next"; import "./globals.css"; import { Providers } from "@/components/Providers"; -import { AuthProvider } from "@/components/AuthProvider"; -import { Sidebar } from "@/components/Sidebar"; export const metadata: Metadata = { title: "Vantage", @@ -17,16 +15,7 @@ export default function RootLayout({ return ( - - -
- -
- {children} -
-
-
-
+ {children} ); diff --git a/web/app/login/page.tsx b/web/app/login/page.tsx new file mode 100644 index 0000000..c669e2e --- /dev/null +++ b/web/app/login/page.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useMutation } from "@tanstack/react-query"; +import { auth } from "@/lib/api"; +import { Button, Card } from "@/components/ui"; + +export default function LoginPage() { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + + // If the instance has no users yet, first-run setup is the only way in. + useEffect(() => { + auth + .bootstrapStatus() + .then((s) => { + if (s.needs_setup) window.location.href = "/setup"; + }) + .catch(() => { + // Status unavailable — let the login form stand. + }); + }, []); + + const { mutate: signIn, isPending, error } = useMutation({ + mutationFn: () => auth.login(email, password), + onSuccess: () => { + window.location.href = "/"; + }, + }); + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + signIn(); + } + + return ( +
+
+
+
+ + + +
+

Sign in to Vantage

+
+ + +
+
+ + setEmail(e.target.value)} + className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30" + /> +
+ +
+ + setPassword(e.target.value)} + className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30" + /> +
+ + {error && ( +
+ {(error as Error).message} +
+ )} + + +
+ +
+
+ or +
+
+ + + + +

+ SSO must be enabled for this organization by an administrator. +

+ +
+
+ ); +} diff --git a/web/app/setup/page.tsx b/web/app/setup/page.tsx new file mode 100644 index 0000000..36b2809 --- /dev/null +++ b/web/app/setup/page.tsx @@ -0,0 +1,169 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useMutation } from "@tanstack/react-query"; +import { auth } from "@/lib/api"; +import { Button, Card } from "@/components/ui"; + +const MIN_PASSWORD_LENGTH = 8; + +/** + * Org hosts are `.vantage.` and the apex is `vantage.` (see + * auth.hostSlug on the server). Build the new org's URL by prepending — or + * replacing — the leftmost label. Hosts that don't match that shape (localhost, + * bare IPs) have no per-org subdomain, so stay put. + */ +function orgUrlForSlug(slug: string): string { + if (typeof window === "undefined") return "/"; + const { protocol, host } = window.location; + const [hostname, port] = host.split(":"); + const parts = hostname.split("."); + + if (parts.length < 2 || parts[parts.length - 1] === "localhost") return "/"; + + const rest = parts[0] === "vantage" ? parts : parts.slice(1); + if (rest[0] !== "vantage") return "/"; + + const newHost = [slug, ...rest].join(".") + (port ? `:${port}` : ""); + return `${protocol}//${newHost}/`; +} + +export default function SetupPage() { + const [orgName, setOrgName] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [confirm, setConfirm] = useState(""); + const [validationError, setValidationError] = useState(null); + + // Setup is a one-shot route; once an owner exists it must not be reachable. + useEffect(() => { + auth + .bootstrapStatus() + .then((s) => { + if (!s.needs_setup) window.location.href = "/login"; + }) + .catch(() => { + // Status unavailable — let the form stand; the backend re-checks on submit. + }); + }, []); + + const { mutate: bootstrap, isPending, error } = useMutation({ + mutationFn: () => auth.bootstrap({ org_name: orgName, email, password }), + onSuccess: (res) => { + window.location.href = orgUrlForSlug(res.slug); + }, + }); + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (password.length < MIN_PASSWORD_LENGTH) { + setValidationError(`Password must be at least ${MIN_PASSWORD_LENGTH} characters.`); + return; + } + if (password !== confirm) { + setValidationError("Passwords do not match."); + return; + } + setValidationError(null); + bootstrap(); + } + + // Prefer the backend's message (it owns the real validation rules). + const message = validationError ?? (error ? (error as Error).message : null); + + const inputClass = + "w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"; + + return ( +
+
+
+

Welcome to Vantage

+

+ Create your organization and its owner account to get started. +

+
+ + +
+
+ + setOrgName(e.target.value)} + className={inputClass} + /> +

+ Used to derive your organization's subdomain. +

+
+ +
+ + setEmail(e.target.value)} + className={inputClass} + /> +
+ +
+ + setPassword(e.target.value)} + className={inputClass} + /> +

+ At least {MIN_PASSWORD_LENGTH} characters. +

+
+ +
+ + setConfirm(e.target.value)} + className={inputClass} + /> +
+ + {message && ( +
+ {message} +
+ )} + + +
+
+
+
+ ); +} diff --git a/web/components/AuthProvider.tsx b/web/components/AuthProvider.tsx index 0242ea5..af151e6 100644 --- a/web/components/AuthProvider.tsx +++ b/web/components/AuthProvider.tsx @@ -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({ user: null, authEnabled: false }); +const AuthContext = createContext({ 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(null); - const [authEnabled, setAuthEnabled] = useState(false); + const [user, setUser] = useState(null); + const [org, setOrg] = useState(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 ( - - {children} - + {children} ); } diff --git a/web/components/Sidebar.tsx b/web/components/Sidebar.tsx index 60d7520..2e0aa97 100644 --- a/web/components/Sidebar.tsx +++ b/web/components/Sidebar.tsx @@ -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 ( + + + + ); +} + const navItems: NavItem[] = [ { href: "/servers", label: "Servers", icon: }, { href: "/monitors", label: "Monitors", icon: }, @@ -84,12 +95,32 @@ const navItems: NavItem[] = [ { href: "/workflows", label: "Workflows", icon: }, { href: "/steps", label: "Steps", icon: }, { href: "/audit", label: "Audit Log", icon: }, - { href: "/settings", label: "Settings", icon: }, + { href: "/settings/org", label: "Organization", icon: , adminOnly: true }, + { href: "/settings", label: "Settings", icon: , 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((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 (
- Vantage +
+ Vantage + {org && {org.name}} +