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
+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>
);
}