slack status without the slack status.zzstoatzz.io/
quickslice

ui(webhooks): expand modal to near full-screen and add collapsible integration guide with examples; improve form labeling and help text

Changed files
+164 -64
templates
+164 -64
templates/status.html
··· 247 247 <button id="close-webhook-modal" aria-label="Close">✕</button> 248 248 </div> 249 249 <div class="webhook-modal-body"> 250 - <div class="webhook-intro"> 250 + <div class="webhook-intro" style="margin-bottom:10px;"> 251 251 <p>Send signed events when your status changes. Configure a URL that accepts JSON POSTs. We include an HMAC-SHA256 signature in <code>X-Status-Webhook-Signature</code> and a UNIX timestamp in <code>X-Status-Webhook-Timestamp</code>.</p> 252 252 </div> 253 - <form id="create-webhook-form" class="webhook-form"> 254 - <input type="url" id="wh-url" placeholder="https://example.com/webhook" required /> 255 - <input type="text" id="wh-secret" placeholder="secret (optional – autogenerated if blank)" /> 256 - <input type="text" id="wh-events" placeholder="events (optional, default *) e.g. status.created,status.deleted" /> 257 - <button type="submit">add webhook</button> 253 + <form id="create-webhook-form" class="webhook-form" aria-label="create webhook"> 254 + <div style="display:flex; flex-direction:column; gap:4px;"> 255 + <input type="url" id="wh-url" placeholder="Webhook URL (https://example.com/webhook)" required /> 256 + <div class="field-help">HTTPS required in production. Local/private hosts allowed only in local dev.</div> 257 + </div> 258 + <div style="display:flex; flex-direction:column; gap:4px;"> 259 + <input type="text" id="wh-secret" placeholder="Secret (optional – autogenerated)" /> 260 + <div class="field-help">Used to sign requests with HMAC-SHA256. Reveal only on creation/rotation.</div> 261 + </div> 262 + <div style="display:flex; flex-direction:column; gap:4px;"> 263 + <input type="text" id="wh-events" placeholder="Events (optional, default *) e.g. status.created,status.deleted" /> 264 + <div class="field-help">Comma-separated. Supported: <code>status.created</code>, <code>status.deleted</code> or <code>*</code>.</div> 265 + </div> 266 + <button type="submit" aria-label="add webhook">add webhook</button> 267 + <div class="field-help">You can add multiple webhooks. Toggle active, rotate secrets, or delete below.</div> 258 268 </form> 259 269 <div id="webhook-list" class="webhook-list" aria-live="polite"></div> 270 + 271 + <details class="wh-guide" id="webhook-guide"> 272 + <summary>Integration guide</summary> 273 + <div class="content"> 274 + <div class="wh-grid"> 275 + <div> 276 + <h4>Request</h4> 277 + <ul> 278 + <li>Method: POST</li> 279 + <li>Content-Type: application/json</li> 280 + <li>Header <code>X-Status-Webhook-Timestamp</code>: UNIX seconds</li> 281 + <li>Header <code>X-Status-Webhook-Signature</code>: <code>sha256=&lt;hex&gt;</code></li> 282 + </ul> 283 + <h4>Payload</h4> 284 + <pre><code>{ 285 + "event": "status.created", // or "status.deleted" 286 + "did": "did:plc:...", 287 + "handle": null, 288 + "status": "🙂", // created only 289 + "text": "in a meeting", // optional 290 + "uri": "at://...", // record URI 291 + "since": "2025-09-10T16:00:00Z", // created only 292 + "expires": null // created only 293 + }</code></pre> 294 + </div> 295 + <div> 296 + <h4>Verify signature</h4> 297 + <p>Compute HMAC-SHA256 over <code>timestamp + "." + rawBody</code> using your secret. Compare to header (without the <code>sha256=</code> prefix) with constant-time equality, and reject if timestamp is too old (e.g., &gt; 5 minutes).</p> 298 + <pre><code>// Node (TypeScript) 299 + import crypto from 'node:crypto'; 300 + 301 + function verify(req: any, rawBody: Buffer, secret: string): boolean { 302 + const ts = req.headers['x-status-webhook-timestamp']; 303 + const sig = String(req.headers['x-status-webhook-signature'] || '').replace(/^sha256=/, ''); 304 + if (!ts || !sig) return false; 305 + const now = Math.floor(Date.now()/1000); 306 + if (Math.abs(now - Number(ts)) > 300) return false; // 5m 307 + const mac = crypto.createHmac('sha256', secret).update(String(ts)).update('.').update(rawBody).digest('hex'); 308 + return crypto.timingSafeEqual(Buffer.from(mac, 'hex'), Buffer.from(sig, 'hex')); 309 + } 310 + </code></pre> 311 + <pre><code>// Rust (axum-ish) 312 + use hmac::{Hmac, Mac}; 313 + use sha2::Sha256; 314 + 315 + fn verify(ts: &str, sig_hdr: &str, body: &[u8], secret: &str) -> bool { 316 + let sig = sig_hdr.strip_prefix("sha256=").unwrap_or(sig_hdr); 317 + if let Ok(ts_int) = ts.parse::<i64>() { 318 + if (chrono::Utc::now().timestamp() - ts_int).abs() > 300 { return false; } 319 + } else { return false; } 320 + let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap(); 321 + mac.update(ts.as_bytes()); 322 + mac.update(b"."); 323 + mac.update(body); 324 + let calc = hex::encode(mac.finalize().into_bytes()); 325 + subtle::ConstantTimeEq::ct_eq(calc.as_bytes(), sig.as_bytes()).into() 326 + } 327 + </code></pre> 328 + </div> 329 + </div> 330 + </div> 331 + </details> 260 332 </div> 261 333 </div> 262 334 </div> ··· 1348 1420 align-items: center; 1349 1421 justify-content: center; 1350 1422 } 1351 - .webhook-modal-content { 1352 - width: min(920px, 95vw); 1353 - max-height: 90vh; 1354 - overflow: auto; 1355 - background: var(--bg, #111); 1356 - color: var(--text, #fff); 1357 - border: 1px solid var(--border-color, #2a2a2a); 1358 - border-radius: 12px; 1359 - padding: 16px 20px; 1360 - } 1361 - .webhook-modal-header { 1362 - display: flex; 1363 - align-items: center; 1364 - justify-content: space-between; 1365 - margin-bottom: 12px; 1366 - } 1367 - .webhook-modal-header h2 { margin: 0; font-size: 18px; } 1368 - .webhook-modal-header button { 1369 - background: transparent; 1370 - color: inherit; 1371 - border: 1px solid var(--border-color, #2a2a2a); 1372 - border-radius: 8px; 1373 - padding: 6px 10px; 1374 - cursor: pointer; 1375 - } 1376 - .webhook-form { 1377 - display: grid; 1378 - grid-template-columns: 1fr 200px 1fr auto; 1379 - gap: 8px; 1380 - margin-bottom: 16px; 1381 - } 1382 - .webhook-form input { 1383 - background: var(--bg-secondary, #0d0d0d); 1384 - color: var(--text, #fff); 1385 - border: 1px solid var(--border-color, #2a2a2a); 1386 - border-radius: 8px; 1387 - padding: 10px 12px; 1388 - } 1389 - .webhook-form button { 1390 - background: var(--accent, #1DA1F2); 1391 - color: #000; 1392 - border: none; 1393 - border-radius: 8px; 1394 - padding: 10px 12px; 1395 - cursor: pointer; 1396 - } 1397 - .webhook-list .item { 1398 - border: 1px solid var(--border-color, #2a2a2a); 1399 - border-radius: 8px; 1400 - padding: 12px; 1401 - margin-bottom: 10px; 1402 - display: grid; 1403 - grid-template-columns: 1fr auto; 1404 - gap: 8px; 1405 - } 1423 + .webhook-modal-content { 1424 + width: 96vw; 1425 + height: 92vh; 1426 + max-width: 1400px; 1427 + max-height: 92vh; 1428 + background: var(--bg, #111); 1429 + color: var(--text, #fff); 1430 + border: 1px solid var(--border-color, #2a2a2a); 1431 + border-radius: 12px; 1432 + display: flex; 1433 + flex-direction: column; 1434 + } 1435 + .webhook-modal-header { 1436 + display: flex; 1437 + align-items: center; 1438 + justify-content: space-between; 1439 + padding: 16px 20px; 1440 + border-bottom: 1px solid var(--border-color, #2a2a2a); 1441 + } 1442 + .webhook-modal-header h2 { margin: 0; font-size: 20px; } 1443 + .webhook-modal-header button { 1444 + background: transparent; 1445 + color: inherit; 1446 + border: 1px solid var(--border-color, #2a2a2a); 1447 + border-radius: 8px; 1448 + padding: 6px 10px; 1449 + cursor: pointer; 1450 + } 1451 + .webhook-modal-body { 1452 + padding: 16px 20px; 1453 + overflow: auto; 1454 + height: calc(92vh - 60px); 1455 + } 1456 + .webhook-form { 1457 + display: grid; 1458 + grid-template-columns: 1.2fr 0.8fr 1fr auto; 1459 + gap: 8px; 1460 + margin-bottom: 16px; 1461 + } 1462 + .webhook-form input { 1463 + background: var(--bg-secondary, #0d0d0d); 1464 + color: var(--text, #fff); 1465 + border: 1px solid var(--border-color, #2a2a2a); 1466 + border-radius: 8px; 1467 + padding: 10px 12px; 1468 + } 1469 + .webhook-form button { 1470 + background: var(--accent, #1DA1F2); 1471 + color: #000; 1472 + border: none; 1473 + border-radius: 8px; 1474 + padding: 10px 12px; 1475 + cursor: pointer; 1476 + } 1477 + .field-help { font-size: 12px; opacity: 0.8; margin-top: 2px; grid-column: 1 / -1; } 1478 + .webhook-list .item { 1479 + border: 1px solid var(--border-color, #2a2a2a); 1480 + border-radius: 8px; 1481 + padding: 12px; 1482 + margin-bottom: 10px; 1483 + display: grid; 1484 + grid-template-columns: 1fr auto; 1485 + gap: 8px; 1486 + } 1406 1487 .webhook-list .meta { font-size: 12px; opacity: 0.8; } 1407 1488 .webhook-actions { display: flex; gap: 8px; align-items: center; } 1408 1489 .webhook-actions button { ··· 1413 1494 padding: 6px 10px; 1414 1495 cursor: pointer; 1415 1496 } 1416 - .webhook-actions .danger { border-color: #803; color: #f77; } 1417 - .webhook-active { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; } 1418 - .webhook-active input { transform: translateY(1px); } 1497 + .webhook-actions .danger { border-color: #803; color: #f77; } 1498 + .webhook-active { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; } 1499 + .webhook-active input { transform: translateY(1px); } 1500 + 1501 + /* Collapsible guide */ 1502 + .wh-guide { 1503 + margin-top: 20px; 1504 + border: 1px solid var(--border-color, #2a2a2a); 1505 + border-radius: 10px; 1506 + overflow: hidden; 1507 + } 1508 + .wh-guide summary { 1509 + padding: 12px 14px; 1510 + cursor: pointer; 1511 + background: var(--bg-secondary, #0f0f0f); 1512 + font-weight: 600; 1513 + outline: none; 1514 + } 1515 + .wh-guide .content { padding: 14px; } 1516 + .wh-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } 1517 + .wh-grid pre { background: #0b0b0b; border: 1px solid var(--border-color); border-radius: 8px; padding: 12px; overflow: auto; font-size: 12px; } 1518 + @media (max-width: 900px) { .wh-grid { grid-template-columns: 1fr; } } 1419 1519 </style> 1420 1520 1421 1521 <script>