A system for building static webapps
0
fork

Configure Feed

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

fix(hono): permissions and data display updates

- issue where uninitialized apps don't create new entry
- updates charts for data

+344 -129
+67 -1
deno.lock
··· 21 21 "jsr:@rodney/parsedown@^1.4.3": "1.4.3", 22 22 "jsr:@std/assert@^1.0.19": "1.0.19", 23 23 "jsr:@std/async@1": "1.3.0", 24 + "jsr:@std/async@^1.3.0": "1.3.0", 24 25 "jsr:@std/bytes@^1.0.2": "1.0.6", 25 26 "jsr:@std/cli@^1.0.29": "1.0.29", 26 27 "jsr:@std/collections@^1.1.3": "1.1.7", 27 28 "jsr:@std/crypto@^1.1.0": "1.1.0", 28 29 "jsr:@std/data-structures@^1.0.11": "1.0.11", 29 30 "jsr:@std/dotenv@~0.225.6": "0.225.6", 31 + "jsr:@std/encoding@0.224.0": "0.224.0", 30 32 "jsr:@std/encoding@1": "1.0.10", 31 33 "jsr:@std/encoding@^1.0.10": "1.0.10", 32 34 "jsr:@std/encoding@^1.0.5": "1.0.10", ··· 58 60 "jsr:@std/toml@^1.0.3": "1.0.11", 59 61 "jsr:@std/ulid@1": "1.0.0", 60 62 "jsr:@std/yaml@^1.0.5": "1.1.0", 63 + "jsr:@zaubrik/djwt@^3.0.2": "3.0.2", 61 64 "jsr:@zip-js/zip-js@^2.7.52": "2.8.26", 62 65 "jsr:@zod/zod@^4.3.6": "4.4.2", 63 66 "jsr:@zod/zod@^4.4.2": "4.4.2", 67 + "npm:@hono/swagger-ui@~0.6.1": "0.6.1_hono@4.12.16", 68 + "npm:@hono/zod-openapi@^1.3.0": "1.3.0_hono@4.12.16_zod@4.4.2", 64 69 "npm:@oslojs/crypto@^1.0.1": "1.0.1", 70 + "npm:@oslojs/encoding@^1.1.0": "1.1.0", 65 71 "npm:autoprefixer@^10.5.0": "10.5.0_postcss@8.5.13", 66 72 "npm:cheerio@^1.2.0": "1.2.0", 67 73 "npm:cssnano@^7.1.7": "7.1.7_postcss@8.5.13", 68 74 "npm:esbuild@0.28": "0.28.0", 69 75 "npm:fake-indexeddb@6.2.5": "6.2.5", 70 76 "npm:fast-json-patch@^3.1.1": "3.1.1", 77 + "npm:hono@^4.12.16": "4.12.16", 71 78 "npm:lit@^3.3.2": "3.3.2", 72 79 "npm:postcss-import@^16.1.1": "16.1.1_postcss@8.5.13", 73 80 "npm:postcss@^8.5.13": "8.5.13" ··· 78 85 "dependencies": [ 79 86 "jsr:@deno-library/progress", 80 87 "jsr:@deno/cache-dir", 81 - "jsr:@std/async", 88 + "jsr:@std/async@1", 82 89 "jsr:@std/encoding@1", 83 90 "jsr:@std/fs@1", 84 91 "jsr:@std/path@1", ··· 219 226 "@std/dotenv@0.225.6": { 220 227 "integrity": "1d6f9db72f565bd26790fa034c26e45ecb260b5245417be76c2279e5734c421b" 221 228 }, 229 + "@std/encoding@0.224.0": { 230 + "integrity": "efb6dca97d3e9c31392bd5c8cfd9f9fc9decf5a1f4d1f78af7900a493bcf89b5" 231 + }, 222 232 "@std/encoding@1.0.10": { 223 233 "integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1" 224 234 }, ··· 290 300 "integrity": "d3152f57b11666bf6358d0e127c7e3488e91178b0c2d8fbf0793e1c53cd13cb1", 291 301 "dependencies": [ 292 302 "jsr:@std/assert", 303 + "jsr:@std/async@^1.3.0", 293 304 "jsr:@std/data-structures", 294 305 "jsr:@std/fs@^1.0.23", 295 306 "jsr:@std/internal@^1.0.13", ··· 314 325 "@std/yaml@1.1.0": { 315 326 "integrity": "fc1c5c63e05c4c5eb6118355f557958035d41940d6c29d35b306ef7155d6edb0" 316 327 }, 328 + "@zaubrik/djwt@3.0.2": { 329 + "integrity": "8070adaa49cd9e5d2b8ae82fd461132c966ef2f8ff8378db4a4da8df4f17c664", 330 + "dependencies": [ 331 + "jsr:@std/encoding@0.224.0" 332 + ] 333 + }, 317 334 "@zip-js/zip-js@2.8.26": { 318 335 "integrity": "2b78bfa2c5d43766c9a487fc0274981d45732de41bb5b82433d29d235be67191" 319 336 }, ··· 325 342 } 326 343 }, 327 344 "npm": { 345 + "@asteasolutions/zod-to-openapi@8.5.0_zod@4.4.2": { 346 + "integrity": "sha512-SABbKiObg5dLRiTFnqiW1WWwGcg1BJfmHtT2asIBnBHg6Smy/Ms2KHc650+JI4Hw7lSkdiNebEGXpwoxfben8Q==", 347 + "dependencies": [ 348 + "openapi3-ts", 349 + "zod" 350 + ] 351 + }, 328 352 "@colordx/core@5.4.3": { 329 353 "integrity": "sha512-kIxYSfA5T8HXjav55UaaH/o/cKivF6jCCGIb8eqtcsfI46wsvlSiT8jMDyrl779qLec3c2c2oHBZo4oAhvbjrQ==" 330 354 }, ··· 458 482 "os": ["win32"], 459 483 "cpu": ["x64"] 460 484 }, 485 + "@hono/swagger-ui@0.6.1_hono@4.12.16": { 486 + "integrity": "sha512-sJTvldu1GPeEPfyeLG7gRj+W4vEuD+JDi+JjJ3TJs/DvMUtBLs0KJO5yokGegWWdy5qrbdnQGekbhgNRmPmYKQ==", 487 + "dependencies": [ 488 + "hono" 489 + ] 490 + }, 491 + "@hono/zod-openapi@1.3.0_hono@4.12.16_zod@4.4.2": { 492 + "integrity": "sha512-loDVevfMaaNa0slskhpMcqjSdidVXba2QJwNVmnS5Dp6L8AqSgtjJxWGJfRZtosyzYOb5gx4ZzXNCe+QhwY7xw==", 493 + "dependencies": [ 494 + "@asteasolutions/zod-to-openapi", 495 + "@hono/zod-validator", 496 + "hono", 497 + "openapi3-ts", 498 + "zod" 499 + ] 500 + }, 501 + "@hono/zod-validator@0.7.6_hono@4.12.16_zod@4.4.2": { 502 + "integrity": "sha512-Io1B6d011Gj1KknV4rXYz4le5+5EubcWEU/speUjuw9XMMIaP3n78yXLhjd2A3PXaXaUwEAluOiAyLqhBEJgsw==", 503 + "dependencies": [ 504 + "hono", 505 + "zod" 506 + ] 507 + }, 461 508 "@lit-labs/ssr-dom-shim@1.5.1": { 462 509 "integrity": "sha512-Aou5UdlSpr5whQe8AA/bZG0jMj96CoJIWbGfZ91qieWu5AWUMKw8VR/pAkQkJYvBNhmCcWnZlyyk5oze8JIqYA==" 463 510 }, ··· 483 530 "@oslojs/binary" 484 531 ] 485 532 }, 533 + "@oslojs/encoding@1.1.0": { 534 + "integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==" 535 + }, 486 536 "@types/trusted-types@2.0.7": { 487 537 "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==" 488 538 }, ··· 749 799 "function-bind" 750 800 ] 751 801 }, 802 + "hono@4.12.16": { 803 + "integrity": "sha512-jN0ZewiNAWSe5khM3EyCmBb250+b40wWbwNILNfEvq84VREWwOIkuUsFONk/3i3nqkz7Oe1PcpM2mwQEK2L9Kg==" 804 + }, 752 805 "htmlparser2@10.1.0": { 753 806 "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", 754 807 "dependencies": [ ··· 820 873 "boolbase" 821 874 ] 822 875 }, 876 + "openapi3-ts@4.5.0": { 877 + "integrity": "sha512-jaL+HgTq2Gj5jRcfdutgRGLosCy/hT8sQf6VOy+P+g36cZOjI1iukdPnijC+4CmeRzg/jEllJUboEic2FhxhtQ==", 878 + "dependencies": [ 879 + "yaml" 880 + ] 881 + }, 823 882 "parse5-htmlparser2-tree-adapter@7.1.0": { 824 883 "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", 825 884 "dependencies": [ ··· 1152 1211 }, 1153 1212 "whatwg-mimetype@4.0.0": { 1154 1213 "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==" 1214 + }, 1215 + "yaml@2.8.4": { 1216 + "integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==", 1217 + "bin": true 1218 + }, 1219 + "zod@4.4.2": { 1220 + "integrity": "sha512-IynmDyxsEsb9RKzO3J9+4SxXnl2FTFSzNBaKKaMV6tsSk0rw9gYw9gs+JFCq/qk2LCZ78KDwyj+Z289TijSkUw==" 1155 1221 } 1156 1222 }, 1157 1223 "workspace": {
+28 -6
packages/hono/auth/ui_router.ts
··· 89 89 export function createAuthUiRouter<TApp extends Hono = Hono>(config: { 90 90 auth: AuthAdapter 91 91 handlers: AuthHandlers 92 + /** Optional — user storage quota in bytes for the dashboard chart */ 93 + maxStoragePerUserBytes?: number 92 94 }): TApp { 93 95 const app = new Hono() as TApp 94 96 ··· 205 207 return c.redirect('/login') 206 208 } 207 209 210 + const limitBytes = config.maxStoragePerUserBytes ?? 0 208 211 return c.html(dashboardLayout( 209 212 user, 210 213 html` ··· 213 216 <p style="color:#999;">Loading...</p> 214 217 </div> 215 218 <script> 219 + window._civLimit = ${limitBytes}; 216 220 (async () => { 217 221 const el = document.getElementById('storage-stats'); 218 222 try { ··· 220 224 const data = await res.json(); 221 225 if (data.status === 'success' && data.data) { 222 226 const s = data.data; 227 + const limit = window._civLimit; 223 228 const fmt = (b) => { 224 229 if (b === 0) return '0 B'; 225 230 const k = 1024, sizes = ['B','KB','MB','GB']; ··· 227 232 return (b / Math.pow(k, i)).toFixed(1) + ' ' + sizes[i]; 228 233 }; 229 234 const total = s.totalDataSize + s.blobStorageSize; 235 + const limitLabel = limit > 0 ? ' / ' + fmt(limit) : ''; 236 + const availBytes = limit > 0 ? Math.max(0, limit - total) : 0; 237 + const showChart = total > 0 || availBytes > 0; 230 238 el.innerHTML = 231 239 '<h2>Storage Usage</h2>' + 232 240 '<div style="display:flex;gap:2rem;align-items:center;flex-wrap:wrap;">' + 233 241 '<div style="flex:1;min-width:160px;">' + 234 242 '<p><strong>Apps:</strong> ' + s.appCount + '</p>' + 235 - '<p><strong>Total:</strong> ' + fmt(total) + '</p>' + 243 + '<p><strong>Total:</strong> ' + fmt(total) + limitLabel + '</p>' + 236 244 '<p><strong>Change data:</strong> ' + fmt(s.totalDataSize) + '</p>' + 237 245 '<p><strong>Blob storage:</strong> ' + fmt(s.blobStorageSize) + '</p>' + 238 246 '</div>' + 239 - (total > 0 ? '<canvas id="dash-storage-chart" style="width:180px;height:180px;flex-shrink:0;"></canvas>' : '') + 247 + (showChart ? '<div style="position:relative;width:130px;height:130px;flex-shrink:0;"><canvas id="dash-storage-chart"></canvas></div>' : '') + 240 248 '</div>'; 241 - if (total > 0 && window.Chart) { 249 + if (showChart && window.Chart) { 250 + const labels = limit > 0 251 + ? ['Change data', 'Blob storage', 'Available'] 252 + : ['Change data', 'Blob storage']; 253 + const chartData = limit > 0 254 + ? [s.totalDataSize, s.blobStorageSize, availBytes] 255 + : [s.totalDataSize, s.blobStorageSize]; 256 + const colors = limit > 0 257 + ? ['#2563eb', '#d97706', '#e5e7eb'] 258 + : ['#2563eb', '#d97706']; 242 259 new Chart(document.getElementById('dash-storage-chart'), { 243 260 type: 'doughnut', 244 261 data: { 245 - labels: ['Change data', 'Blob storage'], 246 - datasets: [{ data: [s.totalDataSize, s.blobStorageSize], backgroundColor: ['#2563eb','#d97706'], borderWidth: 0 }] 262 + labels, 263 + datasets: [{ data: chartData, backgroundColor: colors, borderWidth: 0 }] 247 264 }, 248 - options: { responsive: false, plugins: { legend: { position: 'bottom' } } } 265 + options: { 266 + responsive: true, 267 + maintainAspectRatio: false, 268 + plugins: { legend: { position: 'bottom', labels: { font: { size: 11 } } } } 269 + } 249 270 }); 250 271 } 251 272 } ··· 341 362 export function createAuthUiRouterWithLogout(config: { 342 363 auth: AuthAdapter 343 364 handlers: AuthHandlers 365 + maxStoragePerUserBytes?: number 344 366 }): Hono { 345 367 const app = createAuthUiRouter(config) 346 368
+1
packages/hono/mod.ts
··· 235 235 const authUiRouter = createAuthUiRouterWithLogout({ 236 236 auth: authAdapter, 237 237 handlers: authHandlers, 238 + maxStoragePerUserBytes: resolvedLimits.maxStoragePerUserBytes, 238 239 }) 239 240 app.route('', authUiRouter) 240 241
+248 -122
packages/hono/sync/ui_router.ts
··· 96 96 ): ReturnType<typeof html> { 97 97 const encoded = btoa(JSON.stringify(config)) 98 98 return html` 99 - <canvas id="${id}" style="${style}"></canvas> 99 + <div style="position:relative;${style}"> 100 + <canvas id="${id}"></canvas> 101 + </div> 100 102 <script> 101 103 (function(){ 102 104 new Chart(document.getElementById('${id}'), JSON.parse(atob('${encoded}'))); ··· 148 150 149 151 const storageChartHtml = (() => { 150 152 if (!appStats || appStats.size === 0) return '' 151 - const labels: string[] = [] 152 - const data: number[] = [] 153 - for (const app of apps) { 154 - const s = appStats.get(app.id) 155 - if (!s) continue 156 - const total = s.dataSize + s.blobSize 157 - if (total === 0) continue 158 - labels.push(app.name) 159 - data.push(Math.round(total / 1024)) 160 - } 161 - if (data.length === 0) return '' 162 - 163 - const totalUsed = data.reduce((a, b) => a + b, 0) 164 - const limitKB = limits 165 - ? Math.round(limits.maxStoragePerUserBytes / 1024) 166 - : null 167 - const limitLabel = limitKB 168 - ? ` / ${formatBytes(limits!.maxStoragePerUserBytes)}` 169 - : '' 170 153 171 154 const PALETTE = [ 172 155 '#2563eb', ··· 181 164 '#0d9488', 182 165 ] 183 166 167 + let totalUsedBytes = 0 168 + const segments: Array<{ name: string; bytes: number; color: string }> = [] 169 + for (let i = 0; i < apps.length; i++) { 170 + const a = apps[i] 171 + const s = appStats.get(a.id) 172 + if (!s) continue 173 + const bytes = s.dataSize + s.blobSize 174 + totalUsedBytes += bytes 175 + segments.push({ name: a.name, bytes, color: PALETTE[i % PALETTE.length] }) 176 + } 177 + if (segments.length === 0 || totalUsedBytes === 0) return '' 178 + 179 + const limitBytes = limits?.maxStoragePerUserBytes ?? 0 180 + const pct = (b: number) => 181 + limitBytes > 0 ? Math.round((b / limitBytes) * 100) : 0 182 + 183 + const datasets = segments.map((seg) => ({ 184 + label: `${seg.name} (${pct(seg.bytes)}%)`, 185 + data: [parseFloat((seg.bytes / (1024 * 1024)).toFixed(2))], 186 + backgroundColor: seg.color, 187 + })) 188 + 189 + if (limitBytes > 0) { 190 + const availBytes = Math.max(0, limitBytes - totalUsedBytes) 191 + datasets.push({ 192 + label: `Available (${pct(availBytes)}%)`, 193 + data: [parseFloat((availBytes / (1024 * 1024)).toFixed(2))], 194 + backgroundColor: '#e5e7eb', 195 + }) 196 + } 197 + 198 + const limitLabel = limits 199 + ? ` / ${formatBytes(limits.maxStoragePerUserBytes)}` 200 + : '' 201 + 184 202 return html` 185 203 <div class="card"> 186 204 <h2>Storage</h2> 187 205 <p style="margin-bottom:0.75rem;color:#666;font-size:0.875rem;"> 188 - ${formatBytes(totalUsed * 1024)} used${limitLabel} 206 + ${formatBytes(totalUsedBytes)} used${limitLabel} 189 207 </p> 190 208 ${renderChart('apps-storage-chart', { 191 209 type: 'bar', 192 - data: { 193 - labels, 194 - datasets: [{ 195 - label: 'Storage (KB)', 196 - data, 197 - backgroundColor: labels.map((_, i) => 198 - PALETTE[i % PALETTE.length] 199 - ), 200 - borderRadius: 4, 201 - }], 202 - }, 210 + data: { labels: [''], datasets }, 203 211 options: { 204 212 indexAxis: 'y', 205 213 responsive: true, 214 + maintainAspectRatio: false, 206 215 plugins: { 207 - legend: { display: false }, 208 - tooltip: { 209 - callbacks: { 210 - label: (ctx: { raw: number }) => 211 - ` ${formatBytes(ctx.raw * 1024)}`, 212 - }, 213 - }, 216 + legend: { position: 'bottom' }, 217 + tooltip: { mode: 'nearest', intersect: true }, 214 218 }, 215 219 scales: { 216 220 x: { 217 - ticks: { 218 - callback: (v: number) => `${v} KB`, 219 - }, 221 + stacked: true, 222 + title: { display: true, text: 'MB' }, 223 + grid: { display: false }, 220 224 }, 225 + y: { stacked: true, grid: { display: false } }, 221 226 }, 222 227 }, 223 - }, 'max-width:100%;')} 228 + }, 'max-width:100%;height:180px;')} 224 229 </div> 225 230 ` 226 231 })() ··· 371 376 .toLocaleString()}</p> 372 377 </div> 373 378 ${storageStats 374 - ? html` 375 - <div class="card"> 376 - <h2>Storage</h2> 377 - <div style="display:flex;gap:2rem;align-items:center;flex-wrap:wrap;"> 378 - <div style="flex:1;min-width:160px;"> 379 - <p><strong>Total:</strong> ${formatBytes( 380 - storageStats.dataSize + storageStats.blobSize, 381 - )}${limits 382 - ? html` 383 - / ${formatBytes(limits.maxStoragePerAppBytes)} 384 - ` 385 - : ''}</p> 386 - <p style="margin-top:0.5rem;"> 387 - <strong>Changes:</strong> ${storageStats 388 - .changeCount} (${formatBytes( 389 - storageStats.dataSize, 390 - )}) 391 - </p> 392 - <p><strong>Blobs:</strong> ${storageStats 393 - .blobCount} (${formatBytes( 394 - storageStats.blobSize, 395 - )})</p> 396 - </div> 397 - ${(storageStats.dataSize + storageStats.blobSize) > 0 398 - ? renderChart('app-storage-chart', { 399 - type: 'doughnut', 400 - data: { 401 - labels: ['Change data', 'Blob storage'], 402 - datasets: [{ 403 - data: [storageStats.dataSize, storageStats.blobSize], 404 - backgroundColor: ['#2563eb', '#d97706'], 405 - borderWidth: 0, 406 - }], 407 - }, 408 - options: { 409 - responsive: true, 410 - plugins: { 411 - legend: { position: 'bottom' }, 412 - tooltip: { 413 - callbacks: { 414 - label: (ctx: { raw: number }) => 415 - ` ${formatBytes(ctx.raw)}`, 379 + ? (() => { 380 + const totalBytes = storageStats.dataSize + storageStats.blobSize 381 + const limitBytes = limits?.maxStoragePerAppBytes ?? 0 382 + const availBytes = limitBytes > 0 383 + ? Math.max(0, limitBytes - totalBytes) 384 + : 0 385 + const toMB = (b: number) => parseFloat((b / (1024 * 1024)).toFixed(2)) 386 + 387 + const chartLabels = limitBytes > 0 388 + ? ['Change data', 'Blob storage', 'Available'] 389 + : ['Change data', 'Blob storage'] 390 + const chartData = limitBytes > 0 391 + ? [ 392 + toMB(storageStats.dataSize), 393 + toMB(storageStats.blobSize), 394 + toMB(availBytes), 395 + ] 396 + : [toMB(storageStats.dataSize), toMB(storageStats.blobSize)] 397 + const chartColors = limitBytes > 0 398 + ? ['#2563eb', '#d97706', '#e5e7eb'] 399 + : ['#2563eb', '#d97706'] 400 + 401 + return html` 402 + <div class="card"> 403 + <h2>Storage</h2> 404 + <div style="display:flex;gap:2rem;align-items:center;flex-wrap:wrap;"> 405 + <div style="flex:1;min-width:160px;"> 406 + <p><strong>Total:</strong> ${formatBytes( 407 + totalBytes, 408 + )}${limitBytes > 0 ? ` / ${formatBytes(limitBytes)}` : ''}</p> 409 + <p style="margin-top:0.5rem;"> 410 + <strong>Changes:</strong> ${storageStats 411 + .changeCount} (${formatBytes(storageStats.dataSize)}) 412 + </p> 413 + <p><strong>Blobs:</strong> ${storageStats 414 + .blobCount} (${formatBytes(storageStats.blobSize)})</p> 415 + </div> 416 + ${totalBytes > 0 || availBytes > 0 417 + ? renderChart('app-storage-chart', { 418 + type: 'doughnut', 419 + data: { 420 + labels: chartLabels, 421 + datasets: [{ 422 + data: chartData, 423 + backgroundColor: chartColors, 424 + borderWidth: 0, 425 + }], 426 + }, 427 + options: { 428 + responsive: true, 429 + maintainAspectRatio: false, 430 + plugins: { 431 + legend: { 432 + position: 'bottom', 433 + labels: { font: { size: 11 } }, 416 434 }, 435 + tooltip: { mode: 'nearest' }, 417 436 }, 418 437 }, 419 - }, 420 - }, 'width:180px;height:180px;') 421 - : ''} 438 + }, 'width:130px;height:130px;flex-shrink:0;') 439 + : ''} 440 + </div> 422 441 </div> 423 - </div> 424 - ` 442 + ` 443 + })() 425 444 : ''} 426 445 <div class="card"> 427 446 <h2>API Tokens</h2> ··· 748 767 ) 749 768 } 750 769 770 + function popupCreateApp( 771 + appId: string, 772 + scope: string, 773 + username: string, 774 + ) { 775 + return popupLayout( 776 + 'Create App', 777 + html` 778 + <div class="app-header"> 779 + <div class="app-icon">🆕</div> 780 + <div class="app-name">${appId}</div> 781 + <div class="app-desc">This app doesn't exist yet.</div> 782 + </div> 783 + <p 784 + style="font-size:0.9rem;color:#374151;margin-bottom:1.25rem;text-align:center;" 785 + > 786 + Would you like to create it and grant access? 787 + </p> 788 + <p style="font-size:0.8rem;color:#9ca3af;margin-bottom:0.5rem;"> 789 + Signed in as <strong>${username}</strong> 790 + </p> 791 + <form method="POST" action="/authorize"> 792 + <input type="hidden" name="app_id" value="${appId}"> 793 + <input type="hidden" name="scope" value="${scope}"> 794 + <div class="actions"> 795 + <button type="submit" name="action" value="deny" class="btn btn-secondary"> 796 + Cancel 797 + </button> 798 + <button 799 + type="submit" 800 + name="action" 801 + value="create_app" 802 + class="btn btn-primary" 803 + > 804 + Create &amp; Authorize 805 + </button> 806 + </div> 807 + </form> 808 + `, 809 + ) 810 + } 811 + 751 812 export function createSyncUiRouter<TApp extends Hono = Hono>(config: { 752 813 auth: AuthAdapter 753 814 db: SyncDatabase ··· 835 896 836 897 if (!appId) return c.html(popupError('Missing app_id parameter')) 837 898 838 - const appRecord = await config.db.getApp(appId) 839 - if (!appRecord) return c.html(popupError('App not found')) 899 + const [appRecord, authCtx] = await Promise.all([ 900 + config.db.getApp(appId), 901 + config.auth.validate(c.req.raw), 902 + ]) 840 903 841 - const authCtx = await config.auth.validate(c.req.raw) 904 + if (!appRecord) { 905 + if (!authCtx) { 906 + if (!config.handlers) { 907 + const returnUrl = encodeURIComponent(c.req.url) 908 + return c.redirect(`/login?return=${returnUrl}`) 909 + } 910 + // Show login first; create dialog appears after login 911 + return c.html(popupLoginForm(appId, scope, undefined, appId)) 912 + } 913 + return c.html( 914 + popupCreateApp( 915 + appId, 916 + scope, 917 + authCtx.user.username || authCtx.user.email, 918 + ), 919 + ) 920 + } 842 921 843 922 if (!authCtx) { 844 923 if (!config.handlers) { 845 - // No inline login — redirect to login page with return URL 846 924 const returnUrl = encodeURIComponent(c.req.url) 847 925 return c.redirect(`/login?return=${returnUrl}`) 848 926 } ··· 886 964 887 965 if (!appId) return c.html(popupError('Missing app_id')) 888 966 889 - const appRecord = await config.db.getApp(appId) 890 - if (!appRecord) return c.html(popupError('App not found')) 967 + // Deny never needs app or auth 968 + if (action === 'deny') { 969 + return c.html( 970 + popupPostMessage({ 971 + type: 'civility:auth_result', 972 + error: 'access_denied', 973 + }), 974 + ) 975 + } 891 976 892 977 // Inline login within the popup 893 978 if (action === 'login') { ··· 902 987 !user || 903 988 !await config.handlers.verifyPassword(password, user.password_hash) 904 989 ) { 990 + const appRecord = await config.db.getApp(appId) 905 991 return c.html( 906 992 popupLoginForm( 907 993 appId, 908 994 scope, 909 - appRecord.manifest, 910 - appRecord.name, 995 + appRecord?.manifest, 996 + appRecord?.name ?? appId, 911 997 'Invalid email or password', 912 998 ), 913 999 ) ··· 919 1005 'Set-Cookie', 920 1006 `auth_token=${session.id}; HttpOnly; Path=/; SameSite=Lax; Max-Age=${maxAge}`, 921 1007 ) 1008 + 1009 + const appRecord = await config.db.getApp(appId) 1010 + if (!appRecord) { 1011 + return c.html(popupCreateApp(appId, scope, user.username)) 1012 + } 922 1013 923 1014 const requestedScopes = parseScope(scope) 924 1015 const existingGrants = await config.db.getGrants(appId, user.id) ··· 946 1037 ) 947 1038 } 948 1039 949 - // Approve or deny 950 - const authCtx = await config.auth.validate(c.req.raw) 951 - if (!authCtx) { 1040 + // Create app then show permission dialog 1041 + if (action === 'create_app') { 1042 + const authCtx = await config.auth.validate(c.req.raw) 1043 + if (!authCtx) { 1044 + if (!config.handlers) { 1045 + return c.html(popupError('Not authenticated')) 1046 + } 1047 + return c.html( 1048 + popupLoginForm( 1049 + appId, 1050 + scope, 1051 + undefined, 1052 + appId, 1053 + 'Session expired — please sign in again', 1054 + ), 1055 + ) 1056 + } 1057 + 1058 + const now = new Date().toISOString() 1059 + const appRecord = await config.db.createApp({ 1060 + id: appId, 1061 + userId: authCtx.user.id, 1062 + name: appId, 1063 + createdAt: now, 1064 + updatedAt: now, 1065 + }) 1066 + 1067 + const requestedScopes = parseScope(scope) 952 1068 return c.html( 953 - popupLoginForm( 1069 + popupPermissionDialog( 954 1070 appId, 955 1071 scope, 956 1072 appRecord.manifest, 957 1073 appRecord.name, 958 - 'Session expired — please sign in again', 1074 + requestedScopes, 1075 + [], 1076 + authCtx.user.username || authCtx.user.email, 959 1077 ), 960 1078 ) 961 1079 } 962 1080 963 - if (action === 'deny') { 964 - return c.html( 965 - popupPostMessage({ 966 - type: 'civility:auth_result', 967 - error: 'access_denied', 968 - }), 969 - ) 970 - } 971 - 972 1081 if (action === 'approve') { 973 - const requestedScopes = parseScope(scope) 1082 + const authCtx = await config.auth.validate(c.req.raw) 1083 + if (!authCtx) { 1084 + const appRecord = await config.db.getApp(appId) 1085 + return c.html( 1086 + popupLoginForm( 1087 + appId, 1088 + scope, 1089 + appRecord?.manifest, 1090 + appRecord?.name ?? appId, 1091 + 'Session expired — please sign in again', 1092 + ), 1093 + ) 1094 + } 974 1095 975 - // Create grants for any new scopes 1096 + const appRecord = await config.db.getApp(appId) 1097 + if (!appRecord) { 1098 + return c.html( 1099 + popupCreateApp( 1100 + appId, 1101 + scope, 1102 + authCtx.user.username || authCtx.user.email, 1103 + ), 1104 + ) 1105 + } 1106 + 1107 + const requestedScopes = parseScope(scope) 976 1108 const existingGrants = await config.db.getGrants(appId, authCtx.user.id) 977 1109 const existingScopes = existingGrants.map((g) => ({ 978 1110 store: g.store, ··· 995 1127 action: 'grant', 996 1128 }).catch(() => {}) 997 1129 998 - // Issue a scoped sync token with the full set of approved permissions 999 - const allScopes = [ 1000 - ...existingScopes, 1001 - ...newScopes, 1002 - ] as PermissionScope[] 1003 - const tokenName = tokenNameFromRequest( 1004 - c.req.header('User-Agent') ?? '', 1005 - ) 1130 + const allScopes = [...existingScopes, ...newScopes] as PermissionScope[] 1131 + const tokenName = tokenNameFromRequest(c.req.header('User-Agent') ?? '') 1006 1132 const token = await config.db.createToken( 1007 1133 appId, 1008 1134 tokenName,