mirror of
https://github.com/koel/koel
synced 2024-11-27 22:40:26 +00:00
feat: forgot password
This commit is contained in:
parent
43e1a84cc1
commit
e9695495c9
40 changed files with 783 additions and 101 deletions
|
@ -86,3 +86,12 @@ function gravatar(string $email, int $size = 192): string
|
||||||
{
|
{
|
||||||
return sprintf("https://www.gravatar.com/avatar/%s?s=$size&d=robohash", md5($email));
|
return sprintf("https://www.gravatar.com/avatar/%s?s=$size&d=robohash", md5($email));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A quick check to determine if a mailer is configured.
|
||||||
|
* This is not bulletproof but should work in most cases.
|
||||||
|
*/
|
||||||
|
function mailer_configured(): bool
|
||||||
|
{
|
||||||
|
return config('mail.default') && !in_array(config('mail.default'), ['log', 'array'], true);
|
||||||
|
}
|
||||||
|
|
20
app/Http/Controllers/API/ForgotPasswordController.php
Normal file
20
app/Http/Controllers/API/ForgotPasswordController.php
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\API;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\API\ForgotPasswordRequest;
|
||||||
|
use App\Services\AuthenticationService;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
|
|
||||||
|
class ForgotPasswordController extends Controller
|
||||||
|
{
|
||||||
|
public function __invoke(ForgotPasswordRequest $request, AuthenticationService $auth)
|
||||||
|
{
|
||||||
|
static::disableInDemo();
|
||||||
|
|
||||||
|
return $auth->trySendResetPasswordLink($request->email)
|
||||||
|
? response()->noContent()
|
||||||
|
: response('', Response::HTTP_NOT_FOUND);
|
||||||
|
}
|
||||||
|
}
|
|
@ -9,6 +9,7 @@ use App\Models\User;
|
||||||
use App\Services\TokenManager;
|
use App\Services\TokenManager;
|
||||||
use Illuminate\Contracts\Auth\Authenticatable;
|
use Illuminate\Contracts\Auth\Authenticatable;
|
||||||
use Illuminate\Contracts\Hashing\Hasher;
|
use Illuminate\Contracts\Hashing\Hasher;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
class ProfileController extends Controller
|
class ProfileController extends Controller
|
||||||
|
@ -28,9 +29,7 @@ class ProfileController extends Controller
|
||||||
|
|
||||||
public function update(ProfileUpdateRequest $request)
|
public function update(ProfileUpdateRequest $request)
|
||||||
{
|
{
|
||||||
if (config('koel.misc.demo')) {
|
static::disableInDemo(Response::HTTP_NO_CONTENT);
|
||||||
return response()->noContent();
|
|
||||||
}
|
|
||||||
|
|
||||||
throw_unless(
|
throw_unless(
|
||||||
$this->hash->check($request->current_password, $this->user->password),
|
$this->hash->check($request->current_password, $this->user->password),
|
||||||
|
|
20
app/Http/Controllers/API/ResetPasswordController.php
Normal file
20
app/Http/Controllers/API/ResetPasswordController.php
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\API;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\API\ResetPasswordRequest;
|
||||||
|
use App\Services\AuthenticationService;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
|
|
||||||
|
class ResetPasswordController extends Controller
|
||||||
|
{
|
||||||
|
public function __invoke(ResetPasswordRequest $request, AuthenticationService $auth)
|
||||||
|
{
|
||||||
|
static::disableInDemo();
|
||||||
|
|
||||||
|
return $auth->tryResetPasswordUsingBroker($request->email, $request->password, $request->token)
|
||||||
|
? response()->noContent()
|
||||||
|
: response('', Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||||
|
}
|
||||||
|
}
|
|
@ -5,6 +5,7 @@ namespace App\Http\Controllers;
|
||||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
use Illuminate\Foundation\Bus\DispatchesJobs;
|
use Illuminate\Foundation\Bus\DispatchesJobs;
|
||||||
use Illuminate\Foundation\Validation\ValidatesRequests;
|
use Illuminate\Foundation\Validation\ValidatesRequests;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
use Illuminate\Routing\Controller as BaseController;
|
use Illuminate\Routing\Controller as BaseController;
|
||||||
|
|
||||||
abstract class Controller extends BaseController
|
abstract class Controller extends BaseController
|
||||||
|
@ -12,4 +13,9 @@ abstract class Controller extends BaseController
|
||||||
use AuthorizesRequests;
|
use AuthorizesRequests;
|
||||||
use DispatchesJobs;
|
use DispatchesJobs;
|
||||||
use ValidatesRequests;
|
use ValidatesRequests;
|
||||||
|
|
||||||
|
protected static function disableInDemo(int $code = Response::HTTP_FORBIDDEN): void
|
||||||
|
{
|
||||||
|
abort_if(config('koel.misc.demo'), $code);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,6 +8,7 @@ use App\Http\Middleware\ForceHttps;
|
||||||
use App\Http\Middleware\ObjectStorageAuthenticate;
|
use App\Http\Middleware\ObjectStorageAuthenticate;
|
||||||
use App\Http\Middleware\ThrottleRequests;
|
use App\Http\Middleware\ThrottleRequests;
|
||||||
use App\Http\Middleware\TrimStrings;
|
use App\Http\Middleware\TrimStrings;
|
||||||
|
use App\Http\Middleware\TrustHosts;
|
||||||
use Illuminate\Auth\Middleware\Authorize;
|
use Illuminate\Auth\Middleware\Authorize;
|
||||||
use Illuminate\Foundation\Http\Kernel as HttpKernel;
|
use Illuminate\Foundation\Http\Kernel as HttpKernel;
|
||||||
use Illuminate\Foundation\Http\Middleware\CheckForMaintenanceMode;
|
use Illuminate\Foundation\Http\Middleware\CheckForMaintenanceMode;
|
||||||
|
@ -26,6 +27,7 @@ class Kernel extends HttpKernel
|
||||||
ValidatePostSize::class,
|
ValidatePostSize::class,
|
||||||
TrimStrings::class,
|
TrimStrings::class,
|
||||||
ForceHttps::class,
|
ForceHttps::class,
|
||||||
|
TrustHosts::class,
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
18
app/Http/Middleware/TrustHosts.php
Normal file
18
app/Http/Middleware/TrustHosts.php
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Illuminate\Http\Middleware\TrustHosts as IlluminateTrustHost;
|
||||||
|
|
||||||
|
class TrustHosts extends IlluminateTrustHost
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
public function hosts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
$this->allSubdomainsOfApplicationUrl(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
17
app/Http/Requests/API/ForgotPasswordRequest.php
Normal file
17
app/Http/Requests/API/ForgotPasswordRequest.php
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\API;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @property-read string $email
|
||||||
|
*/
|
||||||
|
class ForgotPasswordRequest extends Request
|
||||||
|
{
|
||||||
|
/** @return array<string, string> */
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'email' => 'required|email',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
23
app/Http/Requests/API/ResetPasswordRequest.php
Normal file
23
app/Http/Requests/API/ResetPasswordRequest.php
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\API;
|
||||||
|
|
||||||
|
use Illuminate\Validation\Rules\Password;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @property-read string $token
|
||||||
|
* @property-read string $email
|
||||||
|
* @property-read string $password
|
||||||
|
*/
|
||||||
|
class ResetPasswordRequest extends Request
|
||||||
|
{
|
||||||
|
/** @return array<mixed> */
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'token' => 'required',
|
||||||
|
'email' => 'required|email',
|
||||||
|
'password' => ['sometimes', Password::defaults()],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
|
@ -4,6 +4,7 @@ namespace App\Providers;
|
||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\TokenManager;
|
use App\Services\TokenManager;
|
||||||
|
use Illuminate\Auth\Notifications\ResetPassword;
|
||||||
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
|
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
@ -25,6 +26,12 @@ class AuthServiceProvider extends ServiceProvider
|
||||||
});
|
});
|
||||||
|
|
||||||
$this->setPasswordDefaultRules();
|
$this->setPasswordDefaultRules();
|
||||||
|
|
||||||
|
ResetPassword::createUrlUsing(static function (User $user, string $token): string {
|
||||||
|
$payload = base64_encode($user->getEmailForPasswordReset() . "|$token");
|
||||||
|
|
||||||
|
return url("/#/reset-password/$payload");
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private function setPasswordDefaultRules(): void
|
private function setPasswordDefaultRules(): void
|
||||||
|
|
|
@ -6,14 +6,18 @@ use App\Exceptions\InvalidCredentialsException;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Repositories\UserRepository;
|
use App\Repositories\UserRepository;
|
||||||
use App\Values\CompositeToken;
|
use App\Values\CompositeToken;
|
||||||
|
use Illuminate\Auth\Events\PasswordReset;
|
||||||
|
use Illuminate\Auth\Passwords\PasswordBroker;
|
||||||
use Illuminate\Hashing\HashManager;
|
use Illuminate\Hashing\HashManager;
|
||||||
|
use Illuminate\Support\Facades\Password;
|
||||||
|
|
||||||
class AuthenticationService
|
class AuthenticationService
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private UserRepository $userRepository,
|
private UserRepository $userRepository,
|
||||||
private TokenManager $tokenManager,
|
private TokenManager $tokenManager,
|
||||||
private HashManager $hash
|
private HashManager $hash,
|
||||||
|
private PasswordBroker $passwordBroker
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -38,4 +42,27 @@ class AuthenticationService
|
||||||
{
|
{
|
||||||
$this->tokenManager->deleteCompositionToken($token);
|
$this->tokenManager->deleteCompositionToken($token);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function trySendResetPasswordLink(string $email): bool
|
||||||
|
{
|
||||||
|
return $this->passwordBroker->sendResetLink(['email' => $email]) === Password::RESET_LINK_SENT;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function tryResetPasswordUsingBroker(string $email, string $password, string $token): bool
|
||||||
|
{
|
||||||
|
$credentials = [
|
||||||
|
'email' => $email,
|
||||||
|
'password' => $password,
|
||||||
|
'password_confirmation' => $password,
|
||||||
|
'token' => $token,
|
||||||
|
];
|
||||||
|
|
||||||
|
$status = $this->passwordBroker->reset($credentials, function (User $user, string $password): void {
|
||||||
|
$user->password = $this->hash->make($password);
|
||||||
|
$user->save();
|
||||||
|
event(new PasswordReset($user));
|
||||||
|
});
|
||||||
|
|
||||||
|
return $status === Password::PASSWORD_RESET;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
134
config/mail.php
134
config/mail.php
|
@ -1,42 +1,85 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
| Mail Driver
|
| Default Mailer
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
|
||||||
| Laravel supports both SMTP and PHP's "mail" function as drivers for the
|
| This option controls the default mailer that is used to send any email
|
||||||
| sending of e-mail. You may specify which one you're using throughout
|
| messages sent by your application. Alternative mailers may be setup
|
||||||
| your application here. By default, Laravel is setup for SMTP mail.
|
| and used as needed; however, this mailer will be used by default.
|
||||||
|
|
|
||||||
| Supported: "smtp", "sendmail", "mailgun", "mandrill", "ses",
|
|
||||||
| "sparkpost", "log", "array"
|
|
||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
'driver' => env('MAIL_DRIVER', 'smtp'),
|
|
||||||
|
'default' => env('MAIL_MAILER', 'smtp'),
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
| SMTP Host Address
|
| Mailer Configurations
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
|
||||||
| Here you may provide the host address of the SMTP server used by your
|
| Here you may configure all of the mailers used by your application plus
|
||||||
| applications. A default option is provided that is compatible with
|
| their respective settings. Several examples have been configured for
|
||||||
| the Mailgun mail service which will provide reliable deliveries.
|
| you and you are free to add your own as your application requires.
|
||||||
|
|
|
||||||
|
| Laravel supports a variety of mail "transport" drivers to be used while
|
||||||
|
| sending an e-mail. You will specify which one you are using for your
|
||||||
|
| mailers below. You are free to add additional mailers as required.
|
||||||
|
|
|
||||||
|
| Supported: "smtp", "sendmail", "mailgun", "ses",
|
||||||
|
| "postmark", "log", "array", "failover"
|
||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
'host' => env('MAIL_HOST', 'smtp.mailgun.org'),
|
|
||||||
/*
|
'mailers' => [
|
||||||
|--------------------------------------------------------------------------
|
'smtp' => [
|
||||||
| SMTP Host Port
|
'transport' => 'smtp',
|
||||||
|--------------------------------------------------------------------------
|
'host' => env('MAIL_HOST', 'smtp.mailgun.org'),
|
||||||
|
|
'port' => env('MAIL_PORT', 587),
|
||||||
| This is the SMTP port used by your application to deliver e-mails to
|
'encryption' => env('MAIL_ENCRYPTION', 'tls'),
|
||||||
| users of the application. Like the host we have set this value to
|
'username' => env('MAIL_USERNAME'),
|
||||||
| stay compatible with the Mailgun e-mail application by default.
|
'password' => env('MAIL_PASSWORD'),
|
||||||
|
|
'timeout' => null,
|
||||||
*/
|
'local_domain' => env('MAIL_EHLO_DOMAIN'),
|
||||||
'port' => env('MAIL_PORT', 587),
|
],
|
||||||
|
|
||||||
|
'ses' => [
|
||||||
|
'transport' => 'ses',
|
||||||
|
],
|
||||||
|
|
||||||
|
'mailgun' => [
|
||||||
|
'transport' => 'mailgun',
|
||||||
|
],
|
||||||
|
|
||||||
|
'postmark' => [
|
||||||
|
'transport' => 'postmark',
|
||||||
|
],
|
||||||
|
|
||||||
|
'sendmail' => [
|
||||||
|
'transport' => 'sendmail',
|
||||||
|
'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'log' => [
|
||||||
|
'transport' => 'log',
|
||||||
|
'channel' => env('MAIL_LOG_CHANNEL'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'array' => [
|
||||||
|
'transport' => 'array',
|
||||||
|
],
|
||||||
|
|
||||||
|
'failover' => [
|
||||||
|
'transport' => 'failover',
|
||||||
|
'mailers' => [
|
||||||
|
'smtp',
|
||||||
|
'log',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
| Global "From" Address
|
| Global "From" Address
|
||||||
|
@ -47,44 +90,12 @@ return [
|
||||||
| used globally for all e-mails that are sent by your application.
|
| used globally for all e-mails that are sent by your application.
|
||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'from' => [
|
'from' => [
|
||||||
'address' => env('MAIL_FROM_ADDRESS', 'noreply@koel.local'),
|
'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),
|
||||||
'name' => env('MAIL_FROM_NAME', 'Koel'),
|
'name' => env('MAIL_FROM_NAME', 'Example'),
|
||||||
],
|
],
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| E-Mail Encryption Protocol
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
|
|
|
||||||
| Here you may specify the encryption protocol that should be used when
|
|
||||||
| the application send e-mail messages. A sensible default using the
|
|
||||||
| transport layer security protocol should provide great security.
|
|
||||||
|
|
|
||||||
*/
|
|
||||||
'encryption' => env('MAIL_ENCRYPTION', 'tls'),
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| SMTP Server Username
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
|
|
|
||||||
| If your SMTP server requires a username for authentication, you should
|
|
||||||
| set it here. This will get used to authenticate with your server on
|
|
||||||
| connection. You may also set the "password" value below this one.
|
|
||||||
|
|
|
||||||
*/
|
|
||||||
'username' => env('MAIL_USERNAME'),
|
|
||||||
'password' => env('MAIL_PASSWORD'),
|
|
||||||
/*
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
| Sendmail System Path
|
|
||||||
|--------------------------------------------------------------------------
|
|
||||||
|
|
|
||||||
| When using the "sendmail" driver to send e-mails, we will need to know
|
|
||||||
| the path to where Sendmail lives on this server. A default path has
|
|
||||||
| been provided here, which will work well on most of your systems.
|
|
||||||
|
|
|
||||||
*/
|
|
||||||
'sendmail' => '/usr/sbin/sendmail -bs',
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
| Markdown Mail Settings
|
| Markdown Mail Settings
|
||||||
|
@ -95,10 +106,13 @@ return [
|
||||||
| of the emails. Or, you may simply stick with the Laravel defaults!
|
| of the emails. Or, you may simply stick with the Laravel defaults!
|
||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'markdown' => [
|
'markdown' => [
|
||||||
'theme' => 'default',
|
'theme' => 'default',
|
||||||
|
|
||||||
'paths' => [
|
'paths' => [
|
||||||
resource_path('views/vendor/mail'),
|
resource_path('views/vendor/mail'),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|
|
@ -48,7 +48,7 @@
|
||||||
<env name="YOUTUBE_API_KEY" value="foo"/>
|
<env name="YOUTUBE_API_KEY" value="foo"/>
|
||||||
<env name="BROADCAST_DRIVER" value="log"/>
|
<env name="BROADCAST_DRIVER" value="log"/>
|
||||||
<env name="CACHE_MEDIA" value="true"/>
|
<env name="CACHE_MEDIA" value="true"/>
|
||||||
<env name="MAIL_DRIVER" value="smtp"/>
|
<env name="MAIL_MAILER" value="log"/>
|
||||||
<ini name="memory_limit" value="512M"/>
|
<ini name="memory_limit" value="512M"/>
|
||||||
</php>
|
</php>
|
||||||
</phpunit>
|
</phpunit>
|
||||||
|
|
|
@ -22,6 +22,7 @@
|
||||||
<LoginForm v-if="layout === 'auth'" @loggedin="onUserLoggedIn" />
|
<LoginForm v-if="layout === 'auth'" @loggedin="onUserLoggedIn" />
|
||||||
|
|
||||||
<AcceptInvitation v-if="layout === 'invitation'" />
|
<AcceptInvitation v-if="layout === 'invitation'" />
|
||||||
|
<ResetPasswordForm v-if="layout === 'reset-password'" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
@ -55,6 +56,7 @@ const CreateNewPlaylistContextMenu = defineAsyncComponent(() => import('@/compon
|
||||||
const SupportKoel = defineAsyncComponent(() => import('@/components/meta/SupportKoel.vue'))
|
const SupportKoel = defineAsyncComponent(() => import('@/components/meta/SupportKoel.vue'))
|
||||||
const DropZone = defineAsyncComponent(() => import('@/components/ui/upload/DropZone.vue'))
|
const DropZone = defineAsyncComponent(() => import('@/components/ui/upload/DropZone.vue'))
|
||||||
const AcceptInvitation = defineAsyncComponent(() => import('@/components/invitation/AcceptInvitation.vue'))
|
const AcceptInvitation = defineAsyncComponent(() => import('@/components/invitation/AcceptInvitation.vue'))
|
||||||
|
const ResetPasswordForm = defineAsyncComponent(() => import('@/components/auth/ResetPasswordForm.vue'))
|
||||||
|
|
||||||
const overlay = ref<InstanceType<typeof Overlay>>()
|
const overlay = ref<InstanceType<typeof Overlay>>()
|
||||||
const dialog = ref<InstanceType<typeof DialogBox>>()
|
const dialog = ref<InstanceType<typeof DialogBox>>()
|
||||||
|
@ -62,9 +64,9 @@ const toaster = ref<InstanceType<typeof MessageToaster>>()
|
||||||
const currentSong = ref<Song>()
|
const currentSong = ref<Song>()
|
||||||
const showDropZone = ref(false)
|
const showDropZone = ref(false)
|
||||||
|
|
||||||
const layout = ref<'main' | 'auth' | 'invitation'>()
|
const layout = ref<'main' | 'auth' | 'invitation' | 'reset-password'>()
|
||||||
|
|
||||||
const { isCurrentScreen, resolveRoute } = useRouter()
|
const { isCurrentScreen, getCurrentScreen, resolveRoute } = useRouter()
|
||||||
const { offline } = useNetworkStatus()
|
const { offline } = useNetworkStatus()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -91,7 +93,17 @@ onMounted(async () => {
|
||||||
layout.value = 'main'
|
layout.value = 'main'
|
||||||
} else {
|
} else {
|
||||||
await resolveRoute()
|
await resolveRoute()
|
||||||
layout.value = isCurrentScreen('Invitation.Accept') ? 'invitation' : 'auth'
|
|
||||||
|
switch (getCurrentScreen()) {
|
||||||
|
case 'Invitation.Accept':
|
||||||
|
layout.value = 'invitation'
|
||||||
|
break
|
||||||
|
case 'Password.Reset':
|
||||||
|
layout.value = 'reset-password'
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
layout.value = 'auth'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add an ugly mac/non-mac class for OS-targeting styles.
|
// Add an ugly mac/non-mac class for OS-targeting styles.
|
||||||
|
|
|
@ -4,13 +4,14 @@ import Axios from 'axios'
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
BASE_URL: string;
|
BASE_URL: string
|
||||||
createLemonSqueezy: () => void;
|
MAILER_CONFIGURED: boolean
|
||||||
|
createLemonSqueezy: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LemonSqueezy {
|
interface LemonSqueezy {
|
||||||
Url: {
|
Url: {
|
||||||
Open: () => void;
|
Open: () => void
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -48,6 +49,8 @@ HTMLDialogElement.prototype.close = vi.fn(function mock () {
|
||||||
})
|
})
|
||||||
|
|
||||||
window.BASE_URL = 'http://test/'
|
window.BASE_URL = 'http://test/'
|
||||||
|
window.MAILER_CONFIGURED = true
|
||||||
|
|
||||||
window.createLemonSqueezy = vi.fn()
|
window.createLemonSqueezy = vi.fn()
|
||||||
|
|
||||||
Axios.defaults.adapter = vi.fn()
|
Axios.defaults.adapter = vi.fn()
|
||||||
|
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { screen } from '@testing-library/vue'
|
||||||
|
import { expect, it } from 'vitest'
|
||||||
|
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||||
|
import { authService } from '@/services'
|
||||||
|
import ForgotPasswordForm from './ForgotPasswordForm.vue'
|
||||||
|
|
||||||
|
new class extends UnitTestCase {
|
||||||
|
protected test () {
|
||||||
|
it('requests reset password link', async () => {
|
||||||
|
const requestMock = this.mock(authService, 'requestResetPasswordLink').mockResolvedValue(null)
|
||||||
|
this.render(ForgotPasswordForm)
|
||||||
|
await this.type(screen.getByPlaceholderText('Your email address'), 'foo@bar.com')
|
||||||
|
await this.user.click(screen.getByText('Reset Password'))
|
||||||
|
|
||||||
|
expect(requestMock).toHaveBeenCalledWith('foo@bar.com')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cancels', async () => {
|
||||||
|
const { emitted } = this.render(ForgotPasswordForm)
|
||||||
|
await this.user.click(screen.getByText('Cancel'))
|
||||||
|
|
||||||
|
expect(emitted().cancel).toBeTruthy()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
69
resources/assets/js/components/auth/ForgotPasswordForm.vue
Normal file
69
resources/assets/js/components/auth/ForgotPasswordForm.vue
Normal file
|
@ -0,0 +1,69 @@
|
||||||
|
<template>
|
||||||
|
<form @submit.prevent="requestResetPasswordLink" data-testid="forgot-password-form">
|
||||||
|
<h1 class="font-size-1.5">Forgot Password</h1>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<input v-model="email" placeholder="Your email address" required type="email" />
|
||||||
|
<Btn :disabled="loading" type="submit">Reset Password</Btn>
|
||||||
|
<Btn :disabled="loading" class="text-secondary" transparent @click="cancel">Cancel</Btn>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { authService } from '@/services'
|
||||||
|
import { useMessageToaster } from '@/composables'
|
||||||
|
|
||||||
|
import Btn from '@/components/ui/Btn.vue'
|
||||||
|
|
||||||
|
const { toastSuccess, toastError } = useMessageToaster()
|
||||||
|
|
||||||
|
const emit = defineEmits<{ (e: 'cancel'): void }>()
|
||||||
|
const email = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const cancel = () => {
|
||||||
|
email.value = ''
|
||||||
|
emit('cancel')
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestResetPasswordLink = async () => {
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
await authService.requestResetPasswordLink(email.value)
|
||||||
|
toastSuccess('Password reset link sent. Please check your email.')
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.response.status === 404) {
|
||||||
|
toastError('No user with this email address found.')
|
||||||
|
} else {
|
||||||
|
toastError('An unknown error occurred.')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
form {
|
||||||
|
min-width: 480px;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin-bottom: .75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
> div {
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
input {
|
||||||
|
flex: 1;
|
||||||
|
border-radius: var(--border-radius-input) 0 0 var(--border-radius-input);
|
||||||
|
}
|
||||||
|
|
||||||
|
[type=submit] {
|
||||||
|
border-radius: 0 var(--border-radius-input) var(--border-radius-input) 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -1,4 +1,4 @@
|
||||||
import { screen } from '@testing-library/vue'
|
import { screen, waitFor } from '@testing-library/vue'
|
||||||
import { expect, it, Mock } from 'vitest'
|
import { expect, it, Mock } from 'vitest'
|
||||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||||
import { authService } from '@/services'
|
import { authService } from '@/services'
|
||||||
|
@ -32,5 +32,20 @@ new class extends UnitTestCase {
|
||||||
expect(emitted().loggedin).toBeFalsy()
|
expect(emitted().loggedin).toBeFalsy()
|
||||||
expect(screen.getByTestId('login-form').classList.contains('error')).toBe(true)
|
expect(screen.getByTestId('login-form').classList.contains('error')).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('shows forgot password form', async () => {
|
||||||
|
this.render(LoginFrom)
|
||||||
|
await this.user.click(screen.getByText('Forgot password?'))
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByTestId('forgot-password-form'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not show forgot password form if mailer is not configure', async () => {
|
||||||
|
window.MAILER_CONFIGURED = false
|
||||||
|
this.render(LoginFrom)
|
||||||
|
|
||||||
|
expect(screen.queryByText('Forgot password?')).toBeNull()
|
||||||
|
window.MAILER_CONFIGURED = true
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,33 +1,55 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="login-wrapper">
|
<div class="login-wrapper">
|
||||||
<form :class="{ error: failed }" data-testid="login-form" @submit.prevent="login">
|
<form
|
||||||
|
v-show="!showingForgotPasswordForm"
|
||||||
|
:class="{ error: failed }"
|
||||||
|
data-testid="login-form"
|
||||||
|
@submit.prevent="login"
|
||||||
|
>
|
||||||
<div class="logo">
|
<div class="logo">
|
||||||
<img alt="Koel's logo" src="@/../img/logo.svg" width="156">
|
<img alt="Koel's logo" src="@/../img/logo.svg" width="156">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input v-model="email" autofocus placeholder="Email Address" required type="email">
|
<input v-model="email" autofocus placeholder="Email Address" required type="email">
|
||||||
<PasswordField v-model="password" placeholder="Password" required />
|
<PasswordField v-model="password" placeholder="Password" required />
|
||||||
|
|
||||||
<Btn type="submit">Log In</Btn>
|
<Btn type="submit">Log In</Btn>
|
||||||
|
<a
|
||||||
|
v-if="canResetPassword"
|
||||||
|
class="reset-password"
|
||||||
|
role="button"
|
||||||
|
@click.prevent="showForgotPasswordForm"
|
||||||
|
>
|
||||||
|
Forgot password?
|
||||||
|
</a>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<ForgotPasswordForm v-if="showingForgotPasswordForm" @cancel="showingForgotPasswordForm = false" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { isDemo } from '@/utils'
|
import { isDemo } from '@/utils'
|
||||||
import { authService } from '@/services'
|
import { authService } from '@/services'
|
||||||
|
|
||||||
import Btn from '@/components/ui/Btn.vue'
|
import Btn from '@/components/ui/Btn.vue'
|
||||||
import PasswordField from '@/components/ui/PasswordField.vue'
|
import PasswordField from '@/components/ui/PasswordField.vue'
|
||||||
|
import ForgotPasswordForm from '@/components/auth/ForgotPasswordForm.vue'
|
||||||
|
|
||||||
const DEMO_ACCOUNT = {
|
const DEMO_ACCOUNT = {
|
||||||
email: 'demo@koel.dev',
|
email: 'demo@koel.dev',
|
||||||
password: 'demo'
|
password: 'demo'
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = ref('')
|
const canResetPassword = window.MAILER_CONFIGURED && !isDemo()
|
||||||
|
|
||||||
const email = ref(isDemo() ? DEMO_ACCOUNT.email : '')
|
const email = ref(isDemo() ? DEMO_ACCOUNT.email : '')
|
||||||
const password = ref(isDemo() ? DEMO_ACCOUNT.password : '')
|
const password = ref(isDemo() ? DEMO_ACCOUNT.password : '')
|
||||||
const failed = ref(false)
|
const failed = ref(false)
|
||||||
|
const showingForgotPasswordForm = ref(false)
|
||||||
|
|
||||||
|
const showForgotPasswordForm = () => (showingForgotPasswordForm.value = true)
|
||||||
|
|
||||||
const emit = defineEmits<{ (e: 'loggedin'): void }>()
|
const emit = defineEmits<{ (e: 'loggedin'): void }>()
|
||||||
|
|
||||||
|
@ -75,10 +97,7 @@ const login = async () => {
|
||||||
.login-wrapper {
|
.login-wrapper {
|
||||||
@include vertical-center();
|
@include vertical-center();
|
||||||
|
|
||||||
display: flex;
|
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
form {
|
form {
|
||||||
|
@ -101,6 +120,12 @@ form {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.reset-password {
|
||||||
|
display: block;
|
||||||
|
text-align: right;
|
||||||
|
font-size: .95rem;
|
||||||
|
}
|
||||||
|
|
||||||
@media only screen and (max-width: 414px) {
|
@media only screen and (max-width: 414px) {
|
||||||
border: 0;
|
border: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { screen } from '@testing-library/vue'
|
||||||
|
import { expect, it } from 'vitest'
|
||||||
|
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||||
|
import { authService } from '@/services'
|
||||||
|
import ResetPasswordForm from './ResetPasswordForm.vue'
|
||||||
|
|
||||||
|
new class extends UnitTestCase {
|
||||||
|
protected test () {
|
||||||
|
it('resets password', async () => {
|
||||||
|
const resetMock = this.mock(authService, 'resetPassword').mockResolvedValue(null)
|
||||||
|
|
||||||
|
await this.router.activateRoute({
|
||||||
|
path: '_',
|
||||||
|
screen: 'Password.Reset'
|
||||||
|
}, { payload: 'Zm9vQGJhci5jb218bXktdG9rZW4=' })
|
||||||
|
|
||||||
|
this.render(ResetPasswordForm)
|
||||||
|
await this.type(screen.getByPlaceholderText('New password'), 'new-password')
|
||||||
|
await this.user.click(screen.getByRole('button', { name: 'Save' }))
|
||||||
|
|
||||||
|
expect(resetMock).toHaveBeenCalledWith('foo@bar.com', 'new-password', 'my-token')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
82
resources/assets/js/components/auth/ResetPasswordForm.vue
Normal file
82
resources/assets/js/components/auth/ResetPasswordForm.vue
Normal file
|
@ -0,0 +1,82 @@
|
||||||
|
<template>
|
||||||
|
<div class="reset-password-wrapper">
|
||||||
|
<form v-if="validPayload" @submit.prevent="submit">
|
||||||
|
<h1 class="font-size-1.5">Set New Password</h1>
|
||||||
|
<div>
|
||||||
|
<label>
|
||||||
|
<PasswordField v-model="password" placeholder="New password" required />
|
||||||
|
<span class="help">Min. 10 characters. Should be a mix of characters, numbers, and symbols.</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Btn :disabled="loading" type="submit">Save</Btn>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { authService } from '@/services'
|
||||||
|
import { base64Decode } from '@/utils'
|
||||||
|
import { useMessageToaster, useRouter } from '@/composables'
|
||||||
|
|
||||||
|
import PasswordField from '@/components/ui/PasswordField.vue'
|
||||||
|
import Btn from '@/components/ui/Btn.vue'
|
||||||
|
|
||||||
|
const { getRouteParam, go } = useRouter()
|
||||||
|
const { toastSuccess, toastError } = useMessageToaster()
|
||||||
|
|
||||||
|
const email = ref('')
|
||||||
|
const token = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const validPayload = computed(() => email.value && token.value)
|
||||||
|
|
||||||
|
try {
|
||||||
|
[email.value, token.value] = base64Decode(decodeURIComponent(getRouteParam('payload')!)).split('|')
|
||||||
|
} catch (err) {
|
||||||
|
toastError('Invalid reset password link.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
await authService.resetPassword(email.value, password.value, token.value)
|
||||||
|
toastSuccess('Password updated. Please log in with your new password.')
|
||||||
|
setTimeout(() => go('/', true), 3000)
|
||||||
|
} catch (err: any) {
|
||||||
|
toastError(err.response?.data?.message || 'Failed to set new password. Please try again.')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.reset-password-wrapper {
|
||||||
|
@include vertical-center;
|
||||||
|
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin-bottom: .75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
form {
|
||||||
|
width: 480px;
|
||||||
|
background: rgba(255, 255, 255, .08);
|
||||||
|
border-radius: .6rem;
|
||||||
|
padding: 1.8rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
|
||||||
|
.help {
|
||||||
|
display: block;
|
||||||
|
margin-top: .8rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -4,7 +4,8 @@ exports[`renders 1`] = `
|
||||||
<div data-v-0b0f87ea="" class="login-wrapper">
|
<div data-v-0b0f87ea="" class="login-wrapper">
|
||||||
<form data-v-0b0f87ea="" class="" data-testid="login-form">
|
<form data-v-0b0f87ea="" class="" data-testid="login-form">
|
||||||
<div data-v-0b0f87ea="" class="logo"><img data-v-0b0f87ea="" alt="Koel's logo" src="undefined/resources/assets/img/logo.svg" width="156"></div><input data-v-0b0f87ea="" autofocus="" placeholder="Email Address" required="" type="email">
|
<div data-v-0b0f87ea="" class="logo"><img data-v-0b0f87ea="" alt="Koel's logo" src="undefined/resources/assets/img/logo.svg" width="156"></div><input data-v-0b0f87ea="" autofocus="" placeholder="Email Address" required="" type="email">
|
||||||
<div data-v-a2893005="" data-v-0b0f87ea=""><input data-v-a2893005="" type="password" placeholder="Password" required=""><button data-v-a2893005="" type="button"><br data-v-a2893005="" data-testid="Icon" icon="[object Object]"></button></div><button data-v-e368fe26="" data-v-0b0f87ea="" type="submit">Log In</button>
|
<div data-v-a2893005="" data-v-0b0f87ea=""><input data-v-a2893005="" type="password" placeholder="Password" required=""><button data-v-a2893005="" type="button"><br data-v-a2893005="" data-testid="Icon" icon="[object Object]"></button></div><button data-v-e368fe26="" data-v-0b0f87ea="" type="submit">Log In</button><a data-v-0b0f87ea="" class="reset-password" role="button"> Forgot password? </a>
|
||||||
</form>
|
</form>
|
||||||
|
<!--v-if-->
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
|
@ -15,20 +15,27 @@
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label>
|
<label>
|
||||||
Your name
|
Your name
|
||||||
<input v-model="formData.name" v-koel-focus type="text" required placeholder="Erm… Bruce Dickinson?">
|
<input
|
||||||
|
v-model="name"
|
||||||
|
v-koel-focus
|
||||||
|
data-testid="name"
|
||||||
|
placeholder="Erm… Bruce Dickinson?"
|
||||||
|
required
|
||||||
|
type="text"
|
||||||
|
>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label>
|
<label>
|
||||||
Password
|
Password
|
||||||
<PasswordField v-model="formData.password" minlength="10" />
|
<PasswordField v-model="password" data-testid="password" required />
|
||||||
<small>Min. 10 characters. Should be a mix of characters, numbers, and symbols.</small>
|
<small>Min. 10 characters. Should be a mix of characters, numbers, and symbols.</small>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<Btn type="submit">Accept & Log In</Btn>
|
<Btn type="submit" :disabled="loading">Accept & Log In</Btn>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
@ -37,7 +44,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, reactive, ref } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
import { invitationService } from '@/services'
|
import { invitationService } from '@/services'
|
||||||
import { useDialogBox, useRouter } from '@/composables'
|
import { useDialogBox, useRouter } from '@/composables'
|
||||||
|
|
||||||
|
@ -49,23 +56,24 @@ import { parseValidationError } from '@/utils'
|
||||||
const { showErrorDialog } = useDialogBox()
|
const { showErrorDialog } = useDialogBox()
|
||||||
const { getRouteParam, go } = useRouter()
|
const { getRouteParam, go } = useRouter()
|
||||||
|
|
||||||
|
const name = ref('')
|
||||||
|
const password = ref('')
|
||||||
const userProspect = ref<User>()
|
const userProspect = ref<User>()
|
||||||
const validToken = ref(true)
|
const validToken = ref(true)
|
||||||
|
const loading = ref(false)
|
||||||
const formData = reactive<{ name: string, password: string }>({
|
|
||||||
name: '',
|
|
||||||
password: ''
|
|
||||||
})
|
|
||||||
|
|
||||||
const token = String(getRouteParam('token')!)
|
const token = String(getRouteParam('token')!)
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
try {
|
try {
|
||||||
await invitationService.accept(token, formData.name, formData.password)
|
loading.value = true
|
||||||
|
await invitationService.accept(token, name.value, password.value)
|
||||||
window.location.href = '/'
|
window.location.href = '/'
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const msg = err.response.status === 422 ? parseValidationError(err.response.data)[0] : 'Unknown error.'
|
const msg = err.response.status === 422 ? parseValidationError(err.response.data)[0] : 'Unknown error.'
|
||||||
showErrorDialog(msg, 'Error')
|
showErrorDialog(msg, 'Error')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -73,12 +81,12 @@ onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
userProspect.value = await invitationService.getUserProspect(token)
|
userProspect.value = await invitationService.getUserProspect(token)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err.response.status === 404) {
|
if (err.response?.status === 404) {
|
||||||
validToken.value = false
|
validToken.value = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const msg = err.response.status === 422 ? parseValidationError(err.response.data)[0] : 'Unknown error.'
|
const msg = err.response?.status === 422 ? parseValidationError(err.response?.data)[0] : 'Unknown error.'
|
||||||
showErrorDialog(msg, 'Error')
|
showErrorDialog(msg, 'Error')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { screen, waitFor } from '@testing-library/vue'
|
||||||
|
import { expect, it } from 'vitest'
|
||||||
|
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||||
|
import AcceptInvitation from './AcceptInvitation.vue'
|
||||||
|
import { invitationService } from '@/services'
|
||||||
|
import factory from '@/__tests__/factory'
|
||||||
|
|
||||||
|
new class extends UnitTestCase {
|
||||||
|
protected test () {
|
||||||
|
it('accepts invitation', async () => {
|
||||||
|
const getProspectMock = this.mock(invitationService, 'getUserProspect')
|
||||||
|
.mockResolvedValue(factory.states('prospect')<User>('user'))
|
||||||
|
|
||||||
|
const acceptMock = this.mock(invitationService, 'accept').mockResolvedValue({
|
||||||
|
token: 'my-api-token',
|
||||||
|
'audio-token': 'my-audio-token'
|
||||||
|
})
|
||||||
|
|
||||||
|
await this.router.activateRoute({
|
||||||
|
path: '_',
|
||||||
|
screen: 'Invitation.Accept'
|
||||||
|
}, {
|
||||||
|
token: 'my-token'
|
||||||
|
})
|
||||||
|
|
||||||
|
this.render(AcceptInvitation)
|
||||||
|
await waitFor(() => expect(getProspectMock).toHaveBeenCalledWith('my-token'))
|
||||||
|
|
||||||
|
await this.tick(2)
|
||||||
|
|
||||||
|
await this.user.type(screen.getByTestId('name'), 'Bruce Dickinson')
|
||||||
|
await this.user.type(screen.getByTestId('password'), 'top-secret')
|
||||||
|
await this.user.click(screen.getByRole('button', { name: 'Accept & Log In' }))
|
||||||
|
|
||||||
|
expect(acceptMock).toHaveBeenCalledWith('my-token', 'Bruce Dickinson', 'top-secret')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -122,7 +122,6 @@ form {
|
||||||
input {
|
input {
|
||||||
&[type="text"], &[type="email"], &[type="password"] {
|
&[type="text"], &[type="email"], &[type="password"] {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 32px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,11 +4,18 @@ import factory from '@/__tests__/factory'
|
||||||
import { screen } from '@testing-library/vue'
|
import { screen } from '@testing-library/vue'
|
||||||
import { http } from '@/services'
|
import { http } from '@/services'
|
||||||
import { eventBus } from '@/utils'
|
import { eventBus } from '@/utils'
|
||||||
|
import { userStore } from '@/stores'
|
||||||
import Btn from '@/components/ui/Btn.vue'
|
import Btn from '@/components/ui/Btn.vue'
|
||||||
import BtnGroup from '@/components/ui/BtnGroup.vue'
|
import BtnGroup from '@/components/ui/BtnGroup.vue'
|
||||||
import UserListScreen from './UserListScreen.vue'
|
import UserListScreen from './UserListScreen.vue'
|
||||||
|
|
||||||
new class extends UnitTestCase {
|
new class extends UnitTestCase {
|
||||||
|
protected beforeEach (cb?: Closure) {
|
||||||
|
super.beforeEach(cb);
|
||||||
|
|
||||||
|
this.beAdmin()
|
||||||
|
}
|
||||||
|
|
||||||
private async renderComponent (users: User[] = []) {
|
private async renderComponent (users: User[] = []) {
|
||||||
if (users.length === 0) {
|
if (users.length === 0) {
|
||||||
users = factory<User>('user', 6)
|
users = factory<User>('user', 6)
|
||||||
|
|
|
@ -10,7 +10,7 @@
|
||||||
<Icon :icon="faPlus" />
|
<Icon :icon="faPlus" />
|
||||||
Add
|
Add
|
||||||
</Btn>
|
</Btn>
|
||||||
<Btn class="btn-invite" orange @click="showInviteUserForm">Invite</Btn>
|
<Btn v-if="canInvite" class="btn-invite" orange @click="showInviteUserForm">Invite</Btn>
|
||||||
</BtnGroup>
|
</BtnGroup>
|
||||||
</template>
|
</template>
|
||||||
</ScreenHeader>
|
</ScreenHeader>
|
||||||
|
@ -57,15 +57,18 @@ const BtnGroup = defineAsyncComponent(() => import('@/components/ui/BtnGroup.vue
|
||||||
const { currentUser } = useAuthorization()
|
const { currentUser } = useAuthorization()
|
||||||
|
|
||||||
const allUsers = toRef(userStore.state, 'users')
|
const allUsers = toRef(userStore.state, 'users')
|
||||||
|
|
||||||
const users = computed(() => allUsers
|
const users = computed(() => allUsers
|
||||||
.value
|
.value
|
||||||
.filter(({ is_prospect }) => !is_prospect)
|
.filter(({ is_prospect }) => !is_prospect)
|
||||||
.sort((a, b) => a.id === currentUser.value.id ? -1 : b.id === currentUser.value.id ? 1 : a.name.localeCompare(b.name))
|
.sort((a, b) => a.id === currentUser.value.id ? -1 : b.id === currentUser.value.id ? 1 : a.name.localeCompare(b.name))
|
||||||
)
|
)
|
||||||
|
|
||||||
const prospects = computed(() => allUsers.value.filter(({ is_prospect }) => is_prospect))
|
const prospects = computed(() => allUsers.value.filter(({ is_prospect }) => is_prospect))
|
||||||
|
|
||||||
const isPhone = isMobile.phone
|
const isPhone = isMobile.phone
|
||||||
const showingControls = ref(false)
|
const showingControls = ref(false)
|
||||||
|
const canInvite = window.MAILER_CONFIGURED
|
||||||
|
|
||||||
const showAddUserForm = () => eventBus.emit('MODAL_SHOW_ADD_USER_FORM')
|
const showAddUserForm = () => eventBus.emit('MODAL_SHOW_ADD_USER_FORM')
|
||||||
const showInviteUserForm = () => eventBus.emit('MODAL_SHOW_INVITE_USER_FORM')
|
const showInviteUserForm = () => eventBus.emit('MODAL_SHOW_INVITE_USER_FORM')
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<input v-model="value" :type="type" v-bind="$attrs">
|
<input v-model="value" :type="type" minlength="10" v-bind="$attrs">
|
||||||
<button type="button" @click.prevent="toggleReveal">
|
<button type="button" @click.prevent="toggleReveal">
|
||||||
<Icon v-if="type === 'password'" :icon="faEye" />
|
<Icon v-if="type === 'password'" :icon="faEye" />
|
||||||
<Icon v-else :icon="faEyeSlash" />
|
<Icon v-else :icon="faEyeSlash" />
|
||||||
|
@ -16,7 +16,6 @@ import { faEye, faEyeSlash } from '@fortawesome/free-regular-svg-icons'
|
||||||
defineOptions({ inheritAttrs: false })
|
defineOptions({ inheritAttrs: false })
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{ modelValue?: string }>(), { modelValue: '' })
|
const props = withDefaults(defineProps<{ modelValue?: string }>(), { modelValue: '' })
|
||||||
|
|
||||||
const emit = defineEmits<{ (e: 'update:modelValue', value: string): void }>()
|
const emit = defineEmits<{ (e: 'update:modelValue', value: string): void }>()
|
||||||
|
|
||||||
const type = ref<'password' | 'text'>('password')
|
const type = ref<'password' | 'text'>('password')
|
||||||
|
|
|
@ -125,5 +125,9 @@ export const routes: Route[] = [
|
||||||
{
|
{
|
||||||
path: `/invitation/accept/(?<token>${UUID_REGEX})`,
|
path: `/invitation/accept/(?<token>${UUID_REGEX})`,
|
||||||
screen: 'Invitation.Accept'
|
screen: 'Invitation.Accept'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: `/reset-password/(?<payload>[a-zA-Z0-9\\+/=]+)`,
|
||||||
|
screen: 'Password.Reset'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
@ -55,5 +55,11 @@ export const authService = {
|
||||||
getAudioToken: () => {
|
getAudioToken: () => {
|
||||||
// for backward compatibility, we first try to get the audio token, and fall back to the (full-privileged) API token
|
// for backward compatibility, we first try to get the audio token, and fall back to the (full-privileged) API token
|
||||||
return localStorageService.get(AUDIO_TOKEN_STORAGE_KEY) || localStorageService.get(API_TOKEN_STORAGE_KEY)
|
return localStorageService.get(AUDIO_TOKEN_STORAGE_KEY) || localStorageService.get(API_TOKEN_STORAGE_KEY)
|
||||||
|
},
|
||||||
|
|
||||||
|
requestResetPasswordLink: async (email: string) => await http.post('forgot-password', { email }),
|
||||||
|
|
||||||
|
resetPassword: async (email: string, password: string, token: string) => {
|
||||||
|
await http.post('reset-password', { email, password, token })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,10 +10,10 @@ const initialState = {
|
||||||
current_version: '',
|
current_version: '',
|
||||||
koel_plus: {
|
koel_plus: {
|
||||||
active: false,
|
active: false,
|
||||||
short_key: null,
|
short_key: null as string | null,
|
||||||
customer_name: null,
|
customer_name: null as string | null,
|
||||||
customer_email: null,
|
customer_email: null as string | null,
|
||||||
product_id: ''
|
product_id: '' as string | null
|
||||||
},
|
},
|
||||||
latest_version: '',
|
latest_version: '',
|
||||||
media_path_set: false,
|
media_path_set: false,
|
||||||
|
|
4
resources/assets/js/types.d.ts
vendored
4
resources/assets/js/types.d.ts
vendored
|
@ -57,8 +57,11 @@ interface Constructable<T> {
|
||||||
|
|
||||||
interface Window {
|
interface Window {
|
||||||
BASE_URL: string
|
BASE_URL: string
|
||||||
|
MAILER_CONFIGURED: boolean
|
||||||
|
|
||||||
readonly PUSHER_APP_KEY: string
|
readonly PUSHER_APP_KEY: string
|
||||||
readonly PUSHER_APP_CLUSTER: string
|
readonly PUSHER_APP_CLUSTER: string
|
||||||
|
|
||||||
readonly MediaMetadata: Constructable<Record<string, any>>
|
readonly MediaMetadata: Constructable<Record<string, any>>
|
||||||
createLemonSqueezy?: () => Closure
|
createLemonSqueezy?: () => Closure
|
||||||
}
|
}
|
||||||
|
@ -352,6 +355,7 @@ declare type ScreenName =
|
||||||
| 'Search.Excerpt'
|
| 'Search.Excerpt'
|
||||||
| 'Search.Songs'
|
| 'Search.Songs'
|
||||||
| 'Invitation.Accept'
|
| 'Invitation.Accept'
|
||||||
|
| 'Password.Reset'
|
||||||
| '404'
|
| '404'
|
||||||
|
|
||||||
declare type ArtistAlbumCardLayout = 'full' | 'compact'
|
declare type ArtistAlbumCardLayout = 'full' | 'compact'
|
||||||
|
|
|
@ -10,3 +10,11 @@ export const uuid = () => {
|
||||||
? window.crypto.randomUUID()
|
? window.crypto.randomUUID()
|
||||||
: URL.createObjectURL(new Blob([])).split(/[:\/]/g).pop()
|
: URL.createObjectURL(new Blob([])).split(/[:\/]/g).pop()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const base64Encode = (str: string) => {
|
||||||
|
return btoa(String.fromCodePoint(...(new TextEncoder().encode(str))))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const base64Decode = (str: string) => {
|
||||||
|
return new TextDecoder().decode(Uint8Array.from(atob(str), c => c.codePointAt(0)!))
|
||||||
|
}
|
||||||
|
|
|
@ -33,6 +33,10 @@ html {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
input,
|
input,
|
||||||
select,
|
select,
|
||||||
button,
|
button,
|
||||||
|
@ -45,7 +49,7 @@ textarea,
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: var(--font-weight-light);
|
font-weight: var(--font-weight-light);
|
||||||
padding: .5rem .6rem;
|
padding: .5rem .6rem;
|
||||||
border-radius: .3rem;
|
border-radius: var(--border-radius-input);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background: var(--color-bg-input);
|
background: var(--color-bg-input);
|
||||||
color: var(--color-input);
|
color: var(--color-input);
|
||||||
|
@ -235,6 +239,18 @@ label {
|
||||||
&0 {
|
&0 {
|
||||||
font-size: 0;
|
font-size: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&1 {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&1\.5 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&2 {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.text- {
|
.text- {
|
||||||
|
|
|
@ -33,6 +33,8 @@
|
||||||
--color-blue: #0191f7;
|
--color-blue: #0191f7;
|
||||||
--color-red: #c34848;
|
--color-red: #c34848;
|
||||||
|
|
||||||
|
--border-radius-input: .3rem;
|
||||||
|
|
||||||
@media screen and (max-width: 768px) {
|
@media screen and (max-width: 768px) {
|
||||||
--header-height: 56px;
|
--header-height: 56px;
|
||||||
--footer-height: 96px;
|
--footer-height: 96px;
|
||||||
|
|
|
@ -33,6 +33,8 @@
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
window.BASE_URL = @json(asset(''));
|
window.BASE_URL = @json(asset(''));
|
||||||
|
window.MAILER_CONFIGURED = @json(mailer_configured());
|
||||||
|
|
||||||
window.PUSHER_APP_KEY = @json(config('broadcasting.connections.pusher.key'));
|
window.PUSHER_APP_KEY = @json(config('broadcasting.connections.pusher.key'));
|
||||||
window.PUSHER_APP_CLUSTER = @json(config('broadcasting.connections.pusher.options.cluster'));
|
window.PUSHER_APP_CLUSTER = @json(config('broadcasting.connections.pusher.options.cluster'));
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -20,6 +20,7 @@ use App\Http\Controllers\API\FetchOverviewController;
|
||||||
use App\Http\Controllers\API\FetchRandomSongsInGenreController;
|
use App\Http\Controllers\API\FetchRandomSongsInGenreController;
|
||||||
use App\Http\Controllers\API\FetchRecentlyPlayedSongController;
|
use App\Http\Controllers\API\FetchRecentlyPlayedSongController;
|
||||||
use App\Http\Controllers\API\FetchSongsForQueueController;
|
use App\Http\Controllers\API\FetchSongsForQueueController;
|
||||||
|
use App\Http\Controllers\API\ForgotPasswordController;
|
||||||
use App\Http\Controllers\API\GenreController;
|
use App\Http\Controllers\API\GenreController;
|
||||||
use App\Http\Controllers\API\GenreSongController;
|
use App\Http\Controllers\API\GenreSongController;
|
||||||
use App\Http\Controllers\API\LambdaSongController as S3SongController;
|
use App\Http\Controllers\API\LambdaSongController as S3SongController;
|
||||||
|
@ -37,6 +38,7 @@ use App\Http\Controllers\API\ProfileController;
|
||||||
use App\Http\Controllers\API\PublicizeSongsController;
|
use App\Http\Controllers\API\PublicizeSongsController;
|
||||||
use App\Http\Controllers\API\QueueStateController;
|
use App\Http\Controllers\API\QueueStateController;
|
||||||
use App\Http\Controllers\API\RegisterPlayController;
|
use App\Http\Controllers\API\RegisterPlayController;
|
||||||
|
use App\Http\Controllers\API\ResetPasswordController;
|
||||||
use App\Http\Controllers\API\ScrobbleController;
|
use App\Http\Controllers\API\ScrobbleController;
|
||||||
use App\Http\Controllers\API\SearchYouTubeController;
|
use App\Http\Controllers\API\SearchYouTubeController;
|
||||||
use App\Http\Controllers\API\SetLastfmSessionKeyController;
|
use App\Http\Controllers\API\SetLastfmSessionKeyController;
|
||||||
|
@ -59,10 +61,13 @@ use Illuminate\Support\Facades\Route;
|
||||||
use Pusher\Pusher;
|
use Pusher\Pusher;
|
||||||
|
|
||||||
Route::prefix('api')->middleware('api')->group(static function (): void {
|
Route::prefix('api')->middleware('api')->group(static function (): void {
|
||||||
|
Route::get('ping', static fn () => null);
|
||||||
|
|
||||||
Route::post('me', [AuthController::class, 'login'])->name('auth.login');
|
Route::post('me', [AuthController::class, 'login'])->name('auth.login');
|
||||||
Route::delete('me', [AuthController::class, 'logout']);
|
Route::delete('me', [AuthController::class, 'logout']);
|
||||||
|
|
||||||
Route::get('ping', static fn () => null);
|
Route::post('forgot-password', ForgotPasswordController::class);
|
||||||
|
Route::post('reset-password', ResetPasswordController::class);
|
||||||
|
|
||||||
Route::get('invitations', [UserInvitationController::class, 'get']);
|
Route::get('invitations', [UserInvitationController::class, 'get']);
|
||||||
Route::post('invitations/accept', [UserInvitationController::class, 'accept']);
|
Route::post('invitations/accept', [UserInvitationController::class, 'accept']);
|
||||||
|
|
67
tests/Feature/ForgotPasswordTest.php
Normal file
67
tests/Feature/ForgotPasswordTest.php
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Services\AuthenticationService;
|
||||||
|
use Illuminate\Auth\Events\PasswordReset;
|
||||||
|
use Illuminate\Support\Facades\Event;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Facades\Password;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
use function Tests\create_user;
|
||||||
|
|
||||||
|
class ForgotPasswordTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testSendResetPasswordRequest(): void
|
||||||
|
{
|
||||||
|
$this->mock(AuthenticationService::class)
|
||||||
|
->shouldReceive('trySendResetPasswordLink')
|
||||||
|
->with('foo@bar.com')
|
||||||
|
->andReturnTrue();
|
||||||
|
|
||||||
|
$this->post('/api/forgot-password', ['email' => 'foo@bar.com'])
|
||||||
|
->assertNoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSendResetPasswordRequestFailed(): void
|
||||||
|
{
|
||||||
|
$this->mock(AuthenticationService::class)
|
||||||
|
->shouldReceive('trySendResetPasswordLink')
|
||||||
|
->with('foo@bar.com')
|
||||||
|
->andReturnFalse();
|
||||||
|
|
||||||
|
$this->post('/api/forgot-password', ['email' => 'foo@bar.com'])
|
||||||
|
->assertNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testResetPassword(): void
|
||||||
|
{
|
||||||
|
Event::fake();
|
||||||
|
$user = create_user();
|
||||||
|
|
||||||
|
$this->post('/api/reset-password', [
|
||||||
|
'email' => $user->email,
|
||||||
|
'password' => 'new-password',
|
||||||
|
'token' => Password::createToken($user),
|
||||||
|
])->assertNoContent();
|
||||||
|
|
||||||
|
self::assertTrue(Hash::check('new-password', $user->refresh()->password));
|
||||||
|
Event::assertDispatched(PasswordReset::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testResetPasswordFailed(): void
|
||||||
|
{
|
||||||
|
Event::fake();
|
||||||
|
$user = create_user(['password' => Hash::make('old-password')]);
|
||||||
|
|
||||||
|
$this->post('/api/reset-password', [
|
||||||
|
'email' => $user->email,
|
||||||
|
'password' => 'new-password',
|
||||||
|
'token' => 'invalid-token',
|
||||||
|
])->assertUnprocessable();
|
||||||
|
|
||||||
|
self::assertTrue(Hash::check('old-password', $user->refresh()->password));
|
||||||
|
Event::assertNotDispatched(PasswordReset::class);
|
||||||
|
}
|
||||||
|
}
|
48
tests/Integration/Services/AuthenticationServiceTest.php
Normal file
48
tests/Integration/Services/AuthenticationServiceTest.php
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Integration\Services;
|
||||||
|
|
||||||
|
use App\Services\AuthenticationService;
|
||||||
|
use Illuminate\Auth\Events\PasswordReset;
|
||||||
|
use Illuminate\Support\Facades\Event;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Facades\Password;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
use function Tests\create_user;
|
||||||
|
|
||||||
|
class AuthenticationServiceTest extends TestCase
|
||||||
|
{
|
||||||
|
private AuthenticationService $service;
|
||||||
|
|
||||||
|
public function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->service = app(AuthenticationService::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTryResetPasswordUsingBroker(): void
|
||||||
|
{
|
||||||
|
Event::fake();
|
||||||
|
$user = create_user();
|
||||||
|
|
||||||
|
self::assertTrue(
|
||||||
|
$this->service->tryResetPasswordUsingBroker($user->email, 'new-password', Password::createToken($user))
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertTrue(Hash::check('new-password', $user->fresh()->password));
|
||||||
|
|
||||||
|
Event::assertDispatched(PasswordReset::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTryResetPasswordUsingBrokerWithInvalidToken(): void
|
||||||
|
{
|
||||||
|
Event::fake();
|
||||||
|
$user = create_user(['password' => Hash::make('old-password')]);
|
||||||
|
|
||||||
|
self::assertFalse($this->service->tryResetPasswordUsingBroker($user->email, 'new-password', 'invalid-token'));
|
||||||
|
self::assertTrue(Hash::check('old-password', $user->fresh()->password));
|
||||||
|
Event::assertNotDispatched(PasswordReset::class);
|
||||||
|
}
|
||||||
|
}
|
48
tests/Unit/Services/AuthenticationServiceTest.php
Normal file
48
tests/Unit/Services/AuthenticationServiceTest.php
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit\Services;
|
||||||
|
|
||||||
|
use App\Repositories\UserRepository;
|
||||||
|
use App\Services\AuthenticationService;
|
||||||
|
use App\Services\TokenManager;
|
||||||
|
use Illuminate\Auth\Passwords\PasswordBroker;
|
||||||
|
use Illuminate\Hashing\HashManager;
|
||||||
|
use Illuminate\Support\Facades\Password;
|
||||||
|
use Mockery\MockInterface;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class AuthenticationServiceTest extends TestCase
|
||||||
|
{
|
||||||
|
private UserRepository|MockInterface $userRepository;
|
||||||
|
private TokenManager|MockInterface $tokenManager;
|
||||||
|
private HashManager|MockInterface $hash;
|
||||||
|
private PasswordBroker|MockInterface $passwordBroker;
|
||||||
|
private AuthenticationService $service;
|
||||||
|
|
||||||
|
public function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->userRepository = $this->mock(UserRepository::class);
|
||||||
|
$this->tokenManager = $this->mock(TokenManager::class);
|
||||||
|
$this->hash = $this->mock(HashManager::class);
|
||||||
|
$this->passwordBroker = $this->mock(PasswordBroker::class);
|
||||||
|
|
||||||
|
$this->service = new AuthenticationService(
|
||||||
|
$this->userRepository,
|
||||||
|
$this->tokenManager,
|
||||||
|
$this->hash,
|
||||||
|
$this->passwordBroker
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTrySendResetPasswordLink(): void
|
||||||
|
{
|
||||||
|
$this->passwordBroker
|
||||||
|
->shouldReceive('sendResetLink')
|
||||||
|
->with(['email' => 'foo@bar.com'])
|
||||||
|
->andReturn(Password::RESET_LINK_SENT);
|
||||||
|
|
||||||
|
$this->assertTrue($this->service->trySendResetPasswordLink('foo@bar.com'));
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue