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.
+ ) : (
+
+
+
+ | Email |
+ Role |
+ Sign-in |
+ Last login |
+ Actions |
+
+
+
+ {users.map((u: OrgUser) => {
+ const isSelf = u.user_id === user?.user_id;
+ return (
+
+ |
+ {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)}>
+
+
+
+ );
+}
+
+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}
+
+
+
+
+
+
+
+ );
+}
+
+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 (
+
+ );
+}
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.
+
+
+
+
+
+
+
+
+ );
+}
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}}
+