GeoNetAgent, LDAPWeb, server-audit, server-connection
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

390 lines
24 KiB

@extends('layouts.app')
@section('title', 'API Clients — ' . config('app.name'))
@section('content')
<div class="max-w-[1600px] mx-auto px-4 py-6 sm:py-8">
<div class="mt-6 mb-8 flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<h1 class="text-3xl font-bold text-white">API Clients</h1>
<p class="text-slate-400 mt-1 text-sm">Kelola API Token dan OAuth2 Client Credentials</p>
</div>
<a href="{{ route('api-docs.index') }}" class="text-sm text-cyan-400 hover:text-cyan-300">→ API Documentation</a>
</div>
@include('partials.flash')
@if ($newToken)
<div class="mb-6 rounded-lg bg-emerald-950/50 border border-emerald-800 p-4">
<p class="text-emerald-200 text-sm font-medium mb-2">New API Token: {{ $newToken['name'] }}</p>
<code class="block bg-slate-950 border border-slate-800 rounded p-3 text-xs text-cyan-300 break-all select-all">{{ $newToken['token'] }}</code>
<p class="text-emerald-400/70 text-xs mt-2">Salin sekarang. Token tidak ditampilkan lagi.</p>
</div>
@endif
@if ($newClient)
<div class="mb-6 rounded-lg bg-violet-950/50 border border-violet-800 p-4 space-y-2 text-sm">
<p class="text-violet-200 font-medium">New OAuth2 Client: {{ $newClient['name'] }}</p>
<p class="text-slate-300">Client ID: <code class="text-cyan-300 select-all">{{ $newClient['client_id'] }}</code></p>
<p class="text-slate-300">Client Secret: <code class="text-cyan-300 break-all select-all">{{ $newClient['client_secret'] }}</code></p>
<p class="text-violet-400/70 text-xs">Salin sekarang. Secret tidak ditampilkan lagi.</p>
</div>
@endif
@if ($newServiceToken)
<div class="mb-6 rounded-lg bg-amber-950/50 border border-amber-800 p-4 space-y-2 text-sm">
<p class="text-amber-200 font-medium">New Service Token: {{ $newServiceToken['name'] }} ({{ $newServiceToken['service'] }})</p>
<p class="text-slate-300">Token: <code class="text-cyan-300 break-all select-all">{{ $newServiceToken['token'] }}</code></p>
<p class="text-amber-400/70 text-xs">Salin sekarang. Token tidak ditampilkan lagi.</p>
</div>
@endif
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="bg-slate-900 border border-slate-800 rounded-2xl p-6">
<h2 class="text-lg font-semibold text-white mb-4">Create API Token</h2>
<form method="POST" action="{{ route('api-clients.tokens.store') }}" class="space-y-4">
@csrf
<div>
<label class="block text-sm text-slate-300 mb-1">Name</label>
<input name="name" required maxlength="100" placeholder="n8n-integration"
class="w-full rounded-lg bg-slate-800 border border-slate-700 px-4 py-2 text-sm text-white">
</div>
<div>
<label class="block text-sm text-slate-300 mb-2">Scopes</label>
<div class="space-y-2">
@foreach ($scopes as $key => $label)
<label class="flex items-center gap-2 text-sm text-slate-300">
<input type="checkbox" name="scopes[]" value="{{ $key }}" class="rounded">
<span class="font-mono text-cyan-400">{{ $key }}</span>
<span class="text-slate-500">— {{ $label }}</span>
</label>
@endforeach
</div>
</div>
<div>
<label class="block text-sm text-slate-300 mb-1">Expires (days, optional)</label>
<input type="number" name="expires_in_days" min="1" max="3650" placeholder="kosong = tidak expire"
class="w-full rounded-lg bg-slate-800 border border-slate-700 px-4 py-2 text-sm text-white">
</div>
<button type="submit" class="rounded-lg bg-cyan-600 hover:bg-cyan-500 px-5 py-2 text-sm font-medium text-white">
Create Token
</button>
</form>
</div>
<div class="bg-slate-900 border border-slate-800 rounded-2xl p-6">
<h2 class="text-lg font-semibold text-white mb-4">Create OAuth2 Client</h2>
<form method="POST" action="{{ route('api-clients.oauth.store') }}" class="space-y-4">
@csrf
<div>
<label class="block text-sm text-slate-300 mb-1">Client Name</label>
<input name="name" required maxlength="100" placeholder="partner-app"
class="w-full rounded-lg bg-slate-800 border border-slate-700 px-4 py-2 text-sm text-white">
</div>
<p class="text-slate-500 text-xs">Grant type: <code class="text-cyan-400">client_credentials</code>. Scope diminta saat request token.</p>
<button type="submit" class="rounded-lg bg-violet-600 hover:bg-violet-500 px-5 py-2 text-sm font-medium text-white">
Create OAuth Client
</button>
</form>
</div>
<div class="bg-slate-900 border border-slate-800 rounded-2xl p-6">
<h2 class="text-lg font-semibold text-white mb-4">Create Service Token</h2>
<form method="POST" action="{{ route('api-clients.service-tokens.store') }}" class="space-y-4">
@csrf
<div>
<label class="block text-sm text-slate-300 mb-1">Name</label>
<input name="name" required maxlength="100" placeholder="GeoNetAgent-Production"
class="w-full rounded-lg bg-slate-800 border border-slate-700 px-4 py-2 text-sm text-white">
</div>
<div>
<label class="block text-sm text-slate-300 mb-1">Service</label>
<input name="service" required maxlength="50" placeholder="GeoNetAgent"
class="w-full rounded-lg bg-slate-800 border border-slate-700 px-4 py-2 text-sm text-white">
</div>
<div>
<label class="block text-sm text-slate-300 mb-1">Expires (days, optional)</label>
<input type="number" name="expires_in_days" min="1" max="3650" placeholder="kosong = tidak expire"
class="w-full rounded-lg bg-slate-800 border border-slate-700 px-4 py-2 text-sm text-white">
</div>
<button type="submit" class="rounded-lg bg-amber-600 hover:bg-amber-500 px-5 py-2 text-sm font-medium text-white">
Create Service Token
</button>
</form>
</div>
</div>
<div class="mt-8 bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden flex flex-col list-panel">
<div class="list-panel__toolbar p-4 border-b border-slate-800">
<h2 class="font-semibold text-white">API Tokens ({{ count($apiTokens) }})</h2>
</div>
<div class="list-panel__scroll">
<table class="min-w-full text-sm list-table list-table--row-actions">
<thead class="text-slate-300">
<tr>
<th class="px-4 py-3 text-left bg-slate-800 border-b border-slate-700">Name</th>
<th class="px-4 py-3 text-left bg-slate-800 border-b border-slate-700">Prefix</th>
<th class="px-4 py-3 text-left bg-slate-800 border-b border-slate-700">Scopes</th>
<th class="px-4 py-3 text-left bg-slate-800 border-b border-slate-700">Last Used</th>
<th class="list-col-actions px-4 py-3 text-left bg-slate-800 border-b border-slate-700">Action</th>
@include('partials.list-table-row-hint', ['tag' => 'th'])
</tr>
</thead>
<tbody class="divide-y divide-slate-800">
@forelse ($apiTokens as $token)
<tr data-list-row-title="{{ $token->name }}">
<td class="px-4 py-3 text-white">{{ $token->name }}</td>
<td class="px-4 py-3 font-mono text-slate-400">{{ $token->token_prefix }}...</td>
<td class="px-4 py-3 text-slate-300 text-xs">{{ implode(', ', $token->scopes ?? []) }}</td>
<td class="px-4 py-3 text-slate-400">{{ $token->last_used_at?->format('Y-m-d H:i') ?? '—' }}</td>
<td class="list-col-actions px-4 py-3">
<form method="POST" action="{{ route('api-clients.tokens.destroy', $token) }}"
onsubmit="return confirm('Revoke token [{{ $token->name }}]?');">
@csrf
@method('DELETE')
<button class="rounded bg-red-800 hover:bg-red-700 text-white text-xs px-2 py-1">Revoke</button>
</form>
</td>
@include('partials.list-table-row-hint', ['tag' => 'td'])
</tr>
@empty
<tr><td colspan="6" class="px-4 py-8 text-center text-slate-400">No API tokens.</td></tr>
@endforelse
</tbody>
</table>
</div>
</div>
<div class="mt-8 bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden flex flex-col list-panel">
<div class="list-panel__toolbar p-4 border-b border-slate-800">
<h2 class="font-semibold text-white">Service Tokens ({{ count(array_filter($serviceTokens, fn($t) => $t->is_active)) }})</h2>
</div>
<div class="list-panel__scroll">
<table class="min-w-full text-sm list-table list-table--row-actions">
<thead class="text-slate-300">
<tr>
<th class="px-4 py-3 text-left bg-slate-800 border-b border-slate-700">Name</th>
<th class="px-4 py-3 text-left bg-slate-800 border-b border-slate-700">Service</th>
<th class="px-4 py-3 text-left bg-slate-800 border-b border-slate-700">Prefix</th>
<th class="px-4 py-3 text-left bg-slate-800 border-b border-slate-700">Status</th>
<th class="px-4 py-3 text-left bg-slate-800 border-b border-slate-700">Last Used</th>
<th class="list-col-actions px-4 py-3 text-left bg-slate-800 border-b border-slate-700">Action</th>
@include('partials.list-table-row-hint', ['tag' => 'th'])
</tr>
</thead>
<tbody class="divide-y divide-slate-800">
@forelse ($serviceTokens as $token)
<tr class="{{ !$token->is_active ? 'opacity-50' : '' }}" data-list-row-title="{{ $token->name }}">
<td class="px-4 py-3 text-white">{{ $token->name }}</td>
<td class="px-4 py-3 text-slate-300">{{ $token->service }}</td>
<td class="px-4 py-3">
<button type="button"
onclick="openTokenModal('{{ $token->name }}', '{{ $token->service }}', '{{ $token->token_prefix }}', '{{ $token->created_by }}', '{{ $token->created_at->format('Y-m-d H:i') }}', '{{ $token->last_used_at?->format('Y-m-d H:i') ?? '-' }}', {{ $token->is_active ? 'true' : 'false' }})"
class="font-mono text-amber-400 hover:text-amber-300 underline decoration-dotted cursor-pointer text-xs">
{{ $token->token_prefix }}
</button>
</td>
<td class="px-4 py-3">
<span class="text-xs {{ !$token->is_active ? 'text-red-400' : ($token->isExpired() ? 'text-amber-400' : 'text-emerald-400') }}">
{{ !$token->is_active ? 'Revoked' : ($token->isExpired() ? 'Expired' : 'Active') }}
</span>
</td>
<td class="px-4 py-3 text-slate-400">{{ $token->last_used_at?->format('Y-m-d H:i') ?? '—' }}</td>
<td class="list-col-actions px-4 py-3 flex gap-1">
@if ($token->is_active)
<form method="POST" action="{{ route('api-clients.service-tokens.regenerate', $token->id) }}"
onsubmit="return confirm('Regenerate token [{{ $token->name }}]? Old token will be revoked.');">
@csrf
<button class="rounded bg-amber-700 hover:bg-amber-600 text-white text-xs px-2 py-1">Regenerate</button>
</form>
<form method="POST" action="{{ route('api-clients.service-tokens.revoke', $token->id) }}"
onsubmit="return confirm('Revoke token [{{ $token->name }}]?');">
@csrf
<button class="rounded bg-red-800 hover:bg-red-700 text-white text-xs px-2 py-1">Revoke</button>
</form>
@endif
<form method="POST" action="{{ route('api-clients.service-tokens.destroy', $token->id) }}"
onsubmit="return confirm('Delete token [{{ $token->name }}]?');">
@csrf
@method('DELETE')
<button class="rounded bg-slate-700 hover:bg-slate-600 text-white text-xs px-2 py-1">Delete</button>
</form>
</td>
@include('partials.list-table-row-hint', ['tag' => 'td'])
</tr>
@empty
<tr><td colspan="7" class="px-4 py-8 text-center text-slate-400">No service tokens.</td></tr>
@endforelse
</tbody>
</table>
</div>
</div>
<div class="mt-8 bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden flex flex-col list-panel">
<div class="list-panel__toolbar p-4 border-b border-slate-800">
<h2 class="font-semibold text-white">OAuth2 Clients ({{ $oauthClients->where('revoked', false)->count() }})</h2>
</div>
<div class="list-panel__scroll">
<table class="min-w-full text-sm list-table list-table--row-actions">
<thead class="text-slate-300">
<tr>
<th class="px-4 py-3 text-left bg-slate-800 border-b border-slate-700">Name</th>
<th class="px-4 py-3 text-left bg-slate-800 border-b border-slate-700">Client ID</th>
<th class="px-4 py-3 text-left bg-slate-800 border-b border-slate-700">Status</th>
<th class="list-col-actions px-4 py-3 text-left bg-slate-800 border-b border-slate-700">Action</th>
@include('partials.list-table-row-hint', ['tag' => 'th'])
</tr>
</thead>
<tbody class="divide-y divide-slate-800">
@forelse ($oauthClients as $client)
<tr class="{{ $client->revoked ? 'opacity-50' : '' }}" data-list-row-title="{{ $client->name }}">
<td class="px-4 py-3 text-white">{{ $client->name }}</td>
<td class="px-4 py-3 font-mono text-xs text-slate-400">{{ $client->id }}</td>
<td class="px-4 py-3">
<span class="text-xs {{ $client->revoked ? 'text-red-400' : 'text-emerald-400' }}">
{{ $client->revoked ? 'Revoked' : 'Active' }}
</span>
</td>
<td class="list-col-actions px-4 py-3">
@unless ($client->revoked)
<form method="POST" action="{{ route('api-clients.oauth.destroy', $client->id) }}"
onsubmit="return confirm('Revoke OAuth client?');">
@csrf
@method('DELETE')
<button class="rounded bg-red-800 hover:bg-red-700 text-white text-xs px-2 py-1">Revoke</button>
</form>
@endunless
</td>
@include('partials.list-table-row-hint', ['tag' => 'td'])
</tr>
@empty
<tr><td colspan="5" class="px-4 py-8 text-center text-slate-400">No OAuth clients.</td></tr>
@endforelse
</tbody>
</table>
</div>
</div>
<aside class="mt-8 bg-slate-900 border border-slate-800 rounded-2xl p-5 notes-panel">
<h2 class="text-lg font-semibold text-white mb-4">Catatan</h2>
<dl class="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm text-slate-400">
<div>
<dt class="font-medium text-cyan-400 mb-1">API Token</dt>
<dd>Token statis <code class="text-cyan-300">dbl_...</code> untuk script/n8n. Scope menentukan akses endpoint.</dd>
</div>
<div>
<dt class="font-medium text-violet-400 mb-1">OAuth2 Client</dt>
<dd>Client Credentials untuk integrasi enterprise. Request token ke <code class="text-cyan-300">/oauth/token</code>.</dd>
</div>
<div>
<dt class="font-medium text-amber-400 mb-1">Service Token</dt>
<dd>Bearer token untuk microservice (GeoNetAgent, Databaselist). Tanpa OAuth flow, simple Bearer auth.</dd>
</div>
<div>
<dt class="font-medium text-amber-400 mb-1">Keamanan</dt>
<dd>Secret/token hanya ditampilkan sekali saat dibuat. Revoke segera jika bocor.</dd>
</div>
<div>
<dt class="font-medium text-emerald-400 mb-1">Dokumentasi</dt>
<dd>Uji API interaktif di <a href="{{ route('api-docs.index') }}" class="text-cyan-400 hover:underline">API Docs</a>.</dd>
</div>
</dl>
</aside>
</div>
{{-- Modal: Service Token Detail --}}
<div id="token-modal" class="fixed inset-0 z-50 hidden items-center justify-center bg-black/60 backdrop-blur-sm" onclick="if(event.target===this)closeTokenModal()">
<div class="bg-slate-900 border border-slate-700 rounded-2xl shadow-2xl w-full max-w-lg mx-4 p-6">
<div class="flex items-center justify-between mb-5">
<h3 class="text-white font-semibold text-lg" id="modal-title">Service Token Detail</h3>
<button onclick="closeTokenModal()" class="text-slate-400 hover:text-white text-xl leading-none">&times;</button>
</div>
<dl class="space-y-3 text-sm">
<div class="flex flex-col gap-1">
<dt class="text-slate-400">Name</dt>
<dd id="modal-name" class="text-white font-medium"></dd>
</div>
<div class="flex flex-col gap-1">
<dt class="text-slate-400">Service</dt>
<dd id="modal-service" class="text-slate-300"></dd>
</div>
<div class="flex flex-col gap-1">
<dt class="text-slate-400">Token Prefix (16 karakter pertama)</dt>
<dd class="flex items-center gap-2">
<code id="modal-prefix" class="text-amber-300 font-mono text-sm bg-slate-800 px-3 py-1.5 rounded select-all flex-1"></code>
<button onclick="copyPrefix()" class="text-xs bg-slate-700 hover:bg-slate-600 text-white px-2 py-1 rounded">Copy</button>
</dd>
</div>
<div class="rounded-lg bg-slate-800 border border-amber-900/50 p-3 text-xs text-amber-200/80 space-y-1">
<p class="font-medium text-amber-300">Cara pakai sebagai Bearer Token:</p>
<code id="modal-bearer" class="block text-cyan-300 break-all select-all"></code>
<p class="text-slate-400 mt-2">Token lengkap hanya ditampilkan <strong class="text-white">sekali</strong> saat dibuat. Jika lupa, gunakan <strong class="text-white">Regenerate</strong> untuk buat token baru.</p>
</div>
<div class="grid grid-cols-2 gap-3 pt-1">
<div>
<dt class="text-slate-400 text-xs">Created By</dt>
<dd id="modal-created-by" class="text-slate-300 text-xs mt-0.5"></dd>
</div>
<div>
<dt class="text-slate-400 text-xs">Created At</dt>
<dd id="modal-created-at" class="text-slate-300 text-xs mt-0.5"></dd>
</div>
<div>
<dt class="text-slate-400 text-xs">Last Used</dt>
<dd id="modal-last-used" class="text-slate-300 text-xs mt-0.5"></dd>
</div>
<div>
<dt class="text-slate-400 text-xs">Status</dt>
<dd id="modal-status" class="text-xs mt-0.5"></dd>
</div>
</div>
</dl>
<div class="mt-5 flex justify-end">
<button onclick="closeTokenModal()" class="rounded-lg bg-slate-700 hover:bg-slate-600 px-4 py-2 text-sm text-white">Tutup</button>
</div>
</div>
</div>
@push('scripts')
<script>
function openTokenModal(name, service, prefix, createdBy, createdAt, lastUsed, isActive) {
document.getElementById('modal-title').textContent = name;
document.getElementById('modal-name').textContent = name;
document.getElementById('modal-service').textContent = service;
document.getElementById('modal-prefix').textContent = prefix;
document.getElementById('modal-bearer').textContent = 'Authorization: Bearer ' + prefix + '... (token lengkap diperlukan)';
document.getElementById('modal-created-by').textContent = createdBy || '-';
document.getElementById('modal-created-at').textContent = createdAt;
document.getElementById('modal-last-used').textContent = lastUsed === '-' ? '—' : lastUsed;
const statusEl = document.getElementById('modal-status');
statusEl.textContent = isActive ? 'Active' : 'Revoked';
statusEl.className = 'text-xs mt-0.5 ' + (isActive ? 'text-emerald-400' : 'text-red-400');
const modal = document.getElementById('token-modal');
modal.classList.remove('hidden');
modal.classList.add('flex');
}
function closeTokenModal() {
const modal = document.getElementById('token-modal');
modal.classList.add('hidden');
modal.classList.remove('flex');
}
function copyPrefix() {
const prefix = document.getElementById('modal-prefix').textContent;
navigator.clipboard.writeText(prefix).then(() => {
const btn = event.target;
btn.textContent = 'Copied!';
btn.classList.add('bg-emerald-700');
setTimeout(() => { btn.textContent = 'Copy'; btn.classList.remove('bg-emerald-700'); }, 1500);
});
}
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeTokenModal(); });
</script>
@endpush
@endsection