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:
2026-01-17 14:30:22 +01:00
parent a11e2acb23
commit 3275bc4a4f
24 changed files with 1551 additions and 73 deletions

View File

@@ -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
---

View File

@@ -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",

View File

@@ -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")

View File

@@ -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!');

View 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;

View 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;

View 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' });
};

View 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;

View 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;

View File

@@ -2,6 +2,8 @@ import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import dotenv from 'dotenv';
import session from 'express-session';
import passport from './lib/passport.js';
dotenv.config();
@@ -17,10 +19,37 @@ app.use(cors({
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Session configuration
app.use(
session({
secret: process.env.SESSION_SECRET || 'your-secret-key-change-in-production',
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === 'production',
httpOnly: true,
maxAge: 1000 * 60 * 60 * 24 * 7, // 7 days
sameSite: process.env.NODE_ENV === 'production' ? 'strict' : 'lax',
},
})
);
// Passport middleware
app.use(passport.initialize());
app.use(passport.session());
// Import routes
import authRoutes from './routes/auth.js';
import userRoutes from './routes/users.js';
// API routes
app.use('/api/auth', authRoutes);
app.use('/api/users', userRoutes);
// Health check route
app.get('/api/health', (_req, res) => {
res.json({
status: 'ok',
res.json({
status: 'ok',
message: 'Backend is running!',
timestamp: new Date().toISOString(),
});
@@ -28,15 +57,16 @@ app.get('/api/health', (_req, res) => {
// Root route
app.get('/', (_req, res) => {
res.json({
res.json({
message: 'Znakovni.hr API',
version: '1.0.0',
});
});
// Start server
app.listen(PORT, () => {
console.log(`🚀 Server running on http://localhost:${PORT}`);
const HOST = process.env.HOST || '0.0.0.0';
app.listen(PORT, HOST, () => {
console.log(`🚀 Server running on http://${HOST}:${PORT}`);
console.log(`📝 Environment: ${process.env.NODE_ENV || 'development'}`);
});

View File

@@ -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>
);

View 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}</>;
}

View 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>
);
}

View 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>
);
}

View 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 }

View 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 }

View 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 }

View 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;

View 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>
);
}

View File

@@ -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">
<div className="text-center">
<h1 className="text-4xl font-bold text-gray-900 mb-4">
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>
<Layout>
<div className="space-y-8">
<div className="text-center">
<h1 className="text-4xl font-bold text-slate-900 mb-4">
Dobrodošli na Znakovni.hr
</h1>
<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>
</div>
</Layout>
);
}

View 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>
);
}

View 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 }),
}));

View 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;
}

View File

@@ -11,6 +11,7 @@ export default defineConfig({
},
},
server: {
host: '192.168.1.238',
port: 5173,
proxy: {
'/api': {