my own indieAuth provider! indiko.dunkirk.sh/docs
indieauth oauth2-server
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: redo the dashboard page to support data deletion

dunkirk.sh 34b084ff f8988746

verified
+243 -444
+3
.gitignore
··· 5 5 .env 6 6 bun.lockb 7 7 data/ 8 + *.db 9 + *.db-shm 10 + *.db-wal
+118 -112
src/client/index.ts
··· 3 3 const welcome = document.getElementById('welcome') as HTMLElement; 4 4 const subtitle = document.getElementById('subtitle') as HTMLElement; 5 5 const recentApps = document.getElementById('recentApps') as HTMLElement; 6 + const message = document.getElementById('message') as HTMLElement; 7 + 8 + // Profile form elements 6 9 const profileForm = document.getElementById('profileForm') as HTMLFormElement; 7 - const saveBtn = document.getElementById('saveBtn') as HTMLButtonElement; 8 - const message = document.getElementById('message') as HTMLDivElement; 9 - const profileName = document.getElementById('profileName') as HTMLElement; 10 - const profileUsername = document.getElementById('profileUsername') as HTMLElement; 11 - const profileAvatar = document.getElementById('profileAvatar') as HTMLElement; 12 - const avatarInitials = document.getElementById('avatarInitials') as HTMLElement; 13 - const publicProfileLink = document.getElementById('publicProfileLink') as HTMLAnchorElement; 14 - const profileLinks = document.getElementById('profileLinks') as HTMLElement; 15 - 10 + const avatarPreview = document.getElementById('avatarPreview') as HTMLElement; 11 + const usernameInput = document.getElementById('username') as HTMLInputElement; 16 12 const nameInput = document.getElementById('name') as HTMLInputElement; 17 13 const emailInput = document.getElementById('email') as HTMLInputElement; 18 14 const photoInput = document.getElementById('photo') as HTMLInputElement; 19 15 const urlInput = document.getElementById('url') as HTMLInputElement; 16 + const saveBtn = document.getElementById('saveBtn') as HTMLButtonElement; 17 + const deleteAccountBtn = document.getElementById('deleteAccountBtn') as HTMLButtonElement; 18 + const dangerZone = document.getElementById('dangerZone') as HTMLElement; 20 19 21 - let currentUsername = ''; 20 + let isAdmin = false; 22 21 23 22 if (!token) { 24 23 window.location.href = '/login'; ··· 38 37 email: string | null; 39 38 photo: string | null; 40 39 url: string | null; 40 + isAdmin?: boolean; 41 41 } 42 42 43 - 44 - 45 - function showMessage(text: string, type: 'success' | 'error') { 43 + function showMessage(text: string, type: 'error' | 'success' = 'error') { 46 44 message.textContent = text; 47 45 message.className = `message show ${type}`; 48 46 setTimeout(() => message.classList.remove('show'), 5000); 47 + } 48 + 49 + function updateAvatarPreview(photo: string | null, username: string) { 50 + if (photo) { 51 + avatarPreview.innerHTML = `<img src="${photo}" alt="${username}" style="width: 100%; height: 100%; object-fit: cover; border-radius: 50%;" />`; 52 + } else { 53 + const initials = username.substring(0, 2).toUpperCase(); 54 + avatarPreview.textContent = initials; 55 + } 49 56 } 50 57 51 58 // Check auth and display user ··· 117 124 } 118 125 119 126 const profile = await response.json() as Profile; 120 - currentUsername = profile.username; 127 + 128 + // Track admin status to hide delete button for admins 129 + isAdmin = profile.isAdmin || false; 130 + if (!isAdmin) { 131 + dangerZone.style.display = 'block'; 132 + } 121 133 122 134 // Populate form 123 - nameInput.value = profile.name; 135 + usernameInput.value = profile.username; 136 + nameInput.value = profile.name || ''; 124 137 emailInput.value = profile.email || ''; 125 138 photoInput.value = profile.photo || ''; 126 139 urlInput.value = profile.url || ''; 127 140 128 - // Initial preview update 129 - updatePreview(); 130 - } catch (error) { 131 - console.error('Failed to load profile:', error); 132 - showMessage('Failed to load profile', 'error'); 133 - } 134 - } 141 + updateAvatarPreview(profile.photo, profile.username); 135 142 136 - // Handle profile form submission 137 - profileForm.addEventListener('submit', async (e) => { 138 - e.preventDefault(); 139 - 140 - const name = nameInput.value.trim(); 141 - const email = emailInput.value.trim(); 142 - const photo = photoInput.value.trim(); 143 - const url = urlInput.value.trim(); 144 - 145 - if (!name) { 146 - showMessage('Name is required', 'error'); 147 - return; 148 - } 149 - 150 - saveBtn.disabled = true; 151 - saveBtn.textContent = 'saving...'; 152 - 153 - try { 154 - const response = await fetch('/api/profile', { 155 - method: 'PUT', 156 - headers: { 157 - 'Authorization': `Bearer ${token}`, 158 - 'Content-Type': 'application/json', 159 - }, 160 - body: JSON.stringify({ 161 - name, 162 - email: email || null, 163 - photo: photo || null, 164 - url: url || null, 165 - }), 143 + // Update avatar preview when photo URL changes 144 + photoInput.addEventListener('input', () => { 145 + updateAvatarPreview(photoInput.value || null, profile.username); 166 146 }); 167 - 168 - if (!response.ok) { 169 - throw new Error('Failed to update profile'); 170 - } 171 - 172 - showMessage('Profile updated successfully!', 'success'); 173 147 } catch (error) { 174 - console.error('Failed to update profile:', error); 175 - showMessage('Failed to update profile', 'error'); 176 - } finally { 177 - saveBtn.disabled = false; 178 - saveBtn.textContent = 'save profile'; 179 - } 180 - }); 181 - 182 - function updatePreview() { 183 - const name = nameInput.value.trim() || 'Your Name'; 184 - const photo = photoInput.value.trim(); 185 - const email = emailInput.value.trim(); 186 - const url = urlInput.value.trim(); 187 - 188 - // Update name 189 - profileName.textContent = name; 190 - profileUsername.textContent = `@${currentUsername}`; 191 - avatarInitials.textContent = currentUsername.slice(0, 2).toUpperCase(); 192 - publicProfileLink.href = `/u/${currentUsername}`; 193 - 194 - // Update photo 195 - const existingImg = profileAvatar.querySelector('img'); 196 - if (photo) { 197 - if (existingImg) { 198 - existingImg.src = photo; 199 - existingImg.alt = name; 200 - } else { 201 - const img = document.createElement('img'); 202 - img.src = photo; 203 - img.alt = name; 204 - profileAvatar.insertBefore(img, avatarInitials); 205 - } 206 - avatarInitials.style.display = 'none'; 207 - } else { 208 - if (existingImg) { 209 - existingImg.remove(); 210 - } 211 - avatarInitials.style.display = ''; 148 + console.error('Failed to load profile:', error); 149 + showMessage('Failed to load profile'); 212 150 } 213 - 214 - // Update links 215 - let links = `<a href="/u/${currentUsername}" id="publicProfileLink">view public profile</a>`; 216 - if (email) { 217 - links += ` • <a href="mailto:${email}">email</a>`; 218 - } 219 - if (url) { 220 - links += ` • <a href="${url}" target="_blank" rel="noopener noreferrer">website</a>`; 221 - } 222 - profileLinks.innerHTML = links; 223 151 } 224 - 225 - // Live update listeners 226 - nameInput.addEventListener('input', updatePreview); 227 - emailInput.addEventListener('input', updatePreview); 228 - photoInput.addEventListener('input', updatePreview); 229 - urlInput.addEventListener('input', updatePreview); 230 152 231 153 async function loadRecentApps() { 232 154 try { ··· 270 192 recentApps.innerHTML = '<div class="empty">Failed to load apps</div>'; 271 193 } 272 194 } 195 + 196 + // Profile form submission 197 + profileForm.addEventListener('submit', async (e) => { 198 + e.preventDefault(); 199 + 200 + saveBtn.disabled = true; 201 + saveBtn.textContent = 'saving...'; 202 + 203 + try { 204 + const response = await fetch('/api/profile', { 205 + method: 'PUT', 206 + headers: { 207 + 'Authorization': `Bearer ${token}`, 208 + 'Content-Type': 'application/json', 209 + }, 210 + body: JSON.stringify({ 211 + name: nameInput.value, 212 + email: emailInput.value || null, 213 + photo: photoInput.value || null, 214 + url: urlInput.value || null, 215 + }), 216 + }); 217 + 218 + if (!response.ok) { 219 + const error = await response.json(); 220 + throw new Error(error.error || 'Failed to update profile'); 221 + } 222 + 223 + showMessage('Profile updated successfully!', 'success'); 224 + } catch (error) { 225 + showMessage((error as Error).message || 'Failed to update profile'); 226 + } finally { 227 + saveBtn.disabled = false; 228 + saveBtn.textContent = 'save changes'; 229 + } 230 + }); 231 + 232 + // Delete account handler 233 + deleteAccountBtn.addEventListener('click', async () => { 234 + const confirmMessage = 'Are you absolutely sure you want to delete your account?\n\n' + 235 + 'This will permanently delete:\n' + 236 + '• Your profile and credentials\n' + 237 + '• All authorized apps\n' + 238 + '• All active sessions\n\n' + 239 + 'This action CANNOT be undone.\n\n' + 240 + 'Type "DELETE" to confirm:'; 241 + 242 + const confirmation = prompt(confirmMessage); 243 + 244 + if (confirmation !== 'DELETE') { 245 + if (confirmation !== null) { 246 + showMessage('Account deletion cancelled. You must type "DELETE" exactly.'); 247 + } 248 + return; 249 + } 250 + 251 + deleteAccountBtn.disabled = true; 252 + deleteAccountBtn.textContent = 'deleting...'; 253 + 254 + try { 255 + const response = await fetch('/api/profile', { 256 + method: 'DELETE', 257 + headers: { 258 + 'Authorization': `Bearer ${token}`, 259 + }, 260 + }); 261 + 262 + if (!response.ok) { 263 + const error = await response.json(); 264 + throw new Error(error.error || 'Failed to delete account'); 265 + } 266 + 267 + // Clear session and redirect 268 + localStorage.removeItem('indiko_session'); 269 + showMessage('Account deleted successfully. Redirecting...', 'success'); 270 + setTimeout(() => { 271 + window.location.href = '/login'; 272 + }, 2000); 273 + } catch (error) { 274 + showMessage((error as Error).message || 'Failed to delete account'); 275 + deleteAccountBtn.disabled = false; 276 + deleteAccountBtn.textContent = 'delete my account'; 277 + } 278 + }); 273 279 274 280 checkAuth();
-109
src/client/profile.ts
··· 1 - const token = localStorage.getItem('indiko_session'); 2 - const profileForm = document.getElementById('profileForm') as HTMLFormElement; 3 - const avatarPreview = document.getElementById('avatarPreview') as HTMLElement; 4 - const message = document.getElementById('message') as HTMLElement; 5 - 6 - const usernameInput = document.getElementById('username') as HTMLInputElement; 7 - const nameInput = document.getElementById('name') as HTMLInputElement; 8 - const emailInput = document.getElementById('email') as HTMLInputElement; 9 - const photoInput = document.getElementById('photo') as HTMLInputElement; 10 - const urlInput = document.getElementById('url') as HTMLInputElement; 11 - 12 - function showMessage(text: string, type: 'error' | 'success' = 'error') { 13 - message.textContent = text; 14 - message.className = `message show ${type}`; 15 - setTimeout(() => message.classList.remove('show'), 5000); 16 - } 17 - 18 - function updateAvatarPreview(photo: string | null, username: string) { 19 - if (photo) { 20 - avatarPreview.innerHTML = `<img src="${photo}" alt="${username}" />`; 21 - } else { 22 - const initials = username.substring(0, 2).toUpperCase(); 23 - avatarPreview.textContent = initials; 24 - } 25 - } 26 - 27 - async function loadProfile() { 28 - if (!token) { 29 - window.location.href = '/login'; 30 - return; 31 - } 32 - 33 - try { 34 - const response = await fetch('/api/profile', { 35 - headers: { 36 - 'Authorization': `Bearer ${token}`, 37 - }, 38 - }); 39 - 40 - if (response.status === 401 || response.status === 403) { 41 - localStorage.removeItem('indiko_session'); 42 - window.location.href = '/login'; 43 - return; 44 - } 45 - 46 - if (!response.ok) { 47 - throw new Error('Failed to load profile'); 48 - } 49 - 50 - const profile = await response.json(); 51 - 52 - usernameInput.value = profile.username; 53 - nameInput.value = profile.name || ''; 54 - emailInput.value = profile.email || ''; 55 - photoInput.value = profile.photo || ''; 56 - urlInput.value = profile.url || ''; 57 - 58 - updateAvatarPreview(profile.photo, profile.username); 59 - 60 - // Update avatar preview when photo URL changes 61 - photoInput.addEventListener('input', () => { 62 - updateAvatarPreview(photoInput.value || null, profile.username); 63 - }); 64 - } catch (error) { 65 - console.error('Failed to load profile:', error); 66 - showMessage('Failed to load profile'); 67 - } 68 - } 69 - 70 - profileForm.addEventListener('submit', async (e) => { 71 - e.preventDefault(); 72 - 73 - const saveBtn = document.getElementById('saveBtn') as HTMLButtonElement; 74 - saveBtn.disabled = true; 75 - saveBtn.textContent = 'saving...'; 76 - 77 - try { 78 - const response = await fetch('/api/profile', { 79 - method: 'PUT', 80 - headers: { 81 - 'Authorization': `Bearer ${token}`, 82 - 'Content-Type': 'application/json', 83 - }, 84 - body: JSON.stringify({ 85 - name: nameInput.value, 86 - email: emailInput.value || null, 87 - photo: photoInput.value || null, 88 - url: urlInput.value || null, 89 - }), 90 - }); 91 - 92 - if (!response.ok) { 93 - const error = await response.json(); 94 - throw new Error(error.error || 'Failed to update profile'); 95 - } 96 - 97 - showMessage('Profile updated successfully!', 'success'); 98 - const redirectTimer = setTimeout(() => { 99 - window.location.href = '/'; 100 - }, 1500); 101 - (redirectTimer as unknown as number); 102 - } catch (error) { 103 - showMessage((error as Error).message || 'Failed to update profile'); 104 - saveBtn.disabled = false; 105 - saveBtn.textContent = 'save changes'; 106 - } 107 - }); 108 - 109 - loadProfile();
+71 -73
src/html/index.html
··· 7 7 <title>dashboard • indiko</title> 8 8 <meta name="description" content="Your Indiko dashboard - manage your profile, apps, and passkeys" /> 9 9 <link rel="icon" href="../../public/favicon.svg" type="image/svg+xml" /> 10 - 10 + 11 11 <!-- Open Graph / Facebook --> 12 12 <meta property="og:type" content="website" /> 13 13 <meta property="og:title" content="Dashboard • Indiko" /> 14 14 <meta property="og:description" content="Your Indiko dashboard - manage your profile, apps, and passkeys" /> 15 - 15 + 16 16 <!-- Twitter --> 17 17 <meta name="twitter:card" content="summary" /> 18 18 <meta name="twitter:title" content="Dashboard • Indiko" /> ··· 23 23 <link rel="stylesheet" href="../styles.css"> 24 24 <style> 25 25 /* Dashboard-specific styles */ 26 - .profile-preview { 26 + .profile-section { 27 + background: rgba(188, 141, 160, 0.05); 28 + border: 1px solid var(--old-rose); 29 + padding: 2rem; 30 + margin-bottom: 2rem; 31 + } 32 + 33 + .section-title { 34 + font-size: 1.25rem; 35 + font-weight: 600; 36 + color: var(--lavender); 37 + margin-bottom: 1.5rem; 38 + } 39 + 40 + .avatar-upload { 27 41 display: flex; 28 42 align-items: center; 29 43 gap: 1.5rem; 30 - background: rgba(188, 141, 160, 0.05); 31 - border: 1px solid var(--old-rose); 32 - padding: 1.5rem; 33 44 margin-bottom: 2rem; 34 45 } 35 46 ··· 37 48 width: 80px; 38 49 height: 80px; 39 50 border: 3px solid var(--berry-crush); 51 + font-size: 2rem; 52 + } 53 + 54 + .avatar-controls { 55 + display: flex; 56 + flex-direction: column; 57 + gap: 0.5rem; 40 58 } 41 59 42 60 .profile-info { ··· 91 109 input[type="text"], 92 110 input[type="email"], 93 111 input[type="url"] { 94 - padding: 0.75rem; 95 - } 96 - 97 - .save-btn { 98 - width: 100%; 99 - padding: 0.75rem 1rem; 100 - background: var(--berry-crush); 101 - border: 2px solid var(--rosewood); 102 - color: var(--lavender); 103 - font-family: "Space Grotesk", sans-serif; 104 - font-size: 1rem; 105 - font-weight: 600; 106 - text-transform: uppercase; 107 - letter-spacing: 0.05rem; 108 - cursor: pointer; 109 - transition: all 0.2s; 110 - box-shadow: none; 111 - } 112 - 113 - .save-btn::before { 114 - display: none; 112 + margin-bottom: 1.5rem; 115 113 } 116 114 117 - .save-btn:hover:not(:disabled) { 118 - background: var(--rosewood); 119 - transform: none; 115 + .button-group { 116 + display: flex; 117 + gap: 1rem; 118 + margin-top: 1.5rem; 120 119 } 121 120 122 - .save-btn:disabled { 123 - opacity: 0.5; 124 - cursor: not-allowed; 121 + .button-group button { 122 + flex: 1; 125 123 } 126 124 127 125 .message { ··· 177 175 </header> 178 176 179 177 <main> 180 - <div class="profile-preview"> 181 - <div class="profile-avatar" id="profileAvatar"> 182 - <span id="avatarInitials"></span> 183 - </div> 184 - <div class="profile-info"> 185 - <div class="profile-name" id="profileName">Loading...</div> 186 - <div class="profile-username" id="profileUsername">@username</div> 187 - <div class="profile-links" id="profileLinks"> 188 - <a href="/u/" id="publicProfileLink">view public profile</a> 189 - </div> 178 + <div id="message" class="message"></div> 179 + 180 + <div class="profile-section"> 181 + <h2 class="section-title">recent apps</h2> 182 + <div id="recentApps" class="apps-preview"> 183 + <div class="loading">loading...</div> 190 184 </div> 191 185 </div> 192 186 193 - <div class="dashboard-grid"> 194 - <div class="card"> 195 - <h2 class="card-title">profile</h2> 196 - <form id="profileForm"> 197 - <div class="form-group"> 198 - <label for="name">name</label> 199 - <input type="text" id="name" name="name" required /> 200 - </div> 187 + <div class="profile-section"> 188 + <h2 class="section-title">profile settings</h2> 201 189 202 - <div class="form-group"> 203 - <label for="email">email (optional)</label> 204 - <input type="email" id="email" name="email" /> 190 + <form id="profileForm"> 191 + <div class="avatar-upload"> 192 + <div class="profile-avatar" id="avatarPreview"> 193 + <!-- Avatar will be rendered here --> 205 194 </div> 206 - 207 - <div class="form-group"> 208 - <label for="photo">photo url (optional)</label> 209 - <input type="url" id="photo" name="photo" placeholder="https://example.com/photo.jpg" /> 195 + <div class="avatar-controls"> 196 + <label for="photo">avatar url</label> 197 + <input type="url" id="photo" name="photo" placeholder="https://example.com/avatar.jpg" /> 198 + <small style="color: var(--old-rose); font-size: 0.75rem;">Enter a URL to an image</small> 210 199 </div> 200 + </div> 211 201 212 - <div class="form-group"> 213 - <label for="url">website url (optional)</label> 214 - <input type="url" id="url" name="url" placeholder="https://example.com" /> 215 - </div> 202 + <label for="username">username</label> 203 + <input type="text" id="username" name="username" required disabled /> 216 204 217 - <button type="submit" class="save-btn" id="saveBtn">save profile</button> 218 - <div id="message" class="message"></div> 219 - </form> 220 - </div> 205 + <label for="name">display name</label> 206 + <input type="text" id="name" name="name" required /> 221 207 222 - <div class="card"> 223 - <h2 class="card-title">recent apps</h2> 224 - <div id="recentApps" class="apps-preview"> 225 - <div class="loading">loading...</div> 226 - </div> 227 - </div> 208 + <label for="email">email</label> 209 + <input type="email" id="email" name="email" /> 210 + 211 + <label for="url">website</label> 212 + <input type="url" id="url" name="url" placeholder="https://example.com" /> 213 + 214 + <button type="submit" id="saveBtn">save changes</button> 215 + </form> 216 + </div> 217 + 218 + <div class="profile-section" id="dangerZone" style="border-color: var(--rosewood); display: none;"> 219 + <h2 class="section-title" style="color: var(--rosewood);">danger zone</h2> 220 + <p style="color: var(--old-rose); margin-bottom: 1.5rem; line-height: 1.6;"> 221 + Permanently delete your account and all associated data. This action cannot be undone. 222 + </p> 223 + <button type="button" id="deleteAccountBtn" style="width: 100%;"> 224 + delete my account 225 + </button> 228 226 </div> 229 227 </main> 230 228 ··· 235 233 <script type="module" src="../client/index.ts"></script> 236 234 </body> 237 235 238 - </html> 236 + </html>
-139
src/html/profile.html
··· 1 - <!doctype html> 2 - <html lang="en"> 3 - 4 - <head> 5 - <meta charset="UTF-8" /> 6 - <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 7 - <title>profile • indiko</title> 8 - <meta name="description" content="Edit your Indiko profile information" /> 9 - <link rel="icon" href="../../public/favicon.svg" type="image/svg+xml" /> 10 - 11 - <!-- Open Graph / Facebook --> 12 - <meta property="og:type" content="website" /> 13 - <meta property="og:title" content="Edit Profile • Indiko" /> 14 - <meta property="og:description" content="Edit your Indiko profile information" /> 15 - 16 - <!-- Twitter --> 17 - <meta name="twitter:card" content="summary" /> 18 - <meta name="twitter:title" content="Edit Profile • Indiko" /> 19 - <meta name="twitter:description" content="Edit your Indiko profile information" /> 20 - <link rel="preconnect" href="https://fonts.googleapis.com"> 21 - <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 22 - <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300..700&display=swap" rel="stylesheet"> 23 - <link rel="stylesheet" href="../styles.css"> 24 - <style> 25 - /* Profile-specific styles */ 26 - header { 27 - align-self: flex-start; 28 - margin-left: auto; 29 - margin-right: auto; 30 - } 31 - 32 - main { 33 - display: flex; 34 - justify-content: center; 35 - } 36 - 37 - .profile-container { 38 - width: 100%; 39 - max-width: 56.25rem; 40 - } 41 - 42 - .profile-section { 43 - background: rgba(188, 141, 160, 0.05); 44 - border: 1px solid var(--old-rose); 45 - padding: 2rem; 46 - margin-bottom: 1.5rem; 47 - } 48 - 49 - .section-title { 50 - font-size: 1.25rem; 51 - font-weight: 600; 52 - color: var(--lavender); 53 - margin-bottom: 1.5rem; 54 - } 55 - 56 - .avatar-upload { 57 - display: flex; 58 - align-items: center; 59 - gap: 1.5rem; 60 - margin-bottom: 2rem; 61 - } 62 - 63 - .avatar-preview { 64 - width: 6rem; 65 - height: 6rem; 66 - font-size: 2rem; 67 - } 68 - 69 - .avatar-controls { 70 - display: flex; 71 - flex-direction: column; 72 - gap: 0.5rem; 73 - } 74 - 75 - input[type="text"], 76 - input[type="email"], 77 - input[type="url"] { 78 - margin-bottom: 1.5rem; 79 - } 80 - 81 - footer { 82 - margin-top: 2rem; 83 - } 84 - </style> 85 - </head> 86 - 87 - <body> 88 - <header> 89 - <img src="../../public/logo.svg" alt="indiko" style="height: 2rem; margin-bottom: 0.5rem;" /> 90 - </header> 91 - 92 - <main> 93 - <div class="profile-container"> 94 - <div id="message" class="message"></div> 95 - 96 - <div class="profile-section"> 97 - <h2 class="section-title">profile settings</h2> 98 - 99 - <form id="profileForm"> 100 - <div class="avatar-upload"> 101 - <div class="avatar-preview" id="avatarPreview"> 102 - <!-- Avatar will be rendered here --> 103 - </div> 104 - <div class="avatar-controls"> 105 - <label for="photo">avatar url</label> 106 - <input type="url" id="photo" name="photo" placeholder="https://example.com/avatar.jpg" /> 107 - <small style="color: var(--old-rose); font-size: 0.75rem;">Enter a URL to an image</small> 108 - </div> 109 - </div> 110 - 111 - <label for="username">username</label> 112 - <input type="text" id="username" name="username" required disabled /> 113 - 114 - <label for="name">display name</label> 115 - <input type="text" id="name" name="name" required /> 116 - 117 - <label for="email">email</label> 118 - <input type="email" id="email" name="email" /> 119 - 120 - <label for="url">website</label> 121 - <input type="url" id="url" name="url" placeholder="https://example.com" /> 122 - 123 - <div class="button-group"> 124 - <button type="submit" id="saveBtn">save changes</button> 125 - <button type="button" class="button-secondary" onclick="window.location.href='/'">cancel</button> 126 - </div> 127 - </form> 128 - </div> 129 - </div> 130 - </main> 131 - 132 - <footer> 133 - <a href="/">← back to dashboard</a> 134 - </footer> 135 - 136 - <script type="module" src="../client/profile.ts"></script> 137 - </body> 138 - 139 - </html>
+2 -2
src/index.ts
··· 5 5 import adminInvitesHTML from "./html/admin-invites.html"; 6 6 import adminClientsHTML from "./html/admin-clients.html"; 7 7 import loginHTML from "./html/login.html"; 8 - import profileHTML from "./html/profile.html"; 9 8 import docsHTML from "./html/docs.html"; 10 9 import appsHTML from "./html/apps.html"; 11 10 import { ··· 28 27 disableUser, 29 28 enableUser, 30 29 deleteUser, 30 + deleteSelfAccount, 31 31 } from "./routes/api"; 32 32 import { 33 33 authorizeGet, ··· 100 100 "/admin/apps": () => Response.redirect("/admin/clients", 302), 101 101 "/admin/clients": adminClientsHTML, 102 102 "/login": loginHTML, 103 - "/profile": profileHTML, 104 103 "/docs": docsHTML, 105 104 "/apps": appsHTML, 106 105 // Well-known endpoints ··· 145 144 "/api/profile": (req: Request) => { 146 145 if (req.method === "GET") return getProfile(req); 147 146 if (req.method === "PUT") return updateProfile(req); 147 + if (req.method === "DELETE") return deleteSelfAccount(req); 148 148 return new Response("Method not allowed", { status: 405 }); 149 149 }, 150 150 "/api/apps": (req: Request) => {
+49 -9
src/routes/api.ts
··· 206 206 }>; 207 207 208 208 return Response.json({ 209 - apps: apps.map((app) => ({ 210 - clientId: app.client_id, 211 - name: app.name || new URL(app.client_id).hostname, 212 - scopes: JSON.parse(app.scopes) as string[], 213 - grantedAt: app.granted_at, 214 - lastUsed: app.last_used, 215 - })), 209 + apps: apps.map((app) => { 210 + let displayName = app.name || app.client_id; 211 + // Try to extract hostname if client_id is a URL 212 + if (!app.name) { 213 + try { 214 + displayName = new URL(app.client_id).hostname; 215 + } catch { 216 + // Not a URL, use client_id as-is 217 + displayName = app.client_id; 218 + } 219 + } 220 + return { 221 + clientId: app.client_id, 222 + name: displayName, 223 + scopes: JSON.parse(app.scopes) as string[], 224 + grantedAt: app.granted_at, 225 + lastUsed: app.last_used, 226 + }; 227 + }), 216 228 }); 217 229 } 218 230 ··· 468 480 } 469 481 470 482 const targetUser = db 471 - .query("SELECT id FROM users WHERE id = ?") 472 - .get(targetUserId) as { id: number } | undefined; 483 + .query("SELECT id, is_admin FROM users WHERE id = ?") 484 + .get(targetUserId) as { id: number; is_admin: number } | undefined; 473 485 474 486 if (!targetUser) { 475 487 return Response.json({ error: "User not found" }, { status: 404 }); 476 488 } 477 489 490 + // Prevent admins from deleting other admin accounts 491 + if (targetUser.is_admin === 1) { 492 + return Response.json({ error: "Cannot delete admin accounts" }, { status: 403 }); 493 + } 494 + 478 495 db.query("DELETE FROM sessions WHERE user_id = ?").run(targetUserId); 479 496 db.query("DELETE FROM credentials WHERE user_id = ?").run(targetUserId); 480 497 db.query("DELETE FROM permissions WHERE user_id = ?").run(targetUserId); ··· 483 500 484 501 return Response.json({ success: true }); 485 502 } 503 + 504 + export function deleteSelfAccount(req: Request): Response { 505 + const user = getSessionUser(req); 506 + if (user instanceof Response) { 507 + return user; 508 + } 509 + 510 + // Prevent admins from deleting their own accounts 511 + if (user.is_admin) { 512 + return Response.json({ 513 + error: "Admin accounts cannot be self-deleted. Contact another admin for account deletion." 514 + }, { status: 403 }); 515 + } 516 + 517 + // Delete all user data 518 + db.query("DELETE FROM sessions WHERE user_id = ?").run(user.userId); 519 + db.query("DELETE FROM credentials WHERE user_id = ?").run(user.userId); 520 + db.query("DELETE FROM permissions WHERE user_id = ?").run(user.userId); 521 + db.query("DELETE FROM authcodes WHERE user_id = ?").run(user.userId); 522 + db.query("DELETE FROM users WHERE id = ?").run(user.userId); 523 + 524 + return Response.json({ success: true }); 525 + }