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.tsxBlame221 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>
3e8f8e8Claude103 <Form
c81ab7aClaude104 action={`/settings/tokens/${token.id}/delete`}
3e8f8e8Claude105 method="POST"
c81ab7aClaude106 >
3e8f8e8Claude107 <Button type="submit" variant="danger" size="sm">
c81ab7aClaude108 Revoke
3e8f8e8Claude109 </Button>
110 </Form>
111 </ListItem>
c81ab7aClaude112 ))
113 )}
114 </div>
115
3e8f8e8Claude116 <Section title="Generate new token" style="margin-top: 24px">
117 <Form action="/settings/tokens" method="POST">
118 <FormGroup label="Token name" htmlFor="name">
119 <Input
120 name="name"
121 id="name"
122 required
123 placeholder="e.g. CI/CD pipeline"
124 />
125 </FormGroup>
126 <FormGroup label="Scopes">
127 <Flex gap={16} wrap>
128 {["repo", "user", "admin"].map((scope) => (
129 <label style="display: flex; align-items: center; gap: 4px; font-size: 14px; cursor: pointer">
130 <input
131 type="checkbox"
132 name="scopes"
133 value={scope}
134 checked={scope === "repo"}
135 />{" "}
136 {scope}
137 </label>
138 ))}
139 </Flex>
140 </FormGroup>
141 <Button type="submit" variant="primary">
142 Generate token
143 </Button>
144 </Form>
145 </Section>
146 </Container>
c81ab7aClaude147 </Layout>
148 );
149});
150
151// Create token
152tokens.post("/settings/tokens", async (c) => {
153 const user = c.get("user")!;
154 const body = await c.req.parseBody();
155 const name = String(body.name || "").trim();
156
157 let scopes: string;
158 const rawScopes = body.scopes;
159 if (Array.isArray(rawScopes)) {
160 scopes = rawScopes.join(",");
161 } else {
162 scopes = String(rawScopes || "repo");
163 }
164
165 if (!name) {
166 return c.redirect("/settings/tokens?error=Name+is+required");
167 }
168
169 const token = generateToken();
170 const tokenH = await hashToken(token);
171
172 await db.insert(apiTokens).values({
173 userId: user.id,
174 name,
175 tokenHash: tokenH,
176 tokenPrefix: token.slice(0, 12),
177 scopes,
178 });
179
180 return c.redirect(
181 `/settings/tokens?new_token=${encodeURIComponent(token)}`
182 );
183});
184
185// Delete token
186tokens.post("/settings/tokens/:id/delete", async (c) => {
187 const user = c.get("user")!;
188 const tokenId = c.req.param("id");
189
190 const [token] = await db
191 .select()
192 .from(apiTokens)
193 .where(eq(apiTokens.id, tokenId))
194 .limit(1);
195
196 if (!token || token.userId !== user.id) {
197 return c.redirect("/settings/tokens");
198 }
199
200 await db.delete(apiTokens).where(eq(apiTokens.id, tokenId));
201 return c.redirect("/settings/tokens?success=Token+revoked");
202});
203
204// API endpoint
205tokens.get("/api/user/tokens", async (c) => {
206 const user = c.get("user")!;
207 const userTokens = await db
208 .select({
209 id: apiTokens.id,
210 name: apiTokens.name,
211 tokenPrefix: apiTokens.tokenPrefix,
212 scopes: apiTokens.scopes,
213 lastUsedAt: apiTokens.lastUsedAt,
214 createdAt: apiTokens.createdAt,
215 })
216 .from(apiTokens)
217 .where(eq(apiTokens.userId, user.id));
218 return c.json(userTokens);
219});
220
221export default tokens;