167 lines
6.5 KiB
HTML
167 lines
6.5 KiB
HTML
<!DOCTYPE html>
|
|||
|
|
<html lang="en">
|
||
|
|
<head>
|
||
|
|
<meta charset="UTF-8">
|
||
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
|
|
<title>My Account · Martinhal ISDSS</title>
|
||
|
|
<link rel="stylesheet" href="/css/style.css">
|
||
|
|
</head>
|
||
|
|
<body>
|
||
|
|
<script src="/js/app.js"></script>
|
||
|
|
<script>
|
||
|
|
(async () => {
|
||
|
|
const shell = await buildShell('account'); if (!shell) return;
|
||
|
|
const { me, content } = shell;
|
||
|
|
|
||
|
|
function render() {
|
||
|
|
content.innerHTML = `
|
||
|
|
<div class="page-head">
|
||
|
|
<h1>My Account</h1>
|
||
|
|
<p>Manage your sign-in security.</p>
|
||
|
|
</div>
|
||
|
|
<div class="card" style="max-width:640px">
|
||
|
|
<div class="card-head"><h2>Profile</h2></div>
|
||
|
|
<div class="card-body">
|
||
|
|
<div class="profile-row">
|
||
|
|
<div class="avatar-lg" id="avatarPreview">${avatarMarkup(me)}</div>
|
||
|
|
<div style="flex:1">
|
||
|
|
<p><strong>Username:</strong> ${esc(me.username)}</p>
|
||
|
|
<p><strong>Email:</strong> ${esc(me.email)}</p>
|
||
|
|
<p><strong>Role:</strong> <span class="badge badge-admin">${esc(me.role)}</span></p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div class="divider"></div>
|
||
|
|
<h3 style="margin:0 0 6px">Profile picture</h3>
|
||
|
|
<p class="hint" style="margin:0 0 12px">
|
||
|
|
Shown next to your name in the sidebar. PNG, JPEG, WebP or GIF, up to 4 MB.
|
||
|
|
</p>
|
||
|
|
<div class="row-actions" style="justify-content:flex-start">
|
||
|
|
<label class="btn btn-primary">
|
||
|
|
${me.avatar ? 'Change picture' : 'Upload picture'}
|
||
|
|
<input type="file" id="avatarFile" accept="image/png,image/jpeg,image/webp,image/gif" hidden>
|
||
|
|
</label>
|
||
|
|
<button class="btn btn-danger" id="avatarRemove" ${me.avatar ? '' : 'disabled'}>Remove</button>
|
||
|
|
</div>
|
||
|
|
<div class="err-line" id="avatarErr"></div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="card" style="max-width:640px">
|
||
|
|
<div class="card-head">
|
||
|
|
<h2>Multi-factor authentication</h2>
|
||
|
|
<span class="badge ${me.mfa_enabled ? 'badge-approved' : 'badge-pending'}">${me.mfa_enabled ? 'Enabled' : 'Disabled'}</span>
|
||
|
|
</div>
|
||
|
|
<div class="card-body" id="mfaBody"></div>
|
||
|
|
</div>`;
|
||
|
|
renderMfa();
|
||
|
|
}
|
||
|
|
|
||
|
|
function renderMfa() {
|
||
|
|
const body = document.getElementById('mfaBody');
|
||
|
|
if (me.mfa_enabled) {
|
||
|
|
body.innerHTML = `
|
||
|
|
<p>Your account is protected with an authenticator app. You'll be asked for a code each time you sign in.</p>
|
||
|
|
<div class="field" style="max-width:320px">
|
||
|
|
<label>Confirm password to turn off MFA</label>
|
||
|
|
<input type="password" id="offpass">
|
||
|
|
</div>
|
||
|
|
<button class="btn btn-danger" id="disableBtn">Turn off MFA</button>`;
|
||
|
|
document.getElementById('disableBtn').onclick = async () => {
|
||
|
|
try {
|
||
|
|
await api('/api/auth/mfa/disable', { method: 'POST', body: { password: document.getElementById('offpass').value } });
|
||
|
|
me.mfa_enabled = false; toast('MFA disabled', 'ok'); render();
|
||
|
|
} catch (e) { toast(e.message, 'err'); }
|
||
|
|
};
|
||
|
|
} else {
|
||
|
|
body.innerHTML = `
|
||
|
|
<p>Add a second layer of security. You'll scan a QR code with an authenticator app (Google Authenticator, Authy, 1Password…) and enter a code to confirm.</p>
|
||
|
|
<button class="btn btn-primary" id="startBtn">${ICON.shield} Set up MFA</button>`;
|
||
|
|
document.getElementById('startBtn').onclick = startSetup;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function startSetup() {
|
||
|
|
const r = await api('/api/auth/mfa/setup', { method: 'POST' });
|
||
|
|
modal('Set up multi-factor authentication', `
|
||
|
|
<div class="qr-box">
|
||
|
|
<img src="${r.qr}" alt="QR code">
|
||
|
|
<p class="hint">Scan with your authenticator app, or enter this key manually:</p>
|
||
|
|
<div class="secret">${esc(r.base32)}</div>
|
||
|
|
</div>
|
||
|
|
<div class="field" style="margin-top:20px">
|
||
|
|
<label>Enter the 6-digit code to confirm</label>
|
||
|
|
<input type="text" id="confirmCode" class="otp-input" inputmode="numeric" maxlength="6" placeholder="••••••">
|
||
|
|
</div>
|
||
|
|
<div class="err-line" id="mfaErr"></div>`, {
|
||
|
|
sticky: true,
|
||
|
|
buttons: [
|
||
|
|
{ label: 'Cancel' },
|
||
|
|
{ label: 'Enable MFA', className: 'btn-primary', onClick: async (back) => {
|
||
|
|
try {
|
||
|
|
await api('/api/auth/mfa/enable', { method: 'POST', body: { token: back.querySelector('#confirmCode').value.trim() } });
|
||
|
|
me.mfa_enabled = true; toast('MFA enabled', 'ok'); back.remove(); render();
|
||
|
|
} catch (e) { back.querySelector('#mfaErr').textContent = e.message; }
|
||
|
|
return false;
|
||
|
|
} },
|
||
|
|
],
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// Picture, or the user's initials on a coloured disc as a fallback.
|
||
|
|
function avatarMarkup(u) {
|
||
|
|
if (u.avatar) return `<img src="${esc(u.avatar)}" alt="Your profile picture">`;
|
||
|
|
const initials = (u.username || '?').slice(0, 2).toUpperCase();
|
||
|
|
return `<span class="avatar-initials">${esc(initials)}</span>`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function bindAvatar() {
|
||
|
|
const fileInput = document.getElementById('avatarFile');
|
||
|
|
const removeBtn = document.getElementById('avatarRemove');
|
||
|
|
const err = document.getElementById('avatarErr');
|
||
|
|
|
||
|
|
if (fileInput) fileInput.onchange = async () => {
|
||
|
|
err.textContent = '';
|
||
|
|
const file = fileInput.files && fileInput.files[0];
|
||
|
|
if (!file) return;
|
||
|
|
if (file.size > 4 * 1024 * 1024) { err.textContent = 'That image is larger than 4 MB.'; return; }
|
||
|
|
const fd = new FormData();
|
||
|
|
fd.append('avatar', file);
|
||
|
|
try {
|
||
|
|
const r = await api('/api/auth/avatar', { method: 'POST', body: fd });
|
||
|
|
me.avatar = r.avatar;
|
||
|
|
toast('Profile picture updated', 'ok');
|
||
|
|
render(); bindAvatar();
|
||
|
|
refreshSidebarAvatar();
|
||
|
|
} catch (e) { err.textContent = e.message; }
|
||
|
|
};
|
||
|
|
|
||
|
|
if (removeBtn) removeBtn.onclick = async () => {
|
||
|
|
err.textContent = '';
|
||
|
|
try {
|
||
|
|
await api('/api/auth/avatar', { method: 'DELETE' });
|
||
|
|
me.avatar = null;
|
||
|
|
toast('Profile picture removed', 'ok');
|
||
|
|
render(); bindAvatar();
|
||
|
|
refreshSidebarAvatar();
|
||
|
|
} catch (e) { err.textContent = e.message; }
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// Update the sidebar avatar live, without a page reload.
|
||
|
|
function refreshSidebarAvatar() {
|
||
|
|
const holder = document.querySelector('.side-user .u-avatar');
|
||
|
|
if (!holder) return;
|
||
|
|
holder.innerHTML = me.avatar
|
||
|
|
? `<img src="${esc(me.avatar)}?t=${Date.now()}" alt="">`
|
||
|
|
: `<span class="avatar-initials">${esc((me.username || '?').slice(0, 2).toUpperCase())}</span>`;
|
||
|
|
}
|
||
|
|
|
||
|
|
const _origRender = render;
|
||
|
|
render = function () { _origRender(); bindAvatar(); };
|
||
|
|
|
||
|
|
render();
|
||
|
|
})();
|
||
|
|
</script>
|
||
|
|
</body>
|
||
|
|
</html>
|