500 lines
20 KiB
PHP
500 lines
20 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers\Api;
|
|
|
|
use App\Events\UserDeactivated;
|
|
use App\Http\Controllers\Controller;
|
|
use App\Models\User;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Routing\Controllers\HasMiddleware;
|
|
use Illuminate\Routing\Controllers\Middleware;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Hash;
|
|
use Illuminate\Validation\Rule;
|
|
use Laravel\Passport\Http\Middleware\CheckToken;
|
|
use OpenApi\Attributes as OA;
|
|
use Spatie\Permission\Models\Role;
|
|
|
|
#[OA\Schema(
|
|
schema: 'User',
|
|
type: 'object',
|
|
properties: [
|
|
new OA\Property(property: 'id', type: 'integer', example: 1),
|
|
new OA\Property(property: 'name', type: 'string', example: 'Ada Lovelace'),
|
|
new OA\Property(property: 'email', type: 'string', format: 'email', example: 'ada@example.com'),
|
|
new OA\Property(property: 'is_active', type: 'boolean', example: true),
|
|
new OA\Property(property: 'roles', type: 'array', items: new OA\Items(type: 'string'), example: ['user']),
|
|
new OA\Property(
|
|
property: 'app_roles',
|
|
type: 'array',
|
|
items: new OA\Items(type: 'string'),
|
|
example: ['editor'],
|
|
description: 'Roles del usuario específicos de la app que llama (el client_id del token usado).'
|
|
),
|
|
new OA\Property(property: 'created_at', type: 'string', format: 'date-time'),
|
|
new OA\Property(property: 'deleted_at', type: 'string', format: 'date-time', nullable: true, description: 'Solo presente para usuarios en la papelera (soft-deleted).'),
|
|
new OA\Property(property: 'approved_at', type: 'string', format: 'date-time', nullable: true, description: 'Null = todavía no lo aprobó ningún admin (registro pendiente).'),
|
|
]
|
|
)]
|
|
class UserController extends Controller implements HasMiddleware
|
|
{
|
|
public static function middleware(): array
|
|
{
|
|
// No usamos "auth:api": rompe con tokens client_credentials (sin usuario).
|
|
// CheckToken valida el bearer token y el scope; los permisos por-usuario
|
|
// (spatie) se chequean a mano en cada acción, ver authorize().
|
|
return [
|
|
new Middleware(CheckToken::using('users:read'), only: ['index', 'show', 'appRoles', 'trashed', 'pending']),
|
|
new Middleware(CheckToken::using('users:write'), only: ['store', 'update', 'destroy', 'assignAppRole', 'revokeAppRole', 'restore', 'forceDelete', 'approve']),
|
|
];
|
|
}
|
|
|
|
#[OA\Get(
|
|
path: '/user',
|
|
summary: 'Usuario del token actual (whoami) + sus roles global y en la app que llama',
|
|
security: [['oauth2' => []]],
|
|
tags: ['Users'],
|
|
responses: [
|
|
new OA\Response(response: 200, description: 'Usuario autenticado', content: new OA\JsonContent(ref: '#/components/schemas/User')),
|
|
new OA\Response(response: 401, description: 'Token inválido o sin usuario asociado (ej. client_credentials)'),
|
|
]
|
|
)]
|
|
public function me(Request $request)
|
|
{
|
|
return response()->json($this->present($request->user()));
|
|
}
|
|
|
|
#[OA\Get(
|
|
path: '/users',
|
|
summary: 'Listar usuarios',
|
|
description: 'Incluye usuarios pendientes de aprobación del panel admin (ver campo approved_at) — esa aprobación solo gobierna el acceso al panel de sesión de esta app, no la consulta de usuarios vía API.',
|
|
security: [['oauth2' => ['users:read']]],
|
|
tags: ['Users'],
|
|
responses: [
|
|
new OA\Response(response: 200, description: 'Listado de usuarios', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/User'))),
|
|
new OA\Response(response: 403, description: 'Sin permiso users.view'),
|
|
]
|
|
)]
|
|
public function index(Request $request)
|
|
{
|
|
$this->authorize($request, 'users.view');
|
|
|
|
// A diferencia del panel admin (Admin\UserController), acá NO se
|
|
// esconden los usuarios pendientes de aprobación: "aprobado" es un
|
|
// concepto del panel de esta app (¿puede entrar a /admin?), no algo
|
|
// que le importe a otra app que solo está consultando/consumiendo
|
|
// usuarios por la API.
|
|
return response()->json(
|
|
User::query()->with('roles:id,name')->orderBy('name')->get()->map($this->present(...))
|
|
);
|
|
}
|
|
|
|
#[OA\Post(
|
|
path: '/users',
|
|
summary: 'Crear un usuario',
|
|
security: [['oauth2' => ['users:write']]],
|
|
tags: ['Users'],
|
|
requestBody: new OA\RequestBody(
|
|
required: true,
|
|
content: new OA\JsonContent(
|
|
required: ['name', 'email', 'password'],
|
|
properties: [
|
|
new OA\Property(property: 'name', type: 'string'),
|
|
new OA\Property(property: 'email', type: 'string', format: 'email'),
|
|
new OA\Property(property: 'password', type: 'string', format: 'password'),
|
|
new OA\Property(property: 'role', type: 'string', example: 'user'),
|
|
]
|
|
)
|
|
),
|
|
responses: [
|
|
new OA\Response(response: 201, description: 'Usuario creado', content: new OA\JsonContent(ref: '#/components/schemas/User')),
|
|
new OA\Response(response: 403, description: 'Sin permiso users.create'),
|
|
]
|
|
)]
|
|
public function store(Request $request)
|
|
{
|
|
$this->authorize($request, 'users.create');
|
|
|
|
$data = $request->validate([
|
|
'name' => ['required', 'string', 'max:255'],
|
|
'email' => ['required', 'string', 'email', 'max:255', Rule::unique('users', 'email')],
|
|
'password' => ['required', 'string', 'min:8'],
|
|
'role' => ['sometimes', Rule::in(Role::pluck('name'))],
|
|
]);
|
|
|
|
$user = User::create([
|
|
'name' => $data['name'],
|
|
'email' => $data['email'],
|
|
'password' => Hash::make($data['password']),
|
|
'is_active' => true,
|
|
'approved_at' => now(), // lo crea un admin/servicio de confianza: ya está aprobado.
|
|
]);
|
|
|
|
$user->assignRole($data['role'] ?? 'user');
|
|
|
|
return response()->json($this->present($user), 201);
|
|
}
|
|
|
|
#[OA\Get(
|
|
path: '/users/{user}',
|
|
summary: 'Ver un usuario (el propio siempre permitido; otros requieren users.view)',
|
|
security: [['oauth2' => ['users:read']]],
|
|
tags: ['Users'],
|
|
parameters: [new OA\Parameter(name: 'user', in: 'path', required: true, schema: new OA\Schema(type: 'integer'))],
|
|
responses: [
|
|
new OA\Response(response: 200, description: 'Datos del usuario', content: new OA\JsonContent(ref: '#/components/schemas/User')),
|
|
new OA\Response(response: 403, description: 'Sin permiso users.view'),
|
|
]
|
|
)]
|
|
public function show(Request $request, User $user)
|
|
{
|
|
$this->authorize($request, 'users.view', $user);
|
|
|
|
return response()->json($this->present($user));
|
|
}
|
|
|
|
#[OA\Put(
|
|
path: '/users/{user}',
|
|
summary: 'Editar un usuario (el propio siempre permitido; rol/estado y otros usuarios requieren users.update)',
|
|
security: [['oauth2' => ['users:write']]],
|
|
tags: ['Users'],
|
|
parameters: [new OA\Parameter(name: 'user', in: 'path', required: true, schema: new OA\Schema(type: 'integer'))],
|
|
requestBody: new OA\RequestBody(
|
|
required: true,
|
|
content: new OA\JsonContent(
|
|
properties: [
|
|
new OA\Property(property: 'name', type: 'string'),
|
|
new OA\Property(property: 'email', type: 'string', format: 'email'),
|
|
new OA\Property(property: 'password', type: 'string', format: 'password'),
|
|
new OA\Property(property: 'is_active', type: 'boolean'),
|
|
new OA\Property(property: 'role', type: 'string'),
|
|
]
|
|
)
|
|
),
|
|
responses: [
|
|
new OA\Response(response: 200, description: 'Usuario actualizado', content: new OA\JsonContent(ref: '#/components/schemas/User')),
|
|
new OA\Response(response: 403, description: 'Sin permiso users.update'),
|
|
]
|
|
)]
|
|
public function update(Request $request, User $user)
|
|
{
|
|
$actor = $this->authorize($request, 'users.update', $user);
|
|
$isAdminAction = is_null($actor) || $actor->can('users.update');
|
|
|
|
$data = $request->validate([
|
|
'name' => ['sometimes', 'string', 'max:255'],
|
|
'email' => ['sometimes', 'string', 'email', 'max:255', Rule::unique('users', 'email')->ignore($user->id)],
|
|
'password' => ['sometimes', 'string', 'min:8'],
|
|
'is_active' => ['sometimes', 'boolean'],
|
|
'role' => ['sometimes', Rule::in(Role::pluck('name'))],
|
|
]);
|
|
|
|
if (! $isAdminAction) {
|
|
$data = array_intersect_key($data, array_flip(['name', 'email', 'password']));
|
|
}
|
|
|
|
if (isset($data['role']) && $user->id === $actor?->id && $data['role'] !== 'admin' && $user->hasRole('admin')) {
|
|
abort(422, __('messages.cannot_remove_own_admin_role'));
|
|
}
|
|
|
|
$wasActive = $user->is_active;
|
|
|
|
$user->update([
|
|
...array_diff_key($data, array_flip(['password', 'role'])),
|
|
...(isset($data['password']) ? ['password' => Hash::make($data['password'])] : []),
|
|
]);
|
|
|
|
if (isset($data['role'])) {
|
|
$user->syncRoles([$data['role']]);
|
|
}
|
|
|
|
$fresh = $user->fresh();
|
|
|
|
if ($wasActive && ! $fresh->is_active) {
|
|
UserDeactivated::dispatch($fresh);
|
|
}
|
|
|
|
return response()->json($this->present($fresh));
|
|
}
|
|
|
|
#[OA\Delete(
|
|
path: '/users/{user}',
|
|
summary: 'Borrar un usuario (requiere users.delete; no se puede autoeliminar)',
|
|
security: [['oauth2' => ['users:write']]],
|
|
tags: ['Users'],
|
|
parameters: [new OA\Parameter(name: 'user', in: 'path', required: true, schema: new OA\Schema(type: 'integer'))],
|
|
responses: [
|
|
new OA\Response(response: 204, description: 'Usuario borrado'),
|
|
new OA\Response(response: 403, description: 'Sin permiso users.delete'),
|
|
new OA\Response(response: 422, description: 'No se puede autoeliminar ni borrar al último admin'),
|
|
]
|
|
)]
|
|
public function destroy(Request $request, User $user)
|
|
{
|
|
$actor = $request->user('api');
|
|
|
|
abort_if($actor && $actor->id === $user->id, 422, __('messages.cannot_delete_self'));
|
|
abort_if($actor && ! $actor->can('users.delete'), 403);
|
|
|
|
if ($user->hasRole('admin') && User::role('admin')->count() <= 1) {
|
|
abort(422, __('messages.cannot_delete_last_admin'));
|
|
}
|
|
|
|
$user->delete();
|
|
|
|
return response()->noContent();
|
|
}
|
|
|
|
#[OA\Get(
|
|
path: '/users/trashed',
|
|
summary: 'Listar usuarios borrados (papelera)',
|
|
security: [['oauth2' => ['users:read']]],
|
|
tags: ['Users'],
|
|
responses: [
|
|
new OA\Response(response: 200, description: 'Listado de usuarios en la papelera', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/User'))),
|
|
new OA\Response(response: 403, description: 'Sin permiso users.view'),
|
|
]
|
|
)]
|
|
public function trashed(Request $request)
|
|
{
|
|
$this->authorize($request, 'users.view');
|
|
|
|
return response()->json(
|
|
User::onlyTrashed()->orderByDesc('deleted_at')->get()->map($this->present(...))
|
|
);
|
|
}
|
|
|
|
#[OA\Post(
|
|
path: '/users/{id}/restore',
|
|
summary: 'Restaurar un usuario borrado',
|
|
security: [['oauth2' => ['users:write']]],
|
|
tags: ['Users'],
|
|
parameters: [new OA\Parameter(name: 'id', in: 'path', required: true, schema: new OA\Schema(type: 'integer'))],
|
|
responses: [
|
|
new OA\Response(response: 200, description: 'Usuario restaurado', content: new OA\JsonContent(ref: '#/components/schemas/User')),
|
|
new OA\Response(response: 403, description: 'Sin permiso users.update'),
|
|
]
|
|
)]
|
|
public function restore(Request $request, int $id)
|
|
{
|
|
$actor = $request->user('api');
|
|
abort_if($actor && ! $actor->can('users.update'), 403);
|
|
|
|
$user = User::onlyTrashed()->findOrFail($id);
|
|
$user->restore();
|
|
|
|
return response()->json($this->present($user));
|
|
}
|
|
|
|
#[OA\Delete(
|
|
path: '/users/{id}/force',
|
|
summary: 'Borrar un usuario definitivamente (tiene que estar en la papelera)',
|
|
security: [['oauth2' => ['users:write']]],
|
|
tags: ['Users'],
|
|
parameters: [new OA\Parameter(name: 'id', in: 'path', required: true, schema: new OA\Schema(type: 'integer'))],
|
|
responses: [
|
|
new OA\Response(response: 204, description: 'Borrado definitivamente'),
|
|
new OA\Response(response: 403, description: 'Sin permiso users.delete'),
|
|
]
|
|
)]
|
|
public function forceDelete(Request $request, int $id)
|
|
{
|
|
$actor = $request->user('api');
|
|
abort_if($actor && ! $actor->can('users.delete'), 403);
|
|
|
|
$user = User::onlyTrashed()->findOrFail($id);
|
|
$user->forceDelete();
|
|
|
|
return response()->noContent();
|
|
}
|
|
|
|
#[OA\Get(
|
|
path: '/users/pending',
|
|
summary: 'Listar usuarios autorregistrados que todavía no aprobó ningún admin',
|
|
security: [['oauth2' => ['users:read']]],
|
|
tags: ['Users'],
|
|
responses: [
|
|
new OA\Response(response: 200, description: 'Listado de usuarios pendientes', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/User'))),
|
|
new OA\Response(response: 403, description: 'Sin permiso users.view'),
|
|
]
|
|
)]
|
|
public function pending(Request $request)
|
|
{
|
|
$this->authorize($request, 'users.view');
|
|
|
|
return response()->json(
|
|
User::query()->whereNull('approved_at')->orderBy('created_at')->get()->map($this->present(...))
|
|
);
|
|
}
|
|
|
|
#[OA\Post(
|
|
path: '/users/{user}/approve',
|
|
summary: 'Aprobar un registro pendiente',
|
|
security: [['oauth2' => ['users:write']]],
|
|
tags: ['Users'],
|
|
parameters: [new OA\Parameter(name: 'user', in: 'path', required: true, schema: new OA\Schema(type: 'integer'))],
|
|
responses: [
|
|
new OA\Response(response: 200, description: 'Usuario aprobado', content: new OA\JsonContent(ref: '#/components/schemas/User')),
|
|
new OA\Response(response: 403, description: 'Sin permiso users.update'),
|
|
]
|
|
)]
|
|
public function approve(Request $request, User $user)
|
|
{
|
|
$actor = $request->user('api');
|
|
abort_if($actor && ! $actor->can('users.update'), 403);
|
|
|
|
$user->update(['approved_at' => now()]);
|
|
|
|
return response()->json($this->present($user));
|
|
}
|
|
|
|
#[OA\Get(
|
|
path: '/users/{user}/app-roles',
|
|
summary: 'Roles que el usuario tiene en cada app (el propio siempre permitido; otros requieren users.view)',
|
|
security: [['oauth2' => ['users:read']]],
|
|
tags: ['Users'],
|
|
parameters: [new OA\Parameter(name: 'user', in: 'path', required: true, schema: new OA\Schema(type: 'integer'))],
|
|
responses: [new OA\Response(response: 200, description: 'Roles agrupados por app')]
|
|
)]
|
|
public function appRoles(Request $request, User $user)
|
|
{
|
|
$this->authorize($request, 'users.view', $user);
|
|
|
|
$rows = DB::table('app_user_roles')
|
|
->join('oauth_clients', 'oauth_clients.id', '=', 'app_user_roles.client_id')
|
|
->where('app_user_roles.user_id', $user->id)
|
|
->get(['oauth_clients.id as client_id', 'oauth_clients.name as client_name', 'app_user_roles.role']);
|
|
|
|
return response()->json(
|
|
$rows->groupBy('client_id')->map(fn ($group) => [
|
|
'client_id' => $group->first()->client_id,
|
|
'client_name' => $group->first()->client_name,
|
|
'roles' => $group->pluck('role'),
|
|
])->values()
|
|
);
|
|
}
|
|
|
|
#[OA\Post(
|
|
path: '/users/{user}/app-roles',
|
|
summary: 'Asignar un rol a un usuario dentro de una app (requiere users.update; sin bypass por self)',
|
|
security: [['oauth2' => ['users:write']]],
|
|
tags: ['Users'],
|
|
parameters: [new OA\Parameter(name: 'user', in: 'path', required: true, schema: new OA\Schema(type: 'integer'))],
|
|
requestBody: new OA\RequestBody(
|
|
required: true,
|
|
content: new OA\JsonContent(
|
|
required: ['client_id', 'role'],
|
|
properties: [
|
|
new OA\Property(property: 'client_id', type: 'string', format: 'uuid'),
|
|
new OA\Property(property: 'role', type: 'string', example: 'admin'),
|
|
]
|
|
)
|
|
),
|
|
responses: [
|
|
new OA\Response(response: 201, description: 'Rol asignado'),
|
|
new OA\Response(response: 403, description: 'Sin permiso users.update'),
|
|
]
|
|
)]
|
|
public function assignAppRole(Request $request, User $user)
|
|
{
|
|
$actor = $request->user('api');
|
|
abort_if($actor && ! $actor->can('users.update'), 403);
|
|
|
|
$data = $request->validate([
|
|
'client_id' => ['required', 'exists:oauth_clients,id'],
|
|
'role' => ['required', Rule::in(Role::pluck('name'))],
|
|
]);
|
|
|
|
DB::table('app_user_roles')->insertOrIgnore([
|
|
'user_id' => $user->id,
|
|
'client_id' => $data['client_id'],
|
|
'role' => $data['role'],
|
|
'created_at' => now(),
|
|
'updated_at' => now(),
|
|
]);
|
|
|
|
return response()->json($data, 201);
|
|
}
|
|
|
|
#[OA\Delete(
|
|
path: '/users/{user}/app-roles/{clientId}/{role}',
|
|
summary: 'Quitarle a un usuario un rol dentro de una app (requiere users.update)',
|
|
security: [['oauth2' => ['users:write']]],
|
|
tags: ['Users'],
|
|
parameters: [
|
|
new OA\Parameter(name: 'user', in: 'path', required: true, schema: new OA\Schema(type: 'integer')),
|
|
new OA\Parameter(name: 'clientId', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
|
new OA\Parameter(name: 'role', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
|
],
|
|
responses: [
|
|
new OA\Response(response: 204, description: 'Rol quitado'),
|
|
new OA\Response(response: 403, description: 'Sin permiso users.update'),
|
|
]
|
|
)]
|
|
public function revokeAppRole(Request $request, User $user, string $clientId, string $role)
|
|
{
|
|
$actor = $request->user('api');
|
|
abort_if($actor && ! $actor->can('users.update'), 403);
|
|
|
|
DB::table('app_user_roles')
|
|
->where('user_id', $user->id)
|
|
->where('client_id', $clientId)
|
|
->where('role', $role)
|
|
->delete();
|
|
|
|
return response()->noContent();
|
|
}
|
|
|
|
/**
|
|
* Un token client_credentials (sin usuario) actúa como servicio de confianza:
|
|
* ya validado por scope, no necesita permiso de usuario. Si hay usuario, puede
|
|
* operar siempre sobre sí mismo; sobre otros usuarios necesita el permiso dado.
|
|
*/
|
|
private function authorize(Request $request, string $permission, ?User $target = null): ?User
|
|
{
|
|
$actor = $request->user('api');
|
|
|
|
if (! $actor) {
|
|
return null;
|
|
}
|
|
|
|
$isSelf = $target && $target->id === $actor->id;
|
|
|
|
if (! $isSelf && ! $actor->can($permission)) {
|
|
abort(403);
|
|
}
|
|
|
|
return $actor;
|
|
}
|
|
|
|
/**
|
|
* Devuelve el usuario más "app_roles": los roles que tiene asignados
|
|
* específicamente para la app que está llamando (el cliente OAuth del
|
|
* token actual), guardados en app_user_roles — un sistema aparte del rol
|
|
* global de spatie (ver Admin\UserController::appRoles).
|
|
*/
|
|
private function present(User $user): array
|
|
{
|
|
$callingClientId = Auth::guard('api')->client()?->id;
|
|
|
|
$appRoles = $callingClientId
|
|
? DB::table('app_user_roles')
|
|
->where('user_id', $user->id)
|
|
->where('client_id', $callingClientId)
|
|
->pluck('role')
|
|
: collect();
|
|
|
|
return [
|
|
'id' => $user->id,
|
|
'name' => $user->name,
|
|
'email' => $user->email,
|
|
'is_active' => $user->is_active,
|
|
'roles' => $user->getRoleNames(),
|
|
'app_roles' => $appRoles,
|
|
'created_at' => $user->created_at,
|
|
'approved_at' => $user->approved_at,
|
|
];
|
|
}
|
|
}
|