From 9d9afbe3add5f6a17bed0eb8ce51e7f26c4a9d67 Mon Sep 17 00:00:00 2001 From: Stefano Bertelli Date: Mon, 30 Mar 2026 18:22:42 -0500 Subject: [PATCH 1/2] fix: CI runner containers for Forgejo actions --- .coverage | Bin 0 -> 122880 bytes .forgejo/workflows/release.yml | 2 + .idea/.gitignore | 10 + .idea/misc.xml | 7 + .idea/vcs.xml | 4 + TODO.md | 67 ++- compose.prod.yml | 2 + tests/test_services.py | 1 + wiregui/pages/account.py | 772 +++++++++++++++++++-------------- wiregui/tasks/connectivity.py | 2 +- 10 files changed, 547 insertions(+), 320 deletions(-) create mode 100644 .coverage create mode 100644 .idea/.gitignore create mode 100644 .idea/misc.xml create mode 100644 .idea/vcs.xml diff --git a/.coverage b/.coverage new file mode 100644 index 0000000000000000000000000000000000000000..474d9804b438c2c3ae5f8229b7871a56394e5af4 GIT binary patch literal 122880 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ΞJsq-}+0kA`ZpFf{6u>o&Mo(JM(04Hkw* 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 KJ8H1600saYM{))L literal 0 HcmV?d00001 diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index bf1f8c6..6d12cf4 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -46,6 +46,8 @@ jobs: needs: test if: github.ref == 'refs/heads/main' && github.event_name == 'push' runs-on: docker + container: + image: node:20-slim outputs: new_tag: ${{ steps.version.outputs.new_tag }} new_version: ${{ steps.version.outputs.new_version }} diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..ab1f416 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..58834d0 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,7 @@ + + + {} + { + "isMigrated": true +} + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..d843f34 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/TODO.md b/TODO.md index 17e555b..22d6fa7 100644 --- a/TODO.md +++ b/TODO.md @@ -188,9 +188,68 @@ Source: `/home/stefanob/PycharmProjects/personal/wirezone` - [x] Loguru configured (wiregui/logging.py), no print statements - [x] File logging to `logs/` when `WG_LOG_TO_FILE=true` -### Deployment -- [ ] Dockerfile (multi-stage) -- [ ] compose.prod.yml (app + postgres + valkey + caddy) -- [ ] Health endpoint `GET /api/health` +### Deployment ✅ +- [x] Dockerfile (multi-stage python:3.13-slim) +- [x] compose.prod.yml (bridge networking, NET_ADMIN, nftables) +- [x] Health endpoint `GET /api/health` +- [x] Forgejo CI: test → semver → Docker registry push - [ ] First-run CLI setup command - [ ] README.md + +--- + +## UI Polish — Account Page (`/account`) + +Redesign from tabbed layout to single scrollable page (matching original wirezone pattern). +Leverage Quasar components + Tailwind utility classes for modern look. + +### Layout change +- [ ] Remove tabs — render all sections stacked vertically on one page +- [ ] Page header: "Account Settings" with subtitle description + +### Section 1: Account Details +- [ ] Quasar `q-card` with clean table layout (not grid) for user info +- [ ] Rows: Email, Role, Last Sign-in, Method, Created +- [ ] Tailwind: rounded borders, hover states on rows, subtle dividers +- [ ] "Edit" button to open email change dialog (future) + +### Section 2: Change Password +- [ ] Separate `q-card` below details +- [ ] Outlined inputs with proper validation feedback +- [ ] Min 8 chars, confirmation match check shown inline +- [ ] Success/error toast notifications + +### Section 3: Connected SSO Providers +- [ ] `q-card` showing OIDC connections as a proper table +- [ ] Columns: Provider, Last Refreshed, Status +- [ ] "Disconnect" action per provider (future) +- [ ] Empty state: "No SSO providers connected" + +### Section 4: Multi-Factor Authentication +- [ ] `q-card` with MFA methods table +- [ ] Columns: Name, Type, Last Used, Actions (delete) +- [ ] Styled delete button (red outline, confirmation dialog) +- [ ] "Add TOTP Method" and "Add Security Key" buttons below table +- [ ] TOTP registration renders inline (QR + verify code) inside an expansion panel +- [ ] Empty state with icon + message + +### Section 5: API Tokens +- [ ] `q-card` with tokens table +- [ ] Columns: Created, Expires, Status (chip: green "Active" / red "Expired"), Actions +- [ ] Quasar `q-chip` for status badges +- [ ] Create token: inline row with expiry input + button (not a dialog) +- [ ] Token display after creation: `q-banner` with copy-to-clipboard button +- [ ] Empty state message + +### Section 6: Danger Zone +- [ ] `q-card` with red left border accent (`border-l-4 border-red-500`) +- [ ] "Delete Your Account" button with `q-btn color=negative outline` +- [ ] Confirmation dialog with typed email verification +- [ ] Disabled if user is the only admin + +### General styling improvements +- [ ] Consistent card spacing (`q-mt-lg` between sections) +- [ ] Section titles: `text-h6 text-weight-medium` +- [ ] Descriptive subtitles below each section title in `text-caption text-grey-7` +- [ ] Responsive: max-width container centered (`max-w-3xl mx-auto`) +- [ ] Smooth scroll between sections diff --git a/compose.prod.yml b/compose.prod.yml index f5394d3..ddcef0b 100644 --- a/compose.prod.yml +++ b/compose.prod.yml @@ -39,6 +39,8 @@ services: postgres: image: postgres:17 restart: unless-stopped + ports: + - "5432:5432" environment: POSTGRES_USER: wiregui POSTGRES_PASSWORD: wiregui diff --git a/tests/test_services.py b/tests/test_services.py index f0cf173..1c32f0e 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -29,6 +29,7 @@ def _make_device(**kwargs) -> Device: async def test_on_device_created_calls_add_peer(mock_wg, mock_fw, mock_settings): mock_settings.return_value.wg_enabled = True mock_wg.add_peer = AsyncMock() + mock_fw.add_user_chain = AsyncMock() mock_fw.add_device_jump_rule = AsyncMock() device = _make_device() diff --git a/wiregui/pages/account.py b/wiregui/pages/account.py index 8448b0a..e96bf5e 100644 --- a/wiregui/pages/account.py +++ b/wiregui/pages/account.py @@ -1,12 +1,12 @@ -"""User account page — password change, MFA management, API tokens.""" +"""User account page — single scrollable page with all account sections.""" +import json +from datetime import timedelta from uuid import UUID from loguru import logger from nicegui import app, ui -from sqlmodel import select - -import json +from sqlmodel import func, select from wiregui.auth.api_token import generate_api_token from wiregui.auth.mfa import generate_totp_qr_svg, generate_totp_secret, get_totp_uri, verify_totp_code @@ -14,8 +14,10 @@ from wiregui.auth.passwords import hash_password, verify_password from wiregui.auth.webauthn import create_registration_options, verify_registration from wiregui.db import async_session from wiregui.models.api_token import ApiToken +from wiregui.models.device import Device from wiregui.models.mfa_method import MFAMethod from wiregui.models.oidc_connection import OIDCConnection +from wiregui.models.rule import Rule from wiregui.models.user import User from wiregui.pages.layout import layout from wiregui.utils.time import utcnow @@ -31,358 +33,498 @@ async def account_page(): async with async_session() as session: user = await session.get(User, user_id) + device_count = (await session.execute( + select(func.count()).select_from(Device).where(Device.user_id == user_id) + )).scalar() + oidc_conns = (await session.execute( + select(OIDCConnection).where(OIDCConnection.user_id == user_id) + )).scalars().all() - with ui.column().classes("w-full p-4"): - ui.label("Account Settings").classes("text-h5 q-mb-md") + with ui.column().classes("w-full max-w-3xl mx-auto p-4"): + # Page header + ui.label("Account Settings").classes("text-h5 text-weight-medium") + ui.label("Manage your profile, security, and API access.").classes("text-caption text-grey-7 q-mb-lg") - with ui.tabs().classes("w-full") as tabs: - profile_tab = ui.tab("Profile") - mfa_tab = ui.tab("Two-Factor Auth") - tokens_tab = ui.tab("API Tokens") + # ===== Section 1: Account Details ===== + _render_details(user, device_count) - with ui.tab_panels(tabs, value=profile_tab).classes("w-full"): + # ===== Section 2: Change Password ===== + await _render_password_section(user_id, user.email) - # === Profile === - with ui.tab_panel(profile_tab): - with ui.card().classes("w-full"): - ui.label("Account Details").classes("text-subtitle1 text-bold") - ui.separator() - with ui.grid(columns=2).classes("w-full gap-2 q-pa-sm"): - ui.label("Email:").classes("text-bold") - ui.label(user.email) - ui.label("Role:").classes("text-bold") - ui.label(user.role) - ui.label("Last Sign-in:").classes("text-bold") - ui.label(str(user.last_signed_in_at)[:19] if user.last_signed_in_at else "-") - ui.label("Method:").classes("text-bold") - ui.label(user.last_signed_in_method or "-") + # ===== Section 3: Connected SSO Providers ===== + _render_sso_section(oidc_conns) - with ui.card().classes("w-full q-mt-md"): - ui.label("Change Password").classes("text-subtitle1 text-bold") - ui.separator() + # ===== Section 4: Multi-Factor Authentication ===== + await _render_mfa_section(user_id, user.email) - current_pw = ui.input("Current Password", password=True, password_toggle_button=True).props("outlined dense").classes("w-full") - new_pw = ui.input("New Password", password=True, password_toggle_button=True).props("outlined dense").classes("w-full") - confirm_pw = ui.input("Confirm New Password", password=True, password_toggle_button=True).props("outlined dense").classes("w-full") + # ===== Section 5: API Tokens ===== + await _render_tokens_section(user_id) - async def change_password(): - if not current_pw.value or not new_pw.value: - ui.notify("Fill in all password fields", type="negative") - return - if new_pw.value != confirm_pw.value: - ui.notify("New passwords do not match", type="negative") - return - if len(new_pw.value) < 8: - ui.notify("Password must be at least 8 characters", type="negative") - return - - async with async_session() as session: - u = await session.get(User, user_id) - if not verify_password(current_pw.value, u.password_hash): - ui.notify("Current password is incorrect", type="negative") - return - u.password_hash = hash_password(new_pw.value) - session.add(u) - await session.commit() - - logger.info("Password changed for {}", user.email) - ui.notify("Password changed", type="positive") - current_pw.value = "" - new_pw.value = "" - confirm_pw.value = "" - - ui.button("Change Password", on_click=change_password).props("color=primary").classes("q-mt-sm") - - # OIDC connections - async with async_session() as session: - oidc_conns = (await session.execute( - select(OIDCConnection).where(OIDCConnection.user_id == user_id) - )).scalars().all() - - if oidc_conns: - with ui.card().classes("w-full q-mt-md"): - ui.label("Connected SSO Providers").classes("text-subtitle1 text-bold") - ui.separator() - for conn in oidc_conns: - with ui.row().classes("w-full items-center justify-between q-pa-xs"): - ui.label(f"{conn.provider}").classes("text-bold") - ui.label(f"Last refreshed: {str(conn.refreshed_at)[:19] if conn.refreshed_at else 'Never'}") - - # === MFA === - with ui.tab_panel(mfa_tab): - await _render_mfa_panel(user_id, user.email) - - # === API Tokens === - with ui.tab_panel(tokens_tab): - await _render_tokens_panel(user_id) + # ===== Section 6: Danger Zone ===== + await _render_danger_zone(user_id, user.email, user.role) -async def _render_mfa_panel(user_id: UUID, email: str): - """Render the MFA management tab.""" - async def load_methods(): - async with async_session() as session: - result = await session.execute( - select(MFAMethod).where(MFAMethod.user_id == user_id).order_by(MFAMethod.inserted_at) - ) - return result.scalars().all() +def _render_details(user: User, device_count: int): + """Section 1: Account details table.""" + with ui.card().classes("w-full q-mt-lg"): + ui.label("Account Details").classes("text-h6 text-weight-medium q-pa-md q-pb-none") + ui.label("Your profile information.").classes("text-caption text-grey-7 q-px-md") + ui.separator() - async def refresh_methods(): - methods = await load_methods() - methods_container.clear() - with methods_container: - if methods: - for m in methods: - with ui.row().classes("w-full items-center justify-between q-pa-xs"): - with ui.row().classes("items-center gap-2"): - ui.icon("security").props("color=primary") - ui.label(m.name).classes("text-bold") - ui.label(f"({m.type})").classes("text-caption text-grey-7") - with ui.row().classes("items-center gap-2"): - ui.label(f"Last used: {str(m.last_used_at)[:19] if m.last_used_at else 'Never'}").classes("text-caption") - ui.button(icon="delete", on_click=lambda mid=m.id: delete_method(mid)).props("flat dense color=negative") - ui.separator() - else: - ui.label("No MFA methods configured.").classes("text-caption text-grey-7 q-pa-sm") + # Table-style layout + rows = [ + ("Email", user.email), + ("Role", ui.badge(user.role, color="primary" if user.role == "admin" else "grey").classes("text-xs")), + ("Last Sign-in", str(user.last_signed_in_at)[:19] if user.last_signed_in_at else "Never"), + ("Method", user.last_signed_in_method or "-"), + ("Devices", str(device_count)), + ("Created", str(user.inserted_at)[:19]), + ] - async def delete_method(method_id): - async with async_session() as session: - m = await session.get(MFAMethod, method_id) - if m and m.user_id == user_id: - await session.delete(m) - await session.commit() - logger.info("MFA method deleted for user {}", email) - ui.notify("MFA method removed") - await refresh_methods() + for i, (label, value) in enumerate(rows): + with ui.row().classes( + "w-full items-center px-4 py-2.5 hover:bg-grey-1 transition-colors" + + (" border-t" if i > 0 else "") + ): + ui.label(label).classes("w-40 text-weight-medium text-grey-8 text-sm") + if isinstance(value, str): + ui.label(value).classes("text-sm") + # Badge was already rendered inline - # Registration state - registration = {"secret": None} - def start_registration(): - secret = generate_totp_secret() - registration["secret"] = secret - uri = get_totp_uri(secret, email) - svg = generate_totp_qr_svg(uri) +async def _render_password_section(user_id: UUID, email: str): + """Section 2: Change password form.""" + with ui.card().classes("w-full q-mt-lg"): + ui.label("Change Password").classes("text-h6 text-weight-medium q-pa-md q-pb-none") + ui.label("Update your account password.").classes("text-caption text-grey-7 q-px-md") + ui.separator() - reg_container.clear() - with reg_container: - ui.label("Scan this QR code with your authenticator app:").classes("text-body2") - ui.html(svg).classes("w-64 q-my-sm") - ui.label(f"Or enter this secret manually: {secret}").classes("text-caption font-mono") - reg_name_input = ui.input("Method Name", value="Authenticator").props("outlined dense").classes("w-full") - reg_code_input = ui.input("Verification Code", placeholder="Enter 6-digit code").props("outlined dense maxlength=6").classes("w-full") + with ui.column().classes("w-full q-pa-md gap-3"): + current_pw = ui.input( + "Current Password", password=True, password_toggle_button=True, + ).props("outlined dense").classes("w-full") + new_pw = ui.input( + "New Password", password=True, password_toggle_button=True, + ).props("outlined dense").classes("w-full") + confirm_pw = ui.input( + "Confirm New Password", password=True, password_toggle_button=True, + ).props("outlined dense").classes("w-full") - async def verify_and_save(): - code = reg_code_input.value.strip() - name = reg_name_input.value.strip() or "Authenticator" - if not verify_totp_code(registration["secret"], code): - ui.notify("Invalid code — check your authenticator", type="negative") + pw_hint = ui.label("").classes("text-caption text-negative").style("display: none") + + async def change_password(): + pw_hint.style("display: none") + if not current_pw.value or not new_pw.value: + pw_hint.text = "All password fields are required." + pw_hint.style("display: block") + return + if new_pw.value != confirm_pw.value: + pw_hint.text = "New passwords do not match." + pw_hint.style("display: block") + return + if len(new_pw.value) < 8: + pw_hint.text = "Password must be at least 8 characters." + pw_hint.style("display: block") return async with async_session() as session: - method = MFAMethod( - name=name, - type="totp", - payload={"secret": registration["secret"]}, - user_id=user_id, - ) - session.add(method) + u = await session.get(User, user_id) + if not verify_password(current_pw.value, u.password_hash): + pw_hint.text = "Current password is incorrect." + pw_hint.style("display: block") + return + u.password_hash = hash_password(new_pw.value) + session.add(u) await session.commit() - logger.info("MFA TOTP registered for {}", email) - ui.notify("MFA method added!", type="positive") - registration["secret"] = None - reg_container.clear() - await refresh_methods() + logger.info("Password changed for {}", email) + ui.notify("Password changed successfully", type="positive") + current_pw.value = "" + new_pw.value = "" + confirm_pw.value = "" - ui.button("Verify & Save", on_click=verify_and_save).props("color=primary").classes("q-mt-sm") - ui.button("Cancel", on_click=lambda: reg_container.clear()).props("flat") + ui.button("Change Password", on_click=change_password).props("color=primary unelevated") - with ui.card().classes("w-full"): - ui.label("Two-Factor Authentication Methods").classes("text-subtitle1 text-bold") + +def _render_sso_section(oidc_conns: list[OIDCConnection]): + """Section 3: Connected SSO providers.""" + with ui.card().classes("w-full q-mt-lg"): + ui.label("Connected SSO Providers").classes("text-h6 text-weight-medium q-pa-md q-pb-none") + ui.label("Single sign-on accounts linked to your profile.").classes("text-caption text-grey-7 q-px-md") + ui.separator() + + if oidc_conns: + for i, conn in enumerate(oidc_conns): + with ui.row().classes( + "w-full items-center justify-between px-4 py-3 hover:bg-grey-1 transition-colors" + + (" border-t" if i > 0 else "") + ): + with ui.row().classes("items-center gap-3"): + ui.icon("login").props("color=primary size=sm") + ui.label(conn.provider).classes("text-weight-medium text-sm") + with ui.row().classes("items-center gap-2"): + refreshed = str(conn.refreshed_at)[:19] if conn.refreshed_at else "Never" + ui.label(f"Last refreshed: {refreshed}").classes("text-caption text-grey-7") + ui.badge("Connected", color="positive").classes("text-xs") + else: + with ui.row().classes("w-full items-center justify-center q-pa-lg"): + ui.icon("link_off").props("color=grey-5 size=lg") + ui.label("No SSO providers connected.").classes("text-caption text-grey-5 q-ml-sm") + + +async def _render_mfa_section(user_id: UUID, email: str): + """Section 4: Multi-factor authentication methods.""" + with ui.card().classes("w-full q-mt-lg") as mfa_card: + ui.label("Multi-Factor Authentication").classes("text-h6 text-weight-medium q-pa-md q-pb-none") + ui.label("Add an extra layer of security to your account.").classes("text-caption text-grey-7 q-px-md") ui.separator() methods_container = ui.column().classes("w-full") - await refresh_methods() + reg_container = ui.column().classes("w-full") + registration = {"secret": None} + webauthn_state = {"challenge": None} - with ui.row().classes("q-mt-sm gap-2"): - ui.button("Add TOTP Method", icon="add", on_click=start_registration).props("outline") - ui.button("Add Security Key", icon="key", on_click=lambda: start_webauthn_registration()).props("outline") + async def load_methods(): + async with async_session() as session: + result = await session.execute( + select(MFAMethod).where(MFAMethod.user_id == user_id).order_by(MFAMethod.inserted_at) + ) + return result.scalars().all() - reg_container = ui.column().classes("w-full q-mt-md") - webauthn_state = {"challenge": None} + async def refresh_methods(): + methods = await load_methods() + methods_container.clear() + with methods_container: + if methods: + for i, m in enumerate(methods): + with ui.row().classes( + "w-full items-center justify-between px-4 py-3 hover:bg-grey-1 transition-colors" + + (" border-t" if i > 0 else "") + ): + with ui.row().classes("items-center gap-3"): + icon = "fingerprint" if m.type in ("native", "portable") else "security" + ui.icon(icon).props("color=primary size=sm") + with ui.column().classes("gap-0"): + ui.label(m.name).classes("text-weight-medium text-sm") + ui.label(m.type.upper()).classes("text-caption text-grey-7") + with ui.row().classes("items-center gap-3"): + last_used = str(m.last_used_at)[:19] if m.last_used_at else "Never" + ui.label(f"Last used: {last_used}").classes("text-caption text-grey-7") + ui.button(icon="delete", on_click=lambda mid=m.id: confirm_delete_mfa(mid)).props( + "flat dense round color=negative size=sm" + ) + else: + with ui.row().classes("w-full items-center justify-center q-pa-lg"): + ui.icon("shield").props("color=grey-5 size=lg") + ui.label("No MFA methods configured.").classes("text-caption text-grey-5 q-ml-sm") - async def start_webauthn_registration(): - # Get existing webauthn credentials to exclude - existing = [] - async with async_session() as session: - from sqlmodel import select as sel - result = await session.execute( - sel(MFAMethod).where(MFAMethod.user_id == user_id, MFAMethod.type.in_(["native", "portable"])) - ) - for m in result.scalars().all(): - existing.append(m.payload) + async def confirm_delete_mfa(method_id): + with ui.dialog(value=True) as dlg: + with ui.card().classes("w-80"): + ui.label("Remove MFA Method?").classes("text-h6") + ui.label("You will no longer be prompted for this method during sign-in.").classes("text-body2") + with ui.row().classes("w-full justify-end q-mt-sm"): + ui.button("Cancel", on_click=dlg.close).props("flat") + ui.button("Remove", on_click=lambda: _do_delete_mfa(method_id, dlg)).props("color=negative unelevated") - try: - reg_data = create_registration_options(user_id, email, existing) - except Exception as e: - ui.notify(f"WebAuthn not available: {e}", type="negative") - return + async def _do_delete_mfa(method_id, dlg): + async with async_session() as session: + m = await session.get(MFAMethod, method_id) + if m and m.user_id == user_id: + await session.delete(m) + await session.commit() + logger.info("MFA method deleted for user {}", email) + dlg.close() + ui.notify("MFA method removed") + await refresh_methods() - webauthn_state["challenge"] = reg_data["challenge"] - options_json = reg_data["options_json"] + def start_totp_registration(): + secret = generate_totp_secret() + registration["secret"] = secret + uri = get_totp_uri(secret, email) + svg = generate_totp_qr_svg(uri) - # Call browser's navigator.credentials.create() via JavaScript - js = f""" - async function() {{ - try {{ - const options = JSON.parse('{options_json}'); - // Convert base64url strings to ArrayBuffers - options.challenge = Uint8Array.from(atob(options.challenge.replace(/-/g,'+').replace(/_/g,'/')), c => c.charCodeAt(0)); - options.user.id = Uint8Array.from(atob(options.user.id.replace(/-/g,'+').replace(/_/g,'/')), c => c.charCodeAt(0)); - if (options.excludeCredentials) {{ - options.excludeCredentials = options.excludeCredentials.map(c => ({{ - ...c, - id: Uint8Array.from(atob(c.id.replace(/-/g,'+').replace(/_/g,'/')), ch => ch.charCodeAt(0)) - }})); + reg_container.clear() + with reg_container: + with ui.card().classes("w-full q-mt-sm").props("bordered"): + ui.label("Set up TOTP Authenticator").classes("text-subtitle1 text-weight-medium q-pa-md q-pb-none") + ui.separator() + with ui.column().classes("q-pa-md items-center gap-3"): + ui.label("Scan this QR code with your authenticator app:").classes("text-body2") + ui.html(svg).classes("w-48") + with ui.row().classes("items-center gap-2"): + ui.label("Manual entry:").classes("text-caption text-grey-7") + ui.label(secret).classes("text-caption font-mono bg-grey-2 px-2 py-1 rounded") + ui.separator() + with ui.column().classes("q-pa-md gap-3"): + reg_name = ui.input("Method Name", value="Authenticator").props("outlined dense").classes("w-full") + reg_code = ui.input( + "Verification Code", placeholder="Enter 6-digit code", + ).props("outlined dense maxlength=6").classes("w-full") + + async def verify_and_save(): + code = reg_code.value.strip() + name = reg_name.value.strip() or "Authenticator" + if not verify_totp_code(registration["secret"], code): + ui.notify("Invalid code — check your authenticator", type="negative") + return + async with async_session() as session: + method = MFAMethod( + name=name, type="totp", + payload={"secret": registration["secret"]}, + user_id=user_id, + ) + session.add(method) + await session.commit() + logger.info("MFA TOTP registered for {}", email) + ui.notify("TOTP method added!", type="positive") + registration["secret"] = None + reg_container.clear() + await refresh_methods() + + with ui.row().classes("gap-2"): + ui.button("Verify & Save", on_click=verify_and_save).props("color=primary unelevated") + ui.button("Cancel", on_click=lambda: reg_container.clear()).props("flat") + + async def start_webauthn_registration(): + existing = [] + async with async_session() as session: + result = await session.execute( + select(MFAMethod).where(MFAMethod.user_id == user_id, MFAMethod.type.in_(["native", "portable"])) + ) + for m in result.scalars().all(): + existing.append(m.payload) + + try: + reg_data = create_registration_options(user_id, email, existing) + except Exception as e: + ui.notify(f"WebAuthn not available: {e}", type="negative") + return + + webauthn_state["challenge"] = reg_data["challenge"] + options_json = reg_data["options_json"] + + js = f""" + async function() {{ + try {{ + const options = JSON.parse('{options_json}'); + options.challenge = Uint8Array.from(atob(options.challenge.replace(/-/g,'+').replace(/_/g,'/')), c => c.charCodeAt(0)); + options.user.id = Uint8Array.from(atob(options.user.id.replace(/-/g,'+').replace(/_/g,'/')), c => c.charCodeAt(0)); + if (options.excludeCredentials) {{ + options.excludeCredentials = options.excludeCredentials.map(c => ({{ + ...c, + id: Uint8Array.from(atob(c.id.replace(/-/g,'+').replace(/_/g,'/')), ch => ch.charCodeAt(0)) + }})); + }} + const credential = await navigator.credentials.create({{publicKey: options}}); + const response = {{ + id: credential.id, + rawId: btoa(String.fromCharCode(...new Uint8Array(credential.rawId))).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=/g,''), + type: credential.type, + response: {{ + attestationObject: btoa(String.fromCharCode(...new Uint8Array(credential.response.attestationObject))).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=/g,''), + clientDataJSON: btoa(String.fromCharCode(...new Uint8Array(credential.response.clientDataJSON))).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=/g,''), + }}, + }}; + return JSON.stringify(response); + }} catch(e) {{ + return JSON.stringify({{"error": e.message}}); }} - const credential = await navigator.credentials.create({{publicKey: options}}); - // Serialize the response - const response = {{ - id: credential.id, - rawId: btoa(String.fromCharCode(...new Uint8Array(credential.rawId))).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=/g,''), - type: credential.type, - response: {{ - attestationObject: btoa(String.fromCharCode(...new Uint8Array(credential.response.attestationObject))).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=/g,''), - clientDataJSON: btoa(String.fromCharCode(...new Uint8Array(credential.response.clientDataJSON))).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=/g,''), - }}, - }}; - return JSON.stringify(response); - }} catch(e) {{ - return JSON.stringify({{"error": e.message}}); }} - }} - """ - result = await ui.run_javascript(f"({js})()") - await _handle_webauthn_response(result) + """ + result = await ui.run_javascript(f"({js})()") + await _handle_webauthn_response(result) - async def _handle_webauthn_response(result_json: str): - try: - result = json.loads(result_json) - except (json.JSONDecodeError, TypeError): - ui.notify("WebAuthn response error", type="negative") - return + async def _handle_webauthn_response(result_json: str): + try: + result = json.loads(result_json) + except (json.JSONDecodeError, TypeError): + ui.notify("WebAuthn response error", type="negative") + return + if "error" in result: + ui.notify(f"WebAuthn failed: {result['error']}", type="negative") + return + challenge = webauthn_state.get("challenge") + if not challenge: + ui.notify("No pending WebAuthn challenge", type="negative") + return + try: + credential_data = verify_registration(result_json, challenge) + except Exception as e: + ui.notify(f"Verification failed: {e}", type="negative") + return + async with async_session() as session: + method = MFAMethod( + name="Security Key", type="portable", + payload=credential_data, user_id=user_id, + ) + session.add(method) + await session.commit() + logger.info("WebAuthn key registered for {}", email) + ui.notify("Security key registered!", type="positive") + webauthn_state["challenge"] = None + await refresh_methods() - if "error" in result: - ui.notify(f"WebAuthn failed: {result['error']}", type="negative") - return - - challenge = webauthn_state.get("challenge") - if not challenge: - ui.notify("No pending WebAuthn challenge", type="negative") - return - - try: - credential_data = verify_registration(result_json, challenge) - except Exception as e: - ui.notify(f"Verification failed: {e}", type="negative") - return - - async with async_session() as session: - method = MFAMethod( - name="Security Key", - type="portable", - payload=credential_data, - user_id=user_id, - ) - session.add(method) - await session.commit() - - logger.info("WebAuthn key registered for {}", email) - ui.notify("Security key registered!", type="positive") - webauthn_state["challenge"] = None await refresh_methods() + with ui.row().classes("q-pa-md gap-2"): + ui.button("Add TOTP Method", icon="qr_code", on_click=start_totp_registration).props("outline unelevated") + ui.button("Add Security Key", icon="key", on_click=lambda: start_webauthn_registration()).props("outline unelevated") -async def _render_tokens_panel(user_id: UUID): - """Render the API tokens tab.""" - async def load_tokens(): - async with async_session() as session: - result = await session.execute( - select(ApiToken).where(ApiToken.user_id == user_id).order_by(ApiToken.inserted_at.desc()) - ) - return result.scalars().all() - async def refresh_tokens(): - tokens = await load_tokens() - token_table.rows = [ - { - "id": str(t.id), - "created": str(t.inserted_at)[:19], - "expires": str(t.expires_at)[:19] if t.expires_at else "Never", - "status": "Expired" if t.expires_at and t.expires_at < utcnow() else "Active", - } - for t in tokens - ] - token_table.update() - - async def create_token(): - from datetime import timedelta - days = int(token_days.value) if token_days.value else 30 - - plaintext, token_hash = generate_api_token() - expires_at = utcnow() + timedelta(days=days) if days > 0 else None - - async with async_session() as session: - token = ApiToken(token_hash=token_hash, expires_at=expires_at, user_id=user_id) - session.add(token) - await session.commit() - - logger.info("API token created (expires in {} days)", days) - - # Show the token once - with ui.dialog(value=True) as token_dialog: - with ui.card().classes("w-96"): - ui.label("API Token Created").classes("text-h6") - ui.label("Copy this token now — it won't be shown again.").classes("text-caption text-negative") - ui.input(value=plaintext).props("readonly outlined dense").classes("w-full font-mono q-mt-sm") - ui.button("Close", on_click=token_dialog.close).props("flat").classes("w-full q-mt-sm") - - await refresh_tokens() - - async def delete_token(token_id: str): - async with async_session() as session: - t = await session.get(ApiToken, UUID(token_id)) - if t and t.user_id == user_id: - await session.delete(t) - await session.commit() - ui.notify("Token deleted") - await refresh_tokens() - - with ui.card().classes("w-full"): - ui.label("API Tokens").classes("text-subtitle1 text-bold") +async def _render_tokens_section(user_id: UUID): + """Section 5: API tokens management.""" + with ui.card().classes("w-full q-mt-lg"): + ui.label("API Tokens").classes("text-h6 text-weight-medium q-pa-md q-pb-none") + ui.label("Use tokens for programmatic access to the REST API.").classes("text-caption text-grey-7 q-px-md") ui.separator() - ui.label("Use API tokens for programmatic access to the REST API.").classes("text-caption text-grey-7") - token_columns = [ - {"name": "created", "label": "Created", "field": "created", "align": "left"}, - {"name": "expires", "label": "Expires", "field": "expires", "align": "left"}, - {"name": "status", "label": "Status", "field": "status", "align": "left"}, - {"name": "actions", "label": "", "field": "id", "align": "center"}, - ] - token_table = ui.table(columns=token_columns, rows=[], row_key="id").classes("w-full") - token_table.add_slot( - "body-cell-actions", - ''' - - - - ''', - ) - token_table.on("delete", lambda e: delete_token(e.args)) + token_banner = ui.column().classes("w-full") + tokens_container = ui.column().classes("w-full") - with ui.row().classes("items-center gap-2 q-mt-sm"): + async def load_tokens(): + async with async_session() as session: + result = await session.execute( + select(ApiToken).where(ApiToken.user_id == user_id).order_by(ApiToken.inserted_at.desc()) + ) + return result.scalars().all() + + async def refresh_tokens(): + tokens = await load_tokens() + tokens_container.clear() + with tokens_container: + if tokens: + for i, t in enumerate(tokens): + is_expired = t.expires_at and t.expires_at < utcnow() + with ui.row().classes( + "w-full items-center justify-between px-4 py-3 hover:bg-grey-1 transition-colors" + + (" border-t" if i > 0 else "") + ): + with ui.row().classes("items-center gap-3"): + ui.icon("vpn_key").props(f"color={'grey-5' if is_expired else 'primary'} size=sm") + with ui.column().classes("gap-0"): + ui.label(f"Created {str(t.inserted_at)[:19]}").classes("text-sm") + expires_text = str(t.expires_at)[:19] if t.expires_at else "Never expires" + ui.label(f"Expires: {expires_text}").classes("text-caption text-grey-7") + with ui.row().classes("items-center gap-2"): + if is_expired: + ui.badge("Expired", color="negative").classes("text-xs") + else: + ui.badge("Active", color="positive").classes("text-xs") + ui.button(icon="delete", on_click=lambda tid=t.id: delete_token(tid)).props( + "flat dense round color=negative size=sm" + ) + else: + with ui.row().classes("w-full items-center justify-center q-pa-lg"): + ui.icon("vpn_key").props("color=grey-5 size=lg") + ui.label("No API tokens created yet.").classes("text-caption text-grey-5 q-ml-sm") + + async def create_token(): + days = int(token_days.value) if token_days.value else 30 + plaintext, token_hash = generate_api_token() + expires_at = utcnow() + timedelta(days=days) if days > 0 else None + + async with async_session() as session: + token = ApiToken(token_hash=token_hash, expires_at=expires_at, user_id=user_id) + session.add(token) + await session.commit() + + logger.info("API token created (expires in {} days)", days) + + # Show token in a banner + token_banner.clear() + with token_banner: + with ui.card().classes("w-full bg-green-1 q-ma-md").props("bordered"): + with ui.row().classes("items-center q-pa-sm gap-2"): + ui.icon("check_circle").props("color=positive") + ui.label("Token created — copy it now, it won't be shown again.").classes("text-sm text-weight-medium") + with ui.row().classes("q-pa-sm q-pt-none items-center gap-2"): + token_input = ui.input(value=plaintext).props("readonly outlined dense").classes("w-full font-mono text-xs") + ui.button(icon="content_copy", on_click=lambda: _copy_token(plaintext)).props("flat dense") + + await refresh_tokens() + + async def _copy_token(token: str): + await ui.run_javascript(f"navigator.clipboard.writeText('{token}')") + ui.notify("Copied to clipboard", type="positive") + + async def delete_token(token_id): + async with async_session() as session: + t = await session.get(ApiToken, token_id) + if t and t.user_id == user_id: + await session.delete(t) + await session.commit() + ui.notify("Token deleted") + await refresh_tokens() + + await refresh_tokens() + + ui.separator() + with ui.row().classes("items-center gap-2 q-pa-md"): token_days = ui.input("Expires in (days)", value="30").props("outlined dense").classes("w-40") - ui.button("Create Token", icon="add", on_click=create_token).props("color=primary") + ui.button("Create Token", icon="add", on_click=create_token).props("color=primary unelevated") - await refresh_tokens() + +async def _render_danger_zone(user_id: UUID, email: str, role: str): + """Section 6: Danger zone — account deletion.""" + with ui.card().classes("w-full q-mt-lg").style("border-left: 4px solid var(--q-negative)"): + ui.label("Danger Zone").classes("text-h6 text-weight-medium text-negative q-pa-md q-pb-none") + ui.label("Irreversible actions for your account.").classes("text-caption text-grey-7 q-px-md") + ui.separator() + + with ui.column().classes("q-pa-md"): + # Check if user is the only admin + async with async_session() as session: + admin_count = (await session.execute( + select(func.count()).select_from(User).where(User.role == "admin") + )).scalar() + + is_only_admin = role == "admin" and admin_count <= 1 + + if is_only_admin: + ui.label("You are the only admin — account deletion is disabled.").classes("text-caption text-grey-7") + + async def confirm_delete(): + with ui.dialog(value=True) as dlg: + with ui.card().classes("w-96"): + ui.label("Delete Your Account?").classes("text-h6 text-negative") + ui.label("This will permanently delete your account, all your devices, and firewall rules. This action cannot be undone.").classes("text-body2 q-my-sm") + ui.label(f"Type your email to confirm: {email}").classes("text-caption text-weight-medium") + confirm_input = ui.input(placeholder=email).props("outlined dense").classes("w-full") + + async def do_delete(): + if confirm_input.value.strip() != email: + ui.notify("Email does not match", type="negative") + return + async with async_session() as session: + # Delete devices + devices = (await session.execute( + select(Device).where(Device.user_id == user_id) + )).scalars().all() + for d in devices: + await session.delete(d) + # Delete rules + rules = (await session.execute( + select(Rule).where(Rule.user_id == user_id) + )).scalars().all() + for r in rules: + await session.delete(r) + # Delete user + u = await session.get(User, user_id) + if u: + await session.delete(u) + await session.commit() + + logger.info("User {} deleted their own account", email) + dlg.close() + app.storage.user.clear() + ui.navigate.to("/login") + + with ui.row().classes("w-full justify-end q-mt-sm"): + ui.button("Cancel", on_click=dlg.close).props("flat") + ui.button("Delete My Account", on_click=do_delete).props("color=negative unelevated") + + ui.button( + "Delete Your Account", icon="delete_forever", + on_click=confirm_delete, + ).props("color=negative outline" + (" disable" if is_only_admin else "")) diff --git a/wiregui/tasks/connectivity.py b/wiregui/tasks/connectivity.py index 9cac99b..25366a2 100644 --- a/wiregui/tasks/connectivity.py +++ b/wiregui/tasks/connectivity.py @@ -12,7 +12,7 @@ from wiregui.models.connectivity_check import ConnectivityCheck from wiregui.services import notifications from wiregui.utils.time import utcnow -DEFAULT_URL = "https://ping-dev.firezone.dev" +DEFAULT_URL = "https://one.one.one.one/cdn-cgi/trace" DEFAULT_INTERVAL = 300 # 5 minutes From 5aff71ec4cab05362b4d275f771ffb01aa603a4e Mon Sep 17 00:00:00 2001 From: Stefano Bertelli Date: Mon, 30 Mar 2026 18:47:07 -0500 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20redesign=20account=20page=20?= =?UTF-8?q?=E2=80=94=20compact=20Firezone-style=20layout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove tabbed layout, stack all sections vertically - Compact key-value rows for user details - Dense bordered tables for API tokens and MFA methods - Consistent button styling with proper padding (BTN_PRIMARY/OUTLINE/DANGER) - Token creation with inline copy-to-clipboard banner - TOTP registration with compact inline QR + form - Danger zone with typed-email confirmation dialog - Add .idea and .coverage to gitignore - Fix CI: add node:20-slim container to release job - Fix connectivity check URL to Cloudflare endpoint --- .coverage | Bin 122880 -> 0 bytes .gitignore | 2 + .idea/.gitignore | 10 - .idea/misc.xml | 7 - .idea/vcs.xml | 4 - wiregui/pages/account.py | 695 ++++++++++++++------------------------- 6 files changed, 257 insertions(+), 461 deletions(-) delete mode 100644 .coverage delete mode 100644 .idea/.gitignore delete mode 100644 .idea/misc.xml delete mode 100644 .idea/vcs.xml diff --git a/.coverage b/.coverage deleted file mode 100644 index 474d9804b438c2c3ae5f8229b7871a56394e5af4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 122880 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ΞJsq-}+0kA`ZpFf{6u>o&Mo(JM(04Hkw* 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 KJ8H1600saYM{))L diff --git a/.gitignore b/.gitignore index 3c981dd..f59019a 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ __pycache__/ .env .nicegui/ logs/ +.idea/ +.coverage diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index ab1f416..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,10 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml -# Ignored default folder with query files -/queries/ -# Datasource local storage ignored files -/dataSources/ -/dataSources.local.xml -# Editor-based HTTP Client requests -/httpRequests/ diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index 58834d0..0000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - {} - { - "isMigrated": true -} - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index d843f34..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/wiregui/pages/account.py b/wiregui/pages/account.py index e96bf5e..941b849 100644 --- a/wiregui/pages/account.py +++ b/wiregui/pages/account.py @@ -1,4 +1,4 @@ -"""User account page — single scrollable page with all account sections.""" +"""User account page — compact, single-page layout matching Firezone's density.""" import json from datetime import timedelta @@ -23,6 +23,28 @@ from wiregui.pages.layout import layout from wiregui.utils.time import utcnow +def _section_header(title: str, description: str = ""): + """Compact section header — bold title + optional subtitle.""" + ui.label(title).classes("text-lg text-weight-bold q-mt-lg q-mb-none") + if description: + ui.label(description).classes("text-caption text-grey-7 q-mb-sm") + + +def _kv_row(label: str, value): + """Compact key-value row.""" + with ui.row().classes("w-full items-baseline gap-4"): + ui.label(label).classes("text-sm text-grey-8 w-36 shrink-0") + if isinstance(value, str): + ui.label(value).classes("text-sm") + + +# Consistent button style: proper padding, readable size, no cramped text +BTN = "unelevated padding=8px 20px" +BTN_PRIMARY = f"color=primary {BTN}" +BTN_OUTLINE = f"outline color=primary {BTN}" +BTN_DANGER = f"color=negative {BTN}" + + @ui.page("/account") async def account_page(): if not app.storage.user.get("authenticated"): @@ -36,421 +58,99 @@ async def account_page(): device_count = (await session.execute( select(func.count()).select_from(Device).where(Device.user_id == user_id) )).scalar() + rule_count = (await session.execute( + select(func.count()).select_from(Rule).where(Rule.user_id == user_id) + )).scalar() oidc_conns = (await session.execute( select(OIDCConnection).where(OIDCConnection.user_id == user_id) )).scalars().all() - with ui.column().classes("w-full max-w-3xl mx-auto p-4"): - # Page header - ui.label("Account Settings").classes("text-h5 text-weight-medium") - ui.label("Manage your profile, security, and API access.").classes("text-caption text-grey-7 q-mb-lg") + with ui.column().classes("w-full p-6"): + ui.label("Account Settings").classes("text-sm text-weight-bold") + ui.label("Configure settings related to your WireGUI account.").classes("text-caption text-grey-7") - # ===== Section 1: Account Details ===== - _render_details(user, device_count) + # ===== Details ===== + _section_header("Details") + with ui.column().classes("gap-1"): + _kv_row("Email", user.email) + _kv_row("Role", user.role) + _kv_row("Last Signed In", str(user.last_signed_in_at)[:19] if user.last_signed_in_at else "-") + _kv_row("Created", str(user.inserted_at)[:19]) + _kv_row("Number of Devices", str(device_count)) + _kv_row("Number of Rules", str(rule_count)) - # ===== Section 2: Change Password ===== - await _render_password_section(user_id, user.email) - - # ===== Section 3: Connected SSO Providers ===== - _render_sso_section(oidc_conns) - - # ===== Section 4: Multi-Factor Authentication ===== - await _render_mfa_section(user_id, user.email) - - # ===== Section 5: API Tokens ===== - await _render_tokens_section(user_id) - - # ===== Section 6: Danger Zone ===== - await _render_danger_zone(user_id, user.email, user.role) - - -def _render_details(user: User, device_count: int): - """Section 1: Account details table.""" - with ui.card().classes("w-full q-mt-lg"): - ui.label("Account Details").classes("text-h6 text-weight-medium q-pa-md q-pb-none") - ui.label("Your profile information.").classes("text-caption text-grey-7 q-px-md") - ui.separator() - - # Table-style layout - rows = [ - ("Email", user.email), - ("Role", ui.badge(user.role, color="primary" if user.role == "admin" else "grey").classes("text-xs")), - ("Last Sign-in", str(user.last_signed_in_at)[:19] if user.last_signed_in_at else "Never"), - ("Method", user.last_signed_in_method or "-"), - ("Devices", str(device_count)), - ("Created", str(user.inserted_at)[:19]), - ] - - for i, (label, value) in enumerate(rows): - with ui.row().classes( - "w-full items-center px-4 py-2.5 hover:bg-grey-1 transition-colors" - + (" border-t" if i > 0 else "") - ): - ui.label(label).classes("w-40 text-weight-medium text-grey-8 text-sm") - if isinstance(value, str): - ui.label(value).classes("text-sm") - # Badge was already rendered inline - - -async def _render_password_section(user_id: UUID, email: str): - """Section 2: Change password form.""" - with ui.card().classes("w-full q-mt-lg"): - ui.label("Change Password").classes("text-h6 text-weight-medium q-pa-md q-pb-none") - ui.label("Update your account password.").classes("text-caption text-grey-7 q-px-md") - ui.separator() - - with ui.column().classes("w-full q-pa-md gap-3"): - current_pw = ui.input( - "Current Password", password=True, password_toggle_button=True, - ).props("outlined dense").classes("w-full") - new_pw = ui.input( - "New Password", password=True, password_toggle_button=True, - ).props("outlined dense").classes("w-full") - confirm_pw = ui.input( - "Confirm New Password", password=True, password_toggle_button=True, - ).props("outlined dense").classes("w-full") - - pw_hint = ui.label("").classes("text-caption text-negative").style("display: none") - - async def change_password(): - pw_hint.style("display: none") - if not current_pw.value or not new_pw.value: - pw_hint.text = "All password fields are required." - pw_hint.style("display: block") - return - if new_pw.value != confirm_pw.value: - pw_hint.text = "New passwords do not match." - pw_hint.style("display: block") - return - if len(new_pw.value) < 8: - pw_hint.text = "Password must be at least 8 characters." - pw_hint.style("display: block") - return - - async with async_session() as session: - u = await session.get(User, user_id) - if not verify_password(current_pw.value, u.password_hash): - pw_hint.text = "Current password is incorrect." - pw_hint.style("display: block") - return - u.password_hash = hash_password(new_pw.value) - session.add(u) - await session.commit() - - logger.info("Password changed for {}", email) - ui.notify("Password changed successfully", type="positive") - current_pw.value = "" - new_pw.value = "" - confirm_pw.value = "" - - ui.button("Change Password", on_click=change_password).props("color=primary unelevated") - - -def _render_sso_section(oidc_conns: list[OIDCConnection]): - """Section 3: Connected SSO providers.""" - with ui.card().classes("w-full q-mt-lg"): - ui.label("Connected SSO Providers").classes("text-h6 text-weight-medium q-pa-md q-pb-none") - ui.label("Single sign-on accounts linked to your profile.").classes("text-caption text-grey-7 q-px-md") - ui.separator() + ui.button("Change Email or Password", icon="edit", on_click=lambda: pw_dialog.open()).props( + BTN_PRIMARY + ).classes("q-mt-sm") + # ===== SSO ===== if oidc_conns: - for i, conn in enumerate(oidc_conns): - with ui.row().classes( - "w-full items-center justify-between px-4 py-3 hover:bg-grey-1 transition-colors" - + (" border-t" if i > 0 else "") - ): - with ui.row().classes("items-center gap-3"): - ui.icon("login").props("color=primary size=sm") - ui.label(conn.provider).classes("text-weight-medium text-sm") - with ui.row().classes("items-center gap-2"): - refreshed = str(conn.refreshed_at)[:19] if conn.refreshed_at else "Never" - ui.label(f"Last refreshed: {refreshed}").classes("text-caption text-grey-7") - ui.badge("Connected", color="positive").classes("text-xs") - else: - with ui.row().classes("w-full items-center justify-center q-pa-lg"): - ui.icon("link_off").props("color=grey-5 size=lg") - ui.label("No SSO providers connected.").classes("text-caption text-grey-5 q-ml-sm") + _section_header("Connected SSO Providers") + cols = [ + {"name": "provider", "label": "Provider", "field": "provider", "align": "left"}, + {"name": "refreshed", "label": "Last Refreshed", "field": "refreshed", "align": "left"}, + ] + rows = [{"provider": c.provider, "refreshed": str(c.refreshed_at)[:19] if c.refreshed_at else "Never"} for c in oidc_conns] + ui.table(columns=cols, rows=rows, row_key="provider").props("dense flat bordered").classes("w-full text-xs") + # ===== API Tokens ===== + _section_header("API Tokens", "Manage API tokens.") -async def _render_mfa_section(user_id: UUID, email: str): - """Section 4: Multi-factor authentication methods.""" - with ui.card().classes("w-full q-mt-lg") as mfa_card: - ui.label("Multi-Factor Authentication").classes("text-h6 text-weight-medium q-pa-md q-pb-none") - ui.label("Add an extra layer of security to your account.").classes("text-caption text-grey-7 q-px-md") - ui.separator() - - methods_container = ui.column().classes("w-full") - reg_container = ui.column().classes("w-full") - registration = {"secret": None} - webauthn_state = {"challenge": None} - - async def load_methods(): - async with async_session() as session: - result = await session.execute( - select(MFAMethod).where(MFAMethod.user_id == user_id).order_by(MFAMethod.inserted_at) - ) - return result.scalars().all() - - async def refresh_methods(): - methods = await load_methods() - methods_container.clear() - with methods_container: - if methods: - for i, m in enumerate(methods): - with ui.row().classes( - "w-full items-center justify-between px-4 py-3 hover:bg-grey-1 transition-colors" - + (" border-t" if i > 0 else "") - ): - with ui.row().classes("items-center gap-3"): - icon = "fingerprint" if m.type in ("native", "portable") else "security" - ui.icon(icon).props("color=primary size=sm") - with ui.column().classes("gap-0"): - ui.label(m.name).classes("text-weight-medium text-sm") - ui.label(m.type.upper()).classes("text-caption text-grey-7") - with ui.row().classes("items-center gap-3"): - last_used = str(m.last_used_at)[:19] if m.last_used_at else "Never" - ui.label(f"Last used: {last_used}").classes("text-caption text-grey-7") - ui.button(icon="delete", on_click=lambda mid=m.id: confirm_delete_mfa(mid)).props( - "flat dense round color=negative size=sm" - ) - else: - with ui.row().classes("w-full items-center justify-center q-pa-lg"): - ui.icon("shield").props("color=grey-5 size=lg") - ui.label("No MFA methods configured.").classes("text-caption text-grey-5 q-ml-sm") - - async def confirm_delete_mfa(method_id): - with ui.dialog(value=True) as dlg: - with ui.card().classes("w-80"): - ui.label("Remove MFA Method?").classes("text-h6") - ui.label("You will no longer be prompted for this method during sign-in.").classes("text-body2") - with ui.row().classes("w-full justify-end q-mt-sm"): - ui.button("Cancel", on_click=dlg.close).props("flat") - ui.button("Remove", on_click=lambda: _do_delete_mfa(method_id, dlg)).props("color=negative unelevated") - - async def _do_delete_mfa(method_id, dlg): - async with async_session() as session: - m = await session.get(MFAMethod, method_id) - if m and m.user_id == user_id: - await session.delete(m) - await session.commit() - logger.info("MFA method deleted for user {}", email) - dlg.close() - ui.notify("MFA method removed") - await refresh_methods() - - def start_totp_registration(): - secret = generate_totp_secret() - registration["secret"] = secret - uri = get_totp_uri(secret, email) - svg = generate_totp_qr_svg(uri) - - reg_container.clear() - with reg_container: - with ui.card().classes("w-full q-mt-sm").props("bordered"): - ui.label("Set up TOTP Authenticator").classes("text-subtitle1 text-weight-medium q-pa-md q-pb-none") - ui.separator() - with ui.column().classes("q-pa-md items-center gap-3"): - ui.label("Scan this QR code with your authenticator app:").classes("text-body2") - ui.html(svg).classes("w-48") - with ui.row().classes("items-center gap-2"): - ui.label("Manual entry:").classes("text-caption text-grey-7") - ui.label(secret).classes("text-caption font-mono bg-grey-2 px-2 py-1 rounded") - ui.separator() - with ui.column().classes("q-pa-md gap-3"): - reg_name = ui.input("Method Name", value="Authenticator").props("outlined dense").classes("w-full") - reg_code = ui.input( - "Verification Code", placeholder="Enter 6-digit code", - ).props("outlined dense maxlength=6").classes("w-full") - - async def verify_and_save(): - code = reg_code.value.strip() - name = reg_name.value.strip() or "Authenticator" - if not verify_totp_code(registration["secret"], code): - ui.notify("Invalid code — check your authenticator", type="negative") - return - async with async_session() as session: - method = MFAMethod( - name=name, type="totp", - payload={"secret": registration["secret"]}, - user_id=user_id, - ) - session.add(method) - await session.commit() - logger.info("MFA TOTP registered for {}", email) - ui.notify("TOTP method added!", type="positive") - registration["secret"] = None - reg_container.clear() - await refresh_methods() - - with ui.row().classes("gap-2"): - ui.button("Verify & Save", on_click=verify_and_save).props("color=primary unelevated") - ui.button("Cancel", on_click=lambda: reg_container.clear()).props("flat") - - async def start_webauthn_registration(): - existing = [] - async with async_session() as session: - result = await session.execute( - select(MFAMethod).where(MFAMethod.user_id == user_id, MFAMethod.type.in_(["native", "portable"])) - ) - for m in result.scalars().all(): - existing.append(m.payload) - - try: - reg_data = create_registration_options(user_id, email, existing) - except Exception as e: - ui.notify(f"WebAuthn not available: {e}", type="negative") - return - - webauthn_state["challenge"] = reg_data["challenge"] - options_json = reg_data["options_json"] - - js = f""" - async function() {{ - try {{ - const options = JSON.parse('{options_json}'); - options.challenge = Uint8Array.from(atob(options.challenge.replace(/-/g,'+').replace(/_/g,'/')), c => c.charCodeAt(0)); - options.user.id = Uint8Array.from(atob(options.user.id.replace(/-/g,'+').replace(/_/g,'/')), c => c.charCodeAt(0)); - if (options.excludeCredentials) {{ - options.excludeCredentials = options.excludeCredentials.map(c => ({{ - ...c, - id: Uint8Array.from(atob(c.id.replace(/-/g,'+').replace(/_/g,'/')), ch => ch.charCodeAt(0)) - }})); - }} - const credential = await navigator.credentials.create({{publicKey: options}}); - const response = {{ - id: credential.id, - rawId: btoa(String.fromCharCode(...new Uint8Array(credential.rawId))).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=/g,''), - type: credential.type, - response: {{ - attestationObject: btoa(String.fromCharCode(...new Uint8Array(credential.response.attestationObject))).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=/g,''), - clientDataJSON: btoa(String.fromCharCode(...new Uint8Array(credential.response.clientDataJSON))).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=/g,''), - }}, - }}; - return JSON.stringify(response); - }} catch(e) {{ - return JSON.stringify({{"error": e.message}}); - }} - }} - """ - result = await ui.run_javascript(f"({js})()") - await _handle_webauthn_response(result) - - async def _handle_webauthn_response(result_json: str): - try: - result = json.loads(result_json) - except (json.JSONDecodeError, TypeError): - ui.notify("WebAuthn response error", type="negative") - return - if "error" in result: - ui.notify(f"WebAuthn failed: {result['error']}", type="negative") - return - challenge = webauthn_state.get("challenge") - if not challenge: - ui.notify("No pending WebAuthn challenge", type="negative") - return - try: - credential_data = verify_registration(result_json, challenge) - except Exception as e: - ui.notify(f"Verification failed: {e}", type="negative") - return - async with async_session() as session: - method = MFAMethod( - name="Security Key", type="portable", - payload=credential_data, user_id=user_id, - ) - session.add(method) - await session.commit() - logger.info("WebAuthn key registered for {}", email) - ui.notify("Security key registered!", type="positive") - webauthn_state["challenge"] = None - await refresh_methods() - - await refresh_methods() - - with ui.row().classes("q-pa-md gap-2"): - ui.button("Add TOTP Method", icon="qr_code", on_click=start_totp_registration).props("outline unelevated") - ui.button("Add Security Key", icon="key", on_click=lambda: start_webauthn_registration()).props("outline unelevated") - - -async def _render_tokens_section(user_id: UUID): - """Section 5: API tokens management.""" - with ui.card().classes("w-full q-mt-lg"): - ui.label("API Tokens").classes("text-h6 text-weight-medium q-pa-md q-pb-none") - ui.label("Use tokens for programmatic access to the REST API.").classes("text-caption text-grey-7 q-px-md") - ui.separator() - - token_banner = ui.column().classes("w-full") tokens_container = ui.column().classes("w-full") - - async def load_tokens(): - async with async_session() as session: - result = await session.execute( - select(ApiToken).where(ApiToken.user_id == user_id).order_by(ApiToken.inserted_at.desc()) - ) - return result.scalars().all() + token_banner = ui.column().classes("w-full") async def refresh_tokens(): - tokens = await load_tokens() + async with async_session() as session: + tokens = (await session.execute( + select(ApiToken).where(ApiToken.user_id == user_id).order_by(ApiToken.inserted_at.desc()) + )).scalars().all() tokens_container.clear() with tokens_container: if tokens: - for i, t in enumerate(tokens): - is_expired = t.expires_at and t.expires_at < utcnow() - with ui.row().classes( - "w-full items-center justify-between px-4 py-3 hover:bg-grey-1 transition-colors" - + (" border-t" if i > 0 else "") - ): - with ui.row().classes("items-center gap-3"): - ui.icon("vpn_key").props(f"color={'grey-5' if is_expired else 'primary'} size=sm") - with ui.column().classes("gap-0"): - ui.label(f"Created {str(t.inserted_at)[:19]}").classes("text-sm") - expires_text = str(t.expires_at)[:19] if t.expires_at else "Never expires" - ui.label(f"Expires: {expires_text}").classes("text-caption text-grey-7") - with ui.row().classes("items-center gap-2"): - if is_expired: - ui.badge("Expired", color="negative").classes("text-xs") - else: - ui.badge("Active", color="positive").classes("text-xs") - ui.button(icon="delete", on_click=lambda tid=t.id: delete_token(tid)).props( - "flat dense round color=negative size=sm" - ) + cols = [ + {"name": "created", "label": "Created", "field": "created", "align": "left"}, + {"name": "expires", "label": "Expires", "field": "expires", "align": "left"}, + {"name": "status", "label": "Status", "field": "status", "align": "left"}, + {"name": "actions", "label": "", "field": "id", "align": "center"}, + ] + rows = [{ + "id": str(t.id), + "created": str(t.inserted_at)[:19], + "expires": str(t.expires_at)[:19] if t.expires_at else "Never", + "status": "Expired" if t.expires_at and t.expires_at < utcnow() else "Active", + } for t in tokens] + tbl = ui.table(columns=cols, rows=rows, row_key="id").props("dense flat bordered").classes("w-full text-xs") + tbl.add_slot("body-cell-status", r'''''') + tbl.add_slot("body-cell-actions", r'''''') + tbl.on("delete", lambda e: delete_token(e.args)) else: - with ui.row().classes("w-full items-center justify-center q-pa-lg"): - ui.icon("vpn_key").props("color=grey-5 size=lg") - ui.label("No API tokens created yet.").classes("text-caption text-grey-5 q-ml-sm") + ui.label("No API tokens.").classes("text-sm text-grey-7") async def create_token(): days = int(token_days.value) if token_days.value else 30 plaintext, token_hash = generate_api_token() expires_at = utcnow() + timedelta(days=days) if days > 0 else None - async with async_session() as session: - token = ApiToken(token_hash=token_hash, expires_at=expires_at, user_id=user_id) - session.add(token) + session.add(ApiToken(token_hash=token_hash, expires_at=expires_at, user_id=user_id)) await session.commit() - logger.info("API token created (expires in {} days)", days) - - # Show token in a banner token_banner.clear() with token_banner: - with ui.card().classes("w-full bg-green-1 q-ma-md").props("bordered"): - with ui.row().classes("items-center q-pa-sm gap-2"): - ui.icon("check_circle").props("color=positive") - ui.label("Token created — copy it now, it won't be shown again.").classes("text-sm text-weight-medium") - with ui.row().classes("q-pa-sm q-pt-none items-center gap-2"): - token_input = ui.input(value=plaintext).props("readonly outlined dense").classes("w-full font-mono text-xs") - ui.button(icon="content_copy", on_click=lambda: _copy_token(plaintext)).props("flat dense") - + with ui.row().classes("w-full items-center bg-green-1 rounded px-3 py-1.5 gap-2 q-mb-xs text-xs"): + ui.icon("check_circle", color="positive").props("size=xs") + ui.label("Copy now — won't be shown again.").classes("text-weight-medium") + with ui.row().classes("w-full items-center gap-1 q-mb-sm"): + ui.input(value=plaintext).props("readonly outlined dense").classes("w-full font-mono").style("font-size: 0.75rem") + ui.button(icon="content_copy", on_click=lambda: _copy(plaintext)).props("flat dense size=sm") await refresh_tokens() - async def _copy_token(token: str): - await ui.run_javascript(f"navigator.clipboard.writeText('{token}')") - ui.notify("Copied to clipboard", type="positive") + async def _copy(text): + await ui.run_javascript(f"navigator.clipboard.writeText('{text}')") + ui.notify("Copied", type="positive") async def delete_token(token_id): async with async_session() as session: - t = await session.get(ApiToken, token_id) + t = await session.get(ApiToken, UUID(token_id)) if t and t.user_id == user_id: await session.delete(t) await session.commit() @@ -458,73 +158,188 @@ async def _render_tokens_section(user_id: UUID): await refresh_tokens() await refresh_tokens() + with ui.row().classes("items-center gap-3 q-mt-sm"): + token_days = ui.input("Expires in days", value="30").props("outlined dense").classes("w-36") + ui.button("+ Add API Token", on_click=create_token).props(BTN_OUTLINE) - ui.separator() - with ui.row().classes("items-center gap-2 q-pa-md"): - token_days = ui.input("Expires in (days)", value="30").props("outlined dense").classes("w-40") - ui.button("Create Token", icon="add", on_click=create_token).props("color=primary unelevated") + # ===== MFA ===== + _section_header("Multi Factor Authentication", "Your MFA methods are invoked when login with username and password.") + methods_container = ui.column().classes("w-full") + reg_container = ui.column().classes("w-full") + registration = {"secret": None} + webauthn_state = {"challenge": None} -async def _render_danger_zone(user_id: UUID, email: str, role: str): - """Section 6: Danger zone — account deletion.""" - with ui.card().classes("w-full q-mt-lg").style("border-left: 4px solid var(--q-negative)"): - ui.label("Danger Zone").classes("text-h6 text-weight-medium text-negative q-pa-md q-pb-none") - ui.label("Irreversible actions for your account.").classes("text-caption text-grey-7 q-px-md") - ui.separator() - - with ui.column().classes("q-pa-md"): - # Check if user is the only admin + async def refresh_methods(): async with async_session() as session: - admin_count = (await session.execute( - select(func.count()).select_from(User).where(User.role == "admin") - )).scalar() + methods = (await session.execute( + select(MFAMethod).where(MFAMethod.user_id == user_id).order_by(MFAMethod.inserted_at) + )).scalars().all() + methods_container.clear() + with methods_container: + if methods: + cols = [ + {"name": "name", "label": "Name", "field": "name", "align": "left"}, + {"name": "type", "label": "Type", "field": "type", "align": "left"}, + {"name": "last_used", "label": "Last Used", "field": "last_used", "align": "left"}, + {"name": "actions", "label": "", "field": "id", "align": "center"}, + ] + rows = [{"id": str(m.id), "name": m.name, "type": m.type.upper(), "last_used": str(m.last_used_at)[:19] if m.last_used_at else "Never"} for m in methods] + tbl = ui.table(columns=cols, rows=rows, row_key="id").props("dense flat bordered").classes("w-full text-xs") + tbl.add_slot("body-cell-actions", r'''''') + tbl.on("delete", lambda e: _confirm_del_mfa(e.args)) + else: + ui.label("No MFA methods added.").classes("text-sm text-grey-7") - is_only_admin = role == "admin" and admin_count <= 1 + async def _confirm_del_mfa(mid): + with ui.dialog(value=True) as dlg: + with ui.card().classes("w-72"): + ui.label("Remove MFA method?").classes("text-subtitle2") + with ui.row().classes("w-full justify-end q-mt-sm gap-2"): + ui.button("Cancel", on_click=dlg.close).props("flat dense size=sm") + ui.button("Remove", on_click=lambda: _del_mfa(mid, dlg)).props("color=negative dense size=sm unelevated") - if is_only_admin: - ui.label("You are the only admin — account deletion is disabled.").classes("text-caption text-grey-7") + async def _del_mfa(mid, dlg): + async with async_session() as session: + m = await session.get(MFAMethod, UUID(mid)) + if m and m.user_id == user_id: + await session.delete(m) + await session.commit() + dlg.close() + ui.notify("Removed") + await refresh_methods() - async def confirm_delete(): - with ui.dialog(value=True) as dlg: - with ui.card().classes("w-96"): - ui.label("Delete Your Account?").classes("text-h6 text-negative") - ui.label("This will permanently delete your account, all your devices, and firewall rules. This action cannot be undone.").classes("text-body2 q-my-sm") - ui.label(f"Type your email to confirm: {email}").classes("text-caption text-weight-medium") - confirm_input = ui.input(placeholder=email).props("outlined dense").classes("w-full") + def start_totp(): + secret = generate_totp_secret() + registration["secret"] = secret + svg = generate_totp_qr_svg(get_totp_uri(secret, user.email)) + reg_container.clear() + with reg_container: + ui.separator().classes("q-my-sm") + with ui.row().classes("items-start gap-4"): + ui.html(svg).style("width: 140px; height: 140px") + with ui.column().classes("gap-2"): + ui.label("Scan or enter manually:").classes("text-xs") + ui.label(secret).classes("text-xs font-mono bg-grey-2 px-2 py-1 rounded") + reg_name = ui.input("Name", value="Authenticator").props("outlined dense").classes("w-52").style("font-size: 0.8rem") + reg_code = ui.input("6-digit code").props("outlined dense maxlength=6").classes("w-52").style("font-size: 0.8rem") - async def do_delete(): - if confirm_input.value.strip() != email: - ui.notify("Email does not match", type="negative") + async def verify(): + if not verify_totp_code(registration["secret"], reg_code.value.strip()): + ui.notify("Invalid code", type="negative") return async with async_session() as session: - # Delete devices - devices = (await session.execute( - select(Device).where(Device.user_id == user_id) - )).scalars().all() - for d in devices: - await session.delete(d) - # Delete rules - rules = (await session.execute( - select(Rule).where(Rule.user_id == user_id) - )).scalars().all() - for r in rules: - await session.delete(r) - # Delete user - u = await session.get(User, user_id) - if u: - await session.delete(u) + session.add(MFAMethod(name=reg_name.value.strip() or "Authenticator", type="totp", payload={"secret": registration["secret"]}, user_id=user_id)) await session.commit() + ui.notify("TOTP added!", type="positive") + reg_container.clear() + await refresh_methods() - logger.info("User {} deleted their own account", email) - dlg.close() - app.storage.user.clear() - ui.navigate.to("/login") + with ui.row().classes("gap-2"): + ui.button("Verify & Save", on_click=verify).props(BTN_PRIMARY) + ui.button("Cancel", on_click=lambda: reg_container.clear()).props("flat dense size=sm") - with ui.row().classes("w-full justify-end q-mt-sm"): - ui.button("Cancel", on_click=dlg.close).props("flat") - ui.button("Delete My Account", on_click=do_delete).props("color=negative unelevated") + async def start_webauthn(): + existing = [] + async with async_session() as session: + existing = [m.payload for m in (await session.execute( + select(MFAMethod).where(MFAMethod.user_id == user_id, MFAMethod.type.in_(["native", "portable"])) + )).scalars().all()] + try: + reg_data = create_registration_options(user_id, user.email, existing) + except Exception as e: + ui.notify(f"WebAuthn unavailable: {e}", type="negative") + return + webauthn_state["challenge"] = reg_data["challenge"] + js = f"""(async()=>{{try{{const o=JSON.parse('{reg_data["options_json"]}');o.challenge=Uint8Array.from(atob(o.challenge.replace(/-/g,'+').replace(/_/g,'/')),c=>c.charCodeAt(0));o.user.id=Uint8Array.from(atob(o.user.id.replace(/-/g,'+').replace(/_/g,'/')),c=>c.charCodeAt(0));if(o.excludeCredentials)o.excludeCredentials=o.excludeCredentials.map(c=>({{...c,id:Uint8Array.from(atob(c.id.replace(/-/g,'+').replace(/_/g,'/')),h=>h.charCodeAt(0))}}));const cr=await navigator.credentials.create({{publicKey:o}});return JSON.stringify({{id:cr.id,rawId:btoa(String.fromCharCode(...new Uint8Array(cr.rawId))).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=/g,''),type:cr.type,response:{{attestationObject:btoa(String.fromCharCode(...new Uint8Array(cr.response.attestationObject))).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=/g,''),clientDataJSON:btoa(String.fromCharCode(...new Uint8Array(cr.response.clientDataJSON))).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=/g,'')}}}})}}catch(e){{return JSON.stringify({{error:e.message}})}}}})()\n""" + result = await ui.run_javascript(js) + try: + data = json.loads(result) + except Exception: + ui.notify("WebAuthn error", type="negative") + return + if "error" in data: + ui.notify(f"WebAuthn: {data['error']}", type="negative") + return + try: + cred = verify_registration(result, webauthn_state["challenge"]) + except Exception as e: + ui.notify(f"Failed: {e}", type="negative") + return + async with async_session() as session: + session.add(MFAMethod(name="Security Key", type="portable", payload=cred, user_id=user_id)) + await session.commit() + ui.notify("Security key registered!", type="positive") + await refresh_methods() - ui.button( - "Delete Your Account", icon="delete_forever", - on_click=confirm_delete, - ).props("color=negative outline" + (" disable" if is_only_admin else "")) + await refresh_methods() + with ui.row().classes("gap-2 q-mt-xs"): + ui.button("+ Add MFA Method", on_click=start_totp).props(BTN_OUTLINE) + ui.button("+ Add Security Key", on_click=lambda: start_webauthn()).props(BTN_OUTLINE) + + # ===== Danger Zone ===== + _section_header("Danger Zone") + async with async_session() as session: + admin_count = (await session.execute(select(func.count()).select_from(User).where(User.role == "admin"))).scalar() + is_only_admin = user.role == "admin" and admin_count <= 1 + + async def confirm_delete(): + with ui.dialog(value=True) as dlg: + with ui.card().classes("w-80"): + ui.label("Are you sure?").classes("text-subtitle2 text-negative") + ui.label(f"Type {user.email} to confirm:").classes("text-xs q-my-xs") + ci = ui.input().props("outlined dense").classes("w-full").style("font-size: 0.8rem") + async def do_del(): + if ci.value.strip() != user.email: + ui.notify("Email doesn't match", type="negative") + return + async with async_session() as session: + for model in (Device, Rule, MFAMethod, ApiToken, OIDCConnection): + for item in (await session.execute(select(model).where(model.user_id == user_id))).scalars().all(): + await session.delete(item) + u = await session.get(User, user_id) + if u: + await session.delete(u) + await session.commit() + dlg.close() + app.storage.user.clear() + ui.navigate.to("/login") + with ui.row().classes("w-full justify-end q-mt-sm gap-2"): + ui.button("Cancel", on_click=dlg.close).props("flat dense size=sm") + ui.button("Delete", on_click=do_del).props("color=negative dense size=sm unelevated") + + ui.button("Delete Your Account", icon="delete", on_click=confirm_delete).props( + BTN_DANGER + (" disable" if is_only_admin else "") + ) + + # Password dialog + with ui.dialog() as pw_dialog: + with ui.card().classes("w-96"): + ui.label("Change Email or Password").classes("text-subtitle1 text-weight-medium") + ui.separator() + cur = ui.input("Current Password", password=True, password_toggle_button=True).props("outlined dense").classes("w-full") + npw = ui.input("New Password", password=True, password_toggle_button=True).props("outlined dense").classes("w-full") + cpw = ui.input("Confirm Password", password=True, password_toggle_button=True).props("outlined dense").classes("w-full") + async def save_pw(): + if not cur.value or not npw.value: + ui.notify("All fields required", type="negative") + return + if npw.value != cpw.value: + ui.notify("Passwords don't match", type="negative") + return + if len(npw.value) < 8: + ui.notify("Min 8 characters", type="negative") + return + async with async_session() as session: + u = await session.get(User, user_id) + if not verify_password(cur.value, u.password_hash): + ui.notify("Wrong current password", type="negative") + return + u.password_hash = hash_password(npw.value) + session.add(u) + await session.commit() + ui.notify("Password changed", type="positive") + pw_dialog.close() + with ui.row().classes("w-full justify-end q-mt-sm"): + ui.button("Cancel", on_click=pw_dialog.close).props("flat") + ui.button("Save", on_click=save_pw).props("color=primary unelevated")