From 2d22158dd95f76399f5716d556be60c77da04a46 Mon Sep 17 00:00:00 2001 From: Tom van der Lee Date: Thu, 30 Jul 2026 16:03:32 +0200 Subject: Admin ui --- authentication/endpoints.py | 10 +- authentication/templates/base.html | 18 +- authentication/templates/login.html | 21 +- authentication/templates/register.html | 33 +- management/endpoints.py | 22 +- management/templates/base.html | 17 +- management/templates/dashboard.html | 16 +- management/templates/users.html | 32 +- templates/_styles.html | 539 +++++++++++++++++++++++++++++++++ templates/global.html | 19 ++ templates/macros.html | 49 +++ 11 files changed, 722 insertions(+), 54 deletions(-) create mode 100644 templates/_styles.html create mode 100644 templates/macros.html diff --git a/authentication/endpoints.py b/authentication/endpoints.py index 6877383..3af49fc 100644 --- a/authentication/endpoints.py +++ b/authentication/endpoints.py @@ -6,6 +6,7 @@ from uuid import uuid7 from fastapi import FastAPI, Request, Form, Query from pydantic import BaseModel, ValidationError, model_validator, EmailStr +from sqlalchemy import func from sqlmodel import select from starlette.authentication import requires from starlette.middleware.authentication import AuthenticationMiddleware @@ -106,10 +107,17 @@ class RegisterForm(BaseModel): return self +def is_first_run() -> bool: + with get_session_context() as session: + user_count = session.exec(select(func.count(User.id))) + return user_count.first() == 0 + + async def base_register(request: Request, form: RegisterForm | None = None, errors: dict[str, str] | None = None): return templates.TemplateResponse(request, 'register.html', context={ 'form': form, 'errors': errors, + 'is_first_run': is_first_run(), }) @@ -145,7 +153,7 @@ async def post_register(request: Request, email: Annotated[str, Form()] = '', pa return RedirectResponse(next, status_code=303) except ValidationError as exc: - errors.update({e['loc'][0]: e['msg'] for e in exc.errors()}) + errors.update({(e['loc'][0] if e['loc'] else 'verify_password'): e['msg'] for e in exc.errors()}) return await base_register(request, form, errors) diff --git a/authentication/templates/base.html b/authentication/templates/base.html index 9874313..ae6cb64 100644 --- a/authentication/templates/base.html +++ b/authentication/templates/base.html @@ -1,4 +1,20 @@ {% extends 'global.html' %} +{% import 'macros.html' as ui %} {% block template %} - {% block content %}{% endblock %} +
+
+
+
{{ ui.connector_icon(20) }} ttun
+
+ local:3000 + {{ ui.connector_icon(20) }} + your-app.ttun.dev +
+

Self-hosted tunnel proxy. Expose a local port through a public URL you control.

+
+
+ {% block content %}{% endblock %} +
+
+
{% endblock %} diff --git a/authentication/templates/login.html b/authentication/templates/login.html index 2db2512..dcc0efd 100644 --- a/authentication/templates/login.html +++ b/authentication/templates/login.html @@ -1,18 +1,13 @@ {% extends "./base.html" %} +{% import 'macros.html' as ui %} {% block content %} +

Sign in

+

Enter your credentials to manage this server.

- - - {% if errors and errors.username %} - {{ errors.username }} - {% endif %} - - - - {% if errors and errors.password%} - {{ errors.password }} - {% endif %} - + {{ ui.field('username', label='Username', value=form.username if form else '', error=errors.username if errors else '') }} + {{ ui.field('password', type='password', label='Password', error=errors.password if errors else '') }} + {{ ui.button('Sign in') }}
-{% endblock %} +

New here? Create an account

+{% endblock %} diff --git a/authentication/templates/register.html b/authentication/templates/register.html index 576db78..a1959eb 100644 --- a/authentication/templates/register.html +++ b/authentication/templates/register.html @@ -1,24 +1,19 @@ {% extends "./base.html" %} +{% import 'macros.html' as ui %} {% block content %} + {% if is_first_run %} +

Set up your ttun server

+

This will be the first account, with full access to manage this server.

+ {% else %} +

Create your account

+

This account will be able to sign in and manage this server.

+ {% endif %}
- - - {% if errors and errors.email%} - {{ errors.email }} - {% endif %} - - - - {% if errors and errors.password%} - {{ errors.password }} - {% endif %} - - - {% if errors and errors.password%} - {{ errors.verify_password }} - {% endif %} - - + {{ ui.field('email', type='email', label='Email', value=form.email if form else '', error=errors.email if errors else '') }} + {{ ui.field('password', type='password', label='Password', error=errors.password if errors else '') }} + {{ ui.field('verify_password', type='password', label='Confirm password', error=errors.verify_password if errors else '') }} + {{ ui.button('Create account') }}
-{% endblock %} +

Already have an account? Sign in

+{% endblock %} diff --git a/management/endpoints.py b/management/endpoints.py index 47602d6..ef44114 100644 --- a/management/endpoints.py +++ b/management/endpoints.py @@ -1,7 +1,9 @@ +from functools import wraps +from urllib.parse import urlencode + from fastapi import FastAPI, Request from sqlalchemy import func from sqlmodel import select -from starlette.authentication import requires from starlette.middleware.authentication import AuthenticationMiddleware from starlette.middleware.sessions import SessionMiddleware from starlette.responses import RedirectResponse @@ -21,6 +23,20 @@ templates = Jinja2Templates(directory=[ 'management/templates' ]) + +def requires_session(func): + """Redirects to an absolute login URL instead of Starlette's `requires(redirect=...)`, + which resolves the redirect target via `request.url_for()` against whichever router is + bound into `request.scope` -- a lookup that only reaches `authentication`'s routes when + `management` happens to be mounted under a shared root app.""" + @wraps(func) + async def wrapper(request: Request, *args, **kwargs): + if not {'authenticated', 'session'}.issubset(request.auth.scopes): + next_param = urlencode({'next': str(request.url)}) + return RedirectResponse(f'/auth/login/?{next_param}', status_code=303) + return await func(request, *args, **kwargs) + return wrapper + @management.get('/') async def index(request: Request): with get_session_context() as session: @@ -34,12 +50,12 @@ async def index(request: Request): @management.get('/dashboard/') -@requires(['authenticated', 'session'], redirect='get_login') +@requires_session async def dashboard(request: Request): return templates.TemplateResponse(request, 'dashboard.html') @management.get('/users/') -@requires(['authenticated', 'session'], redirect='get_login') +@requires_session async def users(request: Request): with get_session_context() as session: query = select(User) diff --git a/management/templates/base.html b/management/templates/base.html index 0b9da11..7ba0adb 100644 --- a/management/templates/base.html +++ b/management/templates/base.html @@ -1,7 +1,18 @@ {% extends 'global.html' %} +{% import 'macros.html' as ui %} {% block template %} -