feat: implement automation feature

This commit is contained in:
Rotimi Best
2026-03-18 23:39:12 +01:00
parent 576d0366de
commit ef4424e267
121 changed files with 4544 additions and 1055 deletions
@@ -1,7 +1,9 @@
import type {
AutomationKey,
AutomationKeyType,
AutomationUsage,
CreateAutomationKeyRequest,
GetAutomationUsageRequest,
ListAutomationKeysRequest,
RevokeAutomationKeyRequest,
RotateAutomationKeyRequest
@@ -15,6 +17,7 @@ class AutomationApi extends BaseApiWithErrors {
generatedSecret = $state<string | null>(null);
isFetched = $state(false);
keys = $state<AutomationKey[]>([]);
usage = $state<AutomationUsage | null>(null);
async listKeys(type?: AutomationKeyType) {
return this.execute<ListAutomationKeysRequest>({
@@ -30,6 +33,19 @@ class AutomationApi extends BaseApiWithErrors {
});
}
async getUsage(type: AutomationKeyType = 'mcp') {
return this.execute<GetAutomationUsageRequest>({
requestFn: () =>
classroomio.organization.automation.usage.$get({
query: { type }
}),
logContext: 'fetching automation usage',
onSuccess: (response) => {
this.usage = response.data;
}
});
}
async createKey(type: AutomationKeyType, label?: string) {
return this.execute<CreateAutomationKeyRequest>({
requestFn: () =>
@@ -43,6 +59,9 @@ class AutomationApi extends BaseApiWithErrors {
onSuccess: (response) => {
this.generatedSecret = response.data.secret;
this.keys = [response.data.key, ...this.keys];
if (this.usage) {
this.usage = { ...this.usage, activeKeys: this.usage.activeKeys + 1 };
}
snackbar.success('snackbar.automation.created');
}
});
@@ -54,6 +73,10 @@ class AutomationApi extends BaseApiWithErrors {
logContext: 'revoking automation key',
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;
this.usage = { ...this.usage, activeKeys };
}
snackbar.success('snackbar.automation.revoked');
}
});
@@ -0,0 +1,14 @@
<script lang="ts">
import { t } from '$lib/utils/functions/translations';
import * as Alert from '@cio/ui/base/alert';
import * as Field from '@cio/ui/base/field';
</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>
@@ -1,269 +0,0 @@
<script lang="ts">
import { automationApi } from '$features/automation/api/automation.svelte';
import {
getClaudeCodeSnippet,
getCodexSnippet,
getCursorSnippet,
getMaskedAutomationSecret
} from '$features/automation/utils/automation-utils';
import { isOrgAdmin } from '$lib/utils/store/org';
import { t } from '$lib/utils/functions/translations';
import * as Alert from '@cio/ui/base/alert';
import { Badge } from '@cio/ui/base/badge';
import { Button } from '@cio/ui/base/button';
import * as Card from '@cio/ui/base/card';
import * as Code from '@cio/ui/custom/code';
import * as Field from '@cio/ui/base/field';
import * as Tabs from '@cio/ui/base/tabs';
import KeyIcon from '@lucide/svelte/icons/key';
import SparklesIcon from '@lucide/svelte/icons/sparkles';
import WandSparklesIcon from '@lucide/svelte/icons/wand-sparkles';
import BracesIcon from '@lucide/svelte/icons/braces';
let activeTab = $state('mcp');
let activeSetupTab = $state('claude-code');
let isRevokingKeyId = $state<string | null>(null);
let isRotatingKeyId = $state<string | null>(null);
$effect(() => {
if (!$isOrgAdmin || automationApi.isFetched) return;
automationApi.listKeys('mcp');
});
async function onGenerateKey() {
await automationApi.createKey('mcp');
}
async function onRevokeKey(keyId: string) {
if (!confirm(t.get('automation.keys.revoke_confirm'))) return;
isRevokingKeyId = keyId;
await automationApi.revokeKey(keyId);
isRevokingKeyId = null;
}
async function onRotateKey(keyId: string) {
if (!confirm(t.get('automation.keys.rotate_confirm'))) return;
isRotatingKeyId = keyId;
await automationApi.rotateKey(keyId);
isRotatingKeyId = null;
}
const mcpSecret = $derived(automationApi.generatedSecret);
const mcpKeys = $derived(automationApi.keys.filter((key) => key.type === 'mcp'));
const claudeCodeSnippet = $derived(getClaudeCodeSnippet(mcpSecret));
const codexSnippet = $derived(getCodexSnippet(mcpSecret));
const cursorSnippet = $derived(getCursorSnippet(mcpSecret));
</script>
<Field.Group class="w-full max-w-5xl! space-y-8 px-2">
{#if !$isOrgAdmin}
<Alert.Callout
variant="information"
title={$t('automation.permissions.disabled_title')}
description={$t('automation.permissions.disabled_description')}
class="w-full"
/>
{/if}
<Tabs.Root bind:value={activeTab} class="w-full">
<Tabs.List class="grid w-full grid-cols-3">
<Tabs.Trigger value="mcp">{$t('automation.tabs.mcp')}</Tabs.Trigger>
<Tabs.Trigger value="zapier" disabled>
<span>{$t('automation.tabs.zapier')}</span>
<Badge variant="outline" class="text-[10px]!">{$t('automation.coming_soon')}</Badge>
</Tabs.Trigger>
<Tabs.Trigger value="api" disabled>
<span>{$t('automation.tabs.api')}</span>
<Badge variant="outline" class="text-[10px]!">{$t('automation.coming_soon')}</Badge>
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="mcp" class="space-y-8">
<Field.Set>
<Field.Legend class="flex items-center gap-2">
<KeyIcon class="size-5" />
{$t('automation.mcp.keys.title')}
</Field.Legend>
<Field.Description>{$t('automation.mcp.keys.description')}</Field.Description>
<div class="flex flex-wrap items-center gap-3">
<Button
onclick={onGenerateKey}
variant="secondary"
loading={automationApi.isLoading}
disabled={!$isOrgAdmin || automationApi.isLoading}
>
{$t('automation.mcp.keys.generate')}
</Button>
</div>
{#if automationApi.generatedSecret}
<div class="mt-2 rounded-lg border border-amber-200 bg-amber-50 p-4">
<p class="ui:text-amber-800 text-sm font-medium">{$t('automation.mcp.keys.secret_once')}</p>
<div class="mt-3">
<Code.Root
code={automationApi.generatedSecret}
lang="bash"
hideLines={true}
class="ui:break-all ui:text-xs"
>
<Code.CopyButton />
</Code.Root>
</div>
</div>
{/if}
{#if $isOrgAdmin}
{#if mcpKeys.length > 0}
<div class="mt-6 grid gap-4 md:grid-cols-2">
{#each mcpKeys as key (key.id)}
<Card.Root>
<Card.Header>
<div class="flex items-start justify-between gap-3">
<div>
<Card.Title>{key.label}</Card.Title>
<Card.Description>{getMaskedAutomationSecret(key.secretPrefix)}</Card.Description>
</div>
<Badge variant={key.revokedAt ? 'secondary' : 'default'}>
{key.revokedAt ? $t('automation.keys.revoked') : $t('automation.keys.active')}
</Badge>
</div>
</Card.Header>
<Card.Content class="space-y-2 text-sm">
<p>
<span class="font-medium">{$t('automation.keys.created_at_label')}</span>
{new Date(key.createdAt).toLocaleString()}
</p>
<p>
<span class="font-medium">{$t('automation.keys.last_used_label')}</span>
{key.lastUsedAt ? new Date(key.lastUsedAt).toLocaleString() : $t('automation.keys.never_used')}
</p>
<p>
<span class="font-medium">{$t('automation.keys.scopes_label')}</span>
{key.scopes.join(', ')}
</p>
</Card.Content>
<Card.Footer class="flex gap-2">
<Button
variant="secondary"
onclick={() => onRotateKey(key.id)}
loading={isRotatingKeyId === key.id}
disabled={!$isOrgAdmin || Boolean(key.revokedAt)}
>
{$t('automation.keys.rotate')}
</Button>
<Button
variant="destructive"
onclick={() => onRevokeKey(key.id)}
loading={isRevokingKeyId === key.id}
disabled={!$isOrgAdmin || Boolean(key.revokedAt)}
>
{$t('automation.keys.revoke')}
</Button>
</Card.Footer>
</Card.Root>
{/each}
</div>
{:else}
<Alert.Callout
variant="information"
title={$t('automation.keys.empty_title')}
description={$t('automation.keys.empty_description')}
class="mt-6 w-full"
/>
{/if}
{/if}
</Field.Set>
<Field.Separator />
<Field.Set>
<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="claude-code">{$t('automation.clients.claude_code')}</Tabs.Trigger>
<Tabs.Trigger value="codex">{$t('automation.clients.codex')}</Tabs.Trigger>
<Tabs.Trigger value="cursor">{$t('automation.clients.cursor')}</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="claude-code" class="mt-4">
<Card.Root>
<Card.Header>
<Card.Title>{$t('automation.clients.claude_code')}</Card.Title>
<Card.Description>{$t('automation.mcp.setup.claude_code_description')}</Card.Description>
</Card.Header>
<Card.Content>
<Code.FileName filename="terminal" />
<Code.Overflow>
<Code.Root code={claudeCodeSnippet} lang="bash" class="ui:w-full ui:border-none">
<Code.CopyButton />
</Code.Root>
</Code.Overflow>
</Card.Content>
</Card.Root>
</Tabs.Content>
<Tabs.Content value="codex" class="mt-4">
<Card.Root>
<Card.Header>
<Card.Title>{$t('automation.clients.codex')}</Card.Title>
<Card.Description>{$t('automation.mcp.setup.codex_description')}</Card.Description>
</Card.Header>
<Card.Content>
<Code.FileName filename="terminal" />
<Code.Overflow>
<Code.Root code={codexSnippet} lang="bash" class="ui:w-full ui:border-none">
<Code.CopyButton />
</Code.Root>
</Code.Overflow>
</Card.Content>
</Card.Root>
</Tabs.Content>
<Tabs.Content value="cursor" class="mt-4">
<Card.Root>
<Card.Header>
<Card.Title>{$t('automation.clients.cursor')}</Card.Title>
<Card.Description>{$t('automation.mcp.setup.cursor_description')}</Card.Description>
</Card.Header>
<Card.Content>
<Code.FileName filename="mcp.json" />
<Code.Overflow>
<Code.Root code={cursorSnippet} lang="json" class="ui:w-full ui:border-none">
<Code.CopyButton />
</Code.Root>
</Code.Overflow>
</Card.Content>
</Card.Root>
</Tabs.Content>
</Tabs.Root>
</Field.Set>
</Tabs.Content>
<Tabs.Content value="zapier" class="mt-6">
<Alert.Callout
variant="information"
title={$t('automation.tabs.zapier')}
description={$t('automation.coming_soon')}
class="w-full"
/>
</Tabs.Content>
<Tabs.Content value="api" class="mt-6">
<Alert.Callout
variant="information"
title={$t('automation.tabs.api')}
description={$t('automation.coming_soon')}
class="w-full"
/>
</Tabs.Content>
</Tabs.Root>
</Field.Group>
@@ -1 +1,3 @@
export { default as AutomationPage } from './automation.svelte';
export { default as ApiAutomationPage } from './api.svelte';
export { default as McpAutomationPage } from './mcp.svelte';
export { default as ZapierAutomationPage } from './zapier.svelte';
@@ -0,0 +1,239 @@
<script lang="ts">
import { automationApi } from '$features/automation/api/automation.svelte';
import {
getClaudeCodeSnippet,
getCodexSnippet,
getCursorSnippet,
getMaskedAutomationSecret
} from '$features/automation/utils/automation-utils';
import { isOrgAdmin } from '$lib/utils/store/org';
import { t } from '$lib/utils/functions/translations';
import * as Alert from '@cio/ui/base/alert';
import { Badge } from '@cio/ui/base/badge';
import * as Code from '@cio/ui/custom/code';
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 * as Tabs from '@cio/ui/base/tabs';
import { IconButton } from '@cio/ui/custom/icon-button';
import CoinsIcon from '@lucide/svelte/icons/coins';
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import GaugeIcon from '@lucide/svelte/icons/gauge';
import KeyIcon from '@lucide/svelte/icons/key';
import PlusIcon from '@lucide/svelte/icons/plus';
import SparklesIcon from '@lucide/svelte/icons/sparkles';
import { ActivityCard } from '$features/ui';
let activeSetupTab = $state('cursor');
let generatedSecret = $state<string | null>(null);
async function onGenerateKey() {
await automationApi.createKey('mcp');
generatedSecret = automationApi.generatedSecret;
automationApi.clearGeneratedSecret();
}
async function onRevokeKey(keyId: string) {
if (!confirm(t.get('automation.keys.revoke_confirm'))) return;
await automationApi.revokeKey(keyId);
}
async function onRotateKey(keyId: string) {
if (!confirm(t.get('automation.keys.rotate_confirm'))) return;
await automationApi.rotateKey(keyId);
generatedSecret = automationApi.generatedSecret;
automationApi.clearGeneratedSecret();
}
const mcpKeys = $derived(automationApi.keys.filter((key) => key.type === 'mcp'));
const usageCards = $derived.by(() => {
if (!automationApi.usage) return [];
return [
{
icon: CoinsIcon,
title: t.get('automation.mcp.usage.credits_title'),
percentage: automationApi.usage.monthlyCreditsRemaining,
description: `${t.get('automation.mcp.usage.used_label')} ${automationApi.usage.monthlyCreditsUsed} / ${automationApi.usage.monthlyCreditsIncluded}`,
hidePercentage: true
},
{
icon: KeyIcon,
title: t.get('automation.mcp.usage.keys_title'),
percentage: automationApi.usage.activeKeys,
description: `${t.get('automation.mcp.usage.used_label')} ${automationApi.usage.activeKeys} / ${automationApi.usage.maxActiveKeys}`,
hidePercentage: true
},
{
icon: GaugeIcon,
title: t.get('automation.mcp.usage.rate_limits_title'),
percentage: `${automationApi.usage.rateLimits.perKey.readPerMinute} / ${automationApi.usage.rateLimits.perKey.writePerMinute} / ${automationApi.usage.rateLimits.perKey.publishPerMinute}`,
description: `${t.get('automation.mcp.usage.rate_limits_description')}`,
hidePercentage: true
}
];
});
const isLastUsageCard = (index: number) => index === usageCards.length - 1;
</script>
<Field.Group class="mx-auto w-full space-y-2">
{#if !$isOrgAdmin}
<Alert.Callout
variant="information"
title={$t('automation.permissions.disabled_title')}
description={$t('automation.permissions.disabled_description')}
class="w-full"
/>
{/if}
{#if $isOrgAdmin && automationApi.usage}
<div class="grid gap-4 md:grid-cols-3">
{#each usageCards as card, i (i)}
<ActivityCard
activity={card}
classes={{
percentage: isLastUsageCard(i) ? 'text-lg' : undefined
}}
/>
{/each}
</div>
{/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.mcp.keys.title')}
</div>
{#if $isOrgAdmin}
<IconButton
onclick={onGenerateKey}
disabled={automationApi.isLoading}
tooltip={$t('automation.mcp.keys.generate')}
>
<PlusIcon size={16} />
</IconButton>
{/if}
</div>
<p class="ui:text-muted-foreground text-sm">{$t('automation.mcp.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.mcp.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 mcpKeys.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 mcpKeys 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}
<Alert.Callout
variant="information"
title={$t('automation.keys.empty_title')}
description={$t('automation.keys.empty_description')}
class="w-full"
/>
{/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>
<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.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="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.Root>
</Field.Set>
</Field.Group>
@@ -0,0 +1,14 @@
<script lang="ts">
import { t } from '$lib/utils/functions/translations';
import * as Alert from '@cio/ui/base/alert';
import * as Field from '@cio/ui/base/field';
</script>
<Field.Group class="w-full max-w-5xl! space-y-8 px-2">
<Alert.Callout
variant="information"
title={$t('automation.tabs.zapier')}
description={$t('automation.coming_soon')}
class="w-full"
/>
</Field.Group>
@@ -1,15 +1,18 @@
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
export type ListAutomationKeysRequest = (typeof classroomio.organization)['automation']['keys']['$get'];
export type GetAutomationUsageRequest = (typeof classroomio.organization)['automation']['usage']['$get'];
export type CreateAutomationKeyRequest = (typeof classroomio.organization)['automation']['keys']['$post'];
export type RevokeAutomationKeyRequest = (typeof classroomio.organization)['automation']['keys'][':keyId']['$delete'];
export type RotateAutomationKeyRequest =
(typeof classroomio.organization)['automation']['keys'][':keyId']['rotate']['$post'];
export type ListAutomationKeysSuccess = Extract<InferResponseType<ListAutomationKeysRequest>, { success: true }>;
export type GetAutomationUsageSuccess = Extract<InferResponseType<GetAutomationUsageRequest>, { success: true }>;
export type CreateAutomationKeySuccess = Extract<InferResponseType<CreateAutomationKeyRequest>, { success: true }>;
export type RevokeAutomationKeySuccess = Extract<InferResponseType<RevokeAutomationKeyRequest>, { success: true }>;
export type RotateAutomationKeySuccess = Extract<InferResponseType<RotateAutomationKeyRequest>, { success: true }>;
export type AutomationKey = ListAutomationKeysSuccess['data'][number];
export type AutomationKeyType = AutomationKey['type'];
export type AutomationUsage = GetAutomationUsageSuccess['data'];