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.tsxBlame559 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 *
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
2228c49copilot-swe-agent[bot]208 aria-label="Confidential client"
bfdb5e7Claude209 />
210 {" "}Confidential client (server-side app)
211 </label>
212 <br />
213 <small style="color: var(--text-muted)">
214 Uncheck for public SPA / mobile apps — they must use PKCE
215 instead of a client secret.
216 </small>
217 </div>
218 <button type="submit" class="btn btn-primary">
219 Register app
220 </button>
221 <a
222 href="/settings/applications"
223 class="btn"
224 style="margin-left: 8px"
225 >
226 Cancel
227 </a>
228 </form>
229 </div>
230 </Layout>
231 );
232});
233
234apps.post("/settings/applications/new", async (c) => {
235 const user = c.get("user")!;
236 const body = await c.req.parseBody();
237 const name = String(body.name || "").trim().slice(0, 80);
238 const homepageUrl = String(body.homepage_url || "").trim().slice(0, 200);
239 const description = String(body.description || "").trim().slice(0, 500);
240 const confidential = String(body.confidential || "") === "on";
241 const redirectRaw = String(body.redirect_uris || "");
242
243 if (!name) {
244 return c.redirect("/settings/applications/new?error=Name+is+required");
245 }
246 const parsed = normaliseRedirectUris(redirectRaw);
247 if (!parsed.ok) {
248 return c.redirect(
249 `/settings/applications/new?error=${encodeURIComponent(parsed.error || "Invalid redirect URIs")}`
250 );
251 }
252
253 const clientId = generateClientId();
254 const clientSecret = generateClientSecret();
255 const clientSecretHash = await sha256Hex(clientSecret);
256
257 try {
258 const [row] = await db
259 .insert(oauthApps)
260 .values({
261 ownerId: user.id,
262 name,
263 clientId,
264 clientSecretHash,
265 clientSecretPrefix: clientSecret.slice(0, 8),
266 redirectUris: parsed.value!,
267 homepageUrl: homepageUrl || null,
268 description: description || null,
269 confidential,
270 })
271 .returning();
272 await audit({
273 userId: user.id,
274 action: "oauth_app.create",
275 targetType: "oauth_app",
276 targetId: row.id,
277 metadata: { clientId },
278 });
279 // Redirect to the manage page with the plaintext secret appended once.
280 return c.redirect(
281 `/settings/applications/${row.id}?secret=${encodeURIComponent(clientSecret)}&success=App+created`
282 );
283 } catch (err) {
284 console.error("[oauth-apps] create:", err);
285 return c.redirect(
286 "/settings/applications/new?error=Service+unavailable"
287 );
288 }
289});
290
291apps.get("/settings/applications/:id", async (c) => {
292 const user = c.get("user")!;
293 const id = c.req.param("id");
294 const error = c.req.query("error");
295 const success = c.req.query("success");
296 const secret = c.req.query("secret");
297
298 let app: typeof oauthApps.$inferSelect | undefined;
299 try {
300 const [row] = await db
301 .select()
302 .from(oauthApps)
303 .where(and(eq(oauthApps.id, id), eq(oauthApps.ownerId, user.id)))
304 .limit(1);
305 app = row;
306 } catch (err) {
307 console.error("[oauth-apps] get:", err);
308 }
309 if (!app) {
310 return c.redirect("/settings/applications?error=Not+found");
311 }
312
313 return c.html(
314 <Layout title={app.name} user={user}>
315 <div class="settings-container">
316 <div class="breadcrumb">
317 <a href="/settings/applications">applications</a>
318 <span>/</span>
319 <span>{app.name}</span>
320 </div>
321 <h2>{app.name}</h2>
322 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
323 {success && (
324 <div class="auth-success">{decodeURIComponent(success)}</div>
325 )}
326
327 {secret && (
328 <div
329 style="padding: 12px; border: 1px solid var(--yellow); background: rgba(255,193,7,0.1); border-radius: var(--radius); margin-bottom: 16px"
330 >
331 <strong>Save this client secret — it will not be shown again:</strong>
332 <pre
333 style="margin-top: 8px; padding: 8px; background: var(--bg); border-radius: 4px; overflow-x: auto; user-select: all"
334 >
335 {secret}
336 </pre>
337 </div>
338 )}
339
340 <dl style="display: grid; grid-template-columns: 200px 1fr; gap: 8px 16px; margin-bottom: 16px">
341 <dt style="color: var(--text-muted)">Client ID</dt>
342 <dd>
343 <code style="user-select: all">{app.clientId}</code>
344 </dd>
345 <dt style="color: var(--text-muted)">Client secret prefix</dt>
346 <dd>
347 <code>{app.clientSecretPrefix}…</code>
348 </dd>
349 <dt style="color: var(--text-muted)">Type</dt>
350 <dd>{app.confidential ? "Confidential" : "Public (PKCE)"}</dd>
351 <dt style="color: var(--text-muted)">Created</dt>
352 <dd>{new Date(app.createdAt).toLocaleString()}</dd>
353 </dl>
354
9e2c6dfClaude355 <form method="post" action={`/settings/applications/${app.id}`}>
bfdb5e7Claude356 <div class="form-group">
357 <label for="name">Application name</label>
358 <input
359 type="text"
360 id="name"
361 name="name"
362 required
363 maxLength={80}
364 defaultValue={app.name}
365 />
366 </div>
367 <div class="form-group">
368 <label for="homepage_url">Homepage URL</label>
369 <input
370 type="url"
371 id="homepage_url"
372 name="homepage_url"
373 defaultValue={app.homepageUrl || ""}
374 />
375 </div>
376 <div class="form-group">
377 <label for="description">Description</label>
378 <textarea
379 id="description"
380 name="description"
381 rows={3}
382 maxLength={500}
383 >
384 {app.description || ""}
385 </textarea>
386 </div>
387 <div class="form-group">
388 <label for="redirect_uris">Authorization callback URLs</label>
389 <textarea
390 id="redirect_uris"
391 name="redirect_uris"
392 rows={4}
393 required
394 >
395 {app.redirectUris}
396 </textarea>
397 </div>
398 <button type="submit" class="btn btn-primary">
399 Save changes
400 </button>
401 </form>
402
403 <hr style="margin: 24px 0; border-color: var(--border)" />
404
405 <h3>Rotate client secret</h3>
406 <p style="color: var(--text-muted); font-size: 13px">
407 Generate a new secret. The old one is invalidated immediately —
408 existing access tokens keep working, but token exchange with the
409 old secret will fail.
410 </p>
411 <form
9e2c6dfClaude412 method="post"
bfdb5e7Claude413 action={`/settings/applications/${app.id}/rotate`}
414 onsubmit="return confirm('Rotate the client secret? The old one will stop working immediately.')"
415 >
416 <button type="submit" class="btn">
417 Rotate secret
418 </button>
419 </form>
420
421 <hr style="margin: 24px 0; border-color: var(--border)" />
422
423 <h3 style="color: var(--red)">Danger zone</h3>
424 <form
9e2c6dfClaude425 method="post"
bfdb5e7Claude426 action={`/settings/applications/${app.id}/delete`}
427 onsubmit="return confirm('Delete this OAuth app? All issued access tokens will be revoked.')"
428 >
429 <button type="submit" class="btn btn-danger">
430 Delete app
431 </button>
432 </form>
433 </div>
434 </Layout>
435 );
436});
437
438apps.post("/settings/applications/:id", async (c) => {
439 const user = c.get("user")!;
440 const id = c.req.param("id");
441 const body = await c.req.parseBody();
442 const name = String(body.name || "").trim().slice(0, 80);
443 const homepageUrl = String(body.homepage_url || "").trim().slice(0, 200);
444 const description = String(body.description || "").trim().slice(0, 500);
445 const redirectRaw = String(body.redirect_uris || "");
446
447 if (!name) {
448 return c.redirect(
449 `/settings/applications/${id}?error=Name+is+required`
450 );
451 }
452 const parsed = normaliseRedirectUris(redirectRaw);
453 if (!parsed.ok) {
454 return c.redirect(
455 `/settings/applications/${id}?error=${encodeURIComponent(parsed.error || "Invalid redirect URIs")}`
456 );
457 }
458 try {
459 const [existing] = await db
460 .select({ id: oauthApps.id, ownerId: oauthApps.ownerId })
461 .from(oauthApps)
462 .where(eq(oauthApps.id, id))
463 .limit(1);
464 if (!existing || existing.ownerId !== user.id) {
465 return c.redirect("/settings/applications?error=Not+found");
466 }
467 await db
468 .update(oauthApps)
469 .set({
470 name,
471 homepageUrl: homepageUrl || null,
472 description: description || null,
473 redirectUris: parsed.value!,
474 updatedAt: new Date(),
475 })
476 .where(eq(oauthApps.id, id));
477 await audit({
478 userId: user.id,
479 action: "oauth_app.update",
480 targetType: "oauth_app",
481 targetId: id,
482 });
483 return c.redirect(`/settings/applications/${id}?success=Saved`);
484 } catch (err) {
485 console.error("[oauth-apps] update:", err);
486 return c.redirect(
487 `/settings/applications/${id}?error=Service+unavailable`
488 );
489 }
490});
491
492apps.post("/settings/applications/:id/rotate", async (c) => {
493 const user = c.get("user")!;
494 const id = c.req.param("id");
495 try {
496 const [existing] = await db
497 .select({ id: oauthApps.id, ownerId: oauthApps.ownerId })
498 .from(oauthApps)
499 .where(eq(oauthApps.id, id))
500 .limit(1);
501 if (!existing || existing.ownerId !== user.id) {
502 return c.redirect("/settings/applications?error=Not+found");
503 }
504 const newSecret = generateClientSecret();
505 const newHash = await sha256Hex(newSecret);
506 await db
507 .update(oauthApps)
508 .set({
509 clientSecretHash: newHash,
510 clientSecretPrefix: newSecret.slice(0, 8),
511 updatedAt: new Date(),
512 })
513 .where(eq(oauthApps.id, id));
514 await audit({
515 userId: user.id,
516 action: "oauth_app.rotate_secret",
517 targetType: "oauth_app",
518 targetId: id,
519 });
520 return c.redirect(
521 `/settings/applications/${id}?secret=${encodeURIComponent(newSecret)}&success=Secret+rotated`
522 );
523 } catch (err) {
524 console.error("[oauth-apps] rotate:", err);
525 return c.redirect(
526 `/settings/applications/${id}?error=Service+unavailable`
527 );
528 }
529});
530
531apps.post("/settings/applications/:id/delete", async (c) => {
532 const user = c.get("user")!;
533 const id = c.req.param("id");
534 try {
535 const [existing] = await db
536 .select({ id: oauthApps.id, ownerId: oauthApps.ownerId })
537 .from(oauthApps)
538 .where(eq(oauthApps.id, id))
539 .limit(1);
540 if (!existing || existing.ownerId !== user.id) {
541 return c.redirect("/settings/applications?error=Not+found");
542 }
543 await db.delete(oauthApps).where(eq(oauthApps.id, id));
544 await audit({
545 userId: user.id,
546 action: "oauth_app.delete",
547 targetType: "oauth_app",
548 targetId: id,
549 });
550 return c.redirect("/settings/applications?success=App+deleted");
551 } catch (err) {
552 console.error("[oauth-apps] delete:", err);
553 return c.redirect(
554 `/settings/applications/${id}?error=Service+unavailable`
555 );
556 }
557});
558
559export default apps;