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
+99 -14
View File
@@ -46,22 +46,111 @@ html .dark {
border: 2px solid var(--scrollbarBG);
}
.prose {
color: var(--foreground);
}
.prose h1,
.prose h2,
.prose h3,
.prose h4,
.prose h5,
.prose h6 {
font-size: revert !important;
font-weight: revert !important;
margin-top: 0px !important;
margin-bottom: 16px !important;
line-height: 1.25 !important;
.prose h4 {
@apply scroll-m-20 tracking-tight text-balance;
color: var(--foreground);
}
.prose h1 {
@apply mt-0 text-4xl font-extrabold;
}
.prose h2 {
@apply mt-10 border-b pb-2 text-3xl font-semibold transition-colors;
}
.prose h2:first-child {
@apply mt-0;
}
.prose h3 {
@apply mt-8 text-2xl font-semibold;
}
.prose h4 {
@apply mt-8 text-xl font-semibold;
}
.prose p,
.prose li,
.prose blockquote {
@apply leading-7;
}
.prose p {
margin-top: 0px !important;
margin-bottom: 10px !important;
@apply my-0;
}
.prose p:not(:first-child) {
@apply mt-6;
}
.prose a {
@apply font-medium underline underline-offset-4;
color: var(--primary);
}
.prose blockquote {
@apply mt-6 border-s-2 ps-6 italic;
border-inline-start-color: var(--border);
}
.prose ul,
.prose ol {
@apply my-6 ms-6 p-0;
}
.prose ul {
@apply list-disc;
}
.prose ol {
@apply list-decimal;
}
.prose li {
@apply mt-2;
}
.prose table {
@apply my-6 w-full border-collapse;
}
.prose thead tr,
.prose tbody tr {
@apply m-0 border-t p-0;
border-top-color: var(--border);
}
.prose tbody tr:nth-child(even) {
background: var(--muted);
}
.prose th,
.prose td {
@apply border px-4 py-2 text-start;
border-color: var(--border);
}
.prose th {
@apply font-bold;
}
.prose th[align='center'],
.prose td[align='center'] {
@apply text-center;
}
.prose th[align='right'],
.prose td[align='right'] {
@apply text-end;
}
/* .dark .prose h1,
@@ -106,10 +195,6 @@ iframe.iframe {
}
}
.prose a {
color: var(--main-primary-color);
}
:not([data-slot='button']) > svg.lucide-icon:not(.custom) {
color: #666666;
}
+7 -4
View File
@@ -1,5 +1,6 @@
import { getSessionData } from '$lib/utils/services/auth/session';
import { getCioCookieString } from '$lib/utils/functions/cookies';
import { applyCspExtensions } from '$lib/utils/csp';
import { type Handle, redirect } from '@sveltejs/kit';
import { isPublicApiRoute, isPublicRoute } from '$lib/utils/functions/routes/isPublicRoute';
import { ROUTE } from '$lib/utils/constants/routes';
@@ -12,13 +13,15 @@ export const handle: Handle = async (args) => {
event.locals = sessionData;
}
// Handle API routes
let response: Response;
if (event.url.pathname.includes('/api')) {
return handleAPIRoutes(args);
response = await handleAPIRoutes(args);
} else {
response = await handlePagesRoutes(args);
}
// Handle other routes (ui pages, etc.)
return handlePagesRoutes(args);
return applyCspExtensions(response);
};
const handlePagesRoutes: Handle = async ({ event, resolve }) => {
@@ -0,0 +1,44 @@
/**
* Global SSO auth state for the current org. Shared by login and signup
* so we don't re-fetch when navigating between them.
*/
import { get, writable } from 'svelte/store';
import { INITIAL_SSO_AUTH_STATE, ssoStateFromOrgInfo, type OrgSsoInfoShape, type SsoAuthState } from './auth-sso';
export type AuthSsoStore = {
/** Org we loaded SSO info for; when nav changes org we re-fetch */
loadedForOrgId: string | null;
/** Org-level SSO state from getOrgSsoInfo */
ssoState: SsoAuthState;
orgSupportsSso: boolean;
};
const initial: AuthSsoStore = {
loadedForOrgId: null,
ssoState: INITIAL_SSO_AUTH_STATE,
orgSupportsSso: false
};
export const authSsoStore = writable<AuthSsoStore>(initial);
/**
* Ensures SSO info for the given org is loaded. If we already have data for
* this orgId, does nothing. Otherwise fetches and updates the store.
*/
export async function ensureSsoInfoLoaded(
orgId: string,
fetchOrgSsoInfo: (id: string) => Promise<OrgSsoInfoShape>
): Promise<void> {
const current = get(authSsoStore);
if (current.loadedForOrgId === orgId) return;
const orgInfo = await fetchOrgSsoInfo(orgId);
const ssoState = ssoStateFromOrgInfo(orgInfo);
authSsoStore.set({
loadedForOrgId: orgId,
ssoState,
orgSupportsSso: orgInfo?.hasSso ?? false
});
}
@@ -0,0 +1,101 @@
/**
* Shared SSO auth state and helpers for login/signup flows.
* Used by (auth)/login and (auth)/signup to avoid duplicating SSO logic.
*/
export type SsoAuthState = {
checking: boolean;
required: boolean;
available: boolean;
redirectUrl: string | null;
providerName: string | null;
providerId: string | null;
};
export const INITIAL_SSO_AUTH_STATE: SsoAuthState = {
checking: false,
required: false,
available: false,
redirectUrl: null,
providerName: null,
providerId: null
};
/** Shape returned by getOrgSsoInfo (avoids importing org types here). */
export type OrgSsoInfoShape = {
hasSso: boolean;
ssoRequired?: boolean;
redirectUrl?: string | null;
providerName?: string | null;
providerId?: string | null;
} | null;
/** Shape returned by discoverSso (avoids importing org types here). */
export type SsoDiscoveryResultShape = {
ssoRequired?: boolean;
ssoAvailable?: boolean;
redirectUrl?: string | null;
providerName?: string | null;
providerId?: string | null;
} | null;
export function ssoStateFromOrgInfo(orgInfo: OrgSsoInfoShape): SsoAuthState {
if (!orgInfo) return INITIAL_SSO_AUTH_STATE;
return {
checking: false,
required: orgInfo.ssoRequired ?? false,
available: orgInfo.hasSso ?? false,
redirectUrl: orgInfo.redirectUrl ?? null,
providerName: orgInfo.providerName ?? null,
providerId: orgInfo.providerId ?? null
};
}
export function ssoStateFromDiscovery(result: SsoDiscoveryResultShape): SsoAuthState {
if (!result) return INITIAL_SSO_AUTH_STATE;
return {
checking: false,
required: result.ssoRequired ?? false,
available: result.ssoAvailable ?? false,
redirectUrl: result.redirectUrl ?? null,
providerName: result.providerName ?? null,
providerId: result.providerId ?? null
};
}
export function buildSsoRedirectUrl(redirectParam: string | null): string {
const base = typeof window !== 'undefined' ? window.location.origin : '';
const path = redirectParam && redirectParam.startsWith('/') ? redirectParam : '';
return `${base}${path}`;
}
const SSO_EMAIL_DEBOUNCE_MS = 500;
export function createSsoEmailChecker(options: {
getEmail: () => string;
getOrgSupportsSso: () => boolean;
discoverSso: (email: string) => Promise<SsoDiscoveryResultShape>;
onChecking: () => void;
onResult: (state: SsoAuthState) => void;
onClear: () => void;
}) {
let timeoutId: ReturnType<typeof setTimeout> | undefined;
return function handleEmailChange() {
options.onClear();
if (!options.getOrgSupportsSso()) return;
clearTimeout(timeoutId);
const email = options.getEmail();
if (!email || !email.includes('@')) {
options.onResult(INITIAL_SSO_AUTH_STATE);
return;
}
options.onChecking();
timeoutId = setTimeout(async () => {
const result = await options.discoverSso(email);
options.onResult(ssoStateFromDiscovery(result));
}, SSO_EMAIL_DEBOUNCE_MS);
};
}
@@ -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'];
@@ -6,7 +6,7 @@
import { shortenName } from '$lib/utils/functions/string';
import { Vote } from '$features/ui';
import { CircleCheckIcon } from '$features/ui/icons';
import { sanitizeHtml } from '@cio/ui/tools/sanitize';
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
interface CommentAuthor {
id: number | string | null;
@@ -65,7 +65,7 @@
{/if}
</header>
<article class="prose prose-sm sm:prose p-2">
{@html sanitizeHtml(comment.body ?? '')}
<SafeHtmlContent content={comment.body ?? ''} />
</article>
</div>
</div>
@@ -22,7 +22,7 @@
import { CommunityDeleteModal, CommunityCommentItem } from '$features/community/components';
import type { CommunityQuestionSuccess } from '../utils/types';
import { currentCommunityQuestion } from '../utils/store';
import { sanitizeHtml } from '@cio/ui/tools/sanitize';
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
interface Props {
slug: string;
@@ -211,7 +211,7 @@
</div>
{:else}
<section class="prose prose-sm sm:prose p-2">
{@html sanitizeHtml(communityApi.question?.body ?? '')}
<SafeHtmlContent content={communityApi.question?.body ?? ''} />
</section>
{/if}
</div>
@@ -20,6 +20,7 @@
import getCurrencyFormatter from '$lib/utils/functions/getCurrencyFormatter';
import { calcCourseProgress, calcProgressRate } from '$features/course/utils/functions';
import CardDropdown from './card-dropdown.svelte';
import CoursePublishBadge from './course-publish-badge.svelte';
import type { OrgCourses, UserEnrolledCourses } from '$features/course/types';
import type { OrgPublicCourses } from '$features/org/utils/types';
@@ -196,13 +197,7 @@
</div>
{/if}
{:else}
<Badge variant={isPublished ? 'default' : 'outline'}>
{#if isPublished}
{$t('courses.course_card.published')}
{:else}
{$t('courses.course_card.unpublished')}
{/if}
</Badge>
<CoursePublishBadge {isPublished} />
{/if}
</div>
</div>
@@ -1,6 +1,7 @@
<script lang="ts">
import { COURSE_SORT_OPTIONS } from '../utils/constants';
import * as Popover from '@cio/ui/base/popover';
import * as ToggleGroup from '@cio/ui/base/toggle-group';
import { Button } from '@cio/ui/base/button';
import { Spinner } from '@cio/ui/base/spinner';
import { Checkbox } from '@cio/ui/base/checkbox';
@@ -27,6 +28,7 @@
interface Props {
selectedId?: string;
selectedOrder?: 'asc' | 'desc';
selectedTags?: string[];
tagGroups?: FilterTagGroup[];
sortOptions?: readonly SortOption[];
@@ -37,6 +39,7 @@
let {
selectedId = $bindable('0'),
selectedOrder = $bindable('desc' as 'asc' | 'desc'),
selectedTags = [],
tagGroups = [],
sortOptions = COURSE_SORT_OPTIONS,
@@ -47,8 +50,7 @@
let isOpen = $state(false);
const hasActiveFilters = $derived(selectedId !== '0' || selectedTags.length > 0);
const hasActiveTagFilters = $derived(selectedTags.length > 0);
const hasActiveFilters = $derived(selectedId !== '0' || selectedOrder !== 'desc' || selectedTags.length > 0);
function isTagSelected(tagSlug: string) {
return selectedTags.includes(tagSlug);
@@ -66,7 +68,7 @@
<FilterIcon size={16} />
{/if}
</IconButton>
{#if hasActiveTagFilters}
{#if hasActiveFilters}
<span
class="ui:bg-primary absolute -top-0.5 -right-0.5 h-2.5 w-2.5 rounded-full border border-white"
aria-hidden="true"
@@ -103,6 +105,26 @@
</div>
</div>
<div class="space-y-2">
<p class="ui:text-muted-foreground text-xs font-semibold uppercase">
{$t('courses.tag_filters.order')}
</p>
<ToggleGroup.Root
type="single"
variant="outline"
size="sm"
value={selectedOrder}
onValueChange={(value) => {
if (value === 'asc' || value === 'desc') {
selectedOrder = value;
}
}}
>
<ToggleGroup.Item value="asc">{$t('courses.tag_filters.ascending')}</ToggleGroup.Item>
<ToggleGroup.Item value="desc">{$t('courses.tag_filters.descending')}</ToggleGroup.Item>
</ToggleGroup.Root>
</div>
<div class="space-y-3">
<p class="ui:text-muted-foreground text-xs font-semibold uppercase">{$t('courses.tag_filters.tags')}</p>
{#if tagGroups.length === 0}
@@ -3,21 +3,35 @@
import * as Sidebar from '@cio/ui/base/sidebar';
import BellIcon from '@lucide/svelte/icons/bell';
import { Button } from '@cio/ui/base/button';
import { IconButton } from '@cio/ui/custom/icon-button';
import * as Popover from '@cio/ui/base/popover';
import Search from '$features/ui/search.svelte';
import { HoverableItem, PreviewIcon } from '@cio/ui/custom/moving-icons';
import RefreshCcwIcon from '@lucide/svelte/icons/refresh-ccw';
import * as Empty from '@cio/ui/base/empty';
import { currentOrg } from '$lib/utils/store/org';
import { currentOrg, currentOrgDomain } from '$lib/utils/store/org';
import { isStudentExperience } from '$lib/utils/store/app';
import { setupProgressApi } from '$features/setup/api/setup-progress.svelte';
import { courseApi } from '$features/course/api';
import { openCoursePreview } from '$features/course/utils/course-preview';
import { t } from '$lib/utils/functions/translations';
import CoursePublishBadge from './course-publish-badge.svelte';
const siteName = $derived($currentOrg.siteName);
const showCoursePublishBadge = $derived(!$isStudentExperience);
$effect(() => {
if (!siteName) return;
setupProgressApi.fetchSetupProgress(siteName);
});
function handlePreview() {
openCoursePreview({
courseId: courseApi.course?.id ?? '',
courseSlug: courseApi.course?.slug,
currentOrgDomain: $currentOrgDomain
});
}
</script>
<header
@@ -30,14 +44,18 @@
<Separator orientation="vertical" />
</div>
<p class="max-w-2xs truncate text-sm font-medium">
{courseApi.course?.title || ''}
</p>
<div class="flex min-w-0 items-center gap-2">
<p class="max-w-2xs truncate text-sm font-medium">
{courseApi.course?.title || ''}
</p>
{#if showCoursePublishBadge}
<CoursePublishBadge isPublished={courseApi.course?.isPublished} />
{/if}
</div>
<span class="grow"></span>
<Search />
<Popover.Root>
<Popover.Trigger>
<Button variant="outline" size="icon">
@@ -56,11 +74,24 @@
<Empty.Content>
<Button variant="outline" size="sm">
<RefreshCcwIcon />
Refresh
{$t('common.refresh')}
</Button>
</Empty.Content>
</Empty.Root>
</Popover.Content>
</Popover.Root>
<HoverableItem>
{#snippet children(isHovered)}
<IconButton
onclick={handlePreview}
disabled={!courseApi.course?.id}
tooltip={$t('course.header.preview')}
aria-label={$t('course.header.preview')}
>
<PreviewIcon {isHovered} size={16} />
</IconButton>
{/snippet}
</HoverableItem>
</div>
</header>
@@ -0,0 +1,18 @@
<script lang="ts">
import { Badge } from '@cio/ui/base/badge';
import { t } from '$lib/utils/functions/translations';
interface Props {
isPublished?: boolean;
}
let { isPublished = false }: Props = $props();
</script>
<Badge variant={isPublished ? 'default' : 'outline'}>
{#if isPublished}
{$t('courses.course_card.published')}
{:else}
{$t('courses.course_card.unpublished')}
{/if}
</Badge>
@@ -1,6 +1,6 @@
<script lang="ts">
import { questionnaire } from './store';
import { sanitizeHtml } from '@cio/ui/tools/sanitize';
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
import { t } from '$lib/utils/functions/translations';
import { InputField } from '@cio/ui/custom/input-field';
@@ -49,9 +49,9 @@
</div>
<article class="preview prose prose-sm sm:prose mt-3 p-2">
{@html sanitizeHtml(
$questionnaire.description || $t('course.navItem.lessons.exercises.all_exercises.description.no')
)}
<SafeHtmlContent
content={$questionnaire.description || $t('course.navItem.lessons.exercises.all_exercises.description.no')}
/>
</article>
{:else}
<InputField
@@ -21,7 +21,7 @@
import { toApiPayload, type AnswerData } from '@cio/question-types';
import { exerciseApi, presignApi } from '$features/course/api';
import type { SubmissionListItem } from '$features/course/utils/types';
import { sanitizeHtml } from '@cio/ui/tools/sanitize';
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$features/ui/snackbar/store';
import { ContentType } from '@cio/utils/constants/content';
@@ -422,9 +422,10 @@
</div>
<article class="preview prose prose-sm sm:prose mt-3 p-2">
{@html sanitizeHtml(
$questionnaire.description || $t('course.navItem.lessons.exercises.all_exercises.view_mode.no_description')
)}
<SafeHtmlContent
content={$questionnaire.description ||
$t('course.navItem.lessons.exercises.all_exercises.view_mode.no_description')}
/>
</article>
<Button onclick={handleStart} type="button" class="float-right my-5">
@@ -8,7 +8,7 @@
import { lessonApi } from '$features/course/api';
import { diffLines } from 'diff';
import { courseApi } from '$features/course/api';
import { sanitizeHtml } from '@cio/ui/tools/sanitize';
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$features/ui/snackbar/store';
@@ -83,7 +83,7 @@
);
}
function fetchLessonHistory(lessonId: string, locale: string, endRange: number) {
function fetchLessonHistory(lessonId: string, locale: TLocale, endRange: number) {
untrack(async () => {
try {
isMoreHistoryLoading = true;
@@ -100,9 +100,18 @@
const data = response.data;
// Filter out duplicates based on timestamp
const toLessonHistory = (item: (typeof data)[number]): LessonHistory => ({
old_content: item.oldContent ?? '',
new_content: item.newContent ?? '',
timestamp: item.timestamp ? new Date(item.timestamp) : new Date(),
locale: (item.locale as TLocale) ?? 'en',
lesson_id: item.lessonId ?? ''
});
const existingTimestamps = new Set(lessonHistory.map((item) => new Date(item.timestamp).getMinutes()));
const newEntries = data.filter((item) => !existingTimestamps.has(new Date(item.timestamp!).getMinutes()));
const newEntries = data
.filter((item) => !existingTimestamps.has(new Date(item.timestamp ?? 0).getMinutes()))
.map(toLessonHistory);
lessonHistory = removeDuplicate([...lessonHistory, ...newEntries]);
if (lessonHistory.length > 0) {
@@ -206,7 +215,7 @@
{#key lessonId}
<HTMLRender id="display" className="m-auto">
<div class="amen">
{@html sanitizeHtml(content)}
<SafeHtmlContent {content} />
</div>
</HTMLRender>
{/key}
@@ -222,7 +231,7 @@
</p>
<div>
{#each lessonHistory as version, index}
{#each lessonHistory as version, index (index)}
<button
onclick={() => updateContentVersion(version, index)}
class="flex w-full cursor-pointer items-start p-4 px-10 hover:bg-gray-200 dark:hover:bg-neutral-700 {index ==
@@ -1,6 +1,6 @@
<script lang="ts">
import { HTMLRender, TextEditor } from '$features/ui';
import { sanitizeHtml } from '@cio/ui/tools/sanitize';
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
import { isHtmlValueEmpty } from '$lib/utils/functions/toHtml';
import { lessonApi } from '$features/course/api';
import { t } from '$lib/utils/functions/translations';
@@ -43,10 +43,6 @@
}
const content = $derived(lessonApi.translations[lessonId]?.[lessonApi.currentLocale] || '');
$effect(() => {
console.log('content', content);
});
</script>
{#if mode === MODES.edit}
@@ -67,7 +63,7 @@
<!-- View Mode -->
{#if !isHtmlValueEmpty(content)}
<HTMLRender>
{@html sanitizeHtml(content)}
<SafeHtmlContent {content} />
</HTMLRender>
{:else if hasAtLeastOneTranslation}
<p class="text-md py-2 font-normal italic dark:text-white">
@@ -1,12 +1,12 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { Badge } from '@cio/ui/base/badge';
import * as Table from '@cio/ui/base/table';
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import { isMobileStore } from '@cio/ui/hooks/is-mobile.svelte';
import { t } from '$lib/utils/functions/translations';
import CoursePublishBadge from './course-publish-badge.svelte';
interface Props {
id?: string;
@@ -88,13 +88,7 @@
<Table.Cell>{totalLessons}</Table.Cell>
<Table.Cell>{totalStudents}</Table.Cell>
<Table.Cell>
<Badge variant={isPublished ? 'default' : 'outline'}>
{#if isPublished}
{$t('courses.course_card.published')}
{:else}
{$t('courses.course_card.unpublished')}
{/if}
</Badge>
<CoursePublishBadge {isPublished} />
</Table.Cell>
{/if}
<Table.Cell class="text-center">
@@ -4,7 +4,7 @@
import { calDateDiff } from '$lib/utils/functions/date';
import { isHtmlValueEmpty } from '$lib/utils/functions/toHtml';
import { sanitizeHtml } from '@cio/ui/tools/sanitize';
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
import { t } from '$lib/utils/functions/translations';
import type { Feed } from '$features/course/utils/types';
@@ -59,7 +59,7 @@
{#if !isHtmlValueEmpty(feed.content || '')}
<HTMLRender className="w-[80%]">
<div>
{@html sanitizeHtml(feed.content || '')}
<SafeHtmlContent content={feed.content || ''} />
</div>
</HTMLRender>
{/if}
@@ -35,7 +35,7 @@
import { IconButton } from '@cio/ui/custom/icon-button';
import UpdateDescription from '$features/course/components/exercise/update-description.svelte';
import { ContentNavigationActions } from '$features/course/components/lesson';
import { RoleBasedSecurity } from '$features/ui';
import { RefreshPageData, RoleBasedSecurity } from '$features/ui';
import { getOrderedNavigableContent } from '$features/course/utils/content';
import { Empty } from '@cio/ui/custom/empty';
import VideoIcon from '@lucide/svelte/icons/video';
@@ -266,6 +266,8 @@
</div>
</div>
{/if}
<RefreshPageData />
</RoleBasedSecurity>
</div>
</Page.Action>
@@ -18,6 +18,7 @@
import { globalStore } from '$lib/utils/store/app';
import { currentOrg } from '$lib/utils/store/org';
import { snackbar } from '$features/ui/snackbar/store';
import { RefreshPageData } from '$features/ui';
import LessonVersionHistory from '$features/course/components/lesson/lesson-version-history.svelte';
import { courseApi, lessonApi } from '$features/course/api';
import { isHtmlValueEmpty } from '$lib/utils/functions/toHtml';
@@ -260,6 +261,7 @@
if (!lessonApi.lesson || !lessonId) return;
if (!lessonApi.isDirty) return;
if ($isLoading) return;
console.log('autoSave');
autoSave();
});
@@ -268,6 +270,7 @@
let didHandleExitEdit = false;
$effect(() => {
if (!lessonId) return;
console.log('didHandleExitEdit');
const currentParam = $page.url.searchParams.get('mode');
const prev = prevModeParam;
@@ -345,8 +348,10 @@
</IconButton>
</div>
<LanguageSelector />
<RefreshPageData onRefresh={() => lessonApi.get(courseId, lessonId)} />
</RoleBasedSecurity>
<LanguageSelector />
</div>
</Page.Action>
</Page.Header>
@@ -0,0 +1,27 @@
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { snackbar } from '$features/ui/snackbar/store';
interface OpenCoursePreviewOptions {
courseId: string;
courseSlug?: string | null;
currentOrgDomain?: string;
}
export function openCoursePreview({ courseId, courseSlug, currentOrgDomain = '' }: OpenCoursePreviewOptions) {
if (!courseSlug) {
snackbar.info('course.header.preview_missing_slug');
if (courseId) {
goto(resolve(`/courses/${courseId}/settings`, {}));
}
return false;
}
const origin = currentOrgDomain || window.location.origin;
const link = new URL(resolve(`/course/${courseSlug}`, {}), origin).toString();
window.open(link, '_blank', 'noopener,noreferrer');
return true;
}
@@ -8,10 +8,12 @@
import * as Select from '@cio/ui/base/select';
import * as Table from '@cio/ui/base/table';
import { Empty } from '@cio/ui/custom/empty';
import { IconButton } from '@cio/ui/custom/icon-button';
import { InputField } from '@cio/ui/custom/input-field';
import { TextareaField } from '@cio/ui/custom/textarea-field';
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import TagIcon from '@lucide/svelte/icons/tag';
import { tagApi } from '$features/tag/api';
@@ -222,7 +224,7 @@
</Table.Header>
<Table.Body>
{#each tagApi.tagGroups as group (group.id)}
<Table.Row class="ui:bg-muted/20">
<Table.Row class="group ui:bg-muted/20">
<Table.Cell colspan={5}>
<div class="flex items-start justify-between gap-4 py-1">
<div class="space-y-1">
@@ -231,9 +233,22 @@
<p class="ui:text-muted-foreground max-w-[320px] truncate text-xs">{group.description}</p>
{/if}
</div>
<Button variant="ghost-default" size="sm" onclick={() => openEditTagGroupModal(group)}>
{$t('tags_admin.actions.edit_group')}
</Button>
<DropdownMenu.Root>
<DropdownMenu.Trigger class="flex shrink-0 items-center justify-center">
<IconButton
variant="outline"
class="pointer-events-none opacity-0 transition-opacity group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100"
aria-label={$t('tags_admin.actions.actions_menu')}
>
<EllipsisVerticalIcon size={16} />
</IconButton>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item onclick={() => openEditTagGroupModal(group)}>
{$t('tags_admin.actions.edit_group')}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
</Table.Cell>
</Table.Row>
@@ -246,7 +261,7 @@
</Table.Row>
{:else}
{#each group.tags as tag (tag.id)}
<Table.Row>
<Table.Row class="group">
<Table.Cell>
<div class="flex items-center gap-2">
<span
@@ -269,9 +284,22 @@
</Table.Cell>
<Table.Cell>{group.name}</Table.Cell>
<Table.Cell class="text-right">
<Button variant="ghost-default" size="sm" onclick={() => openEditTagModal(tag)}>
{$t('tags_admin.actions.edit_tag')}
</Button>
<DropdownMenu.Root>
<DropdownMenu.Trigger class="inline-flex items-center justify-center">
<IconButton
variant="outline"
class="pointer-events-none opacity-0 transition-opacity group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100"
aria-label={$t('tags_admin.actions.actions_menu')}
>
<EllipsisVerticalIcon size={16} />
</IconButton>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item onclick={() => openEditTagModal(tag)}>
{$t('tags_admin.actions.edit_tag')}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
</Table.Row>
{/each}
@@ -14,15 +14,23 @@
interface Props {
activity: Activity;
className?: string;
classes?: {
container?: string;
icon?: string;
title?: string;
percentage?: string;
description?: string;
};
}
let { activity, className = '' }: Props = $props();
let { activity, className = '', classes = {} }: Props = $props();
</script>
<div
class={cn(
'flex w-full flex-col items-center justify-evenly gap-2 rounded-xl border p-3 md:flex-row md:p-5',
className
'flex w-full flex-col items-center justify-evenly gap-2 rounded-xl border p-3 md:flex-row md:px-5 md:py-3',
className,
classes.container
)}
>
<div class="bg-primary-200 w-fit rounded-full p-4 text-black">
@@ -30,17 +38,19 @@
</div>
<div>
<p class="text-center text-sm font-medium text-gray-600 md:text-left dark:text-gray-200">
<p class={cn('text-center text-sm font-medium text-gray-600 md:text-left dark:text-gray-200', classes.title)}>
{activity.title}
</p>
<p class="text-center text-2xl md:text-left">
<p class={cn('text-center text-2xl md:text-left', classes.percentage)}>
{activity.percentage}
{#if !activity.hidePercentage}
%
{/if}
</p>
{#if activity.description}
<p class="text-center text-xs text-gray-500 md:text-left md:text-sm dark:text-gray-300">
<p
class={cn('text-center text-xs text-gray-500 md:text-left md:text-sm dark:text-gray-300', classes.description)}
>
{activity.description}
</p>
{/if}
@@ -1,4 +1,5 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { page } from '$app/state';
import * as Avatar from '@cio/ui/base/avatar';
import { t } from '$lib/utils/functions/translations';
@@ -19,7 +20,8 @@
hideGoogleAuth?: boolean;
redirectPathname?: string;
handleSubmit?: () => void;
children?: import('svelte').Snippet;
children?: Snippet;
getPasswordAuthAlternative?: Snippet;
}
let {
@@ -30,7 +32,8 @@
hideGoogleAuth = false,
redirectPathname = '',
handleSubmit = () => {},
children
children,
getPasswordAuthAlternative
}: Props = $props();
async function signInWithGoogle() {
@@ -100,12 +103,17 @@
<Separator />
<span class="ui:bg-card ui:text-muted-foreground absolute px-2 text-sm"> Or continue With </span>
</div>
<Button variant="outline" onclick={signInWithGoogle} disabled={isLoading} class="w-full">
<GoogleIconColored />
<span>
{isLogin ? $t('login.login_with_google') : $t('login.signup_with_google')}
</span>
</Button>
{#if getPasswordAuthAlternative}
{@render getPasswordAuthAlternative()}
{:else}
<Button variant="outline" onclick={signInWithGoogle} disabled={isLoading} class="w-full">
<GoogleIconColored />
<span>
{isLogin ? $t('login.login_with_google') : $t('login.signup_with_google')}
</span>
</Button>
{/if}
</div>
{/if}
</Card.Content>
@@ -1,5 +1,6 @@
<script lang="ts">
import { sanitizeHtml } from '@cio/ui/tools/sanitize';
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
interface Props {
code?: string;
}
@@ -8,5 +9,5 @@
</script>
<div class="mb-3 rounded-md bg-gray-200 p-5 dark:bg-gray-500">
{@html sanitizeHtml(code)}
<SafeHtmlContent content={code} />
</div>
@@ -32,6 +32,7 @@
import { Button } from '@cio/ui/base/button';
import { courseApi } from '$features/course/api';
import { generateSlug } from '@cio/utils/functions';
import { openCoursePreview } from '$features/course/utils/course-preview';
import * as Sidebar from '@cio/ui/base/sidebar';
import { useSidebar } from '@cio/ui/base/sidebar';
@@ -164,9 +165,12 @@
syncCourseStore(course);
}
async function handlePreview() {
const link = `${$currentOrgDomain}/course/${course.slug}`;
window.open(link, '_blank');
function handlePreview() {
openCoursePreview({
courseId,
courseSlug: course.slug,
currentOrgDomain: $currentOrgDomain
});
}
function setter(value: any, setterKey: string) {
@@ -193,7 +197,7 @@
</Button>
<HoverableItem>
{#snippet children(isHovered)}
<IconButton onclick={handlePreview} disabled={loading || !course.slug}>
<IconButton onclick={handlePreview} disabled={loading} aria-label={$t('course.header.preview')}>
<PreviewIcon {isHovered} size={16} />
</IconButton>
{/snippet}
@@ -5,7 +5,7 @@
import { isCourseFree, calcCourseDiscount } from '$lib/utils/functions/course';
import { currentOrg } from '$lib/utils/store/org';
import { goto } from '$app/navigation';
import { sanitizeHtml } from '@cio/ui/tools/sanitize';
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
import HTMLRender from '$features/ui/html-render.svelte';
import PaymentModal from './payment-modal.svelte';
import type { Course } from '$features/course/utils/types';
@@ -175,7 +175,9 @@
<!-- Gift Container -->
{#if courseData?.metadata?.reward?.show}
<div class="flex flex-col items-center border-t border-b border-gray-300 p-10">
<HTMLRender>{@html sanitizeHtml(get(courseData, 'metadata.reward.description', '') as string)}</HTMLRender>
<HTMLRender
><SafeHtmlContent content={get(courseData, 'metadata.reward.description', '') as string} /></HTMLRender
>
</div>
{/if}
</aside>
@@ -4,7 +4,7 @@
import { page } from '$app/state';
import { onMount, onDestroy } from 'svelte';
import { resolve } from '$app/paths';
import { sanitizeHtml } from '@cio/ui/tools/sanitize';
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
import PlayIcon from '@lucide/svelte/icons/play';
import { getLectureNo } from '$features/course/utils/functions';
@@ -215,7 +215,7 @@
</h3>
<ul class="list font-light">
<HTMLRender>{@html sanitizeHtml(get(courseData, 'metadata.requirements', ''))}</HTMLRender>
<HTMLRender><SafeHtmlContent content={get(courseData, 'metadata.requirements', '')} /></HTMLRender>
</ul>
</NavSection>
{/if}
@@ -228,7 +228,7 @@
</h3>
<HTMLRender className="dark:text-white text-sm font-light">
{@html sanitizeHtml(get(courseData, 'metadata.description', ''))}
<SafeHtmlContent content={get(courseData, 'metadata.description', '')} />
</HTMLRender>
</NavSection>
{/if}
@@ -238,7 +238,7 @@
<NavSection id="goals">
<h3 class="mt-0 mb-3 text-2xl">{$t('course.navItem.landing_page.learn')}</h3>
<ul class="list font-light">
<HTMLRender>{@html sanitizeHtml(get(courseData, 'metadata.goals', ''))}</HTMLRender>
<HTMLRender><SafeHtmlContent content={get(courseData, 'metadata.goals', '')} /></HTMLRender>
</ul>
</NavSection>
{/if}
@@ -17,6 +17,7 @@ export * from './question';
export { default as UploadWidget } from './upload-widget/upload-widget.svelte';
export { default as PageLoadProgress } from './page-load-progress.svelte';
export { default as PoweredBy } from './upgrade-powered-by.svelte';
export { default as RefreshPageData } from './refresh-page-data.svelte';
export { default as RoleBasedSecurity } from './role-based-security.svelte';
export { default as Search } from './search.svelte';
export { default as SenjaEmbed } from './senja-embed.svelte';
@@ -108,7 +108,36 @@ const baseNavConfig: NavItemConfig[] = [
icon: BracesIcon,
requiresAdmin: true,
disableWhenNotAdmin: true,
matchPattern: '^/org/[^/]+/automation(/.*)?$'
useHashUrl: true,
matchPattern: '^/org/[^/]+/automation(/.*)?$',
items: [
{
titleKey: 'automation.tabs.mcp',
path: '/automation/mcp'
},
{
titleKey: 'automation.tabs.zapier',
path: '/automation/zapier'
},
{
titleKey: 'automation.tabs.api',
path: '/automation/api'
}
],
nestedRoutes: [
{
path: 'mcp',
titleKey: 'automation.tabs.mcp'
},
{
path: 'zapier',
titleKey: 'automation.tabs.zapier'
},
{
path: 'api',
titleKey: 'automation.tabs.api'
}
]
},
{
titleKey: 'org_navigation.settings',
@@ -0,0 +1,43 @@
<script lang="ts">
import { invalidateAll } from '$app/navigation';
import RefreshCcwIcon from '@lucide/svelte/icons/refresh-ccw';
import { IconButton } from '@cio/ui/custom/icon-button';
import { t } from '$lib/utils/functions/translations';
interface Props {
disabled?: boolean;
onRefresh?: () => Promise<unknown> | unknown;
}
let { disabled = false, onRefresh }: Props = $props();
let isRefreshing = $state(false);
async function handleRefresh() {
if (disabled || isRefreshing) return;
isRefreshing = true;
try {
if (onRefresh) {
await onRefresh();
return;
}
await invalidateAll();
} finally {
isRefreshing = false;
}
}
</script>
<IconButton
onclick={handleRefresh}
disabled={disabled || isRefreshing}
tooltip={t.get('common.refresh')}
tooltipSide="bottom"
aria-label={t.get('common.refresh')}
>
<RefreshCcwIcon class={isRefreshing ? 'animate-spin' : ''} size={16} />
</IconButton>
@@ -0,0 +1,71 @@
// SaaS default CSP domains, baked in at build time.
// Self-hosted (adapter-node) starts with empty lists; runtime domains
// are added via env vars in hooks.server.ts so pre-built Docker images stay configurable.
const saasDefaults = {
scriptSrc: [
'https://assets.cdn.clsrio.com',
'https://cdnjs.cloudflare.com',
'https://*.posthog.com',
'https://*.senja.io',
'https://umami.hz.oncws.com',
'https://www.youtube.com',
'https://youtube.com',
'https://google.com'
],
styleSrc: [
'https://cdn.plyr.io',
'https://unpkg.com/[email protected]/dist/katex.min.css',
'https://assets.cdn.clsrio.com/eqneditor_1.css',
'https://fonts.googleapis.com'
],
connectSrc: [
'https://*.classroomio.com',
'https://classroomio.com',
'https://app.classroomio.com',
'https://api.classroomio.com',
'https://pgrest.classroomio.com',
'https://play.classroomio.com',
'wss://*.classroomio.com',
'https://assets.cdn.clsrio.com',
'https://cdn.plyr.io',
'https://*.posthog.com',
'https://umami.hz.oncws.com',
'https://*.r2.cloudflarestorage.com',
'https://*.senja.io',
'https://*.ytimg.com',
'https://noembed.com'
],
frameSrc: [
'https://www.youtube.com',
'https://youtube.com',
'https://www.youtube-nocookie.com',
'https://www.google.com',
'https://google.com'
],
fontSrc: ['https://fonts.gstatic.com', 'https://cdn.plyr.io'],
mediaSrc: ['https:']
};
/**
* @param {boolean} isSelfHosted
* @param {string | undefined} serverUrl - PUBLIC_SERVER_URL, added to connect-src for SaaS builds
*/
export function getCspDomains(isSelfHosted, serverUrl) {
if (isSelfHosted) {
return {
scriptSrc: [],
styleSrc: [],
connectSrc: [],
frameSrc: [],
fontSrc: [],
mediaSrc: [],
apiOrigin: null
};
}
return {
...saasDefaults,
apiOrigin: serverUrl ?? null
};
}
+75
View File
@@ -0,0 +1,75 @@
function parseCspDomains(value: string | undefined): string[] {
if (!value) return [];
return value
.split(',')
.map((d) => d.trim())
.filter(Boolean)
.map((d) => (d.startsWith('http://') || d.startsWith('https://') ? d : `https://${d}`));
}
function buildCspExtensions(): Record<string, string[]> {
const allDomains = parseCspDomains(process.env.ALLOWED_EXTERNAL_DOMAINS);
const or = (envVar: string) => (allDomains.length ? allDomains : parseCspDomains(process.env[envVar]));
const scriptSrc = or('CSP_SCRIPT_SRC_DOMAINS');
const styleSrc = or('CSP_STYLE_SRC_DOMAINS');
const connectSrcBase = or('CSP_CONNECT_SRC_DOMAINS');
const frameSrc = or('CSP_FRAME_SRC_DOMAINS');
const fontSrc = or('CSP_FONT_SRC_DOMAINS');
const mediaSrc = or('CSP_MEDIA_SRC_DOMAINS');
const serverUrl = process.env.PUBLIC_SERVER_URL;
const connectSrc = serverUrl ? [...connectSrcBase, serverUrl] : connectSrcBase;
const extensions: Record<string, string[]> = {};
if (scriptSrc.length) extensions['script-src'] = scriptSrc;
if (styleSrc.length) {
extensions['style-src'] = styleSrc;
extensions['style-src-elem'] = styleSrc;
}
if (connectSrc.length) extensions['connect-src'] = connectSrc;
if (frameSrc.length) extensions['frame-src'] = frameSrc;
if (fontSrc.length) extensions['font-src'] = fontSrc;
if (mediaSrc.length) {
extensions['img-src'] = mediaSrc;
extensions['media-src'] = mediaSrc;
}
return extensions;
}
let cachedExtensions: Record<string, string[]> | null = null;
function getExtensions(): Record<string, string[]> {
if (!cachedExtensions) cachedExtensions = buildCspExtensions();
return cachedExtensions;
}
function extendHeader(header: string, extensions: Record<string, string[]>): string {
return header
.split(';')
.map((d) => d.trim())
.filter(Boolean)
.map((directive) => {
const spaceIdx = directive.indexOf(' ');
const name = spaceIdx === -1 ? directive : directive.substring(0, spaceIdx);
const extra = extensions[name];
return extra?.length ? `${directive} ${extra.join(' ')}` : directive;
})
.join('; ');
}
export function applyCspExtensions(response: Response): Response {
const extensions = getExtensions();
if (Object.keys(extensions).length === 0) return response;
for (const name of ['content-security-policy', 'content-security-policy-report-only']) {
const header = response.headers.get(name);
if (header) {
response.headers.set(name, extendHeader(header, extensions));
}
}
return response;
}
@@ -3,6 +3,8 @@ import { browser } from '$app/environment';
export const isHtmlValueEmpty = (html: string) => {
if (!html || typeof html !== 'string') return true;
if (/<svg\b/i.test(html)) return false;
if (!document || !browser) return false;
return getTextFromHTML(html) === '';
@@ -1,5 +1,6 @@
{
"common": {
"refresh": "Opdater",
"unsaved_changes": {
"message": "Du har ikke-gemte ændringer. Er du sikker på, at du vil tage afsted?"
}
@@ -1508,6 +1509,10 @@
"placeholder": "Søg efter lektioner eller øvelser",
"empty": "Ingen resultater fundet"
},
"header": {
"preview": "Preview course",
"preview_missing_slug": "Generate and save a course slug before previewing."
},
"sidebar": {
"back_to_course": "Tilbage til kurset",
"content_count": {
@@ -1582,6 +1587,12 @@
}
},
"course_filter": {
"date_created_newest": "Date Created: Newest first",
"date_created_oldest": "Date Created: Oldest first",
"published_first": "Published: Published first",
"unpublished_first": "Published: Unpublished first",
"most_lessons": "Lessons: Most first",
"fewest_lessons": "Lessons: Fewest first",
"date_created": "Dato oprettet",
"published": "Udgivet",
"lessons": "Lektioner"
@@ -1592,6 +1603,9 @@
"clear_all": "Ryd alle",
"popover_title": "Filtre",
"sort": "Sorter",
"order": "Rækkefølge",
"ascending": "Asc",
"descending": "Desc",
"tags": "Tags",
"empty_tags": "Ingen tags tilgængelige"
}
@@ -2212,7 +2226,6 @@
}
},
"login": {
"sso_enabled": "{provider} er aktiveret for dit domæne",
"sso_redirect_error": "SSO-logon er forkert konfigureret. Indstil PUBLIC_SERVER_URL til din API-server.",
"sso_required": "Log venligst ind med SSO. Adgangskodelogin er deaktiveret.",
"sign_in_with_sso": "Log ind med {provider}",
@@ -1,5 +1,6 @@
{
"common": {
"refresh": "Aktualisieren",
"unsaved_changes": {
"message": "Sie haben nicht gespeicherte Änderungen. Sind Sie sicher, dass Sie gehen möchten?"
}
@@ -1508,6 +1509,10 @@
"placeholder": "Suchen Sie nach Lektionen oder Übungen",
"empty": "Keine Ergebnisse gefunden"
},
"header": {
"preview": "Preview course",
"preview_missing_slug": "Generate and save a course slug before previewing."
},
"sidebar": {
"back_to_course": "Zurück zum Kurs",
"content_count": {
@@ -1582,6 +1587,12 @@
}
},
"course_filter": {
"date_created_newest": "Date Created: Newest first",
"date_created_oldest": "Date Created: Oldest first",
"published_first": "Published: Published first",
"unpublished_first": "Published: Unpublished first",
"most_lessons": "Lessons: Most first",
"fewest_lessons": "Lessons: Fewest first",
"date_created": "Erstellungsdatum",
"published": "Veröffentlicht",
"lessons": "Lektionen"
@@ -1592,6 +1603,9 @@
"clear_all": "Alles löschen",
"popover_title": "Filter",
"sort": "Sortieren",
"order": "Reihenfolge",
"ascending": "Asc",
"descending": "Desc",
"tags": "Schlagworte",
"empty_tags": "Keine Tags verfügbar"
}
@@ -2212,7 +2226,6 @@
}
},
"login": {
"sso_enabled": "{provider} ist für Ihre Domain aktiviert",
"sso_redirect_error": "Die SSO-Anmeldung ist falsch konfiguriert. Bitte legen Sie PUBLIC_SERVER_URL auf Ihren API-Server fest.",
"sso_required": "Bitte melden Sie sich mit SSO an. Die Passwortanmeldung ist deaktiviert.",
"sign_in_with_sso": "Melden Sie sich mit {provider} an",
@@ -93,6 +93,9 @@
"placeholder": "Search"
}
},
"common": {
"refresh": "Refresh"
},
"login": {
"welcome": "Welcome back",
"email": "Your email",
@@ -240,7 +243,7 @@
},
"login": {
"or_continue_with": "Or continue with email and password",
"sso_enabled": "{provider} is enabled for your domain",
"sso_enabled": "{provider} is enabled for this organization",
"sso_redirect_error": "SSO sign-in is misconfigured. Please set PUBLIC_SERVER_URL to your API server.",
"sso_required": "Please sign in with SSO. Password login is disabled.",
"sign_in_with_sso": "Sign in with {provider}",
@@ -566,6 +569,7 @@
"actions": {
"create_tag": "Create Tag",
"create_group": "Create Tag Group",
"actions_menu": "Tag actions",
"edit_group": "Edit Group",
"edit_tag": "Edit Tag",
"save": "Save",
@@ -865,10 +869,19 @@
"clear_all": "Clear all",
"popover_title": "Filters",
"sort": "Sort",
"order": "Order",
"ascending": "Asc",
"descending": "Desc",
"tags": "Tags",
"empty_tags": "No tags available"
},
"course_filter": {
"date_created_newest": "Date Created: Newest first",
"date_created_oldest": "Date Created: Oldest first",
"published_first": "Published: Published first",
"unpublished_first": "Published: Unpublished first",
"most_lessons": "Lessons: Most first",
"fewest_lessons": "Lessons: Fewest first",
"date_created": "Date Created",
"published": "Published",
"lessons": "Lessons"
@@ -947,6 +960,10 @@
"placeholder": "Search lessons or exercises",
"empty": "No results found"
},
"header": {
"preview": "Preview course",
"preview_missing_slug": "Generate and save a course slug before previewing."
},
"navItem": {
"news_feed": {
"heading": "News Feed",
@@ -2083,6 +2100,10 @@
"created_at_label": "Created:",
"last_used_label": "Last used:",
"never_used": "Never",
"table_key": "Key",
"table_status": "Status",
"table_created": "Created",
"table_last_used": "Last used",
"scopes_label": "Scopes:",
"revoke_confirm": "Revoke this automation key?",
"rotate_confirm": "Rotate this automation key?"
@@ -2097,9 +2118,7 @@
"keys_description": "Use one MCP key per integration so access can be rotated or revoked cleanly.",
"rate_limits_title": "Rate limits",
"rate_limits_description": "Read / write / publish requests allowed per minute.",
"used_label": "Used:",
"per_key_label": "Per key:",
"per_org_label": "Per org:"
"used_label": "Used:"
},
"keys": {
"title": "MCP keys",
@@ -1,5 +1,6 @@
{
"common": {
"refresh": "Actualizar",
"unsaved_changes": {
"message": "Tienes cambios sin guardar. ¿Estás seguro de que quieres irte?"
}
@@ -1508,6 +1509,10 @@
"placeholder": "Buscar lecciones o ejercicios",
"empty": "No se encontraron resultados"
},
"header": {
"preview": "Preview course",
"preview_missing_slug": "Generate and save a course slug before previewing."
},
"sidebar": {
"back_to_course": "volver al curso",
"content_count": {
@@ -1582,6 +1587,12 @@
}
},
"course_filter": {
"date_created_newest": "Date Created: Newest first",
"date_created_oldest": "Date Created: Oldest first",
"published_first": "Published: Published first",
"unpublished_first": "Published: Unpublished first",
"most_lessons": "Lessons: Most first",
"fewest_lessons": "Lessons: Fewest first",
"date_created": "Fecha de creación",
"published": "Publicado",
"lessons": "Lecciones"
@@ -1592,6 +1603,9 @@
"clear_all": "Borrar todo",
"popover_title": "Filtros",
"sort": "ordenar",
"order": "Orden",
"ascending": "Asc",
"descending": "Desc",
"tags": "Etiquetas",
"empty_tags": "No hay etiquetas disponibles"
}
@@ -2212,7 +2226,6 @@
}
},
"login": {
"sso_enabled": "{provider} está habilitado para tu dominio",
"sso_redirect_error": "El inicio de sesión SSO está mal configurado. Configure PUBLIC_SERVER_URL en su servidor API.",
"sso_required": "Inicie sesión con SSO. El inicio de sesión con contraseña está deshabilitado.",
"sign_in_with_sso": "Iniciar sesión con {proveedor}",
@@ -1,5 +1,6 @@
{
"common": {
"refresh": "Actualiser",
"unsaved_changes": {
"message": "Vous avez des modifications non enregistrées. Êtes-vous sûr de vouloir partir ?"
}
@@ -1508,6 +1509,10 @@
"placeholder": "Rechercher des leçons ou des exercices",
"empty": "Aucun résultat trouvé"
},
"header": {
"preview": "Preview course",
"preview_missing_slug": "Generate and save a course slug before previewing."
},
"sidebar": {
"back_to_course": "Retour au cours",
"content_count": {
@@ -1582,6 +1587,12 @@
}
},
"course_filter": {
"date_created_newest": "Date Created: Newest first",
"date_created_oldest": "Date Created: Oldest first",
"published_first": "Published: Published first",
"unpublished_first": "Published: Unpublished first",
"most_lessons": "Lessons: Most first",
"fewest_lessons": "Lessons: Fewest first",
"date_created": "Date de création",
"published": "Publié",
"lessons": "Leçons"
@@ -1592,6 +1603,9 @@
"clear_all": "Tout effacer",
"popover_title": "Filtres",
"sort": "Trier",
"order": "Ordre",
"ascending": "Asc",
"descending": "Desc",
"tags": "Balises",
"empty_tags": "Aucune balise disponible"
}
@@ -2212,7 +2226,6 @@
}
},
"login": {
"sso_enabled": "{provider} est activé pour votre domaine",
"sso_redirect_error": "La connexion SSO est mal configurée. Veuillez définir PUBLIC_SERVER_URL sur votre serveur API.",
"sso_required": "Veuillez vous connecter avec SSO. La connexion par mot de passe est désactivée.",
"sign_in_with_sso": "Connectez-vous avec {provider}",
@@ -1,5 +1,6 @@
{
"common": {
"refresh": "रीफ़्रेश करें",
"unsaved_changes": {
"message": "आपके पास सहेजे नहीं गए परिवर्तन हैं. क्या आपकी वाकई छोड़ने की इच्छा है?"
}
@@ -1508,6 +1509,10 @@
"placeholder": "पाठ या अभ्यास खोजें",
"empty": "कोई परिणाम नहीं मिला"
},
"header": {
"preview": "Preview course",
"preview_missing_slug": "Generate and save a course slug before previewing."
},
"sidebar": {
"back_to_course": "पाठ्यक्रम पर वापस जाएँ",
"content_count": {
@@ -1582,6 +1587,12 @@
}
},
"course_filter": {
"date_created_newest": "Date Created: Newest first",
"date_created_oldest": "Date Created: Oldest first",
"published_first": "Published: Published first",
"unpublished_first": "Published: Unpublished first",
"most_lessons": "Lessons: Most first",
"fewest_lessons": "Lessons: Fewest first",
"date_created": "दिनांक निर्मित",
"published": "प्रकाशित",
"lessons": "सबक"
@@ -1592,6 +1603,9 @@
"clear_all": "सब साफ़ करें",
"popover_title": "फिल्टर",
"sort": "क्रमबद्ध करें",
"order": "क्रम",
"ascending": "Asc",
"descending": "Desc",
"tags": "टैग",
"empty_tags": "कोई टैग उपलब्ध नहीं है"
}
@@ -2212,7 +2226,6 @@
}
},
"login": {
"sso_enabled": "आपके डोमेन के लिए {प्रदाता} सक्षम है",
"sso_redirect_error": "SSO साइन-इन ग़लत कॉन्फ़िगर किया गया है. कृपया अपने एपीआई सर्वर पर PUBLIC_SERVER_URL सेट करें।",
"sso_required": "कृपया एसएसओ के साथ साइन इन करें। पासवर्ड लॉगिन अक्षम है.",
"sign_in_with_sso": "{प्रदाता} के साथ साइन इन करें",
@@ -1,5 +1,6 @@
{
"common": {
"refresh": "Odśwież",
"unsaved_changes": {
"message": "Masz niezapisane zmiany. Czy na pewno chcesz wyjść?"
}
@@ -1508,6 +1509,10 @@
"placeholder": "Wyszukaj lekcje lub ćwiczenia",
"empty": "Nie znaleziono żadnych wyników"
},
"header": {
"preview": "Preview course",
"preview_missing_slug": "Generate and save a course slug before previewing."
},
"sidebar": {
"back_to_course": "Powrót na kurs",
"content_count": {
@@ -1582,6 +1587,12 @@
}
},
"course_filter": {
"date_created_newest": "Date Created: Newest first",
"date_created_oldest": "Date Created: Oldest first",
"published_first": "Published: Published first",
"unpublished_first": "Published: Unpublished first",
"most_lessons": "Lessons: Most first",
"fewest_lessons": "Lessons: Fewest first",
"date_created": "Data utworzenia",
"published": "Opublikowano",
"lessons": "Lekcje"
@@ -1592,6 +1603,9 @@
"clear_all": "Wyczyść wszystko",
"popover_title": "Filtry",
"sort": "Sortuj",
"order": "Kolejność",
"ascending": "Asc",
"descending": "Desc",
"tags": "Tagi",
"empty_tags": "Brak dostępnych tagów"
}
@@ -2212,7 +2226,6 @@
}
},
"login": {
"sso_enabled": "Usługa {provider} jest włączona w Twojej domenie",
"sso_redirect_error": "Logowanie jednokrotne jest błędnie skonfigurowane. Ustaw PUBLIC_SERVER_URL na swój serwer API.",
"sso_required": "Proszę zalogować się za pomocą SSO. Logowanie hasłem jest wyłączone.",
"sign_in_with_sso": "Zaloguj się przez {provider}",
@@ -1,5 +1,6 @@
{
"common": {
"refresh": "Atualizar",
"unsaved_changes": {
"message": "Você tem alterações não salvas. Tem certeza de que deseja sair?"
}
@@ -1508,6 +1509,10 @@
"placeholder": "Pesquise aulas ou exercícios",
"empty": "Nenhum resultado encontrado"
},
"header": {
"preview": "Preview course",
"preview_missing_slug": "Generate and save a course slug before previewing."
},
"sidebar": {
"back_to_course": "De volta ao curso",
"content_count": {
@@ -1582,6 +1587,12 @@
}
},
"course_filter": {
"date_created_newest": "Date Created: Newest first",
"date_created_oldest": "Date Created: Oldest first",
"published_first": "Published: Published first",
"unpublished_first": "Published: Unpublished first",
"most_lessons": "Lessons: Most first",
"fewest_lessons": "Lessons: Fewest first",
"date_created": "Data de criação",
"published": "Publicado",
"lessons": "Lições"
@@ -1592,6 +1603,9 @@
"clear_all": "Limpar tudo",
"popover_title": "Filtros",
"sort": "Classificar",
"order": "Ordem",
"ascending": "Asc",
"descending": "Desc",
"tags": "Etiquetas",
"empty_tags": "Nenhuma tag disponível"
}
@@ -2212,7 +2226,6 @@
}
},
"login": {
"sso_enabled": "{provider} está ativado para seu domínio",
"sso_redirect_error": "O login do SSO está configurado incorretamente. Defina PUBLIC_SERVER_URL para seu servidor API.",
"sso_required": "Faça login com SSO. O login por senha está desabilitado.",
"sign_in_with_sso": "Faça login com {provedor}",
@@ -1,5 +1,6 @@
{
"common": {
"refresh": "Обновить",
"unsaved_changes": {
"message": "У вас есть несохраненные изменения. Вы уверены, что хотите уйти?"
}
@@ -1508,6 +1509,10 @@
"placeholder": "Поиск уроков или упражнений",
"empty": "Результаты не найдены"
},
"header": {
"preview": "Preview course",
"preview_missing_slug": "Generate and save a course slug before previewing."
},
"sidebar": {
"back_to_course": "Вернуться к курсу",
"content_count": {
@@ -1582,6 +1587,12 @@
}
},
"course_filter": {
"date_created_newest": "Date Created: Newest first",
"date_created_oldest": "Date Created: Oldest first",
"published_first": "Published: Published first",
"unpublished_first": "Published: Unpublished first",
"most_lessons": "Lessons: Most first",
"fewest_lessons": "Lessons: Fewest first",
"date_created": "Дата создания",
"published": "Опубликовано",
"lessons": "Уроки"
@@ -1592,6 +1603,9 @@
"clear_all": "Очистить все",
"popover_title": "Фильтры",
"sort": "Сортировать",
"order": "Порядок",
"ascending": "Asc",
"descending": "Desc",
"tags": "Теги",
"empty_tags": "Нет доступных тегов"
}
@@ -2212,7 +2226,6 @@
}
},
"login": {
"sso_enabled": "{provider} включен для вашего домена",
"sso_redirect_error": "Вход в систему единого входа настроен неправильно. Установите PUBLIC_SERVER_URL для вашего сервера API.",
"sso_required": "Пожалуйста, войдите с помощью SSO. Вход по паролю отключен.",
"sign_in_with_sso": "Войдите через {provider}",
@@ -1,5 +1,6 @@
{
"common": {
"refresh": "Làm mới",
"unsaved_changes": {
"message": "Bạn có những thay đổi chưa được lưu. Bạn có chắc chắn muốn rời đi không?"
}
@@ -1508,6 +1509,10 @@
"placeholder": "Tìm kiếm bài học hoặc bài tập",
"empty": "Không tìm thấy kết quả nào"
},
"header": {
"preview": "Preview course",
"preview_missing_slug": "Generate and save a course slug before previewing."
},
"sidebar": {
"back_to_course": "Quay lại khóa học",
"content_count": {
@@ -1582,6 +1587,12 @@
}
},
"course_filter": {
"date_created_newest": "Date Created: Newest first",
"date_created_oldest": "Date Created: Oldest first",
"published_first": "Published: Published first",
"unpublished_first": "Published: Unpublished first",
"most_lessons": "Lessons: Most first",
"fewest_lessons": "Lessons: Fewest first",
"date_created": "Ngày tạo",
"published": "Đã xuất bản",
"lessons": "Bài học"
@@ -1592,6 +1603,9 @@
"clear_all": "Xóa tất cả",
"popover_title": "Bộ lọc",
"sort": "Sắp xếp",
"order": "Thứ tự",
"ascending": "Asc",
"descending": "Desc",
"tags": "Thẻ",
"empty_tags": "Không có thẻ nào"
}
@@ -2212,7 +2226,6 @@
}
},
"login": {
"sso_enabled": "{provider} đã được bật cho miền của bạn",
"sso_redirect_error": "Đăng nhập SSO bị định cấu hình sai. Vui lòng đặt PUBLIC_SERVER_URL cho máy chủ API của bạn.",
"sso_required": "Vui lòng đăng nhập bằng SSO. Đăng nhập bằng mật khẩu bị vô hiệu hóa.",
"sign_in_with_sso": "Đăng nhập bằng {nhà cung cấp}",
@@ -15,6 +15,8 @@
import { Button } from '@cio/ui/base/button';
import { ssoApi } from '$features/org/api/sso.svelte';
import ShieldIcon from '@lucide/svelte/icons/shield';
import { buildSsoRedirectUrl, createSsoEmailChecker, type SsoAuthState } from '$features/auth/utils/auth-sso';
import { authSsoStore, ensureSsoInfoLoaded } from '$features/auth/utils/auth-sso-store';
const emailFromUrl = page.url.searchParams.get('email') ?? '';
const isEmailPrefilled = !!emailFromUrl;
@@ -24,102 +26,45 @@
let submitError: string | undefined = $state();
let loading = $state(false);
let errors = $state(Object.assign({}, LOGIN_FIELDS));
let isSSOInfoLoaded = $state(false);
let orgSupportsSso = $state(false);
// SSO state
let ssoState = $state<{
checking: boolean;
required: boolean;
available: boolean;
redirectUrl: string | null;
providerName: string | null;
providerId: string | null;
}>({
checking: false,
required: false,
available: false,
redirectUrl: null,
providerName: null,
providerId: null
});
/** Per-email discovery result; display uses this over org-level when set */
let discoveryState = $state<SsoAuthState | null>(null);
const redirectUrl = $derived(page.url.searchParams.get('redirect'));
const hideGoogleAuth = $derived(!!($globalStore.isOrgSite && $currentOrg.disableGoogleAuth));
async function initSSOInfo() {
if (isSSOInfoLoaded || !$currentOrg.id) return;
isSSOInfoLoaded = true;
const ssoInfo = await ssoApi.getOrgSsoInfo($currentOrg.id);
console.log('ssoInfo', ssoInfo);
if (ssoInfo) {
orgSupportsSso = ssoInfo.hasSso;
ssoState = {
checking: false,
required: ssoInfo.ssoRequired,
available: ssoInfo.hasSso,
redirectUrl: ssoInfo.redirectUrl,
providerName: ssoInfo.providerName,
providerId: ssoInfo.providerId
};
}
}
const ssoState = $derived(discoveryState ?? $authSsoStore.ssoState);
const orgSupportsSso = $derived($authSsoStore.orgSupportsSso);
$effect(() => {
initSSOInfo();
const orgId = $currentOrg.id;
if (orgId) ensureSsoInfoLoaded(orgId, (id) => ssoApi.getOrgSsoInfo(id));
});
let emailCheckTimeout: ReturnType<typeof setTimeout>;
function handleEmailChange() {
errors.email = '';
if (!orgSupportsSso) return;
clearTimeout(emailCheckTimeout);
emailCheckTimeout = setTimeout(async () => {
if (!fields.email || !fields.email.includes('@')) {
ssoState = {
checking: false,
required: false,
available: false,
redirectUrl: null,
providerName: null,
providerId: null
};
return;
}
ssoState.checking = true;
const result = await ssoApi.discoverSso(fields.email);
ssoState.checking = false;
if (result) {
ssoState = {
checking: false,
required: result.ssoRequired,
available: result.ssoAvailable,
redirectUrl: result.redirectUrl,
providerName: result.providerName,
providerId: result.providerId
};
}
}, 500);
}
const handleEmailChange = createSsoEmailChecker({
getEmail: () => fields.email,
getOrgSupportsSso: () => orgSupportsSso,
discoverSso: (email) => ssoApi.discoverSso(email),
onChecking: () => {
discoveryState = { ...$authSsoStore.ssoState, checking: true };
},
onResult: (state) => {
discoveryState = state;
},
onClear: () => {
errors.email = '';
discoveryState = null;
}
});
async function handleSsoLogin() {
const url = `${window.location.origin}${ssoState.redirectUrl || ''}`;
const url = buildSsoRedirectUrl(ssoState.redirectUrl);
const res = await authClient.signIn.sso({
await authClient.signIn.sso({
email: fields.email,
providerId: ssoState.providerId || '',
callbackURL: url
});
console.log('res', res);
}
async function handleSubmit() {
@@ -182,7 +127,32 @@
<title>Welcome back to {$currentOrg.name || 'ClassroomIO'}</title>
</svelte:head>
<AuthUI isLogin={true} {handleSubmit} isLoading={loading} {hideGoogleAuth}>
{#snippet getPasswordAuthAlternative()}
<!-- SSO Section (when SSO is available) -->
<div class="space-y-3">
{#if ssoState.required}
<div class="flex items-center gap-3">
<ShieldIcon class="size-5 text-blue-600" />
<div class="flex-1">
<p class="mt-1 text-xs text-blue-600">
{$t('settings.auth.login.sso_required')}
</p>
</div>
</div>
{/if}
<Button type="button" variant="outline" class="w-full" onclick={handleSsoLogin}>
{$t('settings.auth.login.sign_in_with_sso', { provider: ssoState.providerName || 'SSO' })}
</Button>
</div>
{/snippet}
<AuthUI
isLogin={true}
{handleSubmit}
isLoading={loading}
{hideGoogleAuth}
getPasswordAuthAlternative={ssoState.available ? getPasswordAuthAlternative : undefined}
>
<div class="ui:flex ui:flex-col ui:gap-6">
<Field.Field>
<Field.Label for="email">{$t('login.email')}</Field.Label>
@@ -205,44 +175,6 @@
</Field.Content>
</Field.Field>
<!-- SSO Section (when SSO is available) -->
{#if ssoState.available}
<div
class="ui:p-4 ui:bg-blue-50 ui:dark:bg-blue-900/20 ui:rounded-lg ui:border ui:border-blue-200 ui:dark:border-blue-800"
>
<div class="ui:flex ui:items-center ui:gap-3">
<ShieldIcon class="ui:size-5 ui:text-blue-600" />
<div class="ui:flex-1">
<p class="ui:font-medium ui:text-sm">
{$t('settings.auth.login.sso_enabled', { provider: ssoState.providerName || 'SSO' })}
</p>
{#if ssoState.required}
<p class="ui:text-xs ui:text-blue-600 ui:mt-1">
{$t('settings.auth.login.sso_required')}
</p>
{/if}
</div>
</div>
<Button type="button" variant="default" class="ui:w-full ui:mt-3" onclick={handleSsoLogin}>
{$t('settings.auth.login.sign_in_with_sso', { provider: ssoState.providerName || 'SSO' })}
</Button>
</div>
{/if}
<!-- Divider when both SSO and password login are available -->
{#if ssoState.available && !ssoState.required && !($globalStore.isOrgSite && $currentOrg.disableEmailPassword)}
<div class="ui:relative ui:my-4">
<div class="ui:absolute ui:inset-0 ui:flex ui:items-center" aria-hidden="true">
<div class="ui:w-full ui:border-t ui:border-border"></div>
</div>
<div class="ui:relative ui:flex ui:justify-center ui:text-xs">
<span class="ui:bg-card ui:px-2 ui:text-muted-foreground">
{$t('settings.auth.login.or_continue_with')}
</span>
</div>
</div>
{/if}
<!-- Password Section (when not force SSO and email/password not disabled) -->
{#if !ssoState.required && !($globalStore.isOrgSite && $currentOrg.disableEmailPassword)}
<Field.Field>
@@ -16,6 +16,10 @@
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { ssoApi } from '$features/org/api/sso.svelte';
import ShieldIcon from '@lucide/svelte/icons/shield';
import { buildSsoRedirectUrl, createSsoEmailChecker, type SsoAuthState } from '$features/auth/utils/auth-sso';
import { authSsoStore, ensureSsoInfoLoaded } from '$features/auth/utils/auth-sso-store';
let { data } = $props();
const emailFromUrl = page.url.searchParams.get('email') ?? '';
@@ -29,6 +33,9 @@
} = $state({});
let submitError: string = $state('');
/** Per-email discovery result; display uses this over org-level when set */
let discoveryState = $state<SsoAuthState | null>(null);
const disableSubmit = $derived(getDisableSubmit(fields));
const redirectUrl = $derived(page.url.searchParams.get('redirect'));
const inviteToken = $derived(page.url.searchParams.get('invite_token'));
@@ -36,6 +43,9 @@
// Use org from store or layout data (layout data ensures org is available on self-hosted before store is set)
const org = $derived($currentOrg?.id ? $currentOrg : (data.org ?? $currentOrg));
const ssoState = $derived(discoveryState ?? $authSsoStore.ssoState);
const orgSupportsSso = $derived($authSsoStore.orgSupportsSso);
// Invite context: allow signup when invite_token present or redirect contains invite info
const hasInviteContext = $derived(
!!inviteToken || (!!redirectUrl && (redirectUrl.includes('/invite/t/') || redirectUrl.includes('invite_token')))
@@ -54,12 +64,48 @@
}
});
$effect(() => {
const orgId = org?.id;
if (orgId) ensureSsoInfoLoaded(orgId, (id) => ssoApi.getOrgSsoInfo(id));
});
const handleEmailChange = createSsoEmailChecker({
getEmail: () => fields.email,
getOrgSupportsSso: () => orgSupportsSso,
discoverSso: (email) => ssoApi.discoverSso(email),
onChecking: () => {
discoveryState = { ...$authSsoStore.ssoState, checking: true };
},
onResult: (state) => {
discoveryState = state;
},
onClear: () => {
errors.email = '';
discoveryState = null;
}
});
async function handleSsoLogin() {
const url = buildSsoRedirectUrl(ssoState.redirectUrl);
await authClient.signIn.sso({
email: fields.email,
providerId: ssoState.providerId || '',
callbackURL: url
});
}
async function handleSubmit() {
if (signupRestricted) {
submitError = t.get('settings.auth.login.signup_disabled_error');
return;
}
if (ssoState.required && ssoState.available) {
handleSsoLogin();
return;
}
const validationRes = authValidation(fields);
console.log('validationRes', validationRes);
@@ -151,7 +197,31 @@
</Button>
</div>
{:else}
<AuthUI isLogin={false} {handleSubmit} isLoading={loading} {hideGoogleAuth}>
{#snippet getPasswordAuthAlternative()}
<div class="space-y-3">
{#if ssoState.required}
<div class="flex items-center gap-3">
<ShieldIcon class="size-5 text-blue-600" />
<div class="flex-1">
<p class="mt-1 text-xs text-blue-600">
{$t('settings.auth.login.sso_required')}
</p>
</div>
</div>
{/if}
<Button type="button" variant="outline" class="w-full" onclick={handleSsoLogin}>
{$t('settings.auth.login.sign_in_with_sso', { provider: ssoState.providerName || 'SSO' })}
</Button>
</div>
{/snippet}
<AuthUI
isLogin={false}
{handleSubmit}
isLoading={loading}
{hideGoogleAuth}
getPasswordAuthAlternative={ssoState.available ? getPasswordAuthAlternative : undefined}
>
<div class="ui:flex ui:flex-col ui:gap-6">
<Field.Field>
<Field.Label for="email">{$t('login.fields.email')}</Field.Label>
@@ -160,6 +230,7 @@
id="email"
type="email"
bind:value={fields.email}
oninput={handleEmailChange}
placeholder="[email protected]"
disabled={loading || isEmailPrefilled}
readonly={isEmailPrefilled}
@@ -173,48 +244,50 @@
</Field.Content>
</Field.Field>
<Field.Field>
<Field.Label for="password">{$t('login.fields.password')}</Field.Label>
<Field.Content>
<Password
id="password"
bind:value={fields.password}
placeholder="************"
disabled={loading}
aria-invalid={errors.password ? 'true' : undefined}
autocomplete="new-password"
/>
{#if errors.password}
<Field.Error>{$t(errors.password)}</Field.Error>
{/if}
<Field.Description>{$t('login.fields.password_helper_message')}</Field.Description>
</Field.Content>
</Field.Field>
{#if !ssoState.required}
<Field.Field>
<Field.Label for="password">{$t('login.fields.password')}</Field.Label>
<Field.Content>
<Password
id="password"
bind:value={fields.password}
placeholder="************"
disabled={loading}
aria-invalid={errors.password ? 'true' : undefined}
autocomplete="new-password"
/>
{#if errors.password}
<Field.Error>{$t(errors.password)}</Field.Error>
{/if}
<Field.Description>{$t('login.fields.password_helper_message')}</Field.Description>
</Field.Content>
</Field.Field>
<Field.Field>
<Field.Label for="confirmPassword">{$t('login.fields.confirm_password')}</Field.Label>
<Field.Content>
<Password
id="confirmPassword"
bind:value={fields.confirmPassword}
placeholder="************"
disabled={loading}
aria-invalid={errors.confirmPassword ? 'true' : undefined}
autocomplete="new-password"
/>
{#if errors.confirmPassword}
<Field.Error>{errors.confirmPassword}</Field.Error>
{/if}
</Field.Content>
</Field.Field>
<Field.Field>
<Field.Label for="confirmPassword">{$t('login.fields.confirm_password')}</Field.Label>
<Field.Content>
<Password
id="confirmPassword"
bind:value={fields.confirmPassword}
placeholder="************"
disabled={loading}
aria-invalid={errors.confirmPassword ? 'true' : undefined}
autocomplete="new-password"
/>
{#if errors.confirmPassword}
<Field.Error>{errors.confirmPassword}</Field.Error>
{/if}
</Field.Content>
</Field.Field>
{#if submitError}
<p class="ui:text-sm ui:text-destructive">{submitError}</p>
{#if submitError}
<p class="ui:text-sm ui:text-destructive">{submitError}</p>
{/if}
<Button type="submit" disabled={disableSubmit || loading} {loading} class="ui:w-full">
{$t('login.create_account')}
</Button>
{/if}
<Button type="submit" disabled={disableSubmit || loading} {loading} class="ui:w-full">
{$t('login.create_account')}
</Button>
</div>
</AuthUI>
{/if}
@@ -1,9 +1,10 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { NewsFeedPage } from '$features/course/pages';
import { Button } from '@cio/ui/base/button';
import * as Page from '@cio/ui/base/page';
import { RoleBasedSecurity } from '$features/ui';
import { RefreshPageData, RoleBasedSecurity } from '$features/ui';
import { currentOrg } from '$lib/utils/store/org';
import { t } from '$lib/utils/functions/translations';
import type { AccountOrg } from '$features/app/types';
@@ -25,7 +26,7 @@
<RoleBasedSecurity
allowedRoles={getPageRoles($currentOrg)}
onDenied={() => {
goto(`/courses/${data.courseId}/lessons?next=true`);
goto(resolve(`/courses/${data.courseId}/lessons?next=true`, {}));
}}
>
<Page.Root class="mx-auto flex w-[90%] px-4 md:max-w-2xl lg:max-w-3xl">
@@ -36,9 +37,12 @@
</Page.Title>
</Page.HeaderContent>
<Page.Action>
<Button class="mr-2" onclick={() => newsfeedApi.openNewFeedModal()}>
{$t('course.navItem.news_feed.heading_button.title')}
</Button>
<div class="flex items-center gap-2">
<Button onclick={() => newsfeedApi.openNewFeedModal()}>
{$t('course.navItem.news_feed.heading_button.title')}
</Button>
<RefreshPageData onRefresh={() => newsfeedApi.list(data.courseId)} />
</div>
</Page.Action>
</Page.Header>
@@ -1,5 +1,6 @@
<script lang="ts">
import { AnalyticsPage } from '$features/course/pages';
import { RefreshPageData } from '$features/ui';
import * as Page from '@cio/ui/base/page';
import { t } from '$lib/utils/functions/translations';
@@ -13,6 +14,9 @@
{$t('analytics.title')}
</Page.Title>
</Page.HeaderContent>
<Page.Action>
<RefreshPageData />
</Page.Action>
</Page.Header>
<Page.Body>
{#snippet child()}
@@ -1,7 +1,10 @@
<script lang="ts">
import { AttendancePage } from '$features/course/pages';
import { courseApi } from '$features/course/api';
import { RefreshPageData } from '$features/ui';
import * as Page from '@cio/ui/base/page';
import { t } from '$lib/utils/functions/translations';
import { profile } from '$lib/utils/store/user';
let { data } = $props();
</script>
@@ -13,6 +16,9 @@
{$t('course.navItem.attendance.title')}
</Page.Title>
</Page.HeaderContent>
<Page.Action>
<RefreshPageData onRefresh={() => courseApi.refreshCourse(data.courseId, $profile.id)} />
</Page.Action>
</Page.Header>
<Page.Body>
{#snippet child()}
@@ -1,5 +1,6 @@
<script lang="ts">
import { CertificatesPage } from '$features/course/pages';
import { RefreshPageData } from '$features/ui';
import * as Page from '@cio/ui/base/page';
import { t } from '$lib/utils/functions/translations';
</script>
@@ -11,6 +12,9 @@
{$t('course.navItem.certificates.title')}
</Page.Title>
</Page.HeaderContent>
<Page.Action>
<RefreshPageData />
</Page.Action>
</Page.Header>
<Page.Body>
{#snippet child()}
@@ -1,11 +1,12 @@
<script lang="ts">
import { LessonsPage } from '$features/course/pages';
import { Button } from '@cio/ui/base/button';
import { RoleBasedSecurity } from '$features/ui';
import { RefreshPageData, RoleBasedSecurity } from '$features/ui';
import * as Page from '@cio/ui/base/page';
import { t } from '$lib/utils/functions/translations';
import { contentCreateStoreUtils, contentEditingStore } from '$features/course/components/content/store';
import { courseApi } from '$features/course/api';
import { profile } from '$lib/utils/store/user';
let { data } = $props();
@@ -38,6 +39,7 @@
>{$t('course.navItem.lessons.add_content')}</Button
>
</RoleBasedSecurity>
<RefreshPageData onRefresh={() => courseApi.refreshCourse(data.courseId, $profile.id)} />
</div>
</Page.Action>
</Page.Header>
@@ -18,7 +18,6 @@
$effect(() => {
if (!data.lesson) return;
const lesson = data.lesson;
if (lessonApi.lesson?.id === lesson.id) return;
// Set lesson data
@@ -13,6 +13,7 @@
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import { courseApi } from '$features/course/api';
import { generateMarksCSV, generateMarksPDF } from '$features/course/utils/marks-utils';
import { RefreshPageData } from '$features/ui';
let { data } = $props();
@@ -97,6 +98,7 @@
</DropdownMenu.Content>
</DropdownMenu.Root>
</RoleBasedSecurity>
<RefreshPageData />
</div>
</Page.Action>
</Page.Header>
@@ -1,12 +1,15 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { goto, invalidateAll } from '$app/navigation';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import { IconButton } from '@cio/ui/custom/icon-button';
import { Button } from '@cio/ui/base/button';
import { RoleBasedSecurity } from '$features/ui';
import { RefreshPageData, RoleBasedSecurity } from '$features/ui';
import { t } from '$lib/utils/functions/translations';
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
import * as Page from '@cio/ui/base/page';
import { courseApi } from '$features/course/api';
import { profile } from '$lib/utils/store/user';
let { data = $bindable(), children } = $props();
@@ -14,17 +17,20 @@
let backUrl = $derived(page.url.searchParams.get('back'));
const handleClick = () => {
const url = page.url.href + '?add=true';
goto(url);
goto(resolve(`${page.url.pathname}?add=true`, {}));
};
const handleBackNavigation = () => {
if (backUrl) {
goto(backUrl);
goto(resolve(backUrl, {}));
} else {
goto(`/courses/${data.courseId}/people`);
goto(resolve(`/courses/${data.courseId}/people`, {}));
}
};
async function refreshPeoplePage() {
await Promise.all([courseApi.refreshCourse(data.courseId, $profile.id), invalidateAll()]);
}
</script>
<Page.Root class="mx-auto w-[90%] md:max-w-3xl">
@@ -42,13 +48,16 @@
</Page.Title>
</Page.HeaderContent>
<Page.Action>
{#if !data.personId}
<RoleBasedSecurity allowedRoles={[1, 2]}>
<Button class="mr-2" onclick={handleClick}>
{$t('course.navItem.people.add')}
</Button>
</RoleBasedSecurity>
{/if}
<div class="flex items-center gap-2">
{#if !data.personId}
<RoleBasedSecurity allowedRoles={[1, 2]}>
<Button onclick={handleClick}>
{$t('course.navItem.people.add')}
</Button>
</RoleBasedSecurity>
{/if}
<RefreshPageData onRefresh={refreshPeoplePage} />
</div>
</Page.Action>
</Page.Header>
@@ -3,9 +3,13 @@
import * as Page from '@cio/ui/base/page';
import { t } from '$lib/utils/functions/translations';
import { Button } from '@cio/ui/base/button';
import { courseApi } from '$features/course/api';
import { profile } from '$lib/utils/store/user';
import { RefreshPageData } from '$features/ui';
let settingsComponent: CourseSettingsPage | null = $state(null);
let isSaving = $state(false);
let { data } = $props();
async function handleSave() {
isSaving = true;
@@ -25,9 +29,12 @@
</Page.Title>
</Page.HeaderContent>
<Page.Action>
<Button variant="secondary" loading={isSaving} onclick={handleSave}>
{$t('course.navItem.settings.save')}
</Button>
<div class="flex items-center gap-2">
<Button variant="secondary" loading={isSaving} onclick={handleSave}>
{$t('course.navItem.settings.save')}
</Button>
<RefreshPageData disabled={isSaving} onRefresh={() => courseApi.refreshCourse(data.courseId, $profile.id)} />
</div>
</Page.Action>
</Page.Header>
<Page.Body>
@@ -1,6 +1,6 @@
<script lang="ts">
import { SubmissionsPage } from '$features/course/pages';
import { RoleBasedSecurity } from '$features/ui';
import { RefreshPageData, RoleBasedSecurity } from '$features/ui';
import * as Page from '@cio/ui/base/page';
import { t } from '$lib/utils/functions/translations';
@@ -15,6 +15,9 @@
{$t('course.navItem.submissions.title')}
</Page.Title>
</Page.HeaderContent>
<Page.Action>
<RefreshPageData />
</Page.Action>
</Page.Header>
<Page.Body>
@@ -0,0 +1,6 @@
import { redirect } from '@sveltejs/kit';
import type { PageLoad } from './$types';
export const load: PageLoad = ({ params, url }) => {
throw redirect(307, `/org/${params.slug}/automation/mcp${url.search}`);
};
@@ -1,22 +1,22 @@
<script>
import { AutomationPage } from '$features/automation/pages';
import { ApiAutomationPage } from '$features/automation/pages';
import { t } from '$lib/utils/functions/translations';
import * as Page from '@cio/ui/base/page';
</script>
<svelte:head>
<title>{t.get('automation.title')} - ClassroomIO</title>
<title>{t.get('automation.tabs.api')} - ClassroomIO</title>
</svelte:head>
<Page.Header>
<Page.HeaderContent>
<Page.Title>{$t('automation.title')}</Page.Title>
<Page.Subtitle>{$t('automation.subtitle')}</Page.Subtitle>
<Page.Title>{$t('automation.tabs.api')}</Page.Title>
<Page.Subtitle>{$t('automation.coming_soon')}</Page.Subtitle>
</Page.HeaderContent>
</Page.Header>
<Page.Body>
{#snippet child()}
<AutomationPage />
<ApiAutomationPage />
{/snippet}
</Page.Body>
@@ -0,0 +1,23 @@
import { classroomio, getApiHeaders } from '$lib/utils/services/api';
export const load = async ({ parent, cookies }) => {
const { orgId } = await parent();
if (!orgId) {
return { keys: [], usage: null };
}
const headers = getApiHeaders(cookies, orgId);
const [keysResponse, usageResponse] = await Promise.all([
classroomio.organization.automation.keys.$get({ query: { type: 'mcp' } }, headers),
classroomio.organization.automation.usage.$get({ query: { type: 'mcp' } }, headers)
]);
const [keysData, usageData] = await Promise.all([keysResponse.json(), usageResponse.json()]);
return {
keys: keysData.success ? keysData.data : [],
usage: usageData.success ? usageData.data : null
};
};
@@ -0,0 +1,34 @@
<script lang="ts">
import { McpAutomationPage } from '$features/automation/pages';
import { automationApi } from '$features/automation/api/automation.svelte';
import { t } from '$lib/utils/functions/translations';
import * as Page from '@cio/ui/base/page';
let { data } = $props();
$effect(() => {
if (!data || automationApi.isFetched) return;
automationApi.keys = data.keys;
automationApi.usage = data.usage;
automationApi.isFetched = true;
});
</script>
<svelte:head>
<title>{t.get('automation.tabs.mcp')} - ClassroomIO</title>
</svelte:head>
<Page.Root class="mx-auto w-full max-w-4xl">
<Page.Header>
<Page.HeaderContent>
<Page.Title>{$t('automation.tabs.mcp')}</Page.Title>
<Page.Subtitle>{$t('automation.subtitle')}</Page.Subtitle>
</Page.HeaderContent>
</Page.Header>
<Page.Body>
{#snippet child()}
<McpAutomationPage />
{/snippet}
</Page.Body>
</Page.Root>
@@ -0,0 +1,22 @@
<script>
import { ZapierAutomationPage } from '$features/automation/pages';
import { t } from '$lib/utils/functions/translations';
import * as Page from '@cio/ui/base/page';
</script>
<svelte:head>
<title>{t.get('automation.tabs.zapier')} - ClassroomIO</title>
</svelte:head>
<Page.Header>
<Page.HeaderContent>
<Page.Title>{$t('automation.tabs.zapier')}</Page.Title>
<Page.Subtitle>{$t('automation.coming_soon')}</Page.Subtitle>
</Page.HeaderContent>
</Page.Header>
<Page.Body>
{#snippet child()}
<ZapierAutomationPage />
{/snippet}
</Page.Body>
@@ -13,11 +13,13 @@
let searchValue = $state('');
let selectedId: string = $state('0');
let selectedOrder = $state<'asc' | 'desc'>('desc');
let selectedTags = $state<string[]>([]);
let hasInitializedFilters = $state(false);
let isFiltering = $state(false);
const validSortValues = new Set(COURSE_SORT_OPTIONS.map((option) => option.value));
const validSortOrders = new Set(['asc', 'desc']);
$effect(() => {
if (data.courses) {
@@ -37,6 +39,27 @@
return '0';
}
function normalizeSortOrder(sortOrder: string | null): 'asc' | 'desc' {
if (sortOrder && validSortOrders.has(sortOrder)) {
return sortOrder as 'asc' | 'desc';
}
return 'desc';
}
function getLegacySortState(sortValue: string | null) {
switch (sortValue) {
case '3':
return { selectedId: '0', selectedOrder: 'asc' as const };
case '4':
return { selectedId: '1', selectedOrder: 'asc' as const };
case '5':
return { selectedId: '2', selectedOrder: 'asc' as const };
default:
return null;
}
}
function updateFiltersUrl() {
if (!browser || !hasInitializedFilters) {
return;
@@ -56,6 +79,12 @@
nextUrl.searchParams.delete('sort');
}
if (selectedOrder !== 'desc') {
nextUrl.searchParams.set('order', selectedOrder);
} else {
nextUrl.searchParams.delete('order');
}
const nextPath = `${nextUrl.pathname}${nextUrl.search}${nextUrl.hash}`;
window.history.replaceState(window.history.state, '', nextPath);
}
@@ -65,7 +94,8 @@
return;
}
localStorage.setItem('classroomio_filter_course_key', selectedId);
localStorage.setItem('classroomio_filter_course_sort_key', selectedId);
localStorage.setItem('classroomio_filter_course_order_key', selectedOrder);
updateFiltersUrl();
});
@@ -94,6 +124,7 @@
async function clearFilters() {
selectedId = '0';
selectedOrder = 'desc';
if (selectedTags.length === 0) {
return;
@@ -111,15 +142,27 @@
return false;
});
const sortedCourses = [...filteredCourses];
if (selectedId === '0') {
return filteredCourses.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
return sortedCourses.sort((a, b) =>
selectedOrder === 'asc'
? new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
: new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
} else if (selectedId === '1') {
return filteredCourses.sort((a, b) => (b.isPublished ? 0 : 1) - (a.isPublished ? 0 : 1));
return sortedCourses.sort((a, b) =>
selectedOrder === 'asc'
? Number(a.isPublished) - Number(b.isPublished)
: Number(b.isPublished) - Number(a.isPublished)
);
} else if (selectedId === '2') {
return filteredCourses.sort((a, b) => b.lessonCount - a.lessonCount);
return sortedCourses.sort((a, b) =>
selectedOrder === 'asc' ? a.lessonCount - b.lessonCount : b.lessonCount - a.lessonCount
);
}
return filteredCourses;
return sortedCourses;
});
onMount(() => {
@@ -129,9 +172,32 @@
$courseMetaDeta.view = courseView;
}
const sortFromUrl = normalizeSortValue(new URLSearchParams(window.location.search).get('sort'));
const sortFromStorage = normalizeSortValue(localStorage.getItem('classroomio_filter_course_key'));
selectedId = sortFromUrl !== '0' ? sortFromUrl : sortFromStorage;
const searchParams = new URLSearchParams(window.location.search);
const legacySortFromUrl = getLegacySortState(searchParams.get('sort'));
const legacySortFromStorage = getLegacySortState(localStorage.getItem('classroomio_filter_course_key'));
const oldSortFromStorage = normalizeSortValue(localStorage.getItem('classroomio_filter_course_key'));
const sortFromUrl = normalizeSortValue(searchParams.get('sort'));
const sortFromStorage = normalizeSortValue(localStorage.getItem('classroomio_filter_course_sort_key'));
const orderFromUrl = normalizeSortOrder(searchParams.get('order'));
const orderFromStorage = normalizeSortOrder(localStorage.getItem('classroomio_filter_course_order_key'));
if (legacySortFromUrl) {
selectedId = legacySortFromUrl.selectedId;
selectedOrder = legacySortFromUrl.selectedOrder;
} else if (sortFromUrl !== '0' || orderFromUrl !== 'desc') {
selectedId = sortFromUrl;
selectedOrder = orderFromUrl;
} else if (legacySortFromStorage) {
selectedId = legacySortFromStorage.selectedId;
selectedOrder = legacySortFromStorage.selectedOrder;
} else if (sortFromStorage !== '0' || orderFromStorage !== 'desc') {
selectedId = sortFromStorage;
selectedOrder = orderFromStorage;
} else {
selectedId = oldSortFromStorage;
selectedOrder = 'desc';
}
hasInitializedFilters = true;
updateFiltersUrl();
});
@@ -156,6 +222,7 @@
{#snippet filterControls()}
<CourseFilterPopover
bind:selectedId
bind:selectedOrder
{selectedTags}
tagGroups={data.tagGroups}
{isFiltering}