Auth-herding: roller, kontoadmin, token-revokering + søkbar planliste + neon

Auth (app-modus):
- Roller admin/standard. Admin oppretter/sletter kontoer, resetter passord,
  endrer roller (konto-modal). Eksisterende brukere → admin ved migrering;
  bootstrap-bruker via proxy-bypass → admin. Siste admin vernet.
- Tilbakekalling via per-bruker epoke i tokenet: passordbytte, admin-reset,
  «Logg ut alle enheter» og sletting dreper alle aktive sesjoner. Token-format
  endret (3- → 4-delt) — alle logges ut én gang ved oppgradering.

Dagsplan: søkbar/velgbar planliste («📋 Planer») avledet fra planIndex —
finn plan på navn/dato/person uten å gjette dato.

GUI: login-neon trukket inn i hovedappen (header/knapper/KPI-kanter), og
login-siden fikk mer tech (HUD-hjørner, glitch-tittel, scanlines).

40 integrasjonstester (6 nye), alle grønne. README + CLAUDE.md oppdatert.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jon Vanvik 2026-06-15 14:56:39 +02:00
parent f6268440d9
commit 3f69cd4248
9 changed files with 518 additions and 36 deletions

View file

@ -48,7 +48,7 @@ function catName(id) { return (catById(id) || {}).name || id; }
const state = {
user: 'anon',
canSetName: false,
auth: { mode: 'proxy', via: null, hasPassword: false },
auth: { mode: 'proxy', via: null, hasPassword: false, role: 'standard', isAdmin: false },
tasks: [],
activity: [],
knownUsers: [],
@ -845,6 +845,65 @@ function renderPlan() {
if (own) bindPlanDnD();
}
// ---------- Søkbar planliste ----------
// Avled plansammendrag fra badge-indeksen (allerede filtrert til synlige planer).
function derivePlanList() {
const map = new Map();
for (const entries of Object.values(state.planIndex)) {
for (const e of entries) {
const key = e.user + '|' + e.date;
let g = map.get(key);
if (!g) { g = { user: e.user, date: e.date, name: e.name || '', count: 0 }; map.set(key, g); }
g.count++;
if (!g.name && e.name) g.name = e.name;
}
}
return [...map.values()].sort((a, b) => {
const am = a.user === state.user ? 0 : 1, bm = b.user === state.user ? 0 : 1;
if (am !== bm) return am - bm; // mine planer først
return b.date.localeCompare(a.date); // så nyeste dato først
});
}
function renderPlanPicker(filter = '') {
const q = filter.trim().toLowerCase();
const list = derivePlanList().filter(p => {
if (!q) return true;
return [p.name, p.date, p.user === state.user ? 'meg' : p.user].join(' ').toLowerCase().includes(q);
});
const el = $('plan-picker-list');
if (!list.length) {
el.innerHTML = `<div class="plan-pick-empty">${q ? 'Ingen planer matcher søket.' : 'Ingen planer ennå.'}</div>`;
return;
}
el.innerHTML = list.map(p => {
const me = p.user === state.user;
const d = p.date.slice(8, 10) + '.' + p.date.slice(5, 7) + '.' + p.date.slice(0, 4);
return `<div class="plan-pick-row" onclick="pickPlan('${escapeHtml(p.user)}','${p.date}')">
<span class="pp-date">${d}</span>
<span class="pp-name ${p.name ? '' : 'unnamed'}">${p.name ? escapeHtml(p.name) : '(uten navn)'}</span>
<span class="pp-who ${me ? 'me' : ''}">${me ? 'meg' : escapeHtml(p.user)}</span>
<span class="pp-count">${p.count}</span>
</div>`;
}).join('');
}
function togglePlanPicker(open) {
const panel = $('plan-picker-panel');
const show = open != null ? open : !panel.classList.contains('open');
panel.classList.toggle('open', show);
if (show) {
$('plan-picker-search').value = '';
renderPlanPicker('');
setTimeout(() => $('plan-picker-search').focus(), 30);
}
}
window.pickPlan = function (user, date) {
togglePlanPicker(false);
refreshPlan(date, user);
};
function populatePlanUserSelect() {
const sel = $('plan-view-user');
const users = new Set([state.user, ...state.knownUsers, ...state.online]);
@ -1413,11 +1472,78 @@ function openAccount() {
$('account-current').value = '';
$('account-newpw').value = '';
$('account-newpw2').value = '';
// Logg ut alle enheter krever en lagret konto (ikke ren proxy-bypass)
$('account-logout-all').style.display = has ? '' : 'none';
// Rolle-badge
const badge = $('account-role-badge');
badge.textContent = state.auth.role || 'standard';
badge.className = 'role-badge ' + (state.auth.isAdmin ? 'admin' : 'standard');
badge.style.display = '';
// Admin-seksjon
$('account-admin').style.display = state.auth.isAdmin ? '' : 'none';
if (state.auth.isAdmin) loadUserList();
$('account-modal').classList.add('open');
setTimeout(() => $('account-newpw').focus(), 50);
}
function closeAccount() { $('account-modal').classList.remove('open'); }
// ---------- Kontoadministrasjon (admin) ----------
async function loadUserList() {
const box = $('admin-userlist');
try {
const { users } = await api('GET', '/api/auth/users');
box.innerHTML = users.map(u => {
const me = u.user === state.user;
const toRole = u.role === 'admin' ? 'standard' : 'admin';
return `<div class="admin-user">
<span class="au-name">${escapeHtml(u.user)}</span>
<span class="role-badge ${u.role}">${u.role}</span>
${me ? '<span class="au-me">deg</span>' : `<div class="au-actions">
<button class="icon-btn" title="Gjør til ${toRole}" onclick="adminSetRole('${escapeHtml(u.user)}','${toRole}')"></button>
<button class="icon-btn" title="Tilbakestill passord" onclick="adminResetPassword('${escapeHtml(u.user)}')">🔑</button>
<button class="icon-btn del" title="Slett konto" onclick="adminDeleteUser('${escapeHtml(u.user)}')">🗑</button>
</div>`}
</div>`;
}).join('');
} catch (e) { box.innerHTML = `<div class="plan-empty">Kunne ikke laste brukere: ${escapeHtml(e.message)}</div>`; }
}
async function adminCreateUser() {
const user = $('admin-new-user').value.trim();
const password = $('admin-new-pass').value;
const role = $('admin-new-role').value;
if (!user) { toast('Skriv et brukernavn', { kind: 'warn' }); return; }
if (password.length < 6) { toast('Passord må være minst 6 tegn', { kind: 'warn' }); return; }
try {
await api('POST', '/api/auth/users', { user, password, role });
$('admin-new-user').value = ''; $('admin-new-pass').value = '';
loadUserList();
toast(`Konto «${user}» (${role}) opprettet`);
} catch (e) { toast('Feil: ' + e.message, { kind: 'err' }); }
}
window.adminSetRole = async function (user, role) {
try { await api('PUT', `/api/auth/users/${encodeURIComponent(user)}/role`, { role }); loadUserList(); toast(`${user} er nå ${role}`); }
catch (e) { toast('Feil: ' + e.message, { kind: 'err' }); }
};
window.adminResetPassword = async function (user) {
const pw = prompt(`Nytt passord for ${user} (min 6 tegn). Brukeren blir logget ut overalt:`);
if (pw == null) return;
try { await api('POST', `/api/auth/users/${encodeURIComponent(user)}/password`, { password: pw }); toast(`Passord tilbakestilt for ${user}`); }
catch (e) { toast('Feil: ' + e.message, { kind: 'err' }); }
};
window.adminDeleteUser = async function (user) {
if (!confirm(`Slette kontoen «${user}»? Dette kan ikke angres.`)) return;
try { await api('DELETE', `/api/auth/users/${encodeURIComponent(user)}`); loadUserList(); toast(`Konto «${user}» slettet`); }
catch (e) { toast('Feil: ' + e.message, { kind: 'err' }); }
};
async function logoutAll() {
if (!confirm('Logg ut alle enheter? Du forblir innlogget her, men alle andre økter avsluttes.')) return;
try { await api('POST', '/api/auth/logout-all'); toast('Alle andre økter er logget ut'); }
catch (e) { toast('Feil: ' + e.message, { kind: 'err' }); }
}
async function saveAccountPassword() {
const pw = $('account-newpw').value;
const pw2 = $('account-newpw2').value;
@ -1426,9 +1552,10 @@ async function saveAccountPassword() {
const body = { password: pw };
if ($('account-current-field').style.display !== 'none') body.current = $('account-current').value;
try {
await api('POST', '/api/auth/password', body);
const r = await api('POST', '/api/auth/password', body);
state.auth.hasPassword = true;
state.auth.via = 'session';
if (r.role) { state.auth.role = r.role; state.auth.isAdmin = r.role === 'admin'; }
closeAccount();
renderHeader();
toast('Passord lagret — du er nå innlogget med app-sesjon');
@ -1479,6 +1606,10 @@ function bindUI() {
$('plan-name').addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); e.target.blur(); } });
$('plan-shared').addEventListener('change', togglePlanShared);
$('plan-move-btn').addEventListener('click', movePlanToDate);
$('plan-picker-btn').addEventListener('click', e => { e.stopPropagation(); togglePlanPicker(); });
$('plan-picker-search').addEventListener('input', e => renderPlanPicker(e.target.value));
$('plan-picker-panel').addEventListener('click', e => e.stopPropagation());
document.addEventListener('click', () => togglePlanPicker(false)); // klikk utenfor lukker
$('fieldBtn').addEventListener('click', toggleFieldMode);
bindPlanContainer();
@ -1550,6 +1681,9 @@ function bindUI() {
$('account-cancel').addEventListener('click', closeAccount);
$('account-save').addEventListener('click', saveAccountPassword);
$('account-logout').addEventListener('click', logout);
$('account-logout-all').addEventListener('click', logoutAll);
$('admin-create-btn').addEventListener('click', adminCreateUser);
$('admin-new-pass').addEventListener('keydown', e => { if (e.key === 'Enter') adminCreateUser(); });
$('account-modal').addEventListener('click', e => { if (e.target.id === 'account-modal') closeAccount(); });
$('account-newpw2').addEventListener('keydown', e => { if (e.key === 'Enter') saveAccountPassword(); });