Signed in, one refresh pass (session, then the vault index and the vault list, then the extension) takes longer than the 1.5 s poll. Every poll started a new pass, and the pass in flight saw the newer generation and returned before rendering, so on persimmons.app the Master vault stayed an empty box while every request returned 200 (Tea logs, 2026-10-12: session every ~1.5 s, page-index and vaults ~1 s each, almost never a full pass). refresh() now runs one pass at a time: the poll skips while one is in flight, and a refresh asked for during one (an action, focus) runs once more afterwards. The three identical copies (app.js, assets/app.js, backend/web/app.js) change together. New test extension/tests/homepage-poll.test.mjs holds every request until after the next poll: it fails on the old code (nothing renders) and passes now. Extension suite: 26/26. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wyb3QQDESFkosKRXzrXqwr
569 lines
26 KiB
JavaScript
569 lines
26 KiB
JavaScript
import {updateState} from './version.mjs';
|
||
|
||
const $ = id => document.getElementById(id);
|
||
const EXTENSION_ID = 'kiolgjoohhgdohcafgelhikpgkbmckbg';
|
||
let generation = 0, currentOwner, currentState, dragging = false, juiceBusy = false;
|
||
const masterItems = new Map();
|
||
const openVaults = new Set();
|
||
|
||
$('signOutButton').hidden = true;
|
||
$('signOutButton').textContent = 'Sign out';
|
||
$('userList').innerHTML = `
|
||
<div id="vaultSummary" class="vaultSummary" hidden>
|
||
<span id="extensionState" class="lockIcon" role="img"></span>
|
||
<div><strong id="vaultCount"></strong><div id="extensionHelp" class="hint"></div></div>
|
||
</div>
|
||
<div id="onboarding" class="empty">
|
||
<p id="onboardingHelp"></p>
|
||
<button id="createAccount" class="primary" hidden>Create account</button>
|
||
<button id="getExtension" class="primary" hidden>Get extension</button>
|
||
<button id="updateExtension" hidden>Update available</button>
|
||
</div>
|
||
<div id="vaultItems"></div>`;
|
||
|
||
function extensionMessage(type, owner, extra = {}) {
|
||
return new Promise(resolve => {
|
||
const timer = setTimeout(() => resolve(null), type === 'persimmons.juice.v1' ? 180000 : 2500);
|
||
try {
|
||
chrome.runtime.sendMessage(EXTENSION_ID, {type, owner, ...extra}, result => {
|
||
const failed = chrome.runtime.lastError;
|
||
clearTimeout(timer);
|
||
resolve(failed ? null : result);
|
||
});
|
||
} catch {
|
||
clearTimeout(timer);
|
||
resolve(null);
|
||
}
|
||
});
|
||
}
|
||
|
||
const extensionStatus = owner => extensionMessage('persimmons.connection.v1', owner);
|
||
const extensionInventory = owner => extensionMessage('persimmons.inventory.v1', owner);
|
||
|
||
async function latestVersion() {
|
||
try {
|
||
const response = await fetch('/assets/version.json', {cache: 'no-store'});
|
||
return response.ok ? (await response.json()).version : null;
|
||
} catch { return null; }
|
||
}
|
||
|
||
// One refresh at a time. Signed in, a pass (session, then the vault index and the vault list, then the extension)
|
||
// takes longer than the 1.5 s poll, and each new pass used to make the one in flight bail out before rendering, so
|
||
// the vault stayed empty forever. Now a refresh asked for during another runs once more after it (actions, focus),
|
||
// and the poll simply skips while one is running.
|
||
let refreshing = null, refreshAgain = false;
|
||
function refresh() {
|
||
if (refreshing) { refreshAgain = true; return refreshing; }
|
||
refreshing = (async () => {
|
||
try { do { refreshAgain = false; await refreshOnce(); } while (refreshAgain); }
|
||
finally { refreshing = null; }
|
||
})();
|
||
return refreshing;
|
||
}
|
||
|
||
async function refreshOnce() {
|
||
if (dragging || juiceBusy) return;
|
||
const current = ++generation;
|
||
const response = await fetch('/api/session', {credentials: 'same-origin', cache: 'no-store'});
|
||
if (current !== generation) return;
|
||
if (!response.ok && response.status !== 401) throw new Error();
|
||
|
||
const signedIn = response.ok;
|
||
const identity = signedIn ? await response.json() : null;
|
||
const owner = identity?.owner || identity?.login?.toLowerCase() || identity?.user?.login?.toLowerCase() || identity?.name?.toLowerCase();
|
||
const [state, latest, vault, named] = await Promise.all([
|
||
extensionStatus(owner),
|
||
latestVersion(),
|
||
signedIn
|
||
? fetch('/api/vault-index', {credentials: 'same-origin', cache: 'no-store'})
|
||
.then(async response => response.ok ? response.json() : null)
|
||
.catch(() => null)
|
||
: Promise.resolve(null),
|
||
signedIn ? fetch('/api/vaults', {credentials: 'same-origin', cache: 'no-store'})
|
||
.then(async response => response.ok ? response.json() : null).catch(() => null) : null
|
||
]);
|
||
if (current !== generation) return;
|
||
const decrypted = state?.unlocked ? await extensionInventory(owner) : null;
|
||
if (current !== generation || dragging || juiceBusy) return;
|
||
|
||
$('signInLink').hidden = signedIn;
|
||
$('signOutButton').hidden = !signedIn;
|
||
$('addCredential').hidden = !signedIn || !state?.connected || !state?.initialized;
|
||
$('userState').hidden = true;
|
||
$('userState').textContent = '';
|
||
const displayName = identity?.name || identity?.login || identity?.user?.login || owner || '';
|
||
$('userIdentity').textContent = signedIn ? 'Signed in as ' + displayName : '';
|
||
currentOwner = owner; currentState = state;
|
||
if (!await renderVaults(signedIn, named, current)) return;
|
||
if (current !== generation || dragging || juiceBusy) return;
|
||
|
||
const stale = !!state && updateState(state.version, latest) === 'Update available';
|
||
$('updateExtension').hidden = !signedIn || !stale;
|
||
$('createAccount').hidden = signedIn;
|
||
$('getExtension').hidden = !signedIn || !!state;
|
||
|
||
if (!signedIn) {
|
||
$('vaultSummary').hidden = true;
|
||
$('onboarding').hidden = false;
|
||
$('onboardingHelp').textContent = 'Sign in or create an account to use your encrypted vault.';
|
||
$('vaultItems').replaceChildren();
|
||
return;
|
||
}
|
||
|
||
const linked = state?.connected === true;
|
||
const ready = linked && state.initialized;
|
||
$('vaultSummary').hidden = !ready;
|
||
$('onboarding').hidden = ready;
|
||
$('extensionState').textContent = state?.unlocked ? '🔓' : '🔒';
|
||
$('extensionState').ariaLabel = state?.unlocked ? 'Vault unlocked' : 'Vault locked';
|
||
$('extensionState').title = $('extensionState').ariaLabel;
|
||
$('extensionHelp').textContent = state?.unlocked ? 'Drag a credential to a named vault to create Juice.' : 'Open 🍊 to unlock. Choose a timed auto-lock in Settings for Juice.';
|
||
$('onboardingHelp').textContent =
|
||
!state ? 'Install Persimmons in this browser.' :
|
||
!linked ? 'Open 🍊 and choose Link vault.' :
|
||
!state.initialized ? 'Open 🍊 and choose a vault passphrase.' :
|
||
'';
|
||
|
||
const inventory = $('vaultItems');
|
||
inventory.replaceChildren();
|
||
const serverItems = Array.isArray(vault?.items) ? vault.items : null;
|
||
const extensionItems = Array.isArray(decrypted?.items) ? decrypted.items : null;
|
||
const sourceItems = serverItems || extensionItems;
|
||
const vaultAvailable = Array.isArray(sourceItems);
|
||
const items = vaultAvailable ? sourceItems.filter(item => {
|
||
try { return /^[0-9a-f]{32}$/.test(item.id) && new URL(item.origin).protocol === 'https:'; }
|
||
catch { return false; }
|
||
}) : [];
|
||
$('vaultCount').textContent = !vaultAvailable ? 'Credential list unavailable' :
|
||
items.length === 1 ? '1 saved credential' : items.length + ' saved credentials';
|
||
masterItems.clear();
|
||
for (const item of items) masterItems.set(item.id, item);
|
||
if (!items.length) {
|
||
const empty = document.createElement('div');
|
||
empty.className = 'empty';
|
||
empty.textContent = vaultAvailable ?
|
||
'No saved credentials yet. Add one from the site where you use it.' :
|
||
'Credentials could not be loaded. The page will retry automatically.';
|
||
inventory.append(empty);
|
||
} else {
|
||
const usernames = new Map((decrypted?.items || []).map(item => [item.id, item.username]));
|
||
for (const item of items) {
|
||
const url = new URL(item.origin);
|
||
const username = item.username || usernames.get(item.id);
|
||
const card = document.createElement('article');
|
||
card.className = 'connection';
|
||
card.dataset.sourceId = item.id;
|
||
card.draggable = !!(state?.connected && state?.initialized && state?.unlocked);
|
||
card.ondragstart = event => {
|
||
dragging = true;
|
||
event.dataTransfer.effectAllowed = 'copy';
|
||
event.dataTransfer.setData('application/x-persimmons-source', item.id);
|
||
};
|
||
card.ondragend = () => { dragging = false; };
|
||
|
||
const top = document.createElement('div');
|
||
top.className = 'cardtop';
|
||
const description = document.createElement('div');
|
||
const name = document.createElement('div');
|
||
name.className = 'title';
|
||
name.textContent = '🔑 ' + (username || url.hostname);
|
||
const label = document.createElement('div');
|
||
label.className = 'account';
|
||
label.textContent = username ? 'Website credential' : 'Credential · ID ' + item.id.slice(-6).toUpperCase();
|
||
description.append(name, label);
|
||
const status = document.createElement('span');
|
||
status.className = 'badge';
|
||
status.textContent = state?.unlocked ? '🔓' : '🔒';
|
||
status.ariaLabel = state?.unlocked ? 'Vault unlocked' : 'Vault locked';
|
||
status.title = status.ariaLabel;
|
||
top.append(description, status);
|
||
|
||
const source = document.createElement('div');
|
||
source.className = 'credentialSource';
|
||
const sourceLabel = document.createElement('span');
|
||
sourceLabel.textContent = 'Source';
|
||
const sourceLink = document.createElement('a');
|
||
sourceLink.href = item.origin;
|
||
sourceLink.target = '_blank';
|
||
sourceLink.rel = 'noreferrer';
|
||
sourceLink.textContent = item.origin;
|
||
source.append(sourceLabel, sourceLink);
|
||
|
||
const mask = document.createElement('div');
|
||
mask.className = 'mask';
|
||
mask.textContent = '••••••••••••••••';
|
||
const capability = document.createElement('span');
|
||
capability.className = 'scope';
|
||
capability.textContent = 'Fill only on ' + url.hostname;
|
||
const metadata = document.createElement('div');
|
||
metadata.className = 'meta credentialId';
|
||
metadata.textContent = 'ID ' + item.id.slice(-6);
|
||
|
||
const actions = document.createElement('div');
|
||
actions.className = 'actions';
|
||
const open = document.createElement('a');
|
||
open.className = 'buttonLink';
|
||
open.href = item.origin;
|
||
open.target = '_blank';
|
||
open.rel = 'noreferrer';
|
||
open.textContent = 'Open site';
|
||
const remove = document.createElement('button');
|
||
remove.type = 'button';
|
||
remove.className = 'danger';
|
||
remove.textContent = 'Delete';
|
||
remove.onclick = async () => {
|
||
if (!confirm('Delete this saved credential? This cannot be undone.')) return;
|
||
remove.disabled = true;
|
||
try {
|
||
const deleted = await fetch('/api/vault-index/' + item.id, {
|
||
method: 'DELETE', credentials: 'same-origin', headers: {'X-Requested-With': 'Persimmons'}
|
||
});
|
||
if (!deleted.ok) throw new Error();
|
||
await refresh();
|
||
} catch {
|
||
remove.disabled = false;
|
||
$('userState').hidden = false;
|
||
$('userState').textContent = 'Delete unavailable. Retry shortly.';
|
||
}
|
||
};
|
||
actions.append(open, remove);
|
||
card.append(top, source, mask, capability, metadata, actions);
|
||
inventory.append(card);
|
||
}
|
||
}
|
||
}
|
||
|
||
async function renderVaults(signedIn, result, current) {
|
||
const contents = new Map();
|
||
if (signedIn && Array.isArray(result?.vaults)) await Promise.all(result.vaults.map(async vault => {
|
||
try { contents.set(vault.id, await pageJuice('list', vault)); } catch {}
|
||
}));
|
||
// Resolve all network reads before touching cards. Never replace a drag target mid-gesture.
|
||
if (current !== generation || dragging || juiceBusy) return false;
|
||
$('namedVaults').hidden = !signedIn;
|
||
const list = $('namedVaultList');
|
||
list.replaceChildren();
|
||
if (!signedIn) return true;
|
||
if (!Array.isArray(result?.vaults) || !result.vaults.length) {
|
||
const empty = document.createElement('p');
|
||
empty.className = 'empty';
|
||
empty.textContent = Array.isArray(result?.vaults) ? 'No named vaults yet. Create a private vault when you need one.' : 'Vaults could not be loaded. Retrying shortly.';
|
||
list.append(empty);
|
||
return true;
|
||
}
|
||
for (const vault of result.vaults) {
|
||
const card = document.createElement('article');
|
||
card.className = 'connection namedVaultCard';
|
||
card.dataset.vaultId = vault.id;
|
||
card.ondragover = event => {
|
||
if (!currentState?.connected || !currentState?.initialized || !currentState?.unlocked ||
|
||
!event.dataTransfer.types.includes('application/x-persimmons-source')) return;
|
||
event.preventDefault(); event.dataTransfer.dropEffect = 'copy'; card.classList.add('juiceHover');
|
||
};
|
||
card.ondragleave = event => { if (!card.contains(event.relatedTarget)) card.classList.remove('juiceHover'); };
|
||
card.ondrop = event => {
|
||
event.preventDefault(); dragging = false; card.classList.remove('juiceHover');
|
||
const item = masterItems.get(event.dataTransfer.getData('application/x-persimmons-source'));
|
||
if (item && currentState?.connected && currentState?.initialized && currentState?.unlocked) openJuice(item, vault);
|
||
};
|
||
|
||
|
||
const top = document.createElement('div');
|
||
top.className = 'cardtop';
|
||
const description = document.createElement('div');
|
||
const name = document.createElement('div');
|
||
name.className = 'title';
|
||
name.textContent = '📦 ' + vault.name;
|
||
const owner = document.createElement('div');
|
||
owner.className = 'account';
|
||
owner.textContent = vault.owner + '/' + vault.name;
|
||
description.append(name, owner);
|
||
|
||
const badge = document.createElement('span');
|
||
badge.className = 'badge active';
|
||
badge.textContent = 'private ✓';
|
||
top.append(description, badge);
|
||
|
||
const meta = document.createElement('div');
|
||
meta.className = 'namedVaultMeta';
|
||
const state = document.createElement('span');
|
||
state.className = 'namedVaultBadge';
|
||
const juiceCount = contents.get(vault.id)?.items.length;
|
||
state.textContent = juiceCount ? juiceCount + ' Juice' : vault.empty ? 'Empty vault' : 'Vault';
|
||
meta.append(state);
|
||
|
||
const actions = document.createElement('div');
|
||
actions.className = 'namedVaultActions';
|
||
const open = document.createElement('button');
|
||
open.type = 'button';
|
||
open.textContent = 'Open vault';
|
||
|
||
const body = document.createElement('div');
|
||
body.className = 'namedVaultBody';
|
||
body.hidden = !openVaults.has(vault.owner + '/' + vault.name);
|
||
const bodyTitle = document.createElement('strong');
|
||
bodyTitle.textContent = vault.name;
|
||
const bodyText = document.createElement('span');
|
||
bodyText.textContent = 'Drop an unlocked Master credential here to create Juice.';
|
||
body.append(bodyTitle, bodyText);
|
||
try {
|
||
const result = contents.get(vault.id);
|
||
for (const item of result.items) body.append(juiceCard(item, vault));
|
||
if (result.items.length) bodyText.remove();
|
||
} catch { bodyText.textContent = 'Juice metadata unavailable. Retry shortly.'; }
|
||
|
||
|
||
open.textContent = body.hidden ? 'Open vault' : 'Close vault';
|
||
open.onclick = () => {
|
||
const key = vault.owner + '/' + vault.name;
|
||
if (openVaults.has(key)) openVaults.delete(key);
|
||
else openVaults.add(key);
|
||
body.hidden = !openVaults.has(key);
|
||
open.textContent = body.hidden ? 'Open vault' : 'Close vault';
|
||
};
|
||
actions.append(open);
|
||
|
||
card.append(top, meta, actions, body);
|
||
list.append(card);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
async function pageJuice(operation, vault, extra = {}) {
|
||
const response = await fetch('/api/juice/' + operation, {method: 'POST', credentials: 'same-origin', cache: 'no-store',
|
||
headers: {'Content-Type': 'application/json', 'X-Vault-Owner': currentOwner, 'X-Requested-With': 'Persimmons'},
|
||
body: JSON.stringify({target: {id: vault.id, name: vault.name}, ...extra})});
|
||
if (!response.ok) throw new Error();
|
||
return response.json();
|
||
}
|
||
|
||
function juiceCard(item, vault) {
|
||
const card = document.createElement('article');
|
||
card.className = 'juiceCard'; card.dataset.juiceId = item.id;
|
||
const title = document.createElement('strong');
|
||
title.textContent = '💧 Gitea · ' + item.source_username + '@' + new URL(item.source_origin).hostname;
|
||
const details = document.createElement('p');
|
||
details.textContent = item.scopes.join(', ') + ' · ' + item.status + ' · ' + item.created_at;
|
||
const boundary = document.createElement('p');
|
||
boundary.textContent = 'Provider API-class scopes; not restricted to this vault or a single repository.';
|
||
const status = document.createElement('p');
|
||
status.textContent = 'Target secret: ' + item.target_status + ' · Provider token: ' + item.provider_status;
|
||
if (item.status === 'partial-failure' || item.status === 'pending') status.textContent += ' · ANDON: cleanup may be required. Retry Revoke.';
|
||
const actions = document.createElement('div'); actions.className = 'actions';
|
||
for (const action of ['rotate', 'revoke']) {
|
||
const button = document.createElement('button'); button.textContent = action === 'rotate' ? 'Rotate' : 'Revoke';
|
||
button.disabled = action === 'rotate' && (item.status !== 'active' || !currentState?.unlocked);
|
||
if (action === 'revoke' && item.status === 'revoked') button.disabled = true;
|
||
button.onclick = async () => {
|
||
juiceBusy = true; button.disabled = true;
|
||
try {
|
||
if (action === 'revoke') await pageJuice('remove', vault, {id: item.id});
|
||
const result = await extensionMessage('persimmons.juice.v1', currentOwner,
|
||
{action, target: {id: vault.id, name: vault.name}, id: item.id});
|
||
if (!result && action === 'revoke') {
|
||
status.textContent = 'ANDON: Target secret removed; provider revocation unconfirmed. Connect and unlock the source to retry.';
|
||
} else if (!result?.ok) {
|
||
status.textContent = (action === 'revoke' ? 'ANDON: Target secret removed; provider cleanup unconfirmed. ' : '') +
|
||
(result?.message || 'ANDON: Operation outcome unknown. Refresh and inspect Juice before retrying.');
|
||
} else {
|
||
openVaults.add(vault.owner + '/' + vault.name);
|
||
juiceBusy = false; await refresh();
|
||
}
|
||
} catch { status.textContent = 'ANDON: Operation outcome unknown. Inspect Juice before retrying.'; }
|
||
finally { juiceBusy = false; button.disabled = false; }
|
||
};
|
||
actions.append(button);
|
||
}
|
||
card.append(title, details, boundary, status, actions);
|
||
return card;
|
||
}
|
||
|
||
function openJuice(item, vault) {
|
||
const dialog = ensureDialog(), body = $('siteModalBody');
|
||
body.replaceChildren(closeButton(dialog));
|
||
const title = document.createElement('h2');
|
||
title.style.cssText = 'font-size:20px;overflow-wrap:anywhere;line-height:1.3';
|
||
title.textContent = 'Juice ' + (item.username || 'credential') + '@' + new URL(item.origin).hostname + ' → ' + vault.name;
|
||
body.append(title);
|
||
if (item.origin !== 'https://teahub.xyz') {
|
||
const note = document.createElement('p'); note.textContent = 'Juice adapter not available yet.'; body.append(note);
|
||
} else {
|
||
const form = document.createElement('form');
|
||
form.innerHTML = `<label>Gitea PAT scopes<select name="scope"><option value="read:repository">Read repositories</option><option value="read:issue">Read issues</option><option value="write:issue">Read and write issues</option></select></label>
|
||
<p class="hint">Scopes apply across the source account’s accessible resources in that API category. They are not limited to this vault or one repository. No automatic expiry.</p>
|
||
<button type="submit" class="primary">Create Juice</button><p role="status"></p>`;
|
||
form.onsubmit = async event => {
|
||
event.preventDefault(); juiceBusy = true; form.querySelector('button').disabled = true;
|
||
const result = await extensionMessage('persimmons.juice.v1', currentOwner, {action: 'create', source_id: item.id,
|
||
target: {id: vault.id, name: vault.name}, policy: {scopes: [form.elements.scope.value]}});
|
||
juiceBusy = false;
|
||
if (result?.ok) {
|
||
openVaults.add(vault.owner + '/' + vault.name); dialog.close(); await refresh();
|
||
} else {
|
||
form.querySelector('[role=status]').textContent = result?.message || 'ANDON: Outcome unknown. Close and inspect Juice before retrying.';
|
||
form.querySelector('button').disabled = false;
|
||
}
|
||
};
|
||
body.append(form);
|
||
}
|
||
dialog.showModal();
|
||
}
|
||
|
||
function openNewVault() {
|
||
const dialog = ensureDialog();
|
||
const body = $('siteModalBody');
|
||
body.replaceChildren(closeButton(dialog));
|
||
const title = document.createElement('h2');
|
||
title.textContent = 'New vault';
|
||
const form = document.createElement('form');
|
||
form.innerHTML = `<label>Vault name<input id="vaultName" name="name" autocomplete="off" maxlength="100" required></label>
|
||
<p class="hint">Creates an empty private vault owned by you. Spaces become hyphens.</p>
|
||
<button type="submit" class="primary">Create vault</button><p id="vaultStatus" role="status"></p>`;
|
||
form.onsubmit = async event => {
|
||
event.preventDefault();
|
||
form.querySelector('button').disabled = true;
|
||
try {
|
||
const response = await fetch('/api/vaults', {method: 'POST', credentials: 'same-origin',
|
||
headers: {'Content-Type': 'application/json', 'X-Requested-With': 'Persimmons'},
|
||
body: JSON.stringify({name: form.querySelector('#vaultName').value})});
|
||
const result = await response.json();
|
||
if (!response.ok) {
|
||
form.querySelector('#vaultStatus').textContent = result.error || 'Vault creation unavailable. Retry shortly.';
|
||
form.querySelector('button').disabled = false;
|
||
return;
|
||
}
|
||
dialog.close();
|
||
await refresh();
|
||
} catch {
|
||
form.querySelector('#vaultStatus').textContent = 'Vault creation unavailable. Refresh the page before retrying.';
|
||
form.querySelector('button').disabled = false;
|
||
}
|
||
};
|
||
body.append(title, form);
|
||
dialog.showModal();
|
||
}
|
||
|
||
function ensureDialog() {
|
||
let dialog = $('siteModal');
|
||
if (dialog) return dialog;
|
||
dialog = document.createElement('dialog');
|
||
dialog.id = 'siteModal';
|
||
dialog.style.cssText = 'border:0;border-radius:16px;padding:0;width:min(460px,calc(100vw - 32px));box-shadow:0 24px 80px rgba(36,26,20,.24)';
|
||
dialog.innerHTML = '<div id="siteModalBody" style="padding:22px;background:#fffdfa"></div>';
|
||
document.body.append(dialog);
|
||
dialog.addEventListener('close', () => refresh().catch(failed));
|
||
return dialog;
|
||
}
|
||
|
||
function closeButton(dialog) {
|
||
const button = document.createElement('button');
|
||
button.type = 'button';
|
||
button.textContent = '×';
|
||
button.setAttribute('aria-label', 'Close');
|
||
button.style.cssText = 'float:right;border:0;background:none;font-size:22px';
|
||
button.onclick = () => dialog.close();
|
||
return button;
|
||
}
|
||
|
||
function openAuth(mode = 'login') {
|
||
const dialog = ensureDialog();
|
||
const body = $('siteModalBody');
|
||
body.replaceChildren(closeButton(dialog));
|
||
const title = document.createElement('h2');
|
||
title.textContent = mode === 'register' ? 'Create account' : 'Sign in';
|
||
const form = document.createElement('form');
|
||
form.autocomplete = 'on';
|
||
form.innerHTML = `
|
||
<label>Username<input id="modalName" name="username" autocomplete="username" autocapitalize="none" spellcheck="false" minlength="3" maxlength="64" required></label>
|
||
<label>Password<input id="modalPassword" name="password" type="password" autocomplete="current-password" minlength="12" maxlength="1024" required></label>
|
||
<button type="submit" class="primary">${mode === 'register' ? 'Create account' : 'Sign in'}</button>
|
||
<p id="modalStatus" role="status"></p>`;
|
||
form.onsubmit = async event => {
|
||
event.preventDefault();
|
||
const password = form.querySelector('#modalPassword');
|
||
const payload = {name: form.querySelector('#modalName').value, password: password.value};
|
||
password.value = '';
|
||
form.querySelector('button').disabled = true;
|
||
try {
|
||
const response = await fetch('/api/auth/' + mode, {
|
||
method: 'POST', credentials: 'same-origin',
|
||
headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload)
|
||
});
|
||
payload.password = '';
|
||
if (!response.ok) throw new Error();
|
||
dialog.close();
|
||
await refresh();
|
||
} catch {
|
||
payload.password = '';
|
||
form.querySelector('#modalStatus').textContent = 'Sign-in unavailable. Check details and retry.';
|
||
form.querySelector('button').disabled = false;
|
||
}
|
||
};
|
||
body.append(title, form);
|
||
dialog.showModal();
|
||
}
|
||
|
||
function openInstall(update = false) {
|
||
const dialog = ensureDialog();
|
||
const body = $('siteModalBody');
|
||
body.replaceChildren(closeButton(dialog));
|
||
const title = document.createElement('h2');
|
||
title.textContent = update ? 'Update Persimmons' : 'Get Persimmons';
|
||
const text = document.createElement('p');
|
||
text.textContent = update ? 'Install the current build into your unpacked extension folder, then reload it in Chromium.' : 'Download the current build, unzip it, then use Chromium → Extensions → Developer mode → Load unpacked.';
|
||
const link = document.createElement('a');
|
||
link.href = '/assets/extension.zip';
|
||
link.className = 'buttonLink primary';
|
||
link.textContent = update ? 'Download current build' : 'Download extension';
|
||
body.append(title, text, link);
|
||
dialog.showModal();
|
||
}
|
||
|
||
function openAddCredential() {
|
||
const dialog = ensureDialog();
|
||
const body = $('siteModalBody');
|
||
body.replaceChildren(closeButton(dialog));
|
||
const title = document.createElement('h2');
|
||
title.textContent = 'Add a credential';
|
||
const text = document.createElement('p');
|
||
text.textContent = 'Open the HTTPS site where you use the credential, enter it without submitting, then open 🍊 and choose Save for this site.';
|
||
const note = document.createElement('p');
|
||
note.className = 'hint';
|
||
note.textContent = 'The page updates automatically after the extension saves it.';
|
||
body.append(title, text, note);
|
||
dialog.showModal();
|
||
}
|
||
|
||
const failed = () => {
|
||
$('userState').hidden = false;
|
||
$('userState').textContent = 'Service unavailable. Retry shortly.';
|
||
$('extensionState').hidden = false;
|
||
$('extensionState').textContent = 'Connection could not be checked.';
|
||
};
|
||
|
||
$('signOutButton').onclick = async () => {
|
||
try {
|
||
const response = await fetch('/api/logout', {method: 'POST'});
|
||
if (!response.ok) throw new Error();
|
||
await refresh();
|
||
} catch {
|
||
$('userState').hidden = false;
|
||
$('userState').textContent = 'Sign-out unavailable. Retry shortly.';
|
||
}
|
||
};
|
||
$('signInLink').onclick = event => { event.preventDefault(); openAuth('login'); };
|
||
$('createAccount').onclick = () => openAuth('register');
|
||
$('getExtension').onclick = () => openInstall(false);
|
||
$('updateExtension').onclick = () => openInstall(true);
|
||
$('addCredential').onclick = openAddCredential;
|
||
$('newVault').onclick = openNewVault;
|
||
|
||
window.addEventListener('pointerdown', event => {
|
||
if (event.target.closest?.('.connection[draggable="true"]')) dragging = true;
|
||
}, true);
|
||
window.addEventListener('pointerup', () => { dragging = false; }, true);
|
||
window.addEventListener('pageshow', () => refresh().catch(failed));
|
||
window.addEventListener('focus', () => refresh().catch(failed));
|
||
setInterval(() => { if (!refreshing && !document.hidden && !$('siteModal')?.open) refresh().catch(() => {}); }, 1500);
|
||
refresh().catch(failed);
|