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: seperate admin and user dashboards

+1268 -176
+123
src/client/admin.ts
··· 1 + const token = localStorage.getItem('indiko_session'); 2 + const footer = document.getElementById('footer') as HTMLElement; 3 + const usersList = document.getElementById('usersList') as HTMLElement; 4 + 5 + // Check auth and display user 6 + async function checkAuth() { 7 + if (!token) { 8 + window.location.href = '/login'; 9 + return; 10 + } 11 + 12 + try { 13 + const response = await fetch('/api/hello', { 14 + headers: { 15 + 'Authorization': `Bearer ${token}`, 16 + }, 17 + }); 18 + 19 + if (response.status === 401) { 20 + localStorage.removeItem('indiko_session'); 21 + window.location.href = '/login'; 22 + return; 23 + } 24 + 25 + const data = await response.json(); 26 + 27 + footer.innerHTML = `admin • signed in as <strong><a href="/u/${data.username}">${data.username}</a></strong> • <a href="/login" id="logoutLink">sign out</a> 28 + <div class="back-link"><a href="/">← back to dashboard</a></div>`; 29 + 30 + // Handle logout 31 + document.getElementById('logoutLink')?.addEventListener('click', async (e) => { 32 + e.preventDefault(); 33 + try { 34 + await fetch('/auth/logout', { 35 + method: 'POST', 36 + headers: { 37 + 'Authorization': `Bearer ${token}`, 38 + }, 39 + }); 40 + } catch { 41 + // Ignore logout errors 42 + } 43 + localStorage.removeItem('indiko_session'); 44 + window.location.href = '/login'; 45 + }); 46 + 47 + // Check if admin 48 + if (!data.isAdmin) { 49 + usersList.innerHTML = '<div class="error">Admin access required</div>'; 50 + return; 51 + } 52 + 53 + // Load users if admin 54 + loadUsers(); 55 + } catch (error) { 56 + console.error('Auth check failed:', error); 57 + footer.textContent = 'error loading user info'; 58 + usersList.innerHTML = '<div class="error">Failed to load users</div>'; 59 + } 60 + } 61 + 62 + async function loadUsers() { 63 + try { 64 + const response = await fetch('/api/users', { 65 + headers: { 66 + 'Authorization': `Bearer ${token}`, 67 + }, 68 + }); 69 + 70 + if (!response.ok) { 71 + throw new Error('Failed to load users'); 72 + } 73 + 74 + const data = await response.json(); 75 + 76 + if (data.users.length === 0) { 77 + usersList.innerHTML = '<div class="loading">No users found</div>'; 78 + return; 79 + } 80 + 81 + usersList.innerHTML = data.users.map((user: { 82 + id: number; 83 + username: string; 84 + name: string; 85 + email: string | null; 86 + photo: string | null; 87 + status: string; 88 + role: string; 89 + isAdmin: boolean; 90 + createdAt: number; 91 + credentialCount: number; 92 + }) => { 93 + const createdDate = new Date(user.createdAt * 1000).toLocaleDateString(); 94 + const initials = user.username.substring(0, 2).toUpperCase(); 95 + const avatarContent = user.photo 96 + ? `<img src="${user.photo}" alt="${user.username}" />` 97 + : initials; 98 + 99 + return ` 100 + <div class="user-card"> 101 + <div class="user-avatar">${avatarContent}</div> 102 + <div class="user-info"> 103 + <div class="user-name">${user.username}</div> 104 + <div class="user-meta"> 105 + <span class="user-meta-item">${user.credentialCount} passkey${user.credentialCount !== 1 ? 's' : ''}</span> 106 + <span class="user-meta-item">joined ${createdDate}</span> 107 + ${user.email ? `<span class="user-meta-item">${user.email}</span>` : ''} 108 + </div> 109 + </div> 110 + <div class="user-badges"> 111 + <span class="user-badge badge-status ${user.status}">${user.status}</span> 112 + <span class="user-badge badge-role">${user.role}</span> 113 + </div> 114 + </div> 115 + `; 116 + }).join(''); 117 + } catch (error) { 118 + console.error('Failed to load users:', error); 119 + usersList.innerHTML = '<div class="error">Failed to load users</div>'; 120 + } 121 + } 122 + 123 + checkAuth();
+115
src/client/apps.ts
··· 1 + const token = localStorage.getItem('indiko_session'); 2 + const appsList = document.getElementById('appsList') as HTMLElement; 3 + 4 + if (!token) { 5 + window.location.href = '/login'; 6 + } 7 + 8 + interface App { 9 + clientId: string; 10 + name: string; 11 + scopes: string[]; 12 + grantedAt: number; 13 + lastUsed: number; 14 + } 15 + 16 + async function loadApps() { 17 + try { 18 + const response = await fetch('/api/apps', { 19 + headers: { 20 + 'Authorization': `Bearer ${token}`, 21 + }, 22 + }); 23 + 24 + if (response.status === 401) { 25 + localStorage.removeItem('indiko_session'); 26 + window.location.href = '/login'; 27 + return; 28 + } 29 + 30 + if (!response.ok) { 31 + throw new Error('Failed to load apps'); 32 + } 33 + 34 + const data = await response.json(); 35 + displayApps(data.apps); 36 + } catch (error) { 37 + console.error('Failed to load apps:', error); 38 + appsList.innerHTML = '<div class="error">Failed to load authorized apps</div>'; 39 + } 40 + } 41 + 42 + function displayApps(apps: App[]) { 43 + if (apps.length === 0) { 44 + appsList.innerHTML = '<div class="empty">No authorized apps yet. Apps will appear here after you grant them access.</div>'; 45 + return; 46 + } 47 + 48 + appsList.innerHTML = apps.map((app) => { 49 + const lastUsedDate = new Date(app.lastUsed * 1000).toLocaleDateString(); 50 + const grantedDate = new Date(app.grantedAt * 1000).toLocaleDateString(); 51 + 52 + return ` 53 + <div class="app-card" data-client-id="${app.clientId}"> 54 + <div class="app-header"> 55 + <div> 56 + <div class="app-name">${app.name}</div> 57 + <div class="app-meta">Granted ${grantedDate} • Last used ${lastUsedDate}</div> 58 + </div> 59 + <button class="revoke-btn" onclick="revokeApp('${app.clientId}')">revoke</button> 60 + </div> 61 + <div class="scopes"> 62 + <div class="scope-title">permissions</div> 63 + <div class="scope-list"> 64 + ${app.scopes.map(scope => `<span class="scope-badge">${scope}</span>`).join('')} 65 + </div> 66 + </div> 67 + </div> 68 + `; 69 + }).join(''); 70 + } 71 + 72 + (window as any).revokeApp = async function(clientId: string) { 73 + if (!confirm('Are you sure you want to revoke access for this app? You will need to authorize it again next time.')) { 74 + return; 75 + } 76 + 77 + const card = document.querySelector(`[data-client-id="${clientId}"]`); 78 + const btn = card?.querySelector('.revoke-btn') as HTMLButtonElement; 79 + 80 + if (btn) { 81 + btn.disabled = true; 82 + btn.textContent = 'revoking...'; 83 + } 84 + 85 + try { 86 + const response = await fetch(`/api/apps/${encodeURIComponent(clientId)}`, { 87 + method: 'DELETE', 88 + headers: { 89 + 'Authorization': `Bearer ${token}`, 90 + }, 91 + }); 92 + 93 + if (!response.ok) { 94 + throw new Error('Failed to revoke app'); 95 + } 96 + 97 + // Remove from UI 98 + card?.remove(); 99 + 100 + // Check if list is now empty 101 + const remaining = document.querySelectorAll('.app-card'); 102 + if (remaining.length === 0) { 103 + appsList.innerHTML = '<div class="empty">No authorized apps yet. Apps will appear here after you grant them access.</div>'; 104 + } 105 + } catch (error) { 106 + console.error('Failed to revoke app:', error); 107 + alert('Failed to revoke app access. Please try again.'); 108 + if (btn) { 109 + btn.disabled = false; 110 + btn.textContent = 'revoke'; 111 + } 112 + } 113 + }; 114 + 115 + loadApps();
+201 -47
src/client/index.ts
··· 1 1 const token = localStorage.getItem('indiko_session'); 2 2 const footer = document.getElementById('footer') as HTMLElement; 3 - const usersList = document.getElementById('usersList') as HTMLElement; 3 + const welcome = document.getElementById('welcome') as HTMLElement; 4 + const subtitle = document.getElementById('subtitle') as HTMLElement; 5 + const recentApps = document.getElementById('recentApps') as HTMLElement; 6 + 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 + 16 + const nameInput = document.getElementById('name') as HTMLInputElement; 17 + const emailInput = document.getElementById('email') as HTMLInputElement; 18 + const photoInput = document.getElementById('photo') as HTMLInputElement; 19 + const urlInput = document.getElementById('url') as HTMLInputElement; 20 + 21 + let currentUsername = ''; 22 + 23 + if (!token) { 24 + window.location.href = '/login'; 25 + } 26 + 27 + interface App { 28 + clientId: string; 29 + name: string; 30 + scopes: string[]; 31 + grantedAt: number; 32 + lastUsed: number; 33 + } 34 + 35 + interface Profile { 36 + username: string; 37 + name: string; 38 + email: string | null; 39 + photo: string | null; 40 + url: string | null; 41 + } 42 + 43 + 44 + 45 + function showMessage(text: string, type: 'success' | 'error') { 46 + message.textContent = text; 47 + message.className = `message show ${type}`; 48 + setTimeout(() => message.classList.remove('show'), 5000); 49 + } 4 50 5 51 // Check auth and display user 6 52 async function checkAuth() { ··· 23 69 } 24 70 25 71 const data = await response.json(); 72 + 73 + // Update welcome message 74 + welcome.textContent = `welcome, ${data.username}`; 75 + subtitle.textContent = 'your identity dashboard'; 26 76 27 - footer.innerHTML = `signed in as <strong><a href="/u/${data.username}">${data.username}</a></strong> • <a href="/profile">edit profile</a> • <a href="/oauth-test">test oauth</a> • <a href="/login" id="logoutLink">sign out</a>`; 77 + // Build footer with conditional admin link 78 + const adminLink = data.isAdmin ? ' • <a href="/admin">admin</a>' : ''; 79 + footer.innerHTML = `signed in as <strong><a href="/u/${data.username}">${data.username}</a></strong> • <a href="/apps">apps</a> • <a href="/oauth-test">test oauth</a>${adminLink} • <a href="/login" id="logoutLink">sign out</a>`; 28 80 29 81 // Handle logout 30 82 document.getElementById('logoutLink')?.addEventListener('click', async (e) => { ··· 43 95 window.location.href = '/login'; 44 96 }); 45 97 46 - // Load users if admin 47 - if (data.isAdmin) { 48 - loadUsers(); 49 - } else { 50 - usersList.innerHTML = '<div class="error">Admin access required</div>'; 51 - } 98 + // Load profile and apps 99 + loadProfile(); 100 + loadRecentApps(); 52 101 } catch (error) { 53 102 console.error('Auth check failed:', error); 54 103 footer.textContent = 'error loading user info'; 55 - usersList.innerHTML = '<div class="error">Failed to load users</div>'; 56 104 } 57 105 } 58 106 59 - async function loadUsers() { 107 + async function loadProfile() { 60 108 try { 61 - const response = await fetch('/api/users', { 109 + const response = await fetch('/api/profile', { 62 110 headers: { 63 111 'Authorization': `Bearer ${token}`, 64 112 }, 65 113 }); 66 114 67 115 if (!response.ok) { 68 - throw new Error('Failed to load users'); 116 + throw new Error('Failed to load profile'); 117 + } 118 + 119 + const profile = await response.json() as Profile; 120 + currentUsername = profile.username; 121 + 122 + // Populate form 123 + nameInput.value = profile.name; 124 + emailInput.value = profile.email || ''; 125 + photoInput.value = profile.photo || ''; 126 + urlInput.value = profile.url || ''; 127 + 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 + } 135 + 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 + }), 166 + }); 167 + 168 + if (!response.ok) { 169 + throw new Error('Failed to update profile'); 170 + } 171 + 172 + showMessage('Profile updated successfully!', 'success'); 173 + } 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 = ''; 212 + } 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 + } 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 + 231 + async function loadRecentApps() { 232 + try { 233 + const response = await fetch('/api/apps', { 234 + headers: { 235 + 'Authorization': `Bearer ${token}`, 236 + }, 237 + }); 238 + 239 + if (!response.ok) { 240 + throw new Error('Failed to load apps'); 69 241 } 70 242 71 243 const data = await response.json(); 244 + const apps = data.apps as App[]; 72 245 73 - if (data.users.length === 0) { 74 - usersList.innerHTML = '<div class="loading">No users found</div>'; 246 + if (apps.length === 0) { 247 + recentApps.innerHTML = '<div class="empty">No authorized apps yet</div>'; 75 248 return; 76 249 } 77 250 78 - usersList.innerHTML = data.users.map((user: { 79 - id: number; 80 - username: string; 81 - name: string; 82 - email: string | null; 83 - photo: string | null; 84 - status: string; 85 - role: string; 86 - isAdmin: boolean; 87 - createdAt: number; 88 - credentialCount: number; 89 - }) => { 90 - const createdDate = new Date(user.createdAt * 1000).toLocaleDateString(); 91 - const initials = user.username.substring(0, 2).toUpperCase(); 92 - const avatarContent = user.photo 93 - ? `<img src="${user.photo}" alt="${user.username}" />` 94 - : initials; 251 + // Show top 7 most recent 252 + const recent = apps.slice(0, 7); 253 + 254 + recentApps.innerHTML = recent.map((app) => { 255 + const lastUsedDate = new Date(app.lastUsed * 1000).toLocaleDateString(); 95 256 96 257 return ` 97 - <div class="user-card"> 98 - <div class="user-avatar">${avatarContent}</div> 99 - <div class="user-info"> 100 - <div class="user-name">${user.username}</div> 101 - <div class="user-meta"> 102 - <span class="user-meta-item">${user.credentialCount} passkey${user.credentialCount !== 1 ? 's' : ''}</span> 103 - <span class="user-meta-item">joined ${createdDate}</span> 104 - ${user.email ? `<span class="user-meta-item">${user.email}</span>` : ''} 105 - </div> 106 - </div> 107 - <div class="user-badges"> 108 - <span class="user-badge badge-status ${user.status}">${user.status}</span> 109 - <span class="user-badge badge-role">${user.role}</span> 110 - </div> 258 + <div class="app-item"> 259 + <div class="app-name">${app.name}</div> 260 + <div class="app-date">${lastUsedDate}</div> 111 261 </div> 112 262 `; 113 263 }).join(''); 264 + 265 + if (apps.length > 7) { 266 + recentApps.innerHTML += '<a href="/apps" class="view-all">view all apps →</a>'; 267 + } 114 268 } catch (error) { 115 - console.error('Failed to load users:', error); 116 - usersList.innerHTML = '<div class="error">Failed to load users</div>'; 269 + console.error('Failed to load apps:', error); 270 + recentApps.innerHTML = '<div class="empty">Failed to load apps</div>'; 117 271 } 118 272 } 119 273
+298
src/html/admin.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>admin • indiko</title> 8 + <link rel="icon" href="../../public/favicon.svg" type="image/svg+xml" /> 9 + <link rel="preconnect" href="https://fonts.googleapis.com"> 10 + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 11 + <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300..700&display=swap" rel="stylesheet"> 12 + <style> 13 + :root { 14 + --mahogany: #26242b; 15 + --lavender: #d9d0de; 16 + --old-rose: #bc8da0; 17 + --rosewood: #a04668; 18 + --berry-crush: #ab4967; 19 + } 20 + 21 + * { 22 + margin: 0; 23 + padding: 0; 24 + box-sizing: border-box; 25 + } 26 + 27 + body { 28 + font-family: "Space Grotesk", sans-serif; 29 + background: var(--mahogany); 30 + color: var(--lavender); 31 + min-height: 100vh; 32 + display: flex; 33 + flex-direction: column; 34 + align-items: center; 35 + padding: 2.5rem 1.25rem; 36 + } 37 + 38 + header { 39 + width: 100%; 40 + max-width: 56.25rem; 41 + align-self: flex-start; 42 + margin-left: auto; 43 + margin-right: auto; 44 + margin-bottom: 2rem; 45 + display: flex; 46 + justify-content: space-between; 47 + align-items: flex-start; 48 + } 49 + 50 + .header-nav { 51 + display: flex; 52 + gap: 1rem; 53 + margin-top: 0.5rem; 54 + } 55 + 56 + .header-nav a { 57 + color: var(--old-rose); 58 + text-decoration: none; 59 + font-size: 0.875rem; 60 + font-weight: 500; 61 + padding: 0.5rem 1rem; 62 + border: 1px solid var(--old-rose); 63 + transition: all 0.2s; 64 + } 65 + 66 + .header-nav a:hover { 67 + background: rgba(188, 141, 160, 0.1); 68 + color: var(--berry-crush); 69 + border-color: var(--berry-crush); 70 + } 71 + 72 + .header-nav a.active { 73 + background: var(--berry-crush); 74 + color: var(--lavender); 75 + border-color: var(--berry-crush); 76 + } 77 + 78 + h1 { 79 + font-size: 2rem; 80 + font-weight: 700; 81 + background: linear-gradient(135deg, var(--old-rose), var(--berry-crush), var(--rosewood)); 82 + -webkit-background-clip: text; 83 + -webkit-text-fill-color: transparent; 84 + background-clip: text; 85 + letter-spacing: -0.125rem; 86 + } 87 + 88 + main { 89 + flex: 1; 90 + display: flex; 91 + justify-content: center; 92 + width: 100%; 93 + padding-top: 2rem; 94 + } 95 + 96 + footer { 97 + width: 100%; 98 + max-width: 56.25rem; 99 + padding: 1rem; 100 + text-align: center; 101 + color: var(--old-rose); 102 + font-size: 0.875rem; 103 + font-weight: 300; 104 + letter-spacing: 0.05rem; 105 + } 106 + 107 + footer a { 108 + color: var(--berry-crush); 109 + text-decoration: none; 110 + transition: color 0.2s; 111 + } 112 + 113 + footer a:hover { 114 + color: var(--rosewood); 115 + text-decoration: underline; 116 + } 117 + 118 + .back-link { 119 + margin-top: 0.5rem; 120 + font-size: 0.875rem; 121 + color: var(--old-rose); 122 + } 123 + 124 + .users-section { 125 + width: 100%; 126 + max-width: 56.25rem; 127 + } 128 + 129 + .users-section h2 { 130 + font-size: 1.5rem; 131 + font-weight: 600; 132 + color: var(--lavender); 133 + margin-bottom: 1.5rem; 134 + letter-spacing: -0.05rem; 135 + } 136 + 137 + .users-list { 138 + display: flex; 139 + flex-direction: column; 140 + gap: 1rem; 141 + } 142 + 143 + .user-card { 144 + background: rgba(188, 141, 160, 0.05); 145 + border: 1px solid var(--old-rose); 146 + padding: 1.5rem; 147 + display: flex; 148 + gap: 1.5rem; 149 + align-items: center; 150 + transition: background 0.2s; 151 + } 152 + 153 + .user-card:hover { 154 + background: rgba(188, 141, 160, 0.1); 155 + } 156 + 157 + .user-avatar { 158 + width: 4rem; 159 + height: 4rem; 160 + border-radius: 50%; 161 + background: var(--berry-crush); 162 + display: flex; 163 + align-items: center; 164 + justify-content: center; 165 + font-size: 1.5rem; 166 + font-weight: 700; 167 + color: var(--lavender); 168 + flex-shrink: 0; 169 + text-transform: uppercase; 170 + } 171 + 172 + .user-avatar img { 173 + width: 100%; 174 + height: 100%; 175 + border-radius: 50%; 176 + object-fit: cover; 177 + } 178 + 179 + .user-info { 180 + display: flex; 181 + flex-direction: column; 182 + gap: 0.5rem; 183 + flex: 1; 184 + } 185 + 186 + .user-name { 187 + font-size: 1.125rem; 188 + font-weight: 600; 189 + color: var(--lavender); 190 + } 191 + 192 + .user-meta { 193 + font-size: 0.875rem; 194 + color: var(--old-rose); 195 + display: flex; 196 + flex-wrap: wrap; 197 + gap: 1rem; 198 + } 199 + 200 + .user-meta-item { 201 + display: flex; 202 + align-items: center; 203 + gap: 0.25rem; 204 + } 205 + 206 + .user-badges { 207 + display: flex; 208 + gap: 0.5rem; 209 + flex-wrap: wrap; 210 + } 211 + 212 + .user-badge { 213 + display: inline-block; 214 + padding: 0.25rem 0.75rem; 215 + font-size: 0.75rem; 216 + font-weight: 700; 217 + text-transform: uppercase; 218 + letter-spacing: 0.05rem; 219 + } 220 + 221 + .badge-admin { 222 + background: var(--berry-crush); 223 + color: var(--lavender); 224 + } 225 + 226 + .badge-role { 227 + background: rgba(188, 141, 160, 0.2); 228 + color: var(--lavender); 229 + border: 1px solid var(--old-rose); 230 + } 231 + 232 + .badge-status { 233 + border: 1px solid var(--old-rose); 234 + } 235 + 236 + .badge-status.active { 237 + background: rgba(139, 195, 74, 0.2); 238 + color: #a5d6a7; 239 + border-color: #81c784; 240 + } 241 + 242 + .badge-status.suspended { 243 + background: rgba(244, 67, 54, 0.2); 244 + color: #ef9a9a; 245 + border-color: #e57373; 246 + } 247 + 248 + .badge-status.inactive { 249 + background: rgba(158, 158, 158, 0.2); 250 + color: #bdbdbd; 251 + border-color: #9e9e9e; 252 + } 253 + 254 + .loading { 255 + text-align: center; 256 + padding: 2rem; 257 + color: var(--old-rose); 258 + font-size: 1rem; 259 + } 260 + 261 + .error { 262 + text-align: center; 263 + padding: 2rem; 264 + color: var(--rosewood); 265 + font-size: 1rem; 266 + } 267 + </style> 268 + </head> 269 + 270 + <body> 271 + <header> 272 + <div> 273 + <img src="../../public/logo.svg" alt="indiko" style="height: 2rem;" /> 274 + </div> 275 + <div class="header-nav"> 276 + <a href="/admin" class="active">users</a> 277 + <a href="/admin/apps">apps</a> 278 + </div> 279 + </header> 280 + 281 + <main> 282 + <div class="users-section"> 283 + <h2>users</h2> 284 + <div id="usersList" class="users-list"> 285 + <div class="loading">loading users...</div> 286 + </div> 287 + </div> 288 + </main> 289 + 290 + <footer id="footer"> 291 + loading... 292 + <div class="back-link"><a href="/">← back to dashboard</a></div> 293 + </footer> 294 + 295 + <script type="module" src="../client/admin.ts"></script> 296 + </body> 297 + 298 + </html>
+197
src/html/apps.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>authorized apps • indiko</title> 8 + <link rel="icon" href="../../public/favicon.svg" type="image/svg+xml" /> 9 + <link rel="preconnect" href="https://fonts.googleapis.com"> 10 + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 11 + <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300..700&display=swap" rel="stylesheet"> 12 + <style> 13 + :root { 14 + --mahogany: #26242b; 15 + --lavender: #d9d0de; 16 + --old-rose: #bc8da0; 17 + --rosewood: #a04668; 18 + --berry-crush: #ab4967; 19 + } 20 + 21 + * { 22 + margin: 0; 23 + padding: 0; 24 + box-sizing: border-box; 25 + } 26 + 27 + body { 28 + font-family: "Space Grotesk", sans-serif; 29 + background: var(--mahogany); 30 + color: var(--lavender); 31 + min-height: 100vh; 32 + display: flex; 33 + flex-direction: column; 34 + align-items: center; 35 + padding: 2.5rem 1.25rem; 36 + } 37 + 38 + header { 39 + width: 100%; 40 + max-width: 56.25rem; 41 + margin-bottom: 2rem; 42 + } 43 + 44 + h1 { 45 + font-size: 2rem; 46 + font-weight: 700; 47 + background: linear-gradient(135deg, var(--old-rose), var(--berry-crush), var(--rosewood)); 48 + -webkit-background-clip: text; 49 + -webkit-text-fill-color: transparent; 50 + background-clip: text; 51 + letter-spacing: -0.125rem; 52 + margin-bottom: 0.5rem; 53 + } 54 + 55 + .subtitle { 56 + color: var(--old-rose); 57 + font-size: 1rem; 58 + font-weight: 300; 59 + } 60 + 61 + main { 62 + flex: 1; 63 + width: 100%; 64 + max-width: 56.25rem; 65 + } 66 + 67 + .apps-list { 68 + display: flex; 69 + flex-direction: column; 70 + gap: 1rem; 71 + } 72 + 73 + .app-card { 74 + background: rgba(188, 141, 160, 0.05); 75 + border: 1px solid var(--old-rose); 76 + padding: 1.5rem; 77 + } 78 + 79 + .app-header { 80 + display: flex; 81 + justify-content: space-between; 82 + align-items: flex-start; 83 + margin-bottom: 1rem; 84 + } 85 + 86 + .app-name { 87 + font-size: 1.125rem; 88 + font-weight: 600; 89 + color: var(--lavender); 90 + } 91 + 92 + .app-meta { 93 + font-size: 0.875rem; 94 + color: var(--old-rose); 95 + margin-top: 0.25rem; 96 + } 97 + 98 + .scopes { 99 + margin: 1rem 0; 100 + } 101 + 102 + .scope-title { 103 + font-size: 0.75rem; 104 + color: var(--old-rose); 105 + text-transform: uppercase; 106 + letter-spacing: 0.05rem; 107 + margin-bottom: 0.5rem; 108 + } 109 + 110 + .scope-list { 111 + display: flex; 112 + flex-wrap: wrap; 113 + gap: 0.5rem; 114 + } 115 + 116 + .scope-badge { 117 + padding: 0.25rem 0.75rem; 118 + font-size: 0.75rem; 119 + font-weight: 600; 120 + text-transform: uppercase; 121 + letter-spacing: 0.05rem; 122 + background: rgba(188, 141, 160, 0.2); 123 + color: var(--lavender); 124 + border: 1px solid var(--old-rose); 125 + } 126 + 127 + .revoke-btn { 128 + padding: 0.5rem 1rem; 129 + background: transparent; 130 + color: var(--rosewood); 131 + border: 2px solid var(--rosewood); 132 + font-family: "Space Grotesk", sans-serif; 133 + font-size: 0.875rem; 134 + font-weight: 600; 135 + text-transform: uppercase; 136 + letter-spacing: 0.05rem; 137 + cursor: pointer; 138 + transition: all 0.2s; 139 + } 140 + 141 + .revoke-btn:hover:not(:disabled) { 142 + background: var(--rosewood); 143 + color: var(--lavender); 144 + } 145 + 146 + .revoke-btn:disabled { 147 + opacity: 0.5; 148 + cursor: not-allowed; 149 + } 150 + 151 + .loading, .error, .empty { 152 + text-align: center; 153 + padding: 2rem; 154 + color: var(--old-rose); 155 + } 156 + 157 + .error { 158 + color: var(--rosewood); 159 + } 160 + 161 + .back-link { 162 + text-align: center; 163 + margin-top: 2rem; 164 + font-size: 0.875rem; 165 + } 166 + 167 + .back-link a { 168 + color: var(--berry-crush); 169 + text-decoration: none; 170 + } 171 + 172 + .back-link a:hover { 173 + text-decoration: underline; 174 + } 175 + </style> 176 + </head> 177 + 178 + <body> 179 + <header> 180 + <h1>authorized apps</h1> 181 + <p class="subtitle">manage apps that have access to your account</p> 182 + </header> 183 + 184 + <main> 185 + <div id="appsList" class="apps-list"> 186 + <div class="loading">loading apps...</div> 187 + </div> 188 + 189 + <div class="back-link"> 190 + <a href="/">← back to dashboard</a> 191 + </div> 192 + </main> 193 + 194 + <script type="module" src="../client/apps.ts"></script> 195 + </body> 196 + 197 + </html>
+241 -122
src/html/index.html
··· 4 4 <head> 5 5 <meta charset="UTF-8" /> 6 6 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 7 - <title>indiko</title> 7 + <title>dashboard • indiko</title> 8 8 <link rel="icon" href="../../public/favicon.svg" type="image/svg+xml" /> 9 9 <link rel="preconnect" href="https://fonts.googleapis.com"> 10 10 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> ··· 38 38 header { 39 39 width: 100%; 40 40 max-width: 56.25rem; 41 - align-self: flex-start; 42 - margin-left: auto; 43 - margin-right: auto; 41 + margin-bottom: 2rem; 42 + } 43 + 44 + .profile-preview { 45 + display: flex; 46 + align-items: center; 47 + gap: 1.5rem; 48 + background: rgba(188, 141, 160, 0.05); 49 + border: 1px solid var(--old-rose); 50 + padding: 1.5rem; 51 + margin-bottom: 2rem; 44 52 } 45 53 46 - h1 { 54 + .profile-avatar { 55 + width: 80px; 56 + height: 80px; 57 + border-radius: 50%; 58 + background: var(--berry-crush); 59 + display: flex; 60 + align-items: center; 61 + justify-content: center; 47 62 font-size: 2rem; 48 63 font-weight: 700; 49 - background: linear-gradient(135deg, var(--old-rose), var(--berry-crush), var(--rosewood)); 50 - -webkit-background-clip: text; 51 - -webkit-text-fill-color: transparent; 52 - background-clip: text; 53 - letter-spacing: -0.125rem; 64 + color: var(--lavender); 65 + text-transform: uppercase; 66 + flex-shrink: 0; 67 + border: 3px solid var(--berry-crush); 68 + } 69 + 70 + .profile-avatar img { 71 + width: 100%; 72 + height: 100%; 73 + border-radius: 50%; 74 + object-fit: cover; 54 75 } 55 76 56 - main { 77 + .profile-info { 57 78 flex: 1; 58 - display: flex; 59 - justify-content: center; 60 - width: 100%; 61 - padding-top: 2rem; 79 + } 80 + 81 + .profile-name { 82 + font-size: 1.5rem; 83 + font-weight: 700; 84 + color: var(--lavender); 85 + margin-bottom: 0.25rem; 62 86 } 63 87 64 - footer { 65 - width: 100%; 66 - max-width: 56.25rem; 67 - padding: 1rem; 68 - text-align: center; 88 + .profile-username { 89 + font-size: 1rem; 69 90 color: var(--old-rose); 91 + margin-bottom: 0.5rem; 92 + } 93 + 94 + .profile-links { 95 + display: flex; 96 + gap: 1rem; 70 97 font-size: 0.875rem; 71 - font-weight: 300; 72 - letter-spacing: 0.05rem; 73 98 } 74 99 75 - footer a { 100 + .profile-links a { 76 101 color: var(--berry-crush); 77 102 text-decoration: none; 78 - transition: color 0.2s; 79 103 } 80 104 81 - footer a:hover { 82 - color: var(--rosewood); 105 + .profile-links a:hover { 83 106 text-decoration: underline; 84 107 } 85 108 86 - .users-section { 109 + h1 { 110 + font-size: 2rem; 111 + font-weight: 700; 112 + background: linear-gradient(135deg, var(--old-rose), var(--berry-crush), var(--rosewood)); 113 + -webkit-background-clip: text; 114 + -webkit-text-fill-color: transparent; 115 + background-clip: text; 116 + letter-spacing: -0.125rem; 117 + margin-bottom: 0.5rem; 118 + } 119 + 120 + .subtitle { 121 + color: var(--old-rose); 122 + font-size: 1rem; 123 + font-weight: 300; 124 + } 125 + 126 + main { 127 + flex: 1; 87 128 width: 100%; 88 129 max-width: 56.25rem; 89 130 } 90 131 91 - .users-section h2 { 92 - font-size: 1.5rem; 93 - font-weight: 600; 94 - color: var(--lavender); 95 - margin-bottom: 1.5rem; 96 - letter-spacing: -0.05rem; 132 + .dashboard-grid { 133 + display: grid; 134 + grid-template-columns: 1fr; 135 + gap: 1.5rem; 136 + margin-bottom: 2rem; 97 137 } 98 138 99 - .users-list { 100 - display: flex; 101 - flex-direction: column; 102 - gap: 1rem; 139 + @media (min-width: 768px) { 140 + .dashboard-grid { 141 + grid-template-columns: 1fr 1fr; 142 + } 103 143 } 104 144 105 - .user-card { 145 + .card { 106 146 background: rgba(188, 141, 160, 0.05); 107 147 border: 1px solid var(--old-rose); 108 148 padding: 1.5rem; 109 - display: flex; 110 - gap: 1.5rem; 111 - align-items: center; 112 - transition: background 0.2s; 149 + } 150 + 151 + .card-title { 152 + font-size: 1.125rem; 153 + font-weight: 600; 154 + color: var(--lavender); 155 + margin-bottom: 1rem; 113 156 } 114 157 115 - .user-card:hover { 116 - background: rgba(188, 141, 160, 0.1); 158 + .form-group { 159 + margin-bottom: 1rem; 117 160 } 118 161 119 - .user-avatar { 120 - width: 4rem; 121 - height: 4rem; 122 - border-radius: 50%; 123 - background: var(--berry-crush); 124 - display: flex; 125 - align-items: center; 126 - justify-content: center; 127 - font-size: 1.5rem; 128 - font-weight: 700; 129 - color: var(--lavender); 130 - flex-shrink: 0; 162 + label { 163 + display: block; 164 + color: var(--old-rose); 165 + font-size: 0.875rem; 166 + font-weight: 500; 167 + margin-bottom: 0.5rem; 131 168 text-transform: uppercase; 169 + letter-spacing: 0.05rem; 132 170 } 133 171 134 - .user-avatar img { 172 + input[type="text"], 173 + input[type="email"], 174 + input[type="url"] { 135 175 width: 100%; 136 - height: 100%; 137 - border-radius: 50%; 138 - object-fit: cover; 176 + padding: 0.75rem; 177 + background: rgba(12, 23, 19, 0.6); 178 + border: 2px solid var(--rosewood); 179 + color: var(--lavender); 180 + font-size: 1rem; 181 + font-family: "Space Grotesk", sans-serif; 182 + transition: border-color 0.2s; 139 183 } 140 184 141 - .user-info { 142 - display: flex; 143 - flex-direction: column; 144 - gap: 0.5rem; 145 - flex: 1; 185 + input:focus { 186 + outline: none; 187 + border-color: var(--berry-crush); 188 + background: rgba(12, 23, 19, 0.8); 146 189 } 147 190 148 - .user-name { 149 - font-size: 1.125rem; 191 + .save-btn { 192 + width: 100%; 193 + padding: 0.75rem 1rem; 194 + background: var(--berry-crush); 195 + border: 2px solid var(--rosewood); 196 + color: var(--lavender); 197 + font-family: "Space Grotesk", sans-serif; 198 + font-size: 1rem; 150 199 font-weight: 600; 151 - color: var(--lavender); 200 + text-transform: uppercase; 201 + letter-spacing: 0.05rem; 202 + cursor: pointer; 203 + transition: all 0.2s; 204 + } 205 + 206 + .save-btn:hover:not(:disabled) { 207 + background: var(--rosewood); 208 + } 209 + 210 + .save-btn:disabled { 211 + opacity: 0.5; 212 + cursor: not-allowed; 152 213 } 153 214 154 - .user-meta { 215 + .message { 216 + padding: 0.75rem; 217 + margin-top: 1rem; 155 218 font-size: 0.875rem; 156 - color: var(--old-rose); 157 - display: flex; 158 - flex-wrap: wrap; 159 - gap: 1rem; 219 + display: none; 160 220 } 161 221 162 - .user-meta-item { 163 - display: flex; 164 - align-items: center; 165 - gap: 0.25rem; 222 + .message.show { 223 + display: block; 224 + } 225 + 226 + .message.success { 227 + background: rgba(139, 195, 74, 0.2); 228 + border: 1px solid #81c784; 229 + color: #a5d6a7; 166 230 } 167 231 168 - .user-badges { 169 - display: flex; 170 - gap: 0.5rem; 171 - flex-wrap: wrap; 232 + .message.error { 233 + background: rgba(244, 67, 54, 0.2); 234 + border: 1px solid #e57373; 235 + color: #ef9a9a; 172 236 } 173 237 174 - .user-badge { 175 - display: inline-block; 176 - padding: 0.25rem 0.75rem; 177 - font-size: 0.75rem; 178 - font-weight: 700; 179 - text-transform: uppercase; 180 - letter-spacing: 0.05rem; 238 + .apps-preview { 239 + display: flex; 240 + flex-direction: column; 241 + gap: 0.75rem; 181 242 } 182 243 183 - .badge-admin { 184 - background: var(--berry-crush); 185 - color: var(--lavender); 244 + .app-item { 245 + padding: 0.75rem; 246 + background: rgba(12, 23, 19, 0.6); 247 + border: 1px solid var(--rosewood); 248 + display: flex; 249 + justify-content: space-between; 250 + align-items: center; 186 251 } 187 252 188 - .badge-role { 189 - background: rgba(188, 141, 160, 0.2); 253 + .app-name { 254 + font-weight: 500; 190 255 color: var(--lavender); 191 - border: 1px solid var(--old-rose); 192 256 } 193 257 194 - .badge-status { 195 - border: 1px solid var(--old-rose); 258 + .app-date { 259 + font-size: 0.875rem; 260 + color: var(--old-rose); 196 261 } 197 262 198 - .badge-status.active { 199 - background: rgba(139, 195, 74, 0.2); 200 - color: #a5d6a7; 201 - border-color: #81c784; 263 + .loading, .empty { 264 + text-align: center; 265 + padding: 1rem; 266 + color: var(--old-rose); 267 + font-size: 0.875rem; 202 268 } 203 269 204 - .badge-status.suspended { 205 - background: rgba(244, 67, 54, 0.2); 206 - color: #ef9a9a; 207 - border-color: #e57373; 270 + .view-all { 271 + display: block; 272 + text-align: center; 273 + margin-top: 1rem; 274 + color: var(--berry-crush); 275 + text-decoration: none; 276 + font-size: 0.875rem; 208 277 } 209 278 210 - .badge-status.inactive { 211 - background: rgba(158, 158, 158, 0.2); 212 - color: #bdbdbd; 213 - border-color: #9e9e9e; 279 + .view-all:hover { 280 + text-decoration: underline; 214 281 } 215 282 216 - .loading { 283 + footer { 284 + width: 100%; 285 + max-width: 56.25rem; 286 + padding: 1rem; 217 287 text-align: center; 218 - padding: 2rem; 219 288 color: var(--old-rose); 220 - font-size: 1rem; 289 + font-size: 0.875rem; 290 + font-weight: 300; 291 + letter-spacing: 0.05rem; 292 + } 293 + 294 + footer a { 295 + color: var(--berry-crush); 296 + text-decoration: none; 297 + transition: color 0.2s; 221 298 } 222 299 223 - .error { 224 - text-align: center; 225 - padding: 2rem; 300 + footer a:hover { 226 301 color: var(--rosewood); 227 - font-size: 1rem; 302 + text-decoration: underline; 228 303 } 229 304 </style> 230 305 </head> 231 306 232 307 <body> 233 308 <header> 234 - <img src="../../public/logo.svg" alt="indiko" style="height: 2rem; margin-bottom: 0.5rem;" /> 309 + <h1 id="welcome">welcome</h1> 310 + <p class="subtitle" id="subtitle">loading...</p> 235 311 </header> 236 312 237 313 <main> 238 - <div class="users-section"> 239 - <h2>users</h2> 240 - <div id="usersList" class="users-list"> 241 - <div class="loading">loading users...</div> 314 + <div class="profile-preview"> 315 + <div class="profile-avatar" id="profileAvatar"> 316 + <span id="avatarInitials"></span> 317 + </div> 318 + <div class="profile-info"> 319 + <div class="profile-name" id="profileName">Loading...</div> 320 + <div class="profile-username" id="profileUsername">@username</div> 321 + <div class="profile-links" id="profileLinks"> 322 + <a href="/u/" id="publicProfileLink">view public profile</a> 323 + </div> 324 + </div> 325 + </div> 326 + 327 + <div class="dashboard-grid"> 328 + <div class="card"> 329 + <h2 class="card-title">profile</h2> 330 + <form id="profileForm"> 331 + <div class="form-group"> 332 + <label for="name">name</label> 333 + <input type="text" id="name" name="name" required /> 334 + </div> 335 + 336 + <div class="form-group"> 337 + <label for="email">email (optional)</label> 338 + <input type="email" id="email" name="email" /> 339 + </div> 340 + 341 + <div class="form-group"> 342 + <label for="photo">photo url (optional)</label> 343 + <input type="url" id="photo" name="photo" placeholder="https://example.com/photo.jpg" /> 344 + </div> 345 + 346 + <div class="form-group"> 347 + <label for="url">website url (optional)</label> 348 + <input type="url" id="url" name="url" placeholder="https://example.com" /> 349 + </div> 350 + 351 + <button type="submit" class="save-btn" id="saveBtn">save profile</button> 352 + <div id="message" class="message"></div> 353 + </form> 354 + </div> 355 + 356 + <div class="card"> 357 + <h2 class="card-title">recent apps</h2> 358 + <div id="recentApps" class="apps-preview"> 359 + <div class="loading">loading...</div> 360 + </div> 242 361 </div> 243 362 </div> 244 363 </main> 245 364 246 365 <footer id="footer"> 247 - loading... • <a href="/oauth-test">test oauth</a> 366 + loading... 248 367 </footer> 249 368 250 369 <script type="module" src="../client/index.ts"></script> 251 370 </body> 252 371 253 - </html> 372 + </html>
+24 -4
src/index.ts
··· 1 1 import { env } from "bun"; 2 2 import { db } from "./db"; 3 3 import indexHTML from "./html/index.html"; 4 + import adminHTML from "./html/admin.html"; 4 5 import loginHTML from "./html/login.html"; 5 6 import profileHTML from "./html/profile.html"; 6 7 import oauthTestHTML from "./html/oauth-test.html"; 8 + import appsHTML from "./html/apps.html"; 7 9 import { canRegister, registerOptions, registerVerify, loginOptions, loginVerify } from "./routes/auth"; 8 - import { hello, listUsers, getProfile, updateProfile } from "./routes/api"; 10 + import { hello, listUsers, getProfile, updateProfile, getAuthorizedApps, revokeApp } from "./routes/api"; 9 11 import { authorizeGet, authorizePost, token, logout, userProfile, createInvite } from "./routes/indieauth"; 10 12 11 13 (() => { ··· 25 27 port: env.PORT ? Number.parseInt(env.PORT, 10) : 3000, 26 28 routes: { 27 29 "/": indexHTML, 30 + "/admin": adminHTML, 28 31 "/login": loginHTML, 29 32 "/profile": profileHTML, 30 33 "/oauth-test": oauthTestHTML, 34 + "/apps": appsHTML, 31 35 // API endpoints 32 36 "/api/hello": hello, 33 37 "/api/users": listUsers, ··· 36 40 if (req.method === "PUT") return updateProfile(req); 37 41 return new Response("Method not allowed", { status: 405 }); 38 42 }, 43 + "/api/apps": (req: Request) => { 44 + if (req.method === "GET") return getAuthorizedApps(req); 45 + return new Response("Method not allowed", { status: 405 }); 46 + }, 39 47 "/api/invites/create": (req: Request) => { 40 48 if (req.method === "POST") return createInvite(req); 41 49 return new Response("Method not allowed", { status: 405 }); ··· 65 73 fetch(req) { 66 74 // Handle dynamic routes like /u/:username 67 75 const url = new URL(req.url); 68 - const match = url.pathname.match(/^\/u\/([^\/]+)$/); 69 - if (match) { 70 - const username = match[1]; 76 + 77 + // /u/:username - user profiles 78 + const userMatch = url.pathname.match(/^\/u\/([^\/]+)$/); 79 + if (userMatch) { 80 + const username = userMatch[1]; 71 81 return userProfile(req, username); 82 + } 83 + 84 + // /api/apps/:clientId - revoke app access 85 + const appMatch = url.pathname.match(/^\/api\/apps\/([^\/]+)$/); 86 + if (appMatch) { 87 + if (req.method === "DELETE") { 88 + const clientId = decodeURIComponent(appMatch[1]); 89 + return revokeApp(req, clientId); 90 + } 91 + return new Response("Method not allowed", { status: 405 }); 72 92 } 73 93 74 94 // Let Bun handle static routes
+69 -3
src/routes/api.ts
··· 1 1 import { db } from "../db"; 2 2 3 - function getSessionUser(req: Request): { username: string; is_admin: boolean } | Response { 3 + function getSessionUser(req: Request): { username: string; userId: number; is_admin: boolean } | Response { 4 4 const authHeader = req.headers.get("Authorization"); 5 5 6 6 if (!authHeader || !authHeader.startsWith("Bearer ")) { ··· 12 12 // Look up session 13 13 const session = db 14 14 .query( 15 - `SELECT s.expires_at, u.username, u.is_admin 15 + `SELECT s.expires_at, s.user_id, u.username, u.is_admin 16 16 FROM sessions s 17 17 JOIN users u ON s.user_id = u.id 18 18 WHERE s.token = ?`, 19 19 ) 20 20 .get(token) as 21 - | { expires_at: number; username: string; is_admin: number } 21 + | { expires_at: number; user_id: number; username: string; is_admin: number } 22 22 | undefined; 23 23 24 24 if (!session) { ··· 32 32 33 33 return { 34 34 username: session.username, 35 + userId: session.user_id, 35 36 is_admin: session.is_admin === 1, 36 37 }; 37 38 } ··· 167 168 return Response.json({ error: "Failed to update profile" }, { status: 500 }); 168 169 } 169 170 } 171 + 172 + export function getAuthorizedApps(req: Request): Response { 173 + const user = getSessionUser(req); 174 + if (user instanceof Response) { 175 + return user; 176 + } 177 + 178 + const apps = db 179 + .query( 180 + `SELECT 181 + a.client_id, 182 + a.name, 183 + a.first_seen, 184 + a.last_used as app_last_used, 185 + p.scopes, 186 + p.granted_at, 187 + p.last_used 188 + FROM permissions p 189 + JOIN apps a ON p.client_id = a.client_id 190 + WHERE p.user_id = ? 191 + ORDER BY p.last_used DESC`, 192 + ) 193 + .all(user.userId) as Array<{ 194 + client_id: string; 195 + name: string | null; 196 + first_seen: number; 197 + app_last_used: number; 198 + scopes: string; 199 + granted_at: number; 200 + last_used: number; 201 + }>; 202 + 203 + return Response.json({ 204 + apps: apps.map((app) => ({ 205 + clientId: app.client_id, 206 + name: app.name || new URL(app.client_id).hostname, 207 + scopes: JSON.parse(app.scopes) as string[], 208 + grantedAt: app.granted_at, 209 + lastUsed: app.last_used, 210 + })), 211 + }); 212 + } 213 + 214 + export function revokeApp(req: Request, clientId: string): Response { 215 + const user = getSessionUser(req); 216 + if (user instanceof Response) { 217 + return user; 218 + } 219 + 220 + // Delete permission 221 + const result = db 222 + .query("DELETE FROM permissions WHERE user_id = ? AND client_id = ?") 223 + .run(user.userId, clientId); 224 + 225 + if (result.changes === 0) { 226 + return Response.json({ error: "App not found" }, { status: 404 }); 227 + } 228 + 229 + // Also delete any unused auth codes for this app 230 + db.query( 231 + "DELETE FROM authcodes WHERE user_id = ? AND client_id = ? AND used = 0", 232 + ).run(user.userId, clientId); 233 + 234 + return Response.json({ success: true }); 235 + }