From 1d610f0d2b6d636dbdce7b24c55992cb28276eb7 Mon Sep 17 00:00:00 2001 From: Francisco Gaona Date: Sun, 21 Dec 2025 09:38:51 +0100 Subject: [PATCH] WIP - added front end auth --- .env.api | 1 + .../migration.sql | 15 ++ backend/prisma/schema-central.prisma | 14 ++ backend/scripts/create-admin-user.ts | 50 +++++++ backend/scripts/create-tenant-user.ts | 138 ++++++++++++++++++ backend/src/auth/auth.module.ts | 2 + backend/src/auth/auth.service.ts | 75 +++++----- backend/src/tenant/tenant-database.service.ts | 24 ++- backend/src/tenant/tenant.middleware.ts | 46 ++++-- frontend/app.vue | 5 + frontend/components/LoginForm.vue | 58 ++++++-- frontend/composables/useApi.ts | 47 +++++- frontend/composables/useAuth.ts | 32 ++++ frontend/composables/useToast.ts | 20 +++ frontend/middleware/auth.global.ts | 38 +++++ frontend/nuxt.config.ts | 2 +- frontend/package-lock.json | 9 +- frontend/package.json | 3 +- frontend/pages/index.vue | 3 + frontend/pages/login.vue | 19 +++ frontend/pages/register.vue | 39 +++-- package-lock.json | 6 + 22 files changed, 558 insertions(+), 88 deletions(-) create mode 100644 backend/prisma/migrations/20251221012138_add_users_to_central/migration.sql create mode 100644 backend/scripts/create-admin-user.ts create mode 100644 backend/scripts/create-tenant-user.ts create mode 100644 frontend/composables/useAuth.ts create mode 100644 frontend/composables/useToast.ts create mode 100644 frontend/middleware/auth.global.ts create mode 100644 package-lock.json diff --git a/.env.api b/.env.api index 0227401..1aaf393 100644 --- a/.env.api +++ b/.env.api @@ -2,6 +2,7 @@ NODE_ENV=development PORT=3000 DATABASE_URL="mysql://platform:platform@db:3306/platform" +CENTRAL_DATABASE_URL="mysql://root:asjdnfqTash37faggT@db:3306/central_platform" REDIS_URL="redis://redis:6379" # JWT, multi-tenant hints, etc. diff --git a/backend/prisma/migrations/20251221012138_add_users_to_central/migration.sql b/backend/prisma/migrations/20251221012138_add_users_to_central/migration.sql new file mode 100644 index 0000000..8125412 --- /dev/null +++ b/backend/prisma/migrations/20251221012138_add_users_to_central/migration.sql @@ -0,0 +1,15 @@ +-- CreateTable +CREATE TABLE `users` ( + `id` VARCHAR(191) NOT NULL, + `email` VARCHAR(191) NOT NULL, + `password` VARCHAR(191) NOT NULL, + `firstName` VARCHAR(191) NULL, + `lastName` VARCHAR(191) NULL, + `role` VARCHAR(191) NOT NULL DEFAULT 'admin', + `isActive` BOOLEAN NOT NULL DEFAULT true, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updatedAt` DATETIME(3) NOT NULL, + + UNIQUE INDEX `users_email_key`(`email`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; diff --git a/backend/prisma/schema-central.prisma b/backend/prisma/schema-central.prisma index 5d30e66..b53afe8 100644 --- a/backend/prisma/schema-central.prisma +++ b/backend/prisma/schema-central.prisma @@ -8,6 +8,20 @@ datasource db { url = env("CENTRAL_DATABASE_URL") } +model User { + id String @id @default(cuid()) + email String @unique + password String + firstName String? + lastName String? + role String @default("admin") // admin, superadmin + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@map("users") +} + model Tenant { id String @id @default(cuid()) name String diff --git a/backend/scripts/create-admin-user.ts b/backend/scripts/create-admin-user.ts new file mode 100644 index 0000000..5057500 --- /dev/null +++ b/backend/scripts/create-admin-user.ts @@ -0,0 +1,50 @@ +import { PrismaClient as CentralPrismaClient } from '../node_modules/.prisma/central'; +import * as bcrypt from 'bcrypt'; + +// Central database client +const centralPrisma = new CentralPrismaClient(); + +async function createAdminUser() { + const email = 'admin@example.com'; + const password = 'admin123'; + const firstName = 'Admin'; + const lastName = 'User'; + + try { + // Check if admin user already exists + const existingUser = await centralPrisma.user.findUnique({ + where: { email }, + }); + + if (existingUser) { + console.log(`User ${email} already exists`); + return; + } + + // Hash password + const hashedPassword = await bcrypt.hash(password, 10); + + // Create admin user in central database + const user = await centralPrisma.user.create({ + data: { + email, + password: hashedPassword, + firstName, + lastName, + role: 'superadmin', + isActive: true, + }, + }); + + console.log('\nAdmin user created successfully!'); + console.log('Email:', email); + console.log('Password:', password); + console.log('User ID:', user.id); + } catch (error) { + console.error('Error creating admin user:', error); + } finally { + await centralPrisma.$disconnect(); + } +} + +createAdminUser(); diff --git a/backend/scripts/create-tenant-user.ts b/backend/scripts/create-tenant-user.ts new file mode 100644 index 0000000..5d7232f --- /dev/null +++ b/backend/scripts/create-tenant-user.ts @@ -0,0 +1,138 @@ +import { PrismaClient as CentralPrismaClient } from '../node_modules/.prisma/central'; +import * as bcrypt from 'bcrypt'; +import { Knex, knex } from 'knex'; + +// Central database client +const centralPrisma = new CentralPrismaClient(); + +async function createTenantUser() { + const tenantSlug = 'tenant1'; + const email = 'user@example.com'; + const password = 'user123'; + const firstName = 'Test'; + const lastName = 'User'; + + try { + // Get tenant database connection info + const tenant = await centralPrisma.tenant.findFirst({ + where: { slug: tenantSlug }, + }); + + if (!tenant) { + console.log(`Tenant ${tenantSlug} not found. Creating tenant...`); + + // Create tenant in central database + const newTenant = await centralPrisma.tenant.create({ + data: { + name: 'Default Tenant', + slug: tenantSlug, + dbHost: 'db', + dbPort: 3306, + dbName: 'platform', + dbUsername: 'platform', + dbPassword: 'platform', + status: 'active', + }, + }); + + console.log('Tenant created:', newTenant.slug); + } else { + console.log('Tenant found:', tenant.slug); + } + + const tenantInfo = tenant || { + dbHost: 'db', + dbPort: 3306, + dbName: 'platform', + dbUsername: 'platform', + dbPassword: 'platform', + }; + + // Connect to tenant database (using root for now since tenant password is encrypted) + const tenantDb: Knex = knex({ + client: 'mysql2', + connection: { + host: tenantInfo.dbHost, + port: tenantInfo.dbPort, + database: tenantInfo.dbName, + user: 'root', + password: 'asjdnfqTash37faggT', + }, + }); + + // Check if user already exists + const existingUser = await tenantDb('users') + .where({ email }) + .first(); + + if (existingUser) { + console.log(`User ${email} already exists in tenant ${tenantSlug}`); + await tenantDb.destroy(); + return; + } + + // Hash password + const hashedPassword = await bcrypt.hash(password, 10); + + // Create user + await tenantDb('users').insert({ + email, + password: hashedPassword, + firstName, + lastName, + isActive: true, + created_at: new Date(), + updated_at: new Date(), + }); + + console.log(`\nUser created successfully in tenant ${tenantSlug}!`); + console.log('Email:', email); + console.log('Password:', password); + + // Create admin role if it doesn't exist + let adminRole = await tenantDb('roles') + .where({ name: 'admin' }) + .first(); + + if (!adminRole) { + await tenantDb('roles').insert({ + name: 'admin', + guardName: 'api', + description: 'Administrator role with full access', + created_at: new Date(), + updated_at: new Date(), + }); + + adminRole = await tenantDb('roles') + .where({ name: 'admin' }) + .first(); + + console.log('Admin role created'); + } + + // Get the created user + const user = await tenantDb('users') + .where({ email }) + .first(); + + // Assign admin role to user + if (adminRole && user) { + await tenantDb('user_roles').insert({ + userId: user.id, + roleId: adminRole.id, + created_at: new Date(), + updated_at: new Date(), + }); + + console.log('Admin role assigned to user'); + } + + await tenantDb.destroy(); + } catch (error) { + console.error('Error creating tenant user:', error); + } finally { + await centralPrisma.$disconnect(); + } +} + +createTenantUser(); diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts index bd88d27..6ff25dd 100644 --- a/backend/src/auth/auth.module.ts +++ b/backend/src/auth/auth.module.ts @@ -5,10 +5,12 @@ import { ConfigService } from '@nestjs/config'; import { AuthService } from './auth.service'; import { AuthController } from './auth.controller'; import { JwtStrategy } from './jwt.strategy'; +import { TenantModule } from '../tenant/tenant.module'; @Module({ imports: [ PassportModule, + TenantModule, JwtModule.registerAsync({ inject: [ConfigService], useFactory: (config: ConfigService) => ({ diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index a518c4a..f235364 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -1,12 +1,12 @@ import { Injectable } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; -import { PrismaService } from '../prisma/prisma.service'; +import { TenantDatabaseService } from '../tenant/tenant-database.service'; import * as bcrypt from 'bcrypt'; @Injectable() export class AuthService { constructor( - private prisma: PrismaService, + private tenantDbService: TenantDatabaseService, private jwtService: JwtService, ) {} @@ -15,34 +15,29 @@ export class AuthService { email: string, password: string, ): Promise { - const user = await this.prisma.user.findUnique({ - where: { - tenantId_email: { - tenantId, - email, - }, - }, - include: { - tenant: true, - userRoles: { - include: { - role: { - include: { - rolePermissions: { - include: { - permission: true, - }, - }, - }, - }, - }, - }, - }, - }); + const tenantDb = await this.tenantDbService.getTenantKnex(tenantId); + + const user = await tenantDb('users') + .where({ email }) + .first(); - if (user && (await bcrypt.compare(password, user.password))) { - const { password, ...result } = user; - return result; + if (!user) { + return null; + } + + if (await bcrypt.compare(password, user.password)) { + // Load user roles and permissions + const userRoles = await tenantDb('user_roles') + .where({ userId: user.id }) + .join('roles', 'user_roles.roleId', 'roles.id') + .select('roles.*'); + + const { password: _, ...result } = user; + return { + ...result, + tenantId, + userRoles, + }; } return null; @@ -74,18 +69,24 @@ export class AuthService { firstName?: string, lastName?: string, ) { + const tenantDb = await this.tenantDbService.getTenantKnex(tenantId); + const hashedPassword = await bcrypt.hash(password, 10); - const user = await this.prisma.user.create({ - data: { - tenantId, - email, - password: hashedPassword, - firstName, - lastName, - }, + const [userId] = await tenantDb('users').insert({ + email, + password: hashedPassword, + firstName, + lastName, + isActive: true, + created_at: new Date(), + updated_at: new Date(), }); + const user = await tenantDb('users') + .where({ id: userId }) + .first(); + const { password: _, ...result } = user; return result; } diff --git a/backend/src/tenant/tenant-database.service.ts b/backend/src/tenant/tenant-database.service.ts index 5f5787b..3bb3db2 100644 --- a/backend/src/tenant/tenant-database.service.ts +++ b/backend/src/tenant/tenant-database.service.ts @@ -8,22 +8,30 @@ export class TenantDatabaseService { private readonly logger = new Logger(TenantDatabaseService.name); private tenantConnections: Map = new Map(); - async getTenantKnex(tenantId: string): Promise { - if (this.tenantConnections.has(tenantId)) { - return this.tenantConnections.get(tenantId); + async getTenantKnex(tenantIdOrSlug: string): Promise { + if (this.tenantConnections.has(tenantIdOrSlug)) { + return this.tenantConnections.get(tenantIdOrSlug); } const centralPrisma = getCentralPrisma(); - const tenant = await centralPrisma.tenant.findUnique({ - where: { id: tenantId }, + + // Try to find tenant by ID first, then by slug + let tenant = await centralPrisma.tenant.findUnique({ + where: { id: tenantIdOrSlug }, }); + + if (!tenant) { + tenant = await centralPrisma.tenant.findUnique({ + where: { slug: tenantIdOrSlug }, + }); + } if (!tenant) { - throw new Error(`Tenant ${tenantId} not found`); + throw new Error(`Tenant ${tenantIdOrSlug} not found`); } if (tenant.status !== 'active') { - throw new Error(`Tenant ${tenantId} is not active`); + throw new Error(`Tenant ${tenantIdOrSlug} is not active`); } // Decrypt password @@ -56,7 +64,7 @@ export class TenantDatabaseService { throw error; } - this.tenantConnections.set(tenantId, tenantKnex); + this.tenantConnections.set(tenantIdOrSlug, tenantKnex); return tenantKnex; } diff --git a/backend/src/tenant/tenant.middleware.ts b/backend/src/tenant/tenant.middleware.ts index 8e4ce3d..4a61263 100644 --- a/backend/src/tenant/tenant.middleware.ts +++ b/backend/src/tenant/tenant.middleware.ts @@ -19,29 +19,53 @@ export class TenantMiddleware implements NestMiddleware { const hostname = host.split(':')[0]; // Remove port if present const parts = hostname.split('.'); - // For local development, accept x-tenant-id header as fallback + this.logger.log(`Host header: ${host}, hostname: ${hostname}, parts: ${JSON.stringify(parts)}`); + + // For local development, accept x-tenant-id header let tenantId = req.headers['x-tenant-id'] as string; let subdomain: string | null = null; - // Extract subdomain (e.g., "acme" from "acme.routebox.co") - if (parts.length > 2) { + this.logger.log(`Host header: ${host}, hostname: ${hostname}, parts: ${JSON.stringify(parts)}, x-tenant-id: ${tenantId}`); + + // If x-tenant-id is explicitly provided, use it directly + if (tenantId) { + this.logger.log(`Using explicit x-tenant-id: ${tenantId}`); + (req as any).tenantId = tenantId; + next(); + return; + } + + // Extract subdomain (e.g., "tenant1" from "tenant1.routebox.co") + // For production domains with 3+ parts, extract first part as subdomain + if (parts.length >= 3) { subdomain = parts[0]; // Ignore www subdomain if (subdomain === 'www') { subdomain = null; } } + // For development (e.g., tenant1.localhost), also check 2 parts + else if (parts.length === 2 && parts[1] === 'localhost') { + subdomain = parts[0]; + } + + this.logger.log(`Extracted subdomain: ${subdomain}`); // Get tenant by subdomain if available if (subdomain) { - const tenant = await this.tenantDbService.getTenantByDomain(subdomain); - if (tenant) { - tenantId = tenant.id; - this.logger.log( - `Tenant identified: ${tenant.name} (${tenant.id}) from subdomain: ${subdomain}`, - ); - } else { - this.logger.warn(`No tenant found for subdomain: ${subdomain}`); + try { + const tenant = await this.tenantDbService.getTenantByDomain(subdomain); + if (tenant) { + tenantId = tenant.id; + this.logger.log( + `Tenant identified: ${tenant.name} (${tenant.id}) from subdomain: ${subdomain}`, + ); + } + } catch (error) { + this.logger.warn(`No tenant found for subdomain: ${subdomain}`, error.message); + // Fall back to using subdomain as tenantId directly if domain lookup fails + tenantId = subdomain; + this.logger.log(`Using subdomain as tenantId fallback: ${tenantId}`); } } diff --git a/frontend/app.vue b/frontend/app.vue index 698786c..7f8da09 100644 --- a/frontend/app.vue +++ b/frontend/app.vue @@ -1,5 +1,10 @@ + + diff --git a/frontend/components/LoginForm.vue b/frontend/components/LoginForm.vue index 34e3494..45c6ba9 100644 --- a/frontend/components/LoginForm.vue +++ b/frontend/components/LoginForm.vue @@ -5,8 +5,34 @@ import { Label } from '@/components/ui/label' const config = useRuntimeConfig() const router = useRouter() +const { toast } = useToast() -const tenantId = ref('123') +// Cookie for server-side auth check +const tokenCookie = useCookie('token') + +// Extract subdomain from hostname (e.g., tenant1.localhost → tenant1) +const getSubdomain = () => { + if (!import.meta.client) return null + const hostname = window.location.hostname + const parts = hostname.split('.') + + console.log('Extracting subdomain from:', hostname, 'parts:', parts) + + // For localhost development: tenant1.localhost or localhost + if (hostname === 'localhost' || hostname === '127.0.0.1') { + return null // Use default tenant for plain localhost + } + + // For subdomains like tenant1.routebox.co or tenant1.localhost + if (parts.length >= 2 && parts[0] !== 'www') { + console.log('Using subdomain:', parts[0]) + return parts[0] // Return subdomain + } + + return null +} + +const subdomain = ref(getSubdomain()) const email = ref('') const password = ref('') const loading = ref(false) @@ -17,12 +43,18 @@ const handleLogin = async () => { loading.value = true error.value = '' + const headers: Record = { + 'Content-Type': 'application/json', + } + + // Only send x-tenant-id if we have a subdomain + if (subdomain.value) { + headers['x-tenant-id'] = subdomain.value + } + const response = await fetch(`${config.public.apiBaseUrl}/api/auth/login`, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-tenant-id': tenantId.value, - }, + headers, body: JSON.stringify({ email: email.value, password: password.value, @@ -36,15 +68,23 @@ const handleLogin = async () => { const data = await response.json() - // Store credentials - localStorage.setItem('tenantId', tenantId.value) + // Store credentials in localStorage + // Store the tenant ID that was used for login + const tenantToStore = subdomain.value || data.user?.tenantId || 'tenant1' + localStorage.setItem('tenantId', tenantToStore) localStorage.setItem('token', data.access_token) localStorage.setItem('user', JSON.stringify(data.user)) + + // Also store token in cookie for server-side auth check + tokenCookie.value = data.access_token + toast.success('Login successful!') + // Redirect to home router.push('/') } catch (e: any) { error.value = e.message || 'Login failed' + toast.error(e.message || 'Login failed') } finally { loading.value = false } @@ -65,10 +105,6 @@ const handleLogin = async () => {
-
- - -
diff --git a/frontend/composables/useApi.ts b/frontend/composables/useApi.ts index 30439a5..1e42a0a 100644 --- a/frontend/composables/useApi.ts +++ b/frontend/composables/useApi.ts @@ -1,5 +1,8 @@ export const useApi = () => { const config = useRuntimeConfig() + const router = useRouter() + const { toast } = useToast() + const { isLoggedIn, logout } = useAuth() // Use current domain for API calls (same subdomain routing) const getApiBaseUrl = () => { @@ -34,13 +37,44 @@ export const useApi = () => { return headers } + const handleResponse = async (response: Response) => { + if (response.status === 401) { + // Unauthorized - not authenticated + if (import.meta.client) { + logout() + toast.error('Your session has expired. Please login again.') + router.push('/login') + } + throw new Error('Unauthorized') + } + + if (response.status === 403) { + // Forbidden - not authorized + if (import.meta.client) { + toast.error('You do not have permission to perform this action.') + // Redirect to home if logged in, otherwise to login + if (isLoggedIn()) { + router.push('/') + } else { + router.push('/login') + } + } + throw new Error('Forbidden') + } + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`) + } + + return response.json() + } + const api = { async get(path: string) { const response = await fetch(`${getApiBaseUrl()}/api${path}`, { headers: getHeaders(), }) - if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`) - return response.json() + return handleResponse(response) }, async post(path: string, data: any) { @@ -49,8 +83,7 @@ export const useApi = () => { headers: getHeaders(), body: JSON.stringify(data), }) - if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`) - return response.json() + return handleResponse(response) }, async put(path: string, data: any) { @@ -59,8 +92,7 @@ export const useApi = () => { headers: getHeaders(), body: JSON.stringify(data), }) - if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`) - return response.json() + return handleResponse(response) }, async delete(path: string) { @@ -68,8 +100,7 @@ export const useApi = () => { method: 'DELETE', headers: getHeaders(), }) - if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`) - return response.json() + return handleResponse(response) }, } diff --git a/frontend/composables/useAuth.ts b/frontend/composables/useAuth.ts new file mode 100644 index 0000000..bfd56d2 --- /dev/null +++ b/frontend/composables/useAuth.ts @@ -0,0 +1,32 @@ +export const useAuth = () => { + const tokenCookie = useCookie('token') + + const isLoggedIn = () => { + if (!import.meta.client) return false + const token = localStorage.getItem('token') + const tenantId = localStorage.getItem('tenantId') + return !!(token && tenantId) + } + + const logout = () => { + if (import.meta.client) { + localStorage.removeItem('token') + localStorage.removeItem('tenantId') + localStorage.removeItem('user') + } + // Clear cookie for server-side check + tokenCookie.value = null + } + + const getUser = () => { + if (!import.meta.client) return null + const userStr = localStorage.getItem('user') + return userStr ? JSON.parse(userStr) : null + } + + return { + isLoggedIn, + logout, + getUser, + } +} diff --git a/frontend/composables/useToast.ts b/frontend/composables/useToast.ts new file mode 100644 index 0000000..1622ea4 --- /dev/null +++ b/frontend/composables/useToast.ts @@ -0,0 +1,20 @@ +import { toast as sonnerToast } from 'vue-sonner' + +export const useToast = () => { + const toast = { + success: (message: string) => { + sonnerToast.success(message) + }, + error: (message: string) => { + sonnerToast.error(message) + }, + info: (message: string) => { + sonnerToast.info(message) + }, + warning: (message: string) => { + sonnerToast.warning(message) + }, + } + + return { toast } +} diff --git a/frontend/middleware/auth.global.ts b/frontend/middleware/auth.global.ts new file mode 100644 index 0000000..401d177 --- /dev/null +++ b/frontend/middleware/auth.global.ts @@ -0,0 +1,38 @@ +export default defineNuxtRouteMiddleware((to, from) => { + // Allow pages to opt-out of auth with definePageMeta({ auth: false }) + if (to.meta.auth === false) { + return + } + + // Public routes that don't require authentication + const publicRoutes = ['/login', '/register'] + + if (publicRoutes.includes(to.path)) { + return + } + + const token = useCookie('token') + const authMessage = useCookie('authMessage') + + // Routes that don't need a toast message (user knows they need to login) + const silentRoutes = ['/'] + + // Check token cookie (works on both server and client) + if (!token.value) { + if (!silentRoutes.includes(to.path)) { + authMessage.value = 'Please login to access this page' + } + return navigateTo('/login') + } + + // On client side, also verify localStorage is in sync + if (import.meta.client) { + const { isLoggedIn } = useAuth() + if (!isLoggedIn()) { + if (!silentRoutes.includes(to.path)) { + authMessage.value = 'Please login to access this page' + } + return navigateTo('/login') + } + } +}) diff --git a/frontend/nuxt.config.ts b/frontend/nuxt.config.ts index c864770..0749a5a 100644 --- a/frontend/nuxt.config.ts +++ b/frontend/nuxt.config.ts @@ -52,7 +52,7 @@ export default defineNuxtConfig({ hmr: { clientPort: 3001, }, - allowedHosts: ['jupiter.routebox.co', 'localhost', '127.0.0.1'], + allowedHosts: ['.routebox.co', 'localhost', '127.0.0.1',], }, }, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ff07748..34ae4a2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -20,7 +20,8 @@ "shadcn-nuxt": "^2.3.3", "tailwind-merge": "^2.2.1", "vue": "^3.4.15", - "vue-router": "^4.2.5" + "vue-router": "^4.2.5", + "vue-sonner": "^1.3.2" }, "devDependencies": { "@nuxtjs/color-mode": "^3.3.2", @@ -16035,6 +16036,12 @@ "vue": "^3.5.0" } }, + "node_modules/vue-sonner": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/vue-sonner/-/vue-sonner-1.3.2.tgz", + "integrity": "sha512-UbZ48E9VIya3ToiRHAZUbodKute/z/M1iT8/3fU8zEbwBRE11AKuHikssv18LMk2gTTr6eMQT4qf6JoLHWuj/A==", + "license": "MIT" + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index f096413..5cd7d7c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -26,7 +26,8 @@ "shadcn-nuxt": "^2.3.3", "tailwind-merge": "^2.2.1", "vue": "^3.4.15", - "vue-router": "^4.2.5" + "vue-router": "^4.2.5", + "vue-sonner": "^1.3.2" }, "devDependencies": { "@nuxtjs/color-mode": "^3.3.2", diff --git a/frontend/pages/index.vue b/frontend/pages/index.vue index 845aa66..de9422e 100644 --- a/frontend/pages/index.vue +++ b/frontend/pages/index.vue @@ -1,3 +1,6 @@ + +