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"; | |
| 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 | ||
| 209 | // API endpoint | |
| 210 | tokens.get("/api/user/tokens", async (c) => { | |
| 211 | const user = c.get("user")!; | |
| 212 | const userTokens = await db | |
| 213 | .select({ | |
| 214 | id: apiTokens.id, | |
| 215 | name: apiTokens.name, | |
| 216 | tokenPrefix: apiTokens.tokenPrefix, | |
| 217 | scopes: apiTokens.scopes, | |
| 218 | lastUsedAt: apiTokens.lastUsedAt, | |
| 219 | createdAt: apiTokens.createdAt, | |
| 220 | }) | |
| 221 | .from(apiTokens) | |
| 222 | .where(eq(apiTokens.userId, user.id)); | |
| 223 | return c.json(userTokens); | |
| 224 | }); | |
| 225 | ||
| 226 | export default tokens; |