Files
windmill/frontend/src/lib/components/settings/TokensTable.svelte
T

264 lines
7.3 KiB
Svelte

<script lang="ts">
import { untrack } from 'svelte'
import TableCustom from '$lib/components/TableCustom.svelte'
import { displayDate } from '$lib/utils'
import { UserService, type TruncatedToken } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import CreateToken from './CreateToken.svelte'
import EditTokenScopesModal from './EditTokenScopesModal.svelte'
import Button from '../common/button/Button.svelte'
import Badge from '../common/badge/Badge.svelte'
import Alert from '../common/alert/Alert.svelte'
import { Pen, Trash } from 'lucide-svelte'
// --- Props ---
interface Props {
showMcpMode?: boolean
openWithMcpMode?: boolean
defaultNewTokenLabel?: string
defaultNewTokenWorkspace?: string
scopes?: string[]
onTokenCreated: (token: string) => void
}
let {
showMcpMode = false,
openWithMcpMode = false,
defaultNewTokenLabel,
defaultNewTokenWorkspace,
scopes,
onTokenCreated
}: Props = $props()
// --- Local State ---
let tokens = $state<TruncatedToken[]>([])
let tokenPage = $state(1)
let newTokenLabel = $state<string | undefined>(untrack(() => defaultNewTokenLabel))
let editingToken = $state<
| {
prefix: string
label: string | undefined
scopes: string[] | undefined
workspaceId: string | undefined
}
| undefined
>(undefined)
let editModalOpen = $state(false)
$effect(() => {
listTokens()
})
// Mirror of the canonical `is_user_token` in backend/windmill-common/src/auth.rs.
// When updating this filter, also update that function and the SQL `WHERE`
// mirror in `update_token_label` (backend/windmill-api-users/src/users.rs).
function isUserToken(label: string | undefined): boolean {
if (!label) return true
return (
label !== 'session' &&
label !== 'guest_session' &&
!label.toLowerCase().startsWith('ephemeral') &&
label !== 'debugger-token' &&
!label.startsWith('mcp-oauth-')
)
}
function daysUntilExpiration(expiration: string | undefined): number | null {
if (!expiration) return null
const today = new Date()
today.setHours(0, 0, 0, 0)
const exp = new Date(expiration)
exp.setHours(0, 0, 0, 0)
return Math.round((exp.getTime() - today.getTime()) / 86400000)
}
function expirationBadge(
expiration: string | undefined,
label: string | undefined
): {
color: 'red' | 'orange' | 'yellow' | 'gray'
text: string
} | null {
if (!isUserToken(label)) return null
const days = daysUntilExpiration(expiration)
if (days === null) return null
if (days < 0) return { color: 'red', text: 'Expired' }
if (days === 0) return { color: 'red', text: 'Expires today' }
if (days === 1) return { color: 'orange', text: 'Expires tomorrow' }
if (days <= 7) return { color: 'orange', text: `Expires in ${days}d` }
if (days <= 30) return { color: 'yellow', text: `Expires in ${days}d` }
return null
}
let expiringSoonCount = $derived(
tokens.filter((t) => {
if (!isUserToken(t.label)) return false
const days = daysUntilExpiration(t.expiration)
return days !== null && days >= 0 && days <= 7
}).length
)
function handleTokenCreated(token: string) {
onTokenCreated(token)
listTokens()
}
async function handleDeleteClick(tokenPrefix: string) {
await UserService.deleteToken({ tokenPrefix })
sendUserToast('Successfully deleted token')
listTokens()
}
function handleEditClick(
tokenPrefix: string,
tokenLabel: string | undefined,
tokenScopes: string[] | undefined,
tokenWorkspaceId: string | undefined
) {
editingToken = {
prefix: tokenPrefix,
label: tokenLabel,
scopes: tokenScopes,
workspaceId: tokenWorkspaceId
}
editModalOpen = true
}
async function listTokens(): Promise<void> {
tokens = await UserService.listTokens({
excludeEphemeral: true,
page: tokenPage,
perPage: 100
})
}
function handleNextPage() {
tokenPage += 1
listTokens()
}
function handlePreviousPage() {
tokenPage -= 1
listTokens()
}
</script>
<div class="flex flex-col p-4 border border-border-light rounded-md">
<h2 class="text-emphasis text-sm font-semibold mb-1">Tokens</h2>
<div class="text-xs text-secondary mb-2">
Authenticate to the Windmill API with access tokens.
</div>
{#if expiringSoonCount > 0}
<div class="mb-2">
<Alert
type="warning"
title="{expiringSoonCount} token{expiringSoonCount > 1 ? 's' : ''} expiring within 7 days"
size="xs"
/>
</div>
{/if}
<CreateToken
{showMcpMode}
{openWithMcpMode}
bind:newTokenLabel
{defaultNewTokenWorkspace}
{scopes}
onTokenCreated={handleTokenCreated}
/>
<div class="overflow-auto grow min-h-64 max-h-2/3">
<TableCustom>
{#snippet headerRow()}
<tr>
<th>Prefix</th>
<th>Label</th>
<th>Expiration</th>
<th>Scopes</th>
<th></th>
</tr>
{/snippet}
{#snippet body()}
<tbody>
{#if tokens && tokens.length > 0}
{#each tokens as { token_prefix, expiration, label, scopes, workspace_id, read_only } (token_prefix)}
{@const badge = expirationBadge(expiration, label)}
<tr>
<td class="w-32 text-xs text-primary">{token_prefix}****</td>
<td class="min-w-0 max-w-32 truncate text-xs text-primary">{label ?? ''}</td>
<td class="w-40 whitespace-nowrap text-xs text-secondary">
<div class="flex items-center gap-1.5">
{displayDate(expiration ?? '')}
{#if badge}
<Badge color={badge.color} small>{badge.text}</Badge>
{/if}
</div>
</td>
<td
class="min-w-0 max-w-48 truncate text-xs text-secondary"
title={scopes?.join(', ') ?? ''}
>
<div class="flex items-center gap-1.5 truncate">
{#if read_only}
<Badge color="blue" small>Read-only</Badge>
{/if}
<span class="truncate">{scopes?.join(', ') ?? ''}</span>
</div>
</td>
<td class="w-24 text-center">
<div class="flex items-center justify-center gap-1">
<Button
variant="subtle"
title="Edit token"
on:click={() =>
handleEditClick(
token_prefix,
label ?? undefined,
scopes ?? undefined,
workspace_id ?? undefined
)}
size="xs"
startIcon={{ icon: Pen }}
iconOnly
/>
<Button
variant="subtle"
destructive
on:click={() => handleDeleteClick(token_prefix)}
size="xs"
startIcon={{ icon: Trash }}
iconOnly
/>
</div>
</td>
</tr>
{/each}
{:else if tokens && tokens.length === 0}
<tr class="px-6">
<td class="text-secondary italic text-2xs"> There are no tokens yet</td>
</tr>
{:else}
<tr><td class="text-secondary text-xs">Loading...</td></tr>
{/if}
</tbody>
{/snippet}
</TableCustom>
<div class="flex flex-row-reverse gap-2 w-full mt-2">
{#if tokens?.length == 100}
<Button variant="subtle" size="xs" on:click={handleNextPage}>Next</Button>
{/if}
{#if tokenPage > 1}
<Button variant="subtle" size="xs" on:click={handlePreviousPage}>Previous</Button>
{/if}
</div>
</div>
</div>
<EditTokenScopesModal
bind:open={editModalOpen}
tokenPrefix={editingToken?.prefix}
initialLabel={editingToken?.label}
labelEditable={isUserToken(editingToken?.label)}
initialScopes={editingToken?.scopes}
tokenWorkspaceId={editingToken?.workspaceId}
onSaved={listTokens}
/>