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.tsxBlame558 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 *
13 * All writes audit()' the action. Read-only responses are HTML (SSR JSX).
14 */
15
16import { Hono } from "hono";
17import { and, eq } from "drizzle-orm";
18import { db } from "../db";
19import { oauthApps } from "../db/schema";
20import { Layout } from "../views/layout";
21import { requireAuth } from "../middleware/auth";
22import type { AuthEnv } from "../middleware/auth";
23import {
24 generateClientId,
25 generateClientSecret,
26 sha256Hex,
27 isValidRedirectUri,
28 parseRedirectUris,
29} from "../lib/oauth";
30import { audit } from "../lib/notify";
31
32const apps = new Hono<AuthEnv>();
33
34apps.use("/settings/applications", requireAuth);
35apps.use("/settings/applications/*", requireAuth);
36
37function normaliseRedirectUris(raw: string): {
38 ok: boolean;
39 value?: string;
40 error?: string;
41} {
42 const lines = raw
43 .split(/\r?\n/)
44 .map((s) => s.trim())
45 .filter(Boolean);
46 if (lines.length === 0) {
47 return { ok: false, error: "At least one redirect URI is required" };
48 }
49 if (lines.length > 10) {
50 return { ok: false, error: "At most 10 redirect URIs allowed" };
51 }
52 for (const u of lines) {
53 if (!isValidRedirectUri(u)) {
54 return { ok: false, error: `Invalid redirect URI: ${u}` };
55 }
56 }
57 return { ok: true, value: lines.join("\n") };
58}
59
60apps.get("/settings/applications", async (c) => {
61 const user = c.get("user")!;
62 const error = c.req.query("error");
63 const success = c.req.query("success");
64
65 let rows: (typeof oauthApps.$inferSelect)[] = [];
66 try {
67 rows = await db
68 .select()
69 .from(oauthApps)
70 .where(eq(oauthApps.ownerId, user.id));
71 } catch (err) {
72 console.error("[oauth-apps] list:", err);
73 }
74
75 return c.html(
76 <Layout title="OAuth applications" user={user}>
77 <div class="settings-container">
78 <div class="breadcrumb">
79 <a href="/settings">settings</a>
80 <span>/</span>
81 <span>applications</span>
82 </div>
83 <h2>OAuth applications</h2>
84 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
85 {success && (
86 <div class="auth-success">{decodeURIComponent(success)}</div>
87 )}
88 <p style="color: var(--text-muted); font-size: 13px">
89 Register third-party apps that can request access to gluecron on
90 behalf of users via the OAuth 2.0 authorization code flow.
91 </p>
92 <div style="margin: 16px 0">
93 <a href="/settings/applications/new" class="btn btn-primary">
94 New OAuth app
95 </a>
96 </div>
97 <div
98 style="border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden"
99 >
100 {rows.length === 0 ? (
101 <div
102 style="padding: 16px; color: var(--text-muted); font-size: 13px; background: var(--bg-secondary)"
103 >
104 No OAuth apps registered yet.
105 </div>
106 ) : (
107 rows.map((app) => (
108 <div
109 style="padding: 12px 16px; border-bottom: 1px solid var(--border); background: var(--bg-secondary)"
110 >
111 <div style="display: flex; justify-content: space-between; align-items: center">
112 <div>
113 <strong>
114 <a href={`/settings/applications/${app.id}`}>{app.name}</a>
115 </strong>
116 {app.revokedAt && (
117 <span style="color: var(--red); font-size: 12px; margin-left: 8px">
118 revoked
119 </span>
120 )}
121 <div
122 style="color: var(--text-muted); font-size: 12px; margin-top: 2px"
123 >
124 <code>{app.clientId}</code>
125 {" · "}added {new Date(app.createdAt).toLocaleDateString()}
126 </div>
127 </div>
128 <a
129 href={`/settings/applications/${app.id}`}
130 class="btn btn-sm"
131 >
132 manage
133 </a>
134 </div>
135 </div>
136 ))
137 )}
138 </div>
139 </div>
140 </Layout>
141 );
142});
143
144apps.get("/settings/applications/new", async (c) => {
145 const user = c.get("user")!;
146 const error = c.req.query("error");
147 return c.html(
148 <Layout title="New OAuth app" user={user}>
149 <div class="settings-container">
150 <div class="breadcrumb">
151 <a href="/settings/applications">applications</a>
152 <span>/</span>
153 <span>new</span>
154 </div>
155 <h2>Register a new OAuth app</h2>
156 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
9e2c6dfClaude157 <form method="post" action="/settings/applications/new">
bfdb5e7Claude158 <div class="form-group">
159 <label for="name">Application name</label>
160 <input
161 type="text"
162 id="name"
163 name="name"
164 required
165 maxLength={80}
166 placeholder="My Awesome Integration"
167 />
168 </div>
169 <div class="form-group">
170 <label for="homepage_url">Homepage URL</label>
171 <input
172 type="url"
173 id="homepage_url"
174 name="homepage_url"
175 placeholder="https://example.com"
176 />
177 </div>
178 <div class="form-group">
179 <label for="description">Description</label>
180 <textarea
181 id="description"
182 name="description"
183 rows={3}
184 maxLength={500}
185 />
186 </div>
187 <div class="form-group">
188 <label for="redirect_uris">Authorization callback URLs</label>
189 <textarea
190 id="redirect_uris"
191 name="redirect_uris"
192 rows={4}
193 required
194 placeholder="https://example.com/oauth/callback"
195 />
196 <small style="color: var(--text-muted)">
197 One URL per line. HTTPS required (HTTP allowed for localhost).
198 Exact match; no wildcards.
199 </small>
200 </div>
201 <div class="form-group">
202 <label>
203 <input
204 type="checkbox"
205 name="confidential"
206 value="on"
207 checked
208 />
209 {" "}Confidential client (server-side app)
210 </label>
211 <br />
212 <small style="color: var(--text-muted)">
213 Uncheck for public SPA / mobile apps — they must use PKCE
214 instead of a client secret.
215 </small>
216 </div>
217 <button type="submit" class="btn btn-primary">
218 Register app
219 </button>
220 <a
221 href="/settings/applications"
222 class="btn"
223 style="margin-left: 8px"
224 >
225 Cancel
226 </a>
227 </form>
228 </div>
229 </Layout>
230 );
231});
232
233apps.post("/settings/applications/new", async (c) => {
234 const user = c.get("user")!;
235 const body = await c.req.parseBody();
236 const name = String(body.name || "").trim().slice(0, 80);
237 const homepageUrl = String(body.homepage_url || "").trim().slice(0, 200);
238 const description = String(body.description || "").trim().slice(0, 500);
239 const confidential = String(body.confidential || "") === "on";
240 const redirectRaw = String(body.redirect_uris || "");
241
242 if (!name) {
243 return c.redirect("/settings/applications/new?error=Name+is+required");
244 }
245 const parsed = normaliseRedirectUris(redirectRaw);
246 if (!parsed.ok) {
247 return c.redirect(
248 `/settings/applications/new?error=${encodeURIComponent(parsed.error || "Invalid redirect URIs")}`
249 );
250 }
251
252 const clientId = generateClientId();
253 const clientSecret = generateClientSecret();
254 const clientSecretHash = await sha256Hex(clientSecret);
255
256 try {
257 const [row] = await db
258 .insert(oauthApps)
259 .values({
260 ownerId: user.id,
261 name,
262 clientId,
263 clientSecretHash,
264 clientSecretPrefix: clientSecret.slice(0, 8),
265 redirectUris: parsed.value!,
266 homepageUrl: homepageUrl || null,
267 description: description || null,
268 confidential,
269 })
270 .returning();
271 await audit({
272 userId: user.id,
273 action: "oauth_app.create",
274 targetType: "oauth_app",
275 targetId: row.id,
276 metadata: { clientId },
277 });
278 // Redirect to the manage page with the plaintext secret appended once.
279 return c.redirect(
280 `/settings/applications/${row.id}?secret=${encodeURIComponent(clientSecret)}&success=App+created`
281 );
282 } catch (err) {
283 console.error("[oauth-apps] create:", err);
284 return c.redirect(
285 "/settings/applications/new?error=Service+unavailable"
286 );
287 }
288});
289
290apps.get("/settings/applications/:id", async (c) => {
291 const user = c.get("user")!;
292 const id = c.req.param("id");
293 const error = c.req.query("error");
294 const success = c.req.query("success");
295 const secret = c.req.query("secret");
296
297 let app: typeof oauthApps.$inferSelect | undefined;
298 try {
299 const [row] = await db
300 .select()
301 .from(oauthApps)
302 .where(and(eq(oauthApps.id, id), eq(oauthApps.ownerId, user.id)))
303 .limit(1);
304 app = row;
305 } catch (err) {
306 console.error("[oauth-apps] get:", err);
307 }
308 if (!app) {
309 return c.redirect("/settings/applications?error=Not+found");
310 }
311
312 return c.html(
313 <Layout title={app.name} user={user}>
314 <div class="settings-container">
315 <div class="breadcrumb">
316 <a href="/settings/applications">applications</a>
317 <span>/</span>
318 <span>{app.name}</span>
319 </div>
320 <h2>{app.name}</h2>
321 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
322 {success && (
323 <div class="auth-success">{decodeURIComponent(success)}</div>
324 )}
325
326 {secret && (
327 <div
328 style="padding: 12px; border: 1px solid var(--yellow); background: rgba(255,193,7,0.1); border-radius: var(--radius); margin-bottom: 16px"
329 >
330 <strong>Save this client secret — it will not be shown again:</strong>
331 <pre
332 style="margin-top: 8px; padding: 8px; background: var(--bg); border-radius: 4px; overflow-x: auto; user-select: all"
333 >
334 {secret}
335 </pre>
336 </div>
337 )}
338
339 <dl style="display: grid; grid-template-columns: 200px 1fr; gap: 8px 16px; margin-bottom: 16px">
340 <dt style="color: var(--text-muted)">Client ID</dt>
341 <dd>
342 <code style="user-select: all">{app.clientId}</code>
343 </dd>
344 <dt style="color: var(--text-muted)">Client secret prefix</dt>
345 <dd>
346 <code>{app.clientSecretPrefix}…</code>
347 </dd>
348 <dt style="color: var(--text-muted)">Type</dt>
349 <dd>{app.confidential ? "Confidential" : "Public (PKCE)"}</dd>
350 <dt style="color: var(--text-muted)">Created</dt>
351 <dd>{new Date(app.createdAt).toLocaleString()}</dd>
352 </dl>
353
9e2c6dfClaude354 <form method="post" action={`/settings/applications/${app.id}`}>
bfdb5e7Claude355 <div class="form-group">
356 <label for="name">Application name</label>
357 <input
358 type="text"
359 id="name"
360 name="name"
361 required
362 maxLength={80}
363 defaultValue={app.name}
364 />
365 </div>
366 <div class="form-group">
367 <label for="homepage_url">Homepage URL</label>
368 <input
369 type="url"
370 id="homepage_url"
371 name="homepage_url"
372 defaultValue={app.homepageUrl || ""}
373 />
374 </div>
375 <div class="form-group">
376 <label for="description">Description</label>
377 <textarea
378 id="description"
379 name="description"
380 rows={3}
381 maxLength={500}
382 >
383 {app.description || ""}
384 </textarea>
385 </div>
386 <div class="form-group">
387 <label for="redirect_uris">Authorization callback URLs</label>
388 <textarea
389 id="redirect_uris"
390 name="redirect_uris"
391 rows={4}
392 required
393 >
394 {app.redirectUris}
395 </textarea>
396 </div>
397 <button type="submit" class="btn btn-primary">
398 Save changes
399 </button>
400 </form>
401
402 <hr style="margin: 24px 0; border-color: var(--border)" />
403
404 <h3>Rotate client secret</h3>
405 <p style="color: var(--text-muted); font-size: 13px">
406 Generate a new secret. The old one is invalidated immediately —
407 existing access tokens keep working, but token exchange with the
408 old secret will fail.
409 </p>
410 <form
9e2c6dfClaude411 method="post"
bfdb5e7Claude412 action={`/settings/applications/${app.id}/rotate`}
413 onsubmit="return confirm('Rotate the client secret? The old one will stop working immediately.')"
414 >
415 <button type="submit" class="btn">
416 Rotate secret
417 </button>
418 </form>
419
420 <hr style="margin: 24px 0; border-color: var(--border)" />
421
422 <h3 style="color: var(--red)">Danger zone</h3>
423 <form
9e2c6dfClaude424 method="post"
bfdb5e7Claude425 action={`/settings/applications/${app.id}/delete`}
426 onsubmit="return confirm('Delete this OAuth app? All issued access tokens will be revoked.')"
427 >
428 <button type="submit" class="btn btn-danger">
429 Delete app
430 </button>
431 </form>
432 </div>
433 </Layout>
434 );
435});
436
437apps.post("/settings/applications/:id", async (c) => {
438 const user = c.get("user")!;
439 const id = c.req.param("id");
440 const body = await c.req.parseBody();
441 const name = String(body.name || "").trim().slice(0, 80);
442 const homepageUrl = String(body.homepage_url || "").trim().slice(0, 200);
443 const description = String(body.description || "").trim().slice(0, 500);
444 const redirectRaw = String(body.redirect_uris || "");
445
446 if (!name) {
447 return c.redirect(
448 `/settings/applications/${id}?error=Name+is+required`
449 );
450 }
451 const parsed = normaliseRedirectUris(redirectRaw);
452 if (!parsed.ok) {
453 return c.redirect(
454 `/settings/applications/${id}?error=${encodeURIComponent(parsed.error || "Invalid redirect URIs")}`
455 );
456 }
457 try {
458 const [existing] = await db
459 .select({ id: oauthApps.id, ownerId: oauthApps.ownerId })
460 .from(oauthApps)
461 .where(eq(oauthApps.id, id))
462 .limit(1);
463 if (!existing || existing.ownerId !== user.id) {
464 return c.redirect("/settings/applications?error=Not+found");
465 }
466 await db
467 .update(oauthApps)
468 .set({
469 name,
470 homepageUrl: homepageUrl || null,
471 description: description || null,
472 redirectUris: parsed.value!,
473 updatedAt: new Date(),
474 })
475 .where(eq(oauthApps.id, id));
476 await audit({
477 userId: user.id,
478 action: "oauth_app.update",
479 targetType: "oauth_app",
480 targetId: id,
481 });
482 return c.redirect(`/settings/applications/${id}?success=Saved`);
483 } catch (err) {
484 console.error("[oauth-apps] update:", err);
485 return c.redirect(
486 `/settings/applications/${id}?error=Service+unavailable`
487 );
488 }
489});
490
491apps.post("/settings/applications/:id/rotate", async (c) => {
492 const user = c.get("user")!;
493 const id = c.req.param("id");
494 try {
495 const [existing] = await db
496 .select({ id: oauthApps.id, ownerId: oauthApps.ownerId })
497 .from(oauthApps)
498 .where(eq(oauthApps.id, id))
499 .limit(1);
500 if (!existing || existing.ownerId !== user.id) {
501 return c.redirect("/settings/applications?error=Not+found");
502 }
503 const newSecret = generateClientSecret();
504 const newHash = await sha256Hex(newSecret);
505 await db
506 .update(oauthApps)
507 .set({
508 clientSecretHash: newHash,
509 clientSecretPrefix: newSecret.slice(0, 8),
510 updatedAt: new Date(),
511 })
512 .where(eq(oauthApps.id, id));
513 await audit({
514 userId: user.id,
515 action: "oauth_app.rotate_secret",
516 targetType: "oauth_app",
517 targetId: id,
518 });
519 return c.redirect(
520 `/settings/applications/${id}?secret=${encodeURIComponent(newSecret)}&success=Secret+rotated`
521 );
522 } catch (err) {
523 console.error("[oauth-apps] rotate:", err);
524 return c.redirect(
525 `/settings/applications/${id}?error=Service+unavailable`
526 );
527 }
528});
529
530apps.post("/settings/applications/:id/delete", async (c) => {
531 const user = c.get("user")!;
532 const id = c.req.param("id");
533 try {
534 const [existing] = await db
535 .select({ id: oauthApps.id, ownerId: oauthApps.ownerId })
536 .from(oauthApps)
537 .where(eq(oauthApps.id, id))
538 .limit(1);
539 if (!existing || existing.ownerId !== user.id) {
540 return c.redirect("/settings/applications?error=Not+found");
541 }
542 await db.delete(oauthApps).where(eq(oauthApps.id, id));
543 await audit({
544 userId: user.id,
545 action: "oauth_app.delete",
546 targetType: "oauth_app",
547 targetId: id,
548 });
549 return c.redirect("/settings/applications?success=App+deleted");
550 } catch (err) {
551 console.error("[oauth-apps] delete:", err);
552 return c.redirect(
553 `/settings/applications/${id}?error=Service+unavailable`
554 );
555 }
556});
557
558export default apps;