Files
coolify/resources/views/livewire/security/api-tokens.blade.php
T
Andras Bacsai bdb367555d feat(ui): unify brand accents and polish shared controls
Standardize highlighted buttons, selected controls, and loading
indicators on coollabs accent colors; improve light-mode log toolbar
styling; enhance small popups with compact/icon modes; and align
navbars, helpers, and related layouts with matching tests.
2026-08-05 22:34:56 +02:00

308 lines
21 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<div>
<x-slot:title>
API Tokens | Coolify
</x-slot>
<x-security.settings-layout>
@if (!$isApiEnabled)
<div class="application-settings-form">
<x-application.settings-section title="API disabled"
description="Enable the Coolify API before creating access tokens.">
<x-empty title="API access is turned off"
description="Enable API access in instance settings to issue tokens." icon-name="keys"
size="sm">
<x-slot:actions>
<a href="{{ route('settings.advanced') }}" class="button" {{ wireNavigate() }}>
Open settings
</a>
</x-slot:actions>
</x-empty>
</x-application.settings-section>
</div>
@else
@php
$expirationList = collect($expirationOptions)
->map(fn ($label, $days) => ['value' => (string) $days, 'label' => $label])
->values()
->push(['value' => '', 'label' => 'Never'])
->all();
@endphp
<div class="application-settings-form flex flex-col gap-6">
@can('create', App\Models\PersonalAccessToken::class)
<form wire:submit="addNewToken">
<x-application.settings-section title="New API token">
<x-slot:actions>
<button type="submit"
class="button button-highlighted">
<x-reicon name="plus" class="size-3.5" />
Create token
</button>
</x-slot:actions>
<div class="grid gap-4 lg:grid-cols-2">
<x-forms.input required id="description" label="Description"
placeholder="CI deployment token" />
<x-forms.listbox id="expiresInDays" label="Expires in"
:options="$expirationList" />
</div>
<div class="mt-5 border-t border-neutral-200 pt-4 dark:border-white/[0.08]">
<div class="mb-3 flex items-center gap-2">
<h4 class="text-[12px] font-semibold text-black dark:text-fg">Permissions</h4>
<x-helper helper="Only grant the abilities this token needs." />
</div>
<div class="relative" x-data="{ permissionsOpen: false }"
@click.outside="permissionsOpen = false" @keydown.escape.window="permissionsOpen = false">
<button type="button" class="listbox-trigger" @click="permissionsOpen = !permissionsOpen"
aria-haspopup="listbox" :aria-expanded="permissionsOpen">
<span class="truncate">
Selected permissions: {{ collect($permissions)->map(fn ($permission) => str($permission)->replace(':', ' ')->headline())->join(', ') }}
</span>
<x-reicon name="chevron-down" class="size-3.5 shrink-0 opacity-60" />
</button>
<div x-cloak x-show="permissionsOpen" x-transition.origin.top
class="listbox-panel top-full! mt-1! w-full!" role="listbox">
<div class="listbox-option p-0!">
<x-forms.checkbox id="permission-root" label="Root" fullWidth
wire:model.live="permissions" domValue="root"
helper="Full access to every API operation."
:checked="in_array('root', $permissions)" :disabled="!$canUseRootPermissions" />
</div>
<div class="listbox-option p-0!">
<x-forms.checkbox id="permission-write" label="Write" fullWidth
wire:model.live="permissions" domValue="write"
helper="Create and update resources."
:checked="in_array('write', $permissions)"
:disabled="in_array('root', $permissions) || !$canUseWritePermissions" />
</div>
<div class="listbox-option p-0!">
<x-forms.checkbox id="permission-deploy" label="Deploy" fullWidth
wire:model.live="permissions" domValue="deploy"
helper="Trigger deployments through webhooks."
:checked="in_array('deploy', $permissions)"
:disabled="in_array('root', $permissions) || !$canUseDeployPermissions" />
</div>
<div class="listbox-option p-0!">
<x-forms.checkbox id="permission-read" label="Read" fullWidth
wire:model.live="permissions" domValue="read"
helper="Read non-sensitive resource data."
:checked="in_array('read', $permissions)"
:disabled="in_array('root', $permissions)" />
</div>
<div class="listbox-option p-0!">
<x-forms.checkbox id="permission-read-sensitive" label="Read sensitive data"
fullWidth wire:model.live="permissions" domValue="read:sensitive"
helper="Include secrets, logs, passwords, and Compose content."
:checked="in_array('read:sensitive', $permissions)"
:disabled="in_array('root', $permissions) || !$canUseSensitivePermissions" />
</div>
</div>
</div>
</div>
</x-application.settings-section>
</form>
@endcan
@if (session()->has('token'))
<x-application.settings-section title="Copy your token"
description="This value will not be shown again after you leave this page.">
<div class="relative" x-data="{ copied: false }">
<input type="text" value="{{ session('token') }}" readonly
class="w-full pr-12! font-mono text-[12px]">
<button type="button"
x-on:click="copied = true; navigator.clipboard.writeText(@js(session('token'))); setTimeout(() => copied = false, 1200)"
class="absolute top-1/2 right-2 flex size-7 -translate-y-1/2 items-center justify-center rounded-md text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-black dark:hover:bg-white/[0.06] dark:hover:text-fg"
title="Copy token">
<x-reicon name="file-content" class="size-3.5" />
</button>
<span x-cloak x-show="copied"
class="absolute top-full right-0 mt-1 text-[10px] text-success">Copied</span>
</div>
</x-application.settings-section>
@endif
<x-application.settings-section title="Issued tokens" flush>
<div x-data="{
search: '',
page: 1,
perPage: 10,
tokens: @js($tokens->map(fn ($token) => [
'id' => (string) $token->id,
'search' => strtolower($token->name . ' ' . implode(' ', $token->abilities ?? [])),
])->values()),
get filteredTokens() {
const query = this.search.trim().toLowerCase();
return query
? this.tokens.filter(token => token.search.includes(query))
: this.tokens;
},
get lastPage() {
return Math.max(1, Math.ceil(this.filteredTokens.length / this.perPage));
},
get firstVisibleRow() {
return this.filteredTokens.length === 0 ? 0 : ((this.page - 1) * this.perPage) + 1;
},
get lastVisibleRow() {
return Math.min(this.page * this.perPage, this.filteredTokens.length);
},
isVisible(id) {
const start = (this.page - 1) * this.perPage;
return this.filteredTokens
.slice(start, start + this.perPage)
.some(token => token.id === String(id));
},
goToPage(page) {
this.page = Math.min(Math.max(page, 1), this.lastPage);
}
}">
@if ($tokens->count() > 1)
<div class="border-b border-neutral-200 p-3 dark:border-white/[0.08]">
<div class="relative max-w-sm">
<x-reicon name="search"
class="pointer-events-none absolute top-1/2 left-2.5 z-10 size-3.5 -translate-y-1/2 text-neutral-400 dark:text-fg-faint" />
<input x-model.debounce.150ms="search" x-on:input="page = 1" type="search"
placeholder="Search tokens" aria-label="Search tokens"
class="h-8! w-full rounded-lg! border-neutral-200! bg-white! py-0! pr-8! pl-8! text-[12px]! shadow-none! placeholder:text-neutral-400 focus:border-accent! focus:ring-0! dark:border-white/[0.08]! dark:bg-white/[0.035]! dark:text-fg! dark:placeholder:text-fg-faint">
<button x-cloak x-show="search" x-on:click="search = ''; page = 1" type="button"
class="absolute top-1/2 right-2 flex size-5 -translate-y-1/2 items-center justify-center rounded text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.07] dark:hover:text-fg"
aria-label="Clear search">
<x-reicon name="x" class="size-3" />
</button>
</div>
</div>
@endif
@if ($tokens->isEmpty())
<x-empty title="No API tokens" description="Create a token when an external client needs access."
icon-name="keys" size="sm" />
@else
<div x-cloak x-show="filteredTokens.length > 0" class="data-table">
<div class="data-table-header api-tokens-table-grid">
<span>Description</span>
<span>Permissions</span>
<span>Last used</span>
<span>Created</span>
<span>Expires</span>
<span class="text-right">Actions</span>
</div>
@foreach ($tokens as $token)
<div wire:key="api-token-{{ $token->id }}"
x-show="isVisible(@js((string) $token->id))"
class="data-table-row api-tokens-table-grid border-b border-neutral-200 last:border-b-0 dark:border-white/[0.07]">
<div class="min-w-0 truncate text-[12px] font-medium text-black dark:text-fg">
{{ $token->name }}
</div>
<div class="flex min-w-0 flex-wrap items-center gap-1.5">
@foreach ($token->abilities ?? [] as $ability)
<span @class([
'inline-flex min-h-5 items-center rounded-md border px-2 py-0.5 text-[10px] font-medium',
'border-error/20 bg-error/10 text-error' => $ability === 'root',
'border-warning/20 bg-warning/10 text-warning' => in_array($ability, ['write', 'write:sensitive']),
'border-coollabs/20 bg-coollabs/10 text-coollabs dark:border-warning/20 dark:bg-warning/10 dark:text-warning' => $ability === 'deploy',
'border-neutral-200 bg-neutral-100 text-neutral-600 dark:border-white/[0.08] dark:bg-white/[0.06] dark:text-fg-dim' => in_array($ability, ['read', 'read:sensitive']),
])>{{ $ability }}</span>
@endforeach
</div>
<div class="text-[11px] text-neutral-500 dark:text-fg-dim">
{{ $token->last_used_at?->diffForHumans() ?? 'Never' }}
</div>
<div class="text-[11px] text-neutral-500 dark:text-fg-dim">
{{ $token->created_at->format('Y-m-d') }}
</div>
<div class="text-[11px] text-neutral-500 dark:text-fg-dim">
@if (!$token->expires_at)
Never
@elseif ($token->expires_at->isPast())
<x-status-badge label="Expired" type="error" />
@else
{{ $token->expires_at->format('Y-m-d') }}
@endif
</div>
<div class="flex justify-end">
@if (auth()->id() === $token->tokenable_id)
<x-modal-confirmation title="Confirm API Token Revocation?"
submitAction="revoke({{ $token->id }})" :actions="[
'This API token will be permanently revoked.',
]"
confirmationText="{{ $token->name }}"
confirmationLabel="Enter the token description to confirm"
shortConfirmationLabel="Token description"
:confirmWithPassword="false" step2ButtonText="Revoke token">
<x-slot:trigger>
<button type="button"
class="inline-flex h-7 items-center rounded-md px-2 text-[11px] font-medium text-error transition-colors hover:bg-error/10">
Revoke
</button>
</x-slot:trigger>
</x-modal-confirmation>
@endif
</div>
</div>
@endforeach
</div>
<div x-cloak x-show="filteredTokens.length === 0">
<x-empty size="sm" title="No matching tokens"
description="Try a different description or permission." />
</div>
<div x-cloak x-show="filteredTokens.length > 0"
class="flex min-h-14 items-center justify-between gap-3 border-t border-neutral-200 px-4 py-3 dark:border-white/[0.08]">
<p class="text-[13px] text-neutral-500 dark:text-fg-dim">
Showing
<span class="tabular-nums text-black dark:text-fg"
x-text="`${firstVisibleRow}${lastVisibleRow}`"></span>
of
<span class="tabular-nums text-black dark:text-fg"
x-text="filteredTokens.length"></span>
</p>
<div
class="inline-flex h-8 overflow-hidden rounded-lg border border-neutral-200 dark:border-white/[0.10]">
<button type="button"
class="flex w-10 items-center justify-center border-r border-neutral-200 text-neutral-500 transition-colors hover:bg-neutral-100 disabled:cursor-not-allowed disabled:opacity-35 dark:border-white/[0.10] dark:text-fg-dim dark:hover:bg-white/[0.06]"
aria-label="First page" title="First page" x-on:click="goToPage(1)"
x-bind:disabled="page === 1">
<svg class="size-3.5" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M18 6L12 12L18 18M11 6L5 12L11 18" stroke="currentColor"
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<button type="button"
class="flex w-10 items-center justify-center border-r border-neutral-200 text-neutral-500 transition-colors hover:bg-neutral-100 disabled:cursor-not-allowed disabled:opacity-35 dark:border-white/[0.10] dark:text-fg-dim dark:hover:bg-white/[0.06]"
aria-label="Previous page" title="Previous page"
x-on:click="goToPage(page - 1)" x-bind:disabled="page === 1">
<x-reicon name="arrow-right" class="size-3.5 rotate-180" />
</button>
<span
class="flex min-w-10 items-center justify-center border-r border-neutral-200 px-3 text-[12px] font-medium tabular-nums text-black dark:border-white/[0.10] dark:text-fg"
x-text="page"></span>
<button type="button"
class="flex w-10 items-center justify-center border-r border-neutral-200 text-neutral-500 transition-colors hover:bg-neutral-100 disabled:cursor-not-allowed disabled:opacity-35 dark:border-white/[0.10] dark:text-fg-dim dark:hover:bg-white/[0.06]"
aria-label="Next page" title="Next page" x-on:click="goToPage(page + 1)"
x-bind:disabled="page === lastPage">
<x-reicon name="arrow-right" class="size-3.5" />
</button>
<button type="button"
class="flex w-10 items-center justify-center text-neutral-500 transition-colors hover:bg-neutral-100 disabled:cursor-not-allowed disabled:opacity-35 dark:text-fg-dim dark:hover:bg-white/[0.06]"
aria-label="Last page" title="Last page" x-on:click="goToPage(lastPage)"
x-bind:disabled="page === lastPage">
<svg class="size-3.5" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M6 6L12 12L6 18M13 6L19 12L13 18" stroke="currentColor"
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
</div>
</div>
@endif
</div>
</x-application.settings-section>
</div>
@endif
</x-security.settings-layout>
</div>