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') . '):

' . '

' . $escUrl . '

' . '

Or scan this QR code on your phone:

' . '

Verify email QR code

' diff --git a/sim/cluster0/lab-seeds/backend/src/AuthRegistration.php b/sim/cluster0/lab-seeds/backend/src/AuthRegistration.php index af9ade4..259e67c 100644 --- a/sim/cluster0/lab-seeds/backend/src/AuthRegistration.php +++ b/sim/cluster0/lab-seeds/backend/src/AuthRegistration.php @@ -3,8 +3,6 @@ declare(strict_types=1); /** Email self-registration (verify link). TOTP enrollment follows login — task 3.x. */ final class AuthRegistration { - private const TOKEN_TTL_S = 86400; - private function __construct() { } @@ -12,7 +10,12 @@ final class AuthRegistration { AuthEmailSchema::ensure(Database::pdo()); } - /** @return array{ok:bool,error?:string} */ + public static function verifyEmailTtlSeconds(): int { + $ttl = (int) cfg('auth.verify_email_ttl', 86400); + return max(60, min($ttl, 604800)); + } + + /** @return array{ok:bool,error?:string,expires_at?:string,ttl_seconds?:int,email?:string} */ public static function register(string $email, string $password, string $username = ''): array { self::ensureSchema(); if (AuthAttempts::isRateLimited($username !== '' ? $username : $email)) { @@ -32,9 +35,14 @@ final class AuthRegistration { $username = strstr($emailNorm, '@', true) ?: $emailNorm; } $pdo = Database::pdo(); - $st = $pdo->prepare('SELECT id FROM users WHERE email_normalized = ? OR username = ? LIMIT 1'); + self::purgeStalePending($pdo); + $st = $pdo->prepare('SELECT id, status FROM users WHERE email_normalized = ? OR username = ? LIMIT 1'); $st->execute([$emailNorm, $username]); - if ($st->fetchColumn()) { + $existing = $st->fetch(PDO::FETCH_ASSOC); + if ($existing) { + if (($existing['status'] ?? '') === 'pending') { + return self::resendVerification($emailNorm); + } AuthAttempts::record('register_fail', $username); return ['ok' => false, 'error' => 'already_registered']; } @@ -49,21 +57,117 @@ final class AuthRegistration { 'pending', ]); $userId = (int) $pdo->lastInsertId(); + $sent = self::issueVerifyToken($pdo, $userId, $emailNorm); + if (!$sent['ok']) { + return $sent; + } + AuthAttempts::record('register_ok', $username); + return [ + 'ok' => true, + 'expires_at' => $sent['expires_at'], + 'ttl_seconds' => self::verifyEmailTtlSeconds(), + 'email' => $emailNorm, + ]; + } + + /** @return array{ok:bool,error?:string,expires_at?:string,ttl_seconds?:int,email?:string} */ + public static function resendVerification(string $email): array { + self::ensureSchema(); + $emailNorm = self::normalizeEmail($email); + if ($emailNorm === '' || !filter_var($emailNorm, FILTER_VALIDATE_EMAIL)) { + return ['ok' => false, 'error' => 'invalid_email']; + } + if (AuthAttempts::isRateLimited('resend:' . $emailNorm)) { + return ['ok' => false, 'error' => 'rate_limited']; + } + $pdo = Database::pdo(); + self::purgeStalePending($pdo); + $st = $pdo->prepare( + "SELECT id FROM users WHERE email_normalized = ? AND status = 'pending' AND email_verified_at IS NULL LIMIT 1" + ); + $st->execute([$emailNorm]); + $userId = (int) $st->fetchColumn(); + if ($userId <= 0) { + return ['ok' => false, 'error' => 'not_pending']; + } + $pdo->prepare( + "UPDATE auth_tokens SET used_at = ? WHERE user_id = ? AND purpose = 'verify_email' AND used_at IS NULL" + )->execute([date('Y-m-d H:i:s'), $userId]); + $sent = self::issueVerifyToken($pdo, $userId, $emailNorm); + if (!$sent['ok']) { + return $sent; + } + AuthAttempts::record('register_resend', $emailNorm); + return [ + 'ok' => true, + 'expires_at' => $sent['expires_at'], + 'ttl_seconds' => self::verifyEmailTtlSeconds(), + 'email' => $emailNorm, + ]; + } + + /** + * Pending verification window for countdown UI. + * + * @return array{ok:bool,error?:string,expires_at?:string,ttl_seconds?:int,email?:string}|null + */ + public static function pendingVerification(string $email): ?array { + self::ensureSchema(); + $emailNorm = self::normalizeEmail($email); + if ($emailNorm === '') { + return null; + } + $pdo = Database::pdo(); + $st = $pdo->prepare( + "SELECT u.id FROM users u + WHERE u.email_normalized = ? AND u.status = 'pending' AND u.email_verified_at IS NULL + LIMIT 1" + ); + $st->execute([$emailNorm]); + $userId = (int) $st->fetchColumn(); + if ($userId <= 0) { + return null; + } + $tok = $pdo->prepare( + "SELECT expires_at FROM auth_tokens + WHERE user_id = ? AND purpose = 'verify_email' AND used_at IS NULL + ORDER BY id DESC LIMIT 1" + ); + $tok->execute([$userId]); + $expiresAt = (string) ($tok->fetchColumn() ?: ''); + if ($expiresAt === '') { + return null; + } + $exp = strtotime($expiresAt); + if ($exp !== false && $exp < time()) { + return ['ok' => false, 'error' => 'expired_token', 'email' => $emailNorm]; + } + return [ + 'ok' => true, + 'expires_at' => $expiresAt, + 'ttl_seconds' => self::verifyEmailTtlSeconds(), + 'email' => $emailNorm, + ]; + } + + /** @return array{ok:bool,error?:string,expires_at?:string} */ + private static function issueVerifyToken(\PDO $pdo, int $userId, string $emailNorm): array { $token = bin2hex(random_bytes(32)); $tokenHash = hash('sha256', $token); - $expires = date('Y-m-d H:i:s', time() + self::TOKEN_TTL_S); + $ttl = self::verifyEmailTtlSeconds(); + $expires = date('Y-m-d H:i:s', time() + $ttl); $pdo->prepare( 'INSERT INTO auth_tokens (user_id, purpose, token_hash, email_normalized, expires_at, created_at) VALUES (?, ?, ?, ?, ?, ?)' )->execute([$userId, 'verify_email', $tokenHash, $emailNorm, $expires, date('Y-m-d H:i:s')]); - $base = rtrim((string) cfg('base_path', ''), '/'); $origin = rtrim((string) cfg('public_origin', 'https://apps.f0xx.org'), '/'); - $verifyUrl = $origin . $base . '/verify-email?token=' . urlencode($token); - if (!AuthMailer::sendVerifyEmail($emailNorm, $verifyUrl)) { + $verifyUrl = $origin . Auth::authUrl('/verify-email') . '?token=' . urlencode($token); + $ttlLabel = self::formatTtlLabel($ttl); + if (!AuthMailer::sendVerifyEmail($emailNorm, $verifyUrl, $ttlLabel)) { error_log('AuthRegistration: verify mail failed for ' . $emailNorm); + return ['ok' => false, 'error' => 'mail_failed']; } - AuthAttempts::record('register_ok', $username); - return ['ok' => true]; + return ['ok' => true, 'expires_at' => $expires]; } /** @return array{ok:bool,error?:string} */ @@ -96,7 +200,39 @@ final class AuthRegistration { return ['ok' => true]; } + private static function formatTtlLabel(int $ttlSeconds): string { + if ($ttlSeconds >= 86400 && $ttlSeconds % 86400 === 0) { + $days = (int) ($ttlSeconds / 86400); + return $days === 1 ? '24h' : $days . ' days'; + } + if ($ttlSeconds >= 3600 && $ttlSeconds % 3600 === 0) { + $hours = (int) ($ttlSeconds / 3600); + return $hours === 1 ? '1 hour' : $hours . ' hours'; + } + $mins = max(1, (int) ceil($ttlSeconds / 60)); + return $mins === 1 ? '1 minute' : $mins . ' minutes'; + } + private static function normalizeEmail(string $email): string { return strtolower(trim($email)); } + + /** Drop unverified accounts started more than verify TTL ago. */ + private static function purgeStalePending(\PDO $pdo): void { + $cutoff = date('Y-m-d H:i:s', time() - self::verifyEmailTtlSeconds()); + $st = $pdo->prepare( + "SELECT id FROM users WHERE status = 'pending' AND email_verified_at IS NULL + AND (created_at IS NULL OR created_at < ?)" + ); + $st->execute([$cutoff]); + $ids = $st->fetchAll(\PDO::FETCH_COLUMN); + foreach ($ids as $id) { + $uid = (int) $id; + if ($uid <= 0) { + continue; + } + $pdo->prepare('DELETE FROM auth_tokens WHERE user_id = ?')->execute([$uid]); + $pdo->prepare('DELETE FROM users WHERE id = ? AND status = ?')->execute([$uid, 'pending']); + } + } } diff --git a/sim/cluster0/lab-seeds/backend/views/layout.php b/sim/cluster0/lab-seeds/backend/views/layout.php index c919371..7f723b4 100644 --- a/sim/cluster0/lab-seeds/backend/views/layout.php +++ b/sim/cluster0/lab-seeds/backend/views/layout.php @@ -11,6 +11,12 @@ * Digest: SHA256 21e56cd12df6a94df41a1c0d67be530bd01454865ee1f032cef5f8d2e497509a */ ?> + @@ -25,8 +31,6 @@ if (l === 'en' || l === 'ru') document.documentElement.setAttribute('lang', l); })(); - - @@ -51,6 +55,19 @@ + + + + + + + + - > + + >
- + $__navProjectBase, + 'issues_base' => $__navIssuesBase, + 'active' => $__navActive, + 'username' => $__navUser, + ]); ?>
@@ -607,6 +452,91 @@
+ +
+
+

Media pipelines (experimental)

+
+ +
+
+ +

Loading…

+

+ Lab-only restream control plane. Requires streaming_v2 on BE, ffmpeg, yt-dlp. + Visible when media_pipelines.enabled and global admin / privilege grant. +

+ +
+

New pipeline

+
+ + + + + + + + +
+
+ +
+

Pipelines

+
+ + + + + + + + + + + + +
NameStatusModeSourceHLSActions
+
+
+ + + + + + +