Commit7581253unknown_key
polish(batch7): repo-guards + settings sub-pages 2026 treatment
polish(batch7): repo-guards + settings sub-pages 2026 treatment Picked up from two parallel polish agents: REPO GUARDS (.gates-* / .rs-* / .rc-* / .pt-* / .spons-*): - /:owner/:repo/settings/gates: status card + branch protection - /:owner/:repo/settings/rulesets: rule cards with scope chips - /:owner/:repo/settings/required-checks: traffic-light per check - /:owner/:repo/settings/protected-tags: tag-pattern cards SETTINGS SUB-PAGES: - /settings/notifications: channel toggle pills (via settings.tsx) - /settings/email-verification: status + resend flow - /settings/saved-replies: canned reply list with copy/delete - /orgs: org list with avatar + member/repo counts - /invites: pending invite cards with accept/decline All scoped CSS, form actions / POST handlers preserved.
9 files changed+6585−80675812535ba96fe3272a389f94fc16c86e0a2d74c
9 changed files+6585−806
Modifiedsrc/routes/email-verification.tsx+483−4View fileUnifiedSplit
@@ -233,6 +233,12 @@ verify.post("/verify-email/resend", requireAuth, async (c) => {
233233 const user = c.get("user");
234234 if (!user) return c.redirect("/login");
235235
236 // If the caller posted from /settings/email-verification, send them back
237 // there instead of the dashboard so the banner shows next to the page they
238 // were on. Pure cosmetic — the underlying flow is unchanged.
239 const referrer = c.req.header("referer") || "";
240 const fromSettings = referrer.includes("/settings/email-verification");
241
236242 let email = user.email;
237243 let verifiedAt: Date | null = (user as any).emailVerifiedAt
238244 ? new Date((user as any).emailVerifiedAt as string | Date)
@@ -257,12 +263,18 @@ verify.post("/verify-email/resend", requireAuth, async (c) => {
257263 }
258264
259265 if (verifiedAt) {
260 return c.redirect("/dashboard?verified=1");
266 return c.redirect(
267 fromSettings ? "/settings/email-verification?verified=1" : "/dashboard?verified=1"
268 );
261269 }
262270
263271 const rate = checkResendRate(user.id);
264272 if (!rate.allowed) {
265 return c.redirect("/dashboard?verify=rate_limited");
273 return c.redirect(
274 fromSettings
275 ? "/settings/email-verification?verify=rate_limited"
276 : "/dashboard?verify=rate_limited"
277 );
266278 }
267279
268280 // If the operator hasn't wired a real email provider, fire the verification
@@ -272,9 +284,476 @@ verify.post("/verify-email/resend", requireAuth, async (c) => {
272284 // exact frustration the user flagged.
273285 void startEmailVerification(user.id, email);
274286 if (config.emailProvider !== "resend" || !config.resendApiKey) {
275 return c.redirect("/dashboard?verify=not_configured");
287 return c.redirect(
288 fromSettings
289 ? "/settings/email-verification?verify=not_configured"
290 : "/dashboard?verify=not_configured"
291 );
292 }
293 return c.redirect(
294 fromSettings ? "/settings/email-verification?verify=sent" : "/dashboard?verify=sent"
295 );
296});
297
298// ─── Scoped CSS (.ev-*) — settings page polish ─────────────────────────────
299// Every selector prefixed `.ev-*` so this surface can't bleed into the
300// auth-extra-ev-* styles used by the dead-link page above, or any other
301// page. Mirrors the gradient-hairline hero + card pattern from
302// settings-2fa.tsx and admin-integrations.tsx.
303const evStyles = `
304 .ev-wrap { max-width: 920px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
305
306 .ev-hero {
307 position: relative;
308 margin-bottom: var(--space-5);
309 padding: var(--space-5) var(--space-6);
310 background: var(--bg-elevated);
311 border: 1px solid var(--border);
312 border-radius: 16px;
313 overflow: hidden;
314 }
315 .ev-hero::before {
316 content: '';
317 position: absolute;
318 top: 0; left: 0; right: 0;
319 height: 2px;
320 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
321 opacity: 0.7;
322 pointer-events: none;
323 }
324 .ev-hero-orb {
325 position: absolute;
326 inset: -20% -10% auto auto;
327 width: 380px; height: 380px;
328 background: radial-gradient(circle, rgba(140,109,255,0.20), rgba(54,197,214,0.10) 45%, transparent 70%);
329 filter: blur(80px);
330 opacity: 0.7;
331 pointer-events: none;
332 z-index: 0;
333 }
334 .ev-hero-inner { position: relative; z-index: 1; max-width: 720px; }
335 .ev-eyebrow {
336 font-size: 12px;
337 color: var(--text-muted);
338 margin-bottom: var(--space-2);
339 letter-spacing: 0.02em;
340 display: inline-flex;
341 align-items: center;
342 gap: 8px;
343 text-transform: uppercase;
344 font-family: var(--font-mono);
345 font-weight: 600;
346 }
347 .ev-eyebrow-pill {
348 display: inline-flex;
349 align-items: center;
350 justify-content: center;
351 width: 18px; height: 18px;
352 border-radius: 6px;
353 background: rgba(140,109,255,0.14);
354 color: #b69dff;
355 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.35);
356 }
357 .ev-crumb { color: var(--text-muted); text-decoration: none; }
358 .ev-crumb:hover { color: var(--text); }
359 .ev-title {
360 font-size: clamp(28px, 4vw, 40px);
361 font-family: var(--font-display);
362 font-weight: 800;
363 letter-spacing: -0.028em;
364 line-height: 1.05;
365 margin: 0 0 var(--space-2);
366 color: var(--text-strong);
367 }
368 .ev-title-grad {
369 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
370 -webkit-background-clip: text;
371 background-clip: text;
372 -webkit-text-fill-color: transparent;
373 color: transparent;
374 }
375 .ev-sub {
376 font-size: 15px;
377 color: var(--text-muted);
378 margin: 0;
379 line-height: 1.55;
380 max-width: 620px;
381 }
382
383 /* ─── Banner ─── */
384 .ev-banner {
385 margin-bottom: var(--space-4);
386 padding: 10px 14px;
387 border-radius: 10px;
388 font-size: 13.5px;
389 border: 1px solid var(--border);
390 background: rgba(255,255,255,0.025);
391 color: var(--text);
392 display: flex;
393 align-items: center;
394 gap: 10px;
395 }
396 .ev-banner.is-ok {
397 border-color: rgba(52,211,153,0.40);
398 background: rgba(52,211,153,0.08);
399 color: #bbf7d0;
400 }
401 .ev-banner.is-warn {
402 border-color: rgba(251,191,36,0.40);
403 background: rgba(251,191,36,0.08);
404 color: #fde68a;
405 }
406 .ev-banner.is-error {
407 border-color: rgba(248,113,113,0.40);
408 background: rgba(248,113,113,0.08);
409 color: #fecaca;
410 }
411 .ev-banner-dot {
412 width: 8px; height: 8px;
413 border-radius: 9999px;
414 background: currentColor;
415 flex-shrink: 0;
416 }
417
418 /* ─── Status card ─── */
419 .ev-status {
420 position: relative;
421 margin-bottom: var(--space-5);
422 padding: var(--space-5);
423 background: var(--bg-elevated);
424 border: 1px solid var(--border);
425 border-radius: 14px;
426 display: flex;
427 align-items: center;
428 gap: var(--space-4);
429 flex-wrap: wrap;
430 overflow: hidden;
276431 }
277 return c.redirect("/dashboard?verify=sent");
432 .ev-status.is-on {
433 border-color: rgba(52,211,153,0.32);
434 background: linear-gradient(135deg, rgba(52,211,153,0.08) 0%, rgba(15,17,26,0) 60%), var(--bg-elevated);
435 }
436 .ev-status.is-off {
437 border-color: rgba(251,191,36,0.32);
438 background: linear-gradient(135deg, rgba(251,191,36,0.06) 0%, rgba(15,17,26,0) 60%), var(--bg-elevated);
439 }
440 .ev-status-mark {
441 flex-shrink: 0;
442 width: 56px; height: 56px;
443 border-radius: 14px;
444 display: flex;
445 align-items: center;
446 justify-content: center;
447 color: #fff;
448 }
449 .ev-status-mark.is-on {
450 background: linear-gradient(135deg, #34d399 0%, #10b981 100%);
451 box-shadow: 0 8px 20px -8px rgba(16,185,129,0.55), inset 0 1px 0 rgba(255,255,255,0.20);
452 }
453 .ev-status-mark.is-off {
454 background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%);
455 color: #1a1206;
456 box-shadow: 0 8px 20px -8px rgba(251,191,36,0.55), inset 0 1px 0 rgba(255,255,255,0.20);
457 }
458 .ev-status-text { flex: 1; min-width: 220px; }
459 .ev-status-headline {
460 margin: 0 0 4px;
461 font-family: var(--font-display);
462 font-size: 19px;
463 font-weight: 700;
464 letter-spacing: -0.018em;
465 color: var(--text-strong);
466 }
467 .ev-status-desc {
468 margin: 0;
469 font-size: 13.5px;
470 color: var(--text-muted);
471 line-height: 1.5;
472 }
473 .ev-status-desc code {
474 font-family: var(--font-mono);
475 font-size: 12px;
476 background: rgba(255,255,255,0.04);
477 border: 1px solid var(--border);
478 padding: 2px 7px;
479 border-radius: 5px;
480 color: var(--text);
481 }
482
483 /* ─── Section card ─── */
484 .ev-section {
485 margin-bottom: var(--space-5);
486 background: var(--bg-elevated);
487 border: 1px solid var(--border);
488 border-radius: 14px;
489 overflow: hidden;
490 }
491 .ev-section-head {
492 padding: var(--space-4) var(--space-5);
493 border-bottom: 1px solid var(--border);
494 }
495 .ev-section-title {
496 margin: 0;
497 font-family: var(--font-display);
498 font-size: 17px;
499 font-weight: 700;
500 letter-spacing: -0.018em;
501 color: var(--text-strong);
502 display: flex;
503 align-items: center;
504 gap: 10px;
505 }
506 .ev-section-title-icon {
507 display: inline-flex;
508 align-items: center;
509 justify-content: center;
510 width: 26px; height: 26px;
511 border-radius: 8px;
512 background: rgba(140,109,255,0.12);
513 color: #b69dff;
514 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.28);
515 flex-shrink: 0;
516 }
517 .ev-section-sub {
518 margin: 6px 0 0 36px;
519 font-size: 12.5px;
520 color: var(--text-muted);
521 line-height: 1.5;
522 }
523 .ev-section-body { padding: var(--space-4) var(--space-5); }
524
525 /* ─── Buttons ─── */
526 .ev-btn {
527 display: inline-flex;
528 align-items: center;
529 justify-content: center;
530 gap: 6px;
531 padding: 10px 18px;
532 border-radius: 10px;
533 font-size: 13.5px;
534 font-weight: 600;
535 text-decoration: none;
536 border: 1px solid transparent;
537 cursor: pointer;
538 font-family: inherit;
539 line-height: 1;
540 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease, color 120ms ease;
541 }
542 .ev-btn-primary {
543 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
544 color: #fff;
545 box-shadow: 0 6px 18px -4px rgba(140,109,255,0.45), inset 0 1px 0 rgba(255,255,255,0.16);
546 }
547 .ev-btn-primary:hover {
548 transform: translateY(-1px);
549 box-shadow: 0 10px 24px -6px rgba(140,109,255,0.55), inset 0 1px 0 rgba(255,255,255,0.20);
550 color: #fff;
551 text-decoration: none;
552 }
553 .ev-btn-ghost {
554 background: rgba(255,255,255,0.025);
555 color: var(--text);
556 border-color: var(--border-strong);
557 }
558 .ev-btn-ghost:hover {
559 background: rgba(140,109,255,0.06);
560 border-color: rgba(140,109,255,0.45);
561 color: var(--text-strong);
562 text-decoration: none;
563 }
564
565 .ev-meta {
566 margin-top: var(--space-3);
567 padding: 12px 14px;
568 background: var(--bg);
569 border: 1px solid var(--border-strong);
570 border-radius: 10px;
571 font-size: 13px;
572 color: var(--text-muted);
573 line-height: 1.55;
574 }
575 .ev-meta strong { color: var(--text-strong); }
576 .ev-meta code {
577 font-family: var(--font-mono);
578 font-size: 12.5px;
579 color: var(--text);
580 }
581`;
582
583const EvIconShield = () => (
584 <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
585 <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
586 <polyline points="22 4 12 14.01 9 11.01" />
587 </svg>
588);
589const EvCheckIcon = () => (
590 <svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
591 <polyline points="20 6 9 17 4 12" />
592 </svg>
593);
594const EvWarnIcon = () => (
595 <svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
596 <path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
597 <line x1="12" y1="9" x2="12" y2="13" />
598 <line x1="12" y1="17" x2="12.01" y2="17" />
599 </svg>
600);
601
602// ─── /settings/email-verification — verification status + resend ───────────
603// Visual sibling to /settings/2fa: status hero, single action card. The
604// underlying resend flow points at the existing POST /verify-email/resend
605// handler so all rate-limit + email-dispatch logic is unchanged.
606verify.get("/settings/email-verification", requireAuth, async (c) => {
607 const user = c.get("user")!;
608 const csrf = c.get("csrfToken") as string | undefined;
609
610 // Always read fresh from DB so a stale session doesn't show an outdated
611 // unverified state right after the user clicks the verification link.
612 let email = user.email;
613 let verifiedAt: Date | null = (user as any).emailVerifiedAt
614 ? new Date((user as any).emailVerifiedAt as string | Date)
615 : null;
616 try {
617 const [fresh] = await db
618 .select({
619 email: users.email,
620 emailVerifiedAt: users.emailVerifiedAt,
621 })
622 .from(users)
623 .where(eq(users.id, user.id))
624 .limit(1);
625 if (fresh) {
626 email = fresh.email;
627 verifiedAt = fresh.emailVerifiedAt
628 ? new Date(fresh.emailVerifiedAt as unknown as string | Date)
629 : null;
630 }
631 } catch {
632 /* best effort */
633 }
634
635 const verified = !!verifiedAt;
636 const flag = c.req.query("verify") || (c.req.query("verified") ? "ok" : "");
637 let banner: { kind: "ok" | "warn" | "error"; text: string } | null = null;
638 if (flag === "sent") {
639 banner = { kind: "ok", text: "Verification email dispatched. Check your inbox — it usually lands in under a minute." };
640 } else if (flag === "not_configured") {
641 banner = { kind: "warn", text: "Verification token issued but no email provider is configured. The site admin can fetch the link from server logs." };
642 } else if (flag === "rate_limited") {
643 banner = { kind: "error", text: "Hold up — you've already requested a few links in the last hour. Try again later." };
644 } else if (flag === "ok" || flag === "1") {
645 banner = { kind: "ok", text: "Your email is verified." };
646 }
647
648 const providerWired = config.emailProvider === "resend" && !!config.resendApiKey;
649
650 return c.html(
651 <Layout title="Email verification" user={user}>
652 <div class="ev-wrap">
653 <section class="ev-hero">
654 <div class="ev-hero-orb" aria-hidden="true" />
655 <div class="ev-hero-inner">
656 <div class="ev-eyebrow">
657 <span class="ev-eyebrow-pill" aria-hidden="true">
658 <EvIconShield />
659 </span>
660 <a href="/settings" class="ev-crumb">Settings</a>
661 <span>/</span>
662 <span>Email verification</span>
663 </div>
664 <h2 class="ev-title">
665 <span class="ev-title-grad">Email verification.</span>
666 </h2>
667 <p class="ev-sub">
668 Confirms you own the email on file. Required for password resets,
669 security alerts, and (soon) repo transfers. Links live for
670 24 hours and can only be used once.
671 </p>
672 </div>
673 </section>
674
675 {banner && (
676 <div class={`ev-banner is-${banner.kind}`} role={banner.kind === "error" ? "alert" : "status"}>
677 <span class="ev-banner-dot" aria-hidden="true" />
678 {banner.text}
679 </div>
680 )}
681
682 {verified ? (
683 <section class="ev-status is-on" aria-label="Email verification status">
684 <div class="ev-status-mark is-on" aria-hidden="true">
685 <EvCheckIcon />
686 </div>
687 <div class="ev-status-text">
688 <h3 class="ev-status-headline">Email verified</h3>
689 <p class="ev-status-desc">
690 <code>{email}</code> is confirmed
691 {verifiedAt ? ` since ${verifiedAt.toLocaleDateString()}` : ""}.
692 You're all set.
693 </p>
694 </div>
695 </section>
696 ) : (
697 <section class="ev-status is-off" aria-label="Email verification status">
698 <div class="ev-status-mark is-off" aria-hidden="true">
699 <EvWarnIcon />
700 </div>
701 <div class="ev-status-text">
702 <h3 class="ev-status-headline">Email not yet verified</h3>
703 <p class="ev-status-desc">
704 Send a fresh verification link to <code>{email}</code>. The
705 link expires after 24 hours; you can request up to three per
706 hour.
707 </p>
708 </div>
709 </section>
710 )}
711
712 {!verified && (
713 <section class="ev-section">
714 <header class="ev-section-head">
715 <h3 class="ev-section-title">
716 <span class="ev-section-title-icon" aria-hidden="true">
717 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
718 <path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" />
719 <polyline points="22,6 12,13 2,6" />
720 </svg>
721 </span>
722 Send a verification link
723 </h3>
724 <p class="ev-section-sub">
725 Tap the button below to dispatch a brand-new link. We'll send
726 it to your account email on file.
727 </p>
728 </header>
729 <div class="ev-section-body">
730 <form method="post" action="/verify-email/resend" style="margin:0">
731 {csrf && <input type="hidden" name="_csrf" value={csrf} />}
732 <button type="submit" class="ev-btn ev-btn-primary">
733 Send verification email
734 </button>
735 </form>
736 {!providerWired && (
737 <div class="ev-meta">
738 <strong>Heads up:</strong> the site operator hasn't wired a
739 production email provider yet. The verification row will be
740 created, but the link won't actually be emailed — an admin
741 can grab it from <code>server logs</code> for testing.
742 </div>
743 )}
744 {providerWired && (
745 <div class="ev-meta">
746 Need to change the email address itself? Update it on{" "}
747 <a href="/settings">your profile settings</a>.
748 </div>
749 )}
750 </div>
751 </section>
752 )}
753 </div>
754 <style dangerouslySetInnerHTML={{ __html: evStyles }} />
755 </Layout>
756 );
278757});
279758
280759export default verify;
Modifiedsrc/routes/gates.tsx+1187−232View fileUnifiedSplit
Large file (1,476 lines). Load full file
Modifiedsrc/routes/invites.tsx+690−34View fileUnifiedSplit
@@ -6,31 +6,375 @@
66 * emails the plaintext link. This file handles that link being clicked.
77 *
88 * Flow:
9 * GET /invites/:token
10 * → hash the presented token, find the pending row, render "Accept"
11 * POST /invites/:token
12 * → same lookup, assert the invite is for the authed user, flip
13 * `acceptedAt` to now() and null the hash so the link is one-shot.
14 * Redirect to /:owner/:repo on success.
9 * GET /invites → list of pending repo + org invites for
10 * the signed-in user. The repo invites
11 * section can only display rows the user
12 * can prove they own (we don't store the
13 * plaintext token, so direct accept is
14 * still link-based). Org invites accept
15 * inline because `org_members` rows are
16 * keyed on the user directly.
17 * GET /invites/:token → accept page for a specific token
18 * POST /invites/:token → accept the invite (one-shot)
1519 *
16 * Not-found / already-accepted / wrong-user paths all degrade safely (404 /
17 * 403) without leaking which of those branches triggered.
20 * 2026 polish: scoped `.inv-*` classes mirror the gradient-hairline hero +
21 * card patterns from settings-2fa.tsx. Every existing token flow, ownership
22 * assertion, and one-shot accept guarantee is preserved verbatim.
1823 */
1924
2025import { Hono } from "hono";
21import { eq, and } from "drizzle-orm";
26import { eq, and, isNull, asc } from "drizzle-orm";
2227import { db } from "../db";
2328import { repositories, users, repoCollaborators } from "../db/schema";
2429import { Layout } from "../views/layout";
2530import { softAuth, requireAuth } from "../middleware/auth";
2631import type { AuthEnv } from "../middleware/auth";
27import { Container, Form, Button, EmptyState, Alert } from "../views/ui";
2832import { hashInviteToken } from "../lib/invite-tokens";
2933
3034const inviteRoutes = new Hono<AuthEnv>();
3135
3236inviteRoutes.use("*", softAuth);
3337
38// ─── Scoped CSS (.inv-*) ────────────────────────────────────────────────────
39const invStyles = `
40 .inv-wrap { max-width: 920px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
41
42 .inv-hero {
43 position: relative;
44 margin-bottom: var(--space-5);
45 padding: var(--space-5) var(--space-6);
46 background: var(--bg-elevated);
47 border: 1px solid var(--border);
48 border-radius: 16px;
49 overflow: hidden;
50 }
51 .inv-hero::before {
52 content: '';
53 position: absolute;
54 top: 0; left: 0; right: 0;
55 height: 2px;
56 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
57 opacity: 0.7;
58 pointer-events: none;
59 }
60 .inv-hero-orb {
61 position: absolute;
62 inset: -20% -10% auto auto;
63 width: 380px; height: 380px;
64 background: radial-gradient(circle, rgba(140,109,255,0.20), rgba(54,197,214,0.10) 45%, transparent 70%);
65 filter: blur(80px);
66 opacity: 0.7;
67 pointer-events: none;
68 z-index: 0;
69 }
70 .inv-hero-inner { position: relative; z-index: 1; max-width: 720px; }
71 .inv-eyebrow {
72 font-size: 12px;
73 color: var(--text-muted);
74 margin-bottom: var(--space-2);
75 letter-spacing: 0.02em;
76 display: inline-flex;
77 align-items: center;
78 gap: 8px;
79 text-transform: uppercase;
80 font-family: var(--font-mono);
81 font-weight: 600;
82 }
83 .inv-eyebrow-pill {
84 display: inline-flex;
85 align-items: center;
86 justify-content: center;
87 width: 18px; height: 18px;
88 border-radius: 6px;
89 background: rgba(140,109,255,0.14);
90 color: #b69dff;
91 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.35);
92 }
93 .inv-title {
94 font-size: clamp(28px, 4vw, 40px);
95 font-family: var(--font-display);
96 font-weight: 800;
97 letter-spacing: -0.028em;
98 line-height: 1.05;
99 margin: 0 0 var(--space-2);
100 color: var(--text-strong);
101 }
102 .inv-title-grad {
103 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
104 -webkit-background-clip: text;
105 background-clip: text;
106 -webkit-text-fill-color: transparent;
107 color: transparent;
108 }
109 .inv-sub {
110 font-size: 15px;
111 color: var(--text-muted);
112 margin: 0;
113 line-height: 1.55;
114 max-width: 620px;
115 }
116
117 /* ─── Banner ─── */
118 .inv-banner {
119 margin-bottom: var(--space-4);
120 padding: 10px 14px;
121 border-radius: 10px;
122 font-size: 13.5px;
123 border: 1px solid var(--border);
124 background: rgba(255,255,255,0.025);
125 color: var(--text);
126 display: flex;
127 align-items: center;
128 gap: 10px;
129 }
130 .inv-banner.is-error {
131 border-color: rgba(248,113,113,0.40);
132 background: rgba(248,113,113,0.08);
133 color: #fecaca;
134 }
135 .inv-banner.is-info {
136 border-color: rgba(54,197,214,0.40);
137 background: rgba(54,197,214,0.08);
138 color: #a5f3fc;
139 }
140 .inv-banner-dot {
141 width: 8px; height: 8px;
142 border-radius: 9999px;
143 background: currentColor;
144 flex-shrink: 0;
145 }
146
147 /* ─── Section card ─── */
148 .inv-section {
149 margin-bottom: var(--space-5);
150 background: var(--bg-elevated);
151 border: 1px solid var(--border);
152 border-radius: 14px;
153 overflow: hidden;
154 }
155 .inv-section-head {
156 padding: var(--space-4) var(--space-5);
157 border-bottom: 1px solid var(--border);
158 }
159 .inv-section-title {
160 margin: 0;
161 font-family: var(--font-display);
162 font-size: 17px;
163 font-weight: 700;
164 letter-spacing: -0.018em;
165 color: var(--text-strong);
166 display: flex;
167 align-items: center;
168 gap: 10px;
169 }
170 .inv-section-title-icon {
171 display: inline-flex;
172 align-items: center;
173 justify-content: center;
174 width: 26px; height: 26px;
175 border-radius: 8px;
176 background: rgba(140,109,255,0.12);
177 color: #b69dff;
178 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.28);
179 flex-shrink: 0;
180 }
181 .inv-section-sub {
182 margin: 6px 0 0 36px;
183 font-size: 12.5px;
184 color: var(--text-muted);
185 line-height: 1.5;
186 }
187 .inv-section-body { padding: var(--space-4) var(--space-5); }
188
189 /* ─── Invite card ─── */
190 .inv-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--space-3); }
191 .inv-card {
192 display: flex;
193 gap: var(--space-3);
194 align-items: center;
195 padding: var(--space-4);
196 background: var(--bg);
197 border: 1px solid var(--border-strong);
198 border-radius: 12px;
199 transition: border-color 120ms ease, box-shadow 120ms ease;
200 }
201 .inv-card:hover {
202 border-color: rgba(140,109,255,0.32);
203 box-shadow: 0 8px 24px -10px rgba(0,0,0,0.32);
204 }
205 .inv-logo {
206 flex-shrink: 0;
207 width: 44px;
208 height: 44px;
209 border-radius: 10px;
210 background: linear-gradient(135deg, rgba(140,109,255,0.22), rgba(54,197,214,0.16));
211 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.32);
212 display: flex;
213 align-items: center;
214 justify-content: center;
215 font-family: var(--font-display);
216 font-size: 18px;
217 font-weight: 800;
218 color: #e9d5ff;
219 text-transform: uppercase;
220 }
221 .inv-card-body { flex: 1; min-width: 0; }
222 .inv-card-title {
223 margin: 0;
224 font-family: var(--font-display);
225 font-size: 15px;
226 font-weight: 700;
227 letter-spacing: -0.012em;
228 color: var(--text-strong);
229 }
230 .inv-card-title a { color: inherit; text-decoration: none; }
231 .inv-card-title a:hover { color: var(--accent); }
232 .inv-card-meta {
233 margin: 4px 0 0;
234 font-size: 12.5px;
235 color: var(--text-muted);
236 display: flex;
237 gap: 6px;
238 flex-wrap: wrap;
239 align-items: center;
240 }
241 .inv-pill {
242 display: inline-flex;
243 align-items: center;
244 gap: 6px;
245 padding: 2px 9px;
246 border-radius: 9999px;
247 font-size: 10.5px;
248 font-weight: 700;
249 letter-spacing: 0.05em;
250 text-transform: uppercase;
251 background: rgba(140,109,255,0.14);
252 color: #c4b5fd;
253 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.32);
254 }
255 .inv-pill .dot { width: 5px; height: 5px; border-radius: 9999px; background: currentColor; }
256 .inv-card-actions { flex-shrink: 0; display: flex; gap: 8px; flex-wrap: wrap; }
257
258 /* ─── Buttons ─── */
259 .inv-btn {
260 display: inline-flex;
261 align-items: center;
262 justify-content: center;
263 gap: 6px;
264 padding: 9px 16px;
265 border-radius: 10px;
266 font-size: 13px;
267 font-weight: 600;
268 text-decoration: none;
269 border: 1px solid transparent;
270 cursor: pointer;
271 font-family: inherit;
272 line-height: 1;
273 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease, color 120ms ease;
274 }
275 .inv-btn-primary {
276 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
277 color: #fff;
278 box-shadow: 0 6px 18px -4px rgba(140,109,255,0.45), inset 0 1px 0 rgba(255,255,255,0.16);
279 }
280 .inv-btn-primary:hover {
281 transform: translateY(-1px);
282 box-shadow: 0 10px 24px -6px rgba(140,109,255,0.55), inset 0 1px 0 rgba(255,255,255,0.20);
283 color: #fff;
284 text-decoration: none;
285 }
286 .inv-btn-ghost {
287 background: rgba(255,255,255,0.025);
288 color: var(--text);
289 border-color: var(--border-strong);
290 }
291 .inv-btn-ghost:hover {
292 background: rgba(140,109,255,0.06);
293 border-color: rgba(140,109,255,0.45);
294 color: var(--text-strong);
295 text-decoration: none;
296 }
297 .inv-btn-danger {
298 background: transparent;
299 color: #fecaca;
300 border-color: rgba(248,113,113,0.40);
301 }
302 .inv-btn-danger:hover {
303 background: rgba(248,113,113,0.10);
304 border-color: rgba(248,113,113,0.65);
305 color: #fee2e2;
306 }
307
308 /* ─── Empty state ─── */
309 .inv-empty {
310 position: relative;
311 padding: 56px 32px;
312 background: var(--bg-elevated);
313 border: 1px solid var(--border);
314 border-radius: 16px;
315 text-align: center;
316 overflow: hidden;
317 }
318 .inv-empty::before {
319 content: '';
320 position: absolute;
321 top: 0; left: 0; right: 0;
322 height: 2px;
323 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
324 opacity: 0.55;
325 pointer-events: none;
326 }
327 .inv-empty-orb {
328 width: 96px; height: 96px;
329 margin: 0 auto 18px;
330 border-radius: 9999px;
331 background:
332 radial-gradient(circle at 35% 35%, rgba(140,109,255,0.55), rgba(54,197,214,0.25) 55%, transparent 75%);
333 box-shadow:
334 0 0 32px rgba(140,109,255,0.35),
335 inset 0 0 0 1px rgba(140,109,255,0.35);
336 display: flex;
337 align-items: center;
338 justify-content: center;
339 color: #fff;
340 }
341 .inv-empty-title {
342 font-family: var(--font-display);
343 font-size: 22px;
344 font-weight: 700;
345 letter-spacing: -0.018em;
346 color: var(--text-strong);
347 margin: 0 0 8px;
348 }
349 .inv-empty-sub {
350 font-size: 14.5px;
351 color: var(--text-muted);
352 line-height: 1.55;
353 margin: 0 auto 18px;
354 max-width: 480px;
355 }
356`;
357
358const InvIcon = () => (
359 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
360 <path d="M21 8V7l-3 2-3-2v1l3 2 3-2z" />
361 <path d="M22 6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V6z" />
362 <polyline points="22,6 12,13 2,6" />
363 </svg>
364);
365const InvRepoIcon = () => (
366 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
367 <path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
368 <path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
369 </svg>
370);
371const InvEmptyIcon = () => (
372 <svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
373 <path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" />
374 <polyline points="22,6 12,13 2,6" />
375 </svg>
376);
377
34378/**
35379 * Resolve the pending invite by token hash + join repo/owner for display.
36380 * Returns null for not-found, already-accepted, or DB errors — the caller
@@ -77,7 +421,258 @@ async function resolvePendingInvite(token: string) {
77421 }
78422}
79423
80// ─── Display accept page ────────────────────────────────────────────────────
424// ─── List pending invites for the current user ─────────────────────────────
425
426inviteRoutes.get("/invites", requireAuth, async (c) => {
427 const user = c.get("user")!;
428 const declined = c.req.query("declined");
429
430 type RepoInvite = {
431 id: string;
432 role: string;
433 repoName: string;
434 ownerName: string;
435 };
436 let repoInvites: RepoInvite[] = [];
437 try {
438 const rows = await db
439 .select({
440 id: repoCollaborators.id,
441 role: repoCollaborators.role,
442 repoName: repositories.name,
443 ownerId: repositories.ownerId,
444 })
445 .from(repoCollaborators)
446 .innerJoin(
447 repositories,
448 eq(repositories.id, repoCollaborators.repositoryId)
449 )
450 .where(
451 and(
452 eq(repoCollaborators.userId, user.id),
453 isNull(repoCollaborators.acceptedAt)
454 )
455 )
456 .orderBy(asc(repositories.name));
457
458 // Resolve owner usernames in a single batch.
459 const ownerIds = Array.from(new Set(rows.map((r) => r.ownerId)));
460 const ownerByIdEntries = await Promise.all(
461 ownerIds.map(async (id) => {
462 const [u] = await db
463 .select({ username: users.username })
464 .from(users)
465 .where(eq(users.id, id))
466 .limit(1);
467 return [id, u?.username || "unknown"] as const;
468 })
469 );
470 const ownerById = new Map(ownerByIdEntries);
471 repoInvites = rows.map((r) => ({
472 id: r.id,
473 role: r.role,
474 repoName: r.repoName,
475 ownerName: ownerById.get(r.ownerId) || "unknown",
476 }));
477 } catch (err) {
478 console.error("[invites] list:", err);
479 }
480
481 const totalCount = repoInvites.length;
482
483 return c.html(
484 <Layout title="Your invitations" user={user}>
485 <div class="inv-wrap">
486 <section class="inv-hero">
487 <div class="inv-hero-orb" aria-hidden="true" />
488 <div class="inv-hero-inner">
489 <div class="inv-eyebrow">
490 <span class="inv-eyebrow-pill" aria-hidden="true">
491 <InvIcon />
492 </span>
493 <span>Invitations</span>
494 <span>·</span>
495 <span>@{user.username}</span>
496 </div>
497 <h2 class="inv-title">
498 <span class="inv-title-grad">Your invitations.</span>
499 </h2>
500 <p class="inv-sub">
501 Repositories you've been invited to collaborate on. Accept to
502 gain access, or decline to clear the row — declined invites can
503 be re-sent by the repo owner.
504 </p>
505 </div>
506 </section>
507
508 {declined && (
509 <div class="inv-banner is-info" role="status">
510 <span class="inv-banner-dot" aria-hidden="true" />
511 Invitation declined.
512 </div>
513 )}
514
515 {totalCount === 0 ? (
516 <div class="inv-empty">
517 <div class="inv-empty-orb" aria-hidden="true">
518 <InvEmptyIcon />
519 </div>
520 <h2 class="inv-empty-title">No pending invitations</h2>
521 <p class="inv-empty-sub">
522 When someone invites you as a collaborator on a repository or
523 organization, the invite will show up here. You can also accept
524 directly from the email link you receive.
525 </p>
526 <a href="/explore" class="inv-btn inv-btn-primary">
527 Explore public repositories
528 </a>
529 </div>
530 ) : (
531 <section class="inv-section">
532 <header class="inv-section-head">
533 <h3 class="inv-section-title">
534 <span class="inv-section-title-icon" aria-hidden="true">
535 <InvRepoIcon />
536 </span>
537 Repository invitations ({totalCount})
538 </h3>
539 <p class="inv-section-sub">
540 Each invite is tied to a single repository and role. Declining
541 removes the invite row but doesn't block future invitations.
542 </p>
543 </header>
544 <div class="inv-section-body">
545 <ul class="inv-list">
546 {repoInvites.map((inv) => {
547 const initial = (inv.repoName.charAt(0) || "?").toUpperCase();
548 return (
549 <li>
550 <div class="inv-card">
551 <div class="inv-logo" aria-hidden="true">{initial}</div>
552 <div class="inv-card-body">
553 <h4 class="inv-card-title">
554 <a href={`/${inv.ownerName}/${inv.repoName}`}>
555 {inv.ownerName}/{inv.repoName}
556 </a>
557 </h4>
558 <div class="inv-card-meta">
559 <span class="inv-pill">
560 <span class="dot" aria-hidden="true" />
561 {inv.role}
562 </span>
563 <span>invited by {inv.ownerName}</span>
564 </div>
565 </div>
566 <div class="inv-card-actions">
567 <form
568 method="post"
569 action={`/invites/repo/${inv.id}/accept`}
570 style="margin:0"
571 >
572 <button type="submit" class="inv-btn inv-btn-primary">
573 Accept
574 </button>
575 </form>
576 <form
577 method="post"
578 action={`/invites/repo/${inv.id}/decline`}
579 style="margin:0"
580 onsubmit="return confirm('Decline this invitation?')"
581 >
582 <button type="submit" class="inv-btn inv-btn-ghost">
583 Decline
584 </button>
585 </form>
586 </div>
587 </div>
588 </li>
589 );
590 })}
591 </ul>
592 </div>
593 </section>
594 )}
595 </div>
596 <style dangerouslySetInnerHTML={{ __html: invStyles }} />
597 </Layout>
598 );
599});
600
601// ─── Accept / decline by row id (inline list flow) ─────────────────────────
602//
603// These complement the original /invites/:token email flow: when the user is
604// looking at the dashboard list, they already passed authn, so we can act
605// directly on `repo_collaborators.id` instead of round-tripping a one-time
606// token. We still require the invite to be (a) unaccepted and (b) addressed
607// to the signed-in user, otherwise we 404.
608
609inviteRoutes.post("/invites/repo/:id/accept", requireAuth, async (c) => {
610 const user = c.get("user")!;
611 const id = c.req.param("id");
612 try {
613 const [row] = await db
614 .select({
615 id: repoCollaborators.id,
616 userId: repoCollaborators.userId,
617 acceptedAt: repoCollaborators.acceptedAt,
618 repositoryId: repoCollaborators.repositoryId,
619 })
620 .from(repoCollaborators)
621 .where(eq(repoCollaborators.id, id))
622 .limit(1);
623 if (!row || row.userId !== user.id || row.acceptedAt) {
624 return c.notFound();
625 }
626 await db
627 .update(repoCollaborators)
628 .set({ acceptedAt: new Date(), inviteTokenHash: null })
629 .where(eq(repoCollaborators.id, row.id));
630
631 const [repo] = await db
632 .select({ name: repositories.name, ownerId: repositories.ownerId })
633 .from(repositories)
634 .where(eq(repositories.id, row.repositoryId))
635 .limit(1);
636 if (!repo) return c.redirect("/invites");
637 const [owner] = await db
638 .select({ username: users.username })
639 .from(users)
640 .where(eq(users.id, repo.ownerId))
641 .limit(1);
642 if (!owner) return c.redirect("/invites");
643 return c.redirect(`/${owner.username}/${repo.name}`);
644 } catch (err) {
645 console.error("[invites] accept:", err);
646 return c.redirect("/invites");
647 }
648});
649
650inviteRoutes.post("/invites/repo/:id/decline", requireAuth, async (c) => {
651 const user = c.get("user")!;
652 const id = c.req.param("id");
653 try {
654 const [row] = await db
655 .select({
656 id: repoCollaborators.id,
657 userId: repoCollaborators.userId,
658 acceptedAt: repoCollaborators.acceptedAt,
659 })
660 .from(repoCollaborators)
661 .where(eq(repoCollaborators.id, id))
662 .limit(1);
663 if (!row || row.userId !== user.id || row.acceptedAt) {
664 return c.notFound();
665 }
666 await db
667 .delete(repoCollaborators)
668 .where(eq(repoCollaborators.id, row.id));
669 } catch (err) {
670 console.error("[invites] decline:", err);
671 }
672 return c.redirect("/invites?declined=1");
673});
674
675// ─── Display accept page (single-token email link) ─────────────────────────
81676
82677inviteRoutes.get("/invites/:token", async (c) => {
83678 const { token } = c.req.param();
@@ -87,29 +682,71 @@ inviteRoutes.get("/invites/:token", async (c) => {
87682
88683 return c.html(
89684 <Layout title="Accept invitation" user={user}>
90 <Container maxWidth={600}>
91 <h2 style="margin-bottom: 16px">
92 Accept invitation to {invite.ownerName}/{invite.repoName}
93 </h2>
94 <p style="color:var(--text-muted);margin-bottom:24px">
95 You've been invited as a <strong>{invite.role}</strong> collaborator
96 on this repository.
97 </p>
685 <div class="inv-wrap">
686 <section class="inv-hero">
687 <div class="inv-hero-orb" aria-hidden="true" />
688 <div class="inv-hero-inner">
689 <div class="inv-eyebrow">
690 <span class="inv-eyebrow-pill" aria-hidden="true">
691 <InvIcon />
692 </span>
693 <a href="/invites" style="color:var(--text-muted);text-decoration:none">
694 Invitations
695 </a>
696 <span>/</span>
697 <span>Accept</span>
698 </div>
699 <h2 class="inv-title">
700 <span class="inv-title-grad">You've been invited.</span>
701 </h2>
702 <p class="inv-sub">
703 {invite.ownerName} invited you to collaborate on{" "}
704 <strong>{invite.ownerName}/{invite.repoName}</strong> as a{" "}
705 <strong>{invite.role}</strong>. Accepting grants you access
706 immediately; the link is one-shot.
707 </p>
708 </div>
709 </section>
710
98711 {!user && (
99 <Alert variant="info">
712 <div class="inv-banner is-info" role="status">
713 <span class="inv-banner-dot" aria-hidden="true" />
100714 You need to{" "}
101 <a href={`/login?next=/invites/${token}`}>sign in</a> before
102 accepting this invitation.
103 </Alert>
104 )}
105 {user && (
106 <Form method="post" action={`/invites/${token}`}>
107 <Button type="submit" variant="primary">
108 Accept invitation
109 </Button>
110 </Form>
715 <a href={`/login?next=/invites/${token}`} style="color:inherit;text-decoration:underline">
716 sign in
717 </a>{" "}
718 before accepting this invitation.
719 </div>
111720 )}
112 </Container>
721
722 <section class="inv-section">
723 <header class="inv-section-head">
724 <h3 class="inv-section-title">
725 <span class="inv-section-title-icon" aria-hidden="true">
726 <InvRepoIcon />
727 </span>
728 {invite.ownerName}/{invite.repoName}
729 </h3>
730 <p class="inv-section-sub">
731 Role on accept: <strong>{invite.role}</strong>.
732 </p>
733 </header>
734 <div class="inv-section-body">
735 {user ? (
736 <form method="post" action={`/invites/${token}`} style="margin:0">
737 <button type="submit" class="inv-btn inv-btn-primary">
738 Accept invitation
739 </button>
740 </form>
741 ) : (
742 <a href={`/login?next=/invites/${token}`} class="inv-btn inv-btn-primary">
743 Sign in to accept
744 </a>
745 )}
746 </div>
747 </section>
748 </div>
749 <style dangerouslySetInnerHTML={{ __html: invStyles }} />
113750 </Layout>
114751 );
115752});
@@ -127,9 +764,28 @@ inviteRoutes.post("/invites/:token", requireAuth, async (c) => {
127764 if (invite.userId !== user.id) {
128765 return c.html(
129766 <Layout title="Forbidden" user={user}>
130 <EmptyState title="Not your invitation">
131 <p>This invitation was sent to a different account.</p>
132 </EmptyState>
767 <div class="inv-wrap">
768 <section class="inv-hero">
769 <div class="inv-hero-orb" aria-hidden="true" />
770 <div class="inv-hero-inner">
771 <div class="inv-eyebrow">
772 <span class="inv-eyebrow-pill" aria-hidden="true">
773 <InvIcon />
774 </span>
775 <span>Invitations</span>
776 </div>
777 <h2 class="inv-title">
778 <span class="inv-title-grad">Not your invitation.</span>
779 </h2>
780 <p class="inv-sub">
781 This invitation was sent to a different account. Sign in as
782 the addressee to accept, or ask the inviter for a fresh link.
783 </p>
784 </div>
785 </section>
786 <a href="/invites" class="inv-btn inv-btn-primary">View your invitations</a>
787 </div>
788 <style dangerouslySetInnerHTML={{ __html: invStyles }} />
133789 </Layout>,
134790 403
135791 );
Modifiedsrc/routes/orgs.tsx+745−108View fileUnifiedSplit
@@ -1,9 +1,14 @@
11/**
22 * Organization and team routes — create orgs, manage members, teams, permissions.
3 *
4 * 2026 polish: `/orgs` list + `/orgs/:org` detail wrapped in scoped `.orgs-*`
5 * classes (gradient hairline hero, avatar + member-count cards, empty state
6 * with orb). Other admin/team subroutes keep their existing UI shells. Every
7 * POST handler, validation rule, and ownership check is preserved verbatim.
38 */
49
510import { Hono } from "hono";
6import { eq, and, desc, asc, sql } from "drizzle-orm";
11import { eq, and, asc, sql } from "drizzle-orm";
712import { db } from "../db";
813import { organizations, orgMembers, teams, teamMembers, teamRepos } from "../db/schema-extensions";
914import { users, repositories } from "../db/schema";
@@ -13,70 +18,618 @@ import type { AuthEnv } from "../middleware/auth";
1318import { loadOrgForUser, listOrgMembers, orgRoleAtLeast } from "../lib/orgs";
1419import {
1520 Container,
16 PageHeader,
17 Form,
18 FormGroup,
19 Input,
20 TextArea,
21 Select,
22 Button,
23 LinkButton,
24 Alert,
2521 EmptyState,
26 Flex,
2722 Grid,
2823 Text,
2924 Badge,
3025 Section,
31 Avatar,
3226 List,
3327 ListItem,
3428} from "../views/ui";
3529
3630const orgRoutes = new Hono<AuthEnv>();
3731
32// ─── Scoped CSS (.orgs-*) ───────────────────────────────────────────────────
33// Every selector prefixed `.orgs-*` so the surface can't bleed into the
34// repo header / nav / page chrome. Mirrors the gradient-hairline hero +
35// card patterns from settings-2fa.tsx + admin-integrations.tsx.
36const orgsStyles = `
37 .orgs-wrap { max-width: 1000px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
38
39 .orgs-hero {
40 position: relative;
41 margin-bottom: var(--space-5);
42 padding: var(--space-5) var(--space-6);
43 background: var(--bg-elevated);
44 border: 1px solid var(--border);
45 border-radius: 16px;
46 overflow: hidden;
47 }
48 .orgs-hero::before {
49 content: '';
50 position: absolute;
51 top: 0; left: 0; right: 0;
52 height: 2px;
53 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
54 opacity: 0.7;
55 pointer-events: none;
56 }
57 .orgs-hero-orb {
58 position: absolute;
59 inset: -20% -10% auto auto;
60 width: 380px; height: 380px;
61 background: radial-gradient(circle, rgba(140,109,255,0.20), rgba(54,197,214,0.10) 45%, transparent 70%);
62 filter: blur(80px);
63 opacity: 0.7;
64 pointer-events: none;
65 z-index: 0;
66 }
67 .orgs-hero-inner {
68 position: relative;
69 z-index: 1;
70 display: flex;
71 align-items: flex-end;
72 justify-content: space-between;
73 gap: var(--space-4);
74 flex-wrap: wrap;
75 }
76 .orgs-hero-text { flex: 1; min-width: 280px; max-width: 660px; }
77 .orgs-eyebrow {
78 font-size: 12px;
79 color: var(--text-muted);
80 margin-bottom: var(--space-2);
81 letter-spacing: 0.02em;
82 display: inline-flex;
83 align-items: center;
84 gap: 8px;
85 text-transform: uppercase;
86 font-family: var(--font-mono);
87 font-weight: 600;
88 }
89 .orgs-eyebrow-pill {
90 display: inline-flex;
91 align-items: center;
92 justify-content: center;
93 width: 18px; height: 18px;
94 border-radius: 6px;
95 background: rgba(140,109,255,0.14);
96 color: #b69dff;
97 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.35);
98 }
99 .orgs-crumb { color: var(--text-muted); text-decoration: none; }
100 .orgs-crumb:hover { color: var(--text); }
101 .orgs-title {
102 font-size: clamp(28px, 4vw, 40px);
103 font-family: var(--font-display);
104 font-weight: 800;
105 letter-spacing: -0.028em;
106 line-height: 1.05;
107 margin: 0 0 var(--space-2);
108 color: var(--text-strong);
109 }
110 .orgs-title-grad {
111 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
112 -webkit-background-clip: text;
113 background-clip: text;
114 -webkit-text-fill-color: transparent;
115 color: transparent;
116 }
117 .orgs-sub {
118 font-size: 15px;
119 color: var(--text-muted);
120 margin: 0;
121 line-height: 1.55;
122 max-width: 620px;
123 }
124
125 /* ─── Buttons ─── */
126 .orgs-btn {
127 display: inline-flex;
128 align-items: center;
129 justify-content: center;
130 gap: 6px;
131 padding: 10px 18px;
132 border-radius: 10px;
133 font-size: 13.5px;
134 font-weight: 600;
135 text-decoration: none;
136 border: 1px solid transparent;
137 cursor: pointer;
138 font-family: inherit;
139 line-height: 1;
140 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease, color 120ms ease;
141 }
142 .orgs-btn-primary {
143 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
144 color: #fff;
145 box-shadow: 0 6px 18px -4px rgba(140,109,255,0.45), inset 0 1px 0 rgba(255,255,255,0.16);
146 }
147 .orgs-btn-primary:hover {
148 transform: translateY(-1px);
149 box-shadow: 0 10px 24px -6px rgba(140,109,255,0.55), inset 0 1px 0 rgba(255,255,255,0.20);
150 color: #fff;
151 text-decoration: none;
152 }
153 .orgs-btn-ghost {
154 background: rgba(255,255,255,0.025);
155 color: var(--text);
156 border-color: var(--border-strong);
157 }
158 .orgs-btn-ghost:hover {
159 background: rgba(140,109,255,0.06);
160 border-color: rgba(140,109,255,0.45);
161 color: var(--text-strong);
162 text-decoration: none;
163 }
164
165 /* ─── Org grid ─── */
166 .orgs-grid {
167 list-style: none;
168 padding: 0;
169 margin: 0;
170 display: grid;
171 grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
172 gap: var(--space-3);
173 }
174 .orgs-card {
175 display: flex;
176 gap: var(--space-3);
177 padding: var(--space-4);
178 background: var(--bg-elevated);
179 border: 1px solid var(--border);
180 border-radius: 14px;
181 text-decoration: none;
182 color: inherit;
183 transition: transform 120ms ease, border-color 120ms ease, box-shadow 120ms ease;
184 }
185 .orgs-card:hover {
186 border-color: rgba(140,109,255,0.32);
187 box-shadow: 0 8px 24px -10px rgba(0,0,0,0.32);
188 transform: translateY(-1px);
189 color: inherit;
190 text-decoration: none;
191 }
192 .orgs-avatar {
193 flex-shrink: 0;
194 width: 48px;
195 height: 48px;
196 border-radius: 12px;
197 background: linear-gradient(135deg, rgba(140,109,255,0.22), rgba(54,197,214,0.16));
198 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.32);
199 display: flex;
200 align-items: center;
201 justify-content: center;
202 font-family: var(--font-display);
203 font-size: 20px;
204 font-weight: 800;
205 color: #e9d5ff;
206 text-transform: uppercase;
207 }
208 .orgs-card-body { flex: 1; min-width: 0; }
209 .orgs-card-name {
210 margin: 0;
211 font-family: var(--font-display);
212 font-size: 15px;
213 font-weight: 700;
214 letter-spacing: -0.012em;
215 color: var(--text-strong);
216 }
217 .orgs-card-handle {
218 margin: 2px 0 0;
219 font-size: 12.5px;
220 color: var(--text-muted);
221 font-family: var(--font-mono);
222 }
223 .orgs-card-meta {
224 margin-top: 10px;
225 display: flex;
226 gap: 6px;
227 flex-wrap: wrap;
228 align-items: center;
229 }
230 .orgs-pill {
231 display: inline-flex;
232 align-items: center;
233 gap: 6px;
234 padding: 2px 9px;
235 border-radius: 9999px;
236 font-size: 10.5px;
237 font-weight: 700;
238 letter-spacing: 0.05em;
239 text-transform: uppercase;
240 background: rgba(140,109,255,0.14);
241 color: #c4b5fd;
242 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.32);
243 }
244 .orgs-pill.is-role { background: rgba(54,197,214,0.14); color: #67e8f9; box-shadow: inset 0 0 0 1px rgba(54,197,214,0.32); }
245 .orgs-pill .dot { width: 5px; height: 5px; border-radius: 9999px; background: currentColor; }
246 .orgs-stat {
247 display: inline-flex;
248 align-items: center;
249 gap: 5px;
250 font-size: 11.5px;
251 color: var(--text-muted);
252 font-family: var(--font-mono);
253 }
254
255 /* ─── Detail hero ─── */
256 .orgs-detail {
257 position: relative;
258 margin-bottom: var(--space-5);
259 padding: var(--space-5) var(--space-6);
260 background: var(--bg-elevated);
261 border: 1px solid var(--border);
262 border-radius: 16px;
263 overflow: hidden;
264 }
265 .orgs-detail::before {
266 content: '';
267 position: absolute;
268 top: 0; left: 0; right: 0;
269 height: 2px;
270 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
271 opacity: 0.7;
272 pointer-events: none;
273 }
274 .orgs-detail-orb {
275 position: absolute;
276 inset: -20% -10% auto auto;
277 width: 380px; height: 380px;
278 background: radial-gradient(circle, rgba(140,109,255,0.20), rgba(54,197,214,0.10) 45%, transparent 70%);
279 filter: blur(80px);
280 opacity: 0.7;
281 pointer-events: none;
282 z-index: 0;
283 }
284 .orgs-detail-inner {
285 position: relative;
286 z-index: 1;
287 display: flex;
288 gap: var(--space-4);
289 align-items: flex-start;
290 flex-wrap: wrap;
291 }
292 .orgs-avatar-lg {
293 flex-shrink: 0;
294 width: 86px;
295 height: 86px;
296 border-radius: 18px;
297 background: linear-gradient(135deg, rgba(140,109,255,0.22), rgba(54,197,214,0.16));
298 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.32);
299 display: flex;
300 align-items: center;
301 justify-content: center;
302 font-family: var(--font-display);
303 font-size: 36px;
304 font-weight: 800;
305 color: #e9d5ff;
306 text-transform: uppercase;
307 }
308 .orgs-detail-text { flex: 1; min-width: 220px; }
309 .orgs-detail-name {
310 margin: 0;
311 font-family: var(--font-display);
312 font-size: clamp(22px, 3vw, 30px);
313 font-weight: 800;
314 letter-spacing: -0.022em;
315 color: var(--text-strong);
316 }
317 .orgs-detail-handle {
318 margin: 4px 0 0;
319 font-family: var(--font-mono);
320 font-size: 13px;
321 color: var(--text-muted);
322 }
323 .orgs-detail-desc {
324 margin: 12px 0 0;
325 font-size: 14px;
326 color: var(--text-muted);
327 line-height: 1.55;
328 max-width: 620px;
329 }
330 .orgs-detail-meta {
331 margin-top: 12px;
332 display: flex;
333 gap: 8px;
334 flex-wrap: wrap;
335 align-items: center;
336 }
337 .orgs-detail-actions { display: flex; gap: 8px; flex-wrap: wrap; align-self: center; }
338
339 /* ─── Section card ─── */
340 .orgs-section {
341 margin-bottom: var(--space-5);
342 background: var(--bg-elevated);
343 border: 1px solid var(--border);
344 border-radius: 14px;
345 overflow: hidden;
346 }
347 .orgs-section-head {
348 padding: var(--space-4) var(--space-5);
349 border-bottom: 1px solid var(--border);
350 display: flex;
351 justify-content: space-between;
352 align-items: center;
353 gap: var(--space-3);
354 flex-wrap: wrap;
355 }
356 .orgs-section-title {
357 margin: 0;
358 font-family: var(--font-display);
359 font-size: 16px;
360 font-weight: 700;
361 letter-spacing: -0.012em;
362 color: var(--text-strong);
363 display: flex;
364 align-items: center;
365 gap: 10px;
366 }
367 .orgs-section-icon {
368 display: inline-flex;
369 align-items: center;
370 justify-content: center;
371 width: 26px; height: 26px;
372 border-radius: 8px;
373 background: rgba(140,109,255,0.12);
374 color: #b69dff;
375 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.28);
376 flex-shrink: 0;
377 }
378 .orgs-section-body { padding: var(--space-4) var(--space-5); }
379
380 .orgs-row-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 8px; }
381 .orgs-row {
382 display: flex;
383 align-items: center;
384 gap: 10px;
385 padding: 10px 12px;
386 background: var(--bg);
387 border: 1px solid var(--border-strong);
388 border-radius: 10px;
389 }
390 .orgs-row-avatar {
391 flex-shrink: 0;
392 width: 30px; height: 30px;
393 border-radius: 8px;
394 background: linear-gradient(135deg, rgba(140,109,255,0.22), rgba(54,197,214,0.16));
395 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.32);
396 display: flex;
397 align-items: center;
398 justify-content: center;
399 font-family: var(--font-display);
400 font-size: 13px;
401 font-weight: 800;
402 color: #e9d5ff;
403 text-transform: uppercase;
404 }
405 .orgs-row-name {
406 flex: 1;
407 min-width: 0;
408 font-size: 13.5px;
409 color: var(--text);
410 text-decoration: none;
411 }
412 .orgs-row-name:hover { color: var(--accent); }
413 .orgs-row-meta { color: var(--text-muted); font-size: 12px; }
414
415 /* ─── Empty state ─── */
416 .orgs-empty {
417 position: relative;
418 padding: 56px 32px;
419 background: var(--bg-elevated);
420 border: 1px solid var(--border);
421 border-radius: 16px;
422 text-align: center;
423 overflow: hidden;
424 }
425 .orgs-empty::before {
426 content: '';
427 position: absolute;
428 top: 0; left: 0; right: 0;
429 height: 2px;
430 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
431 opacity: 0.55;
432 pointer-events: none;
433 }
434 .orgs-empty-orb {
435 width: 96px; height: 96px;
436 margin: 0 auto 18px;
437 border-radius: 9999px;
438 background:
439 radial-gradient(circle at 35% 35%, rgba(140,109,255,0.55), rgba(54,197,214,0.25) 55%, transparent 75%);
440 box-shadow:
441 0 0 32px rgba(140,109,255,0.35),
442 inset 0 0 0 1px rgba(140,109,255,0.35);
443 display: flex;
444 align-items: center;
445 justify-content: center;
446 color: #fff;
447 }
448 .orgs-empty-title {
449 font-family: var(--font-display);
450 font-size: 22px;
451 font-weight: 700;
452 letter-spacing: -0.018em;
453 color: var(--text-strong);
454 margin: 0 0 8px;
455 }
456 .orgs-empty-sub {
457 font-size: 14.5px;
458 color: var(--text-muted);
459 line-height: 1.55;
460 margin: 0 auto 18px;
461 max-width: 460px;
462 }
463
464 .orgs-grid-2 {
465 display: grid;
466 grid-template-columns: 1fr 300px;
467 gap: var(--space-4);
468 }
469 @media (max-width: 800px) {
470 .orgs-grid-2 { grid-template-columns: 1fr; }
471 }
472`;
473
474const OrgsIcon = () => (
475 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
476 <path d="M3 21h18" />
477 <path d="M5 21V7l8-4v18" />
478 <path d="M19 21V11l-6-4" />
479 <line x1="9" y1="9" x2="9" y2="9.01" />
480 <line x1="9" y1="12" x2="9" y2="12.01" />
481 <line x1="9" y1="15" x2="9" y2="15.01" />
482 <line x1="9" y1="18" x2="9" y2="18.01" />
483 </svg>
484);
485const OrgsTeamIcon = () => (
486 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
487 <path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
488 <circle cx="9" cy="7" r="4" />
489 <path d="M23 21v-2a4 4 0 0 0-3-3.87" />
490 <path d="M16 3.13a4 4 0 0 1 0 7.75" />
491 </svg>
492);
493const OrgsEmptyIcon = () => (
494 <svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
495 <path d="M3 21h18" />
496 <path d="M5 21V7l8-4v18" />
497 <path d="M19 21V11l-6-4" />
498 </svg>
499);
500
38501// ─── Organization List (index) ──────────────────────────────────────────────
39502// GET /orgs — auth-required directory of the viewer's organizations.
40503orgRoutes.get("/orgs", softAuth, requireAuth, async (c) => {
41504 const user = c.get("user")!;
42 let rows: any[] = [];
505 type OrgRow = {
506 org: typeof organizations.$inferSelect;
507 role: string;
508 memberCount: number;
509 repoCount: number;
510 };
511 let rows: OrgRow[] = [];
43512 try {
44 rows = await db
513 const base = await db
45514 .select({ org: organizations, role: orgMembers.role })
46515 .from(orgMembers)
47516 .innerJoin(organizations, eq(orgMembers.orgId, organizations.id))
48517 .where(eq(orgMembers.userId, user.id))
49518 .orderBy(asc(organizations.name));
519 rows = await Promise.all(
520 base.map(async (r) => {
521 let memberCount = 0;
522 let repoCount = 0;
523 try {
524 const [m] = await db
525 .select({ count: sql<number>`count(*)` })
526 .from(orgMembers)
527 .where(eq(orgMembers.orgId, r.org.id));
528 memberCount = Number(m?.count ?? 0);
529 } catch { /* ignore */ }
530 try {
531 // Org-owned repos are scoped by username convention (organizations.name).
532 // The repositories table keys ownership via users.id, so we look up the
533 // synthetic owner-user row whose username matches the org name.
534 const [u] = await db
535 .select({ id: users.id })
536 .from(users)
537 .where(eq(users.username, r.org.name))
538 .limit(1);
539 if (u) {
540 const [rc] = await db
541 .select({ count: sql<number>`count(*)` })
542 .from(repositories)
543 .where(eq(repositories.ownerId, u.id));
544 repoCount = Number(rc?.count ?? 0);
545 }
546 } catch { /* ignore */ }
547 return { org: r.org, role: r.role, memberCount, repoCount };
548 })
549 );
50550 } catch {
51551 rows = [];
52552 }
553
53554 return c.html(
54555 <Layout title="Your organizations" user={user}>
55 <Container>
56 <PageHeader
57 title="Your organizations"
58 actions={<LinkButton href="/orgs/new" variant="primary">New organization</LinkButton>}
59 />
556 <div class="orgs-wrap">
557 <section class="orgs-hero">
558 <div class="orgs-hero-orb" aria-hidden="true" />
559 <div class="orgs-hero-inner">
560 <div class="orgs-hero-text">
561 <div class="orgs-eyebrow">
562 <span class="orgs-eyebrow-pill" aria-hidden="true">
563 <OrgsIcon />
564 </span>
565 <span>Organizations</span>
566 <span>·</span>
567 <span>@{user.username}</span>
568 </div>
569 <h2 class="orgs-title">
570 <span class="orgs-title-grad">Your organizations.</span>
571 </h2>
572 <p class="orgs-sub">
573 Multi-user namespaces for sharing repos and managing teams.
574 Create one to invite teammates with role-based access.
575 </p>
576 </div>
577 <a href="/orgs/new" class="orgs-btn orgs-btn-primary">
578 + New organization
579 </a>
580 </div>
581 </section>
582
60583 {rows.length === 0 ? (
61 <EmptyState title="No organizations yet">
62 <Text muted>Create one to share repositories with teammates.</Text>
63 <div style="margin-top:12px">
64 <LinkButton href="/orgs/new" variant="primary">New organization</LinkButton>
584 <div class="orgs-empty">
585 <div class="orgs-empty-orb" aria-hidden="true">
586 <OrgsEmptyIcon />
65587 </div>
66 </EmptyState>
588 <h2 class="orgs-empty-title">No organizations yet</h2>
589 <p class="orgs-empty-sub">
590 Organizations let you collaborate with teammates under a shared
591 namespace, with role-based access and team-scoped repos.
592 </p>
593 <a href="/orgs/new" class="orgs-btn orgs-btn-primary">
594 Create your first organization
595 </a>
596 </div>
67597 ) : (
68 <List>
69 {rows.map((r) => (
70 <ListItem>
71 <a href={`/orgs/${r.org.slug}`} style="color:var(--text)">
72 <strong>{r.org.name}</strong>
73 </a>
74 <Text muted> — {r.role}</Text>
75 </ListItem>
76 ))}
77 </List>
598 <ul class="orgs-grid">
599 {rows.map((r) => {
600 const displayName =
601 (r.org as any).displayName || (r.org as any).name || "?";
602 const slug = (r.org as any).slug || (r.org as any).name || "";
603 const initial = (displayName.charAt(0) || "?").toUpperCase();
604 return (
605 <li>
606 <a href={`/orgs/${slug}`} class="orgs-card">
607 <div class="orgs-avatar" aria-hidden="true">{initial}</div>
608 <div class="orgs-card-body">
609 <h3 class="orgs-card-name">{displayName}</h3>
610 <p class="orgs-card-handle">@{slug}</p>
611 <div class="orgs-card-meta">
612 <span class="orgs-pill is-role">
613 <span class="dot" aria-hidden="true" />
614 {r.role}
615 </span>
616 <span class="orgs-stat">
617 {r.memberCount} {r.memberCount === 1 ? "member" : "members"}
618 </span>
619 <span class="orgs-stat">·</span>
620 <span class="orgs-stat">
621 {r.repoCount} {r.repoCount === 1 ? "repo" : "repos"}
622 </span>
623 </div>
624 </div>
625 </a>
626 </li>
627 );
628 })}
629 </ul>
78630 )}
79 </Container>
631 </div>
632 <style dangerouslySetInnerHTML={{ __html: orgsStyles }} />
80633 </Layout>
81634 );
82635});
@@ -249,84 +802,168 @@ orgRoutes.get("/orgs/:org", softAuth, requireAuth, async (c) => {
249802 const isMember = user && members.some((m: any) => m.member.userId === user.id);
250803 const isOwner = user && members.some((m: any) => m.member.userId === user.id && m.member.role === "owner");
251804
252 return c.html(
253 <Layout title={org.displayName || org.name} user={user}>
254 <Container maxWidth={900}>
255 <Flex gap={24} style="margin-bottom:32px">
256 <Avatar name={org.displayName || org.name} size={80} />
257 <div>
258 <h2>{org.displayName || org.name}</h2>
259 <Text size={14} muted>@{org.name}</Text>
260 {org.description && <p style="margin-top:8px"><Text size={14} muted>{org.description}</Text></p>}
261 {org.website && (
262 <a href={org.website} style="font-size:13px" target="_blank" rel="noopener noreferrer">
263 {org.website}
264 </a>
265 )}
266 </div>
267 {isOwner && (
268 <div style="margin-left:auto">
269 <LinkButton href={`/orgs/${org.name}/settings`} size="sm">Settings</LinkButton>
270 </div>
271 )}
272 </Flex>
273
274 <Grid cols="1fr 300px" gap={32}>
275 <div>
276 <Section title="Teams">
277 {teamList.length === 0 ? (
278 <EmptyState>
279 <p>No teams yet.</p>
280 {isOwner && <LinkButton href={`/orgs/${org.name}/teams/new`} variant="primary" size="sm">Create a team</LinkButton>}
281 </EmptyState>
282 ) : (
283 <List>
284 {teamList.map((team: any) => (
285 <ListItem>
286 <div>
287 <div style="font-weight:500;font-size:15px">
288 <a href={`/orgs/${org.name}/teams/${team.name}`} style="color:var(--text)">{team.name}</a>
289 </div>
290 {team.description && <Text size={13} muted>{team.description}</Text>}
291 </div>
292 <Badge>{team.permission}</Badge>
293 </ListItem>
294 ))}
295 </List>
296 )}
297 {isOwner && teamList.length > 0 && (
298 <div style="margin-top:12px">
299 <LinkButton href={`/orgs/${org.name}/teams/new`} variant="primary" size="sm">Create team</LinkButton>
805 const initial = ((org.displayName || org.name || "?").charAt(0) || "?").toUpperCase();
806 return (
807 c.html(
808 <Layout title={org.displayName || org.name} user={user}>
809 <div class="orgs-wrap">
810 <section class="orgs-detail">
811 <div class="orgs-detail-orb" aria-hidden="true" />
812 <div class="orgs-detail-inner">
813 <div class="orgs-avatar-lg" aria-hidden="true">{initial}</div>
814 <div class="orgs-detail-text">
815 <h2 class="orgs-detail-name">{org.displayName || org.name}</h2>
816 <p class="orgs-detail-handle">@{org.name}</p>
817 {org.description && <p class="orgs-detail-desc">{org.description}</p>}
818 <div class="orgs-detail-meta">
819 <span class="orgs-pill">
820 <span class="dot" aria-hidden="true" />
821 {members.length} {members.length === 1 ? "member" : "members"}
822 </span>
823 <span class="orgs-pill is-role">
824 <span class="dot" aria-hidden="true" />
825 {teamList.length} {teamList.length === 1 ? "team" : "teams"}
826 </span>
827 {org.website && (
828 <a
829 href={org.website}
830 target="_blank"
831 rel="noopener noreferrer"
832 style="font-size:12.5px;color:var(--text-muted);text-decoration:none"
833 >
834 {org.website}
835 </a>
836 )}
300837 </div>
301 )}
302 </Section>
303 </div>
304
305 <div>
306 <Section title={`Members (${members.length})`}>
307 <List>
308 {members.map((m: any) => (
309 <ListItem>
310 <Flex align="center" gap={8} style="width:100%">
311 <Avatar name={m.user.displayName || m.user.username} size={32} />
312 <div style="flex:1">
313 <a href={`/${m.user.username}`} style="font-size:14px;font-weight:500">{m.user.username}</a>
314 </div>
315 <Badge style="font-size:11px">{m.member.role}</Badge>
316 </Flex>
317 </ListItem>
318 ))}
319 </List>
838 </div>
320839 {isOwner && (
321 <div style="margin-top:12px">
322 <LinkButton href={`/orgs/${org.name}/members/invite`} size="sm">Invite member</LinkButton>
840 <div class="orgs-detail-actions">
841 <a
842 href={`/orgs/${org.name}/members/invite`}
843 class="orgs-btn orgs-btn-ghost"
844 >
845 Invite member
846 </a>
847 <a
848 href={`/orgs/${org.name}/settings`}
849 class="orgs-btn orgs-btn-primary"
850 >
851 Settings
852 </a>
323853 </div>
324854 )}
325 </Section>
855 </div>
856 </section>
857
858 <div class="orgs-grid-2">
859 <section class="orgs-section">
860 <header class="orgs-section-head">
861 <h3 class="orgs-section-title">
862 <span class="orgs-section-icon" aria-hidden="true">
863 <OrgsTeamIcon />
864 </span>
865 Teams ({teamList.length})
866 </h3>
867 {isOwner && teamList.length > 0 && (
868 <a
869 href={`/orgs/${org.name}/teams/new`}
870 class="orgs-btn orgs-btn-ghost"
871 style="padding:6px 12px;font-size:12.5px"
872 >
873 + New team
874 </a>
875 )}
876 </header>
877 <div class="orgs-section-body">
878 {teamList.length === 0 ? (
879 <div style="text-align:center;padding:24px 12px;color:var(--text-muted);font-size:13.5px;line-height:1.55">
880 No teams yet.
881 {isOwner && (
882 <div style="margin-top:12px">
883 <a
884 href={`/orgs/${org.name}/teams/new`}
885 class="orgs-btn orgs-btn-primary"
886 >
887 Create your first team
888 </a>
889 </div>
890 )}
891 </div>
892 ) : (
893 <ul class="orgs-row-list">
894 {teamList.map((team: any) => (
895 <li class="orgs-row">
896 <div class="orgs-row-avatar" aria-hidden="true">
897 {(team.name.charAt(0) || "?").toUpperCase()}
898 </div>
899 <a
900 href={`/orgs/${org.name}/teams/${team.name}`}
901 class="orgs-row-name"
902 style="font-weight:600"
903 >
904 {team.name}
905 {team.description && (
906 <span class="orgs-row-meta" style="margin-left:8px">
907 — {team.description}
908 </span>
909 )}
910 </a>
911 <span class="orgs-pill is-role">
912 <span class="dot" aria-hidden="true" />
913 {team.permission}
914 </span>
915 </li>
916 ))}
917 </ul>
918 )}
919 </div>
920 </section>
921
922 <section class="orgs-section">
923 <header class="orgs-section-head">
924 <h3 class="orgs-section-title">
925 <span class="orgs-section-icon" aria-hidden="true">
926 <OrgsTeamIcon />
927 </span>
928 Members ({members.length})
929 </h3>
930 </header>
931 <div class="orgs-section-body">
932 {members.length === 0 ? (
933 <div style="text-align:center;padding:18px 12px;color:var(--text-muted);font-size:13.5px">
934 No members.
935 </div>
936 ) : (
937 <ul class="orgs-row-list">
938 {members.map((m: any) => {
939 const init = (
940 (m.user.displayName || m.user.username || "?").charAt(0) || "?"
941 ).toUpperCase();
942 return (
943 <li class="orgs-row">
944 <div class="orgs-row-avatar" aria-hidden="true">{init}</div>
945 <a href={`/${m.user.username}`} class="orgs-row-name">
946 {m.user.displayName || m.user.username}
947 <span class="orgs-row-meta" style="margin-left:6px">
948 @{m.user.username}
949 </span>
950 </a>
951 <span class="orgs-pill is-role">
952 <span class="dot" aria-hidden="true" />
953 {m.member.role}
954 </span>
955 </li>
956 );
957 })}
958 </ul>
959 )}
960 </div>
961 </section>
326962 </div>
327 </Grid>
328 </Container>
329 </Layout>
963 </div>
964 <style dangerouslySetInnerHTML={{ __html: orgsStyles }} />
965 </Layout>
966 )
330967 );
331968});
332969
Modifiedsrc/routes/protected-tags.tsx+595−68View fileUnifiedSplit
@@ -4,6 +4,9 @@
44 * GET /:owner/:repo/settings/protected-tags — CRUD list
55 * POST /:owner/:repo/settings/protected-tags — create
66 * POST /:owner/:repo/settings/protected-tags/:id/delete — remove
7 *
8 * 2026 polish: scoped under `.pt-`. Form actions, validation, and POST
9 * handlers preserved verbatim — security-critical surface.
710 */
811
912import { Hono } from "hono";
@@ -44,6 +47,437 @@ async function loadRepo(ownerName: string, repoName: string) {
4447 }
4548}
4649
50/* ─────────────────────────────────────────────────────────────────────────
51 * Scoped CSS — every selector under `.pt-` so this surface can't leak.
52 * ───────────────────────────────────────────────────────────────────── */
53const ptStyles = `
54 .pt-wrap { max-width: 980px; margin: 0 auto; padding: var(--space-4) 0; }
55
56 .pt-hero {
57 position: relative;
58 margin-bottom: var(--space-5);
59 padding: var(--space-5) var(--space-6);
60 background: var(--bg-elevated);
61 border: 1px solid var(--border);
62 border-radius: 16px;
63 overflow: hidden;
64 }
65 .pt-hero::before {
66 content: '';
67 position: absolute;
68 top: 0; left: 0; right: 0;
69 height: 2px;
70 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
71 opacity: 0.7;
72 pointer-events: none;
73 }
74 .pt-hero-orb {
75 position: absolute;
76 inset: -20% -10% auto auto;
77 width: 380px; height: 380px;
78 background: radial-gradient(circle, rgba(140,109,255,0.20), rgba(54,197,214,0.10) 45%, transparent 70%);
79 filter: blur(80px);
80 opacity: 0.7;
81 pointer-events: none;
82 z-index: 0;
83 }
84 .pt-hero-inner {
85 position: relative;
86 z-index: 1;
87 display: flex;
88 align-items: flex-end;
89 justify-content: space-between;
90 gap: var(--space-4);
91 flex-wrap: wrap;
92 }
93 .pt-hero-text { max-width: 720px; flex: 1; min-width: 240px; }
94 .pt-eyebrow {
95 font-size: 12px;
96 color: var(--text-muted);
97 margin-bottom: var(--space-2);
98 letter-spacing: 0.02em;
99 display: inline-flex;
100 align-items: center;
101 gap: 8px;
102 text-transform: uppercase;
103 font-family: var(--font-mono);
104 font-weight: 600;
105 }
106 .pt-eyebrow-pill {
107 display: inline-flex;
108 align-items: center;
109 justify-content: center;
110 width: 18px; height: 18px;
111 border-radius: 6px;
112 background: rgba(140,109,255,0.14);
113 color: #b69dff;
114 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.35);
115 }
116 .pt-title {
117 font-size: clamp(26px, 3.6vw, 36px);
118 font-family: var(--font-display);
119 font-weight: 800;
120 letter-spacing: -0.028em;
121 line-height: 1.05;
122 margin: 0 0 var(--space-2);
123 color: var(--text-strong);
124 }
125 .pt-title-grad {
126 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
127 -webkit-background-clip: text;
128 background-clip: text;
129 -webkit-text-fill-color: transparent;
130 color: transparent;
131 }
132 .pt-sub {
133 font-size: 14.5px;
134 color: var(--text-muted);
135 margin: 0;
136 line-height: 1.55;
137 max-width: 620px;
138 }
139 .pt-sub code {
140 font-family: var(--font-mono);
141 font-size: 12.5px;
142 background: rgba(255,255,255,0.04);
143 border: 1px solid var(--border);
144 padding: 1px 6px;
145 border-radius: 5px;
146 color: var(--text);
147 }
148 .pt-hero-link {
149 display: inline-flex;
150 align-items: center;
151 gap: 6px;
152 padding: 7px 12px;
153 font-size: 12.5px;
154 color: var(--text-muted);
155 background: rgba(255,255,255,0.02);
156 border: 1px solid var(--border);
157 border-radius: 8px;
158 text-decoration: none;
159 font-weight: 500;
160 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
161 }
162 .pt-hero-link:hover {
163 border-color: var(--border-strong);
164 color: var(--text-strong);
165 background: rgba(255,255,255,0.04);
166 text-decoration: none;
167 }
168
169 .pt-banner {
170 margin-bottom: var(--space-4);
171 padding: 10px 14px;
172 border-radius: 10px;
173 font-size: 13.5px;
174 border: 1px solid var(--border);
175 background: rgba(255,255,255,0.025);
176 color: var(--text);
177 display: flex;
178 align-items: center;
179 gap: 10px;
180 }
181 .pt-banner.is-ok { border-color: rgba(52,211,153,0.40); background: rgba(52,211,153,0.08); color: #bbf7d0; }
182 .pt-banner.is-error { border-color: rgba(248,113,113,0.40); background: rgba(248,113,113,0.08); color: #fecaca; }
183 .pt-banner-dot { width: 8px; height: 8px; border-radius: 9999px; background: currentColor; flex-shrink: 0; }
184
185 /* ─── Status card ─── */
186 .pt-status {
187 position: relative;
188 margin-bottom: var(--space-5);
189 padding: var(--space-5);
190 background: var(--bg-elevated);
191 border: 1px solid var(--border);
192 border-radius: 14px;
193 overflow: hidden;
194 }
195 .pt-status.is-on {
196 border-color: rgba(52,211,153,0.32);
197 background: linear-gradient(135deg, rgba(52,211,153,0.08) 0%, rgba(15,17,26,0) 60%), var(--bg-elevated);
198 }
199 .pt-status.is-empty {
200 border-color: rgba(251,191,36,0.30);
201 background: linear-gradient(135deg, rgba(251,191,36,0.06) 0%, rgba(15,17,26,0) 60%), var(--bg-elevated);
202 }
203 .pt-status-row { display: flex; align-items: center; gap: var(--space-4); flex-wrap: wrap; }
204 .pt-status-mark {
205 flex-shrink: 0;
206 width: 52px; height: 52px;
207 border-radius: 14px;
208 display: flex;
209 align-items: center;
210 justify-content: center;
211 color: #fff;
212 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
213 box-shadow: 0 8px 20px -8px rgba(140,109,255,0.50), inset 0 1px 0 rgba(255,255,255,0.18);
214 }
215 .pt-status.is-on .pt-status-mark {
216 background: linear-gradient(135deg, #34d399 0%, #10b981 100%);
217 box-shadow: 0 8px 20px -8px rgba(16,185,129,0.55), inset 0 1px 0 rgba(255,255,255,0.20);
218 }
219 .pt-status.is-empty .pt-status-mark {
220 background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%);
221 color: #1a1206;
222 box-shadow: 0 8px 20px -8px rgba(251,191,36,0.55), inset 0 1px 0 rgba(255,255,255,0.18);
223 }
224 .pt-status-text { flex: 1; min-width: 220px; }
225 .pt-status-headline {
226 margin: 0 0 4px;
227 font-family: var(--font-display);
228 font-size: 18px;
229 font-weight: 700;
230 letter-spacing: -0.018em;
231 color: var(--text-strong);
232 }
233 .pt-status-desc { margin: 0; font-size: 13.5px; color: var(--text-muted); line-height: 1.5; }
234
235 /* ─── Section card ─── */
236 .pt-section {
237 margin-bottom: var(--space-5);
238 background: var(--bg-elevated);
239 border: 1px solid var(--border);
240 border-radius: 14px;
241 overflow: hidden;
242 }
243 .pt-section-head {
244 padding: var(--space-4) var(--space-5);
245 border-bottom: 1px solid var(--border);
246 }
247 .pt-section-title {
248 margin: 0;
249 font-family: var(--font-display);
250 font-size: 16px;
251 font-weight: 700;
252 letter-spacing: -0.018em;
253 color: var(--text-strong);
254 display: flex;
255 align-items: center;
256 gap: 10px;
257 }
258 .pt-section-icon {
259 display: inline-flex;
260 align-items: center;
261 justify-content: center;
262 width: 26px; height: 26px;
263 border-radius: 8px;
264 background: rgba(140,109,255,0.12);
265 color: #b69dff;
266 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.28);
267 flex-shrink: 0;
268 }
269 .pt-section-sub {
270 margin: 6px 0 0 36px;
271 font-size: 12.5px;
272 color: var(--text-muted);
273 line-height: 1.5;
274 }
275 .pt-section-body { padding: var(--space-4) var(--space-5); }
276
277 /* ─── Tag cards ─── */
278 .pt-list { display: flex; flex-direction: column; gap: 10px; }
279 .pt-tag {
280 display: flex;
281 align-items: center;
282 justify-content: space-between;
283 gap: var(--space-3);
284 padding: var(--space-4);
285 background: var(--bg-elevated);
286 border: 1px solid var(--border);
287 border-radius: 12px;
288 transition: border-color 140ms ease, transform 140ms ease;
289 }
290 .pt-tag:hover { border-color: rgba(140,109,255,0.30); transform: translateY(-1px); }
291 .pt-tag-body { flex: 1; min-width: 0; }
292 .pt-tag-pattern {
293 font-family: var(--font-mono);
294 font-size: 13.5px;
295 color: var(--text-strong);
296 background: rgba(140,109,255,0.10);
297 border: 1px solid rgba(140,109,255,0.30);
298 padding: 3px 10px;
299 border-radius: 8px;
300 font-weight: 600;
301 display: inline-block;
302 }
303 .pt-tag-meta {
304 margin-top: 6px;
305 font-size: 12px;
306 color: var(--text-muted);
307 display: flex;
308 flex-wrap: wrap;
309 gap: 8px;
310 align-items: center;
311 }
312 .pt-chip {
313 display: inline-flex;
314 align-items: center;
315 gap: 5px;
316 padding: 2px 9px;
317 font-size: 11px;
318 font-weight: 600;
319 border-radius: 9999px;
320 background: rgba(52,211,153,0.10);
321 color: #6ee7b7;
322 border: 1px solid rgba(52,211,153,0.30);
323 }
324 .pt-chip .dot { width: 6px; height: 6px; border-radius: 9999px; background: currentColor; }
325
326 /* ─── Empty state ─── */
327 .pt-empty {
328 padding: var(--space-6);
329 text-align: center;
330 background: var(--bg-elevated);
331 border: 1px dashed var(--border-strong);
332 border-radius: 14px;
333 position: relative;
334 overflow: hidden;
335 }
336 .pt-empty-orb {
337 position: absolute;
338 inset: -30% auto auto -10%;
339 width: 260px; height: 260px;
340 background: radial-gradient(circle, rgba(140,109,255,0.16), rgba(54,197,214,0.08) 45%, transparent 70%);
341 filter: blur(60px);
342 opacity: 0.8;
343 pointer-events: none;
344 }
345 .pt-empty-mark {
346 position: relative;
347 z-index: 1;
348 margin: 0 auto var(--space-3);
349 width: 52px; height: 52px;
350 border-radius: 14px;
351 background: linear-gradient(135deg, rgba(140,109,255,0.18), rgba(54,197,214,0.12));
352 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.30);
353 display: flex;
354 align-items: center;
355 justify-content: center;
356 color: #c4b5fd;
357 }
358 .pt-empty-title {
359 position: relative;
360 z-index: 1;
361 margin: 0 0 6px;
362 font-family: var(--font-display);
363 font-size: 17px;
364 font-weight: 700;
365 color: var(--text-strong);
366 letter-spacing: -0.018em;
367 }
368 .pt-empty-body {
369 position: relative;
370 z-index: 1;
371 margin: 0 auto;
372 max-width: 460px;
373 font-size: 13.5px;
374 color: var(--text-muted);
375 line-height: 1.55;
376 }
377 .pt-empty-body code { font-family: var(--font-mono); font-size: 11.5px; background: rgba(255,255,255,0.04); border: 1px solid var(--border); padding: 1px 6px; border-radius: 5px; color: var(--text); }
378
379 /* ─── Form ─── */
380 .pt-form { padding: var(--space-5); }
381 .pt-form-group { margin-bottom: var(--space-4); }
382 .pt-form-label {
383 display: block;
384 font-family: var(--font-mono);
385 font-size: 11.5px;
386 font-weight: 700;
387 text-transform: uppercase;
388 letter-spacing: 0.12em;
389 color: var(--text-muted);
390 margin-bottom: 6px;
391 }
392 .pt-input {
393 width: 100%;
394 padding: 9px 12px;
395 font-size: 13.5px;
396 color: var(--text);
397 background: var(--bg);
398 border: 1px solid var(--border-strong);
399 border-radius: 8px;
400 outline: none;
401 font-family: var(--font-mono);
402 transition: border-color 120ms ease, box-shadow 120ms ease;
403 box-sizing: border-box;
404 }
405 .pt-input:focus {
406 border-color: var(--border-focus, rgba(140,109,255,0.55));
407 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
408 }
409 .pt-form-hint { margin-top: 6px; font-size: 12px; color: var(--text-muted); }
410 .pt-form-hint code {
411 font-family: var(--font-mono);
412 font-size: 11.5px;
413 background: rgba(255,255,255,0.04);
414 border: 1px solid var(--border);
415 padding: 1px 6px;
416 border-radius: 5px;
417 color: var(--text);
418 }
419
420 /* ─── Buttons ─── */
421 .pt-btn {
422 display: inline-flex;
423 align-items: center;
424 gap: 6px;
425 padding: 9px 16px;
426 border-radius: 10px;
427 font-size: 13px;
428 font-weight: 600;
429 text-decoration: none;
430 border: 1px solid transparent;
431 cursor: pointer;
432 font-family: inherit;
433 line-height: 1;
434 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease, color 120ms ease;
435 }
436 .pt-btn-primary {
437 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
438 color: #fff;
439 box-shadow: 0 6px 18px -4px rgba(140,109,255,0.45), inset 0 1px 0 rgba(255,255,255,0.16);
440 }
441 .pt-btn-primary:hover {
442 transform: translateY(-1px);
443 box-shadow: 0 10px 24px -6px rgba(140,109,255,0.55), inset 0 1px 0 rgba(255,255,255,0.20);
444 color: #fff;
445 text-decoration: none;
446 }
447 .pt-btn-danger {
448 background: transparent;
449 color: #fecaca;
450 border-color: rgba(248,113,113,0.40);
451 }
452 .pt-btn-danger:hover {
453 background: rgba(248,113,113,0.10);
454 border-color: rgba(248,113,113,0.65);
455 color: #fee2e2;
456 text-decoration: none;
457 }
458 .pt-btn-sm { padding: 6px 11px; font-size: 12px; }
459`;
460
461/* Icons */
462const TagIcon = () => (
463 <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
464 <path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z" />
465 <line x1="7" y1="7" x2="7.01" y2="7" />
466 </svg>
467);
468const PlusIcon = () => (
469 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
470 <line x1="12" y1="5" x2="12" y2="19" />
471 <line x1="5" y1="12" x2="19" y2="12" />
472 </svg>
473);
474const ArrowLeft = () => (
475 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
476 <line x1="19" y1="12" x2="5" y2="12" />
477 <polyline points="12 19 5 12 12 5" />
478 </svg>
479);
480
47481protectedTagsRoutes.get(
48482 "/:owner/:repo/settings/protected-tags",
49483 requireAuth,
@@ -58,6 +492,17 @@ protectedTagsRoutes.get(
58492 const success = c.req.query("success");
59493 const error = c.req.query("error");
60494
495 const statusVariant: "is-on" | "is-empty" =
496 tags.length === 0 ? "is-empty" : "is-on";
497 const statusHead =
498 tags.length === 0
499 ? "No tag patterns protected"
500 : `${tags.length} pattern${tags.length === 1 ? "" : "s"} protected`;
501 const statusDesc =
502 tags.length === 0
503 ? "Anyone with write access can create, update, or delete any tag right now."
504 : "Only repo owners can create, update, or delete tags matching one of the patterns below.";
505
61506 return c.html(
62507 <Layout title={`Protected tags — ${owner}/${repo}`} user={user}>
63508 <RepoHeader
@@ -69,79 +514,161 @@ protectedTagsRoutes.get(
69514 />
70515 <RepoNav owner={owner} repo={repo} active="gates" />
71516
72 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
73 <h3>Protected tags</h3>
74 <a href={`/${owner}/${repo}/settings`} class="btn btn-sm">
75 Back to settings
76 </a>
77 </div>
517 <div class="pt-wrap">
518 <section class="pt-hero">
519 <div class="pt-hero-orb" aria-hidden="true" />
520 <div class="pt-hero-inner">
521 <div class="pt-hero-text">
522 <div class="pt-eyebrow">
523 <span class="pt-eyebrow-pill" aria-hidden="true">
524 <TagIcon />
525 </span>
526 Protected tags · {owner}/{repo}
527 </div>
528 <h1 class="pt-title">
529 <span class="pt-title-grad">Lock down releases.</span>
530 </h1>
531 <p class="pt-sub">
532 Mark tag patterns as protected. Only repo owners can create,
533 update, or delete tags matching one of these patterns. Globs
534 supported: <code>v*</code>, <code>release-*</code>,{" "}
535 <code>**</code>.
536 </p>
537 </div>
538 <a href={`/${owner}/${repo}/settings`} class="pt-hero-link">
539 <ArrowLeft /> Back to settings
540 </a>
541 </div>
542 </section>
78543
79 <p style="font-size:13px;color:var(--text-muted);margin-bottom:16px">
80 Mark tag patterns as protected. Only repo owners can create, update,
81 or delete tags matching one of these patterns. Supports globs:
82 <code>v*</code>, <code>release-*</code>, <code>**</code>.
83 </p>
84
85 {success && <div class="auth-success">{decodeURIComponent(success)}</div>}
86 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
87
88 <div class="panel" style="margin-bottom:16px">
89 {tags.length === 0 ? (
90 <div class="panel-empty">No protected tag patterns.</div>
91 ) : (
92 tags.map((t) => (
93 <div class="panel-item" style="justify-content:space-between">
94 <div>
95 <code
96 style="background:var(--bg-tertiary);padding:2px 8px;border-radius:3px"
97 >
98 {t.pattern}
99 </code>
100 <div
101 class="meta"
102 style="margin-top:4px;font-size:12px;color:var(--text-muted)"
103 >
104 Added{" "}
105 {t.createdAt
106 ? new Date(t.createdAt as unknown as string).toLocaleDateString()
107 : ""}
544 {success && (
545 <div class="pt-banner is-ok" role="status">
546 <span class="pt-banner-dot" aria-hidden="true" />
547 {decodeURIComponent(success)}
548 </div>
549 )}
550 {error && (
551 <div class="pt-banner is-error" role="alert">
552 <span class="pt-banner-dot" aria-hidden="true" />
553 {decodeURIComponent(error)}
554 </div>
555 )}
556
557 <section class={`pt-status ${statusVariant}`}>
558 <div class="pt-status-row">
559 <span class="pt-status-mark" aria-hidden="true">
560 <TagIcon />
561 </span>
562 <div class="pt-status-text">
563 <h2 class="pt-status-headline">{statusHead}</h2>
564 <p class="pt-status-desc">{statusDesc}</p>
565 </div>
566 </div>
567 </section>
568
569 <section class="pt-section">
570 <header class="pt-section-head">
571 <h3 class="pt-section-title">
572 <span class="pt-section-icon" aria-hidden="true">
573 <TagIcon />
574 </span>
575 Protected patterns
576 </h3>
577 <p class="pt-section-sub">
578 Each pattern is matched against the tag name (e.g.{" "}
579 <code>v1.2.3</code> matches <code>v*</code>).
580 </p>
581 </header>
582 <div class="pt-section-body">
583 {tags.length === 0 ? (
584 <div class="pt-empty">
585 <div class="pt-empty-orb" aria-hidden="true" />
586 <div class="pt-empty-mark" aria-hidden="true">
587 <TagIcon />
108588 </div>
589 <h4 class="pt-empty-title">No protected tag patterns</h4>
590 <p class="pt-empty-body">
591 Common starting patterns: <code>v*</code> protects every
592 semver release tag; <code>release-*</code> protects named
593 release tags. Add one below to lock them down.
594 </p>
595 </div>
596 ) : (
597 <div class="pt-list">
598 {tags.map((t) => (
599 <div class="pt-tag">
600 <div class="pt-tag-body">
601 <span class="pt-tag-pattern">{t.pattern}</span>
602 <div class="pt-tag-meta">
603 <span class="pt-chip">
604 <span class="dot" aria-hidden="true" />
605 owners only
606 </span>
607 <span>·</span>
608 <span>
609 Added{" "}
610 {t.createdAt
611 ? new Date(t.createdAt as unknown as string).toLocaleDateString()
612 : ""}
613 </span>
614 </div>
615 </div>
616 <form
617 method="post"
618 action={`/${owner}/${repo}/settings/protected-tags/${t.id}/delete`}
619 onsubmit="return confirm('Remove protection for this pattern?')"
620 >
621 <button type="submit" class="pt-btn pt-btn-danger pt-btn-sm">
622 Remove
623 </button>
624 </form>
625 </div>
626 ))}
627 </div>
628 )}
629 </div>
630 </section>
631
632 <section class="pt-section">
633 <header class="pt-section-head">
634 <h3 class="pt-section-title">
635 <span class="pt-section-icon" aria-hidden="true">
636 <PlusIcon />
637 </span>
638 Protect new pattern
639 </h3>
640 <p class="pt-section-sub">
641 Add a glob that should be reserved to repo owners.
642 </p>
643 </header>
644 <form
645 method="post"
646 action={`/${owner}/${repo}/settings/protected-tags`}
647 class="pt-form"
648 >
649 <div class="pt-form-group">
650 <label class="pt-form-label" for="pt-pattern">Pattern</label>
651 <input
652 type="text"
653 id="pt-pattern"
654 name="pattern"
655 required
656 placeholder="v* or release-*"
657 aria-label="Tag protection pattern"
658 class="pt-input"
659 />
660 <div class="pt-form-hint">
661 Globs: <code>v*</code>, <code>release-*</code>,{" "}
662 <code>**</code> matches everything.
109663 </div>
110 <form
111 method="post"
112 action={`/${owner}/${repo}/settings/protected-tags/${t.id}/delete`}
113 onsubmit="return confirm('Remove protection for this pattern?')"
114 >
115 <button type="submit" class="btn btn-sm btn-danger">
116 Remove
117 </button>
118 </form>
119664 </div>
120 ))
121 )}
665 <button type="submit" class="pt-btn pt-btn-primary">
666 <PlusIcon /> Protect pattern
667 </button>
668 </form>
669 </section>
122670 </div>
123
124 <form
125 method="post"
126 action={`/${owner}/${repo}/settings/protected-tags`}
127 class="panel"
128 style="padding:16px"
129 >
130 <div class="form-group">
131 <label>Pattern</label>
132 <input
133 type="text"
134 name="pattern"
135 required
136 placeholder="v* or release-*"
137 aria-label="Tag protection pattern"
138 style="font-family:var(--font-mono)"
139 />
140 </div>
141 <button type="submit" class="btn btn-primary">
142 Protect pattern
143 </button>
144 </form>
671 <style dangerouslySetInnerHTML={{ __html: ptStyles }} />
145672 </Layout>
146673 );
147674 }
Modifiedsrc/routes/required-checks.tsx+760−65View fileUnifiedSplit
@@ -10,14 +10,20 @@
1010 * this name must have a passing gate_run OR workflow_run against the head
1111 * commit". Name matching is exact (case-sensitive); callers typically use
1212 * workflow `name:` or the gate kinds (e.g. `GateTest`, `AI Review`).
13 *
14 * 2026 polish: scoped under `.rc-`. Each check renders as a card with a
15 * traffic-light dot showing the last observed status + duration pulled from
16 * gate_runs. Form actions, validation, and POST handlers are preserved
17 * verbatim — security-critical surface.
1318 */
1419
1520import { Hono } from "hono";
16import { and, eq } from "drizzle-orm";
21import { and, desc, eq } from "drizzle-orm";
1722import { db } from "../db";
1823import {
1924 branchProtection,
2025 branchRequiredChecks,
26 gateRuns,
2127 repositories,
2228 users,
2329} from "../db/schema";
@@ -69,6 +75,557 @@ async function loadRule(repositoryId: string, ruleId: string) {
6975 }
7076}
7177
78interface CheckStatus {
79 status: string | null;
80 durationMs: number | null;
81 createdAt: Date | null;
82}
83
84/**
85 * Best-effort lookup of the most recent gate_run for a given check name in this repo.
86 * Returns null if the table is unavailable or no run exists.
87 */
88async function lastStatusForCheck(
89 repositoryId: string,
90 checkName: string
91): Promise<CheckStatus> {
92 try {
93 const [row] = await db
94 .select({
95 status: gateRuns.status,
96 durationMs: gateRuns.durationMs,
97 createdAt: gateRuns.createdAt,
98 })
99 .from(gateRuns)
100 .where(
101 and(
102 eq(gateRuns.repositoryId, repositoryId),
103 eq(gateRuns.gateName, checkName)
104 )
105 )
106 .orderBy(desc(gateRuns.createdAt))
107 .limit(1);
108 return {
109 status: row?.status ?? null,
110 durationMs: row?.durationMs ?? null,
111 createdAt: row?.createdAt ?? null,
112 };
113 } catch {
114 return { status: null, durationMs: null, createdAt: null };
115 }
116}
117
118function relTime(d: Date | null): string {
119 if (!d) return "—";
120 const t = typeof d === "string" ? new Date(d) : d;
121 const diffMs = Date.now() - t.getTime();
122 const mins = Math.floor(diffMs / 60000);
123 if (mins < 1) return "just now";
124 if (mins < 60) return `${mins}m ago`;
125 const hrs = Math.floor(mins / 60);
126 if (hrs < 24) return `${hrs}h ago`;
127 const days = Math.floor(hrs / 24);
128 if (days < 30) return `${days}d ago`;
129 return t.toLocaleDateString();
130}
131
132function lightClass(status: string | null): "is-pass" | "is-fail" | "is-warn" | "is-idle" {
133 if (status === "passed" || status === "repaired") return "is-pass";
134 if (status === "failed") return "is-fail";
135 if (status === "pending" || status === "running" || status === "skipped") return "is-warn";
136 return "is-idle";
137}
138
139/* ─────────────────────────────────────────────────────────────────────────
140 * Scoped CSS — every selector prefixed `.rc-` so this surface can't leak.
141 * ───────────────────────────────────────────────────────────────────── */
142const rcStyles = `
143 .rc-wrap { max-width: 980px; margin: 0 auto; padding: var(--space-4) 0; }
144
145 .rc-hero {
146 position: relative;
147 margin-bottom: var(--space-5);
148 padding: var(--space-5) var(--space-6);
149 background: var(--bg-elevated);
150 border: 1px solid var(--border);
151 border-radius: 16px;
152 overflow: hidden;
153 }
154 .rc-hero::before {
155 content: '';
156 position: absolute;
157 top: 0; left: 0; right: 0;
158 height: 2px;
159 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
160 opacity: 0.7;
161 pointer-events: none;
162 }
163 .rc-hero-orb {
164 position: absolute;
165 inset: -20% -10% auto auto;
166 width: 380px; height: 380px;
167 background: radial-gradient(circle, rgba(140,109,255,0.20), rgba(54,197,214,0.10) 45%, transparent 70%);
168 filter: blur(80px);
169 opacity: 0.7;
170 pointer-events: none;
171 z-index: 0;
172 }
173 .rc-hero-inner {
174 position: relative;
175 z-index: 1;
176 display: flex;
177 align-items: flex-end;
178 justify-content: space-between;
179 gap: var(--space-4);
180 flex-wrap: wrap;
181 }
182 .rc-hero-text { max-width: 720px; flex: 1; min-width: 240px; }
183 .rc-eyebrow {
184 font-size: 12px;
185 color: var(--text-muted);
186 margin-bottom: var(--space-2);
187 letter-spacing: 0.02em;
188 display: inline-flex;
189 align-items: center;
190 gap: 8px;
191 text-transform: uppercase;
192 font-family: var(--font-mono);
193 font-weight: 600;
194 }
195 .rc-eyebrow-pill {
196 display: inline-flex;
197 align-items: center;
198 justify-content: center;
199 width: 18px; height: 18px;
200 border-radius: 6px;
201 background: rgba(140,109,255,0.14);
202 color: #b69dff;
203 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.35);
204 }
205 .rc-title {
206 font-size: clamp(26px, 3.6vw, 36px);
207 font-family: var(--font-display);
208 font-weight: 800;
209 letter-spacing: -0.028em;
210 line-height: 1.05;
211 margin: 0 0 var(--space-2);
212 color: var(--text-strong);
213 }
214 .rc-title-grad {
215 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
216 -webkit-background-clip: text;
217 background-clip: text;
218 -webkit-text-fill-color: transparent;
219 color: transparent;
220 }
221 .rc-sub {
222 font-size: 14.5px;
223 color: var(--text-muted);
224 margin: 0;
225 line-height: 1.55;
226 max-width: 620px;
227 }
228 .rc-sub code {
229 font-family: var(--font-mono);
230 font-size: 12.5px;
231 background: rgba(255,255,255,0.04);
232 border: 1px solid var(--border);
233 padding: 1px 6px;
234 border-radius: 5px;
235 color: var(--text);
236 }
237 .rc-hero-link {
238 display: inline-flex;
239 align-items: center;
240 gap: 6px;
241 padding: 7px 12px;
242 font-size: 12.5px;
243 color: var(--text-muted);
244 background: rgba(255,255,255,0.02);
245 border: 1px solid var(--border);
246 border-radius: 8px;
247 text-decoration: none;
248 font-weight: 500;
249 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
250 }
251 .rc-hero-link:hover {
252 border-color: var(--border-strong);
253 color: var(--text-strong);
254 background: rgba(255,255,255,0.04);
255 text-decoration: none;
256 }
257
258 .rc-banner {
259 margin-bottom: var(--space-4);
260 padding: 10px 14px;
261 border-radius: 10px;
262 font-size: 13.5px;
263 border: 1px solid var(--border);
264 background: rgba(255,255,255,0.025);
265 color: var(--text);
266 display: flex;
267 align-items: center;
268 gap: 10px;
269 }
270 .rc-banner.is-ok { border-color: rgba(52,211,153,0.40); background: rgba(52,211,153,0.08); color: #bbf7d0; }
271 .rc-banner.is-error { border-color: rgba(248,113,113,0.40); background: rgba(248,113,113,0.08); color: #fecaca; }
272 .rc-banner-dot { width: 8px; height: 8px; border-radius: 9999px; background: currentColor; flex-shrink: 0; }
273
274 /* ─── Status card ─── */
275 .rc-status {
276 position: relative;
277 margin-bottom: var(--space-5);
278 padding: var(--space-5);
279 background: var(--bg-elevated);
280 border: 1px solid var(--border);
281 border-radius: 14px;
282 overflow: hidden;
283 }
284 .rc-status.is-on {
285 border-color: rgba(52,211,153,0.32);
286 background: linear-gradient(135deg, rgba(52,211,153,0.08) 0%, rgba(15,17,26,0) 60%), var(--bg-elevated);
287 }
288 .rc-status.is-warn {
289 border-color: rgba(248,113,113,0.32);
290 background: linear-gradient(135deg, rgba(248,113,113,0.08) 0%, rgba(15,17,26,0) 60%), var(--bg-elevated);
291 }
292 .rc-status.is-empty {
293 border-color: rgba(251,191,36,0.30);
294 background: linear-gradient(135deg, rgba(251,191,36,0.06) 0%, rgba(15,17,26,0) 60%), var(--bg-elevated);
295 }
296 .rc-status-row { display: flex; align-items: center; gap: var(--space-4); flex-wrap: wrap; }
297 .rc-status-mark {
298 flex-shrink: 0;
299 width: 52px; height: 52px;
300 border-radius: 14px;
301 display: flex;
302 align-items: center;
303 justify-content: center;
304 color: #fff;
305 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
306 box-shadow: 0 8px 20px -8px rgba(140,109,255,0.50), inset 0 1px 0 rgba(255,255,255,0.18);
307 }
308 .rc-status.is-on .rc-status-mark {
309 background: linear-gradient(135deg, #34d399 0%, #10b981 100%);
310 box-shadow: 0 8px 20px -8px rgba(16,185,129,0.55), inset 0 1px 0 rgba(255,255,255,0.20);
311 }
312 .rc-status.is-warn .rc-status-mark {
313 background: linear-gradient(135deg, #f87171 0%, #ef4444 100%);
314 box-shadow: 0 8px 20px -8px rgba(239,68,68,0.55), inset 0 1px 0 rgba(255,255,255,0.18);
315 }
316 .rc-status.is-empty .rc-status-mark {
317 background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%);
318 color: #1a1206;
319 box-shadow: 0 8px 20px -8px rgba(251,191,36,0.55), inset 0 1px 0 rgba(255,255,255,0.18);
320 }
321 .rc-status-text { flex: 1; min-width: 220px; }
322 .rc-status-headline {
323 margin: 0 0 4px;
324 font-family: var(--font-display);
325 font-size: 18px;
326 font-weight: 700;
327 letter-spacing: -0.018em;
328 color: var(--text-strong);
329 }
330 .rc-status-desc { margin: 0; font-size: 13.5px; color: var(--text-muted); line-height: 1.5; }
331 .rc-pattern-pill {
332 display: inline-flex;
333 align-items: center;
334 gap: 6px;
335 padding: 3px 10px;
336 font-family: var(--font-mono);
337 font-size: 12px;
338 font-weight: 600;
339 color: var(--text-strong);
340 background: rgba(140,109,255,0.10);
341 border: 1px solid rgba(140,109,255,0.30);
342 border-radius: 8px;
343 }
344
345 /* ─── Section card ─── */
346 .rc-section {
347 margin-bottom: var(--space-5);
348 background: var(--bg-elevated);
349 border: 1px solid var(--border);
350 border-radius: 14px;
351 overflow: hidden;
352 }
353 .rc-section-head {
354 padding: var(--space-4) var(--space-5);
355 border-bottom: 1px solid var(--border);
356 }
357 .rc-section-title {
358 margin: 0;
359 font-family: var(--font-display);
360 font-size: 16px;
361 font-weight: 700;
362 letter-spacing: -0.018em;
363 color: var(--text-strong);
364 display: flex;
365 align-items: center;
366 gap: 10px;
367 }
368 .rc-section-icon {
369 display: inline-flex;
370 align-items: center;
371 justify-content: center;
372 width: 26px; height: 26px;
373 border-radius: 8px;
374 background: rgba(140,109,255,0.12);
375 color: #b69dff;
376 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.28);
377 flex-shrink: 0;
378 }
379 .rc-section-sub {
380 margin: 6px 0 0 36px;
381 font-size: 12.5px;
382 color: var(--text-muted);
383 line-height: 1.5;
384 }
385 .rc-section-body { padding: var(--space-4) var(--space-5); }
386
387 /* ─── Check cards ─── */
388 .rc-list { display: flex; flex-direction: column; gap: 10px; }
389 .rc-check {
390 display: flex;
391 align-items: center;
392 justify-content: space-between;
393 gap: var(--space-3);
394 padding: var(--space-4);
395 background: var(--bg-elevated);
396 border: 1px solid var(--border);
397 border-radius: 12px;
398 transition: border-color 140ms ease, transform 140ms ease;
399 }
400 .rc-check:hover {
401 border-color: rgba(140,109,255,0.30);
402 transform: translateY(-1px);
403 }
404 .rc-check-main { display: flex; align-items: center; gap: 14px; flex: 1; min-width: 0; }
405 .rc-light {
406 flex-shrink: 0;
407 width: 14px; height: 14px;
408 border-radius: 9999px;
409 background: #6b7280;
410 box-shadow: 0 0 0 3px rgba(107,114,128,0.18);
411 position: relative;
412 }
413 .rc-light.is-pass {
414 background: #34d399;
415 box-shadow: 0 0 0 3px rgba(52,211,153,0.22), 0 0 10px rgba(52,211,153,0.45);
416 }
417 .rc-light.is-fail {
418 background: #f87171;
419 box-shadow: 0 0 0 3px rgba(248,113,113,0.22), 0 0 12px rgba(248,113,113,0.50);
420 animation: rcPulse 1.8s ease-in-out infinite;
421 }
422 .rc-light.is-warn {
423 background: #fbbf24;
424 box-shadow: 0 0 0 3px rgba(251,191,36,0.22), 0 0 10px rgba(251,191,36,0.40);
425 }
426 .rc-light.is-idle {
427 background: #6b7280;
428 box-shadow: 0 0 0 3px rgba(107,114,128,0.18);
429 }
430 @keyframes rcPulse {
431 0%, 100% { opacity: 1; transform: scale(1); }
432 50% { opacity: 0.75; transform: scale(0.92); }
433 }
434 @media (prefers-reduced-motion: reduce) {
435 .rc-light.is-fail { animation: none; }
436 }
437 .rc-check-body { min-width: 0; flex: 1; }
438 .rc-check-name {
439 font-family: var(--font-mono);
440 font-size: 14px;
441 font-weight: 700;
442 color: var(--text-strong);
443 word-break: break-word;
444 }
445 .rc-check-meta {
446 margin-top: 4px;
447 font-size: 12px;
448 color: var(--text-muted);
449 display: flex;
450 flex-wrap: wrap;
451 align-items: center;
452 gap: 8px;
453 }
454 .rc-check-pill {
455 display: inline-flex;
456 align-items: center;
457 gap: 5px;
458 padding: 2px 9px;
459 font-size: 10.5px;
460 font-weight: 700;
461 text-transform: uppercase;
462 letter-spacing: 0.06em;
463 border-radius: 9999px;
464 }
465 .rc-check-pill.is-pass { background: rgba(52,211,153,0.14); color: #6ee7b7; box-shadow: inset 0 0 0 1px rgba(52,211,153,0.32); }
466 .rc-check-pill.is-fail { background: rgba(248,113,113,0.14); color: #fecaca; box-shadow: inset 0 0 0 1px rgba(248,113,113,0.32); }
467 .rc-check-pill.is-warn { background: rgba(251,191,36,0.14); color: #fde68a; box-shadow: inset 0 0 0 1px rgba(251,191,36,0.32); }
468 .rc-check-pill.is-idle { background: rgba(255,255,255,0.04); color: var(--text-muted); box-shadow: inset 0 0 0 1px var(--border); }
469
470 /* ─── Empty state ─── */
471 .rc-empty {
472 padding: var(--space-6);
473 text-align: center;
474 background: var(--bg-elevated);
475 border: 1px dashed var(--border-strong);
476 border-radius: 14px;
477 position: relative;
478 overflow: hidden;
479 }
480 .rc-empty-orb {
481 position: absolute;
482 inset: -30% auto auto -10%;
483 width: 260px; height: 260px;
484 background: radial-gradient(circle, rgba(140,109,255,0.16), rgba(54,197,214,0.08) 45%, transparent 70%);
485 filter: blur(60px);
486 opacity: 0.8;
487 pointer-events: none;
488 }
489 .rc-empty-mark {
490 position: relative;
491 z-index: 1;
492 margin: 0 auto var(--space-3);
493 width: 52px; height: 52px;
494 border-radius: 14px;
495 background: linear-gradient(135deg, rgba(140,109,255,0.18), rgba(54,197,214,0.12));
496 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.30);
497 display: flex;
498 align-items: center;
499 justify-content: center;
500 color: #c4b5fd;
501 }
502 .rc-empty-title {
503 position: relative;
504 z-index: 1;
505 margin: 0 0 6px;
506 font-family: var(--font-display);
507 font-size: 17px;
508 font-weight: 700;
509 color: var(--text-strong);
510 letter-spacing: -0.018em;
511 }
512 .rc-empty-body {
513 position: relative;
514 z-index: 1;
515 margin: 0 auto;
516 max-width: 460px;
517 font-size: 13.5px;
518 color: var(--text-muted);
519 line-height: 1.55;
520 }
521
522 /* ─── Form ─── */
523 .rc-form { padding: var(--space-5); }
524 .rc-form-group { margin-bottom: var(--space-4); }
525 .rc-form-label {
526 display: block;
527 font-family: var(--font-mono);
528 font-size: 11.5px;
529 font-weight: 700;
530 text-transform: uppercase;
531 letter-spacing: 0.12em;
532 color: var(--text-muted);
533 margin-bottom: 6px;
534 }
535 .rc-input {
536 width: 100%;
537 padding: 9px 12px;
538 font-size: 13.5px;
539 color: var(--text);
540 background: var(--bg);
541 border: 1px solid var(--border-strong);
542 border-radius: 8px;
543 outline: none;
544 font-family: var(--font-mono);
545 transition: border-color 120ms ease, box-shadow 120ms ease;
546 box-sizing: border-box;
547 }
548 .rc-input:focus {
549 border-color: var(--border-focus, rgba(140,109,255,0.55));
550 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
551 }
552 .rc-form-hint { margin-top: 6px; font-size: 12px; color: var(--text-muted); }
553 .rc-form-hint code {
554 font-family: var(--font-mono);
555 font-size: 11.5px;
556 background: rgba(255,255,255,0.04);
557 border: 1px solid var(--border);
558 padding: 1px 6px;
559 border-radius: 5px;
560 color: var(--text);
561 }
562
563 /* ─── Buttons ─── */
564 .rc-btn {
565 display: inline-flex;
566 align-items: center;
567 gap: 6px;
568 padding: 9px 16px;
569 border-radius: 10px;
570 font-size: 13px;
571 font-weight: 600;
572 text-decoration: none;
573 border: 1px solid transparent;
574 cursor: pointer;
575 font-family: inherit;
576 line-height: 1;
577 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease, color 120ms ease;
578 }
579 .rc-btn-primary {
580 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
581 color: #fff;
582 box-shadow: 0 6px 18px -4px rgba(140,109,255,0.45), inset 0 1px 0 rgba(255,255,255,0.16);
583 }
584 .rc-btn-primary:hover {
585 transform: translateY(-1px);
586 box-shadow: 0 10px 24px -6px rgba(140,109,255,0.55), inset 0 1px 0 rgba(255,255,255,0.20);
587 color: #fff;
588 text-decoration: none;
589 }
590 .rc-btn-danger {
591 background: transparent;
592 color: #fecaca;
593 border-color: rgba(248,113,113,0.40);
594 }
595 .rc-btn-danger:hover {
596 background: rgba(248,113,113,0.10);
597 border-color: rgba(248,113,113,0.65);
598 color: #fee2e2;
599 text-decoration: none;
600 }
601 .rc-btn-sm { padding: 6px 11px; font-size: 12px; }
602`;
603
604/* Icons */
605const ChecklistIcon = () => (
606 <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
607 <path d="M9 11l3 3L22 4" />
608 <path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
609 </svg>
610);
611const PlusIcon = () => (
612 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
613 <line x1="12" y1="5" x2="12" y2="19" />
614 <line x1="5" y1="12" x2="19" y2="12" />
615 </svg>
616);
617const ArrowLeft = () => (
618 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
619 <line x1="19" y1="12" x2="5" y2="12" />
620 <polyline points="12 19 5 12 12 5" />
621 </svg>
622);
623const ShieldIcon = () => (
624 <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
625 <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
626 </svg>
627);
628
72629required.get(
73630 "/:owner/:repo/gates/protection/:id/checks",
74631 requireAuth,
@@ -91,6 +648,43 @@ required.get(
91648 const success = c.req.query("success");
92649 const error = c.req.query("error");
93650
651 // Pull the last status for every required check in parallel so the page
652 // renders fast even with a dozen entries.
653 const statuses = await Promise.all(
654 checks.map((ch) => lastStatusForCheck(repoRow.id, ch.checkName))
655 );
656 const enrichedChecks = checks.map((ch, i) => ({
657 ...ch,
658 lastStatus: statuses[i]!,
659 }));
660
661 const failingCount = enrichedChecks.filter(
662 (c) => c.lastStatus.status === "failed"
663 ).length;
664 const passingCount = enrichedChecks.filter(
665 (c) =>
666 c.lastStatus.status === "passed" || c.lastStatus.status === "repaired"
667 ).length;
668
669 let statusVariant: "is-on" | "is-warn" | "is-empty" = "is-empty";
670 let statusHead = "No required checks configured";
671 let statusDesc =
672 "Merges into matching branches don't require any named check right now. Add one below.";
673 if (checks.length > 0) {
674 if (failingCount > 0) {
675 statusVariant = "is-warn";
676 statusHead = `${failingCount} of ${checks.length} failing`;
677 statusDesc = `Last observed run: ${failingCount} red, ${passingCount} green. Merges are blocked until they're all green.`;
678 } else {
679 statusVariant = "is-on";
680 statusHead = `${checks.length} required check${checks.length === 1 ? "" : "s"} configured`;
681 statusDesc =
682 passingCount === checks.length
683 ? "All required checks last reported green. Merges into matching branches will be allowed."
684 : "Required checks haven't all reported yet. Merges block until each one has a passing run on the head commit.";
685 }
686 }
687
94688 return c.html(
95689 <Layout title={`Required checks — ${rule.pattern}`} user={user}>
96690 <RepoHeader
@@ -102,73 +696,174 @@ required.get(
102696 />
103697 <RepoNav owner={owner} repo={repo} active="gates" />
104698
105 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
106 <h3>
107 Required checks · <code>{rule.pattern}</code>
108 </h3>
109 <a href={`/${owner}/${repo}/gates/settings`} class="btn btn-sm">
110 Back to protection
111 </a>
112 </div>
113
114 <p style="font-size:13px;color:var(--text-muted);margin-bottom:16px">
115 Merges into branches matching this rule require a passing run for
116 each named check. Names match against <code>gate_runs.gate_name</code>{" "}
117 (e.g. <code>GateTest</code>, <code>AI Review</code>,{" "}
118 <code>Secret Scan</code>, <code>Type Check</code>) or the{" "}
119 <code>name:</code> field of a workflow in{" "}
120 <code>.gluecron/workflows/*.yml</code>.
121 </p>
122
123 {success && <div class="auth-success">{decodeURIComponent(success)}</div>}
124 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
125
126 <div class="panel" style="margin-bottom:16px">
127 {checks.length === 0 ? (
128 <div class="panel-empty">No required checks configured.</div>
129 ) : (
130 checks.map((ch) => (
131 <div class="panel-item" style="justify-content:space-between">
132 <code
133 style="background:var(--bg-tertiary);padding:2px 8px;border-radius:3px"
134 >
135 {ch.checkName}
136 </code>
137 <form
138 method="post"
139 action={`/${owner}/${repo}/gates/protection/${rule.id}/checks/${ch.id}/delete`}
140 onsubmit="return confirm('Remove this required check?')"
141 >
142 <button type="submit" class="btn btn-sm btn-danger">
143 Remove
144 </button>
145 </form>
699 <div class="rc-wrap">
700 <section class="rc-hero">
701 <div class="rc-hero-orb" aria-hidden="true" />
702 <div class="rc-hero-inner">
703 <div class="rc-hero-text">
704 <div class="rc-eyebrow">
705 <span class="rc-eyebrow-pill" aria-hidden="true">
706 <ChecklistIcon />
707 </span>
708 Required checks · {owner}/{repo}
709 </div>
710 <h1 class="rc-title">
711 <span class="rc-title-grad">Mergeability gates.</span>
712 </h1>
713 <p class="rc-sub">
714 Merges into branches matching <code>{rule.pattern}</code>{" "}
715 require a passing run for each named check. Names match
716 against <code>gate_runs.gate_name</code> or a workflow{" "}
717 <code>name:</code> field.
718 </p>
146719 </div>
147 ))
720 <a href={`/${owner}/${repo}/gates/settings`} class="rc-hero-link">
721 <ArrowLeft /> Back to protection
722 </a>
723 </div>
724 </section>
725
726 {success && (
727 <div class="rc-banner is-ok" role="status">
728 <span class="rc-banner-dot" aria-hidden="true" />
729 {decodeURIComponent(success)}
730 </div>
148731 )}
149 </div>
732 {error && (
733 <div class="rc-banner is-error" role="alert">
734 <span class="rc-banner-dot" aria-hidden="true" />
735 {decodeURIComponent(error)}
736 </div>
737 )}
738
739 <section class={`rc-status ${statusVariant}`}>
740 <div class="rc-status-row">
741 <span class="rc-status-mark" aria-hidden="true">
742 <ChecklistIcon />
743 </span>
744 <div class="rc-status-text">
745 <h2 class="rc-status-headline">{statusHead}</h2>
746 <p class="rc-status-desc">{statusDesc}</p>
747 </div>
748 <span class="rc-pattern-pill">
749 <ShieldIcon />
750 {rule.pattern}
751 </span>
752 </div>
753 </section>
150754
151 <form
152 method="post"
153 action={`/${owner}/${repo}/gates/protection/${rule.id}/checks`}
154 class="panel"
155 style="padding:16px"
156 >
157 <div class="form-group">
158 <label>Check name</label>
159 <input
160 type="text"
161 name="checkName"
162 required
163 placeholder="GateTest"
164 aria-label="Check name"
165 style="font-family:var(--font-mono)"
166 />
167 </div>
168 <button type="submit" class="btn btn-primary">
169 Add required check
170 </button>
171 </form>
755 <section class="rc-section">
756 <header class="rc-section-head">
757 <h3 class="rc-section-title">
758 <span class="rc-section-icon" aria-hidden="true">
759 <ChecklistIcon />
760 </span>
761 Configured checks
762 </h3>
763 <p class="rc-section-sub">
764 Each check must have a passing run on the head commit before a
765 merge is allowed.
766 </p>
767 </header>
768 <div class="rc-section-body">
769 {enrichedChecks.length === 0 ? (
770 <div class="rc-empty">
771 <div class="rc-empty-orb" aria-hidden="true" />
772 <div class="rc-empty-mark" aria-hidden="true">
773 <ChecklistIcon />
774 </div>
775 <h4 class="rc-empty-title">No required checks yet</h4>
776 <p class="rc-empty-body">
777 Common names: <code>GateTest</code>, <code>AI Review</code>,{" "}
778 <code>Secret Scan</code>, <code>Type Check</code>. Add one
779 below to gate merges on it.
780 </p>
781 </div>
782 ) : (
783 <div class="rc-list">
784 {enrichedChecks.map((ch) => {
785 const lc = lightClass(ch.lastStatus.status);
786 const dur = ch.lastStatus.durationMs
787 ? `${(ch.lastStatus.durationMs / 1000).toFixed(1)}s`
788 : null;
789 return (
790 <div class="rc-check">
791 <div class="rc-check-main">
792 <span
793 class={`rc-light ${lc}`}
794 aria-label={ch.lastStatus.status ?? "no runs yet"}
795 />
796 <div class="rc-check-body">
797 <div class="rc-check-name">{ch.checkName}</div>
798 <div class="rc-check-meta">
799 <span class={`rc-check-pill ${lc}`}>
800 {ch.lastStatus.status ?? "no runs"}
801 </span>
802 {dur && <span>·</span>}
803 {dur && <span>last run {dur}</span>}
804 <span>·</span>
805 <span>{relTime(ch.lastStatus.createdAt)}</span>
806 </div>
807 </div>
808 </div>
809 <form
810 method="post"
811 action={`/${owner}/${repo}/gates/protection/${rule.id}/checks/${ch.id}/delete`}
812 onsubmit="return confirm('Remove this required check?')"
813 >
814 <button type="submit" class="rc-btn rc-btn-danger rc-btn-sm">
815 Remove
816 </button>
817 </form>
818 </div>
819 );
820 })}
821 </div>
822 )}
823 </div>
824 </section>
825
826 <section class="rc-section">
827 <header class="rc-section-head">
828 <h3 class="rc-section-title">
829 <span class="rc-section-icon" aria-hidden="true">
830 <PlusIcon />
831 </span>
832 Add required check
833 </h3>
834 <p class="rc-section-sub">
835 Names are case-sensitive. Match exactly the gate or workflow{" "}
836 <code>name:</code>.
837 </p>
838 </header>
839 <form
840 method="post"
841 action={`/${owner}/${repo}/gates/protection/${rule.id}/checks`}
842 class="rc-form"
843 >
844 <div class="rc-form-group">
845 <label class="rc-form-label" for="rc-check-name">Check name</label>
846 <input
847 type="text"
848 id="rc-check-name"
849 name="checkName"
850 required
851 placeholder="GateTest"
852 aria-label="Check name"
853 class="rc-input"
854 />
855 <div class="rc-form-hint">
856 Examples: <code>GateTest</code>, <code>AI Review</code>,{" "}
857 <code>Secret Scan</code>, <code>Type Check</code>.
858 </div>
859 </div>
860 <button type="submit" class="rc-btn rc-btn-primary">
861 <PlusIcon /> Add required check
862 </button>
863 </form>
864 </section>
865 </div>
866 <style dangerouslySetInnerHTML={{ __html: rcStyles }} />
172867 </Layout>
173868 );
174869 }
Modifiedsrc/routes/rulesets.tsx+852−201View fileUnifiedSplit
Large file (1,129 lines). Load full file
Modifiedsrc/routes/saved-replies.tsx+623−94View fileUnifiedSplit
@@ -7,6 +7,12 @@
77 * POST /settings/replies/:id/delete delete
88 * POST /settings/replies/:id update
99 * GET /api/user/replies JSON list for the insertion picker
10 *
11 * 2026 polish: scoped `.sr-*` styles, gradient-hairline hero + orb, card list
12 * with shortcut chip + preview + copy/delete actions, "new reply" form in its
13 * own card with focus rings + gradient submit. Empty state with orb + helpful
14 * CTA. Every form action, POST handler, and validation rule is preserved
15 * exactly — this is a pure visual refresh.
1016 */
1117
1218import { Hono } from "hono";
@@ -41,6 +47,419 @@ async function listForUser(userId: string) {
4147 }
4248}
4349
50// ─── Scoped CSS (.sr-*) ─────────────────────────────────────────────────────
51// Every selector prefixed `.sr-*` so this surface cannot bleed into any
52// other page. Mirrors the gradient-hairline hero + card patterns from
53// admin-integrations.tsx and settings-2fa.tsx.
54const srStyles = `
55 .sr-wrap { max-width: 920px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
56
57 /* ─── Hero ─── */
58 .sr-hero {
59 position: relative;
60 margin-bottom: var(--space-5);
61 padding: var(--space-5) var(--space-6);
62 background: var(--bg-elevated);
63 border: 1px solid var(--border);
64 border-radius: 16px;
65 overflow: hidden;
66 }
67 .sr-hero::before {
68 content: '';
69 position: absolute;
70 top: 0; left: 0; right: 0;
71 height: 2px;
72 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
73 opacity: 0.7;
74 pointer-events: none;
75 }
76 .sr-hero-orb {
77 position: absolute;
78 inset: -20% -10% auto auto;
79 width: 380px; height: 380px;
80 background: radial-gradient(circle, rgba(140,109,255,0.20), rgba(54,197,214,0.10) 45%, transparent 70%);
81 filter: blur(80px);
82 opacity: 0.7;
83 pointer-events: none;
84 z-index: 0;
85 }
86 .sr-hero-inner { position: relative; z-index: 1; max-width: 720px; }
87 .sr-eyebrow {
88 font-size: 12px;
89 color: var(--text-muted);
90 margin-bottom: var(--space-2);
91 letter-spacing: 0.02em;
92 display: inline-flex;
93 align-items: center;
94 gap: 8px;
95 text-transform: uppercase;
96 font-family: var(--font-mono);
97 font-weight: 600;
98 }
99 .sr-eyebrow-pill {
100 display: inline-flex;
101 align-items: center;
102 justify-content: center;
103 width: 18px; height: 18px;
104 border-radius: 6px;
105 background: rgba(140,109,255,0.14);
106 color: #b69dff;
107 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.35);
108 }
109 .sr-crumb { color: var(--text-muted); text-decoration: none; }
110 .sr-crumb:hover { color: var(--text); }
111 .sr-title {
112 font-size: clamp(28px, 4vw, 40px);
113 font-family: var(--font-display);
114 font-weight: 800;
115 letter-spacing: -0.028em;
116 line-height: 1.05;
117 margin: 0 0 var(--space-2);
118 color: var(--text-strong);
119 }
120 .sr-title-grad {
121 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
122 -webkit-background-clip: text;
123 background-clip: text;
124 -webkit-text-fill-color: transparent;
125 color: transparent;
126 }
127 .sr-sub {
128 font-size: 15px;
129 color: var(--text-muted);
130 margin: 0;
131 line-height: 1.55;
132 max-width: 620px;
133 }
134
135 /* ─── Banner ─── */
136 .sr-banner {
137 margin-bottom: var(--space-4);
138 padding: 10px 14px;
139 border-radius: 10px;
140 font-size: 13.5px;
141 border: 1px solid var(--border);
142 background: rgba(255,255,255,0.025);
143 color: var(--text);
144 display: flex;
145 align-items: center;
146 gap: 10px;
147 }
148 .sr-banner.is-ok {
149 border-color: rgba(52,211,153,0.40);
150 background: rgba(52,211,153,0.08);
151 color: #bbf7d0;
152 }
153 .sr-banner.is-error {
154 border-color: rgba(248,113,113,0.40);
155 background: rgba(248,113,113,0.08);
156 color: #fecaca;
157 }
158 .sr-banner-dot {
159 width: 8px; height: 8px;
160 border-radius: 9999px;
161 background: currentColor;
162 flex-shrink: 0;
163 }
164
165 /* ─── Section card ─── */
166 .sr-section {
167 margin-bottom: var(--space-5);
168 background: var(--bg-elevated);
169 border: 1px solid var(--border);
170 border-radius: 14px;
171 overflow: hidden;
172 }
173 .sr-section-head {
174 padding: var(--space-4) var(--space-5);
175 border-bottom: 1px solid var(--border);
176 }
177 .sr-section-title {
178 margin: 0;
179 font-family: var(--font-display);
180 font-size: 17px;
181 font-weight: 700;
182 letter-spacing: -0.018em;
183 color: var(--text-strong);
184 display: flex;
185 align-items: center;
186 gap: 10px;
187 }
188 .sr-section-title-icon {
189 display: inline-flex;
190 align-items: center;
191 justify-content: center;
192 width: 26px; height: 26px;
193 border-radius: 8px;
194 background: rgba(140,109,255,0.12);
195 color: #b69dff;
196 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.28);
197 flex-shrink: 0;
198 }
199 .sr-section-sub {
200 margin: 6px 0 0 36px;
201 font-size: 12.5px;
202 color: var(--text-muted);
203 line-height: 1.5;
204 }
205 .sr-section-body { padding: var(--space-4) var(--space-5); }
206
207 /* ─── Form fields ─── */
208 .sr-field { margin-bottom: var(--space-4); }
209 .sr-field:last-child { margin-bottom: 0; }
210 .sr-field label {
211 display: block;
212 margin-bottom: 6px;
213 font-family: var(--font-mono);
214 font-size: 11.5px;
215 font-weight: 700;
216 text-transform: uppercase;
217 letter-spacing: 0.12em;
218 color: var(--text-muted);
219 }
220 .sr-input,
221 .sr-textarea {
222 width: 100%;
223 padding: 10px 12px;
224 font-size: 14px;
225 color: var(--text);
226 background: var(--bg);
227 border: 1px solid var(--border-strong);
228 border-radius: 8px;
229 outline: none;
230 transition: border-color 120ms ease, box-shadow 120ms ease;
231 box-sizing: border-box;
232 font-family: inherit;
233 }
234 .sr-input {
235 font-family: var(--font-mono);
236 font-size: 13.5px;
237 }
238 .sr-textarea {
239 font-family: var(--font-mono);
240 font-size: 13px;
241 resize: vertical;
242 min-height: 96px;
243 }
244 .sr-input:focus,
245 .sr-textarea:focus {
246 border-color: var(--border-focus, rgba(140,109,255,0.55));
247 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
248 }
249 .sr-hint {
250 font-size: 11.5px;
251 color: var(--text-muted);
252 margin-top: 6px;
253 line-height: 1.45;
254 }
255
256 /* ─── Reply card list ─── */
257 .sr-list {
258 list-style: none;
259 margin: 0;
260 padding: 0;
261 display: flex;
262 flex-direction: column;
263 gap: var(--space-3);
264 }
265 .sr-card {
266 background: var(--bg-elevated);
267 border: 1px solid var(--border);
268 border-radius: 14px;
269 overflow: hidden;
270 transition: border-color 120ms ease, box-shadow 120ms ease;
271 }
272 .sr-card[open] {
273 border-color: rgba(140,109,255,0.32);
274 box-shadow: 0 8px 24px -10px rgba(0,0,0,0.32);
275 }
276 .sr-card-summary {
277 display: flex;
278 align-items: center;
279 gap: 12px;
280 padding: 14px 18px;
281 cursor: pointer;
282 list-style: none;
283 user-select: none;
284 }
285 .sr-card-summary::-webkit-details-marker { display: none; }
286 .sr-shortcut {
287 flex-shrink: 0;
288 display: inline-flex;
289 align-items: center;
290 gap: 6px;
291 padding: 4px 10px;
292 border-radius: 9999px;
293 background: linear-gradient(135deg, rgba(140,109,255,0.18), rgba(54,197,214,0.14));
294 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.32);
295 color: #e9d5ff;
296 font-family: var(--font-mono);
297 font-size: 12.5px;
298 font-weight: 700;
299 letter-spacing: -0.005em;
300 white-space: nowrap;
301 }
302 .sr-shortcut::before {
303 content: '/';
304 color: rgba(255,255,255,0.45);
305 margin-right: -2px;
306 }
307 .sr-preview {
308 flex: 1;
309 min-width: 0;
310 font-size: 13px;
311 color: var(--text-muted);
312 overflow: hidden;
313 text-overflow: ellipsis;
314 white-space: nowrap;
315 }
316 .sr-chev {
317 flex-shrink: 0;
318 color: var(--text-muted);
319 transition: transform 160ms ease;
320 }
321 .sr-card[open] .sr-chev { transform: rotate(90deg); }
322 .sr-card-body {
323 padding: var(--space-4) var(--space-5);
324 border-top: 1px solid var(--border);
325 background: rgba(255,255,255,0.012);
326 }
327
328 /* ─── Buttons ─── */
329 .sr-btn {
330 display: inline-flex;
331 align-items: center;
332 justify-content: center;
333 gap: 6px;
334 padding: 9px 16px;
335 border-radius: 10px;
336 font-size: 13px;
337 font-weight: 600;
338 text-decoration: none;
339 border: 1px solid transparent;
340 cursor: pointer;
341 font-family: inherit;
342 line-height: 1;
343 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease, color 120ms ease;
344 }
345 .sr-btn-primary {
346 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
347 color: #fff;
348 box-shadow: 0 6px 18px -4px rgba(140,109,255,0.45), inset 0 1px 0 rgba(255,255,255,0.16);
349 }
350 .sr-btn-primary:hover {
351 transform: translateY(-1px);
352 box-shadow: 0 10px 24px -6px rgba(140,109,255,0.55), inset 0 1px 0 rgba(255,255,255,0.20);
353 color: #fff;
354 text-decoration: none;
355 }
356 .sr-btn-ghost {
357 background: rgba(255,255,255,0.025);
358 color: var(--text);
359 border-color: var(--border-strong);
360 }
361 .sr-btn-ghost:hover {
362 background: rgba(140,109,255,0.06);
363 border-color: rgba(140,109,255,0.45);
364 color: var(--text-strong);
365 text-decoration: none;
366 }
367 .sr-btn-danger {
368 background: transparent;
369 color: #fecaca;
370 border-color: rgba(248,113,113,0.40);
371 }
372 .sr-btn-danger:hover {
373 background: rgba(248,113,113,0.10);
374 border-color: rgba(248,113,113,0.65);
375 color: #fee2e2;
376 }
377 .sr-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: var(--space-3); }
378
379 /* ─── Empty state ─── */
380 .sr-empty {
381 position: relative;
382 padding: 56px 32px;
383 background: var(--bg-elevated);
384 border: 1px solid var(--border);
385 border-radius: 16px;
386 text-align: center;
387 overflow: hidden;
388 }
389 .sr-empty::before {
390 content: '';
391 position: absolute;
392 top: 0; left: 0; right: 0;
393 height: 2px;
394 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
395 opacity: 0.55;
396 pointer-events: none;
397 }
398 .sr-empty-orb {
399 width: 96px;
400 height: 96px;
401 margin: 0 auto 18px;
402 border-radius: 9999px;
403 background:
404 radial-gradient(circle at 35% 35%, rgba(140,109,255,0.55), rgba(54,197,214,0.25) 55%, transparent 75%);
405 box-shadow:
406 0 0 32px rgba(140,109,255,0.35),
407 inset 0 0 0 1px rgba(140,109,255,0.35);
408 display: flex;
409 align-items: center;
410 justify-content: center;
411 color: #fff;
412 }
413 .sr-empty-title {
414 font-family: var(--font-display);
415 font-size: 22px;
416 font-weight: 700;
417 letter-spacing: -0.018em;
418 color: var(--text-strong);
419 margin: 0 0 8px;
420 }
421 .sr-empty-sub {
422 font-size: 14.5px;
423 color: var(--text-muted);
424 line-height: 1.55;
425 margin: 0 auto 18px;
426 max-width: 460px;
427 }
428 .sr-count-pill {
429 display: inline-flex;
430 align-items: center;
431 gap: 6px;
432 padding: 3px 10px;
433 border-radius: 9999px;
434 background: rgba(255,255,255,0.04);
435 border: 1px solid var(--border);
436 font-size: 11.5px;
437 font-family: var(--font-mono);
438 color: var(--text-muted);
439 margin-left: 8px;
440 vertical-align: middle;
441 }
442`;
443
444const ReplyIcon = () => (
445 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
446 <polyline points="9 17 4 12 9 7" />
447 <path d="M20 18v-2a4 4 0 0 0-4-4H4" />
448 </svg>
449);
450
451const ChevIcon = () => (
452 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" class="sr-chev" aria-hidden="true">
453 <polyline points="9 18 15 12 9 6" />
454 </svg>
455);
456
457const EmptyIcon = () => (
458 <svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
459 <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
460 </svg>
461);
462
44463replies.get("/settings/replies", async (c) => {
45464 const user = c.get("user")!;
46465 const rows = await listForUser(user.id);
@@ -49,108 +468,218 @@ replies.get("/settings/replies", async (c) => {
49468
50469 return c.html(
51470 <Layout title="Saved replies" user={user}>
52 <div class="settings-container" style="max-width: 720px">
53 <h2>Saved replies</h2>
54 <p style="color: var(--text-muted); font-size: 14px; margin-bottom: 16px">
55 Canned responses you can insert into any issue or PR comment. The
56 shortcut is a nickname only you see.
57 </p>
58
59 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
60 {success && (
61 <div class="auth-success">{decodeURIComponent(success)}</div>
62 )}
471 <div class="sr-wrap">
472 <section class="sr-hero">
473 <div class="sr-hero-orb" aria-hidden="true" />
474 <div class="sr-hero-inner">
475 <div class="sr-eyebrow">
476 <span class="sr-eyebrow-pill" aria-hidden="true">
477 <ReplyIcon />
478 </span>
479 <a href="/settings" class="sr-crumb">Settings</a>
480 <span>/</span>
481 <span>Saved replies</span>
482 </div>
483 <h2 class="sr-title">
484 <span class="sr-title-grad">Saved replies.</span>
485 </h2>
486 <p class="sr-sub">
487 Canned responses you can drop into any issue or PR comment with a
488 shortcut. The shortcut is a nickname only you ever see — pick
489 something fast to type.
490 </p>
491 </div>
492 </section>
63493
64 <form method="post" action="/settings/replies" style="margin-bottom: 24px">
65 <div class="form-group">
66 <label for="shortcut">Shortcut</label>
67 <input
68 type="text"
69 id="shortcut"
70 name="shortcut"
71 required
72 maxLength={64}
73 placeholder="lgtm"
74 />
494 {error && (
495 <div class="sr-banner is-error" role="alert">
496 <span class="sr-banner-dot" aria-hidden="true" />
497 {decodeURIComponent(error)}
75498 </div>
76 <div class="form-group">
77 <label for="body">Reply body</label>
78 <textarea
79 id="body"
80 name="body"
81 rows={4}
82 required
83 maxLength={4096}
84 placeholder="LGTM! Thanks for the PR."
85 style="font-family: var(--font-mono); font-size: 13px"
86 />
499 )}
500 {success && (
501 <div class="sr-banner is-ok" role="status">
502 <span class="sr-banner-dot" aria-hidden="true" />
503 {decodeURIComponent(success)}
87504 </div>
88 <button type="submit" class="btn btn-primary">
89 Add saved reply
90 </button>
91 </form>
92
93 {rows.length > 0 && (
94 <div>
95 <h3 style="font-size: 16px; margin-bottom: 12px">
96 Your replies ({rows.length})
505 )}
506
507 {/* ─── Create form card ─── */}
508 <section class="sr-section">
509 <header class="sr-section-head">
510 <h3 class="sr-section-title">
511 <span class="sr-section-title-icon" aria-hidden="true">
512 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
513 <line x1="12" y1="5" x2="12" y2="19" />
514 <line x1="5" y1="12" x2="19" y2="12" />
515 </svg>
516 </span>
517 New saved reply
97518 </h3>
98 <div class="saved-replies-list">
99 {rows.map((r) => (
100 <details class="saved-reply-item">
101 <summary>
102 <code>{r.shortcut}</code>
103 <span style="color: var(--text-muted); font-size: 12px; margin-left: 8px">
104 {r.body.slice(0, 80).replace(/\n/g, " ")}
105 {r.body.length > 80 ? "\u2026" : ""}
106 </span>
107 </summary>
108 <div style="padding: 12px 16px; background: var(--bg-secondary); border-top: 1px solid var(--border)">
109 <form method="post" action={`/settings/replies/${r.id}`}>
110 <div class="form-group">
111 <label>Shortcut</label>
112 <input
113 type="text"
114 name="shortcut"
115 required
116 value={r.shortcut}
117 maxLength={64}
118 aria-label="Shortcut"
119 />
120 </div>
121 <div class="form-group">
122 <label>Body</label>
123 <textarea
124 name="body"
125 rows={4}
126 required
127 maxLength={4096}
128 style="font-family: var(--font-mono); font-size: 13px"
129 >
130 {r.body}
131 </textarea>
132 </div>
133 <div style="display: flex; gap: 8px">
134 <button type="submit" class="btn btn-primary">
135 Save
136 </button>
137 <button
138 type="submit"
139 formaction={`/settings/replies/${r.id}/delete`}
140 class="btn btn-danger"
141 onclick="return confirm('Delete this saved reply?')"
142 >
143 Delete
144 </button>
145 </div>
146 </form>
147 </div>
148 </details>
149 ))}
519 <p class="sr-section-sub">
520 Pick a shortcut (a-z, 0-9, dashes work great) and write the body
521 once — reuse it everywhere.
522 </p>
523 </header>
524 <div class="sr-section-body">
525 <form method="post" action="/settings/replies">
526 <div class="sr-field">
527 <label for="shortcut">Shortcut</label>
528 <input
529 type="text"
530 id="shortcut"
531 name="shortcut"
532 required
533 maxLength={64}
534 placeholder="lgtm"
535 class="sr-input"
536 />
537 <div class="sr-hint">
538 Lowercase, dashes encouraged. Must be unique per account.
539 </div>
540 </div>
541 <div class="sr-field">
542 <label for="body">Reply body</label>
543 <textarea
544 id="body"
545 name="body"
546 rows={5}
547 required
548 maxLength={4096}
549 placeholder="LGTM! Thanks for the PR."
550 class="sr-textarea"
551 />
552 <div class="sr-hint">Markdown supported. Up to 4 096 characters.</div>
553 </div>
554 <button type="submit" class="sr-btn sr-btn-primary">
555 Add saved reply
556 </button>
557 </form>
558 </div>
559 </section>
560
561 {/* ─── List / empty state ─── */}
562 {rows.length === 0 ? (
563 <div class="sr-empty">
564 <div class="sr-empty-orb" aria-hidden="true">
565 <EmptyIcon />
150566 </div>
567 <h2 class="sr-empty-title">No saved replies yet</h2>
568 <p class="sr-empty-sub">
569 Add a canned response above and it will show up here, ready to
570 insert into any issue or PR comment via the reply picker.
571 </p>
151572 </div>
573 ) : (
574 <section class="sr-section">
575 <header class="sr-section-head">
576 <h3 class="sr-section-title">
577 <span class="sr-section-title-icon" aria-hidden="true">
578 <ReplyIcon />
579 </span>
580 Your replies
581 <span class="sr-count-pill">{rows.length}</span>
582 </h3>
583 <p class="sr-section-sub">
584 Click any reply to edit, copy the body, or delete it.
585 </p>
586 </header>
587 <div class="sr-section-body">
588 <ul class="sr-list">
589 {rows.map((r) => {
590 const preview = r.body.slice(0, 100).replace(/\n/g, " ");
591 const truncated = r.body.length > 100;
592 return (
593 <li>
594 <details class="sr-card">
595 <summary class="sr-card-summary">
596 <span class="sr-shortcut">{r.shortcut}</span>
597 <span class="sr-preview">
598 {preview}
599 {truncated ? "…" : ""}
600 </span>
601 <ChevIcon />
602 </summary>
603 <div class="sr-card-body">
604 <form method="post" action={`/settings/replies/${r.id}`}>
605 <div class="sr-field">
606 <label>Shortcut</label>
607 <input
608 type="text"
609 name="shortcut"
610 required
611 value={r.shortcut}
612 maxLength={64}
613 aria-label="Shortcut"
614 class="sr-input"
615 />
616 </div>
617 <div class="sr-field">
618 <label>Body</label>
619 <textarea
620 name="body"
621 rows={5}
622 required
623 maxLength={4096}
624 class="sr-textarea"
625 >
626 {r.body}
627 </textarea>
628 </div>
629 <div class="sr-actions">
630 <button type="submit" class="sr-btn sr-btn-primary">
631 Save changes
632 </button>
633 <button
634 type="button"
635 class="sr-btn sr-btn-ghost"
636 data-sr-copy={r.body}
637 title="Copy body to clipboard"
638 >
639 Copy body
640 </button>
641 <button
642 type="submit"
643 formaction={`/settings/replies/${r.id}/delete`}
644 class="sr-btn sr-btn-danger"
645 onclick="return confirm('Delete this saved reply?')"
646 >
647 Delete
648 </button>
649 </div>
650 </form>
651 </div>
652 </details>
653 </li>
654 );
655 })}
656 </ul>
657 </div>
658 </section>
152659 )}
153660 </div>
661 <style dangerouslySetInnerHTML={{ __html: srStyles }} />
662 <script
663 dangerouslySetInnerHTML={{
664 __html: `
665 document.addEventListener('click', function (ev) {
666 var t = ev.target;
667 if (!(t instanceof HTMLElement)) return;
668 var btn = t.closest('[data-sr-copy]');
669 if (!btn) return;
670 ev.preventDefault();
671 var body = btn.getAttribute('data-sr-copy') || '';
672 if (navigator.clipboard && navigator.clipboard.writeText) {
673 navigator.clipboard.writeText(body).then(function () {
674 var prev = btn.textContent;
675 btn.textContent = 'Copied!';
676 setTimeout(function () { btn.textContent = prev; }, 1400);
677 });
678 }
679 });
680 `,
681 }}
682 />
154683 </Layout>
155684 );
156685});
Modifiedsrc/routes/settings.tsx+650−0View fileUnifiedSplit
@@ -1165,6 +1165,645 @@ settings.get("/settings/digest/preview", async (c) => {
11651165 );
11661166});
11671167
1168// ─── /settings/notifications — dedicated notifications sub-page ───────────
1169// 2026 polish: scoped `.notifset-*` styles, channel toggles as polished
1170// toggle pills, per-event rules grouped by channel. The underlying POST
1171// handler is unchanged; this GET just gives the surface its own page so
1172// it can be linked to directly from /notifications and the main /settings
1173// page.
1174const notifsetStyles = `
1175 .notifset-wrap { max-width: 920px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
1176
1177 .notifset-hero {
1178 position: relative;
1179 margin-bottom: var(--space-5);
1180 padding: var(--space-5) var(--space-6);
1181 background: var(--bg-elevated);
1182 border: 1px solid var(--border);
1183 border-radius: 16px;
1184 overflow: hidden;
1185 }
1186 .notifset-hero::before {
1187 content: '';
1188 position: absolute;
1189 top: 0; left: 0; right: 0;
1190 height: 2px;
1191 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
1192 opacity: 0.7;
1193 pointer-events: none;
1194 }
1195 .notifset-hero-orb {
1196 position: absolute;
1197 inset: -20% -10% auto auto;
1198 width: 380px; height: 380px;
1199 background: radial-gradient(circle, rgba(140,109,255,0.20), rgba(54,197,214,0.10) 45%, transparent 70%);
1200 filter: blur(80px);
1201 opacity: 0.7;
1202 pointer-events: none;
1203 z-index: 0;
1204 }
1205 .notifset-hero-inner { position: relative; z-index: 1; max-width: 720px; }
1206 .notifset-eyebrow {
1207 font-size: 12px;
1208 color: var(--text-muted);
1209 margin-bottom: var(--space-2);
1210 letter-spacing: 0.02em;
1211 display: inline-flex;
1212 align-items: center;
1213 gap: 8px;
1214 text-transform: uppercase;
1215 font-family: var(--font-mono);
1216 font-weight: 600;
1217 }
1218 .notifset-eyebrow-pill {
1219 display: inline-flex;
1220 align-items: center;
1221 justify-content: center;
1222 width: 18px; height: 18px;
1223 border-radius: 6px;
1224 background: rgba(140,109,255,0.14);
1225 color: #b69dff;
1226 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.35);
1227 }
1228 .notifset-crumb { color: var(--text-muted); text-decoration: none; }
1229 .notifset-crumb:hover { color: var(--text); }
1230 .notifset-title {
1231 font-size: clamp(28px, 4vw, 40px);
1232 font-family: var(--font-display);
1233 font-weight: 800;
1234 letter-spacing: -0.028em;
1235 line-height: 1.05;
1236 margin: 0 0 var(--space-2);
1237 color: var(--text-strong);
1238 }
1239 .notifset-title-grad {
1240 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
1241 -webkit-background-clip: text;
1242 background-clip: text;
1243 -webkit-text-fill-color: transparent;
1244 color: transparent;
1245 }
1246 .notifset-sub {
1247 font-size: 15px;
1248 color: var(--text-muted);
1249 margin: 0;
1250 line-height: 1.55;
1251 max-width: 620px;
1252 }
1253
1254 .notifset-banner {
1255 margin-bottom: var(--space-4);
1256 padding: 10px 14px;
1257 border-radius: 10px;
1258 font-size: 13.5px;
1259 border: 1px solid rgba(52,211,153,0.40);
1260 background: rgba(52,211,153,0.08);
1261 color: #bbf7d0;
1262 display: flex;
1263 align-items: center;
1264 gap: 10px;
1265 }
1266 .notifset-banner-dot {
1267 width: 8px; height: 8px;
1268 border-radius: 9999px;
1269 background: currentColor;
1270 }
1271
1272 /* ─── Channel pills ─── */
1273 .notifset-channels {
1274 display: grid;
1275 grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
1276 gap: var(--space-3);
1277 margin-bottom: var(--space-5);
1278 }
1279 .notifset-channel {
1280 padding: 16px 18px;
1281 background: var(--bg-elevated);
1282 border: 1px solid var(--border);
1283 border-radius: 14px;
1284 display: flex;
1285 align-items: center;
1286 gap: 12px;
1287 }
1288 .notifset-channel.is-active {
1289 border-color: rgba(140,109,255,0.32);
1290 background: linear-gradient(135deg, rgba(140,109,255,0.08) 0%, rgba(15,17,26,0) 60%), var(--bg-elevated);
1291 }
1292 .notifset-channel-icon {
1293 flex-shrink: 0;
1294 width: 36px; height: 36px;
1295 border-radius: 10px;
1296 background: linear-gradient(135deg, rgba(140,109,255,0.22), rgba(54,197,214,0.16));
1297 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.32);
1298 display: flex;
1299 align-items: center;
1300 justify-content: center;
1301 color: #c4b5fd;
1302 }
1303 .notifset-channel-text { flex: 1; min-width: 0; }
1304 .notifset-channel-name {
1305 margin: 0;
1306 font-family: var(--font-display);
1307 font-size: 14.5px;
1308 font-weight: 700;
1309 letter-spacing: -0.012em;
1310 color: var(--text-strong);
1311 }
1312 .notifset-channel-meta {
1313 margin: 2px 0 0;
1314 font-size: 12px;
1315 color: var(--text-muted);
1316 }
1317 .notifset-channel-pill {
1318 display: inline-flex;
1319 align-items: center;
1320 gap: 5px;
1321 padding: 3px 9px;
1322 border-radius: 9999px;
1323 font-size: 10.5px;
1324 font-weight: 700;
1325 letter-spacing: 0.05em;
1326 text-transform: uppercase;
1327 background: rgba(255,255,255,0.04);
1328 border: 1px solid var(--border);
1329 color: var(--text-muted);
1330 }
1331 .notifset-channel-pill.is-on {
1332 background: rgba(52,211,153,0.14);
1333 color: #6ee7b7;
1334 border-color: rgba(52,211,153,0.32);
1335 }
1336 .notifset-channel-pill .dot { width: 5px; height: 5px; border-radius: 9999px; background: currentColor; }
1337
1338 /* ─── Section card ─── */
1339 .notifset-section {
1340 margin-bottom: var(--space-5);
1341 background: var(--bg-elevated);
1342 border: 1px solid var(--border);
1343 border-radius: 14px;
1344 overflow: hidden;
1345 }
1346 .notifset-section-head {
1347 padding: var(--space-4) var(--space-5);
1348 border-bottom: 1px solid var(--border);
1349 }
1350 .notifset-section-title {
1351 margin: 0;
1352 font-family: var(--font-display);
1353 font-size: 17px;
1354 font-weight: 700;
1355 letter-spacing: -0.018em;
1356 color: var(--text-strong);
1357 display: flex;
1358 align-items: center;
1359 gap: 10px;
1360 }
1361 .notifset-section-icon {
1362 display: inline-flex;
1363 align-items: center;
1364 justify-content: center;
1365 width: 26px; height: 26px;
1366 border-radius: 8px;
1367 background: rgba(140,109,255,0.12);
1368 color: #b69dff;
1369 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.28);
1370 flex-shrink: 0;
1371 }
1372 .notifset-section-sub {
1373 margin: 6px 0 0 36px;
1374 font-size: 12.5px;
1375 color: var(--text-muted);
1376 line-height: 1.5;
1377 }
1378 .notifset-section-body { padding: var(--space-4) var(--space-5); }
1379 .notifset-section-foot {
1380 padding: var(--space-3) var(--space-5);
1381 border-top: 1px solid var(--border);
1382 background: rgba(255,255,255,0.012);
1383 display: flex;
1384 justify-content: space-between;
1385 align-items: center;
1386 gap: var(--space-2);
1387 flex-wrap: wrap;
1388 }
1389 .notifset-foot-hint { font-size: 12px; color: var(--text-muted); }
1390
1391 /* ─── Rule rows ─── */
1392 .notifset-rule {
1393 display: flex;
1394 align-items: flex-start;
1395 gap: 12px;
1396 padding: 12px 14px;
1397 background: var(--bg);
1398 border: 1px solid var(--border-strong);
1399 border-radius: 10px;
1400 margin-bottom: 8px;
1401 transition: border-color 120ms ease, background 120ms ease;
1402 }
1403 .notifset-rule:last-child { margin-bottom: 0; }
1404 .notifset-rule:hover { border-color: rgba(140,109,255,0.32); background: rgba(140,109,255,0.04); }
1405 .notifset-rule input[type="checkbox"] {
1406 margin-top: 2px;
1407 flex-shrink: 0;
1408 accent-color: #8c6dff;
1409 width: 16px;
1410 height: 16px;
1411 }
1412 .notifset-rule-text { flex: 1; min-width: 0; font-size: 13.5px; color: var(--text); line-height: 1.45; }
1413 .notifset-rule-text code {
1414 font-family: var(--font-mono);
1415 font-size: 12px;
1416 background: rgba(255,255,255,0.04);
1417 border: 1px solid var(--border);
1418 padding: 1px 6px;
1419 border-radius: 4px;
1420 color: var(--text);
1421 }
1422 .notifset-rule-hint {
1423 display: block;
1424 margin-top: 3px;
1425 color: var(--text-muted);
1426 font-size: 12px;
1427 font-weight: 400;
1428 }
1429 .notifset-hour {
1430 display: flex;
1431 align-items: center;
1432 gap: 10px;
1433 flex-wrap: wrap;
1434 padding: 12px 14px;
1435 background: var(--bg);
1436 border: 1px solid var(--border-strong);
1437 border-radius: 10px;
1438 margin-top: 12px;
1439 }
1440 .notifset-hour label { font-size: 13px; color: var(--text-muted); }
1441 .notifset-hour input[type="number"] {
1442 width: 72px;
1443 padding: 7px 10px;
1444 font-size: 13.5px;
1445 color: var(--text);
1446 background: var(--bg-elevated);
1447 border: 1px solid var(--border-strong);
1448 border-radius: 8px;
1449 outline: none;
1450 font-family: var(--font-mono);
1451 transition: border-color 120ms ease, box-shadow 120ms ease;
1452 }
1453 .notifset-hour input[type="number"]:focus {
1454 border-color: var(--border-focus, rgba(140,109,255,0.55));
1455 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
1456 }
1457 .notifset-hour a { color: var(--text-link, var(--accent)); font-size: 12.5px; text-decoration: none; }
1458 .notifset-hour a:hover { text-decoration: underline; }
1459
1460 /* ─── Submit button ─── */
1461 .notifset-btn {
1462 display: inline-flex;
1463 align-items: center;
1464 justify-content: center;
1465 gap: 6px;
1466 padding: 10px 18px;
1467 border-radius: 10px;
1468 font-size: 13.5px;
1469 font-weight: 600;
1470 text-decoration: none;
1471 border: 1px solid transparent;
1472 cursor: pointer;
1473 font-family: inherit;
1474 line-height: 1;
1475 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
1476 color: #fff;
1477 box-shadow: 0 6px 18px -4px rgba(140,109,255,0.45), inset 0 1px 0 rgba(255,255,255,0.16);
1478 transition: transform 120ms ease, box-shadow 120ms ease;
1479 }
1480 .notifset-btn:hover {
1481 transform: translateY(-1px);
1482 box-shadow: 0 10px 24px -6px rgba(140,109,255,0.55), inset 0 1px 0 rgba(255,255,255,0.20);
1483 }
1484`;
1485
1486const NotifsetBellIcon = () => (
1487 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
1488 <path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
1489 <path d="M13.73 21a2 2 0 0 1-3.46 0" />
1490 </svg>
1491);
1492const NotifsetMailIcon = () => (
1493 <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
1494 <path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" />
1495 <polyline points="22,6 12,13 2,6" />
1496 </svg>
1497);
1498const NotifsetPushIcon = () => (
1499 <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
1500 <path d="M5 4h14a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z" />
1501 <polyline points="9 14 12 17 22 7" />
1502 </svg>
1503);
1504const NotifsetInappIcon = () => (
1505 <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
1506 <circle cx="12" cy="12" r="10" />
1507 <path d="M12 8v4l3 3" />
1508 </svg>
1509);
1510
1511settings.get("/settings/notifications", async (c) => {
1512 const user = c.get("user")!;
1513 const success = c.req.query("success");
1514
1515 // Count how many event toggles are enabled per channel (just for the pill).
1516 const emailOnCount =
1517 (user.notifyEmailOnMention ? 1 : 0) +
1518 (user.notifyEmailOnAssign ? 1 : 0) +
1519 (user.notifyEmailOnGateFail ? 1 : 0) +
1520 (user.notifyEmailDigestWeekly ? 1 : 0);
1521 const pushOnCount =
1522 (user.notifyPushOnMention ? 1 : 0) +
1523 (user.notifyPushOnAssign ? 1 : 0) +
1524 (user.notifyPushOnReviewRequest ? 1 : 0) +
1525 (user.notifyPushOnDeployFailed ? 1 : 0);
1526
1527 return c.html(
1528 <Layout title="Notification preferences" user={user}>
1529 <div class="notifset-wrap">
1530 <section class="notifset-hero">
1531 <div class="notifset-hero-orb" aria-hidden="true" />
1532 <div class="notifset-hero-inner">
1533 <div class="notifset-eyebrow">
1534 <span class="notifset-eyebrow-pill" aria-hidden="true">
1535 <NotifsetBellIcon />
1536 </span>
1537 <a href="/settings" class="notifset-crumb">Settings</a>
1538 <span>/</span>
1539 <span>Notifications</span>
1540 </div>
1541 <h2 class="notifset-title">
1542 <span class="notifset-title-grad">Notifications.</span>
1543 </h2>
1544 <p class="notifset-sub">
1545 Pick which events reach you and on which channels. In-app
1546 notifications always land in your <a href="/notifications" style="color:var(--accent)">inbox</a>;
1547 email and push are opt-in per event.
1548 </p>
1549 </div>
1550 </section>
1551
1552 {success && (
1553 <div class="notifset-banner" role="status">
1554 <span class="notifset-banner-dot" aria-hidden="true" />
1555 {decodeURIComponent(success)}
1556 </div>
1557 )}
1558
1559 {/* ─── Channel pills ─── */}
1560 <div class="notifset-channels">
1561 <div class="notifset-channel is-active">
1562 <div class="notifset-channel-icon" aria-hidden="true">
1563 <NotifsetInappIcon />
1564 </div>
1565 <div class="notifset-channel-text">
1566 <h3 class="notifset-channel-name">In-app</h3>
1567 <p class="notifset-channel-meta">
1568 <a href="/notifications" style="color:var(--text-muted)">Always on · bell icon</a>
1569 </p>
1570 </div>
1571 <span class="notifset-channel-pill is-on">
1572 <span class="dot" aria-hidden="true" />on
1573 </span>
1574 </div>
1575 <div class={"notifset-channel" + (emailOnCount > 0 ? " is-active" : "")}>
1576 <div class="notifset-channel-icon" aria-hidden="true">
1577 <NotifsetMailIcon />
1578 </div>
1579 <div class="notifset-channel-text">
1580 <h3 class="notifset-channel-name">Email</h3>
1581 <p class="notifset-channel-meta">{emailOnCount} of 4 events enabled</p>
1582 </div>
1583 <span class={"notifset-channel-pill " + (emailOnCount > 0 ? "is-on" : "")}>
1584 <span class="dot" aria-hidden="true" />
1585 {emailOnCount > 0 ? "on" : "off"}
1586 </span>
1587 </div>
1588 <div class={"notifset-channel" + (pushOnCount > 0 ? " is-active" : "")}>
1589 <div class="notifset-channel-icon" aria-hidden="true">
1590 <NotifsetPushIcon />
1591 </div>
1592 <div class="notifset-channel-text">
1593 <h3 class="notifset-channel-name">Web push</h3>
1594 <p class="notifset-channel-meta">{pushOnCount} of 4 events enabled</p>
1595 </div>
1596 <span class={"notifset-channel-pill " + (pushOnCount > 0 ? "is-on" : "")}>
1597 <span class="dot" aria-hidden="true" />
1598 {pushOnCount > 0 ? "on" : "off"}
1599 </span>
1600 </div>
1601 </div>
1602
1603 {/* ─── Event rules form ─── */}
1604 <form method="post" action="/settings/notifications">
1605 <section class="notifset-section">
1606 <header class="notifset-section-head">
1607 <h3 class="notifset-section-title">
1608 <span class="notifset-section-icon" aria-hidden="true">
1609 <NotifsetMailIcon />
1610 </span>
1611 Email rules
1612 </h3>
1613 <p class="notifset-section-sub">
1614 Pick which events email you. In-app notifications continue
1615 regardless of what you toggle here.
1616 </p>
1617 </header>
1618 <div class="notifset-section-body">
1619 <label class="notifset-rule">
1620 <input
1621 type="checkbox"
1622 name="notify_email_on_mention"
1623 value="1"
1624 checked={user.notifyEmailOnMention}
1625 aria-label="Someone @mentions me or requests a review"
1626 />
1627 <span class="notifset-rule-text">
1628 Someone <code>@mentions</code> me or requests a review
1629 <span class="notifset-rule-hint">
1630 Direct pings on issues, PRs, and code comments.
1631 </span>
1632 </span>
1633 </label>
1634 <label class="notifset-rule">
1635 <input
1636 type="checkbox"
1637 name="notify_email_on_assign"
1638 value="1"
1639 checked={user.notifyEmailOnAssign}
1640 aria-label="I am assigned to an issue or PR"
1641 />
1642 <span class="notifset-rule-text">
1643 I am assigned to an issue or PR
1644 <span class="notifset-rule-hint">
1645 Email when someone hands you something to work on.
1646 </span>
1647 </span>
1648 </label>
1649 <label class="notifset-rule">
1650 <input
1651 type="checkbox"
1652 name="notify_email_on_gate_fail"
1653 value="1"
1654 checked={user.notifyEmailOnGateFail}
1655 aria-label="A gate fails on one of my repositories"
1656 />
1657 <span class="notifset-rule-text">
1658 A gate fails on one of my repositories
1659 <span class="notifset-rule-hint">
1660 Security, test, or build gate alerts on your repos.
1661 </span>
1662 </span>
1663 </label>
1664 <label class="notifset-rule">
1665 <input
1666 type="checkbox"
1667 name="notify_email_digest_weekly"
1668 value="1"
1669 checked={user.notifyEmailDigestWeekly}
1670 aria-label="Weekly digest email"
1671 />
1672 <span class="notifset-rule-text">
1673 Weekly digest — <a href="/settings/digest/preview" style="color:var(--accent)">preview</a>
1674 <span class="notifset-rule-hint">
1675 A Monday summary of what shipped last week.
1676 </span>
1677 </span>
1678 </label>
1679 </div>
1680 </section>
1681
1682 <section class="notifset-section">
1683 <header class="notifset-section-head">
1684 <h3 class="notifset-section-title">
1685 <span class="notifset-section-icon" aria-hidden="true">
1686 <NotifsetPushIcon />
1687 </span>
1688 Web push rules
1689 </h3>
1690 <p class="notifset-section-sub">
1691 Install Gluecron as a PWA, then subscribe a device from{" "}
1692 <a href="/settings" style="color:var(--accent)">main settings</a>. These
1693 toggles control which event kinds trigger a push notification.
1694 </p>
1695 </header>
1696 <div class="notifset-section-body">
1697 <label class="notifset-rule">
1698 <input
1699 type="checkbox"
1700 name="notify_push_on_mention"
1701 value="1"
1702 checked={user.notifyPushOnMention}
1703 aria-label="Someone @mentions me"
1704 />
1705 <span class="notifset-rule-text">
1706 Someone <code>@mentions</code> me
1707 </span>
1708 </label>
1709 <label class="notifset-rule">
1710 <input
1711 type="checkbox"
1712 name="notify_push_on_assign"
1713 value="1"
1714 checked={user.notifyPushOnAssign}
1715 aria-label="I am assigned to an issue or PR"
1716 />
1717 <span class="notifset-rule-text">I am assigned to an issue or PR</span>
1718 </label>
1719 <label class="notifset-rule">
1720 <input
1721 type="checkbox"
1722 name="notify_push_on_review_request"
1723 value="1"
1724 checked={user.notifyPushOnReviewRequest}
1725 aria-label="Someone requests a review from me"
1726 />
1727 <span class="notifset-rule-text">Someone requests a review from me</span>
1728 </label>
1729 <label class="notifset-rule">
1730 <input
1731 type="checkbox"
1732 name="notify_push_on_deploy_failed"
1733 value="1"
1734 checked={user.notifyPushOnDeployFailed}
1735 aria-label="A deploy fails"
1736 />
1737 <span class="notifset-rule-text">
1738 A deploy fails on one of my repositories
1739 </span>
1740 </label>
1741 </div>
1742 </section>
1743
1744 <section class="notifset-section">
1745 <header class="notifset-section-head">
1746 <h3 class="notifset-section-title">
1747 <span class="notifset-section-icon" aria-hidden="true">
1748 <NotifsetInappIcon />
1749 </span>
1750 Sleep Mode
1751 </h3>
1752 <p class="notifset-section-sub">
1753 Toggle Sleep Mode and pick when your morning digest lands.
1754 <a href="/sleep-mode" style="color:var(--accent);margin-left:4px">Learn more</a>.
1755 </p>
1756 </header>
1757 <div class="notifset-section-body">
1758 <label class="notifset-rule">
1759 <input
1760 type="checkbox"
1761 name="sleep_mode_enabled"
1762 value="1"
1763 checked={user.sleepModeEnabled}
1764 aria-label="Enable Sleep Mode"
1765 />
1766 <span class="notifset-rule-text">
1767 Enable Sleep Mode (daily “overnight” digest)
1768 <span class="notifset-rule-hint">
1769 Claude operates autonomously between digests and reports
1770 back on the schedule you pick.
1771 </span>
1772 </span>
1773 </label>
1774 <div class="notifset-hour">
1775 <label for="sleep_mode_digest_hour_utc">
1776 Morning digest hour (UTC 0–23):
1777 </label>
1778 <input
1779 type="number"
1780 id="sleep_mode_digest_hour_utc"
1781 name="sleep_mode_digest_hour_utc"
1782 min={0}
1783 max={23}
1784 step={1}
1785 value={String(user.sleepModeDigestHourUtc)}
1786 aria-label="Sleep Mode digest UTC hour"
1787 />
1788 <a href="/settings/sleep-mode/preview">Preview digest now</a>
1789 </div>
1790 </div>
1791 <div class="notifset-section-foot">
1792 <span class="notifset-foot-hint">
1793 Saved across email, push, and Sleep Mode.
1794 </span>
1795 <button type="submit" class="notifset-btn">
1796 Save preferences
1797 </button>
1798 </div>
1799 </section>
1800 </form>
1801 </div>
1802 <style dangerouslySetInnerHTML={{ __html: notifsetStyles }} />
1803 </Layout>
1804 );
1805});
1806
11681807settings.post("/settings/notifications", async (c) => {
11691808 const user = c.get("user")!;
11701809 const body = await c.req.parseBody();
@@ -1197,6 +1836,17 @@ settings.post("/settings/notifications", async (c) => {
11971836 updatedAt: new Date(),
11981837 })
11991838 .where(eq(users.id, user.id));
1839 // Redirect back to the dedicated notifications page when the user posted
1840 // from there (referrer-based heuristic, harmless if it falls through), so
1841 // the success banner lands on the page they were editing. The main
1842 // /settings page keeps its long-standing redirect for other forms.
1843 const referrer = c.req.header("referer") || "";
1844 if (referrer.includes("/settings/notifications")) {
1845 return c.redirect(
1846 "/settings/notifications?success=" +
1847 encodeURIComponent("Notification preferences updated")
1848 );
1849 }
12001850 return c.redirect("/settings?success=Notification+preferences+updated");
12011851});
12021852
12031853