const $ = selector => document.querySelector(selector); const $$ = selector => [...document.querySelectorAll(selector)]; const escapeHTML = value => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); const date = value => value ? new Intl.DateTimeFormat(undefined, {dateStyle: 'medium', timeStyle: 'short'}).format(new Date(value)) : 'Never'; const notice = (message, error = false) => { const el = $('#notice'); el.textContent = message; el.hidden = false; el.style.borderColor = error ? 'var(--danger)' : 'var(--accent)'; clearTimeout(notice.timer); notice.timer = setTimeout(() => el.hidden = true, 5000) }; const avatar = {dataURL: '', fileName: '', pending: false}; async function api(path, options = {}) { const response = await fetch(path, { credentials: 'same-origin', ...options, headers: {'Content-Type': 'application/json', ...(options.headers || {})} }); if (response.status === 401) { location.href = '/login'; throw new Error('Session expired') } const body = response.status === 204 ? null : await response.json(); if (!response.ok) throw new Error(body?.error || `Request failed (${response.status})`); return body; } async function load() { render(await api('/api/v1/profile')) } function render(profile) { $('#display-name').value = profile.display_name; $('#email').value = profile.email; $('#system-admin').textContent = profile.is_system_administrator ? 'Yes' : 'No'; $('#created-at').textContent = date(profile.created_at); $('#last-login').textContent = date(profile.last_login_at); $('#name-source').textContent = profile.name_customized ? 'You' : 'Your identity provider'; renderAvatar(profile); $('#membership-list').innerHTML = profile.memberships.map(item => ` ${escapeHTML(item.tenant_name)} ${escapeHTML(item.tenant_slug)} ${escapeHTML(item.role)} ${item.permissions.map(permission => `${escapeHTML(permission)}`).join('') || 'None'} `).join('') || 'No tenant role assigned yet' } function renderAvatar(profile) { const image = $('#avatar-image'), fallback = $('#avatar-fallback'), link = $('#avatar-url'); const source = avatar.pending ? avatar.dataURL : profile.avatar_url; $('#avatar-remove').hidden = !profile.avatar_url; $('#copy-avatar').hidden = !profile.avatar_url; if (source) { image.src = source; image.hidden = false; fallback.hidden = true } else { image.hidden = true; image.removeAttribute('src'); fallback.hidden = false; fallback.textContent = initials(profile.display_name || profile.email) } link.textContent = profile.avatar_url || (avatar.pending ? 'Saved on upload' : 'No picture uploaded') } function initials(name) { return String(name || '?').split(/[\s@._-]+/).filter(Boolean).slice(0, 2).map(part => part[0].toUpperCase()).join('') || '?' } $('#profile-form').addEventListener('submit', async event => { event.preventDefault(); try { render(await api('/api/v1/profile', { method: 'PUT', body: JSON.stringify({display_name: $('#display-name').value}) })); notice('Profile saved') } catch (error) { notice(error.message, true) } }); $('#avatar-file').addEventListener('change', async event => { const file = event.target.files?.[0]; avatar.pending = false; if (!file) { $('#avatar-save').disabled = true; return } if (file.size > 2 * 1024 * 1024) { notice('The picture must be 2 MiB or smaller', true); event.target.value = ''; $('#avatar-save').disabled = true; return } avatar.dataURL = await readAsDataURL(file); avatar.fileName = file.name; avatar.pending = true; $('#avatar-save').disabled = false; renderAvatar({avatar_url: '', display_name: $('#display-name').value, email: $('#email').value}) }); $('#avatar-form').addEventListener('submit', async event => { event.preventDefault(); if (!avatar.pending) return; try { const profile = await api('/api/v1/profile', { method: 'PUT', body: JSON.stringify({avatar: avatar.dataURL}) }); avatar.pending = false; $('#avatar-file').value = ''; $('#avatar-save').disabled = true; render(profile); notice('Profile picture updated') } catch (error) { notice(error.message, true) } }); $('#avatar-remove').addEventListener('click', async () => { if (!confirm('Remove your profile picture?')) return; try { const profile = await api('/api/v1/profile', {method: 'PUT', body: JSON.stringify({remove_avatar: true})}); avatar.pending = false; $('#avatar-file').value = ''; $('#avatar-save').disabled = true; render(profile); notice('Profile picture removed') } catch (error) { notice(error.message, true) } }); $('#copy-avatar').addEventListener('click', () => navigator.clipboard.writeText($('#avatar-url').textContent).then(() => notice('Picture link copied'))); function readAsDataURL(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result); reader.onerror = () => reject(new Error('could not read the selected file')); reader.readAsDataURL(file) }) } const theme = $('#theme'); theme.value = localStorage.getItem('gl-theme') || 'tokyo'; document.documentElement.dataset.theme = theme.value; document.documentElement.dataset.mode = localStorage.getItem('gl-mode') || 'dark'; theme.addEventListener('change', () => { document.documentElement.dataset.theme = theme.value; localStorage.setItem('gl-theme', theme.value) }); $('#mode').addEventListener('click', () => { const next = document.documentElement.dataset.mode === 'dark' ? 'light' : 'dark'; document.documentElement.dataset.mode = next; localStorage.setItem('gl-mode', next) }); load().catch(error => notice(error.message, true));