ligbox-ops-platform/activate.html
Ligbox Spec Hub 3a2c64834b Initial import: ligbox-ops-platform + specs + LAPTOP + obsidian merge (CT130)
Source: VM122 /opt + obsidian-infra + LAPTOP
Hub: CT130 spec-hub 10.10.10.130
2026-06-19 17:26:41 +00:00

292 lines
12 KiB
HTML

<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Ativar conta — Ligbox Ops</title>
<link rel="stylesheet" href="/assets/styles.css"/>
</head>
<body class="login-page activate-page">
<div class="activate-card">
<header class="activate-header">
<div>
<h1>Ativar conta</h1>
<p class="activate-sub">Complete <strong>2 de 3</strong> fatores — escolha os que preferir</p>
</div>
<p id="activate-info" class="activate-account">Carregando…</p>
</header>
<div id="factor-progress" class="factor-progress" hidden>
<div class="factor-progress-bar"><span id="factor-progress-fill"></span></div>
<span id="factor-progress-text">0/2 fatores</span>
</div>
<form id="activate-form" class="activate-form" hidden>
<div class="factor-grid">
<section class="factor-tile" id="factor-email" data-factor="email">
<div class="factor-tile-head">
<span class="factor-num">1</span>
<div>
<strong>E-mail</strong>
<p class="factor-desc">Código de 6 dígitos</p>
</div>
<span class="factor-check" id="chk-email" hidden aria-label="validado"></span>
</div>
<button type="button" class="btn btn-ghost btn-sm btn-block" id="btn-email-otp">Enviar código</button>
<label class="factor-label">
Código
<input type="text" name="email_otp" inputmode="numeric" maxlength="6" placeholder="000000" autocomplete="one-time-code"/>
</label>
</section>
<section class="factor-tile" id="factor-phone" data-factor="phone">
<div class="factor-tile-head">
<span class="factor-num">2</span>
<div>
<strong>Telefone</strong>
<p class="factor-desc">Por e-mail (SMS em breve)</p>
</div>
<span class="factor-check" id="chk-phone" hidden aria-label="validado"></span>
</div>
<label class="factor-label">
Número
<input type="tel" id="act-phone" name="phone" placeholder="+5511999999999"/>
</label>
<button type="button" class="btn btn-ghost btn-sm btn-block" id="btn-phone-otp">Enviar código</button>
<label class="factor-label">
Código
<input type="text" name="phone_otp" inputmode="numeric" maxlength="6" placeholder="000000"/>
</label>
</section>
<section class="factor-tile factor-tile-totp" id="factor-totp" data-factor="totp">
<div class="factor-tile-head">
<span class="factor-num">3</span>
<div>
<strong>App autenticador</strong>
<p class="factor-desc">Google Authenticator / Authy</p>
</div>
<span class="factor-check" id="chk-totp" hidden aria-label="validado"></span>
</div>
<div class="qr-panel">
<p class="qr-label">Escaneie o QR code</p>
<div id="qr-wrap" class="qr-wrap">
<p id="qr-loading" class="qr-placeholder">Gerando QR…</p>
</div>
<p id="qr-error" class="qr-error" hidden>Não foi possível gerar o QR. Recarregue a página.</p>
</div>
<div id="ntfy-box" class="ntfy-box" hidden>
<p class="ntfy-title">Push opcional (ntfy)</p>
<a id="ntfy-link" class="ntfy-link" href="#" target="_blank" rel="noopener">Inscrever no tópico</a>
</div>
<label class="factor-label">
Código do app
<input type="text" name="totp_code" inputmode="numeric" maxlength="6" placeholder="000000"/>
</label>
</section>
</div>
<p id="activate-error" class="login-error activate-feedback" hidden></p>
<p id="activate-success" class="login-notice activate-feedback" hidden></p>
<button type="submit" class="btn btn-primary btn-block activate-submit">Ativar conta</button>
</form>
<p class="login-hint activate-footer">
<a href="/login.html">← Voltar ao login</a>
</p>
</div>
<script src="/assets/qrcode.min.js?v=20260610k"></script>
<script src="/assets/auth.js?v=20260610k"></script>
<script>
const params = new URLSearchParams(window.location.search);
const token = params.get('token');
const info = document.getElementById('activate-info');
const progress = document.getElementById('factor-progress');
const progressFill = document.getElementById('factor-progress-fill');
const progressText = document.getElementById('factor-progress-text');
const form = document.getElementById('activate-form');
const err = document.getElementById('activate-error');
const ok = document.getElementById('activate-success');
function setTileVerified(name, on) {
const tile = document.querySelector(`[data-factor="${name}"]`);
tile?.classList.toggle('factor-tile-done', on);
const chk = document.getElementById(`chk-${name}`);
if (chk) chk.hidden = !on;
}
function updateProgress(factors) {
if (!factors) return;
const n = factors.verified_count || 0;
const req = factors.required || 2;
const pct = Math.min(100, Math.round((n / req) * 100));
progressFill.style.width = `${pct}%`;
progressText.textContent = n >= req
? `${n}/${req} fatores — pronto para ativar`
: `${n}/${req} fatores — faltam ${req - n}`;
progress.hidden = false;
setTileVerified('email', !!factors.email);
setTileVerified('phone', !!factors.phone);
setTileVerified('totp', !!factors.totp);
}
function renderQr(uri) {
const wrap = document.getElementById('qr-wrap');
const loading = document.getElementById('qr-loading');
const qrErr = document.getElementById('qr-error');
if (!uri) {
loading.textContent = 'QR indisponível';
return;
}
if (typeof QRCode === 'undefined') {
loading.hidden = true;
qrErr.textContent = 'Biblioteca QR não carregou. Recarregue a página (Ctrl+Shift+R).';
qrErr.hidden = false;
return;
}
loading.textContent = 'Gerando QR…';
QRCode.toCanvas(uri, { width: 140, margin: 1 }, (e, canvas) => {
loading.hidden = true;
if (e || !canvas) {
qrErr.textContent = 'Erro ao gerar QR. Recarregue a página.';
qrErr.hidden = false;
console.error('QRCode:', e);
return;
}
wrap.innerHTML = '';
wrap.appendChild(canvas);
});
}
function setupNtfy(topic, subscribeUrl) {
if (!topic) return;
const box = document.getElementById('ntfy-box');
const link = document.getElementById('ntfy-link');
const url = subscribeUrl || `https://ntfy.sh/${topic}`;
link.href = url;
link.textContent = topic;
box.hidden = false;
}
async function refreshStatus() {
const r = await fetchWithTimeout(`/api/v1/auth/activate?token=${encodeURIComponent(token)}`);
const d = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(d.detail || 'Token inválido');
updateProgress(d.factors);
return d;
}
if (!token) {
info.textContent = 'Link inválido — falta token de ativação.';
} else {
refreshStatus()
.then((d) => {
info.innerHTML = `<strong>${d.email}</strong> · ${d.role || 'técnico'}`;
form.hidden = false;
if (d.otpauth_uri) renderQr(d.otpauth_uri);
setupNtfy(d.ntfy_topic, d.ntfy_subscribe_url);
})
.catch((e) => { info.textContent = e.message; });
}
document.getElementById('btn-email-otp')?.addEventListener('click', async () => {
err.hidden = true;
try {
const r = await fetchWithTimeout(`/api/v1/auth/activate/send-email-otp?token=${encodeURIComponent(token)}`, { method: 'POST' });
const d = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(d.detail || `Falha ao enviar (${r.status})`);
ok.textContent = d.message;
ok.hidden = false;
} catch (e) {
err.textContent = e.message;
err.hidden = false;
}
});
document.getElementById('btn-phone-otp')?.addEventListener('click', async () => {
err.hidden = true;
ok.hidden = true;
const phone = document.getElementById('act-phone')?.value?.trim();
if (!phone) {
err.textContent = 'Informe o telefone primeiro';
err.hidden = false;
return;
}
try {
const r = await fetchWithTimeout('/api/v1/auth/activate/send-phone-otp', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token, phone }),
});
const d = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(d.detail || `Falha ao enviar (${r.status})`);
ok.textContent = d.message;
ok.hidden = false;
} catch (e) {
err.textContent = e.message;
err.hidden = false;
}
});
form?.addEventListener('submit', async (e) => {
e.preventDefault();
err.hidden = true;
ok.hidden = true;
const btn = form.querySelector('button[type="submit"]');
const body = { token };
const emailOtp = form.email_otp.value.trim();
const phoneOtp = form.phone_otp.value.trim();
const totpCode = form.totp_code.value.trim();
if (emailOtp) body.email_otp = emailOtp;
if (phoneOtp) body.phone_otp = phoneOtp;
if (totpCode) body.totp_code = totpCode;
const filled = [emailOtp, phoneOtp, totpCode].filter(Boolean).length;
if (filled < 2) {
err.textContent = 'Preencha códigos de pelo menos 2 fatores diferentes';
err.hidden = false;
return;
}
btn.disabled = true;
try {
const r = await fetchWithTimeout('/api/v1/auth/activate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const d = await r.json().catch(() => ({}));
if (!r.ok) {
await refreshStatus().catch(() => {});
throw new Error(d.detail || 'Ativação falhou');
}
if (d.backup_codes?.length) {
form.hidden = true;
progress.hidden = true;
const panel = document.createElement('div');
panel.className = 'backup-codes-panel';
panel.innerHTML = `
<p class="login-hint" style="text-align:left">
<strong>Guarde estes códigos de backup</strong><br/>
Use no login se perder o autenticador. Cada código é de uso único. Também enviamos por e-mail.
</p>
<ul class="backup-codes-list">${d.backup_codes.map((c) => `<li><code>${c}</code></li>`).join('')}</ul>
<button type="button" class="btn btn-primary btn-block" id="activate-go-login">Ir para o login</button>`;
document.querySelector('.activate-card')?.appendChild(panel);
panel.querySelector('#activate-go-login')?.addEventListener('click', () => {
window.location.href = '/login.html';
});
return;
}
ok.textContent = (d.message || 'Conta ativa') + ' Redirecionando…';
ok.hidden = false;
setTimeout(() => { window.location.href = '/login.html'; }, 2000);
} catch (ex) {
err.textContent = ex.message;
err.hidden = false;
} finally {
btn.disabled = false;
}
});
</script>
</body>
</html>