Init
Build and Publish / Build and Publish Docker Image (push) Successful in 5m0s

This commit is contained in:
Ultradesu
2026-06-29 15:50:25 +03:00
commit e5aacc9b41
24 changed files with 11050 additions and 0 deletions
+509
View File
@@ -0,0 +1,509 @@
{% extends "base.html" %}
{% block title %}{{ t.nav_admin }} | {{ t.site_name }}{% endblock title %}
{% block head_extra %}
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
<style>
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; background: #f6f7f8; color: #1d252d; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
a { color: inherit; }
.shell { min-height: 100vh; display: grid; grid-template-columns: 220px minmax(0, 1fr); }
.sidebar { background: #17202a; color: #f4f7fb; padding: 1rem; }
.sidebar h1 { font-size: 1.05rem; line-height: 1.25; margin: 0 0 1.25rem; }
.sidebar a, .sidebar button { width: 100%; display: block; text-align: left; text-decoration: none; color: #d8dee6; padding: .5rem .55rem; border-radius: 4px; margin-bottom: .2rem; border: 0; background: transparent; cursor: pointer; font: inherit; }
.sidebar a:hover, .sidebar button:hover, .sidebar .active { background: #263544; color: #fff; }
.main-wrap { min-width: 0; display: flex; flex-direction: column; }
.topbar { min-height: 48px; display: flex; align-items: center; justify-content: flex-end; gap: 1rem; padding: .5rem 1.25rem; border-bottom: 1px solid #dde2e6; background: #fff; }
.user-info { font-size: .875rem; color: #53606d; }
.app-version { font-size: .75rem; color: #9aa4ae; white-space: nowrap; }
.logout-link, .lang-switch a { font-size: .875rem; text-decoration: none; color: #53606d; padding: .25rem .45rem; border-radius: 4px; }
.logout-link:hover, .lang-switch a:hover { background: #eef1f4; color: #1d252d; }
.lang-switch a.active { color: #1d252d; font-weight: 700; }
.main { width: min(1180px, 100%); padding: 1.5rem; }
.toolbar { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: 1rem; }
.toolbar h2 { font-size: 1.5rem; margin: 0; }
.actions, .row-actions { display: flex; gap: .5rem; align-items: center; flex-wrap: wrap; }
input, select { min-height: 36px; border: 1px solid #cbd3db; border-radius: 4px; padding: .45rem .55rem; background: #fff; color: #1d252d; width: 100%; }
input[type="checkbox"] { width: auto; min-height: auto; }
button { min-height: 36px; border: 1px solid #17202a; border-radius: 4px; padding: .45rem .75rem; background: #17202a; color: #fff; cursor: pointer; }
button.secondary { background: #fff; color: #17202a; border-color: #cbd3db; }
button.danger { background: #9d2323; border-color: #9d2323; }
button:disabled { opacity: .55; cursor: default; }
table { width: 100%; border-collapse: collapse; background: #fff; border: 1px solid #dde2e6; border-radius: 6px; overflow: hidden; }
th, td { text-align: left; padding: .65rem .75rem; border-bottom: 1px solid #edf0f2; font-size: .92rem; vertical-align: middle; }
th { background: #eef1f4; font-weight: 650; color: #34414f; }
tr:last-child td { border-bottom: 0; }
code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: .84rem; background: #f1f3f5; padding: .15rem .35rem; border-radius: 3px; overflow-wrap: anywhere; }
.status { margin: .75rem 0; min-height: 1.25rem; color: #53606d; }
.error { color: #9d2323; }
.empty { background: #fff; border: 1px solid #dde2e6; border-radius: 6px; padding: 1rem; color: #53606d; }
.summary-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: .75rem; margin-bottom: 1rem; }
.summary-tile { background: #fff; border: 1px solid #dde2e6; border-radius: 6px; padding: .8rem; }
.summary-value { display: block; font-size: 1.6rem; font-weight: 750; line-height: 1.1; }
.summary-label { color: #53606d; font-size: .85rem; }
.section-tabs { display: flex; gap: .4rem; margin: .5rem 0 1rem; flex-wrap: wrap; }
.section-tabs button { min-height: 32px; padding: .35rem .6rem; }
.section-tabs button.active { background: #17202a; color: #fff; border-color: #17202a; }
.settings-grid { display: grid; grid-template-columns: minmax(180px, .7fr) minmax(240px, 1fr) auto; gap: .55rem .75rem; align-items: center; background: #fff; border: 1px solid #dde2e6; border-radius: 6px; padding: .75rem; }
.settings-row { display: contents; }
.field-name { font-weight: 650; color: #34414f; overflow-wrap: anywhere; }
.field-help { color: #53606d; font-size: .78rem; margin-top: .15rem; overflow-wrap: anywhere; }
.badge { display: inline-block; padding: .15rem .55rem; border-radius: 4px; font-size: .8rem; font-weight: 650; }
.badge-default { background: #e8ecef; color: #53606d; }
.badge-database { background: #d7edf1; color: #0f5560; }
.badge-env { background: #fff0c2; color: #6f4e00; }
.servers-list { display: grid; gap: .75rem; }
.server-admin-row { background: #fff; border: 1px solid #dde2e6; border-radius: 6px; padding: .8rem; display: grid; grid-template-columns: minmax(220px, 1fr) minmax(260px, 1.2fr) auto; gap: .75rem; align-items: center; }
.server-admin-name { display: grid; gap: .25rem; min-width: 0; }
.server-admin-name label { font-weight: 650; color: #34414f; font-size: .85rem; }
.server-admin-meta { display: grid; gap: .25rem; min-width: 0; }
.server-admin-meta span { color: #53606d; font-size: .78rem; }
.toggle-row { display: inline-flex; align-items: center; gap: .45rem; color: #34414f; font-weight: 650; white-space: nowrap; }
.modal-backdrop { position: fixed; inset: 0; background: rgba(23,32,42,.35); display: grid; place-items: center; padding: 1rem; z-index: 20; }
.modal { width: min(560px, 100%); background: #fff; border-radius: 6px; border: 1px solid #dde2e6; box-shadow: 0 16px 48px rgba(23,32,42,.24); padding: 1rem; }
.modal h3 { margin: 0 0 .75rem; font-size: 1.1rem; }
.form-grid { display: grid; gap: .7rem; }
.form-row label { display: block; font-weight: 650; color: #34414f; font-size: .9rem; margin-bottom: .25rem; }
.hint { color: #53606d; font-size: .78rem; margin-top: .2rem; }
.debug-grid { display: grid; gap: 1rem; }
.debug-build { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: .55rem; }
@media (max-width: 760px) {
.shell { grid-template-columns: 1fr; }
.sidebar { display: flex; align-items: center; gap: .75rem; overflow-x: auto; }
.sidebar h1 { margin: 0; white-space: nowrap; }
.sidebar a, .sidebar button { margin: 0; white-space: nowrap; width: auto; }
.toolbar { align-items: stretch; flex-direction: column; }
.actions { align-items: stretch; }
.actions button { width: 100%; }
.main { padding: 1rem; }
table { display: block; overflow-x: auto; }
.settings-grid { grid-template-columns: 1fr; }
.server-admin-row { grid-template-columns: 1fr; align-items: stretch; }
.toggle-row { justify-content: space-between; }
}
</style>
{% endblock head_extra %}
{% block body %}
<div class="shell" x-data="adminApp('{{ initial_view }}')" x-init="init()">
<nav class="sidebar">
<h1>{{ t.site_name }}</h1>
<a href="/configs">{{ t.nav_configs }}</a>
<button :class="{ active: view === 'dashboard' }" @click="navigate('dashboard')">{{ t.nav_dashboard }}</button>
<button :class="{ active: view === 'users' }" @click="navigate('users')">{{ t.nav_users }}</button>
<button :class="{ active: view === 'servers' }" @click="navigate('servers')">{{ t.nav_servers }}</button>
<button :class="{ active: view === 'settings' }" @click="navigate('settings')">{{ t.nav_settings }}</button>
<button :class="{ active: view === 'debug' }" @click="navigate('debug')">{{ t.nav_debug }}</button>
</nav>
<div class="main-wrap">
<div class="topbar">
<span class="app-version">v{{ app_version }}</span>
<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>
<main class="main">
<div class="toolbar">
<h2 x-text="title()"></h2>
<div class="actions">
<button class="secondary" @click="reload()" :disabled="busy">{{ t.configs_refresh }}</button>
<template x-if="view === 'users'">
<button @click="openUserModal(null)" :disabled="busy">{{ t.users_add }}</button>
</template>
<template x-if="view === 'settings'">
<button @click="saveSettings()" :disabled="busy">{{ t.settings_save }}</button>
</template>
<template x-if="view === 'servers'">
<button @click="saveServers()" :disabled="busy">{{ t.settings_save }}</button>
</template>
</div>
</div>
<div class="status" :class="{ error: error }" x-text="error || status"></div>
<section x-show="view === 'dashboard'">
<template x-if="summary">
<div>
<div class="summary-grid">
<div class="summary-tile"><span class="summary-value" x-text="summary.users_count"></span><span class="summary-label">{{ t.nav_users }}</span></div>
<div class="summary-tile"><span class="summary-value" x-text="summary.admin_users_count"></span><span class="summary-label">admin</span></div>
<div class="summary-tile"><span class="summary-value" x-text="summary.client_users_count"></span><span class="summary-label">client</span></div>
<div class="summary-tile"><span class="summary-value" x-text="summary.active_users_count"></span><span class="summary-label">{{ t.users_active }}</span></div>
</div>
<div class="debug-build">
<div class="summary-tile"><span class="summary-label">Version</span><br><code x-text="summary.build.pkg_version"></code></div>
<div class="summary-tile"><span class="summary-label">Profile</span><br><code x-text="summary.build.profile"></code></div>
<div class="summary-tile"><span class="summary-label">Target</span><br><code x-text="summary.build.target"></code></div>
</div>
</div>
</template>
</section>
<section x-show="view === 'users'">
<template x-if="users.length === 0 && !busy">
<div class="empty">{{ t.users_heading }}: 0</div>
</template>
<template x-if="users.length > 0">
<table>
<thead>
<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>
</thead>
<tbody>
<template x-for="user in users" :key="user.id">
<tr>
<td x-text="user.username"></td>
<td x-text="user.email"></td>
<td x-text="user.display_name"></td>
<td><code x-text="user.role"></code></td>
<td x-text="user.active ? '{{ t.configs_yes }}' : '{{ t.configs_no }}'"></td>
<td>
<div class="row-actions">
<button class="secondary" @click="openUserModal(user)">{{ t.users_edit }}</button>
<button class="danger" @click="deleteUser(user)">{{ t.users_delete }}</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</template>
</section>
<section x-show="view === 'settings'">
<div class="section-tabs">
<template x-for="section in settingSections" :key="section">
<button class="secondary" :class="{ active: settingsSection === section }" @click="settingsSection = section" x-text="sectionLabel(section)"></button>
</template>
</div>
<template x-if="settings.fields.length > 0">
<div class="settings-grid">
<template x-for="field in visibleSettings()" :key="field.key">
<div class="settings-row">
<div class="field-name">
<span x-text="fieldLabel(field.key)"></span>
<div class="field-help" x-text="field.env_var"></div>
</div>
<div>
<template x-if="field.kind === 'bool'">
<input type="checkbox" x-model="settingsForm[field.key]">
</template>
<template x-if="field.kind !== 'bool'">
<input :type="field.kind === 'password' ? 'password' : field.kind" x-model="settingsForm[field.key]">
</template>
<div class="hint">default: <code x-text="field.default_value || '(empty)'"></code></div>
</div>
<span class="badge" :class="`badge-${field.source}`" x-text="field.source"></span>
</div>
</template>
</div>
</template>
</section>
<section x-show="view === 'servers'">
<template x-if="servers.length === 0 && !busy">
<div class="empty">{{ t.servers_empty }}</div>
</template>
<template x-if="servers.length > 0">
<div class="servers-list">
<template x-for="server in servers" :key="server.name">
<div class="server-admin-row">
<div class="server-admin-name">
<label>{{ t.servers_display_name }}</label>
<input x-model="server.display_name" :placeholder="server.name">
</div>
<div class="server-admin-meta">
<span>{{ t.servers_technical_name }}</span>
<code x-text="server.name"></code>
<span>{{ t.servers_endpoint }}</span>
<code x-text="server.endpoint"></code>
</div>
<label class="toggle-row">
<span>{{ t.configs_enabled }}</span>
<input type="checkbox" x-model="server.enabled">
</label>
</div>
</template>
</div>
</template>
</section>
<section x-show="view === 'debug'" class="debug-grid">
<template x-if="debug">
<div>
<div class="debug-build">
<div class="summary-tile"><span class="summary-label">Package</span><br><code x-text="debug.build.pkg_name"></code></div>
<div class="summary-tile"><span class="summary-label">Version</span><br><code x-text="debug.build.pkg_version"></code></div>
<div class="summary-tile"><span class="summary-label">Rustc</span><br><code x-text="debug.build.rustc_version"></code></div>
<div class="summary-tile"><span class="summary-label">{{ t.debug_db_status }}</span><br><code x-text="debug.db_status"></code></div>
</div>
<table>
<thead>
<tr>
<th>{{ t.debug_field }}</th>
<th>{{ t.debug_value }}</th>
<th>{{ t.debug_source }}</th>
</tr>
</thead>
<tbody>
<template x-for="entry in debug.config_entries" :key="entry.key">
<tr>
<td><code x-text="entry.key"></code><div class="hint" x-text="entry.env_var"></div></td>
<td><code x-text="entry.value || '(empty)'"></code></td>
<td><span class="badge" :class="`badge-${entry.source}`" x-text="entry.source"></span></td>
</tr>
</template>
</tbody>
</table>
</div>
</template>
</section>
</main>
</div>
<template x-if="userModalOpen">
<div class="modal-backdrop" @click.self="closeUserModal()">
<div class="modal">
<h3 x-text="userForm.id ? '{{ t.users_edit_heading }}' : '{{ t.users_new_heading }}'"></h3>
<div class="form-grid">
<div class="form-row"><label>{{ t.users_username }}</label><input x-model="userForm.username"></div>
<div class="form-row"><label>{{ t.users_email }}</label><input type="email" x-model="userForm.email"></div>
<div class="form-row"><label>{{ t.users_display_name }}</label><input x-model="userForm.display_name"></div>
<div class="form-row">
<label>{{ t.login_password }}</label>
<input type="password" x-model="userForm.password">
<template x-if="userForm.id"><div class="hint">{{ t.users_password_hint }}</div></template>
</div>
<div class="form-row">
<label>{{ t.users_role }}</label>
<select x-model="userForm.role">
<option value="client">client</option>
<option value="admin">admin</option>
</select>
</div>
<div class="actions">
<button @click="saveUser()">{{ t.settings_save }}</button>
<button class="secondary" @click="closeUserModal()">{{ t.admin_close }}</button>
</div>
</div>
</div>
</div>
</template>
</div>
<script>
function adminApp(initialView) {
return {
view: initialView || 'dashboard',
busy: false,
status: '',
error: '',
summary: null,
users: [],
servers: [],
settings: { fields: [] },
settingsForm: {},
settingsSection: 'auth',
settingSections: ['auth', 'oidc', 'kubernetes', 'vpn', 'api'],
debug: null,
userModalOpen: false,
userForm: {},
init() {
this.loadForView();
window.addEventListener('popstate', () => {
this.view = this.viewFromPath(location.pathname);
this.loadForView();
});
},
async request(url, options = {}) {
const response = await fetch(url, {
headers: { 'content-type': 'application/json' },
...options,
});
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.error || response.statusText);
return data;
},
navigate(view) {
this.view = view;
history.pushState({}, '', this.pathForView(view));
this.loadForView();
},
pathForView(view) {
return {
dashboard: '/admin/',
users: '/admin/users',
servers: '/admin/servers',
settings: '/admin/settings',
debug: '/admin/debug',
}[view] || '/admin/';
},
viewFromPath(path) {
if (path.endsWith('/users')) return 'users';
if (path.endsWith('/servers')) return 'servers';
if (path.endsWith('/settings')) return 'settings';
if (path.endsWith('/debug')) return 'debug';
return 'dashboard';
},
title() {
return {
dashboard: '{{ t.nav_dashboard }}',
users: '{{ t.nav_users }}',
servers: '{{ t.nav_servers }}',
settings: '{{ t.nav_settings }}',
debug: '{{ t.nav_debug }}',
}[this.view] || '{{ t.nav_admin }}';
},
async loadForView() {
this.error = '';
if (this.view === 'dashboard') await this.loadSummary();
if (this.view === 'users') await this.loadUsers();
if (this.view === 'servers') await this.loadServers();
if (this.view === 'settings') await this.loadSettings();
if (this.view === 'debug') await this.loadDebug();
},
async reload() {
await this.loadForView();
},
async loadSummary() {
this.busy = true;
try { this.summary = await this.request('/admin/api/summary'); }
catch (e) { this.error = e.message; }
finally { this.busy = false; }
},
async loadUsers() {
this.busy = true;
try { this.users = (await this.request('/admin/api/users')).users; }
catch (e) { this.error = e.message; }
finally { this.busy = false; }
},
async loadServers() {
this.busy = true;
try { this.servers = (await this.request('/admin/api/servers')).servers; }
catch (e) { this.error = e.message; }
finally { this.busy = false; }
},
async loadSettings() {
this.busy = true;
try {
this.settings = await this.request('/admin/api/settings');
const form = {};
for (const field of this.settings.fields) {
form[field.key] = field.kind === 'bool' ? field.value === 'true' : field.value;
}
this.settingsForm = form;
} catch (e) { this.error = e.message; }
finally { this.busy = false; }
},
async loadDebug() {
this.busy = true;
try { this.debug = await this.request('/admin/api/debug'); }
catch (e) { this.error = e.message; }
finally { this.busy = false; }
},
visibleSettings() {
return this.settings.fields.filter((field) => field.section === this.settingsSection);
},
async saveSettings() {
this.error = '';
this.status = '';
this.busy = true;
try {
const body = { ...this.settingsForm, vpn_mtu: Number(this.settingsForm.vpn_mtu || 0) };
this.settings = await this.request('/admin/api/settings', {
method: 'POST',
body: JSON.stringify(body),
});
this.status = '{{ t.settings_saved }}';
} catch (e) { this.error = e.message; }
finally { this.busy = false; }
},
async saveServers() {
this.error = '';
this.status = '';
this.busy = true;
try {
const data = await this.request('/admin/api/servers', {
method: 'POST',
body: JSON.stringify({ servers: this.servers }),
});
this.servers = data.servers;
this.status = '{{ t.settings_saved }}';
} catch (e) { this.error = e.message; }
finally { this.busy = false; }
},
openUserModal(user) {
this.userForm = user ? { ...user, password: '' } : {
id: null, username: '', email: '', display_name: '', password: '', role: 'client',
};
this.userModalOpen = true;
},
closeUserModal() {
this.userModalOpen = false;
this.userForm = {};
},
async saveUser() {
this.error = '';
this.status = '';
const isEdit = Boolean(this.userForm.id);
const url = isEdit ? `/admin/api/users/${this.userForm.id}` : '/admin/api/users';
try {
const data = await this.request(url, {
method: 'POST',
body: JSON.stringify(this.userForm),
});
if (isEdit) {
const index = this.users.findIndex((user) => user.id === data.user.id);
if (index !== -1) this.users[index] = data.user;
} else {
this.users.push(data.user);
}
this.closeUserModal();
this.status = '{{ t.settings_saved }}';
} catch (e) { this.error = e.message; }
},
async deleteUser(user) {
if (!confirm('{{ t.users_delete_confirm }}')) return;
this.error = '';
this.status = '';
try {
await this.request(`/admin/api/users/${user.id}/delete`, { method: 'POST' });
this.users = this.users.filter((item) => item.id !== user.id);
this.status = '{{ t.settings_saved }}';
} catch (e) { this.error = e.message; }
},
sectionLabel(section) {
return {
auth: '{{ t.settings_auth }}',
oidc: '{{ t.settings_oidc }}',
kubernetes: '{{ t.settings_kubernetes }}',
vpn: '{{ t.settings_vpn }}',
api: '{{ t.settings_api }}',
}[section] || section;
},
fieldLabel(key) {
return {
auth_password_enabled: '{{ t.settings_password_login }}',
auth_sso_enabled: '{{ t.settings_sso_login }}',
oidc_button_text: '{{ t.settings_oidc_button }}',
oidc_admin_groups: '{{ t.settings_oidc_admin_groups }}',
oidc_client_groups: '{{ t.settings_oidc_client_groups }}',
swagger_enabled: '{{ t.settings_swagger }}',
}[key] || key;
},
};
}
</script>
{% endblock body %}
+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 %}
+14
View File
@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="{{ t.lang.code() }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}{{ t.site_name }}{% endblock title %}</title>
{% block head_extra %}{% endblock head_extra %}
</head>
<body>
{% block body %}
{% block content %}{% endblock content %}
{% endblock body %}
</body>
</html>
+590
View File
@@ -0,0 +1,590 @@
{% extends "base.html" %}
{% block title %}{{ t.configs_heading }} | {{ t.site_name }}{% endblock title %}
{% block head_extra %}
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
<style>
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; background: #f6f7f8; color: #1d252d; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
a { color: inherit; }
.shell { min-height: 100vh; display: grid; grid-template-columns: 220px minmax(0, 1fr); }
.sidebar { background: #17202a; color: #f4f7fb; padding: 1rem; }
.sidebar h1 { font-size: 1.05rem; line-height: 1.25; margin: 0 0 1.25rem; }
.sidebar a { display: block; text-decoration: none; color: #d8dee6; padding: .5rem .55rem; border-radius: 4px; margin-bottom: .2rem; }
.sidebar a:hover, .sidebar a.active { background: #263544; color: #fff; }
.main-wrap { min-width: 0; display: flex; flex-direction: column; }
.topbar { min-height: 48px; display: flex; align-items: center; justify-content: flex-end; gap: 1rem; padding: .5rem 1.25rem; border-bottom: 1px solid #dde2e6; background: #fff; }
.user-info { font-size: .875rem; color: #53606d; }
.app-version { font-size: .75rem; color: #9aa4ae; white-space: nowrap; }
.logout-link, .lang-switch a { font-size: .875rem; text-decoration: none; color: #53606d; padding: .25rem .45rem; border-radius: 4px; }
.logout-link:hover, .lang-switch a:hover { background: #eef1f4; color: #1d252d; }
.lang-switch a.active { color: #1d252d; font-weight: 700; }
.main { width: min(1180px, 100%); padding: 1.5rem; }
.toolbar { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: 1rem; }
.toolbar h2 { font-size: 1.5rem; margin: 0; }
.panel-head { display: flex; align-items: center; justify-content: space-between; gap: .75rem; margin-bottom: .6rem; }
.panel-head h3 { font-size: 1rem; margin: 0; color: #34414f; }
.actions { display: flex; gap: .5rem; align-items: center; flex-wrap: wrap; }
.row-actions { display: flex; gap: .45rem; align-items: center; flex-wrap: wrap; }
input { min-height: 36px; border: 1px solid #cbd3db; border-radius: 4px; padding: .45rem .55rem; background: #fff; color: #1d252d; }
button { min-height: 36px; border: 1px solid #17202a; border-radius: 4px; padding: .45rem .75rem; background: #17202a; color: #fff; cursor: pointer; }
button.secondary { background: #fff; color: #17202a; border-color: #cbd3db; }
button.danger { background: #9d2323; border-color: #9d2323; }
button:disabled { opacity: .55; cursor: default; }
table { width: 100%; border-collapse: collapse; background: #fff; border: 1px solid #dde2e6; border-radius: 6px; overflow: hidden; }
th, td { text-align: left; padding: .65rem .75rem; border-bottom: 1px solid #edf0f2; font-size: .92rem; vertical-align: middle; }
th { background: #eef1f4; font-weight: 650; color: #34414f; }
tr:last-child td { border-bottom: 0; }
.owner-row td { background: #f8fafb; color: #34414f; font-weight: 750; border-top: 1px solid #dde2e6; }
.owner-meta { color: #53606d; font-weight: 500; margin-left: .35rem; }
code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: .84rem; background: #f1f3f5; padding: .15rem .35rem; border-radius: 3px; }
.status { margin: .75rem 0; min-height: 1.25rem; color: #53606d; }
.error { color: #9d2323; }
.empty { background: #fff; border: 1px solid #dde2e6; border-radius: 6px; padding: 1rem; color: #53606d; }
.rollout { margin: 1rem 0 1.25rem; }
.rollout-meta { color: #53606d; font-size: .88rem; margin-bottom: .5rem; }
.rollout-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: .55rem; }
.rollout-tile { position: relative; min-height: 86px; border: 1px solid #dde2e6; border-left-width: 4px; border-radius: 6px; background: #fff; padding: .65rem .7rem; display: grid; gap: .45rem; align-content: space-between; }
.rollout-tile::after { content: attr(data-tooltip); position: absolute; left: .25rem; top: calc(100% + .45rem); z-index: 5; width: max-content; max-width: min(380px, 80vw); padding: .55rem .65rem; border-radius: 4px; background: #17202a; color: #fff; font-size: .78rem; line-height: 1.35; white-space: pre-line; box-shadow: 0 8px 24px rgba(23, 32, 42, .22); opacity: 0; pointer-events: none; transform: translateY(-3px); transition: opacity .12s ease, transform .12s ease; }
.rollout-tile:hover::after, .rollout-tile:focus-within::after { opacity: 1; transform: translateY(0); }
.rollout-tile-applied { border-left-color: #2f8f4e; }
.rollout-tile-starting { border-left-color: #d39a00; }
.rollout-tile-pending_restart { border-left-color: #c83f31; }
.rollout-tile-unknown { border-left-color: #7b8793; }
.rollout-tile-head { display: flex; align-items: center; min-width: 0; gap: .45rem; }
.rollout-dot { width: .55rem; height: .55rem; border-radius: 999px; flex: 0 0 auto; background: #7b8793; }
.rollout-dot-applied { background: #2f8f4e; }
.rollout-dot-starting { background: #d39a00; }
.rollout-dot-pending_restart { background: #c83f31; }
.rollout-dot-unknown { background: #7b8793; }
.rollout-server { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 700; color: #1d252d; }
.rollout-badge { display: inline-flex; align-items: center; min-height: 24px; padding: .15rem .45rem; border-radius: 4px; font-size: .82rem; font-weight: 700; white-space: nowrap; }
.rollout-applied { background: #dcefe4; color: #175c31; }
.rollout-starting { background: #fff0c2; color: #6f4e00; }
.rollout-pending_restart { background: #ffe3df; color: #8b2116; }
.rollout-unknown { background: #e8ecef; color: #53606d; }
.rollout-foot { display: flex; align-items: center; justify-content: space-between; gap: .5rem; color: #53606d; font-size: .82rem; }
.server-name { font-weight: 650; color: #34414f; overflow-wrap: anywhere; }
.server-state { color: #53606d; }
.modal-backdrop { position: fixed; inset: 0; background: rgba(23,32,42,.35); display: grid; place-items: center; padding: 1rem; z-index: 20; }
.modal { width: min(420px, 100%); background: #fff; border-radius: 6px; border: 1px solid #dde2e6; box-shadow: 0 16px 48px rgba(23,32,42,.24); padding: 1rem; }
.modal h3 { margin: 0 0 .75rem; font-size: 1.1rem; }
.servers-modal { width: min(820px, 100%); max-height: min(760px, calc(100vh - 2rem)); display: flex; flex-direction: column; gap: .75rem; }
.modal-head { display: flex; align-items: flex-start; justify-content: space-between; gap: .75rem; }
.modal-title { display: grid; gap: .2rem; min-width: 0; }
.modal-title h3 { margin: 0; }
.modal-subtitle { color: #53606d; font-size: .86rem; overflow-wrap: anywhere; }
.server-filter { width: 100%; }
.server-picker-list { display: grid; gap: .6rem; overflow: auto; padding-right: .15rem; }
.server-card { border: 1px solid #dde2e6; border-radius: 6px; padding: .7rem; display: grid; grid-template-columns: minmax(160px, 1fr) auto; gap: .65rem; align-items: center; }
.server-card-main { display: grid; gap: .25rem; min-width: 0; }
.server-card-actions { display: flex; gap: .45rem; align-items: center; flex-wrap: wrap; justify-content: flex-end; }
.qr-box { display: grid; place-items: center; padding: .75rem; border: 1px solid #dde2e6; border-radius: 6px; background: #fff; }
.qr-box svg { width: min(280px, 100%); height: auto; display: block; }
.modal-actions { display: flex; gap: .5rem; justify-content: flex-end; margin-top: .75rem; flex-wrap: wrap; }
@media (max-width: 760px) {
.shell { grid-template-columns: 1fr; }
.sidebar { display: flex; align-items: center; gap: .75rem; overflow-x: auto; }
.sidebar h1 { margin: 0; white-space: nowrap; }
.sidebar a { margin: 0; white-space: nowrap; }
.toolbar { align-items: stretch; flex-direction: column; }
.actions { align-items: stretch; }
.actions input, .actions button { width: 100%; }
.main { padding: 1rem; }
table { display: block; overflow-x: auto; }
.row-actions { align-items: stretch; }
.servers-modal { width: 100%; max-height: calc(100vh - 2rem); }
.server-card { grid-template-columns: 1fr; }
.server-card-actions { justify-content: stretch; }
.server-card-actions button { flex: 1 1 100%; }
.rollout-grid { grid-template-columns: 1fr; }
}
</style>
{% endblock head_extra %}
{% block body %}
<div class="shell" x-data="configsPage()" x-init="init()">
<nav class="sidebar">
<h1>{{ t.site_name }}</h1>
<a href="/configs" class="active">{{ t.nav_configs }}</a>
{% if is_admin %}
<a href="/admin/">{{ t.nav_admin }}</a>
{% endif %}
</nav>
<div class="main-wrap">
<div class="topbar">
<span class="app-version">v{{ app_version }}</span>
<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>
<main class="main">
<div class="toolbar">
<h2>{{ t.configs_heading }}</h2>
<div class="actions">
<input x-model="newName" placeholder="{{ t.configs_name_placeholder }}">
<button @click="createClient()" :disabled="busy">{{ t.configs_create }}</button>
{% if is_admin %}
<button class="secondary" @click="sync()" :disabled="busy">{{ t.configs_sync }}</button>
{% endif %}
</div>
</div>
<div class="status" :class="{ 'error': error }" x-text="error || status"></div>
<section class="rollout">
<div class="panel-head">
<h3>{{ t.configs_rollout_heading }}</h3>
<button class="secondary" @click="loadRolloutStatus()" :disabled="rolloutBusy">{{ t.configs_refresh }}</button>
</div>
<template x-if="rollout">
<div class="rollout-meta">
{{ t.configs_config_updated }}:
<span x-text="formatTime(rollout.config_updated_at_ms)"></span>
</div>
</template>
<template x-if="rolloutError">
<div class="status error" x-text="rolloutError"></div>
</template>
<template x-if="!rollout && rolloutBusy">
<div class="empty">{{ t.configs_loading_status }}</div>
</template>
<template x-if="rollout && rollout.pods.length === 0">
<div class="empty">{{ t.configs_no_pods }}</div>
</template>
<template x-if="rollout && rollout.pods.length > 0">
<div class="rollout-grid">
<template x-for="pod in rollout.pods" :key="pod.name">
<div class="rollout-tile" :class="`rollout-tile-${pod.rollout_status}`" :data-tooltip="podTooltip(pod)" :title="podTooltip(pod)" tabindex="0">
<div class="rollout-tile-head">
<span class="rollout-dot" :class="`rollout-dot-${pod.rollout_status}`"></span>
<span class="rollout-server" x-text="pod.endpoint_name || pod.node_name || pod.name"></span>
</div>
<span class="rollout-badge" :class="`rollout-${pod.rollout_status}`" x-text="rolloutStatusLabel(pod.rollout_status)"></span>
<div class="rollout-foot">
<span x-text="formatDuration(pod.uptime_seconds)"></span>
<span x-text="pod.ready ? '{{ t.configs_ready }}' : '{{ t.configs_not_ready }}'"></span>
</div>
</div>
</template>
</div>
</template>
</section>
<template x-if="!busy && clients.length === 0">
<div class="empty">{{ t.configs_empty }}</div>
</template>
<template x-if="clients.length > 0">
<table>
<thead>
<tr>
<th>{{ t.configs_name }}</th>
{% if is_admin %}
<th>{{ t.configs_owner }}</th>
{% endif %}
<th>{{ t.configs_address }}</th>
<th>{{ t.configs_public_key }}</th>
<th>{{ t.configs_enabled }}</th>
<th>{{ t.users_actions }}</th>
</tr>
</thead>
<template x-for="group in groupedClients()" :key="group.key">
<tbody>
{% if is_admin %}
<tr class="owner-row">
<td colspan="6">
<span x-text="group.label"></span>
<span class="owner-meta" x-text="`(${group.clients.length})`"></span>
</td>
</tr>
{% endif %}
<template x-for="client in group.clients" :key="client.id">
<tr>
<td x-text="client.name"></td>
{% if is_admin %}
<td x-text="ownerLabel(client)"></td>
{% endif %}
<td><code x-text="client.address + '/32'"></code></td>
<td><code x-text="shortKey(client.public_key)"></code></td>
<td x-text="client.enabled ? '{{ t.configs_yes }}' : '{{ t.configs_no }}'"></td>
<td>
<div class="row-actions">
<button class="secondary" @click="openServers(client)" :disabled="busy">{{ t.configs_servers }}</button>
<button class="secondary" @click="setEnabled(client, !client.enabled)" :disabled="busy" x-text="client.enabled ? '{{ t.configs_disable }}' : '{{ t.configs_enable }}'"></button>
<button class="danger" @click="deleteClient(client)" :disabled="busy">{{ t.users_delete }}</button>
</div>
</td>
</tr>
</template>
</tbody>
</template>
</table>
</template>
</main>
</div>
<template x-if="serverModal">
<div class="modal-backdrop" @click.self="closeServers()">
<div class="modal servers-modal">
<div class="modal-head">
<div class="modal-title">
<h3>{{ t.configs_servers }}</h3>
<div class="modal-subtitle" x-text="serverModal.client.name"></div>
</div>
<button class="secondary" @click="closeServers()">{{ t.admin_close }}</button>
</div>
<input class="server-filter" x-model="serverModal.filter" placeholder="{{ t.configs_server_search }}">
<template x-if="serverModal.loading">
<div class="empty">{{ t.configs_loading_servers }}</div>
</template>
<template x-if="!serverModal.loading && filteredServers().length === 0">
<div class="empty">{{ t.configs_no_servers }}</div>
</template>
<template x-if="!serverModal.loading && filteredServers().length > 0">
<div class="server-picker-list">
<template x-for="server in filteredServers()" :key="server.endpoint_id || server.endpoint_name">
<div class="server-card">
<div class="server-card-main">
<span class="server-name" x-text="server.endpoint_name"></span>
<code x-text="server.endpoint"></code>
</div>
<div class="server-card-actions">
<button class="secondary" @click="copyVpnUrl(serverModal.client, server)" :disabled="busy">{{ t.configs_copy_vpn_url }}</button>
<button class="secondary" @click="openQr(serverModal.client, server)" :disabled="busy">QR</button>
<button class="secondary" @click="downloadConfig(serverModal.client, server)" :disabled="busy">{{ t.configs_download }}</button>
</div>
</div>
</template>
</div>
</template>
</div>
</div>
</template>
<template x-if="qrModal">
<div class="modal-backdrop" @click.self="closeQr()">
<div class="modal">
<h3 x-text="qrModal.title"></h3>
<div class="qr-box" x-html="qrModal.svg"></div>
<div class="modal-actions">
<button class="secondary" @click="copyText(qrModal.url)">{{ t.configs_copy_vpn_url }}</button>
<button @click="closeQr()">{{ t.admin_close }}</button>
</div>
</div>
</div>
</template>
</div>
<script>
function configsPage() {
return {
clients: [],
newName: '',
busy: false,
status: '',
error: '',
rollout: null,
rolloutBusy: false,
rolloutError: '',
rolloutTimer: null,
serverConfigs: {},
serverModal: null,
qrModal: null,
init() {
this.load();
this.loadRolloutStatus();
this.rolloutTimer = setInterval(() => this.loadRolloutStatus(), 10000);
},
async request(url, options = {}) {
const response = await fetch(url, {
headers: { 'content-type': 'application/json' },
...options,
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data.error || response.statusText);
}
return data;
},
async load() {
this.error = '';
this.busy = true;
try {
const data = await this.request('/api/vpn-clients');
this.clients = data.clients;
} catch (e) {
this.error = e.message;
} finally {
this.busy = false;
}
},
async createClient() {
this.error = '';
this.status = '';
this.busy = true;
try {
const data = await this.request('/api/vpn-clients', {
method: 'POST',
body: JSON.stringify({ name: this.newName }),
});
this.clients.push(data.client);
delete this.serverConfigs[data.client.id];
this.newName = '';
this.status = data.sync.message;
await this.loadRolloutStatus();
} catch (e) {
this.error = e.message;
} finally {
this.busy = false;
}
},
async setEnabled(client, enabled) {
this.error = '';
this.status = '';
this.busy = true;
try {
const data = await this.request(`/api/vpn-clients/${client.id}/enabled`, {
method: 'POST',
body: JSON.stringify({ enabled }),
});
const index = this.clients.findIndex((item) => item.id === client.id);
if (index !== -1) this.clients[index] = data.client;
delete this.serverConfigs[client.id];
this.status = data.sync.message;
await this.loadRolloutStatus();
} catch (e) {
this.error = e.message;
} finally {
this.busy = false;
}
},
async deleteClient(client) {
if (!confirm('{{ t.users_delete_confirm }}')) return;
this.error = '';
this.status = '';
this.busy = true;
try {
const data = await this.request(`/api/vpn-clients/${client.id}`, {
method: 'DELETE',
});
this.clients = this.clients.filter((item) => item.id !== client.id);
delete this.serverConfigs[client.id];
this.status = data.sync.message;
await this.loadRolloutStatus();
} catch (e) {
this.error = e.message;
} finally {
this.busy = false;
}
},
async openServers(client) {
this.error = '';
this.status = '';
const cached = this.serverConfigs[client.id];
this.serverModal = {
client,
filter: '',
loading: !cached,
servers: cached ? cached.servers : [],
};
if (cached) return;
this.serverConfigs[client.id] = { loading: true, servers: [] };
try {
const data = await this.request(`/api/vpn-clients/${client.id}/config`);
this.serverConfigs[client.id] = { loading: false, servers: data.servers || [] };
if (this.serverModal && this.serverModal.client.id === client.id) {
this.serverModal.loading = false;
this.serverModal.servers = data.servers || [];
}
} catch (e) {
this.serverConfigs[client.id] = { loading: false, servers: [] };
if (this.serverModal && this.serverModal.client.id === client.id) {
this.serverModal.loading = false;
this.serverModal.servers = [];
}
this.error = e.message;
}
},
closeServers() {
this.serverModal = null;
},
filteredServers() {
if (!this.serverModal) return [];
const query = this.serverModal.filter.trim().toLowerCase();
if (!query) return this.serverModal.servers;
return this.serverModal.servers.filter((server) => {
return [server.endpoint_name, server.endpoint, server.endpoint_id]
.filter(Boolean)
.some((value) => value.toLowerCase().includes(query));
});
},
async downloadConfig(client, server) {
this.error = '';
this.status = '';
this.busy = true;
try {
const blob = new Blob([server.config], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${this.fileSlug(client.name || 'amnezia-client')}-${this.fileSlug(server.endpoint_name)}.conf`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
this.status = `${server.endpoint_name}: ${server.endpoint}`;
} catch (e) {
this.error = e.message;
} finally {
this.busy = false;
}
},
async copyVpnUrl(client, server) {
this.error = '';
this.status = '';
this.busy = true;
try {
await this.copyText(server.vpn_url);
this.status = `${server.endpoint_name}: {{ t.configs_vpn_url_copied }}`;
} catch (e) {
this.error = e.message;
} finally {
this.busy = false;
}
},
openQr(client, server) {
this.qrModal = {
title: `${client.name || 'amnezia-client'} · ${server.endpoint_name}`,
svg: server.qr_svg,
url: server.vpn_url,
};
},
closeQr() {
this.qrModal = null;
},
async copyText(text) {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
return;
}
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.setAttribute('readonly', '');
textarea.style.position = 'fixed';
textarea.style.left = '-9999px';
document.body.appendChild(textarea);
textarea.select();
const copied = document.execCommand('copy');
textarea.remove();
if (!copied) {
throw new Error('copy failed');
}
},
async sync() {
this.error = '';
this.status = '';
this.busy = true;
try {
const data = await this.request('/api/vpn-clients/sync', { method: 'POST' });
this.status = data.message;
await this.loadRolloutStatus();
} catch (e) {
this.error = e.message;
} finally {
this.busy = false;
}
},
async loadRolloutStatus() {
this.rolloutError = '';
this.rolloutBusy = true;
try {
this.rollout = await this.request('/api/vpn-status');
} catch (e) {
this.rolloutError = e.message;
} finally {
this.rolloutBusy = false;
}
},
clientServers(client) {
return (this.serverConfigs[client.id] && this.serverConfigs[client.id].servers) || [];
},
isLoadingServers(client) {
return Boolean(this.serverConfigs[client.id] && this.serverConfigs[client.id].loading);
},
ownerLabel(client) {
return client.owner_display_name || client.owner_username || `#${client.owner_user_id}`;
},
groupedClients() {
const groups = new Map();
for (const client of this.clients) {
const key = String(client.owner_user_id);
if (!groups.has(key)) {
groups.set(key, {
key,
label: this.ownerLabel(client),
clients: [],
});
}
groups.get(key).clients.push(client);
}
return Array.from(groups.values()).sort((left, right) => left.label.localeCompare(right.label));
},
shortKey(key) {
if (!key || key.length <= 16) return key || '';
return `${key.slice(0, 8)}...${key.slice(-8)}`;
},
fileSlug(value) {
return String(value || 'amnezia').trim().replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'amnezia';
},
rolloutStatusLabel(value) {
const labels = {
applied: '{{ t.configs_status_applied }}',
starting: '{{ t.configs_status_starting }}',
pending_restart: '{{ t.configs_status_pending_restart }}',
unknown: '{{ t.configs_status_unknown }}',
};
return labels[value] || value || '{{ t.configs_status_unknown }}';
},
podTooltip(pod) {
return [
`{{ t.configs_server }}: ${pod.endpoint_name || pod.node_name || pod.name}`,
`{{ t.configs_pod }}: ${pod.name}`,
pod.endpoint ? `Endpoint: ${pod.endpoint}` : null,
`{{ t.configs_rollout }}: ${this.rolloutStatusLabel(pod.rollout_status)}`,
`{{ t.configs_ready }}: ${pod.ready ? '{{ t.configs_yes }}' : '{{ t.configs_no }}'}`,
`{{ t.configs_phase }}: ${pod.phase || '{{ t.configs_status_unknown }}'}`,
`{{ t.configs_uptime }}: ${this.formatDuration(pod.uptime_seconds)}`,
`{{ t.configs_restarts }}: ${pod.restart_count}`,
`{{ t.configs_config_updated }}: ${this.formatTime(this.rollout && this.rollout.config_updated_at_ms)}`,
pod.message ? `Message: ${pod.message}` : null,
].filter(Boolean).join('\n');
},
formatTime(ms) {
if (!ms) return '{{ t.configs_never }}';
return new Date(ms).toLocaleString();
},
formatDuration(seconds) {
if (seconds === null || seconds === undefined) return '{{ t.configs_never }}';
let remaining = Number(seconds);
const days = Math.floor(remaining / 86400);
remaining %= 86400;
const hours = Math.floor(remaining / 3600);
remaining %= 3600;
const minutes = Math.floor(remaining / 60);
if (days > 0) return `${days}d ${hours}h`;
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
},
};
}
</script>
{% endblock body %}
+56
View File
@@ -0,0 +1,56 @@
{% extends "base.html" %}
{% block title %}{{ t.login_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; }
.login-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; }
.login-card h1 { margin-bottom: 1.5rem; font-size: 1.5rem; text-align: center; }
.login-card label { display: block; margin-bottom: .25rem; font-weight: 600; font-size: .9rem; }
.login-card input[type="text"],
.login-card input[type="password"] { width: 100%; padding: .5rem .7rem; margin-bottom: 1rem; border: 1px solid #ccc; border-radius: 4px; font-size: .95rem; }
.login-card button,
.login-card .sso-btn { display: block; width: 100%; padding: .6rem; border: none; border-radius: 4px; font-size: 1rem; cursor: pointer; text-align: center; text-decoration: none; }
.login-card button { background: #1a1a2e; color: #fff; }
.login-card button:hover { background: #16213e; }
.login-card .sso-btn { background: #4a90d9; color: #fff; margin-top: .75rem; }
.login-card .sso-btn:hover { background: #357abd; }
.login-card .divider { text-align: center; margin: 1rem 0; color: #999; font-size: .85rem; }
.login-card .message { text-align: center; color: #666; margin-bottom: 1rem; }
.login-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="login-card">
<h1>{{ t.login_heading }}</h1>
{% if !message.is_empty() %}
<div class="flash">{{ message }}</div>
{% endif %}
{% if !auth_password_enabled && !auth_sso_enabled %}
<p class="message">{{ t.login_disabled }}</p>
{% endif %}
{% if auth_password_enabled %}
<form method="post" action="/login">
<label for="username">{{ t.login_username }}</label>
<input type="text" name="username" id="username" required>
<label for="password">{{ t.login_password }}</label>
<input type="password" name="password" id="password" required>
<button type="submit">{{ t.login_submit }}</button>
</form>
{% endif %}
{% if auth_password_enabled && auth_sso_enabled %}
<div class="divider">&mdash; or &mdash;</div>
{% endif %}
{% if auth_sso_enabled %}
<a class="sso-btn" href="/auth/oidc/start">{{ oidc_button_text }}</a>
{% endif %}
</div>
{% endblock body %}