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:
mrhid6 committed 2026-07-22 10:17:12 +01:00
1 parent 156c5354de
commit e70b2f0e67
30 files changed
+979 -55

No files matched your search

File renamed without changes.
File renamed without changes.
File renamed without changes.
+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>
);
}
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+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">
File renamed without changes.
File renamed without changes.
File renamed without changes.
+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>
);
}