Multi-user servers: email and invite codes at sign-up and sign-in
ci / build (ubuntu-22.04) (push) Canceled after 0s
ci / build (windows-2025) (push) Canceled after 0s
ci / deny (push) Canceled after 0s

Create account takes an email and an invite or setup code (none with open
sign-up), joins servers that already have accounts, and Settings shows the
account email (admins manage people from the web vault).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-24 07:52:08 +07:00
co-authored by Claude Opus 5.5
parent 9dd43b76e9
commit b1c98f6376
4 changed files with 62 additions and 23 deletions
+5 -4
View File
@@ -154,17 +154,18 @@ fn state(app: State<'_, App>) -> AppState {
// Argon2id takes ~0.5 s, so these run as async commands off the main thread.
/// "Create a new account": a new vault here, registered with a fresh server using its setup code.
/// "Create a new account": a new vault here, registered with the server using its setup code or an invite.
#[tauri::command]
async fn create_account(
app: State<'_, App>,
server_url: String,
pins: String,
setup_code: String,
email: String,
master_password: String,
) -> CmdResult<String> {
let master_password = Zeroizing::new(master_password);
let sk = app.sync.create_account(&server_url, sync::parse_pins(&pins), &setup_code, master_password).await;
let sk = app.sync.create_account(&server_url, sync::parse_pins(&pins), &setup_code, &email, master_password).await;
app.activity.touch();
sk.map(|s| s.to_string())
}
@@ -378,8 +379,8 @@ async fn server_setup_open(app: State<'_, App>) -> CmdResult<bool> {
}
#[tauri::command]
async fn register_server(app: State<'_, App>, setup_code: String) -> CmdResult<()> {
app.sync.register(&setup_code).await
async fn register_server(app: State<'_, App>, setup_code: String, email: String) -> CmdResult<()> {
app.sync.register(&setup_code, &email).await
}
#[tauri::command]
+43 -11
View File
@@ -139,8 +139,22 @@ fn now_secs() -> u64 {
}
/// User-facing text for a core error.
/// Deliberately loose ([email protected]); the server normalises and checks it again.
fn looks_like_email(s: &str) -> bool {
match s.split_once('@') {
Some((user, domain)) => {
!user.is_empty() && !domain.contains('@') && domain.contains('.') && !s.contains(char::is_whitespace)
}
None => false,
}
}
pub fn describe(e: &CoreError) -> String {
match e {
CoreError::Server { status: 409, .. } => {
"An account with this email already exists on the server. Sign in to it instead, or use another email."
.into()
}
CoreError::Network { msg } => format!("Can't reach the server: {msg}"),
CoreError::PinMismatch => {
"The server's certificate doesn't match the pinned keys. Someone may be intercepting \
@@ -344,33 +358,43 @@ impl SyncService {
Ok(())
}
/// "Create a new account": creates the vault here and registers it with a fresh server using the setup code
/// (`KV_SETUP_CODE`). Returns the Secret Key for the Emergency Kit.
/// "Create a new account": creates the vault here and registers it with the server, using its setup code
/// (`KV_SETUP_CODE`, first account), an admin's invite, or nothing with open sign-up. Returns the Secret Key
/// for the Emergency Kit.
pub async fn create_account(
&self,
server_url: &str,
pins: Vec<String>,
setup_code: &str,
email: &str,
master_password: Zeroizing<String>,
) -> Result<Zeroizing<String>, String> {
if self.core.has_account() {
return Err("This computer already has a vault.".into());
}
if setup_code.trim().is_empty() {
return Err("Enter the server's setup code.".into());
let email = email.trim();
if !looks_like_email(email) {
return Err("Enter your email address. You sign in with it on new devices.".into());
}
let code = setup_code.trim();
self.configure(server_url, pins)?;
let info = self.core.server_info().await.map_err(|e| describe(&e))?;
if !info.setup_open {
if !info.setup_open && !info.multi_user {
return Err(
"This server already has an account. Choose “Use my phone” or “Paste setup code” instead.".into()
);
}
if code.is_empty() && info.setup_open {
return Err("Enter the server's setup code (KV_SETUP_CODE in its .env).".into());
}
if code.is_empty() && !info.open_signup {
return Err("Enter the invite code your server's admin sent you.".into());
}
let sk = Zeroizing::new(self.core.create_account(master_password.to_string()).map_err(|e| describe(&e))?);
self.remember_secret_key(&sk);
if let Err(e) = self.core.register_account(setup_code.trim().to_string()).await {
if let Err(e) = self.core.register_account(code.to_string(), Some(email.to_string())).await {
return Err(format!(
"Your vault was created on this computer, but the server refused it: {}. Check the setup code in \
"Your vault was created on this computer, but the server refused it: {}. Check the code in \
Settings → Sync and try again.",
describe(&e)
));
@@ -379,9 +403,17 @@ impl SyncService {
Ok(sk)
}
/// For a vault created before sync was set up: registers it with a fresh server.
pub async fn register(&self, setup_code: &str) -> Result<(), String> {
self.core.register_account(setup_code.trim().to_string()).await.map_err(|e| describe(&e))?;
/// For a vault created before sync was set up: registers it with the server (setup code, invite, or nothing
/// with open sign-up).
pub async fn register(&self, setup_code: &str, email: &str) -> Result<(), String> {
let email = email.trim();
if !looks_like_email(email) {
return Err("Enter your email address. You sign in with it on new devices.".into());
}
self.core
.register_account(setup_code.trim().to_string(), Some(email.to_string()))
.await
.map_err(|e| describe(&e))?;
self.sync_now().await.map(|_| ())
}
@@ -678,7 +710,7 @@ mod tests {
let phone_key: Arc<dyn kv_ffi::DeviceSigner> =
Arc::new(DeviceKey::load_or_create_file(phone_dir.path()).unwrap());
phone.set_device_signer(phone_key, "phone".into(), PlatformDto::Ios).unwrap();
phone.register_account(SETUP_TOKEN.into()).await.unwrap();
phone.register_account(SETUP_TOKEN.into(), Some("[email protected]".into())).await.unwrap();
server.state().require_approval = true;
let dir = tempfile::tempdir().unwrap();
+2 -1
View File
@@ -73,7 +73,8 @@ async fn create_join_approve_and_sync() {
// A: "Create a new account".
let a = instance(da.path());
let sk = a.create_account(&server, vec![], &code, Zeroizing::new(PASSWORD.into())).await.unwrap();
let sk =
a.create_account(&server, vec![], &code, "[email protected]", Zeroizing::new(PASSWORD.into())).await.unwrap();
assert!(sk.starts_with("K1"));
assert!(a.status().logged_in);
assert!(a.status().last_sync_at.is_some(), "first sync after registration: {:?}", a.status().last_error);