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:
+160
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user