Multi-user: email sign-in, invite/open registration, admin calls

- kv-proto: email on setup, AccountInfo.email/is_admin, ServerInfo.registration, admin DTOs.
- kv-sync: prelogin by email or account id (setup QR / Emergency Kit), register with email,
  set_email, /v1/admin/* client calls.
- kv-ffi: register_account(code, email), login(.., email), account_details, set_account_email,
  admin_create_invite. kv-wasm: the same plus the full admin API for the web vault.
- Live test: two users on one server stay isolated; a new device signs in by email.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-24 07:35:23 +07:00
co-authored by Claude Opus 5.5
parent ead95bb605
commit c84015f67b
16 changed files with 563 additions and 64 deletions
+73 -4
View File
@@ -62,7 +62,12 @@ export interface ItemDto {
export interface TotpCode { code: string; secondsRemaining: number; period: number; }
export interface ServerInfoDto { version: string; api: number; setupOpen: boolean; minClient: string; }
export interface ServerInfoDto { version: string; api: number; setupOpen: boolean; minClient: string; registration: "invite" | "open" | null; }
export interface AccountDetailsDto { email: string | null; isAdmin: boolean; }
export interface AdminUserDto { accountId: string; email: string | null; isAdmin: boolean; disabled: boolean; createdAt: string; lastSeen: number | null; devices: number; items: number; storageBytes: number; }
export interface InviteDto { inviteId: string; email: string | null; createdAt: number; expiresAt: number; used: boolean; revoked: boolean; }
export interface InviteCreatedDto { inviteId: string; code: string; expiresAt: number; }
export interface AdminSettingsDto { registration: "invite" | "open"; allowedDomains: string[]; }
export interface SyncReport {
pulled: number;
@@ -648,8 +653,12 @@ impl VaultCore {
/// First device only (the web vault is rarely that): registers the account with a fresh server.
#[wasm_bindgen(js_name = registerAccount, unchecked_return_type = "Promise<void>")]
pub fn register_account(&self, #[wasm_bindgen(js_name = "setupToken")] setup_token: String) -> Promise {
self.write(move |s| async move { s.core.register_account(&setup_token).await })
pub fn register_account(
&self,
#[wasm_bindgen(js_name = "setupToken")] setup_token: String,
email: Option<String>,
) -> Promise {
self.write(move |s| async move { s.core.register_account(&setup_token, email).await })
}
/// Logs in (and unlocks). Resolves `"pendingApproval"` until an enrolled device approves this
@@ -660,9 +669,69 @@ impl VaultCore {
#[wasm_bindgen(js_name = "masterPassword")] master_password: String,
#[wasm_bindgen(js_name = "secretKey")] secret_key: String,
totp: Option<String>,
email: Option<String>,
) -> Promise {
let (pw, sk) = (zeroize::Zeroizing::new(master_password), zeroize::Zeroizing::new(secret_key));
self.write(move |s| async move { s.core.login(&pw, &sk, totp).await })
self.write(move |s| async move { s.core.login(&pw, &sk, totp, email).await })
}
#[wasm_bindgen(js_name = accountDetails, unchecked_return_type = "Promise<AccountDetailsDto>")]
pub fn account_details(&self) -> Promise {
self.run(|s| async move { s.core.account_details().await })
}
#[wasm_bindgen(js_name = setAccountEmail, unchecked_return_type = "Promise<void>")]
pub fn set_account_email(&self, email: String) -> Promise {
self.run(move |s| async move { s.core.set_account_email(&email).await })
}
#[wasm_bindgen(js_name = adminUsers, unchecked_return_type = "Promise<AdminUserDto[]>")]
pub fn admin_users(&self) -> Promise {
self.run(|s| async move { s.core.admin_users().await })
}
#[wasm_bindgen(js_name = adminUpdateUser, unchecked_return_type = "Promise<void>")]
pub fn admin_update_user(
&self,
#[wasm_bindgen(js_name = "accountId")] account_id: String,
disabled: Option<bool>,
#[wasm_bindgen(js_name = "isAdmin")] is_admin: Option<bool>,
) -> Promise {
self.run(move |s| async move { s.core.admin_update_user(&account_id, disabled, is_admin).await })
}
#[wasm_bindgen(js_name = adminDeleteUser, unchecked_return_type = "Promise<void>")]
pub fn admin_delete_user(&self, #[wasm_bindgen(js_name = "accountId")] account_id: String) -> Promise {
self.run(move |s| async move { s.core.admin_delete_user(&account_id).await })
}
#[wasm_bindgen(js_name = adminInvites, unchecked_return_type = "Promise<InviteDto[]>")]
pub fn admin_invites(&self) -> Promise {
self.run(|s| async move { s.core.admin_invites().await })
}
#[wasm_bindgen(js_name = adminCreateInvite, unchecked_return_type = "Promise<InviteCreatedDto>")]
pub fn admin_create_invite(&self, email: Option<String>, days: Option<u32>) -> Promise {
self.run(move |s| async move { s.core.admin_create_invite(email, days).await })
}
#[wasm_bindgen(js_name = adminRevokeInvite, unchecked_return_type = "Promise<void>")]
pub fn admin_revoke_invite(&self, #[wasm_bindgen(js_name = "inviteId")] invite_id: String) -> Promise {
self.run(move |s| async move { s.core.admin_revoke_invite(&invite_id).await })
}
#[wasm_bindgen(js_name = adminSettings, unchecked_return_type = "Promise<AdminSettingsDto>")]
pub fn admin_settings(&self) -> Promise {
self.run(|s| async move { s.core.admin_settings().await })
}
#[wasm_bindgen(js_name = adminPutSettings, unchecked_return_type = "Promise<AdminSettingsDto>")]
pub fn admin_put_settings(
&self,
registration: String,
#[wasm_bindgen(js_name = "allowedDomains")] allowed_domains: Vec<String>,
) -> Promise {
self.run(move |s| async move { s.core.admin_put_settings(&registration, allowed_domains).await })
}
#[wasm_bindgen(js_name = syncNow, unchecked_return_type = "Promise<SyncReport>")]