chore: auto enroll, custom domain improvement, course pricing style improv.
This commit is contained in:
@@ -5,6 +5,7 @@ import { defaultProfileState, defaultUserState, profile, user } from '$lib/utils
|
||||
import type { AccountResponse } from './types';
|
||||
import { PUBLIC_IS_SELFHOSTED } from '$env/static/public';
|
||||
import type { TUser } from '@cio/db/types';
|
||||
import { authClient } from '$lib/utils/services/auth/client';
|
||||
import { get } from 'svelte/store';
|
||||
import { goto } from '$app/navigation';
|
||||
import { handleLocaleChange } from '$lib/utils/functions/translations';
|
||||
@@ -23,6 +24,8 @@ import shouldRedirectOnAuth from '$lib/utils/functions/routes/shouldRedirectOnAu
|
||||
type AppSetupParams = {
|
||||
isOrgSite: boolean;
|
||||
orgSiteName: string;
|
||||
/** Tenant org id from `getOrgSiteInfo`; used to auto-enroll the user if they aren't a member yet. */
|
||||
orgId?: string | null;
|
||||
};
|
||||
|
||||
/*
|
||||
@@ -49,14 +52,22 @@ class AppInitApi extends BaseApi {
|
||||
|
||||
this.session = locals;
|
||||
|
||||
// Auto-enroll on tenant sites for first-time signups. Idempotent on the
|
||||
// API side (no-ops for existing members so invited admins/tutors keep
|
||||
// their roles). Runs BEFORE the account fetch so the returned org list
|
||||
// already reflects the new membership.
|
||||
if (params.isOrgSite && params.orgId) {
|
||||
await this.autoEnrollOnTenantSite(params.orgId);
|
||||
}
|
||||
|
||||
await this.execute<typeof classroomio.account.$get>({
|
||||
requestFn: () => classroomio.account.$get(),
|
||||
logContext: 'fetching account',
|
||||
onSuccess: (accountData) => {
|
||||
this.data = accountData;
|
||||
licenseApi.setFeatures(accountData.licenseFeatures);
|
||||
onSuccess: (data) => {
|
||||
this.data = data;
|
||||
licenseApi.setFeatures(data.licenseFeatures);
|
||||
setupAnalyticsBasedOnLicense();
|
||||
this.setupStores();
|
||||
this.setupStores(params);
|
||||
this.setUserAnalytics();
|
||||
this.routeUserToNextPage(params);
|
||||
},
|
||||
@@ -66,12 +77,36 @@ class AppInitApi extends BaseApi {
|
||||
});
|
||||
}
|
||||
|
||||
private async autoEnrollOnTenantSite(orgId: string): Promise<void> {
|
||||
try {
|
||||
const response = await classroomio.organization['auto-enroll-student'].$post(
|
||||
{},
|
||||
{ headers: { 'cio-org-id': orgId } }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
// 403 is expected for invite-only / disabled-signup orgs — the user
|
||||
// visited a tenant site but isn't allowed to enroll. Other failures
|
||||
// shouldn't block the rest of setupApp.
|
||||
console.warn('auto-enroll-student failed', response.status, await response.text().catch(() => ''));
|
||||
return;
|
||||
}
|
||||
|
||||
// The user is a new member, so the session cookie cache (orgRoles)
|
||||
// is stale. Force Better Auth to refetch from DB and rewrite the
|
||||
// session_data cookie — Better Auth manages the cookie name itself.
|
||||
await authClient.getSession({ query: { disableCookieCache: true } });
|
||||
} catch (error) {
|
||||
console.warn('auto-enroll-student threw', error);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
1. Update user store
|
||||
2. Update profile store
|
||||
3. Update organizations store
|
||||
*/
|
||||
setupStores() {
|
||||
setupStores(params?: AppSetupParams) {
|
||||
if (!this.data?.success || !this.session) {
|
||||
return;
|
||||
}
|
||||
@@ -86,10 +121,10 @@ class AppInitApi extends BaseApi {
|
||||
profile.set(this.data.profile);
|
||||
handleLocaleChange(this.data.profile.locale ?? 'en');
|
||||
|
||||
this.setOrgStore();
|
||||
this.setOrgStore(params);
|
||||
}
|
||||
|
||||
setOrgStore() {
|
||||
setOrgStore(params?: AppSetupParams) {
|
||||
if (!this.data?.success || !this.data) {
|
||||
return;
|
||||
}
|
||||
@@ -100,10 +135,20 @@ class AppInitApi extends BaseApi {
|
||||
|
||||
orgs.set(this.data.organizations.map((org) => mergeAccountOrgFromServer(org)));
|
||||
|
||||
const lastOrgSiteName = localStorage.getItem('classroomio_org_sitename');
|
||||
const lastOrg = this.data.organizations.find((org) => org.siteName === lastOrgSiteName);
|
||||
// On a tenant site, pin currentOrg to that tenant — never fall back to
|
||||
// localStorage / the user's first org, which is what was making a user
|
||||
// logged in on dblocked.* see the Dblocked dashboard on ciodevs.*.
|
||||
let nextOrg: (typeof this.data.organizations)[number] | undefined;
|
||||
if (params?.isOrgSite && params.orgSiteName) {
|
||||
nextOrg = this.data.organizations.find((org) => org.siteName === params.orgSiteName);
|
||||
}
|
||||
|
||||
currentOrg.set(mergeAccountOrgFromServer(lastOrg || this.data.organizations[0]));
|
||||
if (!nextOrg) {
|
||||
const lastOrgSiteName = localStorage.getItem('classroomio_org_sitename');
|
||||
nextOrg = this.data.organizations.find((org) => org.siteName === lastOrgSiteName) ?? this.data.organizations[0];
|
||||
}
|
||||
|
||||
currentOrg.set(mergeAccountOrgFromServer(nextOrg));
|
||||
|
||||
const theme = get(currentOrg)?.theme;
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ export const NAV_IDS = {
|
||||
AI_ASSISTANT: 'AI_ASSISTANT'
|
||||
};
|
||||
|
||||
export const COURSE_SIDEBAR_DEFAULT_WIDTH = 256;
|
||||
export const COURSE_SIDEBAR_DEFAULT_WIDTH = 320;
|
||||
export const COURSE_SIDEBAR_MIN_WIDTH = 220;
|
||||
export const COURSE_SIDEBAR_MAX_WIDTH = 420;
|
||||
export const COURSE_SIDEBAR_STORAGE_KEY = 'classroomio-course-sidebar-width';
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script lang="ts">
|
||||
import ArrowUpRightIcon from '@lucide/svelte/icons/arrow-up-right';
|
||||
import Copy from '@lucide/svelte/icons/copy';
|
||||
import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw';
|
||||
import TrashIcon from '@lucide/svelte/icons/trash';
|
||||
import type { DomainRequestData, DomainRequestStatus } from '$features/org/utils/types';
|
||||
import isValidDomain from 'is-valid-domain';
|
||||
import { parse as parseDomain } from 'tldts';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount, untrack } from 'svelte';
|
||||
|
||||
@@ -13,13 +13,13 @@
|
||||
import { orgApi } from '$features/org/api/org.svelte';
|
||||
import { blockedSubdomain } from '$lib/utils/constants/app';
|
||||
import { currentOrg, isFreePlan } from '$lib/utils/store/org';
|
||||
import { copyToClipboard } from '$lib/utils/functions/formatYoutubeVideo';
|
||||
import { updateOrgSiteNameValidation } from '$lib/utils/functions/validator';
|
||||
import { sanitizeDomain, sendDomainRequest } from '$lib/utils/functions/domain';
|
||||
import { BRAND_ROOT_DOMAIN, TENANT_ROOT_DOMAIN } from '@cio/utils/constants';
|
||||
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { CopyButton } from '@cio/ui/base/copy-button';
|
||||
import { Textarea } from '@cio/ui/base/textarea';
|
||||
import { DomainInput } from '@cio/ui/custom/domain-input';
|
||||
import { ComingSoon, UpgradeBanner, UploadImage, VisitOrgSiteButton } from '$features/ui';
|
||||
@@ -40,6 +40,20 @@
|
||||
($currentOrg.customDomain ? ($currentOrg.isCustomDomainVerified ? 'verified' : 'pending_verification') : null)
|
||||
);
|
||||
|
||||
function stripZone(recordName: string): string {
|
||||
const hostname = $currentOrg.customDomain;
|
||||
if (!hostname) return recordName;
|
||||
|
||||
const apex = parseDomain(hostname).domain;
|
||||
if (!apex) return recordName;
|
||||
|
||||
const suffix = '.' + apex;
|
||||
if (recordName === apex) return recordName;
|
||||
if (recordName.endsWith(suffix)) return recordName.slice(0, -suffix.length);
|
||||
|
||||
return recordName;
|
||||
}
|
||||
|
||||
type Error = {
|
||||
siteName: string;
|
||||
customDomain: string;
|
||||
@@ -82,6 +96,7 @@
|
||||
}
|
||||
|
||||
function applyDomainSetup(data: DomainRequestData) {
|
||||
const wasVerified = $currentOrg.isCustomDomainVerified;
|
||||
domainSetup = data;
|
||||
|
||||
if (data.status === 'removed') {
|
||||
@@ -93,6 +108,10 @@
|
||||
$currentOrg.customDomain = data.hostname;
|
||||
$currentOrg.isCustomDomainVerified = data.verified;
|
||||
customDomain = '';
|
||||
|
||||
if (data.verified && !wasVerified) {
|
||||
snackbar.success('components.settings.domains.verified_snackbar');
|
||||
}
|
||||
}
|
||||
|
||||
function getDomainStatusLabel(status: DomainRequestStatus | null) {
|
||||
@@ -117,17 +136,17 @@
|
||||
function getDomainStatusClass(status: DomainRequestStatus | null) {
|
||||
switch (status) {
|
||||
case 'verified':
|
||||
return 'bg-green-500 text-white';
|
||||
return 'bg-emerald-50 text-emerald-700 ring-1 ring-emerald-200 dark:bg-emerald-950/40 dark:text-emerald-300 dark:ring-emerald-900';
|
||||
case 'pending_dns':
|
||||
case 'pending_verification':
|
||||
return 'bg-yellow-500 text-white';
|
||||
return 'bg-amber-50 text-amber-800 ring-1 ring-amber-200 dark:bg-amber-950/40 dark:text-amber-300 dark:ring-amber-900';
|
||||
case 'reconnect_required':
|
||||
case 'error':
|
||||
return 'bg-red-500 text-white';
|
||||
return 'bg-red-50 text-red-700 ring-1 ring-red-200 dark:bg-red-950/40 dark:text-red-300 dark:ring-red-900';
|
||||
case 'removed':
|
||||
return 'bg-slate-500 text-white';
|
||||
return 'bg-slate-100 text-slate-700 ring-1 ring-slate-200 dark:bg-slate-900 dark:text-slate-300 dark:ring-slate-800';
|
||||
default:
|
||||
return 'bg-yellow-500 text-white';
|
||||
return 'bg-amber-50 text-amber-800 ring-1 ring-amber-200 dark:bg-amber-950/40 dark:text-amber-300 dark:ring-amber-900';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -336,35 +355,35 @@
|
||||
|
||||
<Field.Description>{getDomainStatusDescription(currentDomainStatus)}</Field.Description>
|
||||
|
||||
{#if domainSetup?.dnsRecords?.length}
|
||||
{#if currentDomainStatus !== 'verified' && domainSetup?.dnsRecords?.length}
|
||||
<Field.Field>
|
||||
<div class="space-y-3 rounded-md border px-4 py-3">
|
||||
{#each domainSetup.dnsRecords as record (record.name)}
|
||||
<div class="grid gap-3 sm:grid-cols-[80px,1fr,1fr] sm:items-center">
|
||||
<div>
|
||||
<Field.Label class="font-light">{$t('components.settings.domains.dns_type')}</Field.Label>
|
||||
<p>{record.type}</p>
|
||||
<div class="flex flex-col gap-3">
|
||||
{#each domainSetup.dnsRecords as record, i (record.name + i)}
|
||||
{@const rows = [
|
||||
{ label: $t('components.settings.domains.dns_type'), value: record.type },
|
||||
{ label: $t('components.settings.domains.dns_name'), value: stripZone(record.name) },
|
||||
{ label: $t('components.settings.domains.dns_value'), value: record.value }
|
||||
]}
|
||||
<div class="ui:bg-card overflow-hidden rounded-lg border">
|
||||
<div
|
||||
class="ui:bg-muted/40 ui:text-muted-foreground border-b px-4 py-2 text-xs font-medium tracking-wide uppercase"
|
||||
>
|
||||
{$t('components.settings.domains.dns_record')}
|
||||
</div>
|
||||
|
||||
<div class="min-w-0">
|
||||
<Field.Label class="font-light">{$t('components.settings.domains.dns_name')}</Field.Label>
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="truncate">{record.name}</p>
|
||||
<Button variant="ghost" size="icon-sm" onclick={() => copyToClipboard(record.name)}>
|
||||
<Copy />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0">
|
||||
<Field.Label class="font-light">{$t('components.settings.domains.dns_value')}</Field.Label>
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="truncate">{record.value}</p>
|
||||
<Button variant="ghost" size="icon-sm" onclick={() => copyToClipboard(record.value)}>
|
||||
<Copy />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<dl class="divide-y">
|
||||
{#each rows as row (row.label)}
|
||||
<div class="grid grid-cols-[80px,1fr,auto] items-center gap-3 px-4 py-2.5">
|
||||
<dt class="ui:text-muted-foreground text-xs font-medium tracking-wide uppercase">
|
||||
{row.label}
|
||||
</dt>
|
||||
<dd class="min-w-0">
|
||||
<span class="block truncate font-mono text-sm">{row.value}</span>
|
||||
</dd>
|
||||
<CopyButton text={row.value} variant="ghost" size="icon-sm" aria-label={'Copy ' + row.label} />
|
||||
</div>
|
||||
{/each}
|
||||
</dl>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -380,16 +399,14 @@
|
||||
<Field.Field orientation="horizontal">
|
||||
{#if currentDomainStatus === 'reconnect_required'}
|
||||
<Button
|
||||
variant="outline"
|
||||
class="flex items-center gap-2 py-2"
|
||||
onclick={handleReconnectCustomDomain}
|
||||
loading={isCustomDomainLoading}
|
||||
>
|
||||
{$t('components.settings.domains.reconnect')}
|
||||
</Button>
|
||||
{:else}
|
||||
{:else if currentDomainStatus !== 'verified'}
|
||||
<Button
|
||||
variant="outline"
|
||||
class="flex items-center gap-2 py-2"
|
||||
onclick={() => handleRefreshCustomDomain()}
|
||||
loading={isRefreshing}
|
||||
@@ -402,8 +419,8 @@
|
||||
{/if}
|
||||
|
||||
<Button
|
||||
variant="destructive"
|
||||
class="flex items-center gap-2 py-2"
|
||||
variant="outline"
|
||||
class="ui:text-destructive ui:hover:text-destructive ui:hover:bg-destructive/10 flex items-center gap-2 py-2"
|
||||
onclick={handleRemoveCustomDomain}
|
||||
loading={isCustomDomainLoading}
|
||||
>
|
||||
|
||||
+1
-1
@@ -122,7 +122,7 @@
|
||||
</div>
|
||||
{:else}
|
||||
<aside
|
||||
class="ui:relative ui:w-[90%] ui:max-w-[405px] ui:rounded-lg ui:border ui:border-border ui:bg-card lg:sticky lg:w-[350px] lg:min-w-[250px] xl:w-[405px] xl:min-w-[330px] {editMode
|
||||
class="ui:border-border ui:bg-card relative w-[90%] max-w-[405px] rounded-lg border lg:sticky lg:w-[350px] lg:min-w-[250px] xl:w-[405px] xl:min-w-[330px] {editMode
|
||||
? 'lg:top-0'
|
||||
: 'lg:top-10'} h-fit {className}"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user