Compare commits
19 Commits
339fb5e232
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 596588241b | |||
| a56aca0050 | |||
| 2351718939 | |||
| dd1e3d9053 | |||
| 4ea87c0cf7 | |||
| a096704b0b | |||
| 1b9bf0efac | |||
| 10f612a901 | |||
| 44c1cbe5f6 | |||
| da97c45dd4 | |||
| e18062864d | |||
| edc1da3e9f | |||
| 86f6af1012 | |||
| 3a481f4258 | |||
| 3d9a2f8778 | |||
| 5de7151b08 | |||
| e311fa5b7e | |||
| 97d8a2b4c2 | |||
| dd83cd33c1 |
33
.env.example
33
.env.example
@@ -47,10 +47,10 @@ REDIS_HOST=127.0.0.1
|
|||||||
REDIS_PASSWORD=null
|
REDIS_PASSWORD=null
|
||||||
REDIS_PORT=6379
|
REDIS_PORT=6379
|
||||||
|
|
||||||
MAIL_MAILER=log
|
MAIL_MAILER=smtp
|
||||||
MAIL_SCHEME=null
|
MAIL_SCHEME=null
|
||||||
MAIL_HOST=127.0.0.1
|
MAIL_HOST=mailpit
|
||||||
MAIL_PORT=2525
|
MAIL_PORT=1025
|
||||||
MAIL_USERNAME=null
|
MAIL_USERNAME=null
|
||||||
MAIL_PASSWORD=null
|
MAIL_PASSWORD=null
|
||||||
MAIL_FROM_ADDRESS="hello@example.com"
|
MAIL_FROM_ADDRESS="hello@example.com"
|
||||||
@@ -62,4 +62,31 @@ AWS_DEFAULT_REGION=us-east-1
|
|||||||
AWS_BUCKET=
|
AWS_BUCKET=
|
||||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||||
|
|
||||||
|
# Auth Features
|
||||||
|
AUTH_ENABLE_REGISTRATION=true
|
||||||
|
AUTH_ENABLE_PASSWORD_RESET=true
|
||||||
|
AUTH_ENABLE_REMEMBER_ME=true
|
||||||
|
AUTH_ENABLE_EMAIL_VERIFICATION=false
|
||||||
|
|
||||||
|
# Auth Redirects
|
||||||
|
# AUTH_REDIRECT_LOGIN=/dashboard
|
||||||
|
# AUTH_REDIRECT_LOGOUT=/
|
||||||
|
# AUTH_REDIRECT_REGISTER=/dashboard
|
||||||
|
|
||||||
|
# Social Login (uncomment and configure providers in config/auth-ui.php)
|
||||||
|
# AUTH_GITHUB_ENABLED=false
|
||||||
|
# GITHUB_CLIENT_ID=
|
||||||
|
# GITHUB_CLIENT_SECRET=
|
||||||
|
# GITHUB_REDIRECT_URI=${APP_URL}/auth/github/callback
|
||||||
|
|
||||||
|
# AUTH_GOOGLE_ENABLED=false
|
||||||
|
# GOOGLE_CLIENT_ID=
|
||||||
|
# GOOGLE_CLIENT_SECRET=
|
||||||
|
# GOOGLE_REDIRECT_URI=${APP_URL}/auth/google/callback
|
||||||
|
|
||||||
|
# Legal
|
||||||
|
# AUTH_TERMS_URL=
|
||||||
|
# AUTH_PRIVACY_URL=
|
||||||
|
# AUTH_SHOW_LEGAL_IN_REGISTER=true
|
||||||
|
|
||||||
VITE_APP_NAME="${APP_NAME}"
|
VITE_APP_NAME="${APP_NAME}"
|
||||||
|
|||||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -23,6 +23,14 @@ Homestead.json
|
|||||||
Homestead.yaml
|
Homestead.yaml
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# Claude Code
|
||||||
|
/.claude/
|
||||||
|
CLAUDE.md
|
||||||
|
|
||||||
|
# Laravel Boost
|
||||||
|
.mcp.json
|
||||||
|
boost.json
|
||||||
|
|
||||||
# Nuxt UI auto-generated type declarations
|
# Nuxt UI auto-generated type declarations
|
||||||
auto-imports.d.ts
|
auto-imports.d.ts
|
||||||
components.d.ts
|
components.d.ts
|
||||||
|
|||||||
154
README.md
154
README.md
@@ -1,59 +1,135 @@
|
|||||||
<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>
|
# Laravel + Nuxt UI + Inertia Template
|
||||||
|
|
||||||
<p align="center">
|
A starter template built with Laravel 13, Inertia.js v3, Vue 3, Nuxt UI, and Tailwind CSS v4.
|
||||||
<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
|
## Stack
|
||||||
|
|
||||||
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:
|
- **Backend:** Laravel 13, PHP 8.5
|
||||||
|
- **Frontend:** Vue 3, Inertia.js v3, TypeScript
|
||||||
|
- **UI:** Nuxt UI v4 (Dashboard components), Tailwind CSS v4
|
||||||
|
- **Validation:** Valibot (frontend), Form Requests (backend)
|
||||||
|
- **Database:** PostgreSQL
|
||||||
|
- **Testing:** Pest v4, Vitest
|
||||||
|
- **Dev Environment:** Laravel Sail (Docker)
|
||||||
|
|
||||||
- [Simple, fast routing engine](https://laravel.com/docs/routing).
|
## Features
|
||||||
- [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.
|
- **Authentication** — Login (email or username), register, forgot/reset password
|
||||||
|
- **Social Login** — OAuth via Laravel Socialite with provider tracking (`social_accounts` table)
|
||||||
|
- **Email Verification** — Optional, toggle via `AUTH_ENABLE_EMAIL_VERIFICATION`
|
||||||
|
- **Dashboard** — Protected page using Nuxt UI dashboard components with `auth` + `verified` middleware
|
||||||
|
- **Security** — Nullable passwords for social-only users, verified-email requirement for provider linking, rate limiting on all auth endpoints
|
||||||
|
- **Code Quality** — ESLint, Laravel Pint, shared Valibot validation schemas
|
||||||
|
|
||||||
## Learning Laravel
|
## Getting Started
|
||||||
|
|
||||||
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. You can also check out [Laravel Learn](https://laravel.com/learn), where you will be guided through building a modern Laravel application.
|
```bash
|
||||||
|
# Clone and install
|
||||||
|
composer install
|
||||||
|
cp .env.example .env
|
||||||
|
|
||||||
If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts 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.
|
# Start Sail
|
||||||
|
vendor/bin/sail up -d
|
||||||
|
|
||||||
## Laravel Sponsors
|
# Setup
|
||||||
|
vendor/bin/sail artisan key:generate
|
||||||
|
vendor/bin/sail artisan migrate
|
||||||
|
vendor/bin/sail npm install
|
||||||
|
vendor/bin/sail npm run build
|
||||||
|
```
|
||||||
|
|
||||||
We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the [Laravel Partners program](https://partners.laravel.com).
|
## Development
|
||||||
|
|
||||||
### Premium Partners
|
```bash
|
||||||
|
# Start Sail (server, database, etc.)
|
||||||
|
vendor/bin/sail up -d
|
||||||
|
|
||||||
- **[Vehikl](https://vehikl.com)**
|
# Start Vite dev server
|
||||||
- **[Tighten Co.](https://tighten.co)**
|
vendor/bin/sail npm run dev
|
||||||
- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)**
|
```
|
||||||
- **[64 Robots](https://64robots.com)**
|
|
||||||
- **[Curotec](https://www.curotec.com/services/technologies/laravel)**
|
|
||||||
- **[DevSquad](https://devsquad.com/hire-laravel-developers)**
|
|
||||||
- **[Redberry](https://redberry.international/laravel-development)**
|
|
||||||
- **[Active Logic](https://activelogic.com)**
|
|
||||||
|
|
||||||
## Contributing
|
Mailpit is available at `localhost:8025` for viewing emails locally.
|
||||||
|
|
||||||
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
|
## Testing
|
||||||
|
|
||||||
## Code of Conduct
|
```bash
|
||||||
|
# PHP tests (Pest)
|
||||||
|
vendor/bin/sail artisan test
|
||||||
|
|
||||||
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).
|
# JavaScript tests (Vitest)
|
||||||
|
vendor/bin/sail npm run test
|
||||||
|
|
||||||
## Security Vulnerabilities
|
# Lint
|
||||||
|
vendor/bin/sail npm run lint
|
||||||
|
vendor/bin/sail bin pint
|
||||||
|
```
|
||||||
|
|
||||||
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.
|
## Auth Configuration
|
||||||
|
|
||||||
## License
|
Feature flags and redirects are configured via environment variables in `.env`:
|
||||||
|
|
||||||
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
|
```env
|
||||||
|
AUTH_ENABLE_REGISTRATION=true
|
||||||
|
AUTH_ENABLE_PASSWORD_RESET=true
|
||||||
|
AUTH_ENABLE_REMEMBER_ME=true
|
||||||
|
AUTH_ENABLE_EMAIL_VERIFICATION=false
|
||||||
|
|
||||||
|
AUTH_REDIRECT_LOGIN=/dashboard
|
||||||
|
AUTH_REDIRECT_LOGOUT=/
|
||||||
|
AUTH_REDIRECT_REGISTER=/dashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
When `AUTH_ENABLE_EMAIL_VERIFICATION=true`:
|
||||||
|
- New users receive a verification email after registration
|
||||||
|
- Unverified users are redirected to the verification page
|
||||||
|
- Social login users are auto-verified (trusted from provider)
|
||||||
|
- Social accounts can only be linked to users with verified emails
|
||||||
|
|
||||||
|
See `config/auth-ui.php` for all available options including page titles, icons, legal links, and social provider configuration.
|
||||||
|
|
||||||
|
## Social Login Setup
|
||||||
|
|
||||||
|
1. Uncomment a provider in `config/auth-ui.php`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
'providers' => [
|
||||||
|
'github' => [
|
||||||
|
'label' => 'GitHub',
|
||||||
|
'icon' => 'i-simple-icons-github',
|
||||||
|
'enabled' => env('AUTH_GITHUB_ENABLED', false),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Add the provider credentials in `config/services.php`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
'github' => [
|
||||||
|
'client_id' => env('GITHUB_CLIENT_ID'),
|
||||||
|
'client_secret' => env('GITHUB_CLIENT_SECRET'),
|
||||||
|
'redirect' => env('GITHUB_REDIRECT_URI'),
|
||||||
|
],
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Set the environment variables in `.env`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
AUTH_GITHUB_ENABLED=true
|
||||||
|
GITHUB_CLIENT_ID=your-client-id
|
||||||
|
GITHUB_CLIENT_SECRET=your-client-secret
|
||||||
|
GITHUB_REDIRECT_URI=${APP_URL}/auth/github/callback
|
||||||
|
```
|
||||||
|
|
||||||
|
Built-in providers (no extra packages needed): GitHub, Google, Facebook, X, LinkedIn, GitLab, Bitbucket, Slack.
|
||||||
|
|
||||||
|
For other providers (Discord, Apple, etc.), install the community package first:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vendor/bin/sail composer require socialiteproviders/discord
|
||||||
|
```
|
||||||
|
|
||||||
|
Then register the event listener as described in the [Socialite Providers](https://socialiteproviders.com/) docs.
|
||||||
|
|
||||||
|
## AI Setup
|
||||||
|
|
||||||
|
If you use an AI coding assistant, run `vendor/bin/sail artisan boost:install` to generate the configuration files. Otherwise, you can remove the package with `vendor/bin/sail composer remove laravel/boost`.
|
||||||
|
|||||||
@@ -3,13 +3,12 @@
|
|||||||
namespace App\Http\Controllers\Auth;
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Auth\CompleteProfileRequest;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Auth\Events\Registered;
|
use Illuminate\Auth\Events\Registered;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Hash;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Inertia\Response;
|
use Inertia\Response;
|
||||||
|
|
||||||
@@ -34,35 +33,23 @@ class CompleteProfileController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Handle the complete profile request.
|
* Handle the complete profile request.
|
||||||
*/
|
*/
|
||||||
public function store(Request $request): RedirectResponse
|
public function store(CompleteProfileRequest $request): RedirectResponse
|
||||||
{
|
{
|
||||||
$socialiteUser = session('socialite_user');
|
$socialiteUser = session('socialite_user');
|
||||||
|
$validated = $request->validated();
|
||||||
if (! $socialiteUser) {
|
|
||||||
return redirect()->route('login');
|
|
||||||
}
|
|
||||||
|
|
||||||
$request->validate([
|
|
||||||
'username' => [
|
|
||||||
'required', 'string', 'max:255', 'alpha_dash',
|
|
||||||
function ($attribute, $value, $fail) {
|
|
||||||
$exists = User::whereRaw('LOWER(username) = ?', [strtolower($value)])->exists();
|
|
||||||
if ($exists) {
|
|
||||||
$fail('The username has already been taken.');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
],
|
|
||||||
'first_name' => ['required', 'string', 'max:255'],
|
|
||||||
'last_name' => ['required', 'string', 'max:255'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$user = User::create([
|
$user = User::create([
|
||||||
'username' => $request->username,
|
'username' => $validated['username'],
|
||||||
'first_name' => $request->first_name,
|
'first_name' => $validated['first_name'],
|
||||||
'last_name' => $request->last_name,
|
'last_name' => $validated['last_name'],
|
||||||
'email' => $socialiteUser['email'],
|
'email' => $socialiteUser['email'],
|
||||||
'password' => Hash::make(Str::random(24)),
|
]);
|
||||||
'email_verified_at' => now(),
|
|
||||||
|
$user->forceFill(['email_verified_at' => now()])->save();
|
||||||
|
|
||||||
|
$user->socialAccounts()->create([
|
||||||
|
'provider' => $socialiteUser['provider'],
|
||||||
|
'provider_id' => $socialiteUser['provider_id'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
session()->forget('socialite_user');
|
session()->forget('socialite_user');
|
||||||
|
|||||||
56
app/Http/Controllers/Auth/EmailVerificationController.php
Normal file
56
app/Http/Controllers/Auth/EmailVerificationController.php
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Foundation\Auth\EmailVerificationRequest;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class EmailVerificationController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
if (! config('auth-ui.features.email_verification')) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display the email verification notice.
|
||||||
|
*/
|
||||||
|
public function notice(Request $request): Response|RedirectResponse
|
||||||
|
{
|
||||||
|
if ($request->user()->hasVerifiedEmail()) {
|
||||||
|
return redirect()->intended(config('auth-ui.redirects.login', '/'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Inertia::render('Auth/VerifyEmail');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle the email verification.
|
||||||
|
*/
|
||||||
|
public function verify(EmailVerificationRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$request->fulfill();
|
||||||
|
|
||||||
|
return redirect()->intended(config('auth-ui.redirects.login', '/'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resend the email verification notification.
|
||||||
|
*/
|
||||||
|
public function resend(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
if ($request->user()->hasVerifiedEmail()) {
|
||||||
|
return redirect()->intended(config('auth-ui.redirects.login', '/'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$request->user()->sendEmailVerificationNotification();
|
||||||
|
|
||||||
|
return back()->with('status', 'verification-link-sent');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,8 +3,8 @@
|
|||||||
namespace App\Http\Controllers\Auth;
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Auth\ForgotPasswordRequest;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Support\Facades\Password;
|
use Illuminate\Support\Facades\Password;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Inertia\Response;
|
use Inertia\Response;
|
||||||
@@ -26,18 +26,10 @@ class ForgotPasswordController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Handle an incoming password reset link request.
|
* Handle an incoming password reset link request.
|
||||||
*/
|
*/
|
||||||
public function store(Request $request): RedirectResponse
|
public function store(ForgotPasswordRequest $request): RedirectResponse
|
||||||
{
|
{
|
||||||
if (! config('auth-ui.features.password_reset')) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$request->validate([
|
|
||||||
'email' => ['required', 'email'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$status = Password::sendResetLink(
|
$status = Password::sendResetLink(
|
||||||
$request->only('email')
|
$request->validated()
|
||||||
);
|
);
|
||||||
|
|
||||||
if ($status === Password::RESET_LINK_SENT) {
|
if ($status === Password::RESET_LINK_SENT) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Http\Controllers\Auth;
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Auth\LoginRequest;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@@ -24,26 +25,21 @@ class LoginController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Handle an incoming authentication request.
|
* Handle an incoming authentication request.
|
||||||
*/
|
*/
|
||||||
public function store(Request $request): RedirectResponse
|
public function store(LoginRequest $request): RedirectResponse
|
||||||
{
|
{
|
||||||
$request->validate([
|
$login = $request->validated('login');
|
||||||
'login' => ['required', 'string'],
|
$password = $request->validated('password');
|
||||||
'password' => ['required', 'string'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$login = $request->input('login');
|
|
||||||
$password = $request->input('password');
|
|
||||||
|
|
||||||
$isEmail = filter_var($login, FILTER_VALIDATE_EMAIL);
|
$isEmail = filter_var($login, FILTER_VALIDATE_EMAIL);
|
||||||
$user = $isEmail
|
$credentials = $isEmail
|
||||||
? User::where('email', $login)->first()
|
? ['email' => $login, 'password' => $password]
|
||||||
: User::whereRaw('LOWER(username) = ?', [strtolower($login)])->first();
|
: ['email' => User::whereRaw('LOWER(username) = ?', [strtolower($login)])->value('email'), 'password' => $password];
|
||||||
|
|
||||||
$remember = config('auth-ui.features.remember_me')
|
$remember = config('auth-ui.features.remember_me')
|
||||||
? $request->boolean('remember')
|
? $request->boolean('remember')
|
||||||
: false;
|
: false;
|
||||||
|
|
||||||
if (! $user || ! Auth::attempt(['email' => $user->email, 'password' => $password], $remember)) {
|
if (! $credentials['email'] || ! Auth::attempt($credentials, $remember)) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'login' => __('auth.failed'),
|
'login' => __('auth.failed'),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -3,13 +3,11 @@
|
|||||||
namespace App\Http\Controllers\Auth;
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Auth\RegisterRequest;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Auth\Events\Registered;
|
use Illuminate\Auth\Events\Registered;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Hash;
|
|
||||||
use Illuminate\Validation\Rules;
|
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Inertia\Response;
|
use Inertia\Response;
|
||||||
|
|
||||||
@@ -30,40 +28,26 @@ class RegisterController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Handle an incoming registration request.
|
* Handle an incoming registration request.
|
||||||
*/
|
*/
|
||||||
public function store(Request $request): RedirectResponse
|
public function store(RegisterRequest $request): RedirectResponse
|
||||||
{
|
{
|
||||||
if (! config('auth-ui.features.registration')) {
|
$validated = $request->validated();
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$request->validate([
|
|
||||||
'username' => [
|
|
||||||
'required', 'string', 'max:255', 'alpha_dash',
|
|
||||||
function ($attribute, $value, $fail) {
|
|
||||||
$exists = User::whereRaw('LOWER(username) = ?', [strtolower($value)])->exists();
|
|
||||||
if ($exists) {
|
|
||||||
$fail('The username has already been taken.');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
],
|
|
||||||
'first_name' => ['required', 'string', 'max:255'],
|
|
||||||
'last_name' => ['required', 'string', 'max:255'],
|
|
||||||
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class],
|
|
||||||
'password' => ['required', 'confirmed', Rules\Password::defaults()],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$user = User::create([
|
$user = User::create([
|
||||||
'username' => $request->username,
|
'username' => $validated['username'],
|
||||||
'first_name' => $request->first_name,
|
'first_name' => $validated['first_name'],
|
||||||
'last_name' => $request->last_name,
|
'last_name' => $validated['last_name'],
|
||||||
'email' => $request->email,
|
'email' => $validated['email'],
|
||||||
'password' => Hash::make($request->password),
|
'password' => $validated['password'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
event(new Registered($user));
|
event(new Registered($user));
|
||||||
|
|
||||||
Auth::login($user);
|
Auth::login($user);
|
||||||
|
|
||||||
|
if (config('auth-ui.features.email_verification')) {
|
||||||
|
return redirect()->route('verification.notice');
|
||||||
|
}
|
||||||
|
|
||||||
return redirect(config('auth-ui.redirects.register', '/'));
|
return redirect(config('auth-ui.redirects.register', '/'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,12 @@
|
|||||||
namespace App\Http\Controllers\Auth;
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Auth\ResetPasswordRequest;
|
||||||
use Illuminate\Auth\Events\PasswordReset;
|
use Illuminate\Auth\Events\PasswordReset;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Hash;
|
|
||||||
use Illuminate\Support\Facades\Password;
|
use Illuminate\Support\Facades\Password;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Illuminate\Validation\Rules;
|
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Inertia\Response;
|
use Inertia\Response;
|
||||||
|
|
||||||
@@ -33,23 +32,13 @@ class ResetPasswordController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Handle an incoming new password request.
|
* Handle an incoming new password request.
|
||||||
*/
|
*/
|
||||||
public function store(Request $request): RedirectResponse
|
public function store(ResetPasswordRequest $request): RedirectResponse
|
||||||
{
|
{
|
||||||
if (! config('auth-ui.features.password_reset')) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$request->validate([
|
|
||||||
'token' => ['required'],
|
|
||||||
'email' => ['required', 'email'],
|
|
||||||
'password' => ['required', 'confirmed', Rules\Password::defaults()],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$status = Password::reset(
|
$status = Password::reset(
|
||||||
$request->only('email', 'password', 'password_confirmation', 'token'),
|
$request->validated(),
|
||||||
function ($user) use ($request) {
|
function ($user, string $password): void {
|
||||||
$user->forceFill([
|
$user->forceFill([
|
||||||
'password' => Hash::make($request->password),
|
'password' => $password,
|
||||||
'remember_token' => Str::random(60),
|
'remember_token' => Str::random(60),
|
||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,10 @@
|
|||||||
namespace App\Http\Controllers\Auth;
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\SocialAccount;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Hash;
|
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Laravel\Socialite\Facades\Socialite;
|
use Laravel\Socialite\Facades\Socialite;
|
||||||
|
|
||||||
@@ -63,11 +63,40 @@ class SocialiteController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find existing user by provider ID or email
|
// First, check if this provider account is already linked to a user
|
||||||
$user = User::where('email', $socialUser->getEmail())->first();
|
$socialAccount = SocialAccount::where('provider', $provider)
|
||||||
|
->where('provider_id', $socialUser->getId())
|
||||||
|
->first();
|
||||||
|
|
||||||
if (! $user) {
|
if ($socialAccount) {
|
||||||
// Create new user if registration is enabled
|
Auth::login($socialAccount->user, remember: true);
|
||||||
|
request()->session()->regenerate();
|
||||||
|
|
||||||
|
return redirect()->intended(config('auth-ui.redirects.login', '/'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if a user with this email already exists
|
||||||
|
$existingUser = User::where('email', $socialUser->getEmail())->first();
|
||||||
|
|
||||||
|
if ($existingUser) {
|
||||||
|
if (! $existingUser->hasVerifiedEmail()) {
|
||||||
|
return redirect()->route('login')->withErrors([
|
||||||
|
'email' => 'An account with this email already exists. Please verify your email first, then link your social account.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$existingUser->socialAccounts()->create([
|
||||||
|
'provider' => $provider,
|
||||||
|
'provider_id' => $socialUser->getId(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Auth::login($existingUser, remember: true);
|
||||||
|
request()->session()->regenerate();
|
||||||
|
|
||||||
|
return redirect()->intended(config('auth-ui.redirects.login', '/'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// New user — check if registration is enabled
|
||||||
if (! config('auth-ui.features.registration')) {
|
if (! config('auth-ui.features.registration')) {
|
||||||
return redirect()->route('login')->withErrors([
|
return redirect()->route('login')->withErrors([
|
||||||
'email' => 'Registration is disabled. Please contact an administrator.',
|
'email' => 'Registration is disabled. Please contact an administrator.',
|
||||||
@@ -80,13 +109,13 @@ class SocialiteController extends Controller
|
|||||||
|
|
||||||
// Check if username is already taken
|
// Check if username is already taken
|
||||||
if (User::whereRaw('LOWER(username) = ?', [strtolower($suggestedUsername)])->exists()) {
|
if (User::whereRaw('LOWER(username) = ?', [strtolower($suggestedUsername)])->exists()) {
|
||||||
// Store social data in session and redirect to complete profile
|
|
||||||
session()->put('socialite_user', [
|
session()->put('socialite_user', [
|
||||||
'email' => $socialUser->getEmail(),
|
'email' => $socialUser->getEmail(),
|
||||||
'first_name' => $nameParts[0],
|
'first_name' => $nameParts[0],
|
||||||
'last_name' => $nameParts[1] ?? '',
|
'last_name' => $nameParts[1] ?? '',
|
||||||
'suggested_username' => $suggestedUsername,
|
'suggested_username' => $suggestedUsername,
|
||||||
'provider' => $provider,
|
'provider' => $provider,
|
||||||
|
'provider_id' => $socialUser->getId(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return redirect()->route('auth.complete-profile');
|
return redirect()->route('auth.complete-profile');
|
||||||
@@ -97,10 +126,14 @@ class SocialiteController extends Controller
|
|||||||
'first_name' => $nameParts[0],
|
'first_name' => $nameParts[0],
|
||||||
'last_name' => $nameParts[1] ?? '',
|
'last_name' => $nameParts[1] ?? '',
|
||||||
'email' => $socialUser->getEmail(),
|
'email' => $socialUser->getEmail(),
|
||||||
'password' => Hash::make(Str::random(24)),
|
|
||||||
'email_verified_at' => now(),
|
|
||||||
]);
|
]);
|
||||||
}
|
|
||||||
|
$user->forceFill(['email_verified_at' => now()])->save();
|
||||||
|
|
||||||
|
$user->socialAccounts()->create([
|
||||||
|
'provider' => $provider,
|
||||||
|
'provider_id' => $socialUser->getId(),
|
||||||
|
]);
|
||||||
|
|
||||||
Auth::login($user, remember: true);
|
Auth::login($user, remember: true);
|
||||||
request()->session()->regenerate();
|
request()->session()->regenerate();
|
||||||
|
|||||||
14
app/Http/Controllers/DashboardController.php
Normal file
14
app/Http/Controllers/DashboardController.php
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class DashboardController extends Controller
|
||||||
|
{
|
||||||
|
public function __invoke(): Response
|
||||||
|
{
|
||||||
|
return Inertia::render('Dashboard');
|
||||||
|
}
|
||||||
|
}
|
||||||
38
app/Http/Requests/Auth/CompleteProfileRequest.php
Normal file
38
app/Http/Requests/Auth/CompleteProfileRequest.php
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Auth;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class CompleteProfileRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return session()->has('socialite_user');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, array<mixed>>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'username' => [
|
||||||
|
'required', 'string', 'max:255', 'alpha_dash',
|
||||||
|
function ($attribute, $value, $fail): void {
|
||||||
|
if (User::whereRaw('LOWER(username) = ?', [strtolower($value)])->exists()) {
|
||||||
|
$fail('The username has already been taken.');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'first_name' => ['required', 'string', 'max:255'],
|
||||||
|
'last_name' => ['required', 'string', 'max:255'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
28
app/Http/Requests/Auth/ForgotPasswordRequest.php
Normal file
28
app/Http/Requests/Auth/ForgotPasswordRequest.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Auth;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class ForgotPasswordRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return (bool) config('auth-ui.features.password_reset');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, array<mixed>>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'email' => ['required', 'email'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
29
app/Http/Requests/Auth/LoginRequest.php
Normal file
29
app/Http/Requests/Auth/LoginRequest.php
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Auth;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class LoginRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, array<mixed>>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'login' => ['required', 'string'],
|
||||||
|
'password' => ['required', 'string'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
41
app/Http/Requests/Auth/RegisterRequest.php
Normal file
41
app/Http/Requests/Auth/RegisterRequest.php
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Auth;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rules;
|
||||||
|
|
||||||
|
class RegisterRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return (bool) config('auth-ui.features.registration');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, array<mixed>>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'username' => [
|
||||||
|
'required', 'string', 'max:255', 'alpha_dash',
|
||||||
|
function ($attribute, $value, $fail): void {
|
||||||
|
if (User::whereRaw('LOWER(username) = ?', [strtolower($value)])->exists()) {
|
||||||
|
$fail('The username has already been taken.');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'first_name' => ['required', 'string', 'max:255'],
|
||||||
|
'last_name' => ['required', 'string', 'max:255'],
|
||||||
|
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class],
|
||||||
|
'password' => ['required', 'confirmed', Rules\Password::defaults()],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
31
app/Http/Requests/Auth/ResetPasswordRequest.php
Normal file
31
app/Http/Requests/Auth/ResetPasswordRequest.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Auth;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rules;
|
||||||
|
|
||||||
|
class ResetPasswordRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return (bool) config('auth-ui.features.password_reset');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, array<mixed>>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'token' => ['required'],
|
||||||
|
'email' => ['required', 'email'],
|
||||||
|
'password' => ['required', 'confirmed', Rules\Password::defaults()],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
28
app/Models/SocialAccount.php
Normal file
28
app/Models/SocialAccount.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class SocialAccount extends Model
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The attributes that are mass assignable.
|
||||||
|
*
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
protected $fillable = [
|
||||||
|
'user_id',
|
||||||
|
'provider',
|
||||||
|
'provider_id',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the user that owns the social account.
|
||||||
|
*/
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,17 +2,18 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
use Database\Factories\UserFactory;
|
||||||
|
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
use Laravel\Sanctum\HasApiTokens;
|
|
||||||
|
|
||||||
class User extends Authenticatable
|
class User extends Authenticatable implements MustVerifyEmail
|
||||||
{
|
{
|
||||||
/** @use HasFactory<\Database\Factories\UserFactory> */
|
/** @use HasFactory<UserFactory> */
|
||||||
use HasApiTokens, HasFactory, Notifiable;
|
use HasFactory, Notifiable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The attributes that are mass assignable.
|
* The attributes that are mass assignable.
|
||||||
@@ -59,6 +60,32 @@ class User extends Authenticatable
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only send verification notification when the feature is enabled.
|
||||||
|
*/
|
||||||
|
public function sendEmailVerificationNotification(): void
|
||||||
|
{
|
||||||
|
if (config('auth-ui.features.email_verification')) {
|
||||||
|
parent::sendEmailVerificationNotification();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the user has a password set (i.e. not a social-only user).
|
||||||
|
*/
|
||||||
|
public function hasPassword(): bool
|
||||||
|
{
|
||||||
|
return $this->password !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the user's social accounts.
|
||||||
|
*/
|
||||||
|
public function socialAccounts(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(SocialAccount::class);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the user's full name.
|
* Get the user's full name.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -10,14 +10,15 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.2",
|
"php": "^8.2",
|
||||||
"inertiajs/inertia-laravel": "^2.0",
|
"inertiajs/inertia-laravel": "^3.0.0-beta",
|
||||||
"laravel/framework": "^12.0",
|
"laravel/framework": "^13.0",
|
||||||
"laravel/sanctum": "^4.0",
|
"laravel/sanctum": "^4.0",
|
||||||
"laravel/socialite": "^5.24",
|
"laravel/socialite": "^5.24",
|
||||||
"laravel/tinker": "^2.10.1"
|
"laravel/tinker": "^3.0"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"fakerphp/faker": "^1.23",
|
"fakerphp/faker": "^1.23",
|
||||||
|
"laravel/boost": "^2.3",
|
||||||
"laravel/pail": "^1.2.2",
|
"laravel/pail": "^1.2.2",
|
||||||
"laravel/pint": "^1.24",
|
"laravel/pint": "^1.24",
|
||||||
"laravel/sail": "^1.53",
|
"laravel/sail": "^1.53",
|
||||||
|
|||||||
894
composer.lock
generated
894
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -113,9 +113,9 @@ return [
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
'redirects' => [
|
'redirects' => [
|
||||||
'login' => env('AUTH_REDIRECT_LOGIN', '/'),
|
'login' => env('AUTH_REDIRECT_LOGIN', '/dashboard'),
|
||||||
'logout' => env('AUTH_REDIRECT_LOGOUT', '/'),
|
'logout' => env('AUTH_REDIRECT_LOGOUT', '/'),
|
||||||
'register' => env('AUTH_REDIRECT_REGISTER', '/'),
|
'register' => env('AUTH_REDIRECT_REGISTER', '/dashboard'),
|
||||||
],
|
],
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -114,4 +114,23 @@ return [
|
|||||||
|
|
||||||
'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'),
|
'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Serializable Classes
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option controls which PHP classes may be unserialized when retrieving
|
||||||
|
| cached values. Setting this to `false` prevents unserialization of any
|
||||||
|
| objects, hardening your cache against PHP deserialization attacks.
|
||||||
|
|
|
||||||
|
| If your application caches PHP objects, list allowed classes explicitly:
|
||||||
|
|
|
||||||
|
| 'serializable_classes' => [
|
||||||
|
| App\Data\CachedDashboardStats::class,
|
||||||
|
| ],
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'serializable_classes' => false,
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
138
config/inertia.php
Normal file
138
config/inertia.php
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Server Side Rendering
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| These options configures if and how Inertia uses Server Side Rendering
|
||||||
|
| to pre-render the initial visits made to your application's pages.
|
||||||
|
|
|
||||||
|
| You can specify a custom SSR bundle path, or omit it to let Inertia
|
||||||
|
| try and automatically detect it for you.
|
||||||
|
|
|
||||||
|
| Do note that enabling these options will NOT automatically make SSR work,
|
||||||
|
| as a separate rendering service needs to be available. To learn more,
|
||||||
|
| please visit https://inertiajs.com/server-side-rendering
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'ssr' => [
|
||||||
|
|
||||||
|
'enabled' => (bool) env('INERTIA_SSR_ENABLED', true),
|
||||||
|
|
||||||
|
'url' => env('INERTIA_SSR_URL', 'http://127.0.0.1:13714'),
|
||||||
|
|
||||||
|
'ensure_bundle_exists' => (bool) env('INERTIA_SSR_ENSURE_BUNDLE_EXISTS', true),
|
||||||
|
|
||||||
|
// 'bundle' => base_path('bootstrap/ssr/ssr.mjs'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| SSR Error Handling
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| When SSR rendering fails, Inertia gracefully falls back to client-side
|
||||||
|
| rendering. Set throw_on_error to true to throw an exception instead.
|
||||||
|
| This is useful for E2E testing where you want SSR errors to fail loudly.
|
||||||
|
|
|
||||||
|
| You can also listen for the Inertia\Ssr\SsrRenderFailed event to handle
|
||||||
|
| failures in your own way (e.g., logging, error tracking service).
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'throw_on_error' => (bool) env('INERTIA_SSR_THROW_ON_ERROR', false),
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Pages
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Set `ensure_pages_exist` to true if you want to enforce that Inertia page
|
||||||
|
| components exist on disk when rendering a page. This is useful for
|
||||||
|
| catching missing or misnamed components.
|
||||||
|
|
|
||||||
|
| The `paths` and `extensions` options define where to look for page
|
||||||
|
| components and which file extensions to consider.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'pages' => [
|
||||||
|
|
||||||
|
'ensure_pages_exist' => false,
|
||||||
|
|
||||||
|
'paths' => [
|
||||||
|
|
||||||
|
resource_path('js/Pages'),
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
'extensions' => [
|
||||||
|
|
||||||
|
'js',
|
||||||
|
'jsx',
|
||||||
|
'svelte',
|
||||||
|
'ts',
|
||||||
|
'tsx',
|
||||||
|
'vue',
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Testing
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| When using `assertInertia`, the assertion attempts to locate the
|
||||||
|
| component as a file relative to the `pages.paths` AND with any of
|
||||||
|
| the `pages.extensions` specified above.
|
||||||
|
|
|
||||||
|
| You can disable this behavior by setting `ensure_pages_exist`
|
||||||
|
| to false.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'testing' => [
|
||||||
|
|
||||||
|
'ensure_pages_exist' => true,
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Expose Shared Prop Keys
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| When enabled, each page response includes a `sharedProps` metadata key
|
||||||
|
| listing the top-level prop keys that were registered via `Inertia::share`.
|
||||||
|
| The frontend can use this to carry shared props over during instant visits.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'expose_shared_prop_keys' => true,
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| History
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Enable `encrypt` to encrypt page data before it is stored in the
|
||||||
|
| browser's history state, preventing sensitive information from
|
||||||
|
| being accessible after logout. Can also be enabled per-request
|
||||||
|
| or via the `inertia.encrypt` middleware.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'history' => [
|
||||||
|
|
||||||
|
'encrypt' => (bool) env('INERTIA_ENCRYPT_HISTORY', false),
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Cookie\Middleware\EncryptCookies;
|
||||||
|
use Illuminate\Foundation\Http\Middleware\PreventRequestForgery;
|
||||||
|
use Laravel\Sanctum\Http\Middleware\AuthenticateSession;
|
||||||
use Laravel\Sanctum\Sanctum;
|
use Laravel\Sanctum\Sanctum;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -76,9 +79,9 @@ return [
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
'middleware' => [
|
'middleware' => [
|
||||||
'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class,
|
'authenticate_session' => AuthenticateSession::class,
|
||||||
'encrypt_cookies' => Illuminate\Cookie\Middleware\EncryptCookies::class,
|
'encrypt_cookies' => EncryptCookies::class,
|
||||||
'validate_csrf_token' => Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class,
|
'validate_csrf_token' => PreventRequestForgery::class,
|
||||||
],
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ return [
|
|||||||
|
|
||||||
'cookie' => env(
|
'cookie' => env(
|
||||||
'SESSION_COOKIE',
|
'SESSION_COOKIE',
|
||||||
Str::slug((string) env('APP_NAME', 'laravel')).'-session'
|
Str::snake((string) env('APP_NAME', 'laravel')).'_session'
|
||||||
),
|
),
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -2,12 +2,13 @@
|
|||||||
|
|
||||||
namespace Database\Factories;
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\User>
|
* @extends Factory<User>
|
||||||
*/
|
*/
|
||||||
class UserFactory extends Factory
|
class UserFactory extends Factory
|
||||||
{
|
{
|
||||||
@@ -43,4 +44,14 @@ class UserFactory extends Factory
|
|||||||
'email_verified_at' => null,
|
'email_verified_at' => null,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicate that the user was created via social login (no password).
|
||||||
|
*/
|
||||||
|
public function social(): static
|
||||||
|
{
|
||||||
|
return $this->state(fn (array $attributes) => [
|
||||||
|
'password' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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->string('password')->nullable()->change();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->string('password')->nullable(false)->change();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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('social_accounts', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->string('provider');
|
||||||
|
$table->string('provider_id');
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->unique(['provider', 'provider_id']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('social_accounts');
|
||||||
|
}
|
||||||
|
};
|
||||||
2338
package-lock.json
generated
2338
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
22
package.json
22
package.json
@@ -12,23 +12,23 @@
|
|||||||
"test:coverage": "vitest run --coverage"
|
"test:coverage": "vitest run --coverage"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@inertiajs/vue3": "^2.3.17",
|
"@inertiajs/vue3": "^3.0.0-beta.3",
|
||||||
"@nuxt/ui": "^4.5.1",
|
"@nuxt/ui": "^4.5.1",
|
||||||
"valibot": "^1.2.0",
|
"valibot": "^1.3.1",
|
||||||
"vue": "^3.5.30"
|
"vue": "^3.5.30"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@antfu/eslint-config": "^7.7.0",
|
"@antfu/eslint-config": "^7.7.3",
|
||||||
"@tailwindcss/vite": "^4.2.1",
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
"@vitejs/plugin-vue": "^6.0.4",
|
"@vitejs/plugin-vue": "^6.0.5",
|
||||||
"@vue/test-utils": "^2.4.6",
|
"@vue/test-utils": "^2.4.6",
|
||||||
"axios": "^1.13.6",
|
|
||||||
"concurrently": "^9.2.1",
|
"concurrently": "^9.2.1",
|
||||||
"eslint": "^10.0.3",
|
"eslint": "^10.0.3",
|
||||||
"jsdom": "^28.1.0",
|
"jsdom": "^29.0.0",
|
||||||
"laravel-vite-plugin": "^2.1.0",
|
"laravel-vite-plugin": "^3.0.0",
|
||||||
"tailwindcss": "^4.2.1",
|
"tailwindcss": "^4.2.2",
|
||||||
"vite": "^7.3.1",
|
"typescript": "^5.9.3",
|
||||||
"vitest": "^4.0.18"
|
"vite": "^8.0.1",
|
||||||
|
"vitest": "^4.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { AuthFormField, FormSubmitEvent } from '@nuxt/ui'
|
import type { AuthFormField, FormSubmitEvent } from '@nuxt/ui'
|
||||||
|
import type * as v from 'valibot'
|
||||||
import { useForm } from '@inertiajs/vue3'
|
import { useForm } from '@inertiajs/vue3'
|
||||||
import * as v from 'valibot'
|
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import AuthLayout from '@/layouts/AuthLayout.vue'
|
import AuthLayout from '@/layouts/AuthLayout.vue'
|
||||||
|
import { completeProfileSchema } from '@/validation/auth'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
socialiteUser: {
|
socialiteUser: {
|
||||||
@@ -46,16 +47,7 @@ const fields: AuthFormField[] = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const schema = v.object({
|
const schema = completeProfileSchema
|
||||||
username: v.pipe(
|
|
||||||
v.string('Username is required'),
|
|
||||||
v.nonEmpty('Username is required'),
|
|
||||||
v.minLength(3, 'Username must be at least 3 characters'),
|
|
||||||
v.regex(/^[\w-]+$/, 'Username can only contain letters, numbers, dashes and underscores'),
|
|
||||||
),
|
|
||||||
first_name: v.pipe(v.string('First name is required'), v.nonEmpty('First name is required')),
|
|
||||||
last_name: v.pipe(v.string('Last name is required'), v.nonEmpty('Last name is required')),
|
|
||||||
})
|
|
||||||
|
|
||||||
type Schema = v.InferOutput<typeof schema>
|
type Schema = v.InferOutput<typeof schema>
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { AuthFormField, FormSubmitEvent } from '@nuxt/ui'
|
import type { AuthFormField, FormSubmitEvent } from '@nuxt/ui'
|
||||||
|
import type * as v from 'valibot'
|
||||||
import { useForm } from '@inertiajs/vue3'
|
import { useForm } from '@inertiajs/vue3'
|
||||||
import * as v from 'valibot'
|
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useAuth } from '@/composables/useAuth'
|
import { useAuth } from '@/composables/useAuth'
|
||||||
import AuthLayout from '@/layouts/AuthLayout.vue'
|
import AuthLayout from '@/layouts/AuthLayout.vue'
|
||||||
|
import { forgotPasswordSchema } from '@/validation/auth'
|
||||||
|
|
||||||
const { config, flash } = useAuth()
|
const { config, flash } = useAuth()
|
||||||
|
|
||||||
@@ -22,9 +23,7 @@ const fields: AuthFormField[] = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const schema = v.object({
|
const schema = forgotPasswordSchema
|
||||||
email: v.pipe(v.string('Email is required'), v.nonEmpty('Email is required'), v.email('Please enter a valid email')),
|
|
||||||
})
|
|
||||||
|
|
||||||
type Schema = v.InferOutput<typeof schema>
|
type Schema = v.InferOutput<typeof schema>
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { AuthFormField, FormSubmitEvent } from '@nuxt/ui'
|
import type { AuthFormField, FormSubmitEvent } from '@nuxt/ui'
|
||||||
|
import type * as v from 'valibot'
|
||||||
import { useForm } from '@inertiajs/vue3'
|
import { useForm } from '@inertiajs/vue3'
|
||||||
import * as v from 'valibot'
|
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { useAuth } from '@/composables/useAuth'
|
import { useAuth } from '@/composables/useAuth'
|
||||||
import AuthLayout from '@/layouts/AuthLayout.vue'
|
import AuthLayout from '@/layouts/AuthLayout.vue'
|
||||||
|
import { loginSchema } from '@/validation/auth'
|
||||||
|
|
||||||
const { config, flash } = useAuth()
|
const { config, flash } = useAuth()
|
||||||
|
|
||||||
@@ -41,11 +42,7 @@ const providers = computed(() =>
|
|||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
|
|
||||||
const schema = v.object({
|
const schema = loginSchema
|
||||||
login: v.pipe(v.string('Email or username is required'), v.nonEmpty('Email or username is required')),
|
|
||||||
password: v.pipe(v.string('Password is required'), v.nonEmpty('Password is required')),
|
|
||||||
remember: v.optional(v.boolean()),
|
|
||||||
})
|
|
||||||
|
|
||||||
type Schema = v.InferOutput<typeof schema>
|
type Schema = v.InferOutput<typeof schema>
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { AuthFormField, FormSubmitEvent } from '@nuxt/ui'
|
import type { AuthFormField, FormSubmitEvent } from '@nuxt/ui'
|
||||||
|
import type * as v from 'valibot'
|
||||||
import { useForm } from '@inertiajs/vue3'
|
import { useForm } from '@inertiajs/vue3'
|
||||||
import * as v from 'valibot'
|
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useAuth } from '@/composables/useAuth'
|
import { useAuth } from '@/composables/useAuth'
|
||||||
import AuthLayout from '@/layouts/AuthLayout.vue'
|
import AuthLayout from '@/layouts/AuthLayout.vue'
|
||||||
|
import { registerSchema } from '@/validation/auth'
|
||||||
|
|
||||||
const { config } = useAuth()
|
const { config } = useAuth()
|
||||||
|
|
||||||
@@ -70,29 +71,7 @@ const providers = computed(() =>
|
|||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
|
|
||||||
const schema = v.pipe(
|
const schema = registerSchema
|
||||||
v.object({
|
|
||||||
username: v.pipe(
|
|
||||||
v.string('Username is required'),
|
|
||||||
v.nonEmpty('Username is required'),
|
|
||||||
v.minLength(3, 'Username must be at least 3 characters'),
|
|
||||||
v.regex(/^[\w-]+$/, 'Username can only contain letters, numbers, dashes and underscores'),
|
|
||||||
),
|
|
||||||
first_name: v.pipe(v.string('First name is required'), v.nonEmpty('First name is required')),
|
|
||||||
last_name: v.pipe(v.string('Last name is required'), v.nonEmpty('Last name is required')),
|
|
||||||
email: v.pipe(v.string('Email is required'), v.nonEmpty('Email is required'), v.email('Please enter a valid email')),
|
|
||||||
password: v.pipe(v.string('Password is required'), v.nonEmpty('Password is required'), v.minLength(8, 'Password must be at least 8 characters')),
|
|
||||||
password_confirmation: v.pipe(v.string('Please confirm your password'), v.nonEmpty('Please confirm your password')),
|
|
||||||
}),
|
|
||||||
v.forward(
|
|
||||||
v.partialCheck(
|
|
||||||
[['password'], ['password_confirmation']],
|
|
||||||
input => input.password === input.password_confirmation,
|
|
||||||
'Passwords do not match',
|
|
||||||
),
|
|
||||||
['password_confirmation'],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
type Schema = v.InferOutput<typeof schema>
|
type Schema = v.InferOutput<typeof schema>
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { AuthFormField, FormSubmitEvent } from '@nuxt/ui'
|
import type { AuthFormField, FormSubmitEvent } from '@nuxt/ui'
|
||||||
|
import type * as v from 'valibot'
|
||||||
import { useForm } from '@inertiajs/vue3'
|
import { useForm } from '@inertiajs/vue3'
|
||||||
import * as v from 'valibot'
|
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useAuth } from '@/composables/useAuth'
|
import { useAuth } from '@/composables/useAuth'
|
||||||
import AuthLayout from '@/layouts/AuthLayout.vue'
|
import AuthLayout from '@/layouts/AuthLayout.vue'
|
||||||
|
import { resetPasswordSchema } from '@/validation/auth'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
email: string
|
email: string
|
||||||
@@ -44,21 +45,7 @@ const fields: AuthFormField[] = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const schema = v.pipe(
|
const schema = resetPasswordSchema
|
||||||
v.object({
|
|
||||||
email: v.pipe(v.string('Email is required'), v.nonEmpty('Email is required'), v.email('Please enter a valid email')),
|
|
||||||
password: v.pipe(v.string('Password is required'), v.nonEmpty('Password is required'), v.minLength(8, 'Password must be at least 8 characters')),
|
|
||||||
password_confirmation: v.pipe(v.string('Please confirm your password'), v.nonEmpty('Please confirm your password')),
|
|
||||||
}),
|
|
||||||
v.forward(
|
|
||||||
v.partialCheck(
|
|
||||||
[['password'], ['password_confirmation']],
|
|
||||||
input => input.password === input.password_confirmation,
|
|
||||||
'Passwords do not match',
|
|
||||||
),
|
|
||||||
['password_confirmation'],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
type Schema = v.InferOutput<typeof schema>
|
type Schema = v.InferOutput<typeof schema>
|
||||||
|
|
||||||
|
|||||||
48
resources/js/Pages/Auth/VerifyEmail.vue
Normal file
48
resources/js/Pages/Auth/VerifyEmail.vue
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useForm } from '@inertiajs/vue3'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useAuth } from '@/composables/useAuth'
|
||||||
|
import AuthLayout from '@/layouts/AuthLayout.vue'
|
||||||
|
|
||||||
|
const { flash } = useAuth()
|
||||||
|
|
||||||
|
const form = useForm('VerifyEmailForm', {})
|
||||||
|
|
||||||
|
function resend() {
|
||||||
|
form.post('/email/verification-notification')
|
||||||
|
}
|
||||||
|
|
||||||
|
const linkSent = computed(() => flash.status === 'verification-link-sent')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AuthLayout>
|
||||||
|
<div class="flex flex-col items-center gap-4 text-center">
|
||||||
|
<UIcon name="i-lucide-mail-check" class="text-primary size-12" />
|
||||||
|
<h2 class="text-xl font-semibold">
|
||||||
|
Verify your email
|
||||||
|
</h2>
|
||||||
|
<p class="text-muted text-sm">
|
||||||
|
We've sent a verification link to your email address. Please check your inbox and click the link to verify your account.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<UAlert
|
||||||
|
v-if="linkSent"
|
||||||
|
color="success"
|
||||||
|
icon="i-lucide-check-circle"
|
||||||
|
title="A new verification link has been sent to your email address."
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<UButton
|
||||||
|
:loading="form.processing"
|
||||||
|
:disabled="form.processing"
|
||||||
|
variant="soft"
|
||||||
|
block
|
||||||
|
@click="resend"
|
||||||
|
>
|
||||||
|
Resend verification email
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</AuthLayout>
|
||||||
|
</template>
|
||||||
99
resources/js/Pages/Dashboard.vue
Normal file
99
resources/js/Pages/Dashboard.vue
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useAuth } from '@/composables/useAuth'
|
||||||
|
import DashboardLayout from '@/layouts/DashboardLayout.vue'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
layout: DashboardLayout,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { user } = useAuth()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UDashboardPanel>
|
||||||
|
<template #header>
|
||||||
|
<UDashboardNavbar title="Dashboard" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #body>
|
||||||
|
<div class="flex flex-col gap-6">
|
||||||
|
<UCard>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<UIcon name="i-lucide-shield-check" class="text-primary size-8" />
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-medium">
|
||||||
|
Welcome back, {{ user?.first_name }}!
|
||||||
|
</h2>
|
||||||
|
<p class="text-muted text-sm">
|
||||||
|
Your email is verified and you have access to this protected page.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</UCard>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<UCard>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<UIcon name="i-lucide-lock" class="text-primary size-5" />
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium">
|
||||||
|
Auth Middleware
|
||||||
|
</p>
|
||||||
|
<p class="text-muted text-xs">
|
||||||
|
Route requires authentication
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</UCard>
|
||||||
|
|
||||||
|
<UCard>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<UIcon name="i-lucide-mail-check" class="text-primary size-5" />
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium">
|
||||||
|
Verified Middleware
|
||||||
|
</p>
|
||||||
|
<p class="text-muted text-xs">
|
||||||
|
Route requires email verification
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</UCard>
|
||||||
|
|
||||||
|
<UCard>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<UIcon name="i-lucide-layout-dashboard" class="text-primary size-5" />
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium">
|
||||||
|
Nuxt UI Dashboard
|
||||||
|
</p>
|
||||||
|
<p class="text-muted text-xs">
|
||||||
|
Built with dashboard components
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</UCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UCard>
|
||||||
|
<template #header>
|
||||||
|
<h3 class="font-medium">
|
||||||
|
Route Protection
|
||||||
|
</h3>
|
||||||
|
</template>
|
||||||
|
<div class="text-sm space-y-2">
|
||||||
|
<p class="text-muted">
|
||||||
|
This page is protected with both <code class="bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded text-xs">auth</code> and <code class="bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded text-xs">verified</code> middleware. Unauthenticated users are redirected to login, and unverified users are redirected to the email verification page.
|
||||||
|
</p>
|
||||||
|
<UAlert
|
||||||
|
icon="i-lucide-info"
|
||||||
|
color="info"
|
||||||
|
variant="subtle"
|
||||||
|
title="Enable email verification by setting AUTH_ENABLE_EMAIL_VERIFICATION=true in your .env file."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</UCard>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UDashboardPanel>
|
||||||
|
</template>
|
||||||
@@ -20,6 +20,9 @@ function logout() {
|
|||||||
<div class="min-h-screen flex flex-col bg-gray-100 dark:bg-gray-900">
|
<div class="min-h-screen flex flex-col bg-gray-100 dark:bg-gray-900">
|
||||||
<header class="flex items-center justify-end gap-4 p-4">
|
<header class="flex items-center justify-end gap-4 p-4">
|
||||||
<template v-if="isAuthenticated">
|
<template v-if="isAuthenticated">
|
||||||
|
<UButton to="/dashboard" variant="ghost">
|
||||||
|
Dashboard
|
||||||
|
</UButton>
|
||||||
<span class="text-sm text-gray-600 dark:text-gray-400">
|
<span class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
{{ user?.full_name }}
|
{{ user?.full_name }}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
8
resources/js/bootstrap.js
vendored
8
resources/js/bootstrap.js
vendored
@@ -1,7 +1 @@
|
|||||||
import axios from 'axios'
|
//
|
||||||
|
|
||||||
window.axios = axios
|
|
||||||
|
|
||||||
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'
|
|
||||||
window.axios.defaults.withCredentials = true
|
|
||||||
window.axios.defaults.withXSRFToken = true
|
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import ErrorBoundary from '../ErrorBoundary.vue'
|
||||||
|
|
||||||
|
// Stub UIcon and UButton since they come from Nuxt UI
|
||||||
|
const stubs = {
|
||||||
|
UIcon: { template: '<span />' },
|
||||||
|
UButton: { template: '<button><slot /></button>' },
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('errorBoundary', () => {
|
||||||
|
it('renders slot content when no error', () => {
|
||||||
|
const wrapper = mount(ErrorBoundary, {
|
||||||
|
slots: {
|
||||||
|
default: '<div>Hello World</div>',
|
||||||
|
},
|
||||||
|
global: { stubs },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Hello World')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not show error UI by default', () => {
|
||||||
|
const wrapper = mount(ErrorBoundary, {
|
||||||
|
slots: {
|
||||||
|
default: '<div>Content</div>',
|
||||||
|
},
|
||||||
|
global: { stubs },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.text()).not.toContain('Something went wrong')
|
||||||
|
expect(wrapper.text()).toContain('Content')
|
||||||
|
})
|
||||||
|
})
|
||||||
85
resources/js/composables/__tests__/useAuth.test.ts
Normal file
85
resources/js/composables/__tests__/useAuth.test.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
const mockPageProps = {
|
||||||
|
auth: { user: null as any },
|
||||||
|
flash: { success: null, error: null, message: null, status: null },
|
||||||
|
authConfig: {
|
||||||
|
appName: 'TestApp',
|
||||||
|
features: {
|
||||||
|
registration: true,
|
||||||
|
password_reset: true,
|
||||||
|
remember_me: true,
|
||||||
|
email_verification: false,
|
||||||
|
},
|
||||||
|
login: { title: 'Login', description: '', icon: '', submit_label: 'Sign in' },
|
||||||
|
register: { title: 'Register', description: '', icon: '', submit_label: 'Sign up' },
|
||||||
|
forgotPassword: { title: 'Forgot', description: '', icon: '', submit_label: 'Send' },
|
||||||
|
resetPassword: { title: 'Reset', description: '', icon: '', submit_label: 'Reset' },
|
||||||
|
providers: [],
|
||||||
|
legal: { terms_url: null, privacy_url: null, show_in_register: false },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mock('@inertiajs/vue3', () => ({
|
||||||
|
usePage: () => ({ props: mockPageProps }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const { useAuth } = await import('../useAuth')
|
||||||
|
|
||||||
|
describe('useAuth', () => {
|
||||||
|
it('returns null user when not authenticated', () => {
|
||||||
|
mockPageProps.auth.user = null
|
||||||
|
|
||||||
|
const { user, isAuthenticated } = useAuth()
|
||||||
|
|
||||||
|
expect(user.value).toBeNull()
|
||||||
|
expect(isAuthenticated.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns user when authenticated', () => {
|
||||||
|
mockPageProps.auth.user = {
|
||||||
|
id: 1,
|
||||||
|
username: 'testuser',
|
||||||
|
first_name: 'Test',
|
||||||
|
last_name: 'User',
|
||||||
|
full_name: 'Test User',
|
||||||
|
email: 'test@example.com',
|
||||||
|
email_verified_at: '2026-01-01',
|
||||||
|
created_at: '2026-01-01',
|
||||||
|
updated_at: '2026-01-01',
|
||||||
|
}
|
||||||
|
|
||||||
|
const { user, isAuthenticated } = useAuth()
|
||||||
|
|
||||||
|
expect(user.value).not.toBeNull()
|
||||||
|
expect(user.value!.username).toBe('testuser')
|
||||||
|
expect(isAuthenticated.value).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns flash messages', () => {
|
||||||
|
mockPageProps.flash.success = 'It worked!'
|
||||||
|
|
||||||
|
const { flash } = useAuth()
|
||||||
|
|
||||||
|
expect(flash.value.success).toBe('It worked!')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns auth config', () => {
|
||||||
|
const { config } = useAuth()
|
||||||
|
|
||||||
|
expect(config.value.appName).toBe('TestApp')
|
||||||
|
expect(config.value.features.registration).toBe(true)
|
||||||
|
expect(config.value.features.email_verification).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns providers from config', () => {
|
||||||
|
mockPageProps.authConfig.providers = [
|
||||||
|
{ key: 'github', label: 'GitHub', icon: 'i-simple-icons-github' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const { config } = useAuth()
|
||||||
|
|
||||||
|
expect(config.value.providers).toHaveLength(1)
|
||||||
|
expect(config.value.providers[0].key).toBe('github')
|
||||||
|
})
|
||||||
|
})
|
||||||
58
resources/js/layouts/DashboardLayout.vue
Normal file
58
resources/js/layouts/DashboardLayout.vue
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useForm } from '@inertiajs/vue3'
|
||||||
|
import Logo from '@/components/common/Logo.vue'
|
||||||
|
import { useAuth } from '@/composables/useAuth'
|
||||||
|
|
||||||
|
const { user } = useAuth()
|
||||||
|
|
||||||
|
const logoutForm = useForm({})
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
logoutForm.post('/logout')
|
||||||
|
}
|
||||||
|
|
||||||
|
const sidebarLinks = [
|
||||||
|
{
|
||||||
|
label: 'Dashboard',
|
||||||
|
icon: 'i-lucide-house',
|
||||||
|
to: '/dashboard',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UApp>
|
||||||
|
<UDashboardGroup>
|
||||||
|
<UDashboardSidebar collapsible>
|
||||||
|
<template #header>
|
||||||
|
<Logo :link-to-home="false" size="sm" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<UNavigationMenu :items="sidebarLinks" />
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex items-center gap-2 px-2">
|
||||||
|
<UAvatar :alt="user?.full_name" size="sm" />
|
||||||
|
<div class="flex-1 truncate text-sm">
|
||||||
|
<p class="font-medium truncate">
|
||||||
|
{{ user?.full_name }}
|
||||||
|
</p>
|
||||||
|
<p class="text-muted truncate text-xs">
|
||||||
|
{{ user?.email }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<UButton
|
||||||
|
icon="i-lucide-log-out"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
:loading="logoutForm.processing"
|
||||||
|
@click="logout"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UDashboardSidebar>
|
||||||
|
|
||||||
|
<slot />
|
||||||
|
</UDashboardGroup>
|
||||||
|
</UApp>
|
||||||
|
</template>
|
||||||
169
resources/js/validation/__tests__/auth.test.ts
Normal file
169
resources/js/validation/__tests__/auth.test.ts
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import * as v from 'valibot'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
completeProfileSchema,
|
||||||
|
forgotPasswordSchema,
|
||||||
|
loginSchema,
|
||||||
|
registerSchema,
|
||||||
|
resetPasswordSchema,
|
||||||
|
} from '../auth'
|
||||||
|
|
||||||
|
function validate<T extends v.BaseSchema<unknown, unknown, v.BaseIssue<unknown>>>(schema: T, data: unknown) {
|
||||||
|
return v.safeParse(schema, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('loginSchema', () => {
|
||||||
|
it('accepts valid login data', () => {
|
||||||
|
const result = validate(loginSchema, { login: 'user@test.com', password: 'secret' })
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects empty login', () => {
|
||||||
|
const result = validate(loginSchema, { login: '', password: 'secret' })
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects empty password', () => {
|
||||||
|
const result = validate(loginSchema, { login: 'user', password: '' })
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts optional remember field', () => {
|
||||||
|
const result = validate(loginSchema, { login: 'user', password: 'secret', remember: true })
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('registerSchema', () => {
|
||||||
|
const validData = {
|
||||||
|
username: 'testuser',
|
||||||
|
first_name: 'Test',
|
||||||
|
last_name: 'User',
|
||||||
|
email: 'test@example.com',
|
||||||
|
password: 'password123',
|
||||||
|
password_confirmation: 'password123',
|
||||||
|
}
|
||||||
|
|
||||||
|
it('accepts valid registration data', () => {
|
||||||
|
const result = validate(registerSchema, validData)
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects username shorter than 3 characters', () => {
|
||||||
|
const result = validate(registerSchema, { ...validData, username: 'ab' })
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects username with spaces', () => {
|
||||||
|
const result = validate(registerSchema, { ...validData, username: 'test user' })
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts username with dashes and underscores', () => {
|
||||||
|
const result = validate(registerSchema, { ...validData, username: 'test-user_123' })
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects invalid email', () => {
|
||||||
|
const result = validate(registerSchema, { ...validData, email: 'not-an-email' })
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects password shorter than 8 characters', () => {
|
||||||
|
const result = validate(registerSchema, { ...validData, password: 'short', password_confirmation: 'short' })
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects mismatched passwords', () => {
|
||||||
|
const result = validate(registerSchema, { ...validData, password_confirmation: 'different123' })
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects empty first name', () => {
|
||||||
|
const result = validate(registerSchema, { ...validData, first_name: '' })
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects empty last name', () => {
|
||||||
|
const result = validate(registerSchema, { ...validData, last_name: '' })
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('forgotPasswordSchema', () => {
|
||||||
|
it('accepts valid email', () => {
|
||||||
|
const result = validate(forgotPasswordSchema, { email: 'test@example.com' })
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects empty email', () => {
|
||||||
|
const result = validate(forgotPasswordSchema, { email: '' })
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects invalid email', () => {
|
||||||
|
const result = validate(forgotPasswordSchema, { email: 'not-email' })
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('resetPasswordSchema', () => {
|
||||||
|
const validData = {
|
||||||
|
email: 'test@example.com',
|
||||||
|
password: 'newpassword123',
|
||||||
|
password_confirmation: 'newpassword123',
|
||||||
|
}
|
||||||
|
|
||||||
|
it('accepts valid reset data', () => {
|
||||||
|
const result = validate(resetPasswordSchema, validData)
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects mismatched passwords', () => {
|
||||||
|
const result = validate(resetPasswordSchema, { ...validData, password_confirmation: 'different' })
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects password shorter than 8 characters', () => {
|
||||||
|
const result = validate(resetPasswordSchema, { ...validData, password: 'short', password_confirmation: 'short' })
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects invalid email', () => {
|
||||||
|
const result = validate(resetPasswordSchema, { ...validData, email: 'bad' })
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('completeProfileSchema', () => {
|
||||||
|
const validData = {
|
||||||
|
username: 'newuser',
|
||||||
|
first_name: 'New',
|
||||||
|
last_name: 'User',
|
||||||
|
}
|
||||||
|
|
||||||
|
it('accepts valid profile data', () => {
|
||||||
|
const result = validate(completeProfileSchema, validData)
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects username shorter than 3 characters', () => {
|
||||||
|
const result = validate(completeProfileSchema, { ...validData, username: 'ab' })
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects username with special characters', () => {
|
||||||
|
const result = validate(completeProfileSchema, { ...validData, username: 'user@name' })
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects empty first name', () => {
|
||||||
|
const result = validate(completeProfileSchema, { ...validData, first_name: '' })
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects empty last name', () => {
|
||||||
|
const result = validate(completeProfileSchema, { ...validData, last_name: '' })
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
62
resources/js/validation/auth.ts
Normal file
62
resources/js/validation/auth.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import * as v from 'valibot'
|
||||||
|
|
||||||
|
export const loginSchema = v.object({
|
||||||
|
login: v.pipe(v.string('Email or username is required'), v.nonEmpty('Email or username is required')),
|
||||||
|
password: v.pipe(v.string('Password is required'), v.nonEmpty('Password is required')),
|
||||||
|
remember: v.optional(v.boolean()),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const registerSchema = v.pipe(
|
||||||
|
v.object({
|
||||||
|
username: v.pipe(
|
||||||
|
v.string('Username is required'),
|
||||||
|
v.nonEmpty('Username is required'),
|
||||||
|
v.minLength(3, 'Username must be at least 3 characters'),
|
||||||
|
v.regex(/^[\w-]+$/, 'Username can only contain letters, numbers, dashes and underscores'),
|
||||||
|
),
|
||||||
|
first_name: v.pipe(v.string('First name is required'), v.nonEmpty('First name is required')),
|
||||||
|
last_name: v.pipe(v.string('Last name is required'), v.nonEmpty('Last name is required')),
|
||||||
|
email: v.pipe(v.string('Email is required'), v.nonEmpty('Email is required'), v.email('Please enter a valid email')),
|
||||||
|
password: v.pipe(v.string('Password is required'), v.nonEmpty('Password is required'), v.minLength(8, 'Password must be at least 8 characters')),
|
||||||
|
password_confirmation: v.pipe(v.string('Please confirm your password'), v.nonEmpty('Please confirm your password')),
|
||||||
|
}),
|
||||||
|
v.forward(
|
||||||
|
v.partialCheck(
|
||||||
|
[['password'], ['password_confirmation']],
|
||||||
|
input => input.password === input.password_confirmation,
|
||||||
|
'Passwords do not match',
|
||||||
|
),
|
||||||
|
['password_confirmation'],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const forgotPasswordSchema = v.object({
|
||||||
|
email: v.pipe(v.string('Email is required'), v.nonEmpty('Email is required'), v.email('Please enter a valid email')),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const resetPasswordSchema = v.pipe(
|
||||||
|
v.object({
|
||||||
|
email: v.pipe(v.string('Email is required'), v.nonEmpty('Email is required'), v.email('Please enter a valid email')),
|
||||||
|
password: v.pipe(v.string('Password is required'), v.nonEmpty('Password is required'), v.minLength(8, 'Password must be at least 8 characters')),
|
||||||
|
password_confirmation: v.pipe(v.string('Please confirm your password'), v.nonEmpty('Please confirm your password')),
|
||||||
|
}),
|
||||||
|
v.forward(
|
||||||
|
v.partialCheck(
|
||||||
|
[['password'], ['password_confirmation']],
|
||||||
|
input => input.password === input.password_confirmation,
|
||||||
|
'Passwords do not match',
|
||||||
|
),
|
||||||
|
['password_confirmation'],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const completeProfileSchema = v.object({
|
||||||
|
username: v.pipe(
|
||||||
|
v.string('Username is required'),
|
||||||
|
v.nonEmpty('Username is required'),
|
||||||
|
v.minLength(3, 'Username must be at least 3 characters'),
|
||||||
|
v.regex(/^[\w-]+$/, 'Username can only contain letters, numbers, dashes and underscores'),
|
||||||
|
),
|
||||||
|
first_name: v.pipe(v.string('First name is required'), v.nonEmpty('First name is required')),
|
||||||
|
last_name: v.pipe(v.string('Last name is required'), v.nonEmpty('Last name is required')),
|
||||||
|
})
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Http\Controllers\Auth\CompleteProfileController;
|
use App\Http\Controllers\Auth\CompleteProfileController;
|
||||||
|
use App\Http\Controllers\Auth\EmailVerificationController;
|
||||||
use App\Http\Controllers\Auth\ForgotPasswordController;
|
use App\Http\Controllers\Auth\ForgotPasswordController;
|
||||||
use App\Http\Controllers\Auth\LoginController;
|
use App\Http\Controllers\Auth\LoginController;
|
||||||
use App\Http\Controllers\Auth\RegisterController;
|
use App\Http\Controllers\Auth\RegisterController;
|
||||||
@@ -23,7 +24,7 @@ Route::middleware('guest')->group(function () {
|
|||||||
|
|
||||||
// Socialite routes
|
// Socialite routes
|
||||||
Route::get('auth/{provider}', [SocialiteController::class, 'redirect'])->name('socialite.redirect');
|
Route::get('auth/{provider}', [SocialiteController::class, 'redirect'])->name('socialite.redirect');
|
||||||
Route::get('auth/{provider}/callback', [SocialiteController::class, 'callback'])->name('socialite.callback');
|
Route::get('auth/{provider}/callback', [SocialiteController::class, 'callback'])->name('socialite.callback')->middleware('throttle:10,1');
|
||||||
|
|
||||||
// Complete profile after social login (when username is taken)
|
// Complete profile after social login (when username is taken)
|
||||||
Route::get('complete-profile', [CompleteProfileController::class, 'create'])->name('auth.complete-profile');
|
Route::get('complete-profile', [CompleteProfileController::class, 'create'])->name('auth.complete-profile');
|
||||||
@@ -32,4 +33,9 @@ Route::middleware('guest')->group(function () {
|
|||||||
|
|
||||||
Route::middleware('auth')->group(function () {
|
Route::middleware('auth')->group(function () {
|
||||||
Route::post('logout', [LoginController::class, 'destroy'])->name('logout');
|
Route::post('logout', [LoginController::class, 'destroy'])->name('logout');
|
||||||
|
|
||||||
|
// Email verification routes
|
||||||
|
Route::get('email/verify', [EmailVerificationController::class, 'notice'])->name('verification.notice');
|
||||||
|
Route::get('email/verify/{id}/{hash}', [EmailVerificationController::class, 'verify'])->middleware('signed')->name('verification.verify');
|
||||||
|
Route::post('email/verification-notification', [EmailVerificationController::class, 'resend'])->middleware('throttle:6,1')->name('verification.send');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,24 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\DashboardController;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
|
|
||||||
Route::get('/', function () {
|
Route::get('/', function () {
|
||||||
|
if (Auth::check()) {
|
||||||
|
return redirect(config('auth-ui.redirects.login', '/dashboard'));
|
||||||
|
}
|
||||||
|
|
||||||
return Inertia::render('Welcome', [
|
return Inertia::render('Welcome', [
|
||||||
'appName' => config('app.name'),
|
'appName' => config('app.name'),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$authMiddleware = config('auth-ui.features.email_verification')
|
||||||
|
? ['auth', 'verified']
|
||||||
|
: ['auth'];
|
||||||
|
|
||||||
|
Route::middleware($authMiddleware)->group(function () {
|
||||||
|
Route::get('/dashboard', DashboardController::class)->name('dashboard');
|
||||||
|
});
|
||||||
|
|||||||
154
tests/Feature/Auth/EmailVerificationTest.php
Normal file
154
tests/Feature/Auth/EmailVerificationTest.php
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Auth\Notifications\VerifyEmail;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Notification;
|
||||||
|
use Illuminate\Support\Facades\URL;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('does not send verification email when feature is disabled', function () {
|
||||||
|
config(['auth-ui.features.email_verification' => false]);
|
||||||
|
|
||||||
|
Notification::fake();
|
||||||
|
|
||||||
|
$this->post('/register', [
|
||||||
|
'username' => 'testuser',
|
||||||
|
'first_name' => 'Test',
|
||||||
|
'last_name' => 'User',
|
||||||
|
'email' => 'test@example.com',
|
||||||
|
'password' => 'password123',
|
||||||
|
'password_confirmation' => 'password123',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Notification::assertNothingSent();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends verification email when feature is enabled', function () {
|
||||||
|
config(['auth-ui.features.email_verification' => true]);
|
||||||
|
|
||||||
|
Notification::fake();
|
||||||
|
|
||||||
|
$this->post('/register', [
|
||||||
|
'username' => 'testuser',
|
||||||
|
'first_name' => 'Test',
|
||||||
|
'last_name' => 'User',
|
||||||
|
'email' => 'test@example.com',
|
||||||
|
'password' => 'password123',
|
||||||
|
'password_confirmation' => 'password123',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = User::where('email', 'test@example.com')->first();
|
||||||
|
|
||||||
|
Notification::assertSentTo($user, VerifyEmail::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows verification notice to unverified users when feature is enabled', function () {
|
||||||
|
config(['auth-ui.features.email_verification' => true]);
|
||||||
|
|
||||||
|
$user = User::factory()->unverified()->create();
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get('/email/verify')
|
||||||
|
->assertSuccessful();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redirects verified users from verification notice', function () {
|
||||||
|
config(['auth-ui.features.email_verification' => true]);
|
||||||
|
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get('/email/verify')
|
||||||
|
->assertRedirect('/dashboard');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verifies email with valid signed url', function () {
|
||||||
|
config(['auth-ui.features.email_verification' => true]);
|
||||||
|
|
||||||
|
$user = User::factory()->unverified()->create();
|
||||||
|
|
||||||
|
$verificationUrl = URL::temporarySignedRoute(
|
||||||
|
'verification.verify',
|
||||||
|
now()->addMinutes(60),
|
||||||
|
['id' => $user->id, 'hash' => sha1($user->email)]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get($verificationUrl)
|
||||||
|
->assertRedirect('/dashboard');
|
||||||
|
|
||||||
|
expect($user->fresh()->hasVerifiedEmail())->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects verification with invalid hash', function () {
|
||||||
|
config(['auth-ui.features.email_verification' => true]);
|
||||||
|
|
||||||
|
$user = User::factory()->unverified()->create();
|
||||||
|
|
||||||
|
$verificationUrl = URL::temporarySignedRoute(
|
||||||
|
'verification.verify',
|
||||||
|
now()->addMinutes(60),
|
||||||
|
['id' => $user->id, 'hash' => sha1('wrong@email.com')]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get($verificationUrl)
|
||||||
|
->assertForbidden();
|
||||||
|
|
||||||
|
expect($user->fresh()->hasVerifiedEmail())->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows resending verification email', function () {
|
||||||
|
config(['auth-ui.features.email_verification' => true]);
|
||||||
|
|
||||||
|
Notification::fake();
|
||||||
|
|
||||||
|
$user = User::factory()->unverified()->create();
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->post('/email/verification-notification')
|
||||||
|
->assertRedirect()
|
||||||
|
->assertSessionHas('status', 'verification-link-sent');
|
||||||
|
|
||||||
|
Notification::assertSentTo($user, VerifyEmail::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not register verification routes when feature is disabled', function () {
|
||||||
|
config(['auth-ui.features.email_verification' => false]);
|
||||||
|
|
||||||
|
$user = User::factory()->unverified()->create();
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->get('/email/verify')
|
||||||
|
->assertNotFound();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redirects to verification notice after registration when feature is enabled', function () {
|
||||||
|
config(['auth-ui.features.email_verification' => true]);
|
||||||
|
|
||||||
|
Notification::fake();
|
||||||
|
|
||||||
|
$this->post('/register', [
|
||||||
|
'username' => 'newuser',
|
||||||
|
'first_name' => 'New',
|
||||||
|
'last_name' => 'User',
|
||||||
|
'email' => 'new@example.com',
|
||||||
|
'password' => 'password123',
|
||||||
|
'password_confirmation' => 'password123',
|
||||||
|
])->assertRedirect('/email/verify');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redirects to home after registration when feature is disabled', function () {
|
||||||
|
config(['auth-ui.features.email_verification' => false]);
|
||||||
|
|
||||||
|
$this->post('/register', [
|
||||||
|
'username' => 'newuser',
|
||||||
|
'first_name' => 'New',
|
||||||
|
'last_name' => 'User',
|
||||||
|
'email' => 'new@example.com',
|
||||||
|
'password' => 'password123',
|
||||||
|
'password_confirmation' => 'password123',
|
||||||
|
])->assertRedirect('/dashboard');
|
||||||
|
});
|
||||||
114
tests/Feature/Auth/LoginTest.php
Normal file
114
tests/Feature/Auth/LoginTest.php
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('allows login with email and password', function () {
|
||||||
|
$user = User::factory()->create([
|
||||||
|
'email' => 'test@example.com',
|
||||||
|
'password' => 'password123',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->post('/login', [
|
||||||
|
'login' => 'test@example.com',
|
||||||
|
'password' => 'password123',
|
||||||
|
])->assertRedirect('/dashboard');
|
||||||
|
|
||||||
|
$this->assertAuthenticatedAs($user);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows login with username and password', function () {
|
||||||
|
$user = User::factory()->create([
|
||||||
|
'username' => 'testuser',
|
||||||
|
'password' => 'password123',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->post('/login', [
|
||||||
|
'login' => 'testuser',
|
||||||
|
'password' => 'password123',
|
||||||
|
])->assertRedirect('/dashboard');
|
||||||
|
|
||||||
|
$this->assertAuthenticatedAs($user);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows case-insensitive username login', function () {
|
||||||
|
$user = User::factory()->create([
|
||||||
|
'username' => 'TestUser',
|
||||||
|
'password' => 'password123',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->post('/login', [
|
||||||
|
'login' => 'testuser',
|
||||||
|
'password' => 'password123',
|
||||||
|
])->assertRedirect('/dashboard');
|
||||||
|
|
||||||
|
$this->assertAuthenticatedAs($user);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects login with wrong password', function () {
|
||||||
|
User::factory()->create([
|
||||||
|
'email' => 'test@example.com',
|
||||||
|
'password' => 'password123',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->post('/login', [
|
||||||
|
'login' => 'test@example.com',
|
||||||
|
'password' => 'wrongpassword',
|
||||||
|
])->assertSessionHasErrors('login');
|
||||||
|
|
||||||
|
$this->assertGuest();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects login with nonexistent user', function () {
|
||||||
|
$this->post('/login', [
|
||||||
|
'login' => 'nobody@example.com',
|
||||||
|
'password' => 'password123',
|
||||||
|
])->assertSessionHasErrors('login');
|
||||||
|
|
||||||
|
$this->assertGuest();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks password login for social-only users', function () {
|
||||||
|
User::factory()->social()->create([
|
||||||
|
'email' => 'social@example.com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->post('/login', [
|
||||||
|
'login' => 'social@example.com',
|
||||||
|
'password' => 'anything',
|
||||||
|
])->assertSessionHasErrors('login');
|
||||||
|
|
||||||
|
$this->assertGuest();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks password login for social-only users with any password attempt', function () {
|
||||||
|
User::factory()->social()->create([
|
||||||
|
'email' => 'social@example.com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$passwords = ['', 'password', 'null', '0', 'false'];
|
||||||
|
|
||||||
|
foreach ($passwords as $password) {
|
||||||
|
$this->post('/login', [
|
||||||
|
'login' => 'social@example.com',
|
||||||
|
'password' => $password,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertGuest();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks password login for social-only users via username', function () {
|
||||||
|
User::factory()->social()->create([
|
||||||
|
'username' => 'socialuser',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->post('/login', [
|
||||||
|
'login' => 'socialuser',
|
||||||
|
'password' => 'anything',
|
||||||
|
])->assertSessionHasErrors('login');
|
||||||
|
|
||||||
|
$this->assertGuest();
|
||||||
|
});
|
||||||
254
tests/Feature/Auth/SocialLoginSecurityTest.php
Normal file
254
tests/Feature/Auth/SocialLoginSecurityTest.php
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\SocialAccount;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Laravel\Socialite\Facades\Socialite;
|
||||||
|
use Laravel\Socialite\Two\User as SocialiteUser;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
config(['auth-ui.providers.github' => [
|
||||||
|
'label' => 'GitHub',
|
||||||
|
'icon' => 'i-simple-icons-github',
|
||||||
|
'enabled' => true,
|
||||||
|
]]);
|
||||||
|
config(['auth-ui.providers.google' => [
|
||||||
|
'label' => 'Google',
|
||||||
|
'icon' => 'i-simple-icons-google',
|
||||||
|
'enabled' => true,
|
||||||
|
]]);
|
||||||
|
config(['auth-ui.features.registration' => true]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates social users without a password', function () {
|
||||||
|
$user = User::factory()->social()->create();
|
||||||
|
|
||||||
|
expect($user->password)->toBeNull();
|
||||||
|
expect($user->hasPassword())->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates regular users with a password', function () {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
expect($user->password)->not->toBeNull();
|
||||||
|
expect($user->hasPassword())->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prevents brute force on social-only accounts', function () {
|
||||||
|
$user = User::factory()->social()->create([
|
||||||
|
'email' => 'social@example.com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$attempts = ['password', '123456', 'admin', $user->email, $user->username, ''];
|
||||||
|
|
||||||
|
foreach ($attempts as $password) {
|
||||||
|
$this->post('/login', [
|
||||||
|
'login' => 'social@example.com',
|
||||||
|
'password' => $password,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertGuest();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not expose whether a user is social-only via login error', function () {
|
||||||
|
User::factory()->social()->create(['email' => 'social@example.com']);
|
||||||
|
User::factory()->create(['email' => 'regular@example.com', 'password' => 'password123']);
|
||||||
|
|
||||||
|
$socialResponse = $this->post('/login', [
|
||||||
|
'login' => 'social@example.com',
|
||||||
|
'password' => 'wrongpassword',
|
||||||
|
]);
|
||||||
|
$socialErrors = $socialResponse->assertSessionHasErrors('login')
|
||||||
|
->getSession()->get('errors')->get('login');
|
||||||
|
|
||||||
|
$this->refreshApplication();
|
||||||
|
|
||||||
|
$regularErrors = $this->post('/login', [
|
||||||
|
'login' => 'regular@example.com',
|
||||||
|
'password' => 'wrongpassword',
|
||||||
|
])->assertSessionHasErrors('login')
|
||||||
|
->getSession()->get('errors')->get('login');
|
||||||
|
|
||||||
|
expect($socialErrors)->toEqual($regularErrors);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows social user to set a password later and login with it', function () {
|
||||||
|
$user = User::factory()->social()->create([
|
||||||
|
'email' => 'social@example.com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($user->hasPassword())->toBeFalse();
|
||||||
|
|
||||||
|
$user->update(['password' => 'newpassword123']);
|
||||||
|
|
||||||
|
expect($user->fresh()->hasPassword())->toBeTrue();
|
||||||
|
|
||||||
|
$this->post('/login', [
|
||||||
|
'login' => 'social@example.com',
|
||||||
|
'password' => 'newpassword123',
|
||||||
|
])->assertRedirect('/dashboard');
|
||||||
|
|
||||||
|
$this->assertAuthenticatedAs($user);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a social account record on first social login', function () {
|
||||||
|
Socialite::fake('github', (new SocialiteUser)->map([
|
||||||
|
'id' => 'github-123',
|
||||||
|
'name' => 'John Doe',
|
||||||
|
'email' => 'john@example.com',
|
||||||
|
'nickname' => 'johndoe',
|
||||||
|
]));
|
||||||
|
|
||||||
|
$this->get('/auth/github/callback');
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('social_accounts', [
|
||||||
|
'provider' => 'github',
|
||||||
|
'provider_id' => 'github-123',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = User::where('email', 'john@example.com')->first();
|
||||||
|
expect($user->socialAccounts)->toHaveCount(1);
|
||||||
|
expect($user->hasPassword())->toBeFalse();
|
||||||
|
expect($user->hasVerifiedEmail())->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('links provider to existing verified user on social login', function () {
|
||||||
|
$user = User::factory()->create([
|
||||||
|
'email' => 'john@example.com',
|
||||||
|
'password' => 'password123',
|
||||||
|
'email_verified_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Socialite::fake('github', (new SocialiteUser)->map([
|
||||||
|
'id' => 'github-456',
|
||||||
|
'name' => 'John Doe',
|
||||||
|
'email' => 'john@example.com',
|
||||||
|
'nickname' => 'johndoe',
|
||||||
|
]));
|
||||||
|
|
||||||
|
$this->get('/auth/github/callback');
|
||||||
|
|
||||||
|
$this->assertAuthenticatedAs($user);
|
||||||
|
expect($user->fresh()->socialAccounts)->toHaveCount(1);
|
||||||
|
expect($user->fresh()->hasPassword())->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects social login linking to unverified existing user', function () {
|
||||||
|
User::factory()->unverified()->create([
|
||||||
|
'email' => 'unverified@example.com',
|
||||||
|
'password' => 'password123',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Socialite::fake('github', (new SocialiteUser)->map([
|
||||||
|
'id' => 'github-hijack',
|
||||||
|
'name' => 'Attacker',
|
||||||
|
'email' => 'unverified@example.com',
|
||||||
|
'nickname' => 'attacker',
|
||||||
|
]));
|
||||||
|
|
||||||
|
$this->get('/auth/github/callback')->assertRedirect('/login');
|
||||||
|
|
||||||
|
$this->assertGuest();
|
||||||
|
$this->assertDatabaseMissing('social_accounts', [
|
||||||
|
'provider_id' => 'github-hijack',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recognizes returning social user by provider id', function () {
|
||||||
|
$user = User::factory()->social()->create([
|
||||||
|
'email' => 'john@example.com',
|
||||||
|
]);
|
||||||
|
$user->socialAccounts()->create([
|
||||||
|
'provider' => 'github',
|
||||||
|
'provider_id' => 'github-789',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Socialite::fake('github', (new SocialiteUser)->map([
|
||||||
|
'id' => 'github-789',
|
||||||
|
'name' => 'John Doe',
|
||||||
|
'email' => 'john@example.com',
|
||||||
|
'nickname' => 'johndoe',
|
||||||
|
]));
|
||||||
|
|
||||||
|
$this->get('/auth/github/callback');
|
||||||
|
|
||||||
|
$this->assertAuthenticatedAs($user);
|
||||||
|
expect(SocialAccount::where('provider', 'github')->where('provider_id', 'github-789')->count())->toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prevents different provider with same email from creating duplicate user', function () {
|
||||||
|
$user = User::factory()->social()->create([
|
||||||
|
'email' => 'john@example.com',
|
||||||
|
'email_verified_at' => now(),
|
||||||
|
]);
|
||||||
|
$user->socialAccounts()->create([
|
||||||
|
'provider' => 'github',
|
||||||
|
'provider_id' => 'github-100',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Socialite::fake('google', (new SocialiteUser)->map([
|
||||||
|
'id' => 'google-200',
|
||||||
|
'name' => 'John Doe',
|
||||||
|
'email' => 'john@example.com',
|
||||||
|
'nickname' => 'johndoe',
|
||||||
|
]));
|
||||||
|
|
||||||
|
$this->get('/auth/google/callback');
|
||||||
|
|
||||||
|
$this->assertAuthenticatedAs($user);
|
||||||
|
expect(User::where('email', 'john@example.com')->count())->toBe(1);
|
||||||
|
expect($user->fresh()->socialAccounts)->toHaveCount(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates social account when completing profile after social login', function () {
|
||||||
|
session()->put('socialite_user', [
|
||||||
|
'email' => 'social@example.com',
|
||||||
|
'first_name' => 'Social',
|
||||||
|
'last_name' => 'User',
|
||||||
|
'suggested_username' => 'socialuser',
|
||||||
|
'provider' => 'github',
|
||||||
|
'provider_id' => 'github-complete-123',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->post('/complete-profile', [
|
||||||
|
'username' => 'newsocialuser',
|
||||||
|
'first_name' => 'Social',
|
||||||
|
'last_name' => 'User',
|
||||||
|
])->assertRedirect('/dashboard');
|
||||||
|
|
||||||
|
$user = User::where('email', 'social@example.com')->first();
|
||||||
|
expect($user)->not->toBeNull();
|
||||||
|
expect($user->socialAccounts)->toHaveCount(1);
|
||||||
|
expect($user->socialAccounts->first()->provider)->toBe('github');
|
||||||
|
expect($user->socialAccounts->first()->provider_id)->toBe('github-complete-123');
|
||||||
|
expect($user->hasVerifiedEmail())->toBeTrue();
|
||||||
|
expect($user->hasPassword())->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects social login for disabled provider', function () {
|
||||||
|
Socialite::fake('github');
|
||||||
|
|
||||||
|
config(['auth-ui.providers.github.enabled' => false]);
|
||||||
|
|
||||||
|
$this->get('/auth/github/callback')->assertNotFound();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects social registration when registration is disabled', function () {
|
||||||
|
config(['auth-ui.features.registration' => false]);
|
||||||
|
|
||||||
|
Socialite::fake('github', (new SocialiteUser)->map([
|
||||||
|
'id' => 'github-new',
|
||||||
|
'name' => 'New User',
|
||||||
|
'email' => 'new@example.com',
|
||||||
|
'nickname' => 'newuser',
|
||||||
|
]));
|
||||||
|
|
||||||
|
$this->get('/auth/github/callback')->assertRedirect('/login');
|
||||||
|
|
||||||
|
$this->assertDatabaseMissing('users', [
|
||||||
|
'email' => 'new@example.com',
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -1,19 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace Tests\Feature;
|
test('the application returns a successful response', function () {
|
||||||
|
$this->get('/')->assertSuccessful();
|
||||||
// use Illuminate\Foundation\Testing\RefreshDatabase;
|
});
|
||||||
use Tests\TestCase;
|
|
||||||
|
|
||||||
class ExampleTest extends TestCase
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* A basic test example.
|
|
||||||
*/
|
|
||||||
public function test_the_application_returns_a_successful_response(): void
|
|
||||||
{
|
|
||||||
$response = $this->get('/');
|
|
||||||
|
|
||||||
$response->assertStatus(200);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user