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

@@ -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': {