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:
@@ -50,16 +50,26 @@ function MembersCard() {
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: changeRole } = useMutation({
|
||||
const { mutate: changeRole, error: roleError } = useMutation({
|
||||
mutationFn: ({ userId, next }: { userId: string; next: Role }) => api.updateOrgUserRole(userId, next),
|
||||
onSuccess: invalidate,
|
||||
// A rejected change (last owner, owner-only grant) leaves the select showing
|
||||
// the value the server refused — refetch so the row snaps back to the truth.
|
||||
onError: invalidate,
|
||||
});
|
||||
|
||||
const { mutate: removeUser } = useMutation({
|
||||
const { mutate: removeUser, error: removeError } = useMutation({
|
||||
mutationFn: (userId: string) => api.deleteOrgUser(userId),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const actionError = (roleError ?? removeError) as Error | null;
|
||||
|
||||
// The server lets only an owner grant or change the owner role. Mirror that
|
||||
// here so admins aren't offered controls that can only 403.
|
||||
const isOwner = user?.role === "owner";
|
||||
const assignableRoles = isOwner ? ROLES : ROLES.filter((r) => r !== "owner");
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="mb-4 flex items-start justify-between gap-3">
|
||||
@@ -74,6 +84,12 @@ function MembersCard() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{actionError && (
|
||||
<div className="mb-4 rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm text-danger">
|
||||
{actionError.message}
|
||||
</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" />
|
||||
@@ -96,6 +112,8 @@ function MembersCard() {
|
||||
<Tbody>
|
||||
{users.map((u: OrgUser) => {
|
||||
const isSelf = u.user_id === user?.user_id;
|
||||
// Own row stays read-only, and only owners may act on owners.
|
||||
const locked = isSelf || (u.role === "owner" && !isOwner);
|
||||
return (
|
||||
<Tr key={u.user_id}>
|
||||
<Td>
|
||||
@@ -103,7 +121,7 @@ function MembersCard() {
|
||||
{isSelf && <span className="ml-2 text-xs text-text-tertiary">(you)</span>}
|
||||
</Td>
|
||||
<Td>
|
||||
{isSelf ? (
|
||||
{locked ? (
|
||||
<Badge variant={roleVariant(u.role)}>{u.role}</Badge>
|
||||
) : (
|
||||
<select
|
||||
@@ -111,7 +129,7 @@ function MembersCard() {
|
||||
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) => (
|
||||
{assignableRoles.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
@@ -126,7 +144,7 @@ function MembersCard() {
|
||||
{u.last_login ? new Date(u.last_login).toLocaleString() : "Never"}
|
||||
</Td>
|
||||
<Td className="text-right">
|
||||
{!isSelf && (
|
||||
{!locked && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -178,7 +196,7 @@ function MembersCard() {
|
||||
|
||||
<Field label="Role">
|
||||
<select value={role} onChange={(e) => setRole(e.target.value as Role)} className={inputClass}>
|
||||
{ROLES.map((r) => (
|
||||
{assignableRoles.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
|
||||
+20
-9
@@ -9,16 +9,27 @@ 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.
|
||||
// If the org has no users yet, first-run setup is the only way in. And if the
|
||||
// visitor already has a valid session on this host, the form is a dead end —
|
||||
// send them into the app instead.
|
||||
useEffect(() => {
|
||||
auth
|
||||
.bootstrapStatus()
|
||||
.then((s) => {
|
||||
if (s.needs_setup) window.location.href = "/setup";
|
||||
})
|
||||
.catch(() => {
|
||||
// Status unavailable — let the login form stand.
|
||||
});
|
||||
(async () => {
|
||||
try {
|
||||
const s = await auth.bootstrapStatus();
|
||||
if (s.needs_setup) {
|
||||
window.location.href = "/setup";
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Status unavailable — fall through and let the login form stand.
|
||||
}
|
||||
try {
|
||||
await auth.me();
|
||||
window.location.href = "/";
|
||||
} catch {
|
||||
// Not signed in (or session invalid here) — show the form.
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const { mutate: signIn, isPending, error } = useMutation({
|
||||
|
||||
+43
-6
@@ -12,20 +12,25 @@ const MIN_PASSWORD_LENGTH = 8;
|
||||
* 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.
|
||||
*
|
||||
* Setup runs on the apex, and the session cookie it sets is scoped to that
|
||||
* exact host by design — org hosts must not share cookies. So the new owner is
|
||||
* sent to the org host's *login* page to sign in there, which is what puts a
|
||||
* session cookie on the host their org actually lives on.
|
||||
*/
|
||||
function orgUrlForSlug(slug: string): string {
|
||||
if (typeof window === "undefined") return "/";
|
||||
function orgLoginUrlForSlug(slug: string): string {
|
||||
if (typeof window === "undefined") return "/login";
|
||||
const { protocol, host } = window.location;
|
||||
const [hostname, port] = host.split(":");
|
||||
const parts = hostname.split(".");
|
||||
|
||||
if (parts.length < 2 || parts[parts.length - 1] === "localhost") return "/";
|
||||
if (parts.length < 2 || parts[parts.length - 1] === "localhost") return "/login";
|
||||
|
||||
const rest = parts[0] === "vantage" ? parts : parts.slice(1);
|
||||
if (rest[0] !== "vantage") return "/";
|
||||
if (rest[0] !== "vantage") return "/login";
|
||||
|
||||
const newHost = [slug, ...rest].join(".") + (port ? `:${port}` : "");
|
||||
return `${protocol}//${newHost}/`;
|
||||
return `${protocol}//${newHost}/login`;
|
||||
}
|
||||
|
||||
export default function SetupPage() {
|
||||
@@ -34,6 +39,7 @@ export default function SetupPage() {
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirm, setConfirm] = useState("");
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
const [created, setCreated] = useState<{ slug: string; loginUrl: string } | null>(null);
|
||||
|
||||
// Setup is a one-shot route; once an owner exists it must not be reachable.
|
||||
useEffect(() => {
|
||||
@@ -50,7 +56,7 @@ export default function SetupPage() {
|
||||
const { mutate: bootstrap, isPending, error } = useMutation({
|
||||
mutationFn: () => auth.bootstrap({ org_name: orgName, email, password }),
|
||||
onSuccess: (res) => {
|
||||
window.location.href = orgUrlForSlug(res.slug);
|
||||
setCreated({ slug: res.slug, loginUrl: orgLoginUrlForSlug(res.slug) });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -74,6 +80,37 @@ export default function SetupPage() {
|
||||
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";
|
||||
|
||||
if (created) {
|
||||
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">Organization created</h1>
|
||||
<p className="mt-1 text-sm text-text-secondary">
|
||||
Your owner account is ready. One more step to finish signing in.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<p className="text-sm text-text-secondary">
|
||||
{created.slug} has its own address, and sign-in is kept separate per organization. Continue
|
||||
to your organization's sign-in page and log in with the email and password you just
|
||||
chose.
|
||||
</p>
|
||||
<code className="mt-3 block overflow-x-auto rounded bg-surface-2 px-2 py-1.5 font-mono text-xs text-text-primary">
|
||||
{created.loginUrl}
|
||||
</code>
|
||||
<a href={created.loginUrl} className="mt-5 block">
|
||||
<Button type="button" variant="primary" className="w-full justify-center">
|
||||
Go to sign in
|
||||
</Button>
|
||||
</a>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||
<div className="w-full max-w-md">
|
||||
|
||||
@@ -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>
|
||||
|
||||
+11
-2
@@ -385,8 +385,17 @@ async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => res.statusText);
|
||||
throw new ApiError(res.status, text || `HTTP ${res.status}`);
|
||||
const text = await res.text().catch(() => "");
|
||||
// Handlers report failures as {"error": "..."} — unwrap it so the message
|
||||
// reaching the UI is the sentence the backend wrote, not raw JSON.
|
||||
let message = text || res.statusText || `HTTP ${res.status}`;
|
||||
try {
|
||||
const body = JSON.parse(text);
|
||||
if (body?.error) message = body.error;
|
||||
} catch {
|
||||
// non-JSON body — keep the text as-is
|
||||
}
|
||||
throw new ApiError(res.status, message);
|
||||
}
|
||||
|
||||
if (res.status === 204) {
|
||||
|
||||
Reference in New Issue
Block a user