Implement Cloudflare custom domain management flow

- Added support for connecting, refreshing, and removing custom domains through Cloudflare.
- Updated API routes and validation schemas to handle new domain actions.
- Refactored domain management logic in the dashboard to accommodate Cloudflare integration.
- Enhanced user experience with clear status messages and DNS instructions for domain setup.
- Updated translations and UI components to reflect new domain management features.
This commit is contained in:
Rotimi Best
2026-03-27 09:12:53 +01:00
parent 3919ff6c2f
commit f3d212f984
23 changed files with 1376 additions and 244 deletions
+4
View File
@@ -8,6 +8,10 @@ const envSchema = z.object({
CLOUDFLARE_RENDERING_API_KEY: z.string().optional(),
CLOUDFLARE_SECRET_ACCESS_KEY: z.string().optional(),
CLOUDFLARE_IMAGE_BUCKET_DOMAIN: z.string().optional(),
CLOUDFLARE_CUSTOM_HOSTNAMES_API_TOKEN: z.string().optional(),
CLOUDFLARE_CUSTOM_HOSTNAMES_ZONE_ID: z.string().optional(),
CLOUDFLARE_CUSTOM_HOSTNAME_CNAME_TARGET: z.string().optional(),
CLOUDFLARE_CUSTOM_HOSTNAME_ORIGIN: z.string().optional(),
// S3-compatible storage (MinIO, AWS S3, or other S3-compatible backends)
OBJECT_STORAGE_ENDPOINT: z.string().optional(),
OBJECT_STORAGE_REGION: z.string().optional(),
+42 -31
View File
@@ -1,49 +1,60 @@
import { addDomainToVercel, getConfigResponse, removeDomainFromVercelProject } from '@api/services/org/domain';
import { ZDomainActionRequest } from '@cio/utils/validation/organization';
import { Hono } from '@api/utils/hono';
import { authMiddleware } from '@api/middlewares/auth';
import {
assertSupportedCustomDomain,
connectDomain,
normalizeCustomDomain,
refreshDomain,
removeDomain
} from '@api/services/org/domain';
import { orgAdminMiddleware } from '@api/middlewares/org-admin';
import { updateOrg } from '@api/services/organization';
import { handleError } from '@api/utils/errors';
import { z } from 'zod';
const ZDomainRequest = z.object({
params: z.object({
key: z.enum(['verify_domain', 'add_domain', 'remove_domain']),
domain: z.string().min(1)
})
});
import { zValidator } from '@hono/zod-validator';
export const domainRouter = new Hono()
/**
* POST /domain
* Handles domain operations: verify, add, or remove domain
* Requires authentication
* Handles domain operations: connect, refresh, or remove
* Requires authentication and organization admin access
*/
.post('/', authMiddleware, async (c) => {
.post('/', authMiddleware, orgAdminMiddleware, zValidator('json', ZDomainActionRequest), async (c) => {
try {
const body = await c.req.json();
const validatedData = ZDomainRequest.parse(body);
const orgId = c.req.header('cio-org-id')!;
const { action, domain } = c.req.valid('json');
const { key, domain } = validatedData.params;
const normalizedDomain = normalizeCustomDomain(domain);
assertSupportedCustomDomain(normalizedDomain);
if (domain.includes('classroomio.com')) {
return c.json({ success: false }, 400);
}
switch (action) {
case 'connect': {
const result = await connectDomain(normalizedDomain);
await updateOrg(orgId, {
customDomain: normalizedDomain,
isCustomDomainVerified: result.verified
});
switch (key) {
case 'verify_domain': {
const configResponse = await getConfigResponse(domain);
return c.json({ success: true, verified: !configResponse.misconfigured }, 200);
return c.json({ success: true, data: result }, 200);
}
case 'add_domain': {
const addDomainResponse = await addDomainToVercel(domain);
return c.json({ success: true, data: addDomainResponse }, 200);
case 'refresh': {
const result = await refreshDomain(normalizedDomain);
await updateOrg(orgId, {
customDomain: normalizedDomain,
isCustomDomainVerified: result.verified
});
return c.json({ success: true, data: result }, 200);
}
case 'remove_domain': {
const removeDomainResponse = await removeDomainFromVercelProject(domain);
return c.json({ success: true, data: removeDomainResponse }, 200);
case 'remove': {
const result = await removeDomain(normalizedDomain);
await updateOrg(orgId, {
customDomain: null,
isCustomDomainVerified: false
});
return c.json({ success: true, data: result }, 200);
}
default:
return c.json({ success: false, message: 'Invalid request' }, 400);
}
} catch (error) {
return handleError(c, error, 'Failed to process domain request');
+317 -40
View File
@@ -1,48 +1,325 @@
// Code from https://github.com/vercel/platforms/blob/1ed55b13bcae97b037c69ec40b4c32df21c2412c/lib/domains.ts
import { env } from '@api/config/env';
import { AppError, ErrorCodes } from '@api/utils/errors';
export const addDomainToVercel = async (domain: string) => {
return await fetch(
`https://api.vercel.com/v10/projects/${env.PROJECT_ID_VERCEL}/domains${
env.TEAM_ID_VERCEL ? `?teamId=${env.TEAM_ID_VERCEL}` : ''
}`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${env.AUTH_BEARER_TOKEN}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: domain
})
export type DomainSetupStatus =
| 'reconnect_required'
| 'pending_dns'
| 'pending_verification'
| 'verified'
| 'removed'
| 'error';
export interface DomainDnsRecord {
type: 'CNAME' | 'TXT';
name: string;
value: string;
status: 'pending' | 'active';
}
export interface DomainSetupResult {
hostname: string;
status: DomainSetupStatus;
verified: boolean;
reconnectRequired: boolean;
message: string;
provider: 'cloudflare';
dnsRecords: DomainDnsRecord[];
}
interface CloudflareApiError {
code: number;
message: string;
}
interface CloudflareEnvelope<T> {
success: boolean;
errors: CloudflareApiError[];
messages: Array<{ code: number; message: string }>;
result: T;
}
interface CloudflareValidationRecord {
status?: string;
txt_name?: string;
txt_value?: string;
}
interface CloudflareCustomHostname {
id: string;
hostname: string;
status?: string;
ownership_verification?: {
type?: string;
name?: string;
value?: string;
};
ssl?: {
status?: string;
validation_records?: CloudflareValidationRecord[];
};
}
const SUPPORTED_CUSTOM_DOMAIN_PATTERN = /^(?=.{4,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.){2,}[a-z]{2,63}$/i;
function ensureCloudflareConfig() {
const missing = [
['CLOUDFLARE_CUSTOM_HOSTNAMES_API_TOKEN', env.CLOUDFLARE_CUSTOM_HOSTNAMES_API_TOKEN],
['CLOUDFLARE_CUSTOM_HOSTNAMES_ZONE_ID', env.CLOUDFLARE_CUSTOM_HOSTNAMES_ZONE_ID],
['CLOUDFLARE_CUSTOM_HOSTNAME_CNAME_TARGET', env.CLOUDFLARE_CUSTOM_HOSTNAME_CNAME_TARGET]
].filter(([, value]) => !value);
if (missing.length > 0) {
throw new AppError(
`Missing Cloudflare custom domain config: ${missing.map(([key]) => key).join(', ')}`,
ErrorCodes.INTERNAL_ERROR,
500
);
}
}
function getCloudflareBasePath() {
ensureCloudflareConfig();
return `https://api.cloudflare.com/client/v4/zones/${env.CLOUDFLARE_CUSTOM_HOSTNAMES_ZONE_ID}/custom_hostnames`;
}
async function cloudflareRequest<T>(path: string, init: RequestInit = {}): Promise<T> {
const response = await fetch(`${getCloudflareBasePath()}${path}`, {
...init,
headers: {
Authorization: `Bearer ${env.CLOUDFLARE_CUSTOM_HOSTNAMES_API_TOKEN}`,
'Content-Type': 'application/json',
...(init.headers ?? {})
}
).then((res) => res.json());
};
});
export const removeDomainFromVercelProject = async (domain: string) => {
return await fetch(
`https://api.vercel.com/v9/projects/${env.PROJECT_ID_VERCEL}/domains/${domain}${
env.TEAM_ID_VERCEL ? `?teamId=${env.TEAM_ID_VERCEL}` : ''
}`,
{
headers: {
Authorization: `Bearer ${env.AUTH_BEARER_TOKEN}`
},
method: 'DELETE'
const payload = (await response.json()) as CloudflareEnvelope<T>;
if (!response.ok || !payload.success) {
const message =
payload.errors?.map((error) => error.message).join(', ') ||
payload.messages?.map((item) => item.message).join(', ') ||
'Cloudflare domain request failed';
throw new AppError(
message,
response.status === 409 ? ErrorCodes.CONFLICT : ErrorCodes.INTERNAL_ERROR,
(response.status || 500) as 400 | 401 | 403 | 404 | 409 | 422 | 500
);
}
return payload.result;
}
function dedupeDnsRecords(records: DomainDnsRecord[]) {
const seen = new Set<string>();
return records.filter((record) => {
const key = `${record.type}:${record.name}:${record.value}`;
if (seen.has(key)) {
return false;
}
).then((res) => res.json());
};
export const getConfigResponse = async (domain: string) => {
return await fetch(
`https://api.vercel.com/v6/domains/${domain}/config${env.TEAM_ID_VERCEL ? `?teamId=${env.TEAM_ID_VERCEL}` : ''}`,
seen.add(key);
return true;
});
}
function buildDnsRecords(hostname: string, customHostname?: CloudflareCustomHostname | null): DomainDnsRecord[] {
const records: DomainDnsRecord[] = [
{
method: 'GET',
headers: {
Authorization: `Bearer ${env.AUTH_BEARER_TOKEN}`,
'Content-Type': 'application/json'
type: 'CNAME',
name: hostname,
value: env.CLOUDFLARE_CUSTOM_HOSTNAME_CNAME_TARGET!,
status: customHostname?.status === 'active' ? 'active' : 'pending'
}
];
if (customHostname?.ownership_verification?.name && customHostname?.ownership_verification?.value) {
records.push({
type: 'TXT',
name: customHostname.ownership_verification.name,
value: customHostname.ownership_verification.value,
status: customHostname.status === 'active' ? 'active' : 'pending'
});
}
customHostname?.ssl?.validation_records?.forEach((record) => {
if (!record.txt_name || !record.txt_value) {
return;
}
records.push({
type: 'TXT',
name: record.txt_name,
value: record.txt_value,
status: record.status === 'active' ? 'active' : 'pending'
});
});
return dedupeDnsRecords(records);
}
function mapDomainStatus(customHostname?: CloudflareCustomHostname | null): DomainSetupStatus {
if (!customHostname) {
return 'reconnect_required';
}
if (customHostname.status === 'active' || customHostname.ssl?.status === 'active') {
return 'verified';
}
const hasOwnershipRecord = Boolean(
customHostname.ownership_verification?.name && customHostname.ownership_verification?.value
);
if (hasOwnershipRecord) {
return 'pending_dns';
}
const hasSslValidationRecords = Boolean(
customHostname.ssl?.validation_records?.some((record) => record.txt_name && record.txt_value)
);
if (hasSslValidationRecords || customHostname.ssl?.status) {
return 'pending_verification';
}
return 'error';
}
function getStatusMessage(status: DomainSetupStatus) {
switch (status) {
case 'reconnect_required':
return 'Reconnect this custom domain to generate new Cloudflare setup records.';
case 'pending_dns':
return 'Add the DNS records below, then refresh verification.';
case 'pending_verification':
return 'DNS records were found. SSL issuance or validation is still in progress.';
case 'verified':
return 'Custom domain verified.';
case 'removed':
return 'Custom domain removed.';
case 'error':
default:
return 'Custom domain exists, but verification is not complete yet.';
}
}
function toDomainSetupResult(hostname: string, customHostname?: CloudflareCustomHostname | null): DomainSetupResult {
const status = mapDomainStatus(customHostname);
return {
hostname,
status,
verified: status === 'verified',
reconnectRequired: status === 'reconnect_required',
message: getStatusMessage(status),
provider: 'cloudflare',
dnsRecords: status === 'reconnect_required' ? [] : buildDnsRecords(hostname, customHostname)
};
}
export function normalizeCustomDomain(domain: string) {
return domain
.trim()
.toLowerCase()
.replace(/^https?:\/\//i, '')
.split('/')[0]
.replace(/\.$/, '');
}
export function assertSupportedCustomDomain(domain: string) {
if (!SUPPORTED_CUSTOM_DOMAIN_PATTERN.test(domain)) {
throw new AppError(
'Only custom subdomains are supported. Use a subdomain like courses.yourwebsite.com.',
ErrorCodes.VALIDATION_ERROR,
400,
'domain'
);
}
if (domain.includes('classroomio.com')) {
throw new AppError("Domain cannot contain 'classroomio'", ErrorCodes.VALIDATION_ERROR, 400, 'domain');
}
}
async function getCustomHostnameByDomain(hostname: string) {
const results = await cloudflareRequest<CloudflareCustomHostname[]>(`?hostname=${encodeURIComponent(hostname)}`, {
method: 'GET'
});
return results.find((result) => result.hostname.toLowerCase() === hostname.toLowerCase()) ?? null;
}
async function createCustomHostname(hostname: string) {
const body: Record<string, unknown> = {
hostname,
ssl: {
method: 'txt',
type: 'dv'
}
};
if (env.CLOUDFLARE_CUSTOM_HOSTNAME_ORIGIN) {
body.custom_origin_server = env.CLOUDFLARE_CUSTOM_HOSTNAME_ORIGIN;
}
return cloudflareRequest<CloudflareCustomHostname>('', {
method: 'POST',
body: JSON.stringify(body)
});
}
async function revalidateCustomHostname(customHostname: CloudflareCustomHostname) {
return cloudflareRequest<CloudflareCustomHostname>(`/${customHostname.id}`, {
method: 'PATCH',
body: JSON.stringify({
ssl: {
method: 'txt',
type: 'dv'
}
}
).then((res) => res.json());
};
})
});
}
export async function connectDomain(hostname: string): Promise<DomainSetupResult> {
const existing = await getCustomHostnameByDomain(hostname);
const customHostname = existing ?? (await createCustomHostname(hostname));
return toDomainSetupResult(hostname, customHostname);
}
export async function refreshDomain(hostname: string): Promise<DomainSetupResult> {
const existing = await getCustomHostnameByDomain(hostname);
if (!existing) {
return toDomainSetupResult(hostname, null);
}
if (existing.status === 'active' || existing.ssl?.status === 'active') {
return toDomainSetupResult(hostname, existing);
}
const refreshed = await revalidateCustomHostname(existing);
return toDomainSetupResult(hostname, refreshed);
}
export async function removeDomain(hostname: string): Promise<DomainSetupResult> {
const existing = await getCustomHostnameByDomain(hostname);
if (existing) {
await cloudflareRequest<null>(`/${existing.id}`, {
method: 'DELETE'
});
}
return {
hostname,
status: 'removed',
verified: false,
reconnectRequired: false,
message: getStatusMessage('removed'),
provider: 'cloudflare',
dnsRecords: []
};
}
@@ -353,23 +353,21 @@ class OrgApi extends BaseApiWithErrors {
}
/**
* Sends a domain request (verify, add, or remove domain)
* @param key Domain operation key: 'verify_domain', 'add_domain', or 'remove_domain'
* Sends a domain request (connect, refresh, or remove)
* @param action Domain operation action
* @param domain Domain name
* @returns Domain request response data
*/
async sendDomainRequest(key: 'verify_domain' | 'add_domain' | 'remove_domain', domain: string) {
async sendDomainRequest(action: 'connect' | 'refresh' | 'remove', domain: string) {
return this.execute<DomainRequestRequest>({
requestFn: () =>
classroomio.domain.$post({
json: {
params: {
key,
domain
}
action,
domain
}
}),
logContext: `processing domain request: ${key}`,
logContext: `processing domain request: ${action}`,
onSuccess: () => {
this.success = true;
this.errors = {};
@@ -53,6 +53,8 @@ export type UpdateOrganizationSuccess = Extract<UpdateOrganizationResponse, { su
export type DomainRequestRequest = typeof classroomio.domain.$post;
export type DomainRequestResponse = InferResponseType<DomainRequestRequest>;
export type DomainRequestSuccess = Extract<DomainRequestResponse, { success: true }>;
export type DomainRequestData = DomainRequestSuccess['data'];
export type DomainRequestStatus = DomainRequestData['status'];
// SSO types
export type GetSsoConfigRequest = (typeof classroomio.organization)['sso']['$get'];
@@ -1,11 +1,12 @@
<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 Copy from '@lucide/svelte/icons/copy';
import type { DomainRequestData, DomainRequestStatus } from '$features/org/utils/types';
import isValidDomain from 'is-valid-domain';
import { goto } from '$app/navigation';
import { untrack } from 'svelte';
import { onMount, untrack } from 'svelte';
import { parse } from 'tldts';
import { t } from '$lib/utils/functions/translations';
@@ -31,13 +32,19 @@
let isLoading = $state(false);
let isCustomDomainLoading = $state(false);
let isRefreshing = $state(false);
let domainSetup = $state<DomainRequestData | null>(null);
const isDomainValid = $derived(isValidDomain(sanitizeDomain(customDomain), { subdomain: true }));
const currentDomainStatus = $derived<DomainRequestStatus | null>(
domainSetup?.status ??
($currentOrg.customDomain ? ($currentOrg.isCustomDomainVerified ? 'verified' : 'pending_verification') : null)
);
type Error = {
siteName: string;
customDomain: string;
};
let errors: Error = $state({
siteName: '',
customDomain: ''
@@ -55,6 +62,7 @@
errors.siteName = 'Sitename already exists.';
return;
}
isLoading = true;
await orgApi.update($currentOrg.id, {
@@ -64,29 +72,93 @@
if (orgApi.success) {
$currentOrg.siteName = siteName;
goto(`/org/${$currentOrg.siteName}/settings/domains`);
} else if (orgApi.errors.general) {
errors.siteName = orgApi.errors.general;
} else {
if (orgApi.errors.general) {
errors.siteName = orgApi.errors.general;
} else {
errors.siteName = $t('add_org.sitename');
}
errors.siteName = $t('add_org.sitename');
}
isLoading = false;
}
function applyDomainSetup(data: DomainRequestData) {
domainSetup = data;
if (data.status === 'removed') {
$currentOrg.customDomain = '';
$currentOrg.isCustomDomainVerified = false;
return;
}
$currentOrg.customDomain = data.hostname;
$currentOrg.isCustomDomainVerified = data.verified;
customDomain = '';
}
function getDomainStatusLabel(status: DomainRequestStatus | null) {
switch (status) {
case 'verified':
return $t('components.settings.domains.status_verified');
case 'pending_dns':
return $t('components.settings.domains.status_pending_dns');
case 'pending_verification':
return $t('components.settings.domains.status_pending_verification');
case 'reconnect_required':
return $t('components.settings.domains.status_reconnect_required');
case 'error':
return $t('components.settings.domains.status_error');
case 'removed':
return $t('components.settings.domains.status_removed');
default:
return '';
}
}
function getDomainStatusClass(status: DomainRequestStatus | null) {
switch (status) {
case 'verified':
return 'bg-green-500 text-white';
case 'pending_dns':
case 'pending_verification':
return 'bg-yellow-500 text-white';
case 'reconnect_required':
case 'error':
return 'bg-red-500 text-white';
case 'removed':
return 'bg-slate-500 text-white';
default:
return 'bg-yellow-500 text-white';
}
}
function getDomainStatusDescription(status: DomainRequestStatus | null) {
switch (status) {
case 'verified':
return $t('components.settings.domains.verified_description');
case 'pending_dns':
return $t('components.settings.domains.pending_dns_description');
case 'pending_verification':
return $t('components.settings.domains.pending_verification_description');
case 'reconnect_required':
return $t('components.settings.domains.reconnect_description');
case 'error':
return $t('components.settings.domains.error_description');
default:
return $t('components.settings.domains.dns_description');
}
}
async function handleSaveCustomDomain() {
if (!isDomainValid) return;
// Prevent free plan users from bypassing UI restrictions
if ($isFreePlan) {
errors.customDomain = 'Custom domains are only available on paid plans';
return;
}
const sanitizedDomain = sanitizeDomain(customDomain);
const details = parse(sanitizedDomain);
if (!details.subdomain) {
errors.customDomain = $t('components.settings.domains.custom_domain_error');
return;
@@ -99,31 +171,39 @@
isCustomDomainLoading = true;
await orgApi.update($currentOrg.id, {
customDomain: sanitizedDomain
});
try {
const response = await sendDomainRequest('connect', sanitizedDomain);
if (!response.success || !response.data) {
throw new Error(response.message || 'Failed to connect domain');
}
if (!orgApi.success) {
errors.customDomain = orgApi.errors.general || 'Failed to update custom domain';
applyDomainSetup(response.data);
} catch (error) {
console.log('Error: connecting domain', error);
snackbar.error(error instanceof Error ? error.message : String(error));
} finally {
isCustomDomainLoading = false;
return;
}
}
async function handleReconnectCustomDomain() {
if (!$currentOrg.customDomain) return;
isCustomDomainLoading = true;
try {
const data = await sendDomainRequest('add_domain', sanitizedDomain);
if (!data.success) {
throw new Error(data.message || 'Failed to add domain');
const response = await sendDomainRequest('connect', $currentOrg.customDomain);
if (!response.success || !response.data) {
throw new Error(response.message || 'Failed to reconnect domain');
}
console.log('added domain to vercel', data);
} catch (error) {
console.log('Error: adding domain to vercel', error);
snackbar.error(error instanceof Error ? error.message : (error as string));
isCustomDomainLoading = false;
return;
}
$currentOrg.customDomain = sanitizedDomain;
isCustomDomainLoading = false;
applyDomainSetup(response.data);
} catch (error) {
console.log('Error: reconnecting domain', error);
snackbar.error(error instanceof Error ? error.message : String(error));
} finally {
isCustomDomainLoading = false;
}
}
async function handleRemoveCustomDomain() {
@@ -131,57 +211,41 @@
isCustomDomainLoading = true;
await orgApi.update($currentOrg.id, {
customDomain: null,
isCustomDomainVerified: false
});
if (!orgApi.success) {
snackbar.error(orgApi.errors.general || 'Failed to remove custom domain');
isCustomDomainLoading = false;
return;
}
try {
const data = await sendDomainRequest('remove_domain', $currentOrg.customDomain);
if (!data.success) {
throw new Error(data.message || 'Failed to remove domain');
const response = await sendDomainRequest('remove', $currentOrg.customDomain);
if (!response.success || !response.data) {
throw new Error(response.message || 'Failed to remove domain');
}
console.log('removed domain from vercel', data);
} catch (error) {
console.log('Error: removing domain from vercel', error);
snackbar.error(error instanceof Error ? error.message : (error as string));
isCustomDomainLoading = false;
return;
}
$currentOrg.customDomain = '';
$currentOrg.isCustomDomainVerified = false;
isCustomDomainLoading = false;
applyDomainSetup(response.data);
} catch (error) {
console.log('Error: removing domain', error);
snackbar.error(error instanceof Error ? error.message : String(error));
} finally {
isCustomDomainLoading = false;
}
}
async function handleRefreshCustomDomain() {
async function handleRefreshCustomDomain(silent = false) {
if (!$currentOrg.customDomain) return;
isRefreshing = true;
try {
const data = await sendDomainRequest('verify_domain', $currentOrg.customDomain || '');
console.log('data', data);
if (data.verified && !$currentOrg.isCustomDomainVerified) {
await orgApi.update($currentOrg.id, {
isCustomDomainVerified: true
});
if (orgApi.success) {
$currentOrg.isCustomDomainVerified = true;
}
const response = await sendDomainRequest('refresh', $currentOrg.customDomain);
if (!response.success || !response.data) {
throw new Error(response.message || 'Failed to refresh domain');
}
applyDomainSetup(response.data);
} catch (error) {
console.log('Error: refreshing domain', error);
snackbar.error(error as string);
if (!silent) {
snackbar.error(error instanceof Error ? error.message : String(error));
}
} finally {
isRefreshing = false;
}
isRefreshing = false;
}
function resetErrors(_siteName: string | null, _customDomain: string | null) {
@@ -189,16 +253,18 @@
if (errors.siteName) {
errors.siteName = '';
}
if (errors.customDomain) {
errors.customDomain = '';
}
});
}
function getSubdomain() {
const details = parse($currentOrg.customDomain || '');
return details.subdomain;
}
onMount(() => {
if ($currentOrg.customDomain) {
void handleRefreshCustomDomain(true);
}
});
$effect(() => {
resetErrors(siteName, customDomain);
@@ -224,6 +290,7 @@
<Field.Error>{errors.siteName}</Field.Error>
{/if}
</Field.Field>
<Field.Field orientation="horizontal">
<Button variant="outline" onclick={handleSaveSiteName} disabled={isLoading}>
{$t('components.settings.domains.update')}
@@ -237,10 +304,11 @@
<Field.Set>
<Field.Legend>{$t('components.settings.domains.custom')}</Field.Legend>
<UpgradeBanner>{$t('upgrade.domain')}</UpgradeBanner>
<Field.Group>
{#if $currentOrg.customDomain}
<Field.Field>
<div class="flex items-center justify-between">
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-2">
<p class="text-md flex items-center gap-2 font-medium">
{$currentOrg.customDomain}
@@ -249,56 +317,88 @@
</Button>
</p>
<div
class="mt-1 h-2 w-2 rounded-full bg-{$currentOrg.isCustomDomainVerified ? 'green' : 'yellow'}-400"
class={`mt-1 h-2 w-2 rounded-full ${
currentDomainStatus === 'verified'
? 'bg-green-400'
: currentDomainStatus === 'reconnect_required' || currentDomainStatus === 'error'
? 'bg-red-400'
: 'bg-yellow-400'
}`}
></div>
</div>
{#if $currentOrg.isCustomDomainVerified}
<Badge variant="default" class="bg-green-500 px-3 text-xs text-white">Verified</Badge>
{:else}
<Badge variant="default" class="bg-yellow-500 px-3 text-xs text-white">Pending verification</Badge>
{/if}
<Badge variant="default" class={`px-3 text-xs ${getDomainStatusClass(currentDomainStatus)}`}>
{getDomainStatusLabel(currentDomainStatus)}
</Badge>
</div>
</Field.Field>
<Field.Description>{$t('components.settings.domains.dns_description')}</Field.Description>
<Field.Description>{getDomainStatusDescription(currentDomainStatus)}</Field.Description>
<Field.Field>
<div class="flex items-center gap-10 rounded-md border px-4 py-2">
<div class="flex h-[72px] flex-col justify-evenly">
<Field.Label class="font-light">{$t('components.settings.domains.dns_type')}</Field.Label>
<p class="flex h-[40px] items-center">CNAME</p>
{#if domainSetup?.dnsRecords?.length}
<Field.Field>
<div class="space-y-3 rounded-md border px-4 py-3">
{#each domainSetup.dnsRecords as record}
<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>
<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>
</div>
{/each}
</div>
<div class="flex h-[72px] flex-col justify-evenly">
<Field.Label class="font-light">{$t('components.settings.domains.dns_name')}</Field.Label>
<p class="flex h-[40px] items-center">
{getSubdomain()}
</p>
</Field.Field>
{:else if currentDomainStatus !== 'verified'}
<Field.Field>
<div class="rounded-md border px-4 py-3 text-sm ui:text-muted-foreground">
{$t('components.settings.domains.reconnect_hint')}
</div>
<div class="flex h-[72px] flex-col justify-evenly">
<Field.Label class="font-light">{$t('components.settings.domains.dns_value')}</Field.Label>
<p class=" flex items-center gap-1">
cname.vercel-dns.com
<Button variant="ghost" size="icon-sm" onclick={() => copyToClipboard('cname.vercel-dns.com')}>
<p>cname.vercel-dns.com</p>
<Copy />
</Button>
</p>
</div>
</div>
</Field.Field>
</Field.Field>
{/if}
<Field.Field orientation="horizontal">
<Button
variant="outline"
class="flex items-center gap-2 py-2"
onclick={handleRefreshCustomDomain}
loading={isRefreshing}
>
{#if !isRefreshing}
<RotateCcwIcon size={16} />
{/if}
{$t('components.settings.domains.refresh')}
</Button>
{#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}
<Button
variant="outline"
class="flex items-center gap-2 py-2"
onclick={() => handleRefreshCustomDomain()}
loading={isRefreshing}
>
{#if !isRefreshing}
<RotateCcwIcon size={16} />
{/if}
{$t('components.settings.domains.refresh')}
</Button>
{/if}
<Button
variant="destructive"
@@ -1,27 +1,25 @@
import type { DomainRequestData } from '$features/org/utils/types';
import { orgApi } from '$features/org/api/org.svelte';
export const sanitizeDomain = (domain: string) => {
return domain
.trim()
.toLowerCase()
.replace(/^(?:https?:\/\/)?(?:www\.)?/i, '')
.split('/')[0];
.replace(/^https?:\/\//i, '')
.split('/')[0]
.replace(/\.$/, '');
};
export async function sendDomainRequest(
key: 'verify_domain' | 'add_domain' | 'remove_domain',
action: 'connect' | 'refresh' | 'remove',
domain: string
): Promise<{ success: boolean; data?: unknown; verified?: boolean; message?: string }> {
const response = await orgApi.sendDomainRequest(key, domain);
if (orgApi.success && response) {
const data = 'data' in response ? response.data : null;
const verified = 'verified' in response ? response.verified : undefined;
): Promise<{ success: boolean; data?: DomainRequestData; message?: string }> {
const response = await orgApi.sendDomainRequest(action, domain);
if (orgApi.success && response?.data) {
return {
success: true,
data,
verified
data: response.data
};
}
@@ -311,6 +311,7 @@
"creating_new": "Oprettelse af nye oversættelser mislykkedes"
},
"exercise": {
"points_required_auto_grade": "Angiv en positiv pointværdi for hvert spørgsmål, før du gemmer.",
"success": "Gemt",
"submission_updated": "Indsendelse opdateret til",
"error_fetching": "Hentning af øvelser mislykkedes. Genindlæs venligst siden"
@@ -750,6 +751,8 @@
"certificates": {
"title": "Certifikater",
"certificate_settings": "Certifikatindstillinger",
"tab_design": "Design",
"tab_settings": "Indstillinger",
"theme": "Vælg et tema",
"logo": "Brand Logo",
"to_update": "Gå til for at opdatere dit brandimage",
@@ -1071,6 +1074,9 @@
"finish": "Afslut",
"next": "Næste",
"check": "Tjek",
"auto_graded_badge": "Auto-graded",
"manual_grading_required_badge": "Manual grading required",
"points_required_auto_grade": "Points must be greater than 0 for auto-graded exercises.",
"analytics": {
"submissions": "Indsendelser",
"individual": {
@@ -1139,6 +1145,8 @@
"link": "Links"
},
"select_type": "Vælg type",
"question_type_auto_gradable": "Auto",
"question_type_manual_grading": "Manual",
"premium_question_type": "Opgrader for at bruge denne spørgsmålstype"
},
"delete_confirmation": {
@@ -1608,6 +1616,27 @@
"nav_certificates": "Certifikater",
"nav_landing_page": "Landingsside",
"nav_settings": "Indstillinger"
},
"completion": {
"modal": {
"title": "Kursuskravene er fuldført!",
"subtitle": "Tillykke – du kan se og downloade dit certifikat når som helst.",
"view_certificate": "Se certifikat",
"later": "Luk"
}
},
"certification": {
"rules_heading": "Certificeringsregler",
"deadline_label": "Certificeringsfrist",
"deadline_helper": "Studerende skal opfylde fuldførelsesprocenten inden denne dato (lokal tid). Lad være tomt uden deadline.",
"threshold_label": "Minimum fuldførelse (%)",
"threshold_helper": "Kombineret fremskridt i lektion og træning kræves for at opnå certifikatet (0-100). Standard er 100 %.",
"email_message_label": "E-mail med certificering",
"email_message_helper": "Valgfri besked vist i e-mailen, når en elev opfylder kravene. Et link til certifikatsiden er altid inkluderet.",
"email_message_placeholder": "f.eks. Vi er stolte af dine fremskridt - fortsæt med at lære!"
},
"certificates": {
"progress_hint": "{current} % gennemført ({required} % kræves for at downloade)"
}
},
"courses": {
@@ -1785,7 +1814,20 @@
"refresh": "Opdater",
"remove": "Fjern",
"custom_favicon": "Brugerdefineret favicon",
"custom_code": "Brugerdefineret kode"
"custom_code": "Brugerdefineret kode",
"reconnect": "Gentilslut domæne",
"reconnect_hint": "Gentilslut dette domæne for at generere nye Cloudflare-opsætningsposter.",
"status_verified": "Verificeret",
"status_pending_dns": "Afventende DNS",
"status_pending_verification": "Afventer bekræftelse",
"status_reconnect_required": "Genopret forbindelse påkrævet",
"status_error": "Har brug for opmærksomhed",
"status_removed": "Fjernet",
"verified_description": "Dette tilpassede domæne er aktivt og bekræftet.",
"pending_dns_description": "Tilføj DNS-posterne nedenfor, og opdater derefter bekræftelsen.",
"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."
}
},
"quiz": {
@@ -311,6 +311,7 @@
"creating_new": "Das Erstellen neuer Übersetzungen ist fehlgeschlagen"
},
"exercise": {
"points_required_auto_grade": "Legen Sie für jede Frage einen positiven Punktwert fest, bevor Sie speichern.",
"success": "Erfolgreich gespeichert",
"submission_updated": "Einreichung aktualisiert auf",
"error_fetching": "Das Abrufen der Übungen ist fehlgeschlagen. Bitte laden Sie die Seite neu"
@@ -750,6 +751,8 @@
"certificates": {
"title": "Zertifikate",
"certificate_settings": "Zertifikatseinstellungen",
"tab_design": "Design",
"tab_settings": "Einstellungen",
"theme": "Wählen Sie ein Thema",
"logo": "Markenlogo",
"to_update": "Um Ihr Markenimage zu aktualisieren, gehen Sie zu",
@@ -1071,6 +1074,9 @@
"finish": "Fertig",
"next": "Als nächstes",
"check": "Überprüfen",
"auto_graded_badge": "Auto-graded",
"manual_grading_required_badge": "Manual grading required",
"points_required_auto_grade": "Points must be greater than 0 for auto-graded exercises.",
"analytics": {
"submissions": "Einsendungen",
"individual": {
@@ -1139,6 +1145,8 @@
"link": "Links"
},
"select_type": "Typ auswählen",
"question_type_auto_gradable": "Auto",
"question_type_manual_grading": "Manual",
"premium_question_type": "Führen Sie ein Upgrade durch, um diesen Fragetyp zu verwenden"
},
"delete_confirmation": {
@@ -1608,6 +1616,27 @@
"nav_certificates": "Zertifikate",
"nav_landing_page": "Landingpage",
"nav_settings": "Einstellungen"
},
"completion": {
"modal": {
"title": "Kursvoraussetzungen erfüllt!",
"subtitle": "Herzlichen Glückwunsch – Sie können Ihr Zertifikat jederzeit einsehen und herunterladen.",
"view_certificate": "Zertifikat ansehen",
"later": "Schließen"
}
},
"certification": {
"rules_heading": "Zertifizierungsregeln",
"deadline_label": "Zertifizierungsfrist",
"deadline_helper": "Die Schüler müssen den Abschlussprozentsatz vor diesem Datum/Uhrzeit (Ortszeit) erreichen. Für keine Frist leer lassen.",
"threshold_label": "Mindestabschluss (%)",
"threshold_helper": "Kombinierter Unterrichts- und Übungsfortschritt, der zum Erwerb des Zertifikats erforderlich ist (0–100). Der Standardwert ist 100 %.",
"email_message_label": "E-Mail-Nachricht zur Zertifizierung",
"email_message_helper": "Optionale Nachricht, die in der E-Mail angezeigt wird, wenn ein Student die Anforderungen erfüllt. Ein Link zur Zertifikatsseite ist immer enthalten.",
"email_message_placeholder": "z.B. Wir sind stolz auf Ihre Fortschritte – lernen Sie weiter!"
},
"certificates": {
"progress_hint": "{current} % abgeschlossen ({required} % zum Herunterladen erforderlich)"
}
},
"courses": {
@@ -1785,7 +1814,20 @@
"refresh": "Aktualisieren",
"remove": "Entfernen",
"custom_favicon": "Benutzerdefiniertes Favicon",
"custom_code": "Benutzerdefinierter Code"
"custom_code": "Benutzerdefinierter Code",
"reconnect": "Domäne erneut verbinden",
"reconnect_hint": "Verbinden Sie diese Domäne erneut, um neue Cloudflare-Setup-Datensätze zu generieren.",
"status_verified": "Verifiziert",
"status_pending_dns": "Ausstehendes DNS",
"status_pending_verification": "Ausstehende Überprüfung",
"status_reconnect_required": "Erneute Verbindung erforderlich",
"status_error": "Braucht Aufmerksamkeit",
"status_removed": "Entfernt",
"verified_description": "Diese benutzerdefinierte Domäne ist aktiv und überprüft.",
"pending_dns_description": "Fügen Sie die DNS-Einträge unten hinzu und aktualisieren Sie dann die Überprüfung.",
"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."
}
},
"quiz": {
@@ -777,7 +777,20 @@
"dns_value": "Value",
"dns_name": "Name",
"refresh": "Refresh",
"reconnect": "Reconnect domain",
"reconnect_hint": "Reconnect this domain to generate new Cloudflare setup records.",
"remove": "Remove",
"status_verified": "Verified",
"status_pending_dns": "Pending DNS",
"status_pending_verification": "Pending verification",
"status_reconnect_required": "Reconnect required",
"status_error": "Needs attention",
"status_removed": "Removed",
"verified_description": "This custom domain is active and verified.",
"pending_dns_description": "Add the DNS records below, then refresh verification.",
"pending_verification_description": "DNS records were found. SSL issuance or validation is still in progress.",
"reconnect_description": "This domain needs to be connected again through the new Cloudflare setup flow.",
"error_description": "This custom domain exists, but verification is not complete yet.",
"custom_favicon": "Custom favicon",
"custom_code": "Custom code"
},
@@ -961,6 +974,27 @@
"placeholder": "Search lessons or exercises",
"empty": "No results found"
},
"certificates": {
"progress_hint": "{current}% complete ({required}% required to download)"
},
"certification": {
"rules_heading": "Certification rules",
"deadline_label": "Certification deadline",
"deadline_helper": "Students must meet the completion percentage before this datetime (local time). Leave empty for no deadline.",
"threshold_label": "Minimum completion (%)",
"threshold_helper": "Combined lesson and exercise progress required to earn the certificate (0–100). Default is 100%.",
"email_message_label": "Certification email message",
"email_message_helper": "Optional message shown in the email when a student meets the requirements. A link to the certificate page is always included.",
"email_message_placeholder": "e.g. We are proud of your progress — keep learning!"
},
"completion": {
"modal": {
"title": "Course requirements complete!",
"subtitle": "Congratulations — you can view and download your certificate anytime.",
"view_certificate": "View certificate",
"later": "Close"
}
},
"header": {
"preview": "Preview course",
"preview_missing_slug": "Generate and save a course slug before previewing."
@@ -1261,6 +1295,9 @@
"finish": "Finish",
"next": "Next",
"check": "Check",
"auto_graded_badge": "Auto-graded",
"manual_grading_required_badge": "Manual grading required",
"points_required_auto_grade": "Points must be greater than 0 for auto-graded exercises.",
"description": {
"title": "Title",
"heading": "Description",
@@ -1279,6 +1316,8 @@
"no": "No, cancel",
"yes": "Yes, delete",
"select_type": "Select type",
"question_type_auto_gradable": "Auto",
"question_type_manual_grading": "Manual",
"premium_question_type": "Upgrade to use this question type",
"deleting": "Deleting...",
"error": "You have some errors",
@@ -1776,6 +1815,8 @@
"certificates": {
"title": "Certificates",
"certificate_settings": "Certificate Settings",
"tab_design": "Design",
"tab_settings": "Settings",
"theme": "Choose a theme",
"logo": "Brand Logo",
"to_update": "To update your brand image, go to",
@@ -2303,6 +2344,7 @@
}
},
"exercise": {
"points_required_auto_grade": "Set a positive point value for every question before saving.",
"success": "Saved Successfully",
"submission_updated": "Submission updated to",
"error_fetching": "Fetching exercises failed, please reload the page"
@@ -311,6 +311,7 @@
"creating_new": "Error al crear nuevas traducciones"
},
"exercise": {
"points_required_auto_grade": "Asigne un valor de puntos positivo a cada pregunta antes de guardar.",
"success": "Guardado exitosamente",
"submission_updated": "Envío actualizado a",
"error_fetching": "No se han podido recuperar los ejercicios. Vuelva a cargar la página."
@@ -750,6 +751,8 @@
"certificates": {
"title": "Certificados",
"certificate_settings": "Configuración del certificado",
"tab_design": "Diseño",
"tab_settings": "Configuración",
"theme": "Elige un tema",
"logo": "Logotipo de la marca",
"to_update": "Para actualizar su imagen de marca, vaya a",
@@ -1071,6 +1074,9 @@
"finish": "terminar",
"next": "Siguiente",
"check": "comprobar",
"auto_graded_badge": "Auto-graded",
"manual_grading_required_badge": "Manual grading required",
"points_required_auto_grade": "Points must be greater than 0 for auto-graded exercises.",
"analytics": {
"submissions": "Envíos",
"individual": {
@@ -1139,6 +1145,8 @@
"link": "Enlaces"
},
"select_type": "Seleccionar tipo",
"question_type_auto_gradable": "Auto",
"question_type_manual_grading": "Manual",
"premium_question_type": "Actualice para usar este tipo de pregunta"
},
"delete_confirmation": {
@@ -1608,6 +1616,27 @@
"nav_certificates": "Certificados",
"nav_landing_page": "Página de destino",
"nav_settings": "Configuración"
},
"completion": {
"modal": {
"title": "¡Requisitos del curso completos!",
"subtitle": "Felicitaciones, puede ver y descargar su certificado en cualquier momento.",
"view_certificate": "Ver certificado",
"later": "Cerrar"
}
},
"certification": {
"rules_heading": "Reglas de certificación",
"deadline_label": "Fecha límite de certificación",
"deadline_helper": "Los estudiantes deben alcanzar el porcentaje de finalización antes de esta fecha (hora local). Déjelo vacío sin fecha límite.",
"threshold_label": "Finalización mínima (%)",
"threshold_helper": "Progreso combinado de lección y ejercicio requerido para obtener el certificado (0–100). El valor predeterminado es 100%.",
"email_message_label": "Mensaje de correo electrónico de certificación",
"email_message_helper": "Mensaje opcional que se muestra en el correo electrónico cuando un estudiante cumple con los requisitos. Siempre se incluye un enlace a la página del certificado.",
"email_message_placeholder": "p.ej. Estamos orgullosos de tu progreso: ¡sigue aprendiendo!"
},
"certificates": {
"progress_hint": "{current}% completado ({required}% requerido para descargar)"
}
},
"courses": {
@@ -1785,7 +1814,20 @@
"refresh": "Actualizar",
"remove": "Quitar",
"custom_favicon": "favicon personalizado",
"custom_code": "código personalizado"
"custom_code": "código personalizado",
"reconnect": "Reconectar dominio",
"reconnect_hint": "Vuelva a conectar este dominio para generar nuevos registros de configuración de Cloudflare.",
"status_verified": "Verificado",
"status_pending_dns": "DNS pendiente",
"status_pending_verification": "Pendiente de verificación",
"status_reconnect_required": "Se requiere reconectar",
"status_error": "necesita atencion",
"status_removed": "Eliminado",
"verified_description": "Este dominio personalizado está activo y verificado.",
"pending_dns_description": "Agregue los registros DNS a continuación y luego actualice la verificación.",
"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."
}
},
"quiz": {
@@ -311,6 +311,7 @@
"creating_new": "La création de nouvelles traductions a échoué"
},
"exercise": {
"points_required_auto_grade": "Définissez un nombre de points positif pour chaque question avant d'enregistrer.",
"success": "Enregistré avec succès",
"submission_updated": "Soumission mise à jour pour",
"error_fetching": "La récupération des exercices a échoué, veuillez recharger la page"
@@ -750,6 +751,8 @@
"certificates": {
"title": "Certificats",
"certificate_settings": "Paramètres du certificat",
"tab_design": "Conception",
"tab_settings": "Paramètres",
"theme": "Choisissez un thème",
"logo": "Logo de la marque",
"to_update": "Pour mettre à jour votre image de marque, rendez-vous sur",
@@ -1071,6 +1074,9 @@
"finish": "Terminer",
"next": "Suivant",
"check": "Vérifier",
"auto_graded_badge": "Auto-graded",
"manual_grading_required_badge": "Manual grading required",
"points_required_auto_grade": "Points must be greater than 0 for auto-graded exercises.",
"analytics": {
"submissions": "Soumissions",
"individual": {
@@ -1139,6 +1145,8 @@
"link": "Liens"
},
"select_type": "Sélectionnez le type",
"question_type_auto_gradable": "Auto",
"question_type_manual_grading": "Manual",
"premium_question_type": "Mettre à niveau pour utiliser ce type de question"
},
"delete_confirmation": {
@@ -1608,6 +1616,27 @@
"nav_certificates": "Certificats",
"nav_landing_page": "Page de destination",
"nav_settings": "Paramètres"
},
"completion": {
"modal": {
"title": "Exigences du cours terminées !",
"subtitle": "Félicitations : vous pouvez consulter et télécharger votre certificat à tout moment.",
"view_certificate": "Afficher le certificat",
"later": "Fermer"
}
},
"certification": {
"rules_heading": "Règles de certification",
"deadline_label": "Date limite de certification",
"deadline_helper": "Les étudiants doivent atteindre le pourcentage d'achèvement avant cette date/heure (heure locale). Laisser vide sans date limite.",
"threshold_label": "Achèvement minimum (%)",
"threshold_helper": "Progrès combiné des leçons et des exercices requis pour obtenir le certificat (0-100). La valeur par défaut est 100 %.",
"email_message_label": "Courriel de certification",
"email_message_helper": "Message facultatif affiché dans l'e-mail lorsqu'un étudiant répond aux exigences. Un lien vers la page du certificat est toujours inclus.",
"email_message_placeholder": "par ex. Nous sommes fiers de vos progrès – continuez à apprendre !"
},
"certificates": {
"progress_hint": "{current} % terminé ({required} % requis pour le téléchargement)"
}
},
"courses": {
@@ -1785,7 +1814,20 @@
"refresh": "Actualiser",
"remove": "Supprimer",
"custom_favicon": "Icône de favori personnalisée",
"custom_code": "Code personnalisé"
"custom_code": "Code personnalisé",
"reconnect": "Reconnecter le domaine",
"reconnect_hint": "Reconnectez ce domaine pour générer de nouveaux enregistrements de configuration Cloudflare.",
"status_verified": "Vérifié",
"status_pending_dns": "DNS en attente",
"status_pending_verification": "En attente de vérification",
"status_reconnect_required": "Reconnexion requise",
"status_error": "A besoin d'attention",
"status_removed": "Supprimé",
"verified_description": "Ce domaine personnalisé est actif et vérifié.",
"pending_dns_description": "Ajoutez les enregistrements DNS ci-dessous, puis actualisez la vérification.",
"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."
}
},
"quiz": {
@@ -311,6 +311,7 @@
"creating_new": "नए अनुवाद बनाना विफल रहा"
},
"exercise": {
"points_required_auto_grade": "सहेजने से पहले प्रत्येक प्रश्न के लिए धनात्मक अंक निर्धारित करें।",
"success": "सफलतापूर्वक सहेजा गया",
"submission_updated": "सबमिशन को अद्यतन किया गया",
"error_fetching": "फ़ेचिंग अभ्यास विफल रहा, कृपया पृष्ठ पुनः लोड करें"
@@ -750,6 +751,8 @@
"certificates": {
"title": "प्रमाणपत्र",
"certificate_settings": "प्रमाणपत्र सेटिंग्स",
"tab_design": "डिज़ाइन",
"tab_settings": "सेटिंग्स",
"theme": "एक थीम चुनें",
"logo": "ब्रांड लोगो",
"to_update": "अपनी ब्रांड छवि को अद्यतन करने के लिए, पर जाएँ",
@@ -1071,6 +1074,9 @@
"finish": "ख़त्म करो",
"next": "अगला",
"check": "जांचें",
"auto_graded_badge": "Auto-graded",
"manual_grading_required_badge": "Manual grading required",
"points_required_auto_grade": "Points must be greater than 0 for auto-graded exercises.",
"analytics": {
"submissions": "प्रस्तुतियाँ",
"individual": {
@@ -1139,6 +1145,8 @@
"link": "कड़ियाँ"
},
"select_type": "प्रकार चुनें",
"question_type_auto_gradable": "Auto",
"question_type_manual_grading": "Manual",
"premium_question_type": "इस प्रश्न प्रकार का उपयोग करने के लिए अपग्रेड करें"
},
"delete_confirmation": {
@@ -1608,6 +1616,27 @@
"nav_certificates": "प्रमाणपत्र",
"nav_landing_page": "लैंडिंग पृष्ठ",
"nav_settings": "सेटिंग्स"
},
"completion": {
"modal": {
"title": "पाठ्यक्रम की आवश्यकताएँ पूर्ण!",
"subtitle": "बधाई हो - आप अपना प्रमाणपत्र कभी भी देख और डाउनलोड कर सकते हैं।",
"view_certificate": "प्रमाणपत्र देखें",
"later": "बंद करें"
}
},
"certification": {
"rules_heading": "प्रमाणन नियम",
"deadline_label": "प्रमाणीकरण की समय सीमा",
"deadline_helper": "छात्रों को इस दिनांक समय (स्थानीय समय) से पहले पूर्णता प्रतिशत पूरा करना होगा। बिना किसी समय सीमा के खाली छोड़ दें.",
"threshold_label": "न्यूनतम पूर्णता (%)",
"threshold_helper": "प्रमाणपत्र (0-100) अर्जित करने के लिए संयुक्त पाठ और अभ्यास प्रगति की आवश्यकता है। डिफ़ॉल्ट 100% है.",
"email_message_label": "प्रमाणीकरण ईमेल संदेश",
"email_message_helper": "जब कोई छात्र आवश्यकताओं को पूरा करता है तो ईमेल में वैकल्पिक संदेश दिखाया जाता है। प्रमाणपत्र पृष्ठ का एक लिंक हमेशा शामिल किया जाता है।",
"email_message_placeholder": "जैसे हमें आपकी प्रगति पर गर्व है - सीखते रहें!"
},
"certificates": {
"progress_hint": "{वर्तमान}% पूर्ण (डाउनलोड करने के लिए {आवश्यक}% आवश्यक)"
}
},
"courses": {
@@ -1785,7 +1814,20 @@
"refresh": "ताज़ा करें",
"remove": "हटाओ",
"custom_favicon": "कस्टम फ़ेविकॉन",
"custom_code": "कस्टम कोड"
"custom_code": "कस्टम कोड",
"reconnect": "डोमेन पुनः कनेक्ट करें",
"reconnect_hint": "नए क्लाउडफ़ेयर सेटअप रिकॉर्ड बनाने के लिए इस डोमेन को पुनः कनेक्ट करें।",
"status_verified": "सत्यापित",
"status_pending_dns": "लंबित डीएनएस",
"status_pending_verification": "सत्यापन लंबित है",
"status_reconnect_required": "पुनः कनेक्ट करना आवश्यक है",
"status_error": "ध्यान देने की जरूरत है",
"status_removed": "हटा दिया गया",
"verified_description": "यह कस्टम डोमेन सक्रिय और सत्यापित है.",
"pending_dns_description": "नीचे DNS रिकॉर्ड जोड़ें, फिर सत्यापन ताज़ा करें।",
"pending_verification_description": "डीएनएस रिकॉर्ड पाए गए. एसएसएल जारी करना या सत्यापन अभी भी जारी है।",
"reconnect_description": "इस डोमेन को नए क्लाउडफ़ेयर सेटअप फ़्लो के माध्यम से फिर से कनेक्ट करने की आवश्यकता है।",
"error_description": "यह कस्टम डोमेन मौजूद है, लेकिन सत्यापन अभी तक पूरा नहीं हुआ है।"
}
},
"quiz": {
@@ -311,6 +311,7 @@
"creating_new": "Tworzenie nowych tłumaczeń nie powiodło się"
},
"exercise": {
"points_required_auto_grade": "Ustaw dodatnią liczbę punktów dla każdego pytania przed zapisaniem.",
"success": "Zapisano pomyślnie",
"submission_updated": "Zgłoszenie zaktualizowane do",
"error_fetching": "Pobieranie ćwiczeń nie powiodło się. Załaduj stronę ponownie"
@@ -750,6 +751,8 @@
"certificates": {
"title": "Certyfikaty",
"certificate_settings": "Ustawienia certyfikatu",
"tab_design": "Projekt",
"tab_settings": "Ustawienia",
"theme": "Wybierz motyw",
"logo": "Logo marki",
"to_update": "Aby zaktualizować wizerunek swojej marki, przejdź do",
@@ -1071,6 +1074,9 @@
"finish": "Zakończ",
"next": "Następny",
"check": "Sprawdź",
"auto_graded_badge": "Auto-graded",
"manual_grading_required_badge": "Manual grading required",
"points_required_auto_grade": "Points must be greater than 0 for auto-graded exercises.",
"analytics": {
"submissions": "Zgłoszenia",
"individual": {
@@ -1139,6 +1145,8 @@
"link": "Linki"
},
"select_type": "Wybierz typ",
"question_type_auto_gradable": "Auto",
"question_type_manual_grading": "Manual",
"premium_question_type": "Uaktualnij, aby używać tego typu pytań"
},
"delete_confirmation": {
@@ -1608,6 +1616,27 @@
"nav_certificates": "Certyfikaty",
"nav_landing_page": "Strona docelowa",
"nav_settings": "Ustawienia"
},
"completion": {
"modal": {
"title": "Wymagania dotyczące kursu zostały spełnione!",
"subtitle": "Gratulacje — możesz wyświetlić i pobrać swój certyfikat w dowolnym momencie.",
"view_certificate": "Zobacz certyfikat",
"later": "Zamknij"
}
},
"certification": {
"rules_heading": "Zasady certyfikacji",
"deadline_label": "Termin certyfikacji",
"deadline_helper": "Studenci muszą osiągnąć procent ukończenia przed tą datą (czasu lokalnego). Pozostaw puste bez terminu.",
"threshold_label": "Minimalne ukończenie (%)",
"threshold_helper": "Do uzyskania certyfikatu wymagany jest łączny postęp w lekcjach i ćwiczeniach (0–100). Wartość domyślna to 100%.",
"email_message_label": "Wiadomość e-mail z certyfikatem",
"email_message_helper": "Opcjonalna wiadomość wyświetlana w wiadomości e-mail, gdy uczeń spełni wymagania. Zawsze dołączany jest link do strony z certyfikatem.",
"email_message_placeholder": "np. Jesteśmy dumni z Twoich postępów — ucz się dalej!"
},
"certificates": {
"progress_hint": "{bieżący}% ukończenia ({required}% wymagane do pobrania)"
}
},
"courses": {
@@ -1785,7 +1814,20 @@
"refresh": "Odśwież",
"remove": "Usuń",
"custom_favicon": "Niestandardowa favikona",
"custom_code": "Kod niestandardowy"
"custom_code": "Kod niestandardowy",
"reconnect": "Połącz ponownie domenę",
"reconnect_hint": "Połącz ponownie tę domenę, aby wygenerować nowe rekordy konfiguracji Cloudflare.",
"status_verified": "Zweryfikowano",
"status_pending_dns": "Oczekujące DNS-y",
"status_pending_verification": "Oczekuje na weryfikację",
"status_reconnect_required": "Wymagane ponowne połączenie",
"status_error": "Potrzebuje uwagi",
"status_removed": "Usunięto",
"verified_description": "Ta domena niestandardowa jest aktywna i zweryfikowana.",
"pending_dns_description": "Dodaj poniższe rekordy DNS, a następnie odśwież weryfikację.",
"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."
}
},
"quiz": {
@@ -311,6 +311,7 @@
"creating_new": "Falha ao criar novas traduções"
},
"exercise": {
"points_required_auto_grade": "Defina um valor de pontos positivo para cada pergunta antes de salvar.",
"success": "Salvo com sucesso",
"submission_updated": "Envio atualizado para",
"error_fetching": "Falha ao buscar exercícios. Atualize a página"
@@ -750,6 +751,8 @@
"certificates": {
"title": "Certificados",
"certificate_settings": "Configurações de certificado",
"tab_design": "Design",
"tab_settings": "Configurações",
"theme": "Escolha um tema",
"logo": "Logotipo da marca",
"to_update": "Para atualizar a imagem da sua marca, acesse",
@@ -1071,6 +1074,9 @@
"finish": "Concluir",
"next": "Próximo",
"check": "Verifique",
"auto_graded_badge": "Auto-graded",
"manual_grading_required_badge": "Manual grading required",
"points_required_auto_grade": "Points must be greater than 0 for auto-graded exercises.",
"analytics": {
"submissions": "Envios",
"individual": {
@@ -1139,6 +1145,8 @@
"link": "Ligações"
},
"select_type": "Selecione o tipo",
"question_type_auto_gradable": "Auto",
"question_type_manual_grading": "Manual",
"premium_question_type": "Atualize para usar este tipo de pergunta"
},
"delete_confirmation": {
@@ -1608,6 +1616,27 @@
"nav_certificates": "Certificados",
"nav_landing_page": "Página inicial",
"nav_settings": "Configurações"
},
"completion": {
"modal": {
"title": "Requisitos do curso concluídos!",
"subtitle": "Parabéns – você pode visualizar e baixar seu certificado a qualquer momento.",
"view_certificate": "Ver certificado",
"later": "Fechar"
}
},
"certification": {
"rules_heading": "Regras de certificação",
"deadline_label": "Prazo de certificação",
"deadline_helper": "Os alunos devem atingir o percentual de conclusão antes desta data e hora (horário local). Deixe em branco para não ter prazo.",
"threshold_label": "Conclusão mínima (%)",
"threshold_helper": "Progresso combinado de aulas e exercícios necessário para obter o certificado (0–100). O padrão é 100%.",
"email_message_label": "Mensagem de e-mail de certificação",
"email_message_helper": "Mensagem opcional mostrada no e-mail quando um aluno atende aos requisitos. Um link para a página do certificado está sempre incluído.",
"email_message_placeholder": "por exemplo Estamos orgulhosos do seu progresso – continue aprendendo!"
},
"certificates": {
"progress_hint": "{current}% concluído ({required}% necessário para download)"
}
},
"courses": {
@@ -1785,7 +1814,20 @@
"refresh": "Atualizar",
"remove": "Remover",
"custom_favicon": "Favicon personalizado",
"custom_code": "Código personalizado"
"custom_code": "Código personalizado",
"reconnect": "Reconectar domínio",
"reconnect_hint": "Reconecte este domínio para gerar novos registros de configuração da Cloudflare.",
"status_verified": "Verificado",
"status_pending_dns": "DNS pendente",
"status_pending_verification": "Verificação pendente",
"status_reconnect_required": "É necessário reconectar",
"status_error": "Precisa de atenção",
"status_removed": "Removido",
"verified_description": "Este domínio personalizado está ativo e verificado.",
"pending_dns_description": "Adicione os registros DNS abaixo e atualize a verificação.",
"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."
}
},
"quiz": {
@@ -311,6 +311,7 @@
"creating_new": "Не удалось создать новые переводы."
},
"exercise": {
"points_required_auto_grade": "Установите положительное количество баллов для каждого вопроса перед сохранением.",
"success": "Сохранено успешно",
"submission_updated": "Представление обновлено до",
"error_fetching": "Не удалось загрузить упражнения. Обновите страницу."
@@ -750,6 +751,8 @@
"certificates": {
"title": "Сертификаты",
"certificate_settings": "Настройки сертификата",
"tab_design": "Дизайн",
"tab_settings": "Настройки",
"theme": "Выберите тему",
"logo": "Логотип бренда",
"to_update": "Чтобы обновить имидж вашего бренда, перейдите по ссылке",
@@ -1071,6 +1074,9 @@
"finish": "Готово",
"next": "Далее",
"check": "Проверить",
"auto_graded_badge": "Auto-graded",
"manual_grading_required_badge": "Manual grading required",
"points_required_auto_grade": "Points must be greater than 0 for auto-graded exercises.",
"analytics": {
"submissions": "Материалы",
"individual": {
@@ -1139,6 +1145,8 @@
"link": "Ссылки"
},
"select_type": "Выберите тип",
"question_type_auto_gradable": "Auto",
"question_type_manual_grading": "Manual",
"premium_question_type": "Обновите, чтобы использовать этот тип вопроса"
},
"delete_confirmation": {
@@ -1608,6 +1616,27 @@
"nav_certificates": "Сертификаты",
"nav_landing_page": "Целевая страница",
"nav_settings": "Настройки"
},
"completion": {
"modal": {
"title": "Требования курса выполнены!",
"subtitle": "Поздравляем — вы можете просмотреть и скачать свой сертификат в любое время.",
"view_certificate": "Посмотреть сертификат",
"later": "Закрыть"
}
},
"certification": {
"rules_heading": "Правила сертификации",
"deadline_label": "Срок сертификации",
"deadline_helper": "Учащиеся должны достичь процента завершения до этой даты и времени (по местному времени). Оставьте пустым, если срок не установлен.",
"threshold_label": "Минимальное завершение (%)",
"threshold_helper": "Для получения сертификата необходим совмещенный прогресс урока и упражнений (0–100). По умолчанию — 100%.",
"email_message_label": "Сертификационное письмо по электронной почте",
"email_message_helper": "Дополнительное сообщение, отображаемое в электронном письме, когда учащийся соответствует требованиям. Ссылка на страницу сертификата всегда включена.",
"email_message_placeholder": "например Мы гордимся вашим прогрессом — продолжайте учиться!"
},
"certificates": {
"progress_hint": "Выполнено {current}% (для загрузки требуется {required}%)"
}
},
"courses": {
@@ -1785,7 +1814,20 @@
"refresh": "Обновить",
"remove": "Удалить",
"custom_favicon": "Пользовательский значок",
"custom_code": "Пользовательский код"
"custom_code": "Пользовательский код",
"reconnect": "Переподключить домен",
"reconnect_hint": "Повторно подключите этот домен, чтобы создать новые записи настройки Cloudflare.",
"status_verified": "Проверено",
"status_pending_dns": "Ожидание DNS",
"status_pending_verification": "Ожидает проверки",
"status_reconnect_required": "Требуется переподключение",
"status_error": "Требует внимания",
"status_removed": "Удален",
"verified_description": "Этот личный домен активен и проверен.",
"pending_dns_description": "Добавьте записи DNS ниже, затем обновите проверку.",
"pending_verification_description": "DNS-записи были найдены. Выдача или проверка SSL все еще продолжается.",
"reconnect_description": "Этот домен необходимо снова подключить с помощью нового процесса настройки Cloudflare.",
"error_description": "Этот личный домен существует, но проверка еще не завершена."
}
},
"quiz": {
@@ -311,6 +311,7 @@
"creating_new": "Tạo bản dịch mới không thành công"
},
"exercise": {
"points_required_auto_grade": "Đặt điểm dương cho mỗi câu hỏi trước khi lưu.",
"success": "Đã lưu thành công",
"submission_updated": "Đã cập nhật bài nộp vào",
"error_fetching": "Tìm nạp bài tập không thành công, vui lòng tải lại trang"
@@ -750,6 +751,8 @@
"certificates": {
"title": "Chứng chỉ",
"certificate_settings": "Cài đặt chứng chỉ",
"tab_design": "Thiết kế",
"tab_settings": "Cài đặt",
"theme": "Chọn một chủ đề",
"logo": "Logo thương hiệu",
"to_update": "Để cập nhật hình ảnh thương hiệu của bạn, hãy truy cập",
@@ -1071,6 +1074,9 @@
"finish": "Kết thúc",
"next": "Tiếp theo",
"check": "Kiểm tra",
"auto_graded_badge": "Auto-graded",
"manual_grading_required_badge": "Manual grading required",
"points_required_auto_grade": "Points must be greater than 0 for auto-graded exercises.",
"analytics": {
"submissions": "bài nộp",
"individual": {
@@ -1139,6 +1145,8 @@
"link": "Liên kết"
},
"select_type": "Chọn loại",
"question_type_auto_gradable": "Auto",
"question_type_manual_grading": "Manual",
"premium_question_type": "Nâng cấp để sử dụng loại câu hỏi này"
},
"delete_confirmation": {
@@ -1608,6 +1616,27 @@
"nav_certificates": "Chứng chỉ",
"nav_landing_page": "Trang đích",
"nav_settings": "Cài đặt"
},
"completion": {
"modal": {
"title": "Yêu cầu khóa học đã hoàn tất!",
"subtitle": "Xin chúc mừng — bạn có thể xem và tải xuống chứng chỉ của mình bất kỳ lúc nào.",
"view_certificate": "Xem chứng chỉ",
"later": "Đóng"
}
},
"certification": {
"rules_heading": "Quy tắc chứng nhận",
"deadline_label": "Thời hạn chứng nhận",
"deadline_helper": "Học sinh phải đáp ứng tỷ lệ hoàn thành trước ngày này (giờ địa phương). Để trống không có thời hạn.",
"threshold_label": "Hoàn thành tối thiểu (%)",
"threshold_helper": "Cần có tiến độ kết hợp của bài học và bài tập để đạt được chứng chỉ (0–100). Mặc định là 100%.",
"email_message_label": "Thông báo email chứng nhận",
"email_message_helper": "Thông báo tùy chọn hiển thị trong email khi học sinh đáp ứng yêu cầu. Một liên kết đến trang chứng chỉ luôn được bao gồm.",
"email_message_placeholder": "ví dụ. Chúng tôi tự hào về sự tiến bộ của bạn - hãy tiếp tục học hỏi!"
},
"certificates": {
"progress_hint": "Đã hoàn thành {current}% (cần có {required}% để tải xuống)"
}
},
"courses": {
@@ -1785,7 +1814,20 @@
"refresh": "Làm mới",
"remove": "Xóa",
"custom_favicon": "Biểu tượng yêu thích tùy chỉnh",
"custom_code": "Mã tùy chỉnh"
"custom_code": "Mã tùy chỉnh",
"reconnect": "Kết nối lại miền",
"reconnect_hint": "Kết nối lại miền này để tạo bản ghi thiết lập Cloudflare mới.",
"status_verified": "Đã xác minh",
"status_pending_dns": "DNS đang chờ xử lý",
"status_pending_verification": "Đang chờ xác minh",
"status_reconnect_required": "Yêu cầu kết nối lại",
"status_error": "Cần chú ý",
"status_removed": "Đã xóa",
"verified_description": "Miền tùy chỉnh này đang hoạt động và đã được xác minh.",
"pending_dns_description": "Thêm bản ghi DNS bên dưới, sau đó làm mới xác minh.",
"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."
}
},
"quiz": {
+30 -20
View File
@@ -1,33 +1,43 @@
<script>
import { goto } from '$app/navigation';
import { browser } from '$app/environment';
import { page } from '$app/state';
import { Button } from '@cio/ui/base/button';
import { Empty } from '@cio/ui/custom/empty';
import HeartCrack from '@lucide/svelte/icons/heart-crack';
if (page.status == 404 && browser) {
goto('/404');
}
const isNotFound = $derived(page.status === 404);
// TODO: Add error tracking here
console.error('error message', page.error?.message);
</script>
<svelte:head>
<title>Something unexpected occured</title>
<title>{isNotFound ? 'Page not found' : 'Something unexpected occurred'}</title>
</svelte:head>
<Empty
title="Something unexpected occured."
description="Don't worry, your learning is safe. It isn't your fault, it is ours. We have gotten the error notification and will push a fix ASAP. In the meantime, take a short break and come back a bit later."
icon={HeartCrack}
variant="page"
layout="full-page"
showLogo={true}
>
<div class="flex gap-2">
<Button href="https://classroomio.com/tools" variant="secondary">Try Free tools</Button>
<Button href="/">Go Home</Button>
</div>
</Empty>
{#if isNotFound}
<Empty
title="Page not found"
description="The page you're looking for doesn't exist or you don't have permission to view it."
icon={HeartCrack}
variant="page"
layout="full-page"
showLogo={true}
>
<div class="flex gap-2">
<Button href="/">Go Home</Button>
</div>
</Empty>
{:else}
<Empty
title="Something unexpected occurred."
description="Don't worry, your learning is safe. It isn't your fault, it is ours. We have gotten the error notification and will push a fix ASAP. In the meantime, take a short break and come back a bit later."
icon={HeartCrack}
variant="page"
layout="full-page"
showLogo={true}
>
<div class="flex gap-2">
<Button href="https://classroomio.com/tools" variant="secondary">Try Free tools</Button>
<Button href="/">Go Home</Button>
</div>
</Empty>
{/if}
@@ -1,14 +1,14 @@
import { classroomio } from '$lib/utils/services/api';
import { getApiKeyHeaders } from '$lib/utils/services/api/server';
import { calcCourseDiscount } from '$lib/utils/functions/course';
import { redirect } from '@sveltejs/kit';
import { error } from '@sveltejs/kit';
export const load = async ({ params, url }) => {
const slug = params?.slug ?? '';
const inviteToken = url.searchParams.get('invite_token') ?? null;
if (!slug) {
redirect(307, '/404');
throw error(404, 'Not found');
}
if (inviteToken) {
@@ -20,7 +20,7 @@ export const load = async ({ params, url }) => {
const result = await response.json();
if (!result.success || !result.data) {
redirect(307, '/404');
throw error(404, 'Not found');
}
const data = result.data;
@@ -50,9 +50,9 @@ export const load = async ({ params, url }) => {
requiresPaymentOrInvite: false,
token: inviteToken
};
} catch (error) {
console.error('Enroll load: failed to load invite', error);
redirect(307, '/404');
} catch (e) {
console.error('Enroll load: failed to load invite', e);
throw error(404, 'Not found');
}
}
@@ -63,7 +63,7 @@ export const load = async ({ params, url }) => {
const result = await response.json();
if (!result.success || !result.data) {
redirect(307, '/404');
throw error(404, 'Not found');
}
const courseData = result.data;
@@ -99,8 +99,8 @@ export const load = async ({ params, url }) => {
requiresPaymentOrInvite: !isFree,
token: null as string | null
};
} catch (error) {
console.error('Enroll load: failed to load course by slug', error);
redirect(307, '/404');
} catch (e) {
console.error('Enroll load: failed to load course by slug', e);
throw error(404, 'Not found');
}
};
@@ -1,7 +1,7 @@
import { getOrgBySiteName } from '$features/org/api/org.server';
import { classroomio } from '$lib/utils/services/api';
import { getApiKeyHeaders } from '$lib/utils/services/api/server';
import { redirect } from '@sveltejs/kit';
import { error } from '@sveltejs/kit';
export const load = async ({ params = { hash: '' } }) => {
try {
@@ -28,8 +28,8 @@ export const load = async ({ params = { hash: '' } }) => {
invite: result.data,
currentOrg
};
} catch (error) {
console.error('Error decoding organization invite params.hash', error);
redirect(307, '/404');
} catch (e) {
console.error('Error decoding organization invite params.hash', e);
throw error(404, 'Not found');
}
};
@@ -0,0 +1,11 @@
import * as z from 'zod';
export const ZDomainAction = z.enum(['connect', 'refresh', 'remove']);
export const ZDomainActionRequest = z.object({
action: ZDomainAction,
domain: z.string().min(1)
});
export type TDomainAction = z.infer<typeof ZDomainAction>;
export type TDomainActionRequest = z.infer<typeof ZDomainActionRequest>;
@@ -1,5 +1,6 @@
export * from './audience';
export * from './organization';
export * from './domain';
export * from './quiz';
export * from './exercise';
export * from './invite';
+300
View File
@@ -0,0 +1,300 @@
# Cloudflare Custom Subdomain Reconnect PRD
## Status
- Draft
## Date
- March 22, 2026
## Purpose
Replace the current Vercel-managed custom domain setup flow with a Cloudflare-managed custom subdomain flow, and require existing customers to reconnect their custom domains through the new setup path.
## Problem Statement
Custom-domain setup is currently tightly coupled to Vercel project-domain APIs. The backend route in `apps/api/src/routes/domain/domain.ts` proxies directly to Vercel-specific actions, and the dashboard UI in `apps/dashboard/src/lib/features/settings/pages/domains.svelte` hardcodes Vercel DNS guidance (`cname.vercel-dns.com`).
This creates three problems:
1. Domain provisioning logic is provider-specific instead of product-specific.
2. The dashboard cannot render provider-agnostic DNS instructions or richer setup states.
3. Existing setup assumes Vercel remains the control plane for custom domains, which no longer matches the intended infrastructure direction.
We are not doing an in-place live migration of existing Vercel-managed domains. Instead, organizations will be asked to set up their custom domain again through a new Cloudflare-backed flow.
## Confirmed Decisions
1. Cloudflare will manage custom-domain setup going forward.
2. The app itself will not be served by Cloudflare as a full-app hosting migration in this project.
3. Scope is limited to custom subdomains only.
4. Apex/root domains are out of scope.
5. Existing customers will reconnect their domain through the new flow instead of being transparently migrated from Vercel.
## Goals
1. Replace Vercel-specific custom-domain setup with a Cloudflare-backed setup flow.
2. Keep the existing organization data model centered on `customDomain` and `isCustomDomainVerified`.
3. Move domain orchestration into the backend so the dashboard does not manage provider sequencing itself.
4. Render DNS instructions from API responses instead of hardcoded provider copy.
5. Preserve runtime host-to-organization lookup for verified custom domains.
6. Provide a clear reset and reconnect path for organizations with previously configured domains.
## Non-Goals
- Supporting apex/root domains.
- Running the main app on Cloudflare.
- Preserving old Vercel domain attachments indefinitely.
- Building a multi-provider long-term abstraction unless required for rollout safety.
- Automatic migration of existing Vercel domain config without user action.
## Data Sources Checked
- `apps/api/src/routes/domain/domain.ts`
- `apps/api/src/services/org/domain.ts`
- `apps/dashboard/src/lib/features/settings/pages/domains.svelte`
- `apps/dashboard/src/lib/features/org/api/org.svelte.ts`
- `apps/dashboard/src/lib/utils/functions/domain.ts`
- `apps/dashboard/src/lib/utils/services/org/domain.ts`
- `apps/dashboard/src/lib/utils/types/org.ts`
- `apps/dashboard/src/lib/features/app/layout-setup.ts`
- `apps/dashboard/src/lib/features/org/api/org.server.ts`
- `apps/docs/content/docs/how-to-guides/customize-organization.mdx`
## Current State
### Backend
- `POST /domain` accepts `verify_domain`, `add_domain`, and `remove_domain`.
- Those actions proxy directly to Vercel in `apps/api/src/services/org/domain.ts`.
- The backend contract is provider-shaped rather than product-shaped.
### Dashboard
- The settings page stores `customDomain` first via organization update, then separately calls the domain route.
- DNS instructions are hardcoded to `CNAME -> cname.vercel-dns.com`.
- Verification UX is reduced to a simple pending/verified state with no provider-neutral detail.
### Runtime
- Runtime org resolution already supports `customDomain` lookups in `apps/dashboard/src/lib/features/app/layout-setup.ts`.
- Server-side org fetchers already support `customDomain + isCustomDomainVerified`.
- This means the main runtime routing model can stay mostly intact if Cloudflare forwards the original host correctly.
### Docs
- User-facing docs still say external custom domains are “coming soon”, which no longer matches the in-app paid feature.
## User Story
As an organization admin, I want to connect a branded subdomain like `courses.example.com` to ClassroomIO through a clear Cloudflare-backed flow, so learners can access my organization site on my own domain.
As an existing organization admin with a previously configured domain, I want to understand that I must reconnect the domain through the new setup flow, and I want the product to show me the exact DNS record I need to add.
## Functional Requirements
1. Custom domain setup must accept only valid subdomains.
2. The API must return normalized domain setup data instead of Vercel-specific payloads.
3. The backend must own the full provisioning sequence:
- validate input
- provision/register domain with Cloudflare
- persist organization domain state
- return DNS instructions and current status
4. The dashboard must render DNS instructions from API data.
5. The dashboard must support at least these states:
- no domain configured
- reconnect required
- pending DNS
- pending verification
- verified
- error
6. The dashboard must allow admins to remove a configured domain.
7. Existing domains that were previously configured through Vercel must be markable as needing reconnection.
8. Host-based org lookup must continue to work for verified custom domains.
9. Free-plan gating for custom domains must remain unchanged unless a separate pricing decision changes it.
## Non-Functional Requirements
1. Domain-related errors must be actionable and provider-neutral in UI copy.
2. Domain setup operations must be idempotent enough for safe retries by admins.
3. Partial state must be minimized; backend orchestration should avoid “saved in DB but not provisioned” behavior.
4. Rollout must allow safe validation in staging before production reset/reconnect begins.
## Product Requirements
### 1) Setup Scope
- Accept only subdomains such as `courses.example.com` or `www.example.com`.
- Reject apex domains such as `example.com`.
- Continue rejecting `*.classroomio.com` as a custom domain.
### 2) Reconnect Requirement
- Existing organizations with `customDomain` set under the old system must not be treated as automatically healthy.
- The product should clearly indicate that custom domains must be reconnected through the new Cloudflare flow.
- A one-time reset/backfill step may mark existing domains as unverified or reconnect-required.
### 3) Backend-Owned Orchestration
The system should move from this sequence:
1. dashboard updates organization record
2. dashboard calls domain provider API
To this sequence:
1. dashboard submits desired domain
2. backend validates and provisions the domain
3. backend updates organization state
4. backend returns DNS instructions and status
### 4) DNS Instruction Model
The API should return a product-owned response shape, for example:
- `status`
- `verified`
- `hostname`
- `dnsRecords[]`
- `message`
- `reconnectRequired`
The dashboard must not hardcode provider DNS values.
### 5) Verification Model
The system should support a refresh/check action that re-queries current provider state and updates organization verification status.
Minimum statuses:
- `pending_dns`
- `pending_verification`
- `verified`
- `error`
### 6) Remove Domain
Removing a domain should:
1. remove or deactivate provider-side setup where applicable
2. clear organization `customDomain`
3. clear verification state
## UX Requirements
1. The settings page must clearly tell admins that only subdomains are supported.
2. Existing domains that need reconnection must show a clear callout instead of silently appearing valid.
3. DNS records must be copyable from the UI.
4. Status badges and refresh actions must remain available after setup begins.
5. User-facing copy must move through dashboard translations and not be hardcoded in component markup.
## Technical Approach
### Recommended Direction
Use a Cloudflare-backed service layer for custom-domain operations and expose a provider-neutral API contract to the dashboard.
### Backend Changes
1. Replace Vercel-specific service functions in `apps/api/src/services/org/domain.ts`.
2. Refactor `apps/api/src/routes/domain/domain.ts` so route responses are normalized around ClassroomIO product states, not Vercel response shapes.
3. Move organization-state persistence into backend orchestration rather than split frontend/backend sequencing.
4. Add any required Cloudflare env configuration in API config.
### Dashboard Changes
1. Update `apps/dashboard/src/lib/features/settings/pages/domains.svelte` to consume normalized API responses.
2. Remove hardcoded Vercel DNS values.
3. Replace any Vercel-specific types in `apps/dashboard/src/lib/utils/types/org.ts`.
4. Remove or refactor Vercel-specific helpers in `apps/dashboard/src/lib/utils/services/org/domain.ts`.
5. Keep request typing in feature `utils/types.ts` consistent with existing dashboard conventions.
### Runtime Changes
1. Keep `apps/dashboard/src/lib/features/app/layout-setup.ts` as the primary host-to-org resolver.
2. Verify that custom domains routed through the new provider path still preserve host-based organization lookup.
3. No large routing-model rewrite is expected in this project.
## Current State Audit
| Area | Current State | Gap | Priority |
| --- | --- | --- | --- |
| API domain setup | Vercel-specific proxy route | Needs Cloudflare-backed product contract | P0 |
| Backend orchestration | Frontend updates org, then provisions domain | Partial-state risk | P0 |
| Dashboard DNS instructions | Hardcoded Vercel CNAME target | Must become API-driven | P0 |
| Verification states | Basic pending/verified only | Needs richer reconnect/pending/error model | P0 |
| Existing domain rollout | Assumes current domain remains valid | Need reset + reconnect plan | P0 |
| Runtime org lookup | Already supports verified custom domains | Needs validation only, not redesign | P1 |
| Docs | External custom domains marked “coming soon” | Needs correction | P1 |
## Implementation Plan
### Phase 1: API Contract + Cloudflare Service
1. Replace Vercel-specific service implementation in `apps/api/src/services/org/domain.ts`.
2. Redesign `POST /domain` response shape to be provider-neutral.
3. Implement backend orchestration so setup and persistence happen together.
4. Add backend validation to enforce subdomain-only custom domains.
### Phase 2: Dashboard Reconnect UX
1. Update the domains settings page to render DNS instructions from API responses.
2. Add reconnect-required state for existing domains.
3. Update remove and refresh flows to use the new backend contract.
4. Remove Vercel-specific helper/types that no longer apply.
### Phase 3: Existing Domain Reset
1. Identify organizations with existing `customDomain` values.
2. Decide reset behavior:
- mark all as unverified
- add reconnect-required marker in UI
- optionally notify admins out-of-band
3. Validate that old Vercel-managed domains no longer appear healthy in the product by default.
### Phase 4: Docs + Rollout
1. Update docs to describe the Cloudflare-backed custom-domain flow.
2. Align in-app copy with product behavior.
3. Test in staging with at least one reconnect scenario and one fresh setup scenario.
4. Roll out production reset/reconnect.
## Required File Touchpoints
### Core API
- `apps/api/src/routes/domain/domain.ts`
- `apps/api/src/services/org/domain.ts`
- `apps/api/src/config/env.ts` or related provider config files
- `apps/api/src/services/organization.ts` if orchestration is moved into org service layer
### Dashboard
- `apps/dashboard/src/lib/features/settings/pages/domains.svelte`
- `apps/dashboard/src/lib/features/org/api/org.svelte.ts`
- `apps/dashboard/src/lib/features/org/utils/types.ts`
- `apps/dashboard/src/lib/utils/functions/domain.ts`
- `apps/dashboard/src/lib/utils/services/org/domain.ts`
- `apps/dashboard/src/lib/utils/types/org.ts`
- `apps/dashboard/src/lib/utils/translations/en.json`
### Runtime Validation
- `apps/dashboard/src/lib/features/app/layout-setup.ts`
- `apps/dashboard/src/lib/features/org/api/org.server.ts`
### Docs
- `apps/docs/content/docs/how-to-guides/customize-organization.mdx`
## Rollout Plan
1. Build the Cloudflare-backed flow behind a feature flag if needed.
2. Validate fresh setup and reconnect flows in staging.
3. Reset existing production custom domains into reconnect-required state.
4. Publish updated docs and product copy.
5. Ask affected organizations to reconnect their domains.
6. Remove stale Vercel-specific setup code after production rollout stabilizes.
## Testing Strategy
### Unit Tests
1. Domain input normalization and subdomain validation.
2. Backend mapping from provider responses to product statuses.
3. Reconnect-required state handling.
### Integration Tests
1. Add domain returns DNS instructions and a pending status.
2. Refresh/verify updates `isCustomDomainVerified` when provider verification succeeds.
3. Remove domain clears organization state correctly.
4. Verified custom domain still resolves organization context via host-based lookup.
### Manual Verification
1. Fresh org connects a new subdomain successfully.
2. Existing org with previously configured domain is shown reconnect-required state.
3. DNS instructions shown in UI match backend response.
4. Verified domain loads the correct organization site.
5. Removing the domain clears status and UI state cleanly.
## Acceptance Criteria
1. The system no longer depends on Vercel project-domain APIs for custom-domain setup.
2. Only subdomains are accepted for custom domains.
3. The dashboard no longer hardcodes `cname.vercel-dns.com`.
4. Existing domains can be explicitly reset into a reconnect-required flow.
5. Verified custom domains continue to resolve to the correct organization site.
6. Docs and in-app copy accurately describe the supported custom-domain flow.
## Risks and Mitigations
| Risk | Impact | Mitigation |
| --- | --- | --- |
| Backend/provider mismatch causes false verification state | Broken domain UX | Normalize provider states and add integration coverage |
| Existing domains appear valid after rollout even though reconnect is required | Admin confusion and broken trust | Reset state explicitly and surface reconnect-required UI |
| Host forwarding differs from current assumptions | Verified domains fail to resolve org context | Validate host preservation in staging before rollout |
| Frontend/backend sequencing remains split | Partial failures persist | Move orchestration into backend before release |
| Docs lag behind rollout | Support burden | Update docs in same release |
## Open Questions
1. Do we need a new persisted field for reconnect-required, or is resetting `isCustomDomainVerified=false` with derived UI state sufficient?
2. Should production rollout notify affected organizations by email, in-app banner, or both?
3. Do we want to keep the existing `/domain` endpoint shape and only change payloads, or introduce a clearer route contract now?