Files
inter-hub/Web/View/ApiConsumers/Show.hs
Bernd Worsch 3cac021213
Some checks failed
Test / test (push) Has been cancelled
feat(WP-0010): IHF Phase 9 — External API Surface and Consumer SDKs
Delivers the full Phase 9 external API layer:

- Versioned REST API (/api/v2/) with OpenAPI 3.1 spec; enum arrays for
  widget_type, event_type, annotation category drawn live from registry tables
- OAuth 2.0 client credentials flow (/api/v2/token); hub:*:write scopes
  gated on active HubCapabilityManifest FK
- API key management: SHA256-hashed tokens, key_prefix for display,
  one-time reveal on creation, revocation support
- TypeScript and Python consumer SDKs generated from registry tables
  (/api/v2/sdk/ihf-client.ts, /api/v2/sdk/ihf-client.py)
- Webhook delivery: HMAC-SHA256 signing, append-only webhook_deliveries,
  fire-and-forget dispatch via forkIO, 3-retry logic
- Admin API dashboard with 24h stats (request count, error rate, last seen)
- Rate limiting (per-minute) and daily quota enforcement via api_request_log
- Schema migration: api_consumers, api_keys, webhook_subscriptions (CHECK
  constraint on 6 framework lifecycle topics), webhook_deliveries
  (append-only trigger), api_request_log
- ARCHITECTURE-LAYERS.md scorecard: 3.34 → 3.41 (approaching Strong)
- contracts/functional/interaction-reporting-v1.md extended with Phase 9
  endpoint catalogue and 422 validation error format

GAAF: no bare TEXT discriminators; webhook event_type uses CHECK constraint
over 6 allowed framework lifecycle topic strings (not widget event types).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 19:52:20 +00:00

162 lines
7.7 KiB
Haskell
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.
module Web.View.ApiConsumers.Show where
import Web.Types
import Generated.Types
import IHP.Prelude
import IHP.ViewPrelude
data ShowView = ShowView
{ consumer :: !ApiConsumer
, apiKeys :: ![ApiKey]
, webhooks :: ![WebhookSubscription]
, mManifest :: !(Maybe HubCapabilityManifest)
}
instance View ShowView where
html ShowView { .. } = [hsx|
<div class="mb-6">
<div class="flex items-start justify-between">
<div>
<h1 class="text-2xl font-semibold">{consumer.name}</h1>
{maybeDescription}
</div>
<div class="flex gap-2">
<a href={EditApiConsumerAction consumer.id}
class="border text-sm px-3 py-1.5 rounded hover:bg-gray-50">Edit</a>
<a href={DeactivateApiConsumerAction consumer.id}
data-method="post" data-confirm="Deactivate this consumer?"
class="border border-red-200 text-red-600 text-sm px-3 py-1.5 rounded hover:bg-red-50">
Deactivate
</a>
</div>
</div>
</div>
<div class="grid grid-cols-3 gap-4 mb-8">
<div class="bg-white border rounded p-4">
<div class="text-xs text-gray-500 uppercase tracking-wide mb-1">Status</div>
{if consumer.isActive
then [hsx|<span class="bg-green-100 text-green-700 text-sm font-medium px-2 py-0.5 rounded">active</span>|]
else [hsx|<span class="bg-gray-100 text-gray-500 text-sm font-medium px-2 py-0.5 rounded">inactive</span>|]}
</div>
<div class="bg-white border rounded p-4">
<div class="text-xs text-gray-500 uppercase tracking-wide mb-1">Rate Limit</div>
<div class="text-sm font-medium">{show consumer.rateLimitPerMinute} req/min</div>
</div>
<div class="bg-white border rounded p-4">
<div class="text-xs text-gray-500 uppercase tracking-wide mb-1">Quota</div>
<div class="text-sm font-medium">{show consumer.quotaPerDay} req/day</div>
</div>
</div>
{manifestPanel}
<div class="mb-8">
<div class="flex items-center justify-between mb-3">
<h2 class="text-lg font-semibold">API Keys</h2>
<a href={NewApiKeyAction consumer.id}
class="bg-indigo-600 text-white text-sm px-3 py-1.5 rounded hover:bg-indigo-700">
New Key
</a>
</div>
{if null apiKeys
then [hsx|<p class="text-sm text-gray-400">No keys yet.</p>|]
else keysTable}
</div>
<div>
<div class="flex items-center justify-between mb-3">
<h2 class="text-lg font-semibold">Webhook Subscriptions</h2>
<a href={NewWebhookSubscriptionAction consumer.id}
class="bg-indigo-600 text-white text-sm px-3 py-1.5 rounded hover:bg-indigo-700">
New Subscription
</a>
</div>
{if null webhooks
then [hsx|<p class="text-sm text-gray-400">No webhooks yet.</p>|]
else webhooksTable}
</div>
|]
where
maybeDescription = case consumer.description of
Just d -> [hsx|<p class="text-sm text-gray-500 mt-1">{d}</p>|]
Nothing -> mempty
manifestPanel = case mManifest of
Nothing -> mempty
Just m -> [hsx|
<div class="bg-indigo-50 border border-indigo-100 rounded p-4 mb-6">
<div class="text-xs text-indigo-500 uppercase tracking-wide mb-1">Hub Capability Manifest</div>
<div class="text-sm font-medium">{m.manifestVersion} <span class="text-indigo-600">{m.status}</span></div>
</div>
|]
keysTable = [hsx|
<div class="bg-white border rounded overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-gray-50 border-b"><tr>
<th class="text-left px-4 py-2 font-medium text-gray-600">Prefix</th>
<th class="text-left px-4 py-2 font-medium text-gray-600">Type</th>
<th class="text-left px-4 py-2 font-medium text-gray-600">Scopes</th>
<th class="text-left px-4 py-2 font-medium text-gray-600">Expires</th>
<th class="text-left px-4 py-2 font-medium text-gray-600">Status</th>
<th class="px-4 py-2"></th>
</tr></thead>
<tbody class="divide-y divide-gray-100">
{forEach apiKeys renderKey}
</tbody>
</table>
</div>
|]
renderKey k = [hsx|
<tr>
<td class="px-4 py-2 font-mono text-xs">{k.keyPrefix}...</td>
<td class="px-4 py-2 text-gray-500">{k.tokenType}</td>
<td class="px-4 py-2 text-gray-500">{if k.scopes == "" then "" else k.scopes}</td>
<td class="px-4 py-2 text-gray-500">{maybe "never" show k.expiresAt}</td>
<td class="px-4 py-2">
{if isJust k.revokedAt
then [hsx|<span class="text-red-500 text-xs">revoked</span>|]
else [hsx|<span class="text-green-600 text-xs">active</span>|]}
</td>
<td class="px-4 py-2 text-right">
{if isNothing k.revokedAt
then [hsx|<a href={RevokeApiKeyAction k.id} data-method="post"
data-confirm="Revoke this key? This cannot be undone."
class="text-red-500 hover:text-red-700 text-xs">Revoke</a>|]
else mempty}
</td>
</tr>
|]
webhooksTable = [hsx|
<div class="bg-white border rounded overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-gray-50 border-b"><tr>
<th class="text-left px-4 py-2 font-medium text-gray-600">Event Type</th>
<th class="text-left px-4 py-2 font-medium text-gray-600">Target URL</th>
<th class="text-left px-4 py-2 font-medium text-gray-600">Status</th>
<th class="px-4 py-2"></th>
</tr></thead>
<tbody class="divide-y divide-gray-100">
{forEach webhooks renderWebhook}
</tbody>
</table>
</div>
|]
renderWebhook wh = [hsx|
<tr>
<td class="px-4 py-2 font-mono text-xs">{wh.eventType}</td>
<td class="px-4 py-2 text-gray-500 text-xs truncate max-w-xs">{wh.targetUrl}</td>
<td class="px-4 py-2">
{if wh.isActive
then [hsx|<span class="text-green-600 text-xs">active</span>|]
else [hsx|<span class="text-gray-400 text-xs">paused</span>|]}
</td>
<td class="px-4 py-2 text-right">
<a href={ToggleWebhookSubscriptionAction wh.id} data-method="post"
class="text-gray-400 hover:text-gray-700 text-xs mr-2">Toggle</a>
<a href={DeleteWebhookSubscriptionAction wh.id} data-method="delete"
data-confirm="Delete this subscription?"
class="text-red-400 hover:text-red-600 text-xs">Delete</a>
</td>
</tr>
|]