Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion taco/.gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/taco
/statesman
/terraform-provider-opentaco
/terraform-provider-opentaco
/token_service
7 changes: 7 additions & 0 deletions taco/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,13 @@ atlas-diff-all: ## Generate migrations for all databases (use: make atlas-diff-a
@echo "\n📊 SQLite..." && atlas migrate diff $(NAME) --env sqlite
@echo "\n✅ All migrations generated successfully!"

atlas-apply-sqlite:
@echo "Applying SQLite migrations..."; \
SQLITE_PATH="$${OPENTACO_SQLITE_DB_PATH:-/app/data/taco.db}"; \
mkdir -p "$$(dirname "$$SQLITE_PATH")"; \
DB_URL="sqlite://$$SQLITE_PATH"; \
atlas migrate apply --url "$$DB_URL" --dir "file://migrations/sqlite"


# Validate and lint all migrations
atlas-lint-all: ## Validate and lint all migration files
Expand Down
67 changes: 67 additions & 0 deletions ui/src/api/tokens.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@

export const getTokens = async (organizationId: string, userId: string) => {
const query = new URLSearchParams({ org_id: organizationId, user_id: userId });
const url = `${process.env.TOKENS_SERVICE_BACKEND_URL}/api/v1/tokens?${query.toString()}`;
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
})
if (!response.ok) {
throw new Error(`Failed to get tokens: ${response.statusText}`);
}
return response.json();
}

export const createToken = async (organizationId: string, userId: string, name: string, expiresAt: string | null ) => {
const response = await fetch(`${process.env.TOKENS_SERVICE_BACKEND_URL}/api/v1/tokens`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
org_id: organizationId,
user_id: userId,
name: name,
expires_in: expiresAt,
}),
})
if (!response.ok) {
throw new Error(`Failed to create token: ${response.statusText}`);
}
return response.json();
}

export const verifyToken = async (token: string) => {
const response = await fetch(`${process.env.TOKENS_SERVICE_BACKEND_URL}/api/v1/tokens/verify`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
token: token,
}),
})
if (!response.ok) {
throw new Error(`Failed to verify token: ${response.statusText}`);
}
return response.json();
}

export const deleteToken = async (organizationId: string, userId: string, tokenId: string) => {
const response = await fetch(`${process.env.TOKENS_SERVICE_BACKEND_URL}/api/v1/tokens/${tokenId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
org_id: organizationId,
user_id: userId,
}),
})
if (!response.ok) {
throw new Error(`Failed to delete token: ${response.statusText}`);
}
return response.json();
}
28 changes: 28 additions & 0 deletions ui/src/api/tokens_serverFunctions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { createServerFn } from "@tanstack/react-start";
import { createToken, getTokens } from "./tokens";
import { verifyToken } from "./tokens";
import { deleteToken } from "./tokens";

export const getTokensFn = createServerFn({method: 'GET'})
.inputValidator((data: {organizationId: string, userId: string}) => data)
.handler(async ({data: {organizationId, userId}}) => {
return getTokens(organizationId, userId);
})

export const createTokenFn = createServerFn({method: 'POST'})
.inputValidator((data: {organizationId: string, userId: string, name: string, expiresAt: string | null}) => data)
.handler(async ({data: {organizationId, userId, name, expiresAt}}) => {
return createToken(organizationId, userId, name, expiresAt);
})

export const verifyTokenFn = createServerFn({method: 'POST'})
.inputValidator((data: { token: string}) => data)
.handler(async ({data: { token}}) => {
return verifyToken( token);
})

export const deleteTokenFn = createServerFn({method: 'POST'})
.inputValidator((data: {organizationId: string, userId: string, tokenId: string}) => data)
.handler(async ({data: {organizationId, userId, tokenId}}) => {
return deleteToken(organizationId, userId, tokenId);
})
172 changes: 150 additions & 22 deletions ui/src/routes/_authenticated/_dashboard/dashboard/settings.tokens.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,100 @@ import { createFileRoute } from '@tanstack/react-router'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { useState } from 'react'
import { createTokenFn, deleteTokenFn, getTokensFn } from '@/api/tokens_serverFunctions'
import { useToast } from '@/hooks/use-toast'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'

export const Route = createFileRoute(
'/_authenticated/_dashboard/dashboard/settings/tokens',
)({
component: RouteComponent,
loader: async ({ context }) => {
const { user, organisationId } = context;
const tokens = await getTokensFn({data: {organizationId: organisationId, userId: user?.id || ''}})
return { tokens, user, organisationId }
}
})

function RouteComponent() {
const [tokens, setTokens] = useState<string[]>([])
const { tokens, user, organisationId } = Route.useLoaderData()
const [tokenList, setTokenList] = useState<typeof tokens>(tokens)
const [newToken, setNewToken] = useState('')
const [open, setOpen] = useState(false)
const [nickname, setNickname] = useState('')
const [expiry, setExpiry] = useState<'1_week' | '30_days' | 'no_expiry'>('1_week')
const [submitting, setSubmitting] = useState(false)
const { toast } = useToast()
const computeExpiry = (value: '1_week' | '30_days' | 'no_expiry'): string | null => {
console.log('value', value)
if (value === 'no_expiry') return null
if (value === '1_week') return `${7*24}h`
if (value === '30_days') return `${30*24}h`
return `${7*24}h`
}

function formatDateString(value?: string | null) {
if (!value) return '—'
const d = new Date(value)
if (isNaN(d.getTime())) return String(value)
return d.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}

function isTokenExpired(token: any) {
if (token?.status && token.status !== 'active') return true
if (token?.expires_at) {
const exp = new Date(token.expires_at)
if (!isNaN(exp.getTime()) && exp.getTime() < Date.now()) return true
}
return false
}

const generateToken = () => {
// This is a placeholder - implement actual token generation logic
const token = `digger_${Math.random().toString(36).substring(2)}`
setTokens([...tokens, token])
setNewToken(token)
const onConfirmGenerate = async () => {
setSubmitting(true)
try {
const expiresAt = computeExpiry(expiry)
const created = await createTokenFn({data: {organizationId: organisationId, userId: user?.id || '', name: nickname || 'New Token', expiresAt}})
if (created && created.token) {
setNewToken(created.token)
}
setOpen(false)
setNickname('')
setExpiry('no_expiry')
const newTokenList = await getTokensFn({data: {organizationId: organisationId, userId: user?.id || ''}})
setTokenList(newTokenList)
} finally {
setSubmitting(false)
}
}

const handleRevokeToken = async (tokenId: string) => {
deleteTokenFn({data: {organizationId: organisationId, userId: user?.id || '', tokenId: tokenId}}).then(() => {
toast({
title: 'Token revoked',
description: 'The token has been revoked',
})
}).catch((error) => {
toast({
title: 'Failed to revoke token',
description: error.message,
variant: 'destructive',
})
}).finally(async () => {
setSubmitting(false)
const newTokenList = await getTokensFn({data: {organizationId: organisationId, userId: user?.id || ''}})
setTokenList(newTokenList)
})
}
return (
<Card>
<CardHeader>
Expand All @@ -31,7 +106,40 @@ function RouteComponent() {
</CardHeader>
<CardContent className="space-y-4">
<div className="flex space-x-4">
<Button onClick={generateToken}>Generate New Token</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>Generate New Token</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Generate API Token</DialogTitle>
<DialogDescription>Provide a nickname and choose an expiry.</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="nickname">Nickname</Label>
<Input id="nickname" placeholder="e.g. CI token" value={nickname} onChange={(e) => setNickname(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Expiry</Label>
<Select value={expiry} onValueChange={(v) => setExpiry(v as typeof expiry)}>
<SelectTrigger>
<SelectValue placeholder="Select expiry" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1_week">1 week</SelectItem>
<SelectItem value="30_days">30 days</SelectItem>
<SelectItem value="no_expiry">No expiry</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)} disabled={submitting}>Cancel</Button>
<Button onClick={onConfirmGenerate} disabled={submitting || (!nickname && expiry === 'no_expiry')}>{submitting ? 'Generating...' : 'Generate'}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
{newToken && (
<div className="space-y-2">
Expand All @@ -46,23 +154,43 @@ function RouteComponent() {
)}
<div className="space-y-2">
<h4 className="text-sm font-medium">Your Tokens</h4>
{tokens.length === 0 ? (
{tokenList.length === 0 ? (
<p className="text-sm text-muted-foreground">No tokens generated yet</p>
) : (
<div className="space-y-2">
{tokens.map((token, index) => (
<div key={index} className="flex items-center justify-between">
<code className="text-sm">•••••••••••{token.slice(-4)}</code>
<Button
variant="destructive"
size="sm"
onClick={() => setTokens(tokens.filter((_, i) => i !== index))}
>
Revoke
</Button>
</div>
))}
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead className="text-left">Name</TableHead>
<TableHead className="text-left">Token</TableHead>
<TableHead className="text-left">Expires</TableHead>
<TableHead className="text-left">Created</TableHead>
<TableHead className="text-left">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{tokenList.map((token, index) => (
<TableRow key={index}>
<TableCell className="font-medium">{token.name}</TableCell>
<TableCell>•••••••••••{token.token.slice(-4)}</TableCell>
<TableCell>
{isTokenExpired(token)
? <span className="text-destructive">This token has expired</span>
: (token.expires_at ? formatDateString(token.expires_at) : 'No expiry')}
</TableCell>
<TableCell>{formatDateString(token.created_at)}</TableCell>
<TableCell>
<Button
variant="destructive"
size="sm"
onClick={() => handleRevokeToken(token.id)}
>
Revoke
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</CardContent>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export const Route = createFileRoute(
beforeLoad: ({ location, search }) => {
if (location.pathname === '/dashboard/settings') {
throw redirect({
to: '.',
to: '/dashboard/settings/user',
search
})
}
Expand Down
13 changes: 13 additions & 0 deletions ui/src/routes/tfe/$.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,21 @@
import { verifyTokenFn } from '@/api/tokens_serverFunctions';
import { createFileRoute } from '@tanstack/react-router'

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

try {
const token = request.headers.get('authorization')?.split(' ')[1]
const tokenValidation = await verifyTokenFn({data: { token: token}})
if (!tokenValidation.valid) {
return new Response('Unauthorized', { status: 401 })
}
} catch (error) {
console.error('Error verifying token', error)
return new Response('Unauthorized', { status: 401 })
}


// important: we need to set these to allow the statesman backend to return the correct URL to opentofu or terraform clients
const outgoingHeaders = new Headers(request.headers);
const originalHost = outgoingHeaders.get('host') ?? '';
Expand Down
Loading