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.
This commit is contained in:
@@ -26,6 +26,7 @@ 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;
|
||||
@@ -50,7 +51,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
window.location.href = "/login";
|
||||
return;
|
||||
}
|
||||
// Backend unreachable or unexpected failure — don't trap the user on a spinner.
|
||||
// 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);
|
||||
}
|
||||
})();
|
||||
@@ -68,7 +73,34 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
const isAdmin = user?.role === "owner" || user?.role === "admin";
|
||||
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'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>
|
||||
|
||||
Reference in New Issue
Block a user