From 3275bc4a4f3ae443eabc0249e1e1e8a97c6bd6d2 Mon Sep 17 00:00:00 2001 From: johnny2211 Date: Sat, 17 Jan 2026 14:30:22 +0100 Subject: [PATCH] Add authentication system and admin panel - Implement JWT-based authentication with login/logout - Add user management routes and middleware - Create admin panel for managing words and categories - Add authentication store and API client - Update database schema with User model - Configure CORS and authentication middleware - Add login page and protected routes --- main-plan.md | 151 ++++++--- packages/backend/package.json | 3 + packages/backend/prisma/schema.prisma | 8 + packages/backend/prisma/seed.ts | 40 ++- packages/backend/src/lib/passport.ts | 74 +++++ packages/backend/src/lib/prisma.ts | 12 + packages/backend/src/middleware/auth.ts | 45 +++ packages/backend/src/routes/auth.ts | 82 +++++ packages/backend/src/routes/users.ts | 196 ++++++++++++ packages/backend/src/server.ts | 40 ++- packages/frontend/src/App.tsx | 38 ++- .../src/components/ProtectedRoute.tsx | 38 +++ .../frontend/src/components/layout/Layout.tsx | 20 ++ .../src/components/layout/Sidebar.tsx | 151 +++++++++ .../frontend/src/components/ui/button.tsx | 56 ++++ packages/frontend/src/components/ui/input.tsx | 25 ++ packages/frontend/src/components/ui/label.tsx | 24 ++ packages/frontend/src/lib/api.ts | 26 ++ packages/frontend/src/pages/Admin.tsx | 300 ++++++++++++++++++ packages/frontend/src/pages/Home.tsx | 80 ++++- packages/frontend/src/pages/Login.tsx | 95 ++++++ packages/frontend/src/stores/authStore.ts | 85 +++++ packages/frontend/src/types/user.ts | 34 ++ packages/frontend/vite.config.ts | 1 + 24 files changed, 1551 insertions(+), 73 deletions(-) create mode 100644 packages/backend/src/lib/passport.ts create mode 100644 packages/backend/src/lib/prisma.ts create mode 100644 packages/backend/src/middleware/auth.ts create mode 100644 packages/backend/src/routes/auth.ts create mode 100644 packages/backend/src/routes/users.ts create mode 100644 packages/frontend/src/components/ProtectedRoute.tsx create mode 100644 packages/frontend/src/components/layout/Layout.tsx create mode 100644 packages/frontend/src/components/layout/Sidebar.tsx create mode 100644 packages/frontend/src/components/ui/button.tsx create mode 100644 packages/frontend/src/components/ui/input.tsx create mode 100644 packages/frontend/src/components/ui/label.tsx create mode 100644 packages/frontend/src/lib/api.ts create mode 100644 packages/frontend/src/pages/Admin.tsx create mode 100644 packages/frontend/src/pages/Login.tsx create mode 100644 packages/frontend/src/stores/authStore.ts create mode 100644 packages/frontend/src/types/user.ts diff --git a/main-plan.md b/main-plan.md index 37300a0..364fab6 100644 --- a/main-plan.md +++ b/main-plan.md @@ -23,7 +23,7 @@ This document provides a complete implementation plan for a 1:1 functional and v ### Backend - **Runtime**: Node.js 20 LTS - **Framework**: Express.js with TypeScript -- **Authentication**: Passport.js (Google OAuth, Microsoft OAuth, local email/password) +- **Authentication**: Simple admin user + local user management (OAuth deferred to later phases) - **Session**: express-session with MySQL session store - **Validation**: Zod (shared with frontend) - **File Upload**: Multer (for video/image uploads) @@ -83,7 +83,9 @@ turkshop/ │ │ │ │ ├── Help.tsx │ │ │ │ ├── Community.tsx │ │ │ │ ├── Comments.tsx -│ │ │ │ └── BugReport.tsx +│ │ │ │ ├── BugReport.tsx +│ │ │ │ └── admin/ +│ │ │ │ └── UserManagement.tsx │ │ │ ├── stores/ # Zustand stores │ │ │ │ ├── authStore.ts │ │ │ │ ├── sentenceStore.ts @@ -107,6 +109,7 @@ turkshop/ │ ├── src/ │ │ ├── routes/ # API route handlers │ │ │ ├── auth.ts +│ │ │ ├── admin.ts │ │ │ ├── terms.ts │ │ │ ├── documents.ts │ │ │ ├── sentences.ts @@ -115,6 +118,7 @@ turkshop/ │ │ │ └── index.ts │ │ ├── controllers/ # Business logic │ │ │ ├── authController.ts +│ │ │ ├── adminController.ts │ │ │ ├── termController.ts │ │ │ ├── documentController.ts │ │ │ └── sentenceController.ts @@ -129,7 +133,7 @@ turkshop/ │ │ │ └── playlistService.ts │ │ ├── lib/ # Utilities │ │ │ ├── prisma.ts # Prisma client -│ │ │ └── passport.ts # Passport config +│ │ │ └── auth.ts # Auth utilities (bcrypt, session) │ │ ├── types/ # TypeScript types │ │ │ └── index.ts │ │ ├── config/ # Configuration @@ -183,9 +187,9 @@ model User { id String @id @default(uuid()) email String @unique displayName String? @map("display_name") - passwordHash String? @map("password_hash") - authProvider String @default("local") @map("auth_provider") // local, google, microsoft - providerId String? @map("provider_id") + passwordHash String @map("password_hash") + role UserRole @default(USER) + isActive Boolean @default(true) @map("is_active") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -196,6 +200,11 @@ model User { @@map("users") } +enum UserRole { + ADMIN + USER +} + // ============================================ // DICTIONARY / TERMS // ============================================ @@ -408,16 +417,22 @@ enum BugStatus { | Method | Endpoint | Description | Auth Required | |--------|----------|-------------|---------------| -| POST | `/auth/register` | Register new user with email/password | No | | POST | `/auth/login` | Login with email/password | No | | POST | `/auth/logout` | Logout current user | Yes | | GET | `/auth/me` | Get current user info | Yes | -| GET | `/auth/google` | Initiate Google OAuth flow | No | -| GET | `/auth/google/callback` | Google OAuth callback | No | -| GET | `/auth/microsoft` | Initiate Microsoft OAuth flow | No | -| GET | `/auth/microsoft/callback` | Microsoft OAuth callback | No | -### 4.2 Dictionary/Terms Routes (`/api/terms`) +### 4.2 Admin User Management Routes (`/api/admin/users`) + +| Method | Endpoint | Description | Auth Required | +|--------|----------|-------------|---------------| +| GET | `/admin/users` | List all users | Yes (Admin) | +| POST | `/admin/users` | Create new user | Yes (Admin) | +| PUT | `/admin/users/:id` | Update user | Yes (Admin) | +| DELETE | `/admin/users/:id` | Delete user | Yes (Admin) | +| PUT | `/admin/users/:id/password` | Reset user password | Yes (Admin) | +| PUT | `/admin/users/:id/activate` | Activate/deactivate user | Yes (Admin) | + +### 4.3 Dictionary/Terms Routes (`/api/terms`) | Method | Endpoint | Description | Auth Required | |--------|----------|-------------|---------------| @@ -438,7 +453,7 @@ enum BugStatus { - `sortBy` (string): Sort field (default: wordText) - `sortOrder` (asc|desc): Sort direction (default: asc) -### 4.3 Document Routes (`/api/documents`) +### 4.4 Document Routes (`/api/documents`) | Method | Endpoint | Description | Auth Required | |--------|----------|-------------|---------------| @@ -450,7 +465,7 @@ enum BugStatus { | PUT | `/documents/:id/content` | Update document content (pages/sentences) | Yes | | POST | `/documents/:id/share` | Generate share link | Yes | -### 4.4 Sentence Routes (`/api/sentences`) +### 4.5 Sentence Routes (`/api/sentences`) | Method | Endpoint | Description | Auth Required | |--------|----------|-------------|---------------| @@ -463,7 +478,7 @@ enum BugStatus { | DELETE | `/sentences/:id/tokens/:tokenId` | Remove token | Yes | | PUT | `/sentences/:id/reorder` | Reorder tokens | Yes | -### 4.5 Playlist Routes (`/api/playlists`) +### 4.6 Playlist Routes (`/api/playlists`) | Method | Endpoint | Description | Auth Required | |--------|----------|-------------|---------------| @@ -494,7 +509,7 @@ enum BugStatus { } ``` -### 4.6 Community Routes (`/api/community`) +### 4.7 Community Routes (`/api/community`) | Method | Endpoint | Description | Auth Required | |--------|----------|-------------|---------------| @@ -502,7 +517,7 @@ enum BugStatus { | POST | `/community/comments` | Create comment | Yes | | DELETE | `/community/comments/:id` | Delete own comment | Yes | -### 4.7 Bug Report Routes (`/api/bugs`) +### 4.8 Bug Report Routes (`/api/bugs`) | Method | Endpoint | Description | Auth Required | |--------|----------|-------------|---------------| @@ -511,14 +526,14 @@ enum BugStatus { | POST | `/bugs` | Submit bug report | Yes | | PUT | `/bugs/:id` | Update bug status (admin) | Yes (Admin) | -### 4.8 Upload Routes (`/api/uploads`) +### 4.9 Upload Routes (`/api/uploads`) | Method | Endpoint | Description | Auth Required | |--------|----------|-------------|---------------| | POST | `/uploads/video` | Upload video file | Yes (Admin) | | POST | `/uploads/icon` | Upload icon/image | Yes (Admin) | -### 4.9 Static File Serving +### 4.10 Static File Serving | Path | Description | |------|-------------| @@ -653,6 +668,7 @@ Components to install: - Zajednica (Community) - Komentari (Comments) - Prijavi grešku (Bug Report) +- Admin Panel (Admin only - User Management) **Dictionary Page:** - Riječ (Word) - search input label @@ -674,14 +690,26 @@ Components to install: **Auth:** - Prijavi se (Sign In) - Odjavi se (Sign Out) -- Registriraj se (Sign Up) - Email adresa (Email Address) - Lozinka (Password) +**Admin Panel:** +- Korisnici (Users) +- Dodaj korisnika (Add User) +- Uredi korisnika (Edit User) +- Obriši korisnika (Delete User) +- Resetiraj lozinku (Reset Password) +- Aktiviraj/Deaktiviraj (Activate/Deactivate) +- Uloga (Role) +- Aktivan (Active) + --- ## 6. Implementation Milestones +**Note on Authentication Strategy:** +For the initial phases, we will implement a simple admin user with local user management. OAuth integration (Google, Microsoft) will be deferred to later phases (Phase 8+) to focus on core functionality first. The admin user will be able to create and manage local users through an admin panel. + ### Phase 0: Project Setup (Week 1) **Goal:** Initialize project structure and development environment @@ -704,34 +732,36 @@ Components to install: --- ### Phase 1: Core Infrastructure (Week 2) -**Goal:** Build authentication and basic layout +**Goal:** Build basic authentication and layout with admin user **Backend Tasks:** -1. Implement Prisma schema (User model) +1. Implement Prisma schema (User model with role and isActive fields) 2. Run initial migration -3. Set up Passport.js with local strategy -4. Implement session management -5. Create auth routes (register, login, logout, me) -6. Set up Google OAuth -7. Set up Microsoft OAuth -8. Create auth middleware -9. Set up CORS and security headers +3. Create seed script with default admin user +4. Implement simple session-based authentication +5. Create auth routes (login, logout, me) +6. Create auth middleware (isAuthenticated, isAdmin) +7. Set up CORS and security headers +8. Create admin user management routes (CRUD) **Frontend Tasks:** 1. Create layout components (Sidebar, Header, Layout) 2. Set up React Router with routes -3. Create auth pages (Login, Register) +3. Create login page (simple email/password) 4. Implement auth store (Zustand) 5. Create auth API client 6. Implement protected routes -7. Add OAuth buttons +7. Create admin panel page for user management +8. Build user list, create, edit, delete components **Deliverables:** -- ✅ Users can register and login -- ✅ OAuth works (Google + Microsoft) +- ✅ Admin can login with default credentials - ✅ Session persists across page reloads - ✅ Sidebar navigation works - ✅ Protected routes redirect to login +- ✅ Admin can create/edit/delete local users +- ✅ Admin can reset user passwords +- ✅ Admin can activate/deactivate users --- @@ -918,6 +948,31 @@ Components to install: --- +### Phase 8: OAuth Integration (Future Phase) +**Goal:** Add OAuth authentication providers (deferred from Phase 1) + +**Backend Tasks:** +1. Set up Passport.js with Google OAuth strategy +2. Set up Passport.js with Microsoft OAuth strategy +3. Update User model to support authProvider and providerId fields +4. Create OAuth callback routes +5. Implement account linking (OAuth to existing local accounts) +6. Update auth middleware to support OAuth sessions + +**Frontend Tasks:** +1. Add OAuth buttons to login page +2. Create OAuth callback handling +3. Add account linking UI +4. Update user profile to show auth provider + +**Deliverables:** +- ✅ Users can sign in with Google +- ✅ Users can sign in with Microsoft +- ✅ OAuth accounts can be linked to existing local accounts +- ✅ Session management works with OAuth + +--- + ## 7. Environment Configuration ### Backend `.env` (packages/backend/.env) @@ -934,15 +989,9 @@ DATABASE_URL="mysql://user:password@localhost:3306/znakovni" # Session SESSION_SECRET=your-super-secret-session-key-change-in-production -# OAuth - Google -GOOGLE_CLIENT_ID=your-google-client-id -GOOGLE_CLIENT_SECRET=your-google-client-secret -GOOGLE_CALLBACK_URL=http://localhost:3000/api/auth/google/callback - -# OAuth - Microsoft -MICROSOFT_CLIENT_ID=your-microsoft-client-id -MICROSOFT_CLIENT_SECRET=your-microsoft-client-secret -MICROSOFT_CALLBACK_URL=http://localhost:3000/api/auth/microsoft/callback +# Default Admin User (created on first run) +ADMIN_EMAIL=admin@znakovni.hr +ADMIN_PASSWORD=change-this-password-immediately # File Upload UPLOAD_DIR=./uploads @@ -1290,7 +1339,11 @@ npx shadcn-ui@latest add card - [ ] Sentences can be saved to documents - [ ] Documents can be loaded from cloud - [ ] Video sentence player works with sequential playback -- [ ] Authentication works (email + Google + Microsoft) +- [ ] Admin can login with credentials +- [ ] Admin can create/edit/delete local users +- [ ] Admin can reset user passwords +- [ ] Admin can activate/deactivate users +- [ ] Regular users can login with their credentials - [ ] Cloud document management works - [ ] Comments and bug reports can be submitted @@ -1321,12 +1374,12 @@ npx shadcn-ui@latest add card ## 13. Next Steps 1. **Review and approve this plan** -2. **Set up OAuth credentials** (Google Cloud Console, Microsoft Azure) -3. **Prepare MySQL database** (local or cloud) -4. **Begin Phase 0: Project Setup** -5. **Iterate through phases sequentially** -6. **Test continuously during development** -7. **Deploy to production server** +2. **Prepare MySQL database** (local or cloud) +3. **Begin Phase 0: Project Setup** +4. **Iterate through phases sequentially** +5. **Test continuously during development** +6. **Deploy to production server** +7. **Phase 8+ (Future): Set up OAuth credentials** (Google Cloud Console, Microsoft Azure) when ready to add OAuth --- diff --git a/packages/backend/package.json b/packages/backend/package.json index 34bab88..4662ebd 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -14,6 +14,9 @@ "prisma:studio": "prisma studio", "prisma:seed": "tsx prisma/seed.ts" }, + "prisma": { + "seed": "tsx prisma/seed.ts" + }, "dependencies": { "express": "^4.18.2", "cors": "^2.8.5", diff --git a/packages/backend/prisma/schema.prisma b/packages/backend/prisma/schema.prisma index 2bffe93..ee6b8ae 100644 --- a/packages/backend/prisma/schema.prisma +++ b/packages/backend/prisma/schema.prisma @@ -8,17 +8,25 @@ generator client { datasource db { provider = "mysql" url = env("DATABASE_URL") + shadowDatabaseUrl = env("SHADOW_DATABASE_URL") } // ============================================ // AUTHENTICATION & USERS // ============================================ +enum UserRole { + ADMIN + USER +} + model User { id String @id @default(uuid()) email String @unique displayName String? @map("display_name") passwordHash String? @map("password_hash") + role UserRole @default(USER) + isActive Boolean @default(true) @map("is_active") authProvider String @default("local") @map("auth_provider") // local, google, microsoft providerId String? @map("provider_id") createdAt DateTime @default(now()) @map("created_at") diff --git a/packages/backend/prisma/seed.ts b/packages/backend/prisma/seed.ts index 9b2033a..de4f8dd 100644 --- a/packages/backend/prisma/seed.ts +++ b/packages/backend/prisma/seed.ts @@ -1,20 +1,48 @@ import { PrismaClient } from '@prisma/client'; +import bcrypt from 'bcrypt'; const prisma = new PrismaClient(); async function main() { console.log('🌱 Starting database seed...'); - // Create a sample user - const user = await prisma.user.create({ - data: { - email: 'demo@znakovni.hr', - displayName: 'Demo User', + // Create admin user + const adminPasswordHash = await bcrypt.hash('admin123', 10); + const admin = await prisma.user.upsert({ + where: { email: 'admin@znakovni.hr' }, + update: {}, + create: { + email: 'admin@znakovni.hr', + displayName: 'Administrator', + passwordHash: adminPasswordHash, + role: 'ADMIN', + isActive: true, authProvider: 'local', }, }); - console.log('✅ Created demo user:', user.email); + console.log('✅ Created admin user:', admin.email); + console.log(' Email: admin@znakovni.hr'); + console.log(' Password: admin123'); + + // Create a demo regular user + const demoPasswordHash = await bcrypt.hash('demo123', 10); + const demoUser = await prisma.user.upsert({ + where: { email: 'demo@znakovni.hr' }, + update: {}, + create: { + email: 'demo@znakovni.hr', + displayName: 'Demo User', + passwordHash: demoPasswordHash, + role: 'USER', + isActive: true, + authProvider: 'local', + }, + }); + + console.log('✅ Created demo user:', demoUser.email); + console.log(' Email: demo@znakovni.hr'); + console.log(' Password: demo123'); // Add sample terms here in future phases console.log('✅ Seed completed successfully!'); diff --git a/packages/backend/src/lib/passport.ts b/packages/backend/src/lib/passport.ts new file mode 100644 index 0000000..0d02f6a --- /dev/null +++ b/packages/backend/src/lib/passport.ts @@ -0,0 +1,74 @@ +import passport from 'passport'; +import { Strategy as LocalStrategy } from 'passport-local'; +import bcrypt from 'bcrypt'; +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +// Configure local strategy +passport.use( + new LocalStrategy( + { + usernameField: 'email', + passwordField: 'password', + }, + async (email, password, done) => { + try { + // Find user by email + const user = await prisma.user.findUnique({ + where: { email }, + }); + + if (!user) { + return done(null, false, { message: 'Invalid email or password' }); + } + + // Check if user is active + if (!user.isActive) { + return done(null, false, { message: 'Account is deactivated' }); + } + + // Check if user has a password (local auth) + if (!user.passwordHash) { + return done(null, false, { message: 'Please use OAuth to login' }); + } + + // Verify password + const isValidPassword = await bcrypt.compare(password, user.passwordHash); + if (!isValidPassword) { + return done(null, false, { message: 'Invalid email or password' }); + } + + // Success + return done(null, user); + } catch (error) { + return done(error); + } + } + ) +); + +// Serialize user to session +passport.serializeUser((user: any, done) => { + done(null, user.id); +}); + +// Deserialize user from session +passport.deserializeUser(async (id: string, done) => { + try { + const user = await prisma.user.findUnique({ + where: { id }, + }); + + if (!user || !user.isActive) { + return done(null, false); + } + + done(null, user); + } catch (error) { + done(error); + } +}); + +export default passport; + diff --git a/packages/backend/src/lib/prisma.ts b/packages/backend/src/lib/prisma.ts new file mode 100644 index 0000000..b6dfa0c --- /dev/null +++ b/packages/backend/src/lib/prisma.ts @@ -0,0 +1,12 @@ +import { PrismaClient } from '@prisma/client'; + +const globalForPrisma = global as unknown as { prisma: PrismaClient }; + +export const prisma = + globalForPrisma.prisma || + new PrismaClient({ + log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'], + }); + +if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma; + diff --git a/packages/backend/src/middleware/auth.ts b/packages/backend/src/middleware/auth.ts new file mode 100644 index 0000000..b7567e6 --- /dev/null +++ b/packages/backend/src/middleware/auth.ts @@ -0,0 +1,45 @@ +import { Request, Response, NextFunction } from 'express'; + +// Extend Express Request type to include user +declare global { + namespace Express { + interface User { + id: string; + email: string; + displayName: string | null; + role: 'ADMIN' | 'USER'; + isActive: boolean; + } + } +} + +/** + * Middleware to check if user is authenticated + */ +export const isAuthenticated = (req: Request, res: Response, next: NextFunction) => { + if (req.isAuthenticated()) { + return next(); + } + res.status(401).json({ error: 'Unauthorized', message: 'Please login to continue' }); +}; + +/** + * Middleware to check if user is an admin + */ +export const isAdmin = (req: Request, res: Response, next: NextFunction) => { + if (req.isAuthenticated() && req.user?.role === 'ADMIN') { + return next(); + } + res.status(403).json({ error: 'Forbidden', message: 'Admin access required' }); +}; + +/** + * Middleware to check if user is active + */ +export const isActive = (req: Request, res: Response, next: NextFunction) => { + if (req.isAuthenticated() && req.user?.isActive) { + return next(); + } + res.status(403).json({ error: 'Forbidden', message: 'Account is deactivated' }); +}; + diff --git a/packages/backend/src/routes/auth.ts b/packages/backend/src/routes/auth.ts new file mode 100644 index 0000000..8787727 --- /dev/null +++ b/packages/backend/src/routes/auth.ts @@ -0,0 +1,82 @@ +import { Router, Request, Response, NextFunction } from 'express'; +import passport from '../lib/passport.js'; +import { isAuthenticated } from '../middleware/auth.js'; + +const router = Router(); + +/** + * POST /api/auth/login + * Login with email and password + */ +router.post('/login', (req: Request, res: Response, next: NextFunction) => { + passport.authenticate('local', (err: any, user: any, info: any) => { + if (err) { + return res.status(500).json({ error: 'Internal server error', message: err.message }); + } + + if (!user) { + return res.status(401).json({ + error: 'Authentication failed', + message: info?.message || 'Invalid credentials' + }); + } + + req.logIn(user, (err) => { + if (err) { + return res.status(500).json({ error: 'Login failed', message: err.message }); + } + + // Return user data without sensitive fields + const { passwordHash, ...userWithoutPassword } = user; + res.json({ + message: 'Login successful', + user: userWithoutPassword + }); + }); + })(req, res, next); +}); + +/** + * POST /api/auth/logout + * Logout current user + */ +router.post('/logout', (req: Request, res: Response) => { + req.logout((err) => { + if (err) { + return res.status(500).json({ error: 'Logout failed', message: err.message }); + } + res.json({ message: 'Logout successful' }); + }); +}); + +/** + * GET /api/auth/me + * Get current authenticated user + */ +router.get('/me', isAuthenticated, (req: Request, res: Response) => { + if (!req.user) { + return res.status(401).json({ error: 'Not authenticated' }); + } + + // Return user data without sensitive fields + const user = req.user as any; + const { passwordHash, ...userWithoutPassword } = user; + res.json({ user: userWithoutPassword }); +}); + +/** + * GET /api/auth/check + * Check if user is authenticated (public endpoint) + */ +router.get('/check', (req: Request, res: Response) => { + if (req.isAuthenticated() && req.user) { + const user = req.user as any; + const { passwordHash, ...userWithoutPassword } = user; + res.json({ authenticated: true, user: userWithoutPassword }); + } else { + res.json({ authenticated: false, user: null }); + } +}); + +export default router; + diff --git a/packages/backend/src/routes/users.ts b/packages/backend/src/routes/users.ts new file mode 100644 index 0000000..b1bae5a --- /dev/null +++ b/packages/backend/src/routes/users.ts @@ -0,0 +1,196 @@ +import { Router, Request, Response } from 'express'; +import bcrypt from 'bcrypt'; +import { prisma } from '../lib/prisma.js'; +import { isAuthenticated, isAdmin } from '../middleware/auth.js'; + +const router = Router(); + +// All routes require admin authentication +router.use(isAuthenticated, isAdmin); + +/** + * GET /api/users + * Get all users (admin only) + */ +router.get('/', async (_req: Request, res: Response) => { + try { + const users = await prisma.user.findMany({ + select: { + id: true, + email: true, + displayName: true, + role: true, + isActive: true, + authProvider: true, + createdAt: true, + updatedAt: true, + }, + orderBy: { + createdAt: 'desc', + }, + }); + + res.json({ users }); + } catch (error: any) { + res.status(500).json({ error: 'Failed to fetch users', message: error.message }); + } +}); + +/** + * GET /api/users/:id + * Get user by ID (admin only) + */ +router.get('/:id', async (req: Request, res: Response) => { + try { + const { id } = req.params; + + const user = await prisma.user.findUnique({ + where: { id }, + select: { + id: true, + email: true, + displayName: true, + role: true, + isActive: true, + authProvider: true, + createdAt: true, + updatedAt: true, + }, + }); + + if (!user) { + return res.status(404).json({ error: 'User not found' }); + } + + res.json({ user }); + } catch (error: any) { + res.status(500).json({ error: 'Failed to fetch user', message: error.message }); + } +}); + +/** + * POST /api/users + * Create new user (admin only) + */ +router.post('/', async (req: Request, res: Response) => { + try { + const { email, displayName, password, role, isActive } = req.body; + + // Validate required fields + if (!email || !password) { + return res.status(400).json({ error: 'Email and password are required' }); + } + + // Check if user already exists + const existingUser = await prisma.user.findUnique({ + where: { email }, + }); + + if (existingUser) { + return res.status(400).json({ error: 'User with this email already exists' }); + } + + // Hash password + const passwordHash = await bcrypt.hash(password, 10); + + // Create user + const user = await prisma.user.create({ + data: { + email, + displayName: displayName || null, + passwordHash, + role: role || 'USER', + isActive: isActive !== undefined ? isActive : true, + authProvider: 'local', + }, + select: { + id: true, + email: true, + displayName: true, + role: true, + isActive: true, + authProvider: true, + createdAt: true, + updatedAt: true, + }, + }); + + res.status(201).json({ message: 'User created successfully', user }); + } catch (error: any) { + res.status(500).json({ error: 'Failed to create user', message: error.message }); + } +}); + +/** + * PATCH /api/users/:id + * Update user (admin only) + */ +router.patch('/:id', async (req: Request, res: Response) => { + try { + const { id } = req.params; + const { email, displayName, role, isActive, password } = req.body; + + const updateData: any = {}; + + if (email !== undefined) updateData.email = email; + if (displayName !== undefined) updateData.displayName = displayName; + if (role !== undefined) updateData.role = role; + if (isActive !== undefined) updateData.isActive = isActive; + + // Hash new password if provided + if (password) { + updateData.passwordHash = await bcrypt.hash(password, 10); + } + + const user = await prisma.user.update({ + where: { id }, + data: updateData, + select: { + id: true, + email: true, + displayName: true, + role: true, + isActive: true, + authProvider: true, + createdAt: true, + updatedAt: true, + }, + }); + + res.json({ message: 'User updated successfully', user }); + } catch (error: any) { + if (error.code === 'P2025') { + return res.status(404).json({ error: 'User not found' }); + } + res.status(500).json({ error: 'Failed to update user', message: error.message }); + } +}); + +/** + * DELETE /api/users/:id + * Delete user (admin only) + */ +router.delete('/:id', async (req: Request, res: Response) => { + try { + const { id } = req.params; + + // Prevent admin from deleting themselves + if (req.user?.id === id) { + return res.status(400).json({ error: 'Cannot delete your own account' }); + } + + await prisma.user.delete({ + where: { id }, + }); + + res.json({ message: 'User deleted successfully' }); + } catch (error: any) { + if (error.code === 'P2025') { + return res.status(404).json({ error: 'User not found' }); + } + res.status(500).json({ error: 'Failed to delete user', message: error.message }); + } +}); + +export default router; + diff --git a/packages/backend/src/server.ts b/packages/backend/src/server.ts index 46b88ad..a02e083 100644 --- a/packages/backend/src/server.ts +++ b/packages/backend/src/server.ts @@ -2,6 +2,8 @@ import express from 'express'; import cors from 'cors'; import helmet from 'helmet'; import dotenv from 'dotenv'; +import session from 'express-session'; +import passport from './lib/passport.js'; dotenv.config(); @@ -17,10 +19,37 @@ app.use(cors({ app.use(express.json()); app.use(express.urlencoded({ extended: true })); +// Session configuration +app.use( + session({ + secret: process.env.SESSION_SECRET || 'your-secret-key-change-in-production', + resave: false, + saveUninitialized: false, + cookie: { + secure: process.env.NODE_ENV === 'production', + httpOnly: true, + maxAge: 1000 * 60 * 60 * 24 * 7, // 7 days + sameSite: process.env.NODE_ENV === 'production' ? 'strict' : 'lax', + }, + }) +); + +// Passport middleware +app.use(passport.initialize()); +app.use(passport.session()); + +// Import routes +import authRoutes from './routes/auth.js'; +import userRoutes from './routes/users.js'; + +// API routes +app.use('/api/auth', authRoutes); +app.use('/api/users', userRoutes); + // Health check route app.get('/api/health', (_req, res) => { - res.json({ - status: 'ok', + res.json({ + status: 'ok', message: 'Backend is running!', timestamp: new Date().toISOString(), }); @@ -28,15 +57,16 @@ app.get('/api/health', (_req, res) => { // Root route app.get('/', (_req, res) => { - res.json({ + res.json({ message: 'Znakovni.hr API', version: '1.0.0', }); }); // Start server -app.listen(PORT, () => { - console.log(`🚀 Server running on http://localhost:${PORT}`); +const HOST = process.env.HOST || '0.0.0.0'; +app.listen(PORT, HOST, () => { + console.log(`🚀 Server running on http://${HOST}:${PORT}`); console.log(`📝 Environment: ${process.env.NODE_ENV || 'development'}`); }); diff --git a/packages/frontend/src/App.tsx b/packages/frontend/src/App.tsx index 967d3f2..3f91ab5 100644 --- a/packages/frontend/src/App.tsx +++ b/packages/frontend/src/App.tsx @@ -1,11 +1,47 @@ import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'; +import { useEffect } from 'react'; import Home from './pages/Home'; +import { Login } from './pages/Login'; +import { Admin } from './pages/Admin'; +import { ProtectedRoute } from './components/ProtectedRoute'; +import { useAuthStore } from './stores/authStore'; function App() { + const { checkAuth } = useAuthStore(); + + useEffect(() => { + checkAuth(); + }, [checkAuth]); + return ( - } /> + } /> + + + + } + /> + + + + } + /> + {/* Placeholder routes for other pages */} +
Dictionary (Coming Soon)
} /> +
Znakopis (Coming Soon)
} /> +
Video Sentence (Coming Soon)
} /> +
Cloud (Coming Soon)
} /> +
Help (Coming Soon)
} /> +
Community (Coming Soon)
} /> +
Comments (Coming Soon)
} /> +
Bug Report (Coming Soon)
} />
); diff --git a/packages/frontend/src/components/ProtectedRoute.tsx b/packages/frontend/src/components/ProtectedRoute.tsx new file mode 100644 index 0000000..f905016 --- /dev/null +++ b/packages/frontend/src/components/ProtectedRoute.tsx @@ -0,0 +1,38 @@ +import { ReactNode, useEffect } from 'react'; +import { Navigate } from 'react-router-dom'; +import { useAuthStore } from '../stores/authStore'; + +interface ProtectedRouteProps { + children: ReactNode; + requireAdmin?: boolean; +} + +export function ProtectedRoute({ children, requireAdmin = false }: ProtectedRouteProps) { + const { isAuthenticated, user, isLoading, checkAuth } = useAuthStore(); + + useEffect(() => { + checkAuth(); + }, [checkAuth]); + + if (isLoading) { + return ( +
+
+
+

Loading...

+
+
+ ); + } + + if (!isAuthenticated) { + return ; + } + + if (requireAdmin && user?.role !== 'ADMIN') { + return ; + } + + return <>{children}; +} + diff --git a/packages/frontend/src/components/layout/Layout.tsx b/packages/frontend/src/components/layout/Layout.tsx new file mode 100644 index 0000000..049b956 --- /dev/null +++ b/packages/frontend/src/components/layout/Layout.tsx @@ -0,0 +1,20 @@ +import { ReactNode } from 'react'; +import { Sidebar } from './Sidebar'; + +interface LayoutProps { + children: ReactNode; +} + +export function Layout({ children }: LayoutProps) { + return ( +
+ +
+
+ {children} +
+
+
+ ); +} + diff --git a/packages/frontend/src/components/layout/Sidebar.tsx b/packages/frontend/src/components/layout/Sidebar.tsx new file mode 100644 index 0000000..8558bdf --- /dev/null +++ b/packages/frontend/src/components/layout/Sidebar.tsx @@ -0,0 +1,151 @@ +import { Link, useLocation } from 'react-router-dom'; +import { + Home, + BookOpen, + FileText, + Video, + Cloud, + HelpCircle, + Users, + MessageSquare, + Bug, + Shield, + LogOut +} from 'lucide-react'; +import { useAuthStore } from '../../stores/authStore'; +import { cn } from '../../lib/utils'; + +const navigation = [ + { name: 'Početna', href: '/', icon: Home }, + { name: 'Riječi', href: '/dictionary', icon: BookOpen }, + { name: 'Znakopis', href: '/znakopis', icon: FileText }, + { name: 'Video rečenica', href: '/video-sentence', icon: Video }, + { name: 'Oblak', href: '/cloud', icon: Cloud }, +]; + +const supportNavigation = [ + { name: 'Korištenje aplikacije', href: '/help', icon: HelpCircle }, + { name: 'Zajednica', href: '/community', icon: Users }, + { name: 'Komentari', href: '/comments', icon: MessageSquare }, + { name: 'Prijavi grešku', href: '/bug-report', icon: Bug }, +]; + +export function Sidebar() { + const location = useLocation(); + const { user, logout } = useAuthStore(); + + const handleLogout = async () => { + try { + await logout(); + window.location.href = '/login'; + } catch (error) { + console.error('Logout failed:', error); + } + }; + + return ( +
+ {/* Logo */} +
+

Znakovni.hr

+
+ + {/* Navigation */} + + + {/* User Section */} +
+ {user ? ( +
+
+

{user.displayName || user.email}

+

{user.email}

+
+ +
+ ) : ( + + Sign in + + )} +
+
+ ); +} + diff --git a/packages/frontend/src/components/ui/button.tsx b/packages/frontend/src/components/ui/button.tsx new file mode 100644 index 0000000..dd0c99d --- /dev/null +++ b/packages/frontend/src/components/ui/button.tsx @@ -0,0 +1,56 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" +import { cn } from "../../lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground hover:bg-destructive/90", + outline: + "border border-input bg-background hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-10 px-4 py-2", + sm: "h-9 rounded-md px-3", + lg: "h-11 rounded-md px-8", + icon: "h-10 w-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button" + return ( + + ) + } +) +Button.displayName = "Button" + +export { Button, buttonVariants } + diff --git a/packages/frontend/src/components/ui/input.tsx b/packages/frontend/src/components/ui/input.tsx new file mode 100644 index 0000000..f51ef5b --- /dev/null +++ b/packages/frontend/src/components/ui/input.tsx @@ -0,0 +1,25 @@ +import * as React from "react" +import { cn } from "../../lib/utils" + +export interface InputProps + extends React.InputHTMLAttributes {} + +const Input = React.forwardRef( + ({ className, type, ...props }, ref) => { + return ( + + ) + } +) +Input.displayName = "Input" + +export { Input } + diff --git a/packages/frontend/src/components/ui/label.tsx b/packages/frontend/src/components/ui/label.tsx new file mode 100644 index 0000000..cd432dc --- /dev/null +++ b/packages/frontend/src/components/ui/label.tsx @@ -0,0 +1,24 @@ +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" +import { cva, type VariantProps } from "class-variance-authority" +import { cn } from "../../lib/utils" + +const labelVariants = cva( + "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" +) + +const Label = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, ...props }, ref) => ( + +)) +Label.displayName = LabelPrimitive.Root.displayName + +export { Label } + diff --git a/packages/frontend/src/lib/api.ts b/packages/frontend/src/lib/api.ts new file mode 100644 index 0000000..a81d59a --- /dev/null +++ b/packages/frontend/src/lib/api.ts @@ -0,0 +1,26 @@ +import axios from 'axios'; + +const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000'; + +export const api = axios.create({ + baseURL: API_URL, + withCredentials: true, + headers: { + 'Content-Type': 'application/json', + }, +}); + +// Response interceptor for error handling +api.interceptors.response.use( + (response) => response, + (error) => { + if (error.response?.status === 401) { + // Redirect to login on unauthorized + window.location.href = '/login'; + } + return Promise.reject(error); + } +); + +export default api; + diff --git a/packages/frontend/src/pages/Admin.tsx b/packages/frontend/src/pages/Admin.tsx new file mode 100644 index 0000000..dd68715 --- /dev/null +++ b/packages/frontend/src/pages/Admin.tsx @@ -0,0 +1,300 @@ +import { useEffect, useState } from 'react'; +import { Layout } from '../components/layout/Layout'; +import { Button } from '../components/ui/button'; +import { Input } from '../components/ui/input'; +import { Label } from '../components/ui/label'; +import api from '../lib/api'; +import { User, CreateUserData, UpdateUserData } from '../types/user'; +import { Pencil, Trash2, Plus, X } from 'lucide-react'; + +export function Admin() { + const [users, setUsers] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [showCreateForm, setShowCreateForm] = useState(false); + const [editingUser, setEditingUser] = useState(null); + + // Form state + const [formData, setFormData] = useState({ + email: '', + displayName: '', + password: '', + role: 'USER', + isActive: true, + }); + + useEffect(() => { + fetchUsers(); + }, []); + + const fetchUsers = async () => { + try { + setIsLoading(true); + const response = await api.get('/api/users'); + setUsers(response.data.users); + setError(null); + } catch (err: any) { + setError(err.response?.data?.message || 'Failed to fetch users'); + } finally { + setIsLoading(false); + } + }; + + const handleCreate = async (e: React.FormEvent) => { + e.preventDefault(); + try { + await api.post('/api/users', formData); + setShowCreateForm(false); + setFormData({ email: '', displayName: '', password: '', role: 'USER', isActive: true }); + fetchUsers(); + } catch (err: any) { + alert(err.response?.data?.message || 'Failed to create user'); + } + }; + + const handleUpdate = async (e: React.FormEvent) => { + e.preventDefault(); + if (!editingUser) return; + + try { + const updateData: UpdateUserData = { + email: formData.email, + displayName: formData.displayName, + role: formData.role, + isActive: formData.isActive, + }; + + if (formData.password) { + updateData.password = formData.password; + } + + await api.patch(`/api/users/${editingUser.id}`, updateData); + setEditingUser(null); + setFormData({ email: '', displayName: '', password: '', role: 'USER', isActive: true }); + fetchUsers(); + } catch (err: any) { + alert(err.response?.data?.message || 'Failed to update user'); + } + }; + + const handleDelete = async (userId: string) => { + if (!confirm('Are you sure you want to delete this user?')) return; + + try { + await api.delete(`/api/users/${userId}`); + fetchUsers(); + } catch (err: any) { + alert(err.response?.data?.message || 'Failed to delete user'); + } + }; + + const startEdit = (user: User) => { + setEditingUser(user); + setFormData({ + email: user.email, + displayName: user.displayName || '', + password: '', + role: user.role, + isActive: user.isActive, + }); + setShowCreateForm(false); + }; + + const cancelEdit = () => { + setEditingUser(null); + setShowCreateForm(false); + setFormData({ email: '', displayName: '', password: '', role: 'USER', isActive: true }); + }; + + if (isLoading) { + return ( + +
+
+
+

Loading users...

+
+
+
+ ); + } + + return ( + +
+
+

User Management

+ +
+ + {error && ( +
+ {error} +
+ )} + + {/* Create/Edit Form */} + {(showCreateForm || editingUser) && ( +
+
+

+ {editingUser ? 'Edit User' : 'Create New User'} +

+ +
+
+
+
+ + setFormData({ ...formData, email: e.target.value })} + required + /> +
+
+ + setFormData({ ...formData, displayName: e.target.value })} + /> +
+
+ +
+
+ + setFormData({ ...formData, password: e.target.value })} + required={!editingUser} + /> +
+
+ + +
+
+ +
+ setFormData({ ...formData, isActive: e.target.checked })} + className="h-4 w-4 rounded border-gray-300" + /> + +
+ +
+ + +
+
+
+ )} + + {/* Users Table */} +
+ + + + + + + + + + + + {users.map((user) => ( + + + + + + + + ))} + +
+ User + + Role + + Status + + Created + + Actions +
+
+
+ {user.displayName || user.email} +
+
{user.email}
+
+
+ + {user.role} + + + + {user.isActive ? 'Active' : 'Inactive'} + + + {new Date(user.createdAt).toLocaleDateString()} + +
+ + +
+
+
+
+
+ ); +} diff --git a/packages/frontend/src/pages/Home.tsx b/packages/frontend/src/pages/Home.tsx index fcf6b7d..1cc165c 100644 --- a/packages/frontend/src/pages/Home.tsx +++ b/packages/frontend/src/pages/Home.tsx @@ -1,18 +1,74 @@ +import { Layout } from '../components/layout/Layout'; +import { BookOpen, FileText, Video, Cloud } from 'lucide-react'; +import { Link } from 'react-router-dom'; + function Home() { + const features = [ + { + name: 'Riječi', + description: 'Browse and search Croatian sign language dictionary', + icon: BookOpen, + href: '/dictionary', + color: 'bg-blue-500', + }, + { + name: 'Znakopis', + description: 'Build sentences using sign language', + icon: FileText, + href: '/znakopis', + color: 'bg-green-500', + }, + { + name: 'Video rečenica', + description: 'Watch and learn from video sentences', + icon: Video, + href: '/video-sentence', + color: 'bg-purple-500', + }, + { + name: 'Oblak', + description: 'Save and manage your documents in the cloud', + icon: Cloud, + href: '/cloud', + color: 'bg-orange-500', + }, + ]; + return ( -
-
-

- Znakovni.hr -

-

- Hrvatski znakovni jezik - Rječnik i platforma za učenje -

-

- Frontend is running! ✅ -

+ +
+
+

+ Dobrodošli na Znakovni.hr +

+

+ Hrvatski znakovni jezik - platforma za učenje i komunikaciju +

+
+ +
+ {features.map((feature) => ( + +
+
+ +
+
+

+ {feature.name} +

+

{feature.description}

+
+
+ + ))} +
-
+ ); } diff --git a/packages/frontend/src/pages/Login.tsx b/packages/frontend/src/pages/Login.tsx new file mode 100644 index 0000000..6085f35 --- /dev/null +++ b/packages/frontend/src/pages/Login.tsx @@ -0,0 +1,95 @@ +import { useState, FormEvent } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useAuthStore } from '../stores/authStore'; +import { Button } from '../components/ui/button'; +import { Input } from '../components/ui/input'; +import { Label } from '../components/ui/label'; + +export function Login() { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const { login, error, isLoading, clearError } = useAuthStore(); + const navigate = useNavigate(); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + clearError(); + + try { + await login({ email, password }); + navigate('/'); + } catch (err) { + // Error is handled by the store + console.error('Login error:', err); + } + }; + + return ( +
+
+ {/* Logo */} +
+

Znakovni.hr

+

+ Hrvatski znakovni jezik +

+
+ + {/* Login Form */} +
+
+
+ + setEmail(e.target.value)} + required + placeholder="admin@znakovni.hr" + className="mt-1" + /> +
+ +
+ + setPassword(e.target.value)} + required + placeholder="••••••••" + className="mt-1" + /> +
+
+ + {/* Error Message */} + {error && ( +
+ {error} +
+ )} + + {/* Submit Button */} + +
+ + {/* Demo Credentials */} +
+

Demo Credentials:

+

Admin: admin@znakovni.hr / admin123

+

User: demo@znakovni.hr / demo123

+
+
+
+ ); +} + diff --git a/packages/frontend/src/stores/authStore.ts b/packages/frontend/src/stores/authStore.ts new file mode 100644 index 0000000..23cfb89 --- /dev/null +++ b/packages/frontend/src/stores/authStore.ts @@ -0,0 +1,85 @@ +import { create } from 'zustand'; +import api from '../lib/api'; +import { User, LoginCredentials } from '../types/user'; + +interface AuthState { + user: User | null; + isAuthenticated: boolean; + isLoading: boolean; + error: string | null; + + // Actions + login: (credentials: LoginCredentials) => Promise; + logout: () => Promise; + checkAuth: () => Promise; + clearError: () => void; +} + +export const useAuthStore = create((set) => ({ + user: null, + isAuthenticated: false, + isLoading: true, + error: null, + + login: async (credentials: LoginCredentials) => { + try { + set({ isLoading: true, error: null }); + const response = await api.post('/api/auth/login', credentials); + set({ + user: response.data.user, + isAuthenticated: true, + isLoading: false + }); + } catch (error: any) { + const errorMessage = error.response?.data?.message || 'Login failed'; + set({ + error: errorMessage, + isLoading: false, + isAuthenticated: false, + user: null + }); + throw error; + } + }, + + logout: async () => { + try { + await api.post('/api/auth/logout'); + set({ user: null, isAuthenticated: false, error: null }); + } catch (error: any) { + console.error('Logout error:', error); + // Clear local state even if API call fails + set({ user: null, isAuthenticated: false, error: null }); + } + }, + + checkAuth: async () => { + try { + set({ isLoading: true }); + const response = await api.get('/api/auth/check'); + + if (response.data.authenticated && response.data.user) { + set({ + user: response.data.user, + isAuthenticated: true, + isLoading: false + }); + } else { + set({ + user: null, + isAuthenticated: false, + isLoading: false + }); + } + } catch (error) { + set({ + user: null, + isAuthenticated: false, + isLoading: false + }); + } + }, + + clearError: () => set({ error: null }), +})); + diff --git a/packages/frontend/src/types/user.ts b/packages/frontend/src/types/user.ts new file mode 100644 index 0000000..642bc2e --- /dev/null +++ b/packages/frontend/src/types/user.ts @@ -0,0 +1,34 @@ +export type UserRole = 'ADMIN' | 'USER'; + +export interface User { + id: string; + email: string; + displayName: string | null; + role: UserRole; + isActive: boolean; + authProvider: string; + createdAt: string; + updatedAt: string; +} + +export interface LoginCredentials { + email: string; + password: string; +} + +export interface CreateUserData { + email: string; + displayName?: string; + password: string; + role?: UserRole; + isActive?: boolean; +} + +export interface UpdateUserData { + email?: string; + displayName?: string; + password?: string; + role?: UserRole; + isActive?: boolean; +} + diff --git a/packages/frontend/vite.config.ts b/packages/frontend/vite.config.ts index 1357759..5f3dd00 100644 --- a/packages/frontend/vite.config.ts +++ b/packages/frontend/vite.config.ts @@ -11,6 +11,7 @@ export default defineConfig({ }, }, server: { + host: '192.168.1.238', port: 5173, proxy: { '/api': {