1603 lines
80 KiB
JavaScript
1603 lines
80 KiB
JavaScript
/**
|
|
* identities — PWA Identity Manager for apxtri
|
|
*
|
|
* Variable names: descriptive names throughout (alias, profile, passphrase, etc.).
|
|
* BUILD: formatted with prettier for readability; minified by buildpwa (terser) for production.
|
|
*
|
|
* DRAFT MODE: screen badge + hover data-action.
|
|
* ALL selectors use [data-action]; every clickable element has data-action attribute.
|
|
*/
|
|
if (window.dayjs_plugin_relativeTime) dayjs.extend(window.dayjs_plugin_relativeTime);
|
|
|
|
const APP_TRIBE = document.querySelector('meta[name="xtribe"]')?.content || 'dev',
|
|
App = {
|
|
profiles: [],
|
|
activeIdentity: null,
|
|
auth: null,
|
|
tmpKey: null,
|
|
myworldData: null,
|
|
paganData: null,
|
|
_multiSelectKey: null,
|
|
|
|
_checkRedirect() {
|
|
var params = new URLSearchParams(window.location.search);
|
|
var redirect = params.get('redirect');
|
|
if (redirect) {
|
|
window.location.href = decodeURIComponent(redirect);
|
|
return true;
|
|
}
|
|
return false;
|
|
},
|
|
async handleSetupHandoff() {
|
|
var params = new URLSearchParams(window.location.search);
|
|
var alias = params.get('setup');
|
|
var xdays = params.get('xdays');
|
|
var xhash = params.get('xhash');
|
|
if (!alias || !xdays || !xhash) return false;
|
|
var clean = () => {
|
|
try { history.replaceState({}, '', window.location.pathname + window.location.hash); } catch {}
|
|
};
|
|
try {
|
|
var result = await Auth.authCall(alias, xdays, xhash, 'pagans');
|
|
if (result && result.status === 200) {
|
|
Log.debug('Setup handoff authenticated as', alias);
|
|
clean();
|
|
return true;
|
|
}
|
|
Log.debug('Setup handoff rejected:', result && result.msg);
|
|
} catch (err) {
|
|
Log.error('Setup handoff auth failed:', err.message || err);
|
|
}
|
|
clean();
|
|
return false;
|
|
},
|
|
async init() {
|
|
Log.debug('App.init() starting');
|
|
Auth.initAxios();
|
|
Auth.initTheme();
|
|
await LocalDB.init('mainapp', 'identities');
|
|
this.loadProfiles();
|
|
this.loadActiveIdentity();
|
|
await Menu.init();
|
|
const lang = Auth.getLang();
|
|
Log.debug('Language:', lang);
|
|
await this.loadLang(lang);
|
|
Menu.showScreen('list');
|
|
try {
|
|
const statusResponse = await axios.get('/api/apxtri/system/status', { validateStatus: () => true });
|
|
Log.debug('System status:', statusResponse.data?.data);
|
|
if (statusResponse.data?.data?.initialized === false) {
|
|
Log.debug('System not initialized, redirecting to /setup/');
|
|
window.location.href = '/setup/';
|
|
return;
|
|
}
|
|
} catch {}
|
|
await this.handleSetupHandoff();
|
|
const activeIdentity = Auth.getActiveIdentity();
|
|
const activeAlias = Auth.getActiveAlias();
|
|
Log.debug('Active identity:', activeIdentity, 'Active alias:', activeAlias);
|
|
if (activeIdentity?.profils || activeAlias) {
|
|
const alias = activeIdentity?.alias || activeAlias;
|
|
this.auth = { xalias: alias, xprofils: activeIdentity?.profils || [], xdays: activeIdentity?.xdays };
|
|
this.profiles = Auth.listIdentities().map(
|
|
(name) => ({ name, alias: name, publickey: '', privatekey: '', passphrase: '' })
|
|
);
|
|
this.activeIdentity = { name: alias, alias };
|
|
this.saveProfiles();
|
|
this.saveActiveIdentity();
|
|
Log.debug('Auth restored from cookie:', this.auth);
|
|
}
|
|
if (this.profiles.length === 1 && !this.activeIdentity) {
|
|
this.activeIdentity = this.profiles[0];
|
|
Auth.setActiveAlias(this.profiles[0].alias);
|
|
Log.debug('Auto-selected single profile:', this.activeIdentity);
|
|
}
|
|
if (this.activeIdentity) {
|
|
const identity = Auth.getIdentity(this.activeIdentity.alias);
|
|
if (identity?.protectedKey) {
|
|
Log.debug('Protected key found, trying auto-auth');
|
|
await this.tryAutoAuth(this.activeIdentity.alias, identity.protectedKey);
|
|
}
|
|
}
|
|
this.bindEvents();
|
|
this.render();
|
|
this.setLoading(false);
|
|
if (this.auth) {
|
|
var params = new URLSearchParams(window.location.search);
|
|
var redirect = params.get('redirect');
|
|
if (redirect) { window.location.href = decodeURIComponent(redirect); return; }
|
|
}
|
|
Log.debug('App.init() complete');
|
|
},
|
|
_lgLoaded: {},
|
|
_lgFallback: {
|
|
'app.title': 'Identities - apxtri',
|
|
'app.back': 'Back',
|
|
'app.save': 'Save',
|
|
'screen.titles.list': 'Identities',
|
|
'screen.titles.create': 'Create',
|
|
'screen.titles.signin': 'Sign In',
|
|
'screen.titles.myworld': 'My World',
|
|
'screen.titles.forgetkey': 'Forgot Key',
|
|
'screen.titles.information': 'About',
|
|
'screen.titles.changepassphrase': 'Change Passphrase',
|
|
'list.empty.title': 'Welcome to your identities',
|
|
'list.empty.subtitle': 'Create or import an apxtri identity',
|
|
'list.empty.create': 'Create an identity',
|
|
'list.empty.import': 'I already have a key',
|
|
'create.title': 'Create an identity',
|
|
'create.subtitle': 'Generate a PGP key pair',
|
|
'create.alias_label': 'Alias *',
|
|
'create.alias_hint': 'lowercase (a-z) and digits (0-9), 3-30 chars',
|
|
'create.email_label': 'Email (optional - recovery)',
|
|
'create.passphrase_label': 'Passphrase (optional)',
|
|
'create.keys_generated': 'Keys generated! Download them before continuing.',
|
|
'create.download_pub': 'Public key',
|
|
'create.download_priv': 'Private key',
|
|
'create.download_backup': 'Download my keys',
|
|
'create.trust_label': 'Trust this domain to store my keys',
|
|
'create.register_btn': 'Save this identity',
|
|
'create.generate_btn': 'Generate my keys',
|
|
'signin.title': 'Sign In',
|
|
'signin.subtitle': 'Use your alias and private key',
|
|
'signin.alias_label': 'Alias',
|
|
'signin.passphrase_label': 'Passphrase (if key is protected)',
|
|
'signin.privatekey_label': 'Private key',
|
|
'signin.remember_label': 'Store identity in this browser',
|
|
'signin.btn': 'Sign In',
|
|
'signin.or': 'or',
|
|
'signin.forgot': 'Forgot your key?',
|
|
'myworld.profil_badge': 'authenticated',
|
|
'myworld.badge_member': 'member',
|
|
'myworld.logout': 'Sign Out',
|
|
'myworld.apps_title': 'Applications',
|
|
'myworld.apps_messaging': 'Messaging',
|
|
'myworld.apps_wallet': 'Wallet',
|
|
'myworld.apps_profile': 'Profile',
|
|
'myworld.stats_title': 'Statistics',
|
|
'myworld.stats_profiles': 'Identities',
|
|
'myworld.stats_profiles_desc': 'locally stored',
|
|
'myworld.stats_tribes': 'Tribes',
|
|
'myworld.stats_tribes_desc': 'joined',
|
|
'myworld.stats_major_persons': 'Total Persons',
|
|
'myworld.stats_major_tribes': 'Tribes',
|
|
'myworld.stats_major_gain': 'APX3 / month',
|
|
'myworld.btn_manage_town': 'Manage Town →',
|
|
'myworld.stats_druid_title': 'My Tribes',
|
|
'myworld.btn_manage_tribe': 'Manage →',
|
|
'myworld.stats_person_title': 'My Tribes',
|
|
'myworld.btn_go_tribe': 'Go →',
|
|
'myworld.btn_create_tribe': '+ Create a Tribe',
|
|
'myworld.btn_explore_nation': 'Explore Nation →',
|
|
'myworld.tribe_info_persons': '{{persons}} persons in this town',
|
|
'myworld.faq_title': 'FAQ',
|
|
'myworld.faq_nation_q': 'What is a nation?',
|
|
'myworld.faq_nation_a': 'A nation is a decentralized network of interconnected towns sharing a blockchain. It defines the economic rules, consensus mechanism, and social contract that all towns in the network follow.',
|
|
'myworld.faq_town_q': 'What is a town?',
|
|
'myworld.faq_town_a': 'A town is a server node in the network. It hosts tribes, validates blockchain transactions, stores data, and provides web applications. Each town is owned by a mayor.',
|
|
'myworld.faq_tribe_q': 'What is a tribe?',
|
|
'myworld.faq_tribe_a': 'A tribe is a community or organization within a town. Each tribe has its own data namespace, web applications, membership rules, and access profiles (druid, mayor, persons).',
|
|
'myworld.faq_public_tribes_q': 'How and why access public tribes?',
|
|
'myworld.faq_public_tribes_a': 'Public tribes are open communities anyone can join. Browse the nation explorer to discover towns and their public tribes. Joining gives you access to tribe-specific applications, messaging, and community features.',
|
|
'myworld.faq_apx3_q': 'How to earn APX3?',
|
|
'myworld.faq_apx3_a': 'APX3 tokens are earned by participating in the network. Mayors and druids earn block rewards and transaction fees. You can also earn by providing services to tribes, creating value for the community.',
|
|
'myworld.faq_anon_q': 'Why is anonymity a fundamental freedom?',
|
|
'myworld.faq_anon_a': 'Anonymity allows you to participate without revealing your identity. It protects your privacy, prevents discrimination, and lets you express ideas freely. In apxtri, your identity is based on cryptographic keys — you choose what to share.',
|
|
'myworld.jointribe_title': 'Join a tribe',
|
|
'myworld.jointribe_subtitle': 'Become a member to access applications',
|
|
'myworld.jointribe_btn': 'Join',
|
|
'forgetkey.title': 'Forgot your key?',
|
|
'forgetkey.subtitle': 'Enter your alias or email to receive your keys.',
|
|
'forgetkey.input_placeholder': 'alias or email',
|
|
'forgetkey.btn': 'Send me my keys',
|
|
'information.title': 'Decentralized identity?',
|
|
'information.p1': 'A way to identify yourself by proving you own an alias. This identity is used to inform, pay, exchange.',
|
|
'information.p2': 'It is a pair of text files called <strong>public key</strong> and <strong>private key</strong> (PGP).',
|
|
'information.p3': 'The private key owner can <strong>sign a message</strong> to prove their identity.',
|
|
'information.p4': 'This interface creates an identity to <strong>authenticate for 24h</strong>. Only the alias/public key pair is sent over the network.',
|
|
'information.p5': "<strong class='text-error'>Your private key must never be shared.</strong>",
|
|
'information.p6': 'You can have <strong>as many identities as you want</strong>.',
|
|
'drawer.identities': 'My identities',
|
|
'drawer.myworld': 'Current Identity',
|
|
'drawer.theme': 'Dark theme',
|
|
'drawer.language': 'Language',
|
|
'drawer.about': 'About',
|
|
'drawer.not_connected': 'Not connected',
|
|
'drawer.connected': 'Connected',
|
|
'notify.alias_not_found': 'Alias "{{alias}}" does not exist',
|
|
'notify.verify_error': 'Verification error',
|
|
'notify.sign_error': 'PGP signature error: check your private key and passphrase',
|
|
'notify.network_error': 'Network error: {{message}}',
|
|
'notify.authenticated': 'Signed in as {{alias}}',
|
|
'notify.auth_failed': 'Authentication failed',
|
|
'notify.alias_exists': 'Alias "{{alias}}" already exists',
|
|
'notify.keys_generated': 'Keys generated successfully! Download them before continuing.',
|
|
'notify.keygen_error': 'Key generation error: {{message}}',
|
|
'notify.sign_error_detail': 'Signature error: {{message}}',
|
|
'notify.identity_created': 'Identity "{{alias}}" created successfully!',
|
|
'notify.create_error': 'Creation error: {{message}}',
|
|
'notify.auth_required': 'You must be authenticated',
|
|
'notify.tribe_joined': 'You joined tribe "{{tribe}}"!',
|
|
'notify.tribe_error': 'Error joining tribe',
|
|
'notify.email_sent': 'Email sent if address is valid',
|
|
'notify.recovery_error': 'Recovery error',
|
|
'notify.invalid_alias': 'Invalid alias: 3+ chars, lowercase and digits only',
|
|
'notify.alias_key_required': 'Alias and private key required',
|
|
'notify.enter_alias_email': 'Enter an alias or email',
|
|
'notify.lang_unavailable': 'Language "{{lg}}" not available',
|
|
'notify.delete_confirm': 'Delete identity "{{name}}"?',
|
|
'notify.passphrase_changed': 'Passphrase changed for {{alias}}',
|
|
'notify.passphrase_mismatch': 'New passphrases do not match',
|
|
'notify.passphrase_wrong': 'Current passphrase is incorrect',
|
|
'notify.paste_key': 'Paste your private key',
|
|
'notify.passphrase_required': 'Passphrase required',
|
|
'notify.wrong_passphrase': 'Incorrect passphrase',
|
|
'notify.alias_required': 'Alias required',
|
|
'notify.app_add_identity': 'Create or add an identity',
|
|
'notify.add_identity': 'Add an identity',
|
|
'passphrase.modal.title': 'Passphrase required',
|
|
'passphrase.modal.desc': 'Enter your passphrase to unlock your identity',
|
|
'passphrase.modal.cancel': 'Cancel',
|
|
'passphrase.modal.confirm': 'Unlock',
|
|
'profile.drawer.status': 'Information',
|
|
'install.app': 'Install app',
|
|
'install.modal.title_update': 'Update available',
|
|
'install.modal.new_version': 'A new version of the app is available.',
|
|
'install.modal.update_btn': 'Update',
|
|
'install.modal.later': 'Later',
|
|
'install.modal.title_ios': 'Install on iOS',
|
|
'install.modal.title_installed': 'App installed',
|
|
'install.modal.installed_desc': 'The app is already installed on your device.',
|
|
'install.modal.got_it': 'Got it',
|
|
'placeholder.privatekey': '-----BEGIN PGP PRIVATE KEY BLOCK-----',
|
|
'changepassphrase.title': 'Change Passphrase',
|
|
'changepassphrase.desc': 'You can change the passphrase without changing your PGP key.',
|
|
'changepassphrase.current': 'Current passphrase',
|
|
'changepassphrase.new': 'New passphrase',
|
|
'changepassphrase.confirm': 'Confirm new passphrase',
|
|
'changepassphrase.submit': 'Change passphrase',
|
|
'checkid.open': 'Verify an identity',
|
|
'checkid.title': 'Verify an identity',
|
|
'checkid.help': 'Scan a QR-code or upload an image to verify the signature.',
|
|
'checkid.start_scan': 'Scan',
|
|
'checkid.scan_file': 'File',
|
|
'checkid.valid': 'Signature valid!',
|
|
'checkid.invalid': 'Invalid signature.',
|
|
'myworld.prove_id': 'Sign',
|
|
'proveid.title': 'Prove my identity',
|
|
'proveid.input_hint': 'Enter text (letters a-z, digits 0-9) to sign with your private key.',
|
|
'proveid.sign_btn': 'Sign',
|
|
'proveid.key_label': 'Private key (required)',
|
|
'proveid.passphrase_label': 'Passphrase (if protected)',
|
|
'keyqr.title': 'Private key (QR)',
|
|
'keyqr.warning': 'Only show to trusted people',
|
|
'keyqr.not_available': 'Private key not available. Sign in with your key first.',
|
|
'keyscan.title': 'Scan private key QR',
|
|
'keyscan.hint': 'Point the camera at the QR code or select an image file.',
|
|
'keyscan.scanning': 'Scanning for QR code...',
|
|
'keyscan.scan': 'Scan',
|
|
'keyscan.file': 'File',
|
|
'keyqr.confirm_text': 'Never show your private key as a QR code. Some governments monitor their citizens\' phones (chatcontrol in Europe). This QR code should not be used for sensitive identities (with significant APX3). Are you sure you are not being monitored right now?',
|
|
'keyqr.confirm_show': 'Show my key',
|
|
'keyqr.confirm_cancel': 'Do not show',
|
|
},
|
|
t(key, vars) {
|
|
let str = this._lgLoaded[key] || this._lgFallback[key] || key;
|
|
if (vars) {
|
|
Object.keys(vars).forEach((k) => {
|
|
str = str.replace(`{{${k}}}`, vars[k]);
|
|
});
|
|
}
|
|
return str;
|
|
},
|
|
async loadLang(lang) {
|
|
if (lang === 'en') {
|
|
this._lgLoaded = {};
|
|
this.applyLang();
|
|
return;
|
|
}
|
|
try {
|
|
const response = await axios.get(`static/lg/${lang}.json`, { validateStatus: () => true });
|
|
if (response.status === 200) {
|
|
this._lgLoaded = response.data;
|
|
}
|
|
} catch {}
|
|
this.applyLang();
|
|
},
|
|
applyLang() {
|
|
document.querySelectorAll('[data-i18n]').forEach((el) => {
|
|
const translated = this.t(el.dataset.i18n);
|
|
if (translated && translated !== el.dataset.i18n) {
|
|
if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') {
|
|
if (el.placeholder !== undefined) el.placeholder = translated;
|
|
} else {
|
|
el.innerHTML = translated;
|
|
}
|
|
}
|
|
});
|
|
},
|
|
loadProfiles() {
|
|
this.profiles = Auth.listIdentities().map((e) => ({ name: e, alias: e }));
|
|
},
|
|
saveProfiles() {
|
|
this.profiles.forEach((e) => Auth.addIdentity(e.alias, {}));
|
|
},
|
|
loadActiveIdentity() {
|
|
const e = Auth.getActiveAlias();
|
|
this.activeIdentity = e ? { name: e, alias: e } : null;
|
|
},
|
|
saveActiveIdentity() {
|
|
this.activeIdentity && Auth.setActiveAlias(this.activeIdentity.alias);
|
|
},
|
|
async pgpSignMessage(publicKeyArmored, privateKeyArmored, passphrase, message) {
|
|
const pubKey = await openpgp.readKey({ armoredKey: publicKeyArmored });
|
|
let privKey = await openpgp.readPrivateKey({ armoredKey: privateKeyArmored });
|
|
if (!(await privKey.isDecrypted())) {
|
|
privKey = await openpgp.decryptKey({ privateKey: privKey, passphrase: passphrase || '' });
|
|
}
|
|
const cleartext = await openpgp.createCleartextMessage({ text: message });
|
|
const signed = await openpgp.sign({ message: cleartext, signingKeys: privKey });
|
|
const verified = await openpgp.verify({
|
|
message: await openpgp.readCleartextMessage({ cleartextMessage: signed }),
|
|
verificationKeys: pubKey,
|
|
});
|
|
await verified.signatures[0].verified;
|
|
return btoa(signed);
|
|
},
|
|
async generateKey(alias, passphrase) {
|
|
if (typeof loadWordlist === 'function') await loadWordlist();
|
|
const mnemonic = typeof generateMnemonic === 'function' ? await generateMnemonic() : '';
|
|
const keypair = typeof deterministicKeypair === 'function' ? await deterministicKeypair(alias, passphrase, mnemonic) : {};
|
|
return { alias, privatekey: keypair.privateKey, publickey: keypair.publicKey, passphrase, mnemonic };
|
|
},
|
|
|
|
notify(msg, isError) {
|
|
const msgEl = document.getElementById('msginfo');
|
|
if (msg) {
|
|
msgEl.classList.remove('hidden');
|
|
msgEl.className = 'mb-3 text-sm p-3 rounded-lg ' + (isError ? 'bg-error/10 text-error' : 'bg-success/10 text-success');
|
|
msgEl.textContent = msg;
|
|
setTimeout(() => msgEl.classList.add('hidden'), 8000);
|
|
} else {
|
|
msgEl.classList.add('hidden');
|
|
}
|
|
},
|
|
setLoading(show) {
|
|
const overlay = document.getElementById('loading-overlay');
|
|
if (show) {
|
|
overlay.classList.remove('opacity-0', 'pointer-events-none');
|
|
} else {
|
|
overlay.classList.add('opacity-0');
|
|
setTimeout(() => overlay.classList.add('pointer-events-none'), 300);
|
|
}
|
|
},
|
|
async signinAlias(alias, remember) {
|
|
const privateKey = document.getElementById('signin-privatekey').value.trim();
|
|
const passphrase = document.getElementById('signin-passphrase').value;
|
|
if (privateKey) {
|
|
await this.authWithKey(alias, privateKey, passphrase, remember);
|
|
} else {
|
|
this.notify(this.t('notify.paste_key'), true);
|
|
}
|
|
},
|
|
async tryAutoAuth(alias, protectedKey) {
|
|
Log.debug('tryAutoAuth() for', alias);
|
|
const identity = Auth.getIdentity(alias);
|
|
if (identity?.passphrase) {
|
|
try {
|
|
const decryptedKey = await mnemonicDecryptKey(protectedKey, identity.passphrase);
|
|
Log.debug('Auto-auth with stored passphrase');
|
|
await this.authWithKey(alias, decryptedKey, identity.passphrase, true);
|
|
return;
|
|
} catch {
|
|
Log.debug('Stored passphrase failed, trying alternatives');
|
|
}
|
|
}
|
|
try {
|
|
const decryptedKey = await mnemonicDecryptKey(protectedKey, '');
|
|
Log.debug('No passphrase needed, auth directly');
|
|
await this.authWithKey(alias, decryptedKey, '', true);
|
|
return;
|
|
} catch {
|
|
Log.debug('Passphrase required, showing modal');
|
|
}
|
|
const passphrase = await this.showPassphraseModal();
|
|
if (!passphrase) {
|
|
Log.debug('User cancelled passphrase');
|
|
return;
|
|
}
|
|
try {
|
|
const decryptedKey = await mnemonicDecryptKey(protectedKey, passphrase);
|
|
await this.authWithKey(alias, decryptedKey, passphrase, true);
|
|
} catch (err) {
|
|
Log.error('tryAutoAuth failed:', err.message || err);
|
|
this.auth = null;
|
|
const errMsg = err.message || '';
|
|
this.notify(
|
|
/decrypt|session key|invalid session key/i.test(errMsg) ? this.t('notify.wrong_passphrase') : this.t('notify.sign_error'),
|
|
true
|
|
);
|
|
}
|
|
},
|
|
showPassphraseModal: () =>
|
|
new Promise((resolve) => {
|
|
const modal = document.getElementById('passphrase-modal');
|
|
const input = document.getElementById('passphrase-modal-input');
|
|
modal.classList.remove('hidden');
|
|
input.placeholder = input.value || 'Enter your passphrase';
|
|
input.value = '';
|
|
input.focus();
|
|
const confirm = () => {
|
|
modal.classList.add('hidden');
|
|
resolve(input.value);
|
|
};
|
|
document.getElementById('passphrase-modal-confirm').onclick = confirm;
|
|
document.getElementById('passphrase-modal-cancel').onclick = () => {
|
|
modal.classList.add('hidden');
|
|
resolve(null);
|
|
};
|
|
input.onkeydown = (evt) => {
|
|
if (evt.key === 'Enter') confirm();
|
|
};
|
|
}),
|
|
async authWithKey(alias, privateKey, passphrase, remember) {
|
|
Log.debug('authWithKey()', { alias, remember });
|
|
this.setLoading(true);
|
|
this.auth = null;
|
|
try {
|
|
const pubKeyResponse = await axios.get(`/api/apxtri/pagans/alias/${alias}`, { validateStatus: () => true });
|
|
Log.debug('Alias check response:', pubKeyResponse.data);
|
|
if (pubKeyResponse.data.status !== 200) {
|
|
const msg = pubKeyResponse.data.msg === 'alias_not_found'
|
|
? this.t('notify.alias_not_found', { alias })
|
|
: pubKeyResponse.data.msg || this.t('notify.verify_error');
|
|
this.notify(msg, true);
|
|
this.setLoading(false);
|
|
return;
|
|
}
|
|
const publicKey = pubKeyResponse.data.data.publickey;
|
|
const timestamp = String(Date.now());
|
|
const message = `${alias}_${timestamp}`;
|
|
let signedMessage;
|
|
Log.debug('PGP sign message:', message);
|
|
try {
|
|
signedMessage = await this.pgpSignMessage(publicKey, privateKey, passphrase, message);
|
|
} catch (err) {
|
|
Log.error('PGP sign error, asking for passphrase:', err);
|
|
const newPassphrase = await this.showPassphraseModal();
|
|
if (!newPassphrase) {
|
|
this.setLoading(false);
|
|
return;
|
|
}
|
|
try {
|
|
signedMessage = await this.pgpSignMessage(publicKey, privateKey, newPassphrase, message);
|
|
} catch (err2) {
|
|
Log.error('PGP sign still failed:', err2);
|
|
this.notify(this.t('notify.sign_error'), true);
|
|
this.setLoading(false);
|
|
return;
|
|
}
|
|
}
|
|
if (!signedMessage) {
|
|
Log.debug('PGP sign failed');
|
|
this.setLoading(false);
|
|
return;
|
|
}
|
|
Log.debug('PGP sign OK, xhash length:', signedMessage.length);
|
|
const authResult = await Auth.authCall(alias, timestamp, signedMessage, 'pagans');
|
|
Log.debug('Auth call result:', authResult);
|
|
if (authResult.status === 200) {
|
|
this.auth = {
|
|
xalias: alias,
|
|
xdays: timestamp,
|
|
xprofils: authResult.data.xprofils,
|
|
xpublickey: publicKey,
|
|
xprivatekey: privateKey,
|
|
xpassphrase: passphrase || '',
|
|
};
|
|
if (remember) {
|
|
this.profiles = Auth.listIdentities().map((name) => ({ name, alias: name }));
|
|
const exists = this.profiles.find((p) => p.alias === alias);
|
|
if (!exists) this.profiles.push({ name: alias, alias });
|
|
Auth.addIdentity(alias, {
|
|
protectedKey: await mnemonicEncryptKey(privateKey, passphrase || ''),
|
|
passphrase: passphrase || '',
|
|
});
|
|
this.activeIdentity = { name: alias, alias };
|
|
this.saveActiveIdentity();
|
|
this.saveProfiles();
|
|
}
|
|
this.notify(this.t('notify.authenticated', { alias }), false);
|
|
this.setLoading(false);
|
|
await this.refreshMyWorld();
|
|
Menu.showScreen('myworld'); if (this._checkRedirect()) return;
|
|
} else {
|
|
this.setLoading(false);
|
|
this.notify(authResult.msg || this.t('notify.auth_failed'), true);
|
|
}
|
|
} catch (err) {
|
|
this.setLoading(false);
|
|
this.notify(this.t('notify.network_error', { message: err.message }), true);
|
|
}
|
|
},
|
|
async handleCreateIdentity(alias, email, passphrase) {
|
|
Log.debug('handleCreateIdentity()', { alias, email });
|
|
if (!passphrase || passphrase.length < 1) {
|
|
this.notify(this.t('notify.passphrase_required'), true);
|
|
return;
|
|
}
|
|
this.setLoading(true);
|
|
try {
|
|
const checkResponse = await axios.get(`/api/apxtri/pagans/alias/${alias}`, { validateStatus: () => true });
|
|
if (checkResponse.data.status !== 404) {
|
|
this.notify(this.t('notify.alias_exists', { alias }), true);
|
|
this.setLoading(false);
|
|
return;
|
|
}
|
|
const keypair = await this.generateKey(alias, passphrase);
|
|
this.tmpKey = { ...keypair, email, passphrase };
|
|
document.getElementById('create-keys-generated').classList.remove('hidden');
|
|
document.querySelector("[data-action='generate-keys']").classList.add('hidden');
|
|
document.getElementById('create-alias').disabled = true;
|
|
document.getElementById('create-email').disabled = true;
|
|
document.getElementById('create-passphrase').disabled = true;
|
|
this.registerBackupDownload(keypair, email, passphrase);
|
|
this.notify(this.t('notify.keys_generated'), false);
|
|
} catch (err) {
|
|
this.notify(this.t('notify.keygen_error', { message: err.message }), true);
|
|
}
|
|
this.setLoading(false);
|
|
},
|
|
async handleRegisterIdentity() {
|
|
Log.debug('handleRegisterIdentity()');
|
|
this.setLoading(true);
|
|
try {
|
|
const trusted = document.getElementById('create-trusted').checked;
|
|
const { alias, publickey, privatekey, passphrase, email } = this.tmpKey;
|
|
const timestamp = String(Date.now());
|
|
const signedMessage = await this.pgpSignMessage(publickey, privatekey, passphrase, `${alias}_${timestamp}`).catch(
|
|
(err) => {
|
|
this.notify(this.t('notify.sign_error_detail', { message: err.message }), true);
|
|
this.setLoading(false);
|
|
return null;
|
|
}
|
|
);
|
|
if (!signedMessage) return;
|
|
const body = { alias, publickey };
|
|
if (trusted) {
|
|
body.passphrase = passphrase;
|
|
body.privatekey = privatekey;
|
|
}
|
|
if (email) {
|
|
body.email = email;
|
|
body.trustedtribe = true;
|
|
}
|
|
const response = await axios.post('/api/apxtri/pagans', body, {
|
|
headers: { ...Auth.getHeaders(), xalias: alias, xdays: timestamp, xhash: signedMessage, xprofils: 'pagans' },
|
|
withCredentials: true,
|
|
validateStatus: () => true,
|
|
});
|
|
if (response.data.status === 201) {
|
|
this.auth = {
|
|
xalias: alias,
|
|
xdays: timestamp,
|
|
xprofils: response.data.data.profils || ['anonymous', 'pagans'],
|
|
xpublickey: publickey,
|
|
xprivatekey: privatekey,
|
|
xpassphrase: passphrase,
|
|
};
|
|
Auth.afterAuth(alias, timestamp, signedMessage, this.auth.xprofils);
|
|
if (trusted) {
|
|
Auth.addIdentity(alias, { protectedKey: await mnemonicEncryptKey(privatekey, passphrase), passphrase });
|
|
}
|
|
this.profiles.push({ name: alias, alias });
|
|
this.activeIdentity = { name: alias, alias };
|
|
this.tmpKey = null;
|
|
this.notify(this.t('notify.identity_created', { alias }), false);
|
|
this.setLoading(false);
|
|
await this.refreshMyWorld();
|
|
Menu.showScreen('myworld'); if (this._checkRedirect()) return;
|
|
} else {
|
|
this.notify(this.t('notify.create_error', { message: response.data.msg || 'unknown' }), true);
|
|
this.setLoading(false);
|
|
}
|
|
} catch (err) {
|
|
this.notify(this.t('notify.network_error', { message: err.message }), true);
|
|
this.setLoading(false);
|
|
}
|
|
},
|
|
async handleJoinTribe(tribe) {
|
|
if (!this.auth) {
|
|
this.notify(this.t('notify.auth_required'), true);
|
|
return;
|
|
}
|
|
this.setLoading(true);
|
|
try {
|
|
const joinResponse = await axios.post(
|
|
`/api/apxtri/pagans/person/${tribe}`,
|
|
{ alias: this.auth.xalias, profils: [...(this.auth.xprofils || []), 'persons'] },
|
|
{ validateStatus: () => true }
|
|
);
|
|
if (joinResponse.data.status === 200 || joinResponse.data.status === 201) {
|
|
this.notify(this.t('notify.tribe_joined', { tribe }), false);
|
|
const authResponse = await axios.get('/api/apxtri/pagans/isauth', { validateStatus: () => true });
|
|
if (authResponse.data.status === 200) {
|
|
this.auth.xprofils = authResponse.data.data.xprofils;
|
|
Auth.updateProfils(this.auth.xalias, this.auth.xprofils);
|
|
}
|
|
await this.refreshMyWorld();
|
|
} else {
|
|
this.notify(joinResponse.data.msg || this.t('notify.tribe_error'), true);
|
|
}
|
|
} catch (err) {
|
|
this.notify(this.t('notify.network_error', { message: err.message }), true);
|
|
}
|
|
this.setLoading(false);
|
|
},
|
|
async handleRecoverKey(input) {
|
|
this.setLoading(true);
|
|
try {
|
|
const isEmail = input.includes('@');
|
|
const response = await axios.post(
|
|
'/api/apxtri/pagans/keyrecovery',
|
|
{ emailalias: isEmail ? 'email' : 'alias', tribe: APP_TRIBE, search: input },
|
|
{ validateStatus: () => true }
|
|
);
|
|
if (response.data.status === 200) {
|
|
this.notify(this.t('notify.email_sent'), false);
|
|
} else {
|
|
this.notify(response.data.msg || this.t('notify.recovery_error'), true);
|
|
}
|
|
} catch (err) {
|
|
this.notify(this.t('notify.network_error', { message: err.message }), true);
|
|
}
|
|
this.setLoading(false);
|
|
},
|
|
async handleLogout() {
|
|
this.setLoading(true);
|
|
await Auth.logout();
|
|
this.auth = null;
|
|
this.profiles = [];
|
|
Auth.removeIdentity(Auth.getActiveAlias());
|
|
this.setLoading(false);
|
|
this.render();
|
|
Menu.showScreen('list');
|
|
},
|
|
handleDeleteProfile(index) {
|
|
const profile = this.profiles[index];
|
|
if (confirm(this.t('notify.delete_confirm', { name: profile.name }))) {
|
|
Auth.removeIdentity(profile.alias);
|
|
this.profiles.splice(index, 1);
|
|
if (this.activeIdentity?.name === profile.name) {
|
|
this.activeIdentity = null;
|
|
if (this.auth?.xalias === profile.alias) {
|
|
this.auth = null;
|
|
}
|
|
}
|
|
this.render();
|
|
}
|
|
},
|
|
handleSignInFromProfile(profile) {
|
|
document.getElementById('signin-alias').value = profile.alias;
|
|
document.getElementById('signin-privatekey').value = '';
|
|
document.getElementById('signin-remember').checked = true;
|
|
Menu.showScreen('signin');
|
|
},
|
|
handleChangePassphrase(profile) {
|
|
document.getElementById('changepassphrase-alias').textContent = profile.alias;
|
|
document.getElementById('changepassphrase-current').value = '';
|
|
document.getElementById('changepassphrase-new').value = '';
|
|
document.getElementById('changepassphrase-confirm').value = '';
|
|
this._changepassphraseAlias = profile.alias;
|
|
Menu.showScreen('changepassphrase');
|
|
},
|
|
async changePassphrase() {
|
|
const alias = this._changepassphraseAlias;
|
|
const currentPass = document.getElementById('changepassphrase-current').value;
|
|
const newPass = document.getElementById('changepassphrase-new').value;
|
|
const confirmPass = document.getElementById('changepassphrase-confirm').value;
|
|
if (!currentPass) return this.notify(this.t('notify.passphrase_wrong'), true);
|
|
if (newPass !== confirmPass) return this.notify(this.t('notify.passphrase_mismatch'), true);
|
|
const protectedKey = Auth.getIdentity(alias)?.protectedKey;
|
|
if (!protectedKey) return this.notify('No protected key found', true);
|
|
try {
|
|
const decryptedKey = await mnemonicDecryptKey(protectedKey, currentPass);
|
|
const reEncryptedKey = await mnemonicEncryptKey(decryptedKey, newPass || 'apx');
|
|
Auth.addIdentity(alias, { protectedKey: reEncryptedKey, passphrase: newPass || '' });
|
|
this.notify(this.t('notify.passphrase_changed', { alias }), false);
|
|
Menu.showScreen('list');
|
|
this.render();
|
|
} catch (err) {
|
|
Log.error('changePassphrase failed:', err.message || err);
|
|
this.notify(this.t('notify.passphrase_wrong'), true);
|
|
}
|
|
},
|
|
async refreshMyWorld() {
|
|
if (!this.auth) return;
|
|
var nation = document.querySelector('meta[name="xnation"]')?.content || '';
|
|
document.getElementById('myworld-alias').textContent = this.auth.xalias;
|
|
document.getElementById('myworld-town-nation').textContent = APP_TRIBE + (nation ? ' - ' + nation : '');
|
|
const initial = this.auth.xalias.charAt(0).toUpperCase();
|
|
document.getElementById('myworld-avatar').textContent = initial;
|
|
try {
|
|
const response = await axios.get('/api/apxtri/pagans/aliasauth/' + this.auth.xalias, { validateStatus: () => true });
|
|
if (response.data.status === 200) {
|
|
this.myworldData = response.data.data;
|
|
} else {
|
|
Log.warn('aliasauth returned status', response.data.status);
|
|
this.myworldData = null;
|
|
}
|
|
} catch (err) {
|
|
Log.error('Failed to fetch aliasauth:', err.message || err);
|
|
this.myworldData = null;
|
|
}
|
|
this.paganData = {
|
|
color: this.myworldData?.color || '#3b82f6',
|
|
genre: this.myworldData?.genre || '',
|
|
languages: this.myworldData?.languages || [],
|
|
agerange: this.myworldData?.agerange || '',
|
|
};
|
|
this._applyAvatarColor(this.paganData.color);
|
|
this._updateAvatarBadge(this.paganData.genre);
|
|
Auth.addIdentity(this.auth.xalias, { genre: this.paganData.genre });
|
|
await this._loadOptions();
|
|
this._renderProfileForm();
|
|
this._profileOriginal = { color: this.paganData.color, genre: this.paganData.genre, agerange: this.paganData.agerange, languages: [...(this.paganData.languages || [])].sort().join(',') };
|
|
this._updateProfileActions(false);
|
|
this.applyLang();
|
|
this._loadThreadSummary();
|
|
this._loadBalance();
|
|
},
|
|
_applyAvatarColor(color) {
|
|
var el = document.getElementById('myworld-avatar');
|
|
if (el) el.style.backgroundColor = color;
|
|
},
|
|
_genreSymbol(genre, size) {
|
|
var s = size || 4;
|
|
if (genre === '0') return '<svg xmlns="http://www.w3.org/2000/svg" width="' + s + '" height="' + s + '" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M16 3h5v5"/><path d="m21 3-6.75 6.75"/><circle cx="10" cy="14" r="6"/></svg>';
|
|
if (genre === '1') return '<svg xmlns="http://www.w3.org/2000/svg" width="' + s + '" height="' + s + '" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M12 15v7"/><path d="M9 19h6"/><circle cx="12" cy="9" r="6"/></svg>';
|
|
if (genre === '2') return '<svg xmlns="http://www.w3.org/2000/svg" width="' + s + '" height="' + s + '" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M10 20h4"/><path d="M12 16v6"/><path d="M17 2h4v4"/><path d="m21 2-5.46 5.46"/><circle cx="12" cy="11" r="5"/></svg>';
|
|
return '';
|
|
},
|
|
_updateAvatarBadge(genre) {
|
|
var badge = document.getElementById('myworld-avatar-badge');
|
|
if (badge) {
|
|
var svg = this._genreSymbol(genre, 14);
|
|
badge.innerHTML = svg;
|
|
badge.classList.toggle('hidden', !svg);
|
|
}
|
|
},
|
|
async _loadOptions() {
|
|
var cached = JSON.parse(localStorage.getItem('mainapp') || '{}');
|
|
this._options = cached.options || {};
|
|
if (!this._options.genre) {
|
|
await LocalDB.init('mainapp', 'identities');
|
|
cached = JSON.parse(localStorage.getItem('mainapp') || '{}');
|
|
this._options = cached.options || {};
|
|
}
|
|
},
|
|
_profileOriginal: null,
|
|
_checkProfileDirty() {
|
|
if (!this._profileOriginal) return false;
|
|
const color = document.getElementById('edit-color')?.value || '';
|
|
const genre = document.getElementById('edit-genre')?.value || '';
|
|
const agerange = document.getElementById('edit-agerange')?.value || '';
|
|
var langHidden = document.getElementById('webapp-field-profile-languages');
|
|
var langs = [];
|
|
try { langs = JSON.parse(langHidden?.value || '[]'); } catch {}
|
|
const langStr = [...langs].sort().join(',');
|
|
return color !== this._profileOriginal.color || genre !== this._profileOriginal.genre ||
|
|
agerange !== this._profileOriginal.agerange || langStr !== this._profileOriginal.languages;
|
|
},
|
|
_updateProfileActions(show) {
|
|
var el = document.getElementById('profile-actions');
|
|
if (el) el.classList.toggle('hidden', !show);
|
|
},
|
|
async _loadBalance() {
|
|
if (!this.auth) return;
|
|
try {
|
|
const res = await axios.get(`/api/apxtri/apx3/balance/${this.auth.xalias}`, { validateStatus: () => true });
|
|
document.getElementById('myworld-balance').textContent = res.data.status === 200 ? parseFloat(res.data.data.balance).toFixed(2) : '-';
|
|
} catch { document.getElementById('myworld-balance').textContent = '-'; }
|
|
},
|
|
async _loadThreadSummary() {
|
|
if (!this.auth) return;
|
|
const el = document.getElementById('thread-summary');
|
|
const emptyEl = document.getElementById('thread-summary-empty');
|
|
if (!el || !emptyEl) return;
|
|
el.innerHTML = ''; emptyEl.classList.remove('hidden');
|
|
try {
|
|
const lastSeen = JSON.parse(localStorage.getItem('thread-lastseen') || '{}');
|
|
const res = await axios.get(`/api/apxtri/thread/${APP_TRIBE}/list/${this.auth.xalias}`, { validateStatus: () => true });
|
|
let items = [];
|
|
if (res.data.status === 200) {
|
|
for (const t of res.data.data || []) {
|
|
if (t.lastTimestamp && (!lastSeen[t.uuid] || dayjs(t.lastTimestamp).isAfter(dayjs(lastSeen[t.uuid])))) {
|
|
items.push({ uuid: t.uuid, title: t.title || t.uuid, type: 'reply', time: t.lastTimestamp, tribe: APP_TRIBE });
|
|
}
|
|
}
|
|
}
|
|
try {
|
|
const pubRes = await axios.get(`/api/apxtri/thread_public/list?owner=${this.auth.xalias}`, { validateStatus: () => true });
|
|
if (pubRes.data.status === 200) {
|
|
for (const pt of pubRes.data.data || []) {
|
|
for (const c of (pt.contacts || [])) {
|
|
if (c.dt && (!lastSeen[pt.uuid] || dayjs(c.dt).isAfter(dayjs(lastSeen[pt.uuid])))) {
|
|
items.push({ uuid: pt.uuid, title: pt.title || pt.uuid, type: 'contact', time: c.dt, tribe: pt.hosting_tribe || APP_TRIBE, contactAlias: c.alias });
|
|
}
|
|
}
|
|
for (const r of (pt.reviews || [])) {
|
|
if (r.dt && (!lastSeen[pt.uuid] || dayjs(r.dt).isAfter(dayjs(lastSeen[pt.uuid])))) {
|
|
items.push({ uuid: pt.uuid, title: pt.title || pt.uuid, type: 'review', time: r.dt, tribe: pt.hosting_tribe || APP_TRIBE, reviewAlias: r.alias });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch {}
|
|
items.sort((a, b) => dayjs(b.time).diff(dayjs(a.time)));
|
|
const display = items.slice(0, 3);
|
|
const remaining = items.slice(3);
|
|
if (display.length === 0) { emptyEl.classList.remove('hidden'); return; }
|
|
emptyEl.classList.add('hidden');
|
|
el.innerHTML = display.map(i => {
|
|
const icon = i.type === 'reply' ? '💬' : i.type === 'contact' ? '📩' : '⭐';
|
|
const desc = i.type === 'reply' ? dayjs(i.time).fromNow() :
|
|
i.type === 'contact' ? 'Contact: ' + (i.contactAlias || '') :
|
|
'Avis: ' + (i.reviewAlias || '');
|
|
return `<div class="flex items-center gap-2 py-1 px-2 rounded hover:bg-base-200 cursor-pointer" data-action="open-thread" data-uuid="${i.uuid}" data-tribe="${i.tribe}"><span>${icon}</span><span class="text-xs flex-1 truncate">${(i.title || i.uuid).substring(0, 30)}</span><span class="text-xs opacity-40 shrink-0">${desc}</span></div>`;
|
|
}).join('');
|
|
if (remaining.length > 0) {
|
|
el.innerHTML += `<div class="collapse collapse-arrow bg-base-200 rounded-lg mt-1"><input type="checkbox" /><div class="collapse-title text-xs font-medium py-1 px-2">${remaining.length} more...</div><div class="collapse-content py-0 px-2">` +
|
|
remaining.map(i => {
|
|
const icon = i.type === 'reply' ? '💬' : i.type === 'contact' ? '📩' : '⭐';
|
|
const desc = i.type === 'reply' ? dayjs(i.time).fromNow() : 'Contact: ' + (i.contactAlias || '');
|
|
return `<div class="flex items-center gap-2 py-1 px-2 rounded hover:bg-base-200 cursor-pointer" data-action="open-thread" data-uuid="${i.uuid}" data-tribe="${i.tribe}"><span>${icon}</span><span class="text-xs flex-1 truncate">${(i.title || i.uuid).substring(0, 30)}</span><span class="text-xs opacity-40 shrink-0">${desc}</span></div>`;
|
|
}).join('') + `</div></div>`;
|
|
}
|
|
} catch {}
|
|
},
|
|
_renderProfileForm() {
|
|
if (!this.paganData) return;
|
|
var colorInput = document.getElementById('edit-color');
|
|
if (colorInput) colorInput.value = this.paganData.color;
|
|
this._renderGenreSelect();
|
|
this._renderAgerangeSelect();
|
|
this._initMultiSelect('languages', this.paganData.languages);
|
|
},
|
|
_renderGenreSelect() {
|
|
var sel = document.getElementById('edit-genre');
|
|
if (!sel) return;
|
|
var itms = this._options?.genre?.itms || {};
|
|
var keys = this._options?.genre?.lst_idx || Object.keys(itms);
|
|
sel.innerHTML = '<option value="">—</option>' + keys.map(function(k) {
|
|
var label = itms[k]?.contactas || itms[k]?.title || k;
|
|
var selected = k === App.paganData.genre ? ' selected' : '';
|
|
return '<option value="' + k + '"' + selected + '>' + label + '</option>';
|
|
}).join('');
|
|
},
|
|
_initMultiSelect(key, values) {
|
|
var hidden = document.getElementById('webapp-field-profile-' + key);
|
|
if (hidden) hidden.value = JSON.stringify(values || []);
|
|
this._updateMultiSelectBadges(key);
|
|
},
|
|
_updateMultiSelectBadges(key) {
|
|
if (!key) return;
|
|
var container = document.getElementById('webapp-multiselect-profile-' + key);
|
|
var hidden = document.getElementById('webapp-field-profile-' + key);
|
|
if (!container || !hidden) return;
|
|
var currentValues = [];
|
|
try { currentValues = JSON.parse(hidden.value || '[]'); } catch (e) {}
|
|
var options = { itms: {} };
|
|
if (key === 'languages') options = this._options?.language || options;
|
|
var itms = options.itms || {};
|
|
var show = container.dataset.show || 'label';
|
|
var existingBadges = container.querySelectorAll('span.badge');
|
|
existingBadges.forEach(function(b) { b.remove(); });
|
|
var pencilBtn = container.querySelector('[data-action="multiselect-open"]');
|
|
currentValues.forEach(function(v) {
|
|
var optLabel = (itms[v] && itms[v].title) || v;
|
|
var badge = document.createElement('span');
|
|
badge.className = 'badge badge-primary badge-sm';
|
|
badge.textContent = show === 'key' ? v : optLabel;
|
|
container.insertBefore(badge, pencilBtn);
|
|
});
|
|
},
|
|
_getOptionsForKey(key) {
|
|
if (key === 'languages') return this._options?.language || { itms: {}, lst_idx: [] };
|
|
return { itms: {}, lst_idx: [] };
|
|
},
|
|
showMultiSelectModal(prefix, key, label) {
|
|
this._multiSelectKey = key;
|
|
var fieldId = 'webapp-field-' + prefix + '-' + key;
|
|
var hiddenInput = document.getElementById(fieldId);
|
|
if (!hiddenInput) return;
|
|
var currentValues = [];
|
|
try { currentValues = JSON.parse(hiddenInput.value || '[]'); } catch (e) {}
|
|
var options = this._getOptionsForKey(key);
|
|
var container = document.getElementById('webapp-multiselect-' + prefix + '-' + key);
|
|
var show = container ? (container.dataset.show || 'label') : 'label';
|
|
var listEl = document.getElementById('multiselect-list');
|
|
var titleEl = document.getElementById('multiselect-title');
|
|
titleEl.textContent = label;
|
|
var html = '';
|
|
(options.lst_idx || Object.keys(options.itms)).forEach(function(opt) {
|
|
var optLabel = (options.itms[opt] && options.itms[opt].title) || opt;
|
|
var isSelected = currentValues.indexOf(opt) !== -1;
|
|
html += '<div class="flex items-center gap-2 p-2 rounded-lg cursor-pointer hover:bg-base-200 ' + (isSelected ? 'bg-base-300' : 'bg-base-200') + '" data-action="multiselect-toggle" data-opt="' + opt.replace(/"/g, '"') + '">';
|
|
if (show === 'key') {
|
|
html += '<span class="badge ' + (isSelected ? 'badge-primary' : 'badge-ghost badge-outline') + ' badge-xs">' + opt.replace(/"/g, '"') + '</span>';
|
|
} else {
|
|
html += '<span class="badge ' + (isSelected ? 'badge-primary' : 'badge-ghost badge-outline') + ' badge-xs">' + optLabel + '</span>';
|
|
}
|
|
html += '</div>';
|
|
});
|
|
listEl.innerHTML = html;
|
|
document.getElementById('multiselect-modal').showModal();
|
|
},
|
|
_handleMultiSelectToggle(rowEl) {
|
|
var key = this._multiSelectKey;
|
|
if (!key) return;
|
|
var fieldId = 'webapp-field-profile-' + key;
|
|
var hiddenInput = document.getElementById(fieldId);
|
|
if (!hiddenInput) return;
|
|
var currentValues = [];
|
|
try { currentValues = JSON.parse(hiddenInput.value || '[]'); } catch (e) {}
|
|
var opt = rowEl.dataset.opt;
|
|
var idx = currentValues.indexOf(opt);
|
|
if (idx === -1) {
|
|
currentValues.push(opt);
|
|
} else {
|
|
currentValues.splice(idx, 1);
|
|
}
|
|
hiddenInput.value = JSON.stringify(currentValues);
|
|
var badge = rowEl.querySelector('.badge');
|
|
if (idx === -1) {
|
|
rowEl.classList.remove('bg-base-200');
|
|
rowEl.classList.add('bg-base-300');
|
|
if (badge) { badge.classList.remove('badge-ghost', 'badge-outline'); badge.classList.add('badge-primary'); }
|
|
} else {
|
|
rowEl.classList.remove('bg-base-300');
|
|
rowEl.classList.add('bg-base-200');
|
|
if (badge) { badge.classList.remove('badge-primary'); badge.classList.add('badge-ghost', 'badge-outline'); }
|
|
}
|
|
this._updateMultiSelectBadges(key);
|
|
},
|
|
_renderAgerangeSelect() {
|
|
var sel = document.getElementById('edit-agerange');
|
|
if (!sel) return;
|
|
var currentDecade = Math.floor(new Date().getFullYear() / 10) * 10;
|
|
var decades = [];
|
|
for (var y = currentDecade; y >= 1930; y -= 10) {
|
|
decades.push({ value: String(y), label: y + ' (' + y + '-' + (y + 9) + ')' });
|
|
}
|
|
sel.innerHTML = '<option value="">—</option>' + decades.map(function(r) {
|
|
var selected = r.value === App.paganData.agerange ? ' selected' : '';
|
|
return '<option value="' + r.value + '"' + selected + '>' + r.label + '</option>';
|
|
}).join('');
|
|
},
|
|
_collectFormData() {
|
|
var data = { alias: this.auth.xalias };
|
|
data.color = document.getElementById('edit-color')?.value || '#3b82f6';
|
|
data.genre = document.getElementById('edit-genre')?.value || '';
|
|
var langHidden = document.getElementById('webapp-field-profile-languages');
|
|
try { data.languages = langHidden ? JSON.parse(langHidden.value || '[]') : []; } catch (e) { data.languages = []; }
|
|
data.agerange = document.getElementById('edit-agerange')?.value || '';
|
|
return data;
|
|
},
|
|
async handleSaveProfile() {
|
|
if (!this.auth) return;
|
|
this.setLoading(true);
|
|
try {
|
|
var data = this._collectFormData();
|
|
var response = await axios.put('/api/apxtri/pagans', data, { validateStatus: function(s) { return true; } });
|
|
if (response.data.status === 200) {
|
|
this.notify(this.t('profile.saved', { fee: '0.05' }), false);
|
|
this.paganData = {
|
|
color: data.color,
|
|
genre: data.genre,
|
|
languages: data.languages,
|
|
agerange: data.agerange,
|
|
};
|
|
this._profileOriginal = { color: data.color, genre: data.genre, agerange: data.agerange, languages: [...(data.languages || [])].sort().join(',') };
|
|
this._updateProfileActions(false);
|
|
this._updateAvatarBadge(data.genre);
|
|
Auth.addIdentity(this.auth.xalias, { genre: data.genre });
|
|
} else {
|
|
var errMsg = response.data.msg || this.t('profile.save_error');
|
|
if (response.data.data?.available !== undefined && response.data.data?.required !== undefined) {
|
|
errMsg = 'Insufficient balance: need ' + response.data.data.required + ' APX3, have ' + response.data.data.available;
|
|
}
|
|
this.notify(errMsg, true);
|
|
}
|
|
} catch (err) {
|
|
this.notify(this.t('profile.save_error') + ': ' + (err.message || ''), true);
|
|
}
|
|
this.setLoading(false);
|
|
},
|
|
handleCancelProfile() {
|
|
if (!this.myworldData) return;
|
|
this.paganData = {
|
|
color: this.myworldData.color || '#3b82f6',
|
|
genre: this.myworldData.genre || '',
|
|
languages: this.myworldData.languages || [],
|
|
agerange: this.myworldData.agerange || '',
|
|
};
|
|
this._applyAvatarColor(this.paganData.color);
|
|
this._updateAvatarBadge(this.paganData.genre);
|
|
this._renderProfileForm();
|
|
this._profileOriginal = { color: this.paganData.color, genre: this.paganData.genre, agerange: this.paganData.agerange, languages: [...(this.paganData.languages || [])].sort().join(',') };
|
|
this._updateProfileActions(false);
|
|
this.notify(this.t('profile.cancelled'), false);
|
|
},
|
|
render() {
|
|
const listEl = document.getElementById('list-profiles');
|
|
const emptyEl = document.getElementById('list-empty');
|
|
if (this.profiles.length === 0) {
|
|
emptyEl.classList.remove('hidden');
|
|
listEl.innerHTML = '';
|
|
return;
|
|
}
|
|
emptyEl.classList.add('hidden');
|
|
listEl.innerHTML =
|
|
this.profiles
|
|
.map((profile, idx) => {
|
|
const isActive = this.auth?.xalias === profile.alias;
|
|
const listGenre = Auth.getIdentity(profile.alias)?.genre;
|
|
const listBadge = listGenre ? this._genreSymbol(listGenre, 10) : '';
|
|
return `<div class="card bg-base-100 shadow-sm ${isActive ? 'ring-2 ring-success' : ''}">\n <div class="card-body p-4">\n <div class="flex items-center justify-between">\n <div class="flex items-center gap-3">\n <div class="avatar placeholder relative"><div class="bg-${isActive ? 'success' : 'neutral'} text-${isActive ? 'success-content' : 'neutral-content'} rounded-full w-10 h-10 flex items-center justify-center font-bold text-sm">${profile.name.charAt(0).toUpperCase()}</div><span class="absolute -bottom-0.5 -right-0.5">${listBadge}</span></div></div>\n <div><p class="font-medium">${profile.name}</p><p class="text-xs opacity-50">${profile.alias}</p></div>\n </div>\n <div class="flex gap-1">\n ${isActive ? '<button class="btn btn-ghost btn-sm btn-square btn-goto-myworld" data-action="goto-myworld"><svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m2.25 12 8.954-8.955a1.126 1.126 0 0 1 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25"/></svg></button>' : `<button class="btn btn-ghost btn-sm btn-square btn-signin-from-profile" data-action="signin-from-profile" data-index="${idx}"><svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 ${Auth.getIdentity(profile.alias)?.protectedKey ? 'text-warning' : 'text-primary'}" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.75 9V5.25A2.25 2.25 0 0 0 13.5 3h-6a2.25 2.25 0 0 0-2.25 2.25v13.5A2.25 2.25 0 0 0 7.5 21h6a2.25 2.25 0 0 0 2.25-2.25V15m3 0 3-3m0 0-3-3m3 3H9"/></svg></button>`}${Auth.getIdentity(profile.alias)?.protectedKey ? `<button class="btn btn-ghost btn-sm btn-square btn-changepassphrase" data-action="change-passphrase" data-index="${idx}"><svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-warning" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.75 5.25a3 3 0 0 1 3 3m3 0a6 6 0 0 1-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1 1 21.75 8.25Z"/></svg></button>` : ''}\n </div>\n </div>\n </div>\n </div>`;
|
|
})
|
|
.join('') +
|
|
'\n <div class="flex gap-2 mt-3">\n <button class="btn btn-primary btn-sm flex-1" data-action="create-identity">\n <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.5v15m7.5-7.5h-15"/></svg>\n ' + this.t('list.empty.create') + '\n </button>\n <button class="btn btn-outline btn-sm flex-1" data-action="add-identity">\n <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.75 5.25a3 3 0 0 1 3 3m3 0a6 6 0 0 1-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1 1 21.75 8.25Z"/></svg>\n ' + this.t('notify.add_identity') + '\n </button>\n </div>'
|
|
listEl.querySelectorAll('[data-action="signin-from-profile"]').forEach((el) => {
|
|
el.addEventListener('click', async () => {
|
|
const profile = this.profiles[parseInt(el.dataset.index)];
|
|
const protectedKey = Auth.getIdentity(profile.alias)?.protectedKey;
|
|
if (protectedKey) {
|
|
await this.tryAutoAuth(profile.alias, protectedKey);
|
|
if (this.auth) {
|
|
await this.refreshMyWorld();
|
|
Menu.showScreen('myworld'); if (this._checkRedirect()) return;
|
|
}
|
|
} else {
|
|
this.handleSignInFromProfile(profile);
|
|
}
|
|
});
|
|
});
|
|
listEl.querySelectorAll('[data-action="goto-myworld"]').forEach((el) => {
|
|
el.addEventListener('click', () => {
|
|
if (this.auth) {
|
|
this.refreshMyWorld();
|
|
Menu.showScreen('myworld'); if (this._checkRedirect()) return;
|
|
}
|
|
});
|
|
});
|
|
listEl.querySelectorAll('[data-action="change-passphrase"]').forEach((el) => {
|
|
el.addEventListener('click', () => this.handleChangePassphrase(this.profiles[parseInt(el.dataset.index)]));
|
|
});
|
|
listEl.querySelectorAll('[data-action="create-identity"]').forEach((btn) =>
|
|
btn.addEventListener('click', () => {
|
|
document.getElementById('create-keys-generated').classList.add('hidden');
|
|
document.querySelector("[data-action='generate-keys']").classList.remove('hidden');
|
|
document.getElementById('create-alias').value = '';
|
|
document.getElementById('create-email').value = '';
|
|
document.getElementById('create-passphrase').value = 'apx';
|
|
['create-alias', 'create-email', 'create-passphrase'].forEach(
|
|
(id) => (document.getElementById(id).disabled = false)
|
|
);
|
|
const rb = document.querySelector('[data-action="register-identity"]');
|
|
if (rb) rb.disabled = true;
|
|
document.getElementById('create-trusted').checked = true;
|
|
this.tmpKey = null;
|
|
Menu.showScreen('create');
|
|
})
|
|
);
|
|
listEl.querySelectorAll('[data-action="add-identity"]').forEach((btn) =>
|
|
btn.addEventListener('click', () => {
|
|
document.getElementById('signin-alias').value = '';
|
|
document.getElementById('signin-privatekey').value = '';
|
|
document.getElementById('signin-remember').checked = true;
|
|
Menu.showScreen('signin');
|
|
})
|
|
);
|
|
},
|
|
bindEvents() {
|
|
Log.debug('bindEvents() - registering event handlers');
|
|
document.querySelectorAll('[data-action="create-identity"]').forEach((btn) =>
|
|
btn.addEventListener('click', () => {
|
|
document.getElementById('create-keys-generated').classList.add('hidden');
|
|
document.querySelector("[data-action='generate-keys']").classList.remove('hidden');
|
|
document.getElementById('create-alias').value = '';
|
|
document.getElementById('create-email').value = '';
|
|
document.getElementById('create-passphrase').value = 'apx';
|
|
['create-alias', 'create-email', 'create-passphrase'].forEach(
|
|
(id) => (document.getElementById(id).disabled = false)
|
|
);
|
|
const rb = document.querySelector('[data-action="register-identity"]');
|
|
if (rb) rb.disabled = true;
|
|
document.getElementById('create-trusted').checked = true;
|
|
this.tmpKey = null;
|
|
Menu.showScreen('create');
|
|
})
|
|
);
|
|
document.querySelectorAll('[data-action="signin"]').forEach((btn) =>
|
|
btn.addEventListener('click', () => {
|
|
document.getElementById('signin-alias').value = '';
|
|
document.getElementById('signin-privatekey').value = '';
|
|
document.getElementById('signin-remember').checked = true;
|
|
Menu.showScreen('signin');
|
|
})
|
|
);
|
|
document.querySelectorAll('[data-action="forget-key"]').forEach((btn) =>
|
|
btn.addEventListener('click', () => {
|
|
document.getElementById('forgetkey-input').value = '';
|
|
Menu.showScreen('forgetkey');
|
|
})
|
|
);
|
|
document.querySelectorAll('[data-action="goto-myworld"]').forEach((btn) =>
|
|
btn.addEventListener('click', () => {
|
|
if (this.auth) {
|
|
this.refreshMyWorld();
|
|
Menu.showScreen('myworld'); if (this._checkRedirect()) return;
|
|
}
|
|
})
|
|
);
|
|
document.querySelectorAll('[data-action="drawer-list"]').forEach((btn) =>
|
|
btn.addEventListener('click', () => {
|
|
var da = document.getElementById('drawer-actions');
|
|
if (da) da.checked = false;
|
|
this.render();
|
|
Menu.showScreen('list');
|
|
})
|
|
);
|
|
document.querySelectorAll('[data-action="drawer-myworld"]').forEach((btn) =>
|
|
btn.addEventListener('click', () => {
|
|
var da = document.getElementById('drawer-actions');
|
|
if (da) da.checked = false;
|
|
if (this.auth) {
|
|
this.refreshMyWorld();
|
|
Menu.showScreen('myworld'); if (this._checkRedirect()) return;
|
|
}
|
|
})
|
|
);
|
|
document.querySelectorAll('[data-action="drawer-info"]').forEach((btn) =>
|
|
btn.addEventListener('click', () => {
|
|
var da = document.getElementById('drawer-actions');
|
|
if (da) da.checked = false;
|
|
Menu.showScreen('information');
|
|
})
|
|
),
|
|
|
|
document.querySelector("[data-action='change-passphrase-submit']")
|
|
?.addEventListener('click', () => this.changePassphrase());
|
|
document.querySelector("[data-action='generate-keys']").addEventListener('click', async () => {
|
|
const alias = document.getElementById('create-alias').value.trim();
|
|
const email = document.getElementById('create-email').value.trim();
|
|
const passphrase = document.getElementById('create-passphrase').value;
|
|
if (!alias || alias.length < 3 || !/^[a-z0-9]+$/.test(alias)) {
|
|
this.notify(this.t('notify.invalid_alias'), true);
|
|
} else {
|
|
await this.handleCreateIdentity(alias, email, passphrase);
|
|
}
|
|
});
|
|
document.querySelector('[data-action="register-identity"]')
|
|
?.addEventListener('click', () => this.handleRegisterIdentity());
|
|
document.querySelector("[data-action='signin-submit']")?.addEventListener('click', async () => {
|
|
const alias = document.getElementById('signin-alias').value.trim();
|
|
const remember = document.getElementById('signin-remember').checked;
|
|
if (alias) {
|
|
await this.signinAlias(alias, remember);
|
|
} else {
|
|
this.notify(this.t('notify.alias_required'), true);
|
|
}
|
|
});
|
|
['signin-alias', 'signin-privatekey', 'signin-passphrase'].forEach((id) => {
|
|
document.getElementById(id)?.addEventListener('keydown', (evt) => {
|
|
if (evt.key === 'Enter') {
|
|
document.querySelector("[data-action='signin-submit']").click();
|
|
}
|
|
});
|
|
});
|
|
document.querySelectorAll('[data-action="join-tribe"]').forEach((btn) => {
|
|
btn.addEventListener('click', () => this.handleJoinTribe(btn.dataset.tribe));
|
|
});
|
|
document.querySelectorAll('[data-action="logout"]')
|
|
.forEach((btn) => btn.addEventListener('click', () => this.handleLogout()));
|
|
document.querySelectorAll('[data-action="goto-mytribes"]')
|
|
.forEach(btn => btn.addEventListener('click', () => { window.location.href = IS_DRAFT ? '/mytribes/draft/' : '/mytribes/'; }));
|
|
document.querySelectorAll('[data-action="goto-mywallet"]')
|
|
.forEach(btn => btn.addEventListener('click', () => { window.location.href = IS_DRAFT ? '/mywallet/draft/' : '/mywallet/'; }));
|
|
document.querySelector('[data-action="recover-key"]')?.addEventListener('click', async () => {
|
|
const input = document.getElementById('forgetkey-input').value.trim();
|
|
if (input) {
|
|
await this.handleRecoverKey(input);
|
|
} else {
|
|
this.notify(this.t('notify.enter_alias_email'), true);
|
|
}
|
|
});
|
|
document.getElementById('forgetkey-input')?.addEventListener('keydown', (evt) => {
|
|
if (evt.key === 'Enter') {
|
|
document.querySelector("[data-action='recover-key']").click();
|
|
}
|
|
});
|
|
|
|
// Profile form events
|
|
document.querySelector('[data-action="save-profile"]')?.addEventListener('click', () => this.handleSaveProfile());
|
|
document.querySelector('[data-action="cancel-profile"]')?.addEventListener('click', () => this.handleCancelProfile());
|
|
document.getElementById('edit-color')?.addEventListener('input', (evt) => {
|
|
this.paganData.color = evt.target.value;
|
|
this._applyAvatarColor(evt.target.value);
|
|
this._updateProfileActions(this._checkProfileDirty());
|
|
});
|
|
document.getElementById('edit-genre')?.addEventListener('change', () => {
|
|
this._updateProfileActions(this._checkProfileDirty());
|
|
this._updateAvatarBadge(document.getElementById('edit-genre').value);
|
|
});
|
|
document.getElementById('edit-agerange')?.addEventListener('change', () =>
|
|
this._updateProfileActions(this._checkProfileDirty()));
|
|
// Dirty check on multiselect close
|
|
document.getElementById('multiselect-modal')?.addEventListener('close', () => {
|
|
this._updateProfileActions(this._checkProfileDirty());
|
|
});
|
|
// Multi-select: open modal
|
|
document.getElementById('myworld-edit-card')?.addEventListener('click', (evt) => {
|
|
var btn = evt.target.closest('[data-action="multiselect-open"]');
|
|
if (btn) this.showMultiSelectModal(btn.dataset.prefix, btn.dataset.key, btn.dataset.label);
|
|
});
|
|
// Multi-select: toggle option in modal
|
|
document.getElementById('multiselect-list')?.addEventListener('click', (evt) => {
|
|
var row = evt.target.closest('[data-action="multiselect-toggle"]');
|
|
if (row) this._handleMultiSelectToggle(row);
|
|
});
|
|
// Multi-select: close modal
|
|
document.querySelector('[data-action="multiselect-close"]')?.addEventListener('click', () => {
|
|
document.getElementById('multiselect-modal').close();
|
|
});
|
|
// Multi-select: update badges on modal close
|
|
document.getElementById('multiselect-modal')?.addEventListener('close', () => {
|
|
if (this._multiSelectKey) this._updateMultiSelectBadges(this._multiSelectKey);
|
|
this._multiSelectKey = null;
|
|
});
|
|
// Multi-select: backdrop click
|
|
document.getElementById('multiselect-modal')?.addEventListener('click', function(evt) {
|
|
if (evt.target === this) this.close();
|
|
});
|
|
document.addEventListener('click', (evt) => {
|
|
var target = evt.target;
|
|
if (!target) return;
|
|
|
|
});
|
|
document.addEventListener('input', (evt) => {
|
|
var target = evt.target;
|
|
if (!target || !target.dataset) return;
|
|
});
|
|
// Navigate to thread with thread param
|
|
document.getElementById('thread-summary')?.addEventListener('click', (evt) => {
|
|
var el = evt.target.closest('[data-action="open-thread"]');
|
|
if (el) {
|
|
localStorage.setItem('thread-open-uuid', el.dataset.uuid);
|
|
localStorage.setItem('thread-open-tribe', el.dataset.tribe);
|
|
window.location.href = '/mythread/';
|
|
}
|
|
});
|
|
// Prove / verify identity
|
|
document.querySelectorAll('[data-action="prove-id"]').forEach(btn =>
|
|
btn.addEventListener('click', () => {
|
|
var k = document.getElementById('proveid-no-key');
|
|
if (!this.auth.xprivatekey && k) k.classList.remove('hidden'); else if (k) k.classList.add('hidden');
|
|
this._qrInstance = null; document.getElementById('proveid-qr').innerHTML = '';
|
|
document.getElementById('proveid-status').classList.add('hidden');
|
|
document.getElementById('proveid-input').value = '';
|
|
Menu.showScreen('proveid');
|
|
})
|
|
);
|
|
document.querySelectorAll('[data-action="check-id"]').forEach(btn =>
|
|
btn.addEventListener('click', () => {
|
|
document.getElementById('checkid-result').classList.add('hidden');
|
|
document.getElementById('checkid-valid').classList.add('hidden');
|
|
document.getElementById('checkid-invalid').classList.add('hidden');
|
|
Menu.showScreen('checkid');
|
|
})
|
|
);
|
|
document.querySelectorAll('[data-action="sign-id"]').forEach(btn =>
|
|
btn.addEventListener('click', () => this.signIdentity())
|
|
);
|
|
document.querySelectorAll('[data-action="start-scan"]').forEach(btn =>
|
|
btn.addEventListener('click', () => this.startScan())
|
|
);
|
|
document.querySelectorAll('[data-action="scan-file"]').forEach(btn =>
|
|
btn.addEventListener('change', (evt) => {
|
|
if (evt.target.files?.[0]) this.scanFromFile(evt.target.files[0]);
|
|
})
|
|
);
|
|
// Key QR
|
|
document.querySelectorAll('[data-action="show-key-qr"]').forEach(btn =>
|
|
btn.addEventListener('click', () => this.showPrivateKeyQr())
|
|
);
|
|
document.querySelectorAll('[data-action="scan-key-qr"]').forEach(btn =>
|
|
btn.addEventListener('click', () => {
|
|
document.getElementById('key-scan-modal').showModal();
|
|
})
|
|
);
|
|
document.querySelectorAll('[data-action="keyscan-start"]').forEach(btn =>
|
|
btn.addEventListener('click', () => this.startKeyScan())
|
|
);
|
|
document.querySelectorAll('[data-action="keyscan-file"]').forEach(btn =>
|
|
btn.addEventListener('change', evt => {
|
|
if (evt.target.files?.[0]) this.scanKeyFromFile(evt.target.files[0]);
|
|
})
|
|
);
|
|
document.querySelectorAll('[data-action="keyqr-close"]').forEach(btn =>
|
|
btn.addEventListener('click', () => document.getElementById('keyqr-modal').close())
|
|
);
|
|
document.querySelectorAll('[data-action="keyqr-confirm-show"]').forEach(btn =>
|
|
btn.addEventListener('click', () => this._showKeyQrAfterConfirm())
|
|
);
|
|
document.querySelectorAll('[data-action="keyqr-confirm-cancel"]').forEach(btn =>
|
|
btn.addEventListener('click', () => document.getElementById('keyqr-confirm-modal').close())
|
|
);
|
|
document.querySelectorAll('[data-action="keyscan-close"]').forEach(btn =>
|
|
btn.addEventListener('click', () => {
|
|
this.stopKeyScan();
|
|
document.getElementById('key-scan-modal').close();
|
|
})
|
|
);
|
|
|
|
},
|
|
async signIdentity() {
|
|
var input = document.getElementById('proveid-input').value.trim();
|
|
if (!input || !/^[a-z0-9]+$/.test(input)) { this.notify(this.t('notify.invalid_alias'), true); return; }
|
|
var privateKey = this.auth.xprivatekey;
|
|
var passphrase = this.auth.xpassphrase || '';
|
|
if (!privateKey) {
|
|
privateKey = document.getElementById('proveid-key').value.trim();
|
|
passphrase = document.getElementById('proveid-passphrase').value;
|
|
if (!privateKey || !privateKey.includes('BEGIN PGP PRIVATE KEY')) {
|
|
this.notify('Private key required', true);
|
|
return;
|
|
}
|
|
}
|
|
var statusEl = document.getElementById('proveid-status');
|
|
statusEl.classList.remove('hidden'); statusEl.textContent = 'Signing...';
|
|
try {
|
|
var signature = await this.pgpSignMessage(this.auth.xpublickey || '', privateKey, passphrase, input);
|
|
var payload = { alias: this.auth.xalias, text: input, signature };
|
|
var jsonStr = JSON.stringify(payload);
|
|
document.getElementById('proveid-qr').innerHTML = '';
|
|
this._qrInstance = new QRCodeStyling({ width: 220, height: 220, data: jsonStr });
|
|
this._qrInstance.append(document.getElementById('proveid-qr'));
|
|
statusEl.textContent = 'Signed!';
|
|
statusEl.className = 'text-sm text-center py-2 text-success';
|
|
} catch (err) {
|
|
Log.error('signIdentity:', err);
|
|
statusEl.textContent = 'Sign error: ' + (err.message || err);
|
|
statusEl.className = 'text-sm text-center py-2 text-error';
|
|
}
|
|
},
|
|
|
|
async verifyIdentity(payload) {
|
|
var validEl = document.getElementById('checkid-valid'), invalidEl = document.getElementById('checkid-invalid');
|
|
var result = document.getElementById('checkid-result');
|
|
result.classList.remove('hidden'); validEl.classList.add('hidden'); invalidEl.classList.add('hidden');
|
|
try {
|
|
var pubKeyRes = await axios.get('/api/apxtri/pagans/alias/' + payload.alias, { validateStatus: () => true });
|
|
if (pubKeyRes.data.status !== 200) { invalidEl.classList.remove('hidden'); return; }
|
|
var pubKey = await openpgp.readKey({ armoredKey: pubKeyRes.data.data.publickey });
|
|
var cleartext = await openpgp.readCleartextMessage({ cleartextMessage: atob(payload.signature) });
|
|
var verification = await openpgp.verify({ message: cleartext, verificationKeys: pubKey });
|
|
await verification.signatures[0].verified;
|
|
if (cleartext.getText() === payload.text) {
|
|
validEl.classList.remove('hidden');
|
|
} else {
|
|
invalidEl.classList.remove('hidden');
|
|
}
|
|
document.getElementById('checkid-result-alias').textContent = payload.alias;
|
|
document.getElementById('checkid-result-text').textContent = cleartext.getText();
|
|
} catch (err) {
|
|
Log.error('verifyIdentity:', err);
|
|
invalidEl.classList.remove('hidden');
|
|
}
|
|
},
|
|
|
|
_scanLoop: null, _scanStream: null,
|
|
|
|
async startScan() {
|
|
if (this._scanStream) this.stopScan();
|
|
try {
|
|
this._scanStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } });
|
|
var video = document.getElementById('checkid-video');
|
|
video.classList.remove('hidden'); video.srcObject = this._scanStream; await video.play();
|
|
var canvas = document.getElementById('checkid-canvas'), ctx = canvas.getContext('2d');
|
|
this._scanLoop = setInterval(() => {
|
|
if (video.readyState !== video.HAVE_ENOUGH_DATA) return;
|
|
canvas.width = video.videoWidth; canvas.height = video.videoHeight;
|
|
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
|
var imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
|
var code = jsQR(imageData.data, canvas.width, canvas.height);
|
|
if (code) {
|
|
try {
|
|
var payload = JSON.parse(code.data);
|
|
if (payload.alias && payload.text && payload.signature) {
|
|
this.stopScan();
|
|
this.verifyIdentity(payload);
|
|
}
|
|
} catch {}
|
|
}
|
|
}, 300);
|
|
} catch (err) {
|
|
Log.error('startScan:', err);
|
|
this.notify('Camera access denied or unavailable', true);
|
|
}
|
|
},
|
|
|
|
stopScan() {
|
|
clearInterval(this._scanLoop); this._scanLoop = null;
|
|
if (this._scanStream) { this._scanStream.getTracks().forEach(t => t.stop()); this._scanStream = null; }
|
|
var video = document.getElementById('checkid-video');
|
|
video.classList.add('hidden'); video.pause(); video.srcObject = null;
|
|
},
|
|
|
|
async scanFromFile(file) {
|
|
this.stopScan();
|
|
var canvas = document.getElementById('checkid-canvas');
|
|
var ctx = canvas.getContext('2d');
|
|
var img = new Image();
|
|
img.src = URL.createObjectURL(file);
|
|
await new Promise(resolve => img.onload = resolve);
|
|
canvas.width = img.width; canvas.height = img.height;
|
|
ctx.drawImage(img, 0, 0);
|
|
var imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
|
var code = jsQR(imageData.data, canvas.width, canvas.height);
|
|
URL.revokeObjectURL(img.src);
|
|
if (code) {
|
|
try {
|
|
var payload = JSON.parse(code.data);
|
|
if (payload.alias && payload.text && payload.signature) this.verifyIdentity(payload);
|
|
} catch {}
|
|
} else {
|
|
this.notify('No QR code found in image', true);
|
|
}
|
|
document.querySelector('[data-action="scan-file"]').value = '';
|
|
},
|
|
|
|
async showPrivateKeyQr() {
|
|
var privKey = this.auth?.xprivatekey;
|
|
if (!privKey) {
|
|
this.notify(this.t('keyqr.not_available'), true);
|
|
Menu.showScreen('signin');
|
|
return;
|
|
}
|
|
document.getElementById('keyqr-confirm-modal').showModal();
|
|
},
|
|
|
|
async _showKeyQrAfterConfirm() {
|
|
document.getElementById('keyqr-confirm-modal').close();
|
|
var privKey = this.auth?.xprivatekey;
|
|
if (!privKey) return;
|
|
document.getElementById('keyqr-content').innerHTML = '';
|
|
var data = await this._compressText(privKey);
|
|
this._keyQrInstance = new QRCodeStyling({ width: 260, height: 260, data: data, qrOptions: { errorCorrectionLevel: 'L' } });
|
|
this._keyQrInstance.append(document.getElementById('keyqr-content'));
|
|
document.getElementById('keyqr-modal').showModal();
|
|
},
|
|
|
|
_scanKeyStream: null, _scanKeyLoop: null,
|
|
|
|
startKeyScan() {
|
|
if (this._scanKeyStream) this.stopKeyScan();
|
|
var status = document.getElementById('keyscan-status');
|
|
status.classList.remove('hidden'); status.textContent = this.t('keyscan.scanning');
|
|
navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } }).then(stream => {
|
|
this._scanKeyStream = stream;
|
|
var video = document.getElementById('key-scan-video');
|
|
video.classList.remove('hidden'); video.srcObject = stream; video.play();
|
|
var canvas = document.getElementById('key-scan-canvas'), ctx = canvas.getContext('2d');
|
|
var self = this;
|
|
this._scanKeyLoop = setInterval(() => {
|
|
if (video.readyState !== video.HAVE_ENOUGH_DATA) return;
|
|
canvas.width = video.videoWidth; canvas.height = video.videoHeight;
|
|
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
|
var imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
|
var code = jsQR(imageData.data, canvas.width, canvas.height);
|
|
if (code && code.data && (code.data.indexOf('PGP PRIVATE KEY') !== -1 || code.data.startsWith('GZIP:'))) {
|
|
self.stopKeyScan();
|
|
document.getElementById('key-scan-modal').close();
|
|
self._decompressText(code.data).then(function (key) {
|
|
document.getElementById('signin-privatekey').value = key;
|
|
self.notify('Private key scanned', false);
|
|
});
|
|
}
|
|
}, 300);
|
|
}).catch(err => {
|
|
Log.error('startKeyScan:', err);
|
|
document.getElementById('keyscan-status').textContent = 'Camera unavailable';
|
|
});
|
|
},
|
|
|
|
stopKeyScan() {
|
|
clearInterval(this._scanKeyLoop); this._scanKeyLoop = null;
|
|
if (this._scanKeyStream) { this._scanKeyStream.getTracks().forEach(t => t.stop()); this._scanKeyStream = null; }
|
|
var video = document.getElementById('key-scan-video');
|
|
video.classList.add('hidden'); video.pause(); video.srcObject = null;
|
|
document.getElementById('keyscan-status').classList.add('hidden');
|
|
},
|
|
|
|
scanKeyFromFile(file) {
|
|
this.stopKeyScan();
|
|
var canvas = document.getElementById('key-scan-canvas');
|
|
var ctx = canvas.getContext('2d');
|
|
var img = new Image();
|
|
img.src = URL.createObjectURL(file);
|
|
var self = this;
|
|
img.onload = function () {
|
|
canvas.width = img.width; canvas.height = img.height;
|
|
ctx.drawImage(img, 0, 0);
|
|
var imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
|
var code = jsQR(imageData.data, canvas.width, canvas.height);
|
|
URL.revokeObjectURL(img.src);
|
|
if (code && code.data && (code.data.indexOf('PGP PRIVATE KEY') !== -1 || code.data.startsWith('GZIP:'))) {
|
|
document.getElementById('key-scan-modal').close();
|
|
self._decompressText(code.data).then(function (key) {
|
|
document.getElementById('signin-privatekey').value = key;
|
|
self.notify('Private key scanned', false);
|
|
});
|
|
} else {
|
|
self.notify('No private key QR found in image', true);
|
|
}
|
|
document.querySelector('[data-action="keyscan-file"]').value = '';
|
|
};
|
|
},
|
|
|
|
async _compressText(str) {
|
|
if (typeof CompressionStream === 'undefined') return str;
|
|
try {
|
|
var blob = new Blob([str], { type: 'text/plain' });
|
|
var compressedStream = blob.stream().pipeThrough(new CompressionStream('gzip'));
|
|
var buf = await new Response(compressedStream).arrayBuffer();
|
|
var bytes = new Uint8Array(buf);
|
|
var b64 = '';
|
|
for (var i = 0; i < bytes.length; i++) b64 += String.fromCharCode(bytes[i]);
|
|
return 'GZIP:' + btoa(b64);
|
|
} catch (e) { return str; }
|
|
},
|
|
|
|
async _decompressText(data) {
|
|
if (data.startsWith('GZIP:')) {
|
|
try {
|
|
var b64 = data.substring(5);
|
|
var binary = atob(b64);
|
|
var bytes = Uint8Array.from(binary, c => c.charCodeAt(0));
|
|
var decompressedStream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream('gzip'));
|
|
return await new Response(decompressedStream).text();
|
|
} catch (e) { return data.startsWith('GZIP:') ? data.substring(5) : data; }
|
|
}
|
|
return data;
|
|
},
|
|
|
|
registerBackupDownload(keypair, email, passphrase) {
|
|
const backupBtn = document.querySelector('[data-action="download-backup"]');
|
|
if (!backupBtn) return;
|
|
const dateStr = new Date().toISOString().split('T')[0];
|
|
const backupContent = `=== apxtri Identity Backup ===\nAlias: ${keypair.alias}\nEmail: ${email || '-'}\nPassphrase: ${passphrase}\nDate: ${dateStr}\n\n=== 24-Word Recovery Phrase ===\n${keypair.mnemonic}\n\n=== Public Key ===\n${keypair.publickey}\n\n=== Private Key ===\n${keypair.privatekey}\n\n=== Instructions ===\nThis file contains everything needed to recover your identity.\nKeep it secure! Never share your private key or recovery phrase.\n`;
|
|
const fileName = `${keypair.alias}_backup.txt`;
|
|
const newBtn = backupBtn.cloneNode(true);
|
|
backupBtn.parentNode.replaceChild(newBtn, backupBtn);
|
|
const registerBtn = document.querySelector('[data-action="register-identity"]');
|
|
newBtn.addEventListener('click', () => {
|
|
const blob = new Blob([backupContent], { type: 'text/plain' });
|
|
const url = URL.createObjectURL(blob);
|
|
const link = document.createElement('a');
|
|
link.href = url;
|
|
link.download = fileName;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
URL.revokeObjectURL(url);
|
|
if (registerBtn) registerBtn.disabled = false;
|
|
});
|
|
},
|
|
};
|
|
|
|
document.addEventListener('DOMContentLoaded', () => App.init());
|
|
|
|
|