chore: auto enroll, custom domain improvement, course pricing style improv.
This commit is contained in:
@@ -48,6 +48,7 @@ import { Hono } from '@api/utils/hono';
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import { TOrganization } from '@db/types';
|
||||
import { assetsRouter } from '@api/routes/organization/assets';
|
||||
import { autoEnrollStudent } from '@api/services/organization/auto-enroll';
|
||||
import { organizationAiTutorRouter } from '@api/routes/organization/ai-tutor';
|
||||
import { authMiddleware } from '@api/middlewares/auth';
|
||||
import { authOrApiKeyMiddleware } from '@api/middlewares/auth-or-api-key';
|
||||
@@ -115,6 +116,28 @@ export const organizationRouter = new Hono()
|
||||
* Gets organization team members (non-students)
|
||||
* Requires authentication
|
||||
*/
|
||||
/**
|
||||
* POST /organization/auto-enroll-student
|
||||
* Idempotently enrolls the authenticated user as a STUDENT in the org
|
||||
* identified by the `cio-org-id` header. Called by the dashboard's
|
||||
* `setupApp` after a user authenticates on a tenant site they aren't a
|
||||
* member of yet. Respects `disableSignup` and `inviteOnly` policies.
|
||||
*/
|
||||
.post('/auto-enroll-student', authMiddleware, async (c) => {
|
||||
try {
|
||||
const user = c.get('user')!;
|
||||
const orgId = c.req.header('cio-org-id');
|
||||
|
||||
if (!orgId) {
|
||||
return c.json({ success: false, error: 'Organization ID is required', code: 'ORG_ID_REQUIRED' }, 400);
|
||||
}
|
||||
|
||||
const result = await autoEnrollStudent(user.id, orgId);
|
||||
return c.json({ success: true, data: result }, 200);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to auto-enroll student');
|
||||
}
|
||||
})
|
||||
.get('/team', authMiddleware, orgTeamMemberMiddleware, async (c) => {
|
||||
try {
|
||||
const orgId = c.req.header('cio-org-id')!;
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import {
|
||||
createOrganizationMember,
|
||||
getOrganizationById,
|
||||
getOrganizationMemberIdByOrgAndProfile
|
||||
} from '@cio/db/queries/organization';
|
||||
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import { getProfileById } from '@cio/db/queries/auth';
|
||||
|
||||
interface OrgSignupSettings {
|
||||
signup?: {
|
||||
inviteOnly?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AutoEnrollResult {
|
||||
alreadyMember: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-enroll the authenticated user as a STUDENT in the given org. Used by
|
||||
* the dashboard when a newly-signed-in user lands on a tenant site they
|
||||
* aren't yet a member of (free-tier `<org>.classroomio.school` or a verified
|
||||
* BYOD domain).
|
||||
*
|
||||
* Idempotent: existing members get an `alreadyMember: true` no-op so invited
|
||||
* admins/tutors don't get downgraded.
|
||||
*
|
||||
* Respects the same org policies as `signupGuard` does for email/password
|
||||
* signups: `disableSignup` and `settings.signup.inviteOnly` both block the
|
||||
* insert.
|
||||
*/
|
||||
export async function autoEnrollStudent(userId: string, orgId: string): Promise<AutoEnrollResult> {
|
||||
const existingMemberId = await getOrganizationMemberIdByOrgAndProfile(orgId, userId);
|
||||
if (existingMemberId) {
|
||||
return { alreadyMember: true };
|
||||
}
|
||||
|
||||
const organization = await getOrganizationById(orgId);
|
||||
if (!organization) {
|
||||
throw new AppError('Organization not found', ErrorCodes.NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
if (organization.disableSignup) {
|
||||
throw new AppError('Signup is disabled for this organization', ErrorCodes.FORBIDDEN, 403);
|
||||
}
|
||||
|
||||
const settings = organization.settings as OrgSignupSettings | null;
|
||||
if (settings?.signup?.inviteOnly) {
|
||||
throw new AppError('This organization requires an invitation to join', ErrorCodes.FORBIDDEN, 403);
|
||||
}
|
||||
|
||||
const profile = await getProfileById(userId);
|
||||
if (!profile?.email) {
|
||||
throw new AppError('Profile email not found', ErrorCodes.NOT_FOUND, 404);
|
||||
}
|
||||
|
||||
await createOrganizationMember({
|
||||
organizationId: orgId,
|
||||
profileId: userId,
|
||||
email: profile.email,
|
||||
roleId: ROLE.STUDENT,
|
||||
verified: true
|
||||
});
|
||||
|
||||
return { alreadyMember: false };
|
||||
}
|
||||
@@ -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}"
|
||||
>
|
||||
|
||||
@@ -2164,7 +2164,9 @@
|
||||
"depth": "Hvor dybt skal kurset gå?",
|
||||
"topic": "Emne eller niche",
|
||||
"expertise_level": "Mål ekspertiseniveau",
|
||||
"documentation_url": "Dokumentations-URL (valgfrit)"
|
||||
"documentation_url": {
|
||||
"hint": "Vi læser dette websted for at basere kurset på dit rigtige produkt."
|
||||
}
|
||||
},
|
||||
"expert_on_x": {
|
||||
"title": "Bliv ekspert på X",
|
||||
@@ -2415,7 +2417,9 @@
|
||||
"pending_verification_description": "DNS-poster blev fundet. SSL-udstedelse eller -validering er stadig i gang.",
|
||||
"reconnect_description": "Dette domæne skal forbindes igen gennem det nye Cloudflare-opsætningsflow.",
|
||||
"error_description": "Dette tilpassede domæne eksisterer, men bekræftelsen er ikke fuldført endnu.",
|
||||
"page_subtitle": "Run your public learning site on a web address you own."
|
||||
"page_subtitle": "Run your public learning site on a web address you own.",
|
||||
"dns_record": "DNS-record",
|
||||
"verified_snackbar": "Tilpasset domæne bekræftet"
|
||||
}
|
||||
},
|
||||
"quiz": {
|
||||
|
||||
@@ -2164,7 +2164,9 @@
|
||||
"depth": "Wie tief soll der Kurs gehen?",
|
||||
"topic": "Thema oder Nische",
|
||||
"expertise_level": "Zielkompetenzniveau",
|
||||
"documentation_url": "Dokumentations-URL (optional)"
|
||||
"documentation_url": {
|
||||
"hint": "Wir werden diese Website lesen, um den Kurs in Ihrem realen Produkt zu verankern."
|
||||
}
|
||||
},
|
||||
"expert_on_x": {
|
||||
"title": "Werden Sie Experte für X",
|
||||
@@ -2415,7 +2417,9 @@
|
||||
"pending_verification_description": "DNS-Einträge wurden gefunden. Die SSL-Ausstellung oder -Validierung ist noch im Gange.",
|
||||
"reconnect_description": "Diese Domäne muss über den neuen Cloudflare-Setup-Ablauf erneut verbunden werden.",
|
||||
"error_description": "Diese benutzerdefinierte Domäne existiert, aber die Überprüfung ist noch nicht abgeschlossen.",
|
||||
"page_subtitle": "Run your public learning site on a web address you own."
|
||||
"page_subtitle": "Run your public learning site on a web address you own.",
|
||||
"dns_record": "DNS-Eintrag",
|
||||
"verified_snackbar": "Benutzerdefinierte Domain verifiziert"
|
||||
}
|
||||
},
|
||||
"quiz": {
|
||||
|
||||
@@ -1523,11 +1523,13 @@
|
||||
"dns_type": "Type",
|
||||
"dns_value": "Value",
|
||||
"dns_name": "Name",
|
||||
"dns_record": "DNS record",
|
||||
"refresh": "Refresh",
|
||||
"reconnect": "Reconnect domain",
|
||||
"reconnect_hint": "Reconnect this domain to generate new Cloudflare setup records.",
|
||||
"remove": "Remove",
|
||||
"status_verified": "Verified",
|
||||
"verified_snackbar": "Custom domain verified",
|
||||
"status_pending_dns": "Pending DNS",
|
||||
"status_pending_verification": "Pending verification",
|
||||
"status_reconnect_required": "Reconnect required",
|
||||
|
||||
@@ -2164,7 +2164,9 @@
|
||||
"depth": "¿Qué profundidad debe tener el curso?",
|
||||
"topic": "Tema o nicho",
|
||||
"expertise_level": "Nivel de experiencia objetivo",
|
||||
"documentation_url": "URL de documentación (opcional)"
|
||||
"documentation_url": {
|
||||
"hint": "Leeremos este sitio para basar el curso en su producto real."
|
||||
}
|
||||
},
|
||||
"expert_on_x": {
|
||||
"title": "Conviértete en un experto en X",
|
||||
@@ -2415,7 +2417,9 @@
|
||||
"pending_verification_description": "Se encontraron registros DNS. La emisión o validación de SSL aún está en curso.",
|
||||
"reconnect_description": "Este dominio debe conectarse nuevamente a través del nuevo flujo de configuración de Cloudflare.",
|
||||
"error_description": "Este dominio personalizado existe, pero la verificación aún no se ha completado.",
|
||||
"page_subtitle": "Run your public learning site on a web address you own."
|
||||
"page_subtitle": "Run your public learning site on a web address you own.",
|
||||
"dns_record": "registro DNS",
|
||||
"verified_snackbar": "Dominio personalizado verificado"
|
||||
}
|
||||
},
|
||||
"quiz": {
|
||||
|
||||
@@ -2164,7 +2164,9 @@
|
||||
"depth": "Jusqu’où doit aller le cours ?",
|
||||
"topic": "Sujet ou niche",
|
||||
"expertise_level": "Niveau d’expertise cible",
|
||||
"documentation_url": "URL de la documentation (facultatif)"
|
||||
"documentation_url": {
|
||||
"hint": "Nous lirons ce site pour ancrer le cours dans votre produit réel."
|
||||
}
|
||||
},
|
||||
"expert_on_x": {
|
||||
"title": "Devenez un expert sur X",
|
||||
@@ -2415,7 +2417,9 @@
|
||||
"pending_verification_description": "Des enregistrements DNS ont été trouvés. L'émission ou la validation SSL est toujours en cours.",
|
||||
"reconnect_description": "Ce domaine doit être à nouveau connecté via le nouveau flux de configuration Cloudflare.",
|
||||
"error_description": "Ce domaine personnalisé existe, mais la vérification n'est pas encore terminée.",
|
||||
"page_subtitle": "Run your public learning site on a web address you own."
|
||||
"page_subtitle": "Run your public learning site on a web address you own.",
|
||||
"dns_record": "Enregistrement DNS",
|
||||
"verified_snackbar": "Domaine personnalisé vérifié"
|
||||
}
|
||||
},
|
||||
"quiz": {
|
||||
|
||||
@@ -2164,7 +2164,9 @@
|
||||
"depth": "पाठ्यक्रम कितना गहरा होना चाहिए?",
|
||||
"topic": "विषय या आला",
|
||||
"expertise_level": "लक्ष्य विशेषज्ञता स्तर",
|
||||
"documentation_url": "दस्तावेज़ीकरण URL (वैकल्पिक)"
|
||||
"documentation_url": {
|
||||
"hint": "हम आपके वास्तविक उत्पाद में पाठ्यक्रम को आधार बनाने के लिए इस साइट को पढ़ेंगे।"
|
||||
}
|
||||
},
|
||||
"expert_on_x": {
|
||||
"title": "एक्स पर विशेषज्ञ बनें",
|
||||
@@ -2415,7 +2417,9 @@
|
||||
"pending_verification_description": "डीएनएस रिकॉर्ड पाए गए. एसएसएल जारी करना या सत्यापन अभी भी जारी है।",
|
||||
"reconnect_description": "इस डोमेन को नए क्लाउडफ़ेयर सेटअप फ़्लो के माध्यम से फिर से कनेक्ट करने की आवश्यकता है।",
|
||||
"error_description": "यह कस्टम डोमेन मौजूद है, लेकिन सत्यापन अभी तक पूरा नहीं हुआ है।",
|
||||
"page_subtitle": "Run your public learning site on a web address you own."
|
||||
"page_subtitle": "Run your public learning site on a web address you own.",
|
||||
"dns_record": "डीएनएस रिकॉर्ड",
|
||||
"verified_snackbar": "कस्टम डोमेन सत्यापित"
|
||||
}
|
||||
},
|
||||
"quiz": {
|
||||
|
||||
@@ -2164,7 +2164,9 @@
|
||||
"depth": "Jak głęboko powinien sięgać kurs?",
|
||||
"topic": "Temat lub nisza",
|
||||
"expertise_level": "Docelowy poziom wiedzy",
|
||||
"documentation_url": "Adres URL dokumentacji (opcjonalnie)"
|
||||
"documentation_url": {
|
||||
"hint": "Przeczytamy tę witrynę, aby oprzeć kurs na Twoim prawdziwym produkcie."
|
||||
}
|
||||
},
|
||||
"expert_on_x": {
|
||||
"title": "Zostań ekspertem w dziedzinie X",
|
||||
@@ -2415,7 +2417,9 @@
|
||||
"pending_verification_description": "Znaleziono rekordy DNS. Wydanie lub weryfikacja protokołu SSL jest nadal w toku.",
|
||||
"reconnect_description": "Ta domena musi zostać ponownie połączona poprzez nowy proces konfiguracji Cloudflare.",
|
||||
"error_description": "Ta domena niestandardowa istnieje, ale weryfikacja nie została jeszcze ukończona.",
|
||||
"page_subtitle": "Run your public learning site on a web address you own."
|
||||
"page_subtitle": "Run your public learning site on a web address you own.",
|
||||
"dns_record": "Rekord DNS",
|
||||
"verified_snackbar": "Zweryfikowano domenę niestandardową"
|
||||
}
|
||||
},
|
||||
"quiz": {
|
||||
|
||||
@@ -2164,7 +2164,9 @@
|
||||
"depth": "Quão profundo o curso deve ir?",
|
||||
"topic": "Tópico ou nicho",
|
||||
"expertise_level": "Nível de especialização alvo",
|
||||
"documentation_url": "URL da documentação (opcional)"
|
||||
"documentation_url": {
|
||||
"hint": "Leremos este site para fundamentar o curso em seu produto real."
|
||||
}
|
||||
},
|
||||
"expert_on_x": {
|
||||
"title": "Torne-se um especialista em X",
|
||||
@@ -2415,7 +2417,9 @@
|
||||
"pending_verification_description": "Registros DNS foram encontrados. A emissão ou validação de SSL ainda está em andamento.",
|
||||
"reconnect_description": "Este domínio precisa ser conectado novamente por meio do novo fluxo de configuração da Cloudflare.",
|
||||
"error_description": "Este domínio personalizado existe, mas a verificação ainda não foi concluída.",
|
||||
"page_subtitle": "Run your public learning site on a web address you own."
|
||||
"page_subtitle": "Run your public learning site on a web address you own.",
|
||||
"dns_record": "Registro DNS",
|
||||
"verified_snackbar": "Domínio personalizado verificado"
|
||||
}
|
||||
},
|
||||
"quiz": {
|
||||
|
||||
@@ -2164,7 +2164,9 @@
|
||||
"depth": "Насколько глубоким должен быть курс?",
|
||||
"topic": "Тема или ниша",
|
||||
"expertise_level": "Целевой уровень экспертизы",
|
||||
"documentation_url": "URL-адрес документации (необязательно)"
|
||||
"documentation_url": {
|
||||
"hint": "Мы прочитаем этот сайт, чтобы закрепить курс на вашем реальном продукте."
|
||||
}
|
||||
},
|
||||
"expert_on_x": {
|
||||
"title": "Станьте экспертом по X",
|
||||
@@ -2415,7 +2417,9 @@
|
||||
"pending_verification_description": "DNS-записи были найдены. Выдача или проверка SSL все еще продолжается.",
|
||||
"reconnect_description": "Этот домен необходимо снова подключить с помощью нового процесса настройки Cloudflare.",
|
||||
"error_description": "Этот личный домен существует, но проверка еще не завершена.",
|
||||
"page_subtitle": "Run your public learning site on a web address you own."
|
||||
"page_subtitle": "Run your public learning site on a web address you own.",
|
||||
"dns_record": "DNS-запись",
|
||||
"verified_snackbar": "Персональный домен подтвержден"
|
||||
}
|
||||
},
|
||||
"quiz": {
|
||||
|
||||
@@ -2164,7 +2164,9 @@
|
||||
"depth": "Khóa học nên đi sâu đến mức nào?",
|
||||
"topic": "Chủ đề hoặc niche",
|
||||
"expertise_level": "Mục tiêu trình độ chuyên môn",
|
||||
"documentation_url": "URL tài liệu (tùy chọn)"
|
||||
"documentation_url": {
|
||||
"hint": "Chúng tôi sẽ đọc trang web này để xây dựng khóa học về sản phẩm thực của bạn."
|
||||
}
|
||||
},
|
||||
"expert_on_x": {
|
||||
"title": "Trở thành chuyên gia về X",
|
||||
@@ -2415,7 +2417,9 @@
|
||||
"pending_verification_description": "Bản ghi DNS đã được tìm thấy. Việc cấp hoặc xác thực SSL vẫn đang được tiến hành.",
|
||||
"reconnect_description": "Miền này cần được kết nối lại thông qua quy trình thiết lập Cloudflare mới.",
|
||||
"error_description": "Miền tùy chỉnh này tồn tại nhưng quá trình xác minh chưa hoàn tất.",
|
||||
"page_subtitle": "Run your public learning site on a web address you own."
|
||||
"page_subtitle": "Run your public learning site on a web address you own.",
|
||||
"dns_record": "bản ghi DNS",
|
||||
"verified_snackbar": "Đã xác minh miền tùy chỉnh"
|
||||
}
|
||||
},
|
||||
"quiz": {
|
||||
|
||||
@@ -53,7 +53,8 @@
|
||||
if (isSessionReady && !appInitApi.isInitializedAndReady && !appInitApi.loading) {
|
||||
appInitApi.setupApp($session.data as App.Locals, {
|
||||
isOrgSite: data.isOrgSite,
|
||||
orgSiteName: data.orgSiteName
|
||||
orgSiteName: data.orgSiteName,
|
||||
orgId: data.org?.id ?? null
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
+24
-24
@@ -83,17 +83,17 @@ The same flow works for `app.classroomio.com` (the proxy-skip branch in `oAuthPr
|
||||
|
||||
## Pieces and where they live
|
||||
|
||||
| Concern | File | Notes |
|
||||
|---|---|---|
|
||||
| Better Auth + `oAuthProxy` plugin registration | `packages/db/src/auth.ts` | `productionURL: CONSTANTS.BASE_URL` makes `api.classroomio.com` the canonical OAuth-callback URL. |
|
||||
| Auth endpoint mount in Hono | `apps/api/src/app.ts` (`/api/auth/*` handler) | Rewrites `request.url` from `X-Forwarded-Host`/`-Proto` so the plugin sees the tenant host the user actually visited. |
|
||||
| Cookies → token swap (outbound + inbound) | `apps/api/src/app.ts` (`/api/auth/*` handler) | Stashes the encrypted `cookies=…` blob in Redis under a short token, drops Location header from 10KB+ to ~150 bytes. |
|
||||
| Redis storage helpers | `apps/api/src/utils/redis/oauth-handoff.ts` | `storeHandoffPayload` (2-minute TTL) + `consumeHandoffPayload` (single-use, GET-and-DEL). |
|
||||
| Cloudflare Worker | `apps/tenant-router/src/index.ts` | Forwards `/proxy/*` → API (stripping prefix) and `/api/auth/*` → API (as-is, no strip), everything else → dashboard. |
|
||||
| Browser-side base URL | `apps/dashboard/src/lib/utils/services/api/index.ts` (`getRequestBaseUrl`) | Returns `${origin}/proxy` in the browser so auth cookies stay host-only. |
|
||||
| Browser-side auth client baseURL | `apps/dashboard/src/lib/utils/services/auth/client.ts` | `${origin}/proxy/api/auth` — Better Auth's client treats a baseURL with a path as the full auth root, so we encode the post-Worker-strip path here. |
|
||||
| Auto-enrollment after first signup | `packages/db/src/auth/hooks/tenant-provisioning.ts` | Resolves the tenant org from `cio-org-id` header (email signup) or `x-forwarded-host` (OAuth host). Idempotent membership insert. |
|
||||
| `customSession` plugin (adds `orgRoles` to session) | `packages/db/src/auth.ts` | Loads `{ [orgId]: roleId }` via `getUserOrgRolesMap` and ships it inside the `session_data` cookie cache. Org-scoped middlewares read it from `c.get('orgRoles')` — no per-request DB query. |
|
||||
| Concern | File | Notes |
|
||||
| --------------------------------------------------- | -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Better Auth + `oAuthProxy` plugin registration | `packages/db/src/auth.ts` | `productionURL: CONSTANTS.BASE_URL` makes `api.classroomio.com` the canonical OAuth-callback URL. |
|
||||
| Auth endpoint mount in Hono | `apps/api/src/app.ts` (`/api/auth/*` handler) | Rewrites `request.url` from `X-Forwarded-Host`/`-Proto` so the plugin sees the tenant host the user actually visited. |
|
||||
| Cookies → token swap (outbound + inbound) | `apps/api/src/app.ts` (`/api/auth/*` handler) | Stashes the encrypted `cookies=…` blob in Redis under a short token, drops Location header from 10KB+ to ~150 bytes. |
|
||||
| Redis storage helpers | `apps/api/src/utils/redis/oauth-handoff.ts` | `storeHandoffPayload` (2-minute TTL) + `consumeHandoffPayload` (single-use, GET-and-DEL). |
|
||||
| Cloudflare Worker | `apps/tenant-router/src/index.ts` | Forwards `/proxy/*` → API (stripping prefix) and `/api/auth/*` → API (as-is, no strip), everything else → dashboard. |
|
||||
| Browser-side base URL | `apps/dashboard/src/lib/utils/services/api/index.ts` (`getRequestBaseUrl`) | Returns `${origin}/proxy` in the browser so auth cookies stay host-only. |
|
||||
| Browser-side auth client baseURL | `apps/dashboard/src/lib/utils/services/auth/client.ts` | `${origin}/proxy/api/auth` — Better Auth's client treats a baseURL with a path as the full auth root, so we encode the post-Worker-strip path here. |
|
||||
| Auto-enrollment after first signup | `packages/db/src/auth/hooks/tenant-provisioning.ts` | Resolves the tenant org from `cio-org-id` header (email signup) or `x-forwarded-host` (OAuth host). Idempotent membership insert. |
|
||||
| `customSession` plugin (adds `orgRoles` to session) | `packages/db/src/auth.ts` | Loads `{ [orgId]: roleId }` via `getUserOrgRolesMap` and ships it inside the `session_data` cookie cache. Org-scoped middlewares read it from `c.get('orgRoles')` — no per-request DB query. |
|
||||
|
||||
## Why the Redis handoff exists
|
||||
|
||||
@@ -126,22 +126,22 @@ If Google's consent screen warns users that the app is requesting "sensitive" or
|
||||
|
||||
On the **API** Render service:
|
||||
|
||||
| Var | Value | Why |
|
||||
|---|---|---|
|
||||
| `BETTER_AUTH_SECRET` | random 32+ char string | Encrypts the oauth-proxy cookies blob and signs sessions. |
|
||||
| `PUBLIC_SERVER_URL` | `https://api.classroomio.com` | Better Auth's `baseURL` and the `oAuthProxy` plugin's `productionURL`. |
|
||||
| `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | from Google Cloud Console | OAuth client credentials. |
|
||||
| `REDIS_URL` | Render Key Value internal URL | Required for the cookies-→-token handoff. Without it the handoff falls back to inline cookies and the OAuth flow breaks on tenant subdomains. |
|
||||
| Var | Value | Why |
|
||||
| ------------------------------------------- | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `BETTER_AUTH_SECRET` | random 32+ char string | Encrypts the oauth-proxy cookies blob and signs sessions. |
|
||||
| `PUBLIC_SERVER_URL` | `https://api.classroomio.com` | Better Auth's `baseURL` and the `oAuthProxy` plugin's `productionURL`. |
|
||||
| `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | from Google Cloud Console | OAuth client credentials. |
|
||||
| `REDIS_URL` | Render Key Value internal URL | Required for the cookies-→-token handoff. Without it the handoff falls back to inline cookies and the OAuth flow breaks on tenant subdomains. |
|
||||
|
||||
On the **dashboard** Render service:
|
||||
|
||||
| Var | Value | Why |
|
||||
|---|---|---|
|
||||
| `HOST_HEADER` | `x-forwarded-host` | SvelteKit adapter-node reads the effective host from this so `event.url.host` is the tenant/admin host the user is on. |
|
||||
| `PROTOCOL_HEADER` | `x-forwarded-proto` | Same, for protocol. |
|
||||
| `PRIVATE_APP_HOST` | `classroomio.school` | Used by `getSubdomain` in `layout-setup.ts` to extract the org siteName. |
|
||||
| `PUBLIC_SERVER_URL` | `https://api.classroomio.com` | Used at runtime only as a fallback / for `authClient`'s SSR-time URL constructor; browser code uses `${origin}/proxy` via `getRequestBaseUrl`. |
|
||||
| `PRIVATE_SERVER_URL` | API's internal Render URL | Server-side dashboard → API calls bypass the public edge. |
|
||||
| Var | Value | Why |
|
||||
| -------------------- | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `HOST_HEADER` | `x-forwarded-host` | SvelteKit adapter-node reads the effective host from this so `event.url.host` is the tenant/admin host the user is on. |
|
||||
| `PROTOCOL_HEADER` | `x-forwarded-proto` | Same, for protocol. |
|
||||
| `PRIVATE_APP_HOST` | `classroomio.school` | Used by `getSubdomain` in `layout-setup.ts` to extract the org siteName. |
|
||||
| `PUBLIC_SERVER_URL` | `https://api.classroomio.com` | Used at runtime only as a fallback / for `authClient`'s SSR-time URL constructor; browser code uses `${origin}/proxy` via `getRequestBaseUrl`. |
|
||||
| `PRIVATE_SERVER_URL` | API's internal Render URL | Server-side dashboard → API calls bypass the public edge. |
|
||||
|
||||
## Verifying the flow
|
||||
|
||||
|
||||
@@ -98,13 +98,9 @@ export const auth: ReturnType<typeof betterAuth> = betterAuth({
|
||||
databaseHooks: {
|
||||
user: {
|
||||
create: {
|
||||
after: async (user, ctx) => {
|
||||
after: async (user) => {
|
||||
console.log('[auth] databaseHooks.user.create.after: running', { userId: user.id });
|
||||
|
||||
const request =
|
||||
(ctx as { context?: { request?: Request }; request?: Request } | undefined)?.context?.request ??
|
||||
(ctx as { request?: Request } | undefined)?.request;
|
||||
await createProfileHook(user, request);
|
||||
await createProfileHook(user);
|
||||
}
|
||||
},
|
||||
update: {
|
||||
|
||||
@@ -6,7 +6,6 @@ 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.
|
||||
@@ -37,7 +36,7 @@ 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, request?: Request) => {
|
||||
export const createProfileHook = async (user: User, _request?: Request) => {
|
||||
console.log('[auth] createProfileHook: running', { userId: user.id });
|
||||
|
||||
const existingProfile = await db.select().from(schema.profile).where(eq(schema.profile.id, user.id)).limit(1);
|
||||
@@ -84,10 +83,6 @@ export const createProfileHook = async (user: User, request?: Request) => {
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
import * as schema from '@db/schema';
|
||||
|
||||
import { BRAND_ROOT_DOMAIN, TENANT_ROOT_DOMAIN, blockedSubdomain } from '@cio/utils/constants';
|
||||
import { getOrganizationByCustomDomain, getOrganizationBySiteName } from '@db/queries/organization';
|
||||
|
||||
import { User } from 'better-auth';
|
||||
import { db } from '@db/drizzle';
|
||||
import { ensureOrgMembership } from './sso-provisioning';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
/**
|
||||
* 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 email signup)
|
||||
* 2. OAuth `state` → verification row → `callbackURL` host. Used for Google
|
||||
* and other OAuth/SSO flows where the callback lands on `api.<root>`
|
||||
* directly with no tenant host header. The dashboard always sets
|
||||
* `callbackURL` to the tenant origin, so its host is the trusted signal.
|
||||
* 3. `x-forwarded-host` / `host`: tenant subdomain, brand subdomain, or
|
||||
* verified custom domain. Fallback for non-OAuth flows.
|
||||
*/
|
||||
async function resolveHostFromOAuthState(request: Request): Promise<string | null> {
|
||||
const requestUrl = new URL(request.url);
|
||||
const state = requestUrl.searchParams.get('state');
|
||||
console.log('[auth] tenantProvisioningHook: oauth state', state);
|
||||
if (!state) return null;
|
||||
|
||||
try {
|
||||
const [row] = await db
|
||||
.select({ value: schema.verification.value })
|
||||
.from(schema.verification)
|
||||
.where(eq(schema.verification.identifier, state))
|
||||
.limit(1);
|
||||
|
||||
console.log('[auth] tenantProvisioningHook: verification row?', !!row);
|
||||
if (!row?.value) return null;
|
||||
|
||||
const stateData = JSON.parse(row.value) as { callbackURL?: string };
|
||||
console.log('[auth] tenantProvisioningHook: state.callbackURL', stateData.callbackURL);
|
||||
if (!stateData.callbackURL) return null;
|
||||
|
||||
const host = new URL(stateData.callbackURL).host;
|
||||
console.log('[auth] tenantProvisioningHook: host from state.callbackURL', host);
|
||||
return host;
|
||||
} catch (error) {
|
||||
console.error('[auth] tenantProvisioningHook: failed to resolve OAuth state', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const tenantProvisioningHook = async (user: User, request?: Request) => {
|
||||
if (!user.email || !request) return;
|
||||
console.log('[auth] tenantProvisioningHook: running', { userId: user.id });
|
||||
|
||||
const headerOrgId = request.headers.get('cio-org-id');
|
||||
console.log('[auth] tenantProvisioningHook: headerOrgId', headerOrgId);
|
||||
if (headerOrgId) {
|
||||
console.log('[auth] tenantProvisioningHook: enrolling via header', { userId: user.id, orgId: headerOrgId });
|
||||
await ensureOrgMembership(user.id, user.email, headerOrgId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Prefer the OAuth state's callbackURL host (set by the dashboard on the
|
||||
// tenant origin) before falling back to forwarded/host headers, which on a
|
||||
// direct Google → api.classroomio.com callback won't reflect the tenant.
|
||||
const stateHost = await resolveHostFromOAuthState(request);
|
||||
const fwdHost = request.headers.get('x-forwarded-host');
|
||||
const rawHost = request.headers.get('host');
|
||||
console.log('[auth] tenantProvisioningHook: candidate hosts', { stateHost, fwdHost, rawHost });
|
||||
|
||||
const host = stateHost ?? fwdHost ?? rawHost;
|
||||
if (!host) {
|
||||
console.log('[auth] tenantProvisioningHook: no host candidate, skipping enrollment');
|
||||
return;
|
||||
}
|
||||
|
||||
const orgId = await resolveOrgIdFromHost(host);
|
||||
console.log('[auth] tenantProvisioningHook: resolved orgId from host', { host, orgId });
|
||||
if (!orgId) {
|
||||
console.log('[auth] tenantProvisioningHook: no org for host, skipping enrollment');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[auth] tenantProvisioningHook: enrolling', { userId: user.id, orgId });
|
||||
await ensureOrgMembership(user.id, user.email, orgId);
|
||||
};
|
||||
Reference in New Issue
Block a user