initial: standalone repo from monorepo split
This commit is contained in:
199
src/app/(admin)/users/page.tsx
Normal file
199
src/app/(admin)/users/page.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import api from '@/lib/api';
|
||||
import EditUserModal from '@/components/edit-user-modal';
|
||||
import ResetPasswordModal from '@/components/reset-password-modal';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
tenant: { id: string; name: string; slug: string } | null;
|
||||
roles: { role: string }[];
|
||||
tenantRoles: { tenantRole: { name: string; slug: string } }[];
|
||||
}
|
||||
|
||||
interface TenantOption {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
export default function UsersPage() {
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [tenantId, setTenantId] = useState('');
|
||||
const [tenants, setTenants] = useState<TenantOption[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const limit = 20;
|
||||
|
||||
const [editingUser, setEditingUser] = useState<User | null>(null);
|
||||
const [resettingUser, setResettingUser] = useState<User | null>(null);
|
||||
|
||||
useEffect(() => { loadTenants(); }, []);
|
||||
useEffect(() => { loadUsers(); }, [search, tenantId, page]);
|
||||
|
||||
async function loadTenants() {
|
||||
try {
|
||||
const res = await api.get('/tenants?limit=100');
|
||||
setTenants(res.data.data.items || []);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
|
||||
if (search) params.set('search', search);
|
||||
if (tenantId) params.set('tenantId', tenantId);
|
||||
const res = await api.get(`/users?${params}`);
|
||||
setUsers(res.data.data.items);
|
||||
setTotal(res.data.data.total);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-surface-900">Users</h2>
|
||||
<p className="text-sm text-surface-500">{total} total users across all tenants</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search users..."
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
|
||||
className="px-3 py-2 border border-surface-300 rounded-lg text-sm flex-1 min-w-[200px] max-w-xs"
|
||||
/>
|
||||
<select
|
||||
value={tenantId}
|
||||
onChange={(e) => { setTenantId(e.target.value); setPage(1); }}
|
||||
className="px-3 py-2 border border-surface-300 rounded-lg text-sm"
|
||||
>
|
||||
<option value="">All Tenants</option>
|
||||
{tenants.map((t) => (
|
||||
<option key={t.id} value={t.id}>{t.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-surface-200">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-surface-200 bg-surface-50">
|
||||
<th className="text-left px-4 py-3 font-medium text-surface-600">Name</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-surface-600">Email</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-surface-600">Tenant</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-surface-600">Roles</th>
|
||||
<th className="text-center px-4 py-3 font-medium text-surface-600">Status</th>
|
||||
<th className="text-center px-4 py-3 font-medium text-surface-600">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td colSpan={6} className="text-center py-8 text-surface-400">Loading...</td></tr>
|
||||
) : users.length === 0 ? (
|
||||
<tr><td colSpan={6} className="text-center py-8 text-surface-400">No users found</td></tr>
|
||||
) : (
|
||||
users.map((u) => (
|
||||
<tr key={u.id} className="border-b border-surface-100 hover:bg-surface-50">
|
||||
<td className="px-4 py-3 font-medium">{u.firstName} {u.lastName}</td>
|
||||
<td className="px-4 py-3 text-surface-500">{u.email}</td>
|
||||
<td className="px-4 py-3 text-surface-500">{u.tenant?.name || '—'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{u.roles.map((r, i) => (
|
||||
<span key={i} className="text-xs px-1.5 py-0.5 bg-surface-100 rounded">{r.role}</span>
|
||||
))}
|
||||
{u.tenantRoles.map((tr, i) => (
|
||||
<span key={i} className="text-xs px-1.5 py-0.5 bg-primary-50 text-primary-700 rounded">
|
||||
{tr.tenantRole.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
u.isActive ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'
|
||||
}`}>
|
||||
{u.isActive ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<button
|
||||
onClick={() => setEditingUser(u)}
|
||||
title="Edit user"
|
||||
className="p-1.5 text-surface-400 hover:text-primary-600 hover:bg-primary-50 rounded-lg"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
|
||||
<path d="M11.5 1.5l3 3L5 14H2v-3z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setResettingUser(u)}
|
||||
title="Reset password"
|
||||
className="p-1.5 text-surface-400 hover:text-orange-600 hover:bg-orange-50 rounded-lg"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
|
||||
<rect x="3" y="6" width="10" height="7" rx="1.5" />
|
||||
<path d="M5 6V4.5a3 3 0 016 0V6" />
|
||||
<circle cx="8" cy="9.5" r="1" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{total > limit && (
|
||||
<div className="flex items-center justify-between text-sm text-surface-500">
|
||||
<span>Showing {(page - 1) * limit + 1}-{Math.min(page * limit, total)} of {total}</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setPage(page - 1)} disabled={page === 1}
|
||||
className="px-3 py-1.5 border border-surface-300 rounded-lg disabled:opacity-50"
|
||||
>Previous</button>
|
||||
<button
|
||||
onClick={() => setPage(page + 1)} disabled={page * limit >= total}
|
||||
className="px-3 py-1.5 border border-surface-300 rounded-lg disabled:opacity-50"
|
||||
>Next</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modals */}
|
||||
{editingUser && (
|
||||
<EditUserModal
|
||||
user={editingUser}
|
||||
onClose={() => setEditingUser(null)}
|
||||
onSuccess={loadUsers}
|
||||
/>
|
||||
)}
|
||||
{resettingUser && (
|
||||
<ResetPasswordModal
|
||||
user={resettingUser}
|
||||
onClose={() => setResettingUser(null)}
|
||||
onSuccess={loadUsers}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user