feat(v1.1.8): users::* SDK module + register hook
Rhai bridge for the v1.1.8 users::* namespace, wired through
sdk::register_all. Collection-less surface (mirrors email::/secrets::,
not kv::'s handle pattern) — app_id never appears on the script-side
signature; the service derives it from cx.app_id.
Eighteen functions bound:
* CRUD: users::create / get / find_by_email / update / delete / list
* Auth: users::login (returns session token string or ()),
users::verify (returns user map or ()),
users::logout
* Email-tied: users::send_verification_email / verify_email /
request_password_reset / complete_password_reset /
invite / accept_invite (two arities: with/without
display_name override)
* Roles: users::add_role / remove_role / has_role
Bindings for methods whose service impl isn't in place yet (email
flows, roles, invitations) still route to the trait — the service
returns UsersError::Backend("not yet implemented") which surfaces
as a Rhai runtime error. Later v1.1.8 commits replace the service
stubs without re-touching the bridge.
User map shape: id, email, display_name, email_verified_at,
last_login_at, created_at (rfc3339), updated_at (rfc3339), roles (Vec).
Never password_hash. list returns #{ users: [...], next_cursor }
where next_cursor is the rfc3339 timestamp of the last row.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -22,6 +22,7 @@ pub mod kv;
|
||||
pub mod pubsub;
|
||||
pub mod secrets;
|
||||
pub mod stdlib;
|
||||
pub mod users;
|
||||
|
||||
pub use bridge::{dynamic_to_json, json_to_dynamic};
|
||||
pub use cx::SdkCallCx;
|
||||
@@ -45,5 +46,6 @@ pub fn register_all(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCal
|
||||
files::register(engine, services, cx.clone());
|
||||
pubsub::register(engine, services, cx.clone());
|
||||
secrets::register(engine, services, cx.clone());
|
||||
email::register(engine, services, cx);
|
||||
email::register(engine, services, cx.clone());
|
||||
users::register(engine, services, cx);
|
||||
}
|
||||
|
||||
620
crates/executor-core/src/sdk/users.rs
Normal file
620
crates/executor-core/src/sdk/users.rs
Normal file
@@ -0,0 +1,620 @@
|
||||
//! `users::` Rhai bridge — data-plane user management (v1.1.8).
|
||||
//!
|
||||
//! ```rhai
|
||||
//! // CRUD
|
||||
//! let u = users::create(#{ email: "a@b", password: "hunter22a", display_name: "Alice" });
|
||||
//! let u = users::get(id); // map or ()
|
||||
//! let u = users::find_by_email("a@b"); // map or ()
|
||||
//! users::update(id, #{ display_name: "Alicia" });
|
||||
//! let removed = users::delete(id); // bool
|
||||
//! let page = users::list(#{ "$limit": 50, cursor: () });
|
||||
//!
|
||||
//! // Auth
|
||||
//! let tok = users::login("a@b", "hunter22a"); // session-token string or ()
|
||||
//! let u = users::verify(tok); // map or () (sliding-TTL bump)
|
||||
//! users::logout(tok);
|
||||
//!
|
||||
//! // Email-tied (commit 5 / 6 / 7)
|
||||
//! users::send_verification_email(id,
|
||||
//! #{ link_base: "https://app/verify", subject: "Confirm", body_template: "..." });
|
||||
//! let u = users::verify_email(token);
|
||||
//! users::request_password_reset("a@b", #{...});
|
||||
//! let u = users::complete_password_reset(token, "new_pw");
|
||||
//! users::invite("a@b",
|
||||
//! #{ link_base: "https://app/invite", subject: "Join", body_template: "...",
|
||||
//! display_name: "Bob", roles: ["editor"] });
|
||||
//! let session_tok = users::accept_invite(token, "pw", "Bob");
|
||||
//!
|
||||
//! // Roles (commit 9)
|
||||
//! users::add_role(id, "admin");
|
||||
//! let removed = users::remove_role(id, "admin"); // bool
|
||||
//! let yes = users::has_role(id, "admin"); // bool
|
||||
//! ```
|
||||
//!
|
||||
//! Collection-less surface (mirrors `email::` / `secrets::` rather
|
||||
//! than `kv::`/`docs::`/`files::`'s handle pattern). `app_id` is
|
||||
//! derived from `cx.app_id` in the service — it never appears on the
|
||||
//! script-side signature, preserving cross-app isolation.
|
||||
//!
|
||||
//! Methods bound but not yet implemented in the underlying service
|
||||
//! return a `users::not_implemented` runtime error. Subsequent v1.1.8
|
||||
//! commits flesh them out without re-touching this file.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use picloud_shared::{
|
||||
AppUser, AppUserId, CreateUserInput, EmailTemplateOpts, GeneratedAccept, GeneratedSession,
|
||||
InviteOpts, SdkCallCx, Services, UpdateUserInput, UsersError, UsersListOpts, UsersListPage,
|
||||
UsersService,
|
||||
};
|
||||
use rhai::{Array, Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module};
|
||||
use tokio::runtime::Handle as TokioHandle;
|
||||
|
||||
pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
|
||||
let svc = services.users.clone();
|
||||
let mut module = Module::new();
|
||||
|
||||
bind_create(&mut module, &svc, &cx);
|
||||
bind_get(&mut module, &svc, &cx);
|
||||
bind_find_by_email(&mut module, &svc, &cx);
|
||||
bind_update(&mut module, &svc, &cx);
|
||||
bind_delete(&mut module, &svc, &cx);
|
||||
bind_list(&mut module, &svc, &cx);
|
||||
bind_login(&mut module, &svc, &cx);
|
||||
bind_verify(&mut module, &svc, &cx);
|
||||
bind_logout(&mut module, &svc, &cx);
|
||||
bind_send_verification_email(&mut module, &svc, &cx);
|
||||
bind_verify_email(&mut module, &svc, &cx);
|
||||
bind_request_password_reset(&mut module, &svc, &cx);
|
||||
bind_complete_password_reset(&mut module, &svc, &cx);
|
||||
bind_invite(&mut module, &svc, &cx);
|
||||
bind_accept_invite(&mut module, &svc, &cx);
|
||||
bind_add_role(&mut module, &svc, &cx);
|
||||
bind_remove_role(&mut module, &svc, &cx);
|
||||
bind_has_role(&mut module, &svc, &cx);
|
||||
|
||||
engine.register_static_module("users", module.into());
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// CRUD
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
fn bind_create(module: &mut Module, svc: &Arc<dyn UsersService>, cx: &Arc<SdkCallCx>) {
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
module.set_native_fn(
|
||||
"create",
|
||||
move |opts: Map| -> Result<Map, Box<EvalAltResult>> {
|
||||
let email = required_string(&opts, "email", "users::create")?;
|
||||
let password = required_string(&opts, "password", "users::create")?;
|
||||
let display_name = optional_string(&opts, "display_name");
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
let user = block_on(async move {
|
||||
svc.create(
|
||||
&cx,
|
||||
CreateUserInput {
|
||||
email,
|
||||
password,
|
||||
display_name,
|
||||
},
|
||||
)
|
||||
.await
|
||||
})?;
|
||||
Ok(user_to_map(&user))
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn bind_get(module: &mut Module, svc: &Arc<dyn UsersService>, cx: &Arc<SdkCallCx>) {
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
module.set_native_fn(
|
||||
"get",
|
||||
move |id: &str| -> Result<Dynamic, Box<EvalAltResult>> {
|
||||
let id = parse_user_id(id, "users::get")?;
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
let user_opt = block_on(async move { svc.get(&cx, id).await })?;
|
||||
Ok(user_opt
|
||||
.map(|u| Dynamic::from(user_to_map(&u)))
|
||||
.unwrap_or(Dynamic::UNIT))
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn bind_find_by_email(module: &mut Module, svc: &Arc<dyn UsersService>, cx: &Arc<SdkCallCx>) {
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
module.set_native_fn(
|
||||
"find_by_email",
|
||||
move |email: &str| -> Result<Dynamic, Box<EvalAltResult>> {
|
||||
let email = email.to_string();
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
let user_opt = block_on(async move { svc.find_by_email(&cx, &email).await })?;
|
||||
Ok(user_opt
|
||||
.map(|u| Dynamic::from(user_to_map(&u)))
|
||||
.unwrap_or(Dynamic::UNIT))
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn bind_update(module: &mut Module, svc: &Arc<dyn UsersService>, cx: &Arc<SdkCallCx>) {
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
module.set_native_fn(
|
||||
"update",
|
||||
move |id: &str, patch: Map| -> Result<Map, Box<EvalAltResult>> {
|
||||
let id = parse_user_id(id, "users::update")?;
|
||||
// display_name as Some(None) means "clear"; absent means
|
||||
// "leave alone". Differentiation: present-with-() vs absent.
|
||||
let display_name = if patch.contains_key("display_name") {
|
||||
Some(optional_string(&patch, "display_name"))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let patch = UpdateUserInput { display_name };
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
let user = block_on(async move { svc.update(&cx, id, patch).await })?;
|
||||
Ok(user_to_map(&user))
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn bind_delete(module: &mut Module, svc: &Arc<dyn UsersService>, cx: &Arc<SdkCallCx>) {
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
module.set_native_fn(
|
||||
"delete",
|
||||
move |id: &str| -> Result<bool, Box<EvalAltResult>> {
|
||||
let id = parse_user_id(id, "users::delete")?;
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
block_on(async move { svc.delete(&cx, id).await })
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn bind_list(module: &mut Module, svc: &Arc<dyn UsersService>, cx: &Arc<SdkCallCx>) {
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
module.set_native_fn(
|
||||
"list",
|
||||
move |opts: Map| -> Result<Map, Box<EvalAltResult>> {
|
||||
let limit = match opts.get("$limit").or_else(|| opts.get("limit")) {
|
||||
None => None,
|
||||
Some(d) if d.is_unit() => None,
|
||||
Some(d) => Some(
|
||||
d.as_int()
|
||||
.map_err(|_| runtime_err("users::list: '$limit' must be an integer"))?,
|
||||
),
|
||||
};
|
||||
let cursor = match opts.get("cursor") {
|
||||
None => None,
|
||||
Some(d) if d.is_unit() => None,
|
||||
Some(d) if d.is_string() => {
|
||||
let s = d.clone().into_string().unwrap_or_default();
|
||||
Some(parse_cursor(&s)?)
|
||||
}
|
||||
Some(_) => return Err(runtime_err("users::list: cursor must be a string or ()")),
|
||||
};
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
let page: UsersListPage =
|
||||
block_on(async move { svc.list(&cx, UsersListOpts { cursor, limit }).await })?;
|
||||
Ok(list_page_to_map(&page))
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Auth
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
fn bind_login(module: &mut Module, svc: &Arc<dyn UsersService>, cx: &Arc<SdkCallCx>) {
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
module.set_native_fn(
|
||||
"login",
|
||||
move |email: &str, password: &str| -> Result<Dynamic, Box<EvalAltResult>> {
|
||||
let email = email.to_string();
|
||||
let password = password.to_string();
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
let session_opt: Option<GeneratedSession> =
|
||||
block_on(async move { svc.login(&cx, &email, &password).await })?;
|
||||
Ok(session_opt
|
||||
.map(|s| Dynamic::from(s.token))
|
||||
.unwrap_or(Dynamic::UNIT))
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn bind_verify(module: &mut Module, svc: &Arc<dyn UsersService>, cx: &Arc<SdkCallCx>) {
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
module.set_native_fn(
|
||||
"verify",
|
||||
move |token: &str| -> Result<Dynamic, Box<EvalAltResult>> {
|
||||
let token = token.to_string();
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
let user_opt = block_on(async move { svc.verify(&cx, &token).await })?;
|
||||
Ok(user_opt
|
||||
.map(|u| Dynamic::from(user_to_map(&u)))
|
||||
.unwrap_or(Dynamic::UNIT))
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn bind_logout(module: &mut Module, svc: &Arc<dyn UsersService>, cx: &Arc<SdkCallCx>) {
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
module.set_native_fn(
|
||||
"logout",
|
||||
move |token: &str| -> Result<(), Box<EvalAltResult>> {
|
||||
let token = token.to_string();
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
block_on(async move { svc.logout(&cx, &token).await })
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Email-tied (commit 5 / 6 / 7 fill the service impl behind these)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
fn bind_send_verification_email(
|
||||
module: &mut Module,
|
||||
svc: &Arc<dyn UsersService>,
|
||||
cx: &Arc<SdkCallCx>,
|
||||
) {
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
module.set_native_fn(
|
||||
"send_verification_email",
|
||||
move |id: &str, opts: Map| -> Result<(), Box<EvalAltResult>> {
|
||||
let id = parse_user_id(id, "users::send_verification_email")?;
|
||||
let opts = parse_email_template(&opts, "users::send_verification_email")?;
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
block_on(async move { svc.send_verification_email(&cx, id, opts).await })
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn bind_verify_email(module: &mut Module, svc: &Arc<dyn UsersService>, cx: &Arc<SdkCallCx>) {
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
module.set_native_fn(
|
||||
"verify_email",
|
||||
move |token: &str| -> Result<Dynamic, Box<EvalAltResult>> {
|
||||
let token = token.to_string();
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
let user_opt = block_on(async move { svc.verify_email(&cx, &token).await })?;
|
||||
Ok(user_opt
|
||||
.map(|u| Dynamic::from(user_to_map(&u)))
|
||||
.unwrap_or(Dynamic::UNIT))
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn bind_request_password_reset(
|
||||
module: &mut Module,
|
||||
svc: &Arc<dyn UsersService>,
|
||||
cx: &Arc<SdkCallCx>,
|
||||
) {
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
module.set_native_fn(
|
||||
"request_password_reset",
|
||||
move |email: &str, opts: Map| -> Result<(), Box<EvalAltResult>> {
|
||||
let email = email.to_string();
|
||||
let opts = parse_email_template(&opts, "users::request_password_reset")?;
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
block_on(async move { svc.request_password_reset(&cx, &email, opts).await })
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn bind_complete_password_reset(
|
||||
module: &mut Module,
|
||||
svc: &Arc<dyn UsersService>,
|
||||
cx: &Arc<SdkCallCx>,
|
||||
) {
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
module.set_native_fn(
|
||||
"complete_password_reset",
|
||||
move |token: &str, new_password: &str| -> Result<Dynamic, Box<EvalAltResult>> {
|
||||
let token = token.to_string();
|
||||
let new_password = new_password.to_string();
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
let user_opt = block_on(async move {
|
||||
svc.complete_password_reset(&cx, &token, &new_password).await
|
||||
})?;
|
||||
Ok(user_opt
|
||||
.map(|u| Dynamic::from(user_to_map(&u)))
|
||||
.unwrap_or(Dynamic::UNIT))
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn bind_invite(module: &mut Module, svc: &Arc<dyn UsersService>, cx: &Arc<SdkCallCx>) {
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
module.set_native_fn(
|
||||
"invite",
|
||||
move |email: &str, opts: Map| -> Result<(), Box<EvalAltResult>> {
|
||||
let email = email.to_string();
|
||||
let opts = parse_invite_opts(&opts)?;
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
block_on(async move { svc.invite(&cx, &email, opts).await })
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn bind_accept_invite(module: &mut Module, svc: &Arc<dyn UsersService>, cx: &Arc<SdkCallCx>) {
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
// Two-arg overload: (token, password). The display_name overload is
|
||||
// bound separately because Rhai resolves functions by arity.
|
||||
{
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
module.set_native_fn(
|
||||
"accept_invite",
|
||||
move |token: &str, password: &str| -> Result<Dynamic, Box<EvalAltResult>> {
|
||||
let token = token.to_string();
|
||||
let password = password.to_string();
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
let accept_opt: Option<GeneratedAccept> =
|
||||
block_on(async move { svc.accept_invite(&cx, &token, &password, None).await })?;
|
||||
Ok(accept_opt
|
||||
.map(|a| Dynamic::from(a.session.token))
|
||||
.unwrap_or(Dynamic::UNIT))
|
||||
},
|
||||
);
|
||||
}
|
||||
{
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
module.set_native_fn(
|
||||
"accept_invite",
|
||||
move |token: &str,
|
||||
password: &str,
|
||||
display_name: &str|
|
||||
-> Result<Dynamic, Box<EvalAltResult>> {
|
||||
let token = token.to_string();
|
||||
let password = password.to_string();
|
||||
let display_name = if display_name.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(display_name.trim().to_string())
|
||||
};
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
let accept_opt: Option<GeneratedAccept> = block_on(async move {
|
||||
svc.accept_invite(&cx, &token, &password, display_name).await
|
||||
})?;
|
||||
Ok(accept_opt
|
||||
.map(|a| Dynamic::from(a.session.token))
|
||||
.unwrap_or(Dynamic::UNIT))
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Roles (commit 9 fills the service impl)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
fn bind_add_role(module: &mut Module, svc: &Arc<dyn UsersService>, cx: &Arc<SdkCallCx>) {
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
module.set_native_fn(
|
||||
"add_role",
|
||||
move |id: &str, role: &str| -> Result<(), Box<EvalAltResult>> {
|
||||
let id = parse_user_id(id, "users::add_role")?;
|
||||
let role = role.to_string();
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
block_on(async move { svc.add_role(&cx, id, &role).await })
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn bind_remove_role(module: &mut Module, svc: &Arc<dyn UsersService>, cx: &Arc<SdkCallCx>) {
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
module.set_native_fn(
|
||||
"remove_role",
|
||||
move |id: &str, role: &str| -> Result<bool, Box<EvalAltResult>> {
|
||||
let id = parse_user_id(id, "users::remove_role")?;
|
||||
let role = role.to_string();
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
block_on(async move { svc.remove_role(&cx, id, &role).await })
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn bind_has_role(module: &mut Module, svc: &Arc<dyn UsersService>, cx: &Arc<SdkCallCx>) {
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
module.set_native_fn(
|
||||
"has_role",
|
||||
move |id: &str, role: &str| -> Result<bool, Box<EvalAltResult>> {
|
||||
let id = parse_user_id(id, "users::has_role")?;
|
||||
let role = role.to_string();
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
block_on(async move { svc.has_role(&cx, id, &role).await })
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Shape conversion helpers
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
fn user_to_map(user: &AppUser) -> Map {
|
||||
let mut m = Map::new();
|
||||
m.insert("id".into(), Dynamic::from(user.id.to_string()));
|
||||
m.insert("email".into(), Dynamic::from(user.email.clone()));
|
||||
m.insert(
|
||||
"display_name".into(),
|
||||
user.display_name
|
||||
.clone()
|
||||
.map(Dynamic::from)
|
||||
.unwrap_or(Dynamic::UNIT),
|
||||
);
|
||||
m.insert(
|
||||
"email_verified_at".into(),
|
||||
user.email_verified_at
|
||||
.map(|d| Dynamic::from(d.to_rfc3339()))
|
||||
.unwrap_or(Dynamic::UNIT),
|
||||
);
|
||||
m.insert(
|
||||
"last_login_at".into(),
|
||||
user.last_login_at
|
||||
.map(|d| Dynamic::from(d.to_rfc3339()))
|
||||
.unwrap_or(Dynamic::UNIT),
|
||||
);
|
||||
m.insert("created_at".into(), Dynamic::from(user.created_at.to_rfc3339()));
|
||||
m.insert("updated_at".into(), Dynamic::from(user.updated_at.to_rfc3339()));
|
||||
let roles: Array = user
|
||||
.roles
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(Dynamic::from)
|
||||
.collect();
|
||||
m.insert("roles".into(), roles.into());
|
||||
m
|
||||
}
|
||||
|
||||
fn list_page_to_map(page: &UsersListPage) -> Map {
|
||||
let mut m = Map::new();
|
||||
let items: Array = page.items.iter().map(|u| Dynamic::from(user_to_map(u))).collect();
|
||||
m.insert("users".into(), items.into());
|
||||
m.insert(
|
||||
"next_cursor".into(),
|
||||
page.next_cursor
|
||||
.map(|d| Dynamic::from(d.to_rfc3339()))
|
||||
.unwrap_or(Dynamic::UNIT),
|
||||
);
|
||||
m
|
||||
}
|
||||
|
||||
fn parse_cursor(s: &str) -> Result<chrono::DateTime<chrono::Utc>, Box<EvalAltResult>> {
|
||||
chrono::DateTime::parse_from_rfc3339(s)
|
||||
.map(|d| d.with_timezone(&chrono::Utc))
|
||||
.map_err(|e| runtime_err(&format!("users::list: invalid cursor: {e}")))
|
||||
}
|
||||
|
||||
fn parse_user_id(s: &str, ctx: &str) -> Result<AppUserId, Box<EvalAltResult>> {
|
||||
uuid::Uuid::parse_str(s)
|
||||
.map(AppUserId::from)
|
||||
.map_err(|e| runtime_err(&format!("{ctx}: id must be a UUID: {e}")))
|
||||
}
|
||||
|
||||
fn parse_email_template(opts: &Map, ctx: &str) -> Result<EmailTemplateOpts, Box<EvalAltResult>> {
|
||||
Ok(EmailTemplateOpts {
|
||||
link_base: required_string(opts, "link_base", ctx)?,
|
||||
subject: required_string(opts, "subject", ctx)?,
|
||||
body_template: required_string(opts, "body_template", ctx)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_invite_opts(opts: &Map) -> Result<InviteOpts, Box<EvalAltResult>> {
|
||||
// The template fields are optional only when invite is configured
|
||||
// to ship without an email; the service decides which is the
|
||||
// hard error. Here we forward whatever the script gave us.
|
||||
let template = if opts.contains_key("link_base")
|
||||
|| opts.contains_key("subject")
|
||||
|| opts.contains_key("body_template")
|
||||
{
|
||||
Some(parse_email_template(opts, "users::invite")?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let display_name = optional_string(opts, "display_name");
|
||||
let roles = match opts.get("roles") {
|
||||
None => Vec::new(),
|
||||
Some(d) if d.is_unit() => Vec::new(),
|
||||
Some(d) => {
|
||||
if let Some(arr) = d.clone().try_cast::<Array>() {
|
||||
let mut out = Vec::with_capacity(arr.len());
|
||||
for el in arr {
|
||||
if !el.is_string() {
|
||||
return Err(runtime_err(
|
||||
"users::invite: 'roles' must be an array of strings",
|
||||
));
|
||||
}
|
||||
out.push(el.into_string().unwrap_or_default());
|
||||
}
|
||||
out
|
||||
} else {
|
||||
return Err(runtime_err(
|
||||
"users::invite: 'roles' must be an array of strings",
|
||||
));
|
||||
}
|
||||
}
|
||||
};
|
||||
Ok(InviteOpts {
|
||||
template,
|
||||
display_name,
|
||||
roles,
|
||||
})
|
||||
}
|
||||
|
||||
fn required_string(opts: &Map, key: &str, ctx: &str) -> Result<String, Box<EvalAltResult>> {
|
||||
match opts.get(key) {
|
||||
Some(d) if d.is_string() => Ok(d.clone().into_string().unwrap_or_default()),
|
||||
_ => Err(runtime_err(&format!(
|
||||
"{ctx}: '{key}' must be a string and is required"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn optional_string(opts: &Map, key: &str) -> Option<String> {
|
||||
match opts.get(key) {
|
||||
None => None,
|
||||
Some(d) if d.is_unit() => None,
|
||||
Some(d) if d.is_string() => Some(d.clone().into_string().unwrap_or_default()),
|
||||
Some(d) => Some(d.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::unnecessary_box_returns)]
|
||||
fn runtime_err(msg: &str) -> Box<EvalAltResult> {
|
||||
EvalAltResult::ErrorRuntime(msg.into(), rhai::Position::NONE).into()
|
||||
}
|
||||
|
||||
/// Run a `UsersService` future inside the synchronous Rhai context.
|
||||
/// Mirrors `kv::block_on` / `email::block_on`.
|
||||
fn block_on<T, F>(fut: F) -> Result<T, Box<EvalAltResult>>
|
||||
where
|
||||
F: std::future::Future<Output = Result<T, UsersError>> + Send,
|
||||
T: Send,
|
||||
{
|
||||
let handle = TokioHandle::try_current().map_err(|e| -> Box<EvalAltResult> {
|
||||
EvalAltResult::ErrorRuntime(
|
||||
format!("users: no tokio runtime available: {e}").into(),
|
||||
rhai::Position::NONE,
|
||||
)
|
||||
.into()
|
||||
})?;
|
||||
handle.block_on(fut).map_err(|err| -> Box<EvalAltResult> {
|
||||
EvalAltResult::ErrorRuntime(format!("users: {err}").into(), rhai::Position::NONE).into()
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user