Initial commit: web-app boilerplate with auth, OIDC/SSO, admin panel, i18n
Rust (cot framework) + PostgreSQL boilerplate providing: - Session-based auth with Admin/User roles - OIDC/SSO login with PKCE, group-to-role mapping, auto-provisioning - Admin panel: user management, settings, debug/config inspector - 3-tier config system (compiled default → DB → FURU_* env vars) - i18n (English + Russian) with compile-time translations macro - JSON API skeleton (GET /api/me) - DB-backed sessions (survive server restarts) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
{% extends "admin/layout.html" %}
|
||||
|
||||
{% block admin_title %}{{ t.nav_debug }}{% endblock admin_title %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
.cfg-key { position: relative; display: inline-flex; align-items: center; gap: .35rem; }
|
||||
.cfg-info-btn {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 16px; height: 16px; border-radius: 50%;
|
||||
background: #ccc; color: #fff; font-size: 11px; font-weight: 700;
|
||||
cursor: help; flex-shrink: 0; line-height: 1;
|
||||
}
|
||||
.cfg-info-btn:hover { background: #888; }
|
||||
.cfg-popup {
|
||||
position: absolute; left: 100%; top: 50%;
|
||||
transform: translateY(-50%); z-index: 10;
|
||||
background: #1a1a2e; color: #eee; padding: .55rem .75rem;
|
||||
border-radius: 6px; box-shadow: 0 4px 16px rgba(0,0,0,.25);
|
||||
font-size: .8rem; white-space: nowrap; min-width: 220px;
|
||||
margin-left: 8px;
|
||||
opacity: 0; visibility: hidden; pointer-events: none;
|
||||
transition: opacity .15s, visibility 0s .3s;
|
||||
user-select: text;
|
||||
}
|
||||
/* invisible bridge so cursor can travel from icon to popup */
|
||||
.cfg-popup::before {
|
||||
content: ''; position: absolute; right: 100%; top: 0; bottom: 0;
|
||||
width: 12px;
|
||||
}
|
||||
.cfg-key:hover .cfg-popup {
|
||||
opacity: 1; visibility: visible; pointer-events: auto;
|
||||
transition: opacity .15s, visibility 0s;
|
||||
}
|
||||
.cfg-popup dt { color: #999; font-size: .7rem; text-transform: uppercase; letter-spacing: .04em; margin-top: .35rem; }
|
||||
.cfg-popup dt:first-child { margin-top: 0; }
|
||||
.cfg-popup dd { margin: .1rem 0 0; }
|
||||
.cfg-popup code { background: rgba(255,255,255,.12); color: #fff; padding: .05rem .3rem; border-radius: 3px; font-size: .8rem; }
|
||||
</style>
|
||||
|
||||
<h1>{{ t.debug_heading }}</h1>
|
||||
|
||||
<h2>{{ t.debug_build_info }}</h2>
|
||||
<table>
|
||||
<tr><th>{{ t.debug_field }}</th><th>{{ t.debug_value }}</th></tr>
|
||||
<tr><td>Package</td><td><code>{{ build.pkg_name }}</code></td></tr>
|
||||
<tr><td>Version</td><td><code>{{ build.pkg_version }}</code></td></tr>
|
||||
<tr><td>Profile</td><td><code>{{ build.profile }}</code></td></tr>
|
||||
<tr><td>Target</td><td><code>{{ build.target }}</code></td></tr>
|
||||
<tr><td>Rustc</td><td><code>{{ build.rustc_version }}</code></td></tr>
|
||||
<tr><td>{{ t.debug_db_status }}</td><td><code>{{ db_status }}</code></td></tr>
|
||||
</table>
|
||||
|
||||
<h2>{{ t.debug_app_config }}</h2>
|
||||
<table>
|
||||
<tr>
|
||||
<th>{{ t.debug_field }}</th>
|
||||
<th>{{ t.debug_value }}</th>
|
||||
<th>{{ t.debug_source }}</th>
|
||||
</tr>
|
||||
{% for entry in config_entries %}
|
||||
<tr>
|
||||
<td>
|
||||
<span class="cfg-key">
|
||||
<code>{{ entry.key }}</code>
|
||||
<span class="cfg-info-btn">i</span>
|
||||
<dl class="cfg-popup">
|
||||
<dt>env var</dt>
|
||||
<dd><code>{{ entry.env_var }}</code></dd>
|
||||
<dt>default</dt>
|
||||
<dd><code>{% if entry.default_value == "" %}(empty){% else %}{{ entry.default_value }}{% endif %}</code></dd>
|
||||
<dt>source</dt>
|
||||
<dd><code>{{ entry.source }}</code></dd>
|
||||
</dl>
|
||||
</span>
|
||||
</td>
|
||||
<td><code>{{ entry.value }}</code></td>
|
||||
<td><span class="badge badge-{{ entry.source }}">{{ entry.source }}</span></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% endblock content %}
|
||||
@@ -0,0 +1,8 @@
|
||||
{% extends "admin/layout.html" %}
|
||||
|
||||
{% block admin_title %}{{ t.nav_dashboard }}{% endblock admin_title %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{{ t.admin_heading }}</h1>
|
||||
<p><a href="/admin/debug">{{ t.admin_debug_link }}</a></p>
|
||||
{% endblock content %}
|
||||
@@ -0,0 +1,57 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{% block admin_title %}{{ t.nav_admin }}{% endblock admin_title %} | {{ t.site_name }}{% endblock title %}
|
||||
|
||||
{% block head_extra %}
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: system-ui, -apple-system, sans-serif; display: flex; min-height: 100vh; background: #f5f5f5; color: #333; }
|
||||
nav.sidebar { width: 220px; background: #1a1a2e; color: #eee; padding: 1rem; }
|
||||
nav.sidebar h2 { font-size: 1.1rem; margin-bottom: 1.5rem; }
|
||||
nav.sidebar a { color: #ccc; text-decoration: none; display: block; padding: .4rem .6rem; border-radius: 4px; }
|
||||
nav.sidebar a:hover { background: #16213e; color: #fff; }
|
||||
.main-wrap { flex: 1; display: flex; flex-direction: column; }
|
||||
.topbar { display: flex; justify-content: flex-end; align-items: center; gap: 1rem; padding: .5rem 1.5rem; background: #fff; border-bottom: 1px solid #e0e0e0; }
|
||||
.user-info { font-size: .85rem; color: #555; }
|
||||
.lang-switch { display: flex; gap: .25rem; }
|
||||
.lang-switch a { text-decoration: none; padding: .25rem .5rem; border-radius: 4px; font-size: .9rem; color: #555; }
|
||||
.lang-switch a:hover { background: #eee; color: #111; }
|
||||
.lang-switch a.active { font-weight: 700; color: #111; }
|
||||
.logout-link { text-decoration: none; font-size: .85rem; color: #555; padding: .25rem .5rem; border-radius: 4px; }
|
||||
.logout-link:hover { background: #eee; color: #111; }
|
||||
.main { flex: 1; padding: 2rem; overflow-x: auto; }
|
||||
table { border-collapse: collapse; width: 100%; margin-bottom: 1.5rem; background: #fff; border-radius: 6px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,.1); }
|
||||
th, td { text-align: left; padding: .6rem .8rem; border-bottom: 1px solid #eee; }
|
||||
th { background: #f0f0f0; font-weight: 600; }
|
||||
h1 { margin-bottom: 1rem; }
|
||||
h2 { margin: 1.5rem 0 .5rem; }
|
||||
code { background: #f4f4f4; padding: .1rem .35rem; border-radius: 3px; font-size: .85em; }
|
||||
.badge { display: inline-block; padding: .15rem .55rem; border-radius: 4px; font-size: .8rem; font-weight: 600; letter-spacing: .02em; }
|
||||
.badge-default { background: #e9ecef; color: #555; }
|
||||
.badge-database { background: #d1ecf1; color: #0c5460; }
|
||||
.badge-env { background: #fff3cd; color: #856404; }
|
||||
</style>
|
||||
{% endblock head_extra %}
|
||||
|
||||
{% block body %}
|
||||
<nav class="sidebar">
|
||||
<h2>{{ t.site_name }} {{ t.nav_admin }}</h2>
|
||||
<a href="/admin/">{{ t.nav_dashboard }}</a>
|
||||
<a href="/admin/users">{{ t.nav_users }}</a>
|
||||
<a href="/admin/settings">{{ t.nav_settings }}</a>
|
||||
<a href="/admin/debug">{{ t.nav_debug }}</a>
|
||||
</nav>
|
||||
<div class="main-wrap">
|
||||
<div class="topbar">
|
||||
<span class="user-info">{{ user_name }} ({{ user_role }})</span>
|
||||
<div class="lang-switch">
|
||||
<a href="#"{% if t.lang.code() == "en" %} class="active"{% endif %} onclick="location.href='/set-lang?lang=en&next='+encodeURIComponent(location.pathname);return false">EN</a>
|
||||
<a href="#"{% if t.lang.code() == "ru" %} class="active"{% endif %} onclick="location.href='/set-lang?lang=ru&next='+encodeURIComponent(location.pathname);return false">RU</a>
|
||||
</div>
|
||||
<a class="logout-link" href="/logout">{{ t.nav_logout }}</a>
|
||||
</div>
|
||||
<div class="main">
|
||||
{% block content %}{% endblock content %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock body %}
|
||||
@@ -0,0 +1,73 @@
|
||||
{% extends "admin/layout.html" %}
|
||||
{% block admin_title %}{{ t.nav_settings }}{% endblock admin_title %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{{ t.settings_heading }}</h1>
|
||||
|
||||
{% if saved %}
|
||||
<p style="color: green; font-weight: bold; margin-bottom: 1rem;">{{ t.settings_saved }}</p>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/admin/settings">
|
||||
|
||||
<h2>{{ t.settings_auth }}</h2>
|
||||
<table>
|
||||
<tr>
|
||||
<th>{{ t.debug_field }}</th>
|
||||
<th>{{ t.debug_value }}</th>
|
||||
<th>{{ t.debug_source }}</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><label for="auth_password_enabled">{{ t.settings_password_login }}</label></td>
|
||||
<td><input type="checkbox" name="auth_password_enabled" id="auth_password_enabled" value="on"{% if auth_password_enabled %} checked{% endif %}></td>
|
||||
<td><span class="badge badge-{{ auth_password_enabled_source }}">{{ auth_password_enabled_source }}</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><label for="auth_sso_enabled">{{ t.settings_sso_login }}</label></td>
|
||||
<td><input type="checkbox" name="auth_sso_enabled" id="auth_sso_enabled" value="on"{% if auth_sso_enabled %} checked{% endif %}></td>
|
||||
<td><span class="badge badge-{{ auth_sso_enabled_source }}">{{ auth_sso_enabled_source }}</span></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h2>{{ t.settings_oidc }}</h2>
|
||||
<p style="font-size:.85rem;color:#666;margin-bottom:.5rem;">{{ t.settings_oidc_help }}</p>
|
||||
<p style="font-size:.85rem;color:#666;margin-bottom:1rem;">
|
||||
<strong>{{ t.settings_oidc_callback }}:</strong>
|
||||
<code id="oidc-callback-url"></code>
|
||||
</p>
|
||||
<script>document.getElementById('oidc-callback-url').textContent=location.origin+'/auth/oidc/callback';</script>
|
||||
<table>
|
||||
<tr>
|
||||
<th>{{ t.debug_field }}</th>
|
||||
<th>{{ t.debug_value }}</th>
|
||||
<th>{{ t.debug_source }}</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><label for="oidc_button_text">{{ t.settings_oidc_button }}</label></td>
|
||||
<td><input name="oidc_button_text" id="oidc_button_text" value="{{ oidc_button_text }}" style="width:100%"></td>
|
||||
<td><span class="badge badge-{{ oidc_button_text_source }}">{{ oidc_button_text_source }}</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><label for="oidc_issuer">oidc_issuer</label><br><span style="font-size:.75rem;color:#999;">{{ t.settings_oidc_issuer_help }}</span></td>
|
||||
<td><input name="oidc_issuer" id="oidc_issuer" value="{{ oidc_issuer }}" style="width:100%"></td>
|
||||
<td><span class="badge badge-{{ oidc_issuer_source }}">{{ oidc_issuer_source }}</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><label for="oidc_client_id">oidc_client_id</label></td>
|
||||
<td><input name="oidc_client_id" id="oidc_client_id" value="{{ oidc_client_id }}" style="width:100%"></td>
|
||||
<td><span class="badge badge-{{ oidc_client_id_source }}">{{ oidc_client_id_source }}</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><label for="oidc_client_secret">oidc_client_secret</label></td>
|
||||
<td><input name="oidc_client_secret" id="oidc_client_secret" type="password" value="{{ oidc_client_secret }}" style="width:100%"></td>
|
||||
<td><span class="badge badge-{{ oidc_client_secret_source }}">{{ oidc_client_secret_source }}</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><label for="oidc_admin_groups">{{ t.settings_oidc_admin_groups }}</label><br><span style="font-size:.75rem;color:#999;">{{ t.settings_oidc_admin_groups_help }}</span></td>
|
||||
<td><input name="oidc_admin_groups" id="oidc_admin_groups" value="{{ oidc_admin_groups }}" style="width:100%"></td>
|
||||
<td><span class="badge badge-{{ oidc_admin_groups_source }}">{{ oidc_admin_groups_source }}</span></td>
|
||||
</tr>
|
||||
</table>
|
||||
<button type="submit" style="margin-top: 1rem; padding: .5rem 1.5rem;">{{ t.settings_save }}</button>
|
||||
</form>
|
||||
{% endblock content %}
|
||||
@@ -0,0 +1,38 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ t.setup_heading }} | {{ t.site_name }}{% endblock title %}
|
||||
|
||||
{% block head_extra %}
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: system-ui, -apple-system, sans-serif; background: #f5f5f5; color: #333; display: flex; justify-content: center; align-items: center; min-height: 100vh; }
|
||||
.setup-card { background: #fff; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,.12); padding: 2rem 2.5rem; width: 100%; max-width: 400px; }
|
||||
.setup-card h1 { margin-bottom: 1.5rem; font-size: 1.5rem; text-align: center; }
|
||||
.setup-card label { display: block; margin-bottom: .25rem; font-weight: 600; font-size: .9rem; }
|
||||
.setup-card input[type="text"],
|
||||
.setup-card input[type="password"] { width: 100%; padding: .5rem .7rem; margin-bottom: 1rem; border: 1px solid #ccc; border-radius: 4px; font-size: .95rem; }
|
||||
.setup-card button { display: block; width: 100%; padding: .6rem; border: none; border-radius: 4px; font-size: 1rem; cursor: pointer; background: #1a1a2e; color: #fff; }
|
||||
.setup-card button:hover { background: #16213e; }
|
||||
.setup-card .flash { color: #856404; background: #fff3cd; padding: .5rem .75rem; border-radius: 4px; margin-bottom: 1rem; text-align: center; font-size: .9rem; }
|
||||
</style>
|
||||
{% endblock head_extra %}
|
||||
|
||||
{% block body %}
|
||||
<div class="setup-card">
|
||||
<h1>{{ t.setup_heading }}</h1>
|
||||
|
||||
{% if !message.is_empty() %}
|
||||
<div class="flash">{{ message }}</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/admin/setup">
|
||||
<label for="username">{{ t.setup_username }}</label>
|
||||
<input type="text" name="username" id="username" required>
|
||||
<label for="password">{{ t.setup_password }}</label>
|
||||
<input type="password" name="password" id="password" required>
|
||||
<label for="confirm_password">{{ t.setup_confirm }}</label>
|
||||
<input type="password" name="confirm_password" id="confirm_password" required>
|
||||
<button type="submit">{{ t.setup_submit }}</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock body %}
|
||||
@@ -0,0 +1,40 @@
|
||||
{% extends "admin/layout.html" %}
|
||||
{% block admin_title %}{% if is_edit %}{{ t.users_edit_heading }}{% else %}{{ t.users_new_heading }}{% endif %}{% endblock admin_title %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{% if is_edit %}{{ t.users_edit_heading }}{% else %}{{ t.users_new_heading }}{% endif %}</h1>
|
||||
|
||||
<form method="post" action="{% if is_edit %}/admin/users/{{ form_user_id }}/edit{% else %}/admin/users/new{% endif %}">
|
||||
<table>
|
||||
<tr>
|
||||
<td><label for="username">{{ t.users_username }}</label></td>
|
||||
<td><input name="username" id="username" value="{{ form_username }}" required style="width:100%"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><label for="email">{{ t.users_email }}</label></td>
|
||||
<td><input name="email" id="email" type="email" value="{{ form_email }}" style="width:100%"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><label for="display_name">{{ t.users_display_name }}</label></td>
|
||||
<td><input name="display_name" id="display_name" value="{{ form_display_name }}" style="width:100%"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><label for="password">{{ t.login_password }}</label></td>
|
||||
<td>
|
||||
<input name="password" id="password" type="password"{% if !is_edit %} required{% endif %} style="width:100%">
|
||||
{% if is_edit %}<br><small style="color:#888;">{{ t.users_password_hint }}</small>{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><label for="role">{{ t.users_role }}</label></td>
|
||||
<td>
|
||||
<select name="role" id="role" style="width:100%; padding:.4rem;">
|
||||
<option value="user"{% if form_role == "user" %} selected{% endif %}>user</option>
|
||||
<option value="admin"{% if form_role == "admin" %} selected{% endif %}>admin</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<button type="submit" style="margin-top: 1rem; padding: .5rem 1.5rem;">{{ t.settings_save }}</button>
|
||||
</form>
|
||||
{% endblock content %}
|
||||
@@ -0,0 +1,37 @@
|
||||
{% extends "admin/layout.html" %}
|
||||
{% block admin_title %}{{ t.nav_users }}{% endblock admin_title %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{{ t.users_heading }}</h1>
|
||||
|
||||
<p style="margin-bottom: 1rem;">
|
||||
<a href="/admin/users/new" style="display:inline-block; padding:.5rem 1rem; background:#1a1a2e; color:#fff; text-decoration:none; border-radius:4px;">{{ t.users_add }}</a>
|
||||
</p>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<th>{{ t.users_username }}</th>
|
||||
<th>{{ t.users_email }}</th>
|
||||
<th>{{ t.users_display_name }}</th>
|
||||
<th>{{ t.users_role }}</th>
|
||||
<th>{{ t.users_active }}</th>
|
||||
<th>{{ t.users_actions }}</th>
|
||||
</tr>
|
||||
{% for u in users %}
|
||||
<tr>
|
||||
<td>{{ u.username_str() }}</td>
|
||||
<td>{{ u.email_str() }}</td>
|
||||
<td>{{ u.display_name_str() }}</td>
|
||||
<td>{{ u.role_str() }}</td>
|
||||
<td>{{ u.is_active() }}</td>
|
||||
<td>
|
||||
<a href="/admin/users/{{ u.id_val() }}/edit">{{ t.users_edit }}</a>
|
||||
|
|
||||
<form method="post" action="/admin/users/{{ u.id_val() }}/delete" style="display:inline;" onsubmit="return confirm('{{ t.users_delete_confirm }}')">
|
||||
<button type="submit" style="background:none; border:none; color:#c00; cursor:pointer; padding:0; text-decoration:underline;">{{ t.users_delete }}</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% endblock content %}
|
||||
Reference in New Issue
Block a user