diff --git a/app/Http/Controllers/Api/RoleApiController.php b/app/Http/Controllers/Api/RoleApiController.php
new file mode 100644
index 0000000..c25a79c
--- /dev/null
+++ b/app/Http/Controllers/Api/RoleApiController.php
@@ -0,0 +1,102 @@
+authorize(Permissions::ADMIN_ROLES_MANAGE);
+ $roles = Role::withCount('permissions')->orderBy('name')->get();
+ return response()->json(['data' => $roles]);
+ }
+
+ public function show(Role $role): JsonResponse
+ {
+ $this->authorize(Permissions::ADMIN_ROLES_MANAGE);
+ return response()->json(['data' => $role->load('permissions')]);
+ }
+
+ public function store(Request $request): JsonResponse
+ {
+ $this->authorize(Permissions::ADMIN_ROLES_MANAGE);
+ $data = $request->validate([
+ 'name' => 'required|string|max:64',
+ 'permissions' => 'sometimes|array',
+ 'permissions.*' => 'string',
+ ]);
+ // Disallow overwriting system roles
+ if (in_array($data['name'], array_keys(Permissions::roleMatrix()), true)) {
+ return response()->json(['error' => 'System role name is reserved'], 422);
+ }
+ $role = Role::create(['name' => $data['name'], 'guard_name' => 'web']);
+ if (! empty($data['permissions'])) {
+ $role->syncPermissions($data['permissions']);
+ }
+ return response()->json(['data' => $role->load('permissions')], 201);
+ }
+
+ public function update(Request $request, Role $role): JsonResponse
+ {
+ $this->authorize(Permissions::ADMIN_ROLES_MANAGE);
+ if (in_array($role->name, array_keys(Permissions::roleMatrix()), true)) {
+ return response()->json(['error' => 'Cannot rename system role'], 422);
+ }
+ $data = $request->validate(['name' => 'required|string|max:64']);
+ $role->update($data);
+ return response()->json(['data' => $role]);
+ }
+
+ public function destroy(Role $role): JsonResponse
+ {
+ $this->authorize(Permissions::ADMIN_ROLES_MANAGE);
+ if (in_array($role->name, array_keys(Permissions::roleMatrix()), true)) {
+ return response()->json(['error' => 'Cannot delete system role'], 422);
+ }
+ $role->delete();
+ return response()->json(['deleted' => true]);
+ }
+
+ public function permissions(Role $role): JsonResponse
+ {
+ $this->authorize(Permissions::ADMIN_ROLES_MANAGE);
+ return response()->json(['data' => $role->permissions->pluck('name')]);
+ }
+
+ public function syncPermissions(Request $request, Role $role): JsonResponse
+ {
+ $this->authorize(Permissions::ADMIN_ROLES_MANAGE);
+ $data = $request->validate([
+ 'permissions' => 'required|array',
+ 'permissions.*' => 'string',
+ ]);
+ $role->syncPermissions($data['permissions']);
+ app(PermissionRegistrar::class)->forgetCachedPermissions();
+ return response()->json(['data' => $role->fresh()->permissions->pluck('name')]);
+ }
+
+ public function permissionCatalog(): JsonResponse
+ {
+ return response()->json([
+ 'data' => Permission::orderBy('name')->get(['id', 'name']),
+ 'grouped' => Permissions::grouped(),
+ 'labels' => Permissions::labels(),
+ 'roles' => Permissions::roleLabels(),
+ ]);
+ }
+
+ private function authorize(string $permission): void
+ {
+ if (! auth()->user() || ! auth()->user()->canDo($permission)) {
+ abort(403, "Missing permission: $permission");
+ }
+ }
+}
diff --git a/app/Http/Controllers/Api/UserApiController.php b/app/Http/Controllers/Api/UserApiController.php
new file mode 100644
index 0000000..09ebc35
--- /dev/null
+++ b/app/Http/Controllers/Api/UserApiController.php
@@ -0,0 +1,228 @@
+authorize(Permissions::ADMIN_USERS_VIEW);
+
+ $q = User::query();
+ if ($role = $request->query('role')) $q->where('role', $role);
+ if ($status = $request->query('status')) $q->where('status', $status);
+ if ($search = $request->query('q')) {
+ $q->where(fn ($qq) => $qq->where('name', 'like', "%$search%")->orWhere('email', 'like', "%$search%"));
+ }
+ return response()->json([
+ 'data' => $q->paginate((int) $request->query('per_page', 25))->items(),
+ 'meta' => ['total' => $q->count()],
+ ]);
+ }
+
+ public function show(User $user): JsonResponse
+ {
+ $this->authorize(Permissions::ADMIN_USERS_VIEW);
+ return response()->json(['data' => $user->load('roles', 'permissionOverrides.permission', 'invitedBy:id,name')]);
+ }
+
+ public function store(Request $request): JsonResponse
+ {
+ $this->authorize(Permissions::ADMIN_USERS_MANAGE);
+
+ $data = $request->validate([
+ 'name' => 'required|string|max:120',
+ 'email' => 'required|email|max:120',
+ 'phone' => 'nullable|string|max:40',
+ 'role' => 'required|string|in:' . implode(',', array_keys(Permissions::roleMatrix())),
+ 'locale' => 'nullable|in:ro,ru,en',
+ 'send_invitation' => 'nullable|boolean',
+ ]);
+
+ $user = User::create([
+ 'name' => $data['name'],
+ 'email' => $data['email'],
+ 'phone' => $data['phone'] ?? null,
+ 'role' => $data['role'],
+ 'locale' => $data['locale'] ?? 'ro',
+ 'status' => 'inactive',
+ 'password' => Hash::make(bin2hex(random_bytes(16))), // placeholder until invitation accept
+ ]);
+ $user->syncRoles([$data['role']]);
+
+ if ($data['send_invitation'] ?? true) {
+ $user->sendInvitation(auth()->user());
+ }
+
+ return response()->json(['data' => $user->fresh(), 'invitation_sent' => $data['send_invitation'] ?? true], 201);
+ }
+
+ public function update(Request $request, User $user): JsonResponse
+ {
+ $this->authorize(Permissions::ADMIN_USERS_MANAGE);
+
+ $data = $request->validate([
+ 'name' => 'sometimes|string|max:120',
+ 'email' => 'sometimes|email|max:120',
+ 'phone' => 'sometimes|nullable|string|max:40',
+ 'locale' => 'sometimes|in:ro,ru,en',
+ 'role' => 'sometimes|in:' . implode(',', array_keys(Permissions::roleMatrix())),
+ 'status' => 'sometimes|in:active,inactive,blocked',
+ ]);
+ $user->update($data);
+ if (isset($data['role'])) $user->syncRoles([$data['role']]);
+ return response()->json(['data' => $user->fresh()]);
+ }
+
+ public function destroy(User $user): JsonResponse
+ {
+ $this->authorize(Permissions::ADMIN_USERS_MANAGE);
+ $user->delete();
+ return response()->json(['deleted' => true]);
+ }
+
+ public function activate(User $user): JsonResponse
+ {
+ $this->authorize(Permissions::ADMIN_USERS_MANAGE);
+ $user->update(['status' => 'active']);
+ return response()->json(['data' => $user->fresh()]);
+ }
+
+ public function deactivate(User $user): JsonResponse
+ {
+ $this->authorize(Permissions::ADMIN_USERS_MANAGE);
+ $user->update(['status' => 'inactive']);
+ DB::table('sessions')->where('user_id', $user->id)->delete();
+ return response()->json(['data' => $user->fresh()]);
+ }
+
+ public function resendInvitation(User $user): JsonResponse
+ {
+ $this->authorize(Permissions::ADMIN_USERS_MANAGE);
+ if ($user->accepted_at) {
+ return response()->json(['error' => 'User already accepted invitation'], 422);
+ }
+ $user->sendInvitation(auth()->user());
+ return response()->json(['invitation_sent' => true]);
+ }
+
+ public function forcePasswordReset(User $user): JsonResponse
+ {
+ $this->authorize(Permissions::ADMIN_USERS_MANAGE);
+ $user->update(['status' => 'inactive', 'accepted_at' => null]);
+ DB::table('sessions')->where('user_id', $user->id)->delete();
+ $user->sendInvitation(auth()->user());
+ return response()->json(['invitation_sent' => true]);
+ }
+
+ public function sessions(User $user): JsonResponse
+ {
+ $this->authorize(Permissions::ADMIN_USERS_MANAGE);
+ $rows = DB::table('sessions')->where('user_id', $user->id)
+ ->select('id', 'ip_address', 'user_agent', 'last_activity')->get();
+ return response()->json(['data' => $rows]);
+ }
+
+ public function revokeSession(User $user, string $sessionId): JsonResponse
+ {
+ $this->authorize(Permissions::ADMIN_USERS_MANAGE);
+ $n = DB::table('sessions')->where('user_id', $user->id)->where('id', $sessionId)->delete();
+ return response()->json(['revoked' => $n > 0]);
+ }
+
+ public function revokeAllSessions(User $user): JsonResponse
+ {
+ $this->authorize(Permissions::ADMIN_USERS_MANAGE);
+ $n = DB::table('sessions')->where('user_id', $user->id)->delete();
+ return response()->json(['revoked_count' => $n]);
+ }
+
+ public function roles(User $user): JsonResponse
+ {
+ $this->authorize(Permissions::ADMIN_USERS_VIEW);
+ return response()->json(['data' => $user->roles->pluck('name')]);
+ }
+
+ public function assignRole(Request $request, User $user): JsonResponse
+ {
+ $this->authorize(Permissions::ADMIN_USERS_MANAGE);
+ $data = $request->validate(['role' => 'required|string']);
+ $user->assignRole($data['role']);
+ return response()->json(['data' => $user->roles->pluck('name')]);
+ }
+
+ public function removeRole(User $user, string $role): JsonResponse
+ {
+ $this->authorize(Permissions::ADMIN_USERS_MANAGE);
+ $user->removeRole($role);
+ return response()->json(['data' => $user->roles->pluck('name')]);
+ }
+
+ public function permissions(User $user): JsonResponse
+ {
+ $this->authorize(Permissions::ADMIN_USERS_VIEW);
+ // Effective: roles + grants - denies (active only)
+ $rolePerms = $user->getAllPermissions()->pluck('name');
+ $denies = $user->permissionOverrides()
+ ->where('mode', 'deny')
+ ->where(fn ($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()))
+ ->with('permission')->get()->pluck('permission.name');
+ $grants = $user->permissionOverrides()
+ ->where('mode', 'grant')
+ ->where(fn ($q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()))
+ ->with('permission')->get()->pluck('permission.name');
+ $effective = $rolePerms->merge($grants)->unique()->reject(fn ($p) => $denies->contains($p))->values();
+ return response()->json([
+ 'data' => $effective,
+ 'overrides' => ['grants' => $grants, 'denies' => $denies],
+ ]);
+ }
+
+ public function addOverride(Request $request, User $user): JsonResponse
+ {
+ $this->authorize(Permissions::ADMIN_USERS_MANAGE);
+ $data = $request->validate([
+ 'permission' => 'required|string',
+ 'mode' => 'required|in:grant,deny',
+ 'reason' => 'nullable|string',
+ 'expires_at' => 'nullable|date',
+ ]);
+ $perm = Permission::where('name', $data['permission'])->firstOrFail();
+ UserPermissionOverride::updateOrCreate(
+ ['user_id' => $user->id, 'permission_id' => $perm->id],
+ [
+ 'mode' => $data['mode'],
+ 'reason' => $data['reason'] ?? null,
+ 'expires_at' => $data['expires_at'] ?? null,
+ 'granted_by_id' => auth()->id(),
+ 'granted_at' => now(),
+ ]
+ );
+ return response()->json(['data' => $user->load('permissionOverrides.permission')]);
+ }
+
+ public function removeOverride(User $user, string $permission): JsonResponse
+ {
+ $this->authorize(Permissions::ADMIN_USERS_MANAGE);
+ $perm = Permission::where('name', $permission)->firstOrFail();
+ UserPermissionOverride::where('user_id', $user->id)->where('permission_id', $perm->id)->delete();
+ return response()->json(['removed' => true]);
+ }
+
+ private function authorize(string $permission): void
+ {
+ if (! auth()->user() || ! auth()->user()->canDo($permission)) {
+ abort(403, "Missing permission: $permission");
+ }
+ }
+}
diff --git a/app/Http/Controllers/InvitationController.php b/app/Http/Controllers/InvitationController.php
new file mode 100644
index 0000000..da759bd
--- /dev/null
+++ b/app/Http/Controllers/InvitationController.php
@@ -0,0 +1,49 @@
+isPendingInvitation()) {
+ return view('invitations.invalid');
+ }
+ // Invitations expire after 7 days
+ if ($user->invited_at && $user->invited_at->lt(now()->subDays(7))) {
+ return view('invitations.expired');
+ }
+
+ return view('invitations.accept', [
+ 'token' => $token,
+ 'name' => $user->name,
+ 'email' => $user->email,
+ 'company' => $user->company?->display_name ?? $user->company?->name ?? 'AutoCRM',
+ ]);
+ }
+
+ public function accept(string $token, Request $request)
+ {
+ $request->validate([
+ 'password' => 'required|min:8|confirmed',
+ ]);
+
+ $user = User::findByInvitationToken($token);
+ if (! $user || ! $user->isPendingInvitation()) {
+ return view('invitations.invalid');
+ }
+ if ($user->invited_at && $user->invited_at->lt(now()->subDays(7))) {
+ return view('invitations.expired');
+ }
+
+ $user->acceptInvitation($request->input('password'));
+
+ // Redirect to tenant login on the appropriate subdomain
+ $loginUrl = $user->company?->url('/app/login') ?? '/app/login';
+ return redirect($loginUrl)->with('status', 'Invitația a fost acceptată. Loghează-te cu noua parolă.');
+ }
+}
diff --git a/app/Mail/UserInvitationMail.php b/app/Mail/UserInvitationMail.php
new file mode 100644
index 0000000..edf2428
--- /dev/null
+++ b/app/Mail/UserInvitationMail.php
@@ -0,0 +1,42 @@
+user->company?->display_name ?? $this->user->company?->name ?? 'AutoCRM';
+ return new Envelope(
+ subject: "Invitație de acces — {$company}",
+ );
+ }
+
+ public function content(): Content
+ {
+ $company = $this->user->company;
+ return new Content(
+ view: 'emails.user-invitation',
+ with: [
+ 'name' => $this->user->name,
+ 'invitedBy' => $this->user->invitedBy?->name ?? 'Echipa',
+ 'companyName' => $company?->display_name ?? $company?->name ?? 'AutoCRM',
+ 'acceptUrl' => url('/invitations/' . $this->rawToken),
+ ],
+ );
+ }
+}
diff --git a/app/Models/Tenant/User.php b/app/Models/Tenant/User.php
index e2adf77..c3dfedf 100644
--- a/app/Models/Tenant/User.php
+++ b/app/Models/Tenant/User.php
@@ -35,6 +35,7 @@ class User extends Authenticatable implements FilamentUser, HasAppAuthentication
'email_verified_at', 'password', 'last_login_at',
'email_authentication_at',
'app_authentication_secret', 'app_authentication_recovery_codes',
+ 'invited_at', 'invited_by_id', 'accepted_at', 'invitation_token',
];
protected $hidden = [
@@ -47,6 +48,8 @@ class User extends Authenticatable implements FilamentUser, HasAppAuthentication
'email_verified_at' => 'datetime',
'last_login_at' => 'datetime',
'email_authentication_at' => 'datetime',
+ 'invited_at' => 'datetime',
+ 'accepted_at' => 'datetime',
'password' => 'hashed',
'app_authentication_secret' => 'encrypted',
'app_authentication_recovery_codes' => 'encrypted:array',
@@ -84,6 +87,16 @@ class User extends Authenticatable implements FilamentUser, HasAppAuthentication
return $this->hasMany(UserPermissionOverride::class);
}
+ public function invitedBy(): \Illuminate\Database\Eloquent\Relations\BelongsTo
+ {
+ return $this->belongsTo(self::class, 'invited_by_id');
+ }
+
+ public function company(): \Illuminate\Database\Eloquent\Relations\BelongsTo
+ {
+ return $this->belongsTo(\App\Models\Central\Company::class);
+ }
+
/**
* Permission check honoring (in order):
* 1. Active deny-override → false
@@ -150,6 +163,50 @@ class User extends Authenticatable implements FilamentUser, HasAppAuthentication
return $this->app_authentication_secret !== null;
}
+ /** Pending invitation (sent but not yet accepted). */
+ public function isPendingInvitation(): bool
+ {
+ return $this->invited_at !== null && $this->accepted_at === null;
+ }
+
+ /**
+ * Create + send an invitation: generates a random token, marks invited_at,
+ * and queues the email with the signed accept link. Idempotent — calling
+ * again regenerates the token (useful for "resend invitation").
+ */
+ public function sendInvitation(?User $invitedBy = null): string
+ {
+ $token = bin2hex(random_bytes(32)); // 64 chars
+ $this->forceFill([
+ 'invitation_token' => hash('sha256', $token),
+ 'invited_at' => now(),
+ 'invited_by_id' => $invitedBy?->id ?? auth()->id(),
+ 'accepted_at' => null,
+ 'status' => 'inactive', // can't login until accepted
+ ])->saveQuietly();
+
+ \Illuminate\Support\Facades\Mail::to($this->email)
+ ->queue(new \App\Mail\UserInvitationMail($this, $token));
+
+ return $token; // returned mainly for tests / API
+ }
+
+ public static function findByInvitationToken(string $rawToken): ?self
+ {
+ return self::where('invitation_token', hash('sha256', $rawToken))->first();
+ }
+
+ public function acceptInvitation(string $password): void
+ {
+ $this->forceFill([
+ 'password' => $password, // hashed cast handles it
+ 'invitation_token' => null,
+ 'accepted_at' => now(),
+ 'status' => 'active',
+ 'email_verified_at' => now(),
+ ])->save();
+ }
+
public function hasEmailAuthentication(): bool
{
return $this->email_authentication_at !== null;
diff --git a/database/migrations/2026_06_04_000005_add_invitation_fields_to_users.php b/database/migrations/2026_06_04_000005_add_invitation_fields_to_users.php
new file mode 100644
index 0000000..46cba70
--- /dev/null
+++ b/database/migrations/2026_06_04_000005_add_invitation_fields_to_users.php
@@ -0,0 +1,49 @@
+timestamp('invited_at')->nullable()->after('status');
+ }
+ if (! Schema::hasColumn('users', 'invited_by_id')) {
+ $t->foreignId('invited_by_id')->nullable()->after('invited_at')->constrained('users')->nullOnDelete();
+ }
+ if (! Schema::hasColumn('users', 'accepted_at')) {
+ $t->timestamp('accepted_at')->nullable()->after('invited_by_id');
+ }
+ if (! Schema::hasColumn('users', 'invitation_token')) {
+ $t->string('invitation_token', 80)->nullable()->after('accepted_at');
+ }
+ });
+ // index on token for fast lookup
+ if (Schema::hasColumn('users', 'invitation_token')) {
+ try {
+ Schema::table('users', function (Blueprint $t) {
+ $t->index('invitation_token', 'users_invitation_token_idx');
+ });
+ } catch (\Throwable $e) { /* idempotent */ }
+ }
+ }
+
+ public function down(): void
+ {
+ Schema::table('users', function (Blueprint $t) {
+ try { $t->dropIndex('users_invitation_token_idx'); } catch (\Throwable $e) {}
+ foreach (['invitation_token', 'accepted_at', 'invited_by_id', 'invited_at'] as $col) {
+ if (Schema::hasColumn('users', $col)) {
+ if ($col === 'invited_by_id') {
+ try { $t->dropForeign(['invited_by_id']); } catch (\Throwable $e) {}
+ }
+ $t->dropColumn($col);
+ }
+ }
+ });
+ }
+};
diff --git a/resources/views/emails/user-invitation.blade.php b/resources/views/emails/user-invitation.blade.php
new file mode 100644
index 0000000..b0829a6
--- /dev/null
+++ b/resources/views/emails/user-invitation.blade.php
@@ -0,0 +1,16 @@
+
+**{{ $companyName }}**
+
Ai fost invitat să accesezi {{ $company }}. Setează o parolă pentru a-ți activa contul.
+ + @if ($errors->any()) +Linkul de invitație a expirat (durata maximă: 7 zile). Roagă administratorul să retrimită invitația.
Linkul nu mai este valabil sau a fost deja folosit.