Skip to content

Commit eb37e2c

Browse files
committed
feat: Phase 19 — User Management UI (invite, roles, activate/deactivate)
Adds full user management for admin/super-admin users: view tenant users, invite new users with role assignment, change roles, toggle active/inactive status, and remove users. Includes migration for is_active column, UserManagementController, settings routes, React frontend page, Sidebar Settings > Users link, and 12 feature tests. https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent d522d9b commit eb37e2c

7 files changed

Lines changed: 435 additions & 0 deletions

File tree

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
<?php
2+
3+
namespace App\Http\Controllers;
4+
5+
use App\Models\User;
6+
use Illuminate\Http\Request;
7+
use Illuminate\Support\Facades\Hash;
8+
use Illuminate\Support\Str;
9+
use Inertia\Inertia;
10+
use Spatie\Permission\Models\Role;
11+
12+
class UserManagementController extends Controller
13+
{
14+
private function authorizeAdmin(Request $request): void
15+
{
16+
$user = $request->user();
17+
if (! $user->hasAnyRole(['super-admin', 'admin'])) {
18+
abort(403);
19+
}
20+
}
21+
22+
public function index(Request $request)
23+
{
24+
$this->authorizeAdmin($request);
25+
26+
$users = User::where('tenant_id', $request->user()->tenant_id)
27+
->with('roles')
28+
->orderBy('name')
29+
->get()
30+
->map(fn ($u) => [
31+
'id' => $u->id,
32+
'name' => $u->name,
33+
'email' => $u->email,
34+
'is_active' => (bool) $u->is_active,
35+
'roles' => $u->roles->pluck('name'),
36+
'created_at' => $u->created_at?->toDateString(),
37+
]);
38+
39+
$roles = Role::whereIn('name', ['admin', 'manager', 'staff'])->pluck('name');
40+
41+
return Inertia::render('Settings/Users/Index', [
42+
'users' => $users,
43+
'roles' => $roles,
44+
]);
45+
}
46+
47+
public function invite(Request $request)
48+
{
49+
$this->authorizeAdmin($request);
50+
51+
$data = $request->validate([
52+
'name' => 'required|string|max:191',
53+
'email' => 'required|email|unique:users,email',
54+
'role' => 'required|in:admin,manager,staff',
55+
]);
56+
57+
$user = User::create([
58+
'tenant_id' => $request->user()->tenant_id,
59+
'name' => $data['name'],
60+
'email' => $data['email'],
61+
'password' => Hash::make(Str::random(16)),
62+
'is_active' => true,
63+
]);
64+
65+
$user->assignRole($data['role']);
66+
67+
return back()->with('success', "User {$user->name} invited successfully.");
68+
}
69+
70+
public function updateRole(Request $request, User $user)
71+
{
72+
$this->authorizeAdmin($request);
73+
$this->ensureSameTenant($request, $user);
74+
75+
$data = $request->validate(['role' => 'required|in:admin,manager,staff']);
76+
77+
$user->syncRoles([$data['role']]);
78+
79+
return back()->with('success', 'Role updated.');
80+
}
81+
82+
public function toggleActive(Request $request, User $user)
83+
{
84+
$this->authorizeAdmin($request);
85+
$this->ensureSameTenant($request, $user);
86+
87+
if ($user->id === $request->user()->id) {
88+
return back()->withErrors(['user' => 'You cannot deactivate yourself.']);
89+
}
90+
91+
$user->update(['is_active' => ! $user->is_active]);
92+
93+
return back()->with('success', $user->is_active ? 'User reactivated.' : 'User deactivated.');
94+
}
95+
96+
public function destroy(Request $request, User $user)
97+
{
98+
$this->authorizeAdmin($request);
99+
$this->ensureSameTenant($request, $user);
100+
101+
if ($user->id === $request->user()->id) {
102+
return back()->withErrors(['user' => 'You cannot remove yourself.']);
103+
}
104+
105+
$user->delete();
106+
107+
return back()->with('success', 'User removed.');
108+
}
109+
110+
private function ensureSameTenant(Request $request, User $user): void
111+
{
112+
if ($user->tenant_id !== $request->user()->tenant_id) {
113+
abort(403);
114+
}
115+
}
116+
}

erp/app/Models/User.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ class User extends Authenticatable implements MustVerifyEmail
2727
'tenant_id',
2828
'avatar',
2929
'last_login_at',
30+
'is_active',
3031
];
3132

3233
protected $hidden = [
@@ -40,6 +41,7 @@ protected function casts(): array
4041
'email_verified_at' => 'datetime',
4142
'last_login_at' => 'datetime',
4243
'password' => 'hashed',
44+
'is_active' => 'boolean',
4345
];
4446
}
4547

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
use Illuminate\Database\Migrations\Migration;
4+
use Illuminate\Database\Schema\Blueprint;
5+
use Illuminate\Support\Facades\Schema;
6+
7+
return new class extends Migration
8+
{
9+
/**
10+
* Run the migrations.
11+
*/
12+
public function up(): void
13+
{
14+
Schema::table('users', function (Blueprint $table) {
15+
$table->boolean('is_active')->default(true)->after('tenant_id');
16+
});
17+
}
18+
19+
/**
20+
* Reverse the migrations.
21+
*/
22+
public function down(): void
23+
{
24+
Schema::table('users', function (Blueprint $table) {
25+
$table->dropColumn('is_active');
26+
});
27+
}
28+
};

erp/resources/js/Components/Layout/Sidebar.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,9 @@ const navItems: NavItem[] = [
129129
</svg>
130130
),
131131
permission: 'roles.manage',
132+
children: [
133+
{ label: 'Users', href: '/settings/users', icon: <span /> },
134+
],
132135
},
133136
];
134137

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import { Head, useForm, router, usePage } from '@inertiajs/react';
2+
import { useState } from 'react';
3+
import AppLayout from '@/Layouts/AppLayout';
4+
import { Button } from '@/Components/Common/Button';
5+
import type { PageProps } from '@/types';
6+
7+
interface UserRow {
8+
id: number;
9+
name: string;
10+
email: string;
11+
is_active: boolean;
12+
roles: string[];
13+
created_at: string;
14+
}
15+
16+
interface Props extends PageProps {
17+
users: UserRow[];
18+
roles: string[];
19+
}
20+
21+
const ROLE_COLORS: Record<string, string> = {
22+
'super-admin': 'bg-purple-100 text-purple-700',
23+
admin: 'bg-indigo-100 text-indigo-700',
24+
manager: 'bg-blue-100 text-blue-700',
25+
staff: 'bg-slate-100 text-slate-600',
26+
};
27+
28+
export default function UsersIndex({ users, roles }: Props) {
29+
const { auth } = usePage<Props>().props;
30+
const [showInvite, setShowInvite] = useState(false);
31+
const { data, setData, post, processing, errors, reset } = useForm({
32+
name: '', email: '', role: 'staff',
33+
});
34+
35+
function submitInvite(e: React.FormEvent) {
36+
e.preventDefault();
37+
post('/settings/users/invite', {
38+
onSuccess: () => { setShowInvite(false); reset(); },
39+
});
40+
}
41+
42+
function changeRole(userId: number, role: string) {
43+
router.patch(`/settings/users/${userId}/role`, { role });
44+
}
45+
46+
function toggleActive(userId: number) {
47+
router.patch(`/settings/users/${userId}/toggle-active`);
48+
}
49+
50+
function removeUser(userId: number, name: string) {
51+
if (confirm(`Remove ${name} from the system?`)) {
52+
router.delete(`/settings/users/${userId}`);
53+
}
54+
}
55+
56+
return (
57+
<AppLayout>
58+
<Head title="User Management" />
59+
<div className="space-y-6">
60+
<div className="flex items-center justify-between">
61+
<h1 className="text-2xl font-semibold text-slate-900">Users</h1>
62+
<Button onClick={() => setShowInvite((v) => !v)}>
63+
{showInvite ? 'Cancel' : 'Invite User'}
64+
</Button>
65+
</div>
66+
67+
{showInvite && (
68+
<form onSubmit={submitInvite} className="rounded-lg border border-slate-200 bg-white p-5 shadow-sm space-y-4">
69+
<h2 className="text-sm font-semibold text-slate-700">Invite New User</h2>
70+
<div className="grid grid-cols-3 gap-4">
71+
<div>
72+
<label className="block text-xs font-medium text-slate-600 mb-1">Name</label>
73+
<input type="text" required value={data.name} onChange={(e) => setData('name', e.target.value)}
74+
className="w-full rounded-md border border-slate-300 px-3 py-1.5 text-sm focus:border-indigo-500 focus:outline-none" />
75+
{errors.name && <p className="text-xs text-red-600 mt-1">{errors.name}</p>}
76+
</div>
77+
<div>
78+
<label className="block text-xs font-medium text-slate-600 mb-1">Email</label>
79+
<input type="email" required value={data.email} onChange={(e) => setData('email', e.target.value)}
80+
className="w-full rounded-md border border-slate-300 px-3 py-1.5 text-sm focus:border-indigo-500 focus:outline-none" />
81+
{errors.email && <p className="text-xs text-red-600 mt-1">{errors.email}</p>}
82+
</div>
83+
<div>
84+
<label className="block text-xs font-medium text-slate-600 mb-1">Role</label>
85+
<select value={data.role} onChange={(e) => setData('role', e.target.value)}
86+
className="w-full rounded-md border border-slate-300 px-3 py-1.5 text-sm focus:border-indigo-500 focus:outline-none">
87+
{roles.map((r) => <option key={r} value={r}>{r}</option>)}
88+
</select>
89+
</div>
90+
</div>
91+
<div className="flex justify-end gap-2">
92+
<Button type="button" variant="secondary" onClick={() => setShowInvite(false)}>Cancel</Button>
93+
<Button type="submit" disabled={processing}>{processing ? 'Inviting…' : 'Send Invite'}</Button>
94+
</div>
95+
</form>
96+
)}
97+
98+
<div className="rounded-lg border border-slate-200 bg-white shadow-sm overflow-hidden">
99+
<table className="w-full text-sm">
100+
<thead className="bg-slate-50 text-xs text-slate-500 uppercase border-b border-slate-200">
101+
<tr>
102+
<th className="px-4 py-2 text-left font-medium">Name</th>
103+
<th className="px-4 py-2 text-left font-medium">Email</th>
104+
<th className="px-4 py-2 text-left font-medium">Role</th>
105+
<th className="px-4 py-2 text-left font-medium">Status</th>
106+
<th className="px-4 py-2 text-left font-medium">Joined</th>
107+
<th className="px-4 py-2 text-right font-medium">Actions</th>
108+
</tr>
109+
</thead>
110+
<tbody className="divide-y divide-slate-100">
111+
{users.map((user) => (
112+
<tr key={user.id} className={`hover:bg-slate-50 ${!user.is_active ? 'opacity-50' : ''}`}>
113+
<td className="px-4 py-3 font-medium text-slate-900">{user.name}</td>
114+
<td className="px-4 py-3 text-slate-500">{user.email}</td>
115+
<td className="px-4 py-3">
116+
{user.roles.map((r) => (
117+
<span key={r} className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${ROLE_COLORS[r] ?? 'bg-slate-100 text-slate-600'}`}>
118+
{r}
119+
</span>
120+
))}
121+
</td>
122+
<td className="px-4 py-3">
123+
<span className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${user.is_active ? 'bg-green-100 text-green-700' : 'bg-slate-100 text-slate-500'}`}>
124+
{user.is_active ? 'Active' : 'Inactive'}
125+
</span>
126+
</td>
127+
<td className="px-4 py-3 text-slate-500">{user.created_at}</td>
128+
<td className="px-4 py-3 text-right">
129+
{user.id !== auth.user?.id && (
130+
<div className="flex justify-end gap-2">
131+
{!user.roles.includes('super-admin') && (
132+
<select
133+
value={user.roles[0] ?? 'staff'}
134+
onChange={(e) => changeRole(user.id, e.target.value)}
135+
className="rounded-md border border-slate-300 px-2 py-1 text-xs focus:border-indigo-500 focus:outline-none"
136+
>
137+
{roles.map((r) => <option key={r} value={r}>{r}</option>)}
138+
</select>
139+
)}
140+
<button
141+
onClick={() => toggleActive(user.id)}
142+
className="rounded-md border border-slate-300 px-2 py-1 text-xs text-slate-600 hover:bg-slate-100"
143+
>
144+
{user.is_active ? 'Deactivate' : 'Reactivate'}
145+
</button>
146+
<button
147+
onClick={() => removeUser(user.id, user.name)}
148+
className="rounded-md border border-red-200 px-2 py-1 text-xs text-red-600 hover:bg-red-50"
149+
>
150+
Remove
151+
</button>
152+
</div>
153+
)}
154+
</td>
155+
</tr>
156+
))}
157+
</tbody>
158+
</table>
159+
</div>
160+
</div>
161+
</AppLayout>
162+
);
163+
}

erp/routes/web.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
use App\Http\Controllers\DashboardController;
44
use App\Http\Controllers\ProfileController;
5+
use App\Http\Controllers\UserManagementController;
56
use Illuminate\Foundation\Application;
67
use Illuminate\Support\Facades\Route;
78
use Inertia\Inertia;
@@ -25,4 +26,12 @@
2526
Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
2627
});
2728

29+
Route::prefix('settings')->middleware(['auth', 'verified'])->group(function () {
30+
Route::get('users', [UserManagementController::class, 'index'])->name('settings.users.index');
31+
Route::post('users/invite', [UserManagementController::class, 'invite'])->name('settings.users.invite');
32+
Route::patch('users/{user}/role', [UserManagementController::class, 'updateRole'])->name('settings.users.update-role');
33+
Route::patch('users/{user}/toggle-active', [UserManagementController::class, 'toggleActive'])->name('settings.users.toggle-active');
34+
Route::delete('users/{user}', [UserManagementController::class, 'destroy'])->name('settings.users.destroy');
35+
});
36+
2837
require __DIR__ . '/auth.php';

0 commit comments

Comments
 (0)