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.tsxBlame226 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`}
3e8f8e8Claude106 method="POST"
c81ab7aClaude107 >
3e8f8e8Claude108 <Button type="submit" variant="danger" size="sm">
c81ab7aClaude109 Revoke
3e8f8e8Claude110 </Button>
111 </Form>
112 </ListItem>
c81ab7aClaude113 ))
114 )}
115 </div>
116
117 <h3 style="margin-top: 24px; margin-bottom: 12px">
118 Generate new token
119 </h3>
001af43Claude120 <form method="post" action="/settings/tokens">
c81ab7aClaude121 <div class="form-group">
122 <label for="name">Token name</label>
123 <input
124 type="text"
125 id="name"
126 name="name"
127 required
128 placeholder="e.g. CI/CD pipeline"
129 />
130 </div>
131 <div class="form-group">
132 <label>Scopes</label>
133 <div style="display: flex; gap: 16px; flex-wrap: wrap">
134 {["repo", "user", "admin"].map((scope) => (
135 <label style="display: flex; align-items: center; gap: 4px; font-size: 14px; cursor: pointer">
136 <input
137 type="checkbox"
138 name="scopes"
139 value={scope}
140 checked={scope === "repo"}
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>
151 </div>
152 </Layout>
153 );
154});
155
156// Create token
157tokens.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
191tokens.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
210tokens.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
226export default tokens;