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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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'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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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<AuthContextType>({ user: null, authEnabled: false });
|
||||
const AuthContext = createContext<AuthContextType>({ 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<User | null>(null);
|
||||
const [authEnabled, setAuthEnabled] = useState(false);
|
||||
const [user, setUser] = useState<SessionUser | null>(null);
|
||||
const [org, setOrg] = useState<Org | null>(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 (
|
||||
<AuthContext.Provider value={{ user, authEnabled }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
<AuthContext.Provider value={{ user, org, isAdmin }}>{children}</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
+49
-12
@@ -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 (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 21h16.5M4.5 3h15M5.25 3v18m13.5-18v18M9 6.75h1.5m-1.5 3h1.5m-1.5 3h1.5m3-6H15m-1.5 3H15m-1.5 3H15M9 21v-3.375c0-.621.504-1.125 1.125-1.125h3.75c.621 0 1.125.504 1.125 1.125V21" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ href: "/servers", label: "Servers", icon: <ServerIcon /> },
|
||||
{ href: "/monitors", label: "Monitors", icon: <MonitorIcon /> },
|
||||
@@ -84,12 +95,32 @@ const navItems: NavItem[] = [
|
||||
{ href: "/workflows", label: "Workflows", icon: <WorkflowIcon /> },
|
||||
{ href: "/steps", label: "Steps", icon: <StepsIcon /> },
|
||||
{ href: "/audit", label: "Audit Log", icon: <AuditIcon /> },
|
||||
{ href: "/settings", label: "Settings", icon: <SettingsIcon /> },
|
||||
{ href: "/settings/org", label: "Organization", icon: <OrgIcon />, adminOnly: true },
|
||||
{ href: "/settings", label: "Settings", icon: <SettingsIcon />, 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<string | null>((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 (
|
||||
<aside className="flex h-screen w-60 flex-col border-r border-border bg-surface">
|
||||
@@ -99,14 +130,16 @@ export function Sidebar() {
|
||||
<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>
|
||||
<span className="text-base font-semibold text-text-primary">Vantage</span>
|
||||
<div className="min-w-0">
|
||||
<span className="block text-base font-semibold leading-tight text-text-primary">Vantage</span>
|
||||
{org && <span className="block truncate text-xs text-text-secondary">{org.name}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 overflow-y-auto px-3 py-4">
|
||||
<ul className="space-y-1">
|
||||
{navItems.map((item) => {
|
||||
const isActive =
|
||||
pathname === item.href || pathname.startsWith(item.href + "/");
|
||||
{visibleItems.map((item) => {
|
||||
const isActive = activeHref === item.href;
|
||||
return (
|
||||
<li key={item.href}>
|
||||
<Link
|
||||
@@ -128,21 +161,25 @@ export function Sidebar() {
|
||||
</nav>
|
||||
|
||||
<div className="border-t border-border px-4 py-3">
|
||||
{authEnabled && user && (
|
||||
{user && (
|
||||
<div className="mb-3">
|
||||
<p className="truncate text-sm font-medium text-text-primary">{user.name || user.email}</p>
|
||||
<p className="truncate text-xs text-text-secondary">{user.email}</p>
|
||||
<p className="truncate text-xs text-text-secondary">
|
||||
{user.email}
|
||||
{user.role && <span className="ml-1 text-text-tertiary">· {user.role}</span>}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-text-secondary">Vantage v1.0</p>
|
||||
{authEnabled && user && (
|
||||
<a
|
||||
href="/auth/logout"
|
||||
{user && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
className="text-xs text-text-secondary transition-colors hover:text-danger"
|
||||
>
|
||||
Logout
|
||||
</a>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+160
@@ -296,6 +296,74 @@ export interface WorkflowRun {
|
||||
server_runs: ServerRun[];
|
||||
}
|
||||
|
||||
export type Role = "owner" | "admin" | "member";
|
||||
|
||||
/** The session as returned by GET /auth/me — mirrors auth.Session on the server. */
|
||||
export interface SessionUser {
|
||||
user_id: string;
|
||||
org_id: string;
|
||||
role: Role;
|
||||
email: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface Org {
|
||||
org_id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface MeResponse {
|
||||
user: SessionUser;
|
||||
org: Org | null;
|
||||
}
|
||||
|
||||
export interface BootstrapStatus {
|
||||
needs_setup: boolean;
|
||||
}
|
||||
|
||||
export interface BootstrapResponse {
|
||||
org: Org;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
export interface OrgUser {
|
||||
user_id: string;
|
||||
org_id: string;
|
||||
email: string;
|
||||
role: Role;
|
||||
auth_source: "local" | "oidc";
|
||||
created_at: string;
|
||||
last_login?: string;
|
||||
}
|
||||
|
||||
export interface OrgUserInput {
|
||||
email: string;
|
||||
password: string;
|
||||
role: Role;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/org/oidc. `client_secret_set` reports whether a secret is stored —
|
||||
* the secret itself is write-only and is never returned. Submitting an empty
|
||||
* `client_secret` on save keeps the stored one.
|
||||
*/
|
||||
export interface OrgOIDCConfig {
|
||||
issuer?: string;
|
||||
client_id?: string;
|
||||
enabled: boolean;
|
||||
client_secret_set: boolean;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface OrgOIDCInput {
|
||||
issuer: string;
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
class ApiError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
@@ -328,7 +396,99 @@ async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth endpoints live at the root (not under /api) and report failures as
|
||||
* `{"error": "..."}`, which we surface verbatim so backend validation messages
|
||||
* reach the user.
|
||||
*/
|
||||
async function authRequest<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json", ...options?.headers },
|
||||
...options,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
let message = `HTTP ${res.status}`;
|
||||
try {
|
||||
const body = await res.json();
|
||||
if (body?.error) message = body.error;
|
||||
} catch {
|
||||
// non-JSON body — keep the status message
|
||||
}
|
||||
throw new ApiError(res.status, message);
|
||||
}
|
||||
|
||||
if (res.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export const auth = {
|
||||
bootstrapStatus(): Promise<BootstrapStatus> {
|
||||
return authRequest<BootstrapStatus>("/auth/bootstrap-status");
|
||||
},
|
||||
|
||||
bootstrap(input: { org_name: string; email: string; password: string }): Promise<BootstrapResponse> {
|
||||
return authRequest<BootstrapResponse>("/auth/bootstrap", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
},
|
||||
|
||||
login(email: string, password: string): Promise<{ ok: boolean }> {
|
||||
return authRequest<{ ok: boolean }>("/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
},
|
||||
|
||||
logout(): Promise<void> {
|
||||
return authRequest<void>("/auth/logout", { method: "POST" });
|
||||
},
|
||||
|
||||
me(): Promise<MeResponse> {
|
||||
return authRequest<MeResponse>("/auth/me");
|
||||
},
|
||||
|
||||
/** The URL an admin must register with their OIDC provider. */
|
||||
oidcRedirectUrl(): string {
|
||||
if (typeof window === "undefined") return "/auth/oidc/callback";
|
||||
return `${window.location.origin}/auth/oidc/callback`;
|
||||
},
|
||||
};
|
||||
|
||||
export const api = {
|
||||
// Organization
|
||||
listOrgUsers(): Promise<OrgUser[]> {
|
||||
return request<OrgUser[]>("/org/users");
|
||||
},
|
||||
|
||||
createOrgUser(input: OrgUserInput): Promise<OrgUser> {
|
||||
return request<OrgUser>("/org/users", { method: "POST", body: JSON.stringify(input) });
|
||||
},
|
||||
|
||||
updateOrgUserRole(userId: string, role: Role): Promise<{ ok: boolean }> {
|
||||
return request<{ ok: boolean }>(`/org/users/${userId}/role`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ role }),
|
||||
});
|
||||
},
|
||||
|
||||
deleteOrgUser(userId: string): Promise<void> {
|
||||
return request<void>(`/org/users/${userId}`, { method: "DELETE" });
|
||||
},
|
||||
|
||||
getOrgOIDC(): Promise<OrgOIDCConfig> {
|
||||
return request<OrgOIDCConfig>("/org/oidc");
|
||||
},
|
||||
|
||||
saveOrgOIDC(input: OrgOIDCInput): Promise<{ saved: boolean }> {
|
||||
return request<{ saved: boolean }>("/org/oidc", { method: "PUT", body: JSON.stringify(input) });
|
||||
},
|
||||
|
||||
// Servers
|
||||
listServers(): Promise<Server[]> {
|
||||
return request<Server[]>("/servers");
|
||||
|
||||
Reference in New Issue
Block a user