668 lines
23 KiB
Rust
668 lines
23 KiB
Rust
//! HTTP client wrapping the `/api/v1` surface.
|
|
//!
|
|
//! Authentication:
|
|
//! 1. Browser cookies (`fetch_credentials_include`) for the HttpOnly session cookie
|
|
//! 2. `Authorization: Bearer <session_token>` from sessionStorage (login body fallback)
|
|
//!
|
|
//! Frontend permission checks are presentation-only — the backend is authoritative.
|
|
//!
|
|
//! Note: reqwest on WASM requires **absolute** URLs.
|
|
|
|
use crate::models::*;
|
|
use crate::services::session;
|
|
use reqwest::{Client, StatusCode};
|
|
use serde::Serialize;
|
|
use serde_json::Value;
|
|
|
|
/// API base path (same-origin).
|
|
const API_PREFIX: &str = "/api/v1";
|
|
|
|
/// Client-side API error.
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub enum ApiError {
|
|
Unauthorized,
|
|
Forbidden,
|
|
NotFound,
|
|
InvalidInput(String),
|
|
Network(String),
|
|
Server(String),
|
|
Other(String),
|
|
}
|
|
|
|
impl std::fmt::Display for ApiError {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
Self::Unauthorized => write!(f, "Invalid username or password."),
|
|
Self::Forbidden => write!(f, "You do not have permission to do that"),
|
|
Self::NotFound => write!(f, "Resource not found"),
|
|
Self::InvalidInput(m) => write!(f, "{m}"),
|
|
Self::Network(m) => write!(f, "Network error: {m}"),
|
|
Self::Server(m) => write!(f, "{m}"),
|
|
Self::Other(m) => write!(f, "{m}"),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Browser origin, e.g. `http://127.0.0.1:8655`.
|
|
fn origin() -> String {
|
|
web_sys::window()
|
|
.and_then(|w| w.location().origin().ok())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
/// Build an absolute API URL (required by reqwest-wasm).
|
|
fn api_url(path: &str) -> String {
|
|
format!("{}{}{}", origin(), API_PREFIX, path)
|
|
}
|
|
|
|
fn client() -> Client {
|
|
Client::new()
|
|
}
|
|
|
|
pub trait RequestBuilderExtHelper {
|
|
fn with_credentials_include(self) -> Self;
|
|
}
|
|
|
|
impl RequestBuilderExtHelper for reqwest::RequestBuilder {
|
|
#[cfg(target_arch = "wasm32")]
|
|
fn with_credentials_include(self) -> Self {
|
|
self.fetch_credentials_include()
|
|
}
|
|
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
fn with_credentials_include(self) -> Self {
|
|
self
|
|
}
|
|
}
|
|
|
|
/// Attach credentials + optional bearer session token.
|
|
fn authorize(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
|
let builder = builder.with_credentials_include();
|
|
if let Some(token) = session::load_access_token() {
|
|
builder.header("Authorization", format!("Bearer {token}"))
|
|
} else {
|
|
builder
|
|
}
|
|
}
|
|
|
|
async fn handle<T: serde::de::DeserializeOwned>(resp: reqwest::Response) -> Result<T, ApiError> {
|
|
let status = resp.status();
|
|
if status == StatusCode::UNAUTHORIZED {
|
|
// Stale client token — drop it so the next login is clean.
|
|
session::clear();
|
|
return Err(ApiError::Unauthorized);
|
|
}
|
|
if status == StatusCode::FORBIDDEN {
|
|
return Err(ApiError::Forbidden);
|
|
}
|
|
if status == StatusCode::NOT_FOUND {
|
|
return Err(ApiError::NotFound);
|
|
}
|
|
|
|
let text = resp
|
|
.text()
|
|
.await
|
|
.map_err(|e| ApiError::Network(e.to_string()))?;
|
|
|
|
if !status.is_success() {
|
|
if let Ok(body) = serde_json::from_str::<ApiErrorBody>(&text) {
|
|
if status == StatusCode::UNPROCESSABLE_ENTITY {
|
|
return Err(ApiError::InvalidInput(body.error));
|
|
}
|
|
return Err(ApiError::Server(body.error));
|
|
}
|
|
return Err(ApiError::Server(format!("HTTP {status}: {text}")));
|
|
}
|
|
|
|
serde_json::from_str(&text).map_err(|e| ApiError::Other(format!("decode error: {e}: {text}")))
|
|
}
|
|
|
|
async fn get<T: serde::de::DeserializeOwned>(path: &str) -> Result<T, ApiError> {
|
|
let url = api_url(path);
|
|
let resp = authorize(client().get(&url))
|
|
.send()
|
|
.await
|
|
.map_err(|e| ApiError::Network(format!("{e} ({url})")))?;
|
|
handle(resp).await
|
|
}
|
|
|
|
async fn post_json<B: Serialize, T: serde::de::DeserializeOwned>(
|
|
path: &str,
|
|
body: &B,
|
|
) -> Result<T, ApiError> {
|
|
let url = api_url(path);
|
|
let resp = authorize(client().post(&url).json(body))
|
|
.send()
|
|
.await
|
|
.map_err(|e| ApiError::Network(format!("{e} ({url})")))?;
|
|
handle(resp).await
|
|
}
|
|
|
|
async fn patch_json<B: Serialize, T: serde::de::DeserializeOwned>(
|
|
path: &str,
|
|
body: &B,
|
|
) -> Result<T, ApiError> {
|
|
let url = api_url(path);
|
|
let resp = authorize(client().patch(&url).json(body))
|
|
.send()
|
|
.await
|
|
.map_err(|e| ApiError::Network(format!("{e} ({url})")))?;
|
|
handle(resp).await
|
|
}
|
|
|
|
async fn put_json<B: Serialize, T: serde::de::DeserializeOwned>(
|
|
path: &str,
|
|
body: &B,
|
|
) -> Result<T, ApiError> {
|
|
let url = api_url(path);
|
|
let resp = authorize(client().put(&url).json(body))
|
|
.send()
|
|
.await
|
|
.map_err(|e| ApiError::Network(format!("{e} ({url})")))?;
|
|
handle(resp).await
|
|
}
|
|
|
|
async fn delete_json<T: serde::de::DeserializeOwned>(path: &str) -> Result<T, ApiError> {
|
|
let url = api_url(path);
|
|
let resp = authorize(client().delete(&url))
|
|
.send()
|
|
.await
|
|
.map_err(|e| ApiError::Network(format!("{e} ({url})")))?;
|
|
handle(resp).await
|
|
}
|
|
|
|
// ── Auth ──────────────────────────────────────────────────────────────────────
|
|
|
|
/// Secure login response (POST JSON only — never query parameters).
|
|
#[derive(Debug, Clone, serde::Deserialize)]
|
|
pub struct LoginResponse {
|
|
pub access_token: String,
|
|
#[serde(default)]
|
|
pub refresh_token: Option<String>,
|
|
#[serde(default)]
|
|
pub user: Option<serde_json::Value>,
|
|
}
|
|
|
|
/// POST /api/v1/auth/login with JSON body.
|
|
///
|
|
/// Credentials are never placed in the URL, query string, or fragment.
|
|
pub async fn login(username: &str, password: &str) -> Result<LoginResponse, ApiError> {
|
|
// Do not send a stale Authorization header on login.
|
|
session::clear();
|
|
let body = serde_json::json!({
|
|
"username": username,
|
|
"password": password,
|
|
});
|
|
let url = api_url("/auth/login");
|
|
let resp = client()
|
|
.post(&url)
|
|
.with_credentials_include()
|
|
.header("Accept", "application/json")
|
|
.header("Content-Type", "application/json")
|
|
.json(&body)
|
|
.send()
|
|
.await
|
|
.map_err(|e| ApiError::Network(format!("{e} ({url})")))?;
|
|
let parsed: LoginResponse = handle(resp).await?;
|
|
if !parsed.access_token.is_empty() {
|
|
session::save_access_token(&parsed.access_token);
|
|
}
|
|
if let Some(ref rt) = parsed.refresh_token {
|
|
if !rt.is_empty() {
|
|
session::save_refresh_token(rt);
|
|
}
|
|
}
|
|
Ok(parsed)
|
|
}
|
|
|
|
pub async fn logout() -> Result<(), ApiError> {
|
|
let result = post_json::<_, Value>("/auth/logout", &serde_json::json!({})).await;
|
|
session::clear();
|
|
result.map(|_| ())
|
|
}
|
|
|
|
pub async fn me() -> Result<Option<MeResponse>, ApiError> {
|
|
let url = api_url("/auth/me");
|
|
let resp = authorize(client().get(&url))
|
|
.send()
|
|
.await
|
|
.map_err(|e| ApiError::Network(format!("{e} ({url})")))?;
|
|
|
|
if resp.status() == StatusCode::UNAUTHORIZED {
|
|
session::clear();
|
|
return Ok(None);
|
|
}
|
|
|
|
let body: MeResponse = handle(resp).await?;
|
|
Ok(Some(body))
|
|
}
|
|
|
|
// ── Dashboard / Profile ───────────────────────────────────────────────────────
|
|
|
|
pub async fn dashboard() -> Result<DashboardResponse, ApiError> {
|
|
get("/dashboard").await
|
|
}
|
|
|
|
pub async fn get_profile() -> Result<ProfileResponse, ApiError> {
|
|
get("/profile").await
|
|
}
|
|
|
|
pub async fn list_tenants() -> Result<Vec<TenantView>, ApiError> {
|
|
let r: TenantsResponse = get("/tenants").await?;
|
|
Ok(r.tenants)
|
|
}
|
|
|
|
pub async fn update_profile(
|
|
email: Option<&str>,
|
|
full_name: Option<&str>,
|
|
) -> Result<Value, ApiError> {
|
|
let body = serde_json::json!({ "email": email, "full_name": full_name });
|
|
patch_json("/profile", &body).await
|
|
}
|
|
|
|
pub async fn change_password(current: &str, new_password: &str) -> Result<(), ApiError> {
|
|
let body = serde_json::json!({
|
|
"current_password": current,
|
|
"new_password": new_password,
|
|
});
|
|
let _: Value = post_json("/profile/password", &body).await?;
|
|
Ok(())
|
|
}
|
|
|
|
// ── Users ─────────────────────────────────────────────────────────────────────
|
|
|
|
pub async fn list_users() -> Result<Vec<UserView>, ApiError> {
|
|
let r: UsersResponse = get("/users").await?;
|
|
Ok(r.users)
|
|
}
|
|
|
|
pub async fn get_user(id: &str) -> Result<UserView, ApiError> {
|
|
let r: Value = get(&format!("/users/{id}")).await?;
|
|
serde_json::from_value(r.get("user").cloned().unwrap_or(Value::Null))
|
|
.map_err(|e| ApiError::Other(e.to_string()))
|
|
}
|
|
|
|
pub async fn create_user(username: &str, password: &str) -> Result<UserView, ApiError> {
|
|
let body = serde_json::json!({ "username": username, "password": password });
|
|
let r: Value = post_json("/users", &body).await?;
|
|
serde_json::from_value(r.get("user").cloned().unwrap_or(Value::Null))
|
|
.map_err(|e| ApiError::Other(e.to_string()))
|
|
}
|
|
|
|
pub async fn update_user_status(id: &str, status: &str) -> Result<UserView, ApiError> {
|
|
let body = serde_json::json!({ "status": status });
|
|
let r: Value = patch_json(&format!("/users/{id}"), &body).await?;
|
|
serde_json::from_value(r.get("user").cloned().unwrap_or(Value::Null))
|
|
.map_err(|e| ApiError::Other(e.to_string()))
|
|
}
|
|
|
|
pub async fn delete_user(id: &str) -> Result<(), ApiError> {
|
|
let _: Value = delete_json(&format!("/users/{id}")).await?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn reset_user_password(id: &str, password: &str) -> Result<(), ApiError> {
|
|
let body = serde_json::json!({ "password": password });
|
|
let _: Value = post_json(&format!("/users/{id}/reset-password"), &body).await?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn list_user_roles(id: &str) -> Result<Vec<RoleView>, ApiError> {
|
|
let r: Value = get(&format!("/users/{id}/roles")).await?;
|
|
serde_json::from_value(r.get("roles").cloned().unwrap_or(Value::Array(vec![])))
|
|
.map_err(|e| ApiError::Other(e.to_string()))
|
|
}
|
|
|
|
pub async fn assign_user_role(user_id: &str, role: &str) -> Result<(), ApiError> {
|
|
let body = serde_json::json!({ "role": role });
|
|
let _: Value = post_json(&format!("/users/{user_id}/roles"), &body).await?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn remove_user_role(user_id: &str, role: &str) -> Result<(), ApiError> {
|
|
let _: Value = delete_json(&format!("/users/{user_id}/roles/{role}")).await?;
|
|
Ok(())
|
|
}
|
|
|
|
// ── Roles / Permissions ───────────────────────────────────────────────────────
|
|
|
|
pub async fn list_roles() -> Result<Vec<RoleView>, ApiError> {
|
|
let r: RolesResponse = get("/roles").await?;
|
|
Ok(r.roles)
|
|
}
|
|
|
|
pub async fn create_role(name: &str, description: Option<&str>) -> Result<RoleView, ApiError> {
|
|
let body = serde_json::json!({ "name": name, "description": description });
|
|
let r: Value = post_json("/roles", &body).await?;
|
|
serde_json::from_value(r.get("role").cloned().unwrap_or(Value::Null))
|
|
.map_err(|e| ApiError::Other(e.to_string()))
|
|
}
|
|
|
|
pub async fn update_role(
|
|
id: &str,
|
|
name: &str,
|
|
description: Option<&str>,
|
|
) -> Result<RoleView, ApiError> {
|
|
let body = serde_json::json!({ "name": name, "description": description });
|
|
let r: Value = patch_json(&format!("/roles/{id}"), &body).await?;
|
|
serde_json::from_value(r.get("role").cloned().unwrap_or(Value::Null))
|
|
.map_err(|e| ApiError::Other(e.to_string()))
|
|
}
|
|
|
|
pub async fn delete_role(id: &str) -> Result<(), ApiError> {
|
|
let _: Value = delete_json(&format!("/roles/{id}")).await?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn set_role_permissions(id: &str, permissions: &[String]) -> Result<(), ApiError> {
|
|
let body = serde_json::json!({ "permissions": permissions });
|
|
let _: Value = put_json(&format!("/roles/{id}/permissions"), &body).await?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn list_permissions() -> Result<PermissionsResponse, ApiError> {
|
|
get("/permissions").await
|
|
}
|
|
|
|
// ── Tokens ────────────────────────────────────────────────────────────────────
|
|
|
|
pub async fn list_tokens() -> Result<Vec<TokenView>, ApiError> {
|
|
let r: TokensResponse = get("/tokens").await?;
|
|
Ok(r.tokens)
|
|
}
|
|
|
|
pub async fn create_token(name: &str) -> Result<CreateTokenResponse, ApiError> {
|
|
let body = serde_json::json!({ "name": name });
|
|
post_json("/tokens", &body).await
|
|
}
|
|
|
|
pub async fn revoke_token(id: &str) -> Result<(), ApiError> {
|
|
let _: Value = delete_json(&format!("/tokens/{id}")).await?;
|
|
Ok(())
|
|
}
|
|
|
|
// ── Applications ──────────────────────────────────────────────────────────────
|
|
|
|
pub async fn list_applications() -> Result<Vec<ApplicationView>, ApiError> {
|
|
let r: ApplicationsResponse = get("/applications").await?;
|
|
Ok(r.applications)
|
|
}
|
|
|
|
pub async fn create_application(
|
|
name: &str,
|
|
slug: &str,
|
|
description: Option<&str>,
|
|
redirect_urls: Option<Vec<String>>,
|
|
scopes: Option<Vec<String>>,
|
|
) -> Result<CreateApplicationResponse, ApiError> {
|
|
let body = serde_json::json!({
|
|
"name": name,
|
|
"slug": slug,
|
|
"description": description,
|
|
"redirect_urls": redirect_urls,
|
|
"scopes": scopes,
|
|
});
|
|
post_json("/applications", &body).await
|
|
}
|
|
|
|
pub async fn update_application(
|
|
id: &str,
|
|
name: &str,
|
|
slug: &str,
|
|
description: Option<&str>,
|
|
redirect_urls: Option<Vec<String>>,
|
|
scopes: Option<Vec<String>>,
|
|
enabled: bool,
|
|
) -> Result<ApplicationView, ApiError> {
|
|
let body = serde_json::json!({
|
|
"name": name,
|
|
"slug": slug,
|
|
"description": description,
|
|
"redirect_urls": redirect_urls,
|
|
"scopes": scopes,
|
|
"enabled": enabled,
|
|
});
|
|
let r: Value = patch_json(&format!("/applications/{id}"), &body).await?;
|
|
serde_json::from_value(r.get("application").cloned().unwrap_or(Value::Null))
|
|
.map_err(|e| ApiError::Other(e.to_string()))
|
|
}
|
|
|
|
pub async fn rotate_application_secret(id: &str) -> Result<String, ApiError> {
|
|
let r: RotateSecretResponse = post_json(
|
|
&format!("/applications/{id}/secret"),
|
|
&serde_json::json!({}),
|
|
)
|
|
.await?;
|
|
Ok(r.client_secret)
|
|
}
|
|
|
|
pub async fn delete_application(id: &str) -> Result<(), ApiError> {
|
|
let _: Value = delete_json(&format!("/applications/{id}")).await?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn get_application(id: &str) -> Result<ApplicationView, ApiError> {
|
|
let r: Value = get(&format!("/applications/{id}")).await?;
|
|
serde_json::from_value(r.get("application").cloned().unwrap_or(Value::Null))
|
|
.map_err(|e| ApiError::Other(e.to_string()))
|
|
}
|
|
|
|
pub async fn list_application_members(
|
|
app_id: &str,
|
|
) -> Result<Vec<ApplicationMemberView>, ApiError> {
|
|
let r: ApplicationMembersResponse = get(&format!("/applications/{app_id}/members")).await?;
|
|
Ok(r.members)
|
|
}
|
|
|
|
pub async fn add_application_member(
|
|
app_id: &str,
|
|
user_id: &str,
|
|
role: Option<&str>,
|
|
) -> Result<ApplicationMemberView, ApiError> {
|
|
let body = serde_json::json!({
|
|
"user_id": user_id,
|
|
"role": role,
|
|
});
|
|
let r: Value = post_json(&format!("/applications/{app_id}/members"), &body).await?;
|
|
serde_json::from_value(r.get("member").cloned().unwrap_or(Value::Null))
|
|
.map_err(|e| ApiError::Other(e.to_string()))
|
|
}
|
|
|
|
pub async fn update_application_member(
|
|
app_id: &str,
|
|
user_id: &str,
|
|
role: Option<&str>,
|
|
enabled: Option<bool>,
|
|
) -> Result<ApplicationMemberView, ApiError> {
|
|
let body = serde_json::json!({
|
|
"role": role,
|
|
"enabled": enabled,
|
|
});
|
|
let r: Value = patch_json(&format!("/applications/{app_id}/members/{user_id}"), &body).await?;
|
|
serde_json::from_value(r.get("member").cloned().unwrap_or(Value::Null))
|
|
.map_err(|e| ApiError::Other(e.to_string()))
|
|
}
|
|
|
|
pub async fn remove_application_member(app_id: &str, user_id: &str) -> Result<(), ApiError> {
|
|
let _: Value = delete_json(&format!("/applications/{app_id}/members/{user_id}")).await?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn list_user_applications(
|
|
user_id: &str,
|
|
) -> Result<Vec<UserApplicationMembershipView>, ApiError> {
|
|
let r: UserApplicationsResponse = get(&format!("/users/{user_id}/applications")).await?;
|
|
Ok(r.applications)
|
|
}
|
|
|
|
// ── Service accounts ──────────────────────────────────────────────────────────
|
|
|
|
pub async fn list_service_accounts() -> Result<Vec<ServiceAccountView>, ApiError> {
|
|
let r: ServiceAccountsResponse = get("/service-accounts").await?;
|
|
Ok(r.service_accounts)
|
|
}
|
|
|
|
pub async fn create_service_account(
|
|
name: &str,
|
|
description: Option<&str>,
|
|
) -> Result<ServiceAccountView, ApiError> {
|
|
let body = serde_json::json!({ "name": name, "description": description });
|
|
let r: Value = post_json("/service-accounts", &body).await?;
|
|
serde_json::from_value(r.get("service_account").cloned().unwrap_or(Value::Null))
|
|
.map_err(|e| ApiError::Other(e.to_string()))
|
|
}
|
|
|
|
pub async fn set_service_account_enabled(id: &str, enabled: bool) -> Result<(), ApiError> {
|
|
let body = serde_json::json!({ "enabled": enabled });
|
|
let _: Value = patch_json(&format!("/service-accounts/{id}"), &body).await?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn delete_service_account(id: &str) -> Result<(), ApiError> {
|
|
let _: Value = delete_json(&format!("/service-accounts/{id}")).await?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn rotate_service_account_secret(id: &str) -> Result<String, ApiError> {
|
|
let r: Value = post_json(
|
|
&format!("/service-accounts/{id}/secret"),
|
|
&serde_json::json!({}),
|
|
)
|
|
.await?;
|
|
Ok(r.get("raw_secret")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or("")
|
|
.to_string())
|
|
}
|
|
|
|
// ── Audit ─────────────────────────────────────────────────────────────────────
|
|
|
|
pub async fn list_audit(query: &str) -> Result<AuditResponse, ApiError> {
|
|
let path = if query.is_empty() {
|
|
"/audit".to_string()
|
|
} else {
|
|
format!("/audit?{query}")
|
|
};
|
|
get(&path).await
|
|
}
|
|
|
|
// ── Sessions ──────────────────────────────────────────────────────────────────
|
|
|
|
pub async fn list_sessions() -> Result<SessionsResponse, ApiError> {
|
|
get("/sessions").await
|
|
}
|
|
|
|
pub async fn terminate_session(id: &str) -> Result<(), ApiError> {
|
|
let _: Value = delete_json(&format!("/sessions/{id}")).await?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn terminate_other_sessions() -> Result<(), ApiError> {
|
|
let _: Value = delete_json("/sessions/others").await?;
|
|
Ok(())
|
|
}
|
|
|
|
// ── Groups ────────────────────────────────────────────────────────────────────
|
|
|
|
pub async fn list_groups() -> Result<Vec<GroupView>, ApiError> {
|
|
let r: GroupsResponse = get("/groups").await?;
|
|
Ok(r.groups)
|
|
}
|
|
|
|
pub async fn create_group(name: &str, description: Option<&str>) -> Result<GroupView, ApiError> {
|
|
let body = serde_json::json!({ "name": name, "description": description });
|
|
let r: Value = post_json("/groups", &body).await?;
|
|
serde_json::from_value(r.get("group").cloned().unwrap_or(Value::Null))
|
|
.map_err(|e| ApiError::Other(e.to_string()))
|
|
}
|
|
|
|
pub async fn get_group(id: &str) -> Result<GroupDetailResponse, ApiError> {
|
|
get(&format!("/groups/{id}")).await
|
|
}
|
|
|
|
pub async fn update_group(
|
|
id: &str,
|
|
name: &str,
|
|
description: Option<&str>,
|
|
) -> Result<GroupView, ApiError> {
|
|
let body = serde_json::json!({ "name": name, "description": description });
|
|
let r: Value = patch_json(&format!("/groups/{id}"), &body).await?;
|
|
serde_json::from_value(r.get("group").cloned().unwrap_or(Value::Null))
|
|
.map_err(|e| ApiError::Other(e.to_string()))
|
|
}
|
|
|
|
pub async fn delete_group(id: &str) -> Result<(), ApiError> {
|
|
let _: Value = delete_json(&format!("/groups/{id}")).await?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn add_group_member(group_id: &str, user_id: &str) -> Result<(), ApiError> {
|
|
let body = serde_json::json!({ "user_id": user_id });
|
|
let _: Value = post_json(&format!("/groups/{group_id}/members"), &body).await?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn remove_group_member(group_id: &str, user_id: &str) -> Result<(), ApiError> {
|
|
let _: Value = delete_json(&format!("/groups/{group_id}/members/{user_id}")).await?;
|
|
Ok(())
|
|
}
|
|
|
|
// ── Tenants (complete) ────────────────────────────────────────────────────────
|
|
|
|
pub async fn create_tenant(name: &str, slug: Option<&str>) -> Result<TenantView, ApiError> {
|
|
let body = serde_json::json!({ "name": name, "slug": slug });
|
|
let r: Value = post_json("/tenants", &body).await?;
|
|
serde_json::from_value(r.get("tenant").cloned().unwrap_or(Value::Null))
|
|
.map_err(|e| ApiError::Other(e.to_string()))
|
|
}
|
|
|
|
pub async fn update_tenant(
|
|
id: &str,
|
|
name: &str,
|
|
slug: Option<&str>,
|
|
) -> Result<TenantView, ApiError> {
|
|
let body = serde_json::json!({ "name": name, "slug": slug });
|
|
let r: Value = patch_json(&format!("/tenants/{id}"), &body).await?;
|
|
serde_json::from_value(r.get("tenant").cloned().unwrap_or(Value::Null))
|
|
.map_err(|e| ApiError::Other(e.to_string()))
|
|
}
|
|
|
|
pub async fn get_tenant(id: &str) -> Result<TenantView, ApiError> {
|
|
let r: Value = get(&format!("/tenants/{id}")).await?;
|
|
serde_json::from_value(r.get("tenant").cloned().unwrap_or(Value::Null))
|
|
.map_err(|e| ApiError::Other(e.to_string()))
|
|
}
|
|
|
|
pub async fn delete_tenant(id: &str) -> Result<(), ApiError> {
|
|
let _: Value = delete_json(&format!("/tenants/{id}")).await?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn list_tenant_users(tenant_id: &str) -> Result<Vec<UserView>, ApiError> {
|
|
let r: Value = get(&format!("/tenants/{tenant_id}/users")).await?;
|
|
serde_json::from_value(r.get("users").cloned().unwrap_or(Value::Array(vec![])))
|
|
.map_err(|e| ApiError::Other(e.to_string()))
|
|
}
|
|
|
|
pub async fn assign_tenant_user(tenant_id: &str, user_id: &str) -> Result<UserView, ApiError> {
|
|
let body = serde_json::json!({ "user_id": user_id });
|
|
let r: Value = post_json(&format!("/tenants/{tenant_id}/users"), &body).await?;
|
|
serde_json::from_value(r.get("user").cloned().unwrap_or(Value::Null))
|
|
.map_err(|e| ApiError::Other(e.to_string()))
|
|
}
|
|
|
|
pub async fn remove_tenant_user(tenant_id: &str, user_id: &str) -> Result<(), ApiError> {
|
|
let _: Value = delete_json(&format!("/tenants/{tenant_id}/users/{user_id}")).await?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn list_tenant_applications(tenant_id: &str) -> Result<Vec<ApplicationView>, ApiError> {
|
|
let r: Value = get(&format!("/tenants/{tenant_id}/applications")).await?;
|
|
serde_json::from_value(
|
|
r.get("applications")
|
|
.cloned()
|
|
.unwrap_or(Value::Array(vec![])),
|
|
)
|
|
.map_err(|e| ApiError::Other(e.to_string()))
|
|
}
|