diff options
| -rw-r--r-- | authentication/endpoints.py | 10 | ||||
| -rw-r--r-- | authentication/templates/base.html | 18 | ||||
| -rw-r--r-- | authentication/templates/login.html | 21 | ||||
| -rw-r--r-- | authentication/templates/register.html | 33 | ||||
| -rw-r--r-- | management/endpoints.py | 22 | ||||
| -rw-r--r-- | management/templates/base.html | 17 | ||||
| -rw-r--r-- | management/templates/dashboard.html | 16 | ||||
| -rw-r--r-- | management/templates/users.html | 32 | ||||
| -rw-r--r-- | templates/_styles.html | 539 | ||||
| -rw-r--r-- | templates/global.html | 19 | ||||
| -rw-r--r-- | templates/macros.html | 49 |
11 files changed, 722 insertions, 54 deletions
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 | |||
| 6 | 6 | ||
| 7 | from fastapi import FastAPI, Request, Form, Query | 7 | from fastapi import FastAPI, Request, Form, Query |
| 8 | from pydantic import BaseModel, ValidationError, model_validator, EmailStr | 8 | from pydantic import BaseModel, ValidationError, model_validator, EmailStr |
| 9 | from sqlalchemy import func | ||
| 9 | from sqlmodel import select | 10 | from sqlmodel import select |
| 10 | from starlette.authentication import requires | 11 | from starlette.authentication import requires |
| 11 | from starlette.middleware.authentication import AuthenticationMiddleware | 12 | from starlette.middleware.authentication import AuthenticationMiddleware |
| @@ -106,10 +107,17 @@ class RegisterForm(BaseModel): | |||
| 106 | return self | 107 | return self |
| 107 | 108 | ||
| 108 | 109 | ||
| 110 | def is_first_run() -> bool: | ||
| 111 | with get_session_context() as session: | ||
| 112 | user_count = session.exec(select(func.count(User.id))) | ||
| 113 | return user_count.first() == 0 | ||
| 114 | |||
| 115 | |||
| 109 | async def base_register(request: Request, form: RegisterForm | None = None, errors: dict[str, str] | None = None): | 116 | async def base_register(request: Request, form: RegisterForm | None = None, errors: dict[str, str] | None = None): |
| 110 | return templates.TemplateResponse(request, 'register.html', context={ | 117 | return templates.TemplateResponse(request, 'register.html', context={ |
| 111 | 'form': form, | 118 | 'form': form, |
| 112 | 'errors': errors, | 119 | 'errors': errors, |
| 120 | 'is_first_run': is_first_run(), | ||
| 113 | }) | 121 | }) |
| 114 | 122 | ||
| 115 | 123 | ||
| @@ -145,7 +153,7 @@ async def post_register(request: Request, email: Annotated[str, Form()] = '', pa | |||
| 145 | return RedirectResponse(next, status_code=303) | 153 | return RedirectResponse(next, status_code=303) |
| 146 | 154 | ||
| 147 | except ValidationError as exc: | 155 | except ValidationError as exc: |
| 148 | errors.update({e['loc'][0]: e['msg'] for e in exc.errors()}) | 156 | errors.update({(e['loc'][0] if e['loc'] else 'verify_password'): e['msg'] for e in exc.errors()}) |
| 149 | 157 | ||
| 150 | return await base_register(request, form, errors) | 158 | return await base_register(request, form, errors) |
| 151 | 159 | ||
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 @@ | |||
| 1 | {% extends 'global.html' %} | 1 | {% extends 'global.html' %} |
| 2 | {% import 'macros.html' as ui %} | ||
| 2 | {% block template %} | 3 | {% block template %} |
| 3 | {% block content %}{% endblock %} | 4 | <div class="auth-page"> |
| 5 | <div class="auth-shell"> | ||
| 6 | <div class="auth-hero"> | ||
| 7 | <div class="auth-hero__brand">{{ ui.connector_icon(20) }} ttun</div> | ||
| 8 | <div class="auth-hero__diagram"> | ||
| 9 | <span class="mono">local:3000</span> | ||
| 10 | <span class="connector">{{ ui.connector_icon(20) }}</span> | ||
| 11 | <span class="mono">your-app.ttun.dev</span> | ||
| 12 | </div> | ||
| 13 | <p class="auth-hero__tagline">Self-hosted tunnel proxy. Expose a local port through a public URL you control.</p> | ||
| 14 | </div> | ||
| 15 | <div class="auth-form"> | ||
| 16 | {% block content %}{% endblock %} | ||
| 17 | </div> | ||
| 18 | </div> | ||
| 19 | </div> | ||
| 4 | {% endblock %} | 20 | {% 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 @@ | |||
| 1 | {% extends "./base.html" %} | 1 | {% extends "./base.html" %} |
| 2 | {% import 'macros.html' as ui %} | ||
| 2 | 3 | ||
| 3 | {% block content %} | 4 | {% block content %} |
| 5 | <h1>Sign in</h1> | ||
| 6 | <p class="auth-form__intro">Enter your credentials to manage this server.</p> | ||
| 4 | <form method="post"> | 7 | <form method="post"> |
| 5 | 8 | {{ ui.field('username', label='Username', value=form.username if form else '', error=errors.username if errors else '') }} | |
| 6 | <input type="text" name="username" {% if form %}value="{{ form.username }}"{% endif %} placeholder="username" /> | 9 | {{ ui.field('password', type='password', label='Password', error=errors.password if errors else '') }} |
| 7 | {% if errors and errors.username %} | 10 | {{ ui.button('Sign in') }} |
| 8 | {{ errors.username }} | ||
| 9 | {% endif %} | ||
| 10 | |||
| 11 | |||
| 12 | <input type="password" name="password" /> | ||
| 13 | {% if errors and errors.password%} | ||
| 14 | {{ errors.password }} | ||
| 15 | {% endif %} | ||
| 16 | <button type="submit">Login</button> | ||
| 17 | </form> | 11 | </form> |
| 18 | {% endblock %} | 12 | <p class="auth-switch">New here? <a href="/auth/register/">Create an account</a></p> |
| 13 | {% 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 @@ | |||
| 1 | {% extends "./base.html" %} | 1 | {% extends "./base.html" %} |
| 2 | {% import 'macros.html' as ui %} | ||
| 2 | 3 | ||
| 3 | {% block content %} | 4 | {% block content %} |
| 5 | {% if is_first_run %} | ||
| 6 | <h1>Set up your ttun server</h1> | ||
| 7 | <p class="auth-form__intro">This will be the first account, with full access to manage this server.</p> | ||
| 8 | {% else %} | ||
| 9 | <h1>Create your account</h1> | ||
| 10 | <p class="auth-form__intro">This account will be able to sign in and manage this server.</p> | ||
| 11 | {% endif %} | ||
| 4 | <form method="post"> | 12 | <form method="post"> |
| 5 | 13 | {{ ui.field('email', type='email', label='Email', value=form.email if form else '', error=errors.email if errors else '') }} | |
| 6 | <input type="email" name="email" {% if form %}value="{{ form.email }}"{% endif %} placeholder="email" /> | 14 | {{ ui.field('password', type='password', label='Password', error=errors.password if errors else '') }} |
| 7 | {% if errors and errors.email%} | 15 | {{ ui.field('verify_password', type='password', label='Confirm password', error=errors.verify_password if errors else '') }} |
| 8 | {{ errors.email }} | 16 | {{ ui.button('Create account') }} |
| 9 | {% endif %} | ||
| 10 | |||
| 11 | |||
| 12 | <input type="password" name="password" /> | ||
| 13 | {% if errors and errors.password%} | ||
| 14 | {{ errors.password }} | ||
| 15 | {% endif %} | ||
| 16 | |||
| 17 | <input type="password" name="verify_password" /> | ||
| 18 | {% if errors and errors.password%} | ||
| 19 | {{ errors.verify_password }} | ||
| 20 | {% endif %} | ||
| 21 | |||
| 22 | <button type="submit">Register</button> | ||
| 23 | </form> | 17 | </form> |
| 24 | {% endblock %} | 18 | <p class="auth-switch">Already have an account? <a href="/auth/login/">Sign in</a></p> |
| 19 | {% 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 @@ | |||
| 1 | from functools import wraps | ||
| 2 | from urllib.parse import urlencode | ||
| 3 | |||
| 1 | from fastapi import FastAPI, Request | 4 | from fastapi import FastAPI, Request |
| 2 | from sqlalchemy import func | 5 | from sqlalchemy import func |
| 3 | from sqlmodel import select | 6 | from sqlmodel import select |
| 4 | from starlette.authentication import requires | ||
| 5 | from starlette.middleware.authentication import AuthenticationMiddleware | 7 | from starlette.middleware.authentication import AuthenticationMiddleware |
| 6 | from starlette.middleware.sessions import SessionMiddleware | 8 | from starlette.middleware.sessions import SessionMiddleware |
| 7 | from starlette.responses import RedirectResponse | 9 | from starlette.responses import RedirectResponse |
| @@ -21,6 +23,20 @@ templates = Jinja2Templates(directory=[ | |||
| 21 | 'management/templates' | 23 | 'management/templates' |
| 22 | ]) | 24 | ]) |
| 23 | 25 | ||
| 26 | |||
| 27 | def requires_session(func): | ||
| 28 | """Redirects to an absolute login URL instead of Starlette's `requires(redirect=...)`, | ||
| 29 | which resolves the redirect target via `request.url_for()` against whichever router is | ||
| 30 | bound into `request.scope` -- a lookup that only reaches `authentication`'s routes when | ||
| 31 | `management` happens to be mounted under a shared root app.""" | ||
| 32 | @wraps(func) | ||
| 33 | async def wrapper(request: Request, *args, **kwargs): | ||
| 34 | if not {'authenticated', 'session'}.issubset(request.auth.scopes): | ||
| 35 | next_param = urlencode({'next': str(request.url)}) | ||
| 36 | return RedirectResponse(f'/auth/login/?{next_param}', status_code=303) | ||
| 37 | return await func(request, *args, **kwargs) | ||
| 38 | return wrapper | ||
| 39 | |||
| 24 | @management.get('/') | 40 | @management.get('/') |
| 25 | async def index(request: Request): | 41 | async def index(request: Request): |
| 26 | with get_session_context() as session: | 42 | with get_session_context() as session: |
| @@ -34,12 +50,12 @@ async def index(request: Request): | |||
| 34 | 50 | ||
| 35 | 51 | ||
| 36 | @management.get('/dashboard/') | 52 | @management.get('/dashboard/') |
| 37 | @requires(['authenticated', 'session'], redirect='get_login') | 53 | @requires_session |
| 38 | async def dashboard(request: Request): | 54 | async def dashboard(request: Request): |
| 39 | return templates.TemplateResponse(request, 'dashboard.html') | 55 | return templates.TemplateResponse(request, 'dashboard.html') |
| 40 | 56 | ||
| 41 | @management.get('/users/') | 57 | @management.get('/users/') |
| 42 | @requires(['authenticated', 'session'], redirect='get_login') | 58 | @requires_session |
| 43 | async def users(request: Request): | 59 | async def users(request: Request): |
| 44 | with get_session_context() as session: | 60 | with get_session_context() as session: |
| 45 | query = select(User) | 61 | 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 @@ | |||
| 1 | {% extends 'global.html' %} | 1 | {% extends 'global.html' %} |
| 2 | {% import 'macros.html' as ui %} | ||
| 2 | {% block template %} | 3 | {% block template %} |
| 3 | <nav> | 4 | <nav class="nav"> |
| 4 | <a href="/auth/logout/">Logout</a> | 5 | <div class="nav__brand">{{ ui.connector_icon(18) }} ttun</div> |
| 6 | <div class="nav__links"> | ||
| 7 | <a class="nav__link{% if request.url.path == '/management/dashboard/' %} nav__link--active{% endif %}" href="/management/dashboard/">Dashboard</a> | ||
| 8 | <a class="nav__link{% if request.url.path == '/management/users/' %} nav__link--active{% endif %}" href="/management/users/">Users</a> | ||
| 9 | </div> | ||
| 10 | <div class="nav__actions"> | ||
| 11 | {{ ui.status_badge('Session active', 'good') }} | ||
| 12 | <a class="nav__link" href="/auth/logout/">Sign out</a> | ||
| 13 | </div> | ||
| 5 | </nav> | 14 | </nav> |
| 6 | {% block content %}{% endblock %} | ||
