From 1fc80b9c0ada717a01e9ef0d9958d7a65a2fd7ed Mon Sep 17 00:00:00 2001 From: Stefano Bertelli Date: Mon, 30 Mar 2026 21:40:29 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20UI=20modernization=20=E2=80=94=20Manrop?= =?UTF-8?q?e=20font,=20dark/light=20theme,=20card-based=20layouts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Manrope as primary UI font via Google Fonts (wiregui/pages/style.py) - Add dark/light/auto theme toggle in header, persisted to users.theme_preference - Alembic migration for theme_preference column - Redesign account page with card-based layout matching admin pages - Convert settings page from tabs to stacked cards - Replace all outline buttons with solid unelevated buttons - Fix dark mode: remove hardcoded bg-grey-1/text-grey-7, use theme-safe colors - Fix CI: add ca-certificates to release job for SSL cert verification - Add no-coauthor and commit conventions to CLAUDE.md --- .forgejo/workflows/release.yml | 2 +- CLAUDE.md | 4 + TODO.md | 78 +-- ...1d8e92b01_add_theme_preference_to_users.py | 27 + wiregui/models/user.py | 1 + wiregui/pages/account.py | 578 +++++++++--------- wiregui/pages/admin/devices.py | 2 +- wiregui/pages/admin/diagnostics.py | 8 +- wiregui/pages/admin/settings.py | 200 +++--- wiregui/pages/auth_magic.py | 1 + wiregui/pages/auth_oidc.py | 1 + wiregui/pages/auth_saml.py | 1 + wiregui/pages/devices.py | 10 +- wiregui/pages/layout.py | 58 +- wiregui/pages/login.py | 7 +- wiregui/pages/mfa_challenge.py | 3 + wiregui/pages/style.py | 20 + 17 files changed, 550 insertions(+), 451 deletions(-) create mode 100644 alembic/versions/a3f1d8e92b01_add_theme_preference_to_users.py create mode 100644 wiregui/pages/style.py diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 6e9d693..b3ea457 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -56,7 +56,7 @@ jobs: steps: - name: Install dependencies and checkout run: | - apt-get update && apt-get install -y --no-install-recommends bash git python3 + apt-get update && apt-get install -y --no-install-recommends bash git python3 ca-certificates git clone ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git . git checkout ${GITHUB_SHA} diff --git a/CLAUDE.md b/CLAUDE.md index 931f036..5815836 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -122,3 +122,7 @@ alembic/ - Run with `uv run pytest` - Use `pytest-asyncio` for async tests - Test database: uses same Postgres instance, separate `wiregui_test` database + +## Git Commits +- **Never** add `Co-Authored-By` lines or any AI attribution to commit messages +- Follow conventional commits format (fix:, feat:, chore:, etc.) matching existing history diff --git a/TODO.md b/TODO.md index 22d6fa7..120e7d9 100644 --- a/TODO.md +++ b/TODO.md @@ -3,7 +3,7 @@ Migration of Wirezone (Elixir/Phoenix) to Python/NiceGUI. Source: `/home/stefanob/PycharmProjects/personal/wirezone` -**Test count: 164 passing | Coverage: 35%** +**Test count: 164 (163 passing, 1 skipped) | Coverage: 35%** --- @@ -198,58 +198,34 @@ Source: `/home/stefanob/PycharmProjects/personal/wirezone` --- -## UI Polish — Account Page (`/account`) +## UI Polish & Styling -Redesign from tabbed layout to single scrollable page (matching original wirezone pattern). -Leverage Quasar components + Tailwind utility classes for modern look. +### Global styling ✅ +- [x] Manrope font loaded from Google Fonts as primary UI font (`wiregui/pages/style.py`) +- [x] Font applied on all pages (layout, login, MFA challenge) +- [x] Dark/light/auto theme toggle in header — cycles with icon button +- [x] Theme preference stored in `users.theme_preference` column (migration `a3f1d8e92b01`) +- [x] Theme persisted to DB and loaded into session on all login flows (password, MFA, magic link, OIDC, SAML) -### Layout change -- [ ] Remove tabs — render all sections stacked vertically on one page -- [ ] Page header: "Account Settings" with subtitle description +### Account page (`/account`) ✅ +- [x] Card-based layout matching admin pages (diagnostics, settings) +- [x] Account Details: `ui.grid(columns=2)` with bold labels, same as diagnostics +- [x] Change Password: inline card section (no modal), outlined inputs, validation +- [x] Connected SSO Providers: always visible card with empty state +- [x] API Tokens: table with status badges, inline create, copy-to-clipboard with green accent card +- [x] MFA: methods table, inline TOTP registration (QR + verify), WebAuthn, empty state +- [x] Danger Zone: red left border accent, typed email confirmation, disabled if only admin -### 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) +### Settings page (`/admin/settings`) ✅ +- [x] Converted from tabbed layout to stacked cards (Client Defaults, Security, Authentication) -### 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 +### Consistency pass ✅ +- [x] All buttons solid (`unelevated`) — no outline buttons anywhere +- [x] All pages use `w-full p-4` container with `text-h5 q-mb-md` page title +- [x] All `text-grey-7` / `text-grey-8` replaced with dark-mode-safe `text-grey` +- [x] Sidebar: removed hardcoded `bg-grey-1`, uses theme-aware background +- [x] Card titles: `text-subtitle1 text-bold` + `ui.separator()` everywhere -### 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 +### Remaining +- [ ] SSO Providers: add Status column, "Disconnect" action +- [ ] Admin pages (users, devices, rules): apply same card-based styling diff --git a/alembic/versions/a3f1d8e92b01_add_theme_preference_to_users.py b/alembic/versions/a3f1d8e92b01_add_theme_preference_to_users.py new file mode 100644 index 0000000..e680dea --- /dev/null +++ b/alembic/versions/a3f1d8e92b01_add_theme_preference_to_users.py @@ -0,0 +1,27 @@ +"""add theme_preference to users + +Revision ID: a3f1d8e92b01 +Revises: 0741bc76e748 +Create Date: 2026-03-30 22:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + + +# revision identifiers, used by Alembic. +revision: str = 'a3f1d8e92b01' +down_revision: Union[str, None] = '0741bc76e748' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column('users', sa.Column('theme_preference', sqlmodel.sql.sqltypes.AutoString(), nullable=False, server_default='auto')) + + +def downgrade() -> None: + op.drop_column('users', 'theme_preference') \ No newline at end of file diff --git a/wiregui/models/user.py b/wiregui/models/user.py index 171bc78..8cf04b9 100644 --- a/wiregui/models/user.py +++ b/wiregui/models/user.py @@ -21,6 +21,7 @@ class User(SQLModel, table=True): sign_in_token_created_at: datetime | None = None disabled_at: datetime | None = None + theme_preference: str = Field(default="auto") # "light" | "dark" | "auto" inserted_at: datetime = Field(default_factory=utcnow) updated_at: datetime = Field(default_factory=utcnow) diff --git a/wiregui/pages/account.py b/wiregui/pages/account.py index 941b849..1eb4082 100644 --- a/wiregui/pages/account.py +++ b/wiregui/pages/account.py @@ -1,4 +1,4 @@ -"""User account page — compact, single-page layout matching Firezone's density.""" +"""User account page — card-based layout matching admin pages.""" import json from datetime import timedelta @@ -23,28 +23,6 @@ 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"): @@ -65,261 +43,42 @@ async def account_page(): select(OIDCConnection).where(OIDCConnection.user_id == user_id) )).scalars().all() - 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") + with ui.column().classes("w-full p-4"): + ui.label("Account Settings").classes("text-h5 q-mb-md") - # ===== 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)) - - 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: - _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.") - - tokens_container = ui.column().classes("w-full") - token_banner = ui.column().classes("w-full") - - async def refresh_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: - 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: - 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: - 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) - token_banner.clear() - with token_banner: - 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(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, 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() - - 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) - - # ===== 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 refresh_methods(): - async with async_session() as session: - 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") - - 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") - - 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() - - 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 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: - 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() - - 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") - - 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() - - 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") + # ===== Account Details ===== + 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.capitalize()) + + ui.label("Last Signed In:").classes("text-bold") + ui.label(str(user.last_signed_in_at)[:19] if user.last_signed_in_at else "Never") + + ui.label("Created:").classes("text-bold") + ui.label(str(user.inserted_at)[:19]) + + ui.label("Devices:").classes("text-bold") + ui.label(str(device_count)) + + ui.label("Rules:").classes("text-bold") + ui.label(str(rule_count)) + + # ===== Change Password ===== + with ui.card().classes("w-full q-mt-md"): + ui.label("Change Password").classes("text-subtitle1 text-bold") + 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") + npw = ui.input("New Password", password=True, password_toggle_button=True).props("outlined dense").classes("w-full q-mt-sm") + cpw = ui.input("Confirm Password", password=True, password_toggle_button=True).props("outlined dense").classes("w-full q-mt-sm") + async def save_pw(): if not cur.value or not npw.value: ui.notify("All fields required", type="negative") @@ -339,7 +98,268 @@ async def account_page(): 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") + cur.value = "" + npw.value = "" + cpw.value = "" + + ui.button("Update Password", on_click=save_pw).props("color=primary unelevated").classes("q-mt-md") + + # ===== Connected SSO Providers ===== + with ui.card().classes("w-full q-mt-md"): + ui.label("Connected SSO Providers").classes("text-subtitle1 text-bold") + ui.separator() + + if oidc_conns: + 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").classes("w-full") + else: + ui.label("No SSO providers connected.").classes("text-caption text-grey q-pa-sm") + + # ===== API Tokens ===== + with ui.card().classes("w-full q-mt-md"): + ui.label("API Tokens").classes("text-subtitle1 text-bold") + ui.label("Create and manage API tokens for programmatic access.").classes("text-caption text-grey") + ui.separator() + + tokens_container = ui.column().classes("w-full") + token_banner = ui.column().classes("w-full") + + async def refresh_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: + 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").classes("w-full") + tbl.add_slot("body-cell-status", r'''''') + tbl.add_slot("body-cell-actions", r'''''') + tbl.on("delete", lambda e: delete_token(e.args)) + else: + ui.label("No API tokens.").classes("text-caption text-grey q-pa-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: + 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) + token_banner.clear() + with token_banner: + with ui.card().classes("w-full").style("border-left: 4px solid var(--q-positive)"): + with ui.row().classes("items-center gap-2"): + ui.icon("check_circle", color="positive") + ui.label("Copy now — this token won't be shown again.").classes("text-weight-medium text-sm") + with ui.row().classes("w-full items-center gap-1 q-mt-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(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, 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() + + await refresh_tokens() + + ui.separator().classes("q-my-sm") + with ui.row().classes("items-center gap-3"): + token_days = ui.input("Expires in days", value="30").props("outlined dense").classes("w-36") + ui.button("Add API Token", icon="add", on_click=create_token).props("color=primary unelevated") + + # ===== Multi-Factor Authentication ===== + with ui.card().classes("w-full q-mt-md"): + ui.label("Multi-Factor Authentication").classes("text-subtitle1 text-bold") + ui.label("MFA methods are required when signing in with email and password.").classes("text-caption text-grey") + 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 refresh_methods(): + async with async_session() as session: + 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").classes("w-full") + tbl.add_slot("body-cell-actions", r'''''') + tbl.on("delete", lambda e: _confirm_del_mfa(e.args)) + else: + ui.label("No MFA methods configured.").classes("text-caption text-grey q-pa-sm") + + async def _confirm_del_mfa(mid): + with ui.dialog(value=True) as dlg: + with ui.card().classes("w-80"): + ui.label("Remove MFA method?").classes("text-subtitle1 text-bold") + ui.label("This action cannot be undone.").classes("text-caption text-grey") + ui.separator() + with ui.row().classes("w-full justify-end q-mt-sm gap-2"): + ui.button("Cancel", on_click=dlg.close).props("flat") + ui.button("Remove", on_click=lambda: _del_mfa(mid, dlg)).props("color=negative unelevated") + + 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("MFA method removed") + await refresh_methods() + + 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") + ui.label("Register TOTP Authenticator").classes("text-subtitle2 text-bold") + with ui.row().classes("items-start gap-6 q-mt-sm"): + ui.html(svg).style("width: 160px; height: 160px") + with ui.column().classes("gap-2"): + ui.label("Scan the QR code with your authenticator app, or enter the secret manually:").classes("text-sm") + ui.input(value=secret).props("readonly outlined dense").classes("w-full font-mono").style("font-size: 0.75rem") + reg_name = ui.input("Name", value="Authenticator").props("outlined dense").classes("w-full") + reg_code = ui.input("6-digit verification code").props("outlined dense maxlength=6").classes("w-full") + + 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: + 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 method added", type="positive") + reg_container.clear() + await refresh_methods() + + with ui.row().classes("gap-2"): + ui.button("Verify & Save", on_click=verify).props("color=primary unelevated") + ui.button("Cancel", on_click=lambda: reg_container.clear()).props("flat") + + 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() + + await refresh_methods() + + ui.separator().classes("q-my-sm") + with ui.row().classes("gap-2"): + ui.button("Add TOTP Method", icon="add", on_click=start_totp).props("color=primary unelevated") + ui.button("Add Security Key", icon="fingerprint", on_click=lambda: start_webauthn()).props("color=primary unelevated") + + # ===== Danger Zone ===== + with ui.card().classes("w-full q-mt-md").style("border-left: 4px solid var(--q-negative)"): + ui.label("Danger Zone").classes("text-subtitle1 text-bold text-negative") + ui.separator() + + 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 + + if is_only_admin: + ui.label("You are the only admin — account deletion is disabled.").classes("text-caption text-grey q-pa-sm") + else: + ui.label("Permanently delete your account and all associated data.").classes("text-caption text-grey") + + async def confirm_delete(): + with ui.dialog(value=True) as dlg: + with ui.card().classes("w-96"): + ui.label("Delete Your Account?").classes("text-subtitle1 text-bold text-negative") + ui.label("This will permanently remove your account, all devices, rules, tokens, and MFA methods.").classes("text-sm q-my-sm") + ui.separator() + ui.label(f"Type your email to confirm:").classes("text-caption text-grey q-mt-sm") + ci = ui.input(placeholder=user.email).props("outlined dense").classes("w-full") + + 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-md gap-2"): + ui.button("Cancel", on_click=dlg.close).props("flat") + ui.button("Delete My Account", on_click=do_del).props("color=negative unelevated") + + ui.button("Delete Your Account", icon="delete_forever", on_click=confirm_delete).props( + "color=negative unelevated" + (" disable" if is_only_admin else "") + ).classes("q-mt-sm") \ No newline at end of file diff --git a/wiregui/pages/admin/devices.py b/wiregui/pages/admin/devices.py index 7cfe424..e43ac6e 100644 --- a/wiregui/pages/admin/devices.py +++ b/wiregui/pages/admin/devices.py @@ -346,5 +346,5 @@ def _show_config_dialog(device_name: str, config_text: str): ui.html(buf.getvalue().decode()).classes("w-full q-mt-sm") except Exception: pass - ui.button("Download .conf", on_click=lambda: ui.download(config_text.encode(), f"{device_name}.conf")).props("color=primary outline").classes("w-full q-mt-sm") + ui.button("Download .conf", on_click=lambda: ui.download(config_text.encode(), f"{device_name}.conf")).props("color=primary unelevated").classes("w-full q-mt-sm") ui.button("Close", on_click=dialog.close).props("flat").classes("w-full") diff --git a/wiregui/pages/admin/diagnostics.py b/wiregui/pages/admin/diagnostics.py index 777c286..ad9d750 100644 --- a/wiregui/pages/admin/diagnostics.py +++ b/wiregui/pages/admin/diagnostics.py @@ -97,7 +97,7 @@ async def diagnostics_page(): ] ui.table(columns=peer_columns, rows=peer_rows, row_key="name").classes("w-full") else: - ui.label("No active peers with recent handshakes.").classes("text-caption text-grey-7 q-pa-sm") + ui.label("No active peers with recent handshakes.").classes("text-caption text-grey q-pa-sm") # --- Connectivity Checks --- with ui.card().classes("w-full q-mt-md"): @@ -128,7 +128,7 @@ async def diagnostics_page(): ] ui.table(columns=check_columns, rows=check_rows, row_key="time").classes("w-full") else: - ui.label("No connectivity checks recorded yet.").classes("text-caption text-grey-7 q-pa-sm") + ui.label("No connectivity checks recorded yet.").classes("text-caption text-grey q-pa-sm") # --- Notifications --- with ui.card().classes("w-full q-mt-md"): @@ -143,10 +143,10 @@ async def diagnostics_page(): ui.icon("error" if n.severity == "error" else "warning" if n.severity == "warning" else "info").props(f"color={color}") ui.label(f"{n.timestamp.strftime('%H:%M:%S')} — {n.message}").classes("text-sm") if n.user: - ui.label(f"({n.user})").classes("text-caption text-grey-7") + ui.label(f"({n.user})").classes("text-caption text-grey") ui.button(icon="close", on_click=lambda nid=n.id: _clear_notif(nid)).props("flat dense size=xs") else: - ui.label("No notifications.").classes("text-caption text-grey-7 q-pa-sm") + ui.label("No notifications.").classes("text-caption text-grey q-pa-sm") if notifs: ui.button("Clear All", on_click=lambda: _clear_all_notifs()).props("flat color=negative").classes("q-mt-sm") diff --git a/wiregui/pages/admin/settings.py b/wiregui/pages/admin/settings.py index 245abb2..72ec470 100644 --- a/wiregui/pages/admin/settings.py +++ b/wiregui/pages/admin/settings.py @@ -143,128 +143,118 @@ async def settings_page(): with ui.column().classes("w-full p-4"): ui.label("Settings").classes("text-h5 q-mb-md") - with ui.tabs().classes("w-full") as tabs: - defaults_tab = ui.tab("Client Defaults") - security_tab = ui.tab("Security") - auth_tab = ui.tab("Authentication") + # === Client Defaults === + with ui.card().classes("w-full"): + ui.label("Default Client Configuration").classes("text-subtitle1 text-bold") + ui.label("These defaults apply to new devices unless overridden per-device.").classes("text-caption text-grey") + ui.separator() - with ui.tab_panels(tabs, value=defaults_tab).classes("w-full"): + defaults_endpoint = ui.input( + "Endpoint", value=config.default_client_endpoint or "", + placeholder="vpn.example.com", + ).props("outlined dense").classes("w-full") + ui.label("IPv4/IPv6 address or FQDN clients connect to").classes("text-caption text-grey") - # === Client Defaults === - with ui.tab_panel(defaults_tab): - with ui.card().classes("w-full"): - ui.label("Default Client Configuration").classes("text-subtitle1 text-bold") - ui.label("These defaults apply to new devices unless overridden per-device.").classes("text-caption text-grey-7") - ui.separator() + defaults_dns = ui.input( + "DNS Servers", value=", ".join(config.default_client_dns), + placeholder="1.1.1.1, 1.0.0.1", + ).props("outlined dense").classes("w-full q-mt-sm") + ui.label("Comma-separated. Leave blank to omit.").classes("text-caption text-grey") - defaults_endpoint = ui.input( - "Endpoint", value=config.default_client_endpoint or "", - placeholder="vpn.example.com", - ).props("outlined dense").classes("w-full") - ui.label("IPv4/IPv6 address or FQDN clients connect to").classes("text-caption text-grey-7") + defaults_allowed_ips = ui.input( + "Allowed IPs", value=", ".join(config.default_client_allowed_ips), + placeholder="0.0.0.0/0, ::/0", + ).props("outlined dense").classes("w-full q-mt-sm") + ui.label("CIDR ranges for split or full tunnel.").classes("text-caption text-grey") - defaults_dns = ui.input( - "DNS Servers", value=", ".join(config.default_client_dns), - placeholder="1.1.1.1, 1.0.0.1", - ).props("outlined dense").classes("w-full q-mt-sm") - ui.label("Comma-separated. Leave blank to omit.").classes("text-caption text-grey-7") + with ui.row().classes("w-full gap-4 q-mt-sm"): + defaults_mtu = ui.input( + "MTU", value=str(config.default_client_mtu), + placeholder="1280", + ).props("outlined dense").classes("w-48") - defaults_allowed_ips = ui.input( - "Allowed IPs", value=", ".join(config.default_client_allowed_ips), - placeholder="0.0.0.0/0, ::/0", - ).props("outlined dense").classes("w-full q-mt-sm") - ui.label("CIDR ranges for split or full tunnel.").classes("text-caption text-grey-7") + defaults_keepalive = ui.input( + "Persistent Keepalive", value=str(config.default_client_persistent_keepalive), + placeholder="25", + ).props("outlined dense").classes("w-48") - with ui.row().classes("w-full gap-4 q-mt-sm"): - defaults_mtu = ui.input( - "MTU", value=str(config.default_client_mtu), - placeholder="1280", - ).props("outlined dense").classes("w-48") + ui.button("Save Defaults", on_click=save_defaults).props("color=primary unelevated").classes("q-mt-md") - defaults_keepalive = ui.input( - "Persistent Keepalive", value=str(config.default_client_persistent_keepalive), - placeholder="25", - ).props("outlined dense").classes("w-48") + # === Security === + with ui.card().classes("w-full q-mt-lg"): + ui.label("Authentication & Access").classes("text-subtitle1 text-bold") + ui.separator() - ui.button("Save Defaults", on_click=save_defaults).props("color=primary").classes("q-mt-md") + security_vpn_duration = ui.select( + VPN_SESSION_OPTIONS, + value=config.vpn_session_duration, + label="VPN Session Duration", + ).props("outlined dense").classes("w-full") + ui.label("How often users must re-authenticate to maintain VPN access.").classes("text-caption text-grey") - # === Security === - with ui.tab_panel(security_tab): - with ui.card().classes("w-full"): - ui.label("Authentication & Access").classes("text-subtitle1 text-bold") - ui.separator() + ui.separator().classes("q-my-md") - security_vpn_duration = ui.select( - VPN_SESSION_OPTIONS, - value=config.vpn_session_duration, - label="VPN Session Duration", - ).props("outlined dense").classes("w-full") - ui.label("How often users must re-authenticate to maintain VPN access.").classes("text-caption text-grey-7") + security_local_auth = ui.switch("Local Authentication (email/password)", value=config.local_auth_enabled) + security_unpriv_mgmt = ui.switch("Allow Unprivileged Device Management", value=config.allow_unprivileged_device_management) + security_unpriv_config = ui.switch("Allow Unprivileged Device Configuration", value=config.allow_unprivileged_device_configuration) - ui.separator().classes("q-my-md") + ui.separator().classes("q-my-md") + ui.label("SSO Behavior").classes("text-subtitle2") + security_disable_vpn_oidc = ui.switch("Auto-disable VPN on OIDC refresh error", value=config.disable_vpn_on_oidc_error) - security_local_auth = ui.switch("Local Authentication (email/password)", value=config.local_auth_enabled) - security_unpriv_mgmt = ui.switch("Allow Unprivileged Device Management", value=config.allow_unprivileged_device_management) - security_unpriv_config = ui.switch("Allow Unprivileged Device Configuration", value=config.allow_unprivileged_device_configuration) + ui.button("Save Security Settings", on_click=save_security).props("color=primary unelevated").classes("q-mt-md") - ui.separator().classes("q-my-md") - ui.label("SSO Behavior").classes("text-subtitle2") - security_disable_vpn_oidc = ui.switch("Auto-disable VPN on OIDC refresh error", value=config.disable_vpn_on_oidc_error) + # === Authentication (OIDC/SAML) === + with ui.card().classes("w-full q-mt-lg"): + ui.label("OpenID Connect Providers").classes("text-subtitle1 text-bold") + ui.separator() - ui.button("Save Security Settings", on_click=save_security).props("color=primary").classes("q-mt-md") + oidc_columns = [ + {"name": "id", "label": "Config ID", "field": "id", "align": "left"}, + {"name": "label", "label": "Label", "field": "label", "align": "left"}, + {"name": "client_id", "label": "Client ID", "field": "client_id", "align": "left"}, + {"name": "discovery", "label": "Discovery URI", "field": "discovery", "align": "left"}, + {"name": "auto_create", "label": "Auto-create", "field": "auto_create", "align": "center"}, + {"name": "actions", "label": "", "field": "id", "align": "center"}, + ] + oidc_table = ui.table(columns=oidc_columns, rows=[], row_key="id").classes("w-full") + oidc_table.add_slot( + "body-cell-actions", + ''' + + + + ''', + ) + oidc_table.on("delete", lambda e: delete_oidc_provider(e.args)) - # === Authentication (OIDC/SAML) === - with ui.tab_panel(auth_tab): - with ui.card().classes("w-full"): - ui.label("OpenID Connect Providers").classes("text-subtitle1 text-bold") - ui.separator() + ui.button("Add OIDC Provider", icon="add", on_click=lambda: oidc_dialog.open()).props("color=primary unelevated").classes("q-mt-sm") - oidc_columns = [ - {"name": "id", "label": "Config ID", "field": "id", "align": "left"}, - {"name": "label", "label": "Label", "field": "label", "align": "left"}, - {"name": "client_id", "label": "Client ID", "field": "client_id", "align": "left"}, - {"name": "discovery", "label": "Discovery URI", "field": "discovery", "align": "left"}, - {"name": "auto_create", "label": "Auto-create", "field": "auto_create", "align": "center"}, - {"name": "actions", "label": "", "field": "id", "align": "center"}, - ] - oidc_table = ui.table(columns=oidc_columns, rows=[], row_key="id").classes("w-full") - oidc_table.add_slot( - "body-cell-actions", - ''' - - - - ''', - ) - oidc_table.on("delete", lambda e: delete_oidc_provider(e.args)) + with ui.card().classes("w-full q-mt-lg"): + ui.label("SAML Identity Providers").classes("text-subtitle1 text-bold") + ui.separator() - ui.button("Add OIDC Provider", icon="add", on_click=lambda: oidc_dialog.open()).props("outline").classes("q-mt-sm") + saml_columns = [ + {"name": "id", "label": "Config ID", "field": "id", "align": "left"}, + {"name": "label", "label": "Label", "field": "label", "align": "left"}, + {"name": "metadata", "label": "Metadata", "field": "metadata", "align": "left"}, + {"name": "auto_create", "label": "Auto-create", "field": "auto_create", "align": "center"}, + {"name": "actions", "label": "", "field": "id", "align": "center"}, + ] + saml_table = ui.table(columns=saml_columns, rows=[], row_key="id").classes("w-full") + saml_table.add_slot( + "body-cell-actions", + ''' + + + + ''', + ) + saml_table.on("delete", lambda e: delete_saml_provider(e.args)) - with ui.card().classes("w-full q-mt-md"): - ui.label("SAML Identity Providers").classes("text-subtitle1 text-bold") - ui.separator() - - saml_columns = [ - {"name": "id", "label": "Config ID", "field": "id", "align": "left"}, - {"name": "label", "label": "Label", "field": "label", "align": "left"}, - {"name": "metadata", "label": "Metadata", "field": "metadata", "align": "left"}, - {"name": "auto_create", "label": "Auto-create", "field": "auto_create", "align": "center"}, - {"name": "actions", "label": "", "field": "id", "align": "center"}, - ] - saml_table = ui.table(columns=saml_columns, rows=[], row_key="id").classes("w-full") - saml_table.add_slot( - "body-cell-actions", - ''' - - - - ''', - ) - saml_table.on("delete", lambda e: delete_saml_provider(e.args)) - - ui.button("Add SAML Provider", icon="add", on_click=lambda: saml_dialog.open()).props("outline").classes("q-mt-sm") + ui.button("Add SAML Provider", icon="add", on_click=lambda: saml_dialog.open()).props("color=primary unelevated").classes("q-mt-sm") # --- SAML provider management --- async def save_saml_provider(): @@ -347,7 +337,7 @@ async def settings_page(): saml_id = ui.input("Config ID", placeholder="okta-saml").props("outlined dense").classes("w-full") saml_label = ui.input("Label", placeholder="Sign in with Okta").props("outlined dense").classes("w-full") saml_metadata_input = ui.textarea("IdP Metadata (XML)").props("outlined").classes("w-full").style("min-height: 120px") - ui.label("Paste the full XML metadata from your identity provider.").classes("text-caption text-grey-7") + ui.label("Paste the full XML metadata from your identity provider.").classes("text-caption text-grey") ui.separator().classes("q-my-sm") ui.label("Security Options").classes("text-subtitle2") diff --git a/wiregui/pages/auth_magic.py b/wiregui/pages/auth_magic.py index 229a79f..4ec4995 100644 --- a/wiregui/pages/auth_magic.py +++ b/wiregui/pages/auth_magic.py @@ -87,5 +87,6 @@ async def magic_link_verify_page(user_id: str, token: str): user_id=str(user.id), email=user.email, role=user.role, + theme_preference=user.theme_preference, ) ui.navigate.to("/") diff --git a/wiregui/pages/auth_oidc.py b/wiregui/pages/auth_oidc.py index 8efaf11..40d4d4a 100644 --- a/wiregui/pages/auth_oidc.py +++ b/wiregui/pages/auth_oidc.py @@ -116,5 +116,6 @@ async def oidc_callback(provider_id: str, request: Request): request.session["user_id"] = str(user.id) request.session["email"] = user.email request.session["role"] = user.role + request.session["theme_preference"] = user.theme_preference return RedirectResponse(url="/") diff --git a/wiregui/pages/auth_saml.py b/wiregui/pages/auth_saml.py index d8ed476..c9dccc2 100644 --- a/wiregui/pages/auth_saml.py +++ b/wiregui/pages/auth_saml.py @@ -105,6 +105,7 @@ async def saml_callback(provider_id: str, request: Request): request.session["user_id"] = str(user.id) request.session["email"] = user.email request.session["role"] = user.role + request.session["theme_preference"] = user.theme_preference logger.info("SAML login: {} via {}", email, provider_id) return RedirectResponse(url="/", status_code=303) diff --git a/wiregui/pages/devices.py b/wiregui/pages/devices.py index a9147fd..eb4c7af 100644 --- a/wiregui/pages/devices.py +++ b/wiregui/pages/devices.py @@ -190,7 +190,7 @@ async def devices_page(): ui.separator().classes("q-my-sm") ui.label("Configuration Overrides").classes("text-subtitle2") - ui.label("Toggle off to set custom values instead of server defaults.").classes("text-caption text-grey-7") + ui.label("Toggle off to set custom values instead of server defaults.").classes("text-caption text-grey") with ui.grid(columns=2).classes("w-full gap-2"): create_use_default_ips = ui.switch("Use default Allowed IPs", value=True) @@ -295,7 +295,7 @@ async def device_detail_page(device_id: str): ui.button(icon="arrow_back", on_click=lambda: ui.navigate.to("/devices")).props("flat") ui.label(device.name).classes("text-h5") if device.description: - ui.label(f"— {device.description}").classes("text-subtitle1 text-grey-7") + ui.label(f"— {device.description}").classes("text-subtitle1 text-grey") # Device info card with ui.card().classes("w-full q-mt-md"): @@ -317,7 +317,7 @@ async def device_detail_page(device_id: str): # Traffic stats (live-updating) with ui.card().classes("w-full q-mt-md"): ui.label("Traffic Stats").classes("text-subtitle1 text-bold") - ui.label("Auto-refreshes every 30s").classes("text-caption text-grey-7") + ui.label("Auto-refreshes every 30s").classes("text-caption text-grey") ui.separator() with ui.grid(columns=2).classes("w-full gap-2 q-pa-sm"): ui.label("RX:").classes("text-bold") @@ -423,7 +423,7 @@ async def device_detail_page(device_id: str): ui.label("Danger Zone").classes("text-subtitle1 text-bold text-negative") ui.separator() ui.button("Delete Device", icon="delete", on_click=lambda: confirm_dialog.open()).props( - "color=negative outline" + "color=negative unelevated" ) # Confirm delete dialog @@ -458,6 +458,6 @@ def _show_config_dialog(device_name: str, config_text: str): ui.button( "Download .conf", on_click=lambda: ui.download(config_text.encode(), f"{device_name}.conf"), - ).props("color=primary outline").classes("w-full q-mt-sm") + ).props("color=primary unelevated").classes("w-full q-mt-sm") ui.button("Close", on_click=dialog.close).props("flat").classes("w-full") diff --git a/wiregui/pages/layout.py b/wiregui/pages/layout.py index f22de04..7075c85 100644 --- a/wiregui/pages/layout.py +++ b/wiregui/pages/layout.py @@ -1,19 +1,65 @@ """Shared layout — sidebar navigation + header.""" +from uuid import UUID + +from loguru import logger from nicegui import app, ui +from wiregui.db import async_session +from wiregui.models.user import User +from wiregui.pages.style import apply_style from wiregui.services import notifications +# Map theme preference to icon +_THEME_ICONS = {"light": "light_mode", "dark": "dark_mode", "auto": "brightness_auto"} +_THEME_CYCLE = {"light": "dark", "dark": "auto", "auto": "light"} + def layout(title: str = "WireGUI"): """Render the shared app chrome (header + sidebar). Call at the top of each page.""" + apply_style() + user_email = app.storage.user.get("email", "") role = app.storage.user.get("role", "") + theme_pref = app.storage.user.get("theme_preference", "auto") + + # Apply theme + dark_value = {"light": False, "dark": True, "auto": None}.get(theme_pref) + dark = ui.dark_mode(dark_value) def logout(): app.storage.user.clear() ui.navigate.to("/login") + async def toggle_theme(): + current = app.storage.user.get("theme_preference", "auto") + new_pref = _THEME_CYCLE[current] + app.storage.user["theme_preference"] = new_pref + + # Apply immediately + new_value = {"light": False, "dark": True, "auto": None}[new_pref] + if new_value is True: + dark.enable() + elif new_value is False: + dark.disable() + else: + dark.auto() + + theme_btn.props(f'icon={_THEME_ICONS[new_pref]}') + + # Persist to database + user_id = app.storage.user.get("user_id") + if user_id: + try: + async with async_session() as session: + user = await session.get(User, UUID(user_id)) + if user: + user.theme_preference = new_pref + session.add(user) + await session.commit() + except Exception as e: + logger.warning("Failed to persist theme preference: {}", e) + # Header with ui.header().classes("items-center justify-between"): with ui.row().classes("items-center"): @@ -28,21 +74,25 @@ def layout(title: str = "WireGUI"): ).props("flat color=white"): if notif_count > 0: ui.badge(str(notif_count), color="red").props("floating") + theme_btn = ui.button( + icon=_THEME_ICONS.get(theme_pref, "brightness_auto"), + on_click=toggle_theme, + ).props("flat color=white") ui.label(f"{user_email}").classes("text-subtitle2") ui.button("Logout", on_click=logout).props("flat color=white") # Sidebar - with ui.left_drawer(value=True, bordered=True).classes("bg-grey-1") as drawer: - ui.label("Navigation").classes("text-subtitle2 q-pa-sm text-grey-7") + with ui.left_drawer(value=True, bordered=True) as drawer: + ui.label("Navigation").classes("text-subtitle2 q-pa-sm text-grey") ui.separator() ui.item("Devices", on_click=lambda: ui.navigate.to("/devices")).classes("cursor-pointer") ui.item("Account", on_click=lambda: ui.navigate.to("/account")).classes("cursor-pointer") if role == "admin": ui.separator() - ui.label("Admin").classes("text-subtitle2 q-pa-sm text-grey-7") + ui.label("Admin").classes("text-subtitle2 q-pa-sm text-grey") ui.item("Users", on_click=lambda: ui.navigate.to("/admin/users")).classes("cursor-pointer") ui.item("All Devices", on_click=lambda: ui.navigate.to("/admin/devices")).classes("cursor-pointer") ui.item("Rules", on_click=lambda: ui.navigate.to("/admin/rules")).classes("cursor-pointer") ui.item("Settings", on_click=lambda: ui.navigate.to("/admin/settings")).classes("cursor-pointer") - ui.item("Diagnostics", on_click=lambda: ui.navigate.to("/admin/diagnostics")).classes("cursor-pointer") + ui.item("Diagnostics", on_click=lambda: ui.navigate.to("/admin/diagnostics")).classes("cursor-pointer") \ No newline at end of file diff --git a/wiregui/pages/login.py b/wiregui/pages/login.py index 6b29056..eb673dd 100644 --- a/wiregui/pages/login.py +++ b/wiregui/pages/login.py @@ -7,6 +7,7 @@ from wiregui.auth.oidc import load_providers from wiregui.auth.session import authenticate_user from wiregui.db import async_session from wiregui.models.mfa_method import MFAMethod +from wiregui.pages.style import apply_style from wiregui.utils.time import utcnow @@ -15,6 +16,8 @@ async def login_page(): if app.storage.user.get("authenticated"): return ui.navigate.to("/") + apply_style() + # Load OIDC providers for SSO buttons oidc_providers = await load_providers() @@ -44,6 +47,7 @@ async def login_page(): "user_id": str(user.id), "email": user.email, "role": user.role, + "theme_preference": user.theme_preference, } ui.navigate.to("/mfa") else: @@ -53,6 +57,7 @@ async def login_page(): user_id=str(user.id), email=user.email, role=user.role, + theme_preference=user.theme_preference, ) ui.navigate.to("/") @@ -79,4 +84,4 @@ async def login_page(): ui.button( label, on_click=lambda p=pid: ui.navigate.to(f"/auth/oidc/{p}"), - ).props("outline").classes("w-full q-mt-xs") + ).props("color=primary unelevated").classes("w-full q-mt-xs") diff --git a/wiregui/pages/mfa_challenge.py b/wiregui/pages/mfa_challenge.py index b178e3a..a7b2ea1 100644 --- a/wiregui/pages/mfa_challenge.py +++ b/wiregui/pages/mfa_challenge.py @@ -9,6 +9,7 @@ from sqlmodel import select from wiregui.auth.mfa import verify_totp_code from wiregui.db import async_session from wiregui.models.mfa_method import MFAMethod +from wiregui.pages.style import apply_style @ui.page("/mfa") @@ -18,6 +19,7 @@ async def mfa_challenge_page(): if not pending: return ui.navigate.to("/login") + apply_style() user_id = UUID(pending["user_id"]) # Load user's MFA methods @@ -82,6 +84,7 @@ def _complete_login(pending: dict): user_id=pending["user_id"], email=pending["email"], role=pending["role"], + theme_preference=pending.get("theme_preference", "auto"), ) # Clear pending state app.storage.user.pop("pending_mfa", None) diff --git a/wiregui/pages/style.py b/wiregui/pages/style.py new file mode 100644 index 0000000..fde5eb4 --- /dev/null +++ b/wiregui/pages/style.py @@ -0,0 +1,20 @@ +"""Shared UI styling — font, theme, global CSS.""" + +from nicegui import ui + + +def apply_style(): + """Add Manrope font and global CSS overrides. Call once per page.""" + ui.add_head_html( + '' + '' + '' + ) + ui.add_css(""" + body, input, button, select, textarea { + font-family: 'Manrope', sans-serif !important; + } + code, .font-mono, .q-table__container .monospace { + font-family: 'JetBrains Mono', 'Fira Code', monospace !important; + } + """) \ No newline at end of file