CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
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"; | |
| 12 | ||
| 13 | const tokens = new Hono<AuthEnv>(); | |
| 14 | ||
| 15 | tokens.use("/settings/tokens*", softAuth, requireAuth); | |
| 16 | tokens.use("/api/user/tokens*", softAuth, requireAuth); | |
| 17 | ||
| 18 | function generateToken(): string { | |
| 19 | const bytes = crypto.getRandomValues(new Uint8Array(32)); | |
| 20 | return ( | |
| 21 | "glc_" + | |
| 22 | Array.from(bytes) | |
| 23 | .map((b) => b.toString(16).padStart(2, "0")) | |
| 24 | .join("") | |
| 25 | ); | |
| 26 | } | |
| 27 | ||
| 28 | async function hashToken(token: string): Promise<string> { | |
| 29 | const data = new TextEncoder().encode(token); | |
| 30 | const hash = await crypto.subtle.digest("SHA-256", data); | |
| 31 | return Array.from(new Uint8Array(hash)) | |
| 32 | .map((b) => b.toString(16).padStart(2, "0")) | |
| 33 | .join(""); | |
| 34 | } | |
| 35 | ||
| 36 | // Token settings page | |
| 37 | tokens.get("/settings/tokens", async (c) => { | |
| 38 | const user = c.get("user")!; | |
| 39 | const success = c.req.query("success"); | |
| 40 | const newToken = c.req.query("new_token"); | |
| 41 | ||
| 42 | const userTokens = await db | |
| 43 | .select() | |
| 44 | .from(apiTokens) | |
| 45 | .where(eq(apiTokens.userId, user.id)); | |
| 46 | ||
| 47 | return c.html( | |
| 48 | <Layout title="API Tokens" user={user}> | |
| 49 | <div class="settings-container"> | |
| 50 | <h2>Personal access tokens</h2> | |
| 51 | {success && ( | |
| 52 | <div class="auth-success" style="margin-top: 12px"> | |
| 53 | {decodeURIComponent(success)} | |
| 54 | </div> | |
| 55 | )} | |
| 56 | {newToken && ( | |
| 57 | <div | |
| 58 | class="auth-success" | |
| 59 | style="margin-top: 12px; font-family: var(--font-mono); word-break: break-all" | |
| 60 | > | |
| 61 | New token (copy now — it won't be shown again):{" "} | |
| 62 | <strong>{decodeURIComponent(newToken)}</strong> | |
| 63 | </div> | |
| 64 | )} | |
| 65 | <div style="margin-top: 16px"> | |
| 66 | {userTokens.length === 0 ? ( | |
| 67 | <p style="color: var(--text-muted)">No tokens yet.</p> | |
| 68 | ) : ( | |
| 69 | userTokens.map((token) => ( | |
| 70 | <div class="ssh-key-item"> | |
| 71 | <div> | |
| 72 | <strong>{token.name}</strong> | |
| 73 | <div class="ssh-key-meta"> | |
| 74 | <code>{token.tokenPrefix}...</code> | |
| 75 | <span style="margin-left: 8px"> | |
| 76 | Scopes: {token.scopes} | |
| 77 | </span> | |
| 78 | {token.lastUsedAt && ( | |
| 79 | <span> | |
| 80 | {" "} | |
| 81 | | Last used{" "} | |
| 82 | {new Date(token.lastUsedAt).toLocaleDateString()} | |
| 83 | </span> | |
| 84 | )} | |
| 85 | </div> | |
| 86 | </div> | |
| 87 | <form | |
| 88 | method="POST" | |
| 89 | action={`/settings/tokens/${token.id}/delete`} | |
| 90 | > | |
| 91 | <button type="submit" class="btn btn-danger btn-sm"> | |
| 92 | Revoke | |
| 93 | </button> | |
| 94 | </form> | |
| 95 | </div> | |
| 96 | )) | |
| 97 | )} | |
| 98 | </div> | |
| 99 | ||
| 100 | <h3 style="margin-top: 24px; margin-bottom: 12px"> | |
| 101 | Generate new token | |
| 102 | </h3> | |
| 103 | <form method="POST" action="/settings/tokens"> | |
| 104 | <div class="form-group"> | |
| 105 | <label for="name">Token name</label> | |
| 106 | <input | |
| 107 | type="text" | |
| 108 | id="name" | |
| 109 | name="name" | |
| 110 | required | |
| 111 | placeholder="e.g. CI/CD pipeline" | |
| 112 | /> | |
| 113 | </div> | |
| 114 | <div class="form-group"> | |
| 115 | <label>Scopes</label> | |
| 116 | <div style="display: flex; gap: 16px; flex-wrap: wrap"> | |
| 117 | {["repo", "user", "admin"].map((scope) => ( | |
| 118 | <label style="display: flex; align-items: center; gap: 4px; font-size: 14px; cursor: pointer"> | |
| 119 | <input | |
| 120 | type="checkbox" | |
| 121 | name="scopes" | |
| 122 | value={scope} | |
| 123 | checked={scope === "repo"} | |
| 124 | />{" "} | |
| 125 | {scope} | |
| 126 | </label> | |
| 127 | ))} | |
| 128 | </div> | |
| 129 | </div> | |
| 130 | <button type="submit" class="btn btn-primary"> | |
| 131 | Generate token | |
| 132 | </button> | |
| 133 | </form> | |
| 134 | </div> | |
| 135 | </Layout> | |
| 136 | ); | |
| 137 | }); | |
| 138 | ||
| 139 | // Create token | |
| 140 | tokens.post("/settings/tokens", async (c) => { | |
| 141 | const user = c.get("user")!; | |
| 142 | const body = await c.req.parseBody(); | |
| 143 | const name = String(body.name || "").trim(); | |
| 144 | ||
| 145 | let scopes: string; | |
| 146 | const rawScopes = body.scopes; | |
| 147 | if (Array.isArray(rawScopes)) { | |
| 148 | scopes = rawScopes.join(","); | |
| 149 | } else { | |
| 150 | scopes = String(rawScopes || "repo"); | |
| 151 | } | |
| 152 | ||
| 153 | if (!name) { | |
| 154 | return c.redirect("/settings/tokens?error=Name+is+required"); | |
| 155 | } | |
| 156 | ||
| 157 | const token = generateToken(); | |
| 158 | const tokenH = await hashToken(token); | |
| 159 | ||
| 160 | await db.insert(apiTokens).values({ | |
| 161 | userId: user.id, | |
| 162 | name, | |
| 163 | tokenHash: tokenH, | |
| 164 | tokenPrefix: token.slice(0, 12), | |
| 165 | scopes, | |
| 166 | }); | |
| 167 | ||
| 168 | return c.redirect( | |
| 169 | `/settings/tokens?new_token=${encodeURIComponent(token)}` | |
| 170 | ); | |
| 171 | }); | |
| 172 | ||
| 173 | // Delete token | |
| 174 | tokens.post("/settings/tokens/:id/delete", async (c) => { | |
| 175 | const user = c.get("user")!; | |
| 176 | const tokenId = c.req.param("id"); | |
| 177 | ||
| 178 | const [token] = await db | |
| 179 | .select() | |
| 180 | .from(apiTokens) | |
| 181 | .where(eq(apiTokens.id, tokenId)) | |
| 182 | .limit(1); | |
| 183 | ||
| 184 | if (!token || token.userId !== user.id) { | |
| 185 | return c.redirect("/settings/tokens"); | |
| 186 | } | |
| 187 | ||
| 188 | await db.delete(apiTokens).where(eq(apiTokens.id, tokenId)); | |
| 189 | return c.redirect("/settings/tokens?success=Token+revoked"); | |
| 190 | }); | |
| 191 | ||
| 192 | // API endpoint | |
| 193 | tokens.get("/api/user/tokens", async (c) => { | |
| 194 | const user = c.get("user")!; | |
| 195 | const userTokens = await db | |
| 196 | .select({ | |
| 197 | id: apiTokens.id, | |
| 198 | name: apiTokens.name, | |
| 199 | tokenPrefix: apiTokens.tokenPrefix, | |
| 200 | scopes: apiTokens.scopes, | |
| 201 | lastUsedAt: apiTokens.lastUsedAt, | |
| 202 | createdAt: apiTokens.createdAt, | |
| 203 | }) | |
| 204 | .from(apiTokens) | |
| 205 | .where(eq(apiTokens.userId, user.id)); | |
| 206 | return c.json(userTokens); | |
| 207 | }); | |
| 208 | ||
| 209 | export default tokens; |