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
This commit is contained in:
151
main-plan.md
151
main-plan.md
@@ -23,7 +23,7 @@ This document provides a complete implementation plan for a 1:1 functional and v
|
|||||||
### Backend
|
### Backend
|
||||||
- **Runtime**: Node.js 20 LTS
|
- **Runtime**: Node.js 20 LTS
|
||||||
- **Framework**: Express.js with TypeScript
|
- **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
|
- **Session**: express-session with MySQL session store
|
||||||
- **Validation**: Zod (shared with frontend)
|
- **Validation**: Zod (shared with frontend)
|
||||||
- **File Upload**: Multer (for video/image uploads)
|
- **File Upload**: Multer (for video/image uploads)
|
||||||
@@ -83,7 +83,9 @@ turkshop/
|
|||||||
│ │ │ │ ├── Help.tsx
|
│ │ │ │ ├── Help.tsx
|
||||||
│ │ │ │ ├── Community.tsx
|
│ │ │ │ ├── Community.tsx
|
||||||
│ │ │ │ ├── Comments.tsx
|
│ │ │ │ ├── Comments.tsx
|
||||||
│ │ │ │ └── BugReport.tsx
|
│ │ │ │ ├── BugReport.tsx
|
||||||
|
│ │ │ │ └── admin/
|
||||||
|
│ │ │ │ └── UserManagement.tsx
|
||||||
│ │ │ ├── stores/ # Zustand stores
|
│ │ │ ├── stores/ # Zustand stores
|
||||||
│ │ │ │ ├── authStore.ts
|
│ │ │ │ ├── authStore.ts
|
||||||
│ │ │ │ ├── sentenceStore.ts
|
│ │ │ │ ├── sentenceStore.ts
|
||||||
@@ -107,6 +109,7 @@ turkshop/
|
|||||||
│ ├── src/
|
│ ├── src/
|
||||||
│ │ ├── routes/ # API route handlers
|
│ │ ├── routes/ # API route handlers
|
||||||
│ │ │ ├── auth.ts
|
│ │ │ ├── auth.ts
|
||||||
|
│ │ │ ├── admin.ts
|
||||||
│ │ │ ├── terms.ts
|
│ │ │ ├── terms.ts
|
||||||
│ │ │ ├── documents.ts
|
│ │ │ ├── documents.ts
|
||||||
│ │ │ ├── sentences.ts
|
│ │ │ ├── sentences.ts
|
||||||
@@ -115,6 +118,7 @@ turkshop/
|
|||||||
│ │ │ └── index.ts
|
│ │ │ └── index.ts
|
||||||
│ │ ├── controllers/ # Business logic
|
│ │ ├── controllers/ # Business logic
|
||||||
│ │ │ ├── authController.ts
|
│ │ │ ├── authController.ts
|
||||||
|
│ │ │ ├── adminController.ts
|
||||||
│ │ │ ├── termController.ts
|
│ │ │ ├── termController.ts
|
||||||
│ │ │ ├── documentController.ts
|
│ │ │ ├── documentController.ts
|
||||||
│ │ │ └── sentenceController.ts
|
│ │ │ └── sentenceController.ts
|
||||||
@@ -129,7 +133,7 @@ turkshop/
|
|||||||
│ │ │ └── playlistService.ts
|
│ │ │ └── playlistService.ts
|
||||||
│ │ ├── lib/ # Utilities
|
│ │ ├── lib/ # Utilities
|
||||||
│ │ │ ├── prisma.ts # Prisma client
|
│ │ │ ├── prisma.ts # Prisma client
|
||||||
│ │ │ └── passport.ts # Passport config
|
│ │ │ └── auth.ts # Auth utilities (bcrypt, session)
|
||||||
│ │ ├── types/ # TypeScript types
|
│ │ ├── types/ # TypeScript types
|
||||||
│ │ │ └── index.ts
|
│ │ │ └── index.ts
|
||||||
│ │ ├── config/ # Configuration
|
│ │ ├── config/ # Configuration
|
||||||
@@ -183,9 +187,9 @@ model User {
|
|||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
email String @unique
|
email String @unique
|
||||||
displayName String? @map("display_name")
|
displayName String? @map("display_name")
|
||||||
passwordHash String? @map("password_hash")
|
passwordHash String @map("password_hash")
|
||||||
authProvider String @default("local") @map("auth_provider") // local, google, microsoft
|
role UserRole @default(USER)
|
||||||
providerId String? @map("provider_id")
|
isActive Boolean @default(true) @map("is_active")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
@@ -196,6 +200,11 @@ model User {
|
|||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum UserRole {
|
||||||
|
ADMIN
|
||||||
|
USER
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// DICTIONARY / TERMS
|
// DICTIONARY / TERMS
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -408,16 +417,22 @@ enum BugStatus {
|
|||||||
|
|
||||||
| Method | Endpoint | Description | Auth Required |
|
| 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/login` | Login with email/password | No |
|
||||||
| POST | `/auth/logout` | Logout current user | Yes |
|
| POST | `/auth/logout` | Logout current user | Yes |
|
||||||
| GET | `/auth/me` | Get current user info | 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 |
|
| Method | Endpoint | Description | Auth Required |
|
||||||
|--------|----------|-------------|---------------|
|
|--------|----------|-------------|---------------|
|
||||||
@@ -438,7 +453,7 @@ enum BugStatus {
|
|||||||
- `sortBy` (string): Sort field (default: wordText)
|
- `sortBy` (string): Sort field (default: wordText)
|
||||||
- `sortOrder` (asc|desc): Sort direction (default: asc)
|
- `sortOrder` (asc|desc): Sort direction (default: asc)
|
||||||
|
|
||||||
### 4.3 Document Routes (`/api/documents`)
|
### 4.4 Document Routes (`/api/documents`)
|
||||||
|
|
||||||
| Method | Endpoint | Description | Auth Required |
|
| Method | Endpoint | Description | Auth Required |
|
||||||
|--------|----------|-------------|---------------|
|
|--------|----------|-------------|---------------|
|
||||||
@@ -450,7 +465,7 @@ enum BugStatus {
|
|||||||
| PUT | `/documents/:id/content` | Update document content (pages/sentences) | Yes |
|
| PUT | `/documents/:id/content` | Update document content (pages/sentences) | Yes |
|
||||||
| POST | `/documents/:id/share` | Generate share link | 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 |
|
| Method | Endpoint | Description | Auth Required |
|
||||||
|--------|----------|-------------|---------------|
|
|--------|----------|-------------|---------------|
|
||||||
@@ -463,7 +478,7 @@ enum BugStatus {
|
|||||||
| DELETE | `/sentences/:id/tokens/:tokenId` | Remove token | Yes |
|
| DELETE | `/sentences/:id/tokens/:tokenId` | Remove token | Yes |
|
||||||
| PUT | `/sentences/:id/reorder` | Reorder tokens | 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 |
|
| 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 |
|
| Method | Endpoint | Description | Auth Required |
|
||||||
|--------|----------|-------------|---------------|
|
|--------|----------|-------------|---------------|
|
||||||
@@ -502,7 +517,7 @@ enum BugStatus {
|
|||||||
| POST | `/community/comments` | Create comment | Yes |
|
| POST | `/community/comments` | Create comment | Yes |
|
||||||
| DELETE | `/community/comments/:id` | Delete own 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 |
|
| Method | Endpoint | Description | Auth Required |
|
||||||
|--------|----------|-------------|---------------|
|
|--------|----------|-------------|---------------|
|
||||||
@@ -511,14 +526,14 @@ enum BugStatus {
|
|||||||
| POST | `/bugs` | Submit bug report | Yes |
|
| POST | `/bugs` | Submit bug report | Yes |
|
||||||
| PUT | `/bugs/:id` | Update bug status (admin) | Yes (Admin) |
|
| 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 |
|
| Method | Endpoint | Description | Auth Required |
|
||||||
|--------|----------|-------------|---------------|
|
|--------|----------|-------------|---------------|
|
||||||
| POST | `/uploads/video` | Upload video file | Yes (Admin) |
|
| POST | `/uploads/video` | Upload video file | Yes (Admin) |
|
||||||
| POST | `/uploads/icon` | Upload icon/image | Yes (Admin) |
|
| POST | `/uploads/icon` | Upload icon/image | Yes (Admin) |
|
||||||
|
|
||||||
### 4.9 Static File Serving
|
### 4.10 Static File Serving
|
||||||
|
|
||||||
| Path | Description |
|
| Path | Description |
|
||||||
|------|-------------|
|
|------|-------------|
|
||||||
@@ -653,6 +668,7 @@ Components to install:
|
|||||||
- Zajednica (Community)
|
- Zajednica (Community)
|
||||||
- Komentari (Comments)
|
- Komentari (Comments)
|
||||||
- Prijavi grešku (Bug Report)
|
- Prijavi grešku (Bug Report)
|
||||||
|
- Admin Panel (Admin only - User Management)
|
||||||
|
|
||||||
**Dictionary Page:**
|
**Dictionary Page:**
|
||||||
- Riječ (Word) - search input label
|
- Riječ (Word) - search input label
|
||||||
@@ -674,14 +690,26 @@ Components to install:
|
|||||||
**Auth:**
|
**Auth:**
|
||||||
- Prijavi se (Sign In)
|
- Prijavi se (Sign In)
|
||||||
- Odjavi se (Sign Out)
|
- Odjavi se (Sign Out)
|
||||||
- Registriraj se (Sign Up)
|
|
||||||
- Email adresa (Email Address)
|
- Email adresa (Email Address)
|
||||||
- Lozinka (Password)
|
- 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
|
## 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)
|
### Phase 0: Project Setup (Week 1)
|
||||||
**Goal:** Initialize project structure and development environment
|
**Goal:** Initialize project structure and development environment
|
||||||
|
|
||||||
@@ -704,34 +732,36 @@ Components to install:
|
|||||||
---
|
---
|
||||||
|
|
||||||
### Phase 1: Core Infrastructure (Week 2)
|
### Phase 1: Core Infrastructure (Week 2)
|
||||||
**Goal:** Build authentication and basic layout
|
**Goal:** Build basic authentication and layout with admin user
|
||||||
|
|
||||||
**Backend Tasks:**
|
**Backend Tasks:**
|
||||||
1. Implement Prisma schema (User model)
|
1. Implement Prisma schema (User model with role and isActive fields)
|
||||||
2. Run initial migration
|
2. Run initial migration
|
||||||
3. Set up Passport.js with local strategy
|
3. Create seed script with default admin user
|
||||||
4. Implement session management
|
4. Implement simple session-based authentication
|
||||||
5. Create auth routes (register, login, logout, me)
|
5. Create auth routes (login, logout, me)
|
||||||
6. Set up Google OAuth
|
6. Create auth middleware (isAuthenticated, isAdmin)
|
||||||
7. Set up Microsoft OAuth
|
7. Set up CORS and security headers
|
||||||
8. Create auth middleware
|
8. Create admin user management routes (CRUD)
|
||||||
9. Set up CORS and security headers
|
|
||||||
|
|
||||||
**Frontend Tasks:**
|
**Frontend Tasks:**
|
||||||
1. Create layout components (Sidebar, Header, Layout)
|
1. Create layout components (Sidebar, Header, Layout)
|
||||||
2. Set up React Router with routes
|
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)
|
4. Implement auth store (Zustand)
|
||||||
5. Create auth API client
|
5. Create auth API client
|
||||||
6. Implement protected routes
|
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:**
|
**Deliverables:**
|
||||||
- ✅ Users can register and login
|
- ✅ Admin can login with default credentials
|
||||||
- ✅ OAuth works (Google + Microsoft)
|
|
||||||
- ✅ Session persists across page reloads
|
- ✅ Session persists across page reloads
|
||||||
- ✅ Sidebar navigation works
|
- ✅ Sidebar navigation works
|
||||||
- ✅ Protected routes redirect to login
|
- ✅ 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
|
## 7. Environment Configuration
|
||||||
|
|
||||||
### Backend `.env` (packages/backend/.env)
|
### Backend `.env` (packages/backend/.env)
|
||||||
@@ -934,15 +989,9 @@ DATABASE_URL="mysql://user:password@localhost:3306/znakovni"
|
|||||||
# Session
|
# Session
|
||||||
SESSION_SECRET=your-super-secret-session-key-change-in-production
|
SESSION_SECRET=your-super-secret-session-key-change-in-production
|
||||||
|
|
||||||
# OAuth - Google
|
# Default Admin User (created on first run)
|
||||||
GOOGLE_CLIENT_ID=your-google-client-id
|
ADMIN_EMAIL=admin@znakovni.hr
|
||||||
GOOGLE_CLIENT_SECRET=your-google-client-secret
|
ADMIN_PASSWORD=change-this-password-immediately
|
||||||
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
|
|
||||||
|
|
||||||
# File Upload
|
# File Upload
|
||||||
UPLOAD_DIR=./uploads
|
UPLOAD_DIR=./uploads
|
||||||
@@ -1290,7 +1339,11 @@ npx shadcn-ui@latest add card
|
|||||||
- [ ] Sentences can be saved to documents
|
- [ ] Sentences can be saved to documents
|
||||||
- [ ] Documents can be loaded from cloud
|
- [ ] Documents can be loaded from cloud
|
||||||
- [ ] Video sentence player works with sequential playback
|
- [ ] 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
|
- [ ] Cloud document management works
|
||||||
- [ ] Comments and bug reports can be submitted
|
- [ ] Comments and bug reports can be submitted
|
||||||
|
|
||||||
@@ -1321,12 +1374,12 @@ npx shadcn-ui@latest add card
|
|||||||
## 13. Next Steps
|
## 13. Next Steps
|
||||||
|
|
||||||
1. **Review and approve this plan**
|
1. **Review and approve this plan**
|
||||||
2. **Set up OAuth credentials** (Google Cloud Console, Microsoft Azure)
|
2. **Prepare MySQL database** (local or cloud)
|
||||||
3. **Prepare MySQL database** (local or cloud)
|
3. **Begin Phase 0: Project Setup**
|
||||||
4. **Begin Phase 0: Project Setup**
|
4. **Iterate through phases sequentially**
|
||||||
5. **Iterate through phases sequentially**
|
5. **Test continuously during development**
|
||||||
6. **Test continuously during development**
|
6. **Deploy to production server**
|
||||||
7. **Deploy to production server**
|
7. **Phase 8+ (Future): Set up OAuth credentials** (Google Cloud Console, Microsoft Azure) when ready to add OAuth
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,9 @@
|
|||||||
"prisma:studio": "prisma studio",
|
"prisma:studio": "prisma studio",
|
||||||
"prisma:seed": "tsx prisma/seed.ts"
|
"prisma:seed": "tsx prisma/seed.ts"
|
||||||
},
|
},
|
||||||
|
"prisma": {
|
||||||
|
"seed": "tsx prisma/seed.ts"
|
||||||
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
|||||||
@@ -8,17 +8,25 @@ generator client {
|
|||||||
datasource db {
|
datasource db {
|
||||||
provider = "mysql"
|
provider = "mysql"
|
||||||
url = env("DATABASE_URL")
|
url = env("DATABASE_URL")
|
||||||
|
shadowDatabaseUrl = env("SHADOW_DATABASE_URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// AUTHENTICATION & USERS
|
// AUTHENTICATION & USERS
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
|
enum UserRole {
|
||||||
|
ADMIN
|
||||||
|
USER
|
||||||
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
email String @unique
|
email String @unique
|
||||||
displayName String? @map("display_name")
|
displayName String? @map("display_name")
|
||||||
passwordHash String? @map("password_hash")
|
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
|
authProvider String @default("local") @map("auth_provider") // local, google, microsoft
|
||||||
providerId String? @map("provider_id")
|
providerId String? @map("provider_id")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|||||||
@@ -1,20 +1,48 @@
|
|||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import bcrypt from 'bcrypt';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
console.log('🌱 Starting database seed...');
|
console.log('🌱 Starting database seed...');
|
||||||
|
|
||||||
// Create a sample user
|
// Create admin user
|
||||||
const user = await prisma.user.create({
|
const adminPasswordHash = await bcrypt.hash('admin123', 10);
|
||||||
data: {
|
const admin = await prisma.user.upsert({
|
||||||
email: 'demo@znakovni.hr',
|
where: { email: 'admin@znakovni.hr' },
|
||||||
displayName: 'Demo User',
|
update: {},
|
||||||
|
create: {
|
||||||
|
email: 'admin@znakovni.hr',
|
||||||
|
displayName: 'Administrator',
|
||||||
|
passwordHash: adminPasswordHash,
|
||||||
|
role: 'ADMIN',
|
||||||
|
isActive: true,
|
||||||
authProvider: 'local',
|
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
|
// Add sample terms here in future phases
|
||||||
console.log('✅ Seed completed successfully!');
|
console.log('✅ Seed completed successfully!');
|
||||||
|
|||||||
74
packages/backend/src/lib/passport.ts
Normal file
74
packages/backend/src/lib/passport.ts
Normal file
@@ -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;
|
||||||
|
|
||||||
12
packages/backend/src/lib/prisma.ts
Normal file
12
packages/backend/src/lib/prisma.ts
Normal file
@@ -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;
|
||||||
|
|
||||||
45
packages/backend/src/middleware/auth.ts
Normal file
45
packages/backend/src/middleware/auth.ts
Normal file
@@ -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' });
|
||||||
|
};
|
||||||
|
|
||||||
82
packages/backend/src/routes/auth.ts
Normal file
82
packages/backend/src/routes/auth.ts
Normal file
@@ -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;
|
||||||
|
|
||||||
196
packages/backend/src/routes/users.ts
Normal file
196
packages/backend/src/routes/users.ts
Normal file
@@ -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;
|
||||||
|
|
||||||
@@ -2,6 +2,8 @@ import express from 'express';
|
|||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import helmet from 'helmet';
|
import helmet from 'helmet';
|
||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
|
import session from 'express-session';
|
||||||
|
import passport from './lib/passport.js';
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
@@ -17,10 +19,37 @@ app.use(cors({
|
|||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(express.urlencoded({ extended: true }));
|
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
|
// Health check route
|
||||||
app.get('/api/health', (_req, res) => {
|
app.get('/api/health', (_req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
message: 'Backend is running!',
|
message: 'Backend is running!',
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
@@ -28,15 +57,16 @@ app.get('/api/health', (_req, res) => {
|
|||||||
|
|
||||||
// Root route
|
// Root route
|
||||||
app.get('/', (_req, res) => {
|
app.get('/', (_req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
message: 'Znakovni.hr API',
|
message: 'Znakovni.hr API',
|
||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Start server
|
// Start server
|
||||||
app.listen(PORT, () => {
|
const HOST = process.env.HOST || '0.0.0.0';
|
||||||
console.log(`🚀 Server running on http://localhost:${PORT}`);
|
app.listen(PORT, HOST, () => {
|
||||||
|
console.log(`🚀 Server running on http://${HOST}:${PORT}`);
|
||||||
console.log(`📝 Environment: ${process.env.NODE_ENV || 'development'}`);
|
console.log(`📝 Environment: ${process.env.NODE_ENV || 'development'}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,47 @@
|
|||||||
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
||||||
|
import { useEffect } from 'react';
|
||||||
import Home from './pages/Home';
|
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() {
|
function App() {
|
||||||
|
const { checkAuth } = useAuthStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkAuth();
|
||||||
|
}, [checkAuth]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Router>
|
<Router>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Home />} />
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route
|
||||||
|
path="/"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<Home />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requireAdmin>
|
||||||
|
<Admin />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{/* Placeholder routes for other pages */}
|
||||||
|
<Route path="/dictionary" element={<ProtectedRoute><div>Dictionary (Coming Soon)</div></ProtectedRoute>} />
|
||||||
|
<Route path="/znakopis" element={<ProtectedRoute><div>Znakopis (Coming Soon)</div></ProtectedRoute>} />
|
||||||
|
<Route path="/video-sentence" element={<ProtectedRoute><div>Video Sentence (Coming Soon)</div></ProtectedRoute>} />
|
||||||
|
<Route path="/cloud" element={<ProtectedRoute><div>Cloud (Coming Soon)</div></ProtectedRoute>} />
|
||||||
|
<Route path="/help" element={<ProtectedRoute><div>Help (Coming Soon)</div></ProtectedRoute>} />
|
||||||
|
<Route path="/community" element={<ProtectedRoute><div>Community (Coming Soon)</div></ProtectedRoute>} />
|
||||||
|
<Route path="/comments" element={<ProtectedRoute><div>Comments (Coming Soon)</div></ProtectedRoute>} />
|
||||||
|
<Route path="/bug-report" element={<ProtectedRoute><div>Bug Report (Coming Soon)</div></ProtectedRoute>} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Router>
|
</Router>
|
||||||
);
|
);
|
||||||
|
|||||||
38
packages/frontend/src/components/ProtectedRoute.tsx
Normal file
38
packages/frontend/src/components/ProtectedRoute.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="flex h-screen items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-4 border-blue-600 border-t-transparent mx-auto"></div>
|
||||||
|
<p className="mt-4 text-slate-600">Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requireAdmin && user?.role !== 'ADMIN') {
|
||||||
|
return <Navigate to="/" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
20
packages/frontend/src/components/layout/Layout.tsx
Normal file
20
packages/frontend/src/components/layout/Layout.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { ReactNode } from 'react';
|
||||||
|
import { Sidebar } from './Sidebar';
|
||||||
|
|
||||||
|
interface LayoutProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Layout({ children }: LayoutProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen bg-slate-50">
|
||||||
|
<Sidebar />
|
||||||
|
<main className="flex-1 overflow-y-auto">
|
||||||
|
<div className="container mx-auto p-6 max-w-7xl">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
151
packages/frontend/src/components/layout/Sidebar.tsx
Normal file
151
packages/frontend/src/components/layout/Sidebar.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="flex h-screen w-60 flex-col bg-slate-800 text-white">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="flex h-16 items-center px-6 border-b border-slate-700">
|
||||||
|
<h1 className="text-xl font-bold">Znakovni.hr</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<nav className="flex-1 space-y-1 px-3 py-4 overflow-y-auto">
|
||||||
|
{/* Main Navigation */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
{navigation.map((item) => {
|
||||||
|
const isActive = location.pathname === item.href;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.name}
|
||||||
|
to={item.href}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
||||||
|
isActive
|
||||||
|
? 'bg-slate-700 text-white'
|
||||||
|
: 'text-slate-300 hover:bg-slate-700 hover:text-white'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<item.icon className="h-5 w-5" />
|
||||||
|
{item.name}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Support Section */}
|
||||||
|
<div className="pt-6">
|
||||||
|
<h3 className="px-3 text-xs font-semibold uppercase tracking-wider text-slate-400 mb-2">
|
||||||
|
Portal za podršku
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{supportNavigation.map((item) => {
|
||||||
|
const isActive = location.pathname === item.href;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.name}
|
||||||
|
to={item.href}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
||||||
|
isActive
|
||||||
|
? 'bg-slate-700 text-white'
|
||||||
|
: 'text-slate-300 hover:bg-slate-700 hover:text-white'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<item.icon className="h-5 w-5" />
|
||||||
|
{item.name}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Admin Panel (only for admins) */}
|
||||||
|
{user?.role === 'ADMIN' && (
|
||||||
|
<div className="pt-6">
|
||||||
|
<Link
|
||||||
|
to="/admin"
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
||||||
|
location.pathname === '/admin'
|
||||||
|
? 'bg-slate-700 text-white'
|
||||||
|
: 'text-slate-300 hover:bg-slate-700 hover:text-white'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Shield className="h-5 w-5" />
|
||||||
|
Admin Panel
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* User Section */}
|
||||||
|
<div className="border-t border-slate-700 p-4">
|
||||||
|
{user ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="px-3 py-2">
|
||||||
|
<p className="text-sm font-medium text-white">{user.displayName || user.email}</p>
|
||||||
|
<p className="text-xs text-slate-400">{user.email}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm font-medium text-slate-300 hover:bg-slate-700 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<LogOut className="h-5 w-5" />
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
to="/login"
|
||||||
|
className="flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium text-slate-300 hover:bg-slate-700 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
56
packages/frontend/src/components/ui/button.tsx
Normal file
56
packages/frontend/src/components/ui/button.tsx
Normal file
@@ -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<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Button.displayName = "Button"
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
|
|
||||||
25
packages/frontend/src/components/ui/input.tsx
Normal file
25
packages/frontend/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cn } from "../../lib/utils"
|
||||||
|
|
||||||
|
export interface InputProps
|
||||||
|
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Input.displayName = "Input"
|
||||||
|
|
||||||
|
export { Input }
|
||||||
|
|
||||||
24
packages/frontend/src/components/ui/label.tsx
Normal file
24
packages/frontend/src/components/ui/label.tsx
Normal file
@@ -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<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||||
|
VariantProps<typeof labelVariants>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(labelVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Label.displayName = LabelPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Label }
|
||||||
|
|
||||||
26
packages/frontend/src/lib/api.ts
Normal file
26
packages/frontend/src/lib/api.ts
Normal file
@@ -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;
|
||||||
|
|
||||||
300
packages/frontend/src/pages/Admin.tsx
Normal file
300
packages/frontend/src/pages/Admin.tsx
Normal file
@@ -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<User[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||||
|
const [editingUser, setEditingUser] = useState<User | null>(null);
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
const [formData, setFormData] = useState<CreateUserData>({
|
||||||
|
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 (
|
||||||
|
<Layout>
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-4 border-blue-600 border-t-transparent mx-auto"></div>
|
||||||
|
<p className="mt-4 text-slate-600">Loading users...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-3xl font-bold text-slate-900">User Management</h1>
|
||||||
|
<Button onClick={() => setShowCreateForm(true)} disabled={showCreateForm || !!editingUser}>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Create User
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md bg-red-50 p-4 text-sm text-red-800">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create/Edit Form */}
|
||||||
|
{(showCreateForm || editingUser) && (
|
||||||
|
<div className="rounded-lg bg-white p-6 shadow">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-xl font-semibold">
|
||||||
|
{editingUser ? 'Edit User' : 'Create New User'}
|
||||||
|
</h2>
|
||||||
|
<Button variant="ghost" size="icon" onClick={cancelEdit}>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={editingUser ? handleUpdate : handleCreate} className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="email">Email *</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={formData.email}
|
||||||
|
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="displayName">Display Name</Label>
|
||||||
|
<Input
|
||||||
|
id="displayName"
|
||||||
|
value={formData.displayName}
|
||||||
|
onChange={(e) => setFormData({ ...formData, displayName: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="password">Password {editingUser && '(leave blank to keep current)'}</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={formData.password}
|
||||||
|
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||||
|
required={!editingUser}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="role">Role</Label>
|
||||||
|
<select
|
||||||
|
id="role"
|
||||||
|
value={formData.role}
|
||||||
|
onChange={(e) => setFormData({ ...formData, role: e.target.value as 'ADMIN' | 'USER' })}
|
||||||
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<option value="USER">User</option>
|
||||||
|
<option value="ADMIN">Admin</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="isActive"
|
||||||
|
checked={formData.isActive}
|
||||||
|
onChange={(e) => setFormData({ ...formData, isActive: e.target.checked })}
|
||||||
|
className="h-4 w-4 rounded border-gray-300"
|
||||||
|
/>
|
||||||
|
<Label htmlFor="isActive" className="cursor-pointer">Active</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button type="submit">
|
||||||
|
{editingUser ? 'Update User' : 'Create User'}
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" onClick={cancelEdit}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Users Table */}
|
||||||
|
<div className="rounded-lg bg-white shadow overflow-hidden">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
User
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Role
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Status
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Created
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
|
{users.map((user) => (
|
||||||
|
<tr key={user.id}>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-gray-900">
|
||||||
|
{user.displayName || user.email}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-500">{user.email}</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
|
||||||
|
user.role === 'ADMIN'
|
||||||
|
? 'bg-purple-100 text-purple-800'
|
||||||
|
: 'bg-gray-100 text-gray-800'
|
||||||
|
}`}>
|
||||||
|
{user.role}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
|
||||||
|
user.isActive
|
||||||
|
? 'bg-green-100 text-green-800'
|
||||||
|
: 'bg-red-100 text-red-800'
|
||||||
|
}`}>
|
||||||
|
{user.isActive ? 'Active' : 'Inactive'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
{new Date(user.createdAt).toLocaleDateString()}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => startEdit(user)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDelete(user.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 text-red-600" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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() {
|
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 (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100">
|
<Layout>
|
||||||
<div className="text-center">
|
<div className="space-y-8">
|
||||||
<h1 className="text-4xl font-bold text-gray-900 mb-4">
|
<div className="text-center">
|
||||||
Znakovni.hr
|
<h1 className="text-4xl font-bold text-slate-900 mb-4">
|
||||||
</h1>
|
Dobrodošli na Znakovni.hr
|
||||||
<p className="text-xl text-gray-600">
|
</h1>
|
||||||
Hrvatski znakovni jezik - Rječnik i platforma za učenje
|
<p className="text-lg text-slate-600">
|
||||||
</p>
|
Hrvatski znakovni jezik - platforma za učenje i komunikaciju
|
||||||
<p className="text-sm text-gray-500 mt-4">
|
</p>
|
||||||
Frontend is running! ✅
|
</div>
|
||||||
</p>
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mt-12">
|
||||||
|
{features.map((feature) => (
|
||||||
|
<Link
|
||||||
|
key={feature.name}
|
||||||
|
to={feature.href}
|
||||||
|
className="block p-6 bg-white rounded-lg shadow hover:shadow-lg transition-shadow"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className={`p-3 rounded-lg ${feature.color}`}>
|
||||||
|
<feature.icon className="h-8 w-8 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-semibold text-slate-900">
|
||||||
|
{feature.name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-slate-600 mt-1">{feature.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
95
packages/frontend/src/pages/Login.tsx
Normal file
95
packages/frontend/src/pages/Login.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-slate-50">
|
||||||
|
<div className="w-full max-w-md space-y-8 rounded-lg bg-white p-8 shadow-lg">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-3xl font-bold text-slate-900">Znakovni.hr</h1>
|
||||||
|
<p className="mt-2 text-sm text-slate-600">
|
||||||
|
Hrvatski znakovni jezik
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Login Form */}
|
||||||
|
<form onSubmit={handleSubmit} className="mt-8 space-y-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="email">Email</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="admin@znakovni.hr"
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="password">Password</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="••••••••"
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Message */}
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md bg-red-50 p-3 text-sm text-red-800">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Submit Button */}
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="w-full"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Signing in...' : 'Sign in'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Demo Credentials */}
|
||||||
|
<div className="mt-4 rounded-md bg-blue-50 p-4 text-sm text-blue-800">
|
||||||
|
<p className="font-semibold">Demo Credentials:</p>
|
||||||
|
<p className="mt-1">Admin: admin@znakovni.hr / admin123</p>
|
||||||
|
<p>User: demo@znakovni.hr / demo123</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
85
packages/frontend/src/stores/authStore.ts
Normal file
85
packages/frontend/src/stores/authStore.ts
Normal file
@@ -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<void>;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
checkAuth: () => Promise<void>;
|
||||||
|
clearError: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuthStore = create<AuthState>((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 }),
|
||||||
|
}));
|
||||||
|
|
||||||
34
packages/frontend/src/types/user.ts
Normal file
34
packages/frontend/src/types/user.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -11,6 +11,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
|
host: '192.168.1.238',
|
||||||
port: 5173,
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
|
|||||||
Reference in New Issue
Block a user