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
+160
View File
@@ -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");