Files
vantage/web/components/AuthProvider.tsx
T
mrhid6 aa31cd8a10 fix: validate roles, guard last owner, scope bootstrap status
Security review of e70b2f0. The UI gating was correctly backed by
RequireRole everywhere; these are the missing validation gaps behind it.

- UpdateUserRole and createOrgUser accepted any role string verbatim, so
  an admin could self-promote to owner, create an owner outright, or set
  a junk role that silently stripped a user's access. Roles are now
  whitelisted, only an owner may grant or remove the owner role, and an
  actor cannot change their own.
- Neither demote nor delete guarded the last owner, so an org could reach
  zero owners. Both now refuse when no owner would remain, returning 409.
  Self-delete rejected.
- CountUsers counted across all orgs, so a locked-out org could never
  re-bootstrap once another tenant existed, and the unauthenticated
  bootstrap-status endpoint reported instance-wide state. It now answers
  per-org on an org host, falling back to global only on the apex.
- HandleMe repeats the middleware's host/org check; it sits outside the
  middleware so it can still return its own 401.
- Post-bootstrap now sends the new owner to their org host's login page.
  The session cookie is deliberately scoped to the exact host, so the old
  redirect landed them unauthenticated.
- AuthProvider renders an error state instead of mounting the shell with
  a null user when /auth/me fails for a reason other than 401.
- api.ts unwraps {"error": ...} so these messages render as text.
2026-07-22 10:26:21 +01:00

109 lines
3.4 KiB
TypeScript

"use client";
import { createContext, useContext, useEffect, useState, ReactNode } from "react";
import { auth, type Org, type Role, type SessionUser } from "@/lib/api";
export type { Org, Role, SessionUser };
interface AuthContextType {
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, 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<SessionUser | null>(null);
const [org, setOrg] = useState<Org | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const status = await auth.bootstrapStatus();
if (status.needs_setup) {
window.location.href = "/setup";
return;
}
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;
}
// Anything else (backend unreachable, org host mismatch) leaves us with
// no session. Rendering children here would mount the whole shell with
// user=null — every page would fire its own doomed API calls and the UI
// would read as a member view. Show the failure instead.
setError((err as Error).message || "Unable to load your session.");
setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, []);
if (loading) {
return (
<div className="flex h-screen items-center justify-center bg-background">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-accent" />
</div>
);
}
if (error || !user) {
return (
<div className="flex h-screen items-center justify-center bg-background p-4">
<div className="w-full max-w-md rounded-xl border border-border bg-surface p-6 text-center">
<h1 className="text-base font-semibold text-text-primary">Can&apos;t load your session</h1>
<p className="mt-2 text-sm text-text-secondary">
{error ?? "Unable to load your session."}
</p>
<div className="mt-5 flex justify-center gap-2">
<button
onClick={() => window.location.reload()}
className="rounded-lg bg-accent px-3 py-2 text-sm font-medium text-white"
>
Retry
</button>
<a
href="/login"
className="rounded-lg border border-border px-3 py-2 text-sm font-medium text-text-secondary"
>
Sign in
</a>
</div>
</div>
</div>
);
}
const isAdmin = user.role === "owner" || user.role === "admin";
return (
<AuthContext.Provider value={{ user, org, isAdmin }}>{children}</AuthContext.Provider>
);
}