mirror of
https://github.com/koel/koel
synced 2024-11-24 05:03:05 +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));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 Illuminate\Contracts\Auth\Authenticatable;
|
||||
use Illuminate\Contracts\Hashing\Hasher;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class ProfileController extends Controller
|
||||
|
@ -28,9 +29,7 @@ class ProfileController extends Controller
|
|||
|
||||
public function update(ProfileUpdateRequest $request)
|
||||
{
|
||||
if (config('koel.misc.demo')) {
|
||||
return response()->noContent();
|
||||
}
|
||||
static::disableInDemo(Response::HTTP_NO_CONTENT);
|
||||
|
||||
throw_unless(
|
||||
$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\Bus\DispatchesJobs;
|
||||
use Illuminate\Foundation\Validation\ValidatesRequests;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Routing\Controller as BaseController;
|
||||
|
||||
abstract class Controller extends BaseController
|
||||
|
@ -12,4 +13,9 @@ abstract class Controller extends BaseController
|
|||
use AuthorizesRequests;
|
||||
use DispatchesJobs;
|
||||
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\ThrottleRequests;
|
||||
use App\Http\Middleware\TrimStrings;
|
||||
use App\Http\Middleware\TrustHosts;
|
||||
use Illuminate\Auth\Middleware\Authorize;
|
||||
use Illuminate\Foundation\Http\Kernel as HttpKernel;
|
||||
use Illuminate\Foundation\Http\Middleware\CheckForMaintenanceMode;
|
||||
|
@ -26,6 +27,7 @@ class Kernel extends HttpKernel
|
|||
ValidatePostSize::class,
|
||||
TrimStrings::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\Services\TokenManager;
|
||||
use Illuminate\Auth\Notifications\ResetPassword;
|
||||
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
@ -25,6 +26,12 @@ class AuthServiceProvider extends ServiceProvider
|
|||
});
|
||||
|
||||
$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
|
||||
|
|
|
@ -6,14 +6,18 @@ use App\Exceptions\InvalidCredentialsException;
|
|||
use App\Models\User;
|
||||
use App\Repositories\UserRepository;
|
||||
use App\Values\CompositeToken;
|
||||
use Illuminate\Auth\Events\PasswordReset;
|
||||
use Illuminate\Auth\Passwords\PasswordBroker;
|
||||
use Illuminate\Hashing\HashManager;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
|
||||
class AuthenticationService
|
||||
{
|
||||
public function __construct(
|
||||
private UserRepository $userRepository,
|
||||
private TokenManager $tokenManager,
|
||||
private HashManager $hash
|
||||
private HashManager $hash,
|
||||
private PasswordBroker $passwordBroker
|
||||
) {
|
||||
}
|
||||
|
||||
|
@ -38,4 +42,27 @@ class AuthenticationService
|
|||
{
|
||||
$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
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Mail Driver
|
||||
| Default Mailer
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Laravel supports both SMTP and PHP's "mail" function as drivers for the
|
||||
| sending of e-mail. You may specify which one you're using throughout
|
||||
| your application here. By default, Laravel is setup for SMTP mail.
|
||||
|
|
||||
| Supported: "smtp", "sendmail", "mailgun", "mandrill", "ses",
|
||||
| "sparkpost", "log", "array"
|
||||
| This option controls the default mailer that is used to send any email
|
||||
| messages sent by your application. Alternative mailers may be setup
|
||||
| and used as needed; however, this mailer will be used by default.
|
||||
|
|
||||
*/
|
||||
'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
|
||||
| applications. A default option is provided that is compatible with
|
||||
| the Mailgun mail service which will provide reliable deliveries.
|
||||
| Here you may configure all of the mailers used by your application plus
|
||||
| their respective settings. Several examples have been configured for
|
||||
| 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'),
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| SMTP Host Port
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This is the SMTP port used by your application to deliver e-mails to
|
||||
| users of the application. Like the host we have set this value to
|
||||
| stay compatible with the Mailgun e-mail application by default.
|
||||
|
|
||||
*/
|
||||
'port' => env('MAIL_PORT', 587),
|
||||
|
||||
'mailers' => [
|
||||
'smtp' => [
|
||||
'transport' => 'smtp',
|
||||
'host' => env('MAIL_HOST', 'smtp.mailgun.org'),
|
||||
'port' => env('MAIL_PORT', 587),
|
||||
'encryption' => env('MAIL_ENCRYPTION', 'tls'),
|
||||
'username' => env('MAIL_USERNAME'),
|
||||
'password' => env('MAIL_PASSWORD'),
|
||||
'timeout' => null,
|
||||
'local_domain' => env('MAIL_EHLO_DOMAIN'),
|
||||
],
|
||||
|
||||
'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
|
||||
|
@ -47,44 +90,12 @@ return [
|
|||
| used globally for all e-mails that are sent by your application.
|
||||
|
|
||||
*/
|
||||
|
||||
'from' => [
|
||||
'address' => env('MAIL_FROM_ADDRESS', 'noreply@koel.local'),
|
||||
'name' => env('MAIL_FROM_NAME', 'Koel'),
|
||||
'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),
|
||||
'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
|
||||
|
@ -95,10 +106,13 @@ return [
|
|||
| of the emails. Or, you may simply stick with the Laravel defaults!
|
||||
|
|
||||
*/
|
||||
|
||||
'markdown' => [
|
||||
'theme' => 'default',
|
||||
|
||||
'paths' => [
|
||||
resource_path('views/vendor/mail'),
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
@ -48,7 +48,7 @@
|
|||
<env name="YOUTUBE_API_KEY" value="foo"/>
|
||||
<env name="BROADCAST_DRIVER" value="log"/>
|
||||
<env name="CACHE_MEDIA" value="true"/>
|
||||
<env name="MAIL_DRIVER" value="smtp"/>
|
||||
<env name="MAIL_MAILER" value="log"/>
|
||||
<ini name="memory_limit" value="512M"/>
|
||||
</php>
|
||||
</phpunit>
|
||||
|
|
|
@ -22,6 +22,7 @@
|
|||
<LoginForm v-if="layout === 'auth'" @loggedin="onUserLoggedIn" />
|
||||
|
||||
<AcceptInvitation v-if="layout === 'invitation'" />
|
||||
<ResetPasswordForm v-if="layout === 'reset-password'" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
|
@ -55,6 +56,7 @@ const CreateNewPlaylistContextMenu = defineAsyncComponent(() => import('@/compon
|
|||
const SupportKoel = defineAsyncComponent(() => import('@/components/meta/SupportKoel.vue'))
|
||||
const DropZone = defineAsyncComponent(() => import('@/components/ui/upload/DropZone.vue'))
|
||||
const AcceptInvitation = defineAsyncComponent(() => import('@/components/invitation/AcceptInvitation.vue'))
|
||||
const ResetPasswordForm = defineAsyncComponent(() => import('@/components/auth/ResetPasswordForm.vue'))
|
||||
|
||||
const overlay = ref<InstanceType<typeof Overlay>>()
|
||||
const dialog = ref<InstanceType<typeof DialogBox>>()
|
||||
|
@ -62,9 +64,9 @@ const toaster = ref<InstanceType<typeof MessageToaster>>()
|
|||
const currentSong = ref<Song>()
|
||||
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()
|
||||
|
||||
/**
|
||||
|
@ -91,7 +93,17 @@ onMounted(async () => {
|
|||
layout.value = 'main'
|
||||
} else {
|
||||
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.
|
||||
|
|
|
@ -4,13 +4,14 @@ import Axios from 'axios'
|
|||
|
||||
declare global {
|
||||
interface Window {
|
||||
BASE_URL: string;
|
||||
createLemonSqueezy: () => void;
|
||||
BASE_URL: string
|
||||
MAILER_CONFIGURED: boolean
|
||||
createLemonSqueezy: () => void
|
||||
}
|
||||
|
||||
interface LemonSqueezy {
|
||||
Url: {
|
||||
Open: () => void;
|
||||
Open: () => void
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -48,6 +49,8 @@ HTMLDialogElement.prototype.close = vi.fn(function mock () {
|
|||
})
|
||||
|
||||
window.BASE_URL = 'http://test/'
|
||||
window.MAILER_CONFIGURED = true
|
||||
|
||||
window.createLemonSqueezy = 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 UnitTestCase from '@/__tests__/UnitTestCase'
|
||||
import { authService } from '@/services'
|
||||
|
@ -32,5 +32,20 @@ new class extends UnitTestCase {
|
|||
expect(emitted().loggedin).toBeFalsy()
|
||||
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>
|
||||
<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">
|
||||
<img alt="Koel's logo" src="@/../img/logo.svg" width="156">
|
||||
</div>
|
||||
|
||||
<input v-model="email" autofocus placeholder="Email Address" required type="email">
|
||||
<PasswordField v-model="password" placeholder="Password" required />
|
||||
|
||||
<Btn type="submit">Log In</Btn>
|
||||
<a
|
||||
v-if="canResetPassword"
|
||||
class="reset-password"
|
||||
role="button"
|
||||
@click.prevent="showForgotPasswordForm"
|
||||
>
|
||||
Forgot password?
|
||||
</a>
|
||||
</form>
|
||||
|
||||
<ForgotPasswordForm v-if="showingForgotPasswordForm" @cancel="showingForgotPasswordForm = false" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { isDemo } from '@/utils'
|
||||
import { authService } from '@/services'
|
||||
|
||||
import Btn from '@/components/ui/Btn.vue'
|
||||
import PasswordField from '@/components/ui/PasswordField.vue'
|
||||
import ForgotPasswordForm from '@/components/auth/ForgotPasswordForm.vue'
|
||||
|
||||
const DEMO_ACCOUNT = {
|
||||
email: 'demo@koel.dev',
|
||||
password: 'demo'
|
||||
}
|
||||
|
||||
const url = ref('')
|
||||
const canResetPassword = window.MAILER_CONFIGURED && !isDemo()
|
||||
|
||||
const email = ref(isDemo() ? DEMO_ACCOUNT.email : '')
|
||||
const password = ref(isDemo() ? DEMO_ACCOUNT.password : '')
|
||||
const failed = ref(false)
|
||||
const showingForgotPasswordForm = ref(false)
|
||||
|
||||
const showForgotPasswordForm = () => (showingForgotPasswordForm.value = true)
|
||||
|
||||
const emit = defineEmits<{ (e: 'loggedin'): void }>()
|
||||
|
||||
|
@ -75,10 +97,7 @@ const login = async () => {
|
|||
.login-wrapper {
|
||||
@include vertical-center();
|
||||
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
form {
|
||||
|
@ -101,6 +120,12 @@ form {
|
|||
text-align: center;
|
||||
}
|
||||
|
||||
.reset-password {
|
||||
display: block;
|
||||
text-align: right;
|
||||
font-size: .95rem;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 414px) {
|
||||
border: 0;
|
||||
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">
|
||||
<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-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>
|
||||
<!--v-if-->
|
||||
</div>
|
||||
`;
|
||||
|
|
|
@ -15,20 +15,27 @@
|
|||
<div class="form-row">
|
||||
<label>
|
||||
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>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label>
|
||||
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>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<Btn type="submit">Accept & Log In</Btn>
|
||||
<Btn type="submit" :disabled="loading">Accept & Log In</Btn>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
@ -37,7 +44,7 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { invitationService } from '@/services'
|
||||
import { useDialogBox, useRouter } from '@/composables'
|
||||
|
||||
|
@ -49,23 +56,24 @@ import { parseValidationError } from '@/utils'
|
|||
const { showErrorDialog } = useDialogBox()
|
||||
const { getRouteParam, go } = useRouter()
|
||||
|
||||
const name = ref('')
|
||||
const password = ref('')
|
||||
const userProspect = ref<User>()
|
||||
const validToken = ref(true)
|
||||
|
||||
const formData = reactive<{ name: string, password: string }>({
|
||||
name: '',
|
||||
password: ''
|
||||
})
|
||||
const loading = ref(false)
|
||||
|
||||
const token = String(getRouteParam('token')!)
|
||||
|
||||
const submit = async () => {
|
||||
try {
|
||||
await invitationService.accept(token, formData.name, formData.password)
|
||||
loading.value = true
|
||||
await invitationService.accept(token, name.value, password.value)
|
||||
window.location.href = '/'
|
||||
} catch (err: any) {
|
||||
const msg = err.response.status === 422 ? parseValidationError(err.response.data)[0] : 'Unknown error.'
|
||||
showErrorDialog(msg, 'Error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -73,12 +81,12 @@ onMounted(async () => {
|
|||
try {
|
||||
userProspect.value = await invitationService.getUserProspect(token)
|
||||
} catch (err: any) {
|
||||
if (err.response.status === 404) {
|
||||
if (err.response?.status === 404) {
|
||||
validToken.value = false
|
||||
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')
|
||||
}
|
||||
})
|
||||
|
|
|
@ -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 {
|
||||
&[type="text"], &[type="email"], &[type="password"] {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,11 +4,18 @@ import factory from '@/__tests__/factory'
|
|||
import { screen } from '@testing-library/vue'
|
||||
import { http } from '@/services'
|
||||
import { eventBus } from '@/utils'
|
||||
import { userStore } from '@/stores'
|
||||
import Btn from '@/components/ui/Btn.vue'
|
||||
import BtnGroup from '@/components/ui/BtnGroup.vue'
|
||||
import UserListScreen from './UserListScreen.vue'
|
||||
|
||||
new class extends UnitTestCase {
|
||||
protected beforeEach (cb?: Closure) {
|
||||
super.beforeEach(cb);
|
||||
|
||||
this.beAdmin()
|
||||
}
|
||||
|
||||
private async renderComponent (users: User[] = []) {
|
||||
if (users.length === 0) {
|
||||
users = factory<User>('user', 6)
|
||||
|
|
|
@ -10,7 +10,7 @@
|
|||
<Icon :icon="faPlus" />
|
||||
Add
|
||||
</Btn>
|
||||
<Btn class="btn-invite" orange @click="showInviteUserForm">Invite</Btn>
|
||||
<Btn v-if="canInvite" class="btn-invite" orange @click="showInviteUserForm">Invite</Btn>
|
||||
</BtnGroup>
|
||||
</template>
|
||||
</ScreenHeader>
|
||||
|
@ -57,15 +57,18 @@ const BtnGroup = defineAsyncComponent(() => import('@/components/ui/BtnGroup.vue
|
|||
const { currentUser } = useAuthorization()
|
||||
|
||||
const allUsers = toRef(userStore.state, 'users')
|
||||
|
||||
const users = computed(() => allUsers
|
||||
.value
|
||||
.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))
|
||||
)
|
||||
|
||||
const prospects = computed(() => allUsers.value.filter(({ is_prospect }) => is_prospect))
|
||||
|
||||
const isPhone = isMobile.phone
|
||||
const showingControls = ref(false)
|
||||
const canInvite = window.MAILER_CONFIGURED
|
||||
|
||||
const showAddUserForm = () => eventBus.emit('MODAL_SHOW_ADD_USER_FORM')
|
||||
const showInviteUserForm = () => eventBus.emit('MODAL_SHOW_INVITE_USER_FORM')
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<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">
|
||||
<Icon v-if="type === 'password'" :icon="faEye" />
|
||||
<Icon v-else :icon="faEyeSlash" />
|
||||
|
@ -16,7 +16,6 @@ import { faEye, faEyeSlash } from '@fortawesome/free-regular-svg-icons'
|
|||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const props = withDefaults(defineProps<{ modelValue?: string }>(), { modelValue: '' })
|
||||
|
||||
const emit = defineEmits<{ (e: 'update:modelValue', value: string): void }>()
|
||||
|
||||
const type = ref<'password' | 'text'>('password')
|
||||
|
|
|
@ -125,5 +125,9 @@ export const routes: Route[] = [
|
|||
{
|
||||
path: `/invitation/accept/(?<token>${UUID_REGEX})`,
|
||||
screen: 'Invitation.Accept'
|
||||
},
|
||||
{
|
||||
path: `/reset-password/(?<payload>[a-zA-Z0-9\\+/=]+)`,
|
||||
screen: 'Password.Reset'
|
||||
}
|
||||
]
|
||||
|
|
|
@ -55,5 +55,11 @@ export const authService = {
|
|||
getAudioToken: () => {
|
||||
// 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)
|
||||
},
|
||||
|
||||
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: '',
|
||||
koel_plus: {
|
||||
active: false,
|
||||
short_key: null,
|
||||
customer_name: null,
|
||||
customer_email: null,
|
||||
product_id: ''
|
||||
short_key: null as string | null,
|
||||
customer_name: null as string | null,
|
||||
customer_email: null as string | null,
|
||||
product_id: '' as string | null
|
||||
},
|
||||
latest_version: '',
|
||||
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 {
|
||||
BASE_URL: string
|
||||
MAILER_CONFIGURED: boolean
|
||||
|
||||
readonly PUSHER_APP_KEY: string
|
||||
readonly PUSHER_APP_CLUSTER: string
|
||||
|
||||
readonly MediaMetadata: Constructable<Record<string, any>>
|
||||
createLemonSqueezy?: () => Closure
|
||||
}
|
||||
|
@ -352,6 +355,7 @@ declare type ScreenName =
|
|||
| 'Search.Excerpt'
|
||||
| 'Search.Songs'
|
||||
| 'Invitation.Accept'
|
||||
| 'Password.Reset'
|
||||
| '404'
|
||||
|
||||
declare type ArtistAlbumCardLayout = 'full' | 'compact'
|
||||
|
|
|
@ -10,3 +10,11 @@ export const uuid = () => {
|
|||
? window.crypto.randomUUID()
|
||||
: 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;
|
||||
}
|
||||
|
||||
input {
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
button,
|
||||
|
@ -45,7 +49,7 @@ textarea,
|
|||
font-size: 1rem;
|
||||
font-weight: var(--font-weight-light);
|
||||
padding: .5rem .6rem;
|
||||
border-radius: .3rem;
|
||||
border-radius: var(--border-radius-input);
|
||||
margin: 0;
|
||||
background: var(--color-bg-input);
|
||||
color: var(--color-input);
|
||||
|
@ -235,6 +239,18 @@ label {
|
|||
&0 {
|
||||
font-size: 0;
|
||||
}
|
||||
|
||||
&1 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
&1\.5 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
&2 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.text- {
|
||||
|
|
|
@ -33,6 +33,8 @@
|
|||
--color-blue: #0191f7;
|
||||
--color-red: #c34848;
|
||||
|
||||
--border-radius-input: .3rem;
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
--header-height: 56px;
|
||||
--footer-height: 96px;
|
||||
|
|
|
@ -33,6 +33,8 @@
|
|||
|
||||
<script>
|
||||
window.BASE_URL = @json(asset(''));
|
||||
window.MAILER_CONFIGURED = @json(mailer_configured());
|
||||
|
||||
window.PUSHER_APP_KEY = @json(config('broadcasting.connections.pusher.key'));
|
||||
window.PUSHER_APP_CLUSTER = @json(config('broadcasting.connections.pusher.options.cluster'));
|
||||
</script>
|
||||
|
|
|
@ -20,6 +20,7 @@ use App\Http\Controllers\API\FetchOverviewController;
|
|||
use App\Http\Controllers\API\FetchRandomSongsInGenreController;
|
||||
use App\Http\Controllers\API\FetchRecentlyPlayedSongController;
|
||||
use App\Http\Controllers\API\FetchSongsForQueueController;
|
||||
use App\Http\Controllers\API\ForgotPasswordController;
|
||||
use App\Http\Controllers\API\GenreController;
|
||||
use App\Http\Controllers\API\GenreSongController;
|
||||
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\QueueStateController;
|
||||
use App\Http\Controllers\API\RegisterPlayController;
|
||||
use App\Http\Controllers\API\ResetPasswordController;
|
||||
use App\Http\Controllers\API\ScrobbleController;
|
||||
use App\Http\Controllers\API\SearchYouTubeController;
|
||||
use App\Http\Controllers\API\SetLastfmSessionKeyController;
|
||||
|
@ -59,10 +61,13 @@ use Illuminate\Support\Facades\Route;
|
|||
use Pusher\Pusher;
|
||||
|
||||
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::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::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