Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
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.

tokens.tsxBlame225 lines · 1 contributor
c81ab7aClaude1/**
2 * API tokens — personal access tokens for automation.
3 */
4
5import { Hono } from "hono";
6import { eq } from "drizzle-orm";
7import { db } from "../db";
8import { apiTokens } from "../db/schema";
9import { Layout } from "../views/layout";
10import { softAuth, requireAuth } from "../middleware/auth";
11import type { AuthEnv } from "../middleware/auth";
3e8f8e8Claude12import {
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";
c81ab7aClaude27
28const tokens = new Hono<AuthEnv>();
29
30tokens.use("/settings/tokens*", softAuth, requireAuth);
31tokens.use("/api/user/tokens*", softAuth, requireAuth);
32
33function 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
43async 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
52tokens.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}>
3e8f8e8Claude64 <Container class="settings-container">
65 <PageHeader title="Personal access tokens" />
c81ab7aClaude66 {success && (
3e8f8e8Claude67 <Alert variant="success">
c81ab7aClaude68 {decodeURIComponent(success)}
3e8f8e8Claude69 </Alert>
c81ab7aClaude70 )}
71 {newToken && (
3e8f8e8Claude72 <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>
c81ab7aClaude78 )}
79 <div style="margin-top: 16px">
80 {userTokens.length === 0 ? (
3e8f8e8Claude81 <EmptyState>
82 <Text muted>No tokens yet.</Text>
83 </EmptyState>
c81ab7aClaude84 ) : (
85 userTokens.map((token) => (
3e8f8e8Claude86 <ListItem>
c81ab7aClaude87 <div>
88 <strong>{token.name}</strong>
89 <div class="ssh-key-meta">
3e8f8e8Claude90 <InlineCode>{token.tokenPrefix}...</InlineCode>
c81ab7aClaude91 <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
001af43Claude104 method="post"
c81ab7aClaude105 action={`/settings/tokens/${token.id}/delete`}
106 >
3e8f8e8Claude107 <Button type="submit" variant="danger" size="sm">
c81ab7aClaude108 Revoke
3e8f8e8Claude109 </Button>
0316dbbClaude110 </form>
3e8f8e8Claude111 </ListItem>
c81ab7aClaude112 ))
113 )}
114 </div>
115
116 <h3 style="margin-top: 24px; margin-bottom: 12px">
117 Generate new token
118 </h3>
001af43Claude119 <form method="post" action="/settings/tokens">
c81ab7aClaude120 <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"}
140 />{" "}
141 {scope}
142 </label>
143 ))}
144 </div>
145 </div>
146 <button type="submit" class="btn btn-primary">
147 Generate token
148 </button>
149 </form>
0316dbbClaude150 </Container>
c81ab7aClaude151 </Layout>
152 );
153});
154
155// Create token
156tokens.post("/settings/tokens", async (c) => {
157 const user = c.get("user")!;
158 const body = await c.req.parseBody();
159 const name = String(body.name || "").trim();
160
161 let scopes: string;
162 const rawScopes = body.scopes;
163 if (Array.isArray(rawScopes)) {
164 scopes = rawScopes.join(",");
165 } else {
166 scopes = String(rawScopes || "repo");
167 }
168
169 if (!name) {
170 return c.redirect("/settings/tokens?error=Name+is+required");
171 }
172
173 const token = generateToken();
174 const tokenH = await hashToken(token);
175
176 await db.insert(apiTokens).values({
177 userId: user.id,
178 name,
179 tokenHash: tokenH,
180 tokenPrefix: token.slice(0, 12),
181 scopes,
182 });
183
184 return c.redirect(
185 `/settings/tokens?new_token=${encodeURIComponent(token)}`
186 );
187});
188
189// Delete token
190tokens.post("/settings/tokens/:id/delete", async (c) => {
191 const user = c.get("user")!;
192 const tokenId = c.req.param("id");
193
194 const [token] = await db
195 .select()
196 .from(apiTokens)
197 .where(eq(apiTokens.id, tokenId))
198 .limit(1);
199
200 if (!token || token.userId !== user.id) {
201 return c.redirect("/settings/tokens");
202 }
203
204 await db.delete(apiTokens).where(eq(apiTokens.id, tokenId));
205 return c.redirect("/settings/tokens?success=Token+revoked");
206});
207
208// API endpoint
209tokens.get("/api/user/tokens", async (c) => {
210 const user = c.get("user")!;
211 const userTokens = await db
212 .select({
213 id: apiTokens.id,
214 name: apiTokens.name,
215 tokenPrefix: apiTokens.tokenPrefix,
216 scopes: apiTokens.scopes,
217 lastUsedAt: apiTokens.lastUsedAt,
218 createdAt: apiTokens.createdAt,
219 })
220 .from(apiTokens)
221 .where(eq(apiTokens.userId, user.id));
222 return c.json(userTokens);
223});
224
225export default tokens;