diff --git a/taco/.gitignore b/taco/.gitignore index d9916f972..43fce1143 100644 --- a/taco/.gitignore +++ b/taco/.gitignore @@ -1,3 +1,4 @@ /taco /statesman -/terraform-provider-opentaco \ No newline at end of file +/terraform-provider-opentaco +/token_service diff --git a/taco/Makefile b/taco/Makefile index 8660e9119..2570b9265 100644 --- a/taco/Makefile +++ b/taco/Makefile @@ -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 diff --git a/ui/src/api/tokens.ts b/ui/src/api/tokens.ts new file mode 100644 index 000000000..5a35c4ac6 --- /dev/null +++ b/ui/src/api/tokens.ts @@ -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(); +} diff --git a/ui/src/api/tokens_serverFunctions.ts b/ui/src/api/tokens_serverFunctions.ts new file mode 100644 index 000000000..8c19db1b7 --- /dev/null +++ b/ui/src/api/tokens_serverFunctions.ts @@ -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); +}) \ No newline at end of file diff --git a/ui/src/routes/_authenticated/_dashboard/dashboard/settings.tokens.tsx b/ui/src/routes/_authenticated/_dashboard/dashboard/settings.tokens.tsx index 742d192a1..8854d1659 100644 --- a/ui/src/routes/_authenticated/_dashboard/dashboard/settings.tokens.tsx +++ b/ui/src/routes/_authenticated/_dashboard/dashboard/settings.tokens.tsx @@ -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([]) + const { tokens, user, organisationId } = Route.useLoaderData() + const [tokenList, setTokenList] = useState(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 ( @@ -31,7 +106,40 @@ function RouteComponent() {
- + + + + + + + Generate API Token + Provide a nickname and choose an expiry. + +
+
+ + setNickname(e.target.value)} /> +
+
+ + +
+
+ + + + +
+
{newToken && (
@@ -46,23 +154,43 @@ function RouteComponent() { )}

Your Tokens

- {tokens.length === 0 ? ( + {tokenList.length === 0 ? (

No tokens generated yet

) : ( -
- {tokens.map((token, index) => ( -
- •••••••••••{token.slice(-4)} - -
- ))} -
+ + + + Name + Token + Expires + Created + Actions + + + + {tokenList.map((token, index) => ( + + {token.name} + •••••••••••{token.token.slice(-4)} + + {isTokenExpired(token) + ? This token has expired + : (token.expires_at ? formatDateString(token.expires_at) : 'No expiry')} + + {formatDateString(token.created_at)} + + + + + ))} + +
)}
diff --git a/ui/src/routes/_authenticated/_dashboard/dashboard/settings.tsx b/ui/src/routes/_authenticated/_dashboard/dashboard/settings.tsx index 4bc6081cd..6fa352549 100644 --- a/ui/src/routes/_authenticated/_dashboard/dashboard/settings.tsx +++ b/ui/src/routes/_authenticated/_dashboard/dashboard/settings.tsx @@ -12,7 +12,7 @@ export const Route = createFileRoute( beforeLoad: ({ location, search }) => { if (location.pathname === '/dashboard/settings') { throw redirect({ - to: '.', + to: '/dashboard/settings/user', search }) } diff --git a/ui/src/routes/tfe/$.tsx b/ui/src/routes/tfe/$.tsx index c541554fe..54698a34f 100644 --- a/ui/src/routes/tfe/$.tsx +++ b/ui/src/routes/tfe/$.tsx @@ -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') ?? '';