/** * Shorebird Self-Hosted Dashboard - Complete SPA Application * Vanilla JS, no framework dependencies. */ // ---- State ---- const state = { token: localStorage.getItem('shorebird_token') || null, user: null, apps: [], users: [], organizations: [], currentApp: null, currentRelease: null, confirmCallback: null, }; // ---- API Client ---- const api = { base: '/api/v1', auth: '/auth', headers() { const h = { 'Content-Type': 'application/json' }; if (state.token) h['Authorization'] = `Bearer ${state.token}`; return h; }, async request(method, path, body) { const url = path.startsWith('/auth') ? path : `${this.base}${path}`; const opts = { method, headers: this.headers() }; if (body) opts.body = JSON.stringify(body); const res = await fetch(url, opts); if (res.status === 204) return null; const data = await res.json(); if (!res.ok) { if (res.status === 401) { logout(); throw new Error('Unauthorized'); } throw new Error(data.message || 'Request failed'); } return data; }, get(path) { return this.request('GET', path); }, post(path, body) { return this.request('POST', path, body); }, patch(path, body) { return this.request('PATCH', path, body); }, del(path) { return this.request('DELETE', path); }, }; // ---- Navigation ---- function navigate(page) { document.querySelectorAll('.page-section').forEach(s => s.classList.remove('active')); const section = document.getElementById(`page-${page}`); if (section) section.classList.add('active'); document.querySelectorAll('#sidebarNav a').forEach(a => a.classList.remove('active')); const link = document.querySelector(`#sidebarNav a[data-page="${page}"]`); if (link) link.classList.add('active'); const titles = { dashboard: 'Dashboard', apps: 'Apps', users: 'Users', organizations: 'Organizations', settings: 'Settings' }; document.getElementById('pageTitle').textContent = titles[page] || page; if (page === 'dashboard') loadDashboard(); if (page === 'apps') loadApps(); if (page === 'users') loadUsers(); if (page === 'organizations') loadOrganizations(); if (page === 'settings') loadSettings(); } // ---- Toast Notifications ---- function showToast(message, type) { const container = document.getElementById('toastContainer'); const toast = document.createElement('div'); toast.className = `toast toast-${type}`; toast.textContent = message; container.appendChild(toast); setTimeout(() => { toast.remove(); }, 4000); } // ---- Modals ---- function openModal(id) { document.getElementById(id).classList.add('active'); } function closeModal(id) { document.getElementById(id).classList.remove('active'); } function confirmAction(title, message, callback) { document.getElementById('confirmTitle').textContent = title; document.getElementById('confirmMessage').textContent = message; state.confirmCallback = callback; openModal('confirmModal'); } function executeConfirm() { if (state.confirmCallback) state.confirmCallback(); closeModal('confirmModal'); } // ---- Auth ---- async function login(email, password) { const data = await api.request('POST', '/auth/token', { email, password }); state.token = data.token; state.mustChangePassword = data.must_change_password; localStorage.setItem('shorebird_token', data.token); await loadCurrentUser(); showApp(); if (state.mustChangePassword || state.user.must_change_password) { navigate('settings'); openModal('forcePasswordOverlay'); } else { navigate('dashboard'); } } async function loadPublicSettings() { try { const data = await api.request('GET', '/auth/public-settings'); document.getElementById('ssoLoginBtn').style.display = data.sso_enabled ? 'inline-flex' : 'none'; const canRegister = data.registration_enabled && !data.sso_only_registration; document.getElementById('registerPrompt').style.display = canRegister ? 'inline' : 'none'; } catch { document.getElementById('ssoLoginBtn').style.display = 'none'; } } function logout() { state.token = null; state.user = null; state.apps = []; localStorage.removeItem('shorebird_token'); document.getElementById('appContainer').classList.remove('active'); document.getElementById('loginPage').style.display = 'flex'; } async function loadCurrentUser() { try { state.user = await api.get('/users/me'); document.getElementById('currentUserEmail').textContent = state.user.email || '-'; if (state.user.must_change_password) setTimeout(() => openModal('forcePasswordOverlay'), 0); } catch (e) { state.user = { email: 'admin' }; } } function showApp() { document.getElementById('loginPage').style.display = 'none'; document.getElementById('appContainer').classList.add('active'); } async function register(name, email, password) { await api.request('POST', '/auth/register', { name, email, password }); closeModal('registerOverlay'); showToast('Account created. Check your email to verify before signing in.', 'success'); } // ---- Dashboard ---- async function loadDashboard() { try { const appsResp = await api.get('/apps'); state.apps = appsResp.apps || []; document.getElementById('statApps').textContent = state.apps.length; document.getElementById('statPatches').textContent = '-'; // Try to get users try { const usersResp = await api.get('/admin/users'); state.users = usersResp.users || []; document.getElementById('statUsers').textContent = state.users.length; } catch { document.getElementById('statUsers').textContent = '-'; } // Try to get organizations try { const orgsResp = await api.get('/organizations'); const orgs = orgsResp.organizations || []; document.getElementById('statOrganizations').textContent = orgs.length; } catch { document.getElementById('statOrganizations').textContent = '-'; } // Recent apps table const tableDiv = document.getElementById('recentAppsTable'); if (state.apps.length === 0) { tableDiv.innerHTML = '
No apps yet. Create your first app to get started.
${a.app_id}`,
new Date(a.created_at).toLocaleDateString(),
])
);
}
} catch (e) {
showToast('Failed to load dashboard', 'error');
}
}
// ---- Apps ----
async function loadApps() {
try {
const resp = await api.get('/apps');
state.apps = resp.apps || [];
const tableDiv = document.getElementById('appsTable');
if (state.apps.length === 0) {
tableDiv.innerHTML = 'No apps yet. Create your first app.
${a.app_id}`,
new Date(a.created_at).toLocaleDateString(),
`
`,
])
);
} catch (e) {
showToast('Failed to load apps', 'error');
}
}
async function createApp(displayName, organizationId) {
try {
await api.post('/apps', { display_name: displayName, organization_id: Number(organizationId || 0) });
closeModal('createAppOverlay');
showToast(`App "${displayName}" created`, 'success');
loadApps();
loadDashboard();
} catch (e) {
showToast(e.message || 'Failed to create app', 'error');
}
}
function confirmDeleteApp(appId, name) {
confirmAction('Delete App', `Are you sure you want to delete "${name}"? This action cannot be undone.`, async () => {
try {
await api.del(`/apps/${appId}`);
showToast(`App "${name}" deleted`, 'success');
loadApps();
loadDashboard();
} catch (e) {
showToast(e.message || 'Failed to delete app', 'error');
}
});
}
function openCreateAppModal() {
document.getElementById('appDisplayName').value = '';
const select = document.getElementById('appOrganizationId');
select.innerHTML = '';
api.get('/organizations').then(resp => {
state.organizations = resp.organizations || [];
if (state.organizations.length > 0) {
select.innerHTML = state.organizations
.map(m => ``)
.join('');
}
}).catch(() => {});
openModal('createAppOverlay');
}
// ---- Users ----
async function loadUsers() {
try {
const resp = await api.get('/admin/users');
state.users = resp.users || [];
const tableDiv = document.getElementById('usersTable');
if (state.users.length === 0) {
tableDiv.innerHTML = 'No users found.
User management API not available. Check server configuration.
${window.location.origin}/api/v1${window.location.origin}/auth| ${h} | `; }); html += '
|---|
| ${cell} | `; }); html += '
No users found.
Admin access required for user management.
No organizations found.
Failed to load organizations.
Organization admin access required to view members.
${window.location.origin}/api/v1${window.location.origin}/authAdmin access required for server settings.
'; } } // ---- Event Listeners ---- document.addEventListener('DOMContentLoaded', () => { loadPublicSettings(); // Login form document.getElementById('loginForm').addEventListener('submit', async e => { e.preventDefault(); const email = document.getElementById('loginEmail').value; const password = document.getElementById('loginPassword').value; const errorDiv = document.getElementById('loginError'); errorDiv.style.display = 'none'; try { await login(email, password); } catch (err) { errorDiv.textContent = err.message || 'Login failed. Check your credentials.'; errorDiv.style.display = 'block'; } }); // Register form (modal) document.getElementById('registerForm').addEventListener('submit', async e => { e.preventDefault(); const name = document.getElementById('regName').value; const email = document.getElementById('regEmail').value; const password = document.getElementById('regPassword').value; try { await register(name, email, password); } catch (err) { showToast(err.message || 'Registration failed', 'error'); } }); // Show register modal document.getElementById('showRegister').addEventListener('click', e => { e.preventDefault(); document.getElementById('regName').value = ''; document.getElementById('regEmail').value = ''; document.getElementById('regPassword').value = ''; openModal('registerOverlay'); }); document.getElementById('showPasswordReset').addEventListener('click', e => { e.preventDefault(); document.getElementById('resetEmail').value = document.getElementById('loginEmail').value || ''; openModal('passwordResetOverlay'); }); function closeRegister() { closeModal('registerOverlay'); } window.closeRegister = closeRegister; // Create app form document.getElementById('createAppForm').addEventListener('submit', async e => { e.preventDefault(); const name = document.getElementById('appDisplayName').value; const orgId = document.getElementById('appOrganizationId').value; await createApp(name, orgId); }); // Create user form document.getElementById('createUserForm').addEventListener('submit', async e => { e.preventDefault(); const name = document.getElementById('newUserName').value; const email = document.getElementById('newUserEmail').value; const password = document.getElementById('newUserPassword').value; await createUser(name, email, password); }); document.getElementById('passwordResetForm').addEventListener('submit', async e => { e.preventDefault(); const email = document.getElementById('resetEmail').value; try { await api.request('POST', '/auth/password-reset/request', { email }); closeModal('passwordResetOverlay'); showToast('Password reset email sent if the account exists', 'success'); } catch (err) { showToast(err.message || 'Failed to request reset', 'error'); } }); document.getElementById('changePasswordForm').addEventListener('submit', async e => { e.preventDefault(); try { await api.patch('/users/me/password', { current_password: document.getElementById('currentPassword').value, new_password: document.getElementById('newPassword').value, }); document.getElementById('changePasswordForm').reset(); showToast('Password changed', 'success'); } catch (err) { showToast(err.message || 'Failed to change password', 'error'); } }); document.getElementById('forcePasswordForm').addEventListener('submit', async e => { e.preventDefault(); try { await api.patch('/users/me/password', { current_password: '', new_password: document.getElementById('forceNewPassword').value, }); state.mustChangePassword = false; if (state.user) state.user.must_change_password = false; closeModal('forcePasswordOverlay'); document.getElementById('forcePasswordForm').reset(); showToast('Password updated', 'success'); navigate('dashboard'); } catch (err) { showToast(err.message || 'Failed to update password', 'error'); } }); document.getElementById('adminSettingsForm').addEventListener('submit', async e => { e.preventDefault(); const body = { registration_enabled: String(document.getElementById('settingRegistrationEnabled').checked), sso_registration_enabled: String(document.getElementById('settingSsoRegistrationEnabled').checked), sso_only_registration: String(document.getElementById('settingSsoOnlyRegistration').checked), casdoor_endpoint: document.getElementById('casdoorEndpoint').value, casdoor_client_id: document.getElementById('casdoorClientId').value, casdoor_client_secret: document.getElementById('casdoorClientSecret').value, casdoor_organization: document.getElementById('casdoorOrganization').value, }; try { await api.request('PUT', '/admin/settings', body); showToast('Settings saved', 'success'); loadPublicSettings(); loadSettings(); } catch (err) { showToast(err.message || 'Failed to save settings', 'error'); } }); document.getElementById('createOrgForm').addEventListener('submit', async e => { e.preventDefault(); await createOrganization(document.getElementById('orgName').value); }); document.getElementById('addOrgUserForm').addEventListener('submit', async e => { e.preventDefault(); await addOrganizationUser( document.getElementById('orgUserOrgId').value, document.getElementById('orgUserEmail').value, document.getElementById('orgUserRole').value, ); }); // Sidebar navigation document.querySelectorAll('#sidebarNav a').forEach(link => { link.addEventListener('click', e => { e.preventDefault(); const page = link.dataset.page; navigate(page); window.location.hash = page; }); }); // Logout document.getElementById('logoutBtn').addEventListener('click', () => { logout(); window.location.hash = ''; }); // Close modals on overlay click document.querySelectorAll('.modal-overlay').forEach(overlay => { overlay.addEventListener('click', e => { if (e.target === overlay) closeModal(overlay.id); }); }); // Auto-login if token exists if (state.token) { loadCurrentUser().then(() => { showApp(); if (state.user.must_change_password) { navigate('settings'); openModal('forcePasswordOverlay'); } else { const page = window.location.hash.replace('#', '') || 'dashboard'; navigate(page); } }).catch(() => { logout(); }); } }); // ---- App Detail Views ---- function setActivePage(page) { document.querySelectorAll('.page-section').forEach(s => s.classList.remove('active')); const section = document.getElementById(`page-${page}`); if (section) section.classList.add('active'); } function platformBadges(statuses) { const entries = Object.entries(statuses || {}); if (entries.length === 0) return 'no platforms'; return entries.map(([platform, status]) => `${escapeHtml(platform)}`).join(' '); } function humanSize(bytes) { if (!bytes) return '0 B'; const units = ['B', 'KB', 'MB', 'GB']; let size = bytes; let unit = 0; while (size >= 1024 && unit < units.length - 1) { size /= 1024; unit++; } return `${size.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`; } async function loadApps() { try { const resp = await api.get('/apps'); state.apps = resp.apps || []; const tableDiv = document.getElementById('appsTable'); if (state.apps.length === 0) { tableDiv.innerHTML = 'No apps yet. Create your first app.
${a.app_id}`,
new Date(a.created_at).toLocaleDateString(),
`
`,
])
);
} catch {
showToast('Failed to load apps', 'error');
}
}
async function openAppPage(appId) {
const app = state.apps.find(a => a.app_id === appId) || { app_id: appId, display_name: appId };
state.currentApp = app;
setActivePage('app-detail');
document.getElementById('pageTitle').textContent = app.display_name;
document.getElementById('appDetailTitle').textContent = app.display_name;
showAppTab('releases');
}
async function showAppTab(tab) {
document.querySelectorAll('[data-app-tab]').forEach(b => b.classList.toggle('active', b.dataset.appTab === tab));
document.querySelectorAll('.app-tab').forEach(el => { el.style.display = el.id === `appTab-${tab}` ? 'block' : 'none'; });
if (!state.currentApp) return;
if (tab === 'releases') await renderAppReleases();
if (tab === 'insights') await renderAppInsights();
if (tab === 'collaborators') await renderAppCollaborators();
if (tab === 'tracks') await renderAppTracks();
if (tab === 'settings') await renderAppSettings();
}
async function renderAppReleases() {
const div = document.getElementById('appTab-releases');
const resp = await api.get(`/apps/${state.currentApp.app_id}/releases`);
state.currentApp.releases = resp.releases || [];
if (state.currentApp.releases.length === 0) {
div.innerHTML = 'No releases yet.
Download metrics are shown when artifact/download events are recorded.
`; } async function renderAppCollaborators() { const orgs = await api.get('/organizations'); const membership = (orgs.organizations || []).find(m => m.organization.id === state.currentApp.organization_id); if (!membership) { document.getElementById('appTab-collaborators').innerHTML = 'No organization membership found.
Organization admin access required.
No tracks found.
${escapeHtml(a.hash || '-')}`,
`Download`,
])
);
}
function renderReleaseSettings() {
document.getElementById('releaseTab-settings').innerHTML = ``;
}
function confirmDeleteCurrentRelease() {
confirmAction('Delete Release', `Delete release ${state.currentRelease.version}?`, async () => {
await api.del(`/apps/${state.currentApp.app_id}/releases/${state.currentRelease.id}`);
showToast('Release deleted', 'success');
await openAppPage(state.currentApp.app_id);
});
}
// Export functions for inline onclick handlers
window.openCreateAppModal = openCreateAppModal;
window.openCreateUserModal = openCreateUserModal;
window.openCreateOrgModal = openCreateOrgModal;
window.openAddOrgUserModal = openAddOrgUserModal;
window.loadOrganizationMembers = loadOrganizationMembers;
window.openAppPage = openAppPage;
window.showAppTab = showAppTab;
window.transferCurrentApp = transferCurrentApp;
window.confirmDeleteCurrentApp = confirmDeleteCurrentApp;
window.openReleasePage = openReleasePage;
window.showReleaseTab = showReleaseTab;
window.confirmDeleteCurrentRelease = confirmDeleteCurrentRelease;
window.verifyUserEmail = verifyUserEmail;
window.setUserAdmin = setUserAdmin;
window.confirmDeleteApp = confirmDeleteApp;
window.copyToClipboard = copyToClipboard;
window.closeModal = closeModal;