Files

953 lines
47 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
$pageTitle = 'Dashboard — TOTPVault';
require __DIR__ . '/layout.php';
$csrfToken = csrf_token();
$avatar = $user['avatar_url'] ? htmlspecialchars($user['avatar_url']) : null;
$initial = strtoupper(substr($user['name'] ?? $user['email'], 0, 1));
$FA_BRAND_ICONS = [
'fa-github','fa-google','fa-microsoft','fa-apple','fa-amazon','fa-facebook',
'fa-twitter','fa-instagram','fa-linkedin','fa-slack','fa-discord','fa-telegram',
'fa-whatsapp','fa-dropbox','fa-spotify','fa-twitch','fa-youtube','fa-reddit',
'fa-gitlab','fa-bitbucket','fa-docker','fa-aws','fa-cloudflare','fa-digital-ocean',
'fa-stripe','fa-paypal','fa-shopify','fa-wordpress','fa-jenkins','fa-jira',
'fa-confluence','fa-trello','fa-npm','fa-node','fa-react','fa-vuejs','fa-angular',
'fa-laravel','fa-php','fa-python','fa-java','fa-swift','fa-android','fa-windows',
'fa-linux','fa-ubuntu','fa-firefox','fa-chrome','fa-safari','fa-steam',
'fa-playstation','fa-xbox',
];
$FA_SOLID_ICONS = [
'fa-shield-halved','fa-key','fa-lock','fa-lock-open','fa-user','fa-users',
'fa-building','fa-globe','fa-server','fa-database','fa-cloud','fa-code',
'fa-terminal','fa-mobile','fa-laptop','fa-desktop','fa-wifi','fa-envelope',
'fa-bell','fa-star','fa-bolt','fa-fire','fa-gear','fa-wrench',
'fa-briefcase','fa-chart-bar','fa-credit-card','fa-wallet','fa-shop',
'fa-robot','fa-microchip',
];
?>
<style>
/* ── Layout ── */
.dash-layout{display:flex;min-height:calc(100vh - 60px)}
.sidebar{width:260px;flex-shrink:0;border-right:1px solid var(--line);padding:1.5rem 1rem;display:flex;flex-direction:column;gap:.25rem;background:var(--bg)}
.main{flex:1;padding:2rem 2.5rem;max-width:100%;overflow:auto}
@media(max-width:768px){.sidebar{display:none}.main{padding:1.25rem 1rem}}
/* ── Sidebar ── */
.sidebar-section{font-size:.6875rem;font-weight:600;color:var(--ink4);letter-spacing:.08em;text-transform:uppercase;padding:.5rem .75rem .25rem;margin-top:.75rem}
.sidebar-link{display:flex;align-items:center;gap:.625rem;padding:.5rem .75rem;border-radius:var(--r);font-size:.875rem;color:var(--ink3);cursor:pointer;transition:all .15s;border:none;background:none;width:100%;text-align:left;text-decoration:none}
.sidebar-link:hover,.sidebar-link.active{background:var(--bg2);color:var(--ink)}
.sidebar-link svg{width:16px;height:16px;flex-shrink:0}
.sidebar-profile-count{margin-left:auto;font-size:.75rem;background:var(--line);color:var(--ink4);border-radius:999px;padding:.1rem .5rem}
/* ── Topbar ── */
.topbar{display:flex;align-items:center;justify-content:space-between;margin-bottom:2rem;flex-wrap:wrap;gap:1rem}
.topbar h1{font-size:1.5rem;font-weight:700;letter-spacing:-.025em}
/* ── Grid ── */
.profiles-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:1.25rem}
/* ── OTP Card ── */
.otp-card{background:var(--bg);border:1px solid var(--line);border-radius:var(--r3);overflow:hidden;transition:box-shadow .2s,transform .2s}
.otp-card:hover{box-shadow:var(--shadow3);transform:translateY(-1px)}
.otp-card-header{padding:1rem 1.25rem .75rem;display:flex;align-items:center;gap:.75rem;border-bottom:1px solid var(--line2)}
.otp-card-color{width:10px;height:10px;border-radius:50%;flex-shrink:0}
.otp-card-name{font-weight:600;font-size:.9375rem;flex:1;min-width:0}
.otp-card-issuer{font-size:.75rem;color:var(--ink4);truncate:true}
.otp-card-body{padding:1.25rem}
.otp-code{font-family:'JetBrains Mono',monospace;font-size:2rem;font-weight:500;letter-spacing:.15em;color:var(--ink);cursor:pointer;transition:color .15s;line-height:1}
.otp-code:hover{color:var(--blue)}
.otp-code.refreshing{color:var(--ink4)}
.otp-code-wrap{display:flex;align-items:center;gap:.75rem;margin-bottom:.875rem}
.otp-copy-btn{padding:.375rem .625rem;font-size:.75rem;border:1px solid var(--line);border-radius:var(--r);background:var(--bg2);color:var(--ink3);cursor:pointer;transition:all .15s;display:flex;align-items:center;gap:.25rem}
.otp-copy-btn:hover{background:var(--blue-l);color:var(--blue);border-color:transparent}
/* ── Progress bar ── */
.progress-wrap{display:flex;align-items:center;gap:.75rem}
.progress-bar{flex:1;height:4px;background:var(--line);border-radius:2px;overflow:hidden}
.progress-fill{height:100%;border-radius:2px;background:var(--blue);transition:width .5s linear}
.progress-fill.warning{background:var(--amber)}
.progress-fill.danger{background:var(--red)}
.progress-time{font-size:.75rem;color:var(--ink4);width:32px;text-align:right;font-family:'JetBrains Mono',monospace}
/* ── Card footer ── */
.otp-card-footer{padding:.75rem 1.25rem;border-top:1px solid var(--line2);display:flex;align-items:center;gap:.5rem}
.otp-meta{font-size:.6875rem;color:var(--ink4);flex:1;display:flex;gap:.75rem;align-items:center;flex-wrap:wrap}
.otp-meta span{display:flex;align-items:center;gap:.2rem}
.otp-actions{display:flex;gap:.25rem}
/* ── Shared badge ── */
.shared-chip{display:inline-flex;align-items:center;gap:.3rem;padding:.2rem .5rem;background:var(--amber-l);color:var(--amber);border-radius:999px;font-size:.6875rem;font-weight:600}
/* ── Empty ── */
.empty-card{text-align:center;padding:4rem 2rem;border:2px dashed var(--line);border-radius:var(--r3);color:var(--ink3)}
.empty-card .icon{font-size:3rem;margin-bottom:1rem}
.empty-card h2{font-size:1.25rem;font-weight:600;color:var(--ink);margin-bottom:.5rem}
.empty-card p{font-size:.9375rem;max-width:360px;margin:0 auto 1.5rem}
/* ── Modal wider ── */
.modal{background:var(--bg);border-radius:var(--r3);box-shadow:var(--shadow4);width:100%;max-width:740px;max-height:90vh;display:flex;flex-direction:column;animation:slideUp .2s ease}
.modal-body{padding:1.5rem;overflow-y:auto;flex:1;-webkit-overflow-scrolling:touch}
@media(max-width:580px){
.modal{max-height:100vh;border-radius:var(--r3) var(--r3) 0 0;position:fixed;bottom:0;left:0;right:0;width:100%}
.modal-backdrop{align-items:flex-end}
.modal-body{padding:1rem}
}
/* ── 2-col form grid ── */
.form-2col{display:grid;grid-template-columns:1fr 1fr;gap:1.25rem 1.75rem;align-items:start}
.form-2col .col{display:flex;flex-direction:column;gap:.875rem}
/* ── Mobile tabs (hidden on desktop) ── */
.mob-tabs{display:none}
@media(max-width:580px){
.form-2col{display:block}
.form-2col .col{display:none}
.form-2col .col.tab-active{display:flex;flex-direction:column;gap:.875rem}
.mob-tabs{display:flex;border-bottom:1px solid var(--line);margin:-1rem -1rem .875rem;padding:0 1rem}
.mob-tab{flex:1;padding:.625rem .5rem;font-size:.875rem;font-weight:500;color:var(--ink3);background:none;border:none;border-bottom:2px solid transparent;margin-bottom:-1px;cursor:pointer;transition:color .15s,border-color .15s}
.mob-tab.active{color:var(--blue);border-bottom-color:var(--blue)}
}
/* ── Form section label ── */
.form-section{font-size:.6875rem;font-weight:700;color:var(--ink4);letter-spacing:.07em;text-transform:uppercase;padding-bottom:.375rem;border-bottom:1px solid var(--line);margin-bottom:.125rem}
/* ── Modal specifics ── */
.color-grid{display:grid;grid-template-columns:repeat(8,1fr);gap:.4rem}
.color-swatch{width:26px;height:26px;border-radius:50%;cursor:pointer;border:2px solid transparent;transition:transform .15s,border-color .15s}
.color-swatch.selected,.color-swatch:hover{transform:scale(1.2);border-color:var(--ink)}
.secret-row{display:flex;gap:.5rem;align-items:center}
.secret-row .input{font-family:'JetBrains Mono',monospace;font-size:.8125rem;letter-spacing:.05em}
/* ── Share panel ── */
.share-list{margin-top:1rem;display:flex;flex-direction:column;gap:.5rem}
.share-row{display:flex;align-items:center;gap:.75rem;padding:.625rem .875rem;border:1px solid var(--line);border-radius:var(--r2);font-size:.875rem}
.share-email{flex:1;min-width:0;truncate:true;font-weight:500}
.share-status{font-size:.75rem;color:var(--ink4)}
/* ── User menu ── */
.user-menu-wrap{position:relative}
.user-menu{position:absolute;top:calc(100% + .5rem);right:0;background:var(--bg);border:1px solid var(--line);border-radius:var(--r2);box-shadow:var(--shadow4);width:220px;z-index:200;overflow:hidden;animation:slideUp .15s ease}
.user-menu-item{display:flex;align-items:center;gap:.625rem;padding:.625rem 1rem;font-size:.875rem;color:var(--ink2);cursor:pointer;transition:background .1s}
.user-menu-item:hover{background:var(--bg2)}
.user-menu-item svg{width:15px;height:15px;color:var(--ink4)}
.user-info{padding:.875rem 1rem;border-bottom:1px solid var(--line)}
.user-info-name{font-weight:600;font-size:.875rem;truncate:true}
.user-info-email{font-size:.75rem;color:var(--ink4);truncate:true}
/* ── Toggle ── */
.toggle{position:relative;width:44px;height:24px;flex-shrink:0}
.toggle input{opacity:0;width:0;height:0}
.toggle-slider{position:absolute;inset:0;border-radius:34px;background:var(--line);cursor:pointer;transition:background .2s}
.toggle-slider:before{content:'';position:absolute;left:2px;top:2px;width:20px;height:20px;border-radius:50%;background:white;transition:transform .2s;box-shadow:var(--shadow)}
.toggle input:checked + .toggle-slider{background:var(--blue)}
.toggle input:checked + .toggle-slider:before{transform:translateX(20px)}
/* ── Reveal hint ── */
.reveal-hint{font-size:.7rem;color:var(--ink4);margin-top:4px;display:flex;align-items:center;gap:.25rem}
</style>
<!-- Nav -->
<nav class="nav">
<div class="nav-brand">
<div class="nav-logo"><img src=favicon.png width=30></div>
TOTPVault
</div>
<div class="nav-spacer"></div>
<div class="user-menu-wrap">
<button onclick="toggleUserMenu()" style="display:flex;align-items:center;gap:.625rem;background:none;border:1px solid var(--line);border-radius:var(--r2);padding:.375rem .75rem .375rem .375rem;cursor:pointer;font-size:.875rem;font-weight:500;color:var(--ink)">
<div class="avatar">
<?php if ($avatar): ?>
<img src="<?= $avatar ?>" alt="avatar" style="width:100%;height:100%;object-fit:cover">
<?php else: ?>
<?= $initial ?>
<?php endif; ?>
</div>
<?= htmlspecialchars(explode(' ', $user['name'] ?? $user['email'])[0]) ?>
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
</button>
<div class="user-menu hidden" id="user-menu">
<div class="user-info">
<div class="user-info-name"><?= htmlspecialchars($user['name'] ?? '') ?></div>
<div class="user-info-email"><?= htmlspecialchars($user['email']) ?></div>
</div>
<a href="/auth/logout" class="user-menu-item">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
Sign out
</a>
</div>
</div>
</nav>
<!-- Dashboard layout -->
<div class="dash-layout">
<aside class="sidebar">
<div class="sidebar-section">Tokens</div>
<button class="sidebar-link active" onclick="filterProfiles('all')">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
All tokens
<span class="sidebar-profile-count" id="count-all"><?= count($profiles) ?></span>
</button>
<button class="sidebar-link" onclick="filterProfiles('mine')">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
My tokens
<span class="sidebar-profile-count" id="count-mine"><?= count(array_filter($profiles, fn($p)=>$p['is_owner'])) ?></span>
</button>
<button class="sidebar-link" onclick="filterProfiles('shared')">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 00-3-3.87"/><path d="M16 3.13a4 4 0 010 7.75"/></svg>
Shared with me
<span class="sidebar-profile-count" id="count-shared"><?= count(array_filter($profiles, fn($p)=>!$p['is_owner'])) ?></span>
</button>
<div style="margin-top:auto;padding:.5rem .75rem;display:flex;flex-direction:column;gap:.5rem">
<a href="/tools/import-google-auth" class="sidebar-link" style="font-size:.8125rem;color:var(--ink4)">
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
Import from Google Auth
</a>
<a href="/api/export-profiles" class="sidebar-link" style="font-size:.8125rem;color:var(--ink4)" title="Export editable profiles as CSV with decrypted seeds">
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Export profiles
</a>
<button class="btn btn-primary w-full" onclick="openAddModal()">
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Add token
</button>
</div>
</aside>
<main class="main">
<div class="topbar">
<h1 id="view-title">All tokens</h1>
<button class="btn btn-primary" onclick="openAddModal()" style="display:none" id="mob-add-btn">
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Add token
</button>
</div>
<?php if (empty($profiles)): ?>
<div class="empty-card">
<div class="icon">🔒</div>
<h2>No tokens yet</h2>
<p>Add your first TOTP token to get started. Your secret keys are encrypted and never leave the server.</p>
<button class="btn btn-primary btn-lg" onclick="openAddModal()">Add your first token</button>
</div>
<?php else: ?>
<div class="profiles-grid" id="profiles-grid">
<?php foreach ($profiles as $p): ?>
<?php
$pId = (int)$p['id'];
$pName = htmlspecialchars($p['name']);
$pIssuer = htmlspecialchars($p['issuer'] ?? '');
$pColor = htmlspecialchars($p['color']);
$pAlgo = htmlspecialchars($p['algorithm']);
$pDigits = (int)$p['digits'];
$pPeriod = (int)$p['period'];
$isOwner = (bool)$p['is_owner'];
$canEdit = (bool)$p['can_edit'];
$pHide = (int)($p['hide_code'] ?? 0);
$pIcon = htmlspecialchars($p['icon'] ?? 'fa-shield-halved');
?>
<div class="otp-card" data-id="<?= $pId ?>" data-owner="<?= $isOwner?'1':'0' ?>" data-hide="<?= $pHide ?>">
<div class="otp-card-header">
<div class="otp-card-color" style="background:<?= $pColor ?>"></div>
<div style="width:28px;height:28px;display:flex;align-items:center;justify-content:center;font-size:1.1rem;color:<?= $pColor ?>">
<i class="<?php echo (in_array($pIcon, $FA_BRAND_ICONS) ? 'fab ' : 'fa ') . $pIcon; ?> <?= $pIcon ?>"></i>
</div>
<div style="flex:1;min-width:0">
<div class="otp-card-name truncate"><?= $pName ?></div>
<?php if ($pIssuer): ?>
<div class="otp-card-issuer truncate"><?= $pIssuer ?></div>
<?php endif; ?>
</div>
<?php if (!$isOwner): ?>
<span class="shared-chip">
<svg width="10" height="10" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="9" cy="7" r="4"/></svg>
Shared
</span>
<?php endif; ?>
</div>
<div class="otp-card-body">
<div class="otp-code-wrap">
<div class="otp-code" id="code-<?= $pId ?>" onclick="copyCode(<?= $pId ?>)" title="<?= $pHide ? 'Click to reveal' : 'Click to copy' ?>">
<span class="dots">••••••</span>
</div>
<button class="otp-copy-btn" onclick="copyCode(<?= $pId ?>)">
<svg width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
<?= $pHide ? 'Reveal' : 'Copy' ?>
</button>
</div>
<?php if ($pHide): ?>
<div class="reveal-hint" id="hint-<?= $pId ?>">
<svg width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
hides after 10 seconds
</div>
<?php endif; ?>
<div class="progress-wrap" style="<?= $pHide ? 'margin-top:8px' : '' ?>">
<div class="progress-bar"><div class="progress-fill" id="prog-<?= $pId ?>"></div></div>
<div class="progress-time" id="time-<?= $pId ?>">—</div>
</div>
</div>
<div class="otp-card-footer">
<div class="otp-meta">
<span><?= $pAlgo ?></span>
<span><?= $pDigits ?> digits</span>
<span><?= $pPeriod ?>s</span>
</div>
<div class="otp-actions">
<?php if ($isOwner): ?>
<button class="btn btn-ghost btn-icon btn-sm" title="Share" onclick="openShareModal(<?= $pId ?>, '<?= $pName ?>')">
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>
</button>
<?php endif; ?>
<?php if ($canEdit): ?>
<button class="btn btn-ghost btn-icon btn-sm" title="Edit" onclick="openEditModal(<?= $pId ?>)">
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
</button>
<?php endif; ?>
<?php if ($isOwner): ?>
<button class="btn btn-ghost btn-icon btn-sm" title="Delete" onclick="deleteProfile(<?= $pId ?>, '<?= $pName ?>')" style="color:var(--red)">
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4h6v2"/></svg>
</button>
<?php endif; ?>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</main>
</div>
<!-- ── Add/Edit Modal ── -->
<div id="profile-modal" class="modal-backdrop hidden">
<div class="modal">
<div class="modal-header">
<div>
<h2 class="text-xl font-bold" id="modal-title">Add token</h2>
<p class="text-sm text-ink3" style="margin-top:.25rem">Enter your TOTP secret key and settings</p>
</div>
<button class="btn btn-ghost btn-icon" onclick="closeModal('profile-modal')">
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div class="modal-body">
<input type="hidden" id="edit-id" value="">
<!-- Mobile tabs (hidden on desktop) -->
<div class="mob-tabs">
<button class="mob-tab active" onclick="switchTab(0)"><i class="fa-solid fa-key" style="margin-right:.375rem"></i>Token</button>
<button class="mob-tab" onclick="switchTab(1)"><i class="fa-solid fa-palette" style="margin-right:.375rem"></i>Appearance</button>
</div>
<div class="form-2col">
<!-- LEFT column -->
<div class="col tab-active" id="form-col-0">
<div class="form-section">Identity</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.75rem">
<div class="field">
<label class="label">Name *</label>
<input class="input" id="f-name" placeholder="e.g. GitHub…">
</div>
<div class="field">
<label class="label">Issuer / Service</label>
<input class="input" id="f-issuer" placeholder="e.g. GitHub">
</div>
</div>
<div class="form-section">Secret key</div>
<div class="field">
<div class="secret-row">
<input class="input" id="f-secret" placeholder="Base32 secret…" style="flex:1">
<button class="btn btn-secondary btn-sm" onclick="generateSecret()" title="Generate random secret">
<i class="fa-solid fa-rotate"></i>
</button>
<button class="btn btn-secondary btn-sm" onclick="toggleQrPanel()" title="Scan QR code">
<i class="fa-solid fa-qrcode"></i>
</button>
</div>
<p class="hint" style="margin-top:.375rem">Enter manually or scan a QR code from your authenticator app.</p>
</div>
<!-- QR panel -->
<div id="qr-panel" style="display:none;max-height:200px;overflow-y:auto;padding:.875rem;background:var(--bg2);border:1px solid var(--line);border-radius:var(--r2)">
<div style="display:flex;flex-direction:column;gap:.625rem">
<div id="qr-dropzone"
onclick="document.getElementById('qr-file').click()"
ondragover="event.preventDefault();this.style.borderColor='var(--blue)'"
ondragleave="this.style.borderColor='var(--line)'"
ondrop="handleQrDrop(event)"
style="width:100%;padding:.625rem 1rem;border:2px dashed var(--line);border-radius:var(--r2);cursor:pointer;transition:border-color .15s;display:flex;align-items:center;justify-content:center;gap:.625rem">
<i class="fa-solid fa-qrcode" style="color:var(--ink4);font-size:1.1rem"></i>
<div>
<span style="font-size:.875rem;font-weight:500;color:var(--ink2)">Drop QR or click to upload</span>
<span style="font-size:.75rem;color:var(--ink4);margin-left:.375rem">· Ctrl+V to paste</span>
</div>
</div>
<canvas id="qr-canvas" style="display:none;position:absolute;pointer-events:none;opacity:0"></canvas>
<div id="qr-result" style="display:none;padding:.625rem .875rem;border-radius:var(--r);font-size:.8125rem"></div>
</div>
<input type="file" id="qr-file" accept="image/*" style="display:none" onchange="handleQrFile(this.files[0])">
</div>
<div class="form-section">TOTP settings</div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:.5rem">
<div class="field">
<label class="label">Algorithm</label>
<select class="input" id="f-algorithm">
<option value="SHA1">SHA1</option>
<option value="SHA256">SHA256</option>
<option value="SHA512">SHA512</option>
</select>
</div>
<div class="field">
<label class="label">Digits</label>
<select class="input" id="f-digits">
<option value="6">6 digits</option>
<option value="8">8 digits</option>
<option value="10">10 digits</option>
</select>
</div>
<div class="field">
<label class="label">Period (s)</label>
<input class="input" id="f-period" type="number" value="30" min="15" max="300">
</div>
</div>
<!-- Hide toggle -->
<div class="field" style="flex-direction:row;align-items:center;gap:1rem;padding:.75rem;background:var(--bg2);border:1px solid var(--line);border-radius:var(--r2)">
<label class="toggle" style="flex-shrink:0">
<input type="checkbox" id="f-hide">
<span class="toggle-slider"></span>
</label>
<div>
<div class="label" style="margin:0 0 .125rem">Hide code until clicked</div>
<div class="hint">Reveals for 10 seconds when tapped, then hides again</div>
</div>
</div>
</div>
<!-- RIGHT column -->
<div class="col" id="form-col-1">
<div class="form-section">Appearance</div>
<div class="field">
<label class="label">Colour</label>
<div class="color-grid" id="color-grid"></div>
<input type="hidden" id="f-color" value="#6366f1">
</div>
<div class="field">
<label class="label">Icon</label>
<input class="input" id="icon-search" placeholder="Search: google, github, apple..." oninput="filterIcons(this.value)" autocomplete="off">
<div id="icon-grid" style="display:flex;flex-wrap:wrap;gap:5px;margin-top:8px;max-height:260px;overflow-y:auto;padding:2px 0"></div>
<input type="hidden" id="f-icon" value="fa-shield-halved">
<input type="hidden" id="f-icon-prefix" value="fa-brands">
</div>
</div>
</div><!-- /form-2col -->
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModal('profile-modal')">Cancel</button>
<button class="btn btn-primary" id="save-btn" onclick="saveProfile()">Save token</button>
</div>
</div>
</div>
<!-- ── Share Modal ── -->
<div id="share-modal" class="modal-backdrop hidden">
<div class="modal" style="max-width:480px">
<div class="modal-header">
<div>
<h2 class="text-xl font-bold">Share token</h2>
<p class="text-sm text-ink3" style="margin-top:.25rem" id="share-modal-sub">Collaborators can view OTP codes without seeing the secret.</p>
</div>
<button class="btn btn-ghost btn-icon" onclick="closeModal('share-modal')">
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div class="modal-body">
<input type="hidden" id="share-profile-id">
<div style="display:flex;gap:.5rem;margin-bottom:.75rem">
<input class="input" id="share-email" type="email" placeholder="colleague@company.com" style="flex:1">
<button class="btn btn-primary" onclick="addShare()">Invite</button>
</div>
<label style="display:flex;align-items:center;gap:.5rem;font-size:.875rem;cursor:pointer;margin-bottom:1rem">
<input type="checkbox" id="share-can-edit" style="width:16px;height:16px">
Allow editing token settings
</label>
<div id="share-list-wrap">
<div class="text-sm text-ink4">Loading shares…</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModal('share-modal')">Done</button>
</div>
</div>
</div>
<script>
const CSRF = <?= json_encode($csrfToken) ?>;
const COLORS = ['#6366f1','#3b82f6','#06b6d4','#10b981','#84cc16','#f59e0b','#f97316','#ef4444','#ec4899','#8b5cf6','#0ea5e9','#14b8a6','#22c55e','#eab308','#f43f5e','#6b7280'];
// ── Color grid ──
function initColorGrid(selected) {
const g = document.getElementById('color-grid');
g.innerHTML = COLORS.map(c =>
`<div class="color-swatch ${c===selected?'selected':''}" style="background:${c}" data-color="${c}" onclick="selectColor('${c}')"></div>`
).join('');
}
function selectColor(c) {
document.getElementById('f-color').value = c;
document.querySelectorAll('.color-swatch').forEach(s => s.classList.toggle('selected', s.dataset.color===c));
}
// ── OTP refresh engine ──
const REFRESH_INTERVAL = 1000;
async function fetchOTP(id) {
const r = await fetch(`/api/otp?id=${id}`);
const d = await r.json();
if (d.error) return null;
return d;
}
function formatCode(code, digits) {
if (digits === 6) return code.slice(0,3) + ' ' + code.slice(3);
if (digits === 8) return code.slice(0,4) + ' ' + code.slice(4);
return code.slice(0,3) + ' ' + code.slice(3,6) + ' ' + code.slice(6);
}
function updateCard(id, data) {
const codeEl = document.getElementById(`code-${id}`);
const progEl = document.getElementById(`prog-${id}`);
const timeEl = document.getElementById(`time-${id}`);
if (!codeEl) return;
const card = codeEl.closest('.otp-card');
const digits = parseInt(card.querySelector('.otp-meta').textContent.match(/(\d+) digit/)[1]);
const isHidden = card.dataset.hide === '1';
codeEl.dataset.rawCode = data.code;
if (isHidden && !codeEl.dataset.revealed) {
const dotMap = {6:'••• •••', 8:'•••• ••••', 10:'••• ••• ••••'};
codeEl.textContent = dotMap[digits] || '••• •••';
codeEl.style.color = 'var(--ink4)';
codeEl.title = 'Click to reveal';
} else {
codeEl.textContent = formatCode(data.code, digits);
codeEl.style.color = '';
}
const pct = 100 - data.progress;
progEl.style.width = pct + '%';
progEl.className = 'progress-fill' + (data.remaining <= 5 ? ' danger' : data.remaining <= 10 ? ' warning' : '');
timeEl.textContent = data.remaining + 's';
if (data.remaining <= 2) codeEl.classList.add('refreshing');
else codeEl.classList.remove('refreshing');
}
let refreshTimers = {};
async function startRefreshing(id) {
const data = await fetchOTP(id);
if (!data) return;
updateCard(id, data);
clearInterval(refreshTimers[id]);
refreshTimers[id] = setInterval(async () => {
const d = await fetchOTP(id);
if (d) updateCard(id, d);
}, REFRESH_INTERVAL);
}
function initAllCards() {
document.querySelectorAll('.otp-card').forEach(card => startRefreshing(parseInt(card.dataset.id)));
}
// ── Copy / Reveal ──
async function copyCode(id) {
const el = document.getElementById(`code-${id}`);
const card = el.closest('.otp-card');
const raw = el.dataset.rawCode;
if (!raw) { toast('Code is loading…', 'info'); return; }
if (card.dataset.hide === '1' && !el.dataset.revealed) { revealCode(id); return; }
try {
await navigator.clipboard.writeText(raw);
toast('Code copied!', 'success');
const btn = card.querySelector('.otp-copy-btn');
const orig = btn.innerHTML;
btn.innerHTML = '✓ Copied';
setTimeout(() => btn.innerHTML = orig, 1500);
} catch { toast('Copy failed', 'error'); }
}
function revealCode(id) {
const el = document.getElementById(`code-${id}`);
const card = el.closest('.otp-card');
const raw = el.dataset.rawCode;
const digits = parseInt(card.querySelector('.otp-meta').textContent.match(/(\d+) digit/)[1]);
const hint = document.getElementById(`hint-${id}`);
const btn = card.querySelector('.otp-copy-btn');
el.dataset.revealed = '1';
el.textContent = formatCode(raw, digits);
el.style.color = '';
el.title = 'Click to copy';
if (hint) hint.style.display = 'none';
if (btn) btn.innerHTML = '<svg width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg> Copy';
resetHideTimer(id, digits);
}
function resetHideTimer(id, digits) {
const el = document.getElementById(`code-${id}`);
const hint = document.getElementById(`hint-${id}`);
const card = el.closest('.otp-card');
const btn = card.querySelector('.otp-copy-btn');
clearTimeout(el._hideTimer);
el._hideTimer = setTimeout(() => {
delete el.dataset.revealed;
const dotMap = {6:'••• •••', 8:'•••• ••••', 10:'••• ••• ••••'};
el.textContent = dotMap[digits] || '••• •••';
el.style.color = 'var(--ink4)';
el.title = 'Click to reveal';
if (hint) hint.style.display = '';
if (btn) btn.innerHTML = '<svg width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg> Reveal';
}, 10000);
}
// ── Modals ──
function closeModal(id) { document.getElementById(id).classList.add('hidden'); }
function openAddModal() {
document.getElementById('edit-id').value = '';
document.getElementById('modal-title').textContent = 'Add token';
document.getElementById('save-btn').textContent = 'Save token';
document.getElementById('f-name').value = '';
document.getElementById('f-issuer').value = '';
document.getElementById('f-secret').value = '';
document.getElementById('f-algorithm').value = 'SHA1';
document.getElementById('f-digits').value = '6';
document.getElementById('f-period').value = '30';
document.getElementById('f-color').value = '#6366f1';
document.getElementById('f-hide').checked = false;
document.getElementById('qr-panel').style.display = 'none';
document.getElementById('qr-result').style.display = 'none';
switchTab(0);
initColorGrid('#6366f1');
document.getElementById('f-icon').value = 'fa-shield-halved';
document.getElementById('f-icon-prefix').value = 'fa-solid';
document.getElementById('icon-search').value = '';
renderIcons('');
document.getElementById('profile-modal').classList.remove('hidden');
document.getElementById('f-name').focus();
}
async function openEditModal(id) {
const r = await fetch(`/api/profiles/${id}`);
const p = await r.json();
if (p.error) { toast(p.error, 'error'); return; }
document.getElementById('edit-id').value = id;
document.getElementById('modal-title').textContent = 'Edit token';
document.getElementById('save-btn').textContent = 'Update token';
document.getElementById('f-name').value = p.name;
document.getElementById('f-issuer').value = p.issuer || '';
document.getElementById('f-secret').value = '';
document.getElementById('f-algorithm').value = p.algorithm;
document.getElementById('f-digits').value = p.digits;
document.getElementById('f-period').value = p.period;
document.getElementById('f-color').value = p.color;
document.getElementById('f-hide').checked = !!parseInt(p.hide_code || 0);
document.getElementById('qr-panel').style.display = 'none';
document.getElementById('qr-result').style.display = 'none';
switchTab(0);
initColorGrid(p.color);
document.getElementById('f-icon').value = p.icon || 'fa-shield-halved';
document.getElementById('f-icon-prefix').value = p.icon_prefix || 'fa-solid';
document.getElementById('icon-search').value = '';
renderIcons('');
document.getElementById('profile-modal').classList.remove('hidden');
}
async function saveProfile() {
const id = document.getElementById('edit-id').value;
const body = {
name: document.getElementById('f-name').value.trim(),
issuer: document.getElementById('f-issuer').value.trim(),
secret: document.getElementById('f-secret').value.trim(),
algorithm: document.getElementById('f-algorithm').value,
digits: document.getElementById('f-digits').value,
period: document.getElementById('f-period').value,
color: document.getElementById('f-color').value,
icon: document.getElementById('f-icon').value,
hide_code: document.getElementById('f-hide').checked ? 1 : 0,
};
if (!body.name) { toast('Name is required', 'error'); return; }
if (!id && !body.secret) { toast('Secret key is required', 'error'); return; }
const url = id ? `/api/profiles/${id}` : '/api/profiles';
const method = id ? 'PUT' : 'POST';
const r = await apiFetch(url, method, body);
if (r.error) { toast(r.error, 'error'); return; }
toast(id ? 'Token updated' : 'Token added', 'success');
closeModal('profile-modal');
setTimeout(() => location.reload(), 500);
}
async function deleteProfile(id, name) {
if (!confirm(`Delete "${name}"? This cannot be undone.`)) return;
const r = await apiFetch(`/api/profiles/${id}`, 'DELETE', {});
if (r.error) { toast(r.error, 'error'); return; }
document.querySelector(`.otp-card[data-id="${id}"]`)?.remove();
toast('Token deleted', 'success');
updateCounts();
}
async function generateSecret() {
const r = await fetch('/api/generate-secret');
const d = await r.json();
document.getElementById('f-secret').value = d.secret;
}
// ── Share modal ──
async function openShareModal(id, name) {
document.getElementById('share-profile-id').value = id;
document.getElementById('share-modal-sub').textContent = `Sharing: ${name}`;
document.getElementById('share-email').value = '';
document.getElementById('share-modal').classList.remove('hidden');
await loadShares(id);
}
async function loadShares(id) {
const wrap = document.getElementById('share-list-wrap');
const r = await fetch(`/api/profiles/${id}/shares`);
const shares = await r.json();
if (!shares.length) {
wrap.innerHTML = '<div class="text-sm text-ink4" style="padding:.5rem 0">No shares yet. Invite someone above.</div>';
return;
}
wrap.innerHTML = `<div class="share-list">${shares.map(s => `
<div class="share-row">
<div class="avatar" style="width:28px;height:28px;font-size:.6875rem">${(s.user_name||s.shared_with_email).charAt(0).toUpperCase()}</div>
<div style="flex:1;min-width:0">
<div class="share-email truncate">${s.shared_with_email}</div>
<div class="share-status">${s.can_edit ? 'Can edit' : 'View only'} · ${s.shared_with_user_id ? 'Active' : 'Pending'}</div>
</div>
<button class="btn btn-ghost btn-sm" style="color:var(--red)" onclick="removeShare(${id},'${s.shared_with_email}')">Remove</button>
</div>`).join('')}</div>`;
}
async function addShare() {
const id = document.getElementById('share-profile-id').value;
const email = document.getElementById('share-email').value.trim();
const canEdit = document.getElementById('share-can-edit').checked;
if (!email) { toast('Enter an email address', 'error'); return; }
const r = await apiFetch(`/api/profiles/${id}/shares`, 'POST', { email, can_edit: canEdit });
if (r.error) { toast(r.error, 'error'); return; }
toast(`Shared with ${email}`, 'success');
document.getElementById('share-email').value = '';
await loadShares(id);
}
async function removeShare(id, email) {
const r = await apiFetch(`/api/profiles/${id}/shares`, 'DELETE', { email });
if (r.error) { toast(r.error, 'error'); return; }
toast('Share removed', 'success');
await loadShares(id);
}
// ── Filtering ──
function filterProfiles(type) {
document.querySelectorAll('.sidebar-link').forEach(l => l.classList.remove('active'));
document.querySelectorAll('.sidebar-link')[['all','mine','shared'].indexOf(type)]?.classList.add('active');
const titles = {all:'All tokens', mine:'My tokens', shared:'Shared with me'};
document.getElementById('view-title').textContent = titles[type];
document.querySelectorAll('.otp-card').forEach(card => {
const owner = card.dataset.owner === '1';
const show = type==='all' || (type==='mine' && owner) || (type==='shared' && !owner);
card.style.display = show ? '' : 'none';
});
}
function updateCounts() {
const cards = [...document.querySelectorAll('.otp-card')];
document.getElementById('count-all').textContent = cards.length;
document.getElementById('count-mine').textContent = cards.filter(c=>c.dataset.owner==='1').length;
document.getElementById('count-shared').textContent = cards.filter(c=>c.dataset.owner!=='1').length;
}
// ── User menu ──
function toggleUserMenu() { document.getElementById('user-menu').classList.toggle('hidden'); }
document.addEventListener('click', e => {
if (!e.target.closest('.user-menu-wrap')) document.getElementById('user-menu')?.classList.add('hidden');
});
// ── API helper ──
async function apiFetch(url, method, body) {
const r = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': CSRF },
body: JSON.stringify(body),
});
return r.json();
}
// ── Toast ──
function toast(msg, type='success') {
const icons = { success:'✓', error:'✕', info:'' };
const el = document.createElement('div');
el.className = `toast ${type}`;
el.innerHTML = `<span class="toast-icon">${icons[type]||''}</span><span>${msg}</span>`;
document.getElementById('toast-container').appendChild(el);
setTimeout(() => { el.style.animation='toastOut .2s ease forwards'; setTimeout(()=>el.remove(),200); }, 3000);
}
document.querySelectorAll('.modal-backdrop').forEach(b => {
b.addEventListener('click', e => { if (e.target === b) b.classList.add('hidden'); });
});
if (window.innerWidth < 768) document.getElementById('mob-add-btn').style.display='';
// ── Icon picker ──
const FA_BRAND_ICONS = <?= json_encode($FA_BRAND_ICONS) ?>;
const FA_SOLID_ICONS = <?= json_encode($FA_SOLID_ICONS) ?>;
function renderIcons(filter) {
const grid = document.getElementById('icon-grid');
const current = document.getElementById('f-icon').value;
const f = (filter || '').toLowerCase();
const brands = FA_BRAND_ICONS.filter(i => !f || i.includes(f)).map(i => iconBtn(i, 'fa-brands', current));
const solids = FA_SOLID_ICONS.filter(i => !f || i.includes(f)).map(i => iconBtn(i, 'fa-solid', current));
grid.innerHTML =
(brands.length ? `<div style="width:100%;font-size:.7rem;color:var(--ink4);padding:2px 0">Brands</div>${brands.join('')}` : '') +
(solids.length ? `<div style="width:100%;font-size:.7rem;color:var(--ink4);padding:6px 0 2px">General</div>${solids.join('')}` : '');
}
function iconBtn(icon, prefix, current) {
const sel = current === icon;
return `<button type="button" onclick="selectIcon('${icon}','${prefix}')" title="${icon.replace('fa-','')}"
style="width:34px;height:34px;border-radius:7px;border:1px solid var(--line);
background:${sel?'var(--blue-l)':'var(--bg2)'};color:${sel?'var(--blue)':'var(--ink3)'};
cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:.95rem;transition:all .15s">
<i class="${prefix} ${icon}"></i>
</button>`;
}
function filterIcons(val) { renderIcons(val); }
function selectIcon(icon, prefix) {
document.getElementById('f-icon').value = icon;
document.getElementById('f-icon-prefix').value = prefix || 'fa-brands';
renderIcons(document.getElementById('icon-search').value);
}
// ── QR Scanner ──
function toggleQrPanel() {
const panel = document.getElementById('qr-panel');
const isHidden = panel.style.display === 'none';
panel.style.display = isHidden ? '' : 'none';
if (isHidden) {
document.getElementById('qr-canvas').style.display = 'none';
document.getElementById('qr-result').style.display = 'none';
}
}
function handleQrDrop(e) {
e.preventDefault();
document.getElementById('qr-dropzone').style.borderColor = 'var(--line)';
const file = e.dataTransfer.files[0];
if (file && file.type.startsWith('image/')) handleQrFile(file);
}
function handleQrFile(file) {
if (!file) return;
const reader = new FileReader();
reader.onload = e => decodeQrFromDataUrl(e.target.result);
reader.readAsDataURL(file);
}
document.addEventListener('paste', e => {
const modal = document.getElementById('profile-modal');
if (modal.classList.contains('hidden')) return;
const items = e.clipboardData?.items;
if (!items) return;
for (const item of items) {
if (item.type.startsWith('image/')) {
document.getElementById('qr-panel').style.display = '';
handleQrFile(item.getAsFile());
return;
}
}
});
function decodeQrFromDataUrl(dataUrl) {
const img = new Image();
img.onload = () => {
const canvas = document.getElementById('qr-canvas');
const ctx = canvas.getContext('2d');
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const code = jsQR(imageData.data, imageData.width, imageData.height);
if (!code) { showQrResult('error', 'No QR code found. Try a clearer or larger image.'); return; }
parseOtpAuth(code.data);
};
img.src = dataUrl;
}
function parseOtpAuth(uri) {
if (!uri.startsWith('otpauth://')) { showQrResult('error', `Not an OTP QR code. Got: ${uri.slice(0,60)}`); return; }
try {
const url = new URL(uri);
const secret = url.searchParams.get('secret');
const issuer = url.searchParams.get('issuer') || '';
const algo = (url.searchParams.get('algorithm') || 'SHA1').toUpperCase();
const digits = url.searchParams.get('digits') || '6';
const period = url.searchParams.get('period') || '30';
let label = decodeURIComponent(url.pathname.replace(/^\//, ''));
const colonIdx = label.indexOf(':');
const name = colonIdx > -1 ? label.slice(colonIdx + 1) : label;
const issuerFromLabel = colonIdx > -1 ? label.slice(0, colonIdx) : '';
const finalIssuer = issuer || issuerFromLabel;
if (!secret) { showQrResult('error', 'QR code found but no secret key present.'); return; }
if (document.getElementById('f-name').value === '') document.getElementById('f-name').value = finalIssuer || name;
if (document.getElementById('f-issuer').value === '') document.getElementById('f-issuer').value = finalIssuer;
document.getElementById('f-secret').value = secret;
document.getElementById('f-algorithm').value = ['SHA1','SHA256','SHA512'].includes(algo) ? algo : 'SHA1';
document.getElementById('f-digits').value = ['6','8','10'].includes(digits) ? digits : '6';
document.getElementById('f-period').value = period;
showQrResult('success', `✓ Secret extracted${finalIssuer ? ` from ${finalIssuer}` : ''}. Review and save.`);
setTimeout(() => { document.getElementById('qr-panel').style.display = 'none'; }, 2000);
} catch (err) { showQrResult('error', 'Failed to parse OTP URI: ' + err.message); }
}
function showQrResult(type, msg) {
const el = document.getElementById('qr-result');
el.style.display = '';
el.style.background = type === 'success' ? 'var(--green-l)' : 'var(--red-l)';
el.style.color = type === 'success' ? 'var(--green)' : 'var(--red)';
el.textContent = msg;
}
// ── Mobile tab switcher ──
function switchTab(idx) {
document.querySelectorAll('.form-2col .col').forEach((c,i) => c.classList.toggle('tab-active', i===idx));
document.querySelectorAll('.mob-tab').forEach((t,i) => t.classList.toggle('active', i===idx));
}
// Init
initColorGrid('#6366f1');
initAllCards();
</script>
</body>
</html>