summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--authentication/endpoints.py10
-rw-r--r--authentication/templates/base.html18
-rw-r--r--authentication/templates/login.html21
-rw-r--r--authentication/templates/register.html33
-rw-r--r--management/endpoints.py22
-rw-r--r--management/templates/base.html17
-rw-r--r--management/templates/dashboard.html16
-rw-r--r--management/templates/users.html32
-rw-r--r--templates/_styles.html539
-rw-r--r--templates/global.html19
-rw-r--r--templates/macros.html49
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
7from fastapi import FastAPI, Request, Form, Query 7from fastapi import FastAPI, Request, Form, Query
8from pydantic import BaseModel, ValidationError, model_validator, EmailStr 8from pydantic import BaseModel, ValidationError, model_validator, EmailStr
9from sqlalchemy import func
9from sqlmodel import select 10from sqlmodel import select
10from starlette.authentication import requires 11from starlette.authentication import requires
11from starlette.middleware.authentication import AuthenticationMiddleware 12from starlette.middleware.authentication import AuthenticationMiddleware
@@ -106,10 +107,17 @@ class RegisterForm(BaseModel):
106 return self 107 return self
107 108
108 109
110def 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
109async def base_register(request: Request, form: RegisterForm | None = None, errors: dict[str, str] | None = None): 116async 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 @@
1from functools import wraps
2from urllib.parse import urlencode
3
1from fastapi import FastAPI, Request 4from fastapi import FastAPI, Request
2from sqlalchemy import func 5from sqlalchemy import func
3from sqlmodel import select 6from sqlmodel import select
4from starlette.authentication import requires
5from starlette.middleware.authentication import AuthenticationMiddleware 7from starlette.middleware.authentication import AuthenticationMiddleware
6from starlette.middleware.sessions import SessionMiddleware 8from starlette.middleware.sessions import SessionMiddleware
7from starlette.responses import RedirectResponse 9from starlette.responses import RedirectResponse
@@ -21,6 +23,20 @@ templates = Jinja2Templates(directory=[
21 'management/templates' 23 'management/templates'
22]) 24])
23 25
26
27def 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('/')
25async def index(request: Request): 41async 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
38async def dashboard(request: Request): 54async 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
43async def users(request: Request): 59async 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') }}