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
@@ -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}