feat: implement automation feature
This commit is contained in:
@@ -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="you@domain.com"
|
||||
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}`);
|
||||
};
|
||||
+5
-5
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user