Initial commit: OAuth IAM Laravel application
Laravel-based OAuth/IAM service with Passport, Vue 3 frontend, and Docker Compose setup for local and production deployment. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
28
.dockerignore
Normal file
28
.dockerignore
Normal file
@@ -0,0 +1,28 @@
|
||||
.git
|
||||
.gitignore
|
||||
.env
|
||||
.env.*
|
||||
!.env.production.example
|
||||
node_modules
|
||||
vendor
|
||||
public/build
|
||||
public/hot
|
||||
public/storage
|
||||
storage/*.key
|
||||
storage/api-docs
|
||||
storage/framework/cache/*
|
||||
storage/framework/sessions/*
|
||||
storage/framework/testing/*
|
||||
storage/framework/views/*
|
||||
storage/logs/*
|
||||
storage/pail
|
||||
docs/*.pdf
|
||||
bootstrap/cache/*.php
|
||||
tests
|
||||
.phpunit.cache
|
||||
.phpunit.result.cache
|
||||
.idea
|
||||
.vscode
|
||||
.DS_Store
|
||||
Dockerfile
|
||||
docker-compose*.yml
|
||||
18
.editorconfig
Normal file
18
.editorconfig
Normal file
@@ -0,0 +1,18 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_size = 4
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.{yml,yaml}]
|
||||
indent_size = 2
|
||||
|
||||
[{compose,docker-compose}.{yml,yaml}]
|
||||
indent_size = 4
|
||||
65
.env.example
Normal file
65
.env.example
Normal file
@@ -0,0 +1,65 @@
|
||||
APP_NAME=Laravel
|
||||
APP_ENV=local
|
||||
APP_KEY=
|
||||
APP_DEBUG=true
|
||||
APP_URL=http://localhost
|
||||
|
||||
APP_LOCALE=en
|
||||
APP_FALLBACK_LOCALE=en
|
||||
APP_FAKER_LOCALE=en_US
|
||||
|
||||
APP_MAINTENANCE_DRIVER=file
|
||||
# APP_MAINTENANCE_STORE=database
|
||||
|
||||
# PHP_CLI_SERVER_WORKERS=4
|
||||
|
||||
BCRYPT_ROUNDS=12
|
||||
|
||||
LOG_CHANNEL=stack
|
||||
LOG_STACK=single
|
||||
LOG_DEPRECATIONS_CHANNEL=null
|
||||
LOG_LEVEL=debug
|
||||
|
||||
DB_CONNECTION=sqlite
|
||||
# DB_HOST=127.0.0.1
|
||||
# DB_PORT=3306
|
||||
# DB_DATABASE=laravel
|
||||
# DB_USERNAME=root
|
||||
# DB_PASSWORD=
|
||||
|
||||
SESSION_DRIVER=database
|
||||
SESSION_LIFETIME=120
|
||||
SESSION_ENCRYPT=false
|
||||
SESSION_PATH=/
|
||||
SESSION_DOMAIN=null
|
||||
|
||||
BROADCAST_CONNECTION=log
|
||||
FILESYSTEM_DISK=local
|
||||
QUEUE_CONNECTION=database
|
||||
|
||||
CACHE_STORE=database
|
||||
# CACHE_PREFIX=
|
||||
|
||||
MEMCACHED_HOST=127.0.0.1
|
||||
|
||||
REDIS_CLIENT=phpredis
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_PASSWORD=null
|
||||
REDIS_PORT=6379
|
||||
|
||||
MAIL_MAILER=log
|
||||
MAIL_SCHEME=null
|
||||
MAIL_HOST=127.0.0.1
|
||||
MAIL_PORT=2525
|
||||
MAIL_USERNAME=null
|
||||
MAIL_PASSWORD=null
|
||||
MAIL_FROM_ADDRESS="hello@example.com"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
AWS_DEFAULT_REGION=us-east-1
|
||||
AWS_BUCKET=
|
||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
46
.env.production.example
Normal file
46
.env.production.example
Normal file
@@ -0,0 +1,46 @@
|
||||
APP_NAME="IAM"
|
||||
APP_ENV=production
|
||||
APP_KEY=
|
||||
APP_DEBUG=false
|
||||
APP_URL=https://tu-dominio.com
|
||||
|
||||
APP_LOCALE=es
|
||||
APP_FALLBACK_LOCALE=en
|
||||
APP_FAKER_LOCALE=es_ES
|
||||
|
||||
LOG_CHANNEL=stack
|
||||
LOG_STACK=single
|
||||
LOG_LEVEL=warning
|
||||
|
||||
# --- Base de datos (PostgreSQL, servicio "db" del docker-compose) ---
|
||||
DB_CONNECTION=pgsql
|
||||
DB_HOST=db
|
||||
DB_PORT=5432
|
||||
DB_DATABASE=oauth_iam
|
||||
DB_USERNAME=oauth_iam
|
||||
DB_PASSWORD=
|
||||
|
||||
# --- Sesión / cache / colas (Redis, servicio "redis") ---
|
||||
SESSION_DRIVER=redis
|
||||
SESSION_LIFETIME=120
|
||||
SESSION_ENCRYPT=false
|
||||
SESSION_SECURE_COOKIE=true
|
||||
SESSION_DOMAIN=.tu-dominio.com
|
||||
|
||||
CACHE_STORE=redis
|
||||
QUEUE_CONNECTION=redis
|
||||
|
||||
REDIS_CLIENT=phpredis
|
||||
REDIS_HOST=redis
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=null
|
||||
|
||||
FILESYSTEM_DISK=local
|
||||
|
||||
MAIL_MAILER=log
|
||||
MAIL_FROM_ADDRESS="no-reply@tu-dominio.com"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
|
||||
L5_SWAGGER_GENERATE_ALWAYS=false
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
11
.gitattributes
vendored
Normal file
11
.gitattributes
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
* text=auto eol=lf
|
||||
|
||||
*.blade.php diff=html
|
||||
*.css diff=css
|
||||
*.html diff=html
|
||||
*.md diff=markdown
|
||||
*.php diff=php
|
||||
|
||||
/.github export-ignore
|
||||
CHANGELOG.md export-ignore
|
||||
.styleci.yml export-ignore
|
||||
28
.gitignore
vendored
Normal file
28
.gitignore
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
*.log
|
||||
.DS_Store
|
||||
.env
|
||||
.env.backup
|
||||
.env.production
|
||||
.phpactor.json
|
||||
.phpunit.result.cache
|
||||
/.claude/
|
||||
/.codex
|
||||
/.cursor/
|
||||
/.idea
|
||||
/.nova
|
||||
/.phpunit.cache
|
||||
/.vscode
|
||||
/.zed
|
||||
/auth.json
|
||||
/node_modules
|
||||
/public/build
|
||||
/public/fonts-manifest.dev.json
|
||||
/public/hot
|
||||
/public/storage
|
||||
/storage/*.key
|
||||
/storage/pail
|
||||
/vendor
|
||||
_ide_helper.php
|
||||
Homestead.json
|
||||
Homestead.yaml
|
||||
Thumbs.db
|
||||
76
Dockerfile
Normal file
76
Dockerfile
Normal file
@@ -0,0 +1,76 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
#
|
||||
# ---- 1. Frontend build (Vite: Vue + TS + SCSS) ----
|
||||
#
|
||||
FROM node:20-alpine AS frontend
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY resources/ resources/
|
||||
COPY vite.config.ts tsconfig.json tsconfig.node.json ./
|
||||
RUN npm run build
|
||||
|
||||
#
|
||||
# ---- 2. PHP dependencies (no dev, no scripts — no app code/DB available yet) ----
|
||||
#
|
||||
FROM composer:2 AS vendor
|
||||
WORKDIR /app
|
||||
COPY composer.json composer.lock ./
|
||||
RUN composer install \
|
||||
--no-dev \
|
||||
--no-scripts \
|
||||
--no-interaction \
|
||||
--no-progress \
|
||||
--prefer-dist \
|
||||
--optimize-autoloader
|
||||
|
||||
#
|
||||
# ---- 3. Application image (PHP-FPM) ----
|
||||
#
|
||||
FROM php:8.4-fpm-alpine AS app
|
||||
|
||||
RUN apk add --no-cache \
|
||||
postgresql-libs \
|
||||
icu-libs \
|
||||
oniguruma \
|
||||
libzip \
|
||||
su-exec \
|
||||
&& apk add --no-cache --virtual .build-deps \
|
||||
postgresql-dev \
|
||||
icu-dev \
|
||||
oniguruma-dev \
|
||||
libzip-dev \
|
||||
linux-headers \
|
||||
$PHPIZE_DEPS \
|
||||
&& docker-php-ext-install -j$(nproc) pdo pdo_pgsql bcmath intl zip pcntl opcache \
|
||||
&& pecl install redis \
|
||||
&& docker-php-ext-enable redis \
|
||||
&& apk del .build-deps
|
||||
|
||||
COPY docker/php/production.ini /usr/local/etc/php/conf.d/zz-production.ini
|
||||
COPY docker/php/opcache.ini /usr/local/etc/php/conf.d/zz-opcache.ini
|
||||
|
||||
WORKDIR /var/www/html
|
||||
|
||||
COPY --from=vendor /app/vendor vendor/
|
||||
COPY . .
|
||||
COPY --from=frontend /app/public/build public/build
|
||||
|
||||
RUN php artisan package:discover --ansi
|
||||
|
||||
RUN chown -R www-data:www-data storage bootstrap/cache \
|
||||
&& chmod -R 775 storage bootstrap/cache
|
||||
|
||||
COPY docker/php/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||
|
||||
ENTRYPOINT ["entrypoint.sh"]
|
||||
CMD ["php-fpm"]
|
||||
|
||||
#
|
||||
# ---- 4. Nginx (static assets + reverse proxy to php-fpm) ----
|
||||
#
|
||||
FROM nginx:1.27-alpine AS nginx
|
||||
COPY --from=frontend /app/public /var/www/html/public
|
||||
COPY docker/nginx/default.conf /etc/nginx/conf.d/default.conf
|
||||
58
README.md
Normal file
58
README.md
Normal file
@@ -0,0 +1,58 @@
|
||||
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
|
||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
|
||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
|
||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
|
||||
</p>
|
||||
|
||||
## About Laravel
|
||||
|
||||
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
|
||||
|
||||
- [Simple, fast routing engine](https://laravel.com/docs/routing).
|
||||
- [Powerful dependency injection container](https://laravel.com/docs/container).
|
||||
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
|
||||
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
|
||||
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
|
||||
- [Robust background job processing](https://laravel.com/docs/queues).
|
||||
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
|
||||
|
||||
Laravel is accessible, powerful, and provides tools required for large, robust applications.
|
||||
|
||||
## Learning Laravel
|
||||
|
||||
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework.
|
||||
|
||||
In addition, [Laracasts](https://laracasts.com) contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
|
||||
|
||||
You can also watch bite-sized lessons with real-world projects on [Laravel Learn](https://laravel.com/learn), where you will be guided through building a Laravel application from scratch while learning PHP fundamentals.
|
||||
|
||||
## Agentic Development
|
||||
|
||||
Laravel's predictable structure and conventions make it ideal for AI coding agents like Claude Code, Cursor, and GitHub Copilot. Install [Laravel Boost](https://laravel.com/docs/ai) to supercharge your AI workflow:
|
||||
|
||||
```bash
|
||||
composer require laravel/boost --dev
|
||||
|
||||
php artisan boost:install
|
||||
```
|
||||
|
||||
Boost provides your agent 15+ tools and skills that help agents build Laravel applications while following best practices.
|
||||
|
||||
## Contributing
|
||||
|
||||
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
|
||||
|
||||
## Security Vulnerabilities
|
||||
|
||||
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
|
||||
|
||||
## License
|
||||
|
||||
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
|
||||
17
app/Http/Controllers/Admin/AnalyticsController.php
Normal file
17
app/Http/Controllers/Admin/AnalyticsController.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Support\AnalyticsSummary;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class AnalyticsController extends Controller
|
||||
{
|
||||
public function summary(Request $request)
|
||||
{
|
||||
$days = min(max((int) $request->integer('days', 30), 7), 90);
|
||||
|
||||
return response()->json(AnalyticsSummary::build($days));
|
||||
}
|
||||
}
|
||||
20
app/Http/Controllers/Admin/AppController.php
Normal file
20
app/Http/Controllers/Admin/AppController.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Laravel\Passport\Client;
|
||||
|
||||
class AppController extends Controller
|
||||
{
|
||||
/**
|
||||
* Todas las apps (clientes OAuth) registradas en el sistema, sin importar
|
||||
* quién las creó — para que el admin elija a cuál asignarle un rol por-app.
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
return response()->json(
|
||||
Client::query()->where('revoked', false)->orderBy('name')->get(['id', 'name'])
|
||||
);
|
||||
}
|
||||
}
|
||||
198
app/Http/Controllers/Admin/UserController.php
Normal file
198
app/Http/Controllers/Admin/UserController.php
Normal file
@@ -0,0 +1,198 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Spatie\Permission\Models\Role;
|
||||
|
||||
class UserController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
return response()->json(
|
||||
User::query()->whereNotNull('approved_at')->orderBy('name')->get()->map($this->present(...))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Usuarios borrados (soft delete) — la "papelera".
|
||||
*/
|
||||
public function trashed()
|
||||
{
|
||||
return response()->json(
|
||||
User::onlyTrashed()->orderByDesc('deleted_at')->get()->map($this->present(...))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Usuarios que se autorregistraron y todavía no aprobó ningún admin.
|
||||
*/
|
||||
public function pending()
|
||||
{
|
||||
return response()->json(
|
||||
User::query()->whereNull('approved_at')->orderBy('created_at')->get()->map($this->present(...))
|
||||
);
|
||||
}
|
||||
|
||||
public function approve(User $user)
|
||||
{
|
||||
$user->update(['approved_at' => now()]);
|
||||
|
||||
return response()->json($this->present($user));
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'string', 'email', 'max:255', Rule::unique('users', 'email')],
|
||||
'password' => ['required', 'string', 'min:8'],
|
||||
'role' => ['required', 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 da de alta un admin: ya está aprobado.
|
||||
]);
|
||||
|
||||
$user->assignRole($data['role']);
|
||||
|
||||
return response()->json($this->present($user), 201);
|
||||
}
|
||||
|
||||
public function update(Request $request, User $user)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'string', 'email', 'max:255', Rule::unique('users', 'email')->ignore($user->id)],
|
||||
'password' => ['nullable', 'string', 'min:8'],
|
||||
'is_active' => ['boolean'],
|
||||
'role' => ['required', Rule::in(Role::pluck('name'))],
|
||||
]);
|
||||
|
||||
if ($user->id === $request->user()->id && $data['role'] !== 'admin' && $user->hasRole('admin')) {
|
||||
abort(422, __('messages.cannot_remove_own_admin_role'));
|
||||
}
|
||||
|
||||
$user->update([
|
||||
'name' => $data['name'],
|
||||
'email' => $data['email'],
|
||||
'is_active' => $data['is_active'] ?? $user->is_active,
|
||||
...(isset($data['password']) ? ['password' => Hash::make($data['password'])] : []),
|
||||
]);
|
||||
|
||||
$user->syncRoles([$data['role']]);
|
||||
|
||||
return response()->json($this->present($user->fresh()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft delete: el usuario deja de aparecer en el sistema pero queda
|
||||
* recuperable desde la papelera.
|
||||
*/
|
||||
public function destroy(Request $request, User $user)
|
||||
{
|
||||
abort_if($user->id === $request->user()->id, 422, __('messages.cannot_delete_self'));
|
||||
|
||||
if ($user->hasRole('admin') && User::role('admin')->count() <= 1) {
|
||||
abort(422, __('messages.cannot_delete_last_admin'));
|
||||
}
|
||||
|
||||
$user->delete();
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
|
||||
public function restore(int $id)
|
||||
{
|
||||
$user = User::onlyTrashed()->findOrFail($id);
|
||||
$user->restore();
|
||||
|
||||
return response()->json($this->present($user));
|
||||
}
|
||||
|
||||
/**
|
||||
* Borrado definitivo — solo tiene sentido sobre alguien que ya está en la papelera.
|
||||
*/
|
||||
public function forceDelete(Request $request, int $id)
|
||||
{
|
||||
$user = User::onlyTrashed()->findOrFail($id);
|
||||
|
||||
$user->forceDelete();
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
|
||||
/**
|
||||
* Roles que este usuario tiene asignados en cada app (cliente OAuth), agrupados.
|
||||
* Es un sistema aparte del rol global de spatie: vive en app_user_roles,
|
||||
* no en las tablas de spatie (evita mezclar "quién administra este panel"
|
||||
* con "qué rol tiene este usuario dentro de la app de un tercero").
|
||||
*/
|
||||
public function appRoles(User $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()
|
||||
);
|
||||
}
|
||||
|
||||
public function assignAppRole(Request $request, User $user)
|
||||
{
|
||||
$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);
|
||||
}
|
||||
|
||||
public function revokeAppRole(User $user, string $clientId, string $role)
|
||||
{
|
||||
DB::table('app_user_roles')
|
||||
->where('user_id', $user->id)
|
||||
->where('client_id', $clientId)
|
||||
->where('role', $role)
|
||||
->delete();
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
|
||||
private function present(User $user): array
|
||||
{
|
||||
return [
|
||||
'id' => $user->id,
|
||||
'name' => $user->name,
|
||||
'email' => $user->email,
|
||||
'is_active' => $user->is_active,
|
||||
'roles' => $user->getRoleNames(),
|
||||
'created_at' => $user->created_at,
|
||||
'deleted_at' => $user->deleted_at,
|
||||
'approved_at' => $user->approved_at,
|
||||
];
|
||||
}
|
||||
}
|
||||
43
app/Http/Controllers/Api/AnalyticsController.php
Normal file
43
app/Http/Controllers/Api/AnalyticsController.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Support\AnalyticsSummary;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controllers\HasMiddleware;
|
||||
use Illuminate\Routing\Controllers\Middleware;
|
||||
use Laravel\Passport\Http\Middleware\CheckToken;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class AnalyticsController extends Controller implements HasMiddleware
|
||||
{
|
||||
public static function middleware(): array
|
||||
{
|
||||
return [new Middleware(CheckToken::using('users:read'))];
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
path: '/analytics/summary',
|
||||
summary: 'Métricas de uso: logins por día, por app, usuarios más activos y actividad reciente',
|
||||
description: 'Requiere el permiso users.view (o un token client_credentials, que actúa como servicio de confianza).',
|
||||
security: [['oauth2' => ['users:read']]],
|
||||
tags: ['Analytics'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'days', in: 'query', required: false, description: 'Ventana en días (7-90, default 30)', schema: new OA\Schema(type: 'integer')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Resumen de analíticas'),
|
||||
new OA\Response(response: 403, description: 'Sin permiso users.view'),
|
||||
]
|
||||
)]
|
||||
public function summary(Request $request)
|
||||
{
|
||||
$actor = $request->user('api');
|
||||
abort_if($actor && ! $actor->can('users.view'), 403);
|
||||
|
||||
$days = min(max((int) $request->integer('days', 30), 7), 90);
|
||||
|
||||
return response()->json(AnalyticsSummary::build($days));
|
||||
}
|
||||
}
|
||||
41
app/Http/Controllers/Api/AppController.php
Normal file
41
app/Http/Controllers/Api/AppController.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controllers\HasMiddleware;
|
||||
use Illuminate\Routing\Controllers\Middleware;
|
||||
use Laravel\Passport\Client;
|
||||
use Laravel\Passport\Http\Middleware\CheckToken;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class AppController extends Controller implements HasMiddleware
|
||||
{
|
||||
public static function middleware(): array
|
||||
{
|
||||
return [new Middleware(CheckToken::using('users:read'))];
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
path: '/apps',
|
||||
summary: 'Listar todas las apps (clientes OAuth) registradas en el sistema',
|
||||
description: 'Requiere el permiso users.view (o un token client_credentials, que actúa como servicio de confianza). Se usa para armar el selector de "roles por app".',
|
||||
security: [['oauth2' => ['users:read']]],
|
||||
tags: ['Users'],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Listado de apps'),
|
||||
new OA\Response(response: 403, description: 'Sin permiso users.view'),
|
||||
]
|
||||
)]
|
||||
public function index(Request $request)
|
||||
{
|
||||
$actor = $request->user('api');
|
||||
|
||||
abort_if($actor && ! $actor->can('users.view'), 403);
|
||||
|
||||
return response()->json(
|
||||
Client::query()->where('revoked', false)->orderBy('name')->get(['id', 'name'])
|
||||
);
|
||||
}
|
||||
}
|
||||
71
app/Http/Controllers/Api/AuthorizedAppController.php
Normal file
71
app/Http/Controllers/Api/AuthorizedAppController.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controllers\HasMiddleware;
|
||||
use Illuminate\Routing\Controllers\Middleware;
|
||||
use Laravel\Passport\Http\Middleware\CheckToken;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class AuthorizedAppController extends Controller implements HasMiddleware
|
||||
{
|
||||
public static function middleware(): array
|
||||
{
|
||||
return [
|
||||
new Middleware(CheckToken::using('account:read'), only: ['index']),
|
||||
new Middleware(CheckToken::using('account:write'), only: ['destroy']),
|
||||
];
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
path: '/authorized-apps',
|
||||
summary: 'Listar apps de terceros a las que le diste acceso a tu cuenta',
|
||||
security: [['oauth2' => ['account:read']]],
|
||||
tags: ['Account'],
|
||||
responses: [new OA\Response(response: 200, description: 'Listado de tokens/apps autorizadas')]
|
||||
)]
|
||||
public function index(Request $request)
|
||||
{
|
||||
$user = $this->requireUser($request);
|
||||
|
||||
$tokens = $user->tokens()
|
||||
->where('revoked', false)
|
||||
->with('client')
|
||||
->get()
|
||||
->reject(fn ($token) => $token->client->revoked || $token->client->hasGrantType('personal_access'))
|
||||
->values();
|
||||
|
||||
return response()->json($tokens);
|
||||
}
|
||||
|
||||
#[OA\Delete(
|
||||
path: '/authorized-apps/{tokenId}',
|
||||
summary: 'Revocar el acceso de una app de terceros',
|
||||
security: [['oauth2' => ['account:write']]],
|
||||
tags: ['Account'],
|
||||
parameters: [new OA\Parameter(name: 'tokenId', in: 'path', required: true, schema: new OA\Schema(type: 'string'))],
|
||||
responses: [
|
||||
new OA\Response(response: 204, description: 'Acceso revocado'),
|
||||
new OA\Response(response: 404, description: 'No existe o no es tuyo'),
|
||||
]
|
||||
)]
|
||||
public function destroy(Request $request, string $tokenId)
|
||||
{
|
||||
$token = $this->requireUser($request)->tokens()->where('id', $tokenId)->first();
|
||||
|
||||
abort_unless($token, 404);
|
||||
|
||||
$token->revoke();
|
||||
$token->refreshToken?->revoke();
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
|
||||
private function requireUser(Request $request): User
|
||||
{
|
||||
return $request->user('api') ?? abort(422, __('messages.self_service_requires_user'));
|
||||
}
|
||||
}
|
||||
170
app/Http/Controllers/Api/ClientController.php
Normal file
170
app/Http/Controllers/Api/ClientController.php
Normal file
@@ -0,0 +1,170 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
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\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Laravel\Passport\Client;
|
||||
use Laravel\Passport\Http\Middleware\CheckToken;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
#[OA\Schema(
|
||||
schema: 'Client',
|
||||
type: 'object',
|
||||
properties: [
|
||||
new OA\Property(property: 'id', type: 'string', format: 'uuid'),
|
||||
new OA\Property(property: 'name', type: 'string', example: 'Mi app Angular'),
|
||||
new OA\Property(property: 'grant_types', type: 'array', items: new OA\Items(type: 'string'), example: ['authorization_code', 'refresh_token']),
|
||||
new OA\Property(property: 'redirect_uris', type: 'array', items: new OA\Items(type: 'string'), example: ['https://mi-app.com/callback']),
|
||||
new OA\Property(property: 'revoked', type: 'boolean'),
|
||||
]
|
||||
)]
|
||||
class ClientController extends Controller implements HasMiddleware
|
||||
{
|
||||
public static function middleware(): array
|
||||
{
|
||||
return [
|
||||
new Middleware(CheckToken::using('account:read'), only: ['index']),
|
||||
new Middleware(CheckToken::using('account:write'), only: ['store', 'update', 'destroy']),
|
||||
];
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
path: '/clients',
|
||||
summary: 'Listar tus clientes OAuth (apps que registraste)',
|
||||
security: [['oauth2' => ['account:read']]],
|
||||
tags: ['Account'],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Listado de clientes', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/Client'))),
|
||||
new OA\Response(response: 422, description: 'El token no tiene un usuario asociado (ej. client_credentials)'),
|
||||
]
|
||||
)]
|
||||
public function index(Request $request)
|
||||
{
|
||||
return response()->json(
|
||||
$this->requireUser($request)->oauthApps()->where('revoked', false)->orderByDesc('created_at')->get()
|
||||
);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
path: '/clients',
|
||||
summary: 'Registrar un nuevo cliente OAuth (tu app)',
|
||||
security: [['oauth2' => ['account:write']]],
|
||||
tags: ['Account'],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: new OA\JsonContent(
|
||||
required: ['name', 'grant_type'],
|
||||
properties: [
|
||||
new OA\Property(property: 'name', type: 'string'),
|
||||
new OA\Property(property: 'grant_type', type: 'string', enum: ['authorization_code', 'client_credentials', 'password']),
|
||||
new OA\Property(property: 'redirect_uris', type: 'array', items: new OA\Items(type: 'string')),
|
||||
new OA\Property(property: 'confidential', type: 'boolean', default: true),
|
||||
]
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(response: 201, description: 'Cliente creado (incluye plain_secret una sola vez)'),
|
||||
new OA\Response(response: 422, description: 'Datos inválidos o token sin usuario asociado'),
|
||||
]
|
||||
)]
|
||||
public function store(Request $request)
|
||||
{
|
||||
$user = $this->requireUser($request);
|
||||
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'grant_type' => ['required', Rule::in(['authorization_code', 'client_credentials', 'password'])],
|
||||
'redirect_uris' => ['required_if:grant_type,authorization_code', 'array'],
|
||||
'redirect_uris.*' => ['url'],
|
||||
'confidential' => ['boolean'],
|
||||
]);
|
||||
|
||||
$confidential = $request->boolean('confidential', true);
|
||||
|
||||
$grantTypes = match ($data['grant_type']) {
|
||||
'authorization_code' => ['authorization_code', 'refresh_token'],
|
||||
'password' => ['password', 'refresh_token'],
|
||||
'client_credentials' => ['client_credentials'],
|
||||
};
|
||||
|
||||
// client_credentials always needs a secret: there is no user present to prove identity.
|
||||
if ($data['grant_type'] === 'client_credentials') {
|
||||
$confidential = true;
|
||||
}
|
||||
|
||||
$client = $user->oauthApps()->create([
|
||||
'name' => $data['name'],
|
||||
'secret' => $confidential ? Str::random(40) : null,
|
||||
'provider' => null,
|
||||
'redirect_uris' => $data['redirect_uris'] ?? [],
|
||||
'grant_types' => $grantTypes,
|
||||
'revoked' => false,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'client' => $client,
|
||||
'plain_secret' => $client->plainSecret,
|
||||
], 201);
|
||||
}
|
||||
|
||||
#[OA\Put(
|
||||
path: '/clients/{client}',
|
||||
summary: 'Editar uno de tus clientes OAuth',
|
||||
security: [['oauth2' => ['account:write']]],
|
||||
tags: ['Account'],
|
||||
parameters: [new OA\Parameter(name: 'client', in: 'path', required: true, schema: new OA\Schema(type: 'string'))],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Cliente actualizado', content: new OA\JsonContent(ref: '#/components/schemas/Client')),
|
||||
new OA\Response(response: 404, description: 'No es tuyo o no existe'),
|
||||
]
|
||||
)]
|
||||
public function update(Request $request, Client $client)
|
||||
{
|
||||
abort_unless($client->owner_id === $this->requireUser($request)->id, 404);
|
||||
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'redirect_uris' => ['array'],
|
||||
'redirect_uris.*' => ['url'],
|
||||
]);
|
||||
|
||||
$client->update([
|
||||
'name' => $data['name'],
|
||||
'redirect_uris' => $data['redirect_uris'] ?? $client->redirect_uris,
|
||||
]);
|
||||
|
||||
return response()->json($client);
|
||||
}
|
||||
|
||||
#[OA\Delete(
|
||||
path: '/clients/{client}',
|
||||
summary: 'Revocar uno de tus clientes OAuth (y sus tokens)',
|
||||
security: [['oauth2' => ['account:write']]],
|
||||
tags: ['Account'],
|
||||
parameters: [new OA\Parameter(name: 'client', in: 'path', required: true, schema: new OA\Schema(type: 'string'))],
|
||||
responses: [
|
||||
new OA\Response(response: 204, description: 'Revocado'),
|
||||
new OA\Response(response: 404, description: 'No es tuyo o no existe'),
|
||||
]
|
||||
)]
|
||||
public function destroy(Request $request, Client $client)
|
||||
{
|
||||
abort_unless($client->owner_id === $this->requireUser($request)->id, 404);
|
||||
|
||||
$client->update(['revoked' => true]);
|
||||
$client->tokens()->update(['revoked' => true]);
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
|
||||
private function requireUser(Request $request): User
|
||||
{
|
||||
return $request->user('api') ?? abort(422, __('messages.self_service_requires_user'));
|
||||
}
|
||||
}
|
||||
106
app/Http/Controllers/Api/PersonalAccessTokenController.php
Normal file
106
app/Http/Controllers/Api/PersonalAccessTokenController.php
Normal file
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
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\Validation\Rule;
|
||||
use Laravel\Passport\Http\Middleware\CheckToken;
|
||||
use Laravel\Passport\Passport;
|
||||
use Laravel\Passport\Token;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class PersonalAccessTokenController extends Controller implements HasMiddleware
|
||||
{
|
||||
public static function middleware(): array
|
||||
{
|
||||
return [
|
||||
new Middleware(CheckToken::using('account:read'), only: ['index']),
|
||||
new Middleware(CheckToken::using('account:write'), only: ['store', 'destroy']),
|
||||
];
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
path: '/personal-access-tokens',
|
||||
summary: 'Listar tus Personal Access Tokens',
|
||||
security: [['oauth2' => ['account:read']]],
|
||||
tags: ['Account'],
|
||||
responses: [new OA\Response(response: 200, description: 'Listado de tokens')]
|
||||
)]
|
||||
public function index(Request $request)
|
||||
{
|
||||
return response()->json(
|
||||
$this->requireUser($request)->tokens()
|
||||
->where('revoked', false)
|
||||
->whereHas('client', fn ($q) => $q->whereJsonContains('grant_types', 'personal_access'))
|
||||
->orderByDesc('created_at')
|
||||
->get(['id', 'name', 'scopes', 'created_at', 'expires_at'])
|
||||
);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
path: '/personal-access-tokens',
|
||||
summary: 'Crear un Personal Access Token (tipo API key)',
|
||||
security: [['oauth2' => ['account:write']]],
|
||||
tags: ['Account'],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: new OA\JsonContent(
|
||||
required: ['name'],
|
||||
properties: [
|
||||
new OA\Property(property: 'name', type: 'string'),
|
||||
new OA\Property(property: 'scopes', type: 'array', items: new OA\Items(type: 'string')),
|
||||
]
|
||||
)
|
||||
),
|
||||
responses: [new OA\Response(response: 201, description: 'Token creado (access_token solo se muestra esta vez)')]
|
||||
)]
|
||||
public function store(Request $request)
|
||||
{
|
||||
$user = $this->requireUser($request);
|
||||
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'scopes' => ['array'],
|
||||
'scopes.*' => [Rule::in(Passport::scopeIds())],
|
||||
]);
|
||||
|
||||
$result = $user->createToken($data['name'], $data['scopes'] ?? []);
|
||||
|
||||
return response()->json([
|
||||
'access_token' => $result->accessToken,
|
||||
'token' => $result->token,
|
||||
], 201);
|
||||
}
|
||||
|
||||
#[OA\Delete(
|
||||
path: '/personal-access-tokens/{tokenId}',
|
||||
summary: 'Revocar un Personal Access Token',
|
||||
security: [['oauth2' => ['account:write']]],
|
||||
tags: ['Account'],
|
||||
parameters: [new OA\Parameter(name: 'tokenId', in: 'path', required: true, schema: new OA\Schema(type: 'string'))],
|
||||
responses: [
|
||||
new OA\Response(response: 204, description: 'Revocado'),
|
||||
new OA\Response(response: 404, description: 'No existe o no es tuyo'),
|
||||
]
|
||||
)]
|
||||
public function destroy(Request $request, string $tokenId)
|
||||
{
|
||||
/** @var Token|null $token */
|
||||
$token = $this->requireUser($request)->tokens()->where('id', $tokenId)->first();
|
||||
|
||||
abort_unless($token, 404);
|
||||
|
||||
$token->revoke();
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
|
||||
private function requireUser(Request $request): User
|
||||
{
|
||||
return $request->user('api') ?? abort(422, __('messages.self_service_requires_user'));
|
||||
}
|
||||
}
|
||||
490
app/Http/Controllers/Api/UserController.php
Normal file
490
app/Http/Controllers/Api/UserController.php
Normal file
@@ -0,0 +1,490 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
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'));
|
||||
}
|
||||
|
||||
$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']]);
|
||||
}
|
||||
|
||||
return response()->json($this->present($user->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,
|
||||
];
|
||||
}
|
||||
}
|
||||
141
app/Http/Controllers/Auth/AuthController.php
Normal file
141
app/Http/Controllers/Auth/AuthController.php
Normal file
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\LoginEvent;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class AuthController extends Controller
|
||||
{
|
||||
public function setupStatus()
|
||||
{
|
||||
return response()->json([
|
||||
'empty' => User::query()->count() === 0,
|
||||
]);
|
||||
}
|
||||
|
||||
public function register(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'string', 'email', 'max:255', Rule::unique('users', 'email')],
|
||||
'password' => ['required', 'string', 'min:8', 'confirmed'],
|
||||
]);
|
||||
|
||||
// Instalación nueva (sin usuarios todavía): el primero en registrarse
|
||||
// queda como admin, aprobado y logueado de una — no hay nadie más que
|
||||
// pueda aprobarlo. De ahí en más, todo registro vuelve a requerir
|
||||
// aprobación de un admin como siempre.
|
||||
$isFirstUser = User::query()->count() === 0;
|
||||
|
||||
$user = User::create([
|
||||
'name' => $data['name'],
|
||||
'email' => $data['email'],
|
||||
'password' => Hash::make($data['password']),
|
||||
'is_active' => true,
|
||||
'approved_at' => $isFirstUser ? now() : null,
|
||||
]);
|
||||
|
||||
if ($isFirstUser) {
|
||||
(new RolesAndPermissionsSeeder)->run();
|
||||
$user->assignRole('admin');
|
||||
|
||||
Auth::login($user);
|
||||
$request->session()->regenerate();
|
||||
$this->recordSessionLogin($request, $user);
|
||||
|
||||
return response()->json([
|
||||
'bootstrap' => true,
|
||||
'user' => $this->present($user),
|
||||
'intended' => $request->session()->pull('url.intended'),
|
||||
]);
|
||||
}
|
||||
|
||||
$user->assignRole('user');
|
||||
|
||||
return response()->json([
|
||||
'pending' => true,
|
||||
'message' => __('messages.registration_pending'),
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function login(Request $request)
|
||||
{
|
||||
$credentials = $request->validate([
|
||||
'email' => ['required', 'string', 'email'],
|
||||
'password' => ['required', 'string'],
|
||||
]);
|
||||
|
||||
if (! Auth::attempt($credentials, $request->boolean('remember'))) {
|
||||
throw ValidationException::withMessages([
|
||||
'email' => __('messages.invalid_credentials'),
|
||||
]);
|
||||
}
|
||||
|
||||
if (! Auth::user()->is_active) {
|
||||
Auth::logout();
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'email' => __('messages.account_deactivated'),
|
||||
]);
|
||||
}
|
||||
|
||||
if (Auth::user()->isPending()) {
|
||||
Auth::logout();
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'email' => __('messages.account_pending_approval'),
|
||||
]);
|
||||
}
|
||||
|
||||
$request->session()->regenerate();
|
||||
$this->recordSessionLogin($request, Auth::user());
|
||||
|
||||
return response()->json([
|
||||
'user' => $this->present(Auth::user()),
|
||||
'intended' => $request->session()->pull('url.intended'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function logout(Request $request)
|
||||
{
|
||||
Auth::logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
|
||||
public function me(Request $request)
|
||||
{
|
||||
return response()->json(['user' => $this->present($request->user())]);
|
||||
}
|
||||
|
||||
private function recordSessionLogin(Request $request, User $user): void
|
||||
{
|
||||
LoginEvent::create([
|
||||
'user_id' => $user->id,
|
||||
'client_id' => null,
|
||||
'type' => 'session',
|
||||
'ip_address' => $request->ip(),
|
||||
'user_agent' => $request->userAgent(),
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function present(User $user): array
|
||||
{
|
||||
return [
|
||||
...$user->toArray(),
|
||||
'roles' => $user->getRoleNames(),
|
||||
'permissions' => $user->getAllPermissions()->pluck('name'),
|
||||
];
|
||||
}
|
||||
}
|
||||
55
app/Http/Controllers/Controller.php
Normal file
55
app/Http/Controllers/Controller.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
#[OA\Info(
|
||||
version: '1.0.0',
|
||||
title: 'OAuth2 Identity Server API',
|
||||
description: 'Servidor de identidad/OAuth2 (Laravel Passport) para que aplicaciones de terceros se autentiquen y gestionen usuarios. Pensada para ser consumida por aplicaciones cliente de terceros (Angular, Flutter, NestJS, etc.) que quieran construir su propio panel sin depender de la sesión de Laravel.'
|
||||
)]
|
||||
#[OA\Server(url: '/api', description: 'API base URL')]
|
||||
#[OA\SecurityScheme(
|
||||
securityScheme: 'oauth2',
|
||||
type: 'oauth2',
|
||||
description: 'Servidor de autorización OAuth2 (Laravel Passport). Soporta Authorization Code + PKCE, Client Credentials y Password Grant.',
|
||||
flows: [
|
||||
new OA\Flow(
|
||||
flow: 'authorizationCode',
|
||||
authorizationUrl: '/oauth/authorize',
|
||||
tokenUrl: '/oauth/token',
|
||||
refreshUrl: '/oauth/token',
|
||||
scopes: [
|
||||
'users:read' => 'Leer usuarios',
|
||||
'users:write' => 'Crear, editar y borrar usuarios',
|
||||
'account:read' => 'Leer tus clientes OAuth, tokens y apps autorizadas',
|
||||
'account:write' => 'Gestionar tus clientes OAuth, tokens y apps autorizadas',
|
||||
]
|
||||
),
|
||||
new OA\Flow(
|
||||
flow: 'clientCredentials',
|
||||
tokenUrl: '/oauth/token',
|
||||
scopes: [
|
||||
'users:read' => 'Leer usuarios',
|
||||
'users:write' => 'Crear, editar y borrar usuarios',
|
||||
'account:read' => 'Leer tus clientes OAuth, tokens y apps autorizadas',
|
||||
'account:write' => 'Gestionar tus clientes OAuth, tokens y apps autorizadas',
|
||||
]
|
||||
),
|
||||
new OA\Flow(
|
||||
flow: 'password',
|
||||
tokenUrl: '/oauth/token',
|
||||
scopes: [
|
||||
'users:read' => 'Leer usuarios',
|
||||
'users:write' => 'Crear, editar y borrar usuarios',
|
||||
'account:read' => 'Leer tus clientes OAuth, tokens y apps autorizadas',
|
||||
'account:write' => 'Gestionar tus clientes OAuth, tokens y apps autorizadas',
|
||||
]
|
||||
),
|
||||
]
|
||||
)]
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
36
app/Http/Controllers/OAuth/AuthorizedAppController.php
Normal file
36
app/Http/Controllers/OAuth/AuthorizedAppController.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\OAuth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class AuthorizedAppController extends Controller
|
||||
{
|
||||
/**
|
||||
* List third-party apps (non first-party clients) the user has granted access to.
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
$tokens = $request->user()->tokens()
|
||||
->where('revoked', false)
|
||||
->with('client')
|
||||
->get()
|
||||
->reject(fn ($token) => $token->client->revoked || $token->client->hasGrantType('personal_access'))
|
||||
->values();
|
||||
|
||||
return response()->json($tokens);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, string $tokenId)
|
||||
{
|
||||
$token = $request->user()->tokens()->where('id', $tokenId)->first();
|
||||
|
||||
abort_unless($token, 404);
|
||||
|
||||
$token->revoke();
|
||||
$token->refreshToken?->revoke();
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
}
|
||||
97
app/Http/Controllers/OAuth/ClientController.php
Normal file
97
app/Http/Controllers/OAuth/ClientController.php
Normal file
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\OAuth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Laravel\Passport\Client;
|
||||
|
||||
class ClientController extends Controller
|
||||
{
|
||||
/**
|
||||
* List the OAuth clients owned by the authenticated user.
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
return response()->json(
|
||||
$request->user()->oauthApps()->where('revoked', false)->orderByDesc('created_at')->get()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new OAuth client (authorization_code+PKCE, client_credentials or password).
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'grant_type' => ['required', Rule::in(['authorization_code', 'client_credentials', 'password'])],
|
||||
'redirect_uris' => ['required_if:grant_type,authorization_code', 'array'],
|
||||
'redirect_uris.*' => ['url'],
|
||||
'confidential' => ['boolean'],
|
||||
]);
|
||||
|
||||
$confidential = $request->boolean('confidential', true);
|
||||
|
||||
$grantTypes = match ($data['grant_type']) {
|
||||
'authorization_code' => ['authorization_code', 'refresh_token'],
|
||||
'password' => ['password', 'refresh_token'],
|
||||
'client_credentials' => ['client_credentials'],
|
||||
};
|
||||
|
||||
// client_credentials always needs a secret: there is no user present to prove identity.
|
||||
if ($data['grant_type'] === 'client_credentials') {
|
||||
$confidential = true;
|
||||
}
|
||||
|
||||
$client = $request->user()->oauthApps()->create([
|
||||
'name' => $data['name'],
|
||||
'secret' => $confidential ? Str::random(40) : null,
|
||||
'provider' => null,
|
||||
'redirect_uris' => $data['redirect_uris'] ?? [],
|
||||
'grant_types' => $grantTypes,
|
||||
'revoked' => false,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'client' => $client,
|
||||
'plain_secret' => $client->plainSecret,
|
||||
], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing client's name/redirect URIs.
|
||||
*/
|
||||
public function update(Request $request, Client $client)
|
||||
{
|
||||
abort_unless($client->owner_id === $request->user()->id, 404);
|
||||
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'redirect_uris' => ['array'],
|
||||
'redirect_uris.*' => ['url'],
|
||||
]);
|
||||
|
||||
$client->update([
|
||||
'name' => $data['name'],
|
||||
'redirect_uris' => $data['redirect_uris'] ?? $client->redirect_uris,
|
||||
]);
|
||||
|
||||
return response()->json($client);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke (soft-delete) a client and its tokens.
|
||||
*/
|
||||
public function destroy(Request $request, Client $client)
|
||||
{
|
||||
abort_unless($client->owner_id === $request->user()->id, 404);
|
||||
|
||||
$client->update(['revoked' => true]);
|
||||
$client->tokens()->update(['revoked' => true]);
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
}
|
||||
51
app/Http/Controllers/OAuth/PersonalAccessTokenController.php
Normal file
51
app/Http/Controllers/OAuth/PersonalAccessTokenController.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\OAuth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Laravel\Passport\Passport;
|
||||
use Laravel\Passport\Token;
|
||||
|
||||
class PersonalAccessTokenController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
return response()->json(
|
||||
$request->user()->tokens()
|
||||
->where('revoked', false)
|
||||
->whereHas('client', fn ($q) => $q->whereJsonContains('grant_types', 'personal_access'))
|
||||
->orderByDesc('created_at')
|
||||
->get(['id', 'name', 'scopes', 'created_at', 'expires_at'])
|
||||
);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'scopes' => ['array'],
|
||||
'scopes.*' => [Rule::in(Passport::scopeIds())],
|
||||
]);
|
||||
|
||||
$result = $request->user()->createToken($data['name'], $data['scopes'] ?? []);
|
||||
|
||||
return response()->json([
|
||||
'access_token' => $result->accessToken,
|
||||
'token' => $result->token,
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, string $tokenId)
|
||||
{
|
||||
/** @var Token|null $token */
|
||||
$token = $request->user()->tokens()->where('id', $tokenId)->first();
|
||||
|
||||
abort_unless($token, 404);
|
||||
|
||||
$token->revoke();
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
}
|
||||
22
app/Http/Middleware/SetLocale.php
Normal file
22
app/Http/Middleware/SetLocale.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class SetLocale
|
||||
{
|
||||
private const SUPPORTED = ['es', 'en', 'ca'];
|
||||
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$locale = $request->session()->get('locale', config('app.locale'));
|
||||
|
||||
App::setLocale(in_array($locale, self::SUPPORTED, true) ? $locale : config('app.locale'));
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
31
app/Listeners/RecordOAuthLoginEvent.php
Normal file
31
app/Listeners/RecordOAuthLoginEvent.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Models\LoginEvent;
|
||||
use Illuminate\Http\Request;
|
||||
use Laravel\Passport\Events\AccessTokenCreated;
|
||||
|
||||
/**
|
||||
* Cada vez que Passport emite un access token (cualquier grant type) es, para
|
||||
* fines de auditoría, un "login" del usuario en esa app — o, para
|
||||
* client_credentials (sin userId), actividad de un servicio de confianza.
|
||||
*/
|
||||
class RecordOAuthLoginEvent
|
||||
{
|
||||
public function __construct(private Request $request)
|
||||
{
|
||||
}
|
||||
|
||||
public function handle(AccessTokenCreated $event): void
|
||||
{
|
||||
LoginEvent::create([
|
||||
'user_id' => $event->userId,
|
||||
'client_id' => $event->clientId,
|
||||
'type' => 'oauth',
|
||||
'ip_address' => $this->request->ip(),
|
||||
'user_agent' => $this->request->userAgent(),
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
24
app/Models/LoginEvent.php
Normal file
24
app/Models/LoginEvent.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Laravel\Passport\Client;
|
||||
|
||||
#[Fillable(['user_id', 'client_id', 'type', 'ip_address', 'user_agent', 'created_at'])]
|
||||
class LoginEvent extends Model
|
||||
{
|
||||
const UPDATED_AT = null;
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function client(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Client::class);
|
||||
}
|
||||
}
|
||||
43
app/Models/User.php
Normal file
43
app/Models/User.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Database\Factories\UserFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Laravel\Passport\HasApiTokens;
|
||||
use Spatie\Permission\Traits\HasRoles;
|
||||
|
||||
#[Fillable(['name', 'email', 'password', 'is_active', 'approved_at'])]
|
||||
#[Hidden(['password', 'remember_token'])]
|
||||
class User extends Authenticatable
|
||||
{
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use HasApiTokens, HasFactory, HasRoles, Notifiable, SoftDeletes;
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
'is_active' => 'boolean',
|
||||
'deleted_at' => 'datetime',
|
||||
'approved_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function isPending(): bool
|
||||
{
|
||||
return $this->approved_at === null;
|
||||
}
|
||||
}
|
||||
50
app/Providers/AppServiceProvider.php
Normal file
50
app/Providers/AppServiceProvider.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Listeners\RecordOAuthLoginEvent;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Laravel\Passport\Events\AccessTokenCreated;
|
||||
use Laravel\Passport\Passport;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
Passport::enablePasswordGrant();
|
||||
Passport::$deviceCodeGrantEnabled = false;
|
||||
|
||||
Passport::tokensExpireIn(now()->addDays(15));
|
||||
Passport::refreshTokensExpireIn(now()->addDays(30));
|
||||
Passport::personalAccessTokensExpireIn(now()->addMonths(6));
|
||||
|
||||
Passport::tokensCan([
|
||||
'users:read' => 'Leer usuarios',
|
||||
'users:write' => 'Crear, editar y borrar usuarios',
|
||||
'account:read' => 'Leer tus clientes OAuth, tokens y apps autorizadas',
|
||||
'account:write' => 'Gestionar tus clientes OAuth, tokens y apps autorizadas',
|
||||
]);
|
||||
|
||||
// Sin scope por defecto: los datos de usuarios son sensibles, el cliente
|
||||
// tiene que pedir explícitamente el acceso que necesita.
|
||||
|
||||
// La pantalla de consentimiento la renderiza la SPA Vue (ver Authorize.vue).
|
||||
// Passport inyecta client/user/scopes/authToken; se los pasamos al shell Blade
|
||||
// como JSON para que el JS los lea (ver resources/views/app.blade.php).
|
||||
Passport::authorizationView('app');
|
||||
|
||||
Event::listen(AccessTokenCreated::class, RecordOAuthLoginEvent::class);
|
||||
}
|
||||
}
|
||||
103
app/Support/AnalyticsSummary.php
Normal file
103
app/Support/AnalyticsSummary.php
Normal file
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\LoginEvent;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Laravel\Passport\Token;
|
||||
|
||||
/**
|
||||
* Agregación compartida por Admin\AnalyticsController (sesión) y
|
||||
* Api\AnalyticsController (token) — misma data, dos superficies de auth.
|
||||
*/
|
||||
class AnalyticsSummary
|
||||
{
|
||||
public static function build(int $days = 30): array
|
||||
{
|
||||
$since = Carbon::now()->subDays($days - 1)->startOfDay();
|
||||
|
||||
return [
|
||||
'kpis' => [
|
||||
'total_users' => User::count(),
|
||||
'active_sessions' => Token::query()->where('revoked', false)->where('expires_at', '>', now())->count(),
|
||||
'logins_today' => LoginEvent::whereDate('created_at', today())->count(),
|
||||
'logins_7d' => LoginEvent::where('created_at', '>=', now()->subDays(7))->count(),
|
||||
],
|
||||
'logins_by_day' => self::loginsByDay($since, $days),
|
||||
'logins_by_app' => self::loginsByApp($since),
|
||||
'top_users' => self::topUsers($since),
|
||||
'recent' => self::recent(),
|
||||
];
|
||||
}
|
||||
|
||||
private static function loginsByDay(Carbon $since, int $days): array
|
||||
{
|
||||
$rows = LoginEvent::query()
|
||||
->selectRaw("date(created_at) as day, type, count(*) as count")
|
||||
->where('created_at', '>=', $since)
|
||||
->groupBy('day', 'type')
|
||||
->get()
|
||||
->groupBy('day');
|
||||
|
||||
$out = [];
|
||||
for ($i = 0; $i < $days; $i++) {
|
||||
$date = $since->copy()->addDays($i)->toDateString();
|
||||
$dayRows = $rows->get($date, collect());
|
||||
|
||||
$out[] = [
|
||||
'date' => $date,
|
||||
'session' => (int) $dayRows->firstWhere('type', 'session')?->count,
|
||||
'oauth' => (int) $dayRows->firstWhere('type', 'oauth')?->count,
|
||||
];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
private static function loginsByApp(Carbon $since): array
|
||||
{
|
||||
return LoginEvent::query()
|
||||
->join('oauth_clients', 'oauth_clients.id', '=', 'login_events.client_id')
|
||||
->where('login_events.type', 'oauth')
|
||||
->where('login_events.created_at', '>=', $since)
|
||||
->groupBy('oauth_clients.id', 'oauth_clients.name')
|
||||
->orderByDesc('count')
|
||||
->limit(8)
|
||||
->get(['oauth_clients.name as client_name', DB::raw('count(*) as count')])
|
||||
->toArray();
|
||||
}
|
||||
|
||||
private static function topUsers(Carbon $since): array
|
||||
{
|
||||
return LoginEvent::query()
|
||||
->join('users', 'users.id', '=', 'login_events.user_id')
|
||||
->where('login_events.created_at', '>=', $since)
|
||||
->groupBy('users.id', 'users.name', 'users.email')
|
||||
->orderByDesc('count')
|
||||
->limit(8)
|
||||
->get(['users.name', 'users.email', DB::raw('count(*) as count')])
|
||||
->toArray();
|
||||
}
|
||||
|
||||
private static function recent(int $limit = 15): array
|
||||
{
|
||||
return LoginEvent::with(['user:id,name,email', 'client:id,name'])
|
||||
->latest('created_at')
|
||||
->limit($limit)
|
||||
->get()
|
||||
->map(fn (LoginEvent $event) => [
|
||||
'user_name' => $event->user?->name,
|
||||
'user_email' => $event->user?->email,
|
||||
// null para logins de sesión (el frontend lo traduce como "Panel admin"/"Admin
|
||||
// panel" según el idioma) y para apps borradas (el frontend traduce ese caso
|
||||
// también) — así el backend no filtra texto sin traducir a la UI.
|
||||
'client_name' => $event->type === 'session' ? null : $event->client?->name,
|
||||
'type' => $event->type,
|
||||
'ip_address' => $event->ip_address,
|
||||
'created_at' => $event->created_at,
|
||||
])
|
||||
->toArray();
|
||||
}
|
||||
}
|
||||
18
artisan
Executable file
18
artisan
Executable file
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
use Illuminate\Foundation\Application;
|
||||
use Symfony\Component\Console\Input\ArgvInput;
|
||||
|
||||
define('LARAVEL_START', microtime(true));
|
||||
|
||||
// Register the Composer autoloader...
|
||||
require __DIR__.'/vendor/autoload.php';
|
||||
|
||||
// Bootstrap Laravel and handle the command...
|
||||
/** @var Application $app */
|
||||
$app = require_once __DIR__.'/bootstrap/app.php';
|
||||
|
||||
$status = $app->handleCommand(new ArgvInput);
|
||||
|
||||
exit($status);
|
||||
23
bootstrap/app.php
Normal file
23
bootstrap/app.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Middleware\SetLocale;
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
return Application::configure(basePath: dirname(__DIR__))
|
||||
->withRouting(
|
||||
web: __DIR__.'/../routes/web.php',
|
||||
api: __DIR__.'/../routes/api.php',
|
||||
commands: __DIR__.'/../routes/console.php',
|
||||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
$middleware->web(append: [SetLocale::class]);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
$exceptions->shouldRenderJsonWhen(
|
||||
fn (Request $request) => $request->is('api/*') || $request->is('web-api/*') || $request->expectsJson(),
|
||||
);
|
||||
})->create();
|
||||
2
bootstrap/cache/.gitignore
vendored
Normal file
2
bootstrap/cache/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
7
bootstrap/providers.php
Normal file
7
bootstrap/providers.php
Normal file
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Providers\AppServiceProvider;
|
||||
|
||||
return [
|
||||
AppServiceProvider::class,
|
||||
];
|
||||
90
composer.json
Normal file
90
composer.json
Normal file
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"$schema": "https://getcomposer.org/schema.json",
|
||||
"name": "laravel/laravel",
|
||||
"type": "project",
|
||||
"description": "The skeleton application for the Laravel framework.",
|
||||
"keywords": ["laravel", "framework"],
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "^8.3",
|
||||
"darkaonline/l5-swagger": "^11.1",
|
||||
"laravel/framework": "^13.8",
|
||||
"laravel/passport": "^13.0",
|
||||
"laravel/tinker": "^3.0",
|
||||
"spatie/laravel-permission": "^8.3"
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.23",
|
||||
"laravel-lang/common": "^6.8",
|
||||
"laravel/pail": "^1.2.5",
|
||||
"laravel/pao": "^1.0.6",
|
||||
"laravel/pint": "^1.27",
|
||||
"mockery/mockery": "^1.6",
|
||||
"nunomaduro/collision": "^8.6",
|
||||
"phpunit/phpunit": "^12.5.12"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"App\\": "app/",
|
||||
"Database\\Factories\\": "database/factories/",
|
||||
"Database\\Seeders\\": "database/seeders/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"setup": [
|
||||
"composer install",
|
||||
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
|
||||
"@php artisan key:generate",
|
||||
"@php artisan migrate --force",
|
||||
"npm install --ignore-scripts",
|
||||
"npm run build"
|
||||
],
|
||||
"dev": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1 --timeout=0\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others"
|
||||
],
|
||||
"test": [
|
||||
"@php artisan config:clear --ansi @no_additional_args",
|
||||
"@php artisan test"
|
||||
],
|
||||
"post-autoload-dump": [
|
||||
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
||||
"@php artisan package:discover --ansi"
|
||||
],
|
||||
"post-update-cmd": [
|
||||
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
|
||||
],
|
||||
"post-root-package-install": [
|
||||
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
|
||||
],
|
||||
"post-create-project-cmd": [
|
||||
"@php artisan key:generate --ansi",
|
||||
"@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
|
||||
"@php artisan migrate --graceful --ansi"
|
||||
],
|
||||
"pre-package-uninstall": [
|
||||
"Illuminate\\Foundation\\ComposerScripts::prePackageUninstall"
|
||||
]
|
||||
},
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"dont-discover": []
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"optimize-autoloader": true,
|
||||
"preferred-install": "dist",
|
||||
"sort-packages": true,
|
||||
"allow-plugins": {
|
||||
"pestphp/pest-plugin": true,
|
||||
"php-http/discovery": true
|
||||
}
|
||||
},
|
||||
"minimum-stability": "stable",
|
||||
"prefer-stable": true
|
||||
}
|
||||
11822
composer.lock
generated
Normal file
11822
composer.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
126
config/app.php
Normal file
126
config/app.php
Normal file
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Name
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value is the name of your application, which will be used when the
|
||||
| framework needs to place the application's name in a notification or
|
||||
| other UI elements where an application name needs to be displayed.
|
||||
|
|
||||
*/
|
||||
|
||||
'name' => env('APP_NAME', 'Laravel'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Environment
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value determines the "environment" your application is currently
|
||||
| running in. This may determine how you prefer to configure various
|
||||
| services the application utilizes. Set this in your ".env" file.
|
||||
|
|
||||
*/
|
||||
|
||||
'env' => env('APP_ENV', 'production'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Debug Mode
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When your application is in debug mode, detailed error messages with
|
||||
| stack traces will be shown on every error that occurs within your
|
||||
| application. If disabled, a simple generic error page is shown.
|
||||
|
|
||||
*/
|
||||
|
||||
'debug' => (bool) env('APP_DEBUG', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application URL
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This URL is used by the console to properly generate URLs when using
|
||||
| the Artisan command line tool. You should set this to the root of
|
||||
| the application so that it's available within Artisan commands.
|
||||
|
|
||||
*/
|
||||
|
||||
'url' => env('APP_URL', 'http://localhost'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Timezone
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify the default timezone for your application, which
|
||||
| will be used by the PHP date and date-time functions. The timezone
|
||||
| is set to "UTC" by default as it is suitable for most use cases.
|
||||
|
|
||||
*/
|
||||
|
||||
'timezone' => 'UTC',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Locale Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The application locale determines the default locale that will be used
|
||||
| by Laravel's translation / localization methods. This option can be
|
||||
| set to any locale for which you plan to have translation strings.
|
||||
|
|
||||
*/
|
||||
|
||||
'locale' => env('APP_LOCALE', 'en'),
|
||||
|
||||
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
|
||||
|
||||
'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Encryption Key
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This key is utilized by Laravel's encryption services and should be set
|
||||
| to a random, 32 character string to ensure that all encrypted values
|
||||
| are secure. You should do this prior to deploying the application.
|
||||
|
|
||||
*/
|
||||
|
||||
'cipher' => 'AES-256-CBC',
|
||||
|
||||
'key' => env('APP_KEY'),
|
||||
|
||||
'previous_keys' => [
|
||||
...array_filter(
|
||||
explode(',', (string) env('APP_PREVIOUS_KEYS', ''))
|
||||
),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Maintenance Mode Driver
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| These configuration options determine the driver used to determine and
|
||||
| manage Laravel's "maintenance mode" status. The "cache" driver will
|
||||
| allow maintenance mode to be controlled across multiple machines.
|
||||
|
|
||||
| Supported drivers: "file", "cache"
|
||||
|
|
||||
*/
|
||||
|
||||
'maintenance' => [
|
||||
'driver' => env('APP_MAINTENANCE_DRIVER', 'file'),
|
||||
'store' => env('APP_MAINTENANCE_STORE', 'database'),
|
||||
],
|
||||
|
||||
];
|
||||
122
config/auth.php
Normal file
122
config/auth.php
Normal file
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Authentication Defaults
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option defines the default authentication "guard" and password
|
||||
| reset "broker" for your application. You may change these values
|
||||
| as required, but they're a perfect start for most applications.
|
||||
|
|
||||
*/
|
||||
|
||||
'defaults' => [
|
||||
'guard' => env('AUTH_GUARD', 'web'),
|
||||
'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Authentication Guards
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Next, you may define every authentication guard for your application.
|
||||
| Of course, a great default configuration has been defined for you
|
||||
| which utilizes session storage plus the Eloquent user provider.
|
||||
|
|
||||
| All authentication guards have a user provider, which defines how the
|
||||
| users are actually retrieved out of your database or other storage
|
||||
| system used by the application. Typically, Eloquent is utilized.
|
||||
|
|
||||
| Supported: "session"
|
||||
|
|
||||
*/
|
||||
|
||||
'guards' => [
|
||||
'web' => [
|
||||
'driver' => 'session',
|
||||
'provider' => 'users',
|
||||
],
|
||||
|
||||
'api' => [
|
||||
'driver' => 'passport',
|
||||
'provider' => 'users',
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| User Providers
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| All authentication guards have a user provider, which defines how the
|
||||
| users are actually retrieved out of your database or other storage
|
||||
| system used by the application. Typically, Eloquent is utilized.
|
||||
|
|
||||
| If you have multiple user tables or models you may configure multiple
|
||||
| providers to represent the model / table. These providers may then
|
||||
| be assigned to any extra authentication guards you have defined.
|
||||
|
|
||||
| Supported: "database", "eloquent"
|
||||
|
|
||||
*/
|
||||
|
||||
'providers' => [
|
||||
'users' => [
|
||||
'driver' => 'eloquent',
|
||||
'model' => env('AUTH_MODEL', User::class),
|
||||
],
|
||||
|
||||
// 'users' => [
|
||||
// 'driver' => 'database',
|
||||
// 'table' => 'users',
|
||||
// ],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Resetting Passwords
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| These configuration options specify the behavior of Laravel's password
|
||||
| reset functionality, including the table utilized for token storage
|
||||
| and the user provider that is invoked to actually retrieve users.
|
||||
|
|
||||
| The expiry time is the number of minutes that each reset token will be
|
||||
| considered valid. This security feature keeps tokens short-lived so
|
||||
| they have less time to be guessed. You may change this as needed.
|
||||
|
|
||||
| The throttle setting is the number of seconds a user must wait before
|
||||
| generating more password reset tokens. This prevents the user from
|
||||
| quickly generating a very large amount of password reset tokens.
|
||||
|
|
||||
*/
|
||||
|
||||
'passwords' => [
|
||||
'users' => [
|
||||
'provider' => 'users',
|
||||
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
|
||||
'expire' => 60,
|
||||
'throttle' => 60,
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Password Confirmation Timeout
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may define the number of seconds before a password confirmation
|
||||
| window expires and users are asked to re-enter their password via the
|
||||
| confirmation screen. By default, the timeout lasts for three hours.
|
||||
|
|
||||
*/
|
||||
|
||||
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
|
||||
|
||||
];
|
||||
136
config/cache.php
Normal file
136
config/cache.php
Normal file
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Cache Store
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option controls the default cache store that will be used by the
|
||||
| framework. This connection is utilized if another isn't explicitly
|
||||
| specified when running a cache operation inside the application.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('CACHE_STORE', 'database'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Cache Stores
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may define all of the cache "stores" for your application as
|
||||
| well as their drivers. You may even define multiple stores for the
|
||||
| same cache driver to group types of items stored in your caches.
|
||||
|
|
||||
| Supported drivers: "array", "database", "file", "memcached",
|
||||
| "redis", "dynamodb", "storage", "octane",
|
||||
| "session", "failover", "null"
|
||||
|
|
||||
*/
|
||||
|
||||
'stores' => [
|
||||
|
||||
'array' => [
|
||||
'driver' => 'array',
|
||||
'serialize' => false,
|
||||
],
|
||||
|
||||
'database' => [
|
||||
'driver' => 'database',
|
||||
'connection' => env('DB_CACHE_CONNECTION'),
|
||||
'table' => env('DB_CACHE_TABLE', 'cache'),
|
||||
'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'),
|
||||
'lock_table' => env('DB_CACHE_LOCK_TABLE'),
|
||||
],
|
||||
|
||||
'file' => [
|
||||
'driver' => 'file',
|
||||
'path' => storage_path('framework/cache/data'),
|
||||
'lock_path' => storage_path('framework/cache/data'),
|
||||
],
|
||||
|
||||
'storage' => [
|
||||
'driver' => 'storage',
|
||||
'disk' => env('CACHE_STORAGE_DISK'),
|
||||
'path' => env('CACHE_STORAGE_PATH', 'framework/cache/data'),
|
||||
],
|
||||
|
||||
'memcached' => [
|
||||
'driver' => 'memcached',
|
||||
'persistent_id' => env('MEMCACHED_PERSISTENT_ID'),
|
||||
'sasl' => [
|
||||
env('MEMCACHED_USERNAME'),
|
||||
env('MEMCACHED_PASSWORD'),
|
||||
],
|
||||
'options' => [
|
||||
// Memcached::OPT_CONNECT_TIMEOUT => 2000,
|
||||
],
|
||||
'servers' => [
|
||||
[
|
||||
'host' => env('MEMCACHED_HOST', '127.0.0.1'),
|
||||
'port' => env('MEMCACHED_PORT', 11211),
|
||||
'weight' => 100,
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
'redis' => [
|
||||
'driver' => 'redis',
|
||||
'connection' => env('REDIS_CACHE_CONNECTION', 'cache'),
|
||||
'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'),
|
||||
],
|
||||
|
||||
'dynamodb' => [
|
||||
'driver' => 'dynamodb',
|
||||
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
|
||||
'table' => env('DYNAMODB_CACHE_TABLE', 'cache'),
|
||||
'endpoint' => env('DYNAMODB_ENDPOINT'),
|
||||
],
|
||||
|
||||
'octane' => [
|
||||
'driver' => 'octane',
|
||||
],
|
||||
|
||||
'failover' => [
|
||||
'driver' => 'failover',
|
||||
'stores' => [
|
||||
'database',
|
||||
'array',
|
||||
],
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Cache Key Prefix
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When utilizing the APC, database, memcached, Redis, and DynamoDB cache
|
||||
| stores, there might be other applications using the same cache. For
|
||||
| that reason, you may prefix every cache key to avoid collisions.
|
||||
|
|
||||
*/
|
||||
|
||||
'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Serializable Classes
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value determines the classes that can be unserialized from cache
|
||||
| storage. By default, no PHP classes will be unserialized from your
|
||||
| cache to prevent gadget chain attacks if your APP_KEY is leaked.
|
||||
|
|
||||
*/
|
||||
|
||||
'serializable_classes' => false,
|
||||
|
||||
];
|
||||
184
config/database.php
Normal file
184
config/database.php
Normal file
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
use Pdo\Mysql;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Database Connection Name
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify which of the database connections below you wish
|
||||
| to use as your default connection for database operations. This is
|
||||
| the connection which will be utilized unless another connection
|
||||
| is explicitly specified when you execute a query / statement.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('DB_CONNECTION', 'sqlite'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Database Connections
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Below are all of the database connections defined for your application.
|
||||
| An example configuration is provided for each database system which
|
||||
| is supported by Laravel. You're free to add / remove connections.
|
||||
|
|
||||
*/
|
||||
|
||||
'connections' => [
|
||||
|
||||
'sqlite' => [
|
||||
'driver' => 'sqlite',
|
||||
'url' => env('DB_URL'),
|
||||
'database' => env('DB_DATABASE', database_path('database.sqlite')),
|
||||
'prefix' => '',
|
||||
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
|
||||
'busy_timeout' => null,
|
||||
'journal_mode' => null,
|
||||
'synchronous' => null,
|
||||
'transaction_mode' => 'DEFERRED',
|
||||
],
|
||||
|
||||
'mysql' => [
|
||||
'driver' => 'mysql',
|
||||
'url' => env('DB_URL'),
|
||||
'host' => env('DB_HOST', '127.0.0.1'),
|
||||
'port' => env('DB_PORT', '3306'),
|
||||
'database' => env('DB_DATABASE', 'laravel'),
|
||||
'username' => env('DB_USERNAME', 'root'),
|
||||
'password' => env('DB_PASSWORD', ''),
|
||||
'unix_socket' => env('DB_SOCKET', ''),
|
||||
'charset' => env('DB_CHARSET', 'utf8mb4'),
|
||||
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
'strict' => true,
|
||||
'engine' => null,
|
||||
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||
Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
|
||||
]) : [],
|
||||
],
|
||||
|
||||
'mariadb' => [
|
||||
'driver' => 'mariadb',
|
||||
'url' => env('DB_URL'),
|
||||
'host' => env('DB_HOST', '127.0.0.1'),
|
||||
'port' => env('DB_PORT', '3306'),
|
||||
'database' => env('DB_DATABASE', 'laravel'),
|
||||
'username' => env('DB_USERNAME', 'root'),
|
||||
'password' => env('DB_PASSWORD', ''),
|
||||
'unix_socket' => env('DB_SOCKET', ''),
|
||||
'charset' => env('DB_CHARSET', 'utf8mb4'),
|
||||
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
'strict' => true,
|
||||
'engine' => null,
|
||||
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||
Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
|
||||
]) : [],
|
||||
],
|
||||
|
||||
'pgsql' => [
|
||||
'driver' => 'pgsql',
|
||||
'url' => env('DB_URL'),
|
||||
'host' => env('DB_HOST', '127.0.0.1'),
|
||||
'port' => env('DB_PORT', '5432'),
|
||||
'database' => env('DB_DATABASE', 'laravel'),
|
||||
'username' => env('DB_USERNAME', 'root'),
|
||||
'password' => env('DB_PASSWORD', ''),
|
||||
'charset' => env('DB_CHARSET', 'utf8'),
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
'search_path' => 'public',
|
||||
'sslmode' => env('DB_SSLMODE', 'prefer'),
|
||||
],
|
||||
|
||||
'sqlsrv' => [
|
||||
'driver' => 'sqlsrv',
|
||||
'url' => env('DB_URL'),
|
||||
'host' => env('DB_HOST', 'localhost'),
|
||||
'port' => env('DB_PORT', '1433'),
|
||||
'database' => env('DB_DATABASE', 'laravel'),
|
||||
'username' => env('DB_USERNAME', 'root'),
|
||||
'password' => env('DB_PASSWORD', ''),
|
||||
'charset' => env('DB_CHARSET', 'utf8'),
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
// 'encrypt' => env('DB_ENCRYPT', 'yes'),
|
||||
// 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'),
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Migration Repository Table
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This table keeps track of all the migrations that have already run for
|
||||
| your application. Using this information, we can determine which of
|
||||
| the migrations on disk haven't actually been run on the database.
|
||||
|
|
||||
*/
|
||||
|
||||
'migrations' => [
|
||||
'table' => 'migrations',
|
||||
'update_date_on_publish' => true,
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Redis Databases
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Redis is an open source, fast, and advanced key-value store that also
|
||||
| provides a richer body of commands than a typical key-value system
|
||||
| such as Memcached. You may define your connection settings here.
|
||||
|
|
||||
*/
|
||||
|
||||
'redis' => [
|
||||
|
||||
'client' => env('REDIS_CLIENT', 'phpredis'),
|
||||
|
||||
'options' => [
|
||||
'cluster' => env('REDIS_CLUSTER', 'redis'),
|
||||
'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'),
|
||||
'persistent' => env('REDIS_PERSISTENT', false),
|
||||
],
|
||||
|
||||
'default' => [
|
||||
'url' => env('REDIS_URL'),
|
||||
'host' => env('REDIS_HOST', '127.0.0.1'),
|
||||
'username' => env('REDIS_USERNAME'),
|
||||
'password' => env('REDIS_PASSWORD'),
|
||||
'port' => env('REDIS_PORT', '6379'),
|
||||
'database' => env('REDIS_DB', '0'),
|
||||
'max_retries' => env('REDIS_MAX_RETRIES', 3),
|
||||
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
|
||||
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
|
||||
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
|
||||
],
|
||||
|
||||
'cache' => [
|
||||
'url' => env('REDIS_URL'),
|
||||
'host' => env('REDIS_HOST', '127.0.0.1'),
|
||||
'username' => env('REDIS_USERNAME'),
|
||||
'password' => env('REDIS_PASSWORD'),
|
||||
'port' => env('REDIS_PORT', '6379'),
|
||||
'database' => env('REDIS_CACHE_DB', '1'),
|
||||
'max_retries' => env('REDIS_MAX_RETRIES', 3),
|
||||
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
|
||||
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
|
||||
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
80
config/filesystems.php
Normal file
80
config/filesystems.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Filesystem Disk
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify the default filesystem disk that should be used
|
||||
| by the framework. The "local" disk, as well as a variety of cloud
|
||||
| based disks are available to your application for file storage.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('FILESYSTEM_DISK', 'local'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Filesystem Disks
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Below you may configure as many filesystem disks as necessary, and you
|
||||
| may even configure multiple disks for the same driver. Examples for
|
||||
| most supported storage drivers are configured here for reference.
|
||||
|
|
||||
| Supported drivers: "local", "ftp", "sftp", "s3"
|
||||
|
|
||||
*/
|
||||
|
||||
'disks' => [
|
||||
|
||||
'local' => [
|
||||
'driver' => 'local',
|
||||
'root' => storage_path('app/private'),
|
||||
'serve' => true,
|
||||
'throw' => false,
|
||||
'report' => false,
|
||||
],
|
||||
|
||||
'public' => [
|
||||
'driver' => 'local',
|
||||
'root' => storage_path('app/public'),
|
||||
'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage',
|
||||
'visibility' => 'public',
|
||||
'throw' => false,
|
||||
'report' => false,
|
||||
],
|
||||
|
||||
's3' => [
|
||||
'driver' => 's3',
|
||||
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||
'region' => env('AWS_DEFAULT_REGION'),
|
||||
'bucket' => env('AWS_BUCKET'),
|
||||
'url' => env('AWS_URL'),
|
||||
'endpoint' => env('AWS_ENDPOINT'),
|
||||
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
|
||||
'throw' => false,
|
||||
'report' => false,
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Symbolic Links
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure the symbolic links that will be created when the
|
||||
| `storage:link` Artisan command is executed. The array keys should be
|
||||
| the locations of the links and the values should be their targets.
|
||||
|
|
||||
*/
|
||||
|
||||
'links' => [
|
||||
public_path('storage') => storage_path('app/public'),
|
||||
],
|
||||
|
||||
];
|
||||
333
config/l5-swagger.php
Normal file
333
config/l5-swagger.php
Normal file
@@ -0,0 +1,333 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'default' => 'default',
|
||||
'documentations' => [
|
||||
'default' => [
|
||||
'api' => [
|
||||
'title' => 'L5 Swagger UI',
|
||||
],
|
||||
|
||||
'routes' => [
|
||||
/*
|
||||
* Route for accessing api documentation interface
|
||||
*/
|
||||
'api' => 'api/documentation',
|
||||
],
|
||||
'paths' => [
|
||||
/*
|
||||
* Edit to include full URL in ui for assets
|
||||
*/
|
||||
'use_absolute_path' => env('L5_SWAGGER_USE_ABSOLUTE_PATH', true),
|
||||
|
||||
/*
|
||||
* Edit to set path where swagger ui assets should be stored
|
||||
*/
|
||||
'swagger_ui_assets_path' => env('L5_SWAGGER_UI_ASSETS_PATH', 'vendor/swagger-api/swagger-ui/dist/'),
|
||||
|
||||
/*
|
||||
* File name of the generated json documentation file
|
||||
*/
|
||||
'docs_json' => 'api-docs.json',
|
||||
|
||||
/*
|
||||
* File name of the generated YAML documentation file
|
||||
*/
|
||||
'docs_yaml' => 'api-docs.yaml',
|
||||
|
||||
/*
|
||||
* Set this to `json` or `yaml` to determine which documentation file to use in UI
|
||||
*/
|
||||
'format_to_use_for_docs' => env('L5_FORMAT_TO_USE_FOR_DOCS', 'json'),
|
||||
|
||||
/*
|
||||
* Absolute paths to directory containing the swagger annotations are stored.
|
||||
*/
|
||||
'annotations' => [
|
||||
base_path('app'),
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
'defaults' => [
|
||||
'routes' => [
|
||||
/*
|
||||
* Route for accessing parsed swagger annotations.
|
||||
*/
|
||||
'docs' => 'docs',
|
||||
|
||||
/*
|
||||
* Route for Oauth2 authentication callback.
|
||||
*/
|
||||
'oauth2_callback' => 'api/oauth2-callback',
|
||||
|
||||
/*
|
||||
* Middleware allows to prevent unexpected access to API documentation
|
||||
*/
|
||||
'middleware' => [
|
||||
'api' => [],
|
||||
'asset' => [],
|
||||
'docs' => [],
|
||||
'oauth2_callback' => [],
|
||||
],
|
||||
|
||||
/*
|
||||
* Route Group options
|
||||
*/
|
||||
'group_options' => [],
|
||||
],
|
||||
|
||||
'paths' => [
|
||||
/*
|
||||
* Absolute path to location where parsed annotations will be stored
|
||||
*/
|
||||
'docs' => storage_path('api-docs'),
|
||||
|
||||
/*
|
||||
* Absolute path to directory where to export views
|
||||
*/
|
||||
'views' => base_path('resources/views/vendor/l5-swagger'),
|
||||
|
||||
/*
|
||||
* Edit to set the api's base path
|
||||
*/
|
||||
'base' => env('L5_SWAGGER_BASE_PATH', null),
|
||||
|
||||
/*
|
||||
* Absolute path to directories that should be excluded from scanning
|
||||
* @deprecated Please use `scanOptions.exclude`
|
||||
* `scanOptions.exclude` overwrites this
|
||||
*/
|
||||
'excludes' => [],
|
||||
],
|
||||
|
||||
'scanOptions' => [
|
||||
/**
|
||||
* Optional CustomGeneratorInterface implementation that creates an OpenApi\Generator instance.
|
||||
* Use this to provide a custom pre-configured generator.
|
||||
* Accepts an instance or a class name (FQCN) implementing the interface.
|
||||
*
|
||||
* @see \L5Swagger\CustomGeneratorInterface
|
||||
*/
|
||||
'generator_factory' => null,
|
||||
|
||||
/**
|
||||
* Configuration for default processors. Allows to pass processors configuration to swagger-php.
|
||||
*
|
||||
* @link https://zircote.github.io/swagger-php/reference/processors.html
|
||||
*/
|
||||
'default_processors_configuration' => [
|
||||
/** Example */
|
||||
/**
|
||||
* 'operationId.hash' => true,
|
||||
* 'pathFilter' => [
|
||||
* 'tags' => [
|
||||
* '/pets/',
|
||||
* '/store/',
|
||||
* ],
|
||||
* ],.
|
||||
*/
|
||||
],
|
||||
|
||||
/**
|
||||
* analyser: defaults to \OpenApi\StaticAnalyser .
|
||||
*
|
||||
* @see \OpenApi\scan
|
||||
*/
|
||||
'analyser' => null,
|
||||
|
||||
/**
|
||||
* analysis: defaults to a new \OpenApi\Analysis .
|
||||
*
|
||||
* @see \OpenApi\scan
|
||||
*/
|
||||
'analysis' => null,
|
||||
|
||||
/**
|
||||
* Custom processors.
|
||||
*
|
||||
* Each entry can be:
|
||||
* - A class name or instance (inserted after BuildPaths by default)
|
||||
* - An array with 'class' and 'after' keys for precise positioning:
|
||||
* ['class' => MyProcessor::class, 'after' => SomeProcessor::class]
|
||||
*
|
||||
* @link https://github.com/zircote/swagger-php/tree/master/Examples/processors/schema-query-parameter
|
||||
* @see \OpenApi\scan
|
||||
*/
|
||||
'processors' => [
|
||||
// \App\SwaggerProcessors\SchemaQueryParameter::class,
|
||||
// ['class' => \App\SwaggerProcessors\Custom::class, 'after' => \OpenApi\Processors\AugmentSchemas::class],
|
||||
],
|
||||
|
||||
/**
|
||||
* pattern: string $pattern File pattern(s) to scan (default: *.php) .
|
||||
*
|
||||
* @see \OpenApi\scan
|
||||
*/
|
||||
'pattern' => null,
|
||||
|
||||
/*
|
||||
* Absolute path to directories that should be excluded from scanning
|
||||
* @note This option overwrites `paths.excludes`
|
||||
* @see \OpenApi\scan
|
||||
*/
|
||||
'exclude' => [],
|
||||
|
||||
/*
|
||||
* Allows to generate specs either for OpenAPI 3.0.0 or OpenAPI 3.1.0.
|
||||
* By default the spec will be in version 3.0.0
|
||||
*/
|
||||
'open_api_spec_version' => env('L5_SWAGGER_OPEN_API_SPEC_VERSION', \L5Swagger\Generator::OPEN_API_DEFAULT_SPEC_VERSION),
|
||||
],
|
||||
|
||||
/*
|
||||
* API security definitions. Will be generated into documentation file.
|
||||
*/
|
||||
'securityDefinitions' => [
|
||||
'securitySchemes' => [
|
||||
/*
|
||||
* Examples of Security schemes
|
||||
*/
|
||||
/*
|
||||
'api_key_security_example' => [ // Unique name of security
|
||||
'type' => 'apiKey', // The type of the security scheme. Valid values are "basic", "apiKey" or "oauth2".
|
||||
'description' => 'A short description for security scheme',
|
||||
'name' => 'api_key', // The name of the header or query parameter to be used.
|
||||
'in' => 'header', // The location of the API key. Valid values are "query" or "header".
|
||||
],
|
||||
'oauth2_security_example' => [ // Unique name of security
|
||||
'type' => 'oauth2', // The type of the security scheme. Valid values are "basic", "apiKey" or "oauth2".
|
||||
'description' => 'A short description for oauth2 security scheme.',
|
||||
'flow' => 'implicit', // The flow used by the OAuth2 security scheme. Valid values are "implicit", "password", "application" or "accessCode".
|
||||
'authorizationUrl' => 'http://example.com/auth', // The authorization URL to be used for (implicit/accessCode)
|
||||
//'tokenUrl' => 'http://example.com/auth' // The authorization URL to be used for (password/application/accessCode)
|
||||
'scopes' => [
|
||||
'read:projects' => 'read your projects',
|
||||
'write:projects' => 'modify projects in your account',
|
||||
]
|
||||
],
|
||||
*/
|
||||
|
||||
/* Open API 3.0 support
|
||||
'passport' => [ // Unique name of security
|
||||
'type' => 'oauth2', // The type of the security scheme. Valid values are "basic", "apiKey" or "oauth2".
|
||||
'description' => 'Laravel passport oauth2 security.',
|
||||
'in' => 'header',
|
||||
'scheme' => 'https',
|
||||
'flows' => [
|
||||
"password" => [
|
||||
"authorizationUrl" => config('app.url') . '/oauth/authorize',
|
||||
"tokenUrl" => config('app.url') . '/oauth/token',
|
||||
"refreshUrl" => config('app.url') . '/token/refresh',
|
||||
"scopes" => []
|
||||
],
|
||||
],
|
||||
],
|
||||
'sanctum' => [ // Unique name of security
|
||||
'type' => 'apiKey', // Valid values are "basic", "apiKey" or "oauth2".
|
||||
'description' => 'Enter token in format (Bearer <token>)',
|
||||
'name' => 'Authorization', // The name of the header or query parameter to be used.
|
||||
'in' => 'header', // The location of the API key. Valid values are "query" or "header".
|
||||
],
|
||||
*/
|
||||
],
|
||||
'security' => [
|
||||
/*
|
||||
* Examples of Securities
|
||||
*/
|
||||
[
|
||||
/*
|
||||
'oauth2_security_example' => [
|
||||
'read',
|
||||
'write'
|
||||
],
|
||||
|
||||
'passport' => []
|
||||
*/
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
* Set this to `true` in development mode so that docs would be regenerated on each request
|
||||
* Set this to `false` to disable swagger generation on production
|
||||
*/
|
||||
'generate_always' => env('L5_SWAGGER_GENERATE_ALWAYS', false),
|
||||
|
||||
/*
|
||||
* Set this to `true` to generate a copy of documentation in yaml format
|
||||
*/
|
||||
'generate_yaml_copy' => env('L5_SWAGGER_GENERATE_YAML_COPY', false),
|
||||
|
||||
/*
|
||||
* Edit to trust the proxy's ip address - needed for AWS Load Balancer
|
||||
* string[]
|
||||
*/
|
||||
'proxy' => false,
|
||||
|
||||
/*
|
||||
* Configs plugin allows to fetch external configs instead of passing them to SwaggerUIBundle.
|
||||
* See more at: https://github.com/swagger-api/swagger-ui#configs-plugin
|
||||
*/
|
||||
'additional_config_url' => null,
|
||||
|
||||
/*
|
||||
* Apply a sort to the operation list of each API. It can be 'alpha' (sort by paths alphanumerically),
|
||||
* 'method' (sort by HTTP method).
|
||||
* Default is the order returned by the server unchanged.
|
||||
*/
|
||||
'operations_sort' => env('L5_SWAGGER_OPERATIONS_SORT', null),
|
||||
|
||||
/*
|
||||
* Pass the validatorUrl parameter to SwaggerUi init on the JS side.
|
||||
* A null value here disables validation.
|
||||
*/
|
||||
'validator_url' => null,
|
||||
|
||||
/*
|
||||
* Swagger UI configuration parameters
|
||||
*/
|
||||
'ui' => [
|
||||
'display' => [
|
||||
'dark_mode' => env('L5_SWAGGER_UI_DARK_MODE', false),
|
||||
/*
|
||||
* Controls the default expansion setting for the operations and tags. It can be :
|
||||
* 'list' (expands only the tags),
|
||||
* 'full' (expands the tags and operations),
|
||||
* 'none' (expands nothing).
|
||||
*/
|
||||
'doc_expansion' => env('L5_SWAGGER_UI_DOC_EXPANSION', 'none'),
|
||||
|
||||
/**
|
||||
* If set, enables filtering. The top bar will show an edit box that
|
||||
* you can use to filter the tagged operations that are shown. Can be
|
||||
* Boolean to enable or disable, or a string, in which case filtering
|
||||
* will be enabled using that string as the filter expression. Filtering
|
||||
* is case-sensitive matching the filter expression anywhere inside
|
||||
* the tag.
|
||||
*/
|
||||
'filter' => env('L5_SWAGGER_UI_FILTERS', true), // true | false
|
||||
],
|
||||
|
||||
'authorization' => [
|
||||
/*
|
||||
* If set to true, it persists authorization data, and it would not be lost on browser close/refresh
|
||||
*/
|
||||
'persist_authorization' => env('L5_SWAGGER_UI_PERSIST_AUTHORIZATION', false),
|
||||
|
||||
'oauth2' => [
|
||||
/*
|
||||
* If set to true, adds PKCE to AuthorizationCodeGrant flow
|
||||
*/
|
||||
'use_pkce_with_authorization_code_grant' => false,
|
||||
],
|
||||
],
|
||||
],
|
||||
/*
|
||||
* Constants which can be used in annotations
|
||||
*/
|
||||
'constants' => [
|
||||
'L5_SWAGGER_CONST_HOST' => env('L5_SWAGGER_CONST_HOST', 'http://my-default-host.com'),
|
||||
],
|
||||
],
|
||||
];
|
||||
132
config/logging.php
Normal file
132
config/logging.php
Normal file
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
use Monolog\Handler\NullHandler;
|
||||
use Monolog\Handler\StreamHandler;
|
||||
use Monolog\Handler\SyslogUdpHandler;
|
||||
use Monolog\Processor\PsrLogMessageProcessor;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Log Channel
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option defines the default log channel that is utilized to write
|
||||
| messages to your logs. The value provided here should match one of
|
||||
| the channels present in the list of "channels" configured below.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('LOG_CHANNEL', 'stack'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Deprecations Log Channel
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option controls the log channel that should be used to log warnings
|
||||
| regarding deprecated PHP and library features. This allows you to get
|
||||
| your application ready for upcoming major versions of dependencies.
|
||||
|
|
||||
*/
|
||||
|
||||
'deprecations' => [
|
||||
'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'),
|
||||
'trace' => env('LOG_DEPRECATIONS_TRACE', false),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Log Channels
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure the log channels for your application. Laravel
|
||||
| utilizes the Monolog PHP logging library, which includes a variety
|
||||
| of powerful log handlers and formatters that you're free to use.
|
||||
|
|
||||
| Available drivers: "single", "daily", "slack", "syslog",
|
||||
| "errorlog", "monolog", "custom", "stack"
|
||||
|
|
||||
*/
|
||||
|
||||
'channels' => [
|
||||
|
||||
'stack' => [
|
||||
'driver' => 'stack',
|
||||
'channels' => explode(',', (string) env('LOG_STACK', 'single')),
|
||||
'ignore_exceptions' => false,
|
||||
],
|
||||
|
||||
'single' => [
|
||||
'driver' => 'single',
|
||||
'path' => storage_path('logs/laravel.log'),
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'daily' => [
|
||||
'driver' => 'daily',
|
||||
'path' => storage_path('logs/laravel.log'),
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'days' => env('LOG_DAILY_DAYS', 14),
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'slack' => [
|
||||
'driver' => 'slack',
|
||||
'url' => env('LOG_SLACK_WEBHOOK_URL'),
|
||||
'username' => env('LOG_SLACK_USERNAME', env('APP_NAME', 'Laravel')),
|
||||
'emoji' => env('LOG_SLACK_EMOJI', ':boom:'),
|
||||
'level' => env('LOG_LEVEL', 'critical'),
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'papertrail' => [
|
||||
'driver' => 'monolog',
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class),
|
||||
'handler_with' => [
|
||||
'host' => env('PAPERTRAIL_URL'),
|
||||
'port' => env('PAPERTRAIL_PORT'),
|
||||
'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'),
|
||||
],
|
||||
'processors' => [PsrLogMessageProcessor::class],
|
||||
],
|
||||
|
||||
'stderr' => [
|
||||
'driver' => 'monolog',
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'handler' => StreamHandler::class,
|
||||
'handler_with' => [
|
||||
'stream' => 'php://stderr',
|
||||
],
|
||||
'formatter' => env('LOG_STDERR_FORMATTER'),
|
||||
'processors' => [PsrLogMessageProcessor::class],
|
||||
],
|
||||
|
||||
'syslog' => [
|
||||
'driver' => 'syslog',
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER),
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'errorlog' => [
|
||||
'driver' => 'errorlog',
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'null' => [
|
||||
'driver' => 'monolog',
|
||||
'handler' => NullHandler::class,
|
||||
],
|
||||
|
||||
'emergency' => [
|
||||
'path' => storage_path('logs/laravel.log'),
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
118
config/mail.php
Normal file
118
config/mail.php
Normal file
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Mailer
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option controls the default mailer that is used to send all email
|
||||
| messages unless another mailer is explicitly specified when sending
|
||||
| the message. All additional mailers can be configured within the
|
||||
| "mailers" array. Examples of each type of mailer are provided.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('MAIL_MAILER', 'log'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Mailer Configurations
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure all of the mailers used by your application plus
|
||||
| their respective settings. Several examples have been configured for
|
||||
| you and you are free to add your own as your application requires.
|
||||
|
|
||||
| Laravel supports a variety of mail "transport" drivers that can be used
|
||||
| when delivering an email. You may specify which one you're using for
|
||||
| your mailers below. You may also add additional mailers if needed.
|
||||
|
|
||||
| Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2",
|
||||
| "postmark", "resend", "log", "array",
|
||||
| "failover", "roundrobin"
|
||||
|
|
||||
*/
|
||||
|
||||
'mailers' => [
|
||||
|
||||
'smtp' => [
|
||||
'transport' => 'smtp',
|
||||
'scheme' => env('MAIL_SCHEME'),
|
||||
'url' => env('MAIL_URL'),
|
||||
'host' => env('MAIL_HOST', '127.0.0.1'),
|
||||
'port' => env('MAIL_PORT', 2525),
|
||||
'username' => env('MAIL_USERNAME'),
|
||||
'password' => env('MAIL_PASSWORD'),
|
||||
'timeout' => null,
|
||||
'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)),
|
||||
],
|
||||
|
||||
'ses' => [
|
||||
'transport' => 'ses',
|
||||
],
|
||||
|
||||
'postmark' => [
|
||||
'transport' => 'postmark',
|
||||
// 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'),
|
||||
// 'client' => [
|
||||
// 'timeout' => 5,
|
||||
// ],
|
||||
],
|
||||
|
||||
'resend' => [
|
||||
'transport' => 'resend',
|
||||
],
|
||||
|
||||
'sendmail' => [
|
||||
'transport' => 'sendmail',
|
||||
'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'),
|
||||
],
|
||||
|
||||
'log' => [
|
||||
'transport' => 'log',
|
||||
'channel' => env('MAIL_LOG_CHANNEL'),
|
||||
],
|
||||
|
||||
'array' => [
|
||||
'transport' => 'array',
|
||||
],
|
||||
|
||||
'failover' => [
|
||||
'transport' => 'failover',
|
||||
'mailers' => [
|
||||
'smtp',
|
||||
'log',
|
||||
],
|
||||
'retry_after' => 60,
|
||||
],
|
||||
|
||||
'roundrobin' => [
|
||||
'transport' => 'roundrobin',
|
||||
'mailers' => [
|
||||
'ses',
|
||||
'postmark',
|
||||
],
|
||||
'retry_after' => 60,
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Global "From" Address
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| You may wish for all emails sent by your application to be sent from
|
||||
| the same address. Here you may specify a name and address that is
|
||||
| used globally for all emails that are sent by your application.
|
||||
|
|
||||
*/
|
||||
|
||||
'from' => [
|
||||
'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),
|
||||
'name' => env('MAIL_FROM_NAME', env('APP_NAME', 'Laravel')),
|
||||
],
|
||||
|
||||
];
|
||||
48
config/passport.php
Normal file
48
config/passport.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Passport Guard
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify which authentication guard Passport will use when
|
||||
| authenticating users. This value should correspond with one of your
|
||||
| guards that is already present in your "auth" configuration file.
|
||||
|
|
||||
*/
|
||||
|
||||
'guard' => 'web',
|
||||
|
||||
'middleware' => [],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Encryption Keys
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Passport uses encryption keys while generating secure access tokens for
|
||||
| your application. By default, the keys are stored as local files but
|
||||
| can be set via environment variables when that is more convenient.
|
||||
|
|
||||
*/
|
||||
|
||||
'private_key' => env('PASSPORT_PRIVATE_KEY'),
|
||||
|
||||
'public_key' => env('PASSPORT_PUBLIC_KEY'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Passport Database Connection
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| By default, Passport's models will utilize your application's default
|
||||
| database connection. If you wish to use a different connection you
|
||||
| may specify the configured name of the database connection here.
|
||||
|
|
||||
*/
|
||||
|
||||
'connection' => env('PASSPORT_CONNECTION'),
|
||||
|
||||
];
|
||||
219
config/permission.php
Normal file
219
config/permission.php
Normal file
@@ -0,0 +1,219 @@
|
||||
<?php
|
||||
|
||||
use Spatie\Permission\DefaultTeamResolver;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
use Spatie\Permission\Models\Role;
|
||||
|
||||
return [
|
||||
|
||||
'models' => [
|
||||
|
||||
/*
|
||||
* When using the "HasPermissions" trait from this package, we need to know which
|
||||
* Eloquent model should be used to retrieve your permissions. Of course, it
|
||||
* is often just the "Permission" model but you may use whatever you like.
|
||||
*
|
||||
* The model you want to use as a Permission model needs to implement the
|
||||
* `Spatie\Permission\Contracts\Permission` contract.
|
||||
*/
|
||||
|
||||
'permission' => Permission::class,
|
||||
|
||||
/*
|
||||
* When using the "HasRoles" trait from this package, we need to know which
|
||||
* Eloquent model should be used to retrieve your roles. Of course, it
|
||||
* is often just the "Role" model but you may use whatever you like.
|
||||
*
|
||||
* The model you want to use as a Role model needs to implement the
|
||||
* `Spatie\Permission\Contracts\Role` contract.
|
||||
*/
|
||||
|
||||
'role' => Role::class,
|
||||
|
||||
/*
|
||||
* When using the "Teams" feature from this package, we need to know which
|
||||
* Eloquent model should be used to retrieve your teams. Of course, it
|
||||
* is often just the "Team" model but you may use whatever you like.
|
||||
*/
|
||||
'team' => null,
|
||||
|
||||
/*
|
||||
* When using the "HasModels" trait and passing raw IDs to syncModels,
|
||||
* attachModels, or detachModels, this model class will be used to
|
||||
* resolve those IDs. If null, defaults to the guard's model.
|
||||
*/
|
||||
'default_model' => null,
|
||||
],
|
||||
|
||||
'table_names' => [
|
||||
|
||||
/*
|
||||
* When using the "HasRoles" trait from this package, we need to know which
|
||||
* table should be used to retrieve your roles. We have chosen a basic
|
||||
* default value but you may easily change it to any table you like.
|
||||
*/
|
||||
|
||||
'roles' => 'roles',
|
||||
|
||||
/*
|
||||
* When using the "HasPermissions" trait from this package, we need to know which
|
||||
* table should be used to retrieve your permissions. We have chosen a basic
|
||||
* default value but you may easily change it to any table you like.
|
||||
*/
|
||||
|
||||
'permissions' => 'permissions',
|
||||
|
||||
/*
|
||||
* When using the "HasPermissions" trait from this package, we need to know which
|
||||
* table should be used to retrieve your models permissions. We have chosen a
|
||||
* basic default value but you may easily change it to any table you like.
|
||||
*/
|
||||
|
||||
'model_has_permissions' => 'model_has_permissions',
|
||||
|
||||
/*
|
||||
* When using the "HasRoles" trait from this package, we need to know which
|
||||
* table should be used to retrieve your models roles. We have chosen a
|
||||
* basic default value but you may easily change it to any table you like.
|
||||
*/
|
||||
|
||||
'model_has_roles' => 'model_has_roles',
|
||||
|
||||
/*
|
||||
* When using the "HasRoles" trait from this package, we need to know which
|
||||
* table should be used to retrieve your roles permissions. We have chosen a
|
||||
* basic default value but you may easily change it to any table you like.
|
||||
*/
|
||||
|
||||
'role_has_permissions' => 'role_has_permissions',
|
||||
],
|
||||
|
||||
'column_names' => [
|
||||
/*
|
||||
* Change this if you want to name the related pivots other than defaults
|
||||
*/
|
||||
'role_pivot_key' => null, // default 'role_id',
|
||||
'permission_pivot_key' => null, // default 'permission_id',
|
||||
|
||||
/*
|
||||
* Change this if you want to name the related model primary key other than
|
||||
* `model_id`.
|
||||
*
|
||||
* For example, this would be nice if your primary keys are all UUIDs. In
|
||||
* that case, name this `model_uuid`.
|
||||
*/
|
||||
|
||||
'model_morph_key' => 'model_id',
|
||||
|
||||
/*
|
||||
* Change this if you want to use the teams feature and your related model's
|
||||
* foreign key is other than `team_id`.
|
||||
*/
|
||||
|
||||
'team_foreign_key' => 'team_id',
|
||||
],
|
||||
|
||||
/*
|
||||
* When set to true, the method for checking permissions will be registered on the gate.
|
||||
* Set this to false if you want to implement custom logic for checking permissions.
|
||||
*/
|
||||
|
||||
'register_permission_check_method' => true,
|
||||
|
||||
/*
|
||||
* When set to true, Laravel\Octane\Events\OperationTerminated event listener will be registered
|
||||
* this will refresh permissions on every TickTerminated, TaskTerminated and RequestTerminated
|
||||
* NOTE: This should not be needed in most cases, but an Octane/Vapor combination benefited from it.
|
||||
*/
|
||||
'register_octane_reset_listener' => false,
|
||||
|
||||
/*
|
||||
* Events will fire when a role or permission is assigned/unassigned:
|
||||
* \Spatie\Permission\Events\RoleAttachedEvent
|
||||
* \Spatie\Permission\Events\RoleDetachedEvent
|
||||
* \Spatie\Permission\Events\PermissionAttachedEvent
|
||||
* \Spatie\Permission\Events\PermissionDetachedEvent
|
||||
*
|
||||
* To enable, set to true, and then create listeners to watch these events.
|
||||
*/
|
||||
'events_enabled' => false,
|
||||
|
||||
/*
|
||||
* Teams Feature.
|
||||
* When set to true the package implements teams using the 'team_foreign_key'.
|
||||
* If you want the migrations to register the 'team_foreign_key', you must
|
||||
* set this to true before doing the migration.
|
||||
* If you already did the migration then you must make a new migration to also
|
||||
* add 'team_foreign_key' to 'roles', 'model_has_roles', and 'model_has_permissions'
|
||||
* (view the latest version of this package's migration file)
|
||||
*/
|
||||
|
||||
'teams' => false,
|
||||
|
||||
/*
|
||||
* The class to use to resolve the permissions team id
|
||||
*/
|
||||
'team_resolver' => DefaultTeamResolver::class,
|
||||
|
||||
/*
|
||||
* Passport Client Credentials Grant
|
||||
* When set to true the package will use Passports Client to check permissions
|
||||
*/
|
||||
|
||||
'use_passport_client_credentials' => false,
|
||||
|
||||
/*
|
||||
* When set to true, the required permission names are added to exception messages.
|
||||
* This could be considered an information leak in some contexts, so the default
|
||||
* setting is false here for optimum safety.
|
||||
*/
|
||||
|
||||
'display_permission_in_exception' => false,
|
||||
|
||||
/*
|
||||
* When set to true, the required role names are added to exception messages.
|
||||
* This could be considered an information leak in some contexts, so the default
|
||||
* setting is false here for optimum safety.
|
||||
*/
|
||||
|
||||
'display_role_in_exception' => false,
|
||||
|
||||
/*
|
||||
* By default wildcard permission lookups are disabled.
|
||||
* See documentation to understand supported syntax.
|
||||
*/
|
||||
|
||||
'enable_wildcard_permission' => false,
|
||||
|
||||
/*
|
||||
* The class to use for interpreting wildcard permissions.
|
||||
* If you need to modify delimiters, override the class and specify its name here.
|
||||
*/
|
||||
// 'wildcard_permission' => Spatie\Permission\WildcardPermission::class,
|
||||
|
||||
/* Cache-specific settings */
|
||||
|
||||
'cache' => [
|
||||
|
||||
/*
|
||||
* By default all permissions are cached for 24 hours to speed up performance.
|
||||
* When permissions or roles are updated the cache is flushed automatically.
|
||||
*/
|
||||
|
||||
'expiration_time' => DateInterval::createFromDateString('24 hours'),
|
||||
|
||||
/*
|
||||
* The cache key used to store all permissions.
|
||||
*/
|
||||
|
||||
'key' => 'spatie.permission.cache',
|
||||
|
||||
/*
|
||||
* You may optionally indicate a specific cache driver to use for permission and
|
||||
* role caching using any of the `store` drivers listed in the cache.php config
|
||||
* file. Using 'default' here means to use the `default` set in cache.php.
|
||||
*/
|
||||
|
||||
'store' => 'default',
|
||||
],
|
||||
];
|
||||
129
config/queue.php
Normal file
129
config/queue.php
Normal file
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Queue Connection Name
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Laravel's queue supports a variety of backends via a single, unified
|
||||
| API, giving you convenient access to each backend using identical
|
||||
| syntax for each. The default queue connection is defined below.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('QUEUE_CONNECTION', 'database'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Queue Connections
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure the connection options for every queue backend
|
||||
| used by your application. An example configuration is provided for
|
||||
| each backend supported by Laravel. You're also free to add more.
|
||||
|
|
||||
| Drivers: "sync", "database", "beanstalkd", "sqs", "redis",
|
||||
| "deferred", "background", "failover", "null"
|
||||
|
|
||||
*/
|
||||
|
||||
'connections' => [
|
||||
|
||||
'sync' => [
|
||||
'driver' => 'sync',
|
||||
],
|
||||
|
||||
'database' => [
|
||||
'driver' => 'database',
|
||||
'connection' => env('DB_QUEUE_CONNECTION'),
|
||||
'table' => env('DB_QUEUE_TABLE', 'jobs'),
|
||||
'queue' => env('DB_QUEUE', 'default'),
|
||||
'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90),
|
||||
'after_commit' => false,
|
||||
],
|
||||
|
||||
'beanstalkd' => [
|
||||
'driver' => 'beanstalkd',
|
||||
'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'),
|
||||
'queue' => env('BEANSTALKD_QUEUE', 'default'),
|
||||
'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90),
|
||||
'block_for' => 0,
|
||||
'after_commit' => false,
|
||||
],
|
||||
|
||||
'sqs' => [
|
||||
'driver' => 'sqs',
|
||||
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||
'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'),
|
||||
'queue' => env('SQS_QUEUE', 'default'),
|
||||
'suffix' => env('SQS_SUFFIX'),
|
||||
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
|
||||
'after_commit' => false,
|
||||
],
|
||||
|
||||
'redis' => [
|
||||
'driver' => 'redis',
|
||||
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
|
||||
'queue' => env('REDIS_QUEUE', 'default'),
|
||||
'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90),
|
||||
'block_for' => null,
|
||||
'after_commit' => false,
|
||||
],
|
||||
|
||||
'deferred' => [
|
||||
'driver' => 'deferred',
|
||||
],
|
||||
|
||||
'background' => [
|
||||
'driver' => 'background',
|
||||
],
|
||||
|
||||
'failover' => [
|
||||
'driver' => 'failover',
|
||||
'connections' => [
|
||||
'database',
|
||||
'deferred',
|
||||
],
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Job Batching
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The following options configure the database and table that store job
|
||||
| batching information. These options can be updated to any database
|
||||
| connection and table which has been defined by your application.
|
||||
|
|
||||
*/
|
||||
|
||||
'batching' => [
|
||||
'database' => env('DB_CONNECTION', 'sqlite'),
|
||||
'table' => 'job_batches',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Failed Queue Jobs
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| These options configure the behavior of failed queue job logging so you
|
||||
| can control how and where failed jobs are stored. Laravel ships with
|
||||
| support for storing failed jobs in a simple file or in a database.
|
||||
|
|
||||
| Supported drivers: "database-uuids", "dynamodb", "file", "null"
|
||||
|
|
||||
*/
|
||||
|
||||
'failed' => [
|
||||
'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
|
||||
'database' => env('DB_CONNECTION', 'sqlite'),
|
||||
'table' => 'failed_jobs',
|
||||
],
|
||||
|
||||
];
|
||||
38
config/services.php
Normal file
38
config/services.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Third Party Services
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This file is for storing the credentials for third party services such
|
||||
| as Mailgun, Postmark, AWS and more. This file provides the de facto
|
||||
| location for this type of information, allowing packages to have
|
||||
| a conventional file to locate the various service credentials.
|
||||
|
|
||||
*/
|
||||
|
||||
'postmark' => [
|
||||
'key' => env('POSTMARK_API_KEY'),
|
||||
],
|
||||
|
||||
'resend' => [
|
||||
'key' => env('RESEND_API_KEY'),
|
||||
],
|
||||
|
||||
'ses' => [
|
||||
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
|
||||
],
|
||||
|
||||
'slack' => [
|
||||
'notifications' => [
|
||||
'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),
|
||||
'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
233
config/session.php
Normal file
233
config/session.php
Normal file
@@ -0,0 +1,233 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Session Driver
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option determines the default session driver that is utilized for
|
||||
| incoming requests. Laravel supports a variety of storage options to
|
||||
| persist session data. Database storage is a great default choice.
|
||||
|
|
||||
| Supported: "file", "cookie", "database", "memcached",
|
||||
| "redis", "dynamodb", "array"
|
||||
|
|
||||
*/
|
||||
|
||||
'driver' => env('SESSION_DRIVER', 'database'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Lifetime
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify the number of minutes that you wish the session
|
||||
| to be allowed to remain idle before it expires. If you want them
|
||||
| to expire immediately when the browser is closed then you may
|
||||
| indicate that via the expire_on_close configuration option.
|
||||
|
|
||||
*/
|
||||
|
||||
'lifetime' => (int) env('SESSION_LIFETIME', 120),
|
||||
|
||||
'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Encryption
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option allows you to easily specify that all of your session data
|
||||
| should be encrypted before it's stored. All encryption is performed
|
||||
| automatically by Laravel and you may use the session like normal.
|
||||
|
|
||||
*/
|
||||
|
||||
'encrypt' => env('SESSION_ENCRYPT', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session File Location
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When utilizing the "file" session driver, the session files are placed
|
||||
| on disk. The default storage location is defined here; however, you
|
||||
| are free to provide another location where they should be stored.
|
||||
|
|
||||
*/
|
||||
|
||||
'files' => storage_path('framework/sessions'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Database Connection
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When using the "database" or "redis" session drivers, you may specify a
|
||||
| connection that should be used to manage these sessions. This should
|
||||
| correspond to a connection in your database configuration options.
|
||||
|
|
||||
*/
|
||||
|
||||
'connection' => env('SESSION_CONNECTION'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Database Table
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When using the "database" session driver, you may specify the table to
|
||||
| be used to store sessions. Of course, a sensible default is defined
|
||||
| for you; however, you're welcome to change this to another table.
|
||||
|
|
||||
*/
|
||||
|
||||
'table' => env('SESSION_TABLE', 'sessions'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Cache Store
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When using one of the framework's cache driven session backends, you may
|
||||
| define the cache store which should be used to store the session data
|
||||
| between requests. This must match one of your defined cache stores.
|
||||
|
|
||||
| Affects: "dynamodb", "memcached", "redis"
|
||||
|
|
||||
*/
|
||||
|
||||
'store' => env('SESSION_STORE'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Sweeping Lottery
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Some session drivers must manually sweep their storage location to get
|
||||
| rid of old sessions from storage. Here are the chances that it will
|
||||
| happen on a given request. By default, the odds are 2 out of 100.
|
||||
|
|
||||
*/
|
||||
|
||||
'lottery' => [2, 100],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Cookie Name
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may change the name of the session cookie that is created by
|
||||
| the framework. Typically, you should not need to change this value
|
||||
| since doing so does not grant a meaningful security improvement.
|
||||
|
|
||||
*/
|
||||
|
||||
'cookie' => env(
|
||||
'SESSION_COOKIE',
|
||||
Str::slug((string) env('APP_NAME', 'laravel')).'-session'
|
||||
),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Cookie Path
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The session cookie path determines the path for which the cookie will
|
||||
| be regarded as available. Typically, this will be the root path of
|
||||
| your application, but you're free to change this when necessary.
|
||||
|
|
||||
*/
|
||||
|
||||
'path' => env('SESSION_PATH', '/'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Cookie Domain
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value determines the domain and subdomains the session cookie is
|
||||
| available to. By default, the cookie will be available to the root
|
||||
| domain without subdomains. Typically, this shouldn't be changed.
|
||||
|
|
||||
*/
|
||||
|
||||
'domain' => env('SESSION_DOMAIN'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| HTTPS Only Cookies
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| By setting this option to true, session cookies will only be sent back
|
||||
| to the server if the browser has a HTTPS connection. This will keep
|
||||
| the cookie from being sent to you when it can't be done securely.
|
||||
|
|
||||
*/
|
||||
|
||||
'secure' => env('SESSION_SECURE_COOKIE'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| HTTP Access Only
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Setting this value to true will prevent JavaScript from accessing the
|
||||
| value of the cookie and the cookie will only be accessible through
|
||||
| the HTTP protocol. It's unlikely you should disable this option.
|
||||
|
|
||||
*/
|
||||
|
||||
'http_only' => env('SESSION_HTTP_ONLY', true),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Same-Site Cookies
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option determines how your cookies behave when cross-site requests
|
||||
| take place, and can be used to mitigate CSRF attacks. By default, we
|
||||
| will set this value to "lax" to permit secure cross-site requests.
|
||||
|
|
||||
| See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value
|
||||
|
|
||||
| Supported: "lax", "strict", "none", null
|
||||
|
|
||||
*/
|
||||
|
||||
'same_site' => env('SESSION_SAME_SITE', 'lax'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Partitioned Cookies
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Setting this value to true will tie the cookie to the top-level site for
|
||||
| a cross-site context. Partitioned cookies are accepted by the browser
|
||||
| when flagged "secure" and the Same-Site attribute is set to "none".
|
||||
|
|
||||
*/
|
||||
|
||||
'partitioned' => env('SESSION_PARTITIONED_COOKIE', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Serialization
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value controls the serialization strategy for session data, which
|
||||
| is JSON by default. Setting this to "php" allows the storage of PHP
|
||||
| objects in the session but can make an application vulnerable to
|
||||
| "gadget chain" serialization attacks if the APP_KEY is leaked.
|
||||
|
|
||||
| Supported: "json", "php"
|
||||
|
|
||||
*/
|
||||
|
||||
'serialization' => 'json',
|
||||
|
||||
];
|
||||
1
database/.gitignore
vendored
Normal file
1
database/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
*.sqlite*
|
||||
45
database/factories/UserFactory.php
Normal file
45
database/factories/UserFactory.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends Factory<User>
|
||||
*/
|
||||
class UserFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* The current password being used by the factory.
|
||||
*/
|
||||
protected static ?string $password;
|
||||
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'name' => fake()->name(),
|
||||
'email' => fake()->unique()->safeEmail(),
|
||||
'email_verified_at' => now(),
|
||||
'password' => static::$password ??= Hash::make('password'),
|
||||
'remember_token' => Str::random(10),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicate that the model's email address should be unverified.
|
||||
*/
|
||||
public function unverified(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'email_verified_at' => null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
49
database/migrations/0001_01_01_000000_create_users_table.php
Normal file
49
database/migrations/0001_01_01_000000_create_users_table.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('users', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->string('email')->unique();
|
||||
$table->timestamp('email_verified_at')->nullable();
|
||||
$table->string('password');
|
||||
$table->rememberToken();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('password_reset_tokens', function (Blueprint $table) {
|
||||
$table->string('email')->primary();
|
||||
$table->string('token');
|
||||
$table->timestamp('created_at')->nullable();
|
||||
});
|
||||
|
||||
Schema::create('sessions', function (Blueprint $table) {
|
||||
$table->string('id')->primary();
|
||||
$table->foreignId('user_id')->nullable()->index();
|
||||
$table->string('ip_address', 45)->nullable();
|
||||
$table->text('user_agent')->nullable();
|
||||
$table->longText('payload');
|
||||
$table->integer('last_activity')->index();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('users');
|
||||
Schema::dropIfExists('password_reset_tokens');
|
||||
Schema::dropIfExists('sessions');
|
||||
}
|
||||
};
|
||||
35
database/migrations/0001_01_01_000001_create_cache_table.php
Normal file
35
database/migrations/0001_01_01_000001_create_cache_table.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('cache', function (Blueprint $table) {
|
||||
$table->string('key')->primary();
|
||||
$table->mediumText('value');
|
||||
$table->bigInteger('expiration')->index();
|
||||
});
|
||||
|
||||
Schema::create('cache_locks', function (Blueprint $table) {
|
||||
$table->string('key')->primary();
|
||||
$table->string('owner');
|
||||
$table->bigInteger('expiration')->index();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('cache');
|
||||
Schema::dropIfExists('cache_locks');
|
||||
}
|
||||
};
|
||||
59
database/migrations/0001_01_01_000002_create_jobs_table.php
Normal file
59
database/migrations/0001_01_01_000002_create_jobs_table.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('jobs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('queue')->index();
|
||||
$table->longText('payload');
|
||||
$table->unsignedSmallInteger('attempts');
|
||||
$table->unsignedInteger('reserved_at')->nullable();
|
||||
$table->unsignedInteger('available_at');
|
||||
$table->unsignedInteger('created_at');
|
||||
});
|
||||
|
||||
Schema::create('job_batches', function (Blueprint $table) {
|
||||
$table->string('id')->primary();
|
||||
$table->string('name');
|
||||
$table->integer('total_jobs');
|
||||
$table->integer('pending_jobs');
|
||||
$table->integer('failed_jobs');
|
||||
$table->longText('failed_job_ids');
|
||||
$table->mediumText('options')->nullable();
|
||||
$table->integer('cancelled_at')->nullable();
|
||||
$table->integer('created_at');
|
||||
$table->integer('finished_at')->nullable();
|
||||
});
|
||||
|
||||
Schema::create('failed_jobs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('uuid')->unique();
|
||||
$table->string('connection');
|
||||
$table->string('queue');
|
||||
$table->longText('payload');
|
||||
$table->longText('exception');
|
||||
$table->timestamp('failed_at')->useCurrent();
|
||||
|
||||
$table->index(['connection', 'queue', 'failed_at']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('jobs');
|
||||
Schema::dropIfExists('job_batches');
|
||||
Schema::dropIfExists('failed_jobs');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('oauth_auth_codes', function (Blueprint $table) {
|
||||
$table->char('id', 80)->primary();
|
||||
$table->foreignId('user_id')->index();
|
||||
$table->foreignUuid('client_id');
|
||||
$table->text('scopes')->nullable();
|
||||
$table->boolean('revoked');
|
||||
$table->dateTime('expires_at')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('oauth_auth_codes');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the migration connection name.
|
||||
*/
|
||||
public function getConnection(): ?string
|
||||
{
|
||||
return $this->connection ?? config('passport.connection');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('oauth_access_tokens', function (Blueprint $table) {
|
||||
$table->char('id', 80)->primary();
|
||||
$table->foreignId('user_id')->nullable()->index();
|
||||
$table->foreignUuid('client_id');
|
||||
$table->string('name')->nullable();
|
||||
$table->text('scopes')->nullable();
|
||||
$table->boolean('revoked');
|
||||
$table->timestamps();
|
||||
$table->dateTime('expires_at')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('oauth_access_tokens');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the migration connection name.
|
||||
*/
|
||||
public function getConnection(): ?string
|
||||
{
|
||||
return $this->connection ?? config('passport.connection');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('oauth_refresh_tokens', function (Blueprint $table) {
|
||||
$table->char('id', 80)->primary();
|
||||
$table->char('access_token_id', 80)->index();
|
||||
$table->boolean('revoked');
|
||||
$table->dateTime('expires_at')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('oauth_refresh_tokens');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the migration connection name.
|
||||
*/
|
||||
public function getConnection(): ?string
|
||||
{
|
||||
return $this->connection ?? config('passport.connection');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('oauth_clients', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
$table->nullableMorphs('owner');
|
||||
$table->string('name');
|
||||
$table->string('secret')->nullable();
|
||||
$table->string('provider')->nullable();
|
||||
$table->text('redirect_uris');
|
||||
$table->text('grant_types');
|
||||
$table->boolean('revoked');
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('oauth_clients');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the migration connection name.
|
||||
*/
|
||||
public function getConnection(): ?string
|
||||
{
|
||||
return $this->connection ?? config('passport.connection');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('oauth_device_codes', function (Blueprint $table) {
|
||||
$table->char('id', 80)->primary();
|
||||
$table->foreignId('user_id')->nullable()->index();
|
||||
$table->foreignUuid('client_id')->index();
|
||||
$table->char('user_code', 8)->unique();
|
||||
$table->text('scopes');
|
||||
$table->boolean('revoked');
|
||||
$table->dateTime('user_approved_at')->nullable();
|
||||
$table->dateTime('last_polled_at')->nullable();
|
||||
$table->dateTime('expires_at')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('oauth_device_codes');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the migration connection name.
|
||||
*/
|
||||
public function getConnection(): ?string
|
||||
{
|
||||
return $this->connection ?? config('passport.connection');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
$teams = config('permission.teams');
|
||||
$tableNames = config('permission.table_names');
|
||||
$columnNames = config('permission.column_names');
|
||||
$pivotRole = $columnNames['role_pivot_key'] ?? 'role_id';
|
||||
$pivotPermission = $columnNames['permission_pivot_key'] ?? 'permission_id';
|
||||
|
||||
throw_if(empty($tableNames), 'Error: config/permission.php not loaded. Run [php artisan config:clear] and try again.');
|
||||
throw_if($teams && empty($columnNames['team_foreign_key'] ?? null), 'Error: team_foreign_key on config/permission.php not loaded. Run [php artisan config:clear] and try again.');
|
||||
|
||||
/**
|
||||
* See `docs/prerequisites.md` for suggested lengths on 'name' and 'guard_name' if "1071 Specified key was too long" errors are encountered.
|
||||
*/
|
||||
Schema::create($tableNames['permissions'], static function (Blueprint $table) {
|
||||
$table->id(); // permission id
|
||||
$table->string('name');
|
||||
$table->string('guard_name');
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['name', 'guard_name']);
|
||||
});
|
||||
|
||||
/**
|
||||
* See `docs/prerequisites.md` for suggested lengths on 'name' and 'guard_name' if "1071 Specified key was too long" errors are encountered.
|
||||
*/
|
||||
Schema::create($tableNames['roles'], static function (Blueprint $table) use ($teams, $columnNames) {
|
||||
$table->id(); // role id
|
||||
if ($teams || config('permission.testing')) { // permission.testing is a fix for sqlite testing
|
||||
$table->unsignedBigInteger($columnNames['team_foreign_key'])->nullable();
|
||||
$table->index($columnNames['team_foreign_key'], 'roles_team_foreign_key_index');
|
||||
}
|
||||
$table->string('name');
|
||||
$table->string('guard_name');
|
||||
$table->timestamps();
|
||||
if ($teams || config('permission.testing')) {
|
||||
$table->unique([$columnNames['team_foreign_key'], 'name', 'guard_name']);
|
||||
} else {
|
||||
$table->unique(['name', 'guard_name']);
|
||||
}
|
||||
});
|
||||
|
||||
Schema::create($tableNames['model_has_permissions'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotPermission, $teams) {
|
||||
$table->unsignedBigInteger($pivotPermission);
|
||||
|
||||
$table->string('model_type');
|
||||
$table->unsignedBigInteger($columnNames['model_morph_key']);
|
||||
$table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_permissions_model_id_model_type_index');
|
||||
|
||||
$table->foreign($pivotPermission)
|
||||
->references('id') // permission id
|
||||
->on($tableNames['permissions'])
|
||||
->cascadeOnDelete();
|
||||
if ($teams) {
|
||||
$table->unsignedBigInteger($columnNames['team_foreign_key']);
|
||||
$table->index($columnNames['team_foreign_key'], 'model_has_permissions_team_foreign_key_index');
|
||||
|
||||
$table->primary([$columnNames['team_foreign_key'], $pivotPermission, $columnNames['model_morph_key'], 'model_type'],
|
||||
'model_has_permissions_permission_model_type_primary');
|
||||
} else {
|
||||
$table->primary([$pivotPermission, $columnNames['model_morph_key'], 'model_type'],
|
||||
'model_has_permissions_permission_model_type_primary');
|
||||
}
|
||||
});
|
||||
|
||||
Schema::create($tableNames['model_has_roles'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotRole, $teams) {
|
||||
$table->unsignedBigInteger($pivotRole);
|
||||
|
||||
$table->string('model_type');
|
||||
$table->unsignedBigInteger($columnNames['model_morph_key']);
|
||||
$table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_roles_model_id_model_type_index');
|
||||
|
||||
$table->foreign($pivotRole)
|
||||
->references('id') // role id
|
||||
->on($tableNames['roles'])
|
||||
->cascadeOnDelete();
|
||||
if ($teams) {
|
||||
$table->unsignedBigInteger($columnNames['team_foreign_key']);
|
||||
$table->index($columnNames['team_foreign_key'], 'model_has_roles_team_foreign_key_index');
|
||||
|
||||
$table->primary([$columnNames['team_foreign_key'], $pivotRole, $columnNames['model_morph_key'], 'model_type'],
|
||||
'model_has_roles_role_model_type_primary');
|
||||
} else {
|
||||
$table->primary([$pivotRole, $columnNames['model_morph_key'], 'model_type'],
|
||||
'model_has_roles_role_model_type_primary');
|
||||
}
|
||||
});
|
||||
|
||||
Schema::create($tableNames['role_has_permissions'], static function (Blueprint $table) use ($tableNames, $pivotRole, $pivotPermission) {
|
||||
$table->unsignedBigInteger($pivotPermission);
|
||||
$table->unsignedBigInteger($pivotRole);
|
||||
|
||||
$table->foreign($pivotPermission)
|
||||
->references('id') // permission id
|
||||
->on($tableNames['permissions'])
|
||||
->cascadeOnDelete();
|
||||
|
||||
$table->foreign($pivotRole)
|
||||
->references('id') // role id
|
||||
->on($tableNames['roles'])
|
||||
->cascadeOnDelete();
|
||||
|
||||
$table->primary([$pivotPermission, $pivotRole], 'role_has_permissions_permission_id_role_id_primary');
|
||||
});
|
||||
|
||||
app('cache')
|
||||
->store(config('permission.cache.store') != 'default' ? config('permission.cache.store') : null)
|
||||
->forget(config('permission.cache.key'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
$tableNames = config('permission.table_names');
|
||||
|
||||
throw_if(empty($tableNames), 'Error: config/permission.php not found and defaults could not be merged. Please publish the package configuration before proceeding, or drop the tables manually.');
|
||||
|
||||
Schema::dropIfExists($tableNames['role_has_permissions']);
|
||||
Schema::dropIfExists($tableNames['model_has_roles']);
|
||||
Schema::dropIfExists($tableNames['model_has_permissions']);
|
||||
Schema::dropIfExists($tableNames['roles']);
|
||||
Schema::dropIfExists($tableNames['permissions']);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->boolean('is_active')->default(true)->after('password');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropColumn('is_active');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('app_user_roles', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignUuid('client_id')->constrained('oauth_clients')->cascadeOnDelete();
|
||||
$table->string('role');
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['user_id', 'client_id', 'role']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('app_user_roles');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('login_events', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->foreignUuid('client_id')->nullable()->constrained('oauth_clients')->nullOnDelete();
|
||||
$table->string('type'); // 'session' (panel admin) | 'oauth' (token emitido para una app)
|
||||
$table->string('ip_address', 45)->nullable();
|
||||
$table->string('user_agent')->nullable();
|
||||
$table->timestamp('created_at')->nullable();
|
||||
|
||||
$table->index(['created_at']);
|
||||
$table->index(['client_id', 'created_at']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('login_events');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->softDeletes()->after('is_active');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropSoftDeletes();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
// null = todavía no lo aprobó un admin (pendiente). Se completa
|
||||
// solo cuando: (a) lo crea un admin directamente, o (b) un admin
|
||||
// aprueba un registro propio del usuario. Es un concepto aparte
|
||||
// de "is_active" (que sirve para suspender a alguien ya aprobado).
|
||||
$table->timestamp('approved_at')->nullable()->after('is_active');
|
||||
});
|
||||
|
||||
// Los usuarios que ya existían antes de este cambio quedan aprobados
|
||||
// (si no, todos se bloquean de golpe la primera vez que se corre esto).
|
||||
DB::table('users')->whereNull('approved_at')->update(['approved_at' => now()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropColumn('approved_at');
|
||||
});
|
||||
}
|
||||
};
|
||||
170
database/seeders/DatabaseSeeder.php
Normal file
170
database/seeders/DatabaseSeeder.php
Normal file
@@ -0,0 +1,170 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\LoginEvent;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Passport\Client;
|
||||
use Laravel\Passport\ClientRepository;
|
||||
|
||||
class DatabaseSeeder extends Seeder
|
||||
{
|
||||
use WithoutModelEvents;
|
||||
|
||||
public function run(ClientRepository $clients): void
|
||||
{
|
||||
$this->call(RolesAndPermissionsSeeder::class);
|
||||
|
||||
$adminUser = User::firstOrCreate(
|
||||
['email' => 'admin@example.com'],
|
||||
['name' => 'Admin', 'password' => Hash::make('password'), 'approved_at' => now()]
|
||||
);
|
||||
$adminUser->syncRoles(['admin']);
|
||||
|
||||
// Usuarios extra solo para que las analíticas (sección "usuarios más activos")
|
||||
// tengan algo interesante que mostrar de entrada.
|
||||
$extraUsers = collect([
|
||||
['name' => 'Carla Gómez', 'email' => 'carla@example.com'],
|
||||
['name' => 'Martín Ruiz', 'email' => 'martin@example.com'],
|
||||
['name' => 'Bea Fernández', 'email' => 'bea@example.com'],
|
||||
])->map(function (array $data) {
|
||||
$u = User::firstOrCreate(['email' => $data['email']], ['name' => $data['name'], 'password' => Hash::make('password'), 'approved_at' => now()]);
|
||||
$u->syncRoles(['user']);
|
||||
|
||||
return $u;
|
||||
});
|
||||
|
||||
// Usuario de ejemplo que se autorregistró y todavía nadie aprobó, para
|
||||
// que la pestaña "Pendientes" del panel de usuarios no arranque vacía.
|
||||
User::firstOrCreate(
|
||||
['email' => 'nuevo@example.com'],
|
||||
['name' => 'Nuevo Registro', 'password' => Hash::make('password'), 'approved_at' => null]
|
||||
)->assignRole('user');
|
||||
|
||||
// Cliente requerido para poder emitir Personal Access Tokens (grant "personal_access").
|
||||
if (! Client::where('personal_access_client', true)->exists()) {
|
||||
$clients->createPersonalAccessGrantClient('Personal Access Client');
|
||||
}
|
||||
|
||||
// Cliente de demo para el flujo Authorization Code + PKCE (público, sin secret).
|
||||
$pkceClient = $adminUser->oauthApps()->firstOrCreate(
|
||||
['name' => 'Demo SPA (Authorization Code + PKCE)'],
|
||||
[
|
||||
'secret' => null,
|
||||
'redirect_uris' => ['http://localhost:8000/callback'],
|
||||
'grant_types' => ['authorization_code', 'refresh_token'],
|
||||
'revoked' => false,
|
||||
]
|
||||
);
|
||||
|
||||
// Cliente de demo para Client Credentials (machine-to-machine).
|
||||
$ccClient = $adminUser->oauthApps()->firstOrCreate(
|
||||
['name' => 'Demo Service (Client Credentials)'],
|
||||
[
|
||||
'secret' => Str::random(40),
|
||||
'redirect_uris' => [],
|
||||
'grant_types' => ['client_credentials'],
|
||||
'revoked' => false,
|
||||
]
|
||||
);
|
||||
|
||||
// Cliente de demo para Password Grant.
|
||||
$pwClient = $adminUser->oauthApps()->firstOrCreate(
|
||||
['name' => 'Demo First-Party App (Password Grant)'],
|
||||
[
|
||||
'secret' => Str::random(40),
|
||||
'redirect_uris' => [],
|
||||
'grant_types' => ['password', 'refresh_token'],
|
||||
'revoked' => false,
|
||||
]
|
||||
);
|
||||
|
||||
// Cliente confidencial para probar el botón "Authorize" de Swagger UI
|
||||
// (usa /api/oauth2-callback, que ya registra L5-Swagger).
|
||||
$swaggerClient = $adminUser->oauthApps()->firstOrCreate(
|
||||
['name' => 'Swagger UI'],
|
||||
[
|
||||
'secret' => Str::random(40),
|
||||
'redirect_uris' => [rtrim(config('app.url'), '/').'/api/oauth2-callback'],
|
||||
'grant_types' => ['authorization_code', 'client_credentials', 'refresh_token'],
|
||||
'revoked' => false,
|
||||
]
|
||||
);
|
||||
|
||||
// Rol por-app de ejemplo: carla@example.com es "admin" dentro de la app
|
||||
// "Demo SPA", aunque globalmente sea un usuario normal. Muestra que el
|
||||
// rol puede variar según qué app pregunte por el usuario.
|
||||
DB::table('app_user_roles')->insertOrIgnore([
|
||||
'user_id' => $extraUsers->first()->id,
|
||||
'client_id' => $pkceClient->id,
|
||||
'role' => 'admin',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
if (! LoginEvent::query()->exists()) {
|
||||
$this->seedLoginEvents(
|
||||
$extraUsers->push($adminUser),
|
||||
collect([$pkceClient, $ccClient, $pwClient, $swaggerClient]),
|
||||
);
|
||||
}
|
||||
|
||||
$this->command?->info('Admin: admin@example.com / password');
|
||||
$this->command?->info('Client Credentials -> client_id: '.$ccClient->id.' secret: '.($ccClient->plainSecret ?? '(ya existía, ver DB)'));
|
||||
$this->command?->info('Password Grant -> client_id: '.$pwClient->id.' secret: '.($pwClient->plainSecret ?? '(ya existía, ver DB)'));
|
||||
$this->command?->info('Swagger UI -> client_id: '.$swaggerClient->id.' secret: '.($swaggerClient->plainSecret ?? '(ya existía, ver DB)'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Historial sintético de logins (últimos 30 días) para que la pantalla de
|
||||
* analíticas tenga algo real que graficar apenas se levanta la app.
|
||||
*
|
||||
* @param \Illuminate\Support\Collection<int, User> $users
|
||||
* @param \Illuminate\Support\Collection<int, Client> $clients
|
||||
*/
|
||||
private function seedLoginEvents($users, $clients): void
|
||||
{
|
||||
$rows = [];
|
||||
|
||||
$now = now();
|
||||
|
||||
for ($daysAgo = 29; $daysAgo >= 0; $daysAgo--) {
|
||||
$dayStart = $now->copy()->subDays($daysAgo)->startOfDay();
|
||||
$maxMinute = $daysAgo === 0 ? $now->diffInMinutes($dayStart) : 1439;
|
||||
$isWeekend = $dayStart->isWeekend();
|
||||
$oauthLogins = random_int($isWeekend ? 1 : 4, $isWeekend ? 6 : 14);
|
||||
$sessionLogins = random_int($isWeekend ? 0 : 1, $isWeekend ? 2 : 5);
|
||||
|
||||
for ($i = 0; $i < $oauthLogins; $i++) {
|
||||
$rows[] = [
|
||||
'user_id' => $users->random()->id,
|
||||
'client_id' => $clients->random()->id,
|
||||
'type' => 'oauth',
|
||||
'ip_address' => '10.0.'.random_int(0, 9).'.'.random_int(2, 254),
|
||||
'user_agent' => 'Mozilla/5.0 (demo seed)',
|
||||
'created_at' => $dayStart->copy()->addMinutes(random_int(0, max(0, $maxMinute))),
|
||||
];
|
||||
}
|
||||
|
||||
for ($i = 0; $i < $sessionLogins; $i++) {
|
||||
$rows[] = [
|
||||
'user_id' => $users->random()->id,
|
||||
'client_id' => null,
|
||||
'type' => 'session',
|
||||
'ip_address' => '10.0.'.random_int(0, 9).'.'.random_int(2, 254),
|
||||
'user_agent' => 'Mozilla/5.0 (demo seed)',
|
||||
'created_at' => $dayStart->copy()->addMinutes(random_int(0, max(0, $maxMinute))),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
foreach (array_chunk($rows, 200) as $chunk) {
|
||||
LoginEvent::insert($chunk);
|
||||
}
|
||||
}
|
||||
}
|
||||
27
database/seeders/RolesAndPermissionsSeeder.php
Normal file
27
database/seeders/RolesAndPermissionsSeeder.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use Illuminate\Database\Seeder;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
use Spatie\Permission\Models\Role;
|
||||
|
||||
/**
|
||||
* Roles y permisos base (admin/user + permisos "users.*"). Se ejecuta desde
|
||||
* DatabaseSeeder y también en runtime cuando se registra el primer usuario
|
||||
* de una instalación (ver AuthController::register) — así el flujo de
|
||||
* "primer admin" funciona aunque nunca se haya corrido "db:seed" en producción.
|
||||
*/
|
||||
class RolesAndPermissionsSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
foreach (['users.view', 'users.create', 'users.update', 'users.delete'] as $permission) {
|
||||
Permission::findOrCreate($permission);
|
||||
}
|
||||
|
||||
$admin = Role::findOrCreate('admin');
|
||||
$admin->syncPermissions(Permission::all());
|
||||
Role::findOrCreate('user');
|
||||
}
|
||||
}
|
||||
102
docker-compose.prod.yml
Normal file
102
docker-compose.prod.yml
Normal file
@@ -0,0 +1,102 @@
|
||||
name: oauth-iam
|
||||
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
target: app
|
||||
image: oauth-iam-app:latest
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
CONTAINER_ROLE: app
|
||||
env_file: .env.production
|
||||
volumes:
|
||||
- app_storage:/var/www/html/storage
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "nc", "-z", "127.0.0.1", "9000"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
queue:
|
||||
build:
|
||||
context: .
|
||||
target: app
|
||||
image: oauth-iam-app:latest
|
||||
restart: unless-stopped
|
||||
command: php artisan queue:work redis --sleep=3 --tries=3 --max-time=3600
|
||||
env_file: .env
|
||||
volumes:
|
||||
- app_storage:/var/www/html/storage
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
scheduler:
|
||||
build:
|
||||
context: .
|
||||
target: app
|
||||
image: oauth-iam-app:latest
|
||||
restart: unless-stopped
|
||||
command: php artisan schedule:work
|
||||
env_file: .env.production
|
||||
volumes:
|
||||
- app_storage:/var/www/html/storage
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
nginx:
|
||||
build:
|
||||
context: .
|
||||
target: nginx
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
# - "443:443"
|
||||
volumes:
|
||||
- ./docker/nginx/certs:/etc/nginx/certs:ro
|
||||
depends_on:
|
||||
- app
|
||||
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: ${DB_DATABASE}
|
||||
POSTGRES_USER: ${DB_USERNAME}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
volumes:
|
||||
- db_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME} -d ${DB_DATABASE}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
restart: unless-stopped
|
||||
command: redis-server --save 60 1 --appendonly yes
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
volumes:
|
||||
app_storage:
|
||||
db_data:
|
||||
redis_data:
|
||||
68
docker/nginx/default.conf
Normal file
68
docker/nginx/default.conf
Normal file
@@ -0,0 +1,68 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /var/www/html/public;
|
||||
index index.php;
|
||||
|
||||
client_max_body_size 20m;
|
||||
|
||||
# DNS embebido de Docker: resuelve "app" en cada request (en vez de una sola vez
|
||||
# al arrancar nginx) para no quedarse con una IP vieja si el contenedor "app" se
|
||||
# recrea (redeploy, restart) mientras nginx sigue corriendo.
|
||||
resolver 127.0.0.11 valid=10s;
|
||||
|
||||
# Descomentar cuando haya certificados en /etc/nginx/certs (ver docs/despliegue.md)
|
||||
# y quitar el "location /" de abajo para forzar HTTPS:
|
||||
# location / { return 301 https://$host$request_uri; }
|
||||
|
||||
location = /favicon.ico { access_log off; log_not_found off; }
|
||||
location = /robots.txt { access_log off; log_not_found off; }
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.php?$query_string;
|
||||
}
|
||||
|
||||
location ~ \.php$ {
|
||||
set $php_upstream app:9000;
|
||||
fastcgi_pass $php_upstream;
|
||||
fastcgi_index index.php;
|
||||
fastcgi_param SCRIPT_FILENAME /var/www/html/public$fastcgi_script_name;
|
||||
include fastcgi_params;
|
||||
fastcgi_read_timeout 60;
|
||||
fastcgi_hide_header X-Powered-By;
|
||||
}
|
||||
|
||||
location ~ /\.(?!well-known) {
|
||||
deny all;
|
||||
}
|
||||
|
||||
gzip on;
|
||||
gzip_types text/css application/javascript application/json image/svg+xml;
|
||||
}
|
||||
|
||||
# server {
|
||||
# listen 443 ssl http2;
|
||||
# server_name tu-dominio.com;
|
||||
# root /var/www/html/public;
|
||||
# index index.php;
|
||||
#
|
||||
# ssl_certificate /etc/nginx/certs/fullchain.pem;
|
||||
# ssl_certificate_key /etc/nginx/certs/privkey.pem;
|
||||
# ssl_protocols TLSv1.2 TLSv1.3;
|
||||
#
|
||||
# client_max_body_size 20m;
|
||||
# resolver 127.0.0.11 valid=10s;
|
||||
#
|
||||
# location / {
|
||||
# try_files $uri $uri/ /index.php?$query_string;
|
||||
# }
|
||||
#
|
||||
# location ~ \.php$ {
|
||||
# set $php_upstream app:9000;
|
||||
# fastcgi_pass $php_upstream;
|
||||
# fastcgi_index index.php;
|
||||
# fastcgi_param SCRIPT_FILENAME /var/www/html/public$fastcgi_script_name;
|
||||
# include fastcgi_params;
|
||||
# fastcgi_read_timeout 60;
|
||||
# }
|
||||
# }
|
||||
24
docker/php/entrypoint.sh
Normal file
24
docker/php/entrypoint.sh
Normal file
@@ -0,0 +1,24 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
cd /var/www/html
|
||||
|
||||
if [ "$CONTAINER_ROLE" = "app" ]; then
|
||||
chown -R www-data:www-data storage bootstrap/cache
|
||||
|
||||
if [ ! -f storage/oauth-private.key ]; then
|
||||
echo "Generando claves de Passport (primer arranque)..."
|
||||
su-exec www-data php artisan passport:keys
|
||||
fi
|
||||
|
||||
echo "Aplicando migraciones..."
|
||||
su-exec www-data php artisan migrate --force
|
||||
|
||||
echo "Cacheando configuración, rutas y vistas..."
|
||||
su-exec www-data php artisan config:cache
|
||||
su-exec www-data php artisan route:cache
|
||||
su-exec www-data php artisan view:cache
|
||||
su-exec www-data php artisan l5-swagger:generate
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
7
docker/php/opcache.ini
Normal file
7
docker/php/opcache.ini
Normal file
@@ -0,0 +1,7 @@
|
||||
opcache.enable = 1
|
||||
opcache.enable_cli = 0
|
||||
opcache.memory_consumption = 128
|
||||
opcache.interned_strings_buffer = 16
|
||||
opcache.max_accelerated_files = 20000
|
||||
opcache.validate_timestamps = 0
|
||||
opcache.save_comments = 1
|
||||
8
docker/php/production.ini
Normal file
8
docker/php/production.ini
Normal file
@@ -0,0 +1,8 @@
|
||||
memory_limit = 256M
|
||||
upload_max_filesize = 20M
|
||||
post_max_size = 20M
|
||||
max_execution_time = 60
|
||||
expose_php = Off
|
||||
display_errors = Off
|
||||
log_errors = On
|
||||
error_log = /proc/self/fd/2
|
||||
108
docs/despliegue.md
Normal file
108
docs/despliegue.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# Despliegue en producción (Docker)
|
||||
|
||||
Stack: `app` (PHP-FPM 8.3) + `nginx` (estáticos y proxy) + `db` (PostgreSQL 16) + `redis`
|
||||
(cache/sesión/colas) + `queue` (worker) + `scheduler`.
|
||||
|
||||
## 1. Preparar el `.env`
|
||||
|
||||
```bash
|
||||
cp .env.production.example .env
|
||||
```
|
||||
|
||||
Editar `.env` y completar como mínimo:
|
||||
|
||||
- `APP_URL` — dominio real, con `https://`.
|
||||
- `DB_PASSWORD` — contraseña de PostgreSQL (usada también por el servicio `db`).
|
||||
- `SESSION_DOMAIN` — dominio (o `.tu-dominio.com` si hay subdominios).
|
||||
- `APP_KEY` — generar uno nuevo, no reutilizar el de desarrollo:
|
||||
|
||||
```bash
|
||||
docker run --rm -v "$PWD":/app -w /app composer:2 sh -c \
|
||||
"php -r \"echo 'base64:'.base64_encode(random_bytes(32)).PHP_EOL;\""
|
||||
```
|
||||
|
||||
Pegar el resultado en `APP_KEY=`.
|
||||
|
||||
## 2. Levantar el stack
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml up -d --build
|
||||
```
|
||||
|
||||
En el primer arranque, el contenedor `app` (ver `docker/php/entrypoint.sh`):
|
||||
|
||||
1. Genera las claves RSA de Passport (`storage/oauth-private.key` / `.pub`) si no existen —
|
||||
quedan persistidas en el volumen `app_storage`, **no se regeneran en cada deploy** (regenerarlas
|
||||
invalidaría todos los tokens ya emitidos).
|
||||
2. Corre `php artisan migrate --force`.
|
||||
3. Cachea config/rutas/vistas y regenera la documentación de Swagger.
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml logs -f app
|
||||
```
|
||||
|
||||
## 3. Crear el primer usuario admin
|
||||
|
||||
**Recomendado para producción:** no correr `db:seed` (ese seeder crea datos de demo con
|
||||
contraseñas conocidas). En su lugar, con la base de usuarios vacía, andá a `/register` y creá una
|
||||
cuenta — al ser la primera, el sistema la marca automáticamente como **admin**, aprobada y con la
|
||||
sesión iniciada (no hace falta que nadie la apruebe, porque todavía no existe ningún admin). Cualquier
|
||||
registro posterior vuelve a requerir aprobación manual desde el panel de usuarios.
|
||||
|
||||
Alternativa solo para entornos de prueba/demo, con datos de ejemplo (usuarios, clientes OAuth, historial
|
||||
de logins falso):
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml exec app php artisan db:seed --force
|
||||
```
|
||||
|
||||
Esto sí crea `admin@example.com` / `password` — **cambiar la contraseña de inmediato** si se usa
|
||||
fuera de un entorno descartable.
|
||||
|
||||
## 4. TLS
|
||||
|
||||
Por defecto `nginx` sirve solo HTTP en el puerto 80 (`docker/nginx/default.conf`). Para HTTPS:
|
||||
|
||||
1. Conseguir certificado (por ejemplo con `certbot --webroot` apuntando a
|
||||
`docker/nginx/certs`, o cualquier CA propia).
|
||||
2. Colocar `fullchain.pem` y `privkey.pem` en `docker/nginx/certs/`.
|
||||
3. Descomentar el bloque `server { listen 443 ... }` en `docker/nginx/default.conf` y el
|
||||
`location /` que redirige a HTTPS.
|
||||
4. Descomentar `"443:443"` en `docker-compose.prod.yml` (servicio `nginx`).
|
||||
5. `docker compose -f docker-compose.prod.yml up -d --build nginx`.
|
||||
|
||||
## 5. Desplegar una actualización
|
||||
|
||||
```bash
|
||||
git pull # o el mecanismo que uses para traer código nuevo
|
||||
docker compose -f docker-compose.prod.yml up -d --build
|
||||
```
|
||||
|
||||
Las claves de Passport, la base de datos y Redis viven en volúmenes con nombre
|
||||
(`app_storage`, `db_data`, `redis_data`) — sobreviven a los rebuilds. El código y los assets del
|
||||
frontend, en cambio, se hornean dentro de la imagen en cada build (no hay bind mounts de código en
|
||||
producción).
|
||||
|
||||
## 6. Backups
|
||||
|
||||
Mínimo indispensable a respaldar:
|
||||
|
||||
- Volumen `db_data` (o mejor, `pg_dump` periódico contra el servicio `db`).
|
||||
- Volumen `app_storage` — contiene, entre otras cosas, las claves de Passport. Perderlas invalida
|
||||
todos los tokens emitidos hasta ese momento.
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml exec db \
|
||||
pg_dump -U "$DB_USERNAME" "$DB_DATABASE" > backup-$(date +%F).sql
|
||||
```
|
||||
|
||||
## Servicios del compose
|
||||
|
||||
| Servicio | Rol |
|
||||
|-------------|-----|
|
||||
| `app` | PHP-FPM. Corre migraciones y cacheo al arrancar (`CONTAINER_ROLE=app`). |
|
||||
| `queue` | `php artisan queue:work redis` — misma imagen que `app`, sin el paso de migración. |
|
||||
| `scheduler` | `php artisan schedule:work` — reemplaza al cron del sistema. |
|
||||
| `nginx` | Sirve `public/build` y hace proxy de `*.php` a `app:9000`. |
|
||||
| `db` | PostgreSQL 16, datos en el volumen `db_data`. |
|
||||
| `redis` | Cache, sesiones y colas, datos en el volumen `redis_data` (AOF activado). |
|
||||
602
docs/integracion-iam.html
Normal file
602
docs/integracion-iam.html
Normal file
@@ -0,0 +1,602 @@
|
||||
<!doctype html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Integrar tu app con este servidor de identidad (IAM)</title>
|
||||
<style>
|
||||
@page {
|
||||
margin: 22mm 18mm;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, "Helvetica Neue", Arial, sans-serif;
|
||||
color: #1f2430;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.cover {
|
||||
height: 245mm;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
page-break-after: always;
|
||||
}
|
||||
.cover .badge {
|
||||
display: inline-block;
|
||||
background: #4f46e5;
|
||||
color: white;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: .08em;
|
||||
text-transform: uppercase;
|
||||
padding: 4px 12px;
|
||||
border-radius: 999px;
|
||||
margin-bottom: 18px;
|
||||
width: fit-content;
|
||||
}
|
||||
.cover h1 {
|
||||
font-size: 32px;
|
||||
margin: 0 0 10px;
|
||||
color: #111827;
|
||||
}
|
||||
.cover p.subtitle {
|
||||
font-size: 15px;
|
||||
color: #4b5563;
|
||||
max-width: 440px;
|
||||
margin: 0 0 28px;
|
||||
}
|
||||
.cover .meta {
|
||||
font-size: 11.5px;
|
||||
color: #9ca3af;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
padding-top: 12px;
|
||||
width: fit-content;
|
||||
}
|
||||
h1, h2, h3 { color: #111827; }
|
||||
h2 {
|
||||
font-size: 19px;
|
||||
margin-top: 0;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 2px solid #4f46e5;
|
||||
}
|
||||
h3 {
|
||||
font-size: 14.5px;
|
||||
margin-bottom: 6px;
|
||||
color: #4338ca;
|
||||
}
|
||||
section {
|
||||
page-break-before: always;
|
||||
padding-top: 4mm;
|
||||
}
|
||||
section:first-of-type { page-break-before: avoid; }
|
||||
p { margin: 6px 0 10px; }
|
||||
ul, ol { margin: 6px 0 12px; padding-left: 20px; }
|
||||
li { margin-bottom: 4px; }
|
||||
code {
|
||||
background: #f1f0fb;
|
||||
color: #4338ca;
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
font-family: "SF Mono", Menlo, Consolas, monospace;
|
||||
font-size: 11.5px;
|
||||
}
|
||||
pre {
|
||||
background: #1e1e2e;
|
||||
color: #e2e2f5;
|
||||
padding: 12px 14px;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
font-family: "SF Mono", Menlo, Consolas, monospace;
|
||||
font-size: 10.3px;
|
||||
line-height: 1.5;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
pre code { background: none; color: inherit; padding: 0; }
|
||||
.lang-tag {
|
||||
display: inline-block;
|
||||
background: #111827;
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: .03em;
|
||||
text-transform: uppercase;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px 4px 0 0;
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 10px 0 16px;
|
||||
font-size: 11.5px;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
th, td {
|
||||
border: 1px solid #e5e7eb;
|
||||
padding: 6px 9px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
th {
|
||||
background: #f5f5fb;
|
||||
color: #374151;
|
||||
font-weight: 600;
|
||||
}
|
||||
tr:nth-child(even) td { background: #fafafa; }
|
||||
.box {
|
||||
background: #f5f5fb;
|
||||
border-left: 4px solid #4f46e5;
|
||||
padding: 10px 14px;
|
||||
border-radius: 6px;
|
||||
margin: 12px 0;
|
||||
font-size: 11.8px;
|
||||
}
|
||||
.box.warn {
|
||||
background: #fffbeb;
|
||||
border-left-color: #d97706;
|
||||
}
|
||||
.box.danger {
|
||||
background: #fef2f2;
|
||||
border-left-color: #dc2626;
|
||||
}
|
||||
.box strong { color: #111827; }
|
||||
.toc { list-style: none; padding-left: 0; }
|
||||
.toc li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px dotted #d1d5db;
|
||||
padding: 5px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
.toc .n { color: #4f46e5; font-weight: 600; margin-right: 8px; }
|
||||
.pill {
|
||||
display: inline-block;
|
||||
background: #eef2ff;
|
||||
color: #4338ca;
|
||||
border-radius: 4px;
|
||||
padding: 1px 7px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
margin-right: 4px;
|
||||
}
|
||||
.stack-grid {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin: 12px 0 18px;
|
||||
}
|
||||
.stack-card {
|
||||
flex: 1;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.stack-card h4 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 12px;
|
||||
color: #4338ca;
|
||||
}
|
||||
.stack-card p {
|
||||
margin: 0;
|
||||
font-size: 10.8px;
|
||||
color: #6b7280;
|
||||
}
|
||||
footer.pagefoot {
|
||||
margin-top: 22px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
font-size: 10px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="cover">
|
||||
<span class="badge">Guía para desarrolladores</span>
|
||||
<h1>Integrar tu app con este servidor de identidad</h1>
|
||||
<p class="subtitle">Cómo conectar Angular, NestJS, Flutter (o cualquier otro stack) a este servidor OAuth2
|
||||
para usarlo como IAM centralizado: login, usuarios, roles globales y roles por-app.</p>
|
||||
<div class="meta">
|
||||
Laravel 12 · Passport 13 (OAuth2, tokens JWT firmados RS256)<br>
|
||||
Generado el 25 de julio de 2026
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h2>Índice</h2>
|
||||
<ul class="toc">
|
||||
<li><span><span class="n">1.</span>Qué te da este servidor</span></li>
|
||||
<li><span><span class="n">2.</span>Paso 1 — Registrar tu app como cliente OAuth</span></li>
|
||||
<li><span><span class="n">3.</span>Elegí el flujo según tu arquitectura</span></li>
|
||||
<li><span><span class="n">4.</span>Angular (SPA) — Authorization Code + PKCE</span></li>
|
||||
<li><span><span class="n">5.</span>NestJS — backend con login de usuarios</span></li>
|
||||
<li><span><span class="n">6.</span>NestJS — servicio backend-a-backend (Client Credentials)</span></li>
|
||||
<li><span><span class="n">7.</span>Flutter / apps móviles</span></li>
|
||||
<li><span><span class="n">8.</span>Validar el token en tu backend</span></li>
|
||||
<li><span><span class="n">9.</span>Roles: global vs. por-app</span></li>
|
||||
<li><span><span class="n">10.</span>Refrescar y revocar tokens</span></li>
|
||||
<li><span><span class="n">11.</span>Checklist de seguridad</span></li>
|
||||
<li><span><span class="n">12.</span>Referencia rápida</span></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>1. Qué te da este servidor</h2>
|
||||
<p>
|
||||
Es un <strong>servidor de identidad OAuth2</strong> (Laravel Passport). En vez de que cada app tuya tenga su
|
||||
propia tabla de usuarios y su propio login, todas hablan con este servidor: acá vive el usuario, la
|
||||
contraseña, y quién es qué.
|
||||
</p>
|
||||
<ul>
|
||||
<li><strong>Login centralizado</strong> — un solo lugar donde el usuario pone su contraseña. Tu app nunca
|
||||
la ve: recibe un <em>access token</em> después de que el usuario aprueba el acceso.</li>
|
||||
<li><strong>Datos de usuario vía API</strong> — <code>GET /api/user</code> con el token te devuelve nombre,
|
||||
email, y sus roles.</li>
|
||||
<li><strong>Roles globales y roles por-app</strong> — un usuario puede ser <code>admin</code> en el sistema
|
||||
en general, pero además tener un rol <em>específico de tu app</em> (por ejemplo <code>editor</code> en la
|
||||
tuya y nada en la de otro equipo). Ver sección 9.</li>
|
||||
<li><strong>Gestión completa por API</strong> — si querés armar tu propio panel de administración de
|
||||
usuarios (en vez de usar el que trae este servidor), toda la funcionalidad está expuesta en
|
||||
<code>/api/*</code> con Bearer token. No hace falta sesión ni cookies de este servidor.</li>
|
||||
</ul>
|
||||
<div class="box">
|
||||
<strong>Dos capas de permisos, no confundirlas:</strong> los <em>scopes</em> del token OAuth (
|
||||
<code>users:read</code>, <code>users:write</code>...) definen qué puede hacer <em>ese token</em> contra la
|
||||
API de este servidor. Los <em>roles por-app</em> definen qué puede hacer <em>ese usuario dentro de tu app</em>
|
||||
— son datos que vos leés e interpretás como quieras, este servidor no te impone nada sobre cómo usarlos.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>2. Paso 1 — Registrar tu app como cliente OAuth</h2>
|
||||
<p>Cada aplicación que se conecte necesita ser un <em>cliente</em> registrado. Se hace una sola vez, desde el
|
||||
panel (<code>/clients</code>, con cualquier cuenta) o por API:</p>
|
||||
<pre><code>curl -X POST http://localhost:8000/api/clients \
|
||||
-H "Authorization: Bearer {tu_access_token}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "Mi App Angular",
|
||||
"grant_type": "authorization_code",
|
||||
"redirect_uris": ["https://mi-app.com/callback"],
|
||||
"confidential": false
|
||||
}'</code></pre>
|
||||
<p>La respuesta trae <code>client.id</code> (el <code>client_id</code>) y, si es confidencial,
|
||||
<code>plain_secret</code> — <strong>se muestra una sola vez</strong>, guardalo ya.</p>
|
||||
<table>
|
||||
<tr><th>Campo</th><th>Qué significa</th></tr>
|
||||
<tr><td><code>grant_type</code></td><td><code>authorization_code</code>, <code>client_credentials</code> o <code>password</code> — ver sección 3 para elegir.</td></tr>
|
||||
<tr><td><code>redirect_uris</code></td><td>A dónde te devuelve el navegador después del login. Tiene que matchear exacto con lo que mandes en <code>/oauth/authorize</code>.</td></tr>
|
||||
<tr><td><code>confidential</code></td><td><code>false</code> = cliente público, sin secret (SPA, apps móviles — usan PKCE en su lugar). <code>true</code> = confidencial, con secret (backends que pueden guardar un secreto de forma segura).</td></tr>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>3. Elegí el flujo según tu arquitectura</h2>
|
||||
<div class="stack-grid">
|
||||
<div class="stack-card">
|
||||
<h4>SPA pura (Angular, React, Vue)</h4>
|
||||
<p>Sin backend propio, corre 100% en el navegador. <strong>Authorization Code + PKCE</strong>, cliente
|
||||
público. El token queda en el navegador.</p>
|
||||
</div>
|
||||
<div class="stack-card">
|
||||
<h4>App con backend (NestJS, Express)</h4>
|
||||
<p>El backend maneja el login por vos (patrón BFF). <strong>Authorization Code</strong>, cliente
|
||||
confidencial — el secret vive en el backend, nunca llega al navegador.</p>
|
||||
</div>
|
||||
<div class="stack-card">
|
||||
<h4>App móvil (Flutter, RN)</h4>
|
||||
<p><strong>Authorization Code + PKCE</strong>, cliente público, con un redirect por deep link / custom
|
||||
scheme.</p>
|
||||
</div>
|
||||
<div class="stack-card">
|
||||
<h4>Servicio sin usuario final</h4>
|
||||
<p>Un cron, un worker, otro backend que llama a la API por su cuenta. <strong>Client Credentials</strong>.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="box">
|
||||
<strong>Password Grant existe pero evitalo</strong> salvo que sea estrictamente first-party (una app que vos
|
||||
mismo controlás de punta a punta): tu app le pide el usuario y contraseña directamente al usuario y se los
|
||||
manda a este servidor. Ningún cliente de terceros debería tocar la contraseña del usuario.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>4. Angular (SPA) — Authorization Code + PKCE</h2>
|
||||
<p>Ejemplo con <code>angular-oauth2-oidc</code> (también podés implementarlo a mano, son cuatro pasos: generar
|
||||
verifier/challenge, redirigir, recibir el <code>code</code>, canjearlo).</p>
|
||||
|
||||
<span class="lang-tag">app.config.ts</span>
|
||||
<pre><code>import { provideOAuthClient, OAuthModule } from 'angular-oauth2-oidc';
|
||||
|
||||
export const authConfig = {
|
||||
issuer: 'http://localhost:8000',
|
||||
loginUrl: 'http://localhost:8000/oauth/authorize',
|
||||
tokenEndpoint: 'http://localhost:8000/oauth/token',
|
||||
redirectUri: window.location.origin + '/callback',
|
||||
clientId: '{tu_client_id}',
|
||||
responseType: 'code',
|
||||
scope: 'users:read account:read',
|
||||
useHttpBasicAuth: false,
|
||||
disablePKCE: false, // clave: PKCE prendido, es cliente público
|
||||
oidc: false, // este servidor es OAuth2 puro, no expone id_token/OIDC
|
||||
};</code></pre>
|
||||
|
||||
<span class="lang-tag">auth.service.ts</span>
|
||||
<pre><code>import { Injectable } from '@angular/core';
|
||||
import { OAuthService } from 'angular-oauth2-oidc';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AuthService {
|
||||
constructor(private oauth: OAuthService) {}
|
||||
|
||||
login() {
|
||||
this.oauth.initCodeFlow(); // redirige a /oauth/authorize con PKCE
|
||||
}
|
||||
|
||||
async me() {
|
||||
const token = this.oauth.getAccessToken();
|
||||
const res = await fetch('http://localhost:8000/api/user', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
return res.json(); // { id, name, email, roles, app_roles, ... }
|
||||
}
|
||||
}</code></pre>
|
||||
<p>Si preferís no traer una librería, el flujo manual es: generar <code>code_verifier</code> (random) y
|
||||
<code>code_challenge</code> (SHA-256 + base64url del verifier), armar la URL de
|
||||
<code>/oauth/authorize</code> con esos parámetros + <code>state</code>, y en el callback hacer
|
||||
<code>POST /oauth/token</code> con <code>grant_type=authorization_code</code>, el <code>code</code> recibido y
|
||||
el <code>code_verifier</code> original.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>5. NestJS — backend con login de usuarios</h2>
|
||||
<p>Tu backend actúa de cliente <strong>confidencial</strong> (guarda el secret) y maneja el flujo Authorization
|
||||
Code por el usuario — patrón "backend for frontend": el navegador nunca ve el access token, solo una cookie de
|
||||
sesión propia de tu app.</p>
|
||||
|
||||
<span class="lang-tag">auth.controller.ts</span>
|
||||
<pre><code>@Controller('auth')
|
||||
export class AuthController {
|
||||
private readonly IAM = 'http://localhost:8000';
|
||||
private readonly CLIENT_ID = process.env.IAM_CLIENT_ID;
|
||||
private readonly CLIENT_SECRET = process.env.IAM_CLIENT_SECRET;
|
||||
|
||||
@Get('login')
|
||||
login(@Res() res: Response) {
|
||||
const params = new URLSearchParams({
|
||||
client_id: this.CLIENT_ID,
|
||||
redirect_uri: 'https://mi-backend.com/auth/callback',
|
||||
response_type: 'code',
|
||||
scope: 'users:read',
|
||||
state: crypto.randomUUID(),
|
||||
});
|
||||
res.redirect(`${this.IAM}/oauth/authorize?${params}`);
|
||||
}
|
||||
|
||||
@Get('callback')
|
||||
async callback(@Query('code') code: string, @Session() session: Record<string, any>) {
|
||||
const { data } = await axios.post(`${this.IAM}/oauth/token`, {
|
||||
grant_type: 'authorization_code',
|
||||
client_id: this.CLIENT_ID,
|
||||
client_secret: this.CLIENT_SECRET,
|
||||
code,
|
||||
redirect_uri: 'https://mi-backend.com/auth/callback',
|
||||
});
|
||||
|
||||
session.accessToken = data.access_token; // guardalo del lado del servidor
|
||||
session.refreshToken = data.refresh_token;
|
||||
}
|
||||
}</code></pre>
|
||||
<p>Como es cliente confidencial, no hace falta PKCE (aunque no molesta agregarlo igual). El token queda
|
||||
guardado en la sesión del backend, nunca expuesto al navegador.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>6. NestJS — servicio backend-a-backend (Client Credentials)</h2>
|
||||
<p>Para un servicio que consulta/gestiona usuarios por su cuenta, sin un usuario logueado de por medio —
|
||||
scheduler, worker, integración entre sistemas.</p>
|
||||
|
||||
<span class="lang-tag">iam-client.service.ts</span>
|
||||
<pre><code>@Injectable()
|
||||
export class IamClientService {
|
||||
private token: string | null = null;
|
||||
private expiresAt = 0;
|
||||
|
||||
private async getToken(): Promise<string> {
|
||||
if (this.token && Date.now() < this.expiresAt) return this.token;
|
||||
|
||||
const { data } = await axios.post('http://localhost:8000/oauth/token', {
|
||||
grant_type: 'client_credentials',
|
||||
client_id: process.env.IAM_SERVICE_CLIENT_ID,
|
||||
client_secret: process.env.IAM_SERVICE_CLIENT_SECRET,
|
||||
scope: 'users:read users:write',
|
||||
});
|
||||
|
||||
this.token = data.access_token;
|
||||
this.expiresAt = Date.now() + (data.expires_in - 60) * 1000; // renovar 1 min antes
|
||||
return this.token;
|
||||
}
|
||||
|
||||
async listUsers() {
|
||||
const token = await this.getToken();
|
||||
const { data } = await axios.get('http://localhost:8000/api/users', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
return data;
|
||||
}
|
||||
}</code></pre>
|
||||
<div class="box">
|
||||
Un token de <code>client_credentials</code> no tiene usuario asociado: actúa como "servicio de confianza".
|
||||
En este servidor eso significa que puede usar <code>/api/users</code> y <code>/api/apps</code> con el
|
||||
scope como único chequeo (sin necesitar un permiso de usuario admin) — pero <strong>no</strong> puede tocar
|
||||
recursos "de cuenta propia" (<code>/api/clients</code>, personal access tokens, apps autorizadas), porque
|
||||
esos son inherentemente de un usuario y no tiene ninguno asociado.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>7. Flutter / apps móviles</h2>
|
||||
<p>Con el paquete <code>flutter_appauth</code> (implementa Authorization Code + PKCE con el navegador del
|
||||
sistema, que es lo recomendado — evita usar un WebView embebido).</p>
|
||||
|
||||
<span class="lang-tag">auth_service.dart</span>
|
||||
<pre><code>final appAuth = FlutterAppAuth();
|
||||
|
||||
Future<String?> login() async {
|
||||
final result = await appAuth.authorizeAndExchangeCode(
|
||||
AuthorizationTokenRequest(
|
||||
'{tu_client_id}',
|
||||
'com.miapp://callback', // custom scheme registrado en tu app
|
||||
serviceConfiguration: const AuthorizationServiceConfiguration(
|
||||
authorizationEndpoint: 'http://localhost:8000/oauth/authorize',
|
||||
tokenEndpoint: 'http://localhost:8000/oauth/token',
|
||||
),
|
||||
scopes: ['users:read'],
|
||||
),
|
||||
);
|
||||
|
||||
return result.accessToken;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> me(String token) async {
|
||||
final res = await http.get(
|
||||
Uri.parse('http://localhost:8000/api/user'),
|
||||
headers: {'Authorization': 'Bearer $token'},
|
||||
);
|
||||
return jsonDecode(res.body);
|
||||
}</code></pre>
|
||||
<p>El <code>redirect_uri</code> (<code>com.miapp://callback</code>) tiene que estar registrado exactamente
|
||||
igual en el cliente OAuth (sección 2) y configurado en tu app nativa (Android: intent filter; iOS: URL scheme
|
||||
en el <code>Info.plist</code>).</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>8. Validar el token en tu backend</h2>
|
||||
<p>Cuando tu backend recibe un request con <code>Authorization: Bearer {token}</code> y necesita confirmar que
|
||||
es válido, hay dos caminos:</p>
|
||||
|
||||
<h3>Opción A — Preguntarle a este servidor (más simple)</h3>
|
||||
<pre><code>GET /api/user
|
||||
Authorization: Bearer {token}</code></pre>
|
||||
<p>Si responde <code>200</code> con los datos del usuario, el token es válido. Es un request de red extra por
|
||||
cada llamada, pero es siempre exacto: refleja revocaciones al instante.</p>
|
||||
|
||||
<h3>Opción B — Validar el JWT localmente (sin red)</h3>
|
||||
<p>Los access tokens de este servidor son <strong>JWT firmados con RS256</strong> (no opacos). Tu backend puede
|
||||
verificar la firma con la clave pública de Passport (<code>storage/oauth-public.key</code> en este proyecto —
|
||||
pedísela al equipo que administra este servidor) sin llamarlo por red:</p>
|
||||
<pre><code>// Node.js, con la librería "jsonwebtoken"
|
||||
import jwt from 'jsonwebtoken';
|
||||
import fs from 'fs';
|
||||
|
||||
const publicKey = fs.readFileSync('oauth-public.key');
|
||||
|
||||
function verify(token: string) {
|
||||
return jwt.verify(token, publicKey, { algorithms: ['RS256'] });
|
||||
// -> { aud, jti, iat, nbf, exp, sub, scopes: string[] }
|
||||
}</code></pre>
|
||||
<p>Claims relevantes: <code>sub</code> = id del usuario (ausente en tokens de <code>client_credentials</code>),
|
||||
<code>aud</code> = <code>client_id</code> que lo pidió, <code>scopes</code> = array de scopes otorgados,
|
||||
<code>exp</code> = expiración (unix timestamp).</p>
|
||||
<div class="box warn">
|
||||
<strong>Trade-off:</strong> la validación local es más rápida (sin red) pero no se entera si el token fue
|
||||
<em>revocado</em> antes de su expiración natural — solo valida firma y expiración. Para casos sensibles
|
||||
(borrar acceso ya mismo), usá la Opción A o combiná: validación local + chequeo periódico contra el
|
||||
servidor.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>9. Roles: global vs. por-app</h2>
|
||||
<p><code>GET /api/user</code> (o <code>/api/users/{id}</code>) devuelve, entre otros campos:</p>
|
||||
<pre><code>{
|
||||
"id": 2,
|
||||
"name": "Carla Gómez",
|
||||
"email": "carla@example.com",
|
||||
"roles": ["user"],
|
||||
"app_roles": ["editor"]
|
||||
}</code></pre>
|
||||
<ul>
|
||||
<li><code>roles</code> — el rol <strong>global</strong> del usuario en este servidor (<code>admin</code> o
|
||||
<code>user</code>). No dice nada sobre qué puede hacer en <em>tu</em> app.</li>
|
||||
<li><code>app_roles</code> — los roles que un admin le asignó a ese usuario <strong>específicamente para el
|
||||
cliente OAuth que hizo la request</strong> (o sea: tu app). Se resuelve solo, según el <code>client_id</code>
|
||||
del token usado — no hace falta pedir nada extra.</li>
|
||||
</ul>
|
||||
<p>Un admin asigna estos roles desde <code>/admin/users</code> → editar usuario → "Roles por app", eligiendo tu
|
||||
app de la lista. Interpretalos como quieras del lado tuyo (un guard de NestJS, un route guard de Angular, etc.)
|
||||
— este servidor solo los guarda y te los devuelve.</p>
|
||||
<span class="lang-tag">nestjs guard (ejemplo)</span>
|
||||
<pre><code>@Injectable()
|
||||
export class AppRoleGuard implements CanActivate {
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const req = context.switchToHttp().getRequest();
|
||||
const user = await this.iam.me(req.headers.authorization);
|
||||
return user.app_roles.includes('editor');
|
||||
}
|
||||
}</code></pre>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>10. Refrescar y revocar tokens</h2>
|
||||
<p>Los access tokens expiran (15 días por defecto en este servidor). Si tu cliente pidió el scope
|
||||
<code>refresh_token</code> implícito del grant (viene incluido en <code>authorization_code</code> y
|
||||
<code>password</code>), la respuesta de <code>/oauth/token</code> trae un <code>refresh_token</code> — usalo
|
||||
así, sin volver a pasar por el login:</p>
|
||||
<pre><code>curl -X POST http://localhost:8000/oauth/token \
|
||||
-d grant_type=refresh_token \
|
||||
-d refresh_token={tu_refresh_token} \
|
||||
-d client_id={tu_client_id} \
|
||||
-d client_secret={tu_client_secret} # si tu cliente es confidencial</code></pre>
|
||||
<p><code>client_credentials</code> no tiene refresh token — simplemente pedís uno nuevo cuando el anterior
|
||||
expira (ver el ejemplo de NestJS en la sección 6).</p>
|
||||
<p>El usuario puede revocarte el acceso en cualquier momento desde <code>/authorized-apps</code> en el panel de
|
||||
este servidor — a partir de ahí tus requests con ese token empiezan a devolver <code>401</code>.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>11. Checklist de seguridad</h2>
|
||||
<ul>
|
||||
<li>☐ Clientes públicos (SPA, móvil) — <strong>siempre PKCE</strong>, nunca les asignes un secret que
|
||||
tendrían que embeber en código que corre en el dispositivo del usuario.</li>
|
||||
<li>☐ Pedí solo los <strong>scopes que realmente necesitás</strong> — no hay scope por defecto en este
|
||||
servidor, es intencional.</li>
|
||||
<li>☐ En una SPA, si podés, no guardes el access token en <code>localStorage</code> (vulnerable a XSS) —
|
||||
preferí el patrón backend-for-frontend (sección 5) con cookie <code>httpOnly</code>.</li>
|
||||
<li>☐ Verificá siempre el parámetro <code>state</code> en el callback de Authorization Code (protección
|
||||
CSRF del flujo).</li>
|
||||
<li>☐ Los <code>client_secret</code> de clientes confidenciales van en variables de entorno del backend,
|
||||
nunca en el repo ni en el bundle del frontend.</li>
|
||||
<li>☐ Si tu servicio backend-a-backend solo necesita leer, pedile a un admin que te dé un cliente con scope
|
||||
<code>users:read</code> nada más (no <code>users:write</code>).</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>12. Referencia rápida</h2>
|
||||
<table>
|
||||
<tr><th>Endpoint</th><th>Para qué</th></tr>
|
||||
<tr><td><code>GET /oauth/authorize</code></td><td>Inicio del flujo Authorization Code (redirige al login/consentimiento).</td></tr>
|
||||
<tr><td><code>POST /oauth/token</code></td><td>Canjear código, credenciales o refresh token por un access token.</td></tr>
|
||||
<tr><td><code>GET /api/user</code></td><td>Whoami — usuario del token actual + roles global y por-app.</td></tr>
|
||||
<tr><td><code>GET/POST/PUT/DELETE /api/users</code></td><td>Directorio de usuarios (scope <code>users:read</code>/<code>write</code>).</td></tr>
|
||||
<tr><td><code>GET/POST/DELETE /api/users/{id}/app-roles</code></td><td>Roles por-app de un usuario (para asignarlos vos mismo por API, si tenés permiso).</td></tr>
|
||||
<tr><td><code>GET /api/apps</code></td><td>Listado de apps (clientes OAuth) registradas.</td></tr>
|
||||
<tr><td><code>GET/POST/PUT/DELETE /api/clients</code></td><td>Gestionar tus propios clientes OAuth por API.</td></tr>
|
||||
</table>
|
||||
<table>
|
||||
<tr><th>Scope</th><th>Da acceso a</th></tr>
|
||||
<tr><td><code>users:read</code></td><td>Leer usuarios y sus roles (global + por-app).</td></tr>
|
||||
<tr><td><code>users:write</code></td><td>Crear/editar/borrar usuarios, asignar roles por-app.</td></tr>
|
||||
<tr><td><code>account:read</code></td><td>Leer tus propios clientes OAuth, tokens y apps autorizadas.</td></tr>
|
||||
<tr><td><code>account:write</code></td><td>Gestionar tus propios clientes OAuth, tokens y apps autorizadas.</td></tr>
|
||||
</table>
|
||||
<p>Documentación interactiva (Swagger, para probar todo esto desde el navegador) en
|
||||
<code>/api/documentation</code>. Manual completo del panel de administración en
|
||||
<code>docs/manual-usuario.pdf</code>.</p>
|
||||
|
||||
<footer class="pagefoot">Guía de integración IAM — generada automáticamente a partir del estado actual del proyecto.</footer>
|
||||
</section>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
BIN
docs/integracion-iam.pdf
Normal file
BIN
docs/integracion-iam.pdf
Normal file
Binary file not shown.
620
docs/manual-usuario.html
Normal file
620
docs/manual-usuario.html
Normal file
@@ -0,0 +1,620 @@
|
||||
<!doctype html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>OAuth2 Server — Manual de uso</title>
|
||||
<style>
|
||||
@page {
|
||||
margin: 22mm 18mm;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, "Helvetica Neue", Arial, sans-serif;
|
||||
color: #1f2430;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.cover {
|
||||
height: 245mm;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
page-break-after: always;
|
||||
}
|
||||
.cover .badge {
|
||||
display: inline-block;
|
||||
background: #4f46e5;
|
||||
color: white;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: .08em;
|
||||
text-transform: uppercase;
|
||||
padding: 4px 12px;
|
||||
border-radius: 999px;
|
||||
margin-bottom: 18px;
|
||||
width: fit-content;
|
||||
}
|
||||
.cover h1 {
|
||||
font-size: 34px;
|
||||
margin: 0 0 10px;
|
||||
color: #111827;
|
||||
}
|
||||
.cover p.subtitle {
|
||||
font-size: 15px;
|
||||
color: #4b5563;
|
||||
max-width: 420px;
|
||||
margin: 0 0 28px;
|
||||
}
|
||||
.cover .meta {
|
||||
font-size: 11.5px;
|
||||
color: #9ca3af;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
padding-top: 12px;
|
||||
width: fit-content;
|
||||
}
|
||||
h1, h2, h3 { color: #111827; }
|
||||
h2 {
|
||||
font-size: 19px;
|
||||
margin-top: 0;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 2px solid #4f46e5;
|
||||
}
|
||||
h3 {
|
||||
font-size: 14.5px;
|
||||
margin-bottom: 6px;
|
||||
color: #4338ca;
|
||||
}
|
||||
section {
|
||||
page-break-before: always;
|
||||
padding-top: 4mm;
|
||||
}
|
||||
section:first-of-type { page-break-before: avoid; }
|
||||
p { margin: 6px 0 10px; }
|
||||
ul, ol { margin: 6px 0 12px; padding-left: 20px; }
|
||||
li { margin-bottom: 4px; }
|
||||
code {
|
||||
background: #f1f0fb;
|
||||
color: #4338ca;
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
font-family: "SF Mono", Menlo, Consolas, monospace;
|
||||
font-size: 11.5px;
|
||||
}
|
||||
pre {
|
||||
background: #1e1e2e;
|
||||
color: #e2e2f5;
|
||||
padding: 12px 14px;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
font-family: "SF Mono", Menlo, Consolas, monospace;
|
||||
font-size: 10.8px;
|
||||
line-height: 1.5;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
pre code { background: none; color: inherit; padding: 0; }
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 10px 0 16px;
|
||||
font-size: 11.5px;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
th, td {
|
||||
border: 1px solid #e5e7eb;
|
||||
padding: 6px 9px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
th {
|
||||
background: #f5f5fb;
|
||||
color: #374151;
|
||||
font-weight: 600;
|
||||
}
|
||||
tr:nth-child(even) td { background: #fafafa; }
|
||||
.box {
|
||||
background: #f5f5fb;
|
||||
border-left: 4px solid #4f46e5;
|
||||
padding: 10px 14px;
|
||||
border-radius: 6px;
|
||||
margin: 12px 0;
|
||||
font-size: 11.8px;
|
||||
}
|
||||
.box.warn {
|
||||
background: #fffbeb;
|
||||
border-left-color: #d97706;
|
||||
}
|
||||
.box strong { color: #111827; }
|
||||
.credentials {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin: 12px 0 18px;
|
||||
}
|
||||
.cred-card {
|
||||
flex: 1;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
.cred-card h4 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 12.5px;
|
||||
color: #4338ca;
|
||||
}
|
||||
.cred-card div { margin-bottom: 3px; font-size: 11.5px; }
|
||||
.cred-card .label { color: #6b7280; display: inline-block; width: 62px; }
|
||||
.toc { list-style: none; padding-left: 0; }
|
||||
.toc li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px dotted #d1d5db;
|
||||
padding: 5px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
.toc .n { color: #4f46e5; font-weight: 600; margin-right: 8px; }
|
||||
.pill {
|
||||
display: inline-block;
|
||||
background: #eef2ff;
|
||||
color: #4338ca;
|
||||
border-radius: 4px;
|
||||
padding: 1px 7px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
margin-right: 4px;
|
||||
}
|
||||
footer.pagefoot {
|
||||
margin-top: 22px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
font-size: 10px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="cover">
|
||||
<span class="badge">Documentación técnica</span>
|
||||
<h1>OAuth2 Identity Server</h1>
|
||||
<p class="subtitle">Manual de uso: puesta en marcha, panel de administración, flujos OAuth2 y API REST completa para construir cualquier cliente propio (Angular, Flutter, NestJS, etc.).</p>
|
||||
<div class="meta">
|
||||
Laravel 12 · Passport 13 · Vue 3 + TypeScript · SCSS/BEM · spatie/laravel-permission · L5-Swagger<br>
|
||||
Generado el 25 de julio de 2026
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h2>Índice</h2>
|
||||
<ul class="toc">
|
||||
<li><span><span class="n">1.</span>Qué es esta app</span></li>
|
||||
<li><span><span class="n">2.</span>Puesta en marcha</span></li>
|
||||
<li><span><span class="n">3.</span>Usuarios por defecto (seed)</span></li>
|
||||
<li><span><span class="n">4.</span>Uso de la interfaz web (panel de administración)</span></li>
|
||||
<li><span><span class="n">5.</span>Analíticas: sesiones, logins y actividad</span></li>
|
||||
<li><span><span class="n">6.</span>Roles, permisos y roles por app</span></li>
|
||||
<li><span><span class="n">7.</span>Idioma (español / inglés / catalán)</span></li>
|
||||
<li><span><span class="n">8.</span>Cómo conectar tu app (Angular, NestJS, Flutter, etc.)</span></li>
|
||||
<li><span><span class="n">9.</span>API REST completa: construí tu propio panel admin</span></li>
|
||||
<li><span><span class="n">10.</span>Documentación interactiva (Swagger)</span></li>
|
||||
<li><span><span class="n">11.</span>Referencia rápida de rutas</span></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>1. Qué es esta app</h2>
|
||||
<p>
|
||||
Es un <strong>servidor de identidad / OAuth2</strong> (Laravel Passport) pensado para ser el centro único de
|
||||
usuarios, autenticación y permisos de un conjunto de aplicaciones propias. No es una app de negocio con datos
|
||||
propios: su único "recurso" son los <strong>usuarios</strong> y sus <strong>roles</strong>.
|
||||
</p>
|
||||
<p>Provee tres piezas:</p>
|
||||
<ul>
|
||||
<li><strong>Servidor de autorización OAuth2</strong> — login, pantalla de consentimiento, y emisión de tokens
|
||||
con los cuatro flujos estándar: Authorization Code + PKCE, Client Credentials, Password Grant y Personal
|
||||
Access Tokens.</li>
|
||||
<li><strong>Panel de administración en Vue 3 + TypeScript</strong> — la UI que trae la app "de fábrica",
|
||||
servida por el propio Laravel, autenticada por sesión. Estilos en SCSS con nomenclatura BEM
|
||||
(<code>resources/scss/</code>), sin frameworks de utility-classes.</li>
|
||||
<li><strong>API REST completa</strong> (<code>/api/*</code>) — el <em>mismo</em> conjunto de funciones del
|
||||
panel Vue (usuarios, roles por app, clientes OAuth, personal access tokens, apps autorizadas), pero
|
||||
autenticada 100% por token OAuth2 (sin sesión ni cookies). Con esto se puede construir <strong>otro admin
|
||||
entero</strong> en Angular, Flutter, o cualquier otro cliente — no hace falta usar la SPA Vue.</li>
|
||||
</ul>
|
||||
<div class="box">
|
||||
<strong>Idea central:</strong> cada aplicación externa que quiera loguear usuarios se registra acá como un
|
||||
<em>cliente OAuth</em> ("app"). Un mismo usuario puede tener un rol global (admin/user) y, además, un rol
|
||||
distinto <em>dentro de cada app</em> (por ejemplo: admin en la app de Angular, usuario normal en la de NestJS).
|
||||
</div>
|
||||
<div class="box">
|
||||
<strong>Dos frentes, misma API subyacente:</strong> la SPA Vue (sesión + cookies, pensada para uso interno
|
||||
en el navegador de este mismo servidor) y la API REST (Bearer token, pensada para cualquier cliente externo)
|
||||
exponen las <em>mismas</em> capacidades. Elegí el que corresponda: sesión para la UI que ya viene incluida,
|
||||
token para construir la tuya propia.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>2. Puesta en marcha</h2>
|
||||
<h3>Requisitos</h3>
|
||||
<ul>
|
||||
<li>PHP 8.4+, Composer</li>
|
||||
<li>Node.js + npm</li>
|
||||
<li>SQLite (por defecto, cero configuración — se puede cambiar en <code>.env</code>)</li>
|
||||
</ul>
|
||||
<h3>Instalación (primera vez)</h3>
|
||||
<pre><code>composer install
|
||||
npm install
|
||||
php artisan migrate --seed
|
||||
npm run build</code></pre>
|
||||
<div class="box">
|
||||
<strong>Frontend:</strong> TypeScript (<code>npm run type-check</code> corre <code>vue-tsc</code> sin
|
||||
emitir archivos) y SCSS compilado por Vite (paquete <code>sass</code>) desde
|
||||
<code>resources/scss/app.scss</code> — no hay ningún framework de CSS utility-first instalado.
|
||||
</div>
|
||||
<h3>Levantar todo en desarrollo</h3>
|
||||
<pre><code>composer run dev</code></pre>
|
||||
<p>Este comando levanta en paralelo: el servidor PHP (<code>php artisan serve</code>), Vite en modo watch, la
|
||||
cola de trabajos y los logs. La app queda disponible en <code>http://localhost:8000</code>.</p>
|
||||
<div class="box warn">
|
||||
<strong>Reiniciar todo de cero:</strong> <code>php artisan migrate:fresh --seed</code> borra la base y
|
||||
vuelve a crear los usuarios/clientes de ejemplo descriptos en la sección 3. Los <code>client_secret</code>
|
||||
de los clientes OAuth de ejemplo se imprimen en la consola en ese momento — solo se muestran una vez.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>3. Usuarios por defecto (seed)</h2>
|
||||
<p>El seeder (<code>database/seeders/DatabaseSeeder.php</code>) crea una cuenta admin y algunos usuarios de
|
||||
ejemplo para poder probar la app de inmediato:</p>
|
||||
<div class="credentials">
|
||||
<div class="cred-card">
|
||||
<h4>🛡 Administrador</h4>
|
||||
<div><span class="label">Email:</span> <code>admin@example.com</code></div>
|
||||
<div><span class="label">Password:</span> <code>password</code></div>
|
||||
<div><span class="label">Rol global:</span> <span class="pill">admin</span></div>
|
||||
<div style="margin-top:6px; color:#6b7280;">Acceso al panel de administración de usuarios.</div>
|
||||
</div>
|
||||
<div class="cred-card">
|
||||
<h4>👤 Usuario normal</h4>
|
||||
<div><span class="label">Email:</span> <code>carla@example.com</code></div>
|
||||
<div><span class="label">Password:</span> <code>password</code></div>
|
||||
<div><span class="label">Rol global:</span> <span class="pill">user</span></div>
|
||||
<div style="margin-top:6px; color:#6b7280;">Sin acceso al panel admin. Es <strong>admin</strong> dentro de la app "Demo SPA" (rol por-app de ejemplo). Junto con <code>martin@example.com</code> y <code>bea@example.com</code>, solo están para que la sección de analíticas tenga datos de entrada.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="box">
|
||||
El seeder también crea <code>nuevo@example.com</code> / <code>password</code> sin aprobar, para que la
|
||||
pestaña <strong>"Pendientes"</strong> del panel de usuarios (sección 4) no arranque vacía.
|
||||
</div>
|
||||
<div class="box">
|
||||
<strong>Instalación sin seed (producción):</strong> si nunca corriste <code>db:seed</code>, no hay ningún
|
||||
usuario en la base. En ese caso, la <strong>primera</strong> cuenta que se registre por <code>/register</code>
|
||||
queda automáticamente como <strong>admin</strong>, aprobada y con la sesión iniciada — no hace falta que
|
||||
nadie más la apruebe, porque todavía no existe ningún admin que pudiera hacerlo. La pantalla de registro
|
||||
avisa de esto con un mensaje cuando detecta que la base está vacía. A partir del segundo registro, vuelve
|
||||
a regir el flujo normal (pendiente de aprobación por un admin).
|
||||
</div>
|
||||
|
||||
<h3>Clientes OAuth (apps) de ejemplo</h3>
|
||||
<p>El seeder también registra apps de prueba, una por cada flujo soportado. El <code>client_id</code> es fijo
|
||||
por ejecución (UUID nuevo cada <code>migrate:fresh</code>); el <code>client_secret</code> solo se muestra por
|
||||
consola al sembrar — si lo perdiste, hay que crear un cliente nuevo desde el panel (sección 4) o volver a
|
||||
sembrar.</p>
|
||||
<table>
|
||||
<tr><th>Nombre</th><th>Grant type</th><th>Uso</th></tr>
|
||||
<tr><td>Demo SPA (Authorization Code + PKCE)</td><td><code>authorization_code</code></td><td>Cliente público (sin secret), para apps tipo SPA/móvil.</td></tr>
|
||||
<tr><td>Demo Service (Client Credentials)</td><td><code>client_credentials</code></td><td>Comunicación máquina-a-máquina, sin usuario.</td></tr>
|
||||
<tr><td>Demo First-Party App (Password Grant)</td><td><code>password</code></td><td>Apps propias de confianza, login directo usuario/contraseña.</td></tr>
|
||||
<tr><td>Swagger UI</td><td><code>authorization_code</code>, <code>client_credentials</code></td><td>Para probar el botón "Authorize" de la documentación interactiva.</td></tr>
|
||||
<tr><td>Personal Access Client</td><td><code>personal_access</code></td><td>Interno — necesario para poder emitir Personal Access Tokens.</td></tr>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>4. Uso de la interfaz web (panel de administración)</h2>
|
||||
<p>Todo se maneja desde <code>http://localhost:8000</code> (Vue Router, sin recargar página).</p>
|
||||
<div class="box">
|
||||
<strong>Modo oscuro:</strong> el ícono de sol/luna en la barra de navegación alterna entre claro y oscuro.
|
||||
La preferencia se guarda en <code>localStorage</code> (por navegador, no por cuenta) y, si nunca la tocaste,
|
||||
arranca siguiendo el modo del sistema operativo. El cambio se aplica antes del primer render — no hay
|
||||
parpadeo del tema equivocado al cargar la página.
|
||||
</div>
|
||||
|
||||
<h3>Login / Registro</h3>
|
||||
<p><code>/login</code> y <code>/register</code>. El registro crea la cuenta con rol global <code>user</code>
|
||||
automáticamente, pero <strong>no loguea al usuario</strong>: queda pendiente de aprobación. La pantalla muestra
|
||||
un mensaje de "Cuenta creada" y el login devuelve error hasta que un admin la aprueba desde el panel de
|
||||
usuarios (sección 4). <strong>Excepción:</strong> si la base de usuarios está vacía, esa primera cuenta se
|
||||
crea como admin y queda logueada de una (ver sección 3).</p>
|
||||
|
||||
<h3>Dashboard</h3>
|
||||
<p><code>/</code> — accesos rápidos a Clientes OAuth, Personal Access Tokens, Apps autorizadas y (si sos admin)
|
||||
Usuarios.</p>
|
||||
|
||||
<h3>Clientes OAuth</h3>
|
||||
<p><code>/clients</code> — cada usuario ve y gestiona <em>sus propios</em> clientes (apps que registró). Al
|
||||
crear uno se elige:</p>
|
||||
<ul>
|
||||
<li><strong>Authorization Code + PKCE</strong>: hay que indicar redirect URIs; opcionalmente "confidencial"
|
||||
(con secret) o público (solo PKCE, sin secret).</li>
|
||||
<li><strong>Client Credentials</strong>: siempre confidencial, sin redirect URI.</li>
|
||||
<li><strong>Password Grant</strong>: para apps propias de confianza.</li>
|
||||
</ul>
|
||||
<div class="box warn">El <code>client_secret</code> se muestra <strong>una sola vez</strong>, justo después de
|
||||
crear el cliente. Copiarlo en ese momento.</div>
|
||||
|
||||
<h3>Personal Access Tokens</h3>
|
||||
<p><code>/tokens</code> — tokens tipo "API key" para el propio usuario, sin pasar por el flujo de consentimiento.
|
||||
Se elige nombre y scopes. El token completo también se muestra una única vez.</p>
|
||||
|
||||
<h3>Apps autorizadas</h3>
|
||||
<p><code>/authorized-apps</code> — lista de apps de terceros a las que el usuario le dio acceso a su cuenta vía
|
||||
<code>/oauth/authorize</code>, con opción de revocar el acceso en cualquier momento.</p>
|
||||
|
||||
<h3>Usuarios (solo admins)</h3>
|
||||
<p><code>/admin/users</code> — alta, edición (nombre, email, contraseña, rol global, activo/desactivado) y baja
|
||||
de cualquier usuario del sistema. Protecciones incluidas: no podés borrarte a vos mismo, no podés quitarte el
|
||||
rol admin a vos mismo, no se puede borrar al último administrador. Un usuario desactivado no puede iniciar
|
||||
sesión.</p>
|
||||
<div class="box">
|
||||
<strong>Borrado = papelera (soft delete).</strong> "Borrar" un usuario no lo elimina de la base: lo oculta
|
||||
(desaparece de logins, listados y de cualquier chequeo de permisos) pero queda recuperable. El botón
|
||||
<strong>"Ver papelera"</strong> arriba a la derecha del listado muestra los usuarios borrados, con dos
|
||||
acciones por fila: <strong>Restaurar</strong> (vuelve a aparecer tal cual estaba) o <strong>Borrar
|
||||
definitivamente</strong> (ahí sí, sin vuelta atrás).
|
||||
</div>
|
||||
<div class="box">
|
||||
<strong>Registros pendientes de aprobación.</strong> Arriba a la derecha del listado hay tres pestañas:
|
||||
<em>Ver activos</em>, <strong>Pendientes</strong> (con un contador si hay alguno) y <em>Ver papelera</em>.
|
||||
Todo el que se registra por <code>/register</code> aparece en "Pendientes" hasta que un admin lo
|
||||
<strong>aprueba</strong> (ahí ya puede iniciar sesión) o lo <strong>rechaza</strong> (va a la papelera,
|
||||
igual que un borrado normal — se puede restaurar). Un usuario dado de alta directamente por un admin
|
||||
(el formulario "Nuevo usuario" de esta misma pantalla) queda aprobado automáticamente.
|
||||
</div>
|
||||
|
||||
<h3>Analíticas (solo admins)</h3>
|
||||
<p><code>/admin/analytics</code> — ver sección 5.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>5. Analíticas: sesiones, logins y actividad</h2>
|
||||
<p>
|
||||
Pantalla de monitoreo para el admin: cada vez que Passport emite un access token (cualquier grant type) o alguien
|
||||
inicia sesión en el panel, queda un registro en <code>login_events</code>. Con eso se arman:
|
||||
</p>
|
||||
<ul>
|
||||
<li><strong>KPIs</strong>: usuarios totales, sesiones activas (tokens OAuth vigentes, no revocados ni
|
||||
expirados), logins de hoy y de los últimos 7 días.</li>
|
||||
<li><strong>Logins por día</strong> (últimos 7/30/90 días, elegible arriba a la derecha): barras apiladas —
|
||||
logins de apps (OAuth) vs. logins al panel admin (sesión) — con tooltip al pasar el mouse sobre cada día.</li>
|
||||
<li><strong>Logins por app</strong>: qué apps se usan más.</li>
|
||||
<li><strong>Usuarios más activos</strong>: ranking por cantidad de logins en el período.</li>
|
||||
<li><strong>Actividad reciente</strong>: tabla con los últimos eventos (usuario, app, tipo, IP, cuándo).</li>
|
||||
</ul>
|
||||
<div class="box">
|
||||
El seeder de demo genera 30 días de actividad sintética para que estos gráficos no arranquen vacíos. En un
|
||||
despliegue real, los datos son 100% eventos reales — no hay nada que "activar".
|
||||
</div>
|
||||
<p>Misma info disponible por API: <code>GET /api/analytics/summary?days=30</code> (scope
|
||||
<code>users:read</code>, requiere el permiso <code>users.view</code> — igual que el resto del panel admin).</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>6. Roles, permisos y roles por app</h2>
|
||||
<h3>Roles globales</h3>
|
||||
<p>Gobiernan el panel de administración de esta app (<code>spatie/laravel-permission</code>):</p>
|
||||
<table>
|
||||
<tr><th>Rol</th><th>Permisos</th></tr>
|
||||
<tr><td><span class="pill">admin</span></td><td><code>users.view</code>, <code>users.create</code>, <code>users.update</code>, <code>users.delete</code></td></tr>
|
||||
<tr><td><span class="pill">user</span></td><td>Ninguno extra — gestiona su propio perfil, sus clientes OAuth y sus tokens.</td></tr>
|
||||
</table>
|
||||
|
||||
<h3>Roles por app</h3>
|
||||
<p>Independientes del rol global: un admin le puede asignar a cualquier usuario un rol (<code>admin</code> o
|
||||
<code>user</code>) <strong>específico de una app registrada</strong>, desde el panel de edición de usuario
|
||||
("Roles por app"). Sirve para que, por ejemplo, un usuario sea <code>admin</code> dentro de la app Angular
|
||||
pero solo <code>user</code> dentro de la app NestJS — sin tocar su rol global.</p>
|
||||
<p>Esa información viaja en la respuesta de la API (<code>app_roles</code>, ver sección 8): cada app ve
|
||||
únicamente el rol que ese usuario tiene <em>en ella</em>, resuelto automáticamente según el <code>client_id</code>
|
||||
del token usado en la request.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>7. Idioma (español / inglés / catalán)</h2>
|
||||
<p>El selector está arriba a la derecha en la barra de navegación (<strong>ES / EN / CA</strong>). Cambiarlo
|
||||
hace un POST a <code>/web-api/locale</code>, guarda el idioma en la sesión y recarga la página — el servidor
|
||||
decide el idioma con el que renderiza esa carga (mensajes de validación/autenticación vía
|
||||
<code>laravel-lang</code>, más los textos propios de la app en <code>lang/{es,en,ca}/messages.php</code>).</p>
|
||||
<table>
|
||||
<tr><th>Idioma</th><th>Mensajes del servidor</th><th>Interfaz (SPA Vue)</th></tr>
|
||||
<tr><td><strong>ES</strong> (español, por defecto)</td><td>✓</td><td>✓</td></tr>
|
||||
<tr><td><strong>EN</strong> (inglés)</td><td>✓</td><td>✓ — traducción completa (nav, botones, formularios, tablas, gráficos)</td></tr>
|
||||
<tr><td><strong>CA</strong> (catalán)</td><td>✓</td><td>✓ — traducción completa</td></tr>
|
||||
</table>
|
||||
<p>La interfaz usa <code>vue-i18n</code> con diccionarios en <code>resources/js/i18n/{es,en,ca}.js</code>
|
||||
(168 claves cada uno). El locale de arranque del frontend se inyecta desde el servidor
|
||||
(<code>window.__LOCALE__</code> en <code>app.blade.php</code>), así que la SPA nace ya en el idioma correcto
|
||||
sin parpadeo.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>8. Cómo conectar tu app (Angular, NestJS, Flutter, etc.)</h2>
|
||||
<h3>Paso 1 — Registrar la app</h3>
|
||||
<p>Desde <code>/clients</code>, crear un cliente OAuth eligiendo el grant type según el tipo de app:</p>
|
||||
<table>
|
||||
<tr><th>Tipo de app</th><th>Grant recomendado</th></tr>
|
||||
<tr><td>SPA (Angular, React) servida al navegador</td><td>Authorization Code + PKCE, cliente <strong>público</strong></td></tr>
|
||||
<tr><td>Backend con secret propio (NestJS, Express)</td><td>Authorization Code, cliente <strong>confidencial</strong>, o Client Credentials si no hay usuario final</td></tr>
|
||||
<tr><td>Servicio interno máquina-a-máquina</td><td>Client Credentials</td></tr>
|
||||
</table>
|
||||
|
||||
<h3>Paso 2 — Login del usuario (Authorization Code + PKCE, ej. Angular)</h3>
|
||||
<p>Redirigir el navegador a:</p>
|
||||
<pre><code>GET /oauth/authorize
|
||||
?client_id={client_id}
|
||||
&redirect_uri={tu_callback}
|
||||
&response_type=code
|
||||
&scope=users:read users:write
|
||||
&state={valor_random}
|
||||
&code_challenge={challenge_pkce}
|
||||
&code_challenge_method=S256</code></pre>
|
||||
<p>El usuario loguea (o ya tiene sesión) y aprueba el acceso. Passport redirige a <code>redirect_uri</code>
|
||||
con <code>?code=...&state=...</code>.</p>
|
||||
|
||||
<h3>Paso 3 — Canjear el código por un token</h3>
|
||||
<pre><code>curl -X POST http://localhost:8000/oauth/token \
|
||||
-d grant_type=authorization_code \
|
||||
-d client_id={client_id} \
|
||||
-d code={code} \
|
||||
-d redirect_uri={tu_callback} \
|
||||
-d code_verifier={verifier_pkce}</code></pre>
|
||||
|
||||
<h3>Alternativa — Client Credentials (ej. NestJS backend-a-backend)</h3>
|
||||
<pre><code>curl -X POST http://localhost:8000/oauth/token \
|
||||
-d grant_type=client_credentials \
|
||||
-d client_id={client_id} \
|
||||
-d client_secret={client_secret} \
|
||||
-d scope="users:read"</code></pre>
|
||||
<div class="box">Un token de <code>client_credentials</code> no tiene usuario asociado: actúa como
|
||||
"servicio de confianza" y puede listar/crear/editar usuarios sin más chequeo que el scope del token.</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>9. API REST completa: construí tu propio panel admin</h2>
|
||||
<p>
|
||||
Todo lo que hace la SPA Vue por sesión también está disponible por token OAuth2. Esto significa que un cliente
|
||||
Angular, Flutter, o lo que sea, puede ser un <strong>admin completo</strong> — no solo consultar usuarios —
|
||||
sin depender de la sesión de Laravel. Todos los endpoints requieren <code>Authorization: Bearer {access_token}</code>.
|
||||
</p>
|
||||
|
||||
<h3>Usuarios y roles (scopes <code>users:read</code> / <code>users:write</code>)</h3>
|
||||
<table>
|
||||
<tr><th>Método</th><th>Ruta</th><th>Descripción</th></tr>
|
||||
<tr><td>GET</td><td><code>/api/user</code></td><td>Whoami: usuario del token actual, con <code>roles</code> y <code>app_roles</code>.</td></tr>
|
||||
<tr><td>GET</td><td><code>/api/users</code></td><td>Listar usuarios (requiere <code>users.view</code>, salvo client_credentials).</td></tr>
|
||||
<tr><td>POST</td><td><code>/api/users</code></td><td>Crear usuario (requiere <code>users.create</code>).</td></tr>
|
||||
<tr><td>GET</td><td><code>/api/users/{id}</code></td><td>Ver un usuario. Siempre permitido sobre uno mismo.</td></tr>
|
||||
<tr><td>PUT</td><td><code>/api/users/{id}</code></td><td>Editar. Uno mismo puede cambiar nombre/email/contraseña; rol/estado y otros usuarios requieren <code>users.update</code>.</td></tr>
|
||||
<tr><td>DELETE</td><td><code>/api/users/{id}</code></td><td>Borrar — soft delete, va a la papelera (requiere <code>users.delete</code>; no se puede autoeliminar).</td></tr>
|
||||
<tr><td>GET</td><td><code>/api/users/trashed</code></td><td>Listar usuarios en la papelera (requiere <code>users.view</code>).</td></tr>
|
||||
<tr><td>POST</td><td><code>/api/users/{id}/restore</code></td><td>Restaurar un usuario borrado (requiere <code>users.update</code>).</td></tr>
|
||||
<tr><td>DELETE</td><td><code>/api/users/{id}/force</code></td><td>Borrado definitivo, sin vuelta atrás (requiere <code>users.delete</code>; el usuario tiene que estar en la papelera).</td></tr>
|
||||
<tr><td>GET</td><td><code>/api/users/pending</code></td><td>Listar registros pendientes de aprobación (requiere <code>users.view</code>).</td></tr>
|
||||
<tr><td>POST</td><td><code>/api/users/{id}/approve</code></td><td>Aprobar un registro pendiente (requiere <code>users.update</code>).</td></tr>
|
||||
<tr><td>GET</td><td><code>/api/users/{id}/app-roles</code></td><td>Roles del usuario por app (propio siempre permitido; otros requieren <code>users.view</code>).</td></tr>
|
||||
<tr><td>POST</td><td><code>/api/users/{id}/app-roles</code></td><td>Asignar un rol en una app (requiere <code>users.update</code>, sin excepción para uno mismo).</td></tr>
|
||||
<tr><td>DELETE</td><td><code>/api/users/{id}/app-roles/{clientId}/{role}</code></td><td>Quitar un rol en una app (requiere <code>users.update</code>).</td></tr>
|
||||
<tr><td>GET</td><td><code>/api/apps</code></td><td>Listar todas las apps registradas (requiere <code>users.view</code>) — para armar el selector de roles-por-app.</td></tr>
|
||||
<tr><td>GET</td><td><code>/api/analytics/summary?days=30</code></td><td>KPIs, logins por día/app, usuarios más activos y actividad reciente (requiere <code>users.view</code>).</td></tr>
|
||||
</table>
|
||||
|
||||
<h3>Ejemplo de respuesta de usuario</h3>
|
||||
<pre><code>{
|
||||
"id": 2,
|
||||
"name": "Carla Gómez",
|
||||
"email": "carla@example.com",
|
||||
"is_active": true,
|
||||
"roles": ["user"],
|
||||
"app_roles": ["admin"],
|
||||
"created_at": "2026-07-25T08:52:39.000000Z"
|
||||
}</code></pre>
|
||||
<p><code>roles</code> es el rol global; <code>app_roles</code> es el rol de ese usuario específicamente en la
|
||||
app dueña del token usado para la request (útil para que cada app decida qué mostrar/permitir sin tener su
|
||||
propio sistema de permisos). El campo <code>deleted_at</code> solo aparece (no-null) en las respuestas de
|
||||
<code>/api/users/trashed</code>.</p>
|
||||
|
||||
<h3>Cuenta propia: clientes, tokens y apps autorizadas (scopes <code>account:read</code> / <code>account:write</code>)</h3>
|
||||
<p>Equivalente por token a <code>/web-api/clients</code>, <code>/personal-access-tokens</code> y
|
||||
<code>/authorized-apps</code> de la sección 4 — siempre sobre el usuario dueño del token (no aplica a
|
||||
client_credentials, que no tiene usuario asociado):</p>
|
||||
<table>
|
||||
<tr><th>Método</th><th>Ruta</th><th>Descripción</th></tr>
|
||||
<tr><td>GET</td><td><code>/api/clients</code></td><td>Listar tus clientes OAuth (apps que registraste).</td></tr>
|
||||
<tr><td>POST</td><td><code>/api/clients</code></td><td>Registrar un cliente nuevo (devuelve <code>plain_secret</code> una sola vez).</td></tr>
|
||||
<tr><td>PUT</td><td><code>/api/clients/{id}</code></td><td>Editar nombre/redirect URIs.</td></tr>
|
||||
<tr><td>DELETE</td><td><code>/api/clients/{id}</code></td><td>Revocar el cliente y sus tokens.</td></tr>
|
||||
<tr><td>GET</td><td><code>/api/personal-access-tokens</code></td><td>Listar tus Personal Access Tokens.</td></tr>
|
||||
<tr><td>POST</td><td><code>/api/personal-access-tokens</code></td><td>Crear uno (el token se muestra una sola vez).</td></tr>
|
||||
<tr><td>DELETE</td><td><code>/api/personal-access-tokens/{id}</code></td><td>Revocarlo.</td></tr>
|
||||
<tr><td>GET</td><td><code>/api/authorized-apps</code></td><td>Apps de terceros a las que le diste acceso.</td></tr>
|
||||
<tr><td>DELETE</td><td><code>/api/authorized-apps/{tokenId}</code></td><td>Revocarle el acceso a una.</td></tr>
|
||||
</table>
|
||||
|
||||
<h3>Scopes disponibles</h3>
|
||||
<table>
|
||||
<tr><th>Scope</th><th>Permite</th></tr>
|
||||
<tr><td><code>users:read</code></td><td>Leer usuarios y roles por app</td></tr>
|
||||
<tr><td><code>users:write</code></td><td>Crear, editar y borrar usuarios; asignar roles por app</td></tr>
|
||||
<tr><td><code>account:read</code></td><td>Leer tus clientes OAuth, tokens y apps autorizadas</td></tr>
|
||||
<tr><td><code>account:write</code></td><td>Gestionar tus clientes OAuth, tokens y apps autorizadas</td></tr>
|
||||
</table>
|
||||
<p>No hay scope por defecto: cada app debe pedir explícitamente los que necesita al iniciar el flujo OAuth
|
||||
(ej. <code>scope=users:read users:write account:read account:write</code> para un admin completo).</p>
|
||||
<div class="box warn">
|
||||
<strong>Client credentials y recursos self-service:</strong> un token <code>client_credentials</code> no
|
||||
tiene usuario asociado, así que <code>/api/clients</code>, <code>/api/personal-access-tokens</code> y
|
||||
<code>/api/authorized-apps</code> le devuelven <code>422</code> (no hay "cuenta propia" que gestionar).
|
||||
Sí puede usar <code>/api/users</code> y <code>/api/apps</code> como servicio de confianza.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>10. Documentación interactiva (Swagger)</h2>
|
||||
<p>Disponible en <code>http://localhost:8000/api/documentation</code>. Incluye el botón <strong>Authorize</strong>
|
||||
para probar los tres flujos (Authorization Code, Client Credentials, Password) directamente desde el navegador,
|
||||
usando el cliente de ejemplo <em>"Swagger UI"</em> sembrado (sección 3).</p>
|
||||
<p>El JSON crudo de la especificación OpenAPI está en <code>/docs</code>.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>11. Referencia rápida de rutas</h2>
|
||||
<h3>Interfaz web (sesión, uso interno de esta app)</h3>
|
||||
<table>
|
||||
<tr><th>Ruta</th><th>Qué hace</th></tr>
|
||||
<tr><td><code>POST /web-api/register</code></td><td>Registro — crea la cuenta pendiente, no loguea.</td></tr>
|
||||
<tr><td><code>POST /web-api/login</code> / <code>/logout</code></td><td>Autenticación por sesión.</td></tr>
|
||||
<tr><td><code>GET /web-api/me</code></td><td>Usuario logueado (con roles/permisos).</td></tr>
|
||||
<tr><td><code>GET/POST/PUT/DELETE /web-api/clients</code></td><td>Clientes OAuth propios.</td></tr>
|
||||
<tr><td><code>GET/POST/DELETE /web-api/personal-access-tokens</code></td><td>Personal Access Tokens propios.</td></tr>
|
||||
<tr><td><code>GET/DELETE /web-api/authorized-apps</code></td><td>Apps de terceros autorizadas.</td></tr>
|
||||
<tr><td><code>GET/POST/PUT/DELETE /web-api/admin/users</code></td><td>Administración de usuarios (admin).</td></tr>
|
||||
<tr><td><code>GET /web-api/admin/users/trashed</code></td><td>Usuarios en la papelera (admin).</td></tr>
|
||||
<tr><td><code>POST /web-api/admin/users/{id}/restore</code></td><td>Restaurar un usuario borrado (admin).</td></tr>
|
||||
<tr><td><code>DELETE /web-api/admin/users/{id}/force</code></td><td>Borrado definitivo (admin).</td></tr>
|
||||
<tr><td><code>GET /web-api/admin/users/pending</code></td><td>Registros pendientes de aprobación (admin).</td></tr>
|
||||
<tr><td><code>POST /web-api/admin/users/{id}/approve</code></td><td>Aprobar un registro pendiente (admin).</td></tr>
|
||||
<tr><td><code>GET /web-api/admin/apps</code></td><td>Listado de todas las apps registradas (admin).</td></tr>
|
||||
<tr><td><code>GET/POST/DELETE /web-api/admin/users/{id}/app-roles</code></td><td>Roles por app de un usuario (admin).</td></tr>
|
||||
<tr><td><code>GET /web-api/admin/analytics/summary</code></td><td>Sesiones, logins y actividad (admin).</td></tr>
|
||||
<tr><td><code>POST /web-api/locale</code></td><td>Cambiar idioma (es/en/ca).</td></tr>
|
||||
</table>
|
||||
|
||||
<h3>OAuth2 (Passport)</h3>
|
||||
<table>
|
||||
<tr><th>Ruta</th><th>Qué hace</th></tr>
|
||||
<tr><td><code>GET /oauth/authorize</code></td><td>Inicio del flujo Authorization Code (pantalla de consentimiento).</td></tr>
|
||||
<tr><td><code>POST /oauth/token</code></td><td>Emisión/renovación de tokens (todos los grant types).</td></tr>
|
||||
</table>
|
||||
|
||||
<h3>API REST (Bearer token, para apps externas — Angular, Flutter, lo que sea)</h3>
|
||||
<table>
|
||||
<tr><th>Ruta</th><th>Qué hace</th></tr>
|
||||
<tr><td><code>GET /api/user</code></td><td>Whoami.</td></tr>
|
||||
<tr><td><code>GET/POST/PUT/DELETE /api/users</code></td><td>Gestión de usuarios (DELETE = papelera).</td></tr>
|
||||
<tr><td><code>GET /api/users/trashed</code></td><td>Usuarios en la papelera.</td></tr>
|
||||
<tr><td><code>POST /api/users/{id}/restore</code></td><td>Restaurar un usuario borrado.</td></tr>
|
||||
<tr><td><code>DELETE /api/users/{id}/force</code></td><td>Borrado definitivo.</td></tr>
|
||||
<tr><td><code>GET /api/users/pending</code></td><td>Registros pendientes de aprobación.</td></tr>
|
||||
<tr><td><code>POST /api/users/{id}/approve</code></td><td>Aprobar un registro pendiente.</td></tr>
|
||||
<tr><td><code>GET/POST/DELETE /api/users/{id}/app-roles</code></td><td>Roles por app de un usuario.</td></tr>
|
||||
<tr><td><code>GET /api/apps</code></td><td>Listado de apps registradas.</td></tr>
|
||||
<tr><td><code>GET /api/analytics/summary</code></td><td>Sesiones, logins y actividad.</td></tr>
|
||||
<tr><td><code>GET/POST/PUT/DELETE /api/clients</code></td><td>Tus clientes OAuth.</td></tr>
|
||||
<tr><td><code>GET/POST/DELETE /api/personal-access-tokens</code></td><td>Tus Personal Access Tokens.</td></tr>
|
||||
<tr><td><code>GET/DELETE /api/authorized-apps</code></td><td>Apps de terceros que autorizaste.</td></tr>
|
||||
</table>
|
||||
|
||||
<footer class="pagefoot">OAuth2 Identity Server — Manual de uso · generado automáticamente a partir del estado actual del proyecto.</footer>
|
||||
</section>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
BIN
docs/manual-usuario.pdf
Normal file
BIN
docs/manual-usuario.pdf
Normal file
Binary file not shown.
229
lang/ca.json
Normal file
229
lang/ca.json
Normal file
@@ -0,0 +1,229 @@
|
||||
{
|
||||
"(and :count more error)": "(i :count error més)",
|
||||
"(and :count more errors)": "(i :count error més)|(i :count errors més)|(i :count errors més)",
|
||||
"A decryption key is required.": "Cal una clau de desxifrat.",
|
||||
"A Timeout Occurred": "S'ha produït un temps mort",
|
||||
"Accept": "Acceptar",
|
||||
"Accepted": "Acceptat",
|
||||
"Action": "Acció",
|
||||
"Actions": "Accions",
|
||||
"Add": "Afegir",
|
||||
"Add :name": "Afegeix :name",
|
||||
"Admin": "Admin",
|
||||
"Agree": "Acordar",
|
||||
"All rights reserved.": "Tots els drets reservats.",
|
||||
"Already Reported": "Ja s'ha informat",
|
||||
"Archive": "Arxiu",
|
||||
"Assign": "Assignar",
|
||||
"Associate": "Associat",
|
||||
"Attach": "Adjuntar",
|
||||
"Bad Gateway": "Porta d'enllaç incorrecta",
|
||||
"Bad Request": "Petició dolenta",
|
||||
"Bandwidth Limit Exceeded": "Límit d'ample de banda excedit",
|
||||
"Browse": "Navega",
|
||||
"Cancel": "Cancel·la",
|
||||
"Choose": "Triar",
|
||||
"Choose :name": "Trieu :name",
|
||||
"Choose File": "Tria el fitxer",
|
||||
"Choose Image": "Trieu Imatge",
|
||||
"Click to copy": "Feu clic per copiar",
|
||||
"Client Closed Request": "Sol·licitud de client tancada",
|
||||
"Close": "Tancar",
|
||||
"Collapse": "Col·lapse",
|
||||
"Collapse All": "Col · lapsar tot",
|
||||
"Comment": "Comenta",
|
||||
"Confirm": "Confirmar",
|
||||
"Conflict": "Conflicte",
|
||||
"Connect": "Connecta't",
|
||||
"Connection Closed Without Response": "Connexió tancada sense resposta",
|
||||
"Connection Timed Out": "Temps de connexió esgotat",
|
||||
"Continue": "Continua",
|
||||
"Create": "Crear",
|
||||
"Create :name": "Crea :name",
|
||||
"Created": "Creat",
|
||||
"Delete": "Esborrar",
|
||||
"Delete :name": "Suprimeix :name",
|
||||
"Detach": "Separa",
|
||||
"Details": "Detalls",
|
||||
"Disable": "Deshabilitar",
|
||||
"Discard": "Descartar",
|
||||
"Done": "Fet",
|
||||
"Down": "Avall",
|
||||
"Duplicate": "Duplicat",
|
||||
"Duplicate :name": "Duplicat: nom",
|
||||
"Edit": "Edita",
|
||||
"Edit :name": "Edita :name",
|
||||
"Enable": "Habilitar",
|
||||
"Encrypted environment file already exists.": "El fitxer d'entorn xifrat ja existeix.",
|
||||
"Encrypted environment file not found.": "No s'ha trobat el fitxer d'entorn xifrat.",
|
||||
"Environment file already exists.": "El fitxer d'entorn ja existeix.",
|
||||
"Environment file not found.": "No s'ha trobat el fitxer d'entorn.",
|
||||
"errors": "errors",
|
||||
"Expand": "Ampliar",
|
||||
"Expand All": "Amplia-ho tot",
|
||||
"Expectation Failed": "L'expectativa ha fallat",
|
||||
"Explanation": "Explicació",
|
||||
"Export": "Exporta",
|
||||
"Export :name": "Export :name",
|
||||
"Failed Dependency": "Dependència fallida",
|
||||
"File": "Dossier",
|
||||
"Files": "Fitxers",
|
||||
"Forbidden": "Prohibit",
|
||||
"Found": "Trobat",
|
||||
"Gateway Timeout": "Temps d'espera de la passarel·la",
|
||||
"Go Home": "Aneu a l'inici",
|
||||
"Go to page :page": "Aneu a la pàgina :page",
|
||||
"Gone": "Desaparegut",
|
||||
"Hello!": "Hola!",
|
||||
"Hide": "Amaga",
|
||||
"Hide :name": "Amaga :name",
|
||||
"Home": "Inici",
|
||||
"HTTP Version Not Supported": "Versió HTTP no compatible",
|
||||
"I'm a teapot": "Sóc una tetera",
|
||||
"If you did not create an account, no further action is required.": "Si no heu creat cap compte, no es requereix cap acció adicional.",
|
||||
"If you did not request a password reset, no further action is required.": "Si no heu sol·licitat el restabliment de la contrasenya, obvieu aquest correu electrònic.",
|
||||
"If you're having trouble clicking the \":actionText\" button, copy and paste the URL below\ninto your web browser:": "Si té algun problema per a fer clic al botó \":actionText\", copii i enganxi la següent URL al seu navegador web",
|
||||
"IM Used": "IM utilitzat",
|
||||
"Image": "Imatge",
|
||||
"Impersonate": "Suplantar",
|
||||
"Impersonation": "Suplantació d'identitat",
|
||||
"Import": "Importar",
|
||||
"Import :name": "Importació :name",
|
||||
"Insufficient Storage": "Emmagatzematge insuficient",
|
||||
"Internal Server Error": "error del servidor intern",
|
||||
"Introduction": "Introducció",
|
||||
"Invalid filename.": "Nom de fitxer no vàlid.",
|
||||
"Invalid JSON was returned from the route.": "S'ha retornat un JSON no vàlid des de la ruta.",
|
||||
"Invalid SSL Certificate": "Certificat SSL no vàlid",
|
||||
"Length Required": "Longitud requerida",
|
||||
"Like": "M'agrada",
|
||||
"Load": "Carrega",
|
||||
"Localize": "Localitza",
|
||||
"Location": "Ubicació",
|
||||
"Locked": "Tancat",
|
||||
"Log In": "Entrar",
|
||||
"Log Out": "Sortir",
|
||||
"Login": "Entrar",
|
||||
"Logout": "Sortir",
|
||||
"Loop Detected": "Bucle detectat",
|
||||
"Maintenance Mode": "mode de manteniment",
|
||||
"Method Not Allowed": "Mètode no permès",
|
||||
"Misdirected Request": "Sol·licitud mal dirigida",
|
||||
"Moved Permanently": "Mogut permanentment",
|
||||
"Multi-Status": "Multiestatus",
|
||||
"Multiple Choices": "Opcions múltiples",
|
||||
"Network Authentication Required": "Es requereix autenticació de xarxa",
|
||||
"Network Connect Timeout Error": "Error de temps d'espera de connexió a la xarxa",
|
||||
"Network Read Timeout Error": "Error de temps d'espera de lectura de la xarxa",
|
||||
"New": "Nous",
|
||||
"New :name": "Nou :name",
|
||||
"No": "No",
|
||||
"No Content": "Sense contingut",
|
||||
"Non-Authoritative Information": "Informació no autoritzada",
|
||||
"Not Acceptable": "No és acceptable",
|
||||
"Not Extended": "No ampliat",
|
||||
"Not Found": "No trobat",
|
||||
"Not Implemented": "No implementat",
|
||||
"Not Modified": "No modificat",
|
||||
"of": "de",
|
||||
"OK": "D'acord",
|
||||
"Open": "Obert",
|
||||
"Open in a current window": "Obre en una finestra actual",
|
||||
"Open in a new window": "Obre en una finestra nova",
|
||||
"Open in a parent frame": "Obre en un marc principal",
|
||||
"Open in the topmost frame": "Obre al marc superior",
|
||||
"Open on the website": "Obert al lloc web",
|
||||
"Origin Is Unreachable": "L'origen és inabastable",
|
||||
"Page Expired": "Pàgina caducada",
|
||||
"Pagination Navigation": "Navegació de la paginació",
|
||||
"Partial Content": "Contingut parcial",
|
||||
"Payload Too Large": "Càrrega útil massa gran",
|
||||
"Payment Required": "Pagament obligatori",
|
||||
"Permanent Redirect": "Redirecció permanent",
|
||||
"Please click the button below to verify your email address.": "Si us plau, feu clic al botó inferior per verificar la vostra adreça electrònica.",
|
||||
"Precondition Failed": "La condició prèvia ha fallat",
|
||||
"Precondition Required": "Condició prèvia requerida",
|
||||
"Preview": "Vista prèvia",
|
||||
"Price": "Preu",
|
||||
"Processing": "Processament",
|
||||
"Proxy Authentication Required": "Es requereix autenticació de proxy",
|
||||
"Railgun Error": "Error de canó de ferrocarril",
|
||||
"Range Not Satisfiable": "Interval no satisfactori",
|
||||
"Record": "Registre",
|
||||
"Regards,": "Salutacions,",
|
||||
"Register": "Registre",
|
||||
"Request Header Fields Too Large": "Els camps de la capçalera de la sol·licitud són massa grans",
|
||||
"Request Timeout": "Temps d'espera esgotat",
|
||||
"Reset Content": "Restablir contingut",
|
||||
"Reset Password": "Restablir contrasenya",
|
||||
"Reset your password": "Restableix la teva contrasenya",
|
||||
"Restore": "Restauració",
|
||||
"Restore :name": "Restaurar :name",
|
||||
"results": "resultats",
|
||||
"Retry With": "Torna-ho a provar amb",
|
||||
"Save": "Desar",
|
||||
"Save & Close": "Desa i tanca",
|
||||
"Save & Return": "Guarda i torna",
|
||||
"Save :name": "Estalvia :name",
|
||||
"Search": "Cerca",
|
||||
"Search :name": "Cerca :name",
|
||||
"See Other": "Vegeu Altres",
|
||||
"Select": "Seleccioni",
|
||||
"Select All": "Seleccionar tot",
|
||||
"Send": "Enviar",
|
||||
"Server Error": "Error del servidor",
|
||||
"Service Unavailable": "Servei no disponible",
|
||||
"Session Has Expired": "La sessió ha caducat",
|
||||
"Settings": "Configuració",
|
||||
"Show": "Espectacle",
|
||||
"Show :name": "Mostra :name",
|
||||
"Show All": "Mostrar tots",
|
||||
"Showing": "Mostrant",
|
||||
"Sign In": "Inicieu la sessió",
|
||||
"Solve": "Resol",
|
||||
"SSL Handshake Failed": "SSL Handshake ha fallat",
|
||||
"Start": "Començar",
|
||||
"Stop": "Atura",
|
||||
"Submit": "Presentar",
|
||||
"Subscribe": "Subscrigui",
|
||||
"Switch": "Interruptor",
|
||||
"Switch To Role": "Canvia al rol",
|
||||
"Switching Protocols": "Protocols de commutació",
|
||||
"Tag": "Etiqueta",
|
||||
"Tags": "Etiquetes",
|
||||
"Temporary Redirect": "Redirecció temporal",
|
||||
"The given data was invalid.": "Les dades proporcionades no eren vàlides.",
|
||||
"The response is not a streamed response.": "La resposta no és una resposta en streaming.",
|
||||
"The response is not a view.": "La resposta no és una visió.",
|
||||
"This action is unauthorized.": "Aquesta acció no està autoritzada.",
|
||||
"This password reset link will expire in :count minutes.": "Aquest enllaç de restabliment de contrasenya caducarà en :count minuts.",
|
||||
"to": "a",
|
||||
"Toggle navigation": "Commutar navegació",
|
||||
"Too Early": "Massa aviat",
|
||||
"Too Many Requests": "Massa peticions",
|
||||
"Translate": "Traduir",
|
||||
"Translate It": "Tradueix-ho",
|
||||
"Unauthorized": "No autoritzat",
|
||||
"Unavailable For Legal Reasons": "No disponible per motius legals",
|
||||
"Unknown Error": "Error desconegut",
|
||||
"Unpack": "Desempaquetar",
|
||||
"Unprocessable Entity": "Entitat no processable",
|
||||
"Unsubscribe": "Cancel·la la subscripció",
|
||||
"Unsupported Media Type": "Tipus de suport no compatible",
|
||||
"Up": "Amunt",
|
||||
"Update": "Actualització",
|
||||
"Update :name": "Actualització :name",
|
||||
"Upgrade Required": "Actualització necessària",
|
||||
"URI Too Long": "URI massa llarg",
|
||||
"Use Proxy": "Utilitzeu el proxy",
|
||||
"User": "Usuari",
|
||||
"Variant Also Negotiates": "La variant també es negocia",
|
||||
"Verify Email Address": "Confirmeu la vostra adreça electrònica",
|
||||
"Verify your email address": "Verifica la teva adreça de correu electrònic",
|
||||
"View": "Vista",
|
||||
"View :name": "Vista :name",
|
||||
"Web Server is Down": "El servidor web està caigut",
|
||||
"Whoops!": "Vaja!",
|
||||
"Yes": "Sí",
|
||||
"You are receiving this email because we received a password reset request for your account.": "Heu rebut aquest correu electrònic perquè s'ha solicitat el restabliment de la contrasenya per al vostre compte."
|
||||
}
|
||||
119
lang/ca/actions.php
Normal file
119
lang/ca/actions.php
Normal file
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'accept' => 'Acceptar',
|
||||
'action' => 'Acció',
|
||||
'actions' => 'Accions',
|
||||
'add' => 'Afegeix',
|
||||
'admin' => 'Admin',
|
||||
'agree' => 'Acordar',
|
||||
'archive' => 'Arxiu',
|
||||
'assign' => 'Assignar',
|
||||
'associate' => 'Associat',
|
||||
'attach' => 'Adjuntar',
|
||||
'browse' => 'Navega',
|
||||
'cancel' => 'Cancel · lar',
|
||||
'choose' => 'Tria',
|
||||
'choose_file' => 'Trieu l\'arxiu',
|
||||
'choose_image' => 'Trieu Imatge',
|
||||
'click_to_copy' => 'Feu clic per copiar',
|
||||
'close' => 'Tanca',
|
||||
'collapse' => 'Col·lapse',
|
||||
'collapse_all' => 'Col · lapsar tot',
|
||||
'comment' => 'Comenta',
|
||||
'confirm' => 'Confirmeu',
|
||||
'connect' => 'Connecta\'t',
|
||||
'create' => 'Crear',
|
||||
'delete' => 'Suprimeix',
|
||||
'detach' => 'Separar',
|
||||
'details' => 'Detalls',
|
||||
'disable' => 'Desactivar',
|
||||
'discard' => 'Descartar',
|
||||
'done' => 'Fet',
|
||||
'down' => 'Avall',
|
||||
'duplicate' => 'Duplicat',
|
||||
'edit' => 'Edita',
|
||||
'enable' => 'Activa',
|
||||
'expand' => 'Ampliar',
|
||||
'expand_all' => 'Amplia-ho tot',
|
||||
'explanation' => 'Explicació',
|
||||
'export' => 'Exporta',
|
||||
'file' => 'Dossier',
|
||||
'files' => 'Fitxers',
|
||||
'go_home' => 'Aneu a l\'inici',
|
||||
'hide' => 'Amaga',
|
||||
'home' => 'Inici',
|
||||
'image' => 'Imatge',
|
||||
'impersonate' => 'Suplantar la identitat',
|
||||
'impersonation' => 'Suplantació d\'identitat',
|
||||
'import' => 'Importar',
|
||||
'introduction' => 'Introducció',
|
||||
'like' => 'M\'agrada',
|
||||
'load' => 'Carrega',
|
||||
'localize' => 'Localitza',
|
||||
'log_in' => 'Iniciar Sessió',
|
||||
'log_out' => 'Tancar sessió',
|
||||
'named' => [
|
||||
'add' => 'Afegeix :name',
|
||||
'choose' => 'Trieu :name',
|
||||
'create' => 'Crea :name',
|
||||
'delete' => 'Suprimeix :name',
|
||||
'duplicate' => 'Duplicat: nom',
|
||||
'edit' => 'Edita :name',
|
||||
'export' => 'Export :name',
|
||||
'hide' => 'Amaga :name',
|
||||
'import' => 'Importació :name',
|
||||
'new' => 'Nou :name',
|
||||
'restore' => 'Restaurar :name',
|
||||
'save' => 'Estalvia :name',
|
||||
'search' => 'Cerca :name',
|
||||
'show' => 'Mostra :name',
|
||||
'update' => 'Actualització :name',
|
||||
'view' => 'Vista :name',
|
||||
],
|
||||
'new' => 'Nou',
|
||||
'no' => 'No',
|
||||
'open' => 'Obert',
|
||||
'open_website' => 'Obert al lloc web',
|
||||
'preview' => 'Vista prèvia',
|
||||
'price' => 'Preu',
|
||||
'record' => 'Registre',
|
||||
'restore' => 'Restaurar',
|
||||
'save' => 'Desa',
|
||||
'save_and_close' => 'Desa i tanca',
|
||||
'save_and_return' => 'Guarda i torna',
|
||||
'search' => 'Cerca',
|
||||
'select' => 'Seleccioneu',
|
||||
'select_all' => 'Seleccionar tot',
|
||||
'send' => 'Enviar',
|
||||
'settings' => 'Configuració',
|
||||
'show' => 'Espectacle',
|
||||
'show_all' => 'Mostrar tots',
|
||||
'sign_in' => 'Inicieu la sessió',
|
||||
'solve' => 'Resol',
|
||||
'start' => 'Començar',
|
||||
'stop' => 'Atura',
|
||||
'submit' => 'Presentar',
|
||||
'subscribe' => 'Subscriu-te',
|
||||
'switch' => 'Interruptor',
|
||||
'switch_to_role' => 'Canvia al rol',
|
||||
'tag' => 'Etiqueta',
|
||||
'tags' => 'Etiquetes',
|
||||
'target_link' => [
|
||||
'blank' => 'Obre en una finestra nova',
|
||||
'parent' => 'Obre en un marc principal',
|
||||
'self' => 'Obre en una finestra actual',
|
||||
'top' => 'Obre al marc superior',
|
||||
],
|
||||
'translate' => 'Traduir',
|
||||
'translate_it' => 'Tradueix-ho',
|
||||
'unpack' => 'Desempaquetar',
|
||||
'unsubscribe' => 'Cancel·la la subscripció',
|
||||
'up' => 'Amunt',
|
||||
'update' => 'Actualització',
|
||||
'user' => 'Usuari',
|
||||
'view' => 'Veure',
|
||||
'yes' => 'Sí',
|
||||
];
|
||||
9
lang/ca/auth.php
Normal file
9
lang/ca/auth.php
Normal file
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'failed' => 'Aquestes credencials no concorden amb els nostres registres.',
|
||||
'password' => 'La contrasenya és incorrecta.',
|
||||
'throttle' => 'Ha superat el nombre màxim d\'intents d\'accés. Si us plau, torni a intentar-ho en :seconds segons.',
|
||||
];
|
||||
84
lang/ca/http-statuses.php
Normal file
84
lang/ca/http-statuses.php
Normal file
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'0' => 'Error desconegut',
|
||||
'100' => 'Continua',
|
||||
'101' => 'Protocols de commutació',
|
||||
'102' => 'Processament',
|
||||
'200' => 'D\'acord',
|
||||
'201' => 'Creat',
|
||||
'202' => 'Acceptat',
|
||||
'203' => 'Informació no autoritzada',
|
||||
'204' => 'Sense contingut',
|
||||
'205' => 'Restablir contingut',
|
||||
'206' => 'Contingut parcial',
|
||||
'207' => 'Multiestatus',
|
||||
'208' => 'Ja s\'ha informat',
|
||||
'226' => 'IM utilitzat',
|
||||
'300' => 'Opcions múltiples',
|
||||
'301' => 'Mogut permanentment',
|
||||
'302' => 'Trobat',
|
||||
'303' => 'Vegeu Altres',
|
||||
'304' => 'No modificat',
|
||||
'305' => 'Utilitzeu el proxy',
|
||||
'307' => 'Redirecció temporal',
|
||||
'308' => 'Redirecció permanent',
|
||||
'400' => 'Petició dolenta',
|
||||
'401' => 'No autoritzat',
|
||||
'402' => 'Pagament obligatori',
|
||||
'403' => 'Prohibit',
|
||||
'404' => 'No trobat',
|
||||
'405' => 'Mètode no permès',
|
||||
'406' => 'No és acceptable',
|
||||
'407' => 'Es requereix autenticació de proxy',
|
||||
'408' => 'Temps d\'espera esgotat',
|
||||
'409' => 'Conflicte',
|
||||
'410' => 'Desaparegut',
|
||||
'411' => 'Longitud requerida',
|
||||
'412' => 'La condició prèvia ha fallat',
|
||||
'413' => 'Càrrega útil massa gran',
|
||||
'414' => 'URI massa llarg',
|
||||
'415' => 'Tipus de suport no compatible',
|
||||
'416' => 'Interval no satisfactori',
|
||||
'417' => 'L\'expectativa ha fallat',
|
||||
'418' => 'Sóc una tetera',
|
||||
'419' => 'La sessió ha caducat',
|
||||
'421' => 'Sol·licitud mal dirigida',
|
||||
'422' => 'Entitat no processable',
|
||||
'423' => 'Tancat',
|
||||
'424' => 'Dependència fallida',
|
||||
'425' => 'Massa aviat',
|
||||
'426' => 'Actualització necessària',
|
||||
'428' => 'Condició prèvia requerida',
|
||||
'429' => 'Massa sol·licituds',
|
||||
'431' => 'Els camps de la capçalera de la sol·licitud són massa grans',
|
||||
'444' => 'Connexió tancada sense resposta',
|
||||
'449' => 'Torna-ho a provar amb',
|
||||
'451' => 'No disponible per motius legals',
|
||||
'499' => 'Sol·licitud de client tancada',
|
||||
'500' => 'error del servidor intern',
|
||||
'501' => 'No implementat',
|
||||
'502' => 'Porta d\'enllaç incorrecta',
|
||||
'503' => 'mode de manteniment',
|
||||
'504' => 'Temps d\'espera de la passarel·la',
|
||||
'505' => 'Versió HTTP no compatible',
|
||||
'506' => 'La variant també es negocia',
|
||||
'507' => 'Emmagatzematge insuficient',
|
||||
'508' => 'Bucle detectat',
|
||||
'509' => 'Límit d\'ample de banda excedit',
|
||||
'510' => 'No ampliat',
|
||||
'511' => 'Es requereix autenticació de xarxa',
|
||||
'520' => 'Error desconegut',
|
||||
'521' => 'El servidor web està caigut',
|
||||
'522' => 'Temps de connexió esgotat',
|
||||
'523' => 'L\'origen és inabastable',
|
||||
'524' => 'S\'ha produït un temps mort',
|
||||
'525' => 'SSL Handshake ha fallat',
|
||||
'526' => 'Certificat SSL no vàlid',
|
||||
'527' => 'Error de canó de ferrocarril',
|
||||
'598' => 'Error de temps d\'espera de lectura de la xarxa',
|
||||
'599' => 'Error de temps d\'espera de connexió a la xarxa',
|
||||
'unknownError' => 'Error desconegut',
|
||||
];
|
||||
13
lang/ca/messages.php
Normal file
13
lang/ca/messages.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'invalid_credentials' => 'Credencials no vàlides.',
|
||||
'account_deactivated' => 'Aquest compte està desactivat.',
|
||||
'account_pending_approval' => 'El teu compte està pendent d\'aprovació per un administrador.',
|
||||
'registration_pending' => 'Compte creat. Un administrador l\'ha d\'aprovar abans que puguis entrar.',
|
||||
'cannot_remove_own_admin_role' => 'No et pots treure el rol d\'admin a tu mateix.',
|
||||
'cannot_delete_self' => 'No pots esborrar el teu propi compte.',
|
||||
'cannot_delete_last_admin' => 'No pots esborrar l\'últim administrador.',
|
||||
'self_service_requires_user' => 'Aquest recurs és self-service: necessita un token amb un usuari associat (no funciona amb client_credentials).',
|
||||
'user_not_trashed' => 'Aquest usuari no és a la paperera.',
|
||||
];
|
||||
8
lang/ca/pagination.php
Normal file
8
lang/ca/pagination.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'next' => 'Següent »',
|
||||
'previous' => '« Anterior',
|
||||
];
|
||||
11
lang/ca/passwords.php
Normal file
11
lang/ca/passwords.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'reset' => 'La contrasenya s\'ha restablert!',
|
||||
'sent' => 'Li hem enviat un correu electrònic amb un enllaç per a reiniciar la teva contrasenya!',
|
||||
'throttled' => 'Si us plau, esperi abans de tornar-ho a intentar.',
|
||||
'token' => 'Aquest token de recuperació de contrasenya és invàlid.',
|
||||
'user' => 'No existeix cap usuari amb aquest correu.',
|
||||
];
|
||||
289
lang/ca/validation.php
Normal file
289
lang/ca/validation.php
Normal file
@@ -0,0 +1,289 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'accepted' => ':Attribute ha de ser acceptat.',
|
||||
'accepted_if' => 'El :attribute s\'ha d\'acceptar quan :other és :value.',
|
||||
'active_url' => ':Attribute no és un URL vàlid.',
|
||||
'after' => ':Attribute ha de ser una data posterior a :date.',
|
||||
'after_or_equal' => ':Attribute ha de ser una data posterior o igual a :date.',
|
||||
'alpha' => ':Attribute només pot contenir lletres.',
|
||||
'alpha_dash' => ':Attribute només pot contenir lletres, números i guions.',
|
||||
'alpha_num' => ':Attribute només pot contenir lletres i números.',
|
||||
'any_of' => 'El camp :attribute no és vàlid.',
|
||||
'array' => ':Attribute ha de ser una matriu.',
|
||||
'ascii' => 'El :attribute només ha de contenir caràcters i símbols alfanumèrics d\'un sol byte.',
|
||||
'base64' => 'The :attribute field must be a valid Base64 string.',
|
||||
'before' => ':Attribute ha de ser una data anterior a :date.',
|
||||
'before_or_equal' => ':Attribute ha de ser una data anterior o igual a :date.',
|
||||
'between' => [
|
||||
'array' => ':Attribute ha de tenir entre :min - :max ítems.',
|
||||
'file' => ':Attribute ha de pesar entre :min - :max kilobytes.',
|
||||
'numeric' => ':Attribute ha d\'estar entre :min - :max.',
|
||||
'string' => ':Attribute ha de tenir entre :min - :max caràcters.',
|
||||
],
|
||||
'boolean' => 'El camp :attribute ha de ser verdader o fals',
|
||||
'can' => 'El camp :attribute conté un valor no autoritzat.',
|
||||
'confirmed' => 'La confirmació de :attribute no coincideix.',
|
||||
'contains' => 'Al camp :attribute li falta un valor obligatori.',
|
||||
'current_password' => 'La contrasenya és incorrecta.',
|
||||
'date' => ':Attribute no és una data vàlida.',
|
||||
'date_equals' => 'El :attribute ha de ser una data igual a :date.',
|
||||
'date_format' => 'El camp :attribute no concorda amb el format :format.',
|
||||
'decimal' => 'El :attribute ha de tenir :decimal decimals.',
|
||||
'declined' => 'Els :attribute s\'han de rebutjar.',
|
||||
'declined_if' => 'El :attribute s\'ha de rebutjar quan :other és :value.',
|
||||
'different' => ':Attribute i :other han de ser diferents.',
|
||||
'digits' => ':Attribute ha de tenir :digits dígits.',
|
||||
'digits_between' => ':Attribute ha de tenir entre :min i :max dígits.',
|
||||
'dimensions' => 'Les dimensions de la imatge :attribute no són vàlides.',
|
||||
'distinct' => 'El camp :attribute té un valor duplicat.',
|
||||
'doesnt_contain' => 'El camp :attribute no ha de contenir cap dels següents: :values.',
|
||||
'doesnt_end_with' => 'El :attribute no pot acabar amb un dels següents: :values.',
|
||||
'doesnt_start_with' => 'El :attribute no pot començar amb un dels següents: :values.',
|
||||
'email' => ':Attribute no és un e-mail vàlid',
|
||||
'encoding' => 'El camp :attribute s\'ha de codificar en :encoding.',
|
||||
'ends_with' => 'La :attribute ha d\'acabar amb una de les següents: :values.',
|
||||
'enum' => 'El :attribute seleccionat no és vàlid.',
|
||||
'exists' => ':Attribute és invàlid.',
|
||||
'extensions' => 'El camp :attribute ha de tenir una de les extensions següents: :values.',
|
||||
'file' => 'El camp :attribute ha de ser un arxiu.',
|
||||
'filled' => 'El camp :attribute és obligatori.',
|
||||
'gt' => [
|
||||
'array' => 'El :attribute ha de tenir més de :value ítems.',
|
||||
'file' => 'El :attribute ha de ser superior a :value kilobytes.',
|
||||
'numeric' => 'El :attribute ha de ser superior a :value.',
|
||||
'string' => 'El :attribute ha de superar els :value caràcters.',
|
||||
],
|
||||
'gte' => [
|
||||
'array' => 'El :attribute ha de tenir :value ítems o més.',
|
||||
'file' => 'El :attribute ha de ser igual o superior a :value kilobytes.',
|
||||
'numeric' => 'El :attribute ha de ser igual o superior a :value.',
|
||||
'string' => 'El :attribute ha de ser igual o superior a :value caràcters.',
|
||||
],
|
||||
'hex_color' => 'El camp :attribute ha de ser un color hexadecimal vàlid.',
|
||||
'image' => ':Attribute ha de ser una imatge.',
|
||||
'in' => ':Attribute és invàlid',
|
||||
'in_array' => 'El camp :attribute no existeix dintre de :other.',
|
||||
'in_array_keys' => 'El camp :attribute ha de contenir almenys una de les tecles següents: :values.',
|
||||
'integer' => ':Attribute ha de ser un nombre enter.',
|
||||
'ip' => ':Attribute ha de ser una adreça IP vàlida.',
|
||||
'ipv4' => ':Attribute ha de ser una adreça IPv4 vàlida.',
|
||||
'ipv6' => ':Attribute ha de ser una adreça IPv6 vàlida.',
|
||||
'json' => 'El camp :attribute ha de ser una cadena JSON vàlida.',
|
||||
'list' => 'El camp :attribute ha de ser una llista.',
|
||||
'lowercase' => 'El :attribute ha d\'anar en minúscula.',
|
||||
'lt' => [
|
||||
'array' => 'El :attribute ha de tenir menys de :value ítems.',
|
||||
'file' => 'El :attribute ha de ser inferior a :value kilobytes.',
|
||||
'numeric' => 'El :attribute ha de ser inferior a :value.',
|
||||
'string' => 'El :attribute no ha de superar els :value caràcters.',
|
||||
],
|
||||
'lte' => [
|
||||
'array' => 'El :attribute no ha de tenir més de :value ítems.',
|
||||
'file' => 'El :attribute ha de ser igual o inferior a :value kilobytes.',
|
||||
'numeric' => 'El :attribute ha de ser igual o inferior a :value.',
|
||||
'string' => 'El :attribute ha de ser igual o inferior a :value caràcters.',
|
||||
],
|
||||
'mac_address' => 'El :attribute ha de ser una adreça MAC vàlida.',
|
||||
'max' => [
|
||||
'array' => ':Attribute no pot tenir més de :max ítems.',
|
||||
'file' => ':Attribute no pot ser més gran que :max kilobytes.',
|
||||
'numeric' => ':Attribute no pot ser més gran que :max.',
|
||||
'string' => ':Attribute no pot ser més gran que :max caràcters.',
|
||||
],
|
||||
'max_digits' => 'El :attribute no ha de tenir més de :max dígits.',
|
||||
'mimes' => ':Attribute ha de ser un arxiu amb format: :values.',
|
||||
'mimetypes' => ':Attribute ha de ser un arxiu amb format: :values.',
|
||||
'min' => [
|
||||
'array' => ':Attribute ha de tenir almenys :min ítems.',
|
||||
'file' => 'La mida de :attribute ha de ser d\'almenys :min kilobytes.',
|
||||
'numeric' => 'La mida de :attribute ha de ser d\'almenys :min.',
|
||||
'string' => ':Attribute ha de contenir almenys :min caràcters.',
|
||||
],
|
||||
'min_digits' => 'El :attribute ha de tenir almenys :min dígits.',
|
||||
'missing' => 'El camp :attribute ha de faltar.',
|
||||
'missing_if' => 'El camp :attribute ha de faltar quan :other és :value.',
|
||||
'missing_unless' => 'El camp :attribute ha de faltar, tret que :other sigui :value.',
|
||||
'missing_with' => 'El camp :attribute ha de faltar quan hi ha :values.',
|
||||
'missing_with_all' => 'El camp :attribute ha de faltar quan n\'hi ha :values.',
|
||||
'multiple_of' => 'La :attribute ha de ser un múltiple de :value',
|
||||
'not_in' => ':Attribute és invàlid.',
|
||||
'not_regex' => 'El format de :attribute no és vàlid.',
|
||||
'numeric' => ':Attribute ha de ser numèric.',
|
||||
'password' => [
|
||||
'letters' => 'El :attribute ha de contenir almenys una lletra.',
|
||||
'mixed' => 'El :attribute ha de contenir almenys una lletra majúscula i una minúscula.',
|
||||
'numbers' => 'El :attribute ha de contenir almenys un número.',
|
||||
'symbols' => 'El :attribute ha de contenir almenys un símbol.',
|
||||
'uncompromised' => 'El :attribute donat ha aparegut en una filtració de dades. Si us plau, trieu un :attribute diferent.',
|
||||
],
|
||||
'present' => 'El camp :attribute ha d\'existir.',
|
||||
'present_if' => 'El camp :attribute ha d\'estar present quan :other és :value.',
|
||||
'present_unless' => 'El camp :attribute ha d\'estar present tret que :other sigui :value.',
|
||||
'present_with' => 'El camp :attribute ha d\'estar present quan n\'hi ha :values.',
|
||||
'present_with_all' => 'El camp :attribute ha d\'estar present quan n\'hi ha :values.',
|
||||
'prohibited' => 'La :attribute camp està prohibit.',
|
||||
'prohibited_if' => 'La :attribute camp és prohibida quan :other és :value.',
|
||||
'prohibited_if_accepted' => 'El camp :attribute està prohibit quan s’accepta :other.',
|
||||
'prohibited_if_declined' => 'El camp :attribute està prohibit quan es declina :other.',
|
||||
'prohibited_unless' => 'La :attribute camp és prohibida, tret que :other és en :values.',
|
||||
'prohibits' => 'El camp :attribute prohibeix la presència de :other.',
|
||||
'regex' => 'El format de :attribute és invàlid.',
|
||||
'required' => 'El camp :attribute és obligatori.',
|
||||
'required_array_keys' => 'El camp :attribute ha de contenir entrades per a: :values.',
|
||||
'required_if' => 'El camp :attribute és obligatori quan :other és :value.',
|
||||
'required_if_accepted' => 'El camp :attribute és obligatori quan s\'accepta :other.',
|
||||
'required_if_declined' => 'El camp :attribute és obligatori quan es rebutja :other.',
|
||||
'required_unless' => 'El camp :attribute és obligatori a no ser que :other sigui a :values.',
|
||||
'required_with' => 'El camp :attribute és obligatori quan hi ha :values.',
|
||||
'required_with_all' => 'El camp :attribute és obligatori quan hi ha :values.',
|
||||
'required_without' => 'El camp :attribute és obligatori quan no hi ha :values.',
|
||||
'required_without_all' => 'El camp :attribute és obligatori quan no hi ha cap valor dels següents: :values.',
|
||||
'same' => ':Attribute i :other han de coincidir.',
|
||||
'size' => [
|
||||
'array' => ':Attribute ha de contenir :size ítems.',
|
||||
'file' => 'La mida de :attribute ha de ser :size kilobytes.',
|
||||
'numeric' => 'La mida de :attribute ha de ser :size.',
|
||||
'string' => ':Attribute ha de contenir :size caràcters.',
|
||||
],
|
||||
'starts_with' => 'El :attribute ha de començar per un dels valors següents: :values',
|
||||
'string' => 'El camp :attribute ha de ser una cadena.',
|
||||
'timezone' => 'El camp :attribute ha de ser una zona vàlida.',
|
||||
'ulid' => 'El :attribute ha de ser un ULID vàlid.',
|
||||
'unique' => ':Attribute ja està registrat i no es pot repetir.',
|
||||
'uploaded' => ':Attribute ha fallat al pujar.',
|
||||
'uppercase' => 'El :attribute ha d\'anar en majúscula.',
|
||||
'url' => ':Attribute no és una adreça web vàlida.',
|
||||
'uuid' => 'El :attribute ha de ser un indentificador únic universal (UUID) vàlid.',
|
||||
'attributes' => [
|
||||
'address' => 'adreça',
|
||||
'affiliate_url' => 'URL d\'afiliat',
|
||||
'age' => 'edat',
|
||||
'amount' => 'quantitat',
|
||||
'announcement' => 'anunci',
|
||||
'area' => 'zona',
|
||||
'audience_prize' => 'premi del públic',
|
||||
'audience_winner' => 'audience winner',
|
||||
'available' => 'disponible',
|
||||
'birthday' => 'aniversari',
|
||||
'body' => 'cos',
|
||||
'city' => 'ciutat',
|
||||
'color' => 'color',
|
||||
'company' => 'company',
|
||||
'compilation' => 'compilació',
|
||||
'concept' => 'concepte',
|
||||
'conditions' => 'condicions',
|
||||
'content' => 'contingut',
|
||||
'contest' => 'contest',
|
||||
'country' => 'país',
|
||||
'cover' => 'coberta',
|
||||
'created_at' => 'creat a',
|
||||
'creator' => 'creador',
|
||||
'currency' => 'moneda',
|
||||
'current_password' => 'Contrasenya actual',
|
||||
'customer' => 'client',
|
||||
'date' => 'data',
|
||||
'date_of_birth' => 'data de naixement',
|
||||
'dates' => 'dates',
|
||||
'day' => 'dia',
|
||||
'deleted_at' => 'esborrat a les',
|
||||
'description' => 'descripció',
|
||||
'display_type' => 'tipus de visualització',
|
||||
'district' => 'districte',
|
||||
'duration' => 'durada',
|
||||
'email' => 'adreça electrònica',
|
||||
'excerpt' => 'extracte',
|
||||
'filter' => 'filtre',
|
||||
'finished_at' => 'acabat a les',
|
||||
'first_name' => 'nom',
|
||||
'gender' => 'gènere',
|
||||
'grand_prize' => 'gran premi',
|
||||
'group' => 'grup',
|
||||
'hour' => 'hora',
|
||||
'image' => 'imatge',
|
||||
'image_desktop' => 'imatge d\'escriptori',
|
||||
'image_main' => 'imatge principal',
|
||||
'image_mobile' => 'imatge mòbil',
|
||||
'images' => 'imatges',
|
||||
'is_audience_winner' => 'és el guanyador del públic',
|
||||
'is_hidden' => 'està amagat',
|
||||
'is_subscribed' => 'està subscrit',
|
||||
'is_visible' => 'és visible',
|
||||
'is_winner' => 'és el guanyador',
|
||||
'items' => 'elements',
|
||||
'key' => 'clau',
|
||||
'last_name' => 'cognom',
|
||||
'lesson' => 'lliçó',
|
||||
'line_address_1' => 'adreça de línia 1',
|
||||
'line_address_2' => 'adreça de línia 2',
|
||||
'login' => 'iniciar Sessió',
|
||||
'message' => 'missatge',
|
||||
'middle_name' => 'segon nom',
|
||||
'minute' => 'minut',
|
||||
'mobile' => 'mòbil',
|
||||
'month' => 'mes',
|
||||
'name' => 'nom',
|
||||
'national_code' => 'codi nacional',
|
||||
'number' => 'nombre',
|
||||
'password' => 'contrasenya',
|
||||
'password_confirmation' => 'confirmació de la contrasenya',
|
||||
'phone' => 'telèfon',
|
||||
'photo' => 'foto',
|
||||
'portfolio' => 'cartera',
|
||||
'postal_code' => 'Codi Postal',
|
||||
'preview' => 'Vista prèvia',
|
||||
'price' => 'preu',
|
||||
'product_id' => 'ID del producte',
|
||||
'product_uid' => 'UID del producte',
|
||||
'product_uuid' => 'UUID del producte',
|
||||
'promo_code' => 'codi promocional',
|
||||
'province' => 'província',
|
||||
'quantity' => 'quantitat',
|
||||
'reason' => 'raó',
|
||||
'recaptcha_response_field' => 'camp de resposta recaptcha',
|
||||
'referee' => 'àrbitre',
|
||||
'referees' => 'àrbitres',
|
||||
'region' => 'region',
|
||||
'reject_reason' => 'rebutjar la raó',
|
||||
'remember' => 'recorda',
|
||||
'restored_at' => 'restaurat a',
|
||||
'result_text_under_image' => 'text del resultat sota la imatge',
|
||||
'role' => 'paper',
|
||||
'rule' => 'regla',
|
||||
'rules' => 'regles',
|
||||
'second' => 'segon',
|
||||
'sex' => 'sexe',
|
||||
'shipment' => 'enviament',
|
||||
'short_text' => 'text breu',
|
||||
'size' => 'mida',
|
||||
'skills' => 'habilitats',
|
||||
'slug' => 'llimac',
|
||||
'specialization' => 'especialització',
|
||||
'started_at' => 'va començar a',
|
||||
'state' => 'estat',
|
||||
'status' => 'estat',
|
||||
'street' => 'carrer',
|
||||
'student' => 'estudiant',
|
||||
'subject' => 'assumpte',
|
||||
'tag' => 'etiqueta',
|
||||
'tags' => 'etiquetes',
|
||||
'teacher' => 'professor',
|
||||
'terms' => 'termes',
|
||||
'test_description' => 'descripció de la prova',
|
||||
'test_locale' => 'localització de prova',
|
||||
'test_name' => 'nom de la prova',
|
||||
'text' => 'text',
|
||||
'time' => 'hora',
|
||||
'title' => 'títol',
|
||||
'type' => 'tipus',
|
||||
'updated_at' => 'actualitzat a',
|
||||
'user' => 'usuari',
|
||||
'username' => 'usuari',
|
||||
'value' => 'valor',
|
||||
'winner' => 'winner',
|
||||
'work' => 'work',
|
||||
'year' => 'any',
|
||||
],
|
||||
];
|
||||
229
lang/en.json
Normal file
229
lang/en.json
Normal file
@@ -0,0 +1,229 @@
|
||||
{
|
||||
"(and :count more error)": "(and :count more error)",
|
||||
"(and :count more errors)": "(and :count more error)|(and :count more errors)|(and :count more errors)",
|
||||
"A decryption key is required.": "A decryption key is required.",
|
||||
"A Timeout Occurred": "A Timeout Occurred",
|
||||
"Accept": "Accept",
|
||||
"Accepted": "Accepted",
|
||||
"Action": "Action",
|
||||
"Actions": "Actions",
|
||||
"Add": "Add",
|
||||
"Add :name": "Add :name",
|
||||
"Admin": "Admin",
|
||||
"Agree": "Agree",
|
||||
"All rights reserved.": "All rights reserved.",
|
||||
"Already Reported": "Already Reported",
|
||||
"Archive": "Archive",
|
||||
"Assign": "Assign",
|
||||
"Associate": "Associate",
|
||||
"Attach": "Attach",
|
||||
"Bad Gateway": "Bad Gateway",
|
||||
"Bad Request": "Bad Request",
|
||||
"Bandwidth Limit Exceeded": "Bandwidth Limit Exceeded",
|
||||
"Browse": "Browse",
|
||||
"Cancel": "Cancel",
|
||||
"Choose": "Choose",
|
||||
"Choose :name": "Choose :name",
|
||||
"Choose File": "Choose File",
|
||||
"Choose Image": "Choose Image",
|
||||
"Click to copy": "Click to copy",
|
||||
"Client Closed Request": "Client Closed Request",
|
||||
"Close": "Close",
|
||||
"Collapse": "Collapse",
|
||||
"Collapse All": "Collapse All",
|
||||
"Comment": "Comment",
|
||||
"Confirm": "Confirm",
|
||||
"Conflict": "Conflict",
|
||||
"Connect": "Connect",
|
||||
"Connection Closed Without Response": "Connection Closed Without Response",
|
||||
"Connection Timed Out": "Connection Timed Out",
|
||||
"Continue": "Continue",
|
||||
"Create": "Create",
|
||||
"Create :name": "Create :name",
|
||||
"Created": "Created",
|
||||
"Delete": "Delete",
|
||||
"Delete :name": "Delete :name",
|
||||
"Detach": "Detach",
|
||||
"Details": "Details",
|
||||
"Disable": "Disable",
|
||||
"Discard": "Discard",
|
||||
"Done": "Done",
|
||||
"Down": "Down",
|
||||
"Duplicate": "Duplicate",
|
||||
"Duplicate :name": "Duplicate :name",
|
||||
"Edit": "Edit",
|
||||
"Edit :name": "Edit :name",
|
||||
"Enable": "Enable",
|
||||
"Encrypted environment file already exists.": "Encrypted environment file already exists.",
|
||||
"Encrypted environment file not found.": "Encrypted environment file not found.",
|
||||
"Environment file already exists.": "Environment file already exists.",
|
||||
"Environment file not found.": "Environment file not found.",
|
||||
"errors": "errors",
|
||||
"Expand": "Expand",
|
||||
"Expand All": "Expand All",
|
||||
"Expectation Failed": "Expectation Failed",
|
||||
"Explanation": "Explanation",
|
||||
"Export": "Export",
|
||||
"Export :name": "Export :name",
|
||||
"Failed Dependency": "Failed Dependency",
|
||||
"File": "File",
|
||||
"Files": "Files",
|
||||
"Forbidden": "Forbidden",
|
||||
"Found": "Found",
|
||||
"Gateway Timeout": "Gateway Timeout",
|
||||
"Go Home": "Go Home",
|
||||
"Go to page :page": "Go to page :page",
|
||||
"Gone": "Gone",
|
||||
"Hello!": "Hello!",
|
||||
"Hide": "Hide",
|
||||
"Hide :name": "Hide :name",
|
||||
"Home": "Home",
|
||||
"HTTP Version Not Supported": "HTTP Version Not Supported",
|
||||
"I'm a teapot": "I'm a teapot",
|
||||
"If you did not create an account, no further action is required.": "If you did not create an account, no further action is required.",
|
||||
"If you did not request a password reset, no further action is required.": "If you did not request a password reset, no further action is required.",
|
||||
"If you're having trouble clicking the \":actionText\" button, copy and paste the URL below\ninto your web browser:": "If you're having trouble clicking the \":actionText\" button, copy and paste the URL below\ninto your web browser:",
|
||||
"IM Used": "IM Used",
|
||||
"Image": "Image",
|
||||
"Impersonate": "Impersonate",
|
||||
"Impersonation": "Impersonation",
|
||||
"Import": "Import",
|
||||
"Import :name": "Import :name",
|
||||
"Insufficient Storage": "Insufficient Storage",
|
||||
"Internal Server Error": "Internal Server Error",
|
||||
"Introduction": "Introduction",
|
||||
"Invalid filename.": "Invalid filename.",
|
||||
"Invalid JSON was returned from the route.": "Invalid JSON was returned from the route.",
|
||||
"Invalid SSL Certificate": "Invalid SSL Certificate",
|
||||
"Length Required": "Length Required",
|
||||
"Like": "Like",
|
||||
"Load": "Load",
|
||||
"Localize": "Localize",
|
||||
"Location": "Location",
|
||||
"Locked": "Locked",
|
||||
"Log In": "Log In",
|
||||
"Log Out": "Log Out",
|
||||
"Login": "Login",
|
||||
"Logout": "Logout",
|
||||
"Loop Detected": "Loop Detected",
|
||||
"Maintenance Mode": "Maintenance Mode",
|
||||
"Method Not Allowed": "Method Not Allowed",
|
||||
"Misdirected Request": "Misdirected Request",
|
||||
"Moved Permanently": "Moved Permanently",
|
||||
"Multi-Status": "Multi-Status",
|
||||
"Multiple Choices": "Multiple Choices",
|
||||
"Network Authentication Required": "Network Authentication Required",
|
||||
"Network Connect Timeout Error": "Network Connect Timeout Error",
|
||||
"Network Read Timeout Error": "Network Read Timeout Error",
|
||||
"New": "New",
|
||||
"New :name": "New :name",
|
||||
"No": "No",
|
||||
"No Content": "No Content",
|
||||
"Non-Authoritative Information": "Non-Authoritative Information",
|
||||
"Not Acceptable": "Not Acceptable",
|
||||
"Not Extended": "Not Extended",
|
||||
"Not Found": "Not Found",
|
||||
"Not Implemented": "Not Implemented",
|
||||
"Not Modified": "Not Modified",
|
||||
"of": "of",
|
||||
"OK": "OK",
|
||||
"Open": "Open",
|
||||
"Open in a current window": "Open in a current window",
|
||||
"Open in a new window": "Open in a new window",
|
||||
"Open in a parent frame": "Open in a parent frame",
|
||||
"Open in the topmost frame": "Open in the topmost frame",
|
||||
"Open on the website": "Open on the website",
|
||||
"Origin Is Unreachable": "Origin Is Unreachable",
|
||||
"Page Expired": "Page Expired",
|
||||
"Pagination Navigation": "Pagination Navigation",
|
||||
"Partial Content": "Partial Content",
|
||||
"Payload Too Large": "Payload Too Large",
|
||||
"Payment Required": "Payment Required",
|
||||
"Permanent Redirect": "Permanent Redirect",
|
||||
"Please click the button below to verify your email address.": "Please click the button below to verify your email address.",
|
||||
"Precondition Failed": "Precondition Failed",
|
||||
"Precondition Required": "Precondition Required",
|
||||
"Preview": "Preview",
|
||||
"Price": "Price",
|
||||
"Processing": "Processing",
|
||||
"Proxy Authentication Required": "Proxy Authentication Required",
|
||||
"Railgun Error": "Railgun Error",
|
||||
"Range Not Satisfiable": "Range Not Satisfiable",
|
||||
"Record": "Record",
|
||||
"Regards,": "Regards,",
|
||||
"Register": "Register",
|
||||
"Request Header Fields Too Large": "Request Header Fields Too Large",
|
||||
"Request Timeout": "Request Timeout",
|
||||
"Reset Content": "Reset Content",
|
||||
"Reset Password": "Reset Password",
|
||||
"Reset your password": "Reset your password",
|
||||
"Restore": "Restore",
|
||||
"Restore :name": "Restore :name",
|
||||
"results": "results",
|
||||
"Retry With": "Retry With",
|
||||
"Save": "Save",
|
||||
"Save & Close": "Save & Close",
|
||||
"Save & Return": "Save & Return",
|
||||
"Save :name": "Save :name",
|
||||
"Search": "Search",
|
||||
"Search :name": "Search :name",
|
||||
"See Other": "See Other",
|
||||
"Select": "Select",
|
||||
"Select All": "Select All",
|
||||
"Send": "Send",
|
||||
"Server Error": "Server Error",
|
||||
"Service Unavailable": "Service Unavailable",
|
||||
"Session Has Expired": "Session Has Expired",
|
||||
"Settings": "Settings",
|
||||
"Show": "Show",
|
||||
"Show :name": "Show :name",
|
||||
"Show All": "Show All",
|
||||
"Showing": "Showing",
|
||||
"Sign In": "Sign In",
|
||||
"Solve": "Solve",
|
||||
"SSL Handshake Failed": "SSL Handshake Failed",
|
||||
"Start": "Start",
|
||||
"Stop": "Stop",
|
||||
"Submit": "Submit",
|
||||
"Subscribe": "Subscribe",
|
||||
"Switch": "Switch",
|
||||
"Switch To Role": "Switch To Role",
|
||||
"Switching Protocols": "Switching Protocols",
|
||||
"Tag": "Tag",
|
||||
"Tags": "Tags",
|
||||
"Temporary Redirect": "Temporary Redirect",
|
||||
"The given data was invalid.": "The given data was invalid.",
|
||||
"The response is not a streamed response.": "The response is not a streamed response.",
|
||||
"The response is not a view.": "The response is not a view.",
|
||||
"This action is unauthorized.": "This action is unauthorized.",
|
||||
"This password reset link will expire in :count minutes.": "This password reset link will expire in :count minutes.",
|
||||
"to": "to",
|
||||
"Toggle navigation": "Toggle navigation",
|
||||
"Too Early": "Too Early",
|
||||
"Too Many Requests": "Too Many Requests",
|
||||
"Translate": "Translate",
|
||||
"Translate It": "Translate It",
|
||||
"Unauthorized": "Unauthorized",
|
||||
"Unavailable For Legal Reasons": "Unavailable For Legal Reasons",
|
||||
"Unknown Error": "Unknown Error",
|
||||
"Unpack": "Unpack",
|
||||
"Unprocessable Entity": "Unprocessable Entity",
|
||||
"Unsubscribe": "Unsubscribe",
|
||||
"Unsupported Media Type": "Unsupported Media Type",
|
||||
"Up": "Up",
|
||||
"Update": "Update",
|
||||
"Update :name": "Update :name",
|
||||
"Upgrade Required": "Upgrade Required",
|
||||
"URI Too Long": "URI Too Long",
|
||||
"Use Proxy": "Use Proxy",
|
||||
"User": "User",
|
||||
"Variant Also Negotiates": "Variant Also Negotiates",
|
||||
"Verify Email Address": "Verify Email Address",
|
||||
"Verify your email address": "Verify your email address",
|
||||
"View": "View",
|
||||
"View :name": "View :name",
|
||||
"Web Server is Down": "Web Server is Down",
|
||||
"Whoops!": "Whoops!",
|
||||
"Yes": "Yes",
|
||||
"You are receiving this email because we received a password reset request for your account.": "You are receiving this email because we received a password reset request for your account."
|
||||
}
|
||||
119
lang/en/actions.php
Normal file
119
lang/en/actions.php
Normal file
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'accept' => 'Accept',
|
||||
'action' => 'Action',
|
||||
'actions' => 'Actions',
|
||||
'add' => 'Add',
|
||||
'admin' => 'Admin',
|
||||
'agree' => 'Agree',
|
||||
'archive' => 'Archive',
|
||||
'assign' => 'Assign',
|
||||
'associate' => 'Associate',
|
||||
'attach' => 'Attach',
|
||||
'browse' => 'Browse',
|
||||
'cancel' => 'Cancel',
|
||||
'choose' => 'Choose',
|
||||
'choose_file' => 'Choose File',
|
||||
'choose_image' => 'Choose Image',
|
||||
'click_to_copy' => 'Click to copy',
|
||||
'close' => 'Close',
|
||||
'collapse' => 'Collapse',
|
||||
'collapse_all' => 'Collapse All',
|
||||
'comment' => 'Comment',
|
||||
'confirm' => 'Confirm',
|
||||
'connect' => 'Connect',
|
||||
'create' => 'Create',
|
||||
'delete' => 'Delete',
|
||||
'detach' => 'Detach',
|
||||
'details' => 'Details',
|
||||
'disable' => 'Disable',
|
||||
'discard' => 'Discard',
|
||||
'done' => 'Done',
|
||||
'down' => 'Down',
|
||||
'duplicate' => 'Duplicate',
|
||||
'edit' => 'Edit',
|
||||
'enable' => 'Enable',
|
||||
'expand' => 'Expand',
|
||||
'expand_all' => 'Expand All',
|
||||
'explanation' => 'Explanation',
|
||||
'export' => 'Export',
|
||||
'file' => 'File',
|
||||
'files' => 'Files',
|
||||
'go_home' => 'Go Home',
|
||||
'hide' => 'Hide',
|
||||
'home' => 'Home',
|
||||
'image' => 'Image',
|
||||
'impersonate' => 'Impersonate',
|
||||
'impersonation' => 'Impersonation',
|
||||
'import' => 'Import',
|
||||
'introduction' => 'Introduction',
|
||||
'like' => 'Like',
|
||||
'load' => 'Load',
|
||||
'localize' => 'Localize',
|
||||
'log_in' => 'Log In',
|
||||
'log_out' => 'Log Out',
|
||||
'named' => [
|
||||
'add' => 'Add :name',
|
||||
'choose' => 'Choose :name',
|
||||
'create' => 'Create :name',
|
||||
'delete' => 'Delete :name',
|
||||
'duplicate' => 'Duplicate :name',
|
||||
'edit' => 'Edit :name',
|
||||
'export' => 'Export :name',
|
||||
'hide' => 'Hide :name',
|
||||
'import' => 'Import :name',
|
||||
'new' => 'New :name',
|
||||
'restore' => 'Restore :name',
|
||||
'save' => 'Save :name',
|
||||
'search' => 'Search :name',
|
||||
'show' => 'Show :name',
|
||||
'update' => 'Update :name',
|
||||
'view' => 'View :name',
|
||||
],
|
||||
'new' => 'New',
|
||||
'no' => 'No',
|
||||
'open' => 'Open',
|
||||
'open_website' => 'Open on the website',
|
||||
'preview' => 'Preview',
|
||||
'price' => 'Price',
|
||||
'record' => 'Record',
|
||||
'restore' => 'Restore',
|
||||
'save' => 'Save',
|
||||
'save_and_close' => 'Save & Close',
|
||||
'save_and_return' => 'Save & Return',
|
||||
'search' => 'Search',
|
||||
'select' => 'Select',
|
||||
'select_all' => 'Select All',
|
||||
'send' => 'Send',
|
||||
'settings' => 'Settings',
|
||||
'show' => 'Show',
|
||||
'show_all' => 'Show All',
|
||||
'sign_in' => 'Sign In',
|
||||
'solve' => 'Solve',
|
||||
'start' => 'Start',
|
||||
'stop' => 'Stop',
|
||||
'submit' => 'Submit',
|
||||
'subscribe' => 'Subscribe',
|
||||
'switch' => 'Switch',
|
||||
'switch_to_role' => 'Switch To Role',
|
||||
'tag' => 'Tag',
|
||||
'tags' => 'Tags',
|
||||
'target_link' => [
|
||||
'blank' => 'Open in a new window',
|
||||
'parent' => 'Open in a parent frame',
|
||||
'self' => 'Open in a current window',
|
||||
'top' => 'Open in the topmost frame',
|
||||
],
|
||||
'translate' => 'Translate',
|
||||
'translate_it' => 'Translate It',
|
||||
'unpack' => 'Unpack',
|
||||
'unsubscribe' => 'Unsubscribe',
|
||||
'up' => 'Up',
|
||||
'update' => 'Update',
|
||||
'user' => 'User',
|
||||
'view' => 'View',
|
||||
'yes' => 'Yes',
|
||||
];
|
||||
9
lang/en/auth.php
Normal file
9
lang/en/auth.php
Normal file
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'failed' => 'These credentials do not match our records.',
|
||||
'password' => 'The provided password is incorrect.',
|
||||
'throttle' => 'Too many login attempts. Please try again in :seconds seconds.',
|
||||
];
|
||||
84
lang/en/http-statuses.php
Normal file
84
lang/en/http-statuses.php
Normal file
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'0' => 'Unknown Error',
|
||||
'100' => 'Continue',
|
||||
'101' => 'Switching Protocols',
|
||||
'102' => 'Processing',
|
||||
'200' => 'OK',
|
||||
'201' => 'Created',
|
||||
'202' => 'Accepted',
|
||||
'203' => 'Non-Authoritative Information',
|
||||
'204' => 'No Content',
|
||||
'205' => 'Reset Content',
|
||||
'206' => 'Partial Content',
|
||||
'207' => 'Multi-Status',
|
||||
'208' => 'Already Reported',
|
||||
'226' => 'IM Used',
|
||||
'300' => 'Multiple Choices',
|
||||
'301' => 'Moved Permanently',
|
||||
'302' => 'Found',
|
||||
'303' => 'See Other',
|
||||
'304' => 'Not Modified',
|
||||
'305' => 'Use Proxy',
|
||||
'307' => 'Temporary Redirect',
|
||||
'308' => 'Permanent Redirect',
|
||||
'400' => 'Bad Request',
|
||||
'401' => 'Unauthorized',
|
||||
'402' => 'Payment Required',
|
||||
'403' => 'Forbidden',
|
||||
'404' => 'Not Found',
|
||||
'405' => 'Method Not Allowed',
|
||||
'406' => 'Not Acceptable',
|
||||
'407' => 'Proxy Authentication Required',
|
||||
'408' => 'Request Timeout',
|
||||
'409' => 'Conflict',
|
||||
'410' => 'Gone',
|
||||
'411' => 'Length Required',
|
||||
'412' => 'Precondition Failed',
|
||||
'413' => 'Payload Too Large',
|
||||
'414' => 'URI Too Long',
|
||||
'415' => 'Unsupported Media Type',
|
||||
'416' => 'Range Not Satisfiable',
|
||||
'417' => 'Expectation Failed',
|
||||
'418' => 'I\'m a teapot',
|
||||
'419' => 'Session Has Expired',
|
||||
'421' => 'Misdirected Request',
|
||||
'422' => 'Unprocessable Entity',
|
||||
'423' => 'Locked',
|
||||
'424' => 'Failed Dependency',
|
||||
'425' => 'Too Early',
|
||||
'426' => 'Upgrade Required',
|
||||
'428' => 'Precondition Required',
|
||||
'429' => 'Too Many Requests',
|
||||
'431' => 'Request Header Fields Too Large',
|
||||
'444' => 'Connection Closed Without Response',
|
||||
'449' => 'Retry With',
|
||||
'451' => 'Unavailable For Legal Reasons',
|
||||
'499' => 'Client Closed Request',
|
||||
'500' => 'Internal Server Error',
|
||||
'501' => 'Not Implemented',
|
||||
'502' => 'Bad Gateway',
|
||||
'503' => 'Maintenance Mode',
|
||||
'504' => 'Gateway Timeout',
|
||||
'505' => 'HTTP Version Not Supported',
|
||||
'506' => 'Variant Also Negotiates',
|
||||
'507' => 'Insufficient Storage',
|
||||
'508' => 'Loop Detected',
|
||||
'509' => 'Bandwidth Limit Exceeded',
|
||||
'510' => 'Not Extended',
|
||||
'511' => 'Network Authentication Required',
|
||||
'520' => 'Unknown Error',
|
||||
'521' => 'Web Server is Down',
|
||||
'522' => 'Connection Timed Out',
|
||||
'523' => 'Origin Is Unreachable',
|
||||
'524' => 'A Timeout Occurred',
|
||||
'525' => 'SSL Handshake Failed',
|
||||
'526' => 'Invalid SSL Certificate',
|
||||
'527' => 'Railgun Error',
|
||||
'598' => 'Network Read Timeout Error',
|
||||
'599' => 'Network Connect Timeout Error',
|
||||
'unknownError' => 'Unknown Error',
|
||||
];
|
||||
13
lang/en/messages.php
Normal file
13
lang/en/messages.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'invalid_credentials' => 'Invalid credentials.',
|
||||
'account_deactivated' => 'This account is deactivated.',
|
||||
'account_pending_approval' => 'Your account is pending approval by an administrator.',
|
||||
'registration_pending' => 'Account created. An administrator has to approve it before you can sign in.',
|
||||
'cannot_remove_own_admin_role' => "You can't remove your own admin role.",
|
||||
'cannot_delete_self' => "You can't delete your own account.",
|
||||
'cannot_delete_last_admin' => "You can't delete the last administrator.",
|
||||
'self_service_requires_user' => 'This resource is self-service: it needs a token with an associated user (does not work with client_credentials).',
|
||||
'user_not_trashed' => 'This user is not in the trash.',
|
||||
];
|
||||
8
lang/en/pagination.php
Normal file
8
lang/en/pagination.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'next' => 'Next »',
|
||||
'previous' => '« Previous',
|
||||
];
|
||||
11
lang/en/passwords.php
Normal file
11
lang/en/passwords.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'reset' => 'Your password has been reset.',
|
||||
'sent' => 'We have emailed your password reset link.',
|
||||
'throttled' => 'Please wait before retrying.',
|
||||
'token' => 'This password reset token is invalid.',
|
||||
'user' => 'We can\'t find a user with that email address.',
|
||||
];
|
||||
289
lang/en/validation.php
Normal file
289
lang/en/validation.php
Normal file
@@ -0,0 +1,289 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'accepted' => 'The :attribute field must be accepted.',
|
||||
'accepted_if' => 'The :attribute field must be accepted when :other is :value.',
|
||||
'active_url' => 'The :attribute field must be a valid URL.',
|
||||
'after' => 'The :attribute field must be a date after :date.',
|
||||
'after_or_equal' => 'The :attribute field must be a date after or equal to :date.',
|
||||
'alpha' => 'The :attribute field must only contain letters.',
|
||||
'alpha_dash' => 'The :attribute field must only contain letters, numbers, dashes, and underscores.',
|
||||
'alpha_num' => 'The :attribute field must only contain letters and numbers.',
|
||||
'any_of' => 'The :attribute field is invalid.',
|
||||
'array' => 'The :attribute field must be an array.',
|
||||
'ascii' => 'The :attribute field must only contain single-byte alphanumeric characters and symbols.',
|
||||
'base64' => 'The :attribute field must be a valid Base64 string.',
|
||||
'before' => 'The :attribute field must be a date before :date.',
|
||||
'before_or_equal' => 'The :attribute field must be a date before or equal to :date.',
|
||||
'between' => [
|
||||
'array' => 'The :attribute field must have between :min and :max items.',
|
||||
'file' => 'The :attribute field must be between :min and :max kilobytes.',
|
||||
'numeric' => 'The :attribute field must be between :min and :max.',
|
||||
'string' => 'The :attribute field must be between :min and :max characters.',
|
||||
],
|
||||
'boolean' => 'The :attribute field must be true or false.',
|
||||
'can' => 'The :attribute field contains an unauthorized value.',
|
||||
'confirmed' => 'The :attribute field confirmation does not match.',
|
||||
'contains' => 'The :attribute field is missing a required value.',
|
||||
'current_password' => 'The password is incorrect.',
|
||||
'date' => 'The :attribute field must be a valid date.',
|
||||
'date_equals' => 'The :attribute field must be a date equal to :date.',
|
||||
'date_format' => 'The :attribute field must match the format :format.',
|
||||
'decimal' => 'The :attribute field must have :decimal decimal places.',
|
||||
'declined' => 'The :attribute field must be declined.',
|
||||
'declined_if' => 'The :attribute field must be declined when :other is :value.',
|
||||
'different' => 'The :attribute field and :other must be different.',
|
||||
'digits' => 'The :attribute field must be :digits digits.',
|
||||
'digits_between' => 'The :attribute field must be between :min and :max digits.',
|
||||
'dimensions' => 'The :attribute field has invalid image dimensions.',
|
||||
'distinct' => 'The :attribute field has a duplicate value.',
|
||||
'doesnt_contain' => 'The :attribute field must not contain any of the following: :values.',
|
||||
'doesnt_end_with' => 'The :attribute field must not end with one of the following: :values.',
|
||||
'doesnt_start_with' => 'The :attribute field must not start with one of the following: :values.',
|
||||
'email' => 'The :attribute field must be a valid email address.',
|
||||
'encoding' => 'The :attribute field must be encoded in :encoding.',
|
||||
'ends_with' => 'The :attribute field must end with one of the following: :values.',
|
||||
'enum' => 'The selected :attribute is invalid.',
|
||||
'exists' => 'The selected :attribute is invalid.',
|
||||
'extensions' => 'The :attribute field must have one of the following extensions: :values.',
|
||||
'file' => 'The :attribute field must be a file.',
|
||||
'filled' => 'The :attribute field must have a value.',
|
||||
'gt' => [
|
||||
'array' => 'The :attribute field must have more than :value items.',
|
||||
'file' => 'The :attribute field must be greater than :value kilobytes.',
|
||||
'numeric' => 'The :attribute field must be greater than :value.',
|
||||
'string' => 'The :attribute field must be greater than :value characters.',
|
||||
],
|
||||
'gte' => [
|
||||
'array' => 'The :attribute field must have :value items or more.',
|
||||
'file' => 'The :attribute field must be greater than or equal to :value kilobytes.',
|
||||
'numeric' => 'The :attribute field must be greater than or equal to :value.',
|
||||
'string' => 'The :attribute field must be greater than or equal to :value characters.',
|
||||
],
|
||||
'hex_color' => 'The :attribute field must be a valid hexadecimal color.',
|
||||
'image' => 'The :attribute field must be an image.',
|
||||
'in' => 'The selected :attribute is invalid.',
|
||||
'in_array' => 'The :attribute field must exist in :other.',
|
||||
'in_array_keys' => 'The :attribute field must contain at least one of the following keys: :values.',
|
||||
'integer' => 'The :attribute field must be an integer.',
|
||||
'ip' => 'The :attribute field must be a valid IP address.',
|
||||
'ipv4' => 'The :attribute field must be a valid IPv4 address.',
|
||||
'ipv6' => 'The :attribute field must be a valid IPv6 address.',
|
||||
'json' => 'The :attribute field must be a valid JSON string.',
|
||||
'list' => 'The :attribute field must be a list.',
|
||||
'lowercase' => 'The :attribute field must be lowercase.',
|
||||
'lt' => [
|
||||
'array' => 'The :attribute field must have less than :value items.',
|
||||
'file' => 'The :attribute field must be less than :value kilobytes.',
|
||||
'numeric' => 'The :attribute field must be less than :value.',
|
||||
'string' => 'The :attribute field must be less than :value characters.',
|
||||
],
|
||||
'lte' => [
|
||||
'array' => 'The :attribute field must not have more than :value items.',
|
||||
'file' => 'The :attribute field must be less than or equal to :value kilobytes.',
|
||||
'numeric' => 'The :attribute field must be less than or equal to :value.',
|
||||
'string' => 'The :attribute field must be less than or equal to :value characters.',
|
||||
],
|
||||
'mac_address' => 'The :attribute field must be a valid MAC address.',
|
||||
'max' => [
|
||||
'array' => 'The :attribute field must not have more than :max items.',
|
||||
'file' => 'The :attribute field must not be greater than :max kilobytes.',
|
||||
'numeric' => 'The :attribute field must not be greater than :max.',
|
||||
'string' => 'The :attribute field must not be greater than :max characters.',
|
||||
],
|
||||
'max_digits' => 'The :attribute field must not have more than :max digits.',
|
||||
'mimes' => 'The :attribute field must be a file of type: :values.',
|
||||
'mimetypes' => 'The :attribute field must be a file of type: :values.',
|
||||
'min' => [
|
||||
'array' => 'The :attribute field must have at least :min items.',
|
||||
'file' => 'The :attribute field must be at least :min kilobytes.',
|
||||
'numeric' => 'The :attribute field must be at least :min.',
|
||||
'string' => 'The :attribute field must be at least :min characters.',
|
||||
],
|
||||
'min_digits' => 'The :attribute field must have at least :min digits.',
|
||||
'missing' => 'The :attribute field must be missing.',
|
||||
'missing_if' => 'The :attribute field must be missing when :other is :value.',
|
||||
'missing_unless' => 'The :attribute field must be missing unless :other is :value.',
|
||||
'missing_with' => 'The :attribute field must be missing when :values is present.',
|
||||
'missing_with_all' => 'The :attribute field must be missing when :values are present.',
|
||||
'multiple_of' => 'The :attribute field must be a multiple of :value.',
|
||||
'not_in' => 'The selected :attribute is invalid.',
|
||||
'not_regex' => 'The :attribute field format is invalid.',
|
||||
'numeric' => 'The :attribute field must be a number.',
|
||||
'password' => [
|
||||
'letters' => 'The :attribute field must contain at least one letter.',
|
||||
'mixed' => 'The :attribute field must contain at least one uppercase and one lowercase letter.',
|
||||
'numbers' => 'The :attribute field must contain at least one number.',
|
||||
'symbols' => 'The :attribute field must contain at least one symbol.',
|
||||
'uncompromised' => 'The given :attribute has appeared in a data leak. Please choose a different :attribute.',
|
||||
],
|
||||
'present' => 'The :attribute field must be present.',
|
||||
'present_if' => 'The :attribute field must be present when :other is :value.',
|
||||
'present_unless' => 'The :attribute field must be present unless :other is :value.',
|
||||
'present_with' => 'The :attribute field must be present when :values is present.',
|
||||
'present_with_all' => 'The :attribute field must be present when :values are present.',
|
||||
'prohibited' => 'The :attribute field is prohibited.',
|
||||
'prohibited_if' => 'The :attribute field is prohibited when :other is :value.',
|
||||
'prohibited_if_accepted' => 'The :attribute field is prohibited when :other is accepted.',
|
||||
'prohibited_if_declined' => 'The :attribute field is prohibited when :other is declined.',
|
||||
'prohibited_unless' => 'The :attribute field is prohibited unless :other is in :values.',
|
||||
'prohibits' => 'The :attribute field prohibits :other from being present.',
|
||||
'regex' => 'The :attribute field format is invalid.',
|
||||
'required' => 'The :attribute field is required.',
|
||||
'required_array_keys' => 'The :attribute field must contain entries for: :values.',
|
||||
'required_if' => 'The :attribute field is required when :other is :value.',
|
||||
'required_if_accepted' => 'The :attribute field is required when :other is accepted.',
|
||||
'required_if_declined' => 'The :attribute field is required when :other is declined.',
|
||||
'required_unless' => 'The :attribute field is required unless :other is in :values.',
|
||||
'required_with' => 'The :attribute field is required when :values is present.',
|
||||
'required_with_all' => 'The :attribute field is required when :values are present.',
|
||||
'required_without' => 'The :attribute field is required when :values is not present.',
|
||||
'required_without_all' => 'The :attribute field is required when none of :values are present.',
|
||||
'same' => 'The :attribute field must match :other.',
|
||||
'size' => [
|
||||
'array' => 'The :attribute field must contain :size items.',
|
||||
'file' => 'The :attribute field must be :size kilobytes.',
|
||||
'numeric' => 'The :attribute field must be :size.',
|
||||
'string' => 'The :attribute field must be :size characters.',
|
||||
],
|
||||
'starts_with' => 'The :attribute field must start with one of the following: :values.',
|
||||
'string' => 'The :attribute field must be a string.',
|
||||
'timezone' => 'The :attribute field must be a valid timezone.',
|
||||
'ulid' => 'The :attribute field must be a valid ULID.',
|
||||
'unique' => 'The :attribute has already been taken.',
|
||||
'uploaded' => 'The :attribute failed to upload.',
|
||||
'uppercase' => 'The :attribute field must be uppercase.',
|
||||
'url' => 'The :attribute field must be a valid URL.',
|
||||
'uuid' => 'The :attribute field must be a valid UUID.',
|
||||
'attributes' => [
|
||||
'address' => 'address',
|
||||
'affiliate_url' => 'affiliate URL',
|
||||
'age' => 'age',
|
||||
'amount' => 'amount',
|
||||
'announcement' => 'announcement',
|
||||
'area' => 'area',
|
||||
'audience_prize' => 'audience prize',
|
||||
'audience_winner' => 'audience winner',
|
||||
'available' => 'available',
|
||||
'birthday' => 'birthday',
|
||||
'body' => 'body',
|
||||
'city' => 'city',
|
||||
'color' => 'color',
|
||||
'company' => 'company',
|
||||
'compilation' => 'compilation',
|
||||
'concept' => 'concept',
|
||||
'conditions' => 'conditions',
|
||||
'content' => 'content',
|
||||
'contest' => 'contest',
|
||||
'country' => 'country',
|
||||
'cover' => 'cover',
|
||||
'created_at' => 'created at',
|
||||
'creator' => 'creator',
|
||||
'currency' => 'currency',
|
||||
'current_password' => 'current password',
|
||||
'customer' => 'customer',
|
||||
'date' => 'date',
|
||||
'date_of_birth' => 'date of birth',
|
||||
'dates' => 'dates',
|
||||
'day' => 'day',
|
||||
'deleted_at' => 'deleted at',
|
||||
'description' => 'description',
|
||||
'display_type' => 'display type',
|
||||
'district' => 'district',
|
||||
'duration' => 'duration',
|
||||
'email' => 'email',
|
||||
'excerpt' => 'excerpt',
|
||||
'filter' => 'filter',
|
||||
'finished_at' => 'finished at',
|
||||
'first_name' => 'first name',
|
||||
'gender' => 'gender',
|
||||
'grand_prize' => 'grand prize',
|
||||
'group' => 'group',
|
||||
'hour' => 'hour',
|
||||
'image' => 'image',
|
||||
'image_desktop' => 'desktop image',
|
||||
'image_main' => 'main image',
|
||||
'image_mobile' => 'mobile image',
|
||||
'images' => 'images',
|
||||
'is_audience_winner' => 'is audience winner',
|
||||
'is_hidden' => 'is hidden',
|
||||
'is_subscribed' => 'is subscribed',
|
||||
'is_visible' => 'is visible',
|
||||
'is_winner' => 'is winner',
|
||||
'items' => 'items',
|
||||
'key' => 'key',
|
||||
'last_name' => 'last name',
|
||||
'lesson' => 'lesson',
|
||||
'line_address_1' => 'line address 1',
|
||||
'line_address_2' => 'line address 2',
|
||||
'login' => 'login',
|
||||
'message' => 'message',
|
||||
'middle_name' => 'middle name',
|
||||
'minute' => 'minute',
|
||||
'mobile' => 'mobile',
|
||||
'month' => 'month',
|
||||
'name' => 'name',
|
||||
'national_code' => 'national code',
|
||||
'number' => 'number',
|
||||
'password' => 'password',
|
||||
'password_confirmation' => 'password confirmation',
|
||||
'phone' => 'phone',
|
||||
'photo' => 'photo',
|
||||
'portfolio' => 'portfolio',
|
||||
'postal_code' => 'postal code',
|
||||
'preview' => 'preview',
|
||||
'price' => 'price',
|
||||
'product_id' => 'product ID',
|
||||
'product_uid' => 'product UID',
|
||||
'product_uuid' => 'product UUID',
|
||||
'promo_code' => 'promo code',
|
||||
'province' => 'province',
|
||||
'quantity' => 'quantity',
|
||||
'reason' => 'reason',
|
||||
'recaptcha_response_field' => 'recaptcha response field',
|
||||
'referee' => 'referee',
|
||||
'referees' => 'referees',
|
||||
'region' => 'region',
|
||||
'reject_reason' => 'reject reason',
|
||||
'remember' => 'remember',
|
||||
'restored_at' => 'restored at',
|
||||
'result_text_under_image' => 'result text under image',
|
||||
'role' => 'role',
|
||||
'rule' => 'rule',
|
||||
'rules' => 'rules',
|
||||
'second' => 'second',
|
||||
'sex' => 'sex',
|
||||
'shipment' => 'shipment',
|
||||
'short_text' => 'short text',
|
||||
'size' => 'size',
|
||||
'skills' => 'skills',
|
||||
'slug' => 'slug',
|
||||
'specialization' => 'specialization',
|
||||
'started_at' => 'started at',
|
||||
'state' => 'state',
|
||||
'status' => 'status',
|
||||
'street' => 'street',
|
||||
'student' => 'student',
|
||||
'subject' => 'subject',
|
||||
'tag' => 'tag',
|
||||
'tags' => 'tags',
|
||||
'teacher' => 'teacher',
|
||||
'terms' => 'terms',
|
||||
'test_description' => 'test description',
|
||||
'test_locale' => 'test locale',
|
||||
'test_name' => 'test name',
|
||||
'text' => 'text',
|
||||
'time' => 'time',
|
||||
'title' => 'title',
|
||||
'type' => 'type',
|
||||
'updated_at' => 'updated at',
|
||||
'user' => 'user',
|
||||
'username' => 'username',
|
||||
'value' => 'value',
|
||||
'winner' => 'winner',
|
||||
'work' => 'work',
|
||||
'year' => 'year',
|
||||
],
|
||||
];
|
||||
229
lang/es.json
Normal file
229
lang/es.json
Normal file
@@ -0,0 +1,229 @@
|
||||
{
|
||||
"(and :count more error)": "(y :count error más)",
|
||||
"(and :count more errors)": "(y :count error más)|(y :count errores más)|(y :count errores más)",
|
||||
"A decryption key is required.": "Se requiere una clave de descifrado.",
|
||||
"A Timeout Occurred": "Se produjo un tiempo de espera",
|
||||
"Accept": "Aceptar",
|
||||
"Accepted": "Aceptado",
|
||||
"Action": "Acción",
|
||||
"Actions": "Acciones",
|
||||
"Add": "Añadir",
|
||||
"Add :name": "Agregar :name",
|
||||
"Admin": "Administrar",
|
||||
"Agree": "Aceptar",
|
||||
"All rights reserved.": "Todos los derechos reservados.",
|
||||
"Already Reported": "Ya Reportado",
|
||||
"Archive": "Archivar",
|
||||
"Assign": "Asignar",
|
||||
"Associate": "Asociar",
|
||||
"Attach": "Adjuntar",
|
||||
"Bad Gateway": "Mala puerta de enlace",
|
||||
"Bad Request": "Solicitud incorrecta",
|
||||
"Bandwidth Limit Exceeded": "Límite de ancho de banda excedido",
|
||||
"Browse": "Navegar",
|
||||
"Cancel": "Cancelar",
|
||||
"Choose": "Elija",
|
||||
"Choose :name": "Elegir :name",
|
||||
"Choose File": "Elija archivo",
|
||||
"Choose Image": "Elegir Imagen",
|
||||
"Click to copy": "Haga clic para copiar",
|
||||
"Client Closed Request": "Solicitud cerrada del cliente",
|
||||
"Close": "Cerrar",
|
||||
"Collapse": "Colapsar",
|
||||
"Collapse All": "Colapsar todo",
|
||||
"Comment": "Comentar",
|
||||
"Confirm": "Confirmar",
|
||||
"Conflict": "Conflicto",
|
||||
"Connect": "Conectar",
|
||||
"Connection Closed Without Response": "Conexión cerrada sin respuesta",
|
||||
"Connection Timed Out": "Tiempo de conexión agotado",
|
||||
"Continue": "Continuar",
|
||||
"Create": "Crear",
|
||||
"Create :name": "Crear :name",
|
||||
"Created": "Creado",
|
||||
"Delete": "Eliminar",
|
||||
"Delete :name": "Eliminar :name",
|
||||
"Detach": "Desvincular",
|
||||
"Details": "Detalles",
|
||||
"Disable": "Deshabilitar",
|
||||
"Discard": "Descartar",
|
||||
"Done": "Hecho",
|
||||
"Down": "Abajo",
|
||||
"Duplicate": "Duplicar",
|
||||
"Duplicate :name": "Duplicar :name",
|
||||
"Edit": "Editar",
|
||||
"Edit :name": "Editar :name",
|
||||
"Enable": "Habilitar",
|
||||
"Encrypted environment file already exists.": "El archivo de entorno cifrado ya existe.",
|
||||
"Encrypted environment file not found.": "No se encontró el archivo de entorno cifrado.",
|
||||
"Environment file already exists.": "El archivo de entorno ya existe.",
|
||||
"Environment file not found.": "Archivo de entorno no encontrado.",
|
||||
"errors": "errores",
|
||||
"Expand": "Expandir",
|
||||
"Expand All": "Expandir todo",
|
||||
"Expectation Failed": "Expectativa fallida",
|
||||
"Explanation": "Explicación",
|
||||
"Export": "Exportar",
|
||||
"Export :name": "Exportar :name",
|
||||
"Failed Dependency": "Dependencia fallida",
|
||||
"File": "Archivo",
|
||||
"Files": "Archivos",
|
||||
"Forbidden": "Prohibido",
|
||||
"Found": "Encontrado",
|
||||
"Gateway Timeout": "Tiempo de espera de puerta de enlace",
|
||||
"Go Home": "Ir a inicio",
|
||||
"Go to page :page": "Ir a la página :page",
|
||||
"Gone": "Recurso no disponible",
|
||||
"Hello!": "¡Hola!",
|
||||
"Hide": "Ocultar",
|
||||
"Hide :name": "Ocultar :name",
|
||||
"Home": "Inicio",
|
||||
"HTTP Version Not Supported": "Versión HTTP no compatible",
|
||||
"I'm a teapot": "Soy una tetera",
|
||||
"If you did not create an account, no further action is required.": "Si no ha creado una cuenta, no se requiere ninguna acción adicional.",
|
||||
"If you did not request a password reset, no further action is required.": "Si no ha solicitado el restablecimiento de contraseña, omita este mensaje de correo electrónico.",
|
||||
"If you're having trouble clicking the \":actionText\" button, copy and paste the URL below\ninto your web browser:": "Si está teniendo problemas al hacer clic en el botón \":actionText\", copie y pegue la URL de abajo\nen su navegador web:",
|
||||
"IM Used": "IM usado",
|
||||
"Image": "Imagen",
|
||||
"Impersonate": "Personificar",
|
||||
"Impersonation": "Personificación",
|
||||
"Import": "Importar",
|
||||
"Import :name": "Importar :name",
|
||||
"Insufficient Storage": "Espacio insuficiente",
|
||||
"Internal Server Error": "Error interno del servidor",
|
||||
"Introduction": "Introducción",
|
||||
"Invalid filename.": "Nombre de archivo no válido.",
|
||||
"Invalid JSON was returned from the route.": "Se devolvió un JSON no válido desde la ruta.",
|
||||
"Invalid SSL Certificate": "Certificado SSL no válido",
|
||||
"Length Required": "Longitud requerida",
|
||||
"Like": "Me gusta",
|
||||
"Load": "Cargar",
|
||||
"Localize": "Localizar",
|
||||
"Location": "Ubicación",
|
||||
"Locked": "Bloqueado",
|
||||
"Log In": "Iniciar sesión",
|
||||
"Log Out": "Finalizar sesión",
|
||||
"Login": "Iniciar sesión",
|
||||
"Logout": "Finalizar sesión",
|
||||
"Loop Detected": "Bucle detectado",
|
||||
"Maintenance Mode": "Modo de mantenimiento",
|
||||
"Method Not Allowed": "Método no permitido",
|
||||
"Misdirected Request": "Solicitud mal dirigida",
|
||||
"Moved Permanently": "Movido permanentemente",
|
||||
"Multi-Status": "Multiestado",
|
||||
"Multiple Choices": "Múltiples opciones",
|
||||
"Network Authentication Required": "Se requiere autenticación de red",
|
||||
"Network Connect Timeout Error": "Error de tiempo de espera de conexión de red",
|
||||
"Network Read Timeout Error": "Error de tiempo de espera de lectura de red",
|
||||
"New": "Nuevo",
|
||||
"New :name": "Nuevo :name",
|
||||
"No": "No",
|
||||
"No Content": "Sin contenido",
|
||||
"Non-Authoritative Information": "Información no autorizada",
|
||||
"Not Acceptable": "Inaceptable",
|
||||
"Not Extended": "no extendido",
|
||||
"Not Found": "No encontrado",
|
||||
"Not Implemented": "No se ha implementado",
|
||||
"Not Modified": "No modificado",
|
||||
"of": "de",
|
||||
"OK": "Correcto",
|
||||
"Open": "Abrir",
|
||||
"Open in a current window": "Abrir en una ventana actual",
|
||||
"Open in a new window": "Abrir en una ventana nueva",
|
||||
"Open in a parent frame": "Abrir en un marco principal",
|
||||
"Open in the topmost frame": "Abrir en el marco superior",
|
||||
"Open on the website": "Abrir en el sitio web",
|
||||
"Origin Is Unreachable": "El origen es inalcanzable",
|
||||
"Page Expired": "Página expirada",
|
||||
"Pagination Navigation": "Navegación por los enlaces de paginación",
|
||||
"Partial Content": "Contenido parcial",
|
||||
"Payload Too Large": "Solicitud demasiado grande",
|
||||
"Payment Required": "Pago requerido",
|
||||
"Permanent Redirect": "Redirección permanente",
|
||||
"Please click the button below to verify your email address.": "Por favor, haga clic en el botón de abajo para verificar su dirección de correo electrónico.",
|
||||
"Precondition Failed": "Error de condición previa",
|
||||
"Precondition Required": "Precondición requerida",
|
||||
"Preview": "Previsualizar",
|
||||
"Price": "Precio",
|
||||
"Processing": "Procesando",
|
||||
"Proxy Authentication Required": "Se requiere autenticación proxy",
|
||||
"Railgun Error": "Error de cañón de riel",
|
||||
"Range Not Satisfiable": "Rango no satisfactorio",
|
||||
"Record": "Registro",
|
||||
"Regards,": "Saludos,",
|
||||
"Register": "Registrarse",
|
||||
"Request Header Fields Too Large": "Campos de encabezado de solicitud demasiado grandes",
|
||||
"Request Timeout": "Solicitud de tiempo de espera",
|
||||
"Reset Content": "Restablecer contenido",
|
||||
"Reset Password": "Restablecer contraseña",
|
||||
"Reset your password": "Restablezca su contraseña",
|
||||
"Restore": "Restaurar",
|
||||
"Restore :name": "Restaurar :name",
|
||||
"results": "resultados",
|
||||
"Retry With": "Reintentar con",
|
||||
"Save": "Guardar",
|
||||
"Save & Close": "Guardar y cerrar",
|
||||
"Save & Return": "Guardar y volver",
|
||||
"Save :name": "Guardar :name",
|
||||
"Search": "Buscar",
|
||||
"Search :name": "Buscar :name",
|
||||
"See Other": "Ver otros",
|
||||
"Select": "Seleccione",
|
||||
"Select All": "Seleccionar todo",
|
||||
"Send": "Enviar",
|
||||
"Server Error": "Error del servidor",
|
||||
"Service Unavailable": "Servicio no disponible",
|
||||
"Session Has Expired": "La sesión ha expirado",
|
||||
"Settings": "Ajustes",
|
||||
"Show": "Mostrar",
|
||||
"Show :name": "Mostrar :name",
|
||||
"Show All": "Mostrar todo",
|
||||
"Showing": "Mostrando",
|
||||
"Sign In": "Iniciar sesión",
|
||||
"Solve": "Resolver",
|
||||
"SSL Handshake Failed": "Protocolo de enlace SSL fallido",
|
||||
"Start": "Comenzar",
|
||||
"Stop": "Detener",
|
||||
"Submit": "Enviar",
|
||||
"Subscribe": "Suscriba",
|
||||
"Switch": "Cambiar",
|
||||
"Switch To Role": "Cambiar de rol",
|
||||
"Switching Protocols": "Protocolos de conmutación",
|
||||
"Tag": "Etiqueta",
|
||||
"Tags": "Etiquetas",
|
||||
"Temporary Redirect": "Redirección temporal",
|
||||
"The given data was invalid.": "Los datos proporcionados no son válidos.",
|
||||
"The response is not a streamed response.": "La respuesta no es una respuesta transmitida.",
|
||||
"The response is not a view.": "La respuesta no es una vista.",
|
||||
"This action is unauthorized.": "Esta acción no está autorizada.",
|
||||
"This password reset link will expire in :count minutes.": "Este enlace de restablecimiento de contraseña expirará en :count minutos.",
|
||||
"to": "al",
|
||||
"Toggle navigation": "Alternar navegación",
|
||||
"Too Early": "Demasiado temprano",
|
||||
"Too Many Requests": "Demasiadas peticiones",
|
||||
"Translate": "Traducir",
|
||||
"Translate It": "Traducirlo",
|
||||
"Unauthorized": "No autorizado",
|
||||
"Unavailable For Legal Reasons": "No disponible por razones legales",
|
||||
"Unknown Error": "Error desconocido",
|
||||
"Unpack": "Desglosar",
|
||||
"Unprocessable Entity": "Entidad no procesable",
|
||||
"Unsubscribe": "Darse de baja",
|
||||
"Unsupported Media Type": "Tipo de medio no admitido",
|
||||
"Up": "Arriba",
|
||||
"Update": "Actualizar",
|
||||
"Update :name": "Actualizar :name",
|
||||
"Upgrade Required": "Se requiere actualización",
|
||||
"URI Too Long": "URI demasiado largo",
|
||||
"Use Proxy": "Usa proxy",
|
||||
"User": "Usuario",
|
||||
"Variant Also Negotiates": "Variante También Negocia",
|
||||
"Verify Email Address": "Confirme su correo electrónico",
|
||||
"Verify your email address": "Verifique su dirección de correo electrónico",
|
||||
"View": "Ver",
|
||||
"View :name": "Ver :name",
|
||||
"Web Server is Down": "El servidor web está caído",
|
||||
"Whoops!": "¡Ups!",
|
||||
"Yes": "Sí",
|
||||
"You are receiving this email because we received a password reset request for your account.": "Ha recibido este mensaje porque se solicitó un restablecimiento de contraseña para su cuenta."
|
||||
}
|
||||
119
lang/es/actions.php
Normal file
119
lang/es/actions.php
Normal file
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'accept' => 'Aceptar',
|
||||
'action' => 'Acción',
|
||||
'actions' => 'Acciones',
|
||||
'add' => 'Agregar',
|
||||
'admin' => 'Administrar',
|
||||
'agree' => 'Aceptar',
|
||||
'archive' => 'Archivar',
|
||||
'assign' => 'Asignar',
|
||||
'associate' => 'Asociar',
|
||||
'attach' => 'Adjuntar',
|
||||
'browse' => 'Navegar',
|
||||
'cancel' => 'Cancelar',
|
||||
'choose' => 'Elegir',
|
||||
'choose_file' => 'Elegir archivo',
|
||||
'choose_image' => 'Elegir Imagen',
|
||||
'click_to_copy' => 'Haga clic para copiar',
|
||||
'close' => 'Cerrar',
|
||||
'collapse' => 'Colapsar',
|
||||
'collapse_all' => 'Colapsar todo',
|
||||
'comment' => 'Comentar',
|
||||
'confirm' => 'Confirmar',
|
||||
'connect' => 'Conectar',
|
||||
'create' => 'Crear',
|
||||
'delete' => 'Borrar',
|
||||
'detach' => 'Desasociar',
|
||||
'details' => 'Detalles',
|
||||
'disable' => 'Desactivar',
|
||||
'discard' => 'Descartar',
|
||||
'done' => 'Hecho',
|
||||
'down' => 'Abajo',
|
||||
'duplicate' => 'Duplicar',
|
||||
'edit' => 'Editar',
|
||||
'enable' => 'Permitir',
|
||||
'expand' => 'Expandir',
|
||||
'expand_all' => 'Expandir todo',
|
||||
'explanation' => 'Explicación',
|
||||
'export' => 'Exportar',
|
||||
'file' => 'Archivo',
|
||||
'files' => 'Archivos',
|
||||
'go_home' => 'Ir a Inicio',
|
||||
'hide' => 'Ocultar',
|
||||
'home' => 'Inicio',
|
||||
'image' => 'Imagen',
|
||||
'impersonate' => 'Personificar',
|
||||
'impersonation' => 'Personificación',
|
||||
'import' => 'Importar',
|
||||
'introduction' => 'Introducción',
|
||||
'like' => 'Me gusta',
|
||||
'load' => 'Cargar',
|
||||
'localize' => 'Localizar',
|
||||
'log_in' => 'Acceder',
|
||||
'log_out' => 'Cerrar sesión',
|
||||
'named' => [
|
||||
'add' => 'Agregar :name',
|
||||
'choose' => 'Elegir :name',
|
||||
'create' => 'Crear :name',
|
||||
'delete' => 'Eliminar :name',
|
||||
'duplicate' => 'Duplicar :name',
|
||||
'edit' => 'Editar :name',
|
||||
'export' => 'Exportar :name',
|
||||
'hide' => 'Ocultar :name',
|
||||
'import' => 'Importar :name',
|
||||
'new' => 'Nuevo :name',
|
||||
'restore' => 'Restaurar :name',
|
||||
'save' => 'Guardar :name',
|
||||
'search' => 'Buscar :name',
|
||||
'show' => 'Mostrar :name',
|
||||
'update' => 'Actualizar :name',
|
||||
'view' => 'Ver :name',
|
||||
],
|
||||
'new' => 'Nuevo',
|
||||
'no' => 'No',
|
||||
'open' => 'Abrir',
|
||||
'open_website' => 'Abrir en el sitio web',
|
||||
'preview' => 'Previsualizar',
|
||||
'price' => 'Precio',
|
||||
'record' => 'Registro',
|
||||
'restore' => 'Restaurar',
|
||||
'save' => 'Guardar',
|
||||
'save_and_close' => 'Guardar y cerrar',
|
||||
'save_and_return' => 'Guardar y volver',
|
||||
'search' => 'Buscar',
|
||||
'select' => 'Seleccionar',
|
||||
'select_all' => 'Seleccionar todo',
|
||||
'send' => 'Enviar',
|
||||
'settings' => 'Ajustes',
|
||||
'show' => 'Mostrar',
|
||||
'show_all' => 'Mostrar todo',
|
||||
'sign_in' => 'Iniciar sesión',
|
||||
'solve' => 'Resolver',
|
||||
'start' => 'Comenzar',
|
||||
'stop' => 'Detener',
|
||||
'submit' => 'Enviar',
|
||||
'subscribe' => 'Suscribir',
|
||||
'switch' => 'Cambiar',
|
||||
'switch_to_role' => 'Cambiar de rol',
|
||||
'tag' => 'Etiqueta',
|
||||
'tags' => 'Etiquetas',
|
||||
'target_link' => [
|
||||
'blank' => 'Abrir en una ventana nueva',
|
||||
'parent' => 'Abrir en el marco principal',
|
||||
'self' => 'Abrir en la ventana actual',
|
||||
'top' => 'Abrir en el marco superior',
|
||||
],
|
||||
'translate' => 'Traducir',
|
||||
'translate_it' => 'Traducirlo',
|
||||
'unpack' => 'Desglosar',
|
||||
'unsubscribe' => 'Darse de baja',
|
||||
'up' => 'Arriba',
|
||||
'update' => 'Actualizar',
|
||||
'user' => 'Usuario',
|
||||
'view' => 'Ver',
|
||||
'yes' => 'Sí',
|
||||
];
|
||||
9
lang/es/auth.php
Normal file
9
lang/es/auth.php
Normal file
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'failed' => 'Estas credenciales no coinciden con nuestros registros.',
|
||||
'password' => 'La contraseña es incorrecta.',
|
||||
'throttle' => 'Demasiados intentos de acceso. Por favor intente nuevamente en :seconds segundos.',
|
||||
];
|
||||
84
lang/es/http-statuses.php
Normal file
84
lang/es/http-statuses.php
Normal file
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'0' => 'Error desconocido',
|
||||
'100' => 'Continuar',
|
||||
'101' => 'Protocolos de conmutación',
|
||||
'102' => 'Procesando',
|
||||
'200' => 'DE ACUERDO',
|
||||
'201' => 'Creado',
|
||||
'202' => 'Aceptado',
|
||||
'203' => 'Información no autorizada',
|
||||
'204' => 'Sin contenido',
|
||||
'205' => 'Restablecer contenido',
|
||||
'206' => 'Contenido parcial',
|
||||
'207' => 'Multiestado',
|
||||
'208' => 'Ya Reportado',
|
||||
'226' => 'IM usado',
|
||||
'300' => 'Múltiples opciones',
|
||||
'301' => 'Movido permanentemente',
|
||||
'302' => 'Encontrado',
|
||||
'303' => 'Ver otros',
|
||||
'304' => 'No modificado',
|
||||
'305' => 'Usa proxy',
|
||||
'307' => 'Redirección temporal',
|
||||
'308' => 'Redirección permanente',
|
||||
'400' => 'Solicitud incorrecta',
|
||||
'401' => 'No autorizado',
|
||||
'402' => 'Pago requerido',
|
||||
'403' => 'Prohibido',
|
||||
'404' => 'No encontrado',
|
||||
'405' => 'Método no permitido',
|
||||
'406' => 'Inaceptable',
|
||||
'407' => 'Se requiere autenticación proxy',
|
||||
'408' => 'Solicitud de tiempo de espera',
|
||||
'409' => 'Conflicto',
|
||||
'410' => 'Recurso no disponible',
|
||||
'411' => 'Longitud requerida',
|
||||
'412' => 'Error de condición previa',
|
||||
'413' => 'Solicitud demasiado grande',
|
||||
'414' => 'URI demasiado largo',
|
||||
'415' => 'Tipo de medio no admitido',
|
||||
'416' => 'Rango no satisfactorio',
|
||||
'417' => 'Expectativa fallida',
|
||||
'418' => 'Soy una tetera',
|
||||
'419' => 'La sesión ha expirado',
|
||||
'421' => 'Solicitud mal dirigida',
|
||||
'422' => 'Entidad no procesable',
|
||||
'423' => 'Bloqueado',
|
||||
'424' => 'Dependencia fallida',
|
||||
'425' => 'Demasiado temprano',
|
||||
'426' => 'Se requiere actualización',
|
||||
'428' => 'Precondición requerida',
|
||||
'429' => 'Demasiadas solicitudes',
|
||||
'431' => 'Campos de encabezado de solicitud demasiado grandes',
|
||||
'444' => 'Conexión cerrada sin respuesta',
|
||||
'449' => 'Reintentar con',
|
||||
'451' => 'No disponible por razones legales',
|
||||
'499' => 'Solicitud cerrada del cliente',
|
||||
'500' => 'Error interno del servidor',
|
||||
'501' => 'No se ha implementado',
|
||||
'502' => 'Mala puerta de enlace',
|
||||
'503' => 'Modo de mantenimiento',
|
||||
'504' => 'Tiempo de espera de puerta de enlace',
|
||||
'505' => 'Versión HTTP no compatible',
|
||||
'506' => 'Variante También Negocia',
|
||||
'507' => 'Espacio insuficiente',
|
||||
'508' => 'Bucle detectado',
|
||||
'509' => 'Límite de ancho de banda excedido',
|
||||
'510' => 'no extendido',
|
||||
'511' => 'Se requiere autenticación de red',
|
||||
'520' => 'Error desconocido',
|
||||
'521' => 'El servidor web está caído',
|
||||
'522' => 'Tiempo de conexión agotado',
|
||||
'523' => 'El origen es inalcanzable',
|
||||
'524' => 'Se produjo un tiempo de espera',
|
||||
'525' => 'Protocolo de enlace SSL fallido',
|
||||
'526' => 'Certificado SSL no válido',
|
||||
'527' => 'Error de cañón de riel',
|
||||
'598' => 'Error de tiempo de espera de lectura de red',
|
||||
'599' => 'Error de tiempo de espera de conexión de red',
|
||||
'unknownError' => 'Error desconocido',
|
||||
];
|
||||
13
lang/es/messages.php
Normal file
13
lang/es/messages.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'invalid_credentials' => 'Credenciales inválidas.',
|
||||
'account_deactivated' => 'Esta cuenta está desactivada.',
|
||||
'account_pending_approval' => 'Tu cuenta está pendiente de aprobación por un administrador.',
|
||||
'registration_pending' => 'Cuenta creada. Un administrador tiene que aprobarla antes de que puedas ingresar.',
|
||||
'cannot_remove_own_admin_role' => 'No podés quitarte el rol de admin a vos mismo.',
|
||||
'cannot_delete_self' => 'No podés borrar tu propia cuenta.',
|
||||
'cannot_delete_last_admin' => 'No podés borrar al último administrador.',
|
||||
'self_service_requires_user' => 'Este recurso es self-service: necesita un token con usuario asociado (no funciona con client_credentials).',
|
||||
'user_not_trashed' => 'Este usuario no está en la papelera.',
|
||||
];
|
||||
8
lang/es/pagination.php
Normal file
8
lang/es/pagination.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'next' => 'Siguiente »',
|
||||
'previous' => '« Anterior',
|
||||
];
|
||||
11
lang/es/passwords.php
Normal file
11
lang/es/passwords.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'reset' => 'Su contraseña ha sido restablecida.',
|
||||
'sent' => 'Le hemos enviado por correo electrónico el enlace para restablecer su contraseña.',
|
||||
'throttled' => 'Por favor espere antes de intentar de nuevo.',
|
||||
'token' => 'El token de restablecimiento de contraseña es inválido.',
|
||||
'user' => 'No encontramos ningún usuario con ese correo electrónico.',
|
||||
];
|
||||
289
lang/es/validation.php
Normal file
289
lang/es/validation.php
Normal file
@@ -0,0 +1,289 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'accepted' => 'El campo :attribute debe ser aceptado.',
|
||||
'accepted_if' => 'El campo :attribute debe ser aceptado cuando :other sea :value.',
|
||||
'active_url' => 'El campo :attribute debe ser una URL válida.',
|
||||
'after' => 'El campo :attribute debe ser una fecha posterior a :date.',
|
||||
'after_or_equal' => 'El campo :attribute debe ser una fecha posterior o igual a :date.',
|
||||
'alpha' => 'El campo :attribute sólo debe contener letras.',
|
||||
'alpha_dash' => 'El campo :attribute sólo debe contener letras, números, guiones y guiones bajos.',
|
||||
'alpha_num' => 'El campo :attribute sólo debe contener letras y números.',
|
||||
'any_of' => 'El campo :attribute no es válido.',
|
||||
'array' => 'El campo :attribute debe ser un conjunto.',
|
||||
'ascii' => 'El campo :attribute solo debe contener caracteres alfanuméricos y símbolos de un solo byte.',
|
||||
'base64' => 'The :attribute field must be a valid Base64 string.',
|
||||
'before' => 'El campo :attribute debe ser una fecha anterior a :date.',
|
||||
'before_or_equal' => 'El campo :attribute debe ser una fecha anterior o igual a :date.',
|
||||
'between' => [
|
||||
'array' => 'El campo :attribute tiene que tener entre :min - :max elementos.',
|
||||
'file' => 'El campo :attribute debe pesar entre :min - :max kilobytes.',
|
||||
'numeric' => 'El campo :attribute tiene que estar entre :min - :max.',
|
||||
'string' => 'El campo :attribute tiene que tener entre :min - :max caracteres.',
|
||||
],
|
||||
'boolean' => 'El campo :attribute debe tener un valor verdadero o falso.',
|
||||
'can' => 'El campo :attribute contiene un valor no autorizado.',
|
||||
'confirmed' => 'La confirmación de :attribute no coincide.',
|
||||
'contains' => 'Al campo :attribute le falta un valor obligatorio.',
|
||||
'current_password' => 'La contraseña es incorrecta.',
|
||||
'date' => 'El campo :attribute debe ser una fecha válida.',
|
||||
'date_equals' => 'El campo :attribute debe ser una fecha igual a :date.',
|
||||
'date_format' => 'El campo :attribute debe coincidir con el formato :format.',
|
||||
'decimal' => 'El campo :attribute debe tener :decimal cifras decimales.',
|
||||
'declined' => 'El campo :attribute debe ser rechazado.',
|
||||
'declined_if' => 'El campo :attribute debe ser rechazado cuando :other sea :value.',
|
||||
'different' => 'El campo :attribute y :other deben ser diferentes.',
|
||||
'digits' => 'El campo :attribute debe tener :digits dígitos.',
|
||||
'digits_between' => 'El campo :attribute debe tener entre :min y :max dígitos.',
|
||||
'dimensions' => 'El campo :attribute tiene dimensiones de imagen no válidas.',
|
||||
'distinct' => 'El campo :attribute contiene un valor duplicado.',
|
||||
'doesnt_contain' => 'El campo :attribute no debe contener ninguno de los siguientes valores: :values.',
|
||||
'doesnt_end_with' => 'El campo :attribute no debe finalizar con uno de los siguientes: :values.',
|
||||
'doesnt_start_with' => 'El campo :attribute no debe comenzar con uno de los siguientes: :values.',
|
||||
'email' => 'El campo :attribute no es un correo válido.',
|
||||
'encoding' => 'El campo :attribute debe estar codificado en :encoding.',
|
||||
'ends_with' => 'El campo :attribute debe finalizar con uno de los siguientes valores: :values',
|
||||
'enum' => 'El campo :attribute no está en la lista de valores permitidos.',
|
||||
'exists' => 'El campo :attribute no existe.',
|
||||
'extensions' => 'El campo :attribute debe tener una de las siguientes extensiones: :values.',
|
||||
'file' => 'El campo :attribute debe ser un archivo.',
|
||||
'filled' => 'El campo :attribute es obligatorio.',
|
||||
'gt' => [
|
||||
'array' => 'El campo :attribute debe tener más de :value elementos.',
|
||||
'file' => 'El campo :attribute debe tener más de :value kilobytes.',
|
||||
'numeric' => 'El campo :attribute debe ser mayor que :value.',
|
||||
'string' => 'El campo :attribute debe tener más de :value caracteres.',
|
||||
],
|
||||
'gte' => [
|
||||
'array' => 'El campo :attribute debe tener como mínimo :value elementos.',
|
||||
'file' => 'El campo :attribute debe tener como mínimo :value kilobytes.',
|
||||
'numeric' => 'El campo :attribute debe ser como mínimo :value.',
|
||||
'string' => 'El campo :attribute debe tener como mínimo :value caracteres.',
|
||||
],
|
||||
'hex_color' => 'El campo :attribute debe tener un color hexadecimal válido.',
|
||||
'image' => 'El campo :attribute debe ser una imagen.',
|
||||
'in' => 'El campo :attribute no está en la lista de valores permitidos.',
|
||||
'in_array' => 'El campo :attribute debe existir en :other.',
|
||||
'in_array_keys' => 'El campo :attribute debe contener al menos una de las siguientes claves: :values.',
|
||||
'integer' => 'El campo :attribute debe ser un número entero.',
|
||||
'ip' => 'El campo :attribute debe ser una dirección IP válida.',
|
||||
'ipv4' => 'El campo :attribute debe ser una dirección IPv4 válida.',
|
||||
'ipv6' => 'El campo :attribute debe ser una dirección IPv6 válida.',
|
||||
'json' => 'El campo :attribute debe ser una cadena JSON válida.',
|
||||
'list' => 'El campo :attribute debe ser una lista.',
|
||||
'lowercase' => 'El campo :attribute debe estar en minúscula.',
|
||||
'lt' => [
|
||||
'array' => 'El campo :attribute debe tener menos de :value elementos.',
|
||||
'file' => 'El campo :attribute debe tener menos de :value kilobytes.',
|
||||
'numeric' => 'El campo :attribute debe ser menor que :value.',
|
||||
'string' => 'El campo :attribute debe tener menos de :value caracteres.',
|
||||
],
|
||||
'lte' => [
|
||||
'array' => 'El campo :attribute debe tener como máximo :value elementos.',
|
||||
'file' => 'El campo :attribute debe tener como máximo :value kilobytes.',
|
||||
'numeric' => 'El campo :attribute debe ser como máximo :value.',
|
||||
'string' => 'El campo :attribute debe tener como máximo :value caracteres.',
|
||||
],
|
||||
'mac_address' => 'El campo :attribute debe ser una dirección MAC válida.',
|
||||
'max' => [
|
||||
'array' => 'El campo :attribute no debe tener más de :max elementos.',
|
||||
'file' => 'El campo :attribute no debe ser mayor que :max kilobytes.',
|
||||
'numeric' => 'El campo :attribute no debe ser mayor que :max.',
|
||||
'string' => 'El campo :attribute no debe ser mayor que :max caracteres.',
|
||||
],
|
||||
'max_digits' => 'El campo :attribute no debe tener más de :max dígitos.',
|
||||
'mimes' => 'El campo :attribute debe ser un archivo con formato: :values.',
|
||||
'mimetypes' => 'El campo :attribute debe ser un archivo con formato: :values.',
|
||||
'min' => [
|
||||
'array' => 'El campo :attribute debe tener al menos :min elementos.',
|
||||
'file' => 'El tamaño de :attribute debe ser de al menos :min kilobytes.',
|
||||
'numeric' => 'El tamaño de :attribute debe ser de al menos :min.',
|
||||
'string' => 'El campo :attribute debe contener al menos :min caracteres.',
|
||||
],
|
||||
'min_digits' => 'El campo :attribute debe tener al menos :min dígitos.',
|
||||
'missing' => 'El campo :attribute no debe estar presente.',
|
||||
'missing_if' => 'El campo :attribute no debe estar presente cuando :other sea :value.',
|
||||
'missing_unless' => 'El campo :attribute no debe estar presente a menos que :other sea :value.',
|
||||
'missing_with' => 'El campo :attribute no debe estar presente si alguno de los campos :values está presente.',
|
||||
'missing_with_all' => 'El campo :attribute no debe estar presente cuando los campos :values estén presentes.',
|
||||
'multiple_of' => 'El campo :attribute debe ser múltiplo de :value',
|
||||
'not_in' => 'El campo :attribute no debe estar en la lista.',
|
||||
'not_regex' => 'El formato del campo :attribute no es válido.',
|
||||
'numeric' => 'El campo :attribute debe ser numérico.',
|
||||
'password' => [
|
||||
'letters' => 'La :attribute debe contener al menos una letra.',
|
||||
'mixed' => 'La :attribute debe contener al menos una letra mayúscula y una minúscula.',
|
||||
'numbers' => 'La :attribute debe contener al menos un número.',
|
||||
'symbols' => 'La :attribute debe contener al menos un símbolo.',
|
||||
'uncompromised' => 'La :attribute proporcionada se ha visto comprometida en una filtración de datos (data leak). Elija una :attribute diferente.',
|
||||
],
|
||||
'present' => 'El campo :attribute debe estar presente.',
|
||||
'present_if' => 'El campo :attribute debe estar presente cuando :other es :value.',
|
||||
'present_unless' => 'El campo :attribute debe estar presente a menos que :other sea :value.',
|
||||
'present_with' => 'El campo :attribute debe estar presente cuando :values esté presente.',
|
||||
'present_with_all' => 'El campo :attribute debe estar presente cuando :values estén presentes.',
|
||||
'prohibited' => 'El campo :attribute está prohibido.',
|
||||
'prohibited_if' => 'El campo :attribute está prohibido cuando :other es :value.',
|
||||
'prohibited_if_accepted' => 'El campo :attribute está prohibido cuando se acepta :other.',
|
||||
'prohibited_if_declined' => 'El campo :attribute está prohibido cuando se rechaza :other.',
|
||||
'prohibited_unless' => 'El campo :attribute está prohibido a menos que :other sea :values.',
|
||||
'prohibits' => 'El campo :attribute prohibe que :other esté presente.',
|
||||
'regex' => 'El formato del campo :attribute no es válido.',
|
||||
'required' => 'El campo :attribute es obligatorio.',
|
||||
'required_array_keys' => 'El campo :attribute debe contener entradas para: :values.',
|
||||
'required_if' => 'El campo :attribute es obligatorio cuando :other es :value.',
|
||||
'required_if_accepted' => 'El campo :attribute es obligatorio si :other es aceptado.',
|
||||
'required_if_declined' => 'El campo :attribute es obligatorio si :other es rechazado.',
|
||||
'required_unless' => 'El campo :attribute es obligatorio a menos que :other esté en :values.',
|
||||
'required_with' => 'El campo :attribute es obligatorio cuando :values está presente.',
|
||||
'required_with_all' => 'El campo :attribute es obligatorio cuando :values están presentes.',
|
||||
'required_without' => 'El campo :attribute es obligatorio cuando :values no está presente.',
|
||||
'required_without_all' => 'El campo :attribute es obligatorio cuando ninguno de :values está presente.',
|
||||
'same' => 'Los campos :attribute y :other deben coincidir.',
|
||||
'size' => [
|
||||
'array' => 'El campo :attribute debe contener :size elementos.',
|
||||
'file' => 'El tamaño de :attribute debe ser :size kilobytes.',
|
||||
'numeric' => 'El tamaño de :attribute debe ser :size.',
|
||||
'string' => 'El campo :attribute debe contener :size caracteres.',
|
||||
],
|
||||
'starts_with' => 'El campo :attribute debe comenzar con uno de los siguientes valores: :values',
|
||||
'string' => 'El campo :attribute debe ser una cadena de caracteres.',
|
||||
'timezone' => 'El campo :attribute debe ser una zona horaria válida.',
|
||||
'ulid' => 'El campo :attribute debe ser un ULID válido.',
|
||||
'unique' => 'El campo :attribute ya ha sido registrado.',
|
||||
'uploaded' => 'Subir :attribute ha fallado.',
|
||||
'uppercase' => 'El campo :attribute debe estar en mayúscula.',
|
||||
'url' => 'El campo :attribute debe ser una URL válida.',
|
||||
'uuid' => 'El campo :attribute debe ser un UUID válido.',
|
||||
'attributes' => [
|
||||
'address' => 'dirección',
|
||||
'affiliate_url' => 'URL de afiliado',
|
||||
'age' => 'edad',
|
||||
'amount' => 'cantidad',
|
||||
'announcement' => 'anuncio',
|
||||
'area' => 'área',
|
||||
'audience_prize' => 'premio del público',
|
||||
'audience_winner' => 'ganador del público',
|
||||
'available' => 'disponible',
|
||||
'birthday' => 'cumpleaños',
|
||||
'body' => 'contenido',
|
||||
'city' => 'ciudad',
|
||||
'color' => 'color',
|
||||
'company' => 'compañía',
|
||||
'compilation' => 'compilación',
|
||||
'concept' => 'concepto',
|
||||
'conditions' => 'condiciones',
|
||||
'content' => 'contenido',
|
||||
'contest' => 'concurso',
|
||||
'country' => 'país',
|
||||
'cover' => 'portada',
|
||||
'created_at' => 'creado el',
|
||||
'creator' => 'creador',
|
||||
'currency' => 'moneda',
|
||||
'current_password' => 'contraseña actual',
|
||||
'customer' => 'cliente',
|
||||
'date' => 'fecha',
|
||||
'date_of_birth' => 'fecha de nacimiento',
|
||||
'dates' => 'fechas',
|
||||
'day' => 'día',
|
||||
'deleted_at' => 'eliminado el',
|
||||
'description' => 'descripción',
|
||||
'display_type' => 'tipo de visualización',
|
||||
'district' => 'distrito',
|
||||
'duration' => 'duración',
|
||||
'email' => 'correo electrónico',
|
||||
'excerpt' => 'extracto',
|
||||
'filter' => 'filtro',
|
||||
'finished_at' => 'terminado el',
|
||||
'first_name' => 'nombre',
|
||||
'gender' => 'género',
|
||||
'grand_prize' => 'gran Premio',
|
||||
'group' => 'grupo',
|
||||
'hour' => 'hora',
|
||||
'image' => 'imagen',
|
||||
'image_desktop' => 'imagen de escritorio',
|
||||
'image_main' => 'imagen principal',
|
||||
'image_mobile' => 'imagen móvil',
|
||||
'images' => 'imágenes',
|
||||
'is_audience_winner' => 'es ganador de audiencia',
|
||||
'is_hidden' => 'está oculto',
|
||||
'is_subscribed' => 'está suscrito',
|
||||
'is_visible' => 'es visible',
|
||||
'is_winner' => 'es ganador',
|
||||
'items' => 'elementos',
|
||||
'key' => 'clave',
|
||||
'last_name' => 'apellidos',
|
||||
'lesson' => 'lección',
|
||||
'line_address_1' => 'línea de dirección 1',
|
||||
'line_address_2' => 'línea de dirección 2',
|
||||
'login' => 'acceso',
|
||||
'message' => 'mensaje',
|
||||
'middle_name' => 'segundo nombre',
|
||||
'minute' => 'minuto',
|
||||
'mobile' => 'móvil',
|
||||
'month' => 'mes',
|
||||
'name' => 'nombre',
|
||||
'national_code' => 'código nacional',
|
||||
'number' => 'número',
|
||||
'password' => 'contraseña',
|
||||
'password_confirmation' => 'confirmación de la contraseña',
|
||||
'phone' => 'teléfono',
|
||||
'photo' => 'foto',
|
||||
'portfolio' => 'portafolio',
|
||||
'postal_code' => 'código postal',
|
||||
'preview' => 'vista preliminar',
|
||||
'price' => 'precio',
|
||||
'product_id' => 'ID del producto',
|
||||
'product_uid' => 'UID del producto',
|
||||
'product_uuid' => 'UUID del producto',
|
||||
'promo_code' => 'código promocional',
|
||||
'province' => 'provincia',
|
||||
'quantity' => 'cantidad',
|
||||
'reason' => 'razón',
|
||||
'recaptcha_response_field' => 'respuesta del recaptcha',
|
||||
'referee' => 'árbitro',
|
||||
'referees' => 'árbitros',
|
||||
'region' => 'región',
|
||||
'reject_reason' => 'motivo de rechazo',
|
||||
'remember' => 'recordar',
|
||||
'restored_at' => 'restaurado el',
|
||||
'result_text_under_image' => 'texto bajo la imagen',
|
||||
'role' => 'rol',
|
||||
'rule' => 'regla',
|
||||
'rules' => 'reglas',
|
||||
'second' => 'segundo',
|
||||
'sex' => 'sexo',
|
||||
'shipment' => 'envío',
|
||||
'short_text' => 'texto corto',
|
||||
'size' => 'tamaño',
|
||||
'skills' => 'habilidades',
|
||||
'slug' => 'slug',
|
||||
'specialization' => 'especialización',
|
||||
'started_at' => 'comenzado el',
|
||||
'state' => 'estado',
|
||||
'status' => 'estado',
|
||||
'street' => 'calle',
|
||||
'student' => 'estudiante',
|
||||
'subject' => 'asunto',
|
||||
'tag' => 'etiqueta',
|
||||
'tags' => 'etiquetas',
|
||||
'teacher' => 'profesor',
|
||||
'terms' => 'términos',
|
||||
'test_description' => 'descripción de prueba',
|
||||
'test_locale' => 'idioma de prueba',
|
||||
'test_name' => 'nombre de prueba',
|
||||
'text' => 'texto',
|
||||
'time' => 'hora',
|
||||
'title' => 'título',
|
||||
'type' => 'tipo',
|
||||
'updated_at' => 'actualizado el',
|
||||
'user' => 'usuario',
|
||||
'username' => 'usuario',
|
||||
'value' => 'valor',
|
||||
'winner' => 'ganador',
|
||||
'work' => 'trabajo',
|
||||
'year' => 'año',
|
||||
],
|
||||
];
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user