Skip to content

Commit

Permalink
Add ForgotPassword
Browse files Browse the repository at this point in the history
  • Loading branch information
daniel-de-wit committed Apr 19, 2021
1 parent a1d347b commit c29d5bd
Show file tree
Hide file tree
Showing 13 changed files with 308 additions and 1 deletion.
21 changes: 21 additions & 0 deletions README.md
Expand Up @@ -43,6 +43,7 @@ type Mutation
- [Logout](#logout)
- [Register](#register)
- [Email Verification](#email-verification)
- [Forgot Password](#forgot-password)

### Login

Expand Down Expand Up @@ -112,6 +113,26 @@ mutation VerifyEmail {
}
```

### Forgot Password

Sends a reset password notification.

Optionally use custom reset url using both `__EMAIL__` and `__TOKEN__` placeholders.

```graphql
mutation ForgotPassword {
forgotPassword(input: {
email: "john.doe@gmail.com"
reset_password_url: {
url: "https://my-front-end.com/reset-password?email=__EMAIL__&token=__TOKEN__"
}
}) {
status
message
}
}
```

## Testing

```bash
Expand Down
22 changes: 22 additions & 0 deletions graphql/sanctum.graphql
Expand Up @@ -40,6 +40,26 @@ input RegisterInput {
verification_url: String @rules(apply: ["url"])
}

input ForgotPasswordInput {
email: String! @rules(apply: ["required", "email"])
reset_password_url: ResetPasswordUrlInput
}

"""
The url used to reset the password.
Use the `__EMAIL__` and `__TOKEN__` placeholders to inject the reset password email and token.
e.g; https://my-front-end.com?reset-password?email=__EMAIL__&token=__TOKEN__
"""
input ResetPasswordUrlInput {
url: String!
}

type ForgotPasswordResponse {
status: ForgotPasswordStatus!
message: String
}

extend type Mutation {
login(input: LoginInput @spread): AccessToken!
@field(resolver: "DanielDeWit\\LighthouseSanctum\\GraphQL\\Mutations\\Login")
Expand All @@ -49,4 +69,6 @@ extend type Mutation {
@field(resolver: "DanielDeWit\\LighthouseSanctum\\GraphQL\\Mutations\\Register")
verifyEmail(input: VerifyEmailInput! @spread): EmailVerificationResponse!
@field(resolver: "DanielDeWit\\LighthouseSanctum\\GraphQL\\Mutations\\VerifyEmail")
forgotPassword(input: ForgotPasswordInput! @spread): ForgotPasswordResponse!
@field(resolver: "DanielDeWit\\LighthouseSanctum\\GraphQL\\Mutations\\ForgotPassword")
}
2 changes: 1 addition & 1 deletion phpstan.neon
Expand Up @@ -13,4 +13,4 @@ parameters:
ignoreErrors:
-
message: '#Parameter \#1 \$(function|callback) of function call_user_func expects callable\(\): mixed, Closure\|null given\.#'
path: tests/Integration/Services/EmailVerificationServiceTest.php
path: tests/Integration/Services/*
10 changes: 10 additions & 0 deletions src/Contracts/Services/ResetPasswordServiceInterface.php
@@ -0,0 +1,10 @@
<?php

declare(strict_types=1);

namespace DanielDeWit\LighthouseSanctum\Contracts\Services;

interface ResetPasswordServiceInterface
{
public function setResetPasswordUrl(string $url): void;
}
13 changes: 13 additions & 0 deletions src/Enums/ForgotPasswordStatus.php
@@ -0,0 +1,13 @@
<?php

namespace DanielDeWit\LighthouseSanctum\Enums;

use BenSampo\Enum\Enum;

/**
* @method static static EMAIL_SENT()
*/
final class ForgotPasswordStatus extends Enum
{
public const EMAIL_SENT = 'EMAIL_SENT';
}
54 changes: 54 additions & 0 deletions src/GraphQL/Mutations/ForgotPassword.php
@@ -0,0 +1,54 @@
<?php

declare(strict_types=1);

namespace DanielDeWit\LighthouseSanctum\GraphQL\Mutations;

use DanielDeWit\LighthouseSanctum\Contracts\Services\ResetPasswordServiceInterface;
use DanielDeWit\LighthouseSanctum\Enums\ForgotPasswordStatus;
use Exception;
use GraphQL\Type\Definition\ResolveInfo;
use Illuminate\Contracts\Auth\PasswordBroker;
use Illuminate\Contracts\Translation\Translator;
use Nuwave\Lighthouse\Support\Contracts\GraphQLContext;

class ForgotPassword
{
protected PasswordBroker $passwordBroker;
protected ResetPasswordServiceInterface $resetPasswordService;
protected Translator $translator;

public function __construct(
PasswordBroker $passwordBroker,
ResetPasswordServiceInterface $resetPasswordService,
Translator $translator
) {
$this->passwordBroker = $passwordBroker;
$this->resetPasswordService = $resetPasswordService;
$this->translator = $translator;
}

/**
* @param mixed $_
* @param array<string, mixed> $args
* @param GraphQLContext|null $context
* @param ResolveInfo $resolveInfo
* @return array<string, ForgotPasswordStatus|array|string|null>
* @throws Exception
*/
public function __invoke($_, array $args, GraphQLContext $context = null, ResolveInfo $resolveInfo): array
{
if ($args['reset_password_url']) {
$this->resetPasswordService->setResetPasswordUrl($args['reset_password_url']['url']);
}

$this->passwordBroker->sendResetLink([
'email' => $args['email'],
]);

return [
'status' => ForgotPasswordStatus::EMAIL_SENT(),
'message' => $this->translator->get('An email has been sent'),
];
}
}
8 changes: 8 additions & 0 deletions src/Providers/LighthouseSanctumServiceProvider.php
Expand Up @@ -5,10 +5,13 @@
namespace DanielDeWit\LighthouseSanctum\Providers;

use DanielDeWit\LighthouseSanctum\Contracts\Services\EmailVerificationServiceInterface;
use DanielDeWit\LighthouseSanctum\Contracts\Services\ResetPasswordServiceInterface;
use DanielDeWit\LighthouseSanctum\Enums\EmailVerificationStatus;
use DanielDeWit\LighthouseSanctum\Enums\ForgotPasswordStatus;
use DanielDeWit\LighthouseSanctum\Enums\LogoutStatus;
use DanielDeWit\LighthouseSanctum\Enums\RegisterStatus;
use DanielDeWit\LighthouseSanctum\Services\EmailVerificationService;
use DanielDeWit\LighthouseSanctum\Services\ResetPasswordService;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Support\ServiceProvider;
use Nuwave\Lighthouse\Schema\TypeRegistry;
Expand All @@ -21,6 +24,7 @@ class LighthouseSanctumServiceProvider extends ServiceProvider
public function register(): void
{
$this->app->singleton(EmailVerificationServiceInterface::class, EmailVerificationService::class);
$this->app->singleton(ResetPasswordServiceInterface::class, ResetPasswordService::class);
}

public function boot(Dispatcher $dispatcher, TypeRegistry $typeRegistry): void
Expand Down Expand Up @@ -70,5 +74,9 @@ protected function registerEnums(): void
$this->typeRegistry->register(
new LaravelEnumType(EmailVerificationStatus::class),
);

$this->typeRegistry->register(
new LaravelEnumType(ForgotPasswordStatus::class),
);
}
}
25 changes: 25 additions & 0 deletions src/Services/ResetPasswordService.php
@@ -0,0 +1,25 @@
<?php

declare(strict_types=1);

namespace DanielDeWit\LighthouseSanctum\Services;

use DanielDeWit\LighthouseSanctum\Contracts\Services\ResetPasswordServiceInterface;
use Illuminate\Auth\Notifications\ResetPassword as ResetPasswordNotification;
use Illuminate\Contracts\Auth\CanResetPassword;

class ResetPasswordService implements ResetPasswordServiceInterface
{
public function setResetPasswordUrl(string $url): void
{
ResetPasswordNotification::createUrlUsing(function (CanResetPassword $notifiable, string $token) use ($url) {
return str_replace([
'__EMAIL__',
'__TOKEN__',
], [
$notifiable->getEmailForPasswordReset(),
$token,
], $url);
});
}
}
4 changes: 4 additions & 0 deletions tests/Integration/AbstractIntegrationTest.php
Expand Up @@ -8,9 +8,11 @@
use DanielDeWit\LighthouseSanctum\Tests\Traits\AssertsGraphQLErrorMessage;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Notifications\NotificationServiceProvider;
use Laravel\Sanctum\SanctumServiceProvider;
use Nuwave\Lighthouse\LighthouseServiceProvider;
use Nuwave\Lighthouse\Testing\MakesGraphQLRequests;
use Nuwave\Lighthouse\Validation\ValidationServiceProvider;
use Orchestra\Testbench\TestCase;

abstract class AbstractIntegrationTest extends TestCase
Expand All @@ -28,7 +30,9 @@ protected function getPackageProviders($app): array
return [
LighthouseSanctumServiceProvider::class,
LighthouseServiceProvider::class,
NotificationServiceProvider::class,
SanctumServiceProvider::class,
ValidationServiceProvider::class,
];
}

Expand Down
79 changes: 79 additions & 0 deletions tests/Integration/GraphQL/Mutations/ForgotPasswordTest.php
@@ -0,0 +1,79 @@
<?php

declare(strict_types=1);

namespace DanielDeWit\LighthouseSanctum\Tests\Integration\GraphQL\Mutations;

use DanielDeWit\LighthouseSanctum\Contracts\Services\ResetPasswordServiceInterface;
use DanielDeWit\LighthouseSanctum\GraphQL\Mutations\ForgotPassword;
use DanielDeWit\LighthouseSanctum\Tests\Integration\AbstractIntegrationTest;
use DanielDeWit\LighthouseSanctum\Tests\stubs\Users\UserCanResetPassword;
use Illuminate\Auth\Notifications\ResetPassword;
use Illuminate\Auth\Notifications\ResetPassword as ResetPasswordNotification;
use Illuminate\Contracts\Auth\PasswordBroker;
use Illuminate\Contracts\Translation\Translator;
use Illuminate\Support\Facades\Notification;
use Laravel\Sanctum\Sanctum;

class ForgotPasswordTest extends AbstractIntegrationTest
{
protected ForgotPassword $mutation;

protected function setUp(): void
{
parent::setUp();

$this->app['config']->set('auth.providers', [
'users' => [
'driver' => 'eloquent',
'model' => UserCanResetPassword::class,
],
]);

$this->mutation = new ForgotPassword(
$this->app->make(PasswordBroker::class),
$this->app->make(ResetPasswordServiceInterface::class),
$this->app->make(Translator::class),
);
}

/**
* @test
*/
public function it_sends_a_reset_password_notification(): void
{
Notification::fake();

/** @var UserCanResetPassword $user */
$user = UserCanResetPassword::factory()->create([
'email' => 'john.doe@gmail.com',
]);

$user->notify(new ResetPasswordNotification('bla'));

Sanctum::actingAs($user);

$this->graphQL(/** @lang GraphQL */ '
mutation {
forgotPassword(input: {
email: "john.doe@gmail.com"
reset_password_url: {
url: "https://my-front-end.com/reset-password?email=__EMAIL__&token=__TOKEN__"
}
}) {
status
message
}
}
')->assertJson([
'data' => [
'forgotPassword' => [
'status' => 'EMAIL_SENT',
'message' => 'An email has been sent',
],
],
]);

Notification::assertSentTo($user, ResetPassword::class);
}
}
41 changes: 41 additions & 0 deletions tests/Integration/Services/ResetPasswordServiceTest.php
@@ -0,0 +1,41 @@
<?php

declare(strict_types=1);

namespace DanielDeWit\LighthouseSanctum\Tests\Integration\Services;

use DanielDeWit\LighthouseSanctum\Services\ResetPasswordService;
use DanielDeWit\LighthouseSanctum\Tests\Integration\AbstractIntegrationTest;
use DanielDeWit\LighthouseSanctum\Tests\stubs\Users\UserMustVerifyEmail;
use Illuminate\Auth\Notifications\ResetPassword;

class ResetPasswordServiceTest extends AbstractIntegrationTest
{
protected ResetPasswordService $service;

protected function setUp(): void
{
parent::setUp();

$this->service = new ResetPasswordService();
}

/**
* @test
*/
public function it_sets_the_reset_password_url(): void
{
/** @var UserMustVerifyEmail $user */
$user = UserMustVerifyEmail::factory()->create([
'email' => 'user@example.com',
]);

$token = 'token123';

$this->service->setResetPasswordUrl('https://mysite.com/reset-password/__EMAIL__/__TOKEN__');

$url = call_user_func(ResetPassword::$createUrlCallback, $user, $token);

static::assertSame('https://mysite.com/reset-password/user@example.com/token123', $url);
}
}
18 changes: 18 additions & 0 deletions tests/stubs/Users/UserCanResetPassword.php
@@ -0,0 +1,18 @@
<?php

declare(strict_types=1);

namespace DanielDeWit\LighthouseSanctum\Tests\stubs\Users;

use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Notifications\Notifiable;

class UserCanResetPassword extends UserHasApiTokens
{
use Notifiable;

protected static function newFactory(): Factory
{
return new UserCanResetPasswordFactory();
}
}
12 changes: 12 additions & 0 deletions tests/stubs/Users/UserCanResetPasswordFactory.php
@@ -0,0 +1,12 @@
<?php

declare(strict_types=1);

namespace DanielDeWit\LighthouseSanctum\Tests\stubs\Users;

use Orchestra\Testbench\Factories\UserFactory;

class UserCanResetPasswordFactory extends UserFactory
{
protected $model = UserCanResetPassword::class;
}

0 comments on commit c29d5bd

Please sign in to comment.