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 double click confirm and consolidate styles

dunkirk.sh 0a446485 ad0b12e4

verified
+830 -1277
+42 -19
src/client/admin-clients.ts
··· 158 158 <div class="client-actions" style="display: flex; gap: 0.5rem; align-items: center;"> 159 159 ${client.isPreregistered ? ` 160 160 <button class="btn-edit" onclick="event.stopPropagation(); editClient('${client.clientId}')">edit</button> 161 - <button class="btn-delete" onclick="event.stopPropagation(); deleteClient('${client.clientId}')">delete</button> 161 + <button class="btn-delete" onclick="event.stopPropagation(); deleteClient('${client.clientId}', event)">delete</button> 162 162 ` : ''} 163 163 <span class="expand-indicator">details <span class="arrow">▼</span></span> 164 164 </div> ··· 332 332 } 333 333 }; 334 334 335 - (window as any).deleteClient = async function(clientId: string) { 336 - if (!confirm('Are you sure you want to delete this client? This will revoke access for all users and cannot be undone.')) { 337 - return; 338 - } 335 + (window as any).deleteClient = async function(clientId: string, event?: Event) { 336 + const btn = event?.target as HTMLButtonElement | undefined; 337 + 338 + // Double-click confirmation pattern 339 + if (btn?.dataset.confirmState === 'pending') { 340 + // Second click - execute delete 341 + delete btn.dataset.confirmState; 342 + btn.disabled = true; 343 + btn.textContent = 'deleting...'; 344 + 345 + try { 346 + const response = await fetch(`/api/admin/clients/${encodeURIComponent(clientId)}`, { 347 + method: 'DELETE', 348 + headers: { 349 + 'Authorization': `Bearer ${token}`, 350 + }, 351 + }); 339 352 340 - try { 341 - const response = await fetch(`/api/admin/clients/${encodeURIComponent(clientId)}`, { 342 - method: 'DELETE', 343 - headers: { 344 - 'Authorization': `Bearer ${token}`, 345 - }, 346 - }); 353 + if (!response.ok) { 354 + throw new Error('Failed to delete client'); 355 + } 347 356 348 - if (!response.ok) { 349 - throw new Error('Failed to delete client'); 357 + await loadClients(); 358 + } catch (error) { 359 + console.error('Failed to delete client:', error); 360 + showToast('Failed to delete client. Please try again.', 'error'); 361 + btn.disabled = false; 362 + btn.textContent = 'delete'; 363 + } 364 + } else { 365 + // First click - set pending state 366 + if (btn) { 367 + const originalText = btn.textContent; 368 + btn.dataset.confirmState = 'pending'; 369 + btn.textContent = 'you sure?'; 370 + 371 + // Reset after 3 seconds if not confirmed 372 + setTimeout(() => { 373 + if (btn.dataset.confirmState === 'pending') { 374 + delete btn.dataset.confirmState; 375 + btn.textContent = originalText; 376 + } 377 + }, 3000); 350 378 } 351 - 352 - await loadClients(); 353 - } catch (error) { 354 - console.error('Failed to delete client:', error); 355 - showToast('Failed to delete client. Please try again.', 'error'); 356 379 } 357 380 }; 358 381
+45 -22
src/client/admin-invites.ts
··· 313 313 <div class="invite-url">${invite.inviteUrl}</div> 314 314 </div> 315 315 <div class="invite-actions-btns"> 316 - <button class="copy-btn" data-invite-id="${invite.id}" data-invite-url="${invite.inviteUrl}" ${isActive ? '' : 'disabled'}>copy link</button> 317 - <button class="edit-btn" onclick="editInvite(${invite.id})" ${isActive ? '' : 'disabled'}>edit</button> 318 - <button class="delete-btn" onclick="deleteInvite(${invite.id})">delete</button> 316 + <button class="btn-copy" data-invite-id="${invite.id}" data-invite-url="${invite.inviteUrl}" ${isActive ? '' : 'disabled'}>copy link</button> 317 + <button class="btn-edit" onclick="editInvite(${invite.id})" ${isActive ? '' : 'disabled'}>edit</button> 318 + <button class="btn-delete" onclick="deleteInvite(${invite.id}, event)">delete</button> 319 319 </div> 320 320 </div> 321 321 `; 322 322 }).join(''); 323 323 324 324 // Add copy button handlers 325 - const copyButtons = invitesList.querySelectorAll('.copy-btn'); 325 + const copyButtons = invitesList.querySelectorAll('.btn-copy'); 326 326 copyButtons.forEach((btn) => { 327 327 btn.addEventListener('click', async (e) => { 328 328 const button = e.target as HTMLButtonElement; ··· 476 476 } 477 477 }; 478 478 479 - (window as any).deleteInvite = async (inviteId: number) => { 480 - if (!confirm('Are you sure you want to delete this invite? This action cannot be undone.')) { 481 - return; 482 - } 479 + (window as any).deleteInvite = async (inviteId: number, event?: Event) => { 480 + const btn = event?.target as HTMLButtonElement | undefined; 481 + 482 + // Double-click confirmation pattern 483 + if (btn?.dataset.confirmState === 'pending') { 484 + // Second click - execute delete 485 + delete btn.dataset.confirmState; 486 + btn.textContent = 'deleting...'; 487 + btn.disabled = true; 488 + 489 + try { 490 + const response = await fetch(`/api/invites/${inviteId}`, { 491 + method: 'DELETE', 492 + headers: { 493 + 'Authorization': `Bearer ${token}`, 494 + }, 495 + }); 483 496 484 - try { 485 - const response = await fetch(`/api/invites/${inviteId}`, { 486 - method: 'DELETE', 487 - headers: { 488 - 'Authorization': `Bearer ${token}`, 489 - }, 490 - }); 497 + if (!response.ok) { 498 + throw new Error('Failed to delete invite'); 499 + } 491 500 492 - if (!response.ok) { 493 - throw new Error('Failed to delete invite'); 501 + await loadInvites(); 502 + } catch (error) { 503 + console.error('Failed to delete invite:', error); 504 + alert('Failed to delete invite'); 505 + btn.textContent = 'delete'; 506 + btn.disabled = false; 507 + } 508 + } else { 509 + // First click - set pending state 510 + if (btn) { 511 + const originalText = btn.textContent; 512 + btn.dataset.confirmState = 'pending'; 513 + btn.textContent = 'you sure?'; 514 + 515 + // Reset after 3 seconds if not confirmed 516 + setTimeout(() => { 517 + if (btn.dataset.confirmState === 'pending') { 518 + delete btn.dataset.confirmState; 519 + btn.textContent = originalText; 520 + } 521 + }, 3000); 494 522 } 495 - 496 - await loadInvites(); 497 - } catch (error) { 498 - console.error('Failed to delete invite:', error); 499 - alert('Failed to delete invite'); 500 523 } 501 524 };
+55 -30
src/client/admin.ts
··· 97 97 : initials; 98 98 99 99 return ` 100 - <div class="user-card" data-user-id="${user.id}"> 100 + <div class="user-card ${user.status === 'suspended' ? 'user-suspended' : ''}" data-user-id="${user.id}"> 101 101 <div class="user-avatar">${avatarContent}</div> 102 102 <div class="user-info"> 103 103 <div class="user-name">${user.username}</div> ··· 112 112 <span class="user-badge badge-role">${user.role}</span> 113 113 </div> 114 114 <div class="user-actions"> 115 - ${user.status !== 'suspended' ? `<button class="btn btn-disable" data-action="disable" data-user-id="${user.id}">disable</button>` : ''} 116 - <button class="btn btn-delete" data-action="delete" data-user-id="${user.id}">delete</button> 115 + ${user.status === 'suspended' 116 + ? `<button class="btn-edit" data-action="enable" data-user-id="${user.id}">enable</button>` 117 + : `<button class="btn-disable" data-action="disable" data-user-id="${user.id}">disable</button>` 118 + } 119 + <button class="btn-delete" data-action="delete" data-user-id="${user.id}">delete</button> 117 120 </div> 118 121 </div> 119 122 `; 120 123 }).join(''); 121 124 122 125 // Add event listeners for action buttons 123 - document.querySelectorAll('.btn[data-action]').forEach(btn => { 126 + document.querySelectorAll('button[data-action]').forEach(btn => { 124 127 btn.addEventListener('click', handleUserAction); 125 128 }); 126 129 } catch (error) { ··· 136 139 137 140 if (!userId || !action) return; 138 141 139 - const confirmMessage = action === 'delete' 140 - ? 'Are you sure you want to delete this user? This cannot be undone.' 141 - : 'Are you sure you want to disable this user? They will be logged out and unable to sign in.'; 142 + // Check if already in confirmation state 143 + if (btn.dataset.confirmState === 'pending') { 144 + // Second click - perform action 145 + btn.dataset.confirmState = ''; 146 + btn.disabled = true; 147 + 148 + try { 149 + let endpoint = ''; 150 + let method = 'POST'; 151 + 152 + if (action === 'delete') { 153 + endpoint = `/api/admin/users/${userId}/delete`; 154 + method = 'DELETE'; 155 + } else if (action === 'disable') { 156 + endpoint = `/api/admin/users/${userId}/disable`; 157 + } else if (action === 'enable') { 158 + endpoint = `/api/admin/users/${userId}/enable`; 159 + } 142 160 143 - if (!confirm(confirmMessage)) return; 161 + const response = await fetch(endpoint, { 162 + method, 163 + headers: { 164 + 'Authorization': `Bearer ${token}`, 165 + }, 166 + }); 144 167 145 - try { 146 - const endpoint = action === 'delete' 147 - ? `/api/admin/users/${userId}/delete` 148 - : `/api/admin/users/${userId}/disable`; 149 - 150 - const method = action === 'delete' ? 'DELETE' : 'POST'; 168 + if (!response.ok) { 169 + const error = await response.json(); 170 + throw new Error(error.error || 'Failed to perform action'); 171 + } 151 172 152 - const response = await fetch(endpoint, { 153 - method, 154 - headers: { 155 - 'Authorization': `Bearer ${token}`, 156 - }, 157 - }); 158 - 159 - if (!response.ok) { 160 - const error = await response.json(); 161 - throw new Error(error.error || 'Failed to perform action'); 173 + // Reload users list 174 + loadUsers(); 175 + } catch (error) { 176 + console.error(`Failed to ${action} user:`, error); 177 + alert(`Failed to ${action} user: ${error instanceof Error ? error.message : 'Unknown error'}`); 178 + btn.disabled = false; 162 179 } 163 - 164 - // Reload users list 165 - loadUsers(); 166 - } catch (error) { 167 - console.error(`Failed to ${action} user:`, error); 168 - alert(`Failed to ${action} user: ${error instanceof Error ? error.message : 'Unknown error'}`); 180 + } else { 181 + // First click - set confirmation state 182 + const originalText = btn.textContent; 183 + btn.dataset.confirmState = 'pending'; 184 + btn.dataset.originalText = originalText || ''; 185 + btn.textContent = 'you sure?'; 186 + 187 + // Reset after 3 seconds if not clicked again 188 + setTimeout(() => { 189 + if (btn.dataset.confirmState === 'pending') { 190 + btn.dataset.confirmState = ''; 191 + btn.textContent = btn.dataset.originalText || originalText; 192 + } 193 + }, 3000); 169 194 } 170 195 } 171 196
+46 -31
src/client/apps.ts
··· 56 56 <div class="app-name">${app.name}</div> 57 57 <div class="app-meta">Granted ${grantedDate} • Last used ${lastUsedDate}</div> 58 58 </div> 59 - <button class="revoke-btn" onclick="revokeApp('${app.clientId}')">revoke</button> 59 + <button class="revoke-btn" onclick="revokeApp('${app.clientId}', event)">revoke</button> 60 60 </div> 61 61 <div class="scopes"> 62 62 <div class="scope-title">permissions</div> ··· 69 69 }).join(''); 70 70 } 71 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; 72 + (window as any).revokeApp = async function(clientId: string, event?: Event) { 73 + const btn = event?.target as HTMLButtonElement | undefined; 79 74 80 - if (btn) { 75 + // Double-click confirmation pattern 76 + if (btn?.dataset.confirmState === 'pending') { 77 + // Second click - execute revoke 78 + delete btn.dataset.confirmState; 81 79 btn.disabled = true; 82 80 btn.textContent = 'revoking...'; 83 - } 81 + 82 + const card = document.querySelector(`[data-client-id="${clientId}"]`); 84 83 85 - try { 86 - const response = await fetch(`/api/apps/${encodeURIComponent(clientId)}`, { 87 - method: 'DELETE', 88 - headers: { 89 - 'Authorization': `Bearer ${token}`, 90 - }, 91 - }); 84 + try { 85 + const response = await fetch(`/api/apps/${encodeURIComponent(clientId)}`, { 86 + method: 'DELETE', 87 + headers: { 88 + 'Authorization': `Bearer ${token}`, 89 + }, 90 + }); 92 91 93 - if (!response.ok) { 94 - throw new Error('Failed to revoke app'); 95 - } 92 + if (!response.ok) { 93 + throw new Error('Failed to revoke app'); 94 + } 96 95 97 - // Remove from UI 98 - card?.remove(); 96 + // Remove from UI 97 + card?.remove(); 99 98 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>'; 99 + // Check if list is now empty 100 + const remaining = document.querySelectorAll('.app-card'); 101 + if (remaining.length === 0) { 102 + appsList.innerHTML = '<div class="empty">No authorized apps yet. Apps will appear here after you grant them access.</div>'; 103 + } 104 + } catch (error) { 105 + console.error('Failed to revoke app:', error); 106 + alert('Failed to revoke app access. Please try again.'); 107 + if (btn) { 108 + btn.disabled = false; 109 + btn.textContent = 'revoke'; 110 + } 104 111 } 105 - } catch (error) { 106 - console.error('Failed to revoke app:', error); 107 - alert('Failed to revoke app access. Please try again.'); 112 + } else { 113 + // First click - set pending state 108 114 if (btn) { 109 - btn.disabled = false; 110 - btn.textContent = 'revoke'; 115 + const originalText = btn.textContent; 116 + btn.dataset.confirmState = 'pending'; 117 + btn.textContent = 'you sure?'; 118 + 119 + // Reset after 3 seconds if not confirmed 120 + setTimeout(() => { 121 + if (btn.dataset.confirmState === 'pending') { 122 + delete btn.dataset.confirmState; 123 + btn.textContent = originalText; 124 + } 125 + }, 3000); 111 126 } 112 127 } 113 128 };
+2 -361
src/html/admin-invites.html
··· 20 20 <link rel="preconnect" href="https://fonts.googleapis.com"> 21 21 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 22 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"> 23 24 <style> 24 - :root { 25 - --mahogany: #26242b; 26 - --lavender: #d9d0de; 27 - --old-rose: #bc8da0; 28 - --rosewood: #a04668; 29 - --berry-crush: #ab4967; 30 - } 31 - 32 - * { 33 - margin: 0; 34 - padding: 0; 35 - box-sizing: border-box; 36 - } 37 - 38 - body { 39 - font-family: "Space Grotesk", sans-serif; 40 - background: var(--mahogany); 41 - color: var(--lavender); 42 - min-height: 100vh; 43 - display: flex; 44 - flex-direction: column; 45 - align-items: center; 46 - padding: 2.5rem 1.25rem; 47 - } 48 - 25 + /* Admin Invites-specific styles */ 49 26 header { 50 - width: 100%; 51 - max-width: 56.25rem; 52 27 align-self: flex-start; 53 28 margin-left: auto; 54 29 margin-right: auto; 55 - margin-bottom: 2rem; 56 30 display: flex; 57 31 justify-content: space-between; 58 32 align-items: flex-start; 59 33 } 60 34 61 - .header-nav { 62 - display: flex; 63 - gap: 1rem; 64 - margin-top: 0.5rem; 65 - } 66 - 67 - .header-nav a { 68 - color: var(--old-rose); 69 - text-decoration: none; 70 - font-size: 0.875rem; 71 - font-weight: 500; 72 - padding: 0.5rem 1rem; 73 - border: 1px solid var(--old-rose); 74 - transition: all 0.2s; 75 - } 76 - 77 - .header-nav a:hover { 78 - background: rgba(188, 141, 160, 0.1); 79 - color: var(--berry-crush); 80 - border-color: var(--berry-crush); 81 - } 82 - 83 - .header-nav a.active { 84 - background: var(--berry-crush); 85 - color: var(--lavender); 86 - border-color: var(--berry-crush); 87 - } 88 - 89 - h1 { 90 - font-size: 2rem; 91 - font-weight: 700; 92 - background: linear-gradient(135deg, var(--old-rose), var(--berry-crush), var(--rosewood)); 93 - -webkit-background-clip: text; 94 - -webkit-text-fill-color: transparent; 95 - background-clip: text; 96 - letter-spacing: -0.125rem; 97 - } 98 - 99 35 main { 100 - flex: 1; 101 - width: 100%; 102 - max-width: 56.25rem; 103 36 padding: 2rem 1.25rem; 104 37 } 105 38 106 - footer { 107 - width: 100%; 108 - max-width: 56.25rem; 109 - padding: 1rem; 110 - text-align: center; 111 - color: var(--old-rose); 112 - font-size: 0.875rem; 113 - font-weight: 300; 114 - letter-spacing: 0.05rem; 115 - } 116 - 117 - footer a { 118 - color: var(--berry-crush); 119 - text-decoration: none; 120 - transition: color 0.2s; 121 - } 122 - 123 - footer a:hover { 124 - color: var(--rosewood); 125 - text-decoration: underline; 126 - } 127 - 128 - .back-link { 129 - margin-top: 0.5rem; 130 - font-size: 0.875rem; 131 - color: var(--old-rose); 132 - } 133 - 134 - .users-section { 135 - width: 100%; 136 - } 137 - 138 - .users-section h2 { 139 - font-size: 1.5rem; 140 - font-weight: 600; 141 - color: var(--lavender); 142 - margin-bottom: 1.5rem; 143 - letter-spacing: -0.05rem; 144 - } 145 - 146 - .users-list { 147 - display: flex; 148 - flex-direction: column; 149 - gap: 1rem; 150 - } 151 - 152 - .user-card { 153 - background: rgba(188, 141, 160, 0.05); 154 - border: 1px solid var(--old-rose); 155 - padding: 1.5rem; 156 - display: flex; 157 - gap: 1.5rem; 158 - align-items: center; 159 - transition: background 0.2s; 160 - } 161 - 162 - .user-card:hover { 163 - background: rgba(188, 141, 160, 0.1); 164 - } 165 - 166 - .user-avatar { 167 - width: 4rem; 168 - height: 4rem; 169 - border-radius: 50%; 170 - background: var(--berry-crush); 171 - display: flex; 172 - align-items: center; 173 - justify-content: center; 174 - font-size: 1.5rem; 175 - font-weight: 700; 176 - color: var(--lavender); 177 - flex-shrink: 0; 178 - text-transform: uppercase; 179 - } 180 - 181 - .user-avatar img { 182 - width: 100%; 183 - height: 100%; 184 - border-radius: 50%; 185 - object-fit: cover; 186 - } 187 - 188 - .user-info { 189 - display: flex; 190 - flex-direction: column; 191 - gap: 0.5rem; 192 - flex: 1; 193 - } 194 - 195 - .user-name { 196 - font-size: 1.125rem; 197 - font-weight: 600; 198 - color: var(--lavender); 199 - } 200 - 201 - .user-meta { 202 - font-size: 0.875rem; 203 - color: var(--old-rose); 204 - display: flex; 205 - flex-wrap: wrap; 206 - gap: 1rem; 207 - } 208 - 209 - .user-meta-item { 210 - display: flex; 211 - align-items: center; 212 - gap: 0.25rem; 213 - } 214 - 215 - .user-badges { 216 - display: flex; 217 - gap: 0.5rem; 218 - flex-wrap: wrap; 219 - } 220 - 221 - .user-badge { 222 - display: inline-block; 223 - padding: 0.25rem 0.75rem; 224 - font-size: 0.75rem; 225 - font-weight: 700; 226 - text-transform: uppercase; 227 - letter-spacing: 0.05rem; 228 - } 229 - 230 - .badge-admin { 231 - background: var(--berry-crush); 232 - color: var(--lavender); 233 - } 234 - 235 - .badge-role { 236 - background: rgba(188, 141, 160, 0.2); 237 - color: var(--lavender); 238 - border: 1px solid var(--old-rose); 239 - } 240 - 241 - .badge-status { 242 - border: 1px solid var(--old-rose); 243 - } 244 - 245 - .badge-status.active { 246 - background: rgba(139, 195, 74, 0.2); 247 - color: #a5d6a7; 248 - border-color: #81c784; 249 - } 250 - 251 - .badge-status.suspended { 252 - background: rgba(244, 67, 54, 0.2); 253 - color: #ef9a9a; 254 - border-color: #e57373; 255 - } 256 - 257 - .badge-status.inactive { 258 - background: rgba(158, 158, 158, 0.2); 259 - color: #bdbdbd; 260 - border-color: #9e9e9e; 261 - } 262 - 263 - .loading { 264 - text-align: center; 265 - padding: 2rem; 266 - color: var(--old-rose); 267 - font-size: 1rem; 268 - } 269 - 270 - .error { 271 - text-align: center; 272 - padding: 2rem; 273 - color: var(--rosewood); 274 - font-size: 1rem; 275 - } 276 - 277 39 .invites-section { 278 40 width: 100%; 279 - } 280 - 281 - .invites-section h2 { 282 - font-size: 1.5rem; 283 - color: var(--lavender); 284 - margin-bottom: 1rem; 285 41 } 286 42 287 43 .invite-actions { ··· 347 103 min-width: 8rem; 348 104 } 349 105 350 - .copy-btn, .delete-btn, .edit-btn { 351 - padding: 0.5rem 1rem; 352 - background: rgba(188, 141, 160, 0.2); 353 - color: var(--lavender); 354 - border: 1px solid var(--old-rose); 355 - cursor: pointer; 356 - font-family: inherit; 357 - font-size: 0.875rem; 358 - transition: background 0.2s; 359 - white-space: nowrap; 360 - } 361 - 362 - .copy-btn:hover { 363 - background: rgba(188, 141, 160, 0.3); 364 - } 365 - 366 - .edit-btn { 367 - background: rgba(171, 73, 103, 0.2); 368 - border-color: var(--berry-crush); 369 - } 370 - 371 - .edit-btn:hover { 372 - background: rgba(171, 73, 103, 0.3); 373 - } 374 - 375 - .delete-btn { 376 - background: rgba(160, 70, 104, 0.2); 377 - border-color: var(--rosewood); 378 - } 379 - 380 - .delete-btn:hover { 381 - background: rgba(160, 70, 104, 0.3); 382 - } 383 - 384 106 .invite-url { 385 107 background: rgba(12, 23, 19, 0.8); 386 108 border: 1px solid var(--rosewood); ··· 423 145 margin-top: 0.5rem; 424 146 } 425 147 426 - /* Modal styles */ 427 - .modal { 428 - display: none; 429 - position: fixed; 430 - top: 0; 431 - left: 0; 432 - width: 100%; 433 - height: 100%; 434 - background: rgba(0, 0, 0, 0.8); 435 - justify-content: center; 436 - align-items: center; 437 - z-index: 1000; 438 - } 439 - 440 - .modal-content { 441 - background: var(--mahogany); 442 - border: 2px solid var(--old-rose); 443 - padding: 2rem; 444 - max-width: 40rem; 445 - width: 90%; 446 - max-height: 90vh; 447 - overflow-y: auto; 448 - } 449 - 450 - .modal-header { 451 - display: flex; 452 - justify-content: space-between; 453 - align-items: center; 454 - margin-bottom: 1.5rem; 455 - } 456 - 457 - .modal-header h3 { 458 - font-size: 1.5rem; 459 - color: var(--lavender); 460 - margin: 0; 461 - } 462 - 463 - .modal-close { 464 - background: none; 465 - border: none; 466 - color: var(--old-rose); 467 - font-size: 1.5rem; 468 - cursor: pointer; 469 - padding: 0; 470 - line-height: 1; 471 - } 472 - 473 - .modal-close:hover { 474 - color: var(--berry-crush); 475 - } 476 - 477 - .form-group { 478 - margin-bottom: 1.5rem; 479 - } 480 - 481 148 .form-group label { 482 - display: block; 483 149 color: var(--lavender); 484 - margin-bottom: 0.5rem; 485 - font-size: 0.875rem; 486 150 } 487 151 488 152 .form-group input, 489 153 .form-group textarea { 490 - width: 100%; 491 154 background: rgba(0, 0, 0, 0.3); 492 155 border: 1px solid var(--old-rose); 493 - color: var(--lavender); 494 - padding: 0.75rem; 495 - font-family: inherit; 496 - font-size: 1rem; 497 - } 498 - 499 - .form-group textarea { 500 - resize: vertical; 501 - min-height: 4rem; 502 156 } 503 157 504 158 .form-group input:focus, 505 159 .form-group textarea:focus { 506 160 outline: none; 507 161 border-color: var(--berry-crush); 508 - } 509 - 510 - .form-help { 511 - font-size: 0.75rem; 512 - color: var(--old-rose); 513 - margin-top: 0.25rem; 514 162 } 515 163 516 164 .app-role-item { ··· 594 242 .app-role-item select.role-select:focus { 595 243 outline: none; 596 244 border-color: var(--berry-crush); 597 - } 598 - 599 - .modal-actions { 600 - display: flex; 601 - gap: 1rem; 602 - justify-content: flex-end; 603 - margin-top: 2rem; 604 245 } 605 246 606 247 .modal-btn {
+4 -213
src/html/admin.html
··· 20 20 <link rel="preconnect" href="https://fonts.googleapis.com"> 21 21 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 22 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"> 23 24 <style> 24 - :root { 25 - --mahogany: #26242b; 26 - --lavender: #d9d0de; 27 - --old-rose: #bc8da0; 28 - --rosewood: #a04668; 29 - --berry-crush: #ab4967; 30 - } 31 - 32 - * { 33 - margin: 0; 34 - padding: 0; 35 - box-sizing: border-box; 36 - } 37 - 38 - body { 39 - font-family: "Space Grotesk", sans-serif; 40 - background: var(--mahogany); 41 - color: var(--lavender); 42 - min-height: 100vh; 43 - display: flex; 44 - flex-direction: column; 45 - align-items: center; 46 - padding: 2.5rem 1.25rem; 47 - } 48 - 25 + /* Admin-specific styles */ 49 26 header { 50 - width: 100%; 51 - max-width: 56.25rem; 52 27 align-self: flex-start; 53 28 margin-left: auto; 54 29 margin-right: auto; 55 - margin-bottom: 2rem; 56 30 display: flex; 57 31 justify-content: space-between; 58 32 align-items: flex-start; 59 33 } 60 34 61 - .header-nav { 62 - display: flex; 63 - gap: 1rem; 64 - margin-top: 0.5rem; 65 - } 66 - 67 - .header-nav a { 68 - color: var(--old-rose); 69 - text-decoration: none; 70 - font-size: 0.875rem; 71 - font-weight: 500; 72 - padding: 0.5rem 1rem; 73 - border: 1px solid var(--old-rose); 74 - transition: all 0.2s; 75 - } 76 - 77 - .header-nav a:hover { 78 - background: rgba(188, 141, 160, 0.1); 79 - color: var(--berry-crush); 80 - border-color: var(--berry-crush); 81 - } 82 - 83 - .header-nav a.active { 84 - background: var(--berry-crush); 85 - color: var(--lavender); 86 - border-color: var(--berry-crush); 87 - } 88 - 89 - h1 { 90 - font-size: 2rem; 91 - font-weight: 700; 92 - background: linear-gradient(135deg, var(--old-rose), var(--berry-crush), var(--rosewood)); 93 - -webkit-background-clip: text; 94 - -webkit-text-fill-color: transparent; 95 - background-clip: text; 96 - letter-spacing: -0.125rem; 97 - } 98 - 99 35 main { 100 - flex: 1; 101 - width: 100%; 102 - max-width: 56.25rem; 103 36 padding: 2rem 1.25rem; 104 37 } 105 38 106 - footer { 107 - width: 100%; 108 - max-width: 56.25rem; 109 - padding: 1rem; 110 - text-align: center; 111 - color: var(--old-rose); 112 - font-size: 0.875rem; 113 - font-weight: 300; 114 - letter-spacing: 0.05rem; 115 - } 116 - 117 - footer a { 118 - color: var(--berry-crush); 119 - text-decoration: none; 120 - transition: color 0.2s; 121 - } 122 - 123 - footer a:hover { 124 - color: var(--rosewood); 125 - text-decoration: underline; 126 - } 127 - 128 - .back-link { 129 - margin-top: 0.5rem; 130 - font-size: 0.875rem; 131 - color: var(--old-rose); 132 - } 133 - 134 39 .users-section { 135 40 width: 100%; 136 - } 137 - 138 - .users-section h2 { 139 - font-size: 1.5rem; 140 - font-weight: 600; 141 - color: var(--lavender); 142 - margin-bottom: 1.5rem; 143 - letter-spacing: -0.05rem; 144 41 } 145 42 146 43 .users-list { ··· 163 60 background: rgba(188, 141, 160, 0.1); 164 61 } 165 62 166 - .user-avatar { 167 - width: 4rem; 168 - height: 4rem; 169 - border-radius: 50%; 170 - background: var(--berry-crush); 171 - display: flex; 172 - align-items: center; 173 - justify-content: center; 174 - font-size: 1.5rem; 175 - font-weight: 700; 176 - color: var(--lavender); 177 - flex-shrink: 0; 178 - text-transform: uppercase; 179 - } 180 - 181 - .user-avatar img { 182 - width: 100%; 183 - height: 100%; 184 - border-radius: 50%; 185 - object-fit: cover; 186 - } 187 - 188 63 .user-info { 189 64 display: flex; 190 65 flex-direction: column; ··· 218 93 flex-wrap: wrap; 219 94 } 220 95 221 - .user-badge { 222 - display: inline-block; 223 - padding: 0.25rem 0.75rem; 224 - font-size: 0.75rem; 225 - font-weight: 700; 226 - text-transform: uppercase; 227 - letter-spacing: 0.05rem; 228 - } 229 - 230 - .badge-admin { 231 - background: var(--berry-crush); 232 - color: var(--lavender); 233 - } 234 - 235 - .badge-role { 236 - background: rgba(188, 141, 160, 0.2); 237 - color: var(--lavender); 238 - border: 1px solid var(--old-rose); 239 - } 240 - 241 - .badge-status { 242 - border: 1px solid var(--old-rose); 243 - } 244 - 245 - .badge-status.active { 246 - background: rgba(139, 195, 74, 0.2); 247 - color: #a5d6a7; 248 - border-color: #81c784; 249 - } 250 - 251 - .badge-status.suspended { 252 - background: rgba(244, 67, 54, 0.2); 253 - color: #ef9a9a; 254 - border-color: #e57373; 255 - } 256 - 257 - .badge-status.inactive { 258 - background: rgba(158, 158, 158, 0.2); 259 - color: #bdbdbd; 260 - border-color: #9e9e9e; 261 - } 262 - 263 96 .user-actions { 264 97 display: flex; 265 98 gap: 0.5rem; 266 99 flex-wrap: wrap; 267 100 } 268 101 269 - .btn { 270 - padding: 0.5rem 1rem; 271 - font-size: 0.75rem; 272 - font-weight: 700; 273 - text-transform: uppercase; 274 - letter-spacing: 0.05rem; 275 - border: 1px solid; 276 - background: transparent; 277 - cursor: pointer; 278 - transition: all 0.2s; 279 - font-family: "Space Grotesk", sans-serif; 280 - } 281 - 282 - .btn-disable { 283 - color: var(--lavender); 284 - border-color: #e57373; 285 - } 286 - 287 - .btn-disable:hover { 288 - background: rgba(244, 67, 54, 0.2); 289 - } 290 - 291 - .btn-delete { 292 - color: var(--lavender); 293 - border-color: var(--rosewood); 294 - } 295 - 296 - .btn-delete:hover { 297 - background: rgba(160, 70, 104, 0.2); 102 + .user-suspended { 103 + opacity: 0.5; 298 104 } 299 - 300 - .loading { 301 - text-align: center; 302 - padding: 2rem; 303 - color: var(--old-rose); 304 - font-size: 1rem; 305 - } 306 - 307 - .error { 308 - text-align: center; 309 - padding: 2rem; 310 - color: var(--rosewood); 311 - font-size: 1rem; 312 - } 313 - 314 105 </style> 315 106 </head> 316 107
+8 -75
src/html/apps.html
··· 20 20 <link rel="preconnect" href="https://fonts.googleapis.com"> 21 21 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 22 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"> 23 24 <style> 24 - :root { 25 - --mahogany: #26242b; 26 - --lavender: #d9d0de; 27 - --old-rose: #bc8da0; 28 - --rosewood: #a04668; 29 - --berry-crush: #ab4967; 30 - } 31 - 32 - * { 33 - margin: 0; 34 - padding: 0; 35 - box-sizing: border-box; 36 - } 37 - 38 - body { 39 - font-family: "Space Grotesk", sans-serif; 40 - background: var(--mahogany); 41 - color: var(--lavender); 42 - min-height: 100vh; 43 - display: flex; 44 - flex-direction: column; 45 - align-items: center; 46 - padding: 2.5rem 1.25rem; 47 - } 48 - 49 - header { 50 - width: 100%; 51 - max-width: 56.25rem; 52 - margin-bottom: 2rem; 53 - } 54 - 25 + /* Apps-specific styles */ 55 26 h1 { 56 - font-size: 2rem; 57 - font-weight: 700; 58 - background: linear-gradient(135deg, var(--old-rose), var(--berry-crush), var(--rosewood)); 59 - -webkit-background-clip: text; 60 - -webkit-text-fill-color: transparent; 61 - background-clip: text; 62 - letter-spacing: -0.125rem; 63 27 margin-bottom: 0.5rem; 64 28 } 65 29 66 - .subtitle { 67 - color: var(--old-rose); 68 - font-size: 1rem; 69 - font-weight: 300; 70 - } 71 - 72 - main { 73 - flex: 1; 74 - width: 100%; 75 - max-width: 56.25rem; 76 - } 77 - 78 30 .apps-list { 79 31 display: flex; 80 32 flex-direction: column; ··· 147 99 letter-spacing: 0.05rem; 148 100 cursor: pointer; 149 101 transition: all 0.2s; 102 + box-shadow: none; 103 + } 104 + 105 + .revoke-btn::before { 106 + display: none; 150 107 } 151 108 152 109 .revoke-btn:hover:not(:disabled) { 153 110 background: var(--rosewood); 154 111 color: var(--lavender); 112 + transform: none; 155 113 } 156 114 157 115 .revoke-btn:disabled { 158 116 opacity: 0.5; 159 117 cursor: not-allowed; 160 - } 161 - 162 - .loading, .error, .empty { 163 - text-align: center; 164 - padding: 2rem; 165 - color: var(--old-rose); 166 - } 167 - 168 - .error { 169 - color: var(--rosewood); 170 - } 171 - 172 - .back-link { 173 - text-align: center; 174 - margin-top: 2rem; 175 - font-size: 0.875rem; 176 - } 177 - 178 - .back-link a { 179 - color: var(--berry-crush); 180 - text-decoration: none; 181 - } 182 - 183 - .back-link a:hover { 184 - text-decoration: underline; 185 118 } 186 119 </style> 187 120 </head>
+8 -153
src/html/index.html
··· 20 20 <link rel="preconnect" href="https://fonts.googleapis.com"> 21 21 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 22 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"> 23 24 <style> 24 - :root { 25 - --mahogany: #26242b; 26 - --lavender: #d9d0de; 27 - --old-rose: #bc8da0; 28 - --rosewood: #a04668; 29 - --berry-crush: #ab4967; 30 - } 31 - 32 - * { 33 - margin: 0; 34 - padding: 0; 35 - box-sizing: border-box; 36 - } 37 - 38 - body { 39 - font-family: "Space Grotesk", sans-serif; 40 - background: var(--mahogany); 41 - color: var(--lavender); 42 - min-height: 100vh; 43 - display: flex; 44 - flex-direction: column; 45 - align-items: center; 46 - padding: 2.5rem 1.25rem; 47 - } 48 - 49 - header { 50 - width: 100%; 51 - max-width: 56.25rem; 52 - margin-bottom: 2rem; 53 - } 54 - 25 + /* Dashboard-specific styles */ 55 26 .profile-preview { 56 27 display: flex; 57 28 align-items: center; ··· 65 36 .profile-avatar { 66 37 width: 80px; 67 38 height: 80px; 68 - border-radius: 50%; 69 - background: var(--berry-crush); 70 - display: flex; 71 - align-items: center; 72 - justify-content: center; 73 - font-size: 2rem; 74 - font-weight: 700; 75 - color: var(--lavender); 76 - text-transform: uppercase; 77 - flex-shrink: 0; 78 39 border: 3px solid var(--berry-crush); 79 - } 80 - 81 - .profile-avatar img { 82 - width: 100%; 83 - height: 100%; 84 - border-radius: 50%; 85 - object-fit: cover; 86 40 } 87 41 88 42 .profile-info { ··· 118 72 } 119 73 120 74 h1 { 121 - font-size: 2rem; 122 - font-weight: 700; 123 - background: linear-gradient(135deg, var(--old-rose), var(--berry-crush), var(--rosewood)); 124 - -webkit-background-clip: text; 125 - -webkit-text-fill-color: transparent; 126 - background-clip: text; 127 - letter-spacing: -0.125rem; 128 75 margin-bottom: 0.5rem; 129 76 } 130 77 131 - .subtitle { 132 - color: var(--old-rose); 133 - font-size: 1rem; 134 - font-weight: 300; 135 - } 136 - 137 - main { 138 - flex: 1; 139 - width: 100%; 140 - max-width: 56.25rem; 141 - } 142 - 143 78 .dashboard-grid { 144 79 display: grid; 145 80 grid-template-columns: 1fr; ··· 153 88 } 154 89 } 155 90 156 - .card { 157 - background: rgba(188, 141, 160, 0.05); 158 - border: 1px solid var(--old-rose); 159 - padding: 1.5rem; 160 - } 161 - 162 - .card-title { 163 - font-size: 1.125rem; 164 - font-weight: 600; 165 - color: var(--lavender); 166 - margin-bottom: 1rem; 167 - } 168 - 169 - .form-group { 170 - margin-bottom: 1rem; 171 - } 172 - 173 - label { 174 - display: block; 175 - color: var(--old-rose); 176 - font-size: 0.875rem; 177 - font-weight: 500; 178 - margin-bottom: 0.5rem; 179 - text-transform: uppercase; 180 - letter-spacing: 0.05rem; 181 - } 182 - 183 91 input[type="text"], 184 92 input[type="email"], 185 93 input[type="url"] { 186 - width: 100%; 187 94 padding: 0.75rem; 188 - background: rgba(12, 23, 19, 0.6); 189 - border: 2px solid var(--rosewood); 190 - color: var(--lavender); 191 - font-size: 1rem; 192 - font-family: "Space Grotesk", sans-serif; 193 - transition: border-color 0.2s; 194 - } 195 - 196 - input:focus { 197 - outline: none; 198 - border-color: var(--berry-crush); 199 - background: rgba(12, 23, 19, 0.8); 200 95 } 201 96 202 97 .save-btn { ··· 212 107 letter-spacing: 0.05rem; 213 108 cursor: pointer; 214 109 transition: all 0.2s; 110 + box-shadow: none; 111 + } 112 + 113 + .save-btn::before { 114 + display: none; 215 115 } 216 116 217 117 .save-btn:hover:not(:disabled) { 218 118 background: var(--rosewood); 119 + transform: none; 219 120 } 220 121 221 122 .save-btn:disabled { ··· 227 128 padding: 0.75rem; 228 129 margin-top: 1rem; 229 130 font-size: 0.875rem; 230 - display: none; 231 - } 232 - 233 - .message.show { 234 - display: block; 235 - } 236 - 237 - .message.success { 238 - background: rgba(139, 195, 74, 0.2); 239 - border: 1px solid #81c784; 240 - color: #a5d6a7; 241 - } 242 - 243 - .message.error { 244 - background: rgba(244, 67, 54, 0.2); 245 - border: 1px solid #e57373; 246 - color: #ef9a9a; 247 131 } 248 132 249 133 .apps-preview { ··· 271 155 color: var(--old-rose); 272 156 } 273 157 274 - .loading, .empty { 275 - text-align: center; 276 - padding: 1rem; 277 - color: var(--old-rose); 278 - font-size: 0.875rem; 279 - } 280 - 281 158 .view-all { 282 159 display: block; 283 160 text-align: center; ··· 288 165 } 289 166 290 167 .view-all:hover { 291 - text-decoration: underline; 292 - } 293 - 294 - footer { 295 - width: 100%; 296 - max-width: 56.25rem; 297 - padding: 1rem; 298 - text-align: center; 299 - color: var(--old-rose); 300 - font-size: 0.875rem; 301 - font-weight: 300; 302 - letter-spacing: 0.05rem; 303 - } 304 - 305 - footer a { 306 - color: var(--berry-crush); 307 - text-decoration: none; 308 - transition: color 0.2s; 309 - } 310 - 311 - footer a:hover { 312 - color: var(--rosewood); 313 168 text-decoration: underline; 314 169 } 315 170 </style>
+2 -171
src/html/login.html
··· 20 20 <link rel="preconnect" href="https://fonts.googleapis.com"> 21 21 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 22 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"> 23 24 <style> 24 - :root { 25 - --mahogany: #26242b; 26 - --lavender: #d9d0de; 27 - --old-rose: #bc8da0; 28 - --rosewood: #a04668; 29 - --berry-crush: #ab4967; 30 - } 31 - 32 - * { 33 - margin: 0; 34 - padding: 0; 35 - box-sizing: border-box; 36 - } 37 - 25 + /* Login-specific styles */ 38 26 body { 39 - font-family: "Space Grotesk", sans-serif; 40 - background: var(--mahogany); 41 - color: var(--lavender); 42 - min-height: 100vh; 43 - padding: 2.5rem 1.25rem; 44 - display: flex; 45 - flex-direction: column; 46 - align-items: center; 47 27 justify-content: center; 48 28 } 49 29 ··· 56 36 h1 { 57 37 font-size: 3rem; 58 38 margin-bottom: 0.5rem; 59 - font-weight: 700; 60 - background: linear-gradient(135deg, var(--old-rose), var(--berry-crush), var(--rosewood)); 61 - -webkit-background-clip: text; 62 - -webkit-text-fill-color: transparent; 63 - background-clip: text; 64 - letter-spacing: -0.125rem; 65 39 } 66 40 67 41 .subtitle { 68 - color: var(--old-rose); 69 42 margin-bottom: 2rem; 70 - font-size: 1rem; 71 - font-weight: 300; 72 - letter-spacing: 0.05rem; 73 43 } 74 44 75 45 .auth-box { 76 46 background: rgba(188, 141, 160, 0.05); 77 47 border: 1px solid var(--old-rose); 78 - border-radius: 0; 79 48 padding: 2rem; 80 49 margin-bottom: 1rem; 81 50 } 82 51 83 52 input[type="text"] { 84 - width: 100%; 85 - padding: 0.875rem 1rem; 86 - background: rgba(12, 23, 19, 0.6); 87 - border: 2px solid var(--rosewood); 88 - border-radius: 0; 89 - color: var(--lavender); 90 - font-size: 1rem; 91 - font-family: "Space Grotesk", sans-serif; 92 53 margin-bottom: 1rem; 93 - transition: border-color 0.2s; 94 - letter-spacing: 0.025rem; 95 - } 96 - 97 - input[type="text"]:focus { 98 - outline: none; 99 - border-color: var(--berry-crush); 100 - background: rgba(12, 23, 19, 0.8); 101 - } 102 - 103 - input::placeholder { 104 - color: rgba(217, 208, 222, 0.4); 105 54 } 106 55 107 56 button { 108 - position: relative; 109 57 width: 100%; 110 58 padding: 1.25rem 2rem; 111 - background: var(--berry-crush); 112 - color: var(--lavender); 113 - border: 4px solid var(--mahogany); 114 - border-radius: 0; 115 59 font-size: 1.125rem; 116 - font-weight: 700; 117 - cursor: pointer; 118 - font-family: "Space Grotesk", sans-serif; 119 - transition: all 0.15s ease; 120 - text-transform: uppercase; 121 - letter-spacing: 0.1rem; 122 60 margin-bottom: 0.75rem; 123 - box-shadow: 6px 6px 0 var(--mahogany); 124 - } 125 - 126 - button::before { 127 - content: ''; 128 - position: absolute; 129 - top: -4px; 130 - left: -4px; 131 - right: -4px; 132 - bottom: -4px; 133 - background: transparent; 134 - border: 4px solid var(--rosewood); 135 - pointer-events: none; 136 - transition: all 0.15s ease; 137 - } 138 - 139 - button:hover:not(:disabled) { 140 - transform: translate(3px, 3px); 141 - box-shadow: 3px 3px 0 var(--mahogany); 142 - } 143 - 144 - button:hover:not(:disabled)::before { 145 - top: -7px; 146 - left: -7px; 147 - right: -7px; 148 - bottom: -7px; 149 - } 150 - 151 - button:active:not(:disabled) { 152 - transform: translate(6px, 6px); 153 - box-shadow: 0 0 0 var(--mahogany); 154 - } 155 - 156 - button:disabled { 157 - opacity: 0.5; 158 - cursor: not-allowed; 159 - } 160 - 161 - .secondary-btn { 162 - background: transparent; 163 - color: var(--old-rose); 164 - box-shadow: 4px 4px 0 var(--mahogany); 165 - } 166 - 167 - .secondary-btn::before { 168 - border-color: var(--old-rose); 169 - } 170 - 171 - .secondary-btn:hover:not(:disabled) { 172 - background: rgba(188, 141, 160, 0.1); 173 - } 174 - 175 - .message { 176 - margin-bottom: 1rem; 177 - padding: 0.875rem; 178 - border-radius: 0.5rem; 179 - font-size: 0.875rem; 180 - letter-spacing: 0.025rem; 181 - display: none; 182 - } 183 - 184 - .message.show { 185 - display: block; 186 - } 187 - 188 - .message.error { 189 - background: rgba(160, 70, 104, 0.2); 190 - border: 2px solid var(--rosewood); 191 - color: var(--lavender); 192 - } 193 - 194 - .message.success { 195 - background: rgba(188, 141, 160, 0.2); 196 - border: 2px solid var(--old-rose); 197 - color: var(--lavender); 198 - } 199 - 200 - .divider { 201 - display: flex; 202 - align-items: center; 203 - text-align: center; 204 - margin: 1.5rem 0; 205 - color: var(--old-rose); 206 - font-size: 0.875rem; 207 - font-weight: 300; 208 - } 209 - 210 - .divider::before, 211 - .divider::after { 212 - content: ''; 213 - flex: 1; 214 - border-bottom: 1px solid rgba(188, 141, 160, 0.3); 215 - } 216 - 217 - .divider span { 218 - padding: 0 1rem; 219 61 } 220 62 221 63 .info { ··· 231 73 232 74 .info strong { 233 75 color: var(--lavender); 234 - } 235 - 236 - a { 237 - color: var(--berry-crush); 238 - text-decoration: none; 239 - transition: color 0.2s; 240 - } 241 - 242 - a:hover { 243 - color: var(--rosewood); 244 - text-decoration: underline; 245 76 } 246 77 </style> 247 78 </head>
+2 -202
src/html/profile.html
··· 20 20 <link rel="preconnect" href="https://fonts.googleapis.com"> 21 21 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 22 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"> 23 24 <style> 24 - :root { 25 - --mahogany: #26242b; 26 - --lavender: #d9d0de; 27 - --old-rose: #bc8da0; 28 - --rosewood: #a04668; 29 - --berry-crush: #ab4967; 30 - } 31 - 32 - * { 33 - margin: 0; 34 - padding: 0; 35 - box-sizing: border-box; 36 - } 37 - 38 - body { 39 - font-family: "Space Grotesk", sans-serif; 40 - background: var(--mahogany); 41 - color: var(--lavender); 42 - min-height: 100vh; 43 - display: flex; 44 - flex-direction: column; 45 - align-items: center; 46 - padding: 2.5rem 1.25rem; 47 - } 48 - 25 + /* Profile-specific styles */ 49 26 header { 50 - width: 100%; 51 - max-width: 56.25rem; 52 27 align-self: flex-start; 53 28 margin-left: auto; 54 29 margin-right: auto; 55 - margin-bottom: 2rem; 56 - } 57 - 58 - h1 { 59 - font-size: 2rem; 60 - font-weight: 700; 61 - background: linear-gradient(135deg, var(--old-rose), var(--berry-crush), var(--rosewood)); 62 - -webkit-background-clip: text; 63 - -webkit-text-fill-color: transparent; 64 - background-clip: text; 65 - letter-spacing: -0.125rem; 66 30 } 67 31 68 32 main { 69 - flex: 1; 70 33 display: flex; 71 34 justify-content: center; 72 - width: 100%; 73 35 } 74 36 75 37 .profile-container { ··· 101 63 .avatar-preview { 102 64 width: 6rem; 103 65 height: 6rem; 104 - border-radius: 50%; 105 - background: var(--berry-crush); 106 - display: flex; 107 - align-items: center; 108 - justify-content: center; 109 66 font-size: 2rem; 110 - font-weight: 700; 111 - color: var(--lavender); 112 - text-transform: uppercase; 113 - overflow: hidden; 114 - } 115 - 116 - .avatar-preview img { 117 - width: 100%; 118 - height: 100%; 119 - object-fit: cover; 120 67 } 121 68 122 69 .avatar-controls { ··· 125 72 gap: 0.5rem; 126 73 } 127 74 128 - label { 129 - display: block; 130 - color: var(--old-rose); 131 - font-size: 0.875rem; 132 - font-weight: 500; 133 - margin-bottom: 0.5rem; 134 - text-transform: uppercase; 135 - letter-spacing: 0.05rem; 136 - } 137 - 138 75 input[type="text"], 139 76 input[type="email"], 140 77 input[type="url"] { 141 - width: 100%; 142 - padding: 0.875rem 1rem; 143 - background: rgba(12, 23, 19, 0.6); 144 - border: 2px solid var(--rosewood); 145 - border-radius: 0; 146 - color: var(--lavender); 147 - font-size: 1rem; 148 - font-family: "Space Grotesk", sans-serif; 149 78 margin-bottom: 1.5rem; 150 - transition: border-color 0.2s; 151 - } 152 - 153 - input:focus { 154 - outline: none; 155 - border-color: var(--berry-crush); 156 - background: rgba(12, 23, 19, 0.8); 157 - } 158 - 159 - input::placeholder { 160 - color: rgba(217, 208, 222, 0.4); 161 - } 162 - 163 - input:disabled { 164 - opacity: 0.5; 165 - cursor: not-allowed; 166 - } 167 - 168 - button { 169 - position: relative; 170 - padding: 1rem 2rem; 171 - background: var(--berry-crush); 172 - color: var(--lavender); 173 - border: 4px solid var(--mahogany); 174 - border-radius: 0; 175 - font-size: 1rem; 176 - font-weight: 700; 177 - cursor: pointer; 178 - font-family: "Space Grotesk", sans-serif; 179 - transition: all 0.15s ease; 180 - text-transform: uppercase; 181 - letter-spacing: 0.1rem; 182 - box-shadow: 6px 6px 0 var(--mahogany); 183 - } 184 - 185 - button::before { 186 - content: ''; 187 - position: absolute; 188 - top: -4px; 189 - left: -4px; 190 - right: -4px; 191 - bottom: -4px; 192 - background: transparent; 193 - border: 4px solid var(--rosewood); 194 - pointer-events: none; 195 - transition: all 0.15s ease; 196 - } 197 - 198 - button:hover:not(:disabled) { 199 - transform: translate(3px, 3px); 200 - box-shadow: 3px 3px 0 var(--mahogany); 201 - } 202 - 203 - button:hover:not(:disabled)::before { 204 - top: -7px; 205 - left: -7px; 206 - right: -7px; 207 - bottom: -7px; 208 - } 209 - 210 - button:active:not(:disabled) { 211 - transform: translate(6px, 6px); 212 - box-shadow: 0 0 0 var(--mahogany); 213 - } 214 - 215 - button:disabled { 216 - opacity: 0.5; 217 - cursor: not-allowed; 218 - } 219 - 220 - .button-secondary { 221 - background: transparent; 222 - color: var(--old-rose); 223 - box-shadow: 4px 4px 0 var(--mahogany); 224 - } 225 - 226 - .button-secondary::before { 227 - border-color: var(--old-rose); 228 - } 229 - 230 - .button-secondary:hover:not(:disabled) { 231 - background: rgba(188, 141, 160, 0.1); 232 - } 233 - 234 - .button-group { 235 - display: flex; 236 - gap: 1rem; 237 - margin-top: 2rem; 238 - } 239 - 240 - .message { 241 - padding: 1rem; 242 - margin-bottom: 1.5rem; 243 - border-radius: 0.5rem; 244 - font-size: 0.875rem; 245 - display: none; 246 - } 247 - 248 - .message.show { 249 - display: block; 250 - } 251 - 252 - .message.error { 253 - background: rgba(160, 70, 104, 0.2); 254 - border: 2px solid var(--rosewood); 255 - color: var(--lavender); 256 - } 257 - 258 - .message.success { 259 - background: rgba(188, 141, 160, 0.2); 260 - border: 2px solid var(--old-rose); 261 - color: var(--lavender); 262 79 } 263 80 264 81 footer { 265 - width: 100%; 266 - max-width: 56.25rem; 267 - padding: 1rem; 268 - text-align: center; 269 - color: var(--old-rose); 270 - font-size: 0.875rem; 271 82 margin-top: 2rem; 272 - } 273 - 274 - footer a { 275 - color: var(--berry-crush); 276 - text-decoration: none; 277 - transition: color 0.2s; 278 - } 279 - 280 - footer a:hover { 281 - color: var(--rosewood); 282 - text-decoration: underline; 283 83 } 284 84 </style> 285 85 </head>
+9
src/index.ts
··· 26 26 getAppDetails, 27 27 revokeAppForUser, 28 28 disableUser, 29 + enableUser, 29 30 deleteUser, 30 31 } from "./routes/api"; 31 32 import { ··· 116 117 } 117 118 return new Response("Method not allowed", { status: 405 }); 118 119 }, 120 + "/api/admin/users/:id/enable": (req: Request) => { 121 + if (req.method === "POST") { 122 + const url = new URL(req.url); 123 + const userId = url.pathname.split("/")[4]; 124 + return enableUser(req, userId); 125 + } 126 + return new Response("Method not allowed", { status: 405 }); 127 + }, 119 128 "/api/admin/users/:id/delete": (req: Request) => { 120 129 if (req.method === "DELETE") { 121 130 const url = new URL(req.url);
+28
src/routes/api.ts
··· 410 410 return Response.json({ success: true }); 411 411 } 412 412 413 + export function enableUser(req: Request, userId: string): Response { 414 + const user = getSessionUser(req); 415 + if (user instanceof Response) { 416 + return user; 417 + } 418 + 419 + if (!user.is_admin) { 420 + return Response.json({ error: "Admin access required" }, { status: 403 }); 421 + } 422 + 423 + const targetUserId = Number.parseInt(userId, 10); 424 + if (Number.isNaN(targetUserId)) { 425 + return Response.json({ error: "Invalid user ID" }, { status: 400 }); 426 + } 427 + 428 + const targetUser = db 429 + .query("SELECT id, username FROM users WHERE id = ?") 430 + .get(targetUserId) as { id: number; username: string } | undefined; 431 + 432 + if (!targetUser) { 433 + return Response.json({ error: "User not found" }, { status: 404 }); 434 + } 435 + 436 + db.query("UPDATE users SET status = 'active' WHERE id = ?").run(targetUserId); 437 + 438 + return Response.json({ success: true }); 439 + } 440 + 413 441 export function deleteUser(req: Request, userId: string): Response { 414 442 const user = getSessionUser(req); 415 443 if (user instanceof Response) {
+579
src/styles.css
··· 1 + /* Indiko - Consolidated Styles */ 2 + 3 + /* CSS Variables */ 4 + :root { 5 + --mahogany: #26242b; 6 + --lavender: #d9d0de; 7 + --old-rose: #bc8da0; 8 + --rosewood: #a04668; 9 + --berry-crush: #ab4967; 10 + } 11 + 12 + /* Reset */ 13 + * { 14 + margin: 0; 15 + padding: 0; 16 + box-sizing: border-box; 17 + } 18 + 19 + /* Base */ 20 + body { 21 + font-family: "Space Grotesk", sans-serif; 22 + background: var(--mahogany); 23 + color: var(--lavender); 24 + min-height: 100vh; 25 + display: flex; 26 + flex-direction: column; 27 + align-items: center; 28 + padding: 2.5rem 1.25rem; 29 + } 30 + 31 + /* Typography */ 32 + h1 { 33 + font-size: 2rem; 34 + font-weight: 700; 35 + background: linear-gradient(135deg, var(--old-rose), var(--berry-crush), var(--rosewood)); 36 + -webkit-background-clip: text; 37 + -webkit-text-fill-color: transparent; 38 + background-clip: text; 39 + letter-spacing: -0.125rem; 40 + } 41 + 42 + h2 { 43 + font-size: 1.5rem; 44 + font-weight: 600; 45 + color: var(--lavender); 46 + margin-bottom: 1.5rem; 47 + letter-spacing: -0.05rem; 48 + } 49 + 50 + .subtitle { 51 + color: var(--old-rose); 52 + font-size: 1rem; 53 + font-weight: 300; 54 + letter-spacing: 0.05rem; 55 + } 56 + 57 + /* Links */ 58 + a { 59 + color: var(--berry-crush); 60 + text-decoration: none; 61 + transition: color 0.2s; 62 + } 63 + 64 + a:hover { 65 + color: var(--rosewood); 66 + text-decoration: underline; 67 + } 68 + 69 + /* Forms */ 70 + label { 71 + display: block; 72 + color: var(--old-rose); 73 + font-size: 0.875rem; 74 + font-weight: 500; 75 + margin-bottom: 0.5rem; 76 + text-transform: uppercase; 77 + letter-spacing: 0.05rem; 78 + } 79 + 80 + input[type="text"], 81 + input[type="email"], 82 + input[type="url"], 83 + input[type="number"], 84 + input[type="datetime-local"], 85 + textarea { 86 + width: 100%; 87 + padding: 0.875rem 1rem; 88 + background: rgba(12, 23, 19, 0.6); 89 + border: 2px solid var(--rosewood); 90 + border-radius: 0; 91 + color: var(--lavender); 92 + font-size: 1rem; 93 + font-family: "Space Grotesk", sans-serif; 94 + transition: border-color 0.2s; 95 + letter-spacing: 0.025rem; 96 + } 97 + 98 + input:focus, 99 + textarea:focus { 100 + outline: none; 101 + border-color: var(--berry-crush); 102 + background: rgba(12, 23, 19, 0.8); 103 + } 104 + 105 + input::placeholder, 106 + textarea::placeholder { 107 + color: rgba(217, 208, 222, 0.4); 108 + } 109 + 110 + input:disabled, 111 + textarea:disabled { 112 + opacity: 0.5; 113 + cursor: not-allowed; 114 + } 115 + 116 + textarea { 117 + resize: vertical; 118 + min-height: 4rem; 119 + } 120 + 121 + .form-group { 122 + margin-bottom: 1.5rem; 123 + } 124 + 125 + .form-help { 126 + font-size: 0.75rem; 127 + color: var(--old-rose); 128 + margin-top: 0.25rem; 129 + } 130 + 131 + /* Buttons */ 132 + button { 133 + position: relative; 134 + padding: 1rem 2rem; 135 + background: var(--berry-crush); 136 + color: var(--lavender); 137 + border: 4px solid var(--mahogany); 138 + border-radius: 0; 139 + font-size: 1rem; 140 + font-weight: 700; 141 + cursor: pointer; 142 + font-family: "Space Grotesk", sans-serif; 143 + transition: all 0.15s ease; 144 + text-transform: uppercase; 145 + letter-spacing: 0.1rem; 146 + box-shadow: 6px 6px 0 var(--mahogany); 147 + } 148 + 149 + button::before { 150 + content: ''; 151 + position: absolute; 152 + top: -4px; 153 + left: -4px; 154 + right: -4px; 155 + bottom: -4px; 156 + background: transparent; 157 + border: 4px solid var(--rosewood); 158 + pointer-events: none; 159 + transition: all 0.15s ease; 160 + } 161 + 162 + button:hover:not(:disabled) { 163 + transform: translate(3px, 3px); 164 + box-shadow: 3px 3px 0 var(--mahogany); 165 + } 166 + 167 + button:hover:not(:disabled)::before { 168 + top: -7px; 169 + left: -7px; 170 + right: -7px; 171 + bottom: -7px; 172 + } 173 + 174 + button:active:not(:disabled) { 175 + transform: translate(6px, 6px); 176 + box-shadow: 0 0 0 var(--mahogany); 177 + } 178 + 179 + button:disabled { 180 + opacity: 0.5; 181 + cursor: not-allowed; 182 + } 183 + 184 + .secondary-btn, 185 + .button-secondary { 186 + background: transparent; 187 + color: var(--old-rose); 188 + box-shadow: 4px 4px 0 var(--mahogany); 189 + } 190 + 191 + .secondary-btn::before, 192 + .button-secondary::before { 193 + border-color: var(--old-rose); 194 + } 195 + 196 + .secondary-btn:hover:not(:disabled), 197 + .button-secondary:hover:not(:disabled) { 198 + background: rgba(188, 141, 160, 0.1); 199 + } 200 + 201 + /* Small action buttons - clean style with subtle backgrounds */ 202 + .btn-edit, 203 + .btn-delete, 204 + .btn-copy, 205 + .btn-disable, 206 + .revoke-btn { 207 + padding: 0.5rem 1rem; 208 + font-family: "Space Grotesk", sans-serif; 209 + font-size: 0.875rem; 210 + font-weight: 600; 211 + cursor: pointer; 212 + transition: all 0.2s; 213 + text-transform: none; 214 + letter-spacing: normal; 215 + box-shadow: none; 216 + position: static; 217 + border: 2px solid transparent; 218 + } 219 + 220 + .btn-edit::before, 221 + .btn-delete::before, 222 + .btn-copy::before, 223 + .btn-disable::before, 224 + .revoke-btn::before { 225 + display: none; 226 + } 227 + 228 + .btn-edit:hover:not(:disabled), 229 + .btn-delete:hover:not(:disabled), 230 + .btn-copy:hover:not(:disabled), 231 + .btn-disable:hover:not(:disabled), 232 + .revoke-btn:hover:not(:disabled) { 233 + transform: none; 234 + } 235 + 236 + .btn-edit { 237 + background: rgba(188, 141, 160, 0.2); 238 + color: var(--lavender); 239 + border: 2px solid var(--old-rose); 240 + } 241 + 242 + .btn-edit:hover:not(:disabled) { 243 + background: rgba(188, 141, 160, 0.3); 244 + } 245 + 246 + .btn-delete, 247 + .revoke-btn { 248 + background: rgba(160, 70, 104, 0.2); 249 + color: var(--lavender); 250 + border: 2px solid var(--rosewood); 251 + } 252 + 253 + .btn-delete:hover:not(:disabled), 254 + .revoke-btn:hover:not(:disabled) { 255 + background: rgba(160, 70, 104, 0.3); 256 + } 257 + 258 + .btn-disable { 259 + background: rgba(229, 115, 115, 0.2); 260 + color: var(--lavender); 261 + border: 2px solid #e57373; 262 + } 263 + 264 + .btn-disable:hover:not(:disabled) { 265 + background: rgba(229, 115, 115, 0.3); 266 + } 267 + 268 + .btn-copy { 269 + background: rgba(188, 141, 160, 0.2); 270 + color: var(--lavender); 271 + border: 2px solid var(--old-rose); 272 + } 273 + 274 + .btn-copy:hover:not(:disabled) { 275 + background: rgba(188, 141, 160, 0.3); 276 + } 277 + 278 + .btn-edit:disabled, 279 + .btn-delete:disabled, 280 + .btn-copy:disabled, 281 + .btn-disable:disabled, 282 + .revoke-btn:disabled { 283 + opacity: 0.5; 284 + cursor: not-allowed; 285 + } 286 + 287 + /* Messages */ 288 + .message { 289 + padding: 0.875rem; 290 + margin-bottom: 1rem; 291 + border-radius: 0.5rem; 292 + font-size: 0.875rem; 293 + letter-spacing: 0.025rem; 294 + display: none; 295 + } 296 + 297 + .message.show { 298 + display: block; 299 + } 300 + 301 + .message.error { 302 + background: rgba(160, 70, 104, 0.2); 303 + border: 2px solid var(--rosewood); 304 + color: var(--lavender); 305 + } 306 + 307 + .message.success { 308 + background: rgba(188, 141, 160, 0.2); 309 + border: 2px solid var(--old-rose); 310 + color: var(--lavender); 311 + } 312 + 313 + /* Cards */ 314 + .card { 315 + background: rgba(188, 141, 160, 0.05); 316 + border: 1px solid var(--old-rose); 317 + padding: 1.5rem; 318 + } 319 + 320 + .card-title { 321 + font-size: 1.125rem; 322 + font-weight: 600; 323 + color: var(--lavender); 324 + margin-bottom: 1rem; 325 + } 326 + 327 + /* Avatars */ 328 + .avatar, 329 + .profile-avatar, 330 + .user-avatar { 331 + width: 4rem; 332 + height: 4rem; 333 + border-radius: 50%; 334 + background: var(--berry-crush); 335 + display: flex; 336 + align-items: center; 337 + justify-content: center; 338 + font-size: 1.5rem; 339 + font-weight: 700; 340 + color: var(--lavender); 341 + text-transform: uppercase; 342 + flex-shrink: 0; 343 + overflow: hidden; 344 + } 345 + 346 + .avatar img, 347 + .profile-avatar img, 348 + .user-avatar img { 349 + width: 100%; 350 + height: 100%; 351 + border-radius: 50%; 352 + object-fit: cover; 353 + } 354 + 355 + /* Badges */ 356 + .badge, 357 + .user-badge, 358 + .scope-badge { 359 + display: inline-block; 360 + padding: 0.25rem 0.75rem; 361 + font-size: 0.75rem; 362 + font-weight: 700; 363 + text-transform: uppercase; 364 + letter-spacing: 0.05rem; 365 + } 366 + 367 + .badge-admin { 368 + background: var(--berry-crush); 369 + color: var(--lavender); 370 + } 371 + 372 + .badge-role { 373 + background: rgba(188, 141, 160, 0.2); 374 + color: var(--lavender); 375 + border: 1px solid var(--old-rose); 376 + } 377 + 378 + .badge-status { 379 + border: 1px solid var(--old-rose); 380 + } 381 + 382 + .badge-status.active { 383 + background: rgba(139, 195, 74, 0.2); 384 + color: #a5d6a7; 385 + border-color: #81c784; 386 + } 387 + 388 + .badge-status.suspended { 389 + background: rgba(244, 67, 54, 0.2); 390 + color: #ef9a9a; 391 + border-color: #e57373; 392 + } 393 + 394 + .badge-status.inactive { 395 + background: rgba(158, 158, 158, 0.2); 396 + color: #bdbdbd; 397 + border-color: #9e9e9e; 398 + } 399 + 400 + /* Header */ 401 + header { 402 + width: 100%; 403 + max-width: 56.25rem; 404 + margin-bottom: 2rem; 405 + } 406 + 407 + .header-nav { 408 + display: flex; 409 + gap: 1rem; 410 + margin-top: 0.5rem; 411 + } 412 + 413 + .header-nav a { 414 + color: var(--old-rose); 415 + text-decoration: none; 416 + font-size: 0.875rem; 417 + font-weight: 500; 418 + padding: 0.5rem 1rem; 419 + border: 1px solid var(--old-rose); 420 + transition: all 0.2s; 421 + } 422 + 423 + .header-nav a:hover { 424 + background: rgba(188, 141, 160, 0.1); 425 + color: var(--berry-crush); 426 + border-color: var(--berry-crush); 427 + } 428 + 429 + .header-nav a.active { 430 + background: var(--berry-crush); 431 + color: var(--lavender); 432 + border-color: var(--berry-crush); 433 + } 434 + 435 + /* Main */ 436 + main { 437 + flex: 1; 438 + width: 100%; 439 + max-width: 56.25rem; 440 + } 441 + 442 + /* Footer */ 443 + footer { 444 + width: 100%; 445 + max-width: 56.25rem; 446 + padding: 1rem; 447 + text-align: center; 448 + color: var(--old-rose); 449 + font-size: 0.875rem; 450 + font-weight: 300; 451 + letter-spacing: 0.05rem; 452 + } 453 + 454 + footer a { 455 + color: var(--berry-crush); 456 + text-decoration: none; 457 + transition: color 0.2s; 458 + } 459 + 460 + footer a:hover { 461 + color: var(--rosewood); 462 + text-decoration: underline; 463 + } 464 + 465 + /* Utility Classes */ 466 + .loading, 467 + .error, 468 + .empty { 469 + text-align: center; 470 + padding: 2rem; 471 + color: var(--old-rose); 472 + font-size: 1rem; 473 + } 474 + 475 + .error { 476 + color: var(--rosewood); 477 + } 478 + 479 + .back-link { 480 + text-align: center; 481 + margin-top: 2rem; 482 + font-size: 0.875rem; 483 + } 484 + 485 + .back-link a { 486 + color: var(--berry-crush); 487 + text-decoration: none; 488 + } 489 + 490 + .back-link a:hover { 491 + text-decoration: underline; 492 + } 493 + 494 + /* Divider */ 495 + .divider { 496 + display: flex; 497 + align-items: center; 498 + text-align: center; 499 + margin: 1.5rem 0; 500 + color: var(--old-rose); 501 + font-size: 0.875rem; 502 + font-weight: 300; 503 + } 504 + 505 + .divider::before, 506 + .divider::after { 507 + content: ''; 508 + flex: 1; 509 + border-bottom: 1px solid rgba(188, 141, 160, 0.3); 510 + } 511 + 512 + .divider span { 513 + padding: 0 1rem; 514 + } 515 + 516 + /* Modals */ 517 + .modal { 518 + display: none; 519 + position: fixed; 520 + top: 0; 521 + left: 0; 522 + width: 100%; 523 + height: 100%; 524 + background: rgba(0, 0, 0, 0.8); 525 + justify-content: center; 526 + align-items: center; 527 + z-index: 1000; 528 + } 529 + 530 + .modal-content { 531 + background: var(--mahogany); 532 + border: 2px solid var(--old-rose); 533 + padding: 2rem; 534 + max-width: 40rem; 535 + width: 90%; 536 + max-height: 90vh; 537 + overflow-y: auto; 538 + } 539 + 540 + .modal-header { 541 + display: flex; 542 + justify-content: space-between; 543 + align-items: center; 544 + margin-bottom: 1.5rem; 545 + } 546 + 547 + .modal-header h3 { 548 + font-size: 1.5rem; 549 + color: var(--lavender); 550 + margin: 0; 551 + } 552 + 553 + .modal-close { 554 + background: none; 555 + border: none; 556 + color: var(--old-rose); 557 + font-size: 1.5rem; 558 + cursor: pointer; 559 + padding: 0; 560 + line-height: 1; 561 + } 562 + 563 + .modal-close:hover { 564 + color: var(--berry-crush); 565 + } 566 + 567 + .modal-actions { 568 + display: flex; 569 + gap: 1rem; 570 + justify-content: flex-end; 571 + margin-top: 2rem; 572 + } 573 + 574 + /* Button Groups */ 575 + .button-group { 576 + display: flex; 577 + gap: 1rem; 578 + margin-top: 2rem; 579 + }