fix(database): use per-user admin passwords
This commit is contained in:
1 parent
33e80a566e
commit
9862348d70
17 files changed
+188
-38
No files matched your search
+1
-1
@@ -3,7 +3,7 @@
|
||||
const DRAFT_KEY = 'lifeos_today_draft_v1';
|
||||
const localDay = () => { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; };
|
||||
const escapeHtml = value => String(value || '').replace(/[&<>]/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[char]));
|
||||
const APP_VERSION = '1.14.0';
|
||||
const APP_VERSION = '1.14.1';
|
||||
window.LifeOSVersion = APP_VERSION;
|
||||
const appIds = ['today', 'timeline', 'museum', 'insights', 'profile', 'tasks', 'calendar', 'system', 'browser', 'terminal', 'database'];
|
||||
const t = (key, values) => LifeI18n.t(key, values);
|
||||
|
||||
+49
-8
@@ -3,7 +3,7 @@
|
||||
const KINDS = ['memories', 'goals', 'tasks', 'museum', 'profile', 'settings', 'system'];
|
||||
const text = (key, values) => LifeI18n.t(`db.${key}`, values);
|
||||
const escape = value => String(value ?? '').replace(/[&<>"']/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[char]));
|
||||
let session = null, expiresAt = 0, kind = 'memories', page = 0, rows = [], total = 0, selected = null, creating = false, error = '', notice = '', draft = null, draftId = null;
|
||||
let session = null, expiresAt = 0, configured = null, checking = false, changeMode = false, kind = 'memories', page = 0, rows = [], total = 0, selected = null, creating = false, error = '', notice = '', draft = null, draftId = null;
|
||||
const current = () => document.querySelector('#content-database');
|
||||
|
||||
async function api(path, options = {}) {
|
||||
@@ -13,19 +13,20 @@
|
||||
}
|
||||
});
|
||||
const result = await response.json();
|
||||
if (response.status === 401 && path !== 'login') { session = null; expiresAt = 0; error = text('sessionExpired'); render(current()); }
|
||||
if (response.status === 401 && !['login', 'setup', 'password'].includes(path)) { session = null; expiresAt = 0; error = text('sessionExpired'); render(current()); }
|
||||
if (!response.ok) throw new Error(result.error || text('requestFailed'));
|
||||
return result;
|
||||
}
|
||||
|
||||
function login(node) {
|
||||
node.innerHTML = `<div class="db-intro"><div class="eyebrow">LIFE OS / D1</div><h1>${text('title')}</h1><p class="sub">${text('loginBody')}</p><form class="db-login">
|
||||
node.innerHTML = `<div class="db-intro"><div class="eyebrow">LIFE OS / D1</div><h1>${configured ? text('title') : text('setupTitle')}</h1><p class="sub">${configured ? text('loginBody') : text('setupBody')}</p><form class="db-login">
|
||||
<label>${text('server')}<input value="Life OS / D1" readonly aria-readonly="true"></label>
|
||||
<label>${text('username')}<input name="username" value="lifeos-admin" readonly aria-readonly="true"></label>
|
||||
<label>${text('password')}<input name="password" type="password" autocomplete="off" required minlength="20"></label>
|
||||
<label>${configured ? text('password') : text('newPassword')}<input name="password" type="password" autocomplete="${configured ? 'current-password' : 'new-password'}" required minlength="12" maxlength="256"></label>
|
||||
${configured ? '' : `<label>${text('confirmPassword')}<input name="confirmPassword" type="password" autocomplete="new-password" required minlength="12" maxlength="256"></label>`}
|
||||
<div class="db-warning" role="alert"><strong>${text('warningTitle')}</strong><p>${text('warningBody')}</p></div>
|
||||
<label class="db-ack"><input type="checkbox" name="acknowledged" required> <span>${text('acknowledge')}</span></label>
|
||||
<div class="actions"><button class="primary" type="submit">${text('login')}</button></div>
|
||||
<div class="actions"><button class="primary" type="submit">${configured ? text('login') : text('setPassword')}</button></div>
|
||||
${error ? `<p class="db-error" role="alert">${escape(error)}</p>` : ''}
|
||||
</form></div>`;
|
||||
node.querySelector('form').addEventListener('submit', async event => {
|
||||
@@ -33,17 +34,40 @@
|
||||
const form = event.currentTarget, button = form.querySelector('button[type=submit]');
|
||||
button.disabled = true; error = '';
|
||||
try {
|
||||
const result = await api('login', { method: 'POST', body: JSON.stringify({
|
||||
if (!configured && form.elements.password.value !== form.elements.confirmPassword.value) throw new Error(text('passwordMismatch'));
|
||||
const result = await api(configured ? 'login' : 'setup', { method: 'POST', body: JSON.stringify({
|
||||
username: form.elements.username.value, password: form.elements.password.value,
|
||||
acknowledged: form.elements.acknowledged.checked
|
||||
}) });
|
||||
form.elements.password.value = '';
|
||||
session = result.token; expiresAt = result.expiresAt; notice = text('connected');
|
||||
configured = true; session = result.token; expiresAt = result.expiresAt; notice = text('connected');
|
||||
await load(); render(current());
|
||||
} catch (cause) { error = cause.message; render(current()); }
|
||||
finally { button.disabled = false; }
|
||||
});
|
||||
}
|
||||
function changePassword(node) {
|
||||
node.innerHTML = `<div class="db-intro"><div class="eyebrow">LIFE OS / D1</div><h1>${text('changePassword')}</h1><form class="db-login">
|
||||
<label>${text('currentPassword')}<input name="currentPassword" type="password" autocomplete="current-password" required></label>
|
||||
<label>${text('newPassword')}<input name="newPassword" type="password" autocomplete="new-password" required minlength="12" maxlength="256"></label>
|
||||
<label>${text('confirmPassword')}<input name="confirmPassword" type="password" autocomplete="new-password" required minlength="12" maxlength="256"></label>
|
||||
<div class="actions"><button class="primary" type="submit">${text('changePassword')}</button><button class="secondary" type="button">${text('cancel')}</button></div>
|
||||
${error ? `<p class="db-error" role="alert">${escape(error)}</p>` : ''}
|
||||
</form></div>`;
|
||||
node.querySelector('button[type=button]').addEventListener('click', () => { changeMode = false; error = ''; render(current()); });
|
||||
node.querySelector('form').addEventListener('submit', async event => {
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget;
|
||||
try {
|
||||
if (form.elements.newPassword.value !== form.elements.confirmPassword.value) throw new Error(text('passwordMismatch'));
|
||||
const result = await api('password', { method: 'POST', body: JSON.stringify({
|
||||
currentPassword: form.elements.currentPassword.value, newPassword: form.elements.newPassword.value
|
||||
}) });
|
||||
form.reset(); session = result.token; expiresAt = result.expiresAt; changeMode = false;
|
||||
notice = text('passwordChanged'); error = ''; render(current());
|
||||
} catch (cause) { error = cause.message; render(current()); }
|
||||
});
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!session) return;
|
||||
@@ -66,7 +90,7 @@
|
||||
}
|
||||
|
||||
function workspace(node) {
|
||||
node.innerHTML = `<div class="db-shell"><header class="db-head"><div><div class="eyebrow">D1 / ${text('ownData')}</div><h1>${text('title')}</h1><p class="sub">${text('directMode')}</p></div><button class="secondary" data-action="logout">${text('logout')}</button></header>
|
||||
node.innerHTML = `<div class="db-shell"><header class="db-head"><div><div class="eyebrow">D1 / ${text('ownData')}</div><h1>${text('title')}</h1><p class="sub">${text('directMode')}</p></div><div class="actions"><button class="secondary" data-action="password">${text('changePassword')}</button><button class="secondary" data-action="logout">${text('logout')}</button></div></header>
|
||||
<div class="db-toolbar"><div class="db-tables" role="tablist" aria-label="${text('tables')}">${KINDS.map(item => `<button role="tab" aria-selected="${kind === item}" class="${kind === item ? 'active' : ''}" data-kind="${item}">${text(item)}</button>`).join('')}</div>
|
||||
<button class="secondary" data-action="refresh">${text('refresh')}</button><button class="primary" data-action="new">${text('newRecord')}</button></div>
|
||||
${notice ? `<p class="db-notice" role="status">${escape(notice)}</p>` : ''}${error ? `<p class="db-error" role="alert">${escape(error)}</p>` : ''}
|
||||
@@ -87,6 +111,7 @@
|
||||
const action = button.dataset.action;
|
||||
if (action === 'reload') { location.reload(); return; }
|
||||
if (action === 'logout') { try { await api('logout', { method: 'POST' }); } catch {} session = null; selected = null; notice = ''; render(current()); return; }
|
||||
if (action === 'password') { changeMode = true; error = ''; render(current()); return; }
|
||||
if (action === 'new') { creating = true; selected = null; draft = null; draftId = null; render(current()); return; }
|
||||
if (action === 'cancel') { creating = false; draft = null; draftId = null; render(current()); return; }
|
||||
if (action === 'delete') { await remove(); return; }
|
||||
@@ -128,7 +153,23 @@
|
||||
}
|
||||
function render(node) {
|
||||
if (!node) return;
|
||||
if (configured === null) {
|
||||
node.innerHTML = `<div class="db-intro"><p class="sub">${text('checking')}</p></div>`;
|
||||
if (!checking) {
|
||||
checking = true;
|
||||
api('config').then(result => { configured = result.configured; error = ''; })
|
||||
.catch(cause => { configured = 'unavailable'; error = cause.message; })
|
||||
.finally(() => { checking = false; render(current()); });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (configured === 'unavailable') {
|
||||
node.innerHTML = `<div class="db-intro"><p class="db-error" role="alert">${escape(error)}</p><button class="secondary">${text('refresh')}</button></div>`;
|
||||
node.querySelector('button').addEventListener('click', () => { configured = null; render(current()); });
|
||||
return;
|
||||
}
|
||||
if (!session || expiresAt <= Date.now()) { session = null; login(node); }
|
||||
else if (changeMode) changePassword(node);
|
||||
else workspace(node);
|
||||
}
|
||||
window.databaseApp = render;
|
||||
|
||||
@@ -110,7 +110,7 @@
|
||||
window.saveProfile = () => { save('lifeos_profile', { name: pname.value, birthday: pbday.value, startDate: pstart.value || day(), motto: pmotto.value }); renderAll(); toast(t('profile.saved')); };
|
||||
window.systemApp = container => {
|
||||
const state = settings(), preference = LifeI18n.preference(), bytes = new Blob([JSON.stringify({ mems: mems(), museum: museum(), profile: profile(), system: sys() })]).size, oldBackup = state.lastBackup && Date.now() - new Date(state.lastBackup) > 12096e5, detected = browserLanguage() === 'zh' ? t('system.detectedZh') : t('system.detectedEn');
|
||||
container.innerHTML = `<div class="eyebrow">${t('system.eyebrow')}</div><h1>${t('system.title')}</h1><div class="settings"><section><div class="eyebrow">${t('system.appearance')}</div><div class="choices">${['dark','light','auto'].map(item => `<button class="chip ${state.theme === item ? 'selected' : ''}" onclick="theme('${item}')">${t(`system.theme.${item}`)}</button>`).join('')}</div></section><section><div class="eyebrow">${t('system.language')}</div><p class="sub">${detected}</p><div class="choices"><button class="chip ${preference === 'auto' ? 'selected' : ''}" onclick="window.setLanguage('auto')">${t('system.auto')}</button><button class="chip ${preference === 'en' ? 'selected' : ''}" onclick="window.setLanguage('en')">${t('system.languageEnglish')}</button><button class="chip ${preference === 'zh' ? 'selected' : ''}" onclick="window.setLanguage('zh')">${t('system.languageChinese')}</button></div></section><section><div class="eyebrow">${t('system.data')}</div><div class="actions"><button class="primary" onclick="exportData()">${t('system.export')}</button><button class="secondary" onclick="document.querySelector('#importer').click()">${t('system.import')}</button><button class="danger" onclick="resetConfirm()">${t('system.reset')}</button><input id="importer" class="hidden" type="file" accept=".json" onchange="readImport(this)"></div></section><section><div class="eyebrow">${t('system.storage')}</div><p>${t('system.local')}<br>${t('system.storageSummary',{memories:LifeI18n.number(mems().length),museum:LifeI18n.number(museum().length),tasks:LifeI18n.number(sys().tasksBoard?.tasks?.length||0),size:LifeI18n.number(Math.ceil(bytes/1024))})}</p></section><section><div class="eyebrow">${t('system.dataStorage')}</div><p>${t('system.stored')}<br>${t('system.backups')}</p><p>${t('system.lastBackup',{value:state.lastBackup ? short(state.lastBackup.slice(0,10)) : t('common.never')})}${oldBackup ? `<br><b style="color:var(--accent)">${t('system.recommended')}</b>` : ''}</p></section><section><div class="eyebrow">${t('system.version')}</div><p>${t('system.release',{version:window.LifeOSVersion||'1.14.0'})}</p></section></div>`;
|
||||
container.innerHTML = `<div class="eyebrow">${t('system.eyebrow')}</div><h1>${t('system.title')}</h1><div class="settings"><section><div class="eyebrow">${t('system.appearance')}</div><div class="choices">${['dark','light','auto'].map(item => `<button class="chip ${state.theme === item ? 'selected' : ''}" onclick="theme('${item}')">${t(`system.theme.${item}`)}</button>`).join('')}</div></section><section><div class="eyebrow">${t('system.language')}</div><p class="sub">${detected}</p><div class="choices"><button class="chip ${preference === 'auto' ? 'selected' : ''}" onclick="window.setLanguage('auto')">${t('system.auto')}</button><button class="chip ${preference === 'en' ? 'selected' : ''}" onclick="window.setLanguage('en')">${t('system.languageEnglish')}</button><button class="chip ${preference === 'zh' ? 'selected' : ''}" onclick="window.setLanguage('zh')">${t('system.languageChinese')}</button></div></section><section><div class="eyebrow">${t('system.data')}</div><div class="actions"><button class="primary" onclick="exportData()">${t('system.export')}</button><button class="secondary" onclick="document.querySelector('#importer').click()">${t('system.import')}</button><button class="danger" onclick="resetConfirm()">${t('system.reset')}</button><input id="importer" class="hidden" type="file" accept=".json" onchange="readImport(this)"></div></section><section><div class="eyebrow">${t('system.storage')}</div><p>${t('system.local')}<br>${t('system.storageSummary',{memories:LifeI18n.number(mems().length),museum:LifeI18n.number(museum().length),tasks:LifeI18n.number(sys().tasksBoard?.tasks?.length||0),size:LifeI18n.number(Math.ceil(bytes/1024))})}</p></section><section><div class="eyebrow">${t('system.dataStorage')}</div><p>${t('system.stored')}<br>${t('system.backups')}</p><p>${t('system.lastBackup',{value:state.lastBackup ? short(state.lastBackup.slice(0,10)) : t('common.never')})}${oldBackup ? `<br><b style="color:var(--accent)">${t('system.recommended')}</b>` : ''}</p></section><section><div class="eyebrow">${t('system.version')}</div><p>${t('system.release',{version:window.LifeOSVersion||'1.14.1'})}</p></section></div>`;
|
||||
};
|
||||
window.importPreview = () => { const data = importData, board = data.system?.tasksBoard || {}; rootModal().innerHTML = `<div class="modal"><div class="modalbox"><div class="eyebrow">${t('dialog.backup')}</div><h2>${t('dialog.ready')}</h2><p class="sub">${t('dialog.exported',{date:short(data.exportedAt.slice(0,10))})}<br>${t('dialog.memories',{count:data.memories.length})}<br>${t('dialog.museumItems',{count:data.museum.length})}<br>${t('dialog.goals',{count:board.goals?.length || 0})}<br>${t('dialog.tasks',{count:board.tasks?.length || 0})}</p><div class="actions"><button class="primary" onclick="applyImport('merge')">${t('dialog.merge')}</button><button class="danger" onclick="replaceConfirm()">${t('dialog.replaceAll')}</button><button class="secondary" onclick="modal.innerHTML=''">${t('common.cancel')}</button></div></div></div>`; };
|
||||
window.replaceConfirm = () => { rootModal().innerHTML = `<div class="modal"><div class="modalbox"><div class="eyebrow">${t('dialog.final')}</div><h2>${t('dialog.replaceQuestion')}</h2><p class="sub">${t('dialog.replaceWarning')}</p><div class="actions"><button class="danger" onclick="applyImport('replace')">${t('dialog.replace')}</button><button class="secondary" onclick="importPreview()">${t('common.cancel')}</button></div></div></div>`; };
|
||||
|
||||
Loaded 3 of 17 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user