Blame · Line-by-line history
tokens.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.
| c81ab7a | 1 | /** |
| 2 | * API tokens — personal access tokens for automation. | |
| 3 | */ | |
| 4 | ||
| 5 | import { Hono } from "hono"; | |
| 6 | import { eq } from "drizzle-orm"; | |
| 7 | import { db } from "../db"; | |
| 8 | import { apiTokens } from "../db/schema"; | |
| 9 | import { Layout } from "../views/layout"; | |
| 10 | import { softAuth, requireAuth } from "../middleware/auth"; | |
| 11 | import type { AuthEnv } from "../middleware/auth"; | |
| 3e8f8e8 | 12 | import { |
| 13 | Container, | |
| 14 | PageHeader, | |
| 15 | Section, | |
| 16 | Alert, | |
| 17 | EmptyState, | |
| 18 | ListItem, | |
| 19 | Flex, | |
| 20 | Form, | |
| 21 | FormGroup, | |
| 22 | Input, | |
| 23 | Button, | |
| 24 | InlineCode, | |
| 25 | Text, | |
| 26 | } from "../views/ui"; | |
| c81ab7a | 27 | |
| 28 | const tokens = new Hono<AuthEnv>(); | |
| 29 | ||
| 30 | tokens.use("/settings/tokens*", softAuth, requireAuth); | |
| 31 | tokens.use("/api/user/tokens*", softAuth, requireAuth); | |
| 32 | ||
| 33 | function generateToken(): string { | |
| 34 | const bytes = crypto.getRandomValues(new Uint8Array(32)); | |
| 35 | return ( | |
| 36 | "glc_" + | |
| 37 | Array.from(bytes) | |
| 38 | .map((b) => b.toString(16).padStart(2, "0")) | |
| 39 | .join("") | |
| 40 | ); | |
| 41 | } | |
| 42 | ||
| 43 | async function hashToken(token: string): Promise<string> { | |
| 44 | const data = new TextEncoder().encode(token); | |
| 45 | const hash = await crypto.subtle.digest("SHA-256", data); | |
| 46 | return Array.from(new Uint8Array(hash)) | |
| 47 | .map((b) => b.toString(16).padStart(2, "0")) | |
| 48 | .join(""); | |
| 49 | } | |
| 50 | ||
| 51 | // Token settings page | |
| 52 | tokens.get("/settings/tokens", async (c) => { | |
| 53 | const user = c.get("user")!; | |
| 54 | const success = c.req.query("success"); | |
| 55 | const newToken = c.req.query("new_token"); | |
| 56 | ||
| 57 | const userTokens = await db | |
| 58 | .select() | |
| 59 | .from(apiTokens) | |
| 60 | .where(eq(apiTokens.userId, user.id)); | |
| 61 | ||
| 62 | return c.html( | |
| 63 | <Layout title="API Tokens" user={user}> | |
| 3e8f8e8 | 64 | <Container class="settings-container"> |
| 65 | <PageHeader title="Personal access tokens" /> | |
| c81ab7a | 66 | {success && ( |
| 3e8f8e8 | 67 | <Alert variant="success"> |
| c81ab7a | 68 | {decodeURIComponent(success)} |
| 3e8f8e8 | 69 | </Alert> |
| c81ab7a | 70 | )} |
| 71 | {newToken && ( | |
| 3e8f8e8 | 72 | <Alert variant="success"> |
| 73 | <span style="font-family: var(--font-mono); word-break: break-all"> | |
| 74 | New token (copy now — it won't be shown again):{" "} | |
| 75 | <strong>{decodeURIComponent(newToken)}</strong> | |
| 76 | </span> | |
| 77 | </Alert> | |
| c81ab7a | 78 | )} |
| 79 | <div style="margin-top: 16px"> | |
| 80 | {userTokens.length === 0 ? ( | |
| 3e8f8e8 | 81 | <EmptyState> |
| 82 | <Text muted>No tokens yet.</Text> | |
| 83 | </EmptyState> | |
| c81ab7a | 84 | ) : ( |
| 85 | userTokens.map((token) => ( | |
| 3e8f8e8 | 86 | <ListItem> |
| c81ab7a | 87 | <div> |
| 88 | <strong>{token.name}</strong> | |
| 89 | <div class="ssh-key-meta"> | |
| 3e8f8e8 | 90 | <InlineCode>{token.tokenPrefix}...</InlineCode> |
| c81ab7a | 91 | <span style="margin-left: 8px"> |
| 92 | Scopes: {token.scopes} | |
| 93 | </span> | |
| 94 | {token.lastUsedAt && ( | |
| 95 | <span> | |
| 96 | {" "} | |
| 97 | | Last used{" "} | |
| 98 | {new Date(token.lastUsedAt).toLocaleDateString()} | |
| 99 | </span> | |
| 100 | )} | |
| 101 | </div> | |
| 102 | </div> | |
| 103 | <form | |
| 001af43 | 104 | method="post" |
| c81ab7a | 105 | action={`/settings/tokens/${token.id}/delete`} |
| 106 | > | |
| 3e8f8e8 | 107 | <Button type="submit" variant="danger" size="sm"> |
| c81ab7a | 108 | Revoke |
| 3e8f8e8 | 109 | </Button> |
| 0316dbb | 110 | </form> |
| 3e8f8e8 | 111 | </ListItem> |
| c81ab7a | 112 | )) |
| 113 | )} | |
| 114 | </div> | |
| 115 | ||
| 116 | <h3 style="margin-top: 24px; margin-bottom: 12px"> | |
| 117 | Generate new token | |
| 118 | </h3> | |
| 001af43 | 119 | <form method="post" action="/settings/tokens"> |
| c81ab7a | 120 | <div class="form-group"> |
| 121 | <label for="name">Token name</label> | |
| 122 | <input | |
| 123 | type="text" | |
| 124 | id="name" | |
| 125 | name="name" | |
| 126 | required | |
| 127 | placeholder="e.g. CI/CD pipeline" | |
| 128 | /> | |
| 129 | </div> | |
| 130 | <div class="form-group"> | |
| 131 | <label>Scopes</label> | |
| 132 | <div style="display: flex; gap: 16px; flex-wrap: wrap"> | |
| 133 | {["repo", "user", "admin"].map((scope) => ( | |
| 134 | <label style="display: flex; align-items: center; gap: 4px; font-size: 14px; cursor: pointer"> | |
| 135 | <input | |
| 136 | type="checkbox" | |
| 137 | name="scopes" | |
| 138 | value={scope} | |
| 139 | checked={scope === "repo"} | |
| 2c3ba6e | 140 | aria-label={scope} |
| c81ab7a | 141 | />{" "} |
| 142 | {scope} | |
| 143 | </label> | |
| 144 | ))} | |
| 145 | </div> | |
| 146 | </div> | |
| 147 | <button type="submit" class="btn btn-primary"> | |
| 148 | Generate token | |
| 149 | </button> | |
| 150 | </form> | |
| 0316dbb | 151 | </Container> |
| c81ab7a | 152 | </Layout> |
| 153 | ); | |
| 154 | }); | |
| 155 | ||
| 156 | // Create token | |
| 157 | tokens.post("/settings/tokens", async (c) => { | |
| 158 | const user = c.get("user")!; | |
| 159 | const body = await c.req.parseBody(); | |
| 160 | const name = String(body.name || "").trim(); | |
| 161 | ||
| 162 | let scopes: string; | |
| 163 | const rawScopes = body.scopes; | |
| 164 | if (Array.isArray(rawScopes)) { | |
| 165 | scopes = rawScopes.join(","); | |
| 166 | } else { | |
| 167 | scopes = String(rawScopes || "repo"); | |
| 168 | } | |
| 169 | ||
| 170 | if (!name) { | |
| 171 | return c.redirect("/settings/tokens?error=Name+is+required"); | |
| 172 | } | |
| 173 | ||
| 174 | const token = generateToken(); | |
| 175 | const tokenH = await hashToken(token); | |
| 176 | ||
| 177 | await db.insert(apiTokens).values({ | |
| 178 | userId: user.id, | |
| 179 | name, | |
| 180 | tokenHash: tokenH, | |
| 181 | tokenPrefix: token.slice(0, 12), | |
| 182 | scopes, | |
| 183 | }); | |
| 184 | ||
| 185 | return c.redirect( | |
| 186 | `/settings/tokens?new_token=${encodeURIComponent(token)}` | |
| 187 | ); | |
| 188 | }); | |
| 189 | ||
| 190 | // Delete token | |
| 191 | tokens.post("/settings/tokens/:id/delete", async (c) => { | |
| 192 | const user = c.get("user")!; | |
| 193 | const tokenId = c.req.param("id"); | |
| 194 | ||
| 195 | const [token] = await db | |
| 196 | .select() | |
| 197 | .from(apiTokens) | |
| 198 | .where(eq(apiTokens.id, tokenId)) | |
| 199 | .limit(1); | |
| 200 | ||
| 201 | if (!token || token.userId !== user.id) { | |
| 202 | return c.redirect("/settings/tokens"); | |
| 203 | } | |
| 204 | ||
| 205 | await db.delete(apiTokens).where(eq(apiTokens.id, tokenId)); | |
| 206 | return c.redirect("/settings/tokens?success=Token+revoked"); | |
| 207 | }); | |
| 208 | ||
| 96942a6 | 209 | /** |
| 210 | * Emergency PAT issuance — break-glass for when the web UI is broken | |
| 211 | * (service-worker loop, css busted, whatever) and an operator needs | |
| 212 | * a token to push a fix. | |
| 213 | * | |
| 214 | * Auth: bearer of the `EMERGENCY_PAT_SECRET` env var (set on the host). | |
| 215 | * If the env var is unset, the endpoint returns 503 — we don't want it | |
| 216 | * silently usable with an empty secret. This is the ONLY token route | |
| 217 | * that isn't behind a normal session, by design. | |
| 218 | * | |
| 219 | * Issues a PAT for the user named in the JSON body's `username` field, | |
| 220 | * defaulting to the site admin / oldest user (same heuristic the | |
| 221 | * self-host bootstrap uses). | |
| 222 | * | |
| 223 | * Returns JSON: { user, token } — the token is shown ONCE. | |
| 224 | * | |
| 225 | * Use: | |
| 226 | * curl -X POST https://gluecron.com/api/admin/emergency-pat \ | |
| 227 | * -H "Authorization: Bearer $EMERGENCY_PAT_SECRET" \ | |
| 228 | * -H "content-type: application/json" \ | |
| 229 | * -d '{"name":"break-glass","scopes":"admin"}' | |
| 230 | */ | |
| 231 | tokens.post("/api/admin/emergency-pat", async (c) => { | |
| 232 | const secret = process.env.EMERGENCY_PAT_SECRET; | |
| 233 | if (!secret) { | |
| 234 | return c.json( | |
| 235 | { error: "emergency PAT endpoint not configured (EMERGENCY_PAT_SECRET unset)" }, | |
| 236 | 503 | |
| 237 | ); | |
| 238 | } | |
| 239 | const provided = (c.req.header("authorization") || "").replace(/^Bearer\s+/i, "").trim(); | |
| 240 | if (provided !== secret) { | |
| 241 | return c.json({ error: "invalid emergency secret" }, 401); | |
| 242 | } | |
| 243 | ||
| 244 | let body: { username?: string; name?: string; scopes?: string }; | |
| 245 | try { | |
| 246 | body = await c.req.json(); | |
| 247 | } catch { | |
| 248 | body = {}; | |
| 249 | } | |
| 250 | const name = (body.name || "emergency-pat").trim(); | |
| 251 | const scopes = (body.scopes || "admin").trim(); | |
| 252 | ||
| 253 | // Resolve target user: explicit username → site admin → oldest user. | |
| 254 | const { users, siteAdmins } = await import("../db/schema"); | |
| 255 | const { eq: eqOp, asc } = await import("drizzle-orm"); | |
| 256 | let target: | |
| 257 | | { id: string; username: string } | |
| 258 | | undefined; | |
| 259 | ||
| 260 | if (body.username) { | |
| 261 | const [u] = await db | |
| 262 | .select({ id: users.id, username: users.username }) | |
| 263 | .from(users) | |
| 264 | .where(eqOp(users.username, body.username)) | |
| 265 | .limit(1); | |
| 266 | target = u; | |
| 267 | } | |
| 268 | if (!target) { | |
| 269 | try { | |
| 270 | const [u] = await db | |
| 271 | .select({ id: users.id, username: users.username }) | |
| 272 | .from(siteAdmins) | |
| 273 | .innerJoin(users, eqOp(siteAdmins.userId, users.id)) | |
| 274 | .limit(1); | |
| 275 | target = u; | |
| 276 | } catch { | |
| 277 | // siteAdmins table may not exist on stale schemas — fall through. | |
| 278 | } | |
| 279 | } | |
| 280 | if (!target) { | |
| 281 | const [u] = await db | |
| 282 | .select({ id: users.id, username: users.username }) | |
| 283 | .from(users) | |
| 284 | .orderBy(asc(users.createdAt)) | |
| 285 | .limit(1); | |
| 286 | target = u; | |
| 287 | } | |
| 288 | if (!target) { | |
| 289 | return c.json({ error: "no user available to issue PAT for" }, 404); | |
| 290 | } | |
| 291 | ||
| 292 | // Token + hash — same algorithm the web flow uses. | |
| 293 | const tokenBytes = crypto.getRandomValues(new Uint8Array(32)); | |
| 294 | const token = | |
| 295 | "glc_" + | |
| 296 | Array.from(tokenBytes) | |
| 297 | .map((b) => b.toString(16).padStart(2, "0")) | |
| 298 | .join(""); | |
| 299 | const hashBuf = await crypto.subtle.digest( | |
| 300 | "SHA-256", | |
| 301 | new TextEncoder().encode(token) | |
| 302 | ); | |
| 303 | const tokenHash = Array.from(new Uint8Array(hashBuf)) | |
| 304 | .map((b) => b.toString(16).padStart(2, "0")) | |
| 305 | .join(""); | |
| 306 | ||
| 307 | await db.insert(apiTokens).values({ | |
| 308 | userId: target.id, | |
| 309 | name, | |
| 310 | tokenHash, | |
| 311 | tokenPrefix: token.slice(0, 12), | |
| 312 | scopes, | |
| 313 | }); | |
| 314 | ||
| 315 | return c.json({ | |
| 316 | user: { id: target.id, username: target.username }, | |
| 317 | token, | |
| 318 | name, | |
| 319 | scopes, | |
| 320 | note: "Token is shown once. Store it now.", | |
| 321 | }); | |
| 322 | }); | |
| 323 | ||
| c81ab7a | 324 | // API endpoint |
| 325 | tokens.get("/api/user/tokens", async (c) => { | |
| 326 | const user = c.get("user")!; | |
| 327 | const userTokens = await db | |
| 328 | .select({ | |
| 329 | id: apiTokens.id, | |
| 330 | name: apiTokens.name, | |
| 331 | tokenPrefix: apiTokens.tokenPrefix, | |
| 332 | scopes: apiTokens.scopes, | |
| 333 | lastUsedAt: apiTokens.lastUsedAt, | |
| 334 | createdAt: apiTokens.createdAt, | |
| 335 | }) | |
| 336 | .from(apiTokens) | |
| 337 | .where(eq(apiTokens.userId, user.id)); | |
| 338 | return c.json(userTokens); | |
| 339 | }); | |
| 340 | ||
| 341 | export default tokens; |