Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

developer-apps.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

developer-apps.tsxBlame1261 lines · 1 contributor
bfdb5e7Claude1/**
2 * Developer Apps UI (Block B6).
3 *
4 * Lets authenticated users register + manage their OAuth 2.0 apps:
5 * GET /settings/applications list + new button
6 * GET /settings/applications/new form
7 * POST /settings/applications/new create (returns client_secret once)
8 * GET /settings/applications/:id edit / rotate secret / delete
9 * POST /settings/applications/:id update
10 * POST /settings/applications/:id/rotate generate a new client secret
11 * POST /settings/applications/:id/delete remove app + all tokens
12 *
b434a1eClaude13 * Visual polish (2026): gradient hairline + orb hero + app cards with logos
14 * + rotate-secret action card + danger zone. Scoped under `.dev-*` so it
15 * can't bleed into other settings pages. All writes audit() the action.
16 * Read-only responses are HTML (SSR JSX). Every secret-generation, rotation,
17 * and revoke flow is preserved EXACTLY — this is security-critical.
bfdb5e7Claude18 */
19
20import { Hono } from "hono";
21import { and, eq } from "drizzle-orm";
22import { db } from "../db";
23import { oauthApps } from "../db/schema";
24import { Layout } from "../views/layout";
25import { requireAuth } from "../middleware/auth";
26import type { AuthEnv } from "../middleware/auth";
27import {
28 generateClientId,
29 generateClientSecret,
30 sha256Hex,
31 isValidRedirectUri,
32 parseRedirectUris,
33} from "../lib/oauth";
34import { audit } from "../lib/notify";
35
36const apps = new Hono<AuthEnv>();
37
38apps.use("/settings/applications", requireAuth);
39apps.use("/settings/applications/*", requireAuth);
40
b434a1eClaude41// ----------------------------------------------------------------------------
42// Scoped styles — every class prefixed `.dev-` so this surface can't bleed
43// into other settings pages.
44// ----------------------------------------------------------------------------
45const devStyles = `
eed4684Claude46 .dev-wrap { max-width: 1320px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
b434a1eClaude47
48 /* ─── Breadcrumb ─── */
49 .dev-breadcrumb {
50 font-size: 13px;
51 color: var(--text-muted);
52 margin-bottom: var(--space-3);
53 display: flex;
54 gap: 8px;
55 align-items: center;
56 flex-wrap: wrap;
57 }
58 .dev-breadcrumb a { color: var(--accent); text-decoration: none; }
59 .dev-breadcrumb a:hover { text-decoration: underline; }
60 .dev-breadcrumb span.sep { color: var(--text-muted); }
61
62 /* ─── Hero ─── */
63 .dev-hero {
64 position: relative;
65 margin-bottom: var(--space-5);
66 padding: var(--space-5) var(--space-6);
67 background: var(--bg-elevated);
68 border: 1px solid var(--border);
69 border-radius: 16px;
70 overflow: hidden;
71 }
72 .dev-hero::before {
73 content: '';
74 position: absolute;
75 top: 0; left: 0; right: 0;
76 height: 2px;
6fd5915Claude77 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
b434a1eClaude78 opacity: 0.7;
79 pointer-events: none;
80 }
81 .dev-hero-orb {
82 position: absolute;
83 inset: -20% -10% auto auto;
84 width: 380px; height: 380px;
6fd5915Claude85 background: radial-gradient(circle, rgba(91,110,232,0.20), rgba(95,143,160,0.10) 45%, transparent 70%);
b434a1eClaude86 filter: blur(80px);
87 opacity: 0.7;
88 pointer-events: none;
89 z-index: 0;
90 }
91 .dev-hero-inner {
92 position: relative;
93 z-index: 1;
94 display: flex;
95 align-items: flex-end;
96 justify-content: space-between;
97 gap: var(--space-4);
98 flex-wrap: wrap;
99 }
100 .dev-hero-text { flex: 1; min-width: 280px; max-width: 660px; }
101 .dev-eyebrow {
102 font-size: 11px;
103 text-transform: uppercase;
104 letter-spacing: 0.18em;
105 color: var(--text-muted);
106 margin-bottom: var(--space-2);
107 display: inline-flex;
108 align-items: center;
109 gap: 8px;
110 font-family: var(--font-mono);
111 font-weight: 600;
112 }
113 .dev-eyebrow-dot {
114 width: 8px; height: 8px;
115 border-radius: 9999px;
6fd5915Claude116 background: linear-gradient(135deg, #5b6ee8, #5f8fa0);
117 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
b434a1eClaude118 }
119 .dev-title {
120 font-size: clamp(26px, 3.5vw, 38px);
121 font-family: var(--font-display);
122 font-weight: 800;
123 letter-spacing: -0.028em;
124 line-height: 1.06;
125 margin: 0 0 var(--space-2);
126 color: var(--text-strong);
127 }
128 .dev-title-grad {
6fd5915Claude129 background-image: linear-gradient(135deg, #5b6ee8 0%, #5b6ee8 50%, #5f8fa0 100%);
b434a1eClaude130 -webkit-background-clip: text;
131 background-clip: text;
132 -webkit-text-fill-color: transparent;
133 color: transparent;
134 }
135 .dev-sub {
136 font-size: 14.5px;
137 color: var(--text-muted);
138 margin: 0;
139 line-height: 1.55;
140 }
141 .dev-sub code {
142 font-family: var(--font-mono);
143 font-size: 12.5px;
144 background: var(--bg-tertiary);
145 padding: 1px 5px;
146 border-radius: 4px;
147 color: var(--text);
148 }
149
150 /* ─── Banner ─── */
151 .dev-banner {
152 margin-bottom: var(--space-4);
153 padding: 10px 14px;
154 border-radius: 10px;
155 font-size: 13.5px;
156 border: 1px solid var(--border);
157 background: rgba(255,255,255,0.025);
158 color: var(--text);
159 }
160 .dev-banner.is-ok {
161 border-color: rgba(52,211,153,0.40);
162 background: rgba(52,211,153,0.08);
163 color: #bbf7d0;
164 }
165 .dev-banner.is-error {
166 border-color: rgba(248,113,113,0.40);
167 background: rgba(248,113,113,0.08);
168 color: #fecaca;
169 }
170
171 /* ─── App cards (list) ─── */
172 .dev-cards {
173 display: grid;
174 grid-template-columns: 1fr;
175 gap: var(--space-3);
176 list-style: none;
177 padding: 0;
178 margin: 0;
179 }
180 .dev-card {
181 display: flex;
182 gap: var(--space-3);
183 align-items: flex-start;
184 padding: var(--space-4);
185 background: var(--bg-elevated);
186 border: 1px solid var(--border);
187 border-radius: 14px;
188 transition: transform 120ms ease, border-color 120ms ease, box-shadow 120ms ease;
189 }
190 .dev-card:hover {
6fd5915Claude191 border-color: rgba(91,110,232,0.32);
b434a1eClaude192 box-shadow: 0 8px 24px -10px rgba(0,0,0,0.32);
193 }
194 .dev-card.is-revoked {
195 opacity: 0.7;
196 border-style: dashed;
197 }
198 .dev-logo {
199 flex-shrink: 0;
200 width: 52px;
201 height: 52px;
202 border-radius: 12px;
6fd5915Claude203 background: linear-gradient(135deg, rgba(91,110,232,0.22), rgba(95,143,160,0.16));
204 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.32);
b434a1eClaude205 display: flex;
206 align-items: center;
207 justify-content: center;
208 font-family: var(--font-display);
209 font-size: 22px;
210 font-weight: 800;
211 color: #e9d5ff;
212 letter-spacing: -0.02em;
213 text-transform: uppercase;
214 }
215 .dev-card-body { flex: 1; min-width: 0; }
216 .dev-card-row {
217 display: flex;
218 align-items: baseline;
219 justify-content: space-between;
220 gap: var(--space-3);
221 flex-wrap: wrap;
222 }
223 .dev-card-name {
224 margin: 0;
225 font-family: var(--font-display);
226 font-size: 16px;
227 font-weight: 700;
228 letter-spacing: -0.012em;
229 color: var(--text-strong);
230 }
231 .dev-card-name a {
232 color: inherit;
233 text-decoration: none;
234 }
235 .dev-card-name a:hover { color: var(--accent); }
236 .dev-card-meta {
237 margin: 6px 0 0;
238 font-size: 12px;
239 color: var(--text-muted);
240 line-height: 1.55;
241 display: flex;
242 align-items: center;
243 flex-wrap: wrap;
244 gap: 6px;
245 }
246 .dev-card-meta code {
247 font-family: var(--font-mono);
248 font-size: 11.5px;
249 background: rgba(255,255,255,0.04);
250 border: 1px solid var(--border);
251 padding: 2px 7px;
252 border-radius: 5px;
253 color: var(--text);
254 }
255 .dev-pill {
256 display: inline-flex;
257 align-items: center;
258 gap: 6px;
259 padding: 2px 9px;
260 border-radius: 9999px;
261 font-size: 10.5px;
262 font-weight: 700;
263 letter-spacing: 0.05em;
264 text-transform: uppercase;
265 }
266 .dev-pill.is-active {
267 background: rgba(52,211,153,0.14);
268 color: #6ee7b7;
269 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.32);
270 }
271 .dev-pill.is-revoked {
272 background: rgba(248,113,113,0.12);
273 color: #fca5a5;
274 box-shadow: inset 0 0 0 1px rgba(248,113,113,0.32);
275 }
276 .dev-pill.is-public {
6fd5915Claude277 background: rgba(95,143,160,0.12);
b434a1eClaude278 color: #67e8f9;
6fd5915Claude279 box-shadow: inset 0 0 0 1px rgba(95,143,160,0.32);
b434a1eClaude280 }
281 .dev-pill .dot { width: 5px; height: 5px; border-radius: 9999px; background: currentColor; }
282
283 .dev-empty {
284 padding: 30px 22px;
285 text-align: center;
286 color: var(--text-muted);
287 font-size: 13.5px;
288 background: var(--bg-elevated);
289 border: 1px dashed var(--border);
290 border-radius: 14px;
291 line-height: 1.6;
292 }
293 .dev-empty strong {
294 display: block;
295 font-family: var(--font-display);
296 font-size: 16px;
297 color: var(--text-strong);
298 margin-bottom: 6px;
299 letter-spacing: -0.01em;
300 }
301
302 /* ─── Section card ─── */
303 .dev-section {
304 margin-bottom: var(--space-5);
305 background: var(--bg-elevated);
306 border: 1px solid var(--border);
307 border-radius: 14px;
308 overflow: hidden;
309 }
310 .dev-section-head {
311 padding: var(--space-4) var(--space-5);
312 border-bottom: 1px solid var(--border);
313 }
314 .dev-section-title {
315 margin: 0;
316 font-family: var(--font-display);
317 font-size: 16px;
318 font-weight: 700;
319 letter-spacing: -0.012em;
320 color: var(--text-strong);
321 }
322 .dev-section-sub {
323 margin: 4px 0 0;
324 font-size: 12.5px;
325 color: var(--text-muted);
326 line-height: 1.45;
327 }
328 .dev-section-body { padding: var(--space-4) var(--space-5); }
329 .dev-section-foot {
330 padding: var(--space-3) var(--space-5);
331 border-top: 1px solid var(--border);
332 background: rgba(255,255,255,0.012);
333 display: flex;
334 justify-content: space-between;
335 align-items: center;
336 gap: var(--space-2);
337 flex-wrap: wrap;
338 }
339 .dev-section-foot-hint {
340 font-size: 12px;
341 color: var(--text-muted);
342 }
343
344 .dev-section.is-danger { border-color: rgba(248,113,113,0.30); }
345 .dev-section.is-danger .dev-section-head { border-bottom-color: rgba(248,113,113,0.30); }
346 .dev-section.is-danger .dev-section-title { color: #fca5a5; }
347
348 /* ─── Field ─── */
349 .dev-field { margin-bottom: var(--space-4); }
350 .dev-field:last-child { margin-bottom: 0; }
351 .dev-field label {
352 display: block;
353 margin-bottom: 6px;
354 font-family: var(--font-mono);
355 font-size: 12.5px;
356 font-weight: 600;
357 color: var(--text-strong);
358 letter-spacing: -0.005em;
359 }
360 .dev-input,
361 .dev-textarea {
362 width: 100%;
363 padding: 9px 12px;
364 font-size: 13.5px;
365 color: var(--text);
366 background: var(--bg);
367 border: 1px solid var(--border-strong);
368 border-radius: 8px;
369 outline: none;
370 font-family: var(--font-mono);
371 transition: border-color 120ms ease, box-shadow 120ms ease;
372 box-sizing: border-box;
373 }
374 .dev-textarea { font-family: var(--font-mono); resize: vertical; }
375 .dev-input:focus,
376 .dev-textarea:focus {
377 border-color: var(--border-focus);
6fd5915Claude378 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
b434a1eClaude379 }
380 .dev-hint {
381 font-size: 11.5px;
382 color: var(--text-muted);
383 margin-top: 6px;
384 line-height: 1.45;
385 }
386 .dev-toggle-row {
387 display: flex;
388 align-items: flex-start;
389 gap: 10px;
390 padding: 10px 12px;
391 background: rgba(255,255,255,0.025);
392 border: 1px solid var(--border);
393 border-radius: 10px;
394 }
395 .dev-toggle-row input[type="checkbox"] { margin-top: 2px; flex-shrink: 0; }
396 .dev-toggle-row span {
397 font-size: 13px;
398 color: var(--text);
399 line-height: 1.45;
400 }
401
402 /* ─── Detail dl ─── */
403 .dev-dl {
404 display: grid;
405 grid-template-columns: 200px 1fr;
406 gap: 10px 16px;
407 margin: 0;
408 }
409 @media (max-width: 600px) {
410 .dev-dl { grid-template-columns: 1fr; gap: 2px 0; }
411 .dev-dl dt { margin-top: 8px; }
412 }
413 .dev-dl dt {
414 font-size: 11px;
415 text-transform: uppercase;
416 letter-spacing: 0.10em;
417 color: var(--text-muted);
418 font-weight: 700;
419 font-family: var(--font-mono);
420 align-self: center;
421 }
422 .dev-dl dd {
423 margin: 0;
424 font-size: 13.5px;
425 color: var(--text);
426 }
427 .dev-dl dd code {
428 font-family: var(--font-mono);
429 font-size: 12.5px;
430 background: rgba(255,255,255,0.04);
431 border: 1px solid var(--border);
432 padding: 3px 8px;
433 border-radius: 6px;
434 color: var(--text);
435 user-select: all;
436 }
437
438 /* ─── Fresh-secret callout ─── */
439 .dev-secret-callout {
440 margin-bottom: var(--space-4);
441 padding: 14px 16px;
442 border-radius: 14px;
443 background: linear-gradient(135deg, rgba(251,191,36,0.10), rgba(248,113,113,0.05));
444 border: 1px solid rgba(251,191,36,0.40);
445 }
446 .dev-secret-callout strong {
447 display: block;
448 color: #fde68a;
449 font-family: var(--font-display);
450 font-size: 14px;
451 letter-spacing: -0.01em;
452 margin-bottom: 8px;
453 }
454 .dev-secret-callout pre {
455 margin: 0;
456 padding: 10px 12px;
457 background: rgba(0,0,0,0.30);
458 border: 1px solid rgba(251,191,36,0.32);
459 border-radius: 10px;
460 color: #e9d5ff;
461 font-family: var(--font-mono);
462 font-size: 13px;
463 word-break: break-all;
464 white-space: pre-wrap;
465 user-select: all;
466 overflow-x: auto;
467 }
468 .dev-secret-callout-hint {
469 margin: 8px 0 0;
470 font-size: 12px;
471 color: #fde68a;
472 opacity: 0.85;
473 line-height: 1.5;
474 }
475
476 /* ─── Buttons ─── */
477 .dev-btn {
478 display: inline-flex;
479 align-items: center;
480 justify-content: center;
481 gap: 8px;
482 padding: 11px 18px;
483 border-radius: 10px;
484 font-size: 13.5px;
485 font-weight: 600;
486 text-decoration: none;
487 border: 1px solid transparent;
488 cursor: pointer;
489 font: inherit;
490 line-height: 1;
491 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease, color 120ms ease;
492 }
493 .dev-btn-primary {
6fd5915Claude494 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
b434a1eClaude495 color: #ffffff;
6fd5915Claude496 box-shadow: 0 6px 18px -6px rgba(91,110,232,0.50), inset 0 1px 0 rgba(255,255,255,0.16);
b434a1eClaude497 }
498 .dev-btn-primary:hover {
499 transform: translateY(-1px);
6fd5915Claude500 box-shadow: 0 10px 24px -8px rgba(91,110,232,0.60), inset 0 1px 0 rgba(255,255,255,0.20);
b434a1eClaude501 color: #ffffff;
502 text-decoration: none;
503 }
504 .dev-btn-ghost {
505 background: transparent;
506 color: var(--text);
507 border-color: var(--border-strong);
508 }
509 .dev-btn-ghost:hover {
6fd5915Claude510 background: rgba(91,110,232,0.06);
511 border-color: rgba(91,110,232,0.45);
b434a1eClaude512 color: var(--text-strong);
513 text-decoration: none;
514 }
515 .dev-btn-warn {
516 background: transparent;
517 color: #fde68a;
518 border-color: rgba(251,191,36,0.40);
519 }
520 .dev-btn-warn:hover {
521 background: rgba(251,191,36,0.10);
522 border-color: rgba(251,191,36,0.65);
523 }
524 .dev-btn-danger {
525 background: transparent;
526 color: #fca5a5;
527 border-color: rgba(248,113,113,0.40);
528 }
529 .dev-btn-danger:hover {
530 background: rgba(248,113,113,0.10);
531 border-color: rgba(248,113,113,0.70);
532 color: #fecaca;
533 }
534 .dev-btn-sm {
535 padding: 6px 12px;
536 font-size: 12.5px;
537 border-radius: 8px;
538 }
539
540 /* ─── Top action bar ─── */
541 .dev-actions-bar {
542 display: flex;
543 align-items: center;
544 gap: var(--space-3);
545 margin-bottom: var(--space-4);
546 flex-wrap: wrap;
547 }
548 .dev-actions-bar form { margin: 0; }
549`;
550
551function appLogoInitial(name: string): string {
552 return (name.trim().charAt(0) || "?").toUpperCase();
553}
554
bfdb5e7Claude555function normaliseRedirectUris(raw: string): {
556 ok: boolean;
557 value?: string;
558 error?: string;
559} {
560 const lines = raw
561 .split(/\r?\n/)
562 .map((s) => s.trim())
563 .filter(Boolean);
564 if (lines.length === 0) {
565 return { ok: false, error: "At least one redirect URI is required" };
566 }
567 if (lines.length > 10) {
568 return { ok: false, error: "At most 10 redirect URIs allowed" };
569 }
570 for (const u of lines) {
571 if (!isValidRedirectUri(u)) {
572 return { ok: false, error: `Invalid redirect URI: ${u}` };
573 }
574 }
575 return { ok: true, value: lines.join("\n") };
576}
577
578apps.get("/settings/applications", async (c) => {
579 const user = c.get("user")!;
580 const error = c.req.query("error");
581 const success = c.req.query("success");
582
583 let rows: (typeof oauthApps.$inferSelect)[] = [];
584 try {
585 rows = await db
586 .select()
587 .from(oauthApps)
588 .where(eq(oauthApps.ownerId, user.id));
589 } catch (err) {
590 console.error("[oauth-apps] list:", err);
591 }
592
b434a1eClaude593 const activeCount = rows.filter((r) => !r.revokedAt).length;
594
bfdb5e7Claude595 return c.html(
596 <Layout title="OAuth applications" user={user}>
b434a1eClaude597 <div class="dev-wrap">
598 <div class="dev-breadcrumb">
bfdb5e7Claude599 <a href="/settings">settings</a>
b434a1eClaude600 <span class="sep">/</span>
601 <span>developer applications</span>
bfdb5e7Claude602 </div>
b434a1eClaude603
604 <section class="dev-hero">
605 <div class="dev-hero-orb" aria-hidden="true" />
606 <div class="dev-hero-inner">
607 <div class="dev-hero-text">
608 <div class="dev-eyebrow">
609 <span class="dev-eyebrow-dot" aria-hidden="true" />
610 Developer · {user.username}
611 </div>
612 <h1 class="dev-title">
613 <span class="dev-title-grad">Your OAuth apps.</span>
614 </h1>
615 <p class="dev-sub">
616 Register third-party apps that can request access to Gluecron
617 on behalf of users via the OAuth 2.0 authorization-code flow.
618 You'll get a <code>client_id</code> + <code>client_secret</code> per
619 app and can scope access via PKCE for SPAs and mobile apps.
620 </p>
bfdb5e7Claude621 </div>
b434a1eClaude622 <a href="/settings/applications/new" class="dev-btn dev-btn-primary">
623 + New OAuth app
624 </a>
625 </div>
626 </section>
627
628 {error && <div class="dev-banner is-error">{decodeURIComponent(error)}</div>}
629 {success && <div class="dev-banner is-ok">{decodeURIComponent(success)}</div>}
630
631 {rows.length === 0 ? (
632 <div class="dev-empty">
633 <strong>No OAuth apps registered yet.</strong>
634 Click "+ New OAuth app" to register one. You'll get a client ID +
635 secret you can use to drive the <code style="font-family:var(--font-mono);font-size:12px;background:var(--bg-tertiary);padding:1px 5px;border-radius:4px">/oauth/authorize</code> + <code style="font-family:var(--font-mono);font-size:12px;background:var(--bg-tertiary);padding:1px 5px;border-radius:4px">/oauth/token</code> flow.
636 </div>
637 ) : (
638 <>
639 <div class="dev-actions-bar">
640 <span style="font-size:12px;color:var(--text-muted);font-family:var(--font-mono)">
641 {activeCount} active · {rows.length - activeCount} revoked
642 </span>
643 </div>
644 <ul class="dev-cards">
645 {rows.map((app) => (
646 <li class={"dev-card" + (app.revokedAt ? " is-revoked" : "")}>
647 <div class="dev-logo" aria-hidden="true">
648 {appLogoInitial(app.name)}
649 </div>
650 <div class="dev-card-body">
651 <div class="dev-card-row">
652 <h3 class="dev-card-name">
653 <a href={`/settings/applications/${app.id}`}>{app.name}</a>
654 </h3>
655 <div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap">
656 {app.revokedAt ? (
657 <span class="dev-pill is-revoked">
658 <span class="dot" aria-hidden="true" />
659 revoked
660 </span>
661 ) : (
662 <span class="dev-pill is-active">
663 <span class="dot" aria-hidden="true" />
664 active
665 </span>
666 )}
667 {!app.confidential && (
668 <span class="dev-pill is-public">
669 <span class="dot" aria-hidden="true" />
670 PKCE
671 </span>
672 )}
673 </div>
674 </div>
675 <div class="dev-card-meta">
bfdb5e7Claude676 <code>{app.clientId}</code>
b434a1eClaude677 <span>·</span>
678 <span>added {new Date(app.createdAt).toLocaleDateString()}</span>
679 </div>
680 {app.description && (
681 <p style="margin:8px 0 0;font-size:13px;color:var(--text-muted);line-height:1.5">
682 {app.description}
683 </p>
684 )}
685 <div style="margin-top:10px;display:flex;gap:8px;flex-wrap:wrap">
686 <a
687 href={`/settings/applications/${app.id}`}
688 class="dev-btn dev-btn-ghost dev-btn-sm"
689 >
690 Manage
691 </a>
bfdb5e7Claude692 </div>
693 </div>
b434a1eClaude694 </li>
695 ))}
696 </ul>
697 </>
698 )}
bfdb5e7Claude699 </div>
b434a1eClaude700 <style dangerouslySetInnerHTML={{ __html: devStyles }} />
bfdb5e7Claude701 </Layout>
702 );
703});
704
705apps.get("/settings/applications/new", async (c) => {
706 const user = c.get("user")!;
707 const error = c.req.query("error");
708 return c.html(
709 <Layout title="New OAuth app" user={user}>
b434a1eClaude710 <div class="dev-wrap">
711 <div class="dev-breadcrumb">
712 <a href="/settings">settings</a>
713 <span class="sep">/</span>
bfdb5e7Claude714 <a href="/settings/applications">applications</a>
b434a1eClaude715 <span class="sep">/</span>
bfdb5e7Claude716 <span>new</span>
717 </div>
b434a1eClaude718
719 <section class="dev-hero">
720 <div class="dev-hero-orb" aria-hidden="true" />
721 <div class="dev-hero-inner">
722 <div class="dev-hero-text">
723 <div class="dev-eyebrow">
724 <span class="dev-eyebrow-dot" aria-hidden="true" />
725 OAuth · Register a new app
726 </div>
727 <h1 class="dev-title">
728 <span class="dev-title-grad">Register an OAuth app.</span>
729 </h1>
730 <p class="dev-sub">
731 We'll generate a <code>client_id</code> + <code>client_secret</code>
732 on submit. The secret is shown <strong>once</strong> — copy it
733 immediately into your app's environment.
734 </p>
735 </div>
bfdb5e7Claude736 </div>
b434a1eClaude737 </section>
738
739 {error && <div class="dev-banner is-error">{decodeURIComponent(error)}</div>}
740
741 <form method="post" action="/settings/applications/new">
742 <section class="dev-section">
743 <header class="dev-section-head">
744 <h3 class="dev-section-title">App identity</h3>
745 <p class="dev-section-sub">
746 Shown to users on the consent screen. Pick a name and (optionally)
747 a description that explains what your app does.
748 </p>
749 </header>
750 <div class="dev-section-body">
751 <div class="dev-field">
752 <label for="name">Application name</label>
753 <input
754 type="text"
755 id="name"
756 name="name"
757 required
758 maxLength={80}
759 placeholder="My Awesome Integration"
760 class="dev-input"
761 />
762 </div>
763 <div class="dev-field">
764 <label for="homepage_url">Homepage URL</label>
765 <input
766 type="url"
767 id="homepage_url"
768 name="homepage_url"
769 placeholder="https://example.com"
770 class="dev-input"
771 />
772 </div>
773 <div class="dev-field">
774 <label for="description">Description</label>
775 <textarea
776 id="description"
777 name="description"
778 rows={3}
779 maxLength={500}
780 class="dev-textarea"
781 />
782 <div class="dev-hint">Up to 500 characters.</div>
783 </div>
784 </div>
785 </section>
786
787 <section class="dev-section">
788 <header class="dev-section-head">
789 <h3 class="dev-section-title">OAuth flow</h3>
790 <p class="dev-section-sub">
791 Where we redirect users after they approve — and whether your
792 app is server-side (confidential, holds a secret) or
793 browser/mobile (public, must use PKCE).
794 </p>
795 </header>
796 <div class="dev-section-body">
797 <div class="dev-field">
798 <label for="redirect_uris">Authorization callback URLs</label>
799 <textarea
800 id="redirect_uris"
801 name="redirect_uris"
802 rows={4}
803 required
804 placeholder="https://example.com/oauth/callback"
805 class="dev-textarea"
806 />
807 <div class="dev-hint">
808 One URL per line. HTTPS required (HTTP allowed for localhost).
809 Exact match; no wildcards. Up to 10.
810 </div>
811 </div>
812
813 <div class="dev-field">
814 <div class="dev-toggle-row">
815 <input
816 type="checkbox"
817 name="confidential"
818 value="on"
819 checked
820 aria-label="Confidential client"
821 id="confidential"
822 />
823 <span>
824 <strong style="color:var(--text-strong)">Confidential client (server-side app).</strong>
825 {" "}Uncheck for public SPA / mobile apps — they must use PKCE
826 instead of a client secret.
827 </span>
828 </div>
829 </div>
830 </div>
831 <div class="dev-section-foot">
832 <span class="dev-section-foot-hint">
833 You'll be shown the client secret <strong>once</strong> after creation.
834 </span>
835 <div style="display:flex;gap:8px">
836 <a href="/settings/applications" class="dev-btn dev-btn-ghost">
837 Cancel
838 </a>
839 <button type="submit" class="dev-btn dev-btn-primary">
840 Register app
841 </button>
842 </div>
843 </div>
844 </section>
bfdb5e7Claude845 </form>
846 </div>
b434a1eClaude847 <style dangerouslySetInnerHTML={{ __html: devStyles }} />
bfdb5e7Claude848 </Layout>
849 );
850});
851
852apps.post("/settings/applications/new", async (c) => {
853 const user = c.get("user")!;
854 const body = await c.req.parseBody();
855 const name = String(body.name || "").trim().slice(0, 80);
856 const homepageUrl = String(body.homepage_url || "").trim().slice(0, 200);
857 const description = String(body.description || "").trim().slice(0, 500);
858 const confidential = String(body.confidential || "") === "on";
859 const redirectRaw = String(body.redirect_uris || "");
860
861 if (!name) {
862 return c.redirect("/settings/applications/new?error=Name+is+required");
863 }
864 const parsed = normaliseRedirectUris(redirectRaw);
865 if (!parsed.ok) {
866 return c.redirect(
867 `/settings/applications/new?error=${encodeURIComponent(parsed.error || "Invalid redirect URIs")}`
868 );
869 }
870
871 const clientId = generateClientId();
872 const clientSecret = generateClientSecret();
873 const clientSecretHash = await sha256Hex(clientSecret);
874
875 try {
876 const [row] = await db
877 .insert(oauthApps)
878 .values({
879 ownerId: user.id,
880 name,
881 clientId,
882 clientSecretHash,
883 clientSecretPrefix: clientSecret.slice(0, 8),
884 redirectUris: parsed.value!,
885 homepageUrl: homepageUrl || null,
886 description: description || null,
887 confidential,
888 })
889 .returning();
890 await audit({
891 userId: user.id,
892 action: "oauth_app.create",
893 targetType: "oauth_app",
894 targetId: row.id,
895 metadata: { clientId },
896 });
897 // Redirect to the manage page with the plaintext secret appended once.
898 return c.redirect(
899 `/settings/applications/${row.id}?secret=${encodeURIComponent(clientSecret)}&success=App+created`
900 );
901 } catch (err) {
902 console.error("[oauth-apps] create:", err);
903 return c.redirect(
904 "/settings/applications/new?error=Service+unavailable"
905 );
906 }
907});
908
909apps.get("/settings/applications/:id", async (c) => {
910 const user = c.get("user")!;
911 const id = c.req.param("id");
912 const error = c.req.query("error");
913 const success = c.req.query("success");
914 const secret = c.req.query("secret");
915
916 let app: typeof oauthApps.$inferSelect | undefined;
917 try {
918 const [row] = await db
919 .select()
920 .from(oauthApps)
921 .where(and(eq(oauthApps.id, id), eq(oauthApps.ownerId, user.id)))
922 .limit(1);
923 app = row;
924 } catch (err) {
925 console.error("[oauth-apps] get:", err);
926 }
927 if (!app) {
928 return c.redirect("/settings/applications?error=Not+found");
929 }
930
931 return c.html(
932 <Layout title={app.name} user={user}>
b434a1eClaude933 <div class="dev-wrap">
934 <div class="dev-breadcrumb">
935 <a href="/settings">settings</a>
936 <span class="sep">/</span>
bfdb5e7Claude937 <a href="/settings/applications">applications</a>
b434a1eClaude938 <span class="sep">/</span>
bfdb5e7Claude939 <span>{app.name}</span>
940 </div>
b434a1eClaude941
942 <section class="dev-hero">
943 <div class="dev-hero-orb" aria-hidden="true" />
944 <div class="dev-hero-inner">
945 <div class="dev-hero-text" style="display:flex;gap:var(--space-3);align-items:center">
946 <div class="dev-logo" aria-hidden="true">
947 {appLogoInitial(app.name)}
948 </div>
949 <div>
950 <div class="dev-eyebrow">
951 <span class="dev-eyebrow-dot" aria-hidden="true" />
952 OAuth app · {app.confidential ? "Confidential" : "Public (PKCE)"}
953 </div>
954 <h1 class="dev-title" style="margin:0">
955 <span class="dev-title-grad">{app.name}</span>
956 </h1>
957 </div>
958 </div>
959 </div>
960 </section>
961
962 {error && <div class="dev-banner is-error">{decodeURIComponent(error)}</div>}
963 {success && <div class="dev-banner is-ok">{decodeURIComponent(success)}</div>}
bfdb5e7Claude964
965 {secret && (
b434a1eClaude966 <div class="dev-secret-callout">
967 <strong>Save this client secret — it will not be shown again.</strong>
968 <pre>{secret}</pre>
969 <p class="dev-secret-callout-hint">
970 Store it in your app's environment (e.g.{" "}
971 <code style="font-family:var(--font-mono);font-size:11.5px;background:rgba(0,0,0,0.30);padding:1px 5px;border-radius:4px">OAUTH_CLIENT_SECRET</code>).
972 Rotating below invalidates it immediately.
973 </p>
bfdb5e7Claude974 </div>
975 )}
976
b434a1eClaude977 {/* ─── Credentials ─── */}
978 <section class="dev-section">
979 <header class="dev-section-head">
980 <h3 class="dev-section-title">Credentials</h3>
981 <p class="dev-section-sub">
982 The client ID is safe to embed in client code. The secret prefix
983 is shown so you can verify which secret is current after a rotate.
984 </p>
985 </header>
986 <div class="dev-section-body">
987 <dl class="dev-dl">
988 <dt>Client ID</dt>
989 <dd><code>{app.clientId}</code></dd>
990 <dt>Client secret</dt>
991 <dd>
992 <code>{app.clientSecretPrefix}…</code>{" "}
993 <span style="font-size:12px;color:var(--text-muted);margin-left:6px">prefix only — full value shown once at creation/rotate</span>
994 </dd>
995 <dt>Type</dt>
996 <dd>
997 {app.confidential ? (
998 <span class="dev-pill is-active">
999 <span class="dot" aria-hidden="true" />
1000 Confidential
1001 </span>
1002 ) : (
1003 <span class="dev-pill is-public">
1004 <span class="dot" aria-hidden="true" />
1005 Public · PKCE required
1006 </span>
1007 )}
1008 </dd>
1009 <dt>Created</dt>
1010 <dd>{new Date(app.createdAt).toLocaleString()}</dd>
1011 </dl>
1012 </div>
1013 </section>
bfdb5e7Claude1014
b434a1eClaude1015 {/* ─── Edit ─── */}
9e2c6dfClaude1016 <form method="post" action={`/settings/applications/${app.id}`}>
b434a1eClaude1017 <section class="dev-section">
1018 <header class="dev-section-head">
1019 <h3 class="dev-section-title">App details</h3>
1020 <p class="dev-section-sub">
1021 Name + description are shown to users on the consent screen.
1022 Callback URLs must match exactly at <code style="font-family:var(--font-mono);font-size:12px;background:var(--bg-tertiary);padding:1px 5px;border-radius:4px">/oauth/authorize</code>.
1023 </p>
1024 </header>
1025 <div class="dev-section-body">
1026 <div class="dev-field">
1027 <label for="name">Application name</label>
1028 <input
1029 type="text"
1030 id="name"
1031 name="name"
1032 required
1033 maxLength={80}
1034 value={app.name}
1035 class="dev-input"
1036 />
1037 </div>
1038 <div class="dev-field">
1039 <label for="homepage_url">Homepage URL</label>
1040 <input
1041 type="url"
1042 id="homepage_url"
1043 name="homepage_url"
1044 value={app.homepageUrl || ""}
1045 class="dev-input"
1046 />
1047 </div>
1048 <div class="dev-field">
1049 <label for="description">Description</label>
1050 <textarea
1051 id="description"
1052 name="description"
1053 rows={3}
1054 maxLength={500}
1055 class="dev-textarea"
1056 >
1057 {app.description || ""}
1058 </textarea>
1059 </div>
1060 <div class="dev-field">
1061 <label for="redirect_uris">Authorization callback URLs</label>
1062 <textarea
1063 id="redirect_uris"
1064 name="redirect_uris"
1065 rows={4}
1066 required
1067 class="dev-textarea"
1068 >
1069 {app.redirectUris}
1070 </textarea>
1071 <div class="dev-hint">
1072 One URL per line. HTTPS required (HTTP allowed for localhost).
1073 </div>
1074 </div>
1075 </div>
1076 <div class="dev-section-foot">
1077 <span class="dev-section-foot-hint">
1078 Changes apply immediately — existing tokens keep working.
1079 </span>
1080 <button type="submit" class="dev-btn dev-btn-primary">
1081 Save changes
1082 </button>
1083 </div>
1084 </section>
1085 </form>
1086
1087 {/* ─── Rotate ─── */}
1088 <section class="dev-section">
1089 <header class="dev-section-head">
1090 <h3 class="dev-section-title">Rotate client secret</h3>
1091 <p class="dev-section-sub">
1092 Generate a new secret. The old one is invalidated immediately —
1093 existing access tokens keep working, but token exchange with the
1094 old secret will fail.
1095 </p>
1096 </header>
1097 <div class="dev-section-body">
1098 <form
1099 method="post"
1100 action={`/settings/applications/${app.id}/rotate`}
1101 onsubmit="return confirm('Rotate the client secret? The old one will stop working immediately.')"
1102 style="margin:0"
bfdb5e7Claude1103 >
b434a1eClaude1104 <button type="submit" class="dev-btn dev-btn-warn">
1105 Rotate secret
1106 </button>
1107 </form>
bfdb5e7Claude1108 </div>
b434a1eClaude1109 </section>
1110
1111 {/* ─── Danger zone ─── */}
1112 <section class="dev-section is-danger">
1113 <header class="dev-section-head">
1114 <h3 class="dev-section-title">Danger zone</h3>
1115 <p class="dev-section-sub">
1116 Deleting an app removes it from the database and revokes every
1117 access + refresh token ever issued under its <code style="font-family:var(--font-mono);font-size:12px;background:var(--bg-tertiary);padding:1px 5px;border-radius:4px">client_id</code>.
1118 This cannot be undone.
1119 </p>
1120 </header>
1121 <div class="dev-section-body">
1122 <form
1123 method="post"
1124 action={`/settings/applications/${app.id}/delete`}
1125 onsubmit="return confirm('Delete this OAuth app? All issued access tokens will be revoked.')"
1126 style="margin:0"
bfdb5e7Claude1127 >
b434a1eClaude1128 <button type="submit" class="dev-btn dev-btn-danger">
1129 Delete app permanently
1130 </button>
1131 </form>
bfdb5e7Claude1132 </div>
b434a1eClaude1133 </section>
bfdb5e7Claude1134 </div>
b434a1eClaude1135 <style dangerouslySetInnerHTML={{ __html: devStyles }} />
bfdb5e7Claude1136 </Layout>
1137 );
1138});
1139
1140apps.post("/settings/applications/:id", async (c) => {
1141 const user = c.get("user")!;
1142 const id = c.req.param("id");
1143 const body = await c.req.parseBody();
1144 const name = String(body.name || "").trim().slice(0, 80);
1145 const homepageUrl = String(body.homepage_url || "").trim().slice(0, 200);
1146 const description = String(body.description || "").trim().slice(0, 500);
1147 const redirectRaw = String(body.redirect_uris || "");
1148
1149 if (!name) {
1150 return c.redirect(
1151 `/settings/applications/${id}?error=Name+is+required`
1152 );
1153 }
1154 const parsed = normaliseRedirectUris(redirectRaw);
1155 if (!parsed.ok) {
1156 return c.redirect(
1157 `/settings/applications/${id}?error=${encodeURIComponent(parsed.error || "Invalid redirect URIs")}`
1158 );
1159 }
1160 try {
1161 const [existing] = await db
1162 .select({ id: oauthApps.id, ownerId: oauthApps.ownerId })
1163 .from(oauthApps)
1164 .where(eq(oauthApps.id, id))
1165 .limit(1);
1166 if (!existing || existing.ownerId !== user.id) {
1167 return c.redirect("/settings/applications?error=Not+found");
1168 }
1169 await db
1170 .update(oauthApps)
1171 .set({
1172 name,
1173 homepageUrl: homepageUrl || null,
1174 description: description || null,
1175 redirectUris: parsed.value!,
1176 updatedAt: new Date(),
1177 })
1178 .where(eq(oauthApps.id, id));
1179 await audit({
1180 userId: user.id,
1181 action: "oauth_app.update",
1182 targetType: "oauth_app",
1183 targetId: id,
1184 });
1185 return c.redirect(`/settings/applications/${id}?success=Saved`);
1186 } catch (err) {
1187 console.error("[oauth-apps] update:", err);
1188 return c.redirect(
1189 `/settings/applications/${id}?error=Service+unavailable`
1190 );
1191 }
1192});
1193
1194apps.post("/settings/applications/:id/rotate", async (c) => {
1195 const user = c.get("user")!;
1196 const id = c.req.param("id");
1197 try {
1198 const [existing] = await db
1199 .select({ id: oauthApps.id, ownerId: oauthApps.ownerId })
1200 .from(oauthApps)
1201 .where(eq(oauthApps.id, id))
1202 .limit(1);
1203 if (!existing || existing.ownerId !== user.id) {
1204 return c.redirect("/settings/applications?error=Not+found");
1205 }
1206 const newSecret = generateClientSecret();
1207 const newHash = await sha256Hex(newSecret);
1208 await db
1209 .update(oauthApps)
1210 .set({
1211 clientSecretHash: newHash,
1212 clientSecretPrefix: newSecret.slice(0, 8),
1213 updatedAt: new Date(),
1214 })
1215 .where(eq(oauthApps.id, id));
1216 await audit({
1217 userId: user.id,
1218 action: "oauth_app.rotate_secret",
1219 targetType: "oauth_app",
1220 targetId: id,
1221 });
1222 return c.redirect(
1223 `/settings/applications/${id}?secret=${encodeURIComponent(newSecret)}&success=Secret+rotated`
1224 );
1225 } catch (err) {
1226 console.error("[oauth-apps] rotate:", err);
1227 return c.redirect(
1228 `/settings/applications/${id}?error=Service+unavailable`
1229 );
1230 }
1231});
1232
1233apps.post("/settings/applications/:id/delete", async (c) => {
1234 const user = c.get("user")!;
1235 const id = c.req.param("id");
1236 try {
1237 const [existing] = await db
1238 .select({ id: oauthApps.id, ownerId: oauthApps.ownerId })
1239 .from(oauthApps)
1240 .where(eq(oauthApps.id, id))
1241 .limit(1);
1242 if (!existing || existing.ownerId !== user.id) {
1243 return c.redirect("/settings/applications?error=Not+found");
1244 }
1245 await db.delete(oauthApps).where(eq(oauthApps.id, id));
1246 await audit({
1247 userId: user.id,
1248 action: "oauth_app.delete",
1249 targetType: "oauth_app",
1250 targetId: id,
1251 });
1252 return c.redirect("/settings/applications?success=App+deleted");
1253 } catch (err) {
1254 console.error("[oauth-apps] delete:", err);
1255 return c.redirect(
1256 `/settings/applications/${id}?error=Service+unavailable`
1257 );
1258 }
1259});
1260
1261export default apps;