Add i18n foundation (fa-IR/en-US) and localize landing + auth.
Introduce path-prefixed locale routing under app/[lang] with a middleware that detects locale from cookie/Accept-Language (default fa-IR) and redirects. Add fa-IR (source of truth) and en-US dictionaries, a server getDictionary, a client I18nProvider/useT, locale-aware Link + router helpers, and a language switcher. The root [lang] layout sets html lang/dir and the per-locale font (Peyda for fa, Inter for en). Landing sections and the login/register/auth shell now read all copy from the dictionaries; dashboard localization follows in a later commit. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,397 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import api from '@/lib/api';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
import { toast } from 'react-toastify';
|
||||
import type { AdminUser } from '@/types';
|
||||
import { Users, Search, X, Clock, KeyRound } from 'lucide-react';
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const currentUser = useAuthStore((s) => s.user);
|
||||
const isAdmin = currentUser?.role === 'admin';
|
||||
const canStaffResetPassword =
|
||||
currentUser?.role === 'admin' || currentUser?.role === 'technical';
|
||||
const canResetPasswordFor = (user: AdminUser) => {
|
||||
if (!canStaffResetPassword) return false;
|
||||
if (currentUser?.role === 'admin') return true;
|
||||
return user.role === 'user';
|
||||
};
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [pwdModalUser, setPwdModalUser] = useState<AdminUser | null>(null);
|
||||
const [pwdModalPassword, setPwdModalPassword] = useState('');
|
||||
const [form, setForm] = useState({
|
||||
email: '',
|
||||
password: '',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
role: 'user' as 'user' | 'admin' | 'technical' | 'sales',
|
||||
});
|
||||
|
||||
const { data: users = [], isLoading } = useQuery<AdminUser[]>({
|
||||
queryKey: ['admin-users', search],
|
||||
queryFn: () =>
|
||||
api.get('/users', { params: search ? { search } : {} }).then((r) => r.data),
|
||||
});
|
||||
|
||||
const createUser = useMutation({
|
||||
mutationFn: (data: typeof form) => api.post('/users', data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
|
||||
toast.success('User created successfully');
|
||||
setShowForm(false);
|
||||
setForm({ email: '', password: '', firstName: '', lastName: '', role: 'user' });
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(err?.response?.data?.message || 'Failed to create user');
|
||||
},
|
||||
});
|
||||
|
||||
const toggleActive = useMutation({
|
||||
mutationFn: ({ id, isActive }: { id: string; isActive: boolean }) =>
|
||||
api.patch(`/users/${id}/${isActive ? 'deactivate' : 'activate'}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
|
||||
toast.success('User updated');
|
||||
},
|
||||
});
|
||||
|
||||
const changeRole = useMutation({
|
||||
mutationFn: ({ id, role }: { id: string; role: string }) =>
|
||||
api.patch(`/users/${id}/role`, { role }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
|
||||
toast.success('Role updated');
|
||||
},
|
||||
});
|
||||
|
||||
const resetPassword = useMutation({
|
||||
mutationFn: ({ id, password }: { id: string; password: string }) =>
|
||||
api.patch(`/users/${id}/password`, { password }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
|
||||
toast.success('Password updated');
|
||||
setPwdModalUser(null);
|
||||
setPwdModalPassword('');
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const msg =
|
||||
err && typeof err === 'object' && 'response' in err
|
||||
? (err as { response?: { data?: { message?: string } } }).response?.data?.message
|
||||
: undefined;
|
||||
toast.error(typeof msg === 'string' ? msg : 'Failed to update password');
|
||||
},
|
||||
});
|
||||
|
||||
const openPwdModal = (user: AdminUser) => {
|
||||
setPwdModalUser(user);
|
||||
setPwdModalPassword('');
|
||||
};
|
||||
|
||||
const closePwdModal = () => {
|
||||
if (resetPassword.isPending) return;
|
||||
setPwdModalUser(null);
|
||||
setPwdModalPassword('');
|
||||
};
|
||||
|
||||
const submitPwdModal = () => {
|
||||
if (!pwdModalUser || pwdModalPassword.length < 8) return;
|
||||
resetPassword.mutate({ id: pwdModalUser.id, password: pwdModalPassword });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">User Management</h1>
|
||||
<p className="page-subtitle">{users.length} user{users.length !== 1 ? 's' : ''} registered</p>
|
||||
</div>
|
||||
<button onClick={() => setShowForm(!showForm)} className={showForm ? 'btn-ghost' : 'btn-primary'}>
|
||||
{showForm ? <><X className="w-4 h-4 inline" /> Cancel</> : '+ Add User'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Create user form */}
|
||||
{showForm && (
|
||||
<div className="card space-y-4 animate-slide-up">
|
||||
<h2 className="text-lg font-semibold text-gray-900">Create New User</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">First Name</label>
|
||||
<input
|
||||
className="input-field"
|
||||
placeholder="John"
|
||||
value={form.firstName}
|
||||
onChange={(e) => setForm({ ...form, firstName: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Last Name</label>
|
||||
<input
|
||||
className="input-field"
|
||||
placeholder="Doe"
|
||||
value={form.lastName}
|
||||
onChange={(e) => setForm({ ...form, lastName: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
|
||||
<input
|
||||
className="input-field"
|
||||
type="email"
|
||||
placeholder="john@example.com"
|
||||
value={form.email}
|
||||
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Password</label>
|
||||
<input
|
||||
className="input-field"
|
||||
type="password"
|
||||
placeholder="Min 8 characters"
|
||||
value={form.password}
|
||||
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Role</label>
|
||||
<select
|
||||
className="input-field w-full sm:w-48"
|
||||
value={form.role}
|
||||
onChange={(e) => setForm({ ...form, role: e.target.value as typeof form.role })}
|
||||
>
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="technical">Technical</option>
|
||||
<option value="sales">Sales</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => createUser.mutate(form)}
|
||||
disabled={!form.email || !form.password || !form.firstName || !form.lastName || createUser.isPending}
|
||||
className="btn-primary disabled:opacity-50"
|
||||
>
|
||||
{createUser.isPending ? <><Clock className="w-4 h-4 inline animate-spin" /> Creating...</> : 'Create User'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search bar */}
|
||||
<div className="relative">
|
||||
<input
|
||||
className="input-field pl-10 w-full"
|
||||
placeholder="Search by name or email..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{[1,2,3].map(i => (
|
||||
<div key={i} className="card flex items-center gap-4">
|
||||
<div className="skeleton w-10 h-10 rounded-full" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="skeleton h-4 w-32" />
|
||||
<div className="skeleton h-3 w-48" />
|
||||
</div>
|
||||
<div className="skeleton h-6 w-16 rounded-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : users.length === 0 ? (
|
||||
<div className="card text-center py-16">
|
||||
<Users className="w-12 h-12 mx-auto text-gray-300 mb-4" />
|
||||
<p className="text-gray-600 font-medium">{search ? 'No users found matching your search.' : 'No users yet.'}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Desktop Table */}
|
||||
<div className="hidden lg:block table-wrapper">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50/80">
|
||||
<tr>
|
||||
<th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">User</th>
|
||||
<th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Email</th>
|
||||
<th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Role</th>
|
||||
<th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Status</th>
|
||||
<th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Apps</th>
|
||||
<th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Created</th>
|
||||
<th className="px-6 py-3.5 text-right text-xs font-semibold text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{users.map((user) => (
|
||||
<tr key={user.id} className="hover:bg-gray-50/50 transition-colors">
|
||||
<td className="px-6 py-4 text-sm font-medium text-gray-900">
|
||||
{user.firstName} {user.lastName}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">{user.email}</td>
|
||||
<td className="px-6 py-4">
|
||||
{isAdmin ? (
|
||||
<select
|
||||
className="text-sm border border-gray-200 rounded-lg px-2.5 py-1.5 bg-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
value={user.role}
|
||||
onChange={(e) => changeRole.mutate({ id: user.id, role: e.target.value })}
|
||||
>
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="technical">Technical</option>
|
||||
<option value="sales">Sales</option>
|
||||
</select>
|
||||
) : (
|
||||
<span className={`badge ${
|
||||
user.role === 'admin' ? 'badge-purple' :
|
||||
user.role === 'technical' ? 'bg-blue-100 text-blue-700 px-2 py-0.5 rounded-full text-xs font-medium' :
|
||||
user.role === 'sales' ? 'bg-green-100 text-green-700 px-2 py-0.5 rounded-full text-xs font-medium' :
|
||||
'badge-gray'
|
||||
}`}>{user.role}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`badge ${user.isActive ? 'badge-green' : 'badge-red'}`}>
|
||||
{user.isActive ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className="badge badge-blue">{user.appCount ?? 0}</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">
|
||||
{new Date(user.createdAt).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<div className="flex flex-wrap items-center justify-end gap-x-3 gap-y-1">
|
||||
{canResetPasswordFor(user) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openPwdModal(user)}
|
||||
className="inline-flex items-center gap-1 text-sm font-medium text-primary-600 hover:text-primary-800"
|
||||
>
|
||||
<KeyRound className="w-3.5 h-3.5" /> Password
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleActive.mutate({ id: user.id, isActive: user.isActive })}
|
||||
className={`text-sm font-medium ${user.isActive ? 'text-red-600 hover:text-red-800' : 'text-green-600 hover:text-green-800'}`}
|
||||
>
|
||||
{user.isActive ? 'Deactivate' : 'Activate'}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Mobile Cards */}
|
||||
<div className="lg:hidden grid gap-3">
|
||||
{users.map((user) => (
|
||||
<div key={user.id} className="card space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900">{user.firstName} {user.lastName}</h3>
|
||||
<p className="text-sm text-gray-500">{user.email}</p>
|
||||
</div>
|
||||
<span className={`badge ${user.isActive ? 'badge-green' : 'badge-red'}`}>
|
||||
{user.isActive ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-gray-500">
|
||||
<span className="badge badge-blue">{user.appCount ?? 0} apps</span>
|
||||
<span className={`badge ${
|
||||
user.role === 'admin' ? 'badge-purple' :
|
||||
user.role === 'technical' ? 'bg-blue-100 text-blue-700 px-2 py-0.5 rounded-full text-xs font-medium' :
|
||||
user.role === 'sales' ? 'bg-green-100 text-green-700 px-2 py-0.5 rounded-full text-xs font-medium' :
|
||||
'badge-gray'
|
||||
}`}>{user.role}</span>
|
||||
<span>{new Date(user.createdAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 pt-2 border-t border-gray-100">
|
||||
{isAdmin ? (
|
||||
<select
|
||||
className="text-sm border border-gray-200 rounded-lg px-2.5 py-1.5 bg-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
value={user.role}
|
||||
onChange={(e) => changeRole.mutate({ id: user.id, role: e.target.value })}
|
||||
>
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="technical">Technical</option>
|
||||
<option value="sales">Sales</option>
|
||||
</select>
|
||||
) : (
|
||||
<span className="text-sm text-gray-500 capitalize">{user.role}</span>
|
||||
)}
|
||||
{canResetPasswordFor(user) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openPwdModal(user)}
|
||||
className="btn-secondary text-xs inline-flex items-center gap-1"
|
||||
>
|
||||
<KeyRound className="w-3.5 h-3.5" /> Password
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleActive.mutate({ id: user.id, isActive: user.isActive })}
|
||||
className={`text-sm font-medium ${user.isActive ? 'text-red-600' : 'text-green-600'}`}
|
||||
>
|
||||
{user.isActive ? 'Deactivate' : 'Activate'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{pwdModalUser && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||
role="presentation"
|
||||
onClick={(e) => e.target === e.currentTarget && closePwdModal()}
|
||||
>
|
||||
<div className="bg-white rounded-xl shadow-xl max-w-md w-full p-6 space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Set password</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
New password for{' '}
|
||||
<strong>
|
||||
{pwdModalUser.firstName} {pwdModalUser.lastName}
|
||||
</strong>{' '}
|
||||
({pwdModalUser.email})
|
||||
</p>
|
||||
<input
|
||||
type="password"
|
||||
className="input-field w-full"
|
||||
placeholder="Min 8 characters"
|
||||
value={pwdModalPassword}
|
||||
onChange={(e) => setPwdModalPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button type="button" className="btn-ghost" onClick={closePwdModal}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary disabled:opacity-50"
|
||||
disabled={pwdModalPassword.length < 8 || resetPassword.isPending}
|
||||
onClick={submitPwdModal}
|
||||
>
|
||||
{resetPassword.isPending ? 'Saving...' : 'Save password'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user