diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-19 16:36:29 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-19 16:36:29 +0200 |
| commit | 90c3c6a01f46c9b29c5d92702d7383f32e8951d7 (patch) | |
| tree | 779f5ee3557d5111e0a7f1fdc211a2845bc3bc69 /packages/meshbay-hub/src/meshbay_hub/api/users.py | |
| parent | 9498ce0a34cc363e8cf9a42575c8fcf7dfe0fd1a (diff) | |
| download | meshbay-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.py | 122 |
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)) |