diff --git a/README.md b/README.md index 2fe16ca..08e2972 100644 --- a/README.md +++ b/README.md @@ -220,6 +220,14 @@ Dashboard --- -## Legal Notice +## License -This project was generated using [Selleo Boilerplate](https://github.com/Selleo/boilerplate) which is licensed under the MIT license. +See `LICENSE`. + +## About Selleo + +![selleo](https://raw.githubusercontent.com/Selleo/selleo-resources/master/public/github_footer.png) + +Software development teams with an entrepreneurial sense of ownership at their core delivering great digital products and building culture people want to belong to. We are a community of engaged co-workers passionate about crafting impactful web solutions which transform the way our clients do business. + +All names and logos for [Selleo](https://selleo.com/about) are trademark of Selleo Labs Sp. z o.o. (formerly Selleo Sp. z o.o. Sp.k.) diff --git a/apps/api/.env.example b/apps/api/.env.example index 93aa3e1..ea0e2c6 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -1,13 +1,14 @@ # GENERAL NODE_ENV=development LOG_LEVEL=debug -CORS_ORIGIN="https://app.boilerplate.localhost" -EMAIL_ADAPTER="mailhog" +CORS_ORIGIN=https://app.boilerplate.localhost +COOKIE_DOMAIN=boilerplate.localhost +EMAIL_ADAPTER=mailhog # DATABASE -DATABASE_URL="postgres://postgres:boilerplate@localhost:5432/boilerplate" -DATABASE_TEST_URL="postgres://postgres:boilerplate@localhost:5432/boilerplate_test" -REDIS_URL="redis://localhost:6379" +DATABASE_URL=postgres://postgres:boilerplate@localhost:5432/boilerplate +DATABASE_TEST_URL=postgres://postgres:boilerplate@localhost:5432/boilerplate_test +REDIS_URL=redis://localhost:6379 BULLBOARD_PASSWORD=admin123 # MAILS @@ -18,16 +19,18 @@ SMTP_PASSWORD= # AWS AWS_REGION=eu-central-1 -AWS_ACCESS_KEY_ID=AKIAV2CD6JAAAAAAAA -AWS_SECRET_ACCESS_KEY=SECREETTTTTTTTTTTTTTTTTTT +AWS_ACCESS_KEY_ID=rustfsadmin +AWS_SECRET_ACCESS_KEY=rustfsadmin +S3_ENDPOINT=http://127.0.0.1:9000 +S3_FORCE_PATH_STYLE=true FILE_STORAGE_ADAPTER=s3 -AWS_BUCKET_NAME=2m3d-boilerplate-prod +AWS_BUCKET_NAME=boilerplate-prod # AUTH -BETTER_AUTH_SECRET="secret999" +BETTER_AUTH_SECRET=secret99secret99secret99secret99secret99 BETTER_AUTH_URL=http://localhost:3000 GOOGLE_CLIENT_ID=1111111111111-example.apps.googleusercontent.com diff --git a/apps/api/package.json b/apps/api/package.json index 8150b7d..864f9fb 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -40,16 +40,15 @@ "@nestjs/config": "^4.0.2", "@nestjs/core": "^11.1.9", "@nestjs/cqrs": "^11.0.3", - "@nestjs/jwt": "^11.0.2", - "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.1.9", "@nestjs/swagger": "^11.2.3", "@nestjs/terminus": "^11.0.0", "@repo/email-templates": "workspace:*", "@sinclair/typebox": "^0.34.41", + "@thallesp/nestjs-better-auth": "^2.4.0", "add": "^2.0.6", "bcrypt": "^6.0.0", - "better-auth": "1.4.5", + "better-auth": "1.4.19", "bullmq": "^5.66.0", "cookie": "^1.1.1", "cookie-parser": "^1.4.7", @@ -63,9 +62,6 @@ "multer": "^2.0.2", "nestjs-typebox": "4.0.0", "nodemailer": "^7.0.11", - "passport": "^0.7.0", - "passport-jwt": "^4.0.1", - "passport-local": "^1.0.0", "postgres": "^3.4.7", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2", @@ -89,8 +85,6 @@ "@types/multer": "^2.0.0", "@types/node": "^25.0.2", "@types/nodemailer": "^7.0.4", - "@types/passport-jwt": "^4.0.1", - "@types/passport-local": "^1.0.38", "@types/supertest": "^6.0.3", "@types/uuid": "^11.0.0", "@typescript-eslint/eslint-plugin": "^8.50.0", diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index eb3562c..2d3d26a 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -4,22 +4,19 @@ import database from "./common/configuration/database"; import { ConfigModule, ConfigService } from "@nestjs/config"; import * as schema from "./storage/schema"; import { UsersModule } from "./users/users.module"; -import { JwtModule, JwtModuleOptions } from "@nestjs/jwt"; import emailConfig from "./common/configuration/email"; import awsConfig from "./common/configuration/aws"; import fileStorageConfig from "./common/configuration/file-storage"; -import { APP_GUARD } from "@nestjs/core"; import { EmailModule } from "./common/emails/emails.module"; import { FileStorageModule } from "./file-storage"; import { TestConfigModule } from "./test-config/test-config.module"; -import { StagingGuard } from "./common/guards/staging.guard"; import { HealthModule } from "./health/health.module"; -import { BetterAuthModule, AuthGuard } from "./auth"; import { AuthModule } from "./auth/auth.module"; import { AuthService } from "./auth/auth.service"; import { buildBetterAuthInstance } from "./lib/better-auth-options"; import { LoggerMiddleware } from "./logger/logger.middleware"; import { QueueModule } from "./queue/queue.module"; +import { AuthModule as BetterModule } from "@thallesp/nestjs-better-auth"; import type { DatabasePg } from "./common"; @@ -44,23 +41,11 @@ import type { DatabasePg } from "./common"; inject: [ConfigService], }), QueueModule, - JwtModule.registerAsync({ - useFactory(configService: ConfigService): JwtModuleOptions { - return { - secret: configService.get("jwt.secret")!, - signOptions: { - expiresIn: configService.get("jwt.expirationTime"), - }, - }; - }, - inject: [ConfigService], - global: true, - }), AuthModule, - BetterAuthModule.forRootAsync({ + BetterModule.forRootAsync({ imports: [EmailModule, AuthModule], inject: [ConfigService, AuthService, "DB"], - useFactory: ( + useFactory: ( configService: ConfigService, authService: AuthService, db: DatabasePg, @@ -83,16 +68,7 @@ import type { DatabasePg } from "./common"; HealthModule, ], controllers: [], - providers: [ - { - provide: APP_GUARD, - useClass: AuthGuard, - }, - { - provide: APP_GUARD, - useClass: StagingGuard, - }, - ], + providers: [], }) export class AppModule { configure(consumer: MiddlewareConsumer) { diff --git a/apps/api/src/auth/auth-email.service.ts b/apps/api/src/auth/auth-email.service.ts index f6c3a92..b785773 100644 --- a/apps/api/src/auth/auth-email.service.ts +++ b/apps/api/src/auth/auth-email.service.ts @@ -7,7 +7,9 @@ import { EMAIL_QUEUE, EmailQueueJobPayloads } from "./auth.queue"; export class AuthEmailService { private readonly jobSettings = { age: 3600 }; - constructor(@InjectQueue(EMAIL_QUEUE.name) private readonly queue: Queue) {} + constructor( + @InjectQueue(EMAIL_QUEUE.name) private readonly queue: Queue, + ) {} public async sendWelcomeMessageEmailAsync( payload: EmailQueueJobPayloads["SEND_WELCOME_EMAIL"], diff --git a/apps/api/src/auth/better-auth.module.ts b/apps/api/src/auth/better-auth.module.ts deleted file mode 100644 index e435d0e..0000000 --- a/apps/api/src/auth/better-auth.module.ts +++ /dev/null @@ -1,351 +0,0 @@ -import { - Inject, - Logger, - MiddlewareConsumer, - Module, - type DynamicModule, - type ModuleMetadata, - type NestModule, - type OnModuleInit, - type Provider, - type Type, -} from "@nestjs/common"; -import { - APP_FILTER, - DiscoveryModule, - DiscoveryService, - HttpAdapterHost, - MetadataScanner, -} from "@nestjs/core"; -import type { Auth } from "better-auth"; -import { toNodeHandler } from "better-auth/node"; -import { createAuthMiddleware } from "better-auth/plugins"; -import type { Request, Response } from "express"; -import { APIErrorExceptionFilter } from "./api-error-exception-filter"; -import { BetterAuthService } from "./better-auth.service"; -import { SkipBodyParsingMiddleware } from "./skip-body-parsing.middleware"; -import { - AFTER_HOOK, - AUTH_INSTANCE, - AUTH_MODULE_OPTIONS, - BEFORE_HOOK, - HOOK_CLASS, -} from "./tokens"; - -export type BetterAuthModuleOptions = { - disableExceptionFilter?: boolean; - disableTrustedOriginsCors?: boolean; - disableBodyParser?: boolean; -}; - -export interface BetterAuthModuleAsyncOptions - extends Pick { - useFactory: (...args: unknown[]) => - | Promise<{ - auth: Auth; - options?: BetterAuthModuleOptions; - }> - | { - auth: Auth; - options?: BetterAuthModuleOptions; - }; - inject?: (string | symbol | Type)[]; - useClass?: Type<{ - createAuthOptions(): - | Promise<{ - auth: Auth; - options?: BetterAuthModuleOptions; - }> - | { - auth: Auth; - options?: BetterAuthModuleOptions; - }; - }>; - useExisting?: Type<{ - createAuthOptions(): - | Promise<{ - auth: Auth; - options?: BetterAuthModuleOptions; - }> - | { - auth: Auth; - options?: BetterAuthModuleOptions; - }; - }>; -} - -const HOOK_METADATA = [ - { metadataKey: BEFORE_HOOK, hookType: "before" as const }, - { metadataKey: AFTER_HOOK, hookType: "after" as const }, -]; - -@Module({ - imports: [DiscoveryModule], -}) -export class BetterAuthModule implements NestModule, OnModuleInit { - private readonly logger = new Logger(BetterAuthModule.name); - - constructor( - @Inject(AUTH_INSTANCE) private readonly auth: Auth, - private readonly discoveryService: DiscoveryService, - private readonly metadataScanner: MetadataScanner, - private readonly adapter: HttpAdapterHost, - @Inject(AUTH_MODULE_OPTIONS) - private readonly options: BetterAuthModuleOptions, - ) {} - - onModuleInit(): void { - if (!this.auth.options.hooks) return; - - const providers = this.discoveryService - .getProviders() - .filter( - ({ metatype }) => metatype && Reflect.getMetadata(HOOK_CLASS, metatype), - ); - - for (const provider of providers) { - const providerPrototype = Object.getPrototypeOf(provider.instance); - const methods = this.metadataScanner.getAllMethodNames(providerPrototype); - - for (const method of methods) { - const providerMethod = providerPrototype[method]; - this.setupHooks(providerMethod, provider.instance); - } - } - } - - configure(consumer: MiddlewareConsumer): void { - const trustedOrigins = this.auth.options.trustedOrigins; - const isArrayTrustedOrigins = - trustedOrigins && Array.isArray(trustedOrigins); - - if (!this.options.disableTrustedOriginsCors && isArrayTrustedOrigins) { - this.adapter.httpAdapter.enableCors({ - origin: trustedOrigins, - methods: ["GET", "POST", "PUT", "DELETE"], - credentials: true, - }); - } else if ( - trustedOrigins && - !this.options.disableTrustedOriginsCors && - !isArrayTrustedOrigins - ) { - throw new Error( - "Function-based trustedOrigins not supported in NestJS. Use string array or disable CORS with disableTrustedOriginsCors: true.", - ); - } - - if (!this.options.disableBodyParser) { - consumer.apply(SkipBodyParsingMiddleware).forRoutes("*path"); - } - - let basePath = this.auth.options.basePath ?? "/api/auth"; - if (!basePath.startsWith("/")) { - basePath = `/${basePath}`; - } - if (basePath.endsWith("/")) { - basePath = basePath.slice(0, -1); - } - - const handler = toNodeHandler(this.auth); - this.adapter.httpAdapter - .getInstance() - .use(`${basePath}/*path`, (req: Request, res: Response) => - handler(req, res), - ); - this.logger.log(`BetterAuth mounted on '${basePath}/*'`); - } - - private setupHooks( - providerMethod: (...args: unknown[]) => unknown, - providerInstance: - | { new (...args: unknown[]): unknown } - | Record, - ) { - if (!this.auth.options.hooks) return; - - for (const { metadataKey, hookType } of HOOK_METADATA) { - const hookPath = Reflect.getMetadata(metadataKey, providerMethod); - if (!hookPath) continue; - - const originalHook = this.auth.options.hooks[hookType]; - this.auth.options.hooks[hookType] = createAuthMiddleware(async (ctx) => { - if (originalHook) { - await originalHook(ctx); - } - - if (hookPath === ctx.path) { - await providerMethod.apply(providerInstance, [ctx]); - } - }); - } - } - - static forRoot( - auth: Auth, - options: BetterAuthModuleOptions = {}, - ): DynamicModule { - auth.options.hooks = { - ...auth.options.hooks, - }; - - const providers: Provider[] = [ - { provide: AUTH_INSTANCE, useValue: auth }, - { provide: AUTH_MODULE_OPTIONS, useValue: options }, - BetterAuthService, - ]; - - if (!options.disableExceptionFilter) { - providers.push({ - provide: APP_FILTER, - useClass: APIErrorExceptionFilter, - }); - } - - return { - global: true, - module: BetterAuthModule, - providers, - exports: [ - { provide: AUTH_INSTANCE, useValue: auth }, - { provide: AUTH_MODULE_OPTIONS, useValue: options }, - BetterAuthService, - ], - }; - } - - static forRootAsync(options: BetterAuthModuleAsyncOptions): DynamicModule { - const asyncProviders = BetterAuthModule.createAsyncProviders(options); - - return { - global: true, - module: BetterAuthModule, - imports: options.imports ?? [], - providers: [...asyncProviders, BetterAuthService], - exports: [ - { provide: AUTH_INSTANCE, useExisting: AUTH_INSTANCE }, - { provide: AUTH_MODULE_OPTIONS, useExisting: AUTH_MODULE_OPTIONS }, - BetterAuthService, - ], - }; - } - - private static createAsyncProviders( - options: BetterAuthModuleAsyncOptions, - ): Provider[] { - if (options.useFactory) { - return [ - { - provide: AUTH_INSTANCE, - useFactory: async (...args: unknown[]) => { - const result = await options.useFactory(...args); - const auth = result.auth; - auth.options.hooks = { - ...auth.options.hooks, - }; - return auth; - }, - inject: options.inject ?? [], - }, - { - provide: AUTH_MODULE_OPTIONS, - useFactory: async (...args: unknown[]) => { - const result = await options.useFactory(...args); - return result.options ?? {}; - }, - inject: options.inject ?? [], - }, - BetterAuthModule.createExceptionFilterProvider(), - ]; - } - - if (options.useClass) { - return [ - { - provide: options.useClass, - useClass: options.useClass, - }, - { - provide: AUTH_INSTANCE, - useFactory: async (factory: { - createAuthOptions(): - | Promise<{ auth: Auth; options?: BetterAuthModuleOptions }> - | { auth: Auth; options?: BetterAuthModuleOptions }; - }) => { - const result = await factory.createAuthOptions(); - const auth = result.auth; - auth.options.hooks = { - ...auth.options.hooks, - }; - return auth; - }, - inject: [options.useClass], - }, - { - provide: AUTH_MODULE_OPTIONS, - useFactory: async (factory: { - createAuthOptions(): - | Promise<{ auth: Auth; options?: BetterAuthModuleOptions }> - | { auth: Auth; options?: BetterAuthModuleOptions }; - }) => { - const result = await factory.createAuthOptions(); - return result.options ?? {}; - }, - inject: [options.useClass], - }, - BetterAuthModule.createExceptionFilterProvider(), - ]; - } - - if (options.useExisting) { - return [ - { - provide: AUTH_INSTANCE, - useFactory: async (factory: { - createAuthOptions(): - | Promise<{ auth: Auth; options?: BetterAuthModuleOptions }> - | { auth: Auth; options?: BetterAuthModuleOptions }; - }) => { - const result = await factory.createAuthOptions(); - const auth = result.auth; - auth.options.hooks = { - ...auth.options.hooks, - }; - return auth; - }, - inject: [options.useExisting], - }, - { - provide: AUTH_MODULE_OPTIONS, - useFactory: async (factory: { - createAuthOptions(): - | Promise<{ auth: Auth; options?: BetterAuthModuleOptions }> - | { auth: Auth; options?: BetterAuthModuleOptions }; - }) => { - const result = await factory.createAuthOptions(); - return result.options ?? {}; - }, - inject: [options.useExisting], - }, - BetterAuthModule.createExceptionFilterProvider(), - ]; - } - - throw new Error( - "Invalid async configuration. Must provide useFactory, useClass, or useExisting.", - ); - } - - private static createExceptionFilterProvider(): Provider { - return { - provide: APP_FILTER, - useFactory: (options: BetterAuthModuleOptions) => { - if (options.disableExceptionFilter) { - return null; - } - return new APIErrorExceptionFilter(); - }, - inject: [AUTH_MODULE_OPTIONS], - }; - } -} diff --git a/apps/api/src/auth/better-auth.service.ts b/apps/api/src/auth/better-auth.service.ts index 0724bc8..3f20317 100644 --- a/apps/api/src/auth/better-auth.service.ts +++ b/apps/api/src/auth/better-auth.service.ts @@ -1,17 +1,5 @@ -import { Inject } from "@nestjs/common"; -import type { Auth } from "better-auth"; -import { AUTH_INSTANCE } from "./tokens"; +import { buildBetterAuthInstance } from "src/lib/better-auth-options"; -export class BetterAuthService { - constructor( - @Inject(AUTH_INSTANCE) private readonly auth: T, - ) {} - - get api(): T["api"] { - return this.auth.api; - } - - get instance(): T { - return this.auth; - } -} +// to use it in DI use: +// private readonly authService: AuthService +export type BetterAuthInstance = ReturnType; diff --git a/apps/api/src/auth/index.ts b/apps/api/src/auth/index.ts index ceb4791..82faf12 100644 --- a/apps/api/src/auth/index.ts +++ b/apps/api/src/auth/index.ts @@ -1,5 +1,4 @@ -export * from "./better-auth.module"; -export * from "./better-auth.service"; export * from "./auth.guard"; export * from "./decorators"; export * from "./tokens"; +export * from "./better-auth.service"; diff --git a/apps/api/src/common/guards/jwt-auth.guard.ts b/apps/api/src/common/guards/jwt-auth.guard.ts deleted file mode 100644 index 608b390..0000000 --- a/apps/api/src/common/guards/jwt-auth.guard.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { - Injectable, - CanActivate, - ExecutionContext, - UnauthorizedException, -} from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; -import { Reflector } from "@nestjs/core"; -import { JwtService } from "@nestjs/jwt"; -import { extractToken } from "src/utils/extract-token"; - -@Injectable() -export class JwtAuthGuard implements CanActivate { - constructor( - private jwtService: JwtService, - private reflector: Reflector, - private configService: ConfigService, - ) {} - - async canActivate(context: ExecutionContext): Promise { - const isPublic = this.reflector.getAllAndOverride("isPublic", [ - context.getHandler(), - context.getClass(), - ]); - - if (isPublic) { - return true; - } - - const request = context.switchToHttp().getRequest(); - const token = extractToken(request, "access_token"); - - if (!token) { - throw new UnauthorizedException("Access token not found"); - } - - try { - const payload = await this.jwtService.verifyAsync(token, { - secret: this.configService.get("jwt.secret"), - }); - - request["user"] = payload; - - return true; - } catch { - throw new UnauthorizedException("Invalid access token"); - } - } -} diff --git a/apps/api/src/common/guards/refresh-token.guard.ts b/apps/api/src/common/guards/refresh-token.guard.ts deleted file mode 100644 index e45531a..0000000 --- a/apps/api/src/common/guards/refresh-token.guard.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { - Injectable, - CanActivate, - ExecutionContext, - UnauthorizedException, -} from "@nestjs/common"; -import { JwtService } from "@nestjs/jwt"; -import { ConfigService } from "@nestjs/config"; -import { extractToken } from "src/utils/extract-token"; - -@Injectable() -export class RefreshTokenGuard implements CanActivate { - constructor( - private readonly jwtService: JwtService, - private readonly configService: ConfigService, - ) {} - - async canActivate(context: ExecutionContext): Promise { - const request = context.switchToHttp().getRequest(); - const refreshToken = extractToken(request, "refresh_token"); - - if (!refreshToken) { - throw new UnauthorizedException("No refresh token provided"); - } - - try { - const payload = await this.jwtService.verifyAsync(refreshToken, { - secret: this.configService.get("jwt.refreshSecret"), - }); - - request["user"] = payload; - request["refreshToken"] = refreshToken; - - return true; - } catch { - throw new UnauthorizedException("Invalid refresh token"); - } - } -} diff --git a/apps/api/src/common/guards/staging.guard.ts b/apps/api/src/common/guards/staging.guard.ts deleted file mode 100644 index 50a5b09..0000000 --- a/apps/api/src/common/guards/staging.guard.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Injectable, CanActivate, ExecutionContext } from "@nestjs/common"; -import { Reflector } from "@nestjs/core"; - -@Injectable() -export class StagingGuard implements CanActivate { - constructor(private reflector: Reflector) {} - - canActivate(context: ExecutionContext): boolean { - const onlyStaging = this.reflector.get( - "onlyStaging", - context.getHandler(), - ); - if (!onlyStaging) { - return true; - } - return process.env.NODE_ENV === "staging"; - } -} diff --git a/apps/api/src/lib/better-auth-options.ts b/apps/api/src/lib/better-auth-options.ts index ea76ce3..c4975c6 100644 --- a/apps/api/src/lib/better-auth-options.ts +++ b/apps/api/src/lib/better-auth-options.ts @@ -43,6 +43,7 @@ export const buildBetterAuthInstance = ({ sendWelcomeVerifyEmail, }: BuildBetterAuthOptionsParams) => { const nodeEnv = env("NODE_ENV"); + const isProd = nodeEnv === "production"; const isDev = nodeEnv === "development"; const isTest = nodeEnv === "test"; @@ -60,6 +61,14 @@ export const buildBetterAuthInstance = ({ const logLevel = env("LOG_LEVEL"); const devSocial = env("DEV_SOCIAL") === "true"; + const trustedOrigins = isProd + ? [env("CORS_ORIGIN")!] + : ["http://localhost:5173", "https://app.boilerplate.localhost"]; + + const crossSubDomainCookiesDomain = isProd + ? env("COOKIE_DOMAIN") + : "boilerplate.localhost"; + const defaultPlugins: BetterAuthPlugin[] = plugins ?? [openAPI(), admin()]; let baseOptions: BetterAuthOptions = { @@ -69,7 +78,10 @@ export const buildBetterAuthInstance = ({ console.log(`[Auth][${ctx.method}] Incoming request: ${ctx.path}`); if (isDev && logLevel === "debug") { - console.log(`[Auth][${ctx.method}] Headers:`, ctx.headers); + // Uncomment if you want to log whole request in dev mode. + // if (logLevel === "debug") { + // console.log(`[Auth][${ctx.method}] Headers:`, ctx.headers); + // } if (ctx.method !== "GET") { console.log( `[Auth][${ctx.method}] Body:`, @@ -116,15 +128,10 @@ export const buildBetterAuthInstance = ({ : { crossSubDomainCookies: { enabled: true, - domain: "boilerplate.localhost", + domain: crossSubDomainCookiesDomain, }, }, - trustedOrigins: [ - "http://localhost:5173", - "http://localhost:5174", - "http://localhost:3000", - "https://app.boilerplate.localhost", - ], + trustedOrigins: trustedOrigins, logger: { level: "debug", log(level, message, ...args) { diff --git a/apps/api/src/utils/extract-token.ts b/apps/api/src/utils/extract-token.ts deleted file mode 100644 index e05e8f9..0000000 --- a/apps/api/src/utils/extract-token.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Request } from "express"; - -export function extractToken( - request: Request, - cookieName: "refresh_token" | "access_token", -): string | null { - if (request.cookies && request.cookies[cookieName]) { - return request.cookies[cookieName]; - } - - if (request.headers.authorization?.startsWith("Bearer ")) { - return request.headers.authorization.split(" ")[1]; - } - - return null; -} diff --git a/apps/api/test/helpers/test-helpers.ts b/apps/api/test/helpers/test-helpers.ts index 1ae4ade..de8b041 100644 --- a/apps/api/test/helpers/test-helpers.ts +++ b/apps/api/test/helpers/test-helpers.ts @@ -1,5 +1,4 @@ import { DatabasePg } from "../../src/common"; -import { JwtService } from "@nestjs/jwt"; import { sql } from "drizzle-orm"; import { vi } from "vitest"; @@ -25,10 +24,6 @@ export function environmentVariablesFactory() { }; } -export function signInAs(userId: string, jwtService: JwtService): string { - return jwtService.sign({ sub: userId }); -} - export async function truncateAllTables(connection: DatabasePg): Promise { const tables = connection._.tableNamesMap; diff --git a/apps/web-app/app/components/ThemeToggle/ThemeToggle.tsx b/apps/web-app/app/components/ThemeToggle/ThemeToggle.tsx index e638162..473d7f5 100644 --- a/apps/web-app/app/components/ThemeToggle/ThemeToggle.tsx +++ b/apps/web-app/app/components/ThemeToggle/ThemeToggle.tsx @@ -1,17 +1,18 @@ import { useThemeStore } from "~/modules/Theme/themeStore"; -import { Button, type ButtonProps } from "../ui/button"; +import { Button } from "../ui/button"; +import type { ComponentProps } from "react"; import { type LucideProps, Moon, Sun } from "lucide-react"; type ThemeToggleProps = { - variant?: ButtonProps["variant"]; + variant?: ComponentProps["variant"]; className?: string; }; export default function ThemeToggle({ className, variant = "ghost" }: ThemeToggleProps) { const { theme, toggleTheme } = useThemeStore(); - const ToggleIcon: React.FC = (props) => { + const ToggleIcon = (props: LucideProps) => { switch (theme) { case "light": return ; diff --git a/apps/web-app/app/components/nav-user.tsx b/apps/web-app/app/components/nav-user.tsx index e333420..8e8304d 100644 --- a/apps/web-app/app/components/nav-user.tsx +++ b/apps/web-app/app/components/nav-user.tsx @@ -3,9 +3,11 @@ import { Bell, ChevronsUpDown, CreditCard, + Languages, LogOut, Sparkles } from "lucide-react"; +import { useTranslation } from "react-i18next"; import { useLogoutUser } from "~/api/mutations/useLogoutUser"; import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar"; @@ -15,7 +17,12 @@ import { DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger } from "~/components/ui/dropdown-menu"; import { @@ -24,6 +31,7 @@ import { SidebarMenuItem, useSidebar } from "~/components/ui/sidebar"; +import { isSupportedLanguage, setLanguage } from "~/lib/i18n"; export function NavUser({ user @@ -36,6 +44,18 @@ export function NavUser({ }) { const { isMobile } = useSidebar(); const { mutate: logout } = useLogoutUser(); + const { t, i18n } = useTranslation(); + const currentLanguage = isSupportedLanguage(i18n.resolvedLanguage ?? "") + ? i18n.resolvedLanguage + : "en"; + + const handleLanguageChange = (language: string) => { + if (!isSupportedLanguage(language)) { + return; + } + + setLanguage(language); + }; return ( @@ -79,28 +99,47 @@ export function NavUser({ - Upgrade to Pro + {t("dashboard.navUser.upgradeToPro")} + + + + {t("dashboard.navUser.language")} + + + + + {t("common.languages.english")} + + + {t("common.languages.polish")} + + + + - Account + {t("dashboard.navUser.account")} - Billing + {t("dashboard.navUser.billing")} - Notifications + {t("dashboard.navUser.notifications")} logout()}> - Log out + {t("dashboard.navUser.logout")} diff --git a/apps/web-app/app/components/ui/accordion.tsx b/apps/web-app/app/components/ui/accordion.tsx new file mode 100644 index 0000000..37d2d97 --- /dev/null +++ b/apps/web-app/app/components/ui/accordion.tsx @@ -0,0 +1,64 @@ +import * as React from "react" +import { ChevronDownIcon } from "lucide-react" +import { Accordion as AccordionPrimitive } from "radix-ui" + +import { cn } from "~/lib/utils" + +function Accordion({ + ...props +}: React.ComponentProps) { + return +} + +function AccordionItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AccordionTrigger({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + svg]:rotate-180", + className + )} + {...props} + > + {children} + + + + ) +} + +function AccordionContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + +
{children}
+
+ ) +} + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/apps/web-app/app/components/ui/alert-dialog.tsx b/apps/web-app/app/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..e4c09a9 --- /dev/null +++ b/apps/web-app/app/components/ui/alert-dialog.tsx @@ -0,0 +1,196 @@ +"use client" + +import * as React from "react" +import { AlertDialog as AlertDialogPrimitive } from "radix-ui" + +import { cn } from "~/lib/utils" +import { Button } from "~/components/ui/button" + +function AlertDialog({ + ...props +}: React.ComponentProps) { + return +} + +function AlertDialogTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogContent({ + className, + size = "default", + ...props +}: React.ComponentProps & { + size?: "default" | "sm" +}) { + return ( + + + + + ) +} + +function AlertDialogHeader({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogFooter({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogMedia({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogAction({ + className, + variant = "default", + size = "default", + ...props +}: React.ComponentProps & + Pick, "variant" | "size">) { + return ( + + ) +} + +function AlertDialogCancel({ + className, + variant = "outline", + size = "default", + ...props +}: React.ComponentProps & + Pick, "variant" | "size">) { + return ( + + ) +} + +export { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogMedia, + AlertDialogOverlay, + AlertDialogPortal, + AlertDialogTitle, + AlertDialogTrigger, +} diff --git a/apps/web-app/app/components/ui/alert.tsx b/apps/web-app/app/components/ui/alert.tsx new file mode 100644 index 0000000..56b3946 --- /dev/null +++ b/apps/web-app/app/components/ui/alert.tsx @@ -0,0 +1,66 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "~/lib/utils" + +const alertVariants = cva( + "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", + { + variants: { + variant: { + default: "bg-card text-card-foreground", + destructive: + "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Alert({ + className, + variant, + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ) +} + +function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDescription({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { Alert, AlertTitle, AlertDescription } diff --git a/apps/web-app/app/components/ui/aspect-ratio.tsx b/apps/web-app/app/components/ui/aspect-ratio.tsx new file mode 100644 index 0000000..57e38fa --- /dev/null +++ b/apps/web-app/app/components/ui/aspect-ratio.tsx @@ -0,0 +1,11 @@ +"use client" + +import { AspectRatio as AspectRatioPrimitive } from "radix-ui" + +function AspectRatio({ + ...props +}: React.ComponentProps) { + return +} + +export { AspectRatio } diff --git a/apps/web-app/app/components/ui/avatar.tsx b/apps/web-app/app/components/ui/avatar.tsx index 065cca3..d1dffda 100644 --- a/apps/web-app/app/components/ui/avatar.tsx +++ b/apps/web-app/app/components/ui/avatar.tsx @@ -1,24 +1,26 @@ -"use client"; +import * as React from "react" +import { Avatar as AvatarPrimitive } from "radix-ui" -import * as React from "react"; -import * as AvatarPrimitive from "@radix-ui/react-avatar"; - -import { cn } from "~/lib/utils"; +import { cn } from "~/lib/utils" function Avatar({ className, + size = "default", ...props -}: React.ComponentProps) { +}: React.ComponentProps & { + size?: "default" | "sm" | "lg" +}) { return ( - ); + ) } function AvatarImage({ @@ -31,7 +33,7 @@ function AvatarImage({ className={cn("aspect-square size-full", className)} {...props} /> - ); + ) } function AvatarFallback({ @@ -42,12 +44,64 @@ function AvatarFallback({ + ) +} + +function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) { + return ( + svg]:hidden", + "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2", + "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2", + className + )} + {...props} + /> + ) +} + +function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AvatarGroupCount({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3", className )} {...props} /> - ); + ) } -export { Avatar, AvatarImage, AvatarFallback }; +export { + Avatar, + AvatarImage, + AvatarFallback, + AvatarBadge, + AvatarGroup, + AvatarGroupCount, +} diff --git a/apps/web-app/app/components/ui/badge.tsx b/apps/web-app/app/components/ui/badge.tsx new file mode 100644 index 0000000..6daa783 --- /dev/null +++ b/apps/web-app/app/components/ui/badge.tsx @@ -0,0 +1,48 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { Slot } from "radix-ui" + +import { cn } from "~/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center justify-center rounded-full border border-transparent px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90", + secondary: + "bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", + destructive: + "bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + link: "text-primary underline-offset-4 [a&]:hover:underline", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant = "default", + asChild = false, + ...props +}: React.ComponentProps<"span"> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot.Root : "span" + + return ( + + ) +} + +export { Badge, badgeVariants } diff --git a/apps/web-app/app/components/ui/breadcrumb.tsx b/apps/web-app/app/components/ui/breadcrumb.tsx index 904baa1..a2005f8 100644 --- a/apps/web-app/app/components/ui/breadcrumb.tsx +++ b/apps/web-app/app/components/ui/breadcrumb.tsx @@ -1,11 +1,11 @@ -import * as React from "react"; -import { Slot } from "@radix-ui/react-slot"; -import { ChevronRight, MoreHorizontal } from "lucide-react"; +import * as React from "react" +import { ChevronRight, MoreHorizontal } from "lucide-react" +import { Slot } from "radix-ui" -import { cn } from "~/lib/utils"; +import { cn } from "~/lib/utils" function Breadcrumb({ ...props }: React.ComponentProps<"nav">) { - return