555 lines
25 KiB
JavaScript
555 lines
25 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; }
|
||
}
|
||
|
||
async function refresh() {
|
||
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 (!document.hidden && !$('siteModal')?.open) refresh().catch(() => {}); }, 1500);
|
||
refresh().catch(failed);
|