394 lines
11 KiB
HTML
394 lines
11 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8"/>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
|
<title>Novela — Credentials</title>
|
|
<link rel="icon" href="/static/favicon.ico" sizes="16x16"/>
|
|
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32.png"/>
|
|
<link rel="icon" type="image/png" sizes="256x256" href="/static/favicon-256.png"/>
|
|
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"/>
|
|
<link rel="preconnect" href="https://fonts.googleapis.com"/>
|
|
<link href="https://fonts.googleapis.com/css2?family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
|
|
<link rel="stylesheet" href="/static/theme.css"/>
|
|
<link rel="stylesheet" href="/static/sidebar.css"/>
|
|
<style>
|
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
|
|
html, body { height: 100%; }
|
|
|
|
body {
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
font-family: var(--serif);
|
|
}
|
|
|
|
/* ── Main content ── */
|
|
.main {
|
|
margin-left: var(--sidebar);
|
|
min-height: 100vh;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
padding: 3rem 1rem 5rem;
|
|
}
|
|
@media (max-width: 768px) {
|
|
.main { margin-left: 0; padding: 4rem 1rem 4rem; }
|
|
}
|
|
|
|
.card {
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius);
|
|
padding: 2rem;
|
|
width: 100%;
|
|
max-width: 620px;
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
|
|
.card-title {
|
|
font-size: 0.7rem;
|
|
font-family: var(--mono);
|
|
letter-spacing: 0.12em;
|
|
text-transform: uppercase;
|
|
color: var(--accent);
|
|
margin-bottom: 1.25rem;
|
|
}
|
|
|
|
label {
|
|
display: block;
|
|
font-size: 0.78rem;
|
|
font-family: var(--mono);
|
|
color: var(--text-dim);
|
|
margin-bottom: 0.4rem;
|
|
letter-spacing: 0.04em;
|
|
}
|
|
|
|
input[type="text"],
|
|
input[type="password"],
|
|
input[type="url"] {
|
|
width: 100%;
|
|
background: var(--bg);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius);
|
|
color: var(--text);
|
|
font-family: var(--mono);
|
|
font-size: 0.85rem;
|
|
padding: 0.65rem 0.85rem;
|
|
outline: none;
|
|
transition: border-color 0.15s;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
input:focus { border-color: var(--accent); }
|
|
|
|
.row {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.input-wrap {
|
|
position: relative;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.input-wrap input { margin-bottom: 0; padding-right: 2.5rem; }
|
|
|
|
.toggle-pw {
|
|
position: absolute;
|
|
right: 0.65rem;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
background: none;
|
|
border: none;
|
|
padding: 0;
|
|
width: auto;
|
|
color: var(--text-faint);
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
|
|
.toggle-pw:hover { color: var(--text-dim); }
|
|
|
|
button {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 0.5rem;
|
|
width: 100%;
|
|
padding: 0.85rem;
|
|
background: var(--accent);
|
|
color: #0f0e0c;
|
|
border: none;
|
|
border-radius: var(--radius);
|
|
font-family: var(--mono);
|
|
font-size: 0.85rem;
|
|
font-weight: 500;
|
|
letter-spacing: 0.05em;
|
|
cursor: pointer;
|
|
transition: background 0.15s, transform 0.1s;
|
|
}
|
|
|
|
button:hover { background: var(--accent2); }
|
|
button:active { transform: scale(0.99); }
|
|
|
|
/* Credential list */
|
|
.cred-list { list-style: none; }
|
|
|
|
.cred-item {
|
|
border-bottom: 1px solid var(--border);
|
|
padding: 1rem 0;
|
|
}
|
|
|
|
.cred-item:first-child { padding-top: 0; }
|
|
.cred-item:last-child { border-bottom: none; padding-bottom: 0; }
|
|
|
|
.cred-item-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
|
|
.cred-site {
|
|
font-family: var(--mono);
|
|
font-size: 0.85rem;
|
|
color: var(--text);
|
|
}
|
|
|
|
.cred-actions {
|
|
display: flex;
|
|
gap: 0.4rem;
|
|
}
|
|
|
|
.btn-sm {
|
|
width: auto;
|
|
padding: 0.3rem 0.7rem;
|
|
font-size: 0.72rem;
|
|
background: var(--surface2);
|
|
color: var(--text-dim);
|
|
border: 1px solid var(--border);
|
|
}
|
|
|
|
.btn-sm:hover { background: var(--border); color: var(--text); }
|
|
|
|
.btn-danger {
|
|
background: rgba(200, 90, 58, 0.12);
|
|
border-color: rgba(200, 90, 58, 0.3);
|
|
color: var(--error);
|
|
}
|
|
|
|
.btn-danger:hover { background: rgba(200, 90, 58, 0.25); }
|
|
|
|
.cred-detail {
|
|
font-family: var(--mono);
|
|
font-size: 0.75rem;
|
|
color: var(--text-dim);
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.25rem;
|
|
}
|
|
|
|
.cred-detail span { display: flex; gap: 0.5rem; align-items: center; }
|
|
.cred-detail .label { color: var(--text-faint); min-width: 5rem; }
|
|
|
|
.pw-mask { letter-spacing: 0.1em; }
|
|
|
|
.toggle-visible {
|
|
font-size: 0.68rem;
|
|
color: var(--text-faint);
|
|
cursor: pointer;
|
|
text-decoration: underline;
|
|
text-underline-offset: 2px;
|
|
background: none;
|
|
border: none;
|
|
padding: 0;
|
|
width: auto;
|
|
font-family: var(--mono);
|
|
}
|
|
|
|
.toggle-visible:hover { color: var(--text-dim); background: none; }
|
|
|
|
.empty-state {
|
|
font-family: var(--mono);
|
|
font-size: 0.8rem;
|
|
color: var(--text-faint);
|
|
text-align: center;
|
|
padding: 1rem 0;
|
|
}
|
|
|
|
.form-feedback {
|
|
font-family: var(--mono);
|
|
font-size: 0.75rem;
|
|
color: var(--success);
|
|
text-align: center;
|
|
margin-top: 0.75rem;
|
|
min-height: 1.2em;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
{% include "_sidebar.html" %}
|
|
|
|
<main class="main">
|
|
|
|
<!-- Saved credentials -->
|
|
<div class="card">
|
|
<div class="card-title">Saved Credentials</div>
|
|
<ul class="cred-list" id="cred-list">
|
|
<li class="empty-state" id="empty-msg">No credentials saved yet.</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<!-- Add / Edit -->
|
|
<div class="card">
|
|
<div class="card-title" id="form-title">Add Credentials</div>
|
|
|
|
<label for="f-site">Site (domain or key)</label>
|
|
<div style="font-family: var(--mono); font-size: 0.7rem; color: var(--text-faint); margin: -0.2rem 0 0.8rem;">Tip: use <code>dropbox</code> with the access token in Password.</div>
|
|
<input type="text" id="f-site" placeholder="e.g. example.com"/>
|
|
|
|
<div class="row">
|
|
<div>
|
|
<label for="f-username">Username</label>
|
|
<input type="text" id="f-username" autocomplete="off"/>
|
|
</div>
|
|
<div>
|
|
<label for="f-password">Password</label>
|
|
<div class="input-wrap">
|
|
<input type="password" id="f-password" autocomplete="off"/>
|
|
<button class="toggle-pw" type="button" onclick="toggleFormPw()" title="Show / hide">
|
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<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>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<button onclick="submitForm()">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
|
<path d="M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z"/>
|
|
<polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/>
|
|
</svg>
|
|
Save
|
|
</button>
|
|
<div class="form-feedback" id="form-feedback"></div>
|
|
</div>
|
|
|
|
</main>
|
|
|
|
<script src="/static/books.js"></script>
|
|
<script>
|
|
let allCredentials = {};
|
|
|
|
async function loadCredentials() {
|
|
const r = await fetch('/credentials');
|
|
allCredentials = await r.json();
|
|
renderList();
|
|
}
|
|
|
|
function renderList() {
|
|
const ul = document.getElementById('cred-list');
|
|
const empty = document.getElementById('empty-msg');
|
|
const sites = Object.keys(allCredentials);
|
|
|
|
if (sites.length === 0) {
|
|
ul.innerHTML = '';
|
|
ul.appendChild(empty);
|
|
return;
|
|
}
|
|
|
|
ul.innerHTML = '';
|
|
sites.forEach(site => {
|
|
const creds = allCredentials[site];
|
|
const li = document.createElement('li');
|
|
li.className = 'cred-item';
|
|
li.id = `site-${CSS.escape(site)}`;
|
|
li.innerHTML = `
|
|
<div class="cred-item-header">
|
|
<span class="cred-site">${site}</span>
|
|
<div class="cred-actions">
|
|
<button class="btn-sm" onclick="editSite('${site}')">Edit</button>
|
|
<button class="btn-sm btn-danger" onclick="deleteSite('${site}')">Delete</button>
|
|
</div>
|
|
</div>
|
|
<div class="cred-detail">
|
|
<span><span class="label">Username</span>${creds.username || '—'}</span>
|
|
<span>
|
|
<span class="label">Password</span>
|
|
<span class="pw-mask" id="pw-${CSS.escape(site)}">●●●●●●●●</span>
|
|
<button class="toggle-visible" onclick="togglePw('${site}')">show</button>
|
|
</span>
|
|
</div>`;
|
|
ul.appendChild(li);
|
|
});
|
|
}
|
|
|
|
function togglePw(site) {
|
|
const el = document.getElementById(`pw-${CSS.escape(site)}`);
|
|
const btn = el.nextElementSibling;
|
|
if (btn.textContent === 'show') {
|
|
el.textContent = allCredentials[site].password || '(empty)';
|
|
btn.textContent = 'hide';
|
|
} else {
|
|
el.textContent = '●●●●●●●●';
|
|
btn.textContent = 'show';
|
|
}
|
|
}
|
|
|
|
function editSite(site) {
|
|
document.getElementById('f-site').value = site;
|
|
document.getElementById('f-username').value = allCredentials[site].username || '';
|
|
document.getElementById('f-password').value = allCredentials[site].password || '';
|
|
document.getElementById('form-title').textContent = `Edit Credentials — ${site}`;
|
|
document.getElementById('f-site').focus();
|
|
document.getElementById('f-site').scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
}
|
|
|
|
async function deleteSite(site) {
|
|
if (!confirm(`Delete credentials for ${site}?`)) return;
|
|
await fetch(`/credentials/${encodeURIComponent(site)}`, { method: 'DELETE' });
|
|
delete allCredentials[site];
|
|
renderList();
|
|
}
|
|
|
|
async function submitForm() {
|
|
const site = document.getElementById('f-site').value.trim().replace(/^www\./, '');
|
|
const username = document.getElementById('f-username').value.trim();
|
|
const password = document.getElementById('f-password').value;
|
|
|
|
if (!site) { alert('Please enter a site domain.'); return; }
|
|
|
|
await fetch('/credentials', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({ site, username, password })
|
|
});
|
|
|
|
allCredentials[site] = { username, password };
|
|
renderList();
|
|
|
|
document.getElementById('f-site').value = '';
|
|
document.getElementById('f-username').value = '';
|
|
document.getElementById('f-password').value = '';
|
|
document.getElementById('form-title').textContent = 'Add Credentials';
|
|
|
|
const fb = document.getElementById('form-feedback');
|
|
fb.textContent = `Saved credentials for ${site}`;
|
|
setTimeout(() => fb.textContent = '', 2500);
|
|
}
|
|
|
|
function toggleFormPw() {
|
|
const input = document.getElementById('f-password');
|
|
input.type = input.type === 'password' ? 'text' : 'password';
|
|
}
|
|
|
|
loadCredentials();
|
|
</script>
|
|
</body>
|
|
</html>
|