Specs stay at repo root (cross-VM). Move deploy and code into logical projects with README per domain, updated manifest.yaml, and symlinks at legacy paths for VM122 backward compatibility.
292 lines
12 KiB
HTML
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>
|