From bcce779d35f26281fc3b6c8b493fd59f296a4346 Mon Sep 17 00:00:00 2001 From: Anton Afanasyeu Date: Sun, 12 Jul 2026 13:53:14 +0200 Subject: [PATCH] feat(platform-web): global form styles, register.js, footer i18n Signed-off-by: Anton Afanasyeu --- assets/css/app.css | 48 +++++++++++++++- assets/i18n/en.json | 6 +- assets/i18n/ru.json | 6 +- assets/js/register.js | 131 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 186 insertions(+), 5 deletions(-) create mode 100644 assets/js/register.js diff --git a/assets/css/app.css b/assets/css/app.css index 00269a6..f62c8f4 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -1592,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/assets/i18n/en.json b/assets/i18n/en.json index 52384b7..c232cb4 100644 --- a/assets/i18n/en.json +++ b/assets/i18n/en.json @@ -147,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.", @@ -173,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/assets/i18n/ru.json b/assets/i18n/ru.json index df923e5..5c63794 100644 --- a/assets/i18n/ru.json +++ b/assets/i18n/ru.json @@ -156,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": "Недействительная или просроченная ссылка.", @@ -182,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/assets/js/register.js b/assets/js/register.js new file mode 100644 index 0000000..d7b7f1a --- /dev/null +++ b/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; + }); + } + }); +})();