feat: migrate auth from supabase to better auth (#596)

* feat: add README for @cio/db package and remove existingAccounts.json

* load session, load profile and organization, add create profile hook

* chore: cache session request

The trick was passing all the cookies to the backend. Previously I was only passing the better-auth.session_token and not better-auth.session_data. Read about the difference here https://www.better-auth.com/docs/concepts/cookies#custom-cookies

* feat: login, logout, onboarding, verify email, reset password.

* feat: complete change email and email verification

* feat: standardise how we make rpc calls and create org endpoints

* fix ui errors

* fix: handle potential undefined customerId and standardize avatar URL property

* refactor: standardize custom domain handling and update telegram chat ID references

* chore: add tailwind prefix to @cio/ui

this fixed the media query issues because  @cio/ui classes were overriding this dashboard tailwind build

* chore: clean up org sidebar

* chore: persist mode state

* feat: implement order breadcrumbs

* chore: upgrade to better auth 1.4

* chore: address common auth flow bugs and setup app for others to setup locally.
This commit is contained in:
Rotimi Best
2025-11-24 01:00:50 +01:00
committed by GitHub
parent 24741644d6
commit a3924100dd
567 changed files with 22364 additions and 13437 deletions
@@ -5,16 +5,7 @@ export const SIGNUP_FIELDS = {
confirmPassword: ''
};
export const RESET_FIELDS = {
password: '',
confirmPassword: ''
};
export const LOGIN_FIELDS = {
email: '',
password: ''
};
export const FORGOT_PASSWORD_FIELDS = {
email: ''
};
@@ -1,8 +1,4 @@
export const ROLE = {
ADMIN: 1,
TUTOR: 2,
STUDENT: 3
};
import { ROLE } from '@cio/utils/constants';
export const ROLE_LABEL = {
[ROLE.ADMIN]: 'course.navItem.people.roles.admin',
@@ -15,11 +15,12 @@ export const ROUTE = {
DISCUSSIONS: '/discussions',
ASK: '/ask',
FORGOT: '/forgot',
RESET: '/reset'
RESET: '/reset',
LOGOUT: '/logout'
};
export const PUBLIC_ROUTES = [
`^${ROUTE.HOME}$`,
// `^${ROUTE.HOME}$`,
ROUTE.LOGIN,
ROUTE.SIGN_UP,
`^${ROUTE.INVITE_TEACHER}/.*`,
@@ -30,6 +31,15 @@ export const PUBLIC_ROUTES = [
'/404'
];
export const PUBLIC_API_ROUTES = [
'/api/completion',
'student_prove_payment',
'teacher_student_buycourse',
'/api/polar',
'/api/lmz',
'/api/verify'
];
export const ROUTES_TO_HIDE_NAV = [
`^${ROUTE.LOGIN}$`,
`^${ROUTE.SIGN_UP}$`,
@@ -1,16 +1,16 @@
import { LOCALE } from '../types';
import type { TLocale } from '@cio/db/types';
export const LANGUAGE = {
[LOCALE.DE]: 'German',
[LOCALE.EN]: 'English',
[LOCALE.ES]: 'Spanish',
[LOCALE.FR]: 'French',
[LOCALE.HI]: 'Hindi',
[LOCALE.PL]: 'Polish',
[LOCALE.PT]: 'Portuguese',
[LOCALE.RU]: 'Russian',
[LOCALE.VI]: 'Vietnamese',
[LOCALE.DA]: 'Danish'
export const LANGUAGE: Record<TLocale, string> = {
da: 'Danish',
de: 'German',
en: 'English',
es: 'Spanish',
fr: 'French',
hi: 'Hindi',
pl: 'Polish',
pt: 'Portuguese',
ru: 'Russian',
vi: 'Vietnamese'
};
export const LANGUAGES = Object.keys(LANGUAGE).map((lang) => ({
+16 -1
View File
@@ -25,5 +25,20 @@ export function isQuizPage(path) {
}
export function isLMSPage(path) {
return /lms[\/a-z 0-9 -]*/.test(path);
return /lms[/a-z 0-9 -]*/.test(path);
}
export function isActive(pagePath: string, itemPath: string, exact: boolean = false) {
const pageLinkItems = pagePath.split('/');
const itemLinkItems = itemPath.split('/');
if (exact) {
return pagePath === itemPath;
}
if (itemLinkItems.length !== pageLinkItems.length) {
return false;
}
return pagePath.includes(itemPath);
}
@@ -1,21 +1,6 @@
import { currentOrg, currentOrgDomain } from '$lib/utils/store/org';
import { identifyPosthogUser, initPosthog } from '$lib/utils/services/posthog';
import { initSentry, setSentryUser } from '$lib/utils/services/sentry';
import { profile, user } from '$lib/utils/store/user';
import { ROLE } from '$lib/utils/constants/roles';
import { ROUTE } from '$lib/utils/constants/routes';
import { dev } from '$app/environment';
import { get } from 'svelte/store';
import { getOrganizations } from '$lib/utils/services/org';
import { goto } from '$app/navigation';
import { handleLocaleChange } from '$lib/utils/functions/translations';
import isEmpty from 'lodash/isEmpty';
import isPublicRoute from '$lib/utils/functions/routes/isPublicRoute';
import { page } from '$app/stores';
import { setTheme } from '$lib/utils/functions/theme';
import shouldRedirectOnAuth from '$lib/utils/functions/routes/shouldRedirectOnAuth';
import { supabase } from '$lib/utils/functions/supabase';
import { initPosthog } from '$lib/utils/services/posthog';
import { initSentry } from '$lib/utils/services/sentry';
export function setupAnalytics() {
// Set up sentry
@@ -29,193 +14,3 @@ export function setupAnalytics() {
localStorage.setItem('umami.disabled', '1');
}
}
function setAnalyticsUser() {
const profileStore = get(profile);
if (!profileStore.id) return;
setSentryUser({
id: profileStore.id,
username: profileStore.username,
email: profileStore.email,
fullname: profileStore.fullname
});
identifyPosthogUser(profileStore.id, {
email: profileStore.email,
name: profileStore.fullname
});
}
export async function getProfile({
path,
queryParam,
isOrgSite,
orgSiteName
}: {
path: string;
queryParam: string;
isOrgSite: boolean;
orgSiteName: string;
}) {
const pageStore = get(page);
const profileStore = get(profile);
const currentOrgStore = get(currentOrg);
const currentOrgDomainStore = get(currentOrgDomain);
const params = new URLSearchParams(window.location.search);
// Get user profile
const {
data: { session }
} = await supabase.auth.getSession();
const { user: authUser } = session || {};
console.log('Get user', authUser);
if (!authUser && !isPublicRoute(pageStore.url?.pathname)) {
return goto('/login?redirect=/' + path + queryParam);
}
// Skip refetching profile, if already in store
if (profileStore.id) {
handleLocaleChange(profileStore.locale);
return;
}
// Check if user has profile
const {
data: profileData,
error,
status
} = await supabase.from('profile').select(`*`).eq('id', authUser?.id).single();
console.log('Get profile', profileData);
if (error && !profileData && status === 406 && authUser) {
// User wasn't found, create profile
console.log(`User wasn't found, create profile`);
const [regexUsernameMatch] = [...(authUser.email?.matchAll(/(.*)@/g) || [])];
const isGoogleAuth = !!authUser.app_metadata?.providers?.includes('google');
const { data: newProfileData, error } = await supabase
.from('profile')
.insert({
id: authUser.id,
username: regexUsernameMatch[1] + `${new Date().getTime()}`,
fullname: regexUsernameMatch[1],
email: authUser.email,
is_email_verified: isGoogleAuth,
verified_at: isGoogleAuth ? new Date().toDateString() : undefined
})
.select();
// Profile created, go to onboarding or lms
if (!error && newProfileData) {
user.update((_user) => ({
..._user,
fetchingUser: false,
isLoggedIn: true,
currentSession: authUser
}));
profile.set(newProfileData[0]);
setAnalyticsUser();
// Fetch language
handleLocaleChange(newProfileData[0].locale);
if (isOrgSite) {
const { data, error } = await supabase
.from('organizationmember')
.insert({
organization_id: currentOrgStore.id,
profile_id: profileStore.id,
role_id: 3
})
.select();
if (error) {
console.error('Error adding user to organisation', error);
} else {
console.log('Success adding user to organisation', data);
const memberId = data?.[0]?.id || '';
currentOrg.update((_currentOrg) => ({
..._currentOrg,
memberId
}));
}
if (params.get('redirect')) {
goto(params.get('redirect') || '');
} else if (shouldRedirectOnAuth(path)) {
goto('/lms');
}
return;
}
// On invite page, don't go to onboarding
if (!path.includes('invite')) {
goto(ROUTE.ONBOARDING);
}
}
user.update((_user) => ({
..._user,
fetchingUser: false
}));
} else if (profileData) {
// Profile exists, go to profile page
user.update((_user) => ({
..._user,
fetchingUser: false,
isLoggedIn: true,
currentSession: authUser
}));
profile.set(profileData);
// Set user in sentry
setAnalyticsUser();
handleLocaleChange(profileData.locale);
const orgRes = await getOrganizations(profileData.id, isOrgSite, orgSiteName);
const isStudentAccount = orgRes.currentOrg.role_id == ROLE.STUDENT;
// student redirect
if (isOrgSite) {
if (params.has('redirect')) {
goto(params.get('redirect') || '');
} else if (shouldRedirectOnAuth(path)) {
goto('/lms');
}
} else {
if (isStudentAccount) {
// Check if the student logged into the dashboard.
console.log('Student logged into dashboard');
if (dev) {
goto('/lms');
} else {
window.location.replace(`${currentOrgDomainStore}/lms`);
}
} else if (isEmpty(orgRes.orgs) && !path.includes('invite')) {
// Not on invite page or no org, go to onboarding
goto(ROUTE.ONBOARDING);
} else if (params.has('redirect')) {
goto(params.get('redirect') || '');
} else if (shouldRedirectOnAuth(path)) {
// By default redirect to first organization
goto(`/org/${orgRes.currentOrg.siteName}`);
}
}
setTheme(orgRes?.currentOrg?.theme);
}
if (!profileData && !isPublicRoute(pageStore.url?.pathname)) {
goto('/login?redirect=/' + path);
}
}
@@ -1,23 +1,23 @@
import posthog from 'posthog-js';
import { supabase } from '$lib/utils/functions/supabase';
import { appInitApi } from '$lib/features/app/init.svelte';
import { authClient } from '$lib/utils/services/auth/client';
import { capturePosthogEvent } from '$lib/utils/services/posthog';
import { user, profile, defaultProfileState, defaultUserState } from '$lib/utils/store/user';
import { goto } from '$app/navigation';
import posthog from 'posthog-js';
import { resolve } from '$app/paths';
export async function logout(redirect = true) {
const { error } = await supabase.auth.signOut();
const { error } = await authClient.signOut();
if (error) {
console.error('Error logging out: ', error);
}
user.set(defaultUserState);
profile.set(defaultProfileState);
appInitApi.reset();
capturePosthogEvent('user_logged_out');
posthog.reset();
if (redirect) {
goto('/login');
goto(resolve('/login', {}));
}
}
@@ -1,4 +1,5 @@
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
export function genQuizPin(): number {
const minm = 100000;
@@ -20,5 +21,9 @@ export function openUpgradeModal() {
const searchParams = url.searchParams.toString();
goto(window.location.pathname + `?${searchParams}`);
goto(resolve(window.location.pathname + `?${searchParams}`, {}));
}
export function getShortOrgName(name: string) {
return name?.substring(0, 2)?.toUpperCase() || '';
}
@@ -1,8 +1,14 @@
import { PUBLIC_ROUTES } from '$lib/utils/constants/routes';
import { PUBLIC_API_ROUTES, PUBLIC_ROUTES } from '$lib/utils/constants/routes';
export default (route = '') => {
export const isPublicRoute = (route = '') => {
return PUBLIC_ROUTES.some((publicRoute) => {
const regex = new RegExp(publicRoute, 'g');
return regex.test(route);
});
};
export default isPublicRoute;
export const isPublicApiRoute = (pathname = '') => {
return PUBLIC_API_ROUTES.some((route) => pathname.includes(route));
};
@@ -1,3 +1,3 @@
export default (path: string): boolean => {
return ['login', 'signup', 'onboarding'].some((r) => path.includes(r)) || path === '';
return ['login', 'signup', 'onboarding'].some((r) => path.includes(r)) || path === '/';
};
@@ -11,6 +11,8 @@ export const getSupabase = () => {
return supabase;
};
getSupabase();
export const hasSession = async () => {
const { data } = await getSupabase().auth.getSession();
console.log('has session', data);
@@ -1,4 +1,4 @@
import { LOCALE } from '$lib/utils/types';
import type { TLocale } from '@cio/db/types';
import i18n from '@sveltekit-i18n/base';
import parser from '@sveltekit-i18n/parser-icu';
import { writable } from 'svelte/store';
@@ -72,7 +72,7 @@ loading.subscribe(async ($loading) => {
}
});
export function handleLocaleChange(newLocale: LOCALE) {
export function handleLocaleChange(newLocale: TLocale) {
if (!newLocale) {
return;
}
@@ -82,7 +82,7 @@ export function handleLocaleChange(newLocale: LOCALE) {
selectedLocale.set(newLocale);
}
export function lessonFallbackNote(note: string, translation: Record<LOCALE, string>, locale: LOCALE) {
export function lessonFallbackNote(note: string, translation: Record<TLocale, string>, locale: TLocale) {
if (!translation) {
return note;
}
@@ -90,7 +90,7 @@ export function lessonFallbackNote(note: string, translation: Record<LOCALE, str
const content = translation[locale];
// if locale is english and no translated content for english but note exists
if (locale === LOCALE.EN && !content && note?.length) {
if (locale === 'en' && !content && note?.length) {
return note;
}
@@ -1,8 +1,7 @@
import z from 'zod';
import { get } from 'svelte/store';
import isNumber from 'lodash/isNumber';
import { t } from '$lib/utils/functions/translations';
import { validateEmail } from './validateEmail';
import { t, initialized } from '$lib/utils/functions/translations';
import z from 'zod';
function getOrgNameValidation() {
return z
@@ -55,7 +54,7 @@ export const processErrors = (error, mapToId?: boolean) => {
if (mapToId) {
let value = '';
path.forEach((p, i) => {
path.forEach((p) => {
const formatP = isNumber(p) ? `[${p}]` : p;
value += !value ? formatP : `.${formatP}`;
@@ -113,28 +112,6 @@ export const coursePaymentValidation = (fields = {}) => {
return processErrors(error);
};
export const resetValidation = (fields = {}) => {
const schema = z.object({
password: z.string().min(6, {
message: `${t.get('validations.reset.password.min_char')}`
})
});
const { error } = schema.safeParse(fields);
return processErrors(error);
};
export const forgotValidation = (fields = {}) => {
const schema = z.object({
email: z.string().email({
message: `${t.get('validations.forgot.invalid_email')}`
})
});
const { error } = schema.safeParse(fields);
return processErrors(error);
};
export const orgLandingpageValidation = (fields = {}) => {
const schema = z.object({
name: z.string().min(6, {
@@ -155,58 +132,6 @@ export const orgLandingpageValidation = (fields = {}) => {
return processErrors(error);
};
export const onboardingValidation = (fields = {}, step) => {
const onboardingValidationSchema = {
stepOne: z.object({
fullname: z.string().min(5, { message: `${t.get('validations.onboarding.step_one.full_name.min_char')}` }),
orgName: getOrgNameValidation(),
siteName: getSiteNameValidation()
}),
stepTwo: z.object({
goal: z
.string({
required_error: `${t.get('validations.onboarding.step_two.goal.required')}`
})
.min(1),
source: z
.string({
required_error: `${t.get('validations.onboarding.step_two.source.required')}`
})
.min(1)
})
};
const schema = step === 1 ? onboardingValidationSchema.stepOne : onboardingValidationSchema.stepTwo;
const { error } = schema.safeParse(fields);
return processErrors(error);
};
export const updateProfileValidation = (fields = {}) => {
const schema = z.object({
email: z.string().email({ message: 'validations.user_profile.email' }),
username: z.string().nonempty({ message: 'validations.user_profile.username' }),
fullname: z.string().min(5, { message: 'validations.user_profile.fullname' })
});
const { error } = schema.safeParse(fields);
return processErrors(error);
};
// export const createTemplateExerciseValidation = (fields = {}) => {
// const schema = z.object({
// orgName: z.string().min(5, {
// message: 'Must be 5 or more characters long',
// }),
// siteName: z.string().min(5, {
// message: 'Must be 5 or more characters long',
// })
// });
// const { error } = schema.safeParse(fields);
// return processErrors(error);
// };
export const createNewsfeedValidation = (newPost) => {
const schema = z.object({
newPost: getNewsfeedValidation()
@@ -0,0 +1,141 @@
import { getErrMsg } from './utils';
import type { InferResponseType } from '@cio/api/rpc-types';
import { ApiError, type ZodValidationError, type ZodValidatorMesssage } from './types';
import { mapZodErrorsToTranslations } from '$lib/utils/validation/validation';
import { ZodError } from 'zod/v4';
// Extract the data type from a success response
type ExtractData<T> = Extract<InferResponseType<T>, { success: true }>;
type ExtractError<T> = Extract<InferResponseType<T>, { success: false }>;
interface ExecuteOptions<TrpcType> {
requestFn: () => Promise<Response>;
errorMessage?: string;
onSuccess?: (data: ExtractData<TrpcType>) => Promise<void> | void;
onError?: (error: ExtractError<TrpcType> | string) => Promise<void> | void;
logContext?: string;
}
/**
* Base API class that provides shared state management and request handling
* for all API classes in the application.
*/
export class BaseApi {
isLoading = $state(false);
error = $state<string | null>(null);
/**
* Execute an API request with automatic loading and error state management
* Type is automatically inferred from the RPC call (e.g., classroomio.organization.plan.$post)
* @param options Configuration for the request
* @returns The data from the response, or the default return value on error
*/
protected async execute<TrpcType>(options: ExecuteOptions<TrpcType>): Promise<ExtractData<TrpcType> | undefined> {
const { requestFn, onSuccess, logContext = 'API request' } = options;
this.isLoading = true;
this.error = null;
try {
const response = await requestFn();
const result = (await response.json()) as unknown;
if (typeof result === 'object' && result !== null && 'success' in result) {
const apiResult = result as { success: boolean; data?: unknown; error?: string; message?: string };
if (apiResult.success === false) {
console.log('apiResult false', apiResult);
options.onError?.(apiResult as ExtractError<TrpcType>);
return;
}
const typedResult = apiResult as ExtractData<TrpcType>;
this.resetErrors();
if (onSuccess) {
await onSuccess(typedResult);
}
return typedResult;
}
} catch (error) {
console.error(`Error in ${logContext}:`, error);
this.error = getErrMsg(error, `Error ${logContext}:`);
const errorResult = this.getServerError<ExtractError<TrpcType>>(error);
options.onError?.(errorResult);
} finally {
this.isLoading = false;
}
}
getServerError<T>(error: unknown): T | string {
if (error instanceof ApiError) {
console.log('instance of ApiError', error);
try {
const json = JSON.parse(error.message) as T;
return json;
} catch (e) {
console.log('error parsing json', e);
return error.message;
}
}
if (error instanceof Error) {
return error.message;
}
if (typeof error === 'string') {
return error;
}
return `${error}`;
}
resetErrors() {
this.error = null;
}
/**
* Reset the API state
*/
reset() {
this.isLoading = false;
this.error = null;
}
}
/**
* Extended base API class that supports errors object (Record<string, string>)
* for form validation scenarios
*/
export class BaseApiWithErrors extends BaseApi {
errors = $state<Record<string, string>>({});
success = $state(false);
handleValidationError(result: { success: false; error: string; code: string; field?: string }): Promise<void> | void {
const zodValidationError = result.error as unknown as ZodValidationError;
if (zodValidationError.name === 'ZodError') {
const issues = JSON.parse(zodValidationError.message) as ZodValidatorMesssage;
this.errors = mapZodErrorsToTranslations(new ZodError(issues));
return;
}
}
override resetErrors() {
this.errors = {};
}
/**
* Reset the API state including errors and success
*/
override reset() {
super.reset();
this.errors = {};
this.success = false;
}
}
@@ -3,7 +3,6 @@ import { type ApiClientConfig, ApiError, type RequestConfig } from './types';
import { DEFAULT_CONFIG } from './constants';
import { delay } from './utils';
import { env } from '$env/dynamic/public';
import { getAccessToken } from '$lib/utils/functions/supabase';
import { hcWithType } from '@cio/api/rpc-types';
class ApiClient {
@@ -14,13 +13,7 @@ class ApiClient {
}
private async makeRequest(input: RequestInfo | URL, requestConfig: RequestConfig = {}): Promise<Response> {
console.log('makeRequest', input, requestConfig);
const {
timeout = this.config.timeout,
retries = this.config.retries,
skipAuth = false,
...fetchConfig
} = requestConfig;
const { timeout = this.config.timeout, retries = this.config.retries, ...fetchConfig } = requestConfig;
const url = typeof input === 'string' ? input : input.toString();
const fullUrl = url.startsWith('http') ? url : `${this.config.baseURL}${url}`;
@@ -48,19 +41,6 @@ class ApiClient {
}
}
// Add authentication if not skipped
if (!skipAuth) {
try {
const token = await getAccessToken();
if (token) {
headers.set('Authorization', `Bearer ${token}`);
}
} catch (error) {
console.warn('Failed to get access token:', error);
// Continue without auth if token retrieval fails
}
}
// Create abort controller for timeout
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
@@ -94,16 +74,11 @@ class ApiClient {
if (response.status >= 400 && response.status < 500) {
const errorText = await response.text().catch(() => 'Unknown error');
throw new ApiError(`Client error: ${errorText}`, response.status, response.statusText, response);
throw new ApiError(errorText, response.status, response.statusText, response);
}
if (response.status >= 500) {
const error = new ApiError(
`Server error: ${response.statusText}`,
response.status,
response.statusText,
response
);
const error = new ApiError(response.statusText, response.status, response.statusText, response);
if (attempt === retries) {
throw error;
@@ -165,6 +140,9 @@ export const apiClient = new ApiClient();
export const classroomio = hcWithType(env.PUBLIC_SERVER_URL, {
fetch: async (input: RequestInfo | URL, requestInit?: RequestInit) => {
return apiClient.request(input, requestInit);
},
init: {
credentials: 'include' // Required for sending cookies cross-origin
}
});
@@ -188,3 +166,6 @@ export {
} from './utils';
export type { InferResponseType, InferRequestType } from '@cio/api/rpc-types';
// Export base API classes
export { BaseApi, BaseApiWithErrors } from './base.svelte';
@@ -1,4 +1,5 @@
// Types for better type safety
import type { ZodError } from 'zod';
export interface ApiClientConfig {
baseURL?: string;
timeout?: number;
@@ -10,6 +11,13 @@ export interface ApiClientConfig {
onResponse?: (response: Response) => Promise<void> | void;
}
export type ZodValidationError = {
message: string; // stringified error.issues from zod
name: 'ZodError';
};
export type ZodValidatorMesssage = ZodError['issues'];
export interface RequestConfig extends RequestInit {
timeout?: number;
retries?: number;
@@ -3,6 +3,23 @@ import { ApiError } from './types';
// Utility function for exponential backoff
export const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export const getErrMsg = (error: unknown, fallback = 'Something went wrong'): string => {
if (error instanceof ApiError) {
return error.message;
}
if (error instanceof Error) {
return error.message;
}
if (typeof error === 'string') {
return error;
}
if (error instanceof Object && 'message' in error) {
return error.message as string;
}
return fallback;
};
// Helper function to handle JSON responses
export const handleJsonResponse = async <T = any>(response: Response): Promise<T> => {
if (!response.ok) {
@@ -0,0 +1,9 @@
import { createAuthClient } from 'better-auth/svelte';
import { env } from '$env/dynamic/public';
export const authClient = createAuthClient({
baseURL: env.PUBLIC_SERVER_URL,
fetchOptions: {
credentials: 'include' // Include cookies in requests
}
});
@@ -0,0 +1,50 @@
import type { Cookies } from '@sveltejs/kit';
import { authClient } from './client';
import { classroomio } from '$lib/utils/services/api';
export const getSessionData = async (cookies: Cookies): Promise<App.Locals | null> => {
try {
const cioCookies = cookies
.getAll()
.filter((c) => c.name.includes('classroomio'))
.map((c) => `${c.name}=${c.value}`)
.join('; ');
if (!cioCookies) return null;
const locals = await getThroughAuthClient(cioCookies);
if (!locals) return null;
locals.fromSessions = !!cookies.get('classroomio.session_data');
return locals;
} catch (error) {
console.error('Session verification failed:', error);
return null;
}
};
export async function getThroughTrpc(allCookies: string) {
const session = await classroomio.session.$get(undefined, {
headers: {
cookie: allCookies
}
});
const data = (await session.json()) as App.Locals;
return data;
}
async function getThroughAuthClient(allCookies: string) {
const session = await authClient.getSession({
fetchOptions: {
headers: {
cookie: allCookies
}
}
});
return session.data as App.Locals | null;
}
@@ -1,8 +1,6 @@
import { getAccessToken } from '$lib/utils/functions/supabase';
export const NOTIFICATION_NAME = {
WELCOME_TO_APP: 'WELCOME TO APP',
VERIFY_EMAIL: 'VERFIY_EMAIL',
INVITE_TEACHER: 'INVITE TEACHER',
WELCOME_TEACHER_TO_COURSE: 'WELCOME TEACHER TO COURSE',
SEND_TEACHER_STUDENT_BUY_REQUEST: 'SEND TEACHER STUDENT BUY REQUEST',
@@ -15,8 +13,6 @@ export const NOTIFICATION_NAME = {
};
const NAME_TO_PATH = {
[NOTIFICATION_NAME.WELCOME_TO_APP]: '/api/email/welcome',
[NOTIFICATION_NAME.VERIFY_EMAIL]: '/api/email/verify_email',
[NOTIFICATION_NAME.INVITE_TEACHER]: '/api/email/invite',
[NOTIFICATION_NAME.WELCOME_TEACHER_TO_COURSE]: '/api/email/course/teacher_welcome',
[NOTIFICATION_NAME.SEND_TEACHER_STUDENT_BUY_REQUEST]: '/api/email/course/teacher_student_buycourse',
@@ -1,9 +1,10 @@
import type { CurrentOrg, OrgTeamMember } from '$lib/utils/types/org';
import { ROLE, ROLE_LABEL } from '$lib/utils/constants/roles';
import { currentOrg, orgAudience, orgTeam, orgs } from '$lib/utils/store/org';
import type { OrganizationPlan } from '$lib/utils/types';
import type { PostgrestError } from '@supabase/supabase-js';
import { ROLE } from '@cio/utils/constants';
import { ROLE_LABEL } from '$lib/utils/constants/roles';
import { get } from 'svelte/store';
import { goto } from '$app/navigation';
import { supabase } from '$lib/utils/functions/supabase';
+34 -24
View File
@@ -1,46 +1,56 @@
import type { CurrentOrg, OrgAudience, OrgTeamMember } from '../types/org';
import type { OrgAudience, OrgTeamMember } from '../types/org';
import { browser, dev } from '$app/environment';
import { derived, writable } from 'svelte/store';
import type { AccountOrg } from '$lib/features/app/types';
import { PLAN } from '@cio/utils/plans';
import { PUBLIC_IS_SELFHOSTED } from '$env/static/public';
import { ROLE } from '$lib/utils/constants/roles';
import { ROLE } from '@cio/utils/constants';
import { STEPS } from '../constants/quiz';
import type { UserLessonDataType } from '$lib/utils/types';
import type { Writable } from 'svelte/store';
// Trigger build
export const defaultCurrentOrgState: CurrentOrg = {
id: '',
name: '',
shortName: '',
siteName: '',
avatar_url: '',
memberId: '',
role_id: 0,
landingpage: {},
export const defaultCurrentOrgState: AccountOrg = {
avatarUrl: '',
createdAt: '',
customCode: '',
customDomain: '',
customization: {
apps: { poll: true, comments: true },
course: { grading: true, newsfeed: true },
dashboard: { exercise: true, community: true, bannerText: '', bannerImage: '' }
},
theme: '',
organization_plan: [],
is_restricted: false
favicon: '',
id: '',
isCustomDomainVerified: false,
isRestricted: false,
landingpage: {},
name: '',
plans: [],
roleId: 0,
settings: {},
siteName: '',
theme: ''
};
export const orgs = writable<CurrentOrg[]>([]);
export const currentOrg: Writable<CurrentOrg> = writable(defaultCurrentOrgState);
export const orgs = writable<AccountOrg[]>([]);
export const currentOrg: Writable<AccountOrg> = writable(defaultCurrentOrgState);
export const orgAudience = writable<OrgAudience[]>([]);
export const orgTeam = writable<OrgTeamMember[]>([]);
export const isOrgAdmin = derived(currentOrg, ($currentOrg) => {
if ($currentOrg.role_id === 0) return null;
if ($currentOrg.roleId === 0) return null;
return $currentOrg.role_id === ROLE.ADMIN;
return $currentOrg.roleId === ROLE.ADMIN;
});
export const isOrgStudent = derived(currentOrg, ($currentOrg) => {
if ($currentOrg.roleId === 0) return null;
return $currentOrg.roleId === ROLE.STUDENT;
});
const getActivePlan = (org: CurrentOrg) => {
return org.organization_plan.find((p) => p.is_active);
const getActivePlan = (org: AccountOrg) => {
return org.plans.find((p) => p.isActive);
};
export const currentOrgPlan = derived(currentOrg, ($currentOrg) => getActivePlan($currentOrg));
@@ -78,15 +88,15 @@ export const isFreePlan = derived(currentOrg, ($currentOrg) => {
const plan = getActivePlan($currentOrg);
return !plan || plan.plan_name === PLAN.BASIC;
return !plan || plan.planName === PLAN.BASIC;
});
export const currentOrgMaxAudience = derived(currentOrgPlan, ($plan) =>
!$plan
? 20
: $plan.plan_name === PLAN.EARLY_ADOPTER
: $plan.planName === PLAN.EARLY_ADOPTER
? 10000
: $plan.plan_name === PLAN.ENTERPRISE
: $plan.planName === PLAN.ENTERPRISE
? Number.MAX_SAFE_INTEGER
: 20
);
+23 -31
View File
@@ -1,31 +1,19 @@
import { LOCALE } from '../types';
import type { User } from '@supabase/supabase-js';
import type { TProfile, TUser } from '@cio/db/types';
import { writable } from 'svelte/store';
interface UserStore {
openAuthModal: boolean;
fetchingUser: boolean;
isLoggedIn: boolean;
currentSession: User | undefined;
currentSession: TUser | undefined;
expiresAt: number;
}
export interface ProfileStore {
// Using TProfile type from @cio/utils/types with optional id for store initialization
export type ProfileStore = Omit<TProfile, 'id'> & {
id: string | undefined;
fullname: string;
avatar_url: string;
profile_url?: string;
username: string;
user_id: string;
email: string;
role: string;
goal: string;
source: string;
telegram_chat_id: number | null;
locale: LOCALE;
is_email_verified: boolean;
verified_at: string;
}
};
export const defaultUserState: UserStore = {
openAuthModal: false,
@@ -35,22 +23,26 @@ export const defaultUserState: UserStore = {
expiresAt: 0
};
export const defaultProfileState: ProfileStore = {
id: undefined,
export const defaultProfileState: TProfile = {
id: '',
fullname: '',
avatar_url: '',
avatarUrl: '',
username: '',
user_id: '',
email: '',
role: '',
goal: '',
source: '',
telegram_chat_id: null,
locale: LOCALE.EN,
is_email_verified: false,
verified_at: ''
email: null,
role: null,
goal: null,
source: null,
telegramChatId: null,
locale: 'en',
isEmailVerified: false,
verifiedAt: null,
canAddCourse: true,
isRestricted: false,
createdAt: '',
updatedAt: '',
metadata: null
};
export const user = writable<UserStore>(defaultUserState);
export const profile = writable<ProfileStore>(defaultProfileState);
export const profile = writable<TProfile>(defaultProfileState);
@@ -99,6 +99,21 @@
"email": "Ugyldig e -mail -adresse",
"username": "Brugernavn kan ikke være tomt",
"fullname": "Navnet skal være mindst 5 tegn"
},
"generic": {
"invalid_type": "Ugyldig type{forventet, vælg, udefineret{} anden{. Forventet {expected}}}{received, select, undefined{} other{ (received {received})}}",
"too_big": "Værdien er for stor{maksimum, vælg, udefineret{} andet{. Maksimum {maksimum}}}",
"too_small": "Værdien er for lille{minimum, vælg, udefineret{} andet{. Minimum {minimum}}}",
"invalid_format": "Ugyldigt format{validering, vælg, udefineret{} andet{ for {validation}}}",
"not_multiple_of": "Værdien skal være et multiplum af {multipleOf}",
"unrecognized_keys": "Indeholder ikke-genkendte nøgler{nøgler, vælg, udefineret{} andet{: {nøgler}}}",
"invalid_union": "Ugyldig værdi for angivne muligheder{unionErrors, select, undefined{} other{. {unionErrors} valgmulighed(er) mislykkedes}}",
"invalid_key": "Ugyldig nøgle{nøgle, vælg, udefineret{} anden{: {nøgle}}}",
"invalid_element": "Ugyldigt element{indeks, vælg, udefineret{} andet{ ved indeks {indeks}}}",
"invalid_value": "Ugyldig værdi{forventet, vælg, udefineret{} anden{. Forventet {expected}}}{received, select, undefined{} other{ (received {received})}}",
"custom": "{besked}",
"required_field": "Dette felt er påkrævet",
"invalid_field": "Ugyldig værdi"
}
},
"generic": {
@@ -1555,6 +1555,21 @@
"email": "Ungültige E-Mail-Adresse",
"username": "Benutzername darf nicht leer sein",
"fullname": "Der Name sollte mindestens 5 Zeichen lang sein"
},
"generic": {
"invalid_type": "Ungültiger Typ {erwartet, ausgewählt, undefiniert{} andere{. Erwartet {erwartet}}}{received, select, undefiniert{} other{ (received {received})}}",
"too_big": "Der Wert ist zu groß{maximum, select, undefiniert{} other{. Maximum {maximum}}}",
"too_small": "Der Wert ist zu klein{minimum, select, undefiniert{} other{. Minimum {Minimum}}}",
"invalid_format": "Ungültiges Format{validation, select, undefiniert{} other{ für {validation}}}",
"not_multiple_of": "Der Wert muss ein Vielfaches von {multipleOf} sein",
"unrecognized_keys": "Enthält nicht erkannte Schlüssel{keys, select, undefiniert{} other{: {keys}}}",
"invalid_union": "Ungültiger Wert für die bereitgestellten Optionen {unionErrors, select, undefined{} other{. {unionErrors}-Option(en) fehlgeschlagen}}",
"invalid_key": "Ungültiger Schlüssel{key, select, undefiniert{} other{: {key}}}",
"invalid_element": "Ungültiges Element{index, select, undefiniert{} other{ am Index {index}}}",
"invalid_value": "Invalid value{expected, select, undefined{} other{. Erwartet {erwartet}}}{received, select, undefiniert{} other{ (received {received})}}",
"custom": "{Nachricht}",
"required_field": "Dieses Feld ist erforderlich",
"invalid_field": "Ungültiger Wert"
}
},
"explore": {
@@ -72,7 +72,7 @@
"full_name": "Full Name",
"password": "Your Password",
"confirm_password": "Confirm Password",
"password_helper_message": "Password must be more than 6 characters",
"password_helper_message": "Password must be more than 8 characters",
"create_account": "Create Account"
},
"validation": {
@@ -117,7 +117,10 @@
"heading": "Personal Information",
"full_name": "Full Name",
"username": "Username",
"email": "Email"
"email": "Email",
"confirm": "Confirm",
"cancel": "Cancel",
"email_change_verification_note": "A verification email has been sent to your current email address. The new email will not be in effect until you accept the verification email."
},
"update_profile": "Update Profile",
"logout": "Log out"
@@ -272,6 +275,7 @@
},
"org_navigation": {
"platform": "Platform",
"dashboard": "Dashboard",
"courses": "Courses",
"community": "Community",
@@ -1661,6 +1665,21 @@
},
"email": {
"not_valid": "Not valid email"
},
"generic": {
"invalid_type": "Invalid type{expected, select, undefined{} other{. Expected {expected}}}{received, select, undefined{} other{ (received {received})}}",
"too_big": "Value is too large{maximum, select, undefined{} other{. Maximum {maximum}}}",
"too_small": "Value is too small{minimum, select, undefined{} other{. Minimum {minimum}}}",
"invalid_format": "Invalid format{validation, select, undefined{} other{ for {validation}}}",
"not_multiple_of": "Value must be a multiple of {multipleOf}",
"unrecognized_keys": "Contains unrecognized keys{keys, select, undefined{} other{: {keys}}}",
"invalid_union": "Invalid value for provided options{unionErrors, select, undefined{} other{. {unionErrors} option(s) failed}}",
"invalid_key": "Invalid key{key, select, undefined{} other{: {key}}}",
"invalid_element": "Invalid element{index, select, undefined{} other{ at index {index}}}",
"invalid_value": "Invalid value{expected, select, undefined{} other{. Expected {expected}}}{received, select, undefined{} other{ (received {received})}}",
"custom": "{message}",
"required_field": "This field is required",
"invalid_field": "Invalid value"
}
},
@@ -1555,6 +1555,21 @@
"email": "Dirección de correo electrónico no válida",
"username": "El nombre de usuario no puede estar vacío",
"fullname": "El nombre debe tener un mínimo de 5 caracteres."
},
"generic": {
"invalid_type": "Invalid type{expected, select, undefined{} other{. Esperado {esperado}}}{recibido, seleccionado, indefinido{} otro{ (recibido {recibido})}}",
"too_big": "El valor es demasiado grande{máximo, selección, undefinido{} otro{. Máximo {máximo}}}",
"too_small": "El valor es demasiado pequeño{mínimo, seleccionado, undefinido{} otro{. Mínimo {mínimo}}}",
"invalid_format": "Formato no válido{validación, selección, indefinido{} otro{ para {validación}}}",
"not_multiple_of": "El valor debe ser múltiplo de {multipleOf}",
"unrecognized_keys": "Contiene claves no reconocidas{keys, select, undefined{} other{: {keys}}}",
"invalid_union": "Valor no válido para las opciones proporcionadas{unionErrors, select, undefined{} other{. {unionErrors} opción(es) fallida(s)}}",
"invalid_key": "Clave no válida{clave, selección, undefinido{} otro{: {clave}}}",
"invalid_element": "Elemento no válido{índice, selección, undefinido{} otro{ en el índice {índice}}}",
"invalid_value": "Valor no válido{esperado, seleccionado, indefinido{} otro{. Esperado {esperado}}}{recibido, seleccionado, indefinido{} otro{ (recibido {recibido})}}",
"custom": "{mensaje}",
"required_field": "Este campo es obligatorio",
"invalid_field": "Invalid value"
}
},
"explore": {
@@ -1555,6 +1555,21 @@
"email": "Adresse e-mail invalide",
"username": "Le nom d'utilisateur ne peut pas être vide",
"fullname": "Le nom doit comporter au minimum 5 caractères"
},
"generic": {
"invalid_type": "Type non valide{attendu, sélectionné, non défini{} autre{. Attendu {attendu}}}{reçu, sélectionné, non défini{} autre{ (reçu {reçu})}}",
"too_big": "La valeur est trop grande{maximum, select, undefined{} autre{. Maximum {maximum}}}",
"too_small": "La valeur est trop petite{minimum, select, undefined{} autre{. Minimum {minimum}}}",
"invalid_format": "Format invalide{validation, select, non défini{} autre{ pour {validation}}}",
"not_multiple_of": "La valeur doit être un multiple de {multipleOf}",
"unrecognized_keys": "Contient des clés non reconnues{keys, select, undefined{} other{: {keys}}}",
"invalid_union": "Valeur non valide pour les options fournies {unionErrors, select, undefined{} other{. {unionErrors} option(s) a échoué}}",
"invalid_key": "Clé invalide{key, select, undefined{} other{: {key}}}",
"invalid_element": "Élément invalide{index, select, undefined{} autre{ à l'index {index}}}",
"invalid_value": "Valeur non valide{attendue, sélectionnée, non définie{} autre{. Attendu {attendu}}}{reçu, sélectionné, non défini{} autre{ (reçu {reçu})}}",
"custom": "{message}",
"required_field": "Ce champ est obligatoire",
"invalid_field": "Valeur invalide"
}
},
"explore": {
@@ -1555,6 +1555,21 @@
"email": "अमान्य ईमेल पता",
"username": "उपयोक्तानाम खाली नहीं हो सकता",
"fullname": "नाम कम से कम 5 अक्षर का होना चाहिए"
},
"generic": {
"invalid_type": "अमान्य प्रकार{अपेक्षित, चयन, अपरिभाषित{} अन्य{। अपेक्षित {अपेक्षित}} {प्राप्त, चयन, अपरिभाषित {} अन्य { (प्राप्त {प्राप्त})}}",
"too_big": "मान बहुत बड़ा है {अधिकतम, चयन करें, अपरिभाषित {} अन्य {। अधिकतम {अधिकतम}}}",
"too_small": "मान बहुत छोटा है {न्यूनतम, चयन करें, अपरिभाषित {} अन्य {। न्यूनतम {न्यूनतम}}}",
"invalid_format": "अमान्य प्रारूप{सत्यापन, चयन, अपरिभाषित{} अन्य{ {सत्यापन}}} के लिए",
"not_multiple_of": "मान {multipleOf} का गुणज होना चाहिए",
"unrecognized_keys": "अज्ञात कुंजियाँ शामिल हैं {कुंजियाँ, चयन करें, अपरिभाषित{} अन्य{: {कुंजियाँ}}}",
"invalid_union": "प्रदत्त विकल्पों के लिए अमान्य मान {संघत्रुटि, चयन, अपरिभाषित{} अन्य{। {unionErrors} विकल्प विफल रहे}}",
"invalid_key": "अमान्य कुंजी{कुंजी, चयन, अपरिभाषित{} अन्य{: {कुंजी}}}",
"invalid_element": "अमान्य तत्व {सूचकांक, चयन, अपरिभाषित {} अन्य {सूचकांक {सूचकांक}}} पर",
"invalid_value": "अमान्य मान{अपेक्षित, चयन, अपरिभाषित{} अन्य{। अपेक्षित {अपेक्षित}} {प्राप्त, चयन, अपरिभाषित {} अन्य { (प्राप्त {प्राप्त})}}",
"custom": "{संदेश}",
"required_field": "यह फ़ील्ड आवश्यक है",
"invalid_field": "अमान्य मान"
}
},
"explore": {
@@ -94,6 +94,21 @@
"email": "Nieprawidłowy adres e -mail",
"username": "Nazwa użytkownika nie może być pusta",
"fullname": "Nazwa powinna mieć minimum 5 znaków"
},
"generic": {
"invalid_type": "Nieprawidłowy typ{oczekiwany, wybierz, niezdefiniowany{} inny{. Oczekiwany {oczekiwany}}}{otrzymany, wybierz, niezdefiniowany{} inny{ (otrzymany {otrzymany})}}",
"too_big": "Wartość jest za duża{maksymalna, wybierz, niezdefiniowana{} inna{. Maksymalnie {maksimum}}}",
"too_small": "Wartość jest za mała{minimum, wybierz, niezdefiniowane{} inne{. Minimum {minimum}}}",
"invalid_format": "Nieprawidłowy format{validacja, wybierz, niezdefiniowany{} other{ for {validation}}}",
"not_multiple_of": "Wartość musi być wielokrotnością {multipleOf}",
"unrecognized_keys": "Zawiera nierozpoznane klucze{klucze, wybierz, niezdefiniowane{} inne{: {klucze}}}",
"invalid_union": "Nieprawidłowa wartość podanych opcji{unionErrors, wybierz, niezdefiniowane{} inne{. Niepowodzenie opcji: {unionErrors}}}",
"invalid_key": "Nieprawidłowy klucz{klucz, wybierz, niezdefiniowany{} inny{: {klucz}}}",
"invalid_element": "Nieprawidłowy element{index, wybierz, niezdefiniowany{} other{ w indeksie {index}}}",
"invalid_value": "Nieprawidłowa wartość{oczekiwana, wybierz, niezdefiniowana{} inna{. Oczekiwany {oczekiwany}}}{otrzymany, wybierz, niezdefiniowany{} inny{ (otrzymany {otrzymany})}}",
"custom": "{wiadomość}",
"required_field": "To pole jest wymagane",
"invalid_field": "Nieprawidłowa wartość"
}
},
"generic": {
@@ -1555,6 +1555,21 @@
"email": "Endereço de email invalido",
"username": "O nome de usuário não pode ficar vazio",
"fullname": "O nome deve ter no mínimo 5 caracteres"
},
"generic": {
"invalid_type": "Tipo inválido{esperado, selecionado, indefinido{} outro{. Esperado {esperado}}}{recebido, selecionado, indefinido{} outro{ (recebido {recebido})}}",
"too_big": "O valor é muito grande{máximo, seleção, indefinido{} outro{. Máximo {máximo}}}",
"too_small": "O valor é muito pequeno{mínimo, selecione, indefinido{} outro{. Mínimo {mínimo}}}",
"invalid_format": "Formato inválido{validação, seleção, indefinido{} outro{ para {validação}}}",
"not_multiple_of": "O valor precisa ser um múltiplo de {multipleOf}",
"unrecognized_keys": "Contém chaves não reconhecidas{keys, select, undefined{} other{: {keys}}}",
"invalid_union": "Valor inválido para opções fornecidas{unionErrors, select, indefinido{} other{. Falha na(s) opção(ões) {unionErrors}}}",
"invalid_key": "Chave inválida{chave, seleção, indefinida{} outra{: {chave}}}",
"invalid_element": "Elemento inválido{index, select, indefinido{} other{ no índice {index}}}",
"invalid_value": "Valor inválido{esperado, selecionado, indefinido{} outro{. Esperado {esperado}}}{recebido, selecionado, indefinido{} outro{ (recebido {recebido})}}",
"custom": "{mensagem}",
"required_field": "Este campo é obrigatório",
"invalid_field": "Valor inválido"
}
},
"explore": {
@@ -1555,6 +1555,21 @@
"email": "Неверный адрес электронной почты",
"username": "Имя пользователя не может быть пустым",
"fullname": "Имя должно состоять минимум из 5 символов."
},
"generic": {
"invalid_type": "Неверный тип {ожидаемый, выбранный, неопределенный{} другое{. Ожидается {ожидается}}}{получено, выберите, неопределенное{} другое { (получено {получено})}}",
"too_big": "Значение слишком велико{максимум, выберите, неопределенное{} другое{. Максимум {максимум}}}",
"too_small": "Значение слишком мало{минимальное, выберите, неопределенное{} другое{. Минимум {минимум}}}",
"invalid_format": "Неверный формат{проверка, выбор, неопределенное{} другое{ для {проверка}}}",
"not_multiple_of": "Значение должно быть кратно {multipleOf}.",
"unrecognized_keys": "Содержит нераспознанные ключи{keys, select, undefine{}other{: {keys}}}",
"invalid_union": "Недопустимое значение для предоставленных параметров{unionErrors, select, undefine{}other{. Вариант(ы) {unionErrors} не выполнен}}",
"invalid_key": "Неверный ключ{key, select, undefine{}other{: {key}}}",
"invalid_element": "Неверный элемент{index, select, undefined{}other{ по индексу {index}}}",
"invalid_value": "Недопустимое значение{ожидаемое, выбранное, неопределенное{} другое{. Ожидается {ожидается}}}{получено, выберите, неопределенное{} другое { (получено {получено})}}",
"custom": "{сообщение}",
"required_field": "Это поле обязательно для заполнения",
"invalid_field": "Недопустимое значение"
}
},
"explore": {
@@ -1555,6 +1555,21 @@
"email": "địa chỉ email không hợp lệ",
"username": "Tên ngươi dung không được để trông",
"fullname": "Tên phải có tối thiểu 5 ký tự"
},
"generic": {
"invalid_type": "Loại không hợp lệ{dự kiến, chọn, không xác định{} khác{. Dự kiến {mong đợi}}}{đã nhận, chọn, không xác định{} khác{ (đã nhận {đã nhận})}}",
"too_big": "Giá trị quá lớn{tối đa, chọn, không xác định{} khác{. Tối đa {tối đa}}}",
"too_small": "Giá trị quá nhỏ{tối thiểu, chọn, không xác định{} khác{. Tối thiểu {tối thiểu}}}",
"invalid_format": "Định dạng không hợp lệ{xác thực, chọn, không xác định{} khác{ cho {xác thực}}}",
"not_multiple_of": "Giá trị phải là bội số của {multipleOf}",
"unrecognized_keys": "Chứa các khóa không được nhận dạng{keys, select, undefind{} other{: {keys}}}",
"invalid_union": "Giá trị không hợp lệ cho các tùy chọn được cung cấp{unionErrors, select, unknown{} other{. {unionErrors} tùy chọn không thành công}}",
"invalid_key": "Khóa không hợp lệ{khóa, chọn, không xác định{} khác{: {key}}}",
"invalid_element": "Phần tử không hợp lệ{chỉ mục, chọn, không xác định{} khác{ tại chỉ mục {index}}}",
"invalid_value": "Giá trị không hợp lệ{dự kiến, chọn, không xác định{} khác{. Dự kiến {mong đợi}}}{đã nhận, chọn, không xác định{} khác{ (đã nhận {đã nhận})}}",
"custom": "{tin nhắn}",
"required_field": "Trường này là bắt buộc",
"invalid_field": "Giá trị không hợp lệ"
}
},
"explore": {
+2 -13
View File
@@ -1,18 +1,7 @@
export * from './config';
export * from './dashboard';
export enum LOCALE {
EN = 'en',
HI = 'hi',
FR = 'fr',
PL = 'pl',
PT = 'pt',
DE = 'de',
VI = 'vi',
RU = 'ru',
ES = 'es',
DA = 'da'
}
import type { TLocale } from '@cio/db/types';
//===============Custom Type===============
@@ -304,7 +293,7 @@ export interface LessonPage {
title: string;
totalExercises: number;
totalComments: number;
locale: LOCALE;
locale: TLocale;
isSaving: boolean;
isFetching: boolean;
materials: {
@@ -1,22 +0,0 @@
import type { Icon } from '@lucide/svelte';
import type { CurrentOrg } from './org';
export interface SidebarData {
teams: CurrentOrg[];
navMain: Array<{
title: string;
url: string;
icon?: typeof Icon;
isActive: boolean;
items?: {
title: string;
url: string;
}[];
}>;
user: {
name: string;
email: string;
avatar: string;
};
}
@@ -0,0 +1,287 @@
# Convention-Based Validation with Translations
## Overview
We use **convention-based translation keys** for Zod validation errors. This approach:
- ✅ No metadata needed - just follow the naming pattern
- ✅ Clean shared schemas without hardcoded messages
- ✅ Automatic fallback to generic translations
- ✅ Works with multiple validation rules per field
## Convention
Translation keys follow this pattern:
```
validations.{fieldName}.{errorCode}
```
### Common Error Codes
| Error Code | When It Occurs | Example Field |
|------------|----------------|---------------|
| `too_small` | String too short, number too small, array too few items | username.too_small |
| `too_big` | String too long, number too large, array too many items | bio.too_big |
| `invalid_email` | Invalid email format | email.invalid_email |
| `invalid_url` | Invalid URL format | website.invalid_url |
| `invalid_type` | Wrong type (e.g., undefined when string expected) | username.invalid_type |
| `invalid_enum_value` | Value not in allowed options | role.invalid_enum_value |
## Quick Start
### 1. Import Schema and Error Mapper
```typescript
import { ZEmailData } from '@cio/utils/validation/mail';
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
let formData = { to: '', subject: '', content: '' };
let errors: Record<string, string> = {};
function handleSubmit() {
const result = ZEmailData.safeParse(formData);
if (!result.success) {
errors = mapZodErrorsToTranslations(result.error);
return;
}
// Use validated data
sendEmail(result.data);
}
```
### 2. Add Translation Keys
```json
{
"validations": {
"to": {
"invalid_email": "Invalid email address",
"invalid_type": "Email is required"
},
"subject": {
"too_small": "Subject is required",
"too_big": "Subject is too long"
},
"content": {
"too_small": "Content must be at least 1 character",
"too_big": "Content is too long"
},
"generic": {
"required": "This field is required",
"invalid_email": "Invalid email address",
"invalid_url": "Invalid URL",
"too_small_string": "Must be at least {min} characters",
"too_big_string": "Must be at most {max} characters"
}
}
}
```
## Complete Example: User Profile Form
### Schema (Shared)
```typescript
// packages/utils/src/validation/user/user.ts
export const ZUserProfile = z.object({
email: z.string().email(),
username: z.string().min(3).max(20),
bio: z.string().min(10).max(500).optional(),
age: z.number().min(13).max(120)
});
```
### Component
```typescript
// Component.svelte
<script lang="ts">
import { ZUserProfile } from '@cio/utils/validation/user';
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
let formData = {
email: '',
username: '',
bio: '',
age: 0
};
let errors: Record<string, string> = {};
function handleSubmit() {
const result = ZUserProfile.safeParse(formData);
if (!result.success) {
errors = mapZodErrorsToTranslations(result.error);
return;
}
// Success!
saveProfile(result.data);
}
</script>
<form on:submit|preventDefault={handleSubmit}>
<div>
<label for="email">Email</label>
<input id="email" type="email" bind:value={formData.email} />
{#if errors.email}
<span class="error">{errors.email}</span>
{/if}
</div>
<div>
<label for="username">Username</label>
<input id="username" bind:value={formData.username} />
{#if errors.username}
<span class="error">{errors.username}</span>
{/if}
</div>
<div>
<label for="bio">Bio</label>
<textarea id="bio" bind:value={formData.bio}></textarea>
{#if errors.bio}
<span class="error">{errors.bio}</span>
{/if}
</div>
<div>
<label for="age">Age</label>
<input id="age" type="number" bind:value={formData.age} />
{#if errors.age}
<span class="error">{errors.age}</span>
{/if}
</div>
<button type="submit">Save Profile</button>
</form>
```
### Translations
```json
{
"validations": {
"email": {
"invalid_email": "Invalid email address",
"invalid_type": "Email is required"
},
"username": {
"too_small": "Username must be at least 3 characters",
"too_big": "Username cannot exceed 20 characters",
"invalid_type": "Username is required"
},
"bio": {
"too_small": "Bio must be at least 10 characters",
"too_big": "Bio cannot exceed 500 characters"
},
"age": {
"too_small": "You must be at least 13 years old",
"too_big": "Invalid age",
"invalid_type": "Age is required"
}
}
}
```
## Multiple Errors Per Field
Zod validates in order and returns the **first error** it encounters:
```typescript
// Schema
z.string().min(3).max(20)
// Input: ""
// Error: too_small (stops at first rule)
// Translation: validations.username.too_small
// Input: "ab"
// Error: too_small (still fails first rule)
// Translation: validations.username.too_small
// Input: "this_is_way_too_long_username"
// Error: too_big (passed min, failed max)
// Translation: validations.username.too_big
```
## Fallback Strategy
The error mapper tries translations in this order:
1. **Specific field + error code**: `validations.email.invalid_email`
2. **Generic error type**: `validations.generic.invalid_email`
3. **Zod's default message**: "Invalid email"
## Advanced: Helper Function
For cleaner code, use the helper:
```typescript
import { validateWithTranslation } from '$lib/utils/validation';
function handleSubmit() {
const result = validateWithTranslation(ZUserProfile, formData);
if ('errors' in result) {
errors = result.errors;
return;
}
// Success - result.data is typed!
saveProfile(result.data);
}
```
## Adding New Fields
1. **Add to schema** (in packages/utils):
```typescript
export const ZUserProfile = z.object({
// ... existing fields
website: z.string().url().optional()
});
```
2. **Add translations**:
```json
{
"validations": {
"website": {
"invalid_url": "Please enter a valid URL",
"invalid_type": "Website is required"
}
}
}
```
3. **That's it!** No metadata, no reimplementation.
## Generic Fallbacks
If you don't add specific translations, the mapper uses generic ones:
```json
{
"validations": {
"generic": {
"required": "This field is required",
"invalid_email": "Invalid email address",
"invalid_url": "Invalid URL",
"invalid_format": "Invalid format",
"too_small_string": "Must be at least {min} characters",
"too_big_string": "Must be at most {max} characters",
"too_small_number": "Must be at least {min}",
"too_big_number": "Must be at most {max}",
"invalid_enum": "Must be one of: {options}",
"error": "Validation error"
}
}
}
```
Just remove the message parameter and add the translation key following the convention!
@@ -0,0 +1,7 @@
/**
* Validation Utilities
*
* Exports utilities for handling Zod validation with translations
*/
export { mapZodErrorsToTranslations, isValidationError, validateWithTranslation } from './validation';
@@ -0,0 +1,299 @@
import { ZodError, z } from 'zod';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import enTranslations from '$lib/utils/translations/en.json' assert { type: 'json' };
import { mapZodErrorsToTranslations } from './validation';
const { mockGet } = vi.hoisted(() => ({
mockGet: vi.fn<(key: string, params?: Record<string, unknown>) => string | null>()
}));
vi.mock('$lib/utils/functions/translations', () => ({
t: {
get: mockGet
}
}));
const createError = (issue: z.core.$ZodIssue) =>
new ZodError([
{
...issue,
path: issue.path ?? ['field'],
message: issue.message ?? 'Issue occurred'
} as z.core.$ZodIssue
]);
function renderTemplate(template: string, params: Record<string, unknown> = {}): string {
let output = template;
output = output.replace(
/\{(\w+),\s*select,\s*undefined\{\}\s*other\{((?:[^{}]|\{[^{}]*\})*)\}\}/g,
(_match, key: string, content: string) => {
const value = params[key];
if (value === undefined || value === null || value === '') {
return '';
}
const renderedContent = content.replace(/\{(\w+)\}/g, (_nestedMatch: string, innerKey: string) => {
const innerValue = params[innerKey];
return innerValue === undefined || innerValue === null ? '' : String(innerValue);
});
return renderedContent;
}
);
output = output.replace(/\{(\w+)\}/g, (_match, key: string) => {
const value = params[key];
return value === undefined || value === null ? '' : String(value);
});
return output;
}
describe('mapZodErrorsToTranslations fallback keys', () => {
beforeEach(() => {
mockGet.mockReset();
});
it('prefers entity-specific translation before other fallbacks', () => {
mockGet.mockImplementation((key) => {
if (key === 'validations.course.title.too_small') {
return 'Course-specific translation';
}
return null;
});
const error = createError({
code: 'too_small',
minimum: 3,
inclusive: true,
path: ['title'],
origin: 'zod',
message: 'Title must be at least 3 characters long'
});
expect(mapZodErrorsToTranslations(error, 'course')).toEqual({ title: 'Course-specific translation' });
expect(mockGet).toHaveBeenCalledWith(
'validations.course.title.too_small',
expect.objectContaining({
entity: 'course',
field: 'title',
minimum: 3,
inclusive: true
})
);
});
it('falls back to field translation when entity-specific key is missing', () => {
mockGet.mockImplementation((key) => {
if (key === 'validations.title.too_small') {
return 'Field translation';
}
return null;
});
const error = createError({
code: 'too_small',
minimum: 3,
inclusive: true,
path: ['title'],
origin: 'zod',
message: 'Title must be at least 3 characters long'
});
expect(mapZodErrorsToTranslations(error, 'course')).toEqual({ title: 'Field translation' });
});
it('falls back to generic translation when no specific keys are found', () => {
mockGet.mockImplementation((key, params = {}) => {
if (key === 'validations.generic.too_small') {
return renderTemplate(enTranslations.validations.generic.too_small, params as Record<string, unknown>);
}
return null;
});
const error = createError({
code: 'too_small',
minimum: 3,
inclusive: true,
path: ['title'],
origin: 'zod',
message: 'Title must be at least 3 characters long'
});
expect(mapZodErrorsToTranslations(error)).toEqual({
title: renderTemplate(enTranslations.validations.generic.too_small, {
field: 'title',
code: 'too_small',
minimum: 3,
inclusive: true
})
});
});
it('falls back to the original Zod message when no translations are available', () => {
mockGet.mockReturnValue(null);
const schema = z.object({
title: z.string().min(5)
});
const result = schema.safeParse({ title: 'abc' });
if (result.success) {
throw new Error('Expected validation to fail');
}
const zodMessage = result.error.issues[0].message;
expect(mapZodErrorsToTranslations(result.error)).toEqual({
title: zodMessage
});
});
});
describe('mapZodErrorsToTranslations generic parameter coverage', () => {
beforeEach(() => {
mockGet.mockReset();
});
const cases: Array<{
code: z.core.$ZodIssue['code'];
issue: Partial<z.core.$ZodIssue> & Record<string, unknown>;
expectedParams: Record<string, unknown>;
}> = [
{
code: 'invalid_type',
issue: {
expected: 'string',
received: 'number',
path: ['field'],
origin: 'zod',
message: 'Field must be a string'
},
expectedParams: { expected: 'string', received: 'number' }
},
{
code: 'too_small',
issue: {
minimum: 3,
inclusive: true,
exact: false,
path: ['field'],
origin: 'zod',
message: 'Field must be at least 3 characters long'
},
expectedParams: { minimum: 3, inclusive: true, exact: false }
},
{
code: 'too_big',
issue: {
maximum: 10,
inclusive: false,
exact: false,
path: ['field'],
origin: 'zod',
message: 'Field must be less than 10 characters long'
},
expectedParams: { maximum: 10, inclusive: false, exact: false }
},
{
code: 'invalid_format',
issue: {
validation: 'email',
regex: /@/,
path: ['field'],
origin: 'zod',
message: 'Field must be a valid email address'
},
expectedParams: { validation: 'email', regex: /@/ }
},
{
code: 'not_multiple_of',
issue: { multipleOf: 5, path: ['field'], origin: 'zod', message: 'Field must be a multiple of 5' },
expectedParams: { multipleOf: 5 }
},
{
code: 'unrecognized_keys',
issue: {
keys: ['foo', 'bar'],
path: ['field'],
origin: 'zod',
message: 'Field must not contain unrecognized keys'
},
expectedParams: { keys: 'foo, bar' }
},
{
code: 'invalid_union',
issue: {
unionErrors: [new ZodError([]), new ZodError([])],
path: ['field'],
origin: 'zod',
message: 'Field must be a valid union'
},
expectedParams: { unionErrors: 2 }
},
{
code: 'invalid_key',
issue: { key: 'unexpected', path: ['field'], origin: 'zod', message: 'Field must not contain unexpected keys' },
expectedParams: { key: 'unexpected' }
},
{
code: 'invalid_element',
issue: {
index: 1,
element: { foo: 'bar' },
path: ['field'],
origin: 'zod',
message: 'Field must not contain invalid elements'
},
expectedParams: { index: 1, element: { foo: 'bar' } }
},
{
code: 'invalid_value',
issue: { expected: 'A', received: 'B', path: ['field'], origin: 'zod', message: 'Field must be a valid value' },
expectedParams: { expected: 'A', received: 'B' }
},
{
code: 'custom',
issue: { params: { reason: 'Invalid' }, message: 'Custom message', path: ['field'], origin: 'zod' },
expectedParams: { params: JSON.stringify({ reason: 'Invalid' }), message: 'Custom message' }
}
];
it.each(cases)('passes expected params to generic translation for %s', ({ code, issue, expectedParams }) => {
mockGet.mockImplementation((key, params = {}) =>
key === `validations.generic.${code}`
? renderTemplate(
enTranslations.validations.generic[code as keyof typeof enTranslations.validations.generic],
params as Record<string, unknown>
)
: null
);
const error = createError({
code,
...issue
} as z.core.$ZodIssue);
const result = mapZodErrorsToTranslations(error);
const call = mockGet.mock.calls.find(([calledKey]) => calledKey === `validations.generic.${code}`);
expect(call?.[0]).toBe(`validations.generic.${code}`);
expect(call?.[1]).toEqual(
expect.objectContaining({
field: 'field',
code,
...expectedParams
})
);
const renderedMessage = renderTemplate(
enTranslations.validations.generic[code as keyof typeof enTranslations.validations.generic],
(call?.[1] ?? {}) as Record<string, unknown>
);
expect(result).toEqual({ field: renderedMessage });
});
});
@@ -0,0 +1,206 @@
import * as z from 'zod';
import type { ZodError } from 'zod';
import { t } from '$lib/utils/functions/translations';
/**
* Maps Zod validation errors to translated messages using convention-based keys.
*
* Convention:
* - With entity: validations.{entityName}.{fieldName}.{errorCode}
* - Without entity: validations.{fieldName}.{errorCode}
*
* Examples:
* - Entity: "course", Field: "title", Error: "too_small"
* → First tries: "validations.course.title.too_small"
* → Falls back to: "validations.title.too_small"
*
* - Field: "email", Error: "invalid_string"
* → Translation key: "validations.email.invalid_string"
*
* This allows entity-specific error messages like "Course title is required"
* vs "Lesson title is required" while still supporting generic messages.
*
* @param error - Zod validation error object
* @param entityName - Optional entity prefix (e.g., 'course', 'lesson')
* @returns Object mapping field paths to translated error messages
*/
export function mapZodErrorsToTranslations(error: ZodError, entityName?: string): Record<string, string> {
const errors: Record<string, string> = {};
if (!error?.issues) {
return errors;
}
error.issues.forEach((issue: z.core.$ZodIssue) => {
const fieldPath = issue.path.join('.');
const fieldName = issue.path[issue.path.length - 1] as string;
if (!fieldName) return;
// Build translation keys with entity-specific and generic fallbacks
const translationKeys = buildTranslationKeys(fieldName, issue, entityName);
const translationParams = buildTranslationParams(issue, fieldName, entityName);
// Try each translation key in order (entity-specific first, then generic)
let translatedMessage: string | null = null;
for (const key of translationKeys) {
if (key === 'validations.generic.custom') {
translatedMessage = t.get(issue.message);
break;
}
if (issue.code === 'invalid_type' && translationParams.received === 'undefined') {
translatedMessage = t.get('validations.generic.required_field');
break;
}
const translation = t.get(key, translationParams);
if (translation && translation !== key) {
translatedMessage = translation;
break;
}
}
errors[fieldPath] = translatedMessage || issue.message;
});
return errors;
}
function buildTranslationKeys(fieldName: string, issue: z.core.$ZodIssue, entityName?: string): string[] {
const keys: string[] = [];
const errorSuffix = issue.code;
if (entityName) {
// 1. Try entity-specific key first: validations.course.title.too_small
keys.push(`validations.${entityName}.${fieldName}.${errorSuffix}`);
}
// 2. Try generic field key: validations.title.too_small
keys.push(`validations.${fieldName}.${errorSuffix}`);
// 3. Try generic translation based on error type
keys.push(`validations.generic.${errorSuffix}`);
return keys;
}
function buildTranslationParams(
issue: z.core.$ZodIssue,
fieldName: string,
entityName?: string
): Record<string, unknown> {
const params: Record<string, unknown> = {};
if (fieldName) params.field = fieldName;
if (entityName) params.entity = entityName;
if (issue.code) params.code = issue.code;
if (issue.message) params.message = issue.message;
const details = issue as unknown as Record<string, unknown>;
switch (issue.code) {
case 'invalid_type':
if ('expected' in details) params.expected = details.expected;
if ('received' in details) params.received = details.received;
if (!details.received) params.received = 'undefined';
break;
case 'too_small':
if ('minimum' in details) params.minimum = details.minimum;
if ('inclusive' in details) params.inclusive = details.inclusive;
if ('type' in details) params.type = details.type;
if ('exact' in details) params.exact = details.exact;
break;
case 'too_big':
if ('maximum' in details) params.maximum = details.maximum;
if ('inclusive' in details) params.inclusive = details.inclusive;
if ('type' in details) params.type = details.type;
if ('exact' in details) params.exact = details.exact;
break;
case 'invalid_format':
if ('format' in details) params.validation = details.format;
if ('regex' in details) params.regex = details.regex;
break;
case 'not_multiple_of':
if ('multipleOf' in details) params.multipleOf = details.multipleOf;
break;
case 'unrecognized_keys':
if ('keys' in details) {
params.keys = Array.isArray(details.keys) ? (details.keys as unknown[]).join(', ') : details.keys;
}
break;
case 'invalid_union':
if ('unionErrors' in details) {
params.unionErrors = Array.isArray(details.unionErrors)
? (details.unionErrors as unknown[]).length
: details.unionErrors;
}
break;
case 'invalid_key':
if ('key' in details) params.key = details.key;
break;
case 'invalid_element':
if ('index' in details) params.index = details.index;
if ('element' in details) params.element = details.element;
break;
case 'invalid_value':
if ('expected' in details) params.expected = details.expected;
if ('received' in details) params.received = details.received;
break;
case 'custom':
if ('params' in details) {
params.params =
typeof details.params === 'object' && details.params !== null
? JSON.stringify(details.params)
: details.params;
}
break;
default:
break;
}
return params;
}
/**
* Helper to check if a Zod validation passed
*/
export function isValidationError(result: {
success: boolean;
error?: ZodError;
}): result is { success: false; error: ZodError } {
return Boolean(result && result.success === false && result.error);
}
/**
* Validates data and returns either errors or valid data
*
* @example
* // Without entity prefix
* const result = validateWithTranslation(ZUserSchema, formData);
*
* // With entity prefix for entity-specific error messages
* const result = validateWithTranslation(ZCourseSchema, formData, 'course');
*
* if (result.errors) {
* // Show errors to user
* setErrors(result.errors);
* } else {
* // Use validated data
* await saveUser(result.data);
* }
*/
export function validateWithTranslation<T>(
schema: { safeParse: (data: unknown) => { success: boolean; error?: ZodError; data?: T } },
data: unknown,
entityName?: string
): { errors: Record<string, string> } | { data: T } {
const result = schema.safeParse(data);
if (!result.success && result.error) {
return { errors: mapZodErrorsToTranslations(result.error, entityName) };
}
return { data: result.data as T };
}