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