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
|
||||
- **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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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!');
|
||||
|
||||
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 helmet from 'helmet';
|
||||
import dotenv from 'dotenv';
|
||||
import session from 'express-session';
|
||||
import passport from './lib/passport.js';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
@@ -17,6 +19,33 @@ 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({
|
||||
@@ -35,8 +64,9 @@ app.get('/', (_req, res) => {
|
||||
});
|
||||
|
||||
// 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'}`);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<Router>
|
||||
<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>
|
||||
</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() {
|
||||
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 (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100">
|
||||
<Layout>
|
||||
<div className="space-y-8">
|
||||
<div className="text-center">
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-4">
|
||||
Znakovni.hr
|
||||
<h1 className="text-4xl font-bold text-slate-900 mb-4">
|
||||
Dobrodošli na Znakovni.hr
|
||||
</h1>
|
||||
<p className="text-xl text-gray-600">
|
||||
Hrvatski znakovni jezik - Rječnik i platforma za učenje
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mt-4">
|
||||
Frontend is running! ✅
|
||||
<p className="text-lg text-slate-600">
|
||||
Hrvatski znakovni jezik - platforma za učenje i komunikaciju
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</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: {
|
||||
host: '192.168.1.238',
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
|
||||
Reference in New Issue
Block a user