feat(api): public API plan gating, docs auth, and dashboard snippets (#701)

* fix(automation): align public API plan gating across cloud and self-hosted

Add shared canUsePublicApi helper so cloud allows Early Adopter and
Enterprise while self-hosted requires Enterprise. Update the API key
dashboard page and backend key creation checks to use the same rules.

Co-authored-by: Rotimi Best <[email protected]>

* docs(api): document bearer auth on public API reference

Add shared OpenAPI enrichment with bearerAuth security scheme and
authentication instructions. The interactive reference now loads an
enriched spec with Authorize support, and the API guide explains how
to create and use org API keys.

Co-authored-by: Rotimi Best <[email protected]>

* feat(dashboard): add API quick-start code snippets on automation page

Mirror the MCP client setup section with cURL, JavaScript, and Python
examples that show how to authenticate public API requests with org keys.

Co-authored-by: Rotimi Best <[email protected]>

* ci(openapi): republish spec when public API OpenAPI helpers change

Co-authored-by: Rotimi Best <[email protected]>

* feat(dashboard): add Go snippet to API quick-start examples

Co-authored-by: Rotimi Best <[email protected]>

* docs(api): use /org/*/api and clarify dashboard key generation path

Co-authored-by: Rotimi Best <[email protected]>

* fix(api): escape backticks in OpenAPI description template literal

Greptile flagged that unescaped backticks around * in
PUBLIC_API_OPENAPI_DESCRIPTION evaluated to NaN and dropped auth docs
from the Scalar spec. Also use plan-specific snackbar message on upgrade.

Co-authored-by: Rotimi Best <[email protected]>

---------

Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Rotimi Best <[email protected]>
This commit is contained in:
Rotimi Best
2026-07-02 11:24:33 +01:00
committed by GitHub
co-authored by Rotimi Best Cursor Agent
parent 447e54b1f4
commit fe9f8c441f
25 changed files with 476 additions and 137 deletions
@@ -1,9 +1,16 @@
<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 {
getMaskedAutomationSecret,
getPublicApiCurlSnippet,
getPublicApiGoSnippet,
getPublicApiJavaScriptSnippet,
getPublicApiPythonSnippet
} from '$features/automation/utils/automation-utils';
import { hasPublicApiAccess, isOrgAdmin } from '$lib/utils/store/org';
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$features/ui/snackbar/store';
import { PUBLIC_IS_SELFHOSTED } from '$env/static/public';
import * as Alert from '@cio/ui/base/alert';
import { Badge } from '@cio/ui/base/badge';
import { Button } from '@cio/ui/base/button';
@@ -13,18 +20,24 @@
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 { InputField } from '@cio/ui/custom/input-field';
import { IconButton } from '@cio/ui/custom/icon-button';
import { UpgradeBanner } from '$features/ui';
import CodeIcon from '@lucide/svelte/icons/code';
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import KeyIcon from '@lucide/svelte/icons/key';
import PlusIcon from '@lucide/svelte/icons/plus';
let activeSetupTab = $state('curl');
let generatedSecret = $state<string | null>(null);
let isCreateKeyModalOpen = $state(false);
let keyLabel = $state('');
const canCreateKey = $derived(keyLabel.trim().length > 0 && !automationApi.isLoading);
const upgradeMessageKey = $derived(
PUBLIC_IS_SELFHOSTED === 'true' ? 'upgrade.enterprise_required' : 'upgrade.public_api_required'
);
function resetCreateKeyModal() {
keyLabel = '';
@@ -32,8 +45,8 @@
}
function openCreateKeyModal() {
if (!$isEnterprisePlan) {
snackbar.error('upgrade.required');
if (!$hasPublicApiAccess) {
snackbar.error(upgradeMessageKey);
return;
}
resetCreateKeyModal();
@@ -41,7 +54,7 @@
}
async function onGenerateKey() {
if (!$isEnterprisePlan) return;
if (!$hasPublicApiAccess) return;
const result = await automationApi.createKey('api', keyLabel);
if (!result) return;
@@ -52,13 +65,13 @@
}
async function onRevokeKey(keyId: string) {
if (!$isEnterprisePlan) return;
if (!$hasPublicApiAccess) return;
if (!confirm(t.get('automation.keys.revoke_confirm'))) return;
await automationApi.revokeKey(keyId);
}
async function onRotateKey(keyId: string) {
if (!$isEnterprisePlan) return;
if (!$hasPublicApiAccess) return;
if (!confirm(t.get('automation.keys.rotate_confirm'))) return;
await automationApi.rotateKey(keyId);
generatedSecret = automationApi.generatedSecret;
@@ -66,10 +79,11 @@
}
const apiKeys = $derived(automationApi.keys.filter((key) => key.type === 'api'));
const hasActiveApiKey = $derived(apiKeys.some((key) => !key.revokedAt));
</script>
<Field.Group class="mx-auto w-full space-y-2">
<UpgradeBanner>{$t('upgrade.enterprise_required')}</UpgradeBanner>
<UpgradeBanner visible={!$hasPublicApiAccess}>{$t(upgradeMessageKey)}</UpgradeBanner>
{#if !$isOrgAdmin}
<Alert.Callout
@@ -89,7 +103,7 @@
{#if $isOrgAdmin}
<IconButton
onclick={openCreateKeyModal}
disabled={automationApi.isLoading || !$isEnterprisePlan}
disabled={automationApi.isLoading || !$hasPublicApiAccess}
tooltip={$t('automation.api.keys.generate')}
>
<PlusIcon size={16} />
@@ -174,7 +188,7 @@
variant="page"
>
{#if $isOrgAdmin}
<Button onclick={openCreateKeyModal} disabled={automationApi.isLoading || !$isEnterprisePlan}>
<Button onclick={openCreateKeyModal} disabled={automationApi.isLoading || !$hasPublicApiAccess}>
<PlusIcon size={16} />
{$t('automation.api.keys.generate')}
</Button>
@@ -183,6 +197,57 @@
{/if}
{/if}
</div>
{#if hasActiveApiKey}
<Field.Set class="gap-3!">
<Field.Legend class="flex items-center gap-2">
<CodeIcon class="size-5" />
{$t('automation.api.setup.title')}
</Field.Legend>
<Field.Description>{$t('automation.api.setup.description')}</Field.Description>
<Tabs.Root bind:value={activeSetupTab} class="w-full">
<Tabs.List class="inline-flex w-auto">
<Tabs.Trigger value="curl">{$t('automation.clients.curl')}</Tabs.Trigger>
<Tabs.Trigger value="javascript">{$t('automation.clients.javascript')}</Tabs.Trigger>
<Tabs.Trigger value="python">{$t('automation.clients.python')}</Tabs.Trigger>
<Tabs.Trigger value="go">{$t('automation.clients.go')}</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="curl" class="mt-4">
<Code.Overflow>
<Code.Root code={getPublicApiCurlSnippet(generatedSecret)} lang="bash">
<Code.CopyButton />
</Code.Root>
</Code.Overflow>
</Tabs.Content>
<Tabs.Content value="javascript" class="mt-4">
<Code.Overflow>
<Code.Root code={getPublicApiJavaScriptSnippet(generatedSecret)} lang="javascript">
<Code.CopyButton />
</Code.Root>
</Code.Overflow>
</Tabs.Content>
<Tabs.Content value="python" class="mt-4">
<Code.Overflow>
<Code.Root code={getPublicApiPythonSnippet(generatedSecret)} lang="python">
<Code.CopyButton />
</Code.Root>
</Code.Overflow>
</Tabs.Content>
<Tabs.Content value="go" class="mt-4">
<Code.Overflow>
<Code.Root code={getPublicApiGoSnippet(generatedSecret)} lang="go">
<Code.CopyButton />
</Code.Root>
</Code.Overflow>
</Tabs.Content>
</Tabs.Root>
</Field.Set>
{/if}
</Field.Group>
<Dialog.Root
@@ -1,9 +1,97 @@
import type { AutomationKeyType } from './types';
import { PUBLIC_API_BASE_URL } from '@cio/utils/openapi/public-api';
const PUBLIC_API_SETUP_SECRET_PLACEHOLDER = '<paste-your-api-key>';
function getServerPath() {
return 'npx -y @classroomio/mcp';
}
export function getPublicApiSetupSecret(secret: string | null) {
return secret ?? PUBLIC_API_SETUP_SECRET_PLACEHOLDER;
}
export function getPublicApiCurlSnippet(secret: string | null) {
const apiKey = getPublicApiSetupSecret(secret);
return `curl ${PUBLIC_API_BASE_URL}/audience \\
-H "Authorization: Bearer ${apiKey}"`;
}
export function getPublicApiJavaScriptSnippet(secret: string | null) {
const apiKey = getPublicApiSetupSecret(secret);
return `const response = await fetch('${PUBLIC_API_BASE_URL}/audience', {
headers: {
Authorization: 'Bearer ${apiKey}'
}
});
const { data } = await response.json();
console.log(data);`;
}
export function getPublicApiPythonSnippet(secret: string | null) {
const apiKey = getPublicApiSetupSecret(secret);
return `import requests
response = requests.get(
"${PUBLIC_API_BASE_URL}/audience",
headers={"Authorization": f"Bearer ${apiKey}"},
)
response.raise_for_status()
data = response.json()["data"]
print(data)`;
}
export function getPublicApiGoSnippet(secret: string | null) {
const apiKey = getPublicApiSetupSecret(secret);
return `package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
)
func main() {
req, err := http.NewRequest(http.MethodGet, "${PUBLIC_API_BASE_URL}/audience", nil)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer ${apiKey}")
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
body, err := io.ReadAll(res.Body)
if err != nil {
panic(err)
}
if res.StatusCode >= 400 {
panic(fmt.Sprintf("request failed: %s", body))
}
var payload struct {
Data json.RawMessage \`json:"data"\`
}
if err := json.Unmarshal(body, &payload); err != nil {
panic(err)
}
fmt.Println(string(payload.Data))
}`;
}
export function getAutomationSetupSecret(secret: string | null) {
return secret ?? '<paste-your-mcp-key>';
}
@@ -8,13 +8,16 @@
interface Props {
className?: string;
onClick?: any;
visible?: boolean;
children?: import('svelte').Snippet;
}
let { className = '', onClick = () => {}, ...restProps }: Props = $props();
let { className = '', onClick = () => {}, visible, ...restProps }: Props = $props();
const shouldShow = $derived(visible ?? $isFreePlan);
</script>
{#if $isFreePlan}
{#if shouldShow}
<HoverableItem>
{#snippet children(isHovered)}
<Item.Root variant="outline" size="sm" class="h-fit w-full border-blue-700! py-2! {className}">