feat(portal): M1 auth gate — signed magic-URL session + get_current_client

backend/portal_auth.py: stdlib HMAC-signed session cookie carrying the access-
token id (re-validated against the DB each request, so revoke kills live
sessions), hash_token, resolve_token, and the get_current_client dependency
(raises PortalAuthError). SECRET_KEY env (insecure dev default + warning).

routers/portal.py: /portal/enter/{token} mints the cookie -> /portal; /logout;
/access; /portal home stub. main.py registers the router + a PortalAuthError
handler (HTML access page for pages, 401 JSON for /portal/api/*).

Portal shell templates (base, access_required, overview stub), branded dark.

Verified: cookie round-trip + tamper/garbage rejection, token resolution
(valid/bad), get_current_client (valid/no-cookie/revoked) — 8/8 against a temp DB.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 21:36:09 +00:00
parent 80a8470b55
commit 6c048a9c30
6 changed files with 286 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
{% extends "portal/base.html" %}
{% block title %}Access{% endblock %}
{% block content %}
<div class="max-w-md mx-auto mt-16 text-center">
<div class="inline-flex items-center justify-center w-14 h-14 rounded-full bg-slate-800 border border-slate-700 mb-5">
<svg class="w-7 h-7 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
</svg>
</div>
{% if reason == "invalid" %}
<h1 class="text-xl font-semibold mb-2">This link isn't valid</h1>
<p class="text-gray-400 text-sm">The access link is expired or has been revoked.
Please contact TMI for a new link.</p>
{% else %}
<h1 class="text-xl font-semibold mb-2">Access link required</h1>
<p class="text-gray-400 text-sm">Open the monitoring link TMI sent you to view your locations.</p>
{% endif %}
</div>
{% endblock %}
+44
View File
@@ -0,0 +1,44 @@
<!DOCTYPE html>
<html lang="en" class="h-full dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Monitoring{% endblock %} · TMI</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: { extend: { colors: { seismo: {
orange: '#f48b1c', navy: '#142a66', burgundy: '#7d234d'
} } } }
}
</script>
<link rel="icon" type="image/png" sizes="32x32" href="/static/icons/favicon-32.png">
<meta name="theme-color" content="#142a66">
{% block head %}{% endblock %}
</head>
<body class="h-full bg-slate-900 text-gray-100 antialiased">
<header class="border-b border-slate-700/70 bg-slate-800/60 backdrop-blur">
<div class="max-w-5xl mx-auto px-4 py-3 flex items-center justify-between">
<a href="/portal" class="flex items-center gap-2 font-semibold">
<span class="inline-block w-2.5 h-2.5 rounded-full bg-seismo-orange"></span>
TMI Monitoring{% if client %} <span class="text-gray-500 font-normal">·</span>
<span class="text-seismo-orange">{{ client.name }}</span>{% endif %}
</a>
{% if client %}
<a href="/portal/logout" class="text-sm text-gray-400 hover:text-gray-200">Sign out</a>
{% endif %}
</div>
</header>
<main class="max-w-5xl mx-auto px-4 py-6">
{% block content %}{% endblock %}
</main>
<footer class="max-w-5xl mx-auto px-4 py-8 text-xs text-gray-600">
Read-only monitoring view. Data is provided as-is for informational purposes.
</footer>
{% block scripts %}{% endblock %}
</body>
</html>
+22
View File
@@ -0,0 +1,22 @@
{% extends "portal/base.html" %}
{% block title %}Your locations{% endblock %}
{% block content %}
<h1 class="text-2xl font-semibold mb-1">Your monitoring locations</h1>
<p class="text-gray-400 text-sm mb-6">Live sound levels for your active locations.</p>
{# M1 task 4 fleshes this out into location tiles + a map. #}
{% if locations %}
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
{% for loc in locations %}
<a href="/portal/location/{{ loc.id }}" class="block rounded-xl border border-slate-700 bg-slate-800/50 p-4 hover:border-seismo-orange transition-colors">
<div class="font-semibold">{{ loc.name }}</div>
<div class="text-xs text-gray-400 mt-1">{{ loc.address or loc.project_name }}</div>
</a>
{% endfor %}
</div>
{% else %}
<div class="rounded-xl border border-slate-700 bg-slate-800/50 p-8 text-center text-gray-400">
No active monitoring locations yet.
</div>
{% endif %}
{% endblock %}