| 1 |
import { apiClient } from "../lib/api-client"; |
| 2 |
|
| 3 |
/* -------------------------------------------------------------------------- */ |
| 4 |
/* Types */ |
| 5 |
/* -------------------------------------------------------------------------- */ |
| 6 |
|
| 7 |
/** Module-level gate state — drives the upgrade card / module-disabled card. */ |
| 8 |
export interface TeamMeta { |
| 9 |
is_agency_active: boolean; |
| 10 |
is_module_enabled: boolean; |
| 11 |
upgrade_url: string; |
| 12 |
docs_url: string; |
| 13 |
audit_log_enabled: boolean; |
| 14 |
invitation_default_expiry_seconds: number; |
| 15 |
} |
| 16 |
|
| 17 |
/** One capability in the registry. */ |
| 18 |
export interface CapabilityDef { |
| 19 |
category: string; |
| 20 |
sensitivity: "low" | "medium" | "high" | "critical"; |
| 21 |
module: string; |
| 22 |
label: string; |
| 23 |
} |
| 24 |
|
| 25 |
export interface CapabilityRegistryResponse { |
| 26 |
capabilities: Record<string, CapabilityDef>; |
| 27 |
by_category: Record<string, Record<string, CapabilityDef>>; |
| 28 |
version: number; |
| 29 |
} |
| 30 |
|
| 31 |
/** Per-user scope assignment. */ |
| 32 |
export interface UserScopes { |
| 33 |
destinations: number[]; |
| 34 |
activities: number[]; |
| 35 |
trips: number[]; |
| 36 |
categories: number[]; |
| 37 |
} |
| 38 |
|
| 39 |
export interface TeamRole { |
| 40 |
slug: string; |
| 41 |
display_name: string; |
| 42 |
description: string; |
| 43 |
is_system: boolean; |
| 44 |
capabilities: string[]; |
| 45 |
capability_count: number; |
| 46 |
member_count: number; |
| 47 |
} |
| 48 |
|
| 49 |
export interface TeamUser { |
| 50 |
id: number; |
| 51 |
display_name: string; |
| 52 |
user_login: string; |
| 53 |
email: string; |
| 54 |
roles: string[]; |
| 55 |
primary_role: string; |
| 56 |
is_wp_admin: boolean; |
| 57 |
effective_caps: string[]; |
| 58 |
caps_grant: string[]; |
| 59 |
caps_revoke: string[]; |
| 60 |
scopes: UserScopes; |
| 61 |
has_scope: boolean; |
| 62 |
last_login: string; |
| 63 |
/** |
| 64 |
* Time-windowed access. Unix seconds; 0 = permanent (no expiry). |
| 65 |
* When the timestamp is in the past, the server has soft-stripped |
| 66 |
* yatra_* caps in-memory and the hourly ExpiryCron is queued to |
| 67 |
* hard-remove the role + grants on its next run. |
| 68 |
*/ |
| 69 |
expires_at: number; |
| 70 |
is_expired: boolean; |
| 71 |
} |
| 72 |
|
| 73 |
export interface TeamInvitation { |
| 74 |
id: string; |
| 75 |
email: string; |
| 76 |
role_slug: string; |
| 77 |
invited_by: number; |
| 78 |
created_at: number; |
| 79 |
expires_at: number; |
| 80 |
status: "pending" | "accepted" | "revoked" | "expired"; |
| 81 |
accepted_at?: number; |
| 82 |
accepted_user_id?: number; |
| 83 |
} |
| 84 |
|
| 85 |
export interface AuditLogRow { |
| 86 |
id: number; |
| 87 |
occurred_at: string; |
| 88 |
actor_user_id: number | null; |
| 89 |
actor_display_name: string; |
| 90 |
actor_ip: string | null; |
| 91 |
actor_user_agent: string | null; |
| 92 |
action: string; |
| 93 |
capability: string | null; |
| 94 |
entity_type: string | null; |
| 95 |
entity_id: number | null; |
| 96 |
context: Record<string, unknown>; |
| 97 |
result: "allowed" | "denied"; |
| 98 |
} |
| 99 |
|
| 100 |
export interface TeamUserWritePayload { |
| 101 |
role_slug?: string; |
| 102 |
caps_grant?: string[]; |
| 103 |
caps_revoke?: string[]; |
| 104 |
scopes?: Partial<UserScopes>; |
| 105 |
/** |
| 106 |
* Time-windowed access. Accept unix seconds, ISO-8601, or null/0 |
| 107 |
* to clear. Owners cannot set this on themselves — the server |
| 108 |
* rejects with 409. Omit the field to leave expiry unchanged. |
| 109 |
*/ |
| 110 |
expires_at?: number | string | null; |
| 111 |
} |
| 112 |
|
| 113 |
export interface SendInvitationPayload { |
| 114 |
email: string; |
| 115 |
role: string; |
| 116 |
expires_in?: number; |
| 117 |
scopes?: Partial<UserScopes>; |
| 118 |
} |
| 119 |
|
| 120 |
/* -------------------------------------------------------------------------- */ |
| 121 |
/* Client */ |
| 122 |
/* -------------------------------------------------------------------------- */ |
| 123 |
|
| 124 |
export const teamApi = { |
| 125 |
getMeta: () => apiClient.get("/team/meta") as Promise<TeamMeta>, |
| 126 |
|
| 127 |
listCapabilities: () => |
| 128 |
apiClient.get("/team/capabilities") as Promise<CapabilityRegistryResponse>, |
| 129 |
|
| 130 |
listRoles: () => |
| 131 |
apiClient.get("/team/roles") as Promise<{ data: TeamRole[] }>, |
| 132 |
|
| 133 |
createRole: (payload: { |
| 134 |
display_name: string; |
| 135 |
capabilities: string[]; |
| 136 |
slug?: string; |
| 137 |
}) => |
| 138 |
apiClient.post("/team/roles", payload) as Promise<{ |
| 139 |
data: TeamRole; |
| 140 |
message: string; |
| 141 |
}>, |
| 142 |
|
| 143 |
getRole: (slug: string) => |
| 144 |
apiClient.get(`/team/roles/${encodeURIComponent(slug)}`) as Promise<{ |
| 145 |
data: TeamRole; |
| 146 |
}>, |
| 147 |
|
| 148 |
updateRole: ( |
| 149 |
slug: string, |
| 150 |
payload: { display_name?: string; capabilities?: string[] }, |
| 151 |
) => |
| 152 |
apiClient.put( |
| 153 |
`/team/roles/${encodeURIComponent(slug)}`, |
| 154 |
payload, |
| 155 |
) as Promise<{ data: TeamRole; message: string }>, |
| 156 |
|
| 157 |
deleteRole: (slug: string, reassignTo: string | null = null) => |
| 158 |
apiClient.delete( |
| 159 |
`/team/roles/${encodeURIComponent(slug)}`, |
| 160 |
reassignTo ? { data: { reassign_to: reassignTo } } : undefined, |
| 161 |
) as Promise<{ message: string }>, |
| 162 |
|
| 163 |
listUsers: () => |
| 164 |
apiClient.get("/team/users") as Promise<{ data: TeamUser[] }>, |
| 165 |
|
| 166 |
/** WP users not yet on the Yatra team — for the "Add existing user" |
| 167 |
* picker. `q` is an optional type-ahead query (search by login / |
| 168 |
* email / display name). */ |
| 169 |
listAvailableUsers: (q = "", limit = 50) => |
| 170 |
apiClient.get("/team/users/available", { |
| 171 |
params: q ? { q, limit } : { limit }, |
| 172 |
}) as Promise<{ |
| 173 |
data: Array<{ |
| 174 |
id: number; |
| 175 |
display_name: string; |
| 176 |
email: string; |
| 177 |
login: string; |
| 178 |
}>; |
| 179 |
}>, |
| 180 |
|
| 181 |
/** Create a brand-new WP user + assign a Yatra role in one shot. |
| 182 |
* Sister flow to invitations — invitations send an email that |
| 183 |
* creates the user on accept; this one provisions immediately. */ |
| 184 |
createUser: (payload: { |
| 185 |
email: string; |
| 186 |
role_slug: string; |
| 187 |
first_name?: string; |
| 188 |
last_name?: string; |
| 189 |
username?: string; |
| 190 |
password?: string; |
| 191 |
send_reset_email?: boolean; |
| 192 |
scopes?: Partial<UserScopes>; |
| 193 |
}) => |
| 194 |
apiClient.post("/team/users/create", payload) as Promise<{ |
| 195 |
data: TeamUser; |
| 196 |
message: string; |
| 197 |
}>, |
| 198 |
|
| 199 |
getUser: (id: number) => |
| 200 |
apiClient.get(`/team/users/${id}`) as Promise<{ data: TeamUser }>, |
| 201 |
|
| 202 |
updateUser: (id: number, payload: TeamUserWritePayload) => |
| 203 |
apiClient.put(`/team/users/${id}`, payload) as Promise<{ |
| 204 |
data: TeamUser; |
| 205 |
message: string; |
| 206 |
}>, |
| 207 |
|
| 208 |
forceLogout: (id: number) => |
| 209 |
apiClient.post(`/team/users/${id}/force-logout`, {}) as Promise<{ |
| 210 |
message: string; |
| 211 |
}>, |
| 212 |
|
| 213 |
removeUser: (id: number) => |
| 214 |
apiClient.delete(`/team/users/${id}`) as Promise<{ message: string }>, |
| 215 |
|
| 216 |
/** |
| 217 |
* Bulk operations on members. |
| 218 |
* |
| 219 |
* Response shape includes per-id success/failure so the UI can |
| 220 |
* highlight rows that didn't complete (e.g. last-admin guard |
| 221 |
* blocking a remove). `ok_count` is the convenience aggregate. |
| 222 |
*/ |
| 223 |
/** Curated role-creation templates served read-only from the server. */ |
| 224 |
listRoleTemplates: () => |
| 225 |
apiClient.get("/team/role-templates") as Promise<{ |
| 226 |
data: Array<{ |
| 227 |
id: string; |
| 228 |
label: string; |
| 229 |
description: string; |
| 230 |
capabilities: string[]; |
| 231 |
}>; |
| 232 |
}>, |
| 233 |
|
| 234 |
/** |
| 235 |
* Module-level settings. Currently surfaces a single forward-looking |
| 236 |
* toggle: what should happen to non-admin team members' Yatra access |
| 237 |
* if the operator ever turns the Team & Access module off? |
| 238 |
* |
| 239 |
* Returns an object (not a flat boolean) so future settings can be |
| 240 |
* added without rev-bumping the response shape. |
| 241 |
*/ |
| 242 |
getSettings: () => |
| 243 |
apiClient.get("/team/settings") as Promise<{ |
| 244 |
data: { |
| 245 |
keep_access_on_module_disable: boolean; |
| 246 |
/** Comma-separated CIDR list. Empty = no restriction. Empty |
| 247 |
* string is the safe default — operators opt in deliberately |
| 248 |
* because misconfiguration locks staff out. WordPress |
| 249 |
* administrators are always exempt regardless. */ |
| 250 |
login_ip_allowlist: string; |
| 251 |
}; |
| 252 |
}>, |
| 253 |
|
| 254 |
/** Partial update — every key is optional. Backend persists only |
| 255 |
* the keys present and writes an audit row when the diff is |
| 256 |
* meaningful (e.g. login_ip_allowlist normalizes to a different |
| 257 |
* list of CIDRs). */ |
| 258 |
updateSettings: (payload: { |
| 259 |
keep_access_on_module_disable?: boolean; |
| 260 |
login_ip_allowlist?: string; |
| 261 |
}) => |
| 262 |
apiClient.put("/team/settings", payload) as Promise<{ |
| 263 |
data: { |
| 264 |
keep_access_on_module_disable: boolean; |
| 265 |
login_ip_allowlist: string; |
| 266 |
}; |
| 267 |
message: string; |
| 268 |
}>, |
| 269 |
|
| 270 |
bulkUsers: (payload: { |
| 271 |
action: "change_role" | "remove" | "force_logout" | "set_expiry"; |
| 272 |
user_ids: number[]; |
| 273 |
role_slug?: string; |
| 274 |
expires_at?: number | string | null; |
| 275 |
}) => |
| 276 |
apiClient.post("/team/users/bulk", payload) as Promise<{ |
| 277 |
data: { |
| 278 |
results: Array<{ id: number; ok: boolean; error?: string }>; |
| 279 |
ok_count: number; |
| 280 |
fail_count: number; |
| 281 |
}; |
| 282 |
message: string; |
| 283 |
}>, |
| 284 |
|
| 285 |
listInvitations: () => |
| 286 |
apiClient.get("/team/invitations") as Promise<{ |
| 287 |
data: Record<string, TeamInvitation>; |
| 288 |
}>, |
| 289 |
|
| 290 |
sendInvitation: (payload: SendInvitationPayload) => |
| 291 |
apiClient.post("/team/invitations", payload) as Promise<{ |
| 292 |
data: TeamInvitation & { accept_url: string; email_sent: boolean }; |
| 293 |
message: string; |
| 294 |
}>, |
| 295 |
|
| 296 |
/** |
| 297 |
* Revoke or delete an invitation. |
| 298 |
* |
| 299 |
* purge=false (default): the invitation must be `pending`. Marks |
| 300 |
* it `revoked` (the magic-link token is invalidated) and keeps |
| 301 |
* the row so the operator can still see it in the list. |
| 302 |
* purge=true: deletes the row entirely. If the invitation was |
| 303 |
* still pending, it's revoked first so the token can never be |
| 304 |
* redeemed after the record is gone. |
| 305 |
*/ |
| 306 |
revokeInvitation: (id: string, opts: { purge?: boolean } = {}) => |
| 307 |
apiClient.delete( |
| 308 |
`/team/invitations/${encodeURIComponent(id)}${opts.purge ? "?purge=1" : ""}`, |
| 309 |
) as Promise<{ |
| 310 |
data: { |
| 311 |
/** revoked | revoked_and_deleted | deleted */ |
| 312 |
action: "revoked" | "revoked_and_deleted" | "deleted"; |
| 313 |
invitation_id: string; |
| 314 |
previous_status?: string; |
| 315 |
}; |
| 316 |
message: string; |
| 317 |
}>, |
| 318 |
|
| 319 |
acceptInvitation: (token: string) => |
| 320 |
apiClient.post("/team/invitations/accept", { token }) as Promise<{ |
| 321 |
user_id: number; |
| 322 |
role_slug: string; |
| 323 |
message: string; |
| 324 |
}>, |
| 325 |
|
| 326 |
listAuditLog: ( |
| 327 |
filters: { |
| 328 |
page?: number; |
| 329 |
per_page?: number; |
| 330 |
actor_user_id?: number; |
| 331 |
action?: string; |
| 332 |
entity_type?: string; |
| 333 |
entity_id?: number; |
| 334 |
result?: "allowed" | "denied"; |
| 335 |
date_from?: string; |
| 336 |
date_to?: string; |
| 337 |
} = {}, |
| 338 |
) => |
| 339 |
apiClient.get("/team/audit-log", { params: filters }) as Promise<{ |
| 340 |
data: AuditLogRow[]; |
| 341 |
total: number; |
| 342 |
page: number; |
| 343 |
per_page: number; |
| 344 |
}>, |
| 345 |
|
| 346 |
auditFacets: () => |
| 347 |
apiClient.get("/team/audit-log/facets") as Promise<{ |
| 348 |
actions: string[]; |
| 349 |
entity_types: string[]; |
| 350 |
}>, |
| 351 |
|
| 352 |
/** |
| 353 |
* Wipe every audit-log entry. Server requires `confirm: true` in the |
| 354 |
* body as a safety belt against accidental DELETEs. The wipe itself |
| 355 |
* is recorded as a new audit entry so the operator who cleared |
| 356 |
* history is documented. |
| 357 |
*/ |
| 358 |
clearAuditLog: () => |
| 359 |
apiClient.delete("/team/audit-log", { |
| 360 |
data: { confirm: true }, |
| 361 |
}) as Promise<{ |
| 362 |
data: { rows_deleted: number }; |
| 363 |
message: string; |
| 364 |
}>, |
| 365 |
|
| 366 |
/** |
| 367 |
* Delete a specific set of audit-log rows by id. Server caps at 500 |
| 368 |
* ids per request. The deletion is recorded as a new audit entry. |
| 369 |
*/ |
| 370 |
bulkDeleteAuditLog: (ids: number[]) => |
| 371 |
apiClient.post("/team/audit-log/bulk-delete", { ids }) as Promise<{ |
| 372 |
data: { rows_deleted: number; requested: number }; |
| 373 |
message: string; |
| 374 |
}>, |
| 375 |
}; |
| 376 |
|