Refactor project structure and update dependencies

main
Flycro 2025-05-11 16:17:40 +02:00
parent 81d692a19b
commit 813f7b18d8
73 changed files with 10156 additions and 7756 deletions

2
.gitignore vendored
View File

@ -49,3 +49,5 @@ logs
/caddy /caddy
frankenphp frankenphp
frankenphp-worker.php frankenphp-worker.php
**/caddy

1
.npmrc
View File

@ -1 +1,2 @@
node-linker=hoisted node-linker=hoisted
shamefully-hoist=true

View File

@ -2,14 +2,13 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Http\Requests\Auth\LoginRequest;
use App\Models\User; use App\Models\User;
use App\Models\UserProvider; use App\Models\UserProvider;
use Browser;
use Illuminate\Auth\Events\PasswordReset; use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Auth\Events\Registered; use Illuminate\Auth\Events\Registered;
use Illuminate\Auth\Events\Verified; use Illuminate\Auth\Events\Verified;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
@ -29,7 +28,7 @@ class AuthController extends Controller
{ {
$request->validate([ $request->validate([
'name' => ['required', 'string', 'max:255'], 'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class], 'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:' . User::class],
'password' => ['required', 'confirmed', Rules\Password::defaults()], 'password' => ['required', 'confirmed', Rules\Password::defaults()],
]); ]);
@ -54,19 +53,20 @@ class AuthController extends Controller
/** /**
* Redirect to provider for authentication * Redirect to provider for authentication
*/ */
public function redirect(Request $request, $provider) public function redirect(Request $request, string $provider): RedirectResponse
{ {
return Socialite::driver($provider)->stateless()->redirect(); return Socialite::driver($provider)->stateless()->redirect();
} }
/** /**
* Handle callback from provider * Handle callback from provider
* @throws \Exception
*/ */
public function callback(Request $request, string $provider): View public function callback(Request $request, string $provider): View
{ {
$oAuthUser = Socialite::driver($provider)->stateless()->user(); $oAuthUser = Socialite::driver($provider)->stateless()->user();
if (! $oAuthUser?->token) { if (!$oAuthUser?->token) {
return view('oauth', [ return view('oauth', [
'message' => [ 'message' => [
'ok' => false, 'ok' => false,
@ -80,7 +80,7 @@ class AuthController extends Controller
->where('provider_id', $oAuthUser->id) ->where('provider_id', $oAuthUser->id)
->first(); ->first();
if (! $userProvider) { if (!$userProvider) {
if (User::where('email', $oAuthUser->email)->exists()) { if (User::where('email', $oAuthUser->email)->exists()) {
return view('oauth', [ return view('oauth', [
'message' => [ 'message' => [
@ -98,8 +98,7 @@ class AuthController extends Controller
$user->avatar = $oAuthUser->picture ?? $oAuthUser->avatar_original ?? $oAuthUser->avatar; $user->avatar = $oAuthUser->picture ?? $oAuthUser->avatar_original ?? $oAuthUser->avatar;
$user->name = $oAuthUser->name; $user->name = $oAuthUser->name;
$user->email = $oAuthUser->email; $user->email = $oAuthUser->email;
$user->password = Hash::make(Str::random(32)); $user->password = null;
$user->has_password = false;
$user->email_verified_at = now(); $user->email_verified_at = now();
$user->save(); $user->save();
@ -113,53 +112,49 @@ class AuthController extends Controller
$user = $userProvider->user; $user = $userProvider->user;
} }
$browser = Browser::parse($request->userAgent()); $token = $user->createDeviceToken(
$device = $browser->platformName() . ' / ' . $browser->browserName(); device: $request->deviceName(),
ip: $request->ip(),
$sanctumToken = $user->createToken( remember: $request->input('remember', false)
$device,
['*'],
now()->addMonth()
); );
$sanctumToken->accessToken->ip = $request->ip();
$sanctumToken->accessToken->save();
return view('oauth', [ return view('oauth', [
'message' => [ 'message' => [
'ok' => true, 'ok' => true,
'provider' => $provider, 'provider' => $provider,
'token' => $sanctumToken->plainTextToken, 'token' => $token,
], ],
]); ]);
} }
/** /**
* Generate sanctum token on successful login * Generate sanctum token on successful login
* @throws ValidationException
*/ */
public function login(LoginRequest $request): JsonResponse public function login(Request $request): JsonResponse
{ {
$user = User::where('email', $request->email)->first(); $request->validate([
'email' => ['required', 'string', 'email'],
'password' => ['required', 'string'],
]);
$request->authenticate($user); $user = User::select(['id', 'password'])->where('email', $request->email)->first();
$browser = Browser::parse($request->userAgent()); if (!$user || !Hash::check($request->password, $user->password)) {
$device = $browser->platformName() . ' / ' . $browser->browserName(); throw ValidationException::withMessages([
'email' => __('auth.failed'),
]);
}
$sanctumToken = $user->createToken( $token = $user->createDeviceToken(
$device, device: $request->deviceName(),
['*'], ip: $request->ip(),
$request->remember ? remember: $request->input('remember', false)
now()->addMonth() :
now()->addDay()
); );
$sanctumToken->accessToken->ip = $request->ip();
$sanctumToken->accessToken->save();
return response()->json([ return response()->json([
'ok' => true, 'ok' => true,
'token' => $sanctumToken->plainTextToken, 'token' => $token,
]); ]);
} }
@ -187,6 +182,7 @@ class AuthController extends Controller
'user' => [ 'user' => [
...$user->toArray(), ...$user->toArray(),
'must_verify_email' => $user->mustVerifyEmail(), 'must_verify_email' => $user->mustVerifyEmail(),
'has_password' => (bool) $user->password,
'roles' => $user->roles()->select('name')->pluck('name'), 'roles' => $user->roles()->select('name')->pluck('name'),
'providers' => $user->userProviders()->select('name')->pluck('name'), 'providers' => $user->userProviders()->select('name')->pluck('name'),
], ],
@ -195,6 +191,7 @@ class AuthController extends Controller
/** /**
* Handle an incoming password reset link request. * Handle an incoming password reset link request.
* @throws ValidationException
*/ */
public function sendResetPasswordLink(Request $request): JsonResponse public function sendResetPasswordLink(Request $request): JsonResponse
{ {
@ -209,7 +206,7 @@ class AuthController extends Controller
$request->only('email') $request->only('email')
); );
if ($status != Password::RESET_LINK_SENT) { if ($status !== Password::RESET_LINK_SENT) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'email' => [__($status)], 'email' => [__($status)],
]); ]);
@ -223,6 +220,7 @@ class AuthController extends Controller
/** /**
* Handle an incoming new password request. * Handle an incoming new password request.
* @throws ValidationException
*/ */
public function resetPassword(Request $request): JsonResponse public function resetPassword(Request $request): JsonResponse
{ {
@ -237,18 +235,17 @@ class AuthController extends Controller
// database. Otherwise we will parse the error and return the response. // database. Otherwise we will parse the error and return the response.
$status = Password::reset( $status = Password::reset(
$request->only('email', 'password', 'password_confirmation', 'token'), $request->only('email', 'password', 'password_confirmation', 'token'),
function ($user) use ($request) { static function ($user) use ($request) {
$user->forceFill([ $user->forceFill([
'password' => Hash::make($request->password), 'password' => Hash::make($request->password),
'remember_token' => Str::random(60), 'remember_token' => Str::random(60),
'has_password' => true,
])->save(); ])->save();
event(new PasswordReset($user)); event(new PasswordReset($user));
} }
); );
if ($status != Password::PASSWORD_RESET) { if ($status !== Password::PASSWORD_RESET) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'email' => [__($status)], 'email' => [__($status)],
]); ]);
@ -263,14 +260,14 @@ class AuthController extends Controller
/** /**
* Mark the authenticated user's email address as verified. * Mark the authenticated user's email address as verified.
*/ */
public function verifyEmail(Request $request, $ulid, $hash): JsonResponse public function verifyEmail(Request $request, string $ulid, string $hash): JsonResponse
{ {
$user = User::whereUlid($ulid)->first(); $user = User::where('ulid', $ulid)->first();
abort_unless(!!$user, 404); abort_if(!$user, 404);
abort_unless(hash_equals(sha1($user->getEmailForVerification()), $hash), 403, __('Invalid verification link')); abort_if(!hash_equals(sha1($user->getEmailForVerification()), $hash), 403, __('Invalid verification link'));
if (! $user->hasVerifiedEmail()) { if (!$user->hasVerifiedEmail()) {
$user->markEmailAsVerified(); $user->markEmailAsVerified();
event(new Verified($user)); event(new Verified($user));
@ -290,8 +287,9 @@ class AuthController extends Controller
'email' => ['required', 'email'], 'email' => ['required', 'email'],
]); ]);
$user = User::where('email', $request->email)->whereNull('email_verified_at')->first(); $user = $request->user()?: User::where('email', $request->email)->whereNull('email_verified_at')->first();
abort_unless(!!$user, 400);
abort_if(!$user, 400);
$user->sendEmailVerificationNotification(); $user->sendEmailVerificationNotification();

View File

@ -1,85 +0,0 @@
<?php
namespace App\Http\Requests\Auth;
use Illuminate\Auth\Events\Lockout;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
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, \Illuminate\Contracts\Validation\Rule|array|string>
*/
public function rules(): array
{
return [
'email' => ['required', 'string', 'email'],
'password' => ['required', 'string'],
];
}
/**
* Attempt to authenticate the request's credentials.
*
* @throws \Illuminate\Validation\ValidationException
*/
public function authenticate($user): void
{
$this->ensureIsNotRateLimited();
if (! $user || ! Hash::check($this->password, $user->password)) {
RateLimiter::hit($this->throttleKey());
throw ValidationException::withMessages([
'email' => __('auth.failed'),
]);
}
RateLimiter::clear($this->throttleKey());
}
/**
* Ensure the login request is not rate limited.
*
* @throws \Illuminate\Validation\ValidationException
*/
public function ensureIsNotRateLimited(): void
{
if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
return;
}
event(new Lockout($this));
$seconds = RateLimiter::availableIn($this->throttleKey());
throw ValidationException::withMessages([
'email' => trans('auth.throttle', [
'seconds' => $seconds,
'minutes' => ceil($seconds / 60),
]),
]);
}
/**
* Get the rate limiting throttle key for the request.
*/
public function throttleKey(): string
{
return Str::transliterate(Str::lower($this->input('email')).'|'.$this->ip());
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace App\Models;
use Laravel\Sanctum\PersonalAccessToken as SanctumPersonalAccessToken;
use Illuminate\Database\Eloquent\Casts\Attribute;
class PersonalAccessToken extends SanctumPersonalAccessToken
{
/**
* Update the last_used_at field no more than 1 time per minute.
* This change increases the performance of HTTP requests requiring sanctum authentication.
*/
protected function lastUsedAt(): Attribute
{
return Attribute::make(
set: fn (string $value) => $this->getOriginal('last_used_at') < now()->parse($value)->subMinute()
? $value
: $this->getOriginal('last_used_at'),
);
}
}

View File

@ -59,6 +59,22 @@ class User extends Authenticatable implements MustVerifyEmail
public function mustVerifyEmail(): bool public function mustVerifyEmail(): bool
{ {
return $this instanceof MustVerifyEmail && !$this->hasVerifiedEmail(); return !$this->hasVerifiedEmail();
}
public function createDeviceToken(string $device, string $ip, bool $remember = false): string
{
$sanctumToken = $this->createToken(
$device,
['*'],
$remember ?
now()->addMonth() :
now()->addDay()
);
$sanctumToken->accessToken->ip = $ip;
$sanctumToken->accessToken->save();
return $sanctumToken->plainTextToken;
} }
} }

View File

@ -3,14 +3,18 @@
namespace App\Providers; namespace App\Providers;
use App\Helpers\Image; use App\Helpers\Image;
use Illuminate\Http\UploadedFile; use App\Models\PersonalAccessToken;
use Illuminate\Support\ServiceProvider; use Illuminate\Auth\Events\Lockout;
use Illuminate\Support\Str;
use Illuminate\Auth\Notifications\ResetPassword; use Illuminate\Auth\Notifications\ResetPassword;
use Illuminate\Auth\Notifications\VerifyEmail; use Illuminate\Auth\Notifications\VerifyEmail;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Laravel\Sanctum\Sanctum;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
{ {
@ -20,7 +24,7 @@ class AppServiceProvider extends ServiceProvider
public function register(): void public function register(): void
{ {
// Register Telescope only in local environment // Register Telescope only in local environment
if ($this->app->environment('local') && class_exists(\Laravel\Telescope\TelescopeServiceProvider::class)) { if (class_exists(\Laravel\Telescope\TelescopeServiceProvider::class) && $this->app->environment('local')) {
$this->app->register(\Laravel\Telescope\TelescopeServiceProvider::class); $this->app->register(\Laravel\Telescope\TelescopeServiceProvider::class);
$this->app->register(TelescopeServiceProvider::class); $this->app->register(TelescopeServiceProvider::class);
} }
@ -31,25 +35,43 @@ class AppServiceProvider extends ServiceProvider
*/ */
public function boot(): void public function boot(): void
{ {
RateLimiter::for('api', function (Request $request) { RateLimiter::for('api', static function (Request $request) {
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip()); return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
}); });
RateLimiter::for('verification-notification', function (Request $request) { RateLimiter::for('verification-notification', static function (Request $request) {
return Limit::perMinute(1)->by($request->user()?->email ?: $request->ip()); return Limit::perMinute(1)->by($request->user()?->email ?: $request->ip());
}); });
RateLimiter::for('uploads', function (Request $request) { RateLimiter::for('uploads', static function (Request $request) {
return $request->user()?->hasRole('admin') return $request->user()?->hasRole('admin')
? Limit::none() ? Limit::none()
: Limit::perMinute(10)->by($request->ip()); : Limit::perMinute(10)->by($request->ip());
}); });
ResetPassword::createUrlUsing(function (object $notifiable, string $token) { RateLimiter::for('login', static function (Request $request) {
return config('app.frontend_url') . "/password-reset/{$token}?email={$notifiable->getEmailForPasswordReset()}"; return Limit::perMinute(5)
->by(Str::transliterate(implode('|', [
strtolower($request->input('email')),
$request->ip()
])))
->response(static function (Request $request, array $headers): void {
event(new Lockout($request));
throw ValidationException::withMessages([
'email' => trans('auth.throttle', [
'seconds' => $headers['Retry-After'],
'minutes' => ceil($headers['Retry-After'] / 60),
]),
]);
});
}); });
VerifyEmail::createUrlUsing(function (object $notifiable) { ResetPassword::createUrlUsing(static function (object $notifiable, string $token) {
return config('app.frontend_url') . '/auth/reset/' . $token . '?email=' . $notifiable->getEmailForPasswordReset();
});
VerifyEmail::createUrlUsing(static function (object $notifiable) {
$url = url()->temporarySignedRoute( $url = url()->temporarySignedRoute(
'verification.verify', 'verification.verify',
now()->addMinutes(config('auth.verification.expire', 60)), now()->addMinutes(config('auth.verification.expire', 60)),
@ -65,8 +87,8 @@ class AppServiceProvider extends ServiceProvider
/** /**
* Convert uploaded image to webp, jpeg or png format and resize it * Convert uploaded image to webp, jpeg or png format and resize it
*/ */
UploadedFile::macro('convert', function (int $width = null, int $height = null, string $extension = 'webp', int $quality = 90): UploadedFile { UploadedFile::macro('convert', function (?int $width = null, ?int $height = null, string $extension = 'webp', int $quality = 90) {
return tap($this, function (UploadedFile $file) use ($width, $height, $extension, $quality) { return tap($this, static function (UploadedFile $file) use ($width, $height, $extension, $quality) {
Image::convert($file->path(), $file->path(), $width, $height, $extension, $quality); Image::convert($file->path(), $file->path(), $width, $height, $extension, $quality);
}); });
}); });
@ -74,10 +96,23 @@ class AppServiceProvider extends ServiceProvider
/** /**
* Remove all special characters from a string * Remove all special characters from a string
*/ */
Str::macro('onlyWords', function (string $text): string { Str::macro('onlyWords', static function (string $text): string {
// \p{L} matches any kind of letter from any language // \p{L} matches any kind of letter from any language
// \d matches a digit in any script // \d matches a digit in any script
return Str::replaceMatches('/[^\p{L}\d ]/u', '', $text); return Str::replaceMatches('/[^\p{L}\d ]/u', '', $text);
}); });
Request::macro('deviceName', function (): string {
$device = $this->device();
return implode(' / ', array_filter([
trim(implode(' ', [$device->getOs('name'), $device->getOs('version')])),
trim(implode(' ', [$device->getClient('name'), $device->getClient('version')])),
])) ?? 'Unknown';
});
Sanctum::usePersonalAccessTokenModel(
PersonalAccessToken::class
);
} }
} }

View File

@ -21,6 +21,9 @@ return Application::configure(basePath: dirname(__DIR__))
->throttleApi(redis: true) ->throttleApi(redis: true)
->trustProxies(at: [ ->trustProxies(at: [
'127.0.0.1', '127.0.0.1',
'10.0.0.0/8',
'172.16.0.0/12',
'192.168.0.0/16',
]) ])
->api(prepend: [ ->api(prepend: [
JsonResponse::class, JsonResponse::class,
@ -62,7 +65,7 @@ return Application::configure(basePath: dirname(__DIR__))
'message' => $e->getMessage(), 'message' => $e->getMessage(),
'errors' => array_map(function ($field, $errors) { 'errors' => array_map(function ($field, $errors) {
return [ return [
'path' => $field, 'name' => $field,
'message' => implode(' ', $errors), 'message' => implode(' ', $errors),
]; ];
}, array_keys($e->errors()), $e->errors()), }, array_keys($e->errors()), $e->errors()),

View File

@ -2,6 +2,5 @@
return [ return [
App\Providers\AppServiceProvider::class, App\Providers\AppServiceProvider::class,
App\Providers\TelescopeServiceProvider::class,
Spatie\Permission\PermissionServiceProvider::class, Spatie\Permission\PermissionServiceProvider::class,
]; ];

View File

@ -9,14 +9,14 @@
"license": "MIT", "license": "MIT",
"require": { "require": {
"php": "^8.2", "php": "^8.2",
"hisorange/browser-detect": "^5.0",
"intervention/image": "^3.4", "intervention/image": "^3.4",
"laravel/framework": "^11.0", "laravel/framework": "^12.0",
"laravel/octane": "^2.3", "laravel/octane": "^2.3",
"laravel/sanctum": "^4.0", "laravel/sanctum": "^4.0",
"laravel/socialite": "^5.12", "laravel/socialite": "^5.12",
"laravel/tinker": "^2.9", "laravel/tinker": "^2.9",
"league/flysystem-aws-s3-v3": "^3.24", "league/flysystem-aws-s3-v3": "^3.24",
"reefki/laravel-device-detector": "^1.0",
"spatie/laravel-permission": "^6.4" "spatie/laravel-permission": "^6.4"
}, },
"require-dev": { "require-dev": {
@ -27,7 +27,7 @@
"laravel/telescope": "^5.0", "laravel/telescope": "^5.0",
"mockery/mockery": "^1.6", "mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.0", "nunomaduro/collision": "^8.0",
"phpunit/phpunit": "^10.5" "phpunit/phpunit": "^11.0"
}, },
"autoload": { "autoload": {
"psr-4": { "psr-4": {

3491
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1 +1 @@
{"admin":{"listen":"localhost:2019"},"apps":{"frankenphp":{"workers":[{"file_name":"/var/www/html/public/frankenphp-worker.php"}]},"http":{"servers":{"srv0":{"automatic_https":{"disable_redirects":true},"listen":[":8000"],"logs":{"default_logger_name":"log0"},"routes":[{"handle":[{"handler":"subroute","routes":[{"handle":[{"handler":"vars","root":"/var/www/html/public"},{"encodings":{"gzip":{},"zstd":{}},"handler":"encode","prefer":["zstd","gzip"]}]},{"handle":[{"handler":"static_response","headers":{"Location":["{http.request.orig_uri.path}/"]},"status_code":308}],"match":[{"file":{"try_files":["{http.request.uri.path}/frankenphp-worker.php"]},"not":[{"path":["*/"]}]}]},{"handle":[{"handler":"rewrite","uri":"{http.matchers.file.relative}"}],"match":[{"file":{"split_path":[".php"],"try_files":["{http.request.uri.path}","{http.request.uri.path}/frankenphp-worker.php","frankenphp-worker.php"]}}]},{"handle":[{"handler":"php","resolve_root_symlink":true,"split_path":[".php"]}],"match":[{"path":["*.php"]}]},{"handle":[{"handler":"file_server"}]}]}]}]}}}},"logging":{"logs":{"default":{"exclude":["http.log.access.log0"]},"log0":{"encoder":{"fields":{"uri":{"actions":[{"parameter":"authorization","type":"replace","value":"REDACTED"}],"filter":"query"}},"format":"filter","wrap":{"format":"json"}},"include":["http.log.access.log0"],"level":"INFO"}}}} {"admin":{"listen":"localhost:2019"},"apps":{"frankenphp":{"workers":[{"file_name":"/var/www/html/public/frankenphp-worker.php"}]},"http":{"servers":{"srv0":{"automatic_https":{"disable_redirects":true},"listen":[":8000"],"logs":{"default_logger_name":"log0"},"routes":[{"handle":[{"handler":"subroute","routes":[{"handle":[{"handler":"vars","root":"/var/www/html/public"},{"encodings":{"br":{},"gzip":{},"zstd":{}},"handler":"encode","prefer":["zstd","br","gzip"]}]},{"handle":[{"handler":"static_response","headers":{"Location":["{http.request.orig_uri.path}/"]},"status_code":308}],"match":[{"file":{"try_files":["{http.request.uri.path}/frankenphp-worker.php"]},"not":[{"path":["*/"]}]}]},{"handle":[{"handler":"rewrite","uri":"{http.matchers.file.relative}"}],"match":[{"file":{"split_path":[".php"],"try_files":["{http.request.uri.path}","frankenphp-worker.php"]}}]},{"handle":[{"handler":"php","resolve_root_symlink":true,"split_path":[".php"]}],"match":[{"path":["*.php"]}]},{"handle":[{"handler":"file_server"}]}]}]}]}}}},"logging":{"logs":{"default":{"exclude":["http.log.access.log0"]},"log0":{"encoder":{"fields":{"uri":{"actions":[{"parameter":"authorization","type":"replace","value":"REDACTED"}],"filter":"query"}},"format":"filter","wrap":{"format":"json"}},"include":["http.log.access.log0"],"level":"INFO"}}}}

View File

@ -1 +1 @@
{"tls":{"timestamp":"2024-03-17T10:27:46.419435691Z","instance_id":"d07009b1-d012-4caf-8fb2-438899bbcac6"}} {"tls":{"timestamp":"2025-05-11T13:50:51.803428144Z","instance_id":"d07009b1-d012-4caf-8fb2-438899bbcac6"}}

6
eslint.config.mjs Normal file
View File

@ -0,0 +1,6 @@
// @ts-check
import withNuxt from './.nuxt/eslint.config.mjs'
export default withNuxt(
)

View File

@ -1,20 +0,0 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Authentication Language Lines
|--------------------------------------------------------------------------
|
| The following language lines are used during authentication for various
| messages that we need to display to the user. You are free to modify
| these language lines according to your application's requirements.
|
*/
'failed' => 'These credentials do not match our records.',
'password' => 'The provided password is incorrect.',
'throttle' => 'Too many login attempts. Please try again in :seconds seconds.',
];

View File

@ -1,19 +0,0 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Pagination Language Lines
|--------------------------------------------------------------------------
|
| The following language lines are used by the paginator library to build
| the simple pagination links. You are free to change them to anything
| you want to customize your views to better match your application.
|
*/
'previous' => '&laquo; Previous',
'next' => 'Next &raquo;',
];

View File

@ -1,22 +0,0 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Password Reset Language Lines
|--------------------------------------------------------------------------
|
| The following language lines are the default lines which match reasons
| that are given by the password broker for a password update attempt
| has failed, such as for an invalid token or invalid new password.
|
*/
'reset' => 'Your password has been reset.',
'sent' => 'We have emailed your password reset link.',
'throttled' => 'Please wait before retrying.',
'token' => 'This password reset token is invalid.',
'user' => "We can't find a user with that email address.",
];

View File

@ -1,192 +0,0 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Validation Language Lines
|--------------------------------------------------------------------------
|
| The following language lines contain the default error messages used by
| the validator class. Some of these rules have multiple versions such
| as the size rules. Feel free to tweak each of these messages here.
|
*/
'accepted' => 'The :attribute field must be accepted.',
'accepted_if' => 'The :attribute field must be accepted when :other is :value.',
'active_url' => 'The :attribute field must be a valid URL.',
'after' => 'The :attribute field must be a date after :date.',
'after_or_equal' => 'The :attribute field must be a date after or equal to :date.',
'alpha' => 'The :attribute field must only contain letters.',
'alpha_dash' => 'The :attribute field must only contain letters, numbers, dashes, and underscores.',
'alpha_num' => 'The :attribute field must only contain letters and numbers.',
'array' => 'The :attribute field must be an array.',
'ascii' => 'The :attribute field must only contain single-byte alphanumeric characters and symbols.',
'before' => 'The :attribute field must be a date before :date.',
'before_or_equal' => 'The :attribute field must be a date before or equal to :date.',
'between' => [
'array' => 'The :attribute field must have between :min and :max items.',
'file' => 'The :attribute field must be between :min and :max kilobytes.',
'numeric' => 'The :attribute field must be between :min and :max.',
'string' => 'The :attribute field must be between :min and :max characters.',
],
'boolean' => 'The :attribute field must be true or false.',
'can' => 'The :attribute field contains an unauthorized value.',
'confirmed' => 'The :attribute field confirmation does not match.',
'current_password' => 'The password is incorrect.',
'date' => 'The :attribute field must be a valid date.',
'date_equals' => 'The :attribute field must be a date equal to :date.',
'date_format' => 'The :attribute field must match the format :format.',
'decimal' => 'The :attribute field must have :decimal decimal places.',
'declined' => 'The :attribute field must be declined.',
'declined_if' => 'The :attribute field must be declined when :other is :value.',
'different' => 'The :attribute field and :other must be different.',
'digits' => 'The :attribute field must be :digits digits.',
'digits_between' => 'The :attribute field must be between :min and :max digits.',
'dimensions' => 'The :attribute field has invalid image dimensions.',
'distinct' => 'The :attribute field has a duplicate value.',
'doesnt_end_with' => 'The :attribute field must not end with one of the following: :values.',
'doesnt_start_with' => 'The :attribute field must not start with one of the following: :values.',
'email' => 'The :attribute field must be a valid email address.',
'ends_with' => 'The :attribute field must end with one of the following: :values.',
'enum' => 'The selected :attribute is invalid.',
'exists' => 'The selected :attribute is invalid.',
'extensions' => 'The :attribute field must have one of the following extensions: :values.',
'file' => 'The :attribute field must be a file.',
'filled' => 'The :attribute field must have a value.',
'gt' => [
'array' => 'The :attribute field must have more than :value items.',
'file' => 'The :attribute field must be greater than :value kilobytes.',
'numeric' => 'The :attribute field must be greater than :value.',
'string' => 'The :attribute field must be greater than :value characters.',
],
'gte' => [
'array' => 'The :attribute field must have :value items or more.',
'file' => 'The :attribute field must be greater than or equal to :value kilobytes.',
'numeric' => 'The :attribute field must be greater than or equal to :value.',
'string' => 'The :attribute field must be greater than or equal to :value characters.',
],
'hex_color' => 'The :attribute field must be a valid hexadecimal color.',
'image' => 'The :attribute field must be an image.',
'in' => 'The selected :attribute is invalid.',
'in_array' => 'The :attribute field must exist in :other.',
'integer' => 'The :attribute field must be an integer.',
'ip' => 'The :attribute field must be a valid IP address.',
'ipv4' => 'The :attribute field must be a valid IPv4 address.',
'ipv6' => 'The :attribute field must be a valid IPv6 address.',
'json' => 'The :attribute field must be a valid JSON string.',
'list' => 'The :attribute field must be a list.',
'lowercase' => 'The :attribute field must be lowercase.',
'lt' => [
'array' => 'The :attribute field must have less than :value items.',
'file' => 'The :attribute field must be less than :value kilobytes.',
'numeric' => 'The :attribute field must be less than :value.',
'string' => 'The :attribute field must be less than :value characters.',
],
'lte' => [
'array' => 'The :attribute field must not have more than :value items.',
'file' => 'The :attribute field must be less than or equal to :value kilobytes.',
'numeric' => 'The :attribute field must be less than or equal to :value.',
'string' => 'The :attribute field must be less than or equal to :value characters.',
],
'mac_address' => 'The :attribute field must be a valid MAC address.',
'max' => [
'array' => 'The :attribute field must not have more than :max items.',
'file' => 'The :attribute field must not be greater than :max kilobytes.',
'numeric' => 'The :attribute field must not be greater than :max.',
'string' => 'The :attribute field must not be greater than :max characters.',
],
'max_digits' => 'The :attribute field must not have more than :max digits.',
'mimes' => 'The :attribute field must be a file of type: :values.',
'mimetypes' => 'The :attribute field must be a file of type: :values.',
'min' => [
'array' => 'The :attribute field must have at least :min items.',
'file' => 'The :attribute field must be at least :min kilobytes.',
'numeric' => 'The :attribute field must be at least :min.',
'string' => 'The :attribute field must be at least :min characters.',
],
'min_digits' => 'The :attribute field must have at least :min digits.',
'missing' => 'The :attribute field must be missing.',
'missing_if' => 'The :attribute field must be missing when :other is :value.',
'missing_unless' => 'The :attribute field must be missing unless :other is :value.',
'missing_with' => 'The :attribute field must be missing when :values is present.',
'missing_with_all' => 'The :attribute field must be missing when :values are present.',
'multiple_of' => 'The :attribute field must be a multiple of :value.',
'not_in' => 'The selected :attribute is invalid.',
'not_regex' => 'The :attribute field format is invalid.',
'numeric' => 'The :attribute field must be a number.',
'password' => [
'letters' => 'The :attribute field must contain at least one letter.',
'mixed' => 'The :attribute field must contain at least one uppercase and one lowercase letter.',
'numbers' => 'The :attribute field must contain at least one number.',
'symbols' => 'The :attribute field must contain at least one symbol.',
'uncompromised' => 'The given :attribute has appeared in a data leak. Please choose a different :attribute.',
],
'present' => 'The :attribute field must be present.',
'present_if' => 'The :attribute field must be present when :other is :value.',
'present_unless' => 'The :attribute field must be present unless :other is :value.',
'present_with' => 'The :attribute field must be present when :values is present.',
'present_with_all' => 'The :attribute field must be present when :values are present.',
'prohibited' => 'The :attribute field is prohibited.',
'prohibited_if' => 'The :attribute field is prohibited when :other is :value.',
'prohibited_unless' => 'The :attribute field is prohibited unless :other is in :values.',
'prohibits' => 'The :attribute field prohibits :other from being present.',
'regex' => 'The :attribute field format is invalid.',
'required' => 'The :attribute field is required.',
'required_array_keys' => 'The :attribute field must contain entries for: :values.',
'required_if' => 'The :attribute field is required when :other is :value.',
'required_if_accepted' => 'The :attribute field is required when :other is accepted.',
'required_unless' => 'The :attribute field is required unless :other is in :values.',
'required_with' => 'The :attribute field is required when :values is present.',
'required_with_all' => 'The :attribute field is required when :values are present.',
'required_without' => 'The :attribute field is required when :values is not present.',
'required_without_all' => 'The :attribute field is required when none of :values are present.',
'same' => 'The :attribute field must match :other.',
'size' => [
'array' => 'The :attribute field must contain :size items.',
'file' => 'The :attribute field must be :size kilobytes.',
'numeric' => 'The :attribute field must be :size.',
'string' => 'The :attribute field must be :size characters.',
],
'starts_with' => 'The :attribute field must start with one of the following: :values.',
'string' => 'The :attribute field must be a string.',
'timezone' => 'The :attribute field must be a valid timezone.',
'unique' => 'The :attribute has already been taken.',
'uploaded' => 'The :attribute failed to upload.',
'uppercase' => 'The :attribute field must be uppercase.',
'url' => 'The :attribute field must be a valid URL.',
'ulid' => 'The :attribute field must be a valid ULID.',
'uuid' => 'The :attribute field must be a valid UUID.',
/*
|--------------------------------------------------------------------------
| Custom Validation Language Lines
|--------------------------------------------------------------------------
|
| Here you may specify custom validation messages for attributes using the
| convention "attribute.rule" to name the lines. This makes it quick to
| specify a specific custom language line for a given attribute rule.
|
*/
'custom' => [
'attribute-name' => [
'rule-name' => 'custom-message',
],
],
/*
|--------------------------------------------------------------------------
| Custom Validation Attributes
|--------------------------------------------------------------------------
|
| The following language lines are used to swap our attribute placeholder
| with something more reader friendly such as "E-Mail Address" instead
| of "email". This simply helps us make our message more expressive.
|
*/
'attributes' => [],
];

View File

@ -1,11 +1,25 @@
// https://nuxt.com/docs/api/configuration/nuxt-config // https://nuxt.com/docs/api/configuration/nuxt-config
import type { IAccountProvider } from '~/types/account'
const providers: { [key: string]: IAccountProvider } = {
/* google: {
name: 'Google',
icon: '',
color: 'neutral',
}, */
}
export default defineNuxtConfig({ export default defineNuxtConfig({
srcDir: 'nuxt/',
/**
* @see https://v3.nuxtjs.org/api/configuration/nuxt.config#modules
*/
modules: ['@nuxt/ui', '@nuxt/image', '@pinia/nuxt', 'dayjs-nuxt', 'nuxt-security', '@nuxt/eslint'],
$development: { $development: {
ssr: true, ssr: true,
devtools: { devtools: {
enabled: false, enabled: true,
}, },
}, },
@ -22,56 +36,14 @@ export default defineNuxtConfig({
}, },
}, },
routeRules: {
'auth/verify': { ssr: false }
},
css: [ css: [
'@/assets/css/main.css', '@/assets/css/main.css',
], ],
/**
* @see https://v3.nuxtjs.org/api/configuration/nuxt.config#modules
*/
extends: ['@nuxt/ui-pro'],
modules: [
'@nuxt/ui',
'@nuxt/image',
'@pinia/nuxt',
'dayjs-nuxt',
'nuxt-security',
],
ui: { ui: {
icons: ['heroicons'], icons: ['heroicons'],
}, },
image: {
domains: [
process.env.API_URL || 'http://127.0.0.1:8000'
],
alias: {
api: process.env.API_URL || 'http://127.0.0.1:8000'
}
},
security: {
headers: {
crossOriginEmbedderPolicy: 'unsafe-none',
crossOriginOpenerPolicy: 'same-origin-allow-popups',
contentSecurityPolicy: {
"img-src": ["'self'", "data:", "https://*", process.env.API_URL || 'http://127.0.0.1:8000'],
},
},
},
dayjs: {
locales: ['en'],
plugins: ['relativeTime', 'utc', 'timezone'],
defaultLocale: 'en',
defaultTimezone: 'America/New_York',
},
/** /**
* @see https://v3.nuxtjs.org/guide/features/runtime-config#exposing-runtime-config * @see https://v3.nuxtjs.org/guide/features/runtime-config#exposing-runtime-config
*/ */
@ -82,11 +54,50 @@ export default defineNuxtConfig({
apiPrefix: '/api/v1', apiPrefix: '/api/v1',
storageBase: process.env.API_URL + '/storage/', storageBase: process.env.API_URL + '/storage/',
providers: { providers: {
google: { ...providers,
name: "Google", },
icon: "", },
color: "gray", },
}, srcDir: 'nuxt/app',
routeRules: {
'auth/verify': { ssr: false },
},
future: {
compatibilityVersion: 4,
},
compatibilityDate: '2024-10-28',
dayjs: {
locales: ['en'],
plugins: ['relativeTime', 'utc', 'timezone'],
defaultLocale: 'en',
defaultTimezone: 'America/New_York',
},
eslint: {
config: {
stylistic: true,
},
},
image: {
domains: [
process.env.API_URL || 'http://127.0.0.1:8000',
],
alias: {
api: process.env.API_URL || 'http://127.0.0.1:8000',
},
},
security: {
headers: {
crossOriginEmbedderPolicy: 'unsafe-none',
crossOriginOpenerPolicy: 'same-origin-allow-popups',
contentSecurityPolicy: {
'img-src': ['\'self\'', 'data:', 'https://*', process.env.API_URL || 'http://127.0.0.1:8000'],
}, },
}, },
}, },

View File

@ -1,10 +0,0 @@
<script lang="ts" setup>
</script>
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
<UNotifications />
</template>

View File

@ -4,12 +4,12 @@ export default defineAppConfig({
primary: 'sky', primary: 'sky',
gray: 'cool', gray: 'cool',
container: { container: {
constrained: 'max-w-7xl w-full' constrained: 'max-w-7xl w-full',
}, },
avatar: { avatar: {
default: { default: {
icon: 'i-heroicons-user', icon: 'i-heroicons-user',
} },
} },
} },
}) })

10
nuxt/app/app.vue Normal file
View File

@ -0,0 +1,10 @@
<script lang="ts" setup>
</script>
<template>
<UApp>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</UApp>
</template>

View File

@ -0,0 +1,7 @@
@import "tailwindcss";
@import "@nuxt/ui";
@theme {
}

View File

@ -0,0 +1,36 @@
<script setup lang="ts">
const items = [{
label: 'Documentation',
icon: 'i-heroicons-book-open',
to: 'https://ui.nuxt.com/getting-started',
}, {
label: 'Pro',
icon: 'i-heroicons-square-3-stack-3d',
to: 'https://ui.nuxt.com/pro',
}, {
label: 'Releases',
icon: 'i-heroicons-rocket-launch',
to: 'https://github.com/nuxt/ui/releases',
target: '_blank',
}]
</script>
<template>
<header
class="bg-background/75 backdrop-blur -mb-px sticky top-0 z-50 border-b border-dashed border-gray-200/80 dark:border-gray-800/80"
>
<UContainer class="flex items-center justify-between gap-3 h-16 py-2">
<AppLogo class="lg:flex-1" />
<UNavigationMenu
orientation="horizontal"
:items="items"
class="hidden lg:block"
/>
<div class="flex items-center justify-end gap-3 lg:flex-1">
<UserDropdown />
</div>
</UContainer>
</header>
</template>

View File

@ -0,0 +1,8 @@
<template>
<NuxtLink
to="/"
class="font-bold text-xl"
>
<span class="text-primary-500">Nuxt</span> Breeze
</NuxtLink>
</template>

View File

@ -25,7 +25,9 @@ const links = [
</script> </script>
<template> <template>
<UContainer> <UNavigationMenu
<UNavigationTree :links="links" /> class="data-[orientation=vertical]:w-48"
</UContainer> orientation="vertical"
:items="links"
/>
</template> </template>

View File

@ -0,0 +1,35 @@
<script setup lang="ts">
const links = [
{
label: 'Account',
to: '/account',
icon: 'i-heroicons-user-solid',
},
{
label: 'Logout',
to: '/logout',
icon: 'i-heroicons-arrow-left-on-rectangle',
}]
</script>
<template>
<UPopover>
<UButton
icon="i-heroicons-user-solid"
color="gray"
variant="ghost"
/>
<template #content>
<div class="p-4">
<UNavigationMenu
:items="links"
:orientation="'vertical'"
/>
</div>
</template>
</UPopover>
</template>
<style scoped>
</style>

View File

@ -0,0 +1,109 @@
<script setup lang="ts">
import type { TableColumn } from '#ui/components/Table.vue'
import type { Device, IDevicesResponse } from '~/types/device'
const UButton = resolveComponent('UButton')
const UBadge = resolveComponent('UBadge')
const UDropdownMenu = resolveComponent('UDropdownMenu')
const dayjs = useDayjs()
const auth = useAuthStore()
const loading = ref(false)
const devices = ref<Device[]>([])
async function fetchData() {
loading.value = true
const response = await $fetch<IDevicesResponse>('devices')
if (response.ok) {
devices.value = response.devices
}
loading.value = false
}
const columns: TableColumn<Device>[] = [
{
accessorKey: 'name',
header: 'Device',
cell: ({ row }) => {
return h('div', { class: 'font-semibold' }, [
row.original.name,
row.original.is_current && h(UBadge, {
label: 'active',
color: 'primary',
variant: 'soft',
size: 'sm',
class: 'ms-1',
}),
h('div', { class: 'font-medium text-sm' }, `IP: ${row.original.ip}`),
])
},
},
{
accessorKey: 'last_used_at',
header: 'Last used at',
cell: ({ row }) => {
return dayjs(row.original.last_used_at).fromNow()
},
},
{
accessorKey: 'actions',
header: 'Actions',
cell: ({ row }) => {
return h('div', { class: 'flex justify-end' },
h(UDropdownMenu, {
items: items(row.original),
}, () => h(UButton, {
icon: 'i-heroicons-ellipsis-vertical-20-solid',
variant: 'ghost',
color: 'neutral',
})),
)
},
},
]
const items = (row: Device) => [
[
{
label: 'Delete',
icon: 'i-heroicons-trash-20-solid',
onSelect: async () => {
await $fetch<never>('devices/disconnect', {
method: 'POST',
body: {
hash: row.hash,
},
async onResponse({ response }) {
if (response._data?.ok) {
await fetchData()
await auth.fetchUser()
}
},
})
},
},
],
]
if (import.meta.client) {
fetchData()
}
</script>
<template>
<ClientOnly>
<UTable
:data="devices"
:columns="columns"
size="lg"
:loading="loading"
/>
</ClientOnly>
</template>
<style scoped>
</style>

View File

@ -0,0 +1,125 @@
<script lang="ts" setup>
import type { IAccountChangePasswordResponse, IVerificationNotificationResponse } from '~/types/account'
const form = ref()
const auth = useAuthStore()
const state = reactive({
current_password: '',
password: '',
password_confirmation: '',
})
const { refresh: onSubmit, status: accountPasswordStatus } = useFetch<IAccountChangePasswordResponse>('account/password', {
method: 'POST',
body: state,
immediate: false,
watch: false,
async onResponse({ response }) {
if (response?.status === 422) {
form.value.setErrors(response._data?.errors)
}
else if (response._data?.ok) {
useToast().add({
icon: 'i-heroicons-check-circle-20-solid',
title: 'The password was successfully updated.',
color: 'primary',
})
state.current_password = ''
state.password = ''
state.password_confirmation = ''
}
},
})
const { refresh: sendResetPasswordEmail, status: resetPasswordEmailStatus } = useFetch<IVerificationNotificationResponse>('verification-notification', {
method: 'POST',
body: { email: auth.user.email },
immediate: false,
watch: false,
onResponse({ response }) {
if (response._data?.ok) {
useToast().add({
icon: 'i-heroicons-check-circle-20-solid',
title: 'A link to reset your password has been sent to your email.',
color: 'primary',
})
}
},
})
</script>
<template>
<div>
<UForm
v-if="auth.user.has_password"
ref="form"
:state="state"
class="space-y-4"
@submit="onSubmit"
>
<UFormField
label="Current Password"
name="current_password"
required
>
<UInput
v-model="state.current_password"
type="password"
autocomplete="off"
/>
</UFormField>
<UFormField
label="New Password"
name="password"
hint="min 8 characters"
:ui="{ hint: 'text-xs text-gray-500 dark:text-gray-400' }"
required
>
<UInput
v-model="state.password"
type="password"
autocomplete="off"
/>
</UFormField>
<UFormField
label="Repeat Password"
name="password_confirmation"
required
>
<UInput
v-model="state.password_confirmation"
type="password"
autocomplete="off"
/>
</UFormField>
<div class="pt-2">
<UButton
type="submit"
label="Save"
:loading="accountPasswordStatus === 'pending'"
/>
</div>
</UForm>
<UAlert
v-else
icon="i-heroicons-information-circle-20-solid"
title="Send a link to your email to reset your password."
description="To create a password for your account, you must go through the password recovery process."
:actions="[
{
label: 'Send link to Email',
variant: 'solid',
color: 'neutral',
loading: resetPasswordEmailStatus === 'pending',
onClick: () => sendResetPasswordEmail(),
},
]"
/>
</div>
</template>

View File

@ -1,6 +1,8 @@
<script lang="ts" setup> <script lang="ts" setup>
const form = ref(); import type { IAccountUpdateResponse, IVerificationNotificationResponse } from '~/types/account'
const auth = useAuthStore();
const form = ref()
const auth = useAuthStore()
const state = reactive({ const state = reactive({
...{ ...{
@ -8,65 +10,86 @@ const state = reactive({
name: auth.user.name, name: auth.user.name,
avatar: auth.user.avatar, avatar: auth.user.avatar,
}, },
}); })
const { refresh: sendEmailVerification, status: resendEmailStatus } = useFetch<any>("verification-notification", { const { refresh: sendEmailVerification, status: resendEmailStatus } = useFetch<IVerificationNotificationResponse>('verification-notification', {
method: "POST", method: 'POST',
body: { email: state.email }, body: { email: state.email },
immediate: false, immediate: false,
watch: false, watch: false,
onResponse({ response }) { onResponse({ response }) {
if (response._data?.ok) { if (response._data?.ok) {
useToast().add({ useToast().add({
icon: "i-heroicons-check-circle-20-solid", icon: 'i-heroicons-check-circle-20-solid',
title: response._data.message, title: response._data.message,
color: "emerald", color: 'primary',
}); })
} }
} },
}); })
const { refresh: onSubmit, status: accountUpdateStatus } = useFetch<any>("account/update", { const { refresh: onSubmit, status: accountUpdateStatus } = useFetch<IAccountUpdateResponse>('account/update', {
method: "POST", method: 'POST',
body: state, body: state,
immediate: false, immediate: false,
watch: false, watch: false,
async onResponse({ response }) { async onResponse({ response }) {
if (response?.status === 422) { if (response?.status === 422) {
form.value.setErrors(response._data?.errors); form.value.setErrors(response._data?.errors)
} else if (response._data?.ok) {
useToast().add({
icon: "i-heroicons-check-circle-20-solid",
title: "Account details have been successfully updated.",
color: "emerald",
});
await auth.fetchUser();
state.name = auth.user.name;
state.email = auth.user.email;
state.avatar = auth.user.avatar;
} }
} else if (response._data?.ok) {
}); useToast().add({
icon: 'i-heroicons-check-circle-20-solid',
title: 'Account details have been successfully updated.',
color: 'primary',
})
await auth.fetchUser()
state.name = auth.user.name
state.email = auth.user.email
state.avatar = auth.user.avatar
}
},
})
</script> </script>
<template> <template>
<UForm ref="form" :state="state" @submit="onSubmit" class="space-y-4"> <UForm
<UFormGroup label="" name="avatar" class="flex"> ref="form"
:state="state"
class="space-y-4"
@submit="onSubmit"
>
<UFormField
label=""
name="avatar"
class="flex"
>
<InputUploadAvatar <InputUploadAvatar
v-model="state.avatar" v-model="state.avatar"
accept=".png, .jpg, .jpeg, .webp" accept=".png, .jpg, .jpeg, .webp"
entity="avatars" entity="avatars"
max-size="2" max-size="2"
/> />
</UFormGroup> </UFormField>
<UFormGroup label="Name" name="name" required> <UFormField
<UInput v-model="state.name" type="text" /> label="Name"
</UFormGroup> name="name"
required
>
<UInput
v-model="state.name"
type="text"
/>
</UFormField>
<UFormGroup label="Email" name="email" required> <UFormField
label="Email"
name="email"
required
>
<UInput <UInput
v-model="state.email" v-model="state.email"
placeholder="you@example.com" placeholder="you@example.com"
@ -74,10 +97,12 @@ const { refresh: onSubmit, status: accountUpdateStatus } = useFetch<any>("accoun
trailing trailing
type="email" type="email"
/> />
</UFormGroup> </UFormField>
<UAlert <UAlert
v-if="auth.user.must_verify_email" v-if="auth.user.must_verify_email"
variant="subtle"
color="warning"
icon="i-heroicons-information-circle-20-solid" icon="i-heroicons-information-circle-20-solid"
title="Please confirm your email address." title="Please confirm your email address."
description="A confirmation email has been sent to your email address. Please click on the confirmation link in the email to verify your email address." description="A confirmation email has been sent to your email address. Please click on the confirmation link in the email to verify your email address."
@ -85,15 +110,19 @@ const { refresh: onSubmit, status: accountUpdateStatus } = useFetch<any>("accoun
{ {
label: 'Resend verification email', label: 'Resend verification email',
variant: 'solid', variant: 'solid',
color: 'gray', color: 'neutral',
loading: resendEmailStatus === 'pending', loading: resendEmailStatus === 'pending',
click: sendEmailVerification, onClick: () => sendEmailVerification(),
}, },
]" ]"
/> />
<div class="pt-2"> <div class="pt-2">
<UButton type="submit" label="Save" :loading="accountUpdateStatus === 'pending'" /> <UButton
type="submit"
label="Save"
:loading="accountUpdateStatus === 'pending'"
/>
</div> </div>
</UForm> </UForm>
</template> </template>

View File

@ -0,0 +1,57 @@
<script setup lang="ts">
import type { IForgotPasswordResponse } from '~/types/account'
const form = useTemplateRef('form')
const state = reactive({
email: '',
})
const { refresh: onSubmit, status: forgotStatus } = useFetch<IForgotPasswordResponse>('forgot-password', {
method: 'POST',
body: state,
immediate: false,
watch: false,
async onResponse({ response }) {
if (response?.status === 422) {
form.value.setErrors(response._data?.errors)
}
else if (response._data?.ok) {
useToast().add({
title: 'Success',
description: response._data.message,
color: 'primary',
})
}
},
})
</script>
<template>
<UForm
ref="form"
:state="state"
class="space-y-8"
@submit="onSubmit"
>
<UFormField
label="Email"
name="email"
>
<UInput
v-model="state.email"
class="w-full"
/>
</UFormField>
<UButton
block
size="md"
type="submit"
:loading="forgotStatus === 'pending'"
icon="i-heroicons-envelope"
>
Reset Password
</UButton>
</UForm>
</template>

View File

@ -0,0 +1,173 @@
<script setup lang="ts">
import type { IAccountLoginResponse, IAccountProvider, IAccountProviderData } from '~/types/account'
import type { ButtonProps } from '#ui/components/Button.vue'
const config = useRuntimeConfig()
const router = useRouter()
const auth = useAuthStore()
const form = useTemplateRef('form')
const state = reactive({
email: '',
password: '',
remember: false,
})
const { refresh: onSubmit, status: loginStatus } = useFetch<IAccountLoginResponse>('login', {
method: 'POST',
body: state,
immediate: false,
watch: false,
async onResponse({ response }) {
if (response?.status === 422) {
form?.value?.setErrors(response._data?.errors)
}
else if (response._data?.ok) {
auth.token = response._data.token
await auth.fetchUser()
await router.push('/')
}
},
})
const providers = ref<{ [key: string]: IAccountProvider }>(
Object.fromEntries(
Object.entries(config.public.providers).map(([key, provider]) => [
key,
{
...provider,
color: provider.color as ButtonProps['color'],
},
]),
),
)
async function handleMessage(event: { data: IAccountProviderData }): Promise<void> {
const provider = event.data.provider as string
if (Object.keys(providers.value).includes(provider) && event.data.token) {
if (providers?.value[provider]?.loading) {
providers.value[provider].loading = false
}
auth.token = event.data.token
await auth.fetchUser()
await router.push('/')
}
else if (event.data.message) {
useToast().add({
icon: 'i-heroicons-exclamation-circle-solid',
color: 'error',
title: event.data.message,
})
}
}
function loginVia(provider: string): void {
providers.value[provider]!.loading = true
const width = 640
const height = 660
const left = window.screen.width / 2 - width / 2
const top = window.screen.height / 2 - height / 2
const popup = window.open(
`${config.public.apiBase}${config.public.apiPrefix}/login/${provider}/redirect`,
'Sign In',
`toolbar=no, location=no, directories=no, status=no, menubar=no, scollbars=no, resizable=no, copyhistory=no, width=${width},height=${height},top=${top},left=${left}`,
)
const interval = setInterval(() => {
if (!popup || popup.closed) {
clearInterval(interval)
providers.value[provider]!.loading = false
}
}, 500)
}
onMounted(() => window.addEventListener('message', handleMessage))
onBeforeUnmount(() => window.removeEventListener('message', handleMessage))
</script>
<template>
<UForm
ref="form"
:state="state"
class="space-y-4"
@submit="onSubmit"
>
<UFormField
label="Email"
name="email"
>
<UInput
v-model="state.email"
class="w-full"
/>
</UFormField>
<UFormField
label="Password"
name="password"
>
<UInput
v-model="state.password"
type="password"
class="w-full"
/>
</UFormField>
<div class="flex items-center justify-between">
<UCheckbox
id="remember-me"
v-model="state.remember"
label="Remember me"
name="remember-me"
/>
<div class="text-sm leading-6">
<NuxtLink
to="/forgot-password"
class="text-primary hover:text-primary-300 font-semibold"
>
Forgot
password?
</NuxtLink>
</div>
</div>
<UButton
block
size="md"
type="submit"
:loading="loginStatus === 'pending'"
icon="i-heroicons-arrow-right-on-rectangle"
>
Login
</UButton>
</UForm>
<USeparator
v-if="Object.keys(providers).length > 0"
color="neutral"
label="Login with"
class="my-4"
/>
<div class="flex gap-4">
<UButton
v-for="(provider, key) in providers"
:key="key"
:loading="provider.loading"
:icon="provider.icon"
:color="provider.color"
:label="provider.name"
size="lg"
class="w-full flex items-center justify-center"
@click="loginVia(key as string)"
/>
</div>
</template>
<style scoped>
</style>

View File

@ -0,0 +1,91 @@
<script setup lang="ts">
import type { IAccountResetPasswordResponse } from '~/types/account'
const router = useRouter()
const route = useRoute()
const auth = useAuthStore()
const form = useTemplateRef('form')
const state = reactive({
email: route.query.email as string,
token: route.params.token,
password: '',
password_confirmation: '',
})
const { refresh: onSubmit, status: resetStatus } = useFetch<IAccountResetPasswordResponse>('reset-password', {
method: 'POST',
body: state,
immediate: false,
watch: false,
async onResponse({ response }) {
if (response?.status === 422) {
form?.value?.setErrors(response._data?.errors)
}
else if (response._data?.ok) {
useToast().add({
title: 'Success',
description: response._data.message,
color: 'primary',
})
if (auth.isLoggedIn) {
await auth.fetchUser()
await router.push('/')
}
else {
await router.push('/login')
}
}
},
})
</script>
<template>
<UForm
ref="form"
:state="state"
class="space-y-4"
@submit="onSubmit"
>
<UFormField
label="Email"
name="email"
>
<UInput
v-model="state.email"
disabled
/>
</UFormField>
<UFormField
label="Password"
name="password"
>
<UInput
v-model="state.password"
type="password"
/>
</UFormField>
<UFormField
label="Confirm Password"
name="password_confirmation"
>
<UInput
v-model="state.password_confirmation"
type="password"
/>
</UFormField>
<UButton
block
size="md"
type="submit"
:loading="resetStatus === 'pending'"
icon="i-heroicon-lock-closed"
>
Change Password
</UButton>
</UForm>
</template>

View File

@ -1,40 +1,54 @@
<script lang="ts" setup> <script lang="ts" setup>
const props = defineProps(["modelValue", "entity", "accept", "maxSize"]); import type { IUploadResponse } from '~/types/account'
const emit = defineEmits(["update:modelValue"]);
const { $storage } = useNuxtApp(); interface Props {
modelValue: string
entity: string
accept: string
maxSize: number
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const { $storage } = useNuxtApp()
const value = computed({ const value = computed({
get() { get(): string {
return props.modelValue; return props.modelValue
}, },
set(value) { set(value: string) {
emit("update:modelValue", value); emit('update:modelValue', value)
}, },
}); })
const inputRef = ref(); const inputRef = ref<HTMLInputElement>()
const loading = ref(false); const loading = ref<boolean>(false)
const onSelect = async (e: any) => { const onSelect = async (e: Event) => {
const file = e.target.files[0]; const target = e.target as HTMLInputElement
e.target.value = null; const file = target.files?.[0]
if (!file) return
target.value = ''
if (file.size > props.maxSize * 1024 * 1024) { if (file.size > props.maxSize * 1024 * 1024) {
return useToast().add({ return useToast().add({
title: "File is too large.", title: 'File is too large.',
color: "red", color: 'error',
icon: "i-heroicons-exclamation-circle-solid", icon: 'i-heroicons-exclamation-circle-solid',
}); })
} }
loading.value = true; loading.value = true
const formData = new FormData(); const formData = new FormData()
formData.append("image", file); formData.append('image', file)
await $fetch<any>("upload", { await $fetch<IUploadResponse>('upload', {
method: "POST", method: 'POST',
body: formData, body: formData,
params: { params: {
entity: props.entity, entity: props.entity,
@ -46,17 +60,18 @@ const onSelect = async (e: any) => {
if (response.status !== 200) { if (response.status !== 200) {
useToast().add({ useToast().add({
icon: 'i-heroicons-exclamation-circle-solid', icon: 'i-heroicons-exclamation-circle-solid',
color: 'red', color: 'error',
title: response._data?.message ?? response.statusText ?? 'Something went wrong', title: response._data?.message ?? response.statusText ?? 'Something went wrong',
}); })
} else if (response._data?.ok) { }
value.value = response._data?.path; else if (response._data?.ok) {
value.value = response._data?.path
} }
loading.value = false; loading.value = false
}, },
}); })
}; }
</script> </script>
<template> <template>
@ -66,7 +81,6 @@ const onSelect = async (e: any) => {
:src="$storage(value)" :src="$storage(value)"
size="3xl" size="3xl"
img-class="object-cover" img-class="object-cover"
:ui="{ rounded: 'rounded-lg' }"
/> />
<UTooltip <UTooltip
@ -76,10 +90,11 @@ const onSelect = async (e: any) => {
> >
<UButton <UButton
type="button" type="button"
color="gray" color="neutral"
variant="link"
icon="i-heroicons-cloud-arrow-up" icon="i-heroicons-cloud-arrow-up"
size="2xs" size="xs"
:ui="{ rounded: 'rounded-full' }" :ui="{ base: 'rounded-full' }"
:loading="loading" :loading="loading"
@click="inputRef.click()" @click="inputRef.click()"
/> />
@ -91,10 +106,11 @@ const onSelect = async (e: any) => {
> >
<UButton <UButton
type="button" type="button"
color="gray" color="neutral"
variant="link"
icon="i-heroicons-x-mark-20-solid" icon="i-heroicons-x-mark-20-solid"
size="2xs" size="xs"
:ui="{ rounded: 'rounded-full' }" :ui="{ base: 'rounded-full' }"
:disabled="loading" :disabled="loading"
@click="value = ''" @click="value = ''"
/> />
@ -105,7 +121,7 @@ const onSelect = async (e: any) => {
class="hidden" class="hidden"
:accept="accept" :accept="accept"
@change="onSelect" @change="onSelect"
/> >
</div> </div>
<div class="text-sm opacity-80"> <div class="text-sm opacity-80">
<div>Max upload size: {{ maxSize }}Mb</div> <div>Max upload size: {{ maxSize }}Mb</div>

View File

@ -1,17 +1,24 @@
<script lang="ts" setup> <script lang="ts" setup>
const modal = useModal() const modal = useModal()
</script> </script>
<template> <template>
<UModal> <UModal>
<UCard> <UCard>
<template #header> <template #header>
<div class="text-2xl leading-tight font-black">Welcome to LaravelNuxt</div> <div class="text-2xl leading-tight font-black">
Welcome to LaravelNuxt
</div>
</template> </template>
<USkeleton class="w-full h-60" /> <USkeleton class="w-full h-60" />
<template #footer> <template #footer>
<UButton label="Close" @click="modal.close" color="gray" /> <UButton
label="Close"
color="gray"
@click="modal.close"
/>
</template> </template>
</UCard> </UCard>
</UModal> </UModal>

33
nuxt/app/error.vue Normal file
View File

@ -0,0 +1,33 @@
<script setup lang="ts">
import type { NuxtError } from '#app'
const props = defineProps<{ error: NuxtError }>()
const handleError = () => clearError({ redirect: '/' })
</script>
<template>
<UContainer class="py-5 flex items-center justify-center">
<AppLogo />
</UContainer>
<UContainer class="flex-grow flex flex-col items-center justify-center space-y-5">
<h1 class="text-9xl font-bold">
{{ props.error?.statusCode }}
</h1>
<div>{{ props.error?.message }}</div>
<div
v-if="props.error?.statusCode >= 500"
>
{{ error?.stack }}
</div>
<div>
<UButton
color="neutral"
size="xl"
variant="outline"
label="Go back"
@click="handleError"
/>
</div>
</UContainer>
</template>

View File

@ -0,0 +1,17 @@
<template>
<div>
<AppHeader />
<UContainer class="flex justify-start mt-4">
<AppNavigation />
<UContainer
as="main"
class="flex-grow py-4 sm:py-7 flex flex-col"
>
<slot />
</UContainer>
</UContainer>
</div>
</template>
<script setup lang="ts">
</script>

View File

@ -1,4 +1,4 @@
export default defineNuxtRouteMiddleware((to, from) => { export default defineNuxtRouteMiddleware(() => {
const nuxtApp = useNuxtApp() const nuxtApp = useNuxtApp()
const auth = useAuthStore() const auth = useAuthStore()

View File

@ -1,4 +1,4 @@
export default defineNuxtRouteMiddleware((to, from) => { export default defineNuxtRouteMiddleware(() => {
const nuxtApp = useNuxtApp() const nuxtApp = useNuxtApp()
const auth = useAuthStore() const auth = useAuthStore()

View File

@ -1,14 +1,14 @@
export default defineNuxtRouteMiddleware((to, from) => { export default defineNuxtRouteMiddleware(() => {
const nuxtApp = useNuxtApp() const nuxtApp = useNuxtApp()
const auth = useAuthStore() const auth = useAuthStore()
if (auth.isLoggedIn && !auth.user.roles.includes('admin')) { if (auth.isLoggedIn && !auth.user.roles.includes('admin')) {
return nuxtApp.runWithContext(() => { return nuxtApp.runWithContext(() => {
useToast().add({ useToast().add({
icon: "i-heroicons-exclamation-circle-solid", icon: 'i-heroicons-exclamation-circle-solid',
title: "Access denied.", title: 'Access denied.',
color: "red", color: 'error',
}); })
return navigateTo('/') return navigateTo('/')
}) })

View File

@ -1,14 +1,14 @@
export default defineNuxtRouteMiddleware((to, from) => { export default defineNuxtRouteMiddleware(() => {
const nuxtApp = useNuxtApp() const nuxtApp = useNuxtApp()
const auth = useAuthStore() const auth = useAuthStore()
if (auth.isLoggedIn && !auth.user.roles.includes('user')) { if (auth.isLoggedIn && !auth.user.roles.includes('user')) {
return nuxtApp.runWithContext(() => { return nuxtApp.runWithContext(() => {
useToast().add({ useToast().add({
icon: "i-heroicons-exclamation-circle-solid", icon: 'i-heroicons-exclamation-circle-solid',
title: "Access denied.", title: 'Access denied.',
color: "red", color: 'error',
}); })
return navigateTo('/') return navigateTo('/')
}) })

View File

@ -1,14 +1,14 @@
export default defineNuxtRouteMiddleware((to, from) => { export default defineNuxtRouteMiddleware(() => {
const nuxtApp = useNuxtApp() const nuxtApp = useNuxtApp()
const auth = useAuthStore() const auth = useAuthStore()
if (auth.isLoggedIn && auth.user.must_verify_email) { if (auth.isLoggedIn && auth.user.must_verify_email) {
return nuxtApp.runWithContext(() => { return nuxtApp.runWithContext(() => {
useToast().add({ useToast().add({
icon: "i-heroicons-exclamation-circle-solid", icon: 'i-heroicons-exclamation-circle-solid',
title: "Please confirm your email.", title: 'Please confirm your email.',
color: "red", color: 'error',
}); })
return navigateTo('/account/general') return navigateTo('/account/general')
}) })

View File

@ -0,0 +1,30 @@
<script lang="ts" setup>
definePageMeta({
middleware: ['auth'],
})
const links = [
[
{
label: 'Account',
icon: 'i-heroicons-user',
to: '/account/general',
},
{
label: 'Devices',
icon: 'i-heroicons-device-phone-mobile',
to: '/account/devices',
},
],
]
</script>
<template>
<div>
<UNavigationMenu
:items="links"
class="border-b border-gray-200 dark:border-gray-800 mb-4"
/>
<NuxtPage class="col-span-10 md:col-span-8" />
</div>
</template>

View File

@ -0,0 +1,5 @@
<template>
<UCard>
<AccountDeviceTable />
</UCard>
</template>

View File

@ -1,8 +1,11 @@
<script lang="ts" setup></script> <script lang="ts" setup></script>
<template> <template>
<UCard :ui="{ body: { base: 'grid grid-cols-12 gap-6 md:gap-8' } }"> <UCard :ui="{ body: 'grid grid-cols-12 gap-6 md:gap-8' }">
<div class="col-span-12 lg:col-span-4"> <div class="col-span-12 lg:col-span-4">
<div class="text-lg font-semibold mb-2">Profile information</div> <div class="text-lg font-semibold mb-2">
Profile information
</div>
<div class="text-sm opacity-80"> <div class="text-sm opacity-80">
Update your account's profile information and email address. Update your account's profile information and email address.
</div> </div>
@ -10,9 +13,11 @@
<div class="col-span-12 lg:col-span-8"> <div class="col-span-12 lg:col-span-8">
<AccountUpdateProfile /> <AccountUpdateProfile />
</div> </div>
<UDivider class="col-span-12" /> <USeparator class="col-span-12" />
<div class="col-span-12 lg:col-span-4"> <div class="col-span-12 lg:col-span-4">
<div class="text-lg font-semibold mb-2">Update Password</div> <div class="text-lg font-semibold mb-2">
Update Password
</div>
<div class="text-sm opacity-80"> <div class="text-sm opacity-80">
Ensure your account is using a long, random password to stay secure. Ensure your account is using a long, random password to stay secure.
</div> </div>

View File

@ -0,0 +1,9 @@
<script lang="ts" setup>
definePageMeta({
redirect: '/account/general',
})
</script>
<template>
<div />
</template>

View File

@ -0,0 +1,29 @@
<script setup lang="ts">
import AuthForgotPasswordForm from '~/components/auth/AuthForgotPasswordForm.vue'
definePageMeta({ middleware: ['guest'], layout: 'auth' })
</script>
<template>
<div class="mx-auto flex min-h-screen w-full items-center justify-center">
<UCard class="w-96">
<template #header>
<div class="space-y-4 text-center ">
<h1 class="text-2xl font-bold">
Forgot Password
</h1>
<p class="text-sm">
Remember your password? <NuxtLink
to="/login"
class="text-primary hover:text-primary-300 font-bold"
>
Login here
</NuxtLink>
</p>
</div>
</template>
<AuthForgotPasswordForm />
<!-- <UDivider label="OR" class=" my-4"/> -->
</UCard>
</div>
</template>

View File

@ -1,9 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
definePageMeta({ middleware: ['auth'] }) definePageMeta({ middleware: ['auth'] })
const modal = useModal();
const router = useRouter();
const auth = useAuthStore();
</script> </script>
<template> <template>

View File

@ -0,0 +1,23 @@
<script setup lang="ts">
import AuthLoginForm from '~/components/auth/AuthLoginForm.vue'
definePageMeta({ middleware: ['guest'], layout: 'auth' })
</script>
<template>
<div class="mx-auto flex min-h-screen w-full items-center justify-center">
<UCard class="w-96">
<template #header>
<h1 class="text-center text-2xl font-bold">
Login
</h1>
</template>
<AuthLoginForm />
<!-- <UDivider label="OR" class=" my-4"/> -->
</UCard>
</div>
</template>
<style scoped></style>

View File

@ -1,10 +1,9 @@
<template> <template>
<div> <div />
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
definePageMeta({ middleware: ['auth'], layout: 'auth' }); definePageMeta({ middleware: ['auth'], layout: 'auth' })
const auth = useAuthStore() const auth = useAuthStore()
auth.logout() auth.logout()
</script> </script>

View File

@ -0,0 +1,21 @@
<script setup lang="ts">
import AuthResetPasswordForm from '~/components/auth/AuthResetPasswordForm.vue'
definePageMeta({ middleware: ['guest'], layout: 'auth' })
</script>
<template>
<div class="mx-auto flex min-h-screen w-full items-center justify-center">
<UCard class="w-96">
<template #header>
<h1 class="text-center text-2xl font-bold">
Reset Password
</h1>
</template>
<AuthResetPasswordForm />
<!-- <UDivider label="OR" class=" my-4"/> -->
</UCard>
</div>
</template>
<style scoped></style>

View File

@ -1,5 +1,5 @@
import { ofetch } from 'ofetch' import { ofetch } from 'ofetch'
import type { FetchOptions } from 'ofetch'; import type { FetchOptions } from 'ofetch'
export default defineNuxtPlugin({ export default defineNuxtPlugin({
name: 'app', name: 'app',
@ -12,8 +12,8 @@ export default defineNuxtPlugin({
nuxtApp.provide('storage', (path: string): string => { nuxtApp.provide('storage', (path: string): string => {
if (!path) return '' if (!path) return ''
return path.startsWith('http://') || path.startsWith('https://') ? return path.startsWith('http://') || path.startsWith('https://')
path ? path
: config.public.storageBase + path : config.public.storageBase + path
}) })
@ -21,45 +21,45 @@ export default defineNuxtPlugin({
// Initial headers with Accept // Initial headers with Accept
const initialHeaders = { const initialHeaders = {
...headers, ...headers,
'Accept': 'application/json', Accept: 'application/json',
}; }
// Conditionally add server-specific headers // Conditionally add server-specific headers
if (process.server) { if (import.meta.server) {
const serverHeaders = { const serverHeaders = {
'referer': useRequestURL().toString(), referer: useRequestURL().toString(),
...useRequestHeaders(['x-forwarded-for', 'user-agent', 'referer']), ...useRequestHeaders(['x-forwarded-for', 'user-agent', 'referer']),
}; }
Object.assign(initialHeaders, serverHeaders); Object.assign(initialHeaders, serverHeaders)
} }
// Conditionally add authorization header if logged in // Conditionally add authorization header if logged in
if (auth.isLoggedIn) { if (auth.isLoggedIn) {
const authHeaders = { const authHeaders = {
'Authorization': `Bearer ${auth.token}`, Authorization: `Bearer ${auth.token}`,
}; }
Object.assign(initialHeaders, authHeaders); Object.assign(initialHeaders, authHeaders)
} }
return initialHeaders; return initialHeaders
} }
function buildBaseURL(baseURL: string): string { function buildBaseURL(baseURL: string): string {
if (baseURL) return baseURL; if (baseURL) return baseURL
return process.server ? return import.meta.server
config.apiLocal + config.public.apiPrefix ? config.apiLocal + config.public.apiPrefix
: config.public.apiBase + config.public.apiPrefix; : config.public.apiBase + config.public.apiPrefix
} }
function buildSecureMethod(options: FetchOptions): void { function buildSecureMethod(options: FetchOptions): void {
if (process.server) return; if (import.meta.server) return
const method = options.method?.toLowerCase() ?? 'get' const method = options.method?.toLowerCase() ?? 'get'
if (options.body instanceof FormData && method === 'put') { if (options.body instanceof FormData && method === 'put') {
options.method = 'POST'; options.method = 'POST'
options.body.append('_method', 'PUT'); options.body.append('_method', 'PUT')
} }
} }
@ -67,7 +67,7 @@ export default defineNuxtPlugin({
return !baseURL return !baseURL
&& !path.startsWith('/_nuxt') && !path.startsWith('/_nuxt')
&& !path.startsWith('http://') && !path.startsWith('http://')
&& !path.startsWith('https://'); && !path.startsWith('https://')
} }
globalThis.$fetch = ofetch.create({ globalThis.$fetch = ofetch.create({
@ -76,18 +76,18 @@ export default defineNuxtPlugin({
onRequest({ request, options }) { onRequest({ request, options }) {
if (!isRequestWithAuth(options.baseURL ?? '', request.toString())) return if (!isRequestWithAuth(options.baseURL ?? '', request.toString())) return
options.credentials = 'include'; options.credentials = 'include'
options.baseURL = buildBaseURL(options.baseURL ?? ''); options.baseURL = buildBaseURL(options.baseURL ?? '')
options.headers = buildHeaders(options.headers); options.headers = buildHeaders(options.headers)
buildSecureMethod(options); buildSecureMethod(options)
}, },
onRequestError({ error }) { onRequestError({ error }) {
if (process.server) return; if (import.meta.server) return
if (error.name === 'AbortError') return; if (error.name === 'AbortError') return
useToast().add({ useToast().add({
icon: 'i-heroicons-exclamation-circle-solid', icon: 'i-heroicons-exclamation-circle-solid',
@ -103,15 +103,16 @@ export default defineNuxtPlugin({
auth.user = {} as User auth.user = {} as User
} }
if (process.client) { if (import.meta.client) {
useToast().add({ useToast().add({
title: 'Please log in to continue', title: 'Please log in to continue',
icon: 'i-heroicons-exclamation-circle-solid', icon: 'i-heroicons-exclamation-circle-solid',
color: 'primary', color: 'primary',
}) })
} }
} else if (response.status !== 422) { }
if (process.client) { else if (response.status !== 422) {
if (import.meta.client) {
useToast().add({ useToast().add({
icon: 'i-heroicons-exclamation-circle-solid', icon: 'i-heroicons-exclamation-circle-solid',
color: 'red', color: 'red',
@ -119,11 +120,11 @@ export default defineNuxtPlugin({
}) })
} }
} }
} },
} as FetchOptions) } as FetchOptions)
if (auth.isLoggedIn) { if (auth.isLoggedIn) {
await auth.fetchUser(); await auth.fetchUser()
} }
}, },
}) })

View File

@ -1,30 +1,31 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import type { IAccountLogoutResponse } from '~/types/account'
export type User = { export type User = {
ulid: string; ulid: string
name: string; name: string
email: string; email: string
avatar: string; avatar: string
must_verify_email: boolean; must_verify_email: boolean
has_password: boolean; has_password: boolean
roles: string[]; roles: string[]
providers: string[]; providers: string[]
} }
export const useAuthStore = defineStore('auth', () => { export const useAuthStore = defineStore('auth', () => {
const config = useRuntimeConfig() const config = useRuntimeConfig()
const nuxtApp = useNuxtApp() const nuxtApp = useNuxtApp()
const user = ref(<User>{}); const user = ref(<User>{})
const token = useCookie('token', { const token = useCookie('token', {
path: '/', path: '/',
sameSite: 'strict', sameSite: 'strict',
secure: config.public.apiBase.startsWith('https://'), secure: config.public.apiBase.startsWith('https://'),
maxAge: 60 * 60 * 24 * 365 maxAge: 60 * 60 * 24 * 365,
}) })
const isLoggedIn = computed(() => !!token.value) const isLoggedIn = computed(() => !!token.value)
const { refresh: logout } = useFetch<any>('logout', { const { refresh: logout } = useFetch<IAccountLogoutResponse>('logout', {
method: 'POST', method: 'POST',
immediate: false, immediate: false,
onResponse({ response }) { onResponse({ response }) {
@ -36,16 +37,16 @@ export const useAuthStore = defineStore('auth', () => {
return navigateTo('/') return navigateTo('/')
}) })
} }
} },
}) })
const { refresh: fetchUser } = useFetch<any>('user', { const { refresh: fetchUser } = useFetch<{ user: User }>('user', {
immediate: false, immediate: false,
onResponse({ response }) { onResponse({ response }) {
if (response.status === 200) { if (response.status === 200) {
user.value = response._data.user user.value = response._data.user
} }
} },
}) })
return { user, isLoggedIn, logout, fetchUser, token } return { user, isLoggedIn, logout, fetchUser, token }

51
nuxt/app/types/account.d.ts vendored Normal file
View File

@ -0,0 +1,51 @@
import type { ButtonProps } from '#ui/components/Button.vue'
export interface IAccountLoginResponse {
ok: boolean
token: string
}
export interface IAccountLogoutResponse {
ok: boolean
}
export interface IAccountProviderData {
provider: string
token: string
message?: string
}
export interface IAccountChangePasswordResponse {
ok: boolean
}
export interface IAccountResetPasswordResponse {
ok: boolean
}
export interface IVerificationNotificationResponse {
ok: boolean
message: string
}
export interface IAccountUpdateResponse {
ok: boolean
}
export interface IForgotPasswordResponse {
ok: boolean
message: string
}
export interface IAccountProvider {
name: string
icon: string
color: ButtonProps['color']
loading?: boolean
}
export interface IUploadResponse {
ok: boolean
path: string
message?: string
}

12
nuxt/app/types/device.d.ts vendored Normal file
View File

@ -0,0 +1,12 @@
export type Device = {
name: string
ip: string
last_used_at: string
is_current: boolean
hash: string
}
export interface IDevicesResponse {
ok: boolean
devices: Device[]
}

0
nuxt/app/types/responses.d.ts vendored Normal file
View File

View File

@ -1,25 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
#__nuxt {
@apply min-h-screen flex flex-col;
}
@layer base {
html {
@apply text-gray-800 dark:bg-gray-900;
}
html.dark {
@apply text-gray-50 bg-gray-900;
}
button, a, [role="button"] {
@apply transition-colors;
}
input[type="checkbox"], input[type="radio"] {
@apply transition-all;
}
}

View File

@ -1,33 +0,0 @@
<script setup lang="ts">
const links = [{
label: 'Documentation',
icon: 'i-heroicons-book-open',
to: 'https://ui.nuxt.com/getting-started',
}, {
label: 'Pro',
icon: 'i-heroicons-square-3-stack-3d',
to: 'https://ui.nuxt.com/pro',
}, {
label: 'Releases',
icon: 'i-heroicons-rocket-launch',
to: 'https://github.com/nuxt/ui/releases',
target: '_blank',
}]
</script>
<template>
<UHeader :links="links">
<template #logo>
<Logo class="h-6 w-auto" />
</template>
<template #right>
<UColorModeButton />
<UserDropdown />
</template>
<template #panel>
<UNavigationTree :links="links" />
</template>
</UHeader>
</template>

View File

@ -1,5 +0,0 @@
<template>
<div>
<span class="text-primary">Nuxt</span> Breeze
</div>
</template>

View File

@ -1,28 +0,0 @@
<script setup lang="ts">
const links = [
{
label: 'Account',
to: '/account',
icon: 'i-heroicons-user-solid',
},
{
label: 'Logout',
to: '/logout',
icon: 'i-heroicons-arrow-left-on-rectangle',
}]
</script>
<template>
<UPopover>
<UButton icon="i-heroicons-user-solid" color="gray" variant="ghost" />
<template #panel>
<div class="p-4">
<UNavigationLinks :links="links" />
</div>
</template>
</UPopover>
</template>
<style scoped>
</style>

View File

@ -1,102 +0,0 @@
<script lang="ts" setup>
const form = ref();
const auth = useAuthStore();
const state = reactive({
current_password: "",
password: "",
password_confirmation: "",
});
const { refresh: onSubmit, status: accountPasswordStatus } = useFetch<any>("account/password", {
method: "POST",
body: state,
immediate: false,
watch: false,
async onResponse({ response }) {
if (response?.status === 422) {
form.value.setErrors(response._data?.errors);
} else if (response._data?.ok) {
useToast().add({
icon: "i-heroicons-check-circle-20-solid",
title: "The password was successfully updated.",
color: "emerald",
});
state.current_password = "";
state.password = "";
state.password_confirmation = "";
}
}
});
const { refresh: sendResetPasswordEmail, status: resetPasswordEmailStatus } = useFetch<any>("verification-notification", {
method: "POST",
body: { email: auth.user.email },
immediate: false,
watch: false,
onResponse({ response }) {
if (response._data?.ok) {
useToast().add({
icon: "i-heroicons-check-circle-20-solid",
title: "A link to reset your password has been sent to your email.",
color: "emerald",
});
}
}
});
</script>
<template>
<div>
<UForm
v-if="auth.user.has_password"
ref="form"
:state="state"
@submit="onSubmit"
class="space-y-4"
>
<UFormGroup label="Current Password" name="current_password" required>
<UInput v-model="state.current_password" type="password" autocomplete="off" />
</UFormGroup>
<UFormGroup
label="New Password"
name="password"
hint="min 8 characters"
:ui="{ hint: 'text-xs text-gray-500 dark:text-gray-400' }"
required
>
<UInput v-model="state.password" type="password" autocomplete="off" />
</UFormGroup>
<UFormGroup label="Repeat Password" name="password_confirmation" required>
<UInput
v-model="state.password_confirmation"
type="password"
autocomplete="off"
/>
</UFormGroup>
<div class="pt-2">
<UButton type="submit" label="Save" :loading="accountPasswordStatus === 'pending'" />
</div>
</UForm>
<UAlert
v-else
icon="i-heroicons-information-circle-20-solid"
title="Send a link to your email to reset your password."
description="To create a password for your account, you must go through the password recovery process."
:actions="[
{
label: 'Send link to Email',
variant: 'solid',
color: 'gray',
loading: resetPasswordEmailStatus === 'pending',
click: sendResetPasswordEmail,
},
]"
/>
</div>
</template>

View File

@ -1,27 +0,0 @@
<script setup lang="ts">
const props = defineProps({
error: Object,
});
const handleError = () => clearError({ redirect: "/" });
</script>
<template>
<UContainer class="py-5 flex items-center justify-center">
<AppLogo />
</UContainer>
<UContainer class="flex-grow flex flex-col items-center justify-center space-y-5">
<h1 class="text-9xl font-bold">{{ error?.statusCode }}</h1>
<div>{{ error?.message }}</div>
<div v-if="error?.statusCode >= 500" v-html="error?.stack"></div>
<div>
<UButton
@click="handleError"
color="gray"
size="xl"
variant="ghost"
label="Go back"
/>
</div>
</UContainer>
</template>

View File

@ -1,17 +0,0 @@
<template>
<div>
<Header />
<UPage>
<template #left>
<UAside class="lg:static">
<Navigation />
</UAside>
</template>
<UPageBody>
<UContainer>
<slot />
</UContainer>
</UPageBody>
</UPage>
</div>
</template>

View File

@ -1,27 +0,0 @@
<script lang="ts" setup>
definePageMeta({
middleware: ["auth"],
});
const links = [
[
{
label: "Account",
icon: "i-heroicons-user",
to: "/account/general",
},
{
label: "Devices",
icon: "i-heroicons-device-phone-mobile",
to: "/account/devices",
},
],
];
</script>
<template>
<UHorizontalNavigation
:links="links"
class="border-b border-gray-200 dark:border-gray-800 mb-4"
/>
<NuxtPage class="col-span-10 md:col-span-8" />
</template>

View File

@ -1,86 +0,0 @@
<script lang="ts" setup>
const dayjs = useDayjs();
const auth = useAuthStore();
const loading = ref(false);
const devices = ref([]);
async function fetchData() {
loading.value = true;
const response = await $fetch<any>("devices");
if (response.ok) {
devices.value = response.devices;
}
loading.value = false;
};
const columns = [
{
key: "name",
label: "Device",
},
{
key: "last_used_at",
label: "Last used at",
class: "max-w-[9rem] w-[9rem] min-w-[9rem]",
},
{
key: "actions",
},
];
const items = (row: any) => [
[
{
label: "Delete",
icon: "i-heroicons-trash-20-solid",
click: async () => {
await $fetch<any>("devices/disconnect", {
method: "POST",
body: {
hash: row.hash,
},
async onResponse({ response }) {
if (response._data?.ok) {
await fetchData();
await auth.fetchUser();
}
}
});
},
},
],
];
if (process.client) {
fetchData();
}
</script>
<template>
<UCard :ui="{ body: { padding: 'p-0' } }">
<ClientOnly>
<UTable :rows="devices" :columns="columns" size="lg" :loading="loading">
<template #name-data="{ row }">
<div class="font-semibold">
{{ row.name }}
<UBadge v-if="row.is_current" label="active" color="emerald" variant="soft" size="xs" class="ms-1" />
</div>
<div class="font-medium text-sm">IP: {{ row.ip }}</div>
</template>
<template #last_used_at-data="{ row }">
{{ dayjs(row.last_used_at).fromNow() }}
</template>
<template #actions-data="{ row }">
<div class="flex justify-end">
<UDropdown :items="items(row)">
<UButton :disabled="row.is_current" color="gray" variant="ghost"
icon="i-heroicons-ellipsis-horizontal-20-solid" />
</UDropdown>
</div>
</template>
</UTable>
</ClientOnly>
</UCard>
</template>

View File

@ -1,6 +0,0 @@
<script lang="ts" setup>
definePageMeta({
redirect: "/account/general",
});
</script>
<template></template>

View File

@ -1,62 +0,0 @@
<script setup lang="ts">
definePageMeta({ middleware: ['guest'], layout: 'auth' })
const form = ref();
const state = reactive({
email: "",
});
const { refresh: onSubmit, status: forgotStatus } = useFetch<any>("forgot-password", {
method: "POST",
body: state,
immediate: false,
watch: false,
async onResponse({ response }) {
if (response?.status === 422) {
form.value.setErrors(response._data?.errors);
} else if (response._data?.ok) {
useToast().add({
title: "Success",
description: response._data.message,
color: "emerald",
});
}
}
});
</script>
<template>
<UMain>
<UPage>
<div class="mx-auto flex min-h-screen w-full items-center justify-center">
<UCard class="w-96">
<template #header>
<div class="space-y-4 text-center ">
<h1 class="text-2xl font-bold">
Forgot Password
</h1>
<p class="text-sm">
Remember your password? <NuxtLink to="/login" class="text-primary hover:text-primary-300 font-semibold">
Login here
</NuxtLink>
</p>
</div>
</template>
<UForm ref="form" :state="state" class="space-y-8" @submit="onSubmit">
<UFormGroup label="Email" name="email">
<UInput v-model="state.email" />
</UFormGroup>
<UButton block size="md" type="submit" :loading="forgotStatus === 'pending'" icon="i-heroicons-envelope">
Reset Password
</UButton>
</UForm>
<!-- <UDivider label="OR" class=" my-4"/> -->
</UCard>
</div>
</UPage>
</UMain>
</template>

View File

@ -1,103 +0,0 @@
<script setup lang="ts">
definePageMeta({ middleware: ['guest'], layout: 'auth' })
const config = useRuntimeConfig();
const router = useRouter();
const auth = useAuthStore();
const form = ref();
type Provider = {
name: string;
icon: string;
color: string;
loading?: boolean;
};
const state = reactive({
email: "",
password: "",
remember: false,
});
const { refresh: onSubmit, status: loginStatus } = useFetch<any>("login", {
method: "POST",
body: state,
immediate: false,
watch: false,
async onResponse({ response }) {
if (response?.status === 422) {
form.value.setErrors(response._data?.errors);
} else if (response._data?.ok) {
auth.token = response._data.token;
await auth.fetchUser();
await router.push("/");
}
}
});
const providers = ref<{ [key: string]: Provider }>(config.public.providers);
async function handleMessage(event: { data: any }): Promise<void> {
const provider = event.data.provider as string;
if (Object.keys(providers.value).includes(provider) && event.data.token) {
providers.value[provider].loading = false;
auth.token = event.data.token;
await auth.fetchUser();
await router.push("/");
} else if (event.data.message) {
useToast().add({
icon: "i-heroicons-exclamation-circle-solid",
color: "red",
title: event.data.message,
});
}
}
</script>
<template>
<UMain>
<UPage>
<div class="mx-auto flex min-h-screen w-full items-center justify-center">
<UCard class="w-96">
<template #header>
<h1 class="text-center text-2xl font-bold">
Login
</h1>
</template>
<UForm ref="form" :state="state" class="space-y-4" @submit="onSubmit">
<UFormGroup label="Email" name="email">
<UInput v-model="state.email" />
</UFormGroup>
<UFormGroup label="Password" name="password">
<UInput v-model="state.password" type="password" />
</UFormGroup>
<div class="flex items-center justify-between">
<UCheckbox id="remember-me" v-model="state.remember" label="Remember me" name="remember-me" />
<div class="text-sm leading-6">
<NuxtLink to="/forgot-password" class="text-primary hover:text-primary-300 font-semibold">
Forgot
password?
</NuxtLink>
</div>
</div>
<UButton block size="md" type="submit" :loading="loginStatus === 'pending'" icon="i-heroicons-arrow-right-on-rectangle">
Login
</UButton>
</UForm>
<!-- <UDivider label="OR" class=" my-4"/> -->
</UCard>
</div>
</UPage>
</UMain>
</template>
<style scoped></style>

View File

@ -1,78 +0,0 @@
<script setup lang="ts">
definePageMeta({ middleware: ['guest'], layout: 'auth' })
const router = useRouter();
const route = useRoute();
const auth = useAuthStore();
const form = ref();
const state = reactive({
email: route.query.email as string,
token: route.params.token,
password: "",
password_confirmation: "",
});
const { refresh: onSubmit, status: resetStatus } = useFetch<any>("reset-password", {
method: "POST",
body: state,
immediate: false,
watch: false,
async onResponse({ response }) {
if (response?.status === 422) {
form.value.setErrors(response._data?.errors);
} else if (response._data?.ok) {
useToast().add({
title: "Success",
description: response._data.message,
color: "emerald",
});
if (auth.isLoggedIn) {
await auth.fetchUser();
await router.push("/");
} else {
await router.push("/login");
}
}
}
});
</script>
<template>
<UMain>
<UPage>
<div class="mx-auto flex min-h-screen w-full items-center justify-center">
<UCard class="w-96">
<template #header>
<h1 class="text-center text-2xl font-bold">
Reset Password
</h1>
</template>
<UForm ref="form" :state="state" class="space-y-4" @submit="onSubmit">
<UFormGroup label="Email" name="email">
<UInput v-model="state.email" disabled />
</UFormGroup>
<UFormGroup label="Password" name="password">
<UInput v-model="state.password" type="password" />
</UFormGroup>
<UFormGroup label="Confirm Password" name="password_confirmation">
<UInput v-model="state.password_confirmation" type="password" />
</UFormGroup>
<UButton block size="md" type="submit" :loading="resetStatus === 'pending'" icon="i-heroicon-lock-closed">
Change Password
</UButton>
</UForm>
<!-- <UDivider label="OR" class=" my-4"/> -->
</UCard>
</div>
</UPage>
</UMain>
</template>
<style scoped></style>

View File

@ -10,24 +10,34 @@
"generate": "nuxt generate", "generate": "nuxt generate",
"preview": "nuxt preview", "preview": "nuxt preview",
"postinstall": "nuxt prepare", "postinstall": "nuxt prepare",
"api": "php artisan octane:start --watch --port=8000 --host=127.0.0.1" "api": "php artisan octane:start --watch --port=8000 --host=127.0.0.1",
"lint": "eslint .",
"lint:fix": "eslint . --fix"
}, },
"devDependencies": { "devDependencies": {
"@iconify-json/heroicons": "^1.1.20", "@iconify-json/heroicons": "^1.2.2",
"@iconify/vue": "^4.1.1", "@iconify-json/lucide": "^1.2.42",
"@nuxt/devtools": "^1.0.8", "@iconify/vue": "^5.0.0",
"@nuxt/image": "^1.4.0", "@nuxt/devtools": "^2.4.0",
"@nuxt/ui-pro": "^1.0.2", "@nuxt/eslint": "^1.3.0",
"@pinia/nuxt": "^0.5.1", "@nuxt/image": "^1.10.0",
"chokidar": "^3.6.0", "@pinia/nuxt": "^0.11.0",
"chokidar": "^4.0.3",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"dayjs-nuxt": "^2.1.9", "dayjs-nuxt": "^2.1.11",
"nuxt": "^3.11.2", "eslint": "^9.26.0",
"nuxt-security": "^1.2.2", "nuxt": "^3.17.2",
"vue": "3.4.21", "nuxt-security": "^2.2.0",
"vue-router": "^4.3.0" "typescript": "^5.8.3",
"vue": "3.5.12",
"vue-router": "^4.5.1"
}, },
"resolutions": { "resolutions": {
"vue": "3.4.21" "vue": "3.5.12"
} },
"dependencies": {
"@nuxt/ui": "3.1.1",
"tailwindcss": "4.1.3"
},
"packageManager": "pnpm@10.10.0"
} }

File diff suppressed because it is too large Load Diff