ipx user rename: give an account the name the proxy signs it in as

An account made by hand before the proxy was set up is called what it was
given ('rays'), while Cloudflare Access vouches for an email address. With
auto_create_users on, the first visit through the tunnel would make a
second, empty account. Renaming keeps the id, so feeds, read state and
admin rights go with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TAC7sLVqfKmY6rsTLXzNgk
This commit is contained in:
2026-09-12 13:54:29 +00:00
parent 2ba83c3aed
commit 586d2c07a1
3 changed files with 44 additions and 0 deletions

View File

@@ -14,6 +14,8 @@ The long form, with what was wrong before and how it was found, is in
- Settings → Users and `ipx user list` show when each account was added and when it last signed - Settings → Users and `ipx user list` show when each account was added and when it last signed
in, to the hour. in, to the hour.
- `ipx user rename <name> <new name>` renames an account and keeps its feeds, read state and admin
rights. An account made before the proxy was set up can take the name the proxy signs it in as.
### Changed ### Changed

View File

@@ -1069,6 +1069,15 @@ impl Db {
Ok(()) Ok(())
} }
/// The proxy signs people in by the name it vouches for, so an account made before the proxy
/// was set up has to take that name to be found by it. The name is UNIQUE, so a taken one is
/// refused here as well as by the caller.
pub fn rename_user(&self, id: i64, name: &str) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute("UPDATE users SET name = ?2 WHERE id = ?1", params![id, name])?;
Ok(())
}
/// Records a sign-in, to the hour: the proxy vouches for every request, and writing each one /// Records a sign-in, to the hour: the proxy vouches for every request, and writing each one
/// would buy nothing. /// would buy nothing.
pub fn signed_in(&self, id: i64) -> Result<()> { pub fn signed_in(&self, id: i64) -> Result<()> {
@@ -1571,6 +1580,20 @@ mod tests {
assert_eq!(kept, 1); assert_eq!(kept, 1);
} }
#[test]
fn a_renamed_account_keeps_everything_but_its_name() {
let db = Db::memory().unwrap();
let ray = db.create_user("rays", None, true).unwrap();
db.create_user("sam", None, false).unwrap();
db.subscribe(ray, "f").unwrap();
db.rename_user(ray, "rays@sdf1.net").unwrap();
assert!(db.user_by_name("rays").unwrap().is_none());
let renamed = db.user_by_name("RAYS@sdf1.net").unwrap().unwrap();
assert_eq!((renamed.id, renamed.is_admin), (ray, true), "same account, still the admin");
assert_eq!(db.subscriptions_for(ray).unwrap().len(), 1, "and still subscribed");
assert!(db.rename_user(ray, "sam").is_err(), "a taken name is refused");
}
#[test] #[test]
fn an_account_knows_when_it_was_made_and_last_signed_in() { fn an_account_knows_when_it_was_made_and_last_signed_in() {
let db = Db::memory().unwrap(); let db = Db::memory().unwrap();

View File

@@ -99,6 +99,9 @@ enum UserCmd {
Passwd { name: String }, Passwd { name: String },
/// Delete an account and everything it knows: its subscriptions and read state /// Delete an account and everything it knows: its subscriptions and read state
Rm { name: String }, Rm { name: String },
/// Rename an account, keeping its feeds, read state and admin rights. This is how an
/// account made before the proxy takes the name the proxy signs it in as
Rename { name: String, new_name: String },
} }
/// What a brand new database starts with, so there is always a way in. Announced loudly /// What a brand new database starts with, so there is always a way in. Announced loudly
@@ -297,6 +300,22 @@ fn user_cmd(ctx: &Arc<Ctx>, cmd: UserCmd) -> Result<()> {
println!("password changed for {name}"); println!("password changed for {name}");
Ok(()) Ok(())
} }
UserCmd::Rename { name, new_name } => {
let name = name.trim().to_ascii_lowercase();
// The same rules as a name the proxy vouches for, or the proxy would never find it.
let new_name = crate::auth::name_from_header(&new_name)
.ok_or_else(|| anyhow::anyhow!("not a usable name: no commas, semicolons or line breaks"))?;
let user = ctx
.db
.user_by_name(&name)?
.ok_or_else(|| anyhow::anyhow!("no such account: {name}"))?;
if ctx.db.user_by_name(&new_name)?.is_some() {
anyhow::bail!("{new_name} already exists");
}
ctx.db.rename_user(user.id, &new_name)?;
println!("renamed {name} to {new_name}");
Ok(())
}
UserCmd::Rm { name } => { UserCmd::Rm { name } => {
let name = name.trim().to_ascii_lowercase(); let name = name.trim().to_ascii_lowercase();
let user = ctx let user = ctx