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:
@@ -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">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user