feat: enhance API and middleware functionality: add public API routes, implement program and course member checks, and improve rate limiting for invite acceptance. Update OpenAPI spec generation to focus on public API endpoints and add new middleware for automation key scopes. Include new documentation for lesson AI features and public API tests.

This commit is contained in:
Rotimi Best
2026-04-12 08:58:41 +01:00
parent 5a8f50f67b
commit d6e812d08b
573 changed files with 31964 additions and 8708 deletions
@@ -85,7 +85,8 @@ class AutomationApi extends BaseApiWithErrors {
onSuccess: (response) => {
this.keys = this.keys.map((key) => (key.id === response.data.id ? response.data : key));
if (this.usage) {
const activeKeys = this.keys.filter((key) => key.type === 'mcp' && !key.revokedAt).length;
const revokedKeyType = response.data.type;
const activeKeys = this.keys.filter((key) => key.type === revokedKeyType && !key.revokedAt).length;
this.usage = { ...this.usage, activeKeys };
}
snackbar.success('snackbar.automation.revoked');
@@ -1,14 +1,221 @@
<script lang="ts">
import { automationApi } from '$features/automation/api/automation.svelte';
import { getMaskedAutomationSecret } from '$features/automation/utils/automation-utils';
import { isOrgAdmin, isEnterprisePlan } from '$lib/utils/store/org';
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$features/ui/snackbar/store';
import * as Alert from '@cio/ui/base/alert';
import { Badge } from '@cio/ui/base/badge';
import { Button } from '@cio/ui/base/button';
import * as Code from '@cio/ui/custom/code';
import { Empty } from '@cio/ui/custom/empty';
import * as Dialog from '@cio/ui/base/dialog';
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import * as Field from '@cio/ui/base/field';
import * as Table from '@cio/ui/base/table';
import { InputField } from '@cio/ui/custom/input-field';
import { IconButton } from '@cio/ui/custom/icon-button';
import { UpgradeBanner } from '$features/ui';
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import KeyIcon from '@lucide/svelte/icons/key';
import PlusIcon from '@lucide/svelte/icons/plus';
let generatedSecret = $state<string | null>(null);
let isCreateKeyModalOpen = $state(false);
let keyLabel = $state('');
const canCreateKey = $derived(keyLabel.trim().length > 0 && !automationApi.isLoading);
function resetCreateKeyModal() {
keyLabel = '';
automationApi.resetErrors();
}
function openCreateKeyModal() {
if (!$isEnterprisePlan) {
snackbar.error('upgrade.required');
return;
}
resetCreateKeyModal();
isCreateKeyModalOpen = true;
}
async function onGenerateKey() {
if (!$isEnterprisePlan) return;
const result = await automationApi.createKey('api', keyLabel);
if (!result) return;
generatedSecret = automationApi.generatedSecret;
automationApi.clearGeneratedSecret();
isCreateKeyModalOpen = false;
resetCreateKeyModal();
}
async function onRevokeKey(keyId: string) {
if (!$isEnterprisePlan) return;
if (!confirm(t.get('automation.keys.revoke_confirm'))) return;
await automationApi.revokeKey(keyId);
}
async function onRotateKey(keyId: string) {
if (!$isEnterprisePlan) return;
if (!confirm(t.get('automation.keys.rotate_confirm'))) return;
await automationApi.rotateKey(keyId);
generatedSecret = automationApi.generatedSecret;
automationApi.clearGeneratedSecret();
}
const apiKeys = $derived(automationApi.keys.filter((key) => key.type === 'api'));
</script>
<Field.Group class="w-full max-w-5xl! space-y-8 px-2">
<Alert.Callout
variant="information"
title={$t('automation.tabs.api')}
description={$t('automation.coming_soon')}
class="w-full"
/>
<Field.Group class="mx-auto w-full space-y-2">
<UpgradeBanner>{$t('upgrade.enterprise_required')}</UpgradeBanner>
{#if !$isOrgAdmin}
<Alert.Callout
variant="information"
title={$t('automation.permissions.disabled_title')}
description={$t('automation.permissions.disabled_description')}
class="w-full"
/>
{/if}
<div class="space-y-3">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2 font-medium">
<KeyIcon class="size-5" />
{$t('automation.api.keys.title')}
</div>
{#if $isOrgAdmin}
<IconButton
onclick={openCreateKeyModal}
disabled={automationApi.isLoading || !$isEnterprisePlan}
tooltip={$t('automation.api.keys.generate')}
>
<PlusIcon size={16} />
</IconButton>
{/if}
</div>
<p class="ui:text-muted-foreground text-sm">{$t('automation.api.keys.description')}</p>
{#if generatedSecret}
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4">
<p class="ui:text-amber-800 text-sm font-medium">{$t('automation.api.keys.secret_once')}</p>
<div class="mt-3">
<Code.Root code={generatedSecret} lang="bash" hideLines={true} class="ui:break-all ui:text-xs">
<Code.CopyButton />
</Code.Root>
</div>
</div>
{/if}
{#if $isOrgAdmin}
{#if apiKeys.length > 0}
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>{$t('automation.keys.table_key')}</Table.Head>
<Table.Head>{$t('automation.keys.table_status')}</Table.Head>
<Table.Head>{$t('automation.keys.table_created')}</Table.Head>
<Table.Head>{$t('automation.keys.table_last_used')}</Table.Head>
<Table.Head class="w-10"></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each apiKeys as key (key.id)}
<Table.Row>
<Table.Cell>
<div>
<p class="font-medium">{key.label}</p>
<p class="ui:text-muted-foreground text-xs">
{getMaskedAutomationSecret(key.secretPrefix)}
</p>
</div>
</Table.Cell>
<Table.Cell>
<Badge variant={key.revokedAt ? 'secondary' : 'default'}>
{key.revokedAt ? $t('automation.keys.revoked') : $t('automation.keys.active')}
</Badge>
</Table.Cell>
<Table.Cell class="ui:text-muted-foreground text-sm">
{new Date(key.createdAt).toLocaleDateString()}
</Table.Cell>
<Table.Cell class="ui:text-muted-foreground text-sm">
{key.lastUsedAt ? new Date(key.lastUsedAt).toLocaleDateString() : $t('automation.keys.never_used')}
</Table.Cell>
<Table.Cell>
{#if !key.revokedAt}
<DropdownMenu.Root>
<DropdownMenu.Trigger class="inline-flex items-center justify-center">
<IconButton aria-label="actions">
<EllipsisVerticalIcon size={16} />
</IconButton>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item onclick={() => onRotateKey(key.id)}>
{$t('automation.keys.rotate')}
</DropdownMenu.Item>
<DropdownMenu.Item onclick={() => onRevokeKey(key.id)} class="ui:text-destructive">
{$t('automation.keys.revoke')}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
{/if}
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
{:else}
<Empty
title={$t('automation.api.keys.empty_title')}
description={$t('automation.api.keys.empty_description')}
icon={KeyIcon}
variant="page"
>
{#if $isOrgAdmin}
<Button onclick={openCreateKeyModal} disabled={automationApi.isLoading || !$isEnterprisePlan}>
<PlusIcon size={16} />
{$t('automation.api.keys.generate')}
</Button>
{/if}
</Empty>
{/if}
{/if}
</div>
</Field.Group>
<Dialog.Root
bind:open={isCreateKeyModalOpen}
onOpenChange={(isOpen) => {
if (!isOpen) {
resetCreateKeyModal();
}
}}
>
<Dialog.Content class="sm:max-w-md">
<Dialog.Header>
<Dialog.Title>{$t('automation.api.keys.modal.title')}</Dialog.Title>
<Dialog.Description>{$t('automation.api.keys.modal.description')}</Dialog.Description>
</Dialog.Header>
<div class="py-2">
<InputField
label={$t('automation.api.keys.modal.name_label')}
placeholder={$t('automation.api.keys.modal.name_placeholder')}
bind:value={keyLabel}
errorMessage={automationApi.errors.label}
autoFocus
/>
</div>
<Dialog.Footer>
<Button variant="outline" onclick={() => (isCreateKeyModalOpen = false)}>
{$t('automation.api.keys.modal.cancel')}
</Button>
<Button onclick={onGenerateKey} loading={automationApi.isLoading} disabled={!canCreateKey}>
{$t('automation.api.keys.modal.save')}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
@@ -13,6 +13,7 @@
import { Badge } from '@cio/ui/base/badge';
import { Button } from '@cio/ui/base/button';
import * as Code from '@cio/ui/custom/code';
import { Empty } from '@cio/ui/custom/empty';
import * as Dialog from '@cio/ui/base/dialog';
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import * as Field from '@cio/ui/base/field';
@@ -97,6 +98,7 @@
});
const isLastUsageCard = (index: number) => index === usageCards.length - 1;
const hasActiveMcpKey = $derived(mcpKeys.some((key) => !key.revokedAt));
</script>
<Field.Group class="mx-auto w-full space-y-2">
@@ -209,64 +211,73 @@
</Table.Body>
</Table.Root>
{:else}
<Alert.Callout
variant="information"
<Empty
title={$t('automation.keys.empty_title')}
description={$t('automation.keys.empty_description')}
class="w-full"
/>
icon={KeyIcon}
variant="page"
>
{#if $isOrgAdmin}
<Button onclick={openCreateKeyModal} disabled={automationApi.isLoading}>
<PlusIcon size={16} />
{$t('automation.mcp.keys.generate')}
</Button>
{/if}
</Empty>
{/if}
{/if}
</div>
<Field.Set class="gap-3!">
<Field.Legend class="flex items-center gap-2">
<SparklesIcon class="size-5" />
{$t('automation.mcp.setup.title')}
</Field.Legend>
<Field.Description>{$t('automation.mcp.setup.description')}</Field.Description>
{#if hasActiveMcpKey}
<Field.Set class="gap-3!">
<Field.Legend class="flex items-center gap-2">
<SparklesIcon class="size-5" />
{$t('automation.mcp.setup.title')}
</Field.Legend>
<Field.Description>{$t('automation.mcp.setup.description')}</Field.Description>
<Tabs.Root bind:value={activeSetupTab} class="w-full">
<Tabs.List class="inline-flex w-auto">
<Tabs.Trigger value="cursor">{$t('automation.clients.cursor')}</Tabs.Trigger>
<Tabs.Trigger value="claude-code">{$t('automation.clients.claude_code')}</Tabs.Trigger>
<Tabs.Trigger value="codex">{$t('automation.clients.codex')}</Tabs.Trigger>
<Tabs.Trigger value="opencode">{$t('automation.clients.opencode')}</Tabs.Trigger>
</Tabs.List>
<Tabs.Root bind:value={activeSetupTab} class="w-full">
<Tabs.List class="inline-flex w-auto">
<Tabs.Trigger value="cursor">{$t('automation.clients.cursor')}</Tabs.Trigger>
<Tabs.Trigger value="claude-code">{$t('automation.clients.claude_code')}</Tabs.Trigger>
<Tabs.Trigger value="codex">{$t('automation.clients.codex')}</Tabs.Trigger>
<Tabs.Trigger value="opencode">{$t('automation.clients.opencode')}</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="claude-code" class="mt-4">
<Code.Overflow>
<Code.Root code={getClaudeCodeSnippet(generatedSecret)} lang="bash">
<Code.CopyButton />
</Code.Root>
</Code.Overflow>
</Tabs.Content>
<Tabs.Content value="claude-code" class="mt-4">
<Code.Overflow>
<Code.Root code={getClaudeCodeSnippet(generatedSecret)} lang="bash">
<Code.CopyButton />
</Code.Root>
</Code.Overflow>
</Tabs.Content>
<Tabs.Content value="codex" class="mt-4">
<Code.Overflow>
<Code.Root code={getCodexSnippet(generatedSecret)} lang="bash">
<Code.CopyButton />
</Code.Root>
</Code.Overflow>
</Tabs.Content>
<Tabs.Content value="codex" class="mt-4">
<Code.Overflow>
<Code.Root code={getCodexSnippet(generatedSecret)} lang="bash">
<Code.CopyButton />
</Code.Root>
</Code.Overflow>
</Tabs.Content>
<Tabs.Content value="cursor" class="mt-4">
<Code.Overflow>
<Code.Root code={getCursorSnippet(generatedSecret)} lang="json">
<Code.CopyButton />
</Code.Root>
</Code.Overflow>
</Tabs.Content>
<Tabs.Content value="cursor" class="mt-4">
<Code.Overflow>
<Code.Root code={getCursorSnippet(generatedSecret)} lang="json">
<Code.CopyButton />
</Code.Root>
</Code.Overflow>
</Tabs.Content>
<Tabs.Content value="opencode" class="mt-4">
<Code.Overflow>
<Code.Root code={getOpenCodeSnippet(generatedSecret)} lang="json">
<Code.CopyButton />
</Code.Root>
</Code.Overflow>
</Tabs.Content>
</Tabs.Root>
</Field.Set>
<Tabs.Content value="opencode" class="mt-4">
<Code.Overflow>
<Code.Root code={getOpenCodeSnippet(generatedSecret)} lang="json">
<Code.CopyButton />
</Code.Root>
</Code.Overflow>
</Tabs.Content>
</Tabs.Root>
</Field.Set>
{/if}
</Field.Group>
<Dialog.Root