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:
co-authored by
Rotimi Best
Cursor Agent
parent
447e54b1f4
commit
fe9f8c441f
@@ -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}">
|
||||
|
||||
Reference in New Issue
Block a user