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
+120 -7
View File
@@ -490,17 +490,110 @@ impl WebCore {
api: info.api,
setup_open: info.setup_open,
min_client: info.min_client,
registration: info.registration.map(registration_name),
})
}
/// First device only: registers the account with a fresh server using its one-time setup token.
pub async fn register_account(&self, setup_token: &str) -> Result<()> {
pub async fn account_details(&self) -> Result<AccountDetailsDto> {
let info = self.api()?.account().await?;
Ok(AccountDetailsDto { email: info.email, is_admin: info.is_admin })
}
pub async fn set_account_email(&self, email: &str) -> Result<()> {
Ok(self.api()?.set_email(email).await?)
}
// ------------------------------------------------------------ admin (docs/03 "Accounts")
pub async fn admin_users(&self) -> Result<Vec<AdminUserDto>> {
Ok(self
.api()?
.admin_users()
.await?
.into_iter()
.map(|u| AdminUserDto {
account_id: u.account_id.to_string(),
email: u.email,
is_admin: u.is_admin,
disabled: u.disabled,
created_at: u.created_at,
last_seen: u.last_seen,
devices: u.devices,
items: u.items,
storage_bytes: u.storage_bytes,
})
.collect())
}
pub async fn admin_update_user(
&self,
account_id: &str,
disabled: Option<bool>,
is_admin: Option<bool>,
) -> Result<()> {
let update = kv_proto::AdminUserUpdate { disabled, is_admin };
Ok(self.api()?.admin_update_user(parse_id(account_id)?, &update).await?)
}
pub async fn admin_delete_user(&self, account_id: &str) -> Result<()> {
Ok(self.api()?.admin_delete_user(parse_id(account_id)?).await?)
}
pub async fn admin_invites(&self) -> Result<Vec<InviteDto>> {
Ok(self
.api()?
.admin_invites()
.await?
.into_iter()
.map(|i| InviteDto {
invite_id: i.invite_id.to_string(),
email: i.email,
created_at: i.created_at,
expires_at: i.expires_at,
used: i.used,
revoked: i.revoked,
})
.collect())
}
pub async fn admin_create_invite(&self, email: Option<String>, days: Option<u32>) -> Result<InviteCreatedDto> {
let c = self.api()?.admin_create_invite(&kv_proto::InviteCreate { email, days }).await?;
Ok(InviteCreatedDto { invite_id: c.invite_id.to_string(), code: c.code, expires_at: c.expires_at })
}
pub async fn admin_revoke_invite(&self, invite_id: &str) -> Result<()> {
Ok(self.api()?.admin_revoke_invite(parse_id(invite_id)?).await?)
}
pub async fn admin_settings(&self) -> Result<AdminSettingsDto> {
let s = self.api()?.admin_settings().await?;
Ok(AdminSettingsDto { registration: registration_name(s.registration), allowed_domains: s.allowed_domains })
}
pub async fn admin_put_settings(
&self,
registration: &str,
allowed_domains: Vec<String>,
) -> Result<AdminSettingsDto> {
let registration = match registration {
"open" => kv_proto::Registration::Open,
"invite" => kv_proto::Registration::Invite,
other => return Err(CoreError::Invalid { msg: format!("unknown sign-up mode {other}") }),
};
let s = self.api()?.admin_put_settings(&kv_proto::AdminSettings { registration, allowed_domains }).await?;
Ok(AdminSettingsDto { registration: registration_name(s.registration), allowed_domains: s.allowed_domains })
}
/// Registers the account: `setup_token` is the server's setup code (first account), an invite code, or empty
/// with open sign-up; `email` is required once the server has an account.
pub async fn register_account(&self, setup_token: &str, email: Option<String>) -> Result<()> {
let api = self.api()?;
let (acct, u) = self.unlocked_snapshot()?;
let auth = u.auth_input.clone().ok_or(CoreError::NotAuthenticated)?;
let device = self.enroll()?;
api.register(
setup_token,
email.as_deref(),
acct.account_id,
&acct.kdf,
auth.as_ref(),
@@ -515,9 +608,15 @@ impl WebCore {
/// Logs in with the master password and Secret Key. A browser with no account fetches it from the
/// server; call `sync_now` afterwards to download the vault.
pub async fn login(&self, master_password: &str, secret_key: &str, totp: Option<String>) -> Result<LoginResult> {
pub async fn login(
&self,
master_password: &str,
secret_key: &str,
totp: Option<String>,
email: Option<String>,
) -> Result<LoginResult> {
let sk = SecretKey::parse(secret_key).inspect_err(|_| self.forget_secret_key())?;
self.login_inner(master_password, sk, totp, None, None).await
self.login_inner(master_password, sk, totp, None, None, email).await
}
/// `login`, optionally presenting an enrollment ticket and checking the server holds `expect_account`.
@@ -528,8 +627,9 @@ impl WebCore {
totp: Option<String>,
ticket: Option<String>,
expect_account: Option<Uuid>,
email: Option<String>,
) -> Result<LoginResult> {
let r = self.login_unchecked(master_password, sk, totp, ticket, expect_account).await;
let r = self.login_unchecked(master_password, sk, totp, ticket, expect_account, email).await;
r.inspect_err(|_| self.forget_secret_key())
}
@@ -540,13 +640,19 @@ impl WebCore {
totp: Option<String>,
ticket: Option<String>,
expect_account: Option<Uuid>,
email: Option<String>,
) -> Result<LoginResult> {
let api = self.api()?;
let local = self.lock_inner().account.clone();
let (account_id, kdf_params) = match &local {
Some(a) => (a.account_id, a.kdf.clone()),
None => {
let pre = api.prelogin().await?;
let by = match (expect_account, email.as_deref().map(str::trim).filter(|e| !e.is_empty())) {
(Some(id), _) => kv_sync::PreloginBy::Account(id),
(None, Some(e)) => kv_sync::PreloginBy::Email(e),
(None, None) => kv_sync::PreloginBy::OnlyAccount,
};
let pre = api.prelogin(by).await?;
(pre.account_id, pre.kdf)
}
};
@@ -720,7 +826,7 @@ impl WebCore {
}
self.configure_server(&p.server_url, p.spki_pins.clone())?;
let sk = SecretKey::parse(&p.secret_key)?;
self.login_inner(master_password, sk, None, p.ticket.clone(), Some(p.account_id)).await
self.login_inner(master_password, sk, None, p.ticket.clone(), Some(p.account_id), None).await
}
/// Joining browser: opens a rendezvous on `server_url` and returns the QR to show. Then call
@@ -1169,3 +1275,10 @@ pub fn device_key_fingerprint(p256_pk: &[u8]) -> String {
fn parse_id(s: &str) -> Result<Uuid> {
Uuid::parse_str(s).map_err(|_| CoreError::Invalid { msg: "id must be a UUID".into() })
}
fn registration_name(r: kv_proto::Registration) -> String {
match r {
kv_proto::Registration::Invite => "invite".into(),
kv_proto::Registration::Open => "open".into(),
}
}