Compare commits

...

21 Commits

Author SHA1 Message Date
giles
882bae87f0 Fix circular fragment fetching (shared submodule update)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 51s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 18:21:11 +00:00
giles
523b49794c Sync shared: fragment failures now raise by default
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 40s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 18:04:39 +00:00
giles
f18693f308 trigger rebuild
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
2026-02-24 18:02:02 +00:00
giles
0c5816adfa Remove cross-domain cart mini copy (shared _oob.html now uses fragment)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m11s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 17:33:16 +00:00
giles
7353be5b9d Add cross-domain template copy: cart mini for OOB header swaps
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 58s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 17:17:38 +00:00
giles
2d5e262414 Sync shared submodule (bound DB connection pool)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 45s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 17:08:24 +00:00
giles
ee2952e885 Own account domain templates (Phase 6)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 41s
Auth templates moved from shared to account/templates/.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 16:55:50 +00:00
giles
daea61a481 Replace widget registry with fragment fetches (Phase 5)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 42s
Account nav and page panels now fetched from events/cart fragments
instead of using shared widget registry.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 13:58:10 +00:00
giles
3016724133 Sync shared submodule: Phase 4 container widget → fragment changes
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 43s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 13:33:49 +00:00
giles
254c8a6424 Restore menu_items fallback for nav, update shared submodule
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 42s
Keep get_navigation_tree() as fallback when nav-tree fragment fetch
fails. Update shared submodule with fixed app slug URLs in nav.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 11:57:54 +00:00
giles
2145332cb6 Fetch nav-tree fragment from blog, drop local menu_items query
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 46s
Navigation is now rendered by blog as an HTML fragment. This app
fetches it with its own app_name and path for correct highlighting.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 11:39:39 +00:00
giles
c2f2443d99 Update shared submodule (product_slug rename in templates)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 43s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 10:30:19 +00:00
giles
1ba8788189 Add responsive visibility classes to auth-menu fragment
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 40s
Desktop span: hidden md:inline-flex, mobile span: block md:hidden.
Prevents both showing when fragment is injected in a single spot.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 09:51:52 +00:00
giles
072de719df Add auth-menu fragment handler to account app
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 42s
Phase 2 of fragment composition: account exposes auth-menu fragment
(sign-in button or user email for desktop + mobile) via
/internal/fragments/ endpoint. Updates shared submodule.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 09:11:52 +00:00
giles
77576d2ad4 Add fragment blueprint + sync shared: micro-frontend infrastructure
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 45s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 08:27:54 +00:00
giles
b7f759ff91 Sync shared: instant logout detection
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 43s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 01:30:44 +00:00
giles
3e0669a335 Exempt oauth/token from CSRF — server-to-server API endpoint
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 48s
External clients like artdag POST to this endpoint from their
backend, so there's no browser session with a CSRF token.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 01:06:19 +00:00
giles
12822fbd52 Sync shared submodule: external delivery handler
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 44s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 00:41:23 +00:00
giles
38072c2133 Emit rose:DeviceAuth activities on login and logout
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 54s
Signals external services (artdag) about device auth state changes
via the AP internal bus, replacing shared-Redis coupling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 00:22:02 +00:00
giles
64cc64193b Sync shared: add artdag_url() helper
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 56s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 23:26:37 +00:00
giles
f5153b711c Add artdag to OAuth clients + POST /auth/oauth/token endpoint
Standard HTTP token exchange for clients that don't share the coop DB.
Returns user_id, username, display_name, grant_token in exchange for
a valid authorization code.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 23:26:10 +00:00
22 changed files with 596 additions and 18 deletions

11
app.py
View File

@@ -2,13 +2,13 @@ from __future__ import annotations
import path_setup # noqa: F401 # adds shared/ to sys.path
from pathlib import Path
from quart import g
from quart import g, request
from jinja2 import FileSystemLoader, ChoiceLoader
from shared.infrastructure.factory import create_base_app
from shared.services.registry import services
from bp import register_account_bp, register_auth_bp
from bp import register_account_bp, register_auth_bp, register_fragments
async def account_context() -> dict:
@@ -16,9 +16,15 @@ async def account_context() -> dict:
from shared.infrastructure.context import base_context
from shared.services.navigation import get_navigation_tree
from shared.infrastructure.cart_identity import current_cart_identity
from shared.infrastructure.fragments import fetch_fragment
ctx = await base_context()
ctx["nav_tree_html"] = await fetch_fragment(
"blog", "nav-tree",
params={"app_name": "account", "path": request.path},
)
# Fallback for _nav.html when nav-tree fragment fetch fails
ctx["menu_items"] = await get_navigation_tree(g.s)
# Cart data (consistent with all other apps)
@@ -51,6 +57,7 @@ def create_app() -> "Quart":
# --- blueprints ---
app.register_blueprint(register_auth_bp())
app.register_blueprint(register_account_bp())
app.register_blueprint(register_fragments())
return app

View File

@@ -1,2 +1,3 @@
from .account.routes import register as register_account_bp
from .auth.routes import register as register_auth_bp
from .fragments import register_fragments

View File

@@ -1,6 +1,6 @@
"""Account pages blueprint.
Moved from federation/bp/auth — newsletters, widget pages (tickets, bookings).
Moved from federation/bp/auth — newsletters, fragment pages (tickets, bookings).
Mounted at root /.
"""
from __future__ import annotations
@@ -17,8 +17,8 @@ from sqlalchemy import select
from shared.models import UserNewsletter
from shared.models.ghost_membership_entities import GhostNewsletter
from shared.services.widget_registry import widgets
from shared.infrastructure.urls import login_url
from shared.infrastructure.fragments import fetch_fragment, fetch_fragments
oob = {
"oob_extends": "oob_elements.html",
@@ -36,8 +36,12 @@ def register(url_prefix="/"):
account_bp = Blueprint("account", __name__, url_prefix=url_prefix)
@account_bp.context_processor
def context():
return {"oob": oob, "account_nav_links": widgets.account_nav}
async def context():
events_nav, cart_nav = await fetch_fragments([
("events", "account-nav-item", {}),
("cart", "account-nav-item", {}),
])
return {"oob": oob, "account_nav_html": events_nav + cart_nav}
@account_bp.get("/")
async def account():
@@ -128,33 +132,35 @@ def register(url_prefix="/"):
un=un,
)
# Catch-all for widget pages — must be last
# Catch-all for fragment-provided pages — must be last
@account_bp.get("/<slug>/")
async def widget_page(slug):
async def fragment_page(slug):
from shared.browser.app.utils.htmx import is_htmx_request
from quart import abort
widget = widgets.account_page_by_slug(slug)
if not widget:
abort(404)
if not g.get("user"):
return redirect(login_url(f"/{slug}/"))
ctx = await widget.context_fn(g.s, user_id=g.user.id)
w_oob = {**oob, "main": widget.template}
fragment_html = await fetch_fragment(
"events", "account-page",
params={"slug": slug, "user_id": str(g.user.id)},
)
if not fragment_html:
abort(404)
w_oob = {**oob, "main": "_types/auth/_fragment_panel.html"}
if not is_htmx_request():
html = await render_template(
"_types/auth/index.html",
oob=w_oob,
**ctx,
page_fragment_html=fragment_html,
)
else:
html = await render_template(
"_types/auth/_oob_elements.html",
oob=w_oob,
**ctx,
page_fragment_html=fragment_html,
)
return await make_response(html)

View File

@@ -43,7 +43,7 @@ from .services import (
SESSION_USER_KEY = "uid"
ACCOUNT_SESSION_KEY = "account_sid"
ALLOWED_CLIENTS = {"blog", "market", "cart", "events", "federation"}
ALLOWED_CLIENTS = {"blog", "market", "cart", "events", "federation", "artdag"}
def register(url_prefix="/auth"):
@@ -121,6 +121,72 @@ def register(url_prefix="/auth"):
f"&account_did={account_did}"
)
# --- OAuth2 token exchange (for external clients like artdag) -------------
from shared.browser.app.csrf import csrf_exempt
@csrf_exempt
@auth_bp.post("/oauth/token")
@auth_bp.post("/oauth/token/")
async def oauth_token():
"""Exchange an authorization code for user info + grant token.
Used by clients that don't share the coop database (e.g. artdag).
Accepts JSON: {code, client_id, redirect_uri}
Returns JSON: {user_id, username, display_name, grant_token}
"""
data = await request.get_json()
if not data:
return jsonify({"error": "invalid_request"}), 400
code = data.get("code", "")
client_id = data.get("client_id", "")
redirect_uri = data.get("redirect_uri", "")
if client_id not in ALLOWED_CLIENTS:
return jsonify({"error": "invalid_client"}), 400
now = datetime.now(timezone.utc)
async with get_session() as s:
async with s.begin():
result = await s.execute(
select(OAuthCode)
.where(OAuthCode.code == code)
.with_for_update()
)
oauth_code = result.scalar_one_or_none()
if not oauth_code:
return jsonify({"error": "invalid_grant"}), 400
if oauth_code.used_at is not None:
return jsonify({"error": "invalid_grant"}), 400
if oauth_code.expires_at < now:
return jsonify({"error": "invalid_grant"}), 400
if oauth_code.client_id != client_id:
return jsonify({"error": "invalid_grant"}), 400
if oauth_code.redirect_uri != redirect_uri:
return jsonify({"error": "invalid_grant"}), 400
oauth_code.used_at = now
user_id = oauth_code.user_id
grant_token = oauth_code.grant_token
user = await s.get(User, user_id)
if not user:
return jsonify({"error": "invalid_grant"}), 400
return jsonify({
"user_id": user_id,
"username": user.email or "",
"display_name": user.name or "",
"grant_token": grant_token,
})
# --- Grant verification (internal endpoint) ------------------------------
@auth_bp.get("/internal/verify-grant")
@@ -276,6 +342,17 @@ def register(url_prefix="/auth"):
"session_id": anon_session_id,
},
)
# Notify external services of device login
await emit_activity(
s,
activity_type="rose:DeviceAuth",
actor_uri="internal:system",
object_type="Device",
object_data={
"device_id": g.device_id,
"action": "login",
},
)
except SQLAlchemyError:
current_app.logger.exception(
"[auth] non-fatal DB update for user_id=%s", user_id
@@ -328,6 +405,23 @@ def register(url_prefix="/auth"):
except Exception:
pass
# Notify external services of device logout
try:
async with get_session() as s:
async with s.begin():
await emit_activity(
s,
activity_type="rose:DeviceAuth",
actor_uri="internal:system",
object_type="Device",
object_data={
"device_id": g.device_id,
"action": "logout",
},
)
except Exception:
current_app.logger.exception("[auth] failed to emit DeviceAuth logout")
qsession.pop(SESSION_USER_KEY, None)
qsession.pop(ACCOUNT_SESSION_KEY, None)
from shared.infrastructure.urls import blog_url
@@ -359,6 +453,23 @@ def register(url_prefix="/auth"):
except Exception:
pass
# Notify external services of device logout
try:
async with get_session() as s:
async with s.begin():
await emit_activity(
s,
activity_type="rose:DeviceAuth",
actor_uri="internal:system",
object_type="Device",
object_data={
"device_id": g.device_id,
"action": "logout",
},
)
except Exception:
current_app.logger.exception("[auth] failed to emit DeviceAuth logout")
qsession.pop(SESSION_USER_KEY, None)
qsession.pop(ACCOUNT_SESSION_KEY, None)
from shared.infrastructure.urls import blog_url

1
bp/fragments/__init__.py Normal file
View File

@@ -0,0 +1 @@
from .routes import register as register_fragments

Binary file not shown.

52
bp/fragments/routes.py Normal file
View File

@@ -0,0 +1,52 @@
"""Account app fragment endpoints.
Exposes HTML fragments at ``/internal/fragments/<type>`` for consumption
by other coop apps via the fragment client.
Fragments:
auth-menu Desktop + mobile auth menu (sign-in or user link)
"""
from __future__ import annotations
from quart import Blueprint, Response, request, render_template
from shared.infrastructure.fragments import FRAGMENT_HEADER
def register():
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
# ---------------------------------------------------------------
# Fragment handlers
# ---------------------------------------------------------------
async def _auth_menu():
user_email = request.args.get("email", "")
return await render_template(
"fragments/auth_menu.html",
user_email=user_email,
)
_handlers = {
"auth-menu": _auth_menu,
}
# ---------------------------------------------------------------
# Routing
# ---------------------------------------------------------------
@bp.before_request
async def _require_fragment_header():
if not request.headers.get(FRAGMENT_HEADER):
return Response("", status=403)
@bp.get("/<fragment_type>")
async def get_fragment(fragment_type: str):
handler = _handlers.get(fragment_type)
if handler is None:
return Response("", status=200, content_type="text/html")
html = await handler()
return Response(html, status=200, content_type="text/html")
return bp

2
shared

Submodule shared updated: d9b51b1f84...9ab4b7b3fe

View File

@@ -0,0 +1,44 @@
<div class="w-full max-w-3xl mx-auto px-4 py-6">
<div class="bg-white/70 backdrop-blur rounded-2xl shadow border border-stone-200 p-6 sm:p-8 space-y-6">
<h1 class="text-xl font-semibold tracking-tight">Bookings</h1>
{% if bookings %}
<div class="divide-y divide-stone-100">
{% for booking in bookings %}
<div class="py-4 first:pt-0 last:pb-0">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-stone-800">{{ booking.name }}</p>
<div class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-stone-500">
<span>{{ booking.start_at.strftime('%d %b %Y, %H:%M') }}</span>
{% if booking.end_at %}
<span>&ndash; {{ booking.end_at.strftime('%H:%M') }}</span>
{% endif %}
{% if booking.calendar_name %}
<span>&middot; {{ booking.calendar_name }}</span>
{% endif %}
{% if booking.cost %}
<span>&middot; &pound;{{ booking.cost }}</span>
{% endif %}
</div>
</div>
<div class="flex-shrink-0">
{% if booking.state == 'confirmed' %}
<span class="inline-flex items-center rounded-full bg-emerald-50 border border-emerald-200 px-2.5 py-0.5 text-xs font-medium text-emerald-700">confirmed</span>
{% elif booking.state == 'provisional' %}
<span class="inline-flex items-center rounded-full bg-amber-50 border border-amber-200 px-2.5 py-0.5 text-xs font-medium text-amber-700">provisional</span>
{% else %}
<span class="inline-flex items-center rounded-full bg-stone-50 border border-stone-200 px-2.5 py-0.5 text-xs font-medium text-stone-600">{{ booking.state }}</span>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-sm text-stone-500">No bookings yet.</p>
{% endif %}
</div>
</div>

View File

@@ -0,0 +1 @@
{{ page_fragment_html | safe }}

View File

@@ -0,0 +1,49 @@
<div class="w-full max-w-3xl mx-auto px-4 py-6">
<div class="bg-white/70 backdrop-blur rounded-2xl shadow border border-stone-200 p-6 sm:p-8 space-y-8">
{% if error %}
<div class="rounded-lg border border-red-200 bg-red-50 text-red-800 px-4 py-3 text-sm">
{{ error }}
</div>
{% endif %}
{# Account header #}
<div class="flex items-center justify-between">
<div>
<h1 class="text-xl font-semibold tracking-tight">Account</h1>
{% if g.user %}
<p class="text-sm text-stone-500 mt-1">{{ g.user.email }}</p>
{% if g.user.name %}
<p class="text-sm text-stone-600">{{ g.user.name }}</p>
{% endif %}
{% endif %}
</div>
<form action="/auth/logout/" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button
type="submit"
class="inline-flex items-center gap-2 rounded-full border border-stone-300 px-4 py-2 text-sm font-medium text-stone-700 hover:bg-stone-50 transition"
>
<i class="fa-solid fa-right-from-bracket text-xs"></i>
Sign out
</button>
</form>
</div>
{# Labels #}
{% set labels = g.user.labels if g.user is defined and g.user.labels is defined else [] %}
{% if labels %}
<div>
<h2 class="text-base font-semibold tracking-tight mb-3">Labels</h2>
<div class="flex flex-wrap gap-2">
{% for label in labels %}
<span class="inline-flex items-center rounded-full border border-stone-200 px-3 py-1 text-xs font-medium bg-white/60">
{{ label.name }}
</span>
{% endfor %}
</div>
</div>
{% endif %}
</div>
</div>

View File

@@ -0,0 +1,7 @@
{% import 'macros/links.html' as links %}
{% call links.link(account_url('/newsletters/'), hx_select_search, select_colours, True, aclass=styles.nav_button) %}
newsletters
{% endcall %}
{% if account_nav_html %}
{{ account_nav_html | safe }}
{% endif %}

View File

@@ -0,0 +1,17 @@
<div id="nl-{{ un.newsletter_id }}" class="flex items-center">
<button
hx-post="{{ account_url('/newsletter/' ~ un.newsletter_id ~ '/toggle/') }}"
hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
hx-target="#nl-{{ un.newsletter_id }}"
hx-swap="outerHTML"
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2
{% if un.subscribed %}bg-emerald-500{% else %}bg-stone-300{% endif %}"
role="switch"
aria-checked="{{ 'true' if un.subscribed else 'false' }}"
>
<span
class="inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform
{% if un.subscribed %}translate-x-6{% else %}translate-x-1{% endif %}"
></span>
</button>
</div>

View File

@@ -0,0 +1,46 @@
<div class="w-full max-w-3xl mx-auto px-4 py-6">
<div class="bg-white/70 backdrop-blur rounded-2xl shadow border border-stone-200 p-6 sm:p-8 space-y-6">
<h1 class="text-xl font-semibold tracking-tight">Newsletters</h1>
{% if newsletter_list %}
<div class="divide-y divide-stone-100">
{% for item in newsletter_list %}
<div class="flex items-center justify-between py-4 first:pt-0 last:pb-0">
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-stone-800">{{ item.newsletter.name }}</p>
{% if item.newsletter.description %}
<p class="text-xs text-stone-500 mt-0.5 truncate">{{ item.newsletter.description }}</p>
{% endif %}
</div>
<div class="ml-4 flex-shrink-0">
{% if item.un %}
{% with un=item.un %}
{% include "_types/auth/_newsletter_toggle.html" %}
{% endwith %}
{% else %}
{# No subscription row yet — show an off toggle that will create one #}
<div id="nl-{{ item.newsletter.id }}" class="flex items-center">
<button
hx-post="{{ account_url('/newsletter/' ~ item.newsletter.id ~ '/toggle/') }}"
hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
hx-target="#nl-{{ item.newsletter.id }}"
hx-swap="outerHTML"
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 bg-stone-300"
role="switch"
aria-checked="false"
>
<span class="inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform translate-x-1"></span>
</button>
</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-sm text-stone-500">No newsletters available.</p>
{% endif %}
</div>
</div>

View File

@@ -0,0 +1,29 @@
{% extends 'oob_elements.html' %}
{# OOB elements for HTMX navigation - all elements that need updating #}
{# Import shared OOB macros #}
{% from '_types/root/_oob_menu.html' import mobile_menu with context %}
{# Header with app title - includes cart-mini, navigation, and market-specific header #}
{% block oobs %}
{% from '_types/root/_n/macros.html' import oob_header with context %}
{{oob_header('root-header-child', 'auth-header-child', '_types/auth/header/_header.html')}}
{% from '_types/root/header/_header.html' import header_row with context %}
{{ header_row(oob=True) }}
{% endblock %}
{% block mobile_menu %}
{% include '_types/auth/_nav.html' %}
{% endblock %}
{% block content %}
{% include oob.main %}
{% endblock %}

View File

@@ -0,0 +1,44 @@
<div class="w-full max-w-3xl mx-auto px-4 py-6">
<div class="bg-white/70 backdrop-blur rounded-2xl shadow border border-stone-200 p-6 sm:p-8 space-y-6">
<h1 class="text-xl font-semibold tracking-tight">Tickets</h1>
{% if tickets %}
<div class="divide-y divide-stone-100">
{% for ticket in tickets %}
<div class="py-4 first:pt-0 last:pb-0">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 flex-1">
<a href="{{ events_url('/tickets/' ~ ticket.code ~ '/') }}"
class="text-sm font-medium text-stone-800 hover:text-emerald-700 transition">
{{ ticket.entry_name }}
</a>
<div class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-stone-500">
<span>{{ ticket.entry_start_at.strftime('%d %b %Y, %H:%M') }}</span>
{% if ticket.calendar_name %}
<span>&middot; {{ ticket.calendar_name }}</span>
{% endif %}
{% if ticket.ticket_type_name %}
<span>&middot; {{ ticket.ticket_type_name }}</span>
{% endif %}
</div>
</div>
<div class="flex-shrink-0">
{% if ticket.state == 'checked_in' %}
<span class="inline-flex items-center rounded-full bg-blue-50 border border-blue-200 px-2.5 py-0.5 text-xs font-medium text-blue-700">checked in</span>
{% elif ticket.state == 'confirmed' %}
<span class="inline-flex items-center rounded-full bg-emerald-50 border border-emerald-200 px-2.5 py-0.5 text-xs font-medium text-emerald-700">confirmed</span>
{% else %}
<span class="inline-flex items-center rounded-full bg-amber-50 border border-amber-200 px-2.5 py-0.5 text-xs font-medium text-amber-700">{{ ticket.state }}</span>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-sm text-stone-500">No tickets yet.</p>
{% endif %}
</div>
</div>

View File

@@ -0,0 +1,33 @@
{% extends "_types/root/index.html" %}
{% block content %}
<div class="w-full max-w-md">
<div class="bg-white/70 dark:bg-neutral-900/70 backdrop-blur rounded-2xl shadow p-6 sm:p-8 border border-neutral-200 dark:border-neutral-800">
<h1 class="text-2xl font-semibold tracking-tight">Check your email</h1>
<p class="text-base text-stone-700 dark:text-stone-300 mt-3">
If an account exists for
<strong class="text-stone-900 dark:text-white">{{ email }}</strong>,
youll receive a link to sign in. It expires in 15 minutes.
</p>
{% if email_error %}
<div
class="mt-4 rounded-lg border border-red-300 bg-red-50 text-red-700 text-sm px-3 py-2 flex items-start gap-2"
role="alert"
>
<span class="font-medium">Heads up:</span>
<span>{{ email_error }}</span>
</div>
{% endif %}
<p class="mt-6 text-sm">
<a
href="{{ blog_url('/auth/login/') }}"
class="text-stone-600 dark:text-stone-300 hover:underline"
>
← Back
</a>
</p>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,12 @@
{% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %}
{% call links.menu_row(id='auth-row', oob=oob) %}
{% call links.link(account_url('/'), hx_select_search ) %}
<i class="fa-solid fa-user"></i>
<div>account</div>
{% endcall %}
{% call links.desktop_nav() %}
{% include "_types/auth/_nav.html" %}
{% endcall %}
{% endcall %}
{% endmacro %}

View File

@@ -0,0 +1,18 @@
{% extends "_types/root/_index.html" %}
{% block root_header_child %}
{% from '_types/root/_n/macros.html' import index_row with context %}
{% call index_row('auth-header-child', '_types/auth/header/_header.html') %}
{% block auth_header_child %}
{% endblock %}
{% endcall %}
{% endblock %}
{% block _main_mobile_menu %}
{% include "_types/auth/_nav.html" %}
{% endblock %}
{% block content %}
{% include '_types/auth/_main_panel.html' %}
{% endblock %}

View File

@@ -0,0 +1,18 @@
{% extends oob.extends %}
{% block root_header_child %}
{% from '_types/root/_n/macros.html' import index_row with context %}
{% call index_row(oob.child_id, oob.header) %}
{% block auth_header_child %}
{% endblock %}
{% endcall %}
{% endblock %}
{% block _main_mobile_menu %}
{% include oob.nav %}
{% endblock %}
{% block content %}
{% include oob.main %}
{% endblock %}

View File

@@ -0,0 +1,46 @@
{% extends "_types/root/index.html" %}
{% block content %}
<div class="w-full max-w-md">
<div class="bg-white/70 dark:bg-neutral-900/70 backdrop-blur rounded-2xl shadow p-6 sm:p-8 border border-neutral-200 dark:border-neutral-800">
<h1 class="text-2xl font-semibold tracking-tight">Sign in</h1>
<p class="mt-2 text-sm text-neutral-600 dark:text-neutral-400">
Enter your email and well email you a one-time sign-in link.
</p>
{% if error %}
<div class="mt-4 rounded-lg border border-red-200 bg-red-50 text-red-800 dark:border-red-900/40 dark:bg-red-950/40 dark:text-red-200 px-4 py-3 text-sm">
{{ error }}
</div>
{% endif %}
<form
method="post" action="{{ blog_url('/auth/start/') }}"
class="mt-6 space-y-5"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div>
<label for="email" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300">
Email
</label>
<input
type="email"
id="email"
name="email"
value="{{ email or '' }}"
required
class="mt-2 block w-full rounded-lg border border-neutral-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 text-neutral-900 dark:text-neutral-100 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-0 focus:ring-neutral-900 dark:focus:ring-neutral-200"
autocomplete="email"
inputmode="email"
>
</div>
<button
type="submit"
class="inline-flex w-full items-center justify-center rounded-lg bg-neutral-900 px-4 py-2.5 text-sm font-medium text-white hover:bg-neutral-800 focus:outline-none focus:ring-2 focus:ring-neutral-900 disabled:opacity-50 dark:bg-neutral-50 dark:text-neutral-900 dark:hover:bg-white"
>
Send link
</button>
</form>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,36 @@
{# Desktop auth menu #}
<span id="auth-menu-desktop" class="hidden md:inline-flex">
{% if user_email %}
<a
href="{{ account_url('/') }}"
class="justify-center cursor-pointer flex flex-row items-center p-3 gap-2 rounded bg-stone-200 text-black"
data-close-details
>
<i class="fa-solid fa-user"></i>
<span>{{ user_email }}</span>
</a>
{% else %}
<a
href="{{ account_url('/') }}"
class="justify-center cursor-pointer flex flex-row items-center p-3 gap-2 rounded bg-stone-200 text-black"
data-close-details
>
<i class="fa-solid fa-key"></i>
<span>sign in or register</span>
</a>
{% endif %}
</span>
{# Mobile auth menu #}
<span id="auth-menu-mobile" class="block md:hidden text-md font-bold">
{% if user_email %}
<a href="{{ account_url('/') }}" data-close-details>
<i class="fa-solid fa-user"></i>
<span>{{ user_email }}</span>
</a>
{% else %}
<a href="{{ account_url('/') }}">
<i class="fa-solid fa-key"></i>
<span>sign in or register</span>
</a>
{% endif %}
</span>