1
0
mirror of git://f0xx.org/ac/ac-deploy synced 2026-07-29 07:17:38 +03:00

feat(deploy): lab-seeds sync, msmtp nginx-readable secret, hub downloads

Signed-off-by: Anton Afanasyeu <a.afanasieff@gmail.com>
This commit is contained in:
Anton Afanasyeu
2026-07-12 13:53:38 +02:00
parent 624c9d3d7e
commit 465efa8d98
16 changed files with 691 additions and 241 deletions

View File

@@ -19,6 +19,19 @@ if ($action === 'verify') {
json_out($result, $result['ok'] ? 200 : 400);
}
if ($action === 'resend') {
$result = AuthRegistration::resendVerification((string) ($data['email'] ?? ''));
json_out($result, $result['ok'] ? 200 : 400);
}
if ($action === 'pending') {
$pending = AuthRegistration::pendingVerification((string) ($data['email'] ?? ''));
if ($pending === null) {
json_out(['ok' => false, 'error' => 'not_pending'], 404);
}
json_out($pending, ($pending['ok'] ?? false) ? 200 : 410);
}
$email = (string) ($data['email'] ?? '');
$password = (string) ($data['password'] ?? '');
$username = (string) ($data['username'] ?? '');

View File

@@ -41,7 +41,7 @@ function hub_docs_section_html(string $section): string
{
switch ($section) {
case 'intro-authors':
return '<p>Project owner: Anton Afanaasyeu. Implementation assistant: Cursor coding agent.</p>';
return '<p>Project owner: Anton Afanasyeu. Implementation assistant: Cursor coding agent.</p>';
case 'intro-revision':
return '<p>Revision scope: landing compass interaction, XHR-driven deployment docs view, Docker orchestration model integration for next branch.</p>';
case 'deploy-prep':

View File

@@ -375,6 +375,37 @@ a:hover { text-decoration: underline; }
left: 10px;
height: 12px;
}
.nav-icon--monitor {
width: 16px;
height: 16px;
border: 2px solid currentColor;
border-radius: 50%;
}
.nav-icon--monitor::before {
content: "";
position: absolute;
left: 50%;
top: 50%;
width: 2px;
height: 5px;
margin-left: -1px;
margin-top: -5px;
background: currentColor;
transform-origin: bottom center;
transform: rotate(-45deg);
border-radius: 1px;
}
.nav-icon--monitor::after {
content: "";
position: absolute;
left: 50%;
bottom: 2px;
width: 3px;
height: 3px;
margin-left: -1.5px;
background: currentColor;
border-radius: 50%;
}
.ticket-col-issue { min-width: 200px; max-width: 420px; }
.ticket-issue-title { font-weight: 600; }
.ticket-issue-brief { font-size: 0.88em; margin-top: 4px; }
@@ -1561,20 +1592,62 @@ button.report-tag--filter:hover {
font-size: 12px;
color: var(--muted);
}
.tag-field input[type="text"] {
padding: 8px 10px;
border-radius: 6px;
.tag-field input[type="text"],
.tag-field input[type="url"],
.tag-field input[type="email"],
.tag-field input[type="number"],
.tag-field input[type="password"],
.tag-field input[type="search"],
.tag-field select,
.tag-field textarea {
padding: 10px 12px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--bg);
color: var(--text);
font-size: 14px;
min-width: 10rem;
}
.tag-field input:focus,
.tag-field select:focus,
.tag-field textarea:focus {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.tag-field select {
cursor: pointer;
appearance: auto;
}
.tag-editor-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
}
.data-table select,
#rbac-users-table select {
padding: 8px 10px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--bg);
color: var(--text);
font-size: 14px;
min-width: 8rem;
cursor: pointer;
}
.data-table select:focus,
#rbac-users-table select:focus {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.register-countdown-wrap {
margin: 1rem 0;
font-size: 14px;
}
.register-countdown-wrap strong {
font-variant-numeric: tabular-nums;
margin-left: 0.35rem;
}
.tag-editor-status--err { color: var(--danger); }
.tag-preset-bar {
display: flex;

View File

@@ -16,6 +16,8 @@
"nav.access_desc": "Roles, privilege sets, and operators.",
"nav.builder": "Builder",
"nav.builder_desc": "Docker CI for APK baking and OTA artifacts.",
"nav.monitor": "Monitoring",
"nav.monitor_desc": "Grafana dashboards — cluster health, alerts, exporters.",
"theme.label": "Theme",
"theme.dark": "Dark",
"theme.light": "Light",
@@ -135,8 +137,7 @@
"login.username": "Username",
"login.password": "Password",
"login.submit": "Sign in",
"login.hint_default": "Default: admin / admin",
"login.register": "Register",
"login.register": "Create account",
"login.register_soon": "(coming soon)",
"login.error": "Invalid credentials",
"register.title": "Create account",
@@ -146,6 +147,10 @@
"register.password": "Password",
"register.submit": "Register",
"register.back_login": "Back to sign in",
"register.pending_title": "Check your email",
"register.pending_hint": "We sent a verification link. Confirm your email before signing in.",
"register.countdown_label": "Link expires in",
"register.resend": "Resend verification email",
"verify.title": "Email verification",
"verify.ok": "Your email is verified. You can sign in and enroll two-factor authentication.",
"verify.fail": "Invalid or expired link.",
@@ -153,6 +158,11 @@
"verify.register_again": "Register again",
"twofa.title": "Two-factor authentication",
"twofa.hint": "Enter the 6-digit code from your authenticator app.",
"twofa.hint_scan": "Scan the QR code with your phone camera (not Google Authenticator) to approve this sign-in, or enter the 6-digit code from your authenticator app below.",
"twofa.qr_caption": "Phone camera → tap link → Approve",
"twofa.waiting_approval": "Waiting for mobile approval…",
"twofa.approved": "Approved — signing you in…",
"twofa.qr_expired": "QR code expired. Reload to get a new one.",
"twofa.code": "Authentication code",
"twofa.submit": "Continue",
"twofa.cancel": "Cancel",
@@ -167,7 +177,7 @@
"security.remove_totp": "Remove authenticator",
"security.back_console": "Back to console",
"nav.security": "Security",
"footer.copyright": "© Anton Afanaasyeu, {year}",
"footer.copyright": "{holder}, {year}",
"tickets.title": "Tickets",
"issues.new": "New issue",
"tickets.new": "New ticket",

View File

@@ -26,6 +26,8 @@
"nav.access_desc": "Роли, наборы привилегий и операторы.",
"nav.builder": "Сборщик",
"nav.builder_desc": "Docker CI для сборки APK и OTA.",
"nav.monitor": "Мониторинг",
"nav.monitor_desc": "Дашборды Grafana — состояние кластера, алерты, экспортёры.",
"theme.label": "Тема",
"theme.dark": "Тёмная",
"theme.light": "Светлая",
@@ -144,8 +146,7 @@
"login.username": "Имя пользователя",
"login.password": "Пароль",
"login.submit": "Войти",
"login.hint_default": "По умолчанию: admin / admin",
"login.register": "Регистрация",
"login.register": "Создать аккаунт",
"login.register_soon": "(скоро)",
"login.error": "Неверные учётные данные",
"register.title": "Создать аккаунт",
@@ -155,6 +156,10 @@
"register.password": "Пароль",
"register.submit": "Зарегистрироваться",
"register.back_login": "Назад ко входу",
"register.pending_title": "Проверьте почту",
"register.pending_hint": "Мы отправили ссылку для подтверждения. Подтвердите email перед входом.",
"register.countdown_label": "Ссылка действует",
"register.resend": "Отправить письмо повторно",
"verify.title": "Подтверждение email",
"verify.ok": "Email подтверждён. Войдите и настройте двухфакторную аутентификацию.",
"verify.fail": "Недействительная или просроченная ссылка.",
@@ -162,6 +167,11 @@
"verify.register_again": "Зарегистрироваться снова",
"twofa.title": "Двухфакторная аутентификация",
"twofa.hint": "Введите 6-значный код из приложения-аутентификатора.",
"twofa.hint_scan": "Отсканируйте QR камерой телефона (не Google Authenticator), чтобы подтвердить вход, или введите 6-значный код из приложения-аутентификатора ниже.",
"twofa.qr_caption": "Камера телефона → ссылка → Подтвердить",
"twofa.waiting_approval": "Ожидание подтверждения с телефона…",
"twofa.approved": "Подтверждено — выполняется вход…",
"twofa.qr_expired": "QR-код истёк. Обновите страницу.",
"twofa.code": "Код аутентификации",
"twofa.submit": "Продолжить",
"twofa.cancel": "Отмена",
@@ -176,7 +186,7 @@
"security.remove_totp": "Отключить аутентификатор",
"security.back_console": "Назад в консоль",
"nav.security": "Безопасность",
"footer.copyright": "© Anton Afanaasyeu, {year}",
"footer.copyright": "{holder}, {year}",
"nav.tickets": "Тикеты",
"ticket.lifecycle": "Жизненный цикл",
"ticket.assignees": "Исполнители",

View File

@@ -0,0 +1,131 @@
(function () {
function authBase() {
return document.body.getAttribute('data-auth-base') || '';
}
function apiBase() {
var bp = document.body.getAttribute('data-base-path') || '';
return bp.replace(/\/issues\/?$/, '') + '/api/auth_register.php';
}
function formatCountdown(totalSeconds) {
var s = Math.max(0, totalSeconds);
var m = Math.floor(s / 60);
var r = s % 60;
return m + ':' + String(r).padStart(2, '0');
}
function parseExpiresMs(iso) {
if (!iso) return 0;
var t = Date.parse(iso.replace(' ', 'T') + 'Z');
if (Number.isNaN(t)) {
t = Date.parse(iso);
}
return t;
}
function initCountdown(expiresTs, countdownEl, onExpired) {
var expMs = expiresTs > 0 ? expiresTs * 1000 : 0;
if (!expMs) {
countdownEl.textContent = '—';
return null;
}
function tick() {
var left = Math.ceil((expMs - Date.now()) / 1000);
if (left <= 0) {
countdownEl.textContent = '0:00';
if (onExpired) onExpired();
return;
}
countdownEl.textContent = formatCountdown(left);
}
tick();
return window.setInterval(tick, 1000);
}
async function postJson(body) {
var res = await fetch(apiBase(), {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
var data = await res.json().catch(function () { return {}; });
return { ok: res.ok && data.ok !== false, status: res.status, data: data };
}
document.addEventListener('DOMContentLoaded', function () {
var pending = document.getElementById('register-pending');
var form = document.getElementById('register-form');
var countdownEl = document.getElementById('register-countdown');
var resendBtn = document.getElementById('register-resend-btn');
var resendStatus = document.getElementById('register-resend-status');
var emailEl = document.getElementById('register-pending-email');
if (!pending || !countdownEl) return;
var email = document.body.getAttribute('data-register-email') || '';
var expiresTs = parseInt(document.body.getAttribute('data-register-expires-ts') || '0', 10);
var timerId = null;
function showPending(nextEmail, nextExpiresTs) {
if (form) form.hidden = true;
pending.hidden = false;
email = nextEmail || email;
expiresTs = nextExpiresTs || expiresTs;
if (emailEl && email) emailEl.textContent = email;
if (timerId) window.clearInterval(timerId);
timerId = initCountdown(expiresTs, countdownEl, function () {
if (resendStatus) {
resendStatus.textContent = 'Verification link expired — use Resend to get a new one.';
resendStatus.classList.add('error');
}
if (resendBtn) resendBtn.disabled = false;
});
}
if (document.body.getAttribute('data-register-pending') === '1') {
showPending(email, expiresTs);
}
if (resendBtn) {
resendBtn.addEventListener('click', async function () {
if (!email) {
var input = form && form.querySelector('input[name="email"]');
email = input ? input.value.trim() : '';
}
if (!email) {
if (resendStatus) resendStatus.textContent = 'Enter your email on the registration form first.';
return;
}
resendBtn.disabled = true;
if (resendStatus) {
resendStatus.textContent = 'Sending…';
resendStatus.classList.remove('error');
}
var out = await postJson({ action: 'resend', email: email });
if (out.ok && out.data.expires_at) {
var ts = out.data.expires_at ? Math.floor(Date.parse(String(out.data.expires_at).replace(' ', 'T')) / 1000) : 0;
if (!ts && out.data.ttl_seconds) {
ts = Math.floor(Date.now() / 1000) + Number(out.data.ttl_seconds);
}
showPending(email, ts);
if (resendStatus) resendStatus.textContent = 'Verification email sent.';
resendBtn.disabled = false;
return;
}
var err = (out.data && out.data.error) || 'resend_failed';
if (resendStatus) {
resendStatus.textContent = err === 'rate_limited'
? 'Too many attempts — wait a few minutes.'
: err === 'not_pending'
? 'No pending registration for this email.'
: err === 'mail_failed'
? 'Could not send email — contact support.'
: 'Could not resend verification email.';
resendStatus.classList.add('error');
}
resendBtn.disabled = false;
});
}
});
})();

View File

@@ -209,6 +209,8 @@ if ($route === '/register' && $_SERVER['REQUEST_METHOD'] === 'POST') {
$out = AuthRegistration::register($email, $password, $username);
if ($out['ok']) {
$registerSuccess = 'Check your email for a verification link.';
$registerPending = true;
$registerExpiresAt = (string) ($out['expires_at'] ?? '');
require __DIR__ . '/../views/register.php';
exit;
}
@@ -217,6 +219,7 @@ if ($route === '/register' && $_SERVER['REQUEST_METHOD'] === 'POST') {
'weak_password' => 'Password must be at least 10 characters.',
'already_registered' => 'An account with this email or username already exists.',
'rate_limited' => 'Too many attempts — try again later.',
'mail_failed' => 'Could not send verification email — try again or contact support.',
default => 'Registration failed.',
};
require __DIR__ . '/../views/register.php';