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:
+120
-7
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user