feat: auto enroll into org as student and oauth improvements
This commit is contained in:
+19
-1
@@ -86,7 +86,25 @@ export const app = new Hono()
|
||||
})
|
||||
)
|
||||
.use('/api/auth/sign-up/*', signupGuard)
|
||||
.on(['POST', 'GET'], '/api/auth/*', (c) => auth.handler(c.req.raw))
|
||||
.on(['POST', 'GET'], '/api/auth/*', (c) => {
|
||||
// Behind the Cloudflare Worker proxy the raw Request URL points at the
|
||||
// upstream `.onrender.com` host. Better Auth (and the oauth-proxy plugin)
|
||||
// need the original tenant/admin host to construct correct OAuth callback
|
||||
// URLs and to decide whether to engage the proxy flow. Rebuild from
|
||||
// X-Forwarded-Host / X-Forwarded-Proto when present.
|
||||
let request = c.req.raw;
|
||||
const fwdHost = c.req.header('x-forwarded-host');
|
||||
const fwdProto = c.req.header('x-forwarded-proto');
|
||||
if (fwdHost) {
|
||||
const url = new URL(request.url);
|
||||
url.host = fwdHost;
|
||||
if (fwdProto === 'https' || fwdProto === 'http') {
|
||||
url.protocol = `${fwdProto}:`;
|
||||
}
|
||||
request = new Request(url, request);
|
||||
}
|
||||
return auth.handler(request);
|
||||
})
|
||||
.get('/session', async (c) => {
|
||||
const session = c.get('session');
|
||||
const user = c.get('user');
|
||||
|
||||
@@ -3,6 +3,7 @@ import { getActiveOrganizationPlan, getOrganizationById } from '@cio/db/queries/
|
||||
import { getCourseTagsByCourseIdsForOrganization, getTagGroupsWithTags } from '@cio/db/queries/tag';
|
||||
import { listWidgetCourses } from '@cio/db/queries/widget';
|
||||
import { PLAN } from '@cio/utils/plans';
|
||||
import { TENANT_ROOT_DOMAIN } from '@cio/utils/constants';
|
||||
import {
|
||||
buildWidgetPayload as buildWidgetPayloadShared,
|
||||
normalizeWidgetConfig,
|
||||
@@ -148,7 +149,7 @@ export function getCourseBaseUrl(siteName: string, customDomain?: string | null)
|
||||
return `https://${customDomain}`;
|
||||
}
|
||||
|
||||
return siteName ? `https://${siteName}.classroomio.com` : DEFAULT_WIDGET_APP_BASE_URL;
|
||||
return siteName ? `https://${siteName}.${TENANT_ROOT_DOMAIN}` : DEFAULT_WIDGET_APP_BASE_URL;
|
||||
}
|
||||
|
||||
export function getCourseWidgetScriptUrl(): string {
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import { newOrgModal } from '../new-org-modal-store';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { orgApi } from '$features/org/api/org.svelte';
|
||||
import { TENANT_ROOT_DOMAIN } from '@cio/utils/constants';
|
||||
|
||||
let orgName = $state('');
|
||||
let siteName = $derived(generateSitename(orgName));
|
||||
@@ -52,7 +53,7 @@
|
||||
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('add_org.org_sitename')}</Field.Label>
|
||||
<DomainInput bind:value={siteName} placeholder="myschool" prefix="https://" suffix=".classroomio.com" />
|
||||
<DomainInput bind:value={siteName} placeholder="myschool" prefix="https://" suffix=".{TENANT_ROOT_DOMAIN}" />
|
||||
{#if orgApi.errors.siteName || orgApi.errors.general}
|
||||
<Field.Error>{orgApi.errors.siteName || orgApi.errors.general}</Field.Error>
|
||||
{/if}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
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';
|
||||
@@ -164,7 +165,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (sanitizedDomain.includes('classroomio.com')) {
|
||||
if (sanitizedDomain.includes(BRAND_ROOT_DOMAIN) || sanitizedDomain.includes(TENANT_ROOT_DOMAIN)) {
|
||||
errors.customDomain = $t('components.settings.domains.custom_domain_not_classroomio');
|
||||
return;
|
||||
}
|
||||
@@ -284,7 +285,7 @@
|
||||
onchange={(e) => (siteName = (e.target as HTMLInputElement)?.value ?? '')}
|
||||
placeholder="myschool"
|
||||
prefix="https://"
|
||||
suffix=".classroomio.com"
|
||||
suffix=".{TENANT_ROOT_DOMAIN}"
|
||||
/>
|
||||
{#if errors.siteName}
|
||||
<Field.Error>{errors.siteName}</Field.Error>
|
||||
|
||||
@@ -4,8 +4,13 @@
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import { PLAN_NAMES, PLAN } from '@cio/utils/plans';
|
||||
import { BRAND_ROOT_DOMAIN, TENANT_ROOT_DOMAIN } from '@cio/utils/constants';
|
||||
|
||||
const plan = $derived($currentOrg.plans?.[0]?.planName || PLAN.BASIC);
|
||||
const utmSource = $derived(
|
||||
$currentOrg.customDomain ||
|
||||
($currentOrg.siteName ? `${$currentOrg.siteName}.${TENANT_ROOT_DOMAIN}` : BRAND_ROOT_DOMAIN)
|
||||
);
|
||||
</script>
|
||||
|
||||
<Sidebar.Menu>
|
||||
@@ -16,7 +21,7 @@
|
||||
>
|
||||
{#snippet child({ props })}
|
||||
<a
|
||||
href="https://classroomio.com?utm_source={$currentOrg.siteName}.classroomio.com"
|
||||
href="https://{BRAND_ROOT_DOMAIN}?utm_source={utmSource}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
{...props}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script>
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import { TENANT_ROOT_DOMAIN } from '@cio/utils/constants';
|
||||
</script>
|
||||
|
||||
<section id="student-dashboard" class="bg-black px-3 py-[10%] text-center text-white lg:px-0">
|
||||
<Badge class="px-4! py-2! text-sm! underline">yourorg.classroomio.com</Badge>
|
||||
<Badge class="px-4! py-2! text-sm! underline">yourorg.{TENANT_ROOT_DOMAIN}</Badge>
|
||||
<h1 class="mx-auto flex flex-col items-center gap-4 text-3xl leading-[1.5] font-normal md:text-4xl lg:text-6xl">
|
||||
Your Student Dashboard
|
||||
</h1>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<PageHeader className="flex flex-col items-center justify-center bg-[#F5F8FE] text-center pb-8">
|
||||
<PageHeader className="flex flex-col items-center justify-center text-center pb-8">
|
||||
<div class="mb-2 flex w-full items-center justify-center">
|
||||
<span
|
||||
class="rounded-full border-2 border-[#C2D2FF] bg-[#DCE5FF] px-4 py-1 text-sm font-medium text-blue-700"
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<PageHeader className="flex flex-col items-center justify-center bg-[#F5F8FE] text-center pb-8">
|
||||
<PageHeader className="flex flex-col items-center justify-center text-center pb-8">
|
||||
<div class="mb-2 flex w-full items-center justify-center">
|
||||
<span
|
||||
class="rounded-full border-2 border-[#C2D2FF] bg-[#DCE5FF] px-4 py-1 text-sm font-medium text-blue-700"
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
class="flex w-full rounded-md border shadow-sm transition-all ease-in-out hover:scale-95 md:max-w-[370px]"
|
||||
>
|
||||
<img class="w-[30%] rounded-l-md" src={tool.src} alt="" />
|
||||
<div class="w-[70%] rounded-r-md bg-[#F5F8FE] p-5">
|
||||
<div class="w-[70%] rounded-r-md p-5">
|
||||
<h1 class="text-sm md:text-base">{tool.title}</h1>
|
||||
<p class="mt-1.5 text-xs text-gray-600">
|
||||
{tool.subText}
|
||||
|
||||
+15
-3
@@ -12,6 +12,7 @@ import { drizzleAdapter } from 'better-auth/adapters/drizzle';
|
||||
import { config as emailAndPassword } from './auth/email-password';
|
||||
import { getUserOrgRolesMap } from './queries/organization/organization';
|
||||
import { loginLink } from './auth/plugins/login-link';
|
||||
import { oAuthProxy } from 'better-auth/plugins/oauth-proxy';
|
||||
import { resolveTrustedBrowserOrigin } from './utils';
|
||||
import { sso } from '@better-auth/sso';
|
||||
import { syncUserWithProfile } from './auth/hooks/sync-user';
|
||||
@@ -77,9 +78,12 @@ export const auth: ReturnType<typeof betterAuth> = betterAuth({
|
||||
databaseHooks: {
|
||||
user: {
|
||||
create: {
|
||||
after: async (user) => {
|
||||
console.log('[auth] databaseHooks.user.create.after: running', { userId: user.id });
|
||||
await createProfileHook(user);
|
||||
after: async (user, ctx) => {
|
||||
console.log('[auth] databaseHooks.user.create.after: running', { userId: user.id, ctx });
|
||||
const request =
|
||||
(ctx as { context?: { request?: Request }; request?: Request } | undefined)?.context?.request ??
|
||||
(ctx as { request?: Request } | undefined)?.request;
|
||||
await createProfileHook(user, request);
|
||||
}
|
||||
},
|
||||
update: {
|
||||
@@ -109,6 +113,14 @@ export const auth: ReturnType<typeof betterAuth> = betterAuth({
|
||||
// OIDC providers are registered dynamically per organization
|
||||
// via the admin API (auth.api.registerSSOProvider)
|
||||
}),
|
||||
// Lets OAuth/SSO callbacks land on the canonical production URL
|
||||
// (api.classroomio.com) while still completing the flow on the tenant
|
||||
// host the user signed in from (<org>.classroomio.school or a BYOD
|
||||
// domain). Without this, the state cookie set on the tenant host isn't
|
||||
// sent to the api host on the Google redirect → state_security_mismatch.
|
||||
oAuthProxy({
|
||||
productionURL: CONSTANTS.BASE_URL
|
||||
}),
|
||||
loginLink(),
|
||||
tokenExchange(),
|
||||
// Attaches the user's org memberships ({ [orgId]: roleId }) to the session
|
||||
|
||||
@@ -6,6 +6,7 @@ import { User } from 'better-auth';
|
||||
import { db } from '@db/drizzle';
|
||||
import { getOrganizationCount } from '@db/queries/organization';
|
||||
import { ssoProvisioningHook } from './sso-provisioning';
|
||||
import { tenantProvisioningHook } from './tenant-provisioning';
|
||||
|
||||
/**
|
||||
* True if the user has any non-credential account (OAuth/SSO). Those providers verify email.
|
||||
@@ -36,8 +37,8 @@ async function isSelfHostedFirstSignup(): Promise<boolean> {
|
||||
*
|
||||
* Self-hosted first signup: auto-verify email (no orgs exist yet, this user will create the only org).
|
||||
*/
|
||||
export const createProfileHook = async (user: User) => {
|
||||
console.log('[auth] createProfileHook: running', { userId: user.id });
|
||||
export const createProfileHook = async (user: User, request?: Request) => {
|
||||
console.log('[auth] createProfileHook: running', { userId: user.id, request });
|
||||
|
||||
const existingProfile = await db.select().from(schema.profile).where(eq(schema.profile.id, user.id)).limit(1);
|
||||
console.debug('existingProfile', existingProfile);
|
||||
@@ -81,8 +82,12 @@ export const createProfileHook = async (user: User) => {
|
||||
});
|
||||
console.debug('newProfile', newProfile);
|
||||
|
||||
// Run SSO provisioning hook for JIT org membership
|
||||
// Run SSO provisioning hook for JIT org membership (email-domain match)
|
||||
await ssoProvisioningHook(user);
|
||||
|
||||
// Run tenant provisioning hook for signups on a tenant subdomain
|
||||
// (signupGuard has already enforced disableSignup / inviteOnly)
|
||||
await tenantProvisioningHook(user, request);
|
||||
} catch (error) {
|
||||
console.error('Error creating profile for user:', error);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { User } from 'better-auth';
|
||||
import { BRAND_ROOT_DOMAIN, TENANT_ROOT_DOMAIN, blockedSubdomain } from '@cio/utils/constants';
|
||||
import { getOrganizationByCustomDomain, getOrganizationBySiteName } from '@db/queries/organization';
|
||||
|
||||
import { ensureOrgMembership } from './sso-provisioning';
|
||||
|
||||
/**
|
||||
* Subdomains under the brand/tenant root that are NOT a tenant org.
|
||||
* Reuses the dashboard's `blockedSubdomain` list so the two stay in sync.
|
||||
*/
|
||||
const RESERVED_SUBDOMAINS = new Set<string>(blockedSubdomain);
|
||||
|
||||
/**
|
||||
* Resolve the org a signup happened on, given the inbound request's host.
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. Subdomain under TENANT_ROOT_DOMAIN (e.g. ciodevs.classroomio.school) → siteName lookup
|
||||
* 2. Subdomain under BRAND_ROOT_DOMAIN (e.g. ciodevs.classroomio.com) → siteName lookup
|
||||
* 3. Anything else (e.g. learn.acme.com) → verified-custom-domain lookup
|
||||
*
|
||||
* Returns null for the apex, reserved subdomains (app/api/www/…), and unverified custom domains.
|
||||
*/
|
||||
async function resolveOrgIdFromHost(host: string): Promise<string | null> {
|
||||
const hostname = host.split(':')[0]?.toLowerCase();
|
||||
if (!hostname) return null;
|
||||
|
||||
const tenantSuffix = `.${TENANT_ROOT_DOMAIN}`;
|
||||
const brandSuffix = `.${BRAND_ROOT_DOMAIN}`;
|
||||
|
||||
if (hostname.endsWith(tenantSuffix) || hostname.endsWith(brandSuffix)) {
|
||||
const suffix = hostname.endsWith(tenantSuffix) ? tenantSuffix : brandSuffix;
|
||||
const subdomain = hostname.slice(0, -suffix.length);
|
||||
|
||||
// Apex (no subdomain) or multi-level subdomain we don't recognise
|
||||
if (!subdomain || subdomain.includes('.')) return null;
|
||||
if (RESERVED_SUBDOMAINS.has(subdomain)) return null;
|
||||
|
||||
const org = await getOrganizationBySiteName(subdomain);
|
||||
return org?.id ?? null;
|
||||
}
|
||||
|
||||
const org = await getOrganizationByCustomDomain(hostname);
|
||||
return org?.id ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-enroll a signup into the org they signed up on.
|
||||
*
|
||||
* Runs after `ssoProvisioningHook` so SSO-domain matches (which carry policy-driven
|
||||
* role mapping) win. `ensureOrgMembership` is idempotent and invite-aware, so if SSO
|
||||
* already inserted a row this is a no-op.
|
||||
*
|
||||
* The `signupGuard` middleware (apps/api/src/middlewares/signup-guard.ts) has already
|
||||
* rejected `disableSignup` orgs and `inviteOnly` orgs without a valid invite by the
|
||||
* time this runs — so any signup that reaches the hook is allowed to join.
|
||||
*
|
||||
* Org resolution order:
|
||||
* 1. `cio-org-id` request header (set by the dashboard on every API call)
|
||||
* 2. Host header: tenant subdomain, brand subdomain, or verified custom domain.
|
||||
* Prefers `x-forwarded-host` (set by the Cloudflare Worker proxy) over the
|
||||
* raw `Host`, which behind the proxy is the upstream service name and not
|
||||
* the tenant host the user actually visited.
|
||||
*
|
||||
* TODO(oauth): OAuth/SSO signups lose both the header and the original host —
|
||||
* the callback lands on `api.classroomio.com/api/auth/callback/<provider>`, so
|
||||
* neither path here can resolve the tenant. To fix, encode the org id in
|
||||
* Better Auth's OAuth `state` (or `additionalParams`) at signup initiation and
|
||||
* pull it out of `request` here. Until then, OAuth signups on a tenant site
|
||||
* will not be auto-enrolled and must be invited manually.
|
||||
*/
|
||||
export const tenantProvisioningHook = async (user: User, request?: Request) => {
|
||||
if (!user.email || !request) return;
|
||||
|
||||
const headerOrgId = request.headers.get('cio-org-id');
|
||||
if (headerOrgId) {
|
||||
await ensureOrgMembership(user.id, user.email, headerOrgId);
|
||||
return;
|
||||
}
|
||||
|
||||
const host = request.headers.get('x-forwarded-host') ?? request.headers.get('host');
|
||||
if (!host) return;
|
||||
|
||||
const orgId = await resolveOrgIdFromHost(host);
|
||||
if (!orgId) return;
|
||||
|
||||
await ensureOrgMembership(user.id, user.email, orgId);
|
||||
};
|
||||
@@ -197,6 +197,26 @@ export const getOrganizationBySiteName = async (siteName: string): Promise<TOrga
|
||||
return organization || null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets an organization by its verified custom domain.
|
||||
* Only matches rows where isCustomDomainVerified is true so we never auto-enroll
|
||||
* a signup into an org just because someone pointed DNS at us.
|
||||
*/
|
||||
export const getOrganizationByCustomDomain = async (customDomain: string): Promise<TOrganization | null> => {
|
||||
const [organization] = await db
|
||||
.select()
|
||||
.from(schema.organization)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.organization.customDomain, customDomain.toLowerCase()),
|
||||
eq(schema.organization.isCustomDomainVerified, true)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return organization || null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets an organization by ID
|
||||
* @param id Organization ID
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { BRAND_ROOT_DOMAIN, TENANT_ROOT_DOMAIN } from '@cio/utils/constants';
|
||||
import { getVerifiedCustomDomainHostnames } from '../../queries/organization/organization';
|
||||
|
||||
const CLASSROOMIO_ROOT = 'classroomio.com';
|
||||
const FIRST_PARTY_ROOTS: readonly string[] = [BRAND_ROOT_DOMAIN, TENANT_ROOT_DOMAIN];
|
||||
|
||||
/** Lowercase hostnames with verified custom domains ( warmed at API boot + updated on domain routes ). */
|
||||
const verifiedCustomDomainHostnames = new Set<string>();
|
||||
@@ -25,7 +26,7 @@ function originMatchesStaticEntry(origin: string, entry: string): boolean {
|
||||
function isClassroomioHost(hostname: string): boolean {
|
||||
const host = hostname.toLowerCase();
|
||||
|
||||
return host === CLASSROOMIO_ROOT || host.endsWith(`.${CLASSROOMIO_ROOT}`);
|
||||
return FIRST_PARTY_ROOTS.some((root) => host === root || host.endsWith(`.${root}`));
|
||||
}
|
||||
|
||||
export function trustCustomDomainHostname(hostname: string): void {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { BRAND_ROOT_DOMAIN, TENANT_ROOT_DOMAIN } from '@cio/utils/constants';
|
||||
import {
|
||||
getWidgetThemeSerifStack,
|
||||
type TWidgetConfig,
|
||||
@@ -62,7 +63,7 @@ export function formatLessonsLine(course: TWidgetPayloadCourse): string {
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
/** Hostname used in UTM (custom domain or `{siteName}.classroomio.com`). */
|
||||
/** Hostname used in UTM (custom domain or `{siteName}.${TENANT_ROOT_DOMAIN}`). */
|
||||
export function getOrgDomainForUtm(organization: TWidgetPayload['organization']): string {
|
||||
const custom = organization.customDomain?.trim();
|
||||
if (custom) {
|
||||
@@ -76,7 +77,7 @@ export function getOrgDomainForUtm(organization: TWidgetPayload['organization'])
|
||||
return custom.replace(/^\/+/, '').split('/')[0] || organization.siteName;
|
||||
}
|
||||
const site = organization.siteName.trim();
|
||||
return site ? `${site}.classroomio.com` : 'classroomio.com';
|
||||
return site ? `${site}.${TENANT_ROOT_DOMAIN}` : BRAND_ROOT_DOMAIN;
|
||||
}
|
||||
|
||||
/** Marketing homepage link with widget + org attribution for analytics. */
|
||||
@@ -89,5 +90,5 @@ export function buildPoweredByMarketingUrl(payload: TWidgetPayload): string {
|
||||
utm_content: payload.widgetId,
|
||||
utm_term: orgDomain
|
||||
});
|
||||
return `https://classroomio.com/?${params.toString()}`;
|
||||
return `https://${BRAND_ROOT_DOMAIN}/?${params.toString()}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* The apex that hosts every free-tier tenant site as `<orgSiteName>.<TENANT_ROOT_DOMAIN>`.
|
||||
* The `apps/cio-tenant-router` Cloudflare Worker terminates traffic for this zone and forwards
|
||||
* to the dashboard service. Marketing apex (classroomio.com) is intentionally separate.
|
||||
*/
|
||||
export const TENANT_ROOT_DOMAIN = 'classroomio.school';
|
||||
|
||||
/** The marketing / admin / api zone. */
|
||||
export const BRAND_ROOT_DOMAIN = 'classroomio.com';
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './roles';
|
||||
export * from './org';
|
||||
export * from './content';
|
||||
export * from './domains';
|
||||
export * from './error-codes';
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
# Onboarding Platform Showcase PRD
|
||||
|
||||
## Status
|
||||
- Draft
|
||||
|
||||
## Date
|
||||
- May 20, 2026
|
||||
|
||||
## Purpose
|
||||
Redesign the new-user onboarding flow so creators see (a) what their public-facing **org landing page** can look like and pick a theme, and (b) land in a workspace that is already pre-populated with **goal-relevant demo courses** instead of an empty `/courses` page. The goal is to move first impression from "blank slate" to "this platform is alive and ready for me."
|
||||
|
||||
## Problem Statement
|
||||
Today's onboarding is a sterile 2-step form:
|
||||
|
||||
1. Org name + slug + full name
|
||||
2. Goal + source + locale
|
||||
|
||||
Then the user is dropped onto `/org/{slug}/courses?create=true` — a page with zero courses, zero branding decisions made, and no visible evidence that ClassroomIO is anything more than a CRUD form for a course list. Two problems compound:
|
||||
|
||||
1. **The landing page feature is invisible.** We ship 8 production-quality landing page themes (`minimal`, `bold`, `classic`, `saas`, `tech`, `studio`, `corporate`, `terminal`) with full preview PNGs, but new users never see them — they have to discover Settings → Landing Page on their own.
|
||||
2. **The workspace looks empty.** New users have no reference for what a "real" ClassroomIO course looks like (sections, lessons, video embeds, certificates). They have to imagine the product from a blank canvas.
|
||||
|
||||
Result: low activation, high time-to-first-meaningful-action, and the impression that the platform is thinner than it actually is.
|
||||
|
||||
## ICP (Primary)
|
||||
1. SaaS founders launching a product academy.
|
||||
2. Independent course creators selling self-paced cohorts.
|
||||
3. Operations leaders running employee/compliance training.
|
||||
4. Customer-success teams running customer enablement.
|
||||
|
||||
## Target Jobs-To-Be-Done
|
||||
1. Quickly evaluate whether ClassroomIO can produce a school site that looks like the creator's brand.
|
||||
2. See what a "good" course structure looks like on the platform without having to build one to find out.
|
||||
3. Get to a publishable-feeling state in a single session.
|
||||
|
||||
## Confirmed Product Decisions
|
||||
1. **Theme picker** is a real step (not a passive preview gallery). The user's selection is persisted to `organization.landingPage.theme`.
|
||||
2. **Auto-seed goal-relevant demo courses** — no extra picker step. The existing `goal` answer in metadata drives which course set lands in the org.
|
||||
3. **Step order**: `org-setup → theme-picker (+ course preview) → user-metadata → seed-and-redirect`.
|
||||
4. **Course source**: a **new curated demo set** authored in code (not the existing `seedCourses()` templates in `packages/db/src/utils/seed/course.ts`, which were built for E2E/dev seeding).
|
||||
|
||||
## Non-Goals (v1)
|
||||
- AI-generated courses from a URL — that is a separate initiative tracked in `prd/onboarding-ai-bootstrap/`.
|
||||
- Custom theme editing inside onboarding (colors, hero text, logo upload). The full landing-page editor stays in `/settings/landingpage`.
|
||||
- Letting the user pick *which* demo courses to seed. Goal-mapping is opinionated.
|
||||
- Multi-language demo course content. v1 demo courses ship in English only; the user's `locale` choice affects UI chrome but not seeded course text.
|
||||
- Surfacing demo courses on the **public** landing page automatically — they seed as unpublished/draft so the org owner reviews first.
|
||||
|
||||
## Data Sources Checked
|
||||
- `apps/dashboard/src/routes/(auth)/onboarding/+page.svelte`
|
||||
- `apps/dashboard/src/lib/features/onboarding/api/onboarding.svelte.ts`
|
||||
- `apps/dashboard/src/lib/features/onboarding/utils/constants.ts`
|
||||
- `apps/dashboard/src/lib/features/onboarding/utils/types.ts`
|
||||
- `apps/dashboard/src/lib/features/onboarding/components/verify-email-modal.svelte`
|
||||
- `apps/dashboard/src/lib/features/onboarding/components/welcome-modal.svelte`
|
||||
- `apps/dashboard/src/lib/features/org/utils/landing-page.ts`
|
||||
- `apps/dashboard/src/lib/utils/types/org.ts`
|
||||
- `apps/dashboard/src/lib/features/settings/pages/landingpage.svelte`
|
||||
- `apps/dashboard/static/templates/*.png` (8 theme preview PNGs)
|
||||
- `packages/ui/src/custom/org-landing-page/` (theme component implementations)
|
||||
- `apps/api/src/routes/onboarding/onboarding.ts`
|
||||
- `apps/api/src/services/onboarding.ts`
|
||||
- `apps/api/src/services/course/course.ts` (createCourse pattern)
|
||||
- `apps/api/src/services/course-import/course-import.ts` (bulk lesson/section seed pattern)
|
||||
- `apps/api/src/routes/organization/course-import.ts`
|
||||
- `packages/db/src/schema.ts` (organization, course, courseSection, lesson, courseImportDraft)
|
||||
- `packages/db/src/utils/seed/course.ts` (existing dev seed, reference only)
|
||||
- `packages/utils/src/validation/onboarding/onboarding.ts`
|
||||
- `prd/onboarding-ai-bootstrap/README.md` (sibling initiative, must not conflict)
|
||||
- `prd/org-landing-page-theme-picker [DONE]/README.md` (foundation shipped Apr 2026)
|
||||
|
||||
## Current Baseline (As-Is)
|
||||
1. `ONBOARDING_STEPS = { ORG_SETUP: 1, USER_METADATA: 2 }` — two steps.
|
||||
2. `createOrganizationWithOwner` creates the org with `landingPage = createDefaultLandingPageSettings()` (theme `'minimal'`, default hero copy from `defaultLandingPageHero` in `apps/dashboard/src/lib/features/org/utils/landing-page.ts:33`).
|
||||
3. `updateUserOnboarding` saves goal/source/fullname, updates user locale, and the API class redirects to `/org/{siteName}?welcomePopup=...`.
|
||||
4. No course creation happens during onboarding. The "create=true" query param on the courses page is unused.
|
||||
5. Theme picker exists *only* in `/settings/landingpage` — never surfaced to first-time users.
|
||||
6. The 8 theme preview PNGs at `apps/dashboard/static/templates/{theme}.png` are already shipped and used by the settings editor; we can reuse them as-is.
|
||||
|
||||
---
|
||||
|
||||
## Proposed Experience
|
||||
|
||||
### Step 1: Account + Org Basics (unchanged)
|
||||
- `fullname`
|
||||
- `orgName`
|
||||
- `siteName`
|
||||
|
||||
On submit: existing `createOrganizationWithOwner` runs and the org is created with the default `'minimal'` theme.
|
||||
|
||||
### Step 2 (NEW): Theme Picker + Course Preview
|
||||
|
||||
Single screen, two stacked sections inside the existing centered onboarding card.
|
||||
|
||||
**Top section — "Pick a look for your school"**
|
||||
- Responsive 4-up grid (collapsing to 2-up on small screens) of 8 theme cards.
|
||||
- Each card: `/templates/{theme}.png` + theme display name + a subtle "Selected" ring when active.
|
||||
- Default selection: `minimal` (matches what was already saved at org-create time).
|
||||
- Clicking a card sets local `fields.theme`. No autosave — persistence happens on "Continue".
|
||||
|
||||
**Bottom section — "Your workspace will come pre-loaded"**
|
||||
- 4 static course-tile mockups (using the same primitive `/courses` uses) showing variety: one `SELF_PACED`, one `LIVE_CLASS`, one `COMPLIANCE`, one with a certificate badge.
|
||||
- Subtext: "We'll add a few sample courses you can edit, publish, or delete."
|
||||
- These tiles are *previews only* — not the actual courses that will be seeded. They communicate "you're not landing in an empty room."
|
||||
|
||||
**Continue**: calls a new `POST /onboarding/select-theme` with `{ theme }`, then advances to step 3.
|
||||
**Back**: returns to step 1 without losing org data (org is already persisted; back is just a step-state change).
|
||||
|
||||
### Step 3: User Metadata (unchanged in shape, behavior change on submit)
|
||||
- `goal` (radio)
|
||||
- `source` (radio)
|
||||
- `locale` (select)
|
||||
|
||||
On submit: existing `updateUserOnboarding` runs, **then** the route invokes `seedDemoCoursesForGoal(orgId, profileId, goal)` synchronously (with a short timeout — see FR-4) before returning. Response includes `seededCourseIds` so the frontend can surface them in the post-redirect view.
|
||||
|
||||
### Post-Onboarding
|
||||
- Redirect to `/org/{siteName}/courses` (drop the `?create=true` marker — it was unused and the page is no longer empty).
|
||||
- The existing `welcomePopup` modal still fires and now references the seeded courses ("We added a few sample courses to get you started — feel free to edit or delete them.").
|
||||
|
||||
---
|
||||
|
||||
## Functional Requirements
|
||||
|
||||
### FR-1 — Theme Persistence
|
||||
- `POST /onboarding/select-theme` accepts `{ theme: OrgLandingPageTheme }`, validates against `landingPageThemes` (8 values), and patches `organization.landingPage.theme` using a `jsonb_set` so other landing-page fields (hero, navItems, footer) remain untouched.
|
||||
- Endpoint requires `authMiddleware`. Caller must be the owner of the org tied to the current onboarding session.
|
||||
- Idempotent — re-submitting the same theme is a no-op success; re-submitting a different theme overwrites.
|
||||
|
||||
### FR-2 — Curated Demo Course Set
|
||||
- Define `DEMO_COURSES_BY_GOAL: Record<OnboardingGoal, DemoCourseSpec[]>` in `packages/db/src/utils/demo-courses/index.ts`.
|
||||
- Each `DemoCourseSpec` includes: `title`, `description`, `overview` (markdown), `type` (`SELF_PACED` | `LIVE_CLASS` | `COMPLIANCE` | `PUBLIC`), `bannerImage` (URL to a static asset), optional `certificate` config, and a `sections: Array<{ title, lessons: Array<{ title, note, videos? }> }>` tree.
|
||||
- Lesson content: title + ~150-word markdown `note` + optional public YouTube embed reference. No file uploads required.
|
||||
- Target size: 2 courses per goal, 3 sections per course, 3 lessons per section — enough to look real, cheap to seed.
|
||||
- Mapping (initial):
|
||||
- `sell-online` → "Launch Your First Paid Course" (`SELF_PACED`), "Marketing Funnels for Course Creators" (`SELF_PACED`)
|
||||
- `teach-online` → "Designing Engaging Live Cohorts" (`LIVE_CLASS`), "Active Learning Toolkit" (`SELF_PACED`)
|
||||
- `employees` → "Workplace Compliance 101" (`COMPLIANCE`, with certificate), "Manager Essentials" (`SELF_PACED`)
|
||||
- `customers` → "Customer Onboarding Bootcamp" (`SELF_PACED`), "Product Mastery" (`PUBLIC`)
|
||||
- `expanding-platform` → one of each `type` (broadest exposure)
|
||||
|
||||
### FR-3 — Demo Course Seeding
|
||||
- New service `seedDemoCoursesForGoal(orgId, profileId, goal)` in `apps/api/src/services/onboarding/seed-demo-courses.ts`.
|
||||
- Internally calls a new query helper `createDemoCoursesForOrg(orgId, ownerProfileId, specs)` in `packages/db/src/queries/onboarding/demo-courses.ts` that runs **inside a single transaction**, mirroring the insert shape used by `createCourse` (`apps/api/src/services/course/course.ts:181`) — group + course + group-member + default section + welcome newsfeed — extended for the additional sections/lessons.
|
||||
- Seeded courses are created with `isPublished: false` and `isTemplate: false` so they appear as drafts the owner can edit/publish/delete.
|
||||
- **Idempotency**: before seeding, the service counts the org's non-template courses; if `>= 1`, it skips. This guards against double-submit of `/update-metadata`.
|
||||
|
||||
### FR-4 — Seed Failure Doesn't Block Redirect
|
||||
- The `/update-metadata` handler awaits `seedDemoCoursesForGoal` with a 5-second timeout.
|
||||
- On timeout or thrown error: log structured warning (`onboarding.seed_demo_courses.failed` with `orgId`, `goal`, `error`), return `{ success: true, data: { ...metadata, seededCourseIds: [] } }` so the user is not stranded. A background retry is **not** in scope for v1.
|
||||
|
||||
### FR-5 — Theme Picker UI
|
||||
- New `apps/dashboard/src/lib/features/onboarding/components/theme-picker.svelte`.
|
||||
- Renders the 8-theme grid + the 4-tile preview block.
|
||||
- Theme list is sourced from `landingPageThemes` in `apps/dashboard/src/lib/features/org/utils/landing-page.ts:22` (single source of truth).
|
||||
- Theme labels are translation keys: `onboarding.theme.{themeId}`.
|
||||
- Preview tiles reuse the existing course-card primitive (the one rendered at `/courses` — verify the component name during implementation; if multiple exist, pick the one used in the published course list).
|
||||
|
||||
### FR-6 — Step Constants & Progress Bar
|
||||
- Update `ONBOARDING_STEPS` to `{ ORG_SETUP: 1, THEME_PICKER: 2, USER_METADATA: 3 }`.
|
||||
- The existing progress bar (`apps/dashboard/src/routes/(auth)/onboarding/+page.svelte:24`) uses `Object.keys(ONBOARDING_STEPS).length`, so it auto-scales.
|
||||
- Back button is enabled on steps 2 and 3 (currently only enabled when `step > ORG_SETUP`).
|
||||
|
||||
### FR-7 — Validation
|
||||
- Add `ZOnboardingSelectTheme` in `packages/utils/src/validation/onboarding/`:
|
||||
```ts
|
||||
export const ZOnboardingSelectTheme = z.object({
|
||||
theme: z.enum(LANDING_PAGE_THEMES)
|
||||
});
|
||||
```
|
||||
- `LANDING_PAGE_THEMES` must be a single shared literal exported once (either from `@cio/ui/custom/org-landing-page/types` or `@cio/utils`) and reused by both the validator and the frontend `landingPageThemes` array.
|
||||
|
||||
### FR-8 — Frontend Type Hygiene
|
||||
- Per CLAUDE.md frontend conventions:
|
||||
- All request/response types live in `apps/dashboard/src/lib/features/onboarding/utils/types.ts`, inferred from the API (`typeof classroomio.onboarding['select-theme'].$post`).
|
||||
- **No** type definitions in `.svelte.ts` files.
|
||||
- All copy lives in `apps/dashboard/src/lib/utils/translations/en.json` under `onboarding.theme.*`.
|
||||
|
||||
### FR-9 — Observability
|
||||
- Emit funnel events (whatever analytics layer the dashboard currently uses — verify before instrumenting):
|
||||
- `onboarding.theme_picker_viewed`
|
||||
- `onboarding.theme_selected` (with `theme` property)
|
||||
- `onboarding.demo_courses_seeded` (with `goal`, `count`, `latency_ms`)
|
||||
- `onboarding.demo_courses_seed_failed` (with `goal`, `error`)
|
||||
- These are additive; do not remove existing onboarding analytics.
|
||||
|
||||
### FR-10 — Self-Host Safety
|
||||
- The demo-course seed payload is a static constant in `@cio/db`. Self-hosted instances get the same behavior with no external dependencies (no LLM calls, no image hosting beyond static assets bundled with the dashboard).
|
||||
- Default theme `'minimal'` continues to be applied at org-create time, so users who skip step 2 (e.g. close the tab) still end up with a working landing page.
|
||||
|
||||
---
|
||||
|
||||
## Proposed API + Service Design
|
||||
|
||||
### Validation — `packages/utils/src/validation/onboarding/`
|
||||
- `ZOnboardingSelectTheme` — see FR-7.
|
||||
- `ZOnboardingUpdateMetadata` — **no shape change**; behavior change is server-side only.
|
||||
|
||||
### Routes — `apps/api/src/routes/onboarding/onboarding.ts`
|
||||
- `POST /onboarding/select-theme` *(new)* — auth + zValidator(ZOnboardingSelectTheme); delegates to `selectOnboardingTheme(profileId, orgId, theme)`.
|
||||
- `POST /onboarding/update-metadata` *(extended)* — after `updateUserOnboarding`, awaits `seedDemoCoursesForGoal(orgId, profileId, goal)` with timeout/log-on-fail behavior in FR-4. Returns `{ success: true, data: { ...existing, seededCourseIds } }`.
|
||||
|
||||
### Services — `apps/api/src/services/onboarding/`
|
||||
- `select-theme.ts` — `selectOnboardingTheme(profileId, orgId, theme)`. Authorizes profile is owner; calls query helper.
|
||||
- `seed-demo-courses.ts` — `seedDemoCoursesForGoal(orgId, profileId, goal)`. Idempotency check + delegate to query helper.
|
||||
|
||||
### Queries — `packages/db/src/queries/`
|
||||
- `organization/landing-page.ts` — `updateOrgLandingPageTheme(orgId, theme)` using `jsonb_set`.
|
||||
- `onboarding/demo-courses.ts` — `createDemoCoursesForOrg(orgId, profileId, specs)` running in a single transaction.
|
||||
|
||||
### Data — `packages/db/src/utils/demo-courses/index.ts`
|
||||
- Static export `DEMO_COURSES_BY_GOAL` + types. Lives in `@cio/db` so both API services and (future) self-host migration tools can import it.
|
||||
|
||||
### Dashboard — `apps/dashboard/src/lib/features/onboarding/`
|
||||
- `utils/constants.ts` — add `THEME_PICKER` step.
|
||||
- `utils/types.ts` — extend `OnboardingField` with `theme?: OrgLandingPageTheme`; add request type for the new endpoint.
|
||||
- `api/onboarding.svelte.ts` — add `submitTheme(theme)` method; update step dispatcher; advance to `USER_METADATA` on success.
|
||||
- `components/theme-picker.svelte` *(new)* — the new step UI.
|
||||
- `routes/(auth)/onboarding/+page.svelte` — extend the if/else block to a 3-way switch; wire back-button to step down through all 3 states.
|
||||
|
||||
---
|
||||
|
||||
## Data Model Changes
|
||||
**None at schema level.** Both new behaviors reuse existing tables:
|
||||
- Theme selection writes to the existing `organization.landingPage` jsonb column.
|
||||
- Demo courses use the existing `course`, `courseSection`, `lesson` tables (no new columns).
|
||||
|
||||
No migration is required. (Aligns with the user's standing rule: schema edits stop at a passing `@cio/db` build; migrations are handled outside this workflow.)
|
||||
|
||||
---
|
||||
|
||||
## Translation Keys (new)
|
||||
Under `onboarding.theme.*` in `apps/dashboard/src/lib/utils/translations/en.json`:
|
||||
- `heading` — "Pick a look for your school"
|
||||
- `subheading` — "You can change this anytime in settings."
|
||||
- `demo_heading` — "Your workspace will come pre-loaded"
|
||||
- `demo_subheading` — "We'll add a few sample courses you can edit, publish, or delete."
|
||||
- `minimal`, `bold`, `classic`, `saas`, `tech`, `studio`, `corporate`, `terminal` — display labels.
|
||||
|
||||
---
|
||||
|
||||
## UX Quality Bar
|
||||
Per the user's standing UI rules:
|
||||
- Use `@cio/ui/base/card` for theme tiles, with `ui:ring-primary` for selected state (color tokens prefixed `ui:` for theme resolution).
|
||||
- Use the same course-card primitive `/courses` uses for the preview tiles — do not invent a new card. If a suitable component does not exist, extract one rather than copy-pasting markup.
|
||||
- No hardcoded user-facing strings.
|
||||
- No native `<input>` / `<label>` in this UI; use `@cio/ui/custom/*-field` wrappers if any inputs are needed (none required for v1, but applies if scope grows).
|
||||
|
||||
---
|
||||
|
||||
## Rollout Plan
|
||||
1. **Phase 1 — internal alpha (week 1)**: ship behind a soft "feature flag" by gating step 2 on an env var (`PUBLIC_ONBOARDING_SHOWCASE_ENABLED`) so we can test in staging without exposing to prod signups.
|
||||
2. **Phase 2 — 25% cloud rollout (week 2)**: bucket by user ID hash.
|
||||
3. **Phase 3 — 100% cloud (week 3, assuming no regression)**: remove the env gate; legacy 2-step flow code is deleted.
|
||||
4. **Phase 4 — self-host**: ships with the next normal release; no toggle needed (no external dependencies).
|
||||
|
||||
---
|
||||
|
||||
## Success Metrics
|
||||
1. **+15% relative lift in onboarding completion** (step-3 submit / step-1 view).
|
||||
2. **+30% relative lift in "user views the public landing page in their first session"** — proxy for "did the theme picker land?"
|
||||
3. **+50% relative lift in "user edits at least one course in their first session"** — proxy for the seeded courses being a real lure into the editor.
|
||||
4. **Median onboarding time stays under 4 minutes** — adding a step must not bloat the flow.
|
||||
5. **<2% seed-failure rate** measured by `onboarding.demo_courses_seed_failed` over `onboarding.theme_selected`.
|
||||
|
||||
---
|
||||
|
||||
## Risks and Mitigations
|
||||
1. **Risk**: Seeded demo courses confuse users who think they're real templates they have to keep. **Mitigation**: courses ship as `isPublished: false` drafts; welcome popup copy explicitly says "feel free to edit or delete"; a small "Sample" badge on the course card is a v1.1 candidate.
|
||||
2. **Risk**: Demo course content drifts and looks dated. **Mitigation**: content is in code (`DEMO_COURSES_BY_GOAL`) and reviewed every release; quarterly content refresh is owned by the content team.
|
||||
3. **Risk**: Seed transaction is slow on large goal sets (5+ courses × 9 lessons each = lots of inserts). **Mitigation**: bulk insert with a single transaction; cap demo set at 2 courses × 3 sections × 3 lessons; FR-4 timeout protects the user-facing path.
|
||||
4. **Risk**: Goal value drift (someone adds a 6th goal to `GOALS`) breaks the `DEMO_COURSES_BY_GOAL` lookup. **Mitigation**: TypeScript `Record<OnboardingGoal, ...>` makes this a compile-time error; CI catches it.
|
||||
5. **Risk**: Conflict with the parallel `onboarding-ai-bootstrap` PRD. **Mitigation**: that PRD adds a *website URL* step and AI-generated courses; this PRD's theme step and curated demo set are orthogonal and can stack later (AI courses replace demo courses when AI flow runs; theme picker is shared). Document in `prd/onboarding-ai-bootstrap/README.md` once both are merged.
|
||||
6. **Risk**: Theme choice in onboarding gets overwritten by the default-applied theme on org create. **Mitigation**: org create still applies `'minimal'` default; the new `/select-theme` endpoint runs after and uses `jsonb_set` to patch only the theme field — confirmed by manual E2E in the verification plan.
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
1. Should the preview tiles in step 2 be **personalized** with the org's just-entered name (e.g. "Welcome to {orgName}") to feel less canned? Adds complexity for marginal trust gain.
|
||||
2. Should the welcome popup link directly to one of the seeded courses to encourage immediate editing, vs. dropping the user on the courses list?
|
||||
3. Should `locale != 'en'` users see a banner saying "Sample courses are in English — translate or replace as you like" instead of getting English-language content silently?
|
||||
4. Do we want to display a "Sample" pill on the seeded courses in the course list so the owner remembers what's editable demo content? (Recommendation: yes, but ship in v1.1.)
|
||||
5. Should the theme picker also let the user upload a logo? (Recommendation: no — keep scope tight; logo upload lives in settings.)
|
||||
|
||||
---
|
||||
|
||||
## Verification Plan
|
||||
1. **Build**: `pnpm --filter @cio/db build && pnpm --filter @cio/api build && pnpm --filter @cio/dashboard check`
|
||||
2. **Manual E2E**, fresh user per run:
|
||||
- Sign up → confirm step 1 looks unchanged.
|
||||
- Step 2: 8 themes render with correct preview PNGs; selection ring follows clicks; "Continue" persists theme.
|
||||
- Step 3: submit metadata → confirm redirect to `/org/{slug}/courses` populated with 2 demo courses matching the chosen goal.
|
||||
- Public landing page `/{slug}` renders the picked theme (not `minimal`).
|
||||
- Settings → Landing Page reflects the picked theme.
|
||||
- Open a seeded course → confirm sections, lessons, and lesson notes render.
|
||||
3. **Goal coverage**: run the E2E once per `goal` value (5 runs) — confirm correct course set per goal.
|
||||
4. **Idempotency**: replay `POST /onboarding/update-metadata` via dev tools — confirm no duplicate courses created.
|
||||
5. **Seed failure path**: temporarily throw inside `createDemoCoursesForOrg` — confirm user still completes onboarding and lands on `/courses` (empty but functional), and that `onboarding.demo_courses_seed_failed` log line emits.
|
||||
6. **Theme override**: change theme via back button in step 2 multiple times — last selection wins.
|
||||
Reference in New Issue
Block a user