diff --git a/sim/cluster0/lab-seeds/backend/public/api/auth_register.php b/sim/cluster0/lab-seeds/backend/public/api/auth_register.php index b8b6e6c..01e1a74 100644 --- a/sim/cluster0/lab-seeds/backend/public/api/auth_register.php +++ b/sim/cluster0/lab-seeds/backend/public/api/auth_register.php @@ -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'] ?? ''); diff --git a/sim/cluster0/lab-seeds/backend/public/api/hub_deploy_docs.php b/sim/cluster0/lab-seeds/backend/public/api/hub_deploy_docs.php index 2addb56..3e019ad 100644 --- a/sim/cluster0/lab-seeds/backend/public/api/hub_deploy_docs.php +++ b/sim/cluster0/lab-seeds/backend/public/api/hub_deploy_docs.php @@ -41,7 +41,7 @@ function hub_docs_section_html(string $section): string { switch ($section) { case 'intro-authors': - return '
Project owner: Anton Afanaasyeu. Implementation assistant: Cursor coding agent.
'; + return 'Project owner: Anton Afanasyeu. Implementation assistant: Cursor coding agent.
'; case 'intro-revision': return 'Revision scope: landing compass interaction, XHR-driven deployment docs view, Docker orchestration model integration for next branch.
'; case 'deploy-prep': diff --git a/sim/cluster0/lab-seeds/backend/public/assets/css/app.css b/sim/cluster0/lab-seeds/backend/public/assets/css/app.css index e8cda5e..f62c8f4 100644 --- a/sim/cluster0/lab-seeds/backend/public/assets/css/app.css +++ b/sim/cluster0/lab-seeds/backend/public/assets/css/app.css @@ -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; diff --git a/sim/cluster0/lab-seeds/backend/public/assets/i18n/en.json b/sim/cluster0/lab-seeds/backend/public/assets/i18n/en.json index 204b525..c232cb4 100644 --- a/sim/cluster0/lab-seeds/backend/public/assets/i18n/en.json +++ b/sim/cluster0/lab-seeds/backend/public/assets/i18n/en.json @@ -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", diff --git a/sim/cluster0/lab-seeds/backend/public/assets/i18n/ru.json b/sim/cluster0/lab-seeds/backend/public/assets/i18n/ru.json index b72d81c..5c63794 100644 --- a/sim/cluster0/lab-seeds/backend/public/assets/i18n/ru.json +++ b/sim/cluster0/lab-seeds/backend/public/assets/i18n/ru.json @@ -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": "Исполнители", diff --git a/sim/cluster0/lab-seeds/backend/public/assets/js/register.js b/sim/cluster0/lab-seeds/backend/public/assets/js/register.js new file mode 100644 index 0000000..d7b7f1a --- /dev/null +++ b/sim/cluster0/lab-seeds/backend/public/assets/js/register.js @@ -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; + }); + } + }); +})(); diff --git a/sim/cluster0/lab-seeds/backend/public/index.php b/sim/cluster0/lab-seeds/backend/public/index.php index 5fcde15..8a045bb 100644 --- a/sim/cluster0/lab-seeds/backend/public/index.php +++ b/sim/cluster0/lab-seeds/backend/public/index.php @@ -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'; diff --git a/sim/cluster0/lab-seeds/backend/src/AuthMailer.php b/sim/cluster0/lab-seeds/backend/src/AuthMailer.php index 8fb5e5b..9846728 100644 --- a/sim/cluster0/lab-seeds/backend/src/AuthMailer.php +++ b/sim/cluster0/lab-seeds/backend/src/AuthMailer.php @@ -28,7 +28,7 @@ final class AuthMailer { return self::sendSmtp($to, $subject, $bodyText, $from, $replyTo, $attachments, $bodyHtml, $inlineImages); } - public static function sendVerifyEmail(string $to, string $verifyUrl): bool { + public static function sendVerifyEmail(string $to, string $verifyUrl, string $validFor = '24h'): bool { $subject = 'Verify your Android Cast account'; $shortUrl = $verifyUrl; $qrImageUrl = null; @@ -49,7 +49,7 @@ final class AuthMailer { $qrImageUrl = null; } } - $body = "Open this link to verify your email (valid 24h):\n\n" + $body = "Open this link to verify your email (valid {$validFor}):\n\n" . $shortUrl . "\n"; $bodyHtml = null; $inline = []; @@ -70,7 +70,7 @@ final class AuthMailer { $cid = 'verify-qr-' . bin2hex(random_bytes(4)) . '@androidcast'; $escUrl = htmlspecialchars($shortUrl, ENT_QUOTES | ENT_HTML5, 'UTF-8'); $bodyHtml = '' - . 'Open this link to verify your email (valid 24h):
' + . 'Open this link to verify your email (valid ' . htmlspecialchars($validFor, ENT_QUOTES | ENT_HTML5, 'UTF-8') . '):
' . '' . 'Or scan this QR code on your phone:
' . 'Loading…
+
+ Lab-only restream control plane. Requires streaming_v2 on BE, ffmpeg, yt-dlp.
+ Visible when media_pipelines.enabled and global admin / privilege grant.
+
| Name | +Status | +Mode | +Source | +HLS | +Actions | +
|---|