feat: UI modernization — Manrope font, dark/light theme, card-based layouts
- 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
This commit is contained in:
parent
3601de3600
commit
1fc80b9c0a
17 changed files with 550 additions and 451 deletions
|
|
@ -56,7 +56,7 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
- name: Install dependencies and checkout
|
- name: Install dependencies and checkout
|
||||||
run: |
|
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 clone ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git .
|
||||||
git checkout ${GITHUB_SHA}
|
git checkout ${GITHUB_SHA}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -122,3 +122,7 @@ alembic/
|
||||||
- Run with `uv run pytest`
|
- Run with `uv run pytest`
|
||||||
- Use `pytest-asyncio` for async tests
|
- Use `pytest-asyncio` for async tests
|
||||||
- Test database: uses same Postgres instance, separate `wiregui_test` database
|
- 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
|
||||||
|
|
|
||||||
78
TODO.md
78
TODO.md
|
|
@ -3,7 +3,7 @@
|
||||||
Migration of Wirezone (Elixir/Phoenix) to Python/NiceGUI.
|
Migration of Wirezone (Elixir/Phoenix) to Python/NiceGUI.
|
||||||
Source: `/home/stefanob/PycharmProjects/personal/wirezone`
|
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).
|
### Global styling ✅
|
||||||
Leverage Quasar components + Tailwind utility classes for modern look.
|
- [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
|
### Account page (`/account`) ✅
|
||||||
- [ ] Remove tabs — render all sections stacked vertically on one page
|
- [x] Card-based layout matching admin pages (diagnostics, settings)
|
||||||
- [ ] Page header: "Account Settings" with subtitle description
|
- [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
|
### Settings page (`/admin/settings`) ✅
|
||||||
- [ ] Quasar `q-card` with clean table layout (not grid) for user info
|
- [x] Converted from tabbed layout to stacked cards (Client Defaults, Security, Authentication)
|
||||||
- [ ] 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
|
### Consistency pass ✅
|
||||||
- [ ] Separate `q-card` below details
|
- [x] All buttons solid (`unelevated`) — no outline buttons anywhere
|
||||||
- [ ] Outlined inputs with proper validation feedback
|
- [x] All pages use `w-full p-4` container with `text-h5 q-mb-md` page title
|
||||||
- [ ] Min 8 chars, confirmation match check shown inline
|
- [x] All `text-grey-7` / `text-grey-8` replaced with dark-mode-safe `text-grey`
|
||||||
- [ ] Success/error toast notifications
|
- [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
|
### Remaining
|
||||||
- [ ] `q-card` showing OIDC connections as a proper table
|
- [ ] SSO Providers: add Status column, "Disconnect" action
|
||||||
- [ ] Columns: Provider, Last Refreshed, Status
|
- [ ] Admin pages (users, devices, rules): apply same card-based styling
|
||||||
- [ ] "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
|
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
|
|
@ -21,6 +21,7 @@ class User(SQLModel, table=True):
|
||||||
sign_in_token_created_at: datetime | None = None
|
sign_in_token_created_at: datetime | None = None
|
||||||
|
|
||||||
disabled_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)
|
inserted_at: datetime = Field(default_factory=utcnow)
|
||||||
updated_at: datetime = Field(default_factory=utcnow)
|
updated_at: datetime = Field(default_factory=utcnow)
|
||||||
|
|
|
||||||
|
|
@ -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
|
import json
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
@ -23,28 +23,6 @@ from wiregui.pages.layout import layout
|
||||||
from wiregui.utils.time import utcnow
|
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")
|
@ui.page("/account")
|
||||||
async def account_page():
|
async def account_page():
|
||||||
if not app.storage.user.get("authenticated"):
|
if not app.storage.user.get("authenticated"):
|
||||||
|
|
@ -65,261 +43,42 @@ async def account_page():
|
||||||
select(OIDCConnection).where(OIDCConnection.user_id == user_id)
|
select(OIDCConnection).where(OIDCConnection.user_id == user_id)
|
||||||
)).scalars().all()
|
)).scalars().all()
|
||||||
|
|
||||||
with ui.column().classes("w-full p-6"):
|
with ui.column().classes("w-full p-4"):
|
||||||
ui.label("Account Settings").classes("text-sm text-weight-bold")
|
ui.label("Account Settings").classes("text-h5 q-mb-md")
|
||||||
ui.label("Configure settings related to your WireGUI account.").classes("text-caption text-grey-7")
|
|
||||||
|
|
||||||
# ===== Details =====
|
# ===== Account Details =====
|
||||||
_section_header("Details")
|
with ui.card().classes("w-full"):
|
||||||
with ui.column().classes("gap-1"):
|
ui.label("Account Details").classes("text-subtitle1 text-bold")
|
||||||
_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'''<q-td :props="props"><q-badge :color="props.row.status === 'Active' ? 'positive' : 'negative'" :label="props.row.status" /></q-td>''')
|
|
||||||
tbl.add_slot("body-cell-actions", r'''<q-td :props="props"><q-btn flat dense icon="delete" color="negative" size="xs" @click.stop="() => $parent.$emit('delete', props.row.id)" /></q-td>''')
|
|
||||||
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'''<q-td :props="props"><q-btn flat dense label="Delete" color="warning" size="xs" @click.stop="() => $parent.$emit('delete', props.row.id)" /></q-td>''')
|
|
||||||
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")
|
|
||||||
ui.separator()
|
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")
|
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")
|
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")
|
cpw = ui.input("Confirm Password", password=True, password_toggle_button=True).props("outlined dense").classes("w-full q-mt-sm")
|
||||||
|
|
||||||
async def save_pw():
|
async def save_pw():
|
||||||
if not cur.value or not npw.value:
|
if not cur.value or not npw.value:
|
||||||
ui.notify("All fields required", type="negative")
|
ui.notify("All fields required", type="negative")
|
||||||
|
|
@ -339,7 +98,268 @@ async def account_page():
|
||||||
session.add(u)
|
session.add(u)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
ui.notify("Password changed", type="positive")
|
ui.notify("Password changed", type="positive")
|
||||||
pw_dialog.close()
|
cur.value = ""
|
||||||
with ui.row().classes("w-full justify-end q-mt-sm"):
|
npw.value = ""
|
||||||
ui.button("Cancel", on_click=pw_dialog.close).props("flat")
|
cpw.value = ""
|
||||||
ui.button("Save", on_click=save_pw).props("color=primary unelevated")
|
|
||||||
|
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'''<q-td :props="props"><q-badge :color="props.row.status === 'Active' ? 'positive' : 'negative'" :label="props.row.status" /></q-td>''')
|
||||||
|
tbl.add_slot("body-cell-actions", r'''<q-td :props="props"><q-btn flat dense icon="delete" color="negative" size="xs" @click.stop="() => $parent.$emit('delete', props.row.id)" /></q-td>''')
|
||||||
|
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'''<q-td :props="props"><q-btn flat dense icon="delete" color="negative" size="xs" @click.stop="() => $parent.$emit('delete', props.row.id)" /></q-td>''')
|
||||||
|
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")
|
||||||
|
|
@ -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")
|
ui.html(buf.getvalue().decode()).classes("w-full q-mt-sm")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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")
|
ui.button("Close", on_click=dialog.close).props("flat").classes("w-full")
|
||||||
|
|
|
||||||
|
|
@ -97,7 +97,7 @@ async def diagnostics_page():
|
||||||
]
|
]
|
||||||
ui.table(columns=peer_columns, rows=peer_rows, row_key="name").classes("w-full")
|
ui.table(columns=peer_columns, rows=peer_rows, row_key="name").classes("w-full")
|
||||||
else:
|
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 ---
|
# --- Connectivity Checks ---
|
||||||
with ui.card().classes("w-full q-mt-md"):
|
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")
|
ui.table(columns=check_columns, rows=check_rows, row_key="time").classes("w-full")
|
||||||
else:
|
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 ---
|
# --- Notifications ---
|
||||||
with ui.card().classes("w-full q-mt-md"):
|
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.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")
|
ui.label(f"{n.timestamp.strftime('%H:%M:%S')} — {n.message}").classes("text-sm")
|
||||||
if n.user:
|
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")
|
ui.button(icon="close", on_click=lambda nid=n.id: _clear_notif(nid)).props("flat dense size=xs")
|
||||||
else:
|
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:
|
if notifs:
|
||||||
ui.button("Clear All", on_click=lambda: _clear_all_notifs()).props("flat color=negative").classes("q-mt-sm")
|
ui.button("Clear All", on_click=lambda: _clear_all_notifs()).props("flat color=negative").classes("q-mt-sm")
|
||||||
|
|
|
||||||
|
|
@ -143,128 +143,118 @@ async def settings_page():
|
||||||
with ui.column().classes("w-full p-4"):
|
with ui.column().classes("w-full p-4"):
|
||||||
ui.label("Settings").classes("text-h5 q-mb-md")
|
ui.label("Settings").classes("text-h5 q-mb-md")
|
||||||
|
|
||||||
with ui.tabs().classes("w-full") as tabs:
|
# === Client Defaults ===
|
||||||
defaults_tab = ui.tab("Client Defaults")
|
with ui.card().classes("w-full"):
|
||||||
security_tab = ui.tab("Security")
|
ui.label("Default Client Configuration").classes("text-subtitle1 text-bold")
|
||||||
auth_tab = ui.tab("Authentication")
|
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 ===
|
defaults_dns = ui.input(
|
||||||
with ui.tab_panel(defaults_tab):
|
"DNS Servers", value=", ".join(config.default_client_dns),
|
||||||
with ui.card().classes("w-full"):
|
placeholder="1.1.1.1, 1.0.0.1",
|
||||||
ui.label("Default Client Configuration").classes("text-subtitle1 text-bold")
|
).props("outlined dense").classes("w-full q-mt-sm")
|
||||||
ui.label("These defaults apply to new devices unless overridden per-device.").classes("text-caption text-grey-7")
|
ui.label("Comma-separated. Leave blank to omit.").classes("text-caption text-grey")
|
||||||
ui.separator()
|
|
||||||
|
|
||||||
defaults_endpoint = ui.input(
|
defaults_allowed_ips = ui.input(
|
||||||
"Endpoint", value=config.default_client_endpoint or "",
|
"Allowed IPs", value=", ".join(config.default_client_allowed_ips),
|
||||||
placeholder="vpn.example.com",
|
placeholder="0.0.0.0/0, ::/0",
|
||||||
).props("outlined dense").classes("w-full")
|
).props("outlined dense").classes("w-full q-mt-sm")
|
||||||
ui.label("IPv4/IPv6 address or FQDN clients connect to").classes("text-caption text-grey-7")
|
ui.label("CIDR ranges for split or full tunnel.").classes("text-caption text-grey")
|
||||||
|
|
||||||
defaults_dns = ui.input(
|
with ui.row().classes("w-full gap-4 q-mt-sm"):
|
||||||
"DNS Servers", value=", ".join(config.default_client_dns),
|
defaults_mtu = ui.input(
|
||||||
placeholder="1.1.1.1, 1.0.0.1",
|
"MTU", value=str(config.default_client_mtu),
|
||||||
).props("outlined dense").classes("w-full q-mt-sm")
|
placeholder="1280",
|
||||||
ui.label("Comma-separated. Leave blank to omit.").classes("text-caption text-grey-7")
|
).props("outlined dense").classes("w-48")
|
||||||
|
|
||||||
defaults_allowed_ips = ui.input(
|
defaults_keepalive = ui.input(
|
||||||
"Allowed IPs", value=", ".join(config.default_client_allowed_ips),
|
"Persistent Keepalive", value=str(config.default_client_persistent_keepalive),
|
||||||
placeholder="0.0.0.0/0, ::/0",
|
placeholder="25",
|
||||||
).props("outlined dense").classes("w-full q-mt-sm")
|
).props("outlined dense").classes("w-48")
|
||||||
ui.label("CIDR ranges for split or full tunnel.").classes("text-caption text-grey-7")
|
|
||||||
|
|
||||||
with ui.row().classes("w-full gap-4 q-mt-sm"):
|
ui.button("Save Defaults", on_click=save_defaults).props("color=primary unelevated").classes("q-mt-md")
|
||||||
defaults_mtu = ui.input(
|
|
||||||
"MTU", value=str(config.default_client_mtu),
|
|
||||||
placeholder="1280",
|
|
||||||
).props("outlined dense").classes("w-48")
|
|
||||||
|
|
||||||
defaults_keepalive = ui.input(
|
# === Security ===
|
||||||
"Persistent Keepalive", value=str(config.default_client_persistent_keepalive),
|
with ui.card().classes("w-full q-mt-lg"):
|
||||||
placeholder="25",
|
ui.label("Authentication & Access").classes("text-subtitle1 text-bold")
|
||||||
).props("outlined dense").classes("w-48")
|
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 ===
|
ui.separator().classes("q-my-md")
|
||||||
with ui.tab_panel(security_tab):
|
|
||||||
with ui.card().classes("w-full"):
|
|
||||||
ui.label("Authentication & Access").classes("text-subtitle1 text-bold")
|
|
||||||
ui.separator()
|
|
||||||
|
|
||||||
security_vpn_duration = ui.select(
|
security_local_auth = ui.switch("Local Authentication (email/password)", value=config.local_auth_enabled)
|
||||||
VPN_SESSION_OPTIONS,
|
security_unpriv_mgmt = ui.switch("Allow Unprivileged Device Management", value=config.allow_unprivileged_device_management)
|
||||||
value=config.vpn_session_duration,
|
security_unpriv_config = ui.switch("Allow Unprivileged Device Configuration", value=config.allow_unprivileged_device_configuration)
|
||||||
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")
|
|
||||||
|
|
||||||
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)
|
ui.button("Save Security Settings", on_click=save_security).props("color=primary unelevated").classes("q-mt-md")
|
||||||
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")
|
# === Authentication (OIDC/SAML) ===
|
||||||
ui.label("SSO Behavior").classes("text-subtitle2")
|
with ui.card().classes("w-full q-mt-lg"):
|
||||||
security_disable_vpn_oidc = ui.switch("Auto-disable VPN on OIDC refresh error", value=config.disable_vpn_on_oidc_error)
|
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",
|
||||||
|
'''
|
||||||
|
<q-td :props="props">
|
||||||
|
<q-btn flat dense icon="delete" color="negative"
|
||||||
|
@click.stop="() => $parent.$emit('delete', props.row.id)" />
|
||||||
|
</q-td>
|
||||||
|
''',
|
||||||
|
)
|
||||||
|
oidc_table.on("delete", lambda e: delete_oidc_provider(e.args))
|
||||||
|
|
||||||
# === Authentication (OIDC/SAML) ===
|
ui.button("Add OIDC Provider", icon="add", on_click=lambda: oidc_dialog.open()).props("color=primary unelevated").classes("q-mt-sm")
|
||||||
with ui.tab_panel(auth_tab):
|
|
||||||
with ui.card().classes("w-full"):
|
|
||||||
ui.label("OpenID Connect Providers").classes("text-subtitle1 text-bold")
|
|
||||||
ui.separator()
|
|
||||||
|
|
||||||
oidc_columns = [
|
with ui.card().classes("w-full q-mt-lg"):
|
||||||
{"name": "id", "label": "Config ID", "field": "id", "align": "left"},
|
ui.label("SAML Identity Providers").classes("text-subtitle1 text-bold")
|
||||||
{"name": "label", "label": "Label", "field": "label", "align": "left"},
|
ui.separator()
|
||||||
{"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",
|
|
||||||
'''
|
|
||||||
<q-td :props="props">
|
|
||||||
<q-btn flat dense icon="delete" color="negative"
|
|
||||||
@click.stop="() => $parent.$emit('delete', props.row.id)" />
|
|
||||||
</q-td>
|
|
||||||
''',
|
|
||||||
)
|
|
||||||
oidc_table.on("delete", lambda e: delete_oidc_provider(e.args))
|
|
||||||
|
|
||||||
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",
|
||||||
|
'''
|
||||||
|
<q-td :props="props">
|
||||||
|
<q-btn flat dense icon="delete" color="negative"
|
||||||
|
@click.stop="() => $parent.$emit('delete', props.row.id)" />
|
||||||
|
</q-td>
|
||||||
|
''',
|
||||||
|
)
|
||||||
|
saml_table.on("delete", lambda e: delete_saml_provider(e.args))
|
||||||
|
|
||||||
with ui.card().classes("w-full q-mt-md"):
|
ui.button("Add SAML Provider", icon="add", on_click=lambda: saml_dialog.open()).props("color=primary unelevated").classes("q-mt-sm")
|
||||||
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",
|
|
||||||
'''
|
|
||||||
<q-td :props="props">
|
|
||||||
<q-btn flat dense icon="delete" color="negative"
|
|
||||||
@click.stop="() => $parent.$emit('delete', props.row.id)" />
|
|
||||||
</q-td>
|
|
||||||
''',
|
|
||||||
)
|
|
||||||
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")
|
|
||||||
|
|
||||||
# --- SAML provider management ---
|
# --- SAML provider management ---
|
||||||
async def save_saml_provider():
|
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_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_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")
|
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.separator().classes("q-my-sm")
|
||||||
ui.label("Security Options").classes("text-subtitle2")
|
ui.label("Security Options").classes("text-subtitle2")
|
||||||
|
|
|
||||||
|
|
@ -87,5 +87,6 @@ async def magic_link_verify_page(user_id: str, token: str):
|
||||||
user_id=str(user.id),
|
user_id=str(user.id),
|
||||||
email=user.email,
|
email=user.email,
|
||||||
role=user.role,
|
role=user.role,
|
||||||
|
theme_preference=user.theme_preference,
|
||||||
)
|
)
|
||||||
ui.navigate.to("/")
|
ui.navigate.to("/")
|
||||||
|
|
|
||||||
|
|
@ -116,5 +116,6 @@ async def oidc_callback(provider_id: str, request: Request):
|
||||||
request.session["user_id"] = str(user.id)
|
request.session["user_id"] = str(user.id)
|
||||||
request.session["email"] = user.email
|
request.session["email"] = user.email
|
||||||
request.session["role"] = user.role
|
request.session["role"] = user.role
|
||||||
|
request.session["theme_preference"] = user.theme_preference
|
||||||
|
|
||||||
return RedirectResponse(url="/")
|
return RedirectResponse(url="/")
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,7 @@ async def saml_callback(provider_id: str, request: Request):
|
||||||
request.session["user_id"] = str(user.id)
|
request.session["user_id"] = str(user.id)
|
||||||
request.session["email"] = user.email
|
request.session["email"] = user.email
|
||||||
request.session["role"] = user.role
|
request.session["role"] = user.role
|
||||||
|
request.session["theme_preference"] = user.theme_preference
|
||||||
|
|
||||||
logger.info("SAML login: {} via {}", email, provider_id)
|
logger.info("SAML login: {} via {}", email, provider_id)
|
||||||
return RedirectResponse(url="/", status_code=303)
|
return RedirectResponse(url="/", status_code=303)
|
||||||
|
|
|
||||||
|
|
@ -190,7 +190,7 @@ async def devices_page():
|
||||||
|
|
||||||
ui.separator().classes("q-my-sm")
|
ui.separator().classes("q-my-sm")
|
||||||
ui.label("Configuration Overrides").classes("text-subtitle2")
|
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"):
|
with ui.grid(columns=2).classes("w-full gap-2"):
|
||||||
create_use_default_ips = ui.switch("Use default Allowed IPs", value=True)
|
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.button(icon="arrow_back", on_click=lambda: ui.navigate.to("/devices")).props("flat")
|
||||||
ui.label(device.name).classes("text-h5")
|
ui.label(device.name).classes("text-h5")
|
||||||
if device.description:
|
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
|
# Device info card
|
||||||
with ui.card().classes("w-full q-mt-md"):
|
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)
|
# Traffic stats (live-updating)
|
||||||
with ui.card().classes("w-full q-mt-md"):
|
with ui.card().classes("w-full q-mt-md"):
|
||||||
ui.label("Traffic Stats").classes("text-subtitle1 text-bold")
|
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()
|
ui.separator()
|
||||||
with ui.grid(columns=2).classes("w-full gap-2 q-pa-sm"):
|
with ui.grid(columns=2).classes("w-full gap-2 q-pa-sm"):
|
||||||
ui.label("RX:").classes("text-bold")
|
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.label("Danger Zone").classes("text-subtitle1 text-bold text-negative")
|
||||||
ui.separator()
|
ui.separator()
|
||||||
ui.button("Delete Device", icon="delete", on_click=lambda: confirm_dialog.open()).props(
|
ui.button("Delete Device", icon="delete", on_click=lambda: confirm_dialog.open()).props(
|
||||||
"color=negative outline"
|
"color=negative unelevated"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Confirm delete dialog
|
# Confirm delete dialog
|
||||||
|
|
@ -458,6 +458,6 @@ def _show_config_dialog(device_name: str, config_text: str):
|
||||||
ui.button(
|
ui.button(
|
||||||
"Download .conf",
|
"Download .conf",
|
||||||
on_click=lambda: ui.download(config_text.encode(), f"{device_name}.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")
|
ui.button("Close", on_click=dialog.close).props("flat").classes("w-full")
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,65 @@
|
||||||
"""Shared layout — sidebar navigation + header."""
|
"""Shared layout — sidebar navigation + header."""
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
from nicegui import app, ui
|
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
|
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"):
|
def layout(title: str = "WireGUI"):
|
||||||
"""Render the shared app chrome (header + sidebar). Call at the top of each page."""
|
"""Render the shared app chrome (header + sidebar). Call at the top of each page."""
|
||||||
|
apply_style()
|
||||||
|
|
||||||
user_email = app.storage.user.get("email", "")
|
user_email = app.storage.user.get("email", "")
|
||||||
role = app.storage.user.get("role", "")
|
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():
|
def logout():
|
||||||
app.storage.user.clear()
|
app.storage.user.clear()
|
||||||
ui.navigate.to("/login")
|
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
|
# Header
|
||||||
with ui.header().classes("items-center justify-between"):
|
with ui.header().classes("items-center justify-between"):
|
||||||
with ui.row().classes("items-center"):
|
with ui.row().classes("items-center"):
|
||||||
|
|
@ -28,21 +74,25 @@ def layout(title: str = "WireGUI"):
|
||||||
).props("flat color=white"):
|
).props("flat color=white"):
|
||||||
if notif_count > 0:
|
if notif_count > 0:
|
||||||
ui.badge(str(notif_count), color="red").props("floating")
|
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.label(f"{user_email}").classes("text-subtitle2")
|
||||||
ui.button("Logout", on_click=logout).props("flat color=white")
|
ui.button("Logout", on_click=logout).props("flat color=white")
|
||||||
|
|
||||||
# Sidebar
|
# Sidebar
|
||||||
with ui.left_drawer(value=True, bordered=True).classes("bg-grey-1") as drawer:
|
with ui.left_drawer(value=True, bordered=True) as drawer:
|
||||||
ui.label("Navigation").classes("text-subtitle2 q-pa-sm text-grey-7")
|
ui.label("Navigation").classes("text-subtitle2 q-pa-sm text-grey")
|
||||||
ui.separator()
|
ui.separator()
|
||||||
ui.item("Devices", on_click=lambda: ui.navigate.to("/devices")).classes("cursor-pointer")
|
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")
|
ui.item("Account", on_click=lambda: ui.navigate.to("/account")).classes("cursor-pointer")
|
||||||
|
|
||||||
if role == "admin":
|
if role == "admin":
|
||||||
ui.separator()
|
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("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("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("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("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")
|
||||||
|
|
@ -7,6 +7,7 @@ from wiregui.auth.oidc import load_providers
|
||||||
from wiregui.auth.session import authenticate_user
|
from wiregui.auth.session import authenticate_user
|
||||||
from wiregui.db import async_session
|
from wiregui.db import async_session
|
||||||
from wiregui.models.mfa_method import MFAMethod
|
from wiregui.models.mfa_method import MFAMethod
|
||||||
|
from wiregui.pages.style import apply_style
|
||||||
from wiregui.utils.time import utcnow
|
from wiregui.utils.time import utcnow
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -15,6 +16,8 @@ async def login_page():
|
||||||
if app.storage.user.get("authenticated"):
|
if app.storage.user.get("authenticated"):
|
||||||
return ui.navigate.to("/")
|
return ui.navigate.to("/")
|
||||||
|
|
||||||
|
apply_style()
|
||||||
|
|
||||||
# Load OIDC providers for SSO buttons
|
# Load OIDC providers for SSO buttons
|
||||||
oidc_providers = await load_providers()
|
oidc_providers = await load_providers()
|
||||||
|
|
||||||
|
|
@ -44,6 +47,7 @@ async def login_page():
|
||||||
"user_id": str(user.id),
|
"user_id": str(user.id),
|
||||||
"email": user.email,
|
"email": user.email,
|
||||||
"role": user.role,
|
"role": user.role,
|
||||||
|
"theme_preference": user.theme_preference,
|
||||||
}
|
}
|
||||||
ui.navigate.to("/mfa")
|
ui.navigate.to("/mfa")
|
||||||
else:
|
else:
|
||||||
|
|
@ -53,6 +57,7 @@ async def login_page():
|
||||||
user_id=str(user.id),
|
user_id=str(user.id),
|
||||||
email=user.email,
|
email=user.email,
|
||||||
role=user.role,
|
role=user.role,
|
||||||
|
theme_preference=user.theme_preference,
|
||||||
)
|
)
|
||||||
ui.navigate.to("/")
|
ui.navigate.to("/")
|
||||||
|
|
||||||
|
|
@ -79,4 +84,4 @@ async def login_page():
|
||||||
ui.button(
|
ui.button(
|
||||||
label,
|
label,
|
||||||
on_click=lambda p=pid: ui.navigate.to(f"/auth/oidc/{p}"),
|
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")
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ from sqlmodel import select
|
||||||
from wiregui.auth.mfa import verify_totp_code
|
from wiregui.auth.mfa import verify_totp_code
|
||||||
from wiregui.db import async_session
|
from wiregui.db import async_session
|
||||||
from wiregui.models.mfa_method import MFAMethod
|
from wiregui.models.mfa_method import MFAMethod
|
||||||
|
from wiregui.pages.style import apply_style
|
||||||
|
|
||||||
|
|
||||||
@ui.page("/mfa")
|
@ui.page("/mfa")
|
||||||
|
|
@ -18,6 +19,7 @@ async def mfa_challenge_page():
|
||||||
if not pending:
|
if not pending:
|
||||||
return ui.navigate.to("/login")
|
return ui.navigate.to("/login")
|
||||||
|
|
||||||
|
apply_style()
|
||||||
user_id = UUID(pending["user_id"])
|
user_id = UUID(pending["user_id"])
|
||||||
|
|
||||||
# Load user's MFA methods
|
# Load user's MFA methods
|
||||||
|
|
@ -82,6 +84,7 @@ def _complete_login(pending: dict):
|
||||||
user_id=pending["user_id"],
|
user_id=pending["user_id"],
|
||||||
email=pending["email"],
|
email=pending["email"],
|
||||||
role=pending["role"],
|
role=pending["role"],
|
||||||
|
theme_preference=pending.get("theme_preference", "auto"),
|
||||||
)
|
)
|
||||||
# Clear pending state
|
# Clear pending state
|
||||||
app.storage.user.pop("pending_mfa", None)
|
app.storage.user.pop("pending_mfa", None)
|
||||||
|
|
|
||||||
20
wiregui/pages/style.py
Normal file
20
wiregui/pages/style.py
Normal file
|
|
@ -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(
|
||||||
|
'<link rel="preconnect" href="https://fonts.googleapis.com">'
|
||||||
|
'<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>'
|
||||||
|
'<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@200..800&display=swap" rel="stylesheet">'
|
||||||
|
)
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
""")
|
||||||
Loading…
Add table
Add a link
Reference in a new issue