Skip to content

Commit 35a6d3a

Browse files
authored
token service ui functionality (#2356)
1 parent 84c753e commit 35a6d3a

File tree

7 files changed

+268
-24
lines changed

7 files changed

+268
-24
lines changed

taco/.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
/taco
22
/statesman
3-
/terraform-provider-opentaco
3+
/terraform-provider-opentaco
4+
/token_service

taco/Makefile

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,13 @@ atlas-diff-all: ## Generate migrations for all databases (use: make atlas-diff-a
160160
@echo "\n📊 SQLite..." && atlas migrate diff $(NAME) --env sqlite
161161
@echo "\n✅ All migrations generated successfully!"
162162

163+
atlas-apply-sqlite:
164+
@echo "Applying SQLite migrations..."; \
165+
SQLITE_PATH="$${OPENTACO_SQLITE_DB_PATH:-/app/data/taco.db}"; \
166+
mkdir -p "$$(dirname "$$SQLITE_PATH")"; \
167+
DB_URL="sqlite://$$SQLITE_PATH"; \
168+
atlas migrate apply --url "$$DB_URL" --dir "file://migrations/sqlite"
169+
163170

164171
# Validate and lint all migrations
165172
atlas-lint-all: ## Validate and lint all migration files

ui/src/api/tokens.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
2+
export const getTokens = async (organizationId: string, userId: string) => {
3+
const query = new URLSearchParams({ org_id: organizationId, user_id: userId });
4+
const url = `${process.env.TOKENS_SERVICE_BACKEND_URL}/api/v1/tokens?${query.toString()}`;
5+
const response = await fetch(url, {
6+
method: 'GET',
7+
headers: {
8+
'Content-Type': 'application/json',
9+
},
10+
})
11+
if (!response.ok) {
12+
throw new Error(`Failed to get tokens: ${response.statusText}`);
13+
}
14+
return response.json();
15+
}
16+
17+
export const createToken = async (organizationId: string, userId: string, name: string, expiresAt: string | null ) => {
18+
const response = await fetch(`${process.env.TOKENS_SERVICE_BACKEND_URL}/api/v1/tokens`, {
19+
method: 'POST',
20+
headers: {
21+
'Content-Type': 'application/json',
22+
},
23+
body: JSON.stringify({
24+
org_id: organizationId,
25+
user_id: userId,
26+
name: name,
27+
expires_in: expiresAt,
28+
}),
29+
})
30+
if (!response.ok) {
31+
throw new Error(`Failed to create token: ${response.statusText}`);
32+
}
33+
return response.json();
34+
}
35+
36+
export const verifyToken = async (token: string) => {
37+
const response = await fetch(`${process.env.TOKENS_SERVICE_BACKEND_URL}/api/v1/tokens/verify`, {
38+
method: 'POST',
39+
headers: {
40+
'Content-Type': 'application/json',
41+
},
42+
body: JSON.stringify({
43+
token: token,
44+
}),
45+
})
46+
if (!response.ok) {
47+
throw new Error(`Failed to verify token: ${response.statusText}`);
48+
}
49+
return response.json();
50+
}
51+
52+
export const deleteToken = async (organizationId: string, userId: string, tokenId: string) => {
53+
const response = await fetch(`${process.env.TOKENS_SERVICE_BACKEND_URL}/api/v1/tokens/${tokenId}`, {
54+
method: 'DELETE',
55+
headers: {
56+
'Content-Type': 'application/json',
57+
},
58+
body: JSON.stringify({
59+
org_id: organizationId,
60+
user_id: userId,
61+
}),
62+
})
63+
if (!response.ok) {
64+
throw new Error(`Failed to delete token: ${response.statusText}`);
65+
}
66+
return response.json();
67+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { createServerFn } from "@tanstack/react-start";
2+
import { createToken, getTokens } from "./tokens";
3+
import { verifyToken } from "./tokens";
4+
import { deleteToken } from "./tokens";
5+
6+
export const getTokensFn = createServerFn({method: 'GET'})
7+
.inputValidator((data: {organizationId: string, userId: string}) => data)
8+
.handler(async ({data: {organizationId, userId}}) => {
9+
return getTokens(organizationId, userId);
10+
})
11+
12+
export const createTokenFn = createServerFn({method: 'POST'})
13+
.inputValidator((data: {organizationId: string, userId: string, name: string, expiresAt: string | null}) => data)
14+
.handler(async ({data: {organizationId, userId, name, expiresAt}}) => {
15+
return createToken(organizationId, userId, name, expiresAt);
16+
})
17+
18+
export const verifyTokenFn = createServerFn({method: 'POST'})
19+
.inputValidator((data: { token: string}) => data)
20+
.handler(async ({data: { token}}) => {
21+
return verifyToken( token);
22+
})
23+
24+
export const deleteTokenFn = createServerFn({method: 'POST'})
25+
.inputValidator((data: {organizationId: string, userId: string, tokenId: string}) => data)
26+
.handler(async ({data: {organizationId, userId, tokenId}}) => {
27+
return deleteToken(organizationId, userId, tokenId);
28+
})

ui/src/routes/_authenticated/_dashboard/dashboard/settings.tokens.tsx

Lines changed: 150 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,100 @@ import { createFileRoute } from '@tanstack/react-router'
22
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
33
import { Button } from '@/components/ui/button'
44
import { Input } from '@/components/ui/input'
5+
import { Label } from '@/components/ui/label'
6+
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
7+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
58
import { useState } from 'react'
9+
import { createTokenFn, deleteTokenFn, getTokensFn } from '@/api/tokens_serverFunctions'
10+
import { useToast } from '@/hooks/use-toast'
11+
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
612

713
export const Route = createFileRoute(
814
'/_authenticated/_dashboard/dashboard/settings/tokens',
915
)({
1016
component: RouteComponent,
17+
loader: async ({ context }) => {
18+
const { user, organisationId } = context;
19+
const tokens = await getTokensFn({data: {organizationId: organisationId, userId: user?.id || ''}})
20+
return { tokens, user, organisationId }
21+
}
1122
})
1223

1324
function RouteComponent() {
14-
const [tokens, setTokens] = useState<string[]>([])
25+
const { tokens, user, organisationId } = Route.useLoaderData()
26+
const [tokenList, setTokenList] = useState<typeof tokens>(tokens)
1527
const [newToken, setNewToken] = useState('')
28+
const [open, setOpen] = useState(false)
29+
const [nickname, setNickname] = useState('')
30+
const [expiry, setExpiry] = useState<'1_week' | '30_days' | 'no_expiry'>('1_week')
31+
const [submitting, setSubmitting] = useState(false)
32+
const { toast } = useToast()
33+
const computeExpiry = (value: '1_week' | '30_days' | 'no_expiry'): string | null => {
34+
console.log('value', value)
35+
if (value === 'no_expiry') return null
36+
if (value === '1_week') return `${7*24}h`
37+
if (value === '30_days') return `${30*24}h`
38+
return `${7*24}h`
39+
}
40+
41+
function formatDateString(value?: string | null) {
42+
if (!value) return '—'
43+
const d = new Date(value)
44+
if (isNaN(d.getTime())) return String(value)
45+
return d.toLocaleDateString('en-US', {
46+
year: 'numeric',
47+
month: 'short',
48+
day: 'numeric',
49+
hour: '2-digit',
50+
minute: '2-digit'
51+
})
52+
}
53+
54+
function isTokenExpired(token: any) {
55+
if (token?.status && token.status !== 'active') return true
56+
if (token?.expires_at) {
57+
const exp = new Date(token.expires_at)
58+
if (!isNaN(exp.getTime()) && exp.getTime() < Date.now()) return true
59+
}
60+
return false
61+
}
1662

17-
const generateToken = () => {
18-
// This is a placeholder - implement actual token generation logic
19-
const token = `digger_${Math.random().toString(36).substring(2)}`
20-
setTokens([...tokens, token])
21-
setNewToken(token)
63+
const onConfirmGenerate = async () => {
64+
setSubmitting(true)
65+
try {
66+
const expiresAt = computeExpiry(expiry)
67+
const created = await createTokenFn({data: {organizationId: organisationId, userId: user?.id || '', name: nickname || 'New Token', expiresAt}})
68+
if (created && created.token) {
69+
setNewToken(created.token)
70+
}
71+
setOpen(false)
72+
setNickname('')
73+
setExpiry('no_expiry')
74+
const newTokenList = await getTokensFn({data: {organizationId: organisationId, userId: user?.id || ''}})
75+
setTokenList(newTokenList)
76+
} finally {
77+
setSubmitting(false)
78+
}
2279
}
2380

81+
const handleRevokeToken = async (tokenId: string) => {
82+
deleteTokenFn({data: {organizationId: organisationId, userId: user?.id || '', tokenId: tokenId}}).then(() => {
83+
toast({
84+
title: 'Token revoked',
85+
description: 'The token has been revoked',
86+
})
87+
}).catch((error) => {
88+
toast({
89+
title: 'Failed to revoke token',
90+
description: error.message,
91+
variant: 'destructive',
92+
})
93+
}).finally(async () => {
94+
setSubmitting(false)
95+
const newTokenList = await getTokensFn({data: {organizationId: organisationId, userId: user?.id || ''}})
96+
setTokenList(newTokenList)
97+
})
98+
}
2499
return (
25100
<Card>
26101
<CardHeader>
@@ -31,7 +106,40 @@ function RouteComponent() {
31106
</CardHeader>
32107
<CardContent className="space-y-4">
33108
<div className="flex space-x-4">
34-
<Button onClick={generateToken}>Generate New Token</Button>
109+
<Dialog open={open} onOpenChange={setOpen}>
110+
<DialogTrigger asChild>
111+
<Button>Generate New Token</Button>
112+
</DialogTrigger>
113+
<DialogContent>
114+
<DialogHeader>
115+
<DialogTitle>Generate API Token</DialogTitle>
116+
<DialogDescription>Provide a nickname and choose an expiry.</DialogDescription>
117+
</DialogHeader>
118+
<div className="space-y-4">
119+
<div className="space-y-2">
120+
<Label htmlFor="nickname">Nickname</Label>
121+
<Input id="nickname" placeholder="e.g. CI token" value={nickname} onChange={(e) => setNickname(e.target.value)} />
122+
</div>
123+
<div className="space-y-2">
124+
<Label>Expiry</Label>
125+
<Select value={expiry} onValueChange={(v) => setExpiry(v as typeof expiry)}>
126+
<SelectTrigger>
127+
<SelectValue placeholder="Select expiry" />
128+
</SelectTrigger>
129+
<SelectContent>
130+
<SelectItem value="1_week">1 week</SelectItem>
131+
<SelectItem value="30_days">30 days</SelectItem>
132+
<SelectItem value="no_expiry">No expiry</SelectItem>
133+
</SelectContent>
134+
</Select>
135+
</div>
136+
</div>
137+
<DialogFooter>
138+
<Button variant="outline" onClick={() => setOpen(false)} disabled={submitting}>Cancel</Button>
139+
<Button onClick={onConfirmGenerate} disabled={submitting || (!nickname && expiry === 'no_expiry')}>{submitting ? 'Generating...' : 'Generate'}</Button>
140+
</DialogFooter>
141+
</DialogContent>
142+
</Dialog>
35143
</div>
36144
{newToken && (
37145
<div className="space-y-2">
@@ -46,23 +154,43 @@ function RouteComponent() {
46154
)}
47155
<div className="space-y-2">
48156
<h4 className="text-sm font-medium">Your Tokens</h4>
49-
{tokens.length === 0 ? (
157+
{tokenList.length === 0 ? (
50158
<p className="text-sm text-muted-foreground">No tokens generated yet</p>
51159
) : (
52-
<div className="space-y-2">
53-
{tokens.map((token, index) => (
54-
<div key={index} className="flex items-center justify-between">
55-
<code className="text-sm">•••••••••••{token.slice(-4)}</code>
56-
<Button
57-
variant="destructive"
58-
size="sm"
59-
onClick={() => setTokens(tokens.filter((_, i) => i !== index))}
60-
>
61-
Revoke
62-
</Button>
63-
</div>
64-
))}
65-
</div>
160+
<Table>
161+
<TableHeader>
162+
<TableRow>
163+
<TableHead className="text-left">Name</TableHead>
164+
<TableHead className="text-left">Token</TableHead>
165+
<TableHead className="text-left">Expires</TableHead>
166+
<TableHead className="text-left">Created</TableHead>
167+
<TableHead className="text-left">Actions</TableHead>
168+
</TableRow>
169+
</TableHeader>
170+
<TableBody>
171+
{tokenList.map((token, index) => (
172+
<TableRow key={index}>
173+
<TableCell className="font-medium">{token.name}</TableCell>
174+
<TableCell>•••••••••••{token.token.slice(-4)}</TableCell>
175+
<TableCell>
176+
{isTokenExpired(token)
177+
? <span className="text-destructive">This token has expired</span>
178+
: (token.expires_at ? formatDateString(token.expires_at) : 'No expiry')}
179+
</TableCell>
180+
<TableCell>{formatDateString(token.created_at)}</TableCell>
181+
<TableCell>
182+
<Button
183+
variant="destructive"
184+
size="sm"
185+
onClick={() => handleRevokeToken(token.id)}
186+
>
187+
Revoke
188+
</Button>
189+
</TableCell>
190+
</TableRow>
191+
))}
192+
</TableBody>
193+
</Table>
66194
)}
67195
</div>
68196
</CardContent>

ui/src/routes/_authenticated/_dashboard/dashboard/settings.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export const Route = createFileRoute(
1212
beforeLoad: (({ location, search }) => {
1313
if (location.pathname === '/dashboard/settings') {
1414
throw redirect({
15-
to: '.',
15+
to: '/dashboard/settings/user',
1616
search
1717
})
1818
}

ui/src/routes/tfe/$.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,21 @@
1+
import { verifyTokenFn } from '@/api/tokens_serverFunctions';
12
import { createFileRoute } from '@tanstack/react-router'
23

34
async function handler({ request }) {
45
const url = new URL(request.url);
56

7+
try {
8+
const token = request.headers.get('authorization')?.split(' ')[1]
9+
const tokenValidation = await verifyTokenFn({data: { token: token}})
10+
if (!tokenValidation.valid) {
11+
return new Response('Unauthorized', { status: 401 })
12+
}
13+
} catch (error) {
14+
console.error('Error verifying token', error)
15+
return new Response('Unauthorized', { status: 401 })
16+
}
17+
18+
619
// important: we need to set these to allow the statesman backend to return the correct URL to opentofu or terraform clients
720
const outgoingHeaders = new Headers(request.headers);
821
const originalHost = outgoingHeaders.get('host') ?? '';

0 commit comments

Comments
 (0)