Refactor project structure and update dependencies
parent
81d692a19b
commit
813f7b18d8
|
|
@ -49,3 +49,5 @@ logs
|
||||||
/caddy
|
/caddy
|
||||||
frankenphp
|
frankenphp
|
||||||
frankenphp-worker.php
|
frankenphp-worker.php
|
||||||
|
|
||||||
|
**/caddy
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"}}}}
|
||||||
|
|
@ -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"}}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
// @ts-check
|
||||||
|
import withNuxt from './.nuxt/eslint.config.mjs'
|
||||||
|
|
||||||
|
export default withNuxt(
|
||||||
|
|
||||||
|
)
|
||||||
|
|
@ -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.',
|
|
||||||
|
|
||||||
];
|
|
||||||
|
|
@ -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' => '« Previous',
|
|
||||||
'next' => 'Next »',
|
|
||||||
|
|
||||||
];
|
|
||||||
|
|
@ -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.",
|
|
||||||
|
|
||||||
];
|
|
||||||
|
|
@ -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' => [],
|
|
||||||
|
|
||||||
];
|
|
||||||
109
nuxt.config.ts
109
nuxt.config.ts
|
|
@ -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'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
10
nuxt/app.vue
10
nuxt/app.vue
|
|
@ -1,10 +0,0 @@
|
||||||
<script lang="ts" setup>
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<NuxtLayout>
|
|
||||||
<NuxtPage />
|
|
||||||
</NuxtLayout>
|
|
||||||
|
|
||||||
<UNotifications />
|
|
||||||
</template>
|
|
||||||
|
|
@ -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',
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UApp>
|
||||||
|
<NuxtLayout>
|
||||||
|
<NuxtPage />
|
||||||
|
</NuxtLayout>
|
||||||
|
</UApp>
|
||||||
|
</template>
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
@import "tailwindcss";
|
||||||
|
@import "@nuxt/ui";
|
||||||
|
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
<template>
|
||||||
|
<NuxtLink
|
||||||
|
to="/"
|
||||||
|
class="font-bold text-xl"
|
||||||
|
>
|
||||||
|
<span class="text-primary-500">Nuxt</span> Breeze
|
||||||
|
</NuxtLink>
|
||||||
|
</template>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -4,4 +4,4 @@ declare module '#app' {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export { }
|
export { }
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
export default defineNuxtRouteMiddleware((to, from) => {
|
export default defineNuxtRouteMiddleware(() => {
|
||||||
const nuxtApp = useNuxtApp()
|
const nuxtApp = useNuxtApp()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
export default defineNuxtRouteMiddleware((to, from) => {
|
export default defineNuxtRouteMiddleware(() => {
|
||||||
const nuxtApp = useNuxtApp()
|
const nuxtApp = useNuxtApp()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
|
||||||
|
|
@ -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('/')
|
||||||
})
|
})
|
||||||
|
|
@ -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('/')
|
||||||
})
|
})
|
||||||
|
|
@ -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')
|
||||||
})
|
})
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
<template>
|
||||||
|
<UCard>
|
||||||
|
<AccountDeviceTable />
|
||||||
|
</UCard>
|
||||||
|
</template>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
definePageMeta({
|
||||||
|
redirect: '/account/general',
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div />
|
||||||
|
</template>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
@ -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 }
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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[]
|
||||||
|
}
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
<template>
|
|
||||||
<div>
|
|
||||||
<span class="text-primary">Nuxt</span> Breeze
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
<script lang="ts" setup>
|
|
||||||
definePageMeta({
|
|
||||||
redirect: "/account/general",
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
<template></template>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
40
package.json
40
package.json
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
11852
pnpm-lock.yaml
11852
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue