diff --git a/.cursor/rules/otp.mdc b/.cursor/rules/otp.mdc new file mode 100644 index 000000000..da45e57de --- /dev/null +++ b/.cursor/rules/otp.mdc @@ -0,0 +1,59 @@ +--- +description: The OTP API provides the ability to perform additional checks before executing sensitive operations +globs: +alwaysApply: false +--- +The OTP API allows the user to: + +1. protect sensitive operations behind an additional layer of verification +2. other security operations such as oAuth2 (storing the "state" parameter with additional metadata) + +- API: The OTP API [index.ts](mdc:packages/otp/src/api/index.ts) abstract operations with the Database RPCs. +- The Database schema can be found at [12-one-time-tokens.sql](mdc:apps/web/supabase/schemas/12-one-time-tokens.sql) + +## Creating an OTP Token +We can se the [verify-otp-form.tsx](mdc:packages/otp/src/components/verify-otp-form.tsx) for creating a quick form to create tokens server side. + +```tsx +import { VerifyOtpForm } from '@kit/otp/components'; + +function MyVerificationPage(props: { + userEmail: string; +}) { + return ( + { + // Handle successful verification + // Use the OTP for verification on the server + }} + CancelButton={ + + } + /> + ); +} +``` + +## Verifying a Token +And here is the server action that verifies the OTP: + +```tsx +// Verify the token +const result = await api.verifyToken({ + token: submittedToken, + purpose: 'email-verification' +}); + +if (result.valid) { + // Token is valid, proceed with the operation + const { userId, metadata } = result; + // Handle successful verification +} else { + // Token is invalid or expired + // Handle verification failure +} +``` \ No newline at end of file diff --git a/.cursor/rules/security.mdc b/.cursor/rules/security.mdc new file mode 100644 index 000000000..93d769b7d --- /dev/null +++ b/.cursor/rules/security.mdc @@ -0,0 +1,152 @@ +--- +description: Security guidelines for writing secure code +globs: +alwaysApply: false +--- +# Next.js-Specific Security + +### Client Component Data Passing + +- **Never pass sensitive data** to Client Components +- **Never pass unsanitized data** to Client Components (raw cookies, client-provided data) + +### Server Components Security + +- **Always sanitize user input** before using in Server Components +- **Validate cookies and headers** in Server Components + +### Environment Variables + +- **Use `import 'server-only'`** for code that should only be run on the server side +- **Never expose server-only env vars** to the client +- **Never pass environment variables as props to client components** unless they're suffixed with `NEXT_PUBLIC_` +- **Never use `NEXT_PUBLIC_` prefix** for sensitive data (ex. API keys, secrets) +- **Use `NEXT_PUBLIC_` prefix** only for client-safe variables + +### Client Hydration Protection + +- **Never expose sensitive data** in initial HTML + +## Authentication & Authorization + +### Row Level Security (RLS) + +- **Always enable RLS** on all tables unless explicitly specified otherwise [database.mdc](mdc:.cursor/rules/database.mdc) + +### Super Admin Protected Routes +Always perform extra checks when writing Super Admin code [super-admin.mdc](mdc:.cursor/rules/super-admin.mdc) + +## Server Actions & API Routes + +### Server Actions + +- Always use `enhanceAction` wrapper for consistent security [server-actions.mdc](mdc:.cursor/rules/server-actions.mdc) +- Always use 'use server' directive at the top of the file to safely bundle server-side code +- Validate input with Zod schemas +- Implement authentication checks: + +```typescript +'use server'; + +import { enhanceAction } from '@kit/next/actions'; +import { MyActionSchema } from '../schema'; + +export const secureAction = enhanceAction( + async function(data, user) { + // Additional permission checks + const hasPermission = await checkUserPermission(user.id, data.accountId, 'action.perform'); + if (!hasPermission) throw new Error('Insufficient permissions'); + + // Validated data available + return processAction(data); + }, + { + auth: true, + schema: MyActionSchema + } +); +``` + +### API Routes + +- Use `enhanceRouteHandler` for consistent security [route-handlers.mdc](mdc:.cursor/rules/route-handlers.mdc) +- Implement authentication and authorization checks: + +```typescript +import { enhanceRouteHandler } from '@kit/next/routes'; +import { RouteSchema } from '../schema'; + +export const POST = enhanceRouteHandler( + async function({ body, user, request }) { + // Additional authorization checks + const canAccess = await canAccessResource(user.id, body.resourceId); + + if (!canAccess) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + + // Safe to process with validated body + return NextResponse.json({ success: true }); + }, + { + auth: true, + schema: RouteSchema + } +); +``` + +## Client Components Security + +### Context Awareness + +- Use appropriate workspace contexts for access control: + - `useUserWorkspace()` in personal account pages + - `useTeamAccountWorkspace()` in team account pages +- Check permissions before rendering sensitive UI elements: + +```typescript +function SecureComponent() { + const { account, user } = useTeamAccountWorkspace(); + const canEdit = account.permissions.includes('entity.update'); + + if (!canEdit) return null; + + return ; +} +``` + +### Data Fetching + +- Use React Query with proper error handling +- Never trust client-side permission checks alone + +## One-Time Tokens + +Consider using OTP tokens when implementing highly destructive operations like deleting an entity that would otherwise require a full backup: [otp.mdc](mdc:.cursor/rules/otp.mdc) + +## Critical Data Protection + +### Personal Information + +- Never log or expose sensitive user data +- Use proper session management + +## Error Handling + +- Never expose internal errors to clients +- Log errors securely with appropriate context +- Return generic error messages to users + +```typescript +try { + await sensitiveOperation(); +} catch (error) { + logger.error({ error, context }, 'Operation failed'); + return { error: 'Unable to complete operation' }; +} +``` + +## Database Security + +- Avoid dynamic SQL generation +- Use SECURITY DEFINER functions sparingly and carefully, warn user if you do so +- Implement proper foreign key constraints +- Use appropriate data types with constraints \ No newline at end of file diff --git a/apps/web/app/home/(user)/layout.tsx b/apps/web/app/home/(user)/layout.tsx index 26b423d1a..6ede44705 100644 --- a/apps/web/app/home/(user)/layout.tsx +++ b/apps/web/app/home/(user)/layout.tsx @@ -2,6 +2,8 @@ import { use } from 'react'; import { cookies } from 'next/headers'; +import { z } from 'zod'; + import { UserWorkspaceContextProvider } from '@kit/accounts/components'; import { Page, PageMobileNavigation, PageNavigation } from '@kit/ui/page'; import { SidebarProvider } from '@kit/ui/shadcn-sidebar'; @@ -88,18 +90,23 @@ function MobileNavigation({ async function getLayoutState() { const cookieStore = await cookies(); + const LayoutStyleSchema = z.enum(['sidebar', 'header', 'custom']); + const layoutStyleCookie = cookieStore.get('layout-style'); const sidebarOpenCookie = cookieStore.get('sidebar:state'); - const sidebarOpenCookieValue = sidebarOpenCookie + const sidebarOpen = sidebarOpenCookie ? sidebarOpenCookie.value === 'false' : !personalAccountNavigationConfig.sidebarCollapsed; - const style = - layoutStyleCookie?.value ?? personalAccountNavigationConfig.style; + const parsedStyle = LayoutStyleSchema.safeParse(layoutStyleCookie?.value); + + const style = parsedStyle.success + ? parsedStyle.data + : personalAccountNavigationConfig.style; return { - open: sidebarOpenCookieValue, + open: sidebarOpen, style, }; } diff --git a/apps/web/app/home/[account]/layout.tsx b/apps/web/app/home/[account]/layout.tsx index 1bd21ef5e..ffefb5f7c 100644 --- a/apps/web/app/home/[account]/layout.tsx +++ b/apps/web/app/home/[account]/layout.tsx @@ -2,13 +2,10 @@ import { use } from 'react'; import { cookies } from 'next/headers'; +import { z } from 'zod'; + import { TeamAccountWorkspaceContextProvider } from '@kit/team-accounts/components'; -import { - Page, - PageLayoutStyle, - PageMobileNavigation, - PageNavigation, -} from '@kit/ui/page'; +import { Page, PageMobileNavigation, PageNavigation } from '@kit/ui/page'; import { SidebarProvider } from '@kit/ui/shadcn-sidebar'; import { AppLogo } from '~/components/app-logo'; @@ -124,19 +121,26 @@ function HeaderLayout({ async function getLayoutState(account: string) { const cookieStore = await cookies(); + const config = getTeamAccountSidebarConfig(account); + + const LayoutStyleSchema = z + .enum(['sidebar', 'header', 'custom']) + .default(config.style); + const sidebarOpenCookie = cookieStore.get('sidebar:state'); const layoutCookie = cookieStore.get('layout-style'); - const layoutStyle = layoutCookie?.value as PageLayoutStyle; - const config = getTeamAccountSidebarConfig(account); + const layoutStyle = LayoutStyleSchema.safeParse(layoutCookie?.value); const sidebarOpenCookieValue = sidebarOpenCookie ? sidebarOpenCookie.value === 'false' : !config.sidebarCollapsed; + const style = layoutStyle.success ? layoutStyle.data : config.style; + return { open: sidebarOpenCookieValue, - style: layoutStyle ?? config.style, + style, }; } diff --git a/apps/web/lib/i18n/i18n.resolver.ts b/apps/web/lib/i18n/i18n.resolver.ts index 96f1c7d49..46d70fe2d 100644 --- a/apps/web/lib/i18n/i18n.resolver.ts +++ b/apps/web/lib/i18n/i18n.resolver.ts @@ -1,11 +1,31 @@ +import { getLogger } from '@kit/shared/logger'; + /** - * Resolves the translation file for a given language and namespace. - * + * @name i18nResolver + * @description Resolve the translation file for the given language and namespace in the current application. + * @param language + * @param namespace */ export async function i18nResolver(language: string, namespace: string) { - const data = await import( - `../../public/locales/${language}/${namespace}.json` - ); + const logger = await getLogger(); - return data as Record; + try { + const data = await import( + `../../public/locales/${language}/${namespace}.json` + ); + + return data as Record; + } catch (error) { + console.group( + `Error while loading translation file: ${language}/${namespace}`, + ); + logger.error(error instanceof Error ? error.message : error); + logger.warn( + `Please create a translation file for this language at "public/locales/${language}/${namespace}.json"`, + ); + console.groupEnd(); + + // return an empty object if the file could not be loaded to avoid loops + return {}; + } } diff --git a/apps/web/lib/i18n/i18n.server.ts b/apps/web/lib/i18n/i18n.server.ts index 1ee774363..9074d2b02 100644 --- a/apps/web/lib/i18n/i18n.server.ts +++ b/apps/web/lib/i18n/i18n.server.ts @@ -1,7 +1,11 @@ +import 'server-only'; + import { cache } from 'react'; import { cookies, headers } from 'next/headers'; +import { z } from 'zod'; + import { initializeServerI18n, parseAcceptLanguageHeader, @@ -32,13 +36,13 @@ const priority = featuresFlagConfig.languagePriority; */ async function createInstance() { const cookieStore = await cookies(); - const cookie = cookieStore.get(I18N_COOKIE_NAME)?.value; + const langCookieValue = cookieStore.get(I18N_COOKIE_NAME)?.value; let selectedLanguage: string | undefined = undefined; // if the cookie is set, use the language from the cookie - if (cookie) { - selectedLanguage = getLanguageOrFallback(cookie); + if (langCookieValue) { + selectedLanguage = getLanguageOrFallback(langCookieValue); } // if not, check if the language priority is set to user and @@ -56,10 +60,15 @@ async function createInstance() { export const createI18nServerInstance = cache(createInstance); +/** + * @name getPreferredLanguageFromBrowser + * Get the user's preferred language from the accept-language header. + */ async function getPreferredLanguageFromBrowser() { const headersStore = await headers(); const acceptLanguage = headersStore.get('accept-language'); + // no accept-language header, return if (!acceptLanguage) { return; } @@ -67,16 +76,23 @@ async function getPreferredLanguageFromBrowser() { return parseAcceptLanguageHeader(acceptLanguage, languages)[0]; } -function getLanguageOrFallback(language: string | undefined) { - let selectedLanguage = language; +/** + * @name getLanguageOrFallback + * Get the language or fallback to the default language. + * @param selectedLanguage + */ +function getLanguageOrFallback(selectedLanguage: string | undefined) { + const language = z + .enum(languages as [string, ...string[]]) + .safeParse(selectedLanguage); - if (!languages.includes(language ?? '')) { - console.warn( - `Language "${language}" is not supported. Falling back to "${languages[0]}"`, - ); - - selectedLanguage = languages[0]; + if (language.success) { + return language.data; } - return selectedLanguage; + console.warn( + `The language passed is invalid. Defaulted back to "${languages[0]}"`, + ); + + return languages[0]; } diff --git a/apps/web/lib/root-theme.ts b/apps/web/lib/root-theme.ts index f45eca8f6..a7aba8bfa 100644 --- a/apps/web/lib/root-theme.ts +++ b/apps/web/lib/root-theme.ts @@ -1,6 +1,28 @@ import { cookies } from 'next/headers'; -type Theme = 'light' | 'dark' | 'system'; +import { z } from 'zod'; + +/** + * @name Theme + * @description The theme mode enum. + */ +const Theme = z.enum(['light', 'dark', 'system'], { + description: 'The theme mode', +}); + +/** + * @name appDefaultThemeMode + * @description The default theme mode set by the application. + */ +const appDefaultThemeMode = Theme.safeParse( + process.env.NEXT_PUBLIC_DEFAULT_THEME_MODE, +); + +/** + * @name fallbackThemeMode + * @description The fallback theme mode if none of the other options are available. + */ +const fallbackThemeMode = `light`; /** * @name getRootTheme @@ -9,8 +31,19 @@ type Theme = 'light' | 'dark' | 'system'; */ export async function getRootTheme() { const cookiesStore = await cookies(); + const themeCookieValue = cookiesStore.get('theme')?.value; + const theme = Theme.safeParse(themeCookieValue); - const themeCookie = cookiesStore.get('theme')?.value as Theme; + // pass the theme from the cookie if it exists + if (theme.success) { + return theme.data; + } - return themeCookie ?? process.env.NEXT_PUBLIC_DEFAULT_THEME_MODE ?? 'light'; + // pass the default theme from the environment variable if it exists + if (appDefaultThemeMode.success) { + return appDefaultThemeMode.data; + } + + // in all other cases, fallback to the default theme + return fallbackThemeMode; }