fix: CI runner containers for Forgejo actions

This commit is contained in:
Stefano Bertelli 2026-03-30 18:22:42 -05:00
parent 0546b44507
commit 9d9afbe3ad
10 changed files with 547 additions and 320 deletions

BIN
.coverage Normal file

Binary file not shown.

View file

@ -46,6 +46,8 @@ jobs:
needs: test needs: test
if: github.ref == 'refs/heads/main' && github.event_name == 'push' if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: docker runs-on: docker
container:
image: node:20-slim
outputs: outputs:
new_tag: ${{ steps.version.outputs.new_tag }} new_tag: ${{ steps.version.outputs.new_tag }}
new_version: ${{ steps.version.outputs.new_version }} new_version: ${{ steps.version.outputs.new_version }}

10
.idea/.gitignore generated vendored Normal file
View file

@ -0,0 +1,10 @@
# Default ignored files
/shelf/
/workspace.xml
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
# Editor-based HTTP Client requests
/httpRequests/

7
.idea/misc.xml generated Normal file
View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KubernetesApiPersistence">{}</component>
<component name="KubernetesApiProvider">{
&quot;isMigrated&quot;: true
}</component>
</project>

4
.idea/vcs.xml generated Normal file
View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings" defaultProject="true" />
</project>

67
TODO.md
View file

@ -188,9 +188,68 @@ Source: `/home/stefanob/PycharmProjects/personal/wirezone`
- [x] Loguru configured (wiregui/logging.py), no print statements - [x] Loguru configured (wiregui/logging.py), no print statements
- [x] File logging to `logs/` when `WG_LOG_TO_FILE=true` - [x] File logging to `logs/` when `WG_LOG_TO_FILE=true`
### Deployment ### Deployment ✅
- [ ] Dockerfile (multi-stage) - [x] Dockerfile (multi-stage python:3.13-slim)
- [ ] compose.prod.yml (app + postgres + valkey + caddy) - [x] compose.prod.yml (bridge networking, NET_ADMIN, nftables)
- [ ] Health endpoint `GET /api/health` - [x] Health endpoint `GET /api/health`
- [x] Forgejo CI: test → semver → Docker registry push
- [ ] First-run CLI setup command - [ ] First-run CLI setup command
- [ ] README.md - [ ] README.md
---
## UI Polish — Account Page (`/account`)
Redesign from tabbed layout to single scrollable page (matching original wirezone pattern).
Leverage Quasar components + Tailwind utility classes for modern look.
### Layout change
- [ ] Remove tabs — render all sections stacked vertically on one page
- [ ] Page header: "Account Settings" with subtitle description
### Section 1: Account Details
- [ ] Quasar `q-card` with clean table layout (not grid) for user info
- [ ] Rows: Email, Role, Last Sign-in, Method, Created
- [ ] Tailwind: rounded borders, hover states on rows, subtle dividers
- [ ] "Edit" button to open email change dialog (future)
### Section 2: Change Password
- [ ] Separate `q-card` below details
- [ ] Outlined inputs with proper validation feedback
- [ ] Min 8 chars, confirmation match check shown inline
- [ ] Success/error toast notifications
### Section 3: Connected SSO Providers
- [ ] `q-card` showing OIDC connections as a proper table
- [ ] Columns: Provider, Last Refreshed, Status
- [ ] "Disconnect" action per provider (future)
- [ ] Empty state: "No SSO providers connected"
### Section 4: Multi-Factor Authentication
- [ ] `q-card` with MFA methods table
- [ ] Columns: Name, Type, Last Used, Actions (delete)
- [ ] Styled delete button (red outline, confirmation dialog)
- [ ] "Add TOTP Method" and "Add Security Key" buttons below table
- [ ] TOTP registration renders inline (QR + verify code) inside an expansion panel
- [ ] Empty state with icon + message
### Section 5: API Tokens
- [ ] `q-card` with tokens table
- [ ] Columns: Created, Expires, Status (chip: green "Active" / red "Expired"), Actions
- [ ] Quasar `q-chip` for status badges
- [ ] Create token: inline row with expiry input + button (not a dialog)
- [ ] Token display after creation: `q-banner` with copy-to-clipboard button
- [ ] Empty state message
### Section 6: Danger Zone
- [ ] `q-card` with red left border accent (`border-l-4 border-red-500`)
- [ ] "Delete Your Account" button with `q-btn color=negative outline`
- [ ] Confirmation dialog with typed email verification
- [ ] Disabled if user is the only admin
### General styling improvements
- [ ] Consistent card spacing (`q-mt-lg` between sections)
- [ ] Section titles: `text-h6 text-weight-medium`
- [ ] Descriptive subtitles below each section title in `text-caption text-grey-7`
- [ ] Responsive: max-width container centered (`max-w-3xl mx-auto`)
- [ ] Smooth scroll between sections

View file

@ -39,6 +39,8 @@ services:
postgres: postgres:
image: postgres:17 image: postgres:17
restart: unless-stopped restart: unless-stopped
ports:
- "5432:5432"
environment: environment:
POSTGRES_USER: wiregui POSTGRES_USER: wiregui
POSTGRES_PASSWORD: wiregui POSTGRES_PASSWORD: wiregui

View file

@ -29,6 +29,7 @@ def _make_device(**kwargs) -> Device:
async def test_on_device_created_calls_add_peer(mock_wg, mock_fw, mock_settings): async def test_on_device_created_calls_add_peer(mock_wg, mock_fw, mock_settings):
mock_settings.return_value.wg_enabled = True mock_settings.return_value.wg_enabled = True
mock_wg.add_peer = AsyncMock() mock_wg.add_peer = AsyncMock()
mock_fw.add_user_chain = AsyncMock()
mock_fw.add_device_jump_rule = AsyncMock() mock_fw.add_device_jump_rule = AsyncMock()
device = _make_device() device = _make_device()

View file

@ -1,12 +1,12 @@
"""User account page — password change, MFA management, API tokens.""" """User account page — single scrollable page with all account sections."""
import json
from datetime import timedelta
from uuid import UUID from uuid import UUID
from loguru import logger from loguru import logger
from nicegui import app, ui from nicegui import app, ui
from sqlmodel import select from sqlmodel import func, select
import json
from wiregui.auth.api_token import generate_api_token from wiregui.auth.api_token import generate_api_token
from wiregui.auth.mfa import generate_totp_qr_svg, generate_totp_secret, get_totp_uri, verify_totp_code from wiregui.auth.mfa import generate_totp_qr_svg, generate_totp_secret, get_totp_uri, verify_totp_code
@ -14,8 +14,10 @@ from wiregui.auth.passwords import hash_password, verify_password
from wiregui.auth.webauthn import create_registration_options, verify_registration from wiregui.auth.webauthn import create_registration_options, verify_registration
from wiregui.db import async_session from wiregui.db import async_session
from wiregui.models.api_token import ApiToken from wiregui.models.api_token import ApiToken
from wiregui.models.device import Device
from wiregui.models.mfa_method import MFAMethod from wiregui.models.mfa_method import MFAMethod
from wiregui.models.oidc_connection import OIDCConnection from wiregui.models.oidc_connection import OIDCConnection
from wiregui.models.rule import Rule
from wiregui.models.user import User from wiregui.models.user import User
from wiregui.pages.layout import layout from wiregui.pages.layout import layout
from wiregui.utils.time import utcnow from wiregui.utils.time import utcnow
@ -31,94 +33,157 @@ async def account_page():
async with async_session() as session: async with async_session() as session:
user = await session.get(User, user_id) user = await session.get(User, user_id)
device_count = (await session.execute(
select(func.count()).select_from(Device).where(Device.user_id == user_id)
)).scalar()
oidc_conns = (await session.execute(
select(OIDCConnection).where(OIDCConnection.user_id == user_id)
)).scalars().all()
with ui.column().classes("w-full p-4"): with ui.column().classes("w-full max-w-3xl mx-auto p-4"):
ui.label("Account Settings").classes("text-h5 q-mb-md") # Page header
ui.label("Account Settings").classes("text-h5 text-weight-medium")
ui.label("Manage your profile, security, and API access.").classes("text-caption text-grey-7 q-mb-lg")
with ui.tabs().classes("w-full") as tabs: # ===== Section 1: Account Details =====
profile_tab = ui.tab("Profile") _render_details(user, device_count)
mfa_tab = ui.tab("Two-Factor Auth")
tokens_tab = ui.tab("API Tokens")
with ui.tab_panels(tabs, value=profile_tab).classes("w-full"): # ===== Section 2: Change Password =====
await _render_password_section(user_id, user.email)
# === Profile === # ===== Section 3: Connected SSO Providers =====
with ui.tab_panel(profile_tab): _render_sso_section(oidc_conns)
with ui.card().classes("w-full"):
ui.label("Account Details").classes("text-subtitle1 text-bold")
ui.separator()
with ui.grid(columns=2).classes("w-full gap-2 q-pa-sm"):
ui.label("Email:").classes("text-bold")
ui.label(user.email)
ui.label("Role:").classes("text-bold")
ui.label(user.role)
ui.label("Last Sign-in:").classes("text-bold")
ui.label(str(user.last_signed_in_at)[:19] if user.last_signed_in_at else "-")
ui.label("Method:").classes("text-bold")
ui.label(user.last_signed_in_method or "-")
with ui.card().classes("w-full q-mt-md"): # ===== Section 4: Multi-Factor Authentication =====
ui.label("Change Password").classes("text-subtitle1 text-bold") await _render_mfa_section(user_id, user.email)
# ===== Section 5: API Tokens =====
await _render_tokens_section(user_id)
# ===== Section 6: Danger Zone =====
await _render_danger_zone(user_id, user.email, user.role)
def _render_details(user: User, device_count: int):
"""Section 1: Account details table."""
with ui.card().classes("w-full q-mt-lg"):
ui.label("Account Details").classes("text-h6 text-weight-medium q-pa-md q-pb-none")
ui.label("Your profile information.").classes("text-caption text-grey-7 q-px-md")
ui.separator() ui.separator()
current_pw = ui.input("Current Password", password=True, password_toggle_button=True).props("outlined dense").classes("w-full") # Table-style layout
new_pw = ui.input("New Password", password=True, password_toggle_button=True).props("outlined dense").classes("w-full") rows = [
confirm_pw = ui.input("Confirm New Password", password=True, password_toggle_button=True).props("outlined dense").classes("w-full") ("Email", user.email),
("Role", ui.badge(user.role, color="primary" if user.role == "admin" else "grey").classes("text-xs")),
("Last Sign-in", str(user.last_signed_in_at)[:19] if user.last_signed_in_at else "Never"),
("Method", user.last_signed_in_method or "-"),
("Devices", str(device_count)),
("Created", str(user.inserted_at)[:19]),
]
for i, (label, value) in enumerate(rows):
with ui.row().classes(
"w-full items-center px-4 py-2.5 hover:bg-grey-1 transition-colors"
+ (" border-t" if i > 0 else "")
):
ui.label(label).classes("w-40 text-weight-medium text-grey-8 text-sm")
if isinstance(value, str):
ui.label(value).classes("text-sm")
# Badge was already rendered inline
async def _render_password_section(user_id: UUID, email: str):
"""Section 2: Change password form."""
with ui.card().classes("w-full q-mt-lg"):
ui.label("Change Password").classes("text-h6 text-weight-medium q-pa-md q-pb-none")
ui.label("Update your account password.").classes("text-caption text-grey-7 q-px-md")
ui.separator()
with ui.column().classes("w-full q-pa-md gap-3"):
current_pw = ui.input(
"Current Password", password=True, password_toggle_button=True,
).props("outlined dense").classes("w-full")
new_pw = ui.input(
"New Password", password=True, password_toggle_button=True,
).props("outlined dense").classes("w-full")
confirm_pw = ui.input(
"Confirm New Password", password=True, password_toggle_button=True,
).props("outlined dense").classes("w-full")
pw_hint = ui.label("").classes("text-caption text-negative").style("display: none")
async def change_password(): async def change_password():
pw_hint.style("display: none")
if not current_pw.value or not new_pw.value: if not current_pw.value or not new_pw.value:
ui.notify("Fill in all password fields", type="negative") pw_hint.text = "All password fields are required."
pw_hint.style("display: block")
return return
if new_pw.value != confirm_pw.value: if new_pw.value != confirm_pw.value:
ui.notify("New passwords do not match", type="negative") pw_hint.text = "New passwords do not match."
pw_hint.style("display: block")
return return
if len(new_pw.value) < 8: if len(new_pw.value) < 8:
ui.notify("Password must be at least 8 characters", type="negative") pw_hint.text = "Password must be at least 8 characters."
pw_hint.style("display: block")
return return
async with async_session() as session: async with async_session() as session:
u = await session.get(User, user_id) u = await session.get(User, user_id)
if not verify_password(current_pw.value, u.password_hash): if not verify_password(current_pw.value, u.password_hash):
ui.notify("Current password is incorrect", type="negative") pw_hint.text = "Current password is incorrect."
pw_hint.style("display: block")
return return
u.password_hash = hash_password(new_pw.value) u.password_hash = hash_password(new_pw.value)
session.add(u) session.add(u)
await session.commit() await session.commit()
logger.info("Password changed for {}", user.email) logger.info("Password changed for {}", email)
ui.notify("Password changed", type="positive") ui.notify("Password changed successfully", type="positive")
current_pw.value = "" current_pw.value = ""
new_pw.value = "" new_pw.value = ""
confirm_pw.value = "" confirm_pw.value = ""
ui.button("Change Password", on_click=change_password).props("color=primary").classes("q-mt-sm") ui.button("Change Password", on_click=change_password).props("color=primary unelevated")
# OIDC connections
async with async_session() as session: def _render_sso_section(oidc_conns: list[OIDCConnection]):
oidc_conns = (await session.execute( """Section 3: Connected SSO providers."""
select(OIDCConnection).where(OIDCConnection.user_id == user_id) with ui.card().classes("w-full q-mt-lg"):
)).scalars().all() ui.label("Connected SSO Providers").classes("text-h6 text-weight-medium q-pa-md q-pb-none")
ui.label("Single sign-on accounts linked to your profile.").classes("text-caption text-grey-7 q-px-md")
ui.separator()
if oidc_conns: if oidc_conns:
with ui.card().classes("w-full q-mt-md"): for i, conn in enumerate(oidc_conns):
ui.label("Connected SSO Providers").classes("text-subtitle1 text-bold") with ui.row().classes(
"w-full items-center justify-between px-4 py-3 hover:bg-grey-1 transition-colors"
+ (" border-t" if i > 0 else "")
):
with ui.row().classes("items-center gap-3"):
ui.icon("login").props("color=primary size=sm")
ui.label(conn.provider).classes("text-weight-medium text-sm")
with ui.row().classes("items-center gap-2"):
refreshed = str(conn.refreshed_at)[:19] if conn.refreshed_at else "Never"
ui.label(f"Last refreshed: {refreshed}").classes("text-caption text-grey-7")
ui.badge("Connected", color="positive").classes("text-xs")
else:
with ui.row().classes("w-full items-center justify-center q-pa-lg"):
ui.icon("link_off").props("color=grey-5 size=lg")
ui.label("No SSO providers connected.").classes("text-caption text-grey-5 q-ml-sm")
async def _render_mfa_section(user_id: UUID, email: str):
"""Section 4: Multi-factor authentication methods."""
with ui.card().classes("w-full q-mt-lg") as mfa_card:
ui.label("Multi-Factor Authentication").classes("text-h6 text-weight-medium q-pa-md q-pb-none")
ui.label("Add an extra layer of security to your account.").classes("text-caption text-grey-7 q-px-md")
ui.separator() ui.separator()
for conn in oidc_conns:
with ui.row().classes("w-full items-center justify-between q-pa-xs"):
ui.label(f"{conn.provider}").classes("text-bold")
ui.label(f"Last refreshed: {str(conn.refreshed_at)[:19] if conn.refreshed_at else 'Never'}")
# === MFA === methods_container = ui.column().classes("w-full")
with ui.tab_panel(mfa_tab): reg_container = ui.column().classes("w-full")
await _render_mfa_panel(user_id, user.email) registration = {"secret": None}
webauthn_state = {"challenge": None}
# === API Tokens ===
with ui.tab_panel(tokens_tab):
await _render_tokens_panel(user_id)
async def _render_mfa_panel(user_id: UUID, email: str):
"""Render the MFA management tab."""
async def load_methods(): async def load_methods():
async with async_session() as session: async with async_session() as session:
result = await session.execute( result = await session.execute(
@ -131,33 +196,49 @@ async def _render_mfa_panel(user_id: UUID, email: str):
methods_container.clear() methods_container.clear()
with methods_container: with methods_container:
if methods: if methods:
for m in methods: for i, m in enumerate(methods):
with ui.row().classes("w-full items-center justify-between q-pa-xs"): with ui.row().classes(
with ui.row().classes("items-center gap-2"): "w-full items-center justify-between px-4 py-3 hover:bg-grey-1 transition-colors"
ui.icon("security").props("color=primary") + (" border-t" if i > 0 else "")
ui.label(m.name).classes("text-bold") ):
ui.label(f"({m.type})").classes("text-caption text-grey-7") with ui.row().classes("items-center gap-3"):
with ui.row().classes("items-center gap-2"): icon = "fingerprint" if m.type in ("native", "portable") else "security"
ui.label(f"Last used: {str(m.last_used_at)[:19] if m.last_used_at else 'Never'}").classes("text-caption") ui.icon(icon).props("color=primary size=sm")
ui.button(icon="delete", on_click=lambda mid=m.id: delete_method(mid)).props("flat dense color=negative") with ui.column().classes("gap-0"):
ui.separator() ui.label(m.name).classes("text-weight-medium text-sm")
ui.label(m.type.upper()).classes("text-caption text-grey-7")
with ui.row().classes("items-center gap-3"):
last_used = str(m.last_used_at)[:19] if m.last_used_at else "Never"
ui.label(f"Last used: {last_used}").classes("text-caption text-grey-7")
ui.button(icon="delete", on_click=lambda mid=m.id: confirm_delete_mfa(mid)).props(
"flat dense round color=negative size=sm"
)
else: else:
ui.label("No MFA methods configured.").classes("text-caption text-grey-7 q-pa-sm") with ui.row().classes("w-full items-center justify-center q-pa-lg"):
ui.icon("shield").props("color=grey-5 size=lg")
ui.label("No MFA methods configured.").classes("text-caption text-grey-5 q-ml-sm")
async def delete_method(method_id): async def confirm_delete_mfa(method_id):
with ui.dialog(value=True) as dlg:
with ui.card().classes("w-80"):
ui.label("Remove MFA Method?").classes("text-h6")
ui.label("You will no longer be prompted for this method during sign-in.").classes("text-body2")
with ui.row().classes("w-full justify-end q-mt-sm"):
ui.button("Cancel", on_click=dlg.close).props("flat")
ui.button("Remove", on_click=lambda: _do_delete_mfa(method_id, dlg)).props("color=negative unelevated")
async def _do_delete_mfa(method_id, dlg):
async with async_session() as session: async with async_session() as session:
m = await session.get(MFAMethod, method_id) m = await session.get(MFAMethod, method_id)
if m and m.user_id == user_id: if m and m.user_id == user_id:
await session.delete(m) await session.delete(m)
await session.commit() await session.commit()
logger.info("MFA method deleted for user {}", email) logger.info("MFA method deleted for user {}", email)
dlg.close()
ui.notify("MFA method removed") ui.notify("MFA method removed")
await refresh_methods() await refresh_methods()
# Registration state def start_totp_registration():
registration = {"secret": None}
def start_registration():
secret = generate_totp_secret() secret = generate_totp_secret()
registration["secret"] = secret registration["secret"] = secret
uri = get_totp_uri(secret, email) uri = get_totp_uri(secret, email)
@ -165,59 +246,51 @@ async def _render_mfa_panel(user_id: UUID, email: str):
reg_container.clear() reg_container.clear()
with reg_container: with reg_container:
with ui.card().classes("w-full q-mt-sm").props("bordered"):
ui.label("Set up TOTP Authenticator").classes("text-subtitle1 text-weight-medium q-pa-md q-pb-none")
ui.separator()
with ui.column().classes("q-pa-md items-center gap-3"):
ui.label("Scan this QR code with your authenticator app:").classes("text-body2") ui.label("Scan this QR code with your authenticator app:").classes("text-body2")
ui.html(svg).classes("w-64 q-my-sm") ui.html(svg).classes("w-48")
ui.label(f"Or enter this secret manually: {secret}").classes("text-caption font-mono") with ui.row().classes("items-center gap-2"):
reg_name_input = ui.input("Method Name", value="Authenticator").props("outlined dense").classes("w-full") ui.label("Manual entry:").classes("text-caption text-grey-7")
reg_code_input = ui.input("Verification Code", placeholder="Enter 6-digit code").props("outlined dense maxlength=6").classes("w-full") ui.label(secret).classes("text-caption font-mono bg-grey-2 px-2 py-1 rounded")
ui.separator()
with ui.column().classes("q-pa-md gap-3"):
reg_name = ui.input("Method Name", value="Authenticator").props("outlined dense").classes("w-full")
reg_code = ui.input(
"Verification Code", placeholder="Enter 6-digit code",
).props("outlined dense maxlength=6").classes("w-full")
async def verify_and_save(): async def verify_and_save():
code = reg_code_input.value.strip() code = reg_code.value.strip()
name = reg_name_input.value.strip() or "Authenticator" name = reg_name.value.strip() or "Authenticator"
if not verify_totp_code(registration["secret"], code): if not verify_totp_code(registration["secret"], code):
ui.notify("Invalid code — check your authenticator", type="negative") ui.notify("Invalid code — check your authenticator", type="negative")
return return
async with async_session() as session: async with async_session() as session:
method = MFAMethod( method = MFAMethod(
name=name, name=name, type="totp",
type="totp",
payload={"secret": registration["secret"]}, payload={"secret": registration["secret"]},
user_id=user_id, user_id=user_id,
) )
session.add(method) session.add(method)
await session.commit() await session.commit()
logger.info("MFA TOTP registered for {}", email) logger.info("MFA TOTP registered for {}", email)
ui.notify("MFA method added!", type="positive") ui.notify("TOTP method added!", type="positive")
registration["secret"] = None registration["secret"] = None
reg_container.clear() reg_container.clear()
await refresh_methods() await refresh_methods()
ui.button("Verify & Save", on_click=verify_and_save).props("color=primary").classes("q-mt-sm") with ui.row().classes("gap-2"):
ui.button("Verify & Save", on_click=verify_and_save).props("color=primary unelevated")
ui.button("Cancel", on_click=lambda: reg_container.clear()).props("flat") ui.button("Cancel", on_click=lambda: reg_container.clear()).props("flat")
with ui.card().classes("w-full"):
ui.label("Two-Factor Authentication Methods").classes("text-subtitle1 text-bold")
ui.separator()
methods_container = ui.column().classes("w-full")
await refresh_methods()
with ui.row().classes("q-mt-sm gap-2"):
ui.button("Add TOTP Method", icon="add", on_click=start_registration).props("outline")
ui.button("Add Security Key", icon="key", on_click=lambda: start_webauthn_registration()).props("outline")
reg_container = ui.column().classes("w-full q-mt-md")
webauthn_state = {"challenge": None}
async def start_webauthn_registration(): async def start_webauthn_registration():
# Get existing webauthn credentials to exclude
existing = [] existing = []
async with async_session() as session: async with async_session() as session:
from sqlmodel import select as sel
result = await session.execute( result = await session.execute(
sel(MFAMethod).where(MFAMethod.user_id == user_id, MFAMethod.type.in_(["native", "portable"])) select(MFAMethod).where(MFAMethod.user_id == user_id, MFAMethod.type.in_(["native", "portable"]))
) )
for m in result.scalars().all(): for m in result.scalars().all():
existing.append(m.payload) existing.append(m.payload)
@ -231,12 +304,10 @@ async def _render_mfa_panel(user_id: UUID, email: str):
webauthn_state["challenge"] = reg_data["challenge"] webauthn_state["challenge"] = reg_data["challenge"]
options_json = reg_data["options_json"] options_json = reg_data["options_json"]
# Call browser's navigator.credentials.create() via JavaScript
js = f""" js = f"""
async function() {{ async function() {{
try {{ try {{
const options = JSON.parse('{options_json}'); const options = JSON.parse('{options_json}');
// Convert base64url strings to ArrayBuffers
options.challenge = Uint8Array.from(atob(options.challenge.replace(/-/g,'+').replace(/_/g,'/')), c => c.charCodeAt(0)); options.challenge = Uint8Array.from(atob(options.challenge.replace(/-/g,'+').replace(/_/g,'/')), c => c.charCodeAt(0));
options.user.id = Uint8Array.from(atob(options.user.id.replace(/-/g,'+').replace(/_/g,'/')), c => c.charCodeAt(0)); options.user.id = Uint8Array.from(atob(options.user.id.replace(/-/g,'+').replace(/_/g,'/')), c => c.charCodeAt(0));
if (options.excludeCredentials) {{ if (options.excludeCredentials) {{
@ -246,7 +317,6 @@ async def _render_mfa_panel(user_id: UUID, email: str):
}})); }}));
}} }}
const credential = await navigator.credentials.create({{publicKey: options}}); const credential = await navigator.credentials.create({{publicKey: options}});
// Serialize the response
const response = {{ const response = {{
id: credential.id, id: credential.id,
rawId: btoa(String.fromCharCode(...new Uint8Array(credential.rawId))).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=/g,''), rawId: btoa(String.fromCharCode(...new Uint8Array(credential.rawId))).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=/g,''),
@ -271,40 +341,47 @@ async def _render_mfa_panel(user_id: UUID, email: str):
except (json.JSONDecodeError, TypeError): except (json.JSONDecodeError, TypeError):
ui.notify("WebAuthn response error", type="negative") ui.notify("WebAuthn response error", type="negative")
return return
if "error" in result: if "error" in result:
ui.notify(f"WebAuthn failed: {result['error']}", type="negative") ui.notify(f"WebAuthn failed: {result['error']}", type="negative")
return return
challenge = webauthn_state.get("challenge") challenge = webauthn_state.get("challenge")
if not challenge: if not challenge:
ui.notify("No pending WebAuthn challenge", type="negative") ui.notify("No pending WebAuthn challenge", type="negative")
return return
try: try:
credential_data = verify_registration(result_json, challenge) credential_data = verify_registration(result_json, challenge)
except Exception as e: except Exception as e:
ui.notify(f"Verification failed: {e}", type="negative") ui.notify(f"Verification failed: {e}", type="negative")
return return
async with async_session() as session: async with async_session() as session:
method = MFAMethod( method = MFAMethod(
name="Security Key", name="Security Key", type="portable",
type="portable", payload=credential_data, user_id=user_id,
payload=credential_data,
user_id=user_id,
) )
session.add(method) session.add(method)
await session.commit() await session.commit()
logger.info("WebAuthn key registered for {}", email) logger.info("WebAuthn key registered for {}", email)
ui.notify("Security key registered!", type="positive") ui.notify("Security key registered!", type="positive")
webauthn_state["challenge"] = None webauthn_state["challenge"] = None
await refresh_methods() await refresh_methods()
await refresh_methods()
with ui.row().classes("q-pa-md gap-2"):
ui.button("Add TOTP Method", icon="qr_code", on_click=start_totp_registration).props("outline unelevated")
ui.button("Add Security Key", icon="key", on_click=lambda: start_webauthn_registration()).props("outline unelevated")
async def _render_tokens_section(user_id: UUID):
"""Section 5: API tokens management."""
with ui.card().classes("w-full q-mt-lg"):
ui.label("API Tokens").classes("text-h6 text-weight-medium q-pa-md q-pb-none")
ui.label("Use tokens for programmatic access to the REST API.").classes("text-caption text-grey-7 q-px-md")
ui.separator()
token_banner = ui.column().classes("w-full")
tokens_container = ui.column().classes("w-full")
async def _render_tokens_panel(user_id: UUID):
"""Render the API tokens tab."""
async def load_tokens(): async def load_tokens():
async with async_session() as session: async with async_session() as session:
result = await session.execute( result = await session.execute(
@ -314,21 +391,36 @@ async def _render_tokens_panel(user_id: UUID):
async def refresh_tokens(): async def refresh_tokens():
tokens = await load_tokens() tokens = await load_tokens()
token_table.rows = [ tokens_container.clear()
{ with tokens_container:
"id": str(t.id), if tokens:
"created": str(t.inserted_at)[:19], for i, t in enumerate(tokens):
"expires": str(t.expires_at)[:19] if t.expires_at else "Never", is_expired = t.expires_at and t.expires_at < utcnow()
"status": "Expired" if t.expires_at and t.expires_at < utcnow() else "Active", with ui.row().classes(
} "w-full items-center justify-between px-4 py-3 hover:bg-grey-1 transition-colors"
for t in tokens + (" border-t" if i > 0 else "")
] ):
token_table.update() with ui.row().classes("items-center gap-3"):
ui.icon("vpn_key").props(f"color={'grey-5' if is_expired else 'primary'} size=sm")
with ui.column().classes("gap-0"):
ui.label(f"Created {str(t.inserted_at)[:19]}").classes("text-sm")
expires_text = str(t.expires_at)[:19] if t.expires_at else "Never expires"
ui.label(f"Expires: {expires_text}").classes("text-caption text-grey-7")
with ui.row().classes("items-center gap-2"):
if is_expired:
ui.badge("Expired", color="negative").classes("text-xs")
else:
ui.badge("Active", color="positive").classes("text-xs")
ui.button(icon="delete", on_click=lambda tid=t.id: delete_token(tid)).props(
"flat dense round color=negative size=sm"
)
else:
with ui.row().classes("w-full items-center justify-center q-pa-lg"):
ui.icon("vpn_key").props("color=grey-5 size=lg")
ui.label("No API tokens created yet.").classes("text-caption text-grey-5 q-ml-sm")
async def create_token(): async def create_token():
from datetime import timedelta
days = int(token_days.value) if token_days.value else 30 days = int(token_days.value) if token_days.value else 30
plaintext, token_hash = generate_api_token() plaintext, token_hash = generate_api_token()
expires_at = utcnow() + timedelta(days=days) if days > 0 else None expires_at = utcnow() + timedelta(days=days) if days > 0 else None
@ -339,50 +431,100 @@ async def _render_tokens_panel(user_id: UUID):
logger.info("API token created (expires in {} days)", days) logger.info("API token created (expires in {} days)", days)
# Show the token once # Show token in a banner
with ui.dialog(value=True) as token_dialog: token_banner.clear()
with ui.card().classes("w-96"): with token_banner:
ui.label("API Token Created").classes("text-h6") with ui.card().classes("w-full bg-green-1 q-ma-md").props("bordered"):
ui.label("Copy this token now — it won't be shown again.").classes("text-caption text-negative") with ui.row().classes("items-center q-pa-sm gap-2"):
ui.input(value=plaintext).props("readonly outlined dense").classes("w-full font-mono q-mt-sm") ui.icon("check_circle").props("color=positive")
ui.button("Close", on_click=token_dialog.close).props("flat").classes("w-full q-mt-sm") ui.label("Token created — copy it now, it won't be shown again.").classes("text-sm text-weight-medium")
with ui.row().classes("q-pa-sm q-pt-none items-center gap-2"):
token_input = ui.input(value=plaintext).props("readonly outlined dense").classes("w-full font-mono text-xs")
ui.button(icon="content_copy", on_click=lambda: _copy_token(plaintext)).props("flat dense")
await refresh_tokens() await refresh_tokens()
async def delete_token(token_id: str): async def _copy_token(token: str):
await ui.run_javascript(f"navigator.clipboard.writeText('{token}')")
ui.notify("Copied to clipboard", type="positive")
async def delete_token(token_id):
async with async_session() as session: async with async_session() as session:
t = await session.get(ApiToken, UUID(token_id)) t = await session.get(ApiToken, token_id)
if t and t.user_id == user_id: if t and t.user_id == user_id:
await session.delete(t) await session.delete(t)
await session.commit() await session.commit()
ui.notify("Token deleted") ui.notify("Token deleted")
await refresh_tokens() await refresh_tokens()
with ui.card().classes("w-full"):
ui.label("API Tokens").classes("text-subtitle1 text-bold")
ui.separator()
ui.label("Use API tokens for programmatic access to the REST API.").classes("text-caption text-grey-7")
token_columns = [
{"name": "created", "label": "Created", "field": "created", "align": "left"},
{"name": "expires", "label": "Expires", "field": "expires", "align": "left"},
{"name": "status", "label": "Status", "field": "status", "align": "left"},
{"name": "actions", "label": "", "field": "id", "align": "center"},
]
token_table = ui.table(columns=token_columns, rows=[], row_key="id").classes("w-full")
token_table.add_slot(
"body-cell-actions",
'''
<q-td :props="props">
<q-btn flat dense icon="delete" color="negative"
@click.stop="() => $parent.$emit('delete', props.row.id)" />
</q-td>
''',
)
token_table.on("delete", lambda e: delete_token(e.args))
with ui.row().classes("items-center gap-2 q-mt-sm"):
token_days = ui.input("Expires in (days)", value="30").props("outlined dense").classes("w-40")
ui.button("Create Token", icon="add", on_click=create_token).props("color=primary")
await refresh_tokens() await refresh_tokens()
ui.separator()
with ui.row().classes("items-center gap-2 q-pa-md"):
token_days = ui.input("Expires in (days)", value="30").props("outlined dense").classes("w-40")
ui.button("Create Token", icon="add", on_click=create_token).props("color=primary unelevated")
async def _render_danger_zone(user_id: UUID, email: str, role: str):
"""Section 6: Danger zone — account deletion."""
with ui.card().classes("w-full q-mt-lg").style("border-left: 4px solid var(--q-negative)"):
ui.label("Danger Zone").classes("text-h6 text-weight-medium text-negative q-pa-md q-pb-none")
ui.label("Irreversible actions for your account.").classes("text-caption text-grey-7 q-px-md")
ui.separator()
with ui.column().classes("q-pa-md"):
# Check if user is the only admin
async with async_session() as session:
admin_count = (await session.execute(
select(func.count()).select_from(User).where(User.role == "admin")
)).scalar()
is_only_admin = role == "admin" and admin_count <= 1
if is_only_admin:
ui.label("You are the only admin — account deletion is disabled.").classes("text-caption text-grey-7")
async def confirm_delete():
with ui.dialog(value=True) as dlg:
with ui.card().classes("w-96"):
ui.label("Delete Your Account?").classes("text-h6 text-negative")
ui.label("This will permanently delete your account, all your devices, and firewall rules. This action cannot be undone.").classes("text-body2 q-my-sm")
ui.label(f"Type your email to confirm: {email}").classes("text-caption text-weight-medium")
confirm_input = ui.input(placeholder=email).props("outlined dense").classes("w-full")
async def do_delete():
if confirm_input.value.strip() != email:
ui.notify("Email does not match", type="negative")
return
async with async_session() as session:
# Delete devices
devices = (await session.execute(
select(Device).where(Device.user_id == user_id)
)).scalars().all()
for d in devices:
await session.delete(d)
# Delete rules
rules = (await session.execute(
select(Rule).where(Rule.user_id == user_id)
)).scalars().all()
for r in rules:
await session.delete(r)
# Delete user
u = await session.get(User, user_id)
if u:
await session.delete(u)
await session.commit()
logger.info("User {} deleted their own account", email)
dlg.close()
app.storage.user.clear()
ui.navigate.to("/login")
with ui.row().classes("w-full justify-end q-mt-sm"):
ui.button("Cancel", on_click=dlg.close).props("flat")
ui.button("Delete My Account", on_click=do_delete).props("color=negative unelevated")
ui.button(
"Delete Your Account", icon="delete_forever",
on_click=confirm_delete,
).props("color=negative outline" + (" disable" if is_only_admin else ""))

View file

@ -12,7 +12,7 @@ from wiregui.models.connectivity_check import ConnectivityCheck
from wiregui.services import notifications from wiregui.services import notifications
from wiregui.utils.time import utcnow from wiregui.utils.time import utcnow
DEFAULT_URL = "https://ping-dev.firezone.dev" DEFAULT_URL = "https://one.one.one.one/cdn-cgi/trace"
DEFAULT_INTERVAL = 300 # 5 minutes DEFAULT_INTERVAL = 300 # 5 minutes