Compare commits

...

19 Commits

Author SHA1 Message Date
596588241b fix: only apply verified middleware when email verification is enabled 2026-03-22 17:55:30 +01:00
a56aca0050 chore: update env example, gitignore, readme, and deps 2026-03-19 23:17:48 +01:00
2351718939 test: add auth, social login, email verification, and validation tests 2026-03-19 23:17:32 +01:00
dd1e3d9053 refactor: extract validation schemas to shared module 2026-03-19 23:15:49 +01:00
4ea87c0cf7 feat: add dashboard page with Nuxt UI components and verified middleware 2026-03-19 23:15:23 +01:00
a096704b0b feat: add optional email verification flow 2026-03-19 23:15:07 +01:00
1b9bf0efac feat: add social_accounts table and provider tracking with verified-email linking 2026-03-19 23:15:01 +01:00
10f612a901 feat: make user password nullable for social-only accounts 2026-03-19 23:14:57 +01:00
44c1cbe5f6 refactor: use form requests in auth controllers, remove double hashing 2026-03-19 23:14:52 +01:00
da97c45dd4 feat: add form request classes for all auth controllers 2026-03-19 23:14:33 +01:00
e18062864d chore: upgrade vite to v8, laravel-vite-plugin to v3, remove axios, and bump all npm deps 2026-03-19 22:02:31 +01:00
edc1da3e9f chore: publish Inertia v3 config 2026-03-19 21:57:02 +01:00
86f6af1012 chore: remove axios from bootstrap.js 2026-03-19 21:57:01 +01:00
3a481f4258 chore: upgrade @inertiajs/vue3 to v3 and remove axios 2026-03-19 21:57:00 +01:00
3d9a2f8778 chore: upgrade inertiajs/inertia-laravel to v3 2026-03-19 21:56:59 +01:00
5de7151b08 chore: update session cookie name to use Str::snake per Laravel 13 2026-03-19 21:53:44 +01:00
e311fa5b7e chore: add serializable_classes security option to cache config 2026-03-19 21:53:43 +01:00
97d8a2b4c2 chore: rename ValidateCsrfToken to PreventRequestForgery 2026-03-19 21:53:43 +01:00
dd83cd33c1 chore: upgrade laravel/framework to ^13.0 and laravel/tinker to ^3.0 2026-03-19 21:53:40 +01:00
50 changed files with 3864 additions and 1570 deletions

View File

@@ -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
View File

@@ -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
View File

@@ -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`.

View File

@@ -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');

View 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');
}
}

View File

@@ -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) {

View File

@@ -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'),
]); ]);

View File

@@ -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', '/'));
} }
} }

View File

@@ -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();

View File

@@ -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();

View 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');
}
}

View 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'],
];
}
}

View 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'],
];
}
}

View 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'],
];
}
}

View 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()],
];
}
}

View 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()],
];
}
}

View 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);
}
}

View File

@@ -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.
*/ */

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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'),
], ],
/* /*

View File

@@ -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
View 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),
],
];

View File

@@ -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,
], ],
]; ];

View File

@@ -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'
), ),
/* /*

View File

@@ -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,
]);
}
} }

View File

@@ -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();
});
}
};

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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"
} }
} }

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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>

View 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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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')
})
})

View 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')
})
})

View 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>

View 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)
})
})

View 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')),
})

View File

@@ -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');
}); });

View File

@@ -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');
});

View 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');
});

View 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();
});

View 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',
]);
});

View File

@@ -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);
}
}