aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/api/users.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-19 16:36:29 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-19 16:36:29 +0200
commit90c3c6a01f46c9b29c5d92702d7383f32e8951d7 (patch)
tree779f5ee3557d5111e0a7f1fdc211a2845bc3bc69 /packages/meshbay-hub/src/meshbay_hub/api/users.py
parent9498ce0a34cc363e8cf9a42575c8fcf7dfe0fd1a (diff)
downloadmeshbay-90c3c6a01f46c9b29c5d92702d7383f32e8951d7.tar.gz
feat(ui): 11-point UI overhaul — tabs, transfers, settings, uploads
Hub/UI: - Icon-only group tabs (chat, files, settings) with per-group default tab - Transfer widget: filename becomes a clickable link to open completed downloads - Pulse animation on transfer icon (pale→dark green) while active - Download button feedback in FilePreview (spinner, auto-reset) - Group mute toggle persists across navigation - Login page autofocus, chat refocus after send - Theme toggle closes menu, status badge and duplicate connecting removed - Create-folder restricted to operators, download-path note removed - User preferences API (CRUD) with Alembic migration - Profile: email display/edit via PATCH /v1/users/me - Settings: "Defaults" section for default tab selector - All 10 locale files updated Node: - upload_dir in node.toml: separate filesystem path for uploads - Root.direct flag: uploads land at root path, no subdirectory - CLI --upload-dir flag on `group add` - Admin UI accepts upload_dir Client (Electron): - shell.openPath bridge for opening completed downloads - platform.js passes open callback from native save Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/api/users.py')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/users.py122
1 files changed, 121 insertions, 1 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py
index b5fa205..a50a2d7 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/users.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py
@@ -15,6 +15,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from meshbay_hub.auth import (
current_pw_version,
decode_access_token,
+ decrypt_email,
encrypt_email,
generate_refresh_token,
hash_password,
@@ -29,7 +30,8 @@ from meshbay_hub.api.netutil import client_ip
from meshbay_hub.config import HubConfig
from meshbay_hub.db.engine import get_db
from meshbay_hub.db.models import (
- Group, GroupMember, IPLog, Node, Notification, RefreshToken, User, UserDevice,
+ Group, GroupMember, IPLog, Node, Notification, RefreshToken, User,
+ UserDevice, UserPreference,
)
from meshbay_hub.api.deps import get_current_user, require_user_scope
@@ -474,14 +476,131 @@ async def token_refresh(
async def get_current_user_info(
current_user: User = Depends(get_current_user),
):
+ email = ""
+ try:
+ email = decrypt_email(current_user.email) if current_user.email else ""
+ except Exception:
+ pass
+ return {
+ "user_id": current_user.id,
+ "username": current_user.username,
+ "email": email,
+ "role": current_user.role,
+ "status": current_user.status,
+ }
+
+
+class UpdateProfileRequest(BaseModel):
+ email: str | None = None
+
+ @field_validator("email")
+ @classmethod
+ def email_valid(cls, v: str | None) -> str | None:
+ if v is None:
+ return v
+ v = v.strip()
+ local, sep, domain = v.partition("@")
+ if (not sep or not local or not domain
+ or "." not in domain
+ or len(v) > 254
+ or any(c.isspace() or ord(c) < 32 for c in v)):
+ raise ValueError("invalid email address")
+ return v
+
+
+@router.patch("/me")
+async def update_profile(
+ body: UpdateProfileRequest,
+ current_user: User = Depends(require_user_scope),
+ db: AsyncSession = Depends(get_db),
+):
+ if body.email is not None:
+ current_user.email = encrypt_email(body.email)
+ await db.commit()
+ email = ""
+ try:
+ email = decrypt_email(current_user.email) if current_user.email else ""
+ except Exception:
+ pass
return {
"user_id": current_user.id,
"username": current_user.username,
+ "email": email,
"role": current_user.role,
"status": current_user.status,
}
+# ── User preferences ────────────────────────────────────────────────────────
+
+ALLOWED_PREF_KEYS = frozenset([
+ "notifications_disabled",
+ "default_tab",
+])
+
+def _valid_pref_key(key: str) -> bool:
+ if key in ALLOWED_PREF_KEYS:
+ return True
+ if key.startswith("default_tab:"):
+ return True
+ return False
+
+
+@router.get("/me/preferences")
+async def get_preferences(
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ result = await db.execute(
+ select(UserPreference).where(UserPreference.user_id == current_user.id))
+ prefs = {p.key: p.value for p in result.scalars().all()}
+ return prefs
+
+
+class PrefValue(BaseModel):
+ value: str
+
+
+@router.put("/me/preferences/{key:path}")
+async def set_preference(
+ key: str,
+ body: PrefValue,
+ current_user: User = Depends(require_user_scope),
+ db: AsyncSession = Depends(get_db),
+):
+ if not _valid_pref_key(key):
+ raise HTTPException(status_code=400, detail=f"Unknown preference key: {key}")
+ result = await db.execute(
+ select(UserPreference).where(
+ UserPreference.user_id == current_user.id,
+ UserPreference.key == key))
+ pref = result.scalar_one_or_none()
+ if pref:
+ pref.value = body.value
+ else:
+ db.add(UserPreference(
+ user_id=current_user.id, key=key, value=body.value))
+ await db.commit()
+ return {"key": key, "value": body.value}
+
+
+@router.delete("/me/preferences/{key:path}")
+async def delete_preference(
+ key: str,
+ current_user: User = Depends(require_user_scope),
+ db: AsyncSession = Depends(get_db),
+):
+ result = await db.execute(
+ select(UserPreference).where(
+ UserPreference.user_id == current_user.id,
+ UserPreference.key == key))
+ pref = result.scalar_one_or_none()
+ if pref:
+ await db.delete(pref)
+ await db.commit()
+ return {"status": "deleted", "key": key}
+
+
class NodeKeyRequest(BaseModel):
pk_node_ed25519: str # base64 raw 32B Ed25519 public key
@@ -541,6 +660,7 @@ async def erase_account(db: AsyncSession, user: User) -> dict:
"account would strand their members."),
)
+ await db.execute(delete(UserPreference).where(UserPreference.user_id == user.id))
await db.execute(delete(GroupMember).where(GroupMember.user_id == user.id))
await db.execute(delete(Notification).where(Notification.user_id == user.id))
await db.execute(delete(RefreshToken).where(RefreshToken.user_id == user.id))