diff --git a/frontend-modern/src/components/Settings/APITokenManager.tsx b/frontend-modern/src/components/Settings/APITokenManager.tsx index 439c411..cf1f213 100644 --- a/frontend-modern/src/components/Settings/APITokenManager.tsx +++ b/frontend-modern/src/components/Settings/APITokenManager.tsx @@ -1,11 +1,13 @@ import { Component, For, Show, createMemo, createSignal, onCleanup, onMount } from 'solid-js'; -import { Card } from '@/components/shared/Card'; import { SecurityAPI, type APITokenRecord } from '@/api/security'; import { showError, showSuccess } from '@/utils/toast'; import { formatRelativeTime } from '@/utils/format'; import { useWebSocket } from '@/App'; import type { DockerHost } from '@/types/api'; import { showTokenReveal, useTokenRevealState } from '@/stores/tokenReveal'; +import { Card } from '@/components/shared/Card'; +import { SectionHeader } from '@/components/shared/SectionHeader'; +import { ApiIcon } from '@/components/icons/ApiIcon'; import { API_SCOPE_LABELS, API_SCOPE_OPTIONS, @@ -63,12 +65,6 @@ export const APITokenManager: Component = (props) => { ); const scopedTokenCount = createMemo(() => totalTokens() - wildcardCount()); const hasWildcardTokens = createMemo(() => wildcardCount() > 0); - const mostRecentLabel = createMemo(() => { - const first = sortedTokens()[0]; - if (!first) return '—'; - const timestamp = new Date(first.createdAt).getTime(); - return Number.isFinite(timestamp) ? formatRelativeTime(timestamp) : '—'; - }); const [loading, setLoading] = createSignal(true); const [isGenerating, setIsGenerating] = createSignal(false); @@ -140,22 +136,6 @@ export const APITokenManager: Component = (props) => { return target.every((scope) => selection.includes(scope)); }; - const presetButtonBase = - 'flex w-full items-start justify-between gap-3 rounded-md border px-3 py-2 text-left text-sm transition-colors'; - const presetButtonActive = - 'border-blue-400 ring-1 ring-blue-300 bg-blue-50/70 dark:border-blue-500 dark:ring-blue-400/40 dark:bg-blue-900/20'; - const presetButtonInactive = - 'border-gray-300 bg-white hover:border-blue-400 dark:border-gray-600 dark:bg-gray-900/60 dark:hover:border-blue-500'; - const selectedScopeChips = createMemo(() => - selectedScopes() - .filter((scope) => scope !== WILDCARD_SCOPE) - .map((scope) => ({ - value: scope, - label: API_SCOPE_LABELS[scope] ?? scope, - })) - .sort((a, b) => a.label.localeCompare(b.label)), - ); - const [advancedScopesOpen, setAdvancedScopesOpen] = createSignal(false); const applyScopePreset = (scopes: string[]) => { const unique = Array.from(new Set(scopes)).filter(Boolean); @@ -302,530 +282,423 @@ export const APITokenManager: Component = (props) => { }; return ( -
-
-
-

API tokens

-

- Authenticate host agents, Docker integrations, and automation pipelines with scoped access. -

+
+ +
+
+
+
+ +
+ +
+ +
+ +
+
+
+ Total tokens +
+
+ {totalTokens()} +
+

+ Stored credentials across all agents +

+
+
+
+ Scoped tokens +
+
+ {scopedTokenCount()} +
+

+ Limited access tokens with defined scopes +

+
+
+
+ Full access tokens +
+
+ {wildcardCount()} +
+

+ {hasWildcardTokens() + ? 'Legacy wildcard tokens – rotate into scoped presets when possible.' + : 'All tokens scoped – no wildcard credentials detected.'} +

+
+
- -
+ -
+ Refreshing security status… -
+
-
- -
-

Active tokens

-

- Rotate tokens regularly and scope them to the minimum access required. -

-
- -
-
-

- Active tokens -

-

{totalTokens()}

-
-
-

- Scoped tokens -

-

{scopedTokenCount()}

-
-
-

- {hasWildcardTokens() ? 'Full access tokens' : 'Last generated'} -

-

- {hasWildcardTokens() - ? wildcardCount() - : totalTokens() > 0 - ? mostRecentLabel() - : '—'} -

-
-
- - 0} - fallback={ -
-

- No tokens yet. Generate one to authenticate agents and automation. -

- -
- } - > -
- - - - - - - - - - - - - - {(token) => { - const usage = dockerTokenUsage().get(token.id); - const hostTitle = usage ? usage.hosts.join(', ') : undefined; - const hostPreview = usage ? usage.hosts.slice(0, 2).join(', ') : ''; - const extraCount = usage ? usage.hosts.length - 2 : 0; - const hostSummary = - usage && usage.count === 1 - ? usage.hosts[0] - : usage - ? `${hostPreview}${extraCount > 0 ? `, +${extraCount} more` : ''}` - : ''; - const hostCountLabel = - usage && usage.count === 1 ? 'host' : usage ? 'hosts' : ''; - const rawScopes = token.scopes && token.scopes.length > 0 ? token.scopes : ['*']; - const scopeBadges = rawScopes.includes('*') - ? [{ value: '*', label: 'Full access' }] - : rawScopes.map((scope) => ({ - value: scope, - label: API_SCOPE_LABELS[scope] ?? scope, - })); - const rowIsWildcard = scopeBadges.some((scope) => scope.value === '*'); - - return ( - - - - - - - - - ); - }} - - -
LabelToken hintScopesCreatedLast usedActions
-
- {token.name || 'Untitled token'} - - - Docker - - -
- -
- Used by Docker {hostCountLabel}: {hostSummary} -
-
-
- {tokenHint(token)} - -
- - {(scope) => { - const isWildcard = scope.value === '*'; - const badgeClass = isWildcard - ? 'inline-flex items-center rounded-full bg-amber-100 dark:bg-amber-900/40 px-2 py-0.5 text-[11px] font-semibold text-amber-800 dark:text-amber-200' - : 'inline-flex items-center rounded-full bg-gray-100 dark:bg-gray-800 px-2 py-0.5 text-[11px] font-medium text-gray-700 dark:text-gray-200'; - return ( - - {scope.label} - - ); - }} - -
-
- {formatRelativeTime(new Date(token.createdAt).getTime())} - - {token.lastUsedAt ? formatRelativeTime(new Date(token.lastUsedAt).getTime()) : 'Never'} - - -
-
-
-
- - - -
-
- - - -
-
-

- Token ready to copy -

-

- Tokens are only shown once. Copy it now or store it securely before you leave this page. -

- -

- Label{' '} - {newTokenRecord()?.name || 'Untitled token'} - - {' '}· Hint{' '} - - {tokenHint(newTokenRecord()!)} - - -

-
-
-
-
- - -
-
-
- + { - createSectionRef = el; - }} + tone="success" + padding="sm" + class="flex flex-wrap items-center justify-between gap-3 border border-green-300/70 text-sm text-green-800 dark:border-green-700/70 dark:text-green-200" > -
-
-

Generate new token

-

- Tokens are only displayed once. Follow the steps below to create a scoped credential. -

-
- -
    -
  1. -
    - 1 -
    -
    -

    Name the token

    -

    - Use something descriptive so you can identify the integration later. -

    - setNameInput(event.currentTarget.value)} - placeholder="e.g., docker-host-1" - class="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100" - /> -
    -
  2. - -
  3. -
    - 2 -
    -
    -

    Set a baseline scope

    -

    - Start with a preset that matches the integration, or choose full access if you plan to trim later. -

    -
    - - - {(preset) => ( - - )} - -
    -
    - - Current selection - - 0} - fallback={Full access (wildcard)} - > -
    - - {(chip) => ( - - {chip.label} - - )} - -
    -
    -
    -
    -
  4. - -
  5. -
    - 3 -
    -
    -
    -

    Fine-tune permissions

    -

    - Toggle advanced scopes if the integration needs additional access beyond the preset. -

    -
    -
    - 0}> -
    - - {(chip) => ( - - {chip.value} - - )} - -
    -
    - -
    - -
    - - {([group, options]) => ( -
    -

    - {group} -

    -
    - - {(option) => { - const inputId = `scope-${option.value.replace(/[:]/g, '-')}`; - const checked = () => selectedScopes().includes(option.value); - return ( - - ); - }} - -
    -
    - )} -
    -
    -
    -
    -
  6. -
-
- -
- + - - -
+
- - -

- Full access tokens detected -

-

- Edit existing tokens to assign scopes, or generate replacements with the presets above so compromised credentials can’t control everything. -

+ 0} + fallback={ + + No tokens yet.{' '} + {' '} + to authenticate agents and integrations. + + } + > + +
+
+

Token inventory

+

+ Active credentials sorted by most recent creation date. +

+
- - +
- -

Good practices

-
    -
  • - - Issue separate tokens for Docker agents, host agents, and automation pipelines so you can revoke them independently. -
  • -
  • - - Rotate tokens on a schedule and remove ones that haven’t been used recently. -
  • -
  • - - - View the{' '} - - scoped token guide - {' '} - for the full list of available permissions. - -
  • -
+
+ + + + + + + + + + + + + + + {(token) => { + const usage = dockerTokenUsage().get(token.id); + const hostSummary = + usage ? (usage.count === 1 ? usage.hosts[0] : `${usage.count} hosts`) : '—'; + const rawScopes = token.scopes && token.scopes.length > 0 ? token.scopes : ['*']; + const scopeBadges = rawScopes.includes('*') + ? [{ value: '*', label: 'Full' }] + : rawScopes.map((scope) => ({ + value: scope, + label: API_SCOPE_LABELS[scope] ?? scope, + })); + const rowIsWildcard = scopeBadges.some((scope) => scope.value === '*'); + + return ( + + + + + + + + + + ); + }} + + +
NameHintScopesUsageCreatedLast usedAction
+ {token.name || 'Untitled'} + + {tokenHint(token)} + +
+ + {(scope) => { + const isWildcard = scope.value === '*'; + return ( + + {scope.label} + + ); + }} + +
+
+ {hostSummary} + + {formatRelativeTime(new Date(token.createdAt).getTime())} + + {token.lastUsedAt + ? formatRelativeTime(new Date(token.lastUsedAt).getTime()) + : 'Never'} + + +
+
-
+ + + { + createSectionRef = el; + }} + > +
+
+ + +
+ +
+
+ + setNameInput(e.currentTarget.value)} + placeholder="e.g. Docker pipeline" + class="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 shadow-sm transition focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 dark:focus:border-blue-400 dark:focus:ring-blue-500/40" + /> +
+ +
+
+ + Quick presets + + +
+ +
+ + + + {(preset) => ( + + )} + +
+
+ +
+ + Custom scopes + +
+ + {([group, options]) => ( +
+
+ {group} +
+
+ + {(option) => { + const isActive = () => selectedScopes().includes(option.value); + return ( + + ); + }} + +
+
+ )} +
+
+
+
+
+
+ + + + ⚠ {wildcardCount()} full access {wildcardCount() === 1 ? 'token' : 'tokens'} – consider switching to scoped presets for least privilege. + + + + + 💡 Separate tokens per integration • Rotate regularly •{' '} + + Scope reference + +
); };