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
+17
View File
@@ -0,0 +1,17 @@
import { AuthProvider } from "@/components/AuthProvider";
import { Sidebar } from "@/components/Sidebar";
export default function AppLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<AuthProvider>
<div className="flex h-screen overflow-hidden">
<Sidebar />
<main className="flex-1 overflow-y-auto">{children}</main>
</div>
</AuthProvider>
);
}
+400
View File
@@ -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 (
<div>
<label className="mb-1.5 block text-sm font-medium text-text-secondary">{label}</label>
{children}
{hint && <p className="mt-1 text-xs text-text-tertiary">{hint}</p>}
</div>
);
}
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<Role>("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 (
<Card>
<div className="mb-4 flex items-start justify-between gap-3">
<div>
<h2 className="text-base font-semibold text-text-primary">Members</h2>
<p className="mt-0.5 text-sm text-text-secondary">
People with access to this organization. Owners and admins can manage settings.
</p>
</div>
<Button variant="primary" size="sm" onClick={() => setAddOpen(true)}>
Add Member
</Button>
</div>
{isLoading ? (
<div className="flex justify-center py-8">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-border border-t-accent" />
</div>
) : error ? (
<p className="py-6 text-sm text-danger">{(error as Error).message}</p>
) : !users || users.length === 0 ? (
<p className="py-6 text-sm text-text-secondary">No members yet.</p>
) : (
<Table>
<Thead>
<Tr>
<Th>Email</Th>
<Th>Role</Th>
<Th>Sign-in</Th>
<Th>Last login</Th>
<Th className="text-right">Actions</Th>
</Tr>
</Thead>
<Tbody>
{users.map((u: OrgUser) => {
const isSelf = u.user_id === user?.user_id;
return (
<Tr key={u.user_id}>
<Td>
<span className="font-medium">{u.email}</span>
{isSelf && <span className="ml-2 text-xs text-text-tertiary">(you)</span>}
</Td>
<Td>
{isSelf ? (
<Badge variant={roleVariant(u.role)}>{u.role}</Badge>
) : (
<select
value={u.role}
onChange={(e) => changeRole({ userId: u.user_id, next: e.target.value as Role })}
className="rounded-lg border border-border bg-surface-2 px-2 py-1 text-sm text-text-primary focus:border-accent/50 focus:outline-none"
>
{ROLES.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
)}
</Td>
<Td>
<Badge variant="neutral">{u.auth_source === "oidc" ? "SSO" : "Password"}</Badge>
</Td>
<Td className="text-text-secondary">
{u.last_login ? new Date(u.last_login).toLocaleString() : "Never"}
</Td>
<Td className="text-right">
{!isSelf && (
<Button
variant="ghost"
size="sm"
onClick={() => {
if (confirm(`Remove ${u.email} from this organization?`)) removeUser(u.user_id);
}}
>
Remove
</Button>
)}
</Td>
</Tr>
);
})}
</Tbody>
</Table>
)}
<Modal open={addOpen} title="Add Member" onClose={() => setAddOpen(false)}>
<form
onSubmit={(e) => {
e.preventDefault();
createUser();
}}
className="space-y-4"
>
<Field label="Email">
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className={inputClass}
/>
</Field>
<Field
label="Password"
hint="Leave blank if this member will sign in through SSO instead."
>
<input
type="password"
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className={inputClass}
/>
</Field>
<Field label="Role">
<select value={role} onChange={(e) => setRole(e.target.value as Role)} className={inputClass}>
{ROLES.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
</Field>
{createError && (
<div className="rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">
{(createError as Error).message}
</div>
)}
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" onClick={() => setAddOpen(false)}>
Cancel
</Button>
<Button type="submit" variant="primary" loading={creating}>
Add Member
</Button>
</div>
</form>
</Modal>
</Card>
);
}
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 (
<Card>
<div className="flex justify-center py-8">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-border border-t-accent" />
</div>
</Card>
);
}
const secretSet = cfg?.client_secret_set ?? false;
return (
<Card>
<div className="mb-4">
<h2 className="text-base font-semibold text-text-primary">Single Sign-On (OIDC)</h2>
<p className="mt-0.5 text-sm text-text-secondary">
Let members sign in with your identity provider. Users are provisioned into this organization on
first sign-in.
</p>
</div>
<div className="mb-5 rounded-lg border border-border bg-surface-2 p-3">
<p className="mb-2 text-xs font-medium text-text-secondary">
Register this redirect URL with your provider:
</p>
<div className="flex items-center gap-2">
<code className="flex-1 overflow-x-auto rounded bg-background px-2 py-1.5 font-mono text-xs text-text-primary">
{redirectUrl}
</code>
<Button type="button" variant="ghost" size="sm" onClick={copyRedirect}>
{copied ? "Copied!" : "Copy"}
</Button>
</div>
</div>
<form
onSubmit={(e) => {
e.preventDefault();
save();
}}
className="space-y-4"
>
<Field label="Issuer URL" hint="The provider's OIDC discovery base, e.g. https://accounts.google.com">
<input
type="url"
required
value={issuer}
onChange={(e) => setIssuer(e.target.value)}
className={inputClass}
/>
</Field>
<Field label="Client ID">
<input
type="text"
required
value={clientId}
onChange={(e) => setClientId(e.target.value)}
className={inputClass}
/>
</Field>
<Field
label="Client Secret"
hint={
secretSet
? "A secret is stored. Leave this blank to keep it, or enter a new one to replace it."
: "No secret stored yet."
}
>
<input
type="password"
autoComplete="new-password"
placeholder={secretSet ? "•••••••• (unchanged)" : "Enter client secret"}
value={clientSecret}
onChange={(e) => setClientSecret(e.target.value)}
className={inputClass}
/>
</Field>
<div className="flex items-center gap-2 text-sm">
<span className={`inline-block h-2 w-2 rounded-full ${secretSet ? "bg-success" : "bg-text-tertiary"}`} />
<span className="text-text-secondary">
{secretSet ? "Client secret is configured" : "No client secret configured"}
</span>
</div>
<label className="flex items-center gap-2 text-sm text-text-secondary">
<input
type="checkbox"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
className="h-4 w-4 rounded border-border bg-surface-2 accent-accent"
/>
Enable SSO sign-in for this organization
</label>
{enabled && !secretSet && !clientSecret && (
<div className="rounded-lg border border-warning/30 bg-warning/10 px-3 py-2 text-sm text-warning">
SSO cannot complete sign-in without a client secret.
</div>
)}
{error && (
<div className="rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">
{(error as Error).message}
</div>
)}
<div className="flex items-center gap-3">
<Button type="submit" variant="primary" loading={isPending}>
{saved ? "Saved!" : "Save SSO Settings"}
</Button>
{saved && <span className="text-sm text-success">SSO settings saved.</span>}
</div>
</form>
</Card>
);
}
export default function OrgSettingsPage() {
const { org, isAdmin } = useAuth();
if (!isAdmin) {
return (
<div className="p-8">
<Card className="max-w-lg">
<h1 className="text-base font-semibold text-text-primary">You don&apos;t have access</h1>
<p className="mt-1 text-sm text-text-secondary">
Organization settings are available to owners and admins only. Ask an administrator if you need
access.
</p>
</Card>
</div>
);
}
return (
<div className="p-8">
<div className="mb-8">
<h1 className="text-2xl font-bold text-text-primary">Organization</h1>
<p className="mt-1 text-sm text-text-secondary">
{org ? `Manage members and sign-in for ${org.name}.` : "Manage members and sign-in."}
</p>
</div>
<div className="space-y-6">
<MembersCard />
<OIDCCard />
</div>
</div>
);
}
@@ -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 (
<div className="p-8">
<Card className="max-w-lg">
<h1 className="text-base font-semibold text-text-primary">You don&apos;t have access</h1>
<p className="mt-1 text-sm text-text-secondary">
Settings are available to owners and admins only. Ask an administrator if you need access.
</p>
</Card>
</div>
);
}
if (isLoading) {
return (
<div className="flex h-full items-center justify-center">
+1 -12
View File
@@ -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 (
<html lang="en" className="dark">
<body className="bg-background text-text-primary">
<Providers>
<AuthProvider>
<div className="flex h-screen overflow-hidden">
<Sidebar />
<main className="flex-1 overflow-y-auto">
{children}
</main>
</div>
</AuthProvider>
</Providers>
<Providers>{children}</Providers>
</body>
</html>
);
+109
View File
@@ -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 (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<div className="w-full max-w-sm">
<div className="mb-8 flex flex-col items-center gap-3">
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-accent">
<svg className="h-6 w-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<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>
<h1 className="text-xl font-semibold text-text-primary">Sign in to Vantage</h1>
</div>
<Card>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="email" className="mb-1.5 block text-sm font-medium text-text-secondary">
Email
</label>
<input
id="email"
type="email"
required
autoComplete="username"
value={email}
onChange={(e) => 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"
/>
</div>
<div>
<label htmlFor="password" className="mb-1.5 block text-sm font-medium text-text-secondary">
Password
</label>
<input
id="password"
type="password"
required
autoComplete="current-password"
value={password}
onChange={(e) => 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"
/>
</div>
{error && (
<div className="rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">
{(error as Error).message}
</div>
)}
<Button type="submit" variant="primary" loading={isPending} className="w-full justify-center">
Sign In
</Button>
</form>
<div className="my-5 flex items-center gap-3">
<div className="h-px flex-1 bg-border" />
<span className="text-xs uppercase tracking-wider text-text-tertiary">or</span>
<div className="h-px flex-1 bg-border" />
</div>
<a href="/auth/oidc/start" className="block">
<Button type="button" variant="secondary" className="w-full justify-center">
Sign in with your organization&apos;s SSO
</Button>
</a>
<p className="mt-3 text-center text-xs text-text-tertiary">
SSO must be enabled for this organization by an administrator.
</p>
</Card>
</div>
</div>
);
}
+169
View File
@@ -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 `<slug>.vantage.<rest>` and the apex is `vantage.<rest>` (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<string | null>(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 (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<div className="w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-xl font-semibold text-text-primary">Welcome to Vantage</h1>
<p className="mt-1 text-sm text-text-secondary">
Create your organization and its owner account to get started.
</p>
</div>
<Card>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="org" className="mb-1.5 block text-sm font-medium text-text-secondary">
Organization name
</label>
<input
id="org"
type="text"
required
value={orgName}
onChange={(e) => setOrgName(e.target.value)}
className={inputClass}
/>
<p className="mt-1 text-xs text-text-tertiary">
Used to derive your organization&apos;s subdomain.
</p>
</div>
<div>
<label htmlFor="email" className="mb-1.5 block text-sm font-medium text-text-secondary">
Owner email
</label>
<input
id="email"
type="email"
required
autoComplete="username"
value={email}
onChange={(e) => setEmail(e.target.value)}
className={inputClass}
/>
</div>
<div>
<label htmlFor="password" className="mb-1.5 block text-sm font-medium text-text-secondary">
Password
</label>
<input
id="password"
type="password"
required
minLength={MIN_PASSWORD_LENGTH}
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className={inputClass}
/>
<p className="mt-1 text-xs text-text-tertiary">
At least {MIN_PASSWORD_LENGTH} characters.
</p>
</div>
<div>
<label htmlFor="confirm" className="mb-1.5 block text-sm font-medium text-text-secondary">
Confirm password
</label>
<input
id="confirm"
type="password"
required
autoComplete="new-password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
className={inputClass}
/>
</div>
{message && (
<div className="rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">
{message}
</div>
)}
<Button type="submit" variant="primary" loading={isPending} className="w-full justify-center">
Create Organization
</Button>
</form>
</Card>
</div>
</div>
);
}