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
+42 -28
View File
@@ -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>
);
}