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:
2026-05-21 14:22:33 +03:00
commit 16abe754af
24 changed files with 7664 additions and 0 deletions
+82
View File
@@ -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 %}
+8
View File
@@ -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 %}
+57
View File
@@ -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 %}
+73
View File
@@ -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 %}
+38
View File
@@ -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 %}
+40
View File
@@ -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 %}
+37
View File
@@ -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>
&nbsp;|&nbsp;
<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 %}