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: add users list and profile

+813 -9
+74 -2
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 4 4 5 // Check auth and display user 5 6 async function checkAuth() { ··· 23 24 24 25 const data = await response.json(); 25 26 26 - footer.innerHTML = `signed in as <strong>${data.username}</strong> • <a href="/login" id="logoutLink">sign out</a>`; 27 + footer.innerHTML = `signed in as <strong>${data.username}</strong> • <a href="/profile">edit profile</a> • <a href="/login" id="logoutLink">sign out</a>`; 27 28 28 29 // Handle logout 29 30 document.getElementById('logoutLink')?.addEventListener('click', async (e) => { ··· 35 36 'Authorization': `Bearer ${token}`, 36 37 }, 37 38 }); 38 - } catch (e) { } 39 + } catch { 40 + // Ignore logout errors 41 + } 39 42 localStorage.removeItem('indiko_session'); 40 43 window.location.href = '/login'; 41 44 }); 45 + 46 + // Load users if admin 47 + if (data.isAdmin) { 48 + loadUsers(); 49 + } else { 50 + usersList.innerHTML = '<div class="error">Admin access required</div>'; 51 + } 42 52 } catch (error) { 43 53 console.error('Auth check failed:', error); 44 54 footer.textContent = 'error loading user info'; 55 + usersList.innerHTML = '<div class="error">Failed to load users</div>'; 56 + } 57 + } 58 + 59 + async function loadUsers() { 60 + try { 61 + const response = await fetch('/api/users', { 62 + headers: { 63 + 'Authorization': `Bearer ${token}`, 64 + }, 65 + }); 66 + 67 + if (!response.ok) { 68 + throw new Error('Failed to load users'); 69 + } 70 + 71 + const data = await response.json(); 72 + 73 + if (data.users.length === 0) { 74 + usersList.innerHTML = '<div class="loading">No users found</div>'; 75 + return; 76 + } 77 + 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; 95 + 96 + 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> 111 + </div> 112 + `; 113 + }).join(''); 114 + } catch (error) { 115 + console.error('Failed to load users:', error); 116 + usersList.innerHTML = '<div class="error">Failed to load users</div>'; 45 117 } 46 118 } 47 119
+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) { 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();
+151 -1
src/html/index.html
··· 56 56 main { 57 57 flex: 1; 58 58 display: flex; 59 - align-items: center; 60 59 justify-content: center; 61 60 width: 100%; 61 + padding-top: 2rem; 62 62 } 63 63 64 64 footer { ··· 87 87 color: var(--rosewood); 88 88 text-decoration: underline; 89 89 } 90 + 91 + .users-section { 92 + width: 100%; 93 + max-width: 56.25rem; 94 + } 95 + 96 + .users-section h2 { 97 + font-size: 1.5rem; 98 + font-weight: 600; 99 + color: var(--lavender); 100 + margin-bottom: 1.5rem; 101 + letter-spacing: -0.05rem; 102 + } 103 + 104 + .users-list { 105 + display: flex; 106 + flex-direction: column; 107 + gap: 1rem; 108 + } 109 + 110 + .user-card { 111 + background: rgba(188, 141, 160, 0.05); 112 + border: 1px solid var(--old-rose); 113 + padding: 1.5rem; 114 + display: flex; 115 + gap: 1.5rem; 116 + align-items: center; 117 + transition: background 0.2s; 118 + } 119 + 120 + .user-card:hover { 121 + background: rgba(188, 141, 160, 0.1); 122 + } 123 + 124 + .user-avatar { 125 + width: 4rem; 126 + height: 4rem; 127 + border-radius: 50%; 128 + background: var(--berry-crush); 129 + display: flex; 130 + align-items: center; 131 + justify-content: center; 132 + font-size: 1.5rem; 133 + font-weight: 700; 134 + color: var(--lavender); 135 + flex-shrink: 0; 136 + text-transform: uppercase; 137 + } 138 + 139 + .user-avatar img { 140 + width: 100%; 141 + height: 100%; 142 + border-radius: 50%; 143 + object-fit: cover; 144 + } 145 + 146 + .user-info { 147 + display: flex; 148 + flex-direction: column; 149 + gap: 0.5rem; 150 + flex: 1; 151 + } 152 + 153 + .user-name { 154 + font-size: 1.125rem; 155 + font-weight: 600; 156 + color: var(--lavender); 157 + } 158 + 159 + .user-meta { 160 + font-size: 0.875rem; 161 + color: var(--old-rose); 162 + display: flex; 163 + flex-wrap: wrap; 164 + gap: 1rem; 165 + } 166 + 167 + .user-meta-item { 168 + display: flex; 169 + align-items: center; 170 + gap: 0.25rem; 171 + } 172 + 173 + .user-badges { 174 + display: flex; 175 + gap: 0.5rem; 176 + flex-wrap: wrap; 177 + } 178 + 179 + .user-badge { 180 + display: inline-block; 181 + padding: 0.25rem 0.75rem; 182 + font-size: 0.75rem; 183 + font-weight: 700; 184 + text-transform: uppercase; 185 + letter-spacing: 0.05rem; 186 + } 187 + 188 + .badge-admin { 189 + background: var(--berry-crush); 190 + color: var(--lavender); 191 + } 192 + 193 + .badge-role { 194 + background: rgba(188, 141, 160, 0.2); 195 + color: var(--lavender); 196 + border: 1px solid var(--old-rose); 197 + } 198 + 199 + .badge-status { 200 + border: 1px solid var(--old-rose); 201 + } 202 + 203 + .badge-status.active { 204 + background: rgba(139, 195, 74, 0.2); 205 + color: #a5d6a7; 206 + border-color: #81c784; 207 + } 208 + 209 + .badge-status.suspended { 210 + background: rgba(244, 67, 54, 0.2); 211 + color: #ef9a9a; 212 + border-color: #e57373; 213 + } 214 + 215 + .badge-status.inactive { 216 + background: rgba(158, 158, 158, 0.2); 217 + color: #bdbdbd; 218 + border-color: #9e9e9e; 219 + } 220 + 221 + .loading { 222 + text-align: center; 223 + padding: 2rem; 224 + color: var(--old-rose); 225 + font-size: 1rem; 226 + } 227 + 228 + .error { 229 + text-align: center; 230 + padding: 2rem; 231 + color: var(--rosewood); 232 + font-size: 1rem; 233 + } 90 234 </style> 91 235 </head> 92 236 ··· 96 240 </header> 97 241 98 242 <main> 243 + <div class="users-section"> 244 + <h2>users</h2> 245 + <div id="usersList" class="users-list"> 246 + <div class="loading">loading users...</div> 247 + </div> 248 + </div> 99 249 </main> 100 250 101 251 <footer id="footer">
+328
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 + <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 + } 46 + 47 + h1 { 48 + font-size: 2rem; 49 + font-weight: 700; 50 + background: linear-gradient(135deg, var(--old-rose), var(--berry-crush), var(--rosewood)); 51 + -webkit-background-clip: text; 52 + -webkit-text-fill-color: transparent; 53 + background-clip: text; 54 + letter-spacing: -0.125rem; 55 + } 56 + 57 + main { 58 + flex: 1; 59 + display: flex; 60 + justify-content: center; 61 + width: 100%; 62 + } 63 + 64 + .profile-container { 65 + width: 100%; 66 + max-width: 56.25rem; 67 + } 68 + 69 + .profile-section { 70 + background: rgba(188, 141, 160, 0.05); 71 + border: 1px solid var(--old-rose); 72 + padding: 2rem; 73 + margin-bottom: 1.5rem; 74 + } 75 + 76 + .section-title { 77 + font-size: 1.25rem; 78 + font-weight: 600; 79 + color: var(--lavender); 80 + margin-bottom: 1.5rem; 81 + } 82 + 83 + .avatar-upload { 84 + display: flex; 85 + align-items: center; 86 + gap: 1.5rem; 87 + margin-bottom: 2rem; 88 + } 89 + 90 + .avatar-preview { 91 + width: 6rem; 92 + height: 6rem; 93 + border-radius: 50%; 94 + background: var(--berry-crush); 95 + display: flex; 96 + align-items: center; 97 + justify-content: center; 98 + font-size: 2rem; 99 + font-weight: 700; 100 + color: var(--lavender); 101 + text-transform: uppercase; 102 + overflow: hidden; 103 + } 104 + 105 + .avatar-preview img { 106 + width: 100%; 107 + height: 100%; 108 + object-fit: cover; 109 + } 110 + 111 + .avatar-controls { 112 + display: flex; 113 + flex-direction: column; 114 + gap: 0.5rem; 115 + } 116 + 117 + label { 118 + display: block; 119 + color: var(--old-rose); 120 + font-size: 0.875rem; 121 + font-weight: 500; 122 + margin-bottom: 0.5rem; 123 + text-transform: uppercase; 124 + letter-spacing: 0.05rem; 125 + } 126 + 127 + input[type="text"], 128 + input[type="email"], 129 + input[type="url"] { 130 + width: 100%; 131 + padding: 0.875rem 1rem; 132 + background: rgba(12, 23, 19, 0.6); 133 + border: 2px solid var(--rosewood); 134 + border-radius: 0; 135 + color: var(--lavender); 136 + font-size: 1rem; 137 + font-family: "Space Grotesk", sans-serif; 138 + margin-bottom: 1.5rem; 139 + transition: border-color 0.2s; 140 + } 141 + 142 + input:focus { 143 + outline: none; 144 + border-color: var(--berry-crush); 145 + background: rgba(12, 23, 19, 0.8); 146 + } 147 + 148 + input::placeholder { 149 + color: rgba(217, 208, 222, 0.4); 150 + } 151 + 152 + input:disabled { 153 + opacity: 0.5; 154 + cursor: not-allowed; 155 + } 156 + 157 + button { 158 + position: relative; 159 + padding: 1rem 2rem; 160 + background: var(--berry-crush); 161 + color: var(--lavender); 162 + border: 4px solid var(--mahogany); 163 + border-radius: 0; 164 + font-size: 1rem; 165 + font-weight: 700; 166 + cursor: pointer; 167 + font-family: "Space Grotesk", sans-serif; 168 + transition: all 0.15s ease; 169 + text-transform: uppercase; 170 + letter-spacing: 0.1rem; 171 + box-shadow: 6px 6px 0 var(--mahogany); 172 + } 173 + 174 + button::before { 175 + content: ''; 176 + position: absolute; 177 + top: -4px; 178 + left: -4px; 179 + right: -4px; 180 + bottom: -4px; 181 + background: transparent; 182 + border: 4px solid var(--rosewood); 183 + pointer-events: none; 184 + transition: all 0.15s ease; 185 + } 186 + 187 + button:hover:not(:disabled) { 188 + transform: translate(3px, 3px); 189 + box-shadow: 3px 3px 0 var(--mahogany); 190 + } 191 + 192 + button:hover:not(:disabled)::before { 193 + top: -7px; 194 + left: -7px; 195 + right: -7px; 196 + bottom: -7px; 197 + } 198 + 199 + button:active:not(:disabled) { 200 + transform: translate(6px, 6px); 201 + box-shadow: 0 0 0 var(--mahogany); 202 + } 203 + 204 + button:disabled { 205 + opacity: 0.5; 206 + cursor: not-allowed; 207 + } 208 + 209 + .button-secondary { 210 + background: transparent; 211 + color: var(--old-rose); 212 + box-shadow: 4px 4px 0 var(--mahogany); 213 + } 214 + 215 + .button-secondary::before { 216 + border-color: var(--old-rose); 217 + } 218 + 219 + .button-secondary:hover:not(:disabled) { 220 + background: rgba(188, 141, 160, 0.1); 221 + } 222 + 223 + .button-group { 224 + display: flex; 225 + gap: 1rem; 226 + margin-top: 2rem; 227 + } 228 + 229 + .message { 230 + padding: 1rem; 231 + margin-bottom: 1.5rem; 232 + border-radius: 0.5rem; 233 + font-size: 0.875rem; 234 + display: none; 235 + } 236 + 237 + .message.show { 238 + display: block; 239 + } 240 + 241 + .message.error { 242 + background: rgba(160, 70, 104, 0.2); 243 + border: 2px solid var(--rosewood); 244 + color: var(--lavender); 245 + } 246 + 247 + .message.success { 248 + background: rgba(188, 141, 160, 0.2); 249 + border: 2px solid var(--old-rose); 250 + color: var(--lavender); 251 + } 252 + 253 + footer { 254 + width: 100%; 255 + max-width: 56.25rem; 256 + padding: 1rem; 257 + text-align: center; 258 + color: var(--old-rose); 259 + font-size: 0.875rem; 260 + margin-top: 2rem; 261 + } 262 + 263 + footer a { 264 + color: var(--berry-crush); 265 + text-decoration: none; 266 + transition: color 0.2s; 267 + } 268 + 269 + footer a:hover { 270 + color: var(--rosewood); 271 + text-decoration: underline; 272 + } 273 + </style> 274 + </head> 275 + 276 + <body> 277 + <header> 278 + <img src="../../public/logo.svg" alt="indiko" style="height: 2rem; margin-bottom: 0.5rem;" /> 279 + </header> 280 + 281 + <main> 282 + <div class="profile-container"> 283 + <div id="message" class="message"></div> 284 + 285 + <div class="profile-section"> 286 + <h2 class="section-title">profile settings</h2> 287 + 288 + <form id="profileForm"> 289 + <div class="avatar-upload"> 290 + <div class="avatar-preview" id="avatarPreview"> 291 + <!-- Avatar will be rendered here --> 292 + </div> 293 + <div class="avatar-controls"> 294 + <label for="photo">avatar url</label> 295 + <input type="url" id="photo" name="photo" placeholder="https://example.com/avatar.jpg" /> 296 + <small style="color: var(--old-rose); font-size: 0.75rem;">Enter a URL to an image</small> 297 + </div> 298 + </div> 299 + 300 + <label for="username">username</label> 301 + <input type="text" id="username" name="username" required disabled /> 302 + 303 + <label for="name">display name</label> 304 + <input type="text" id="name" name="name" required /> 305 + 306 + <label for="email">email</label> 307 + <input type="email" id="email" name="email" /> 308 + 309 + <label for="url">website</label> 310 + <input type="url" id="url" name="url" placeholder="https://example.com" /> 311 + 312 + <div class="button-group"> 313 + <button type="submit" id="saveBtn">save changes</button> 314 + <button type="button" class="button-secondary" onclick="window.location.href='/'">cancel</button> 315 + </div> 316 + </form> 317 + </div> 318 + </div> 319 + </main> 320 + 321 + <footer> 322 + <a href="/">← back to dashboard</a> 323 + </footer> 324 + 325 + <script type="module" src="../client/profile.ts"></script> 326 + </body> 327 + 328 + </html>
+9 -1
src/index.ts
··· 2 2 import { db } from "./db"; 3 3 import indexHTML from "./html/index.html"; 4 4 import loginHTML from "./html/login.html"; 5 + import profileHTML from "./html/profile.html"; 5 6 import { canRegister, registerOptions, registerVerify, loginOptions, loginVerify } from "./routes/auth"; 6 - import { hello } from "./routes/api"; 7 + import { hello, listUsers, getProfile, updateProfile } from "./routes/api"; 7 8 8 9 (() => { 9 10 const required = ["ORIGIN", "RP_ID"]; ··· 23 24 routes: { 24 25 "/": indexHTML, 25 26 "/login": loginHTML, 27 + "/profile": profileHTML, 26 28 // API endpoints 27 29 "/api/hello": hello, 30 + "/api/users": listUsers, 31 + "/api/profile": (req: Request) => { 32 + if (req.method === "GET") return getProfile(req); 33 + if (req.method === "PUT") return updateProfile(req); 34 + return new Response("Method not allowed", { status: 405 }); 35 + }, 28 36 "/auth/can-register": canRegister, 29 37 "/auth/register/options": registerOptions, 30 38 "/auth/register/verify": registerVerify,
+6
src/migrations/002_add_user_status_role.sql
··· 1 + -- Add status and role columns to users table 2 + ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'suspended', 'inactive')); 3 + ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user'; 4 + 5 + -- Update existing admin users to have 'admin' role 6 + UPDATE users SET role = 'admin' WHERE is_admin = 1;
+135 -4
src/routes/api.ts
··· 1 1 import { db } from "../db"; 2 2 3 - export function hello(req: Request): Response { 3 + function getSessionUser(req: Request): { username: string; is_admin: boolean } | Response { 4 4 const authHeader = req.headers.get("Authorization"); 5 5 6 6 if (!authHeader || !authHeader.startsWith("Bearer ")) { ··· 30 30 return Response.json({ error: "Session expired" }, { status: 401 }); 31 31 } 32 32 33 + return { 34 + username: session.username, 35 + is_admin: session.is_admin === 1, 36 + }; 37 + } 38 + 39 + export function hello(req: Request): Response { 40 + const user = getSessionUser(req); 41 + if (user instanceof Response) { 42 + return user; 43 + } 44 + 33 45 return Response.json({ 34 - message: `Hello ${session.username}! You're authenticated with passkeys.`, 35 - username: session.username, 36 - isAdmin: session.is_admin === 1, 46 + message: `Hello ${user.username}! You're authenticated with passkeys.`, 47 + username: user.username, 48 + isAdmin: user.is_admin, 49 + }); 50 + } 51 + 52 + export function listUsers(req: Request): Response { 53 + const user = getSessionUser(req); 54 + if (user instanceof Response) { 55 + return user; 56 + } 57 + 58 + if (!user.is_admin) { 59 + return Response.json({ error: "Admin access required" }, { status: 403 }); 60 + } 61 + 62 + const users = db 63 + .query( 64 + `SELECT u.id, u.username, u.name, u.email, u.photo, u.status, u.role, u.is_admin, u.created_at, 65 + COUNT(c.id) as credential_count 66 + FROM users u 67 + LEFT JOIN credentials c ON u.id = c.user_id 68 + GROUP BY u.id 69 + ORDER BY u.created_at DESC`, 70 + ) 71 + .all() as Array<{ 72 + id: number; 73 + username: string; 74 + name: string; 75 + email: string | null; 76 + photo: string | null; 77 + status: string; 78 + role: string; 79 + is_admin: number; 80 + created_at: number; 81 + credential_count: number; 82 + }>; 83 + 84 + return Response.json({ 85 + users: users.map((u) => ({ 86 + id: u.id, 87 + username: u.username, 88 + name: u.name, 89 + email: u.email, 90 + photo: u.photo, 91 + status: u.status, 92 + role: u.role, 93 + isAdmin: u.is_admin === 1, 94 + createdAt: u.created_at, 95 + credentialCount: u.credential_count, 96 + })), 97 + }); 98 + } 99 + 100 + export async function getProfile(req: Request): Promise<Response> { 101 + const user = getSessionUser(req); 102 + if (user instanceof Response) { 103 + return user; 104 + } 105 + 106 + const profile = db 107 + .query( 108 + `SELECT id, username, name, email, photo, url, status, role, is_admin, created_at 109 + FROM users 110 + WHERE username = ?`, 111 + ) 112 + .get(user.username) as 113 + | { 114 + id: number; 115 + username: string; 116 + name: string; 117 + email: string | null; 118 + photo: string | null; 119 + url: string | null; 120 + status: string; 121 + role: string; 122 + is_admin: number; 123 + created_at: number; 124 + } 125 + | undefined; 126 + 127 + if (!profile) { 128 + return Response.json({ error: "Profile not found" }, { status: 404 }); 129 + } 130 + 131 + return Response.json({ 132 + id: profile.id, 133 + username: profile.username, 134 + name: profile.name, 135 + email: profile.email, 136 + photo: profile.photo, 137 + url: profile.url, 138 + status: profile.status, 139 + role: profile.role, 140 + isAdmin: profile.is_admin === 1, 141 + createdAt: profile.created_at, 37 142 }); 38 143 } 144 + 145 + export async function updateProfile(req: Request): Promise<Response> { 146 + const user = getSessionUser(req); 147 + if (user instanceof Response) { 148 + return user; 149 + } 150 + 151 + try { 152 + const body = await req.json(); 153 + const { name, email, photo, url } = body; 154 + 155 + if (!name || typeof name !== "string") { 156 + return Response.json({ error: "Name is required" }, { status: 400 }); 157 + } 158 + 159 + // Update profile 160 + db.query( 161 + "UPDATE users SET name = ?, email = ?, photo = ?, url = ? WHERE username = ?", 162 + ).run(name, email || null, photo || null, url || null, user.username); 163 + 164 + return Response.json({ success: true }); 165 + } catch (error) { 166 + console.error("Update profile error:", error); 167 + return Response.json({ error: "Failed to update profile" }, { status: 500 }); 168 + } 169 + }
+1 -1
src/routes/auth.ts
··· 170 170 171 171 // Create user (bootstrap is always admin) 172 172 const insertUser = db.query( 173 - "INSERT INTO users (username, name, is_admin) VALUES (?, ?, 1) RETURNING id", 173 + "INSERT INTO users (username, name, is_admin, role) VALUES (?, ?, 1, 'admin') RETURNING id", 174 174 ); 175 175 const user = insertUser.get(username, username) as { 176 176 id: number;