Initial commit: LotusGuild Terminal Design System v1.0
Unified CSS, JavaScript utilities, HTML template, and framework skeleton files for Tinker Tickets (PHP), PULSE (Node.js), and GANDALF (Flask). Includes aesthetic_diff.md documenting every divergence between the three apps with prioritised recommendations for convergence. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
92
python/auth.py
Normal file
92
python/auth.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""
|
||||
LOTUSGUILD TERMINAL DESIGN SYSTEM — Flask Auth Helpers
|
||||
Provides Authelia SSO integration via remote headers.
|
||||
|
||||
Usage:
|
||||
from auth import require_auth, get_user
|
||||
|
||||
@app.route('/my-page')
|
||||
@require_auth
|
||||
def my_page():
|
||||
user = get_user()
|
||||
return render_template('page.html', user=user)
|
||||
"""
|
||||
from functools import wraps
|
||||
from flask import request, g
|
||||
|
||||
|
||||
def get_user() -> dict:
|
||||
"""
|
||||
Parse Authelia SSO headers into a normalised user dict.
|
||||
Authelia injects these headers after validating the session:
|
||||
Remote-User → username / login name
|
||||
Remote-Name → display name
|
||||
Remote-Email → email address
|
||||
Remote-Groups → comma-separated group list
|
||||
"""
|
||||
groups_raw = request.headers.get('Remote-Groups', '')
|
||||
groups = [g.strip() for g in groups_raw.split(',') if g.strip()]
|
||||
return {
|
||||
'username': request.headers.get('Remote-User', ''),
|
||||
'name': request.headers.get('Remote-Name', ''),
|
||||
'email': request.headers.get('Remote-Email', ''),
|
||||
'groups': groups,
|
||||
'is_admin': 'admin' in groups,
|
||||
}
|
||||
|
||||
|
||||
def require_auth(f):
|
||||
"""
|
||||
Decorator that enforces authentication + group-based authorisation.
|
||||
Reads allowed_groups from app config (key: 'auth.allowed_groups').
|
||||
Falls back to ['admin'] if not configured.
|
||||
|
||||
Returns 401 if no user header present (request bypassed Authelia).
|
||||
Returns 403 if user is not in an allowed group.
|
||||
"""
|
||||
@wraps(f)
|
||||
def wrapper(*args, **kwargs):
|
||||
from flask import current_app
|
||||
user = get_user()
|
||||
|
||||
if not user['username']:
|
||||
return (
|
||||
'<h1 style="font-family:monospace;color:#ff4444">401 — Not Authenticated</h1>'
|
||||
'<p style="font-family:monospace">Access this service through Authelia SSO.</p>',
|
||||
401,
|
||||
)
|
||||
|
||||
cfg = current_app.config.get('APP_CONFIG', {})
|
||||
allowed = cfg.get('auth', {}).get('allowed_groups', ['admin'])
|
||||
|
||||
if not any(grp in allowed for grp in user['groups']):
|
||||
return (
|
||||
f'<h1 style="font-family:monospace;color:#ff4444">403 — Access Denied</h1>'
|
||||
f'<p style="font-family:monospace">'
|
||||
f'Account <strong>{user["username"]}</strong> is not in an allowed group '
|
||||
f'({", ".join(allowed)}).</p>',
|
||||
403,
|
||||
)
|
||||
|
||||
# Store user on Flask's request context for convenience
|
||||
g.user = user
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def require_admin(f):
|
||||
"""
|
||||
Stricter decorator — requires the 'admin' group regardless of config.
|
||||
"""
|
||||
@wraps(f)
|
||||
@require_auth
|
||||
def wrapper(*args, **kwargs):
|
||||
user = get_user()
|
||||
if not user['is_admin']:
|
||||
return (
|
||||
'<h1 style="font-family:monospace;color:#ff4444">403 — Admin Required</h1>',
|
||||
403,
|
||||
)
|
||||
return f(*args, **kwargs)
|
||||
return wrapper
|
||||
141
python/base.html
Normal file
141
python/base.html
Normal file
@@ -0,0 +1,141 @@
|
||||
{#
|
||||
LOTUSGUILD TERMINAL DESIGN SYSTEM — Flask/Jinja2 Base Template
|
||||
Extend this in every page template:
|
||||
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Dashboard{% endblock %}
|
||||
{% block active_nav %}dashboard{% endblock %}
|
||||
{% block content %}
|
||||
… your page HTML …
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script nonce="{{ nonce }}">
|
||||
lt.sortTable.init('my-table');
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
Required Flask setup (app.py):
|
||||
- Pass `nonce` into every render_template() call via a context processor
|
||||
- Pass `user` dict from _get_user() helper
|
||||
- Pass `config` dict with APP_NAME, etc.
|
||||
|
||||
Context processor example:
|
||||
@app.context_processor
|
||||
def inject_globals():
|
||||
import base64, os
|
||||
nonce = base64.b64encode(os.urandom(16)).decode()
|
||||
return dict(nonce=nonce, user=_get_user(), config=_config())
|
||||
#}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Dashboard{% endblock %} — {{ config.get('app_name', 'LotusGuild') }}</title>
|
||||
<meta name="description" content="LotusGuild infrastructure management">
|
||||
|
||||
<!-- Unified design system CSS -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='../web_template/base.css') }}">
|
||||
<!-- App-specific CSS -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||
|
||||
<link rel="icon" href="{{ url_for('static', filename='favicon.png') }}" type="image/png">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Boot overlay -->
|
||||
<div id="lt-boot" class="lt-boot-overlay"
|
||||
data-app-name="{{ config.get('app_name', 'APP') | upper }}"
|
||||
style="display:none">
|
||||
<pre id="lt-boot-text" class="lt-boot-text"></pre>
|
||||
</div>
|
||||
|
||||
<!-- =========================================================
|
||||
HEADER
|
||||
========================================================= -->
|
||||
<header class="lt-header">
|
||||
<div class="lt-header-left">
|
||||
|
||||
<div class="lt-brand">
|
||||
<a href="{{ url_for('index') }}" class="lt-brand-title" style="text-decoration:none">
|
||||
{{ config.get('app_name', 'APP') | upper }}
|
||||
</a>
|
||||
<span class="lt-brand-subtitle">{{ config.get('app_subtitle', 'LotusGuild Infrastructure') }}</span>
|
||||
</div>
|
||||
|
||||
<nav class="lt-nav" aria-label="Main navigation">
|
||||
{# Each page sets {% block active_nav %}pagename{% endblock %} #}
|
||||
{% set active = self.active_nav() | default('') %}
|
||||
<a href="{{ url_for('index') }}"
|
||||
class="lt-nav-link {% if active == 'dashboard' %}active{% endif %}">
|
||||
Dashboard
|
||||
</a>
|
||||
<a href="{{ url_for('links_page') }}"
|
||||
class="lt-nav-link {% if active == 'links' %}active{% endif %}">
|
||||
Link Debug
|
||||
</a>
|
||||
<a href="{{ url_for('inspector') }}"
|
||||
class="lt-nav-link {% if active == 'inspector' %}active{% endif %}">
|
||||
Inspector
|
||||
</a>
|
||||
<a href="{{ url_for('suppressions_page') }}"
|
||||
class="lt-nav-link {% if active == 'suppressions' %}active{% endif %}">
|
||||
Suppressions
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="lt-header-right">
|
||||
{% if user.name or user.username %}
|
||||
<span class="lt-header-user">{{ user.name or user.username }}</span>
|
||||
{% endif %}
|
||||
{% if 'admin' in user.groups %}
|
||||
<span class="lt-badge-admin">admin</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- =========================================================
|
||||
MAIN CONTENT
|
||||
========================================================= -->
|
||||
<main class="lt-main lt-container">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<!-- =========================================================
|
||||
SCRIPTS
|
||||
All <script> tags MUST carry the nonce attribute for CSP.
|
||||
========================================================= -->
|
||||
|
||||
<!-- Runtime config (no CSRF needed for Gandalf — SameSite=Strict) -->
|
||||
<script nonce="{{ nonce }}">
|
||||
window.APP_CONFIG = {
|
||||
ticketWebUrl: {{ config.get('ticket_api', {}).get('web_url', 'https://t.lotusguild.org/ticket/') | tojson }},
|
||||
};
|
||||
window.CURRENT_USER = {
|
||||
username: {{ user.username | tojson }},
|
||||
name: {{ (user.name or user.username) | tojson }},
|
||||
groups: {{ user.groups | tojson }},
|
||||
isAdmin: {{ ('admin' in user.groups) | lower }},
|
||||
};
|
||||
</script>
|
||||
|
||||
<!-- Unified design system JS -->
|
||||
<script nonce="{{ nonce }}" src="{{ url_for('static', filename='../web_template/base.js') }}"></script>
|
||||
|
||||
<!-- App JS -->
|
||||
<script nonce="{{ nonce }}" src="{{ url_for('static', filename='app.js') }}"></script>
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
{# ---------------------------------------------------------------
|
||||
active_nav block — override in each page template:
|
||||
|
||||
{% block active_nav %}dashboard{% endblock %}
|
||||
|
||||
Values: dashboard | links | inspector | suppressions
|
||||
--------------------------------------------------------------- #}
|
||||
{% block active_nav %}{% endblock %}
|
||||
Reference in New Issue
Block a user