feat: forgot password

This commit is contained in:
Phan An 2024-02-26 02:32:53 +07:00
parent 43e1a84cc1
commit e9695495c9
40 changed files with 783 additions and 101 deletions

View file

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

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

View file

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

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

View file

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

View file

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

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

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

View file

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

View file

@ -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 &amp; Log In</Btn>
<Btn type="submit" :disabled="loading">Accept &amp; 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')
}
})

View file

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

View file

@ -122,7 +122,6 @@ form {
input {
&[type="text"], &[type="email"], &[type="password"] {
width: 100%;
height: 32px;
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

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