diff --git a/core/auth.py b/core/auth.py index ded0f86..b254ffc 100644 --- a/core/auth.py +++ b/core/auth.py @@ -210,6 +210,36 @@ class AuthManager: logger.info(f"Deleted user '{username}' (by {requesting_user}); revoked {revoked} active session(s)") return True + def rename_user(self, old_username: str, new_username: str, requesting_user: str) -> bool: + """Rename a user in auth config and active sessions. Admin only.""" + old_username = old_username.strip().lower() + new_username = new_username.strip().lower() + requesting_user = (requesting_user or "").strip().lower() + if not old_username or not new_username: + return False + if old_username not in self.users: + return False + if new_username in self.users: + return False + if not self.users.get(requesting_user, {}).get("is_admin"): + return False + self._config.setdefault("users", {})[new_username] = self._config["users"].pop(old_username) + self._save() + + renamed_sessions = 0 + with self._sessions_lock: + for sess in self._sessions.values(): + if (sess or {}).get("username") == old_username: + sess["username"] = new_username + renamed_sessions += 1 + if renamed_sessions: + self._save_sessions() + logger.info( + "Renamed user '%s' -> '%s' (by %s); updated %d active session(s)", + old_username, new_username, requesting_user, renamed_sessions, + ) + return True + def is_admin(self, username: str) -> bool: return self.users.get(username, {}).get("is_admin", False) diff --git a/routes/auth_routes.py b/routes/auth_routes.py index 00a298a..dca14c3 100644 --- a/routes/auth_routes.py +++ b/routes/auth_routes.py @@ -61,6 +61,10 @@ class DeleteUserRequest(BaseModel): username: str +class RenameUserRequest(BaseModel): + username: str + + SESSION_COOKIE = "odysseus_session" @@ -266,6 +270,64 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter: raise HTTPException(404, "User not found or is admin") return {"ok": True, "privileges": auth_manager.get_privileges(username)} + @router.put("/users/{username}/rename") + async def rename_user(username: str, body: RenameUserRequest, request: Request): + user = _get_current_user(request) + if not user or not auth_manager.is_admin(user): + raise HTTPException(403, "Admin only") + old_username = (username or "").strip().lower() + new_username = (body.username or "").strip().lower() + if not new_username: + raise HTTPException(400, "Username required") + if old_username == new_username: + return {"ok": True, "username": new_username, "renamed_self": old_username == user} + if old_username not in auth_manager.users: + raise HTTPException(404, "User not found") + if new_username in auth_manager.users: + raise HTTPException(409, "Username already taken") + + # Usernames are ownership keys for user data. Rename the common + # owner-scoped DB rows before changing auth so the account keeps + # access to its sessions, docs, email accounts, tasks, etc. + try: + from core.database import Base, SessionLocal + db = SessionLocal() + try: + for mapper in Base.registry.mappers: + model = mapper.class_ + if not hasattr(model, "owner"): + continue + ( + db.query(model) + .filter(model.owner == old_username) + .update({"owner": new_username}, synchronize_session=False) + ) + db.commit() + except Exception: + db.rollback() + raise + finally: + db.close() + except Exception as e: + logger.error("Failed to rename owner references %s -> %s: %s", old_username, new_username, e) + raise HTTPException(500, "Failed to rename user data") + + # Per-user prefs are JSON-backed, not SQL-backed. + try: + from routes.prefs_routes import _load as _load_prefs, _save as _save_prefs + prefs = _load_prefs() + users = prefs.get("_users") if isinstance(prefs, dict) else None + if isinstance(users, dict) and old_username in users and new_username not in users: + users[new_username] = users.pop(old_username) + _save_prefs(prefs) + except Exception as e: + logger.warning("Failed to rename user prefs %s -> %s: %s", old_username, new_username, e) + + ok = auth_manager.rename_user(old_username, new_username, user) + if not ok: + raise HTTPException(400, "Cannot rename user") + return {"ok": True, "username": new_username, "renamed_self": old_username == user} + @router.post("/signup-toggle") async def toggle_signup(request: Request): """Toggle open registration on/off. Admin only.""" diff --git a/static/js/admin.js b/static/js/admin.js index 3f81993..e032f3e 100644 --- a/static/js/admin.js +++ b/static/js/admin.js @@ -53,6 +53,7 @@ async function loadUsers() {