feat: implement password reset functionality with email notifications

This commit is contained in:
Yoga Pangestu 2026-09-06 01:34:44 +07:00
parent 3182a8570c
commit 9837e027df
11 changed files with 416 additions and 8 deletions

View File

@ -3,8 +3,10 @@
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\ForgotPasswordRequest;
use App\Http\Requests\Auth\LoginRequest;
use App\Http\Requests\Auth\RegisterRequest;
use App\Http\Requests\Auth\ResetPasswordRequest;
use App\Http\Requests\Auth\SsoExchangeRequest;
use App\Http\Resources\UserResource;
use App\Services\AuthService;
@ -43,6 +45,20 @@ public function login(LoginRequest $request): JsonResponse
], 'Selamat datang, '.$user->full_name.'. Semoga harimu menyenangkan!');
}
public function forgotPassword(ForgotPasswordRequest $request): JsonResponse
{
$this->authService->forgotPassword($request->validated('identifier'));
return $this->success(null, 'Jika akun ditemukan, tautan reset kata sandi telah dikirim ke email Anda.');
}
public function resetPassword(ResetPasswordRequest $request): JsonResponse
{
$this->authService->resetPassword($request->validated());
return $this->success(null, 'Kata sandi berhasil diperbarui. Silakan masuk kembali.');
}
public function ssoHandoff(Request $request): JsonResponse
{
$code = $this->authService->createSsoHandoffCode($request->user());

View File

@ -0,0 +1,22 @@
<?php
namespace App\Http\Requests\Auth;
use App\Http\Requests\BaseRequest;
class ForgotPasswordRequest extends BaseRequest
{
public function rules(): array
{
return [
'identifier' => ['required', 'string'],
];
}
public function attributes(): array
{
return [
'identifier' => 'email/username',
];
}
}

View File

@ -0,0 +1,26 @@
<?php
namespace App\Http\Requests\Auth;
use App\Http\Requests\BaseRequest;
class ResetPasswordRequest extends BaseRequest
{
public function rules(): array
{
return [
'token' => ['required', 'string'],
'email' => ['required', 'string', 'email'],
'password' => ['required', 'string', 'min:8', 'confirmed'],
];
}
public function attributes(): array
{
return [
'token' => 'token',
'email' => 'email',
'password' => 'kata sandi',
];
}
}

View File

@ -3,6 +3,7 @@
namespace App\Models;
use App\Enums\UserStatus;
use App\Notifications\QueuedResetPassword;
use App\Notifications\QueuedVerifyEmail;
use App\Traits\Filterable;
use App\Traits\Sortable;
@ -84,4 +85,9 @@ public function sendEmailVerificationNotification(): void
{
$this->notify(new QueuedVerifyEmail);
}
public function sendPasswordResetNotification(#[\SensitiveParameter] $token)
{
$this->notify(new QueuedResetPassword($token));
}
}

View File

@ -0,0 +1,37 @@
<?php
namespace App\Notifications;
use Illuminate\Auth\Notifications\ResetPassword;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
/**
* Queued (see QueuedVerifyEmail for why) and pointed at the SPA's own
* reset-password page instead of the stock `password.reset` named route,
* which doesn't exist here the frontend reads the token/email query
* params and POSTs them to /v1/auth/reset-password itself.
*/
class QueuedResetPassword extends ResetPassword implements ShouldQueue
{
use Queueable;
public $tries = 3;
public $backoff = 5;
public function toMail(mixed $notifiable): MailMessage
{
$frontendUrl = config('app.frontend_url');
$url = "{$frontendUrl}/auth/reset-password?token={$this->token}&email=".urlencode($notifiable->getEmailForPasswordReset());
return (new MailMessage)
->subject('Reset Kata Sandi Anda - Profitra')
->view('emails.reset-password', [
'name' => $notifiable->full_name ?? $notifiable->username,
'url' => $url,
'expireMinutes' => config('auth.passwords.users.expire'),
]);
}
}

View File

@ -9,9 +9,11 @@
use App\Models\User;
use App\Models\UserProfile;
use Illuminate\Auth\Events\Registered;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str;
use Laravel\Sanctum\NewAccessToken;
use Laravel\Socialite\Two\User as SocialiteUser;
@ -198,6 +200,43 @@ public function resendVerificationEmail(User $user): bool
return true;
}
/**
* Send a password reset link if the identifier matches an account.
* Silently no-ops otherwise the controller always replies with the
* same generic message so this can't be used to enumerate accounts.
*/
public function forgotPassword(string $identifier): void
{
$user = User::query()
->where('email', $identifier)
->orWhere('username', $identifier)
->first();
if ($user) {
Password::broker()->sendResetLink(['email' => $user->email]);
}
}
/**
* Redeem a password reset token and set the new password.
*
* @param array{email: string, token: string, password: string, password_confirmation: string} $data
*/
public function resetPassword(array $data): void
{
$status = Password::broker()->reset(
Arr::only($data, ['email', 'password', 'password_confirmation', 'token']),
function (User $user, string $password) {
$user->forceFill(['password' => $password])->save();
$user->tokens()->delete();
}
);
if ($status !== Password::PASSWORD_RESET) {
throw new UnauthorizedException('Tautan reset tidak valid atau sudah kedaluwarsa.');
}
}
protected function generateUsernameFrom(string $name): string
{
$slug = Str::of($name)->slug('')->lower()->toString();

View File

@ -0,0 +1,123 @@
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Reset Kata Sandi</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700&family=Roboto:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
body,
table,
td {
font-family: Roboto, -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
}
h1 {
font-family: Nunito, Roboto, -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
}
body {
margin: 0;
padding: 0;
background-color: #f5f6fa;
}
a {
color: #0284c7;
}
</style>
</head>
<body style="margin:0; padding:0; background-color:#f5f6fa;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#f5f6fa;">
<tr>
<td align="center" style="padding: 40px 16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:560px;">
{{-- Brand header --}}
<tr>
<td align="center" style="padding-bottom: 24px;">
<span
style="display:inline-block; width:28px; height:28px; line-height:28px; border-radius:6px; background-color:#0284c7; color:#ffffff; font-weight:bold; font-size:15px; text-align:center; vertical-align:middle;">P</span>
<span
style="vertical-align:middle; font-family:Nunito,Roboto,sans-serif; font-size:20px; font-weight:bold; color:#364a63; margin-left:8px;">Profitra</span>
<p style="margin:8px 0 0; font-size:13px; color:#0284c7;">Mitra Anda untuk profit yang
bertumbuh</p>
</td>
</tr>
{{-- Card --}}
<tr>
<td
style="background-color:#ffffff; border-radius:8px; padding:40px; box-shadow: 0 1px 3px rgba(0,0,0,0.08);">
<h1 style="margin:0 0 20px; font-family:Nunito,Roboto,sans-serif; font-size:20px; font-weight:bold; color:#364a63;">Reset Kata Sandi Anda
</h1>
<p style="margin:0 0 16px; font-size:15px; line-height:1.6; color:#526484;">Hi {{ $name }},
</p>
<p style="margin:0 0 16px; font-size:15px; line-height:1.6; color:#526484;">
Anda menerima email ini karena kami menerima permintaan reset kata sandi untuk akun
Anda.
</p>
<p style="margin:0 0 28px; font-size:15px; line-height:1.6; color:#526484;">
Klik tombol di bawah untuk membuat kata sandi baru. Tautan ini berlaku selama
{{ $expireMinutes }} menit dan hanya dapat digunakan sekali.
</p>
<table role="presentation" cellpadding="0" cellspacing="0" style="margin: 0 auto 28px;">
<tr>
<td style="border-radius:6px; background-color:#0284c7;">
<a href="{{ $url }}" target="_blank"
style="display:inline-block; padding:14px 32px; font-size:14px; font-weight:bold; letter-spacing:0.5px; color:#ffffff; text-decoration:none;">RESET
KATA SANDI</a>
</td>
</tr>
</table>
<p
style="margin:0 0 12px; font-size:13px; font-weight:bold; color:#364a63; text-align:center;">
ATAU</p>
<p style="margin:0 0 8px; font-size:14px; line-height:1.6; color:#526484;">
Jika tombol di atas tidak berfungsi, salin dan tempel tautan berikut ke browser Anda:
</p>
<p style="margin:0 0 24px; font-size:13px; word-break:break-all;">
<a href="{{ $url }}" style="color:#0284c7;">{{ $url }}</a>
</p>
<p style="margin:0 0 16px; font-size:14px; line-height:1.6; color:#526484;">
Jika Anda tidak meminta reset kata sandi, abaikan saja email ini kata sandi Anda
tidak akan berubah.
</p>
<p style="margin:0; font-size:13px; line-height:1.6; color:#a1a1aa;">
Email ini dikirim otomatis, mohon tidak membalas. Ada kendala? Hubungi kami di
<a href="mailto:support@profitra.id" style="color:#0284c7;">support@profitra.id</a>.
</p>
</td>
</tr>
{{-- Footer --}}
<tr>
<td align="center" style="padding-top: 24px;">
<p style="margin:0; font-size:12px; color:#a1a1aa;">&copy; {{ date('Y') }} Profitra. Hak
cipta dilindungi.</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@ -17,6 +17,9 @@
Route::post('/register', [AuthController::class, 'register'])->middleware('rate.limit:register,5,1');
Route::post('/login', [AuthController::class, 'login'])->middleware('rate.limit:login,5,1');
Route::post('/forgot-password', [AuthController::class, 'forgotPassword'])->middleware('rate.limit:forgot-password,3,1');
Route::post('/reset-password', [AuthController::class, 'resetPassword'])->middleware('rate.limit:reset-password,5,1');
Route::get('/email/verify/{id}/{hash}', [AuthController::class, 'verifyEmail'])
->middleware('signed')
->name('verification.verify');

View File

@ -48,6 +48,12 @@ const router = createRouter({
component: () => import('../views/auth/ForgotPasswordView.vue'),
meta: { guestOnly: true },
},
{
path: '/auth/reset-password',
name: 'reset-password',
component: () => import('../views/auth/ResetPasswordView.vue'),
meta: { guestOnly: true },
},
{
path: '/legal/terms',
name: 'terms',

View File

@ -2,18 +2,39 @@
import { ArrowLeft, MailCheck } from '@lucide/vue'
import { reactive, ref } from 'vue'
import AuthHeroPanel from '@/components/auth/AuthHeroPanel.vue'
import { apiFetch } from '@/lib/api'
import { useToastStore } from '@shared/stores/toast'
import { handleFetchError } from '@shared/utils/apiError'
const toast = useToastStore()
const identifier = ref('')
const submitted = ref(false)
const isSubmitting = ref(false)
// Populated from the API's `errors[]` response (field/message docs/api-design.md)
// once this form is wired to POST /auth/forgot-password. Not validated client-side.
const errors = reactive<{ identifier?: string }>({})
function handleSubmit() {
// UI-only for now wiring to POST /auth/forgot-password comes later.
// The form "Cek Email Anda" transition below is real UI behavior already.
submitted.value = true
async function handleSubmit() {
if (!identifier.value.trim()) {
errors.identifier = 'Email atau username wajib diisi.'
return
}
errors.identifier = undefined
isSubmitting.value = true
try {
await apiFetch('/v1/auth/forgot-password', {
method: 'POST',
body: JSON.stringify({ identifier: identifier.value }),
})
submitted.value = true
} catch (error) {
handleFetchError(toast, error)
} finally {
isSubmitting.value = false
}
}
</script>
@ -54,8 +75,9 @@ function handleSubmit() {
<p v-if="errors.identifier" class="mt-1.5 text-xs text-danger">{{ errors.identifier }}</p>
</div>
<button type="submit" class="h-10 w-full rounded-lg bg-action text-sm font-semibold text-action-text">
Kirim Link Reset
<button type="submit" :disabled="isSubmitting"
class="h-10 w-full rounded-lg bg-action text-sm font-semibold text-action-text disabled:opacity-60">
{{ isSubmitting ? 'Mengirim…' : 'Kirim Link Reset' }}
</button>
</form>
</template>
@ -74,7 +96,8 @@ function handleSubmit() {
<p class="mt-6 text-sm text-secondary">
Tidak menerima email?
<button type="button" class="font-medium text-action underline-offset-2 hover:underline"
<button type="button" :disabled="isSubmitting"
class="font-medium text-action underline-offset-2 hover:underline disabled:opacity-60"
@click="handleSubmit">
Kirim ulang
</button>

View File

@ -0,0 +1,107 @@
<script setup lang="ts">
import { Eye, EyeOff } from '@lucide/vue'
import { reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import AuthHeroPanel from '@/components/auth/AuthHeroPanel.vue'
import { apiFetch } from '@/lib/api'
import { handleFetchError, isValidationError } from '@shared/utils/apiError'
import { useToastStore } from '@shared/stores/toast'
const route = useRoute()
const router = useRouter()
const toast = useToastStore()
// Present only when landing here from the link the backend emailed
// (QueuedResetPassword) not something the SPA sets itself.
const token = (route.query.token as string | undefined) ?? ''
const email = (route.query.email as string | undefined) ?? ''
const password = ref('')
const passwordConfirmation = ref('')
const showPassword = ref(false)
const isSubmitting = ref(false)
const errors = reactive<{ password?: string }>({})
async function handleSubmit() {
errors.password = undefined
isSubmitting.value = true
try {
const response = await apiFetch<{ message: string }>('/v1/auth/reset-password', {
method: 'POST',
body: JSON.stringify({
token,
email,
password: password.value,
password_confirmation: passwordConfirmation.value,
}),
})
toast.success(response.message)
router.push('/auth/login')
} catch (error) {
if (isValidationError(error)) {
errors.password = error.errors.password
} else {
handleFetchError(toast, error)
}
} finally {
isSubmitting.value = false
}
}
</script>
<template>
<div class="min-h-screen flex">
<div class="relative flex w-full items-center justify-center overflow-hidden bg-bg px-4 py-10 lg:w-1/2">
<div class="pointer-events-none absolute -left-16 -top-16 h-72 w-72 rounded-full bg-primary/[0.04]"
aria-hidden="true" />
<div class="pointer-events-none absolute -bottom-24 right-10 h-64 w-64 rounded-full bg-primary/[0.04]"
aria-hidden="true" />
<div class="pointer-events-none absolute inset-0"
style="background-image: radial-gradient(currentColor 1px, transparent 1px); background-size: 24px 24px; color: var(--color-border); mask-image: radial-gradient(ellipse 60% 60% at 50% 40%, black, transparent);"
aria-hidden="true" />
<div class="relative z-10 w-full max-w-xs">
<h1 class="text-2xl font-bold text-primary">Buat Kata Sandi Baru</h1>
<p class="mt-2 text-sm text-secondary">
Masukkan kata sandi baru untuk akun <span class="font-medium text-primary">{{ email }}</span>.
</p>
<form class="mt-6 space-y-3" @submit.prevent="handleSubmit">
<div>
<label for="password" class="text-sm font-medium text-primary">Kata Sandi Baru <span
class="text-red-500">*</span></label>
<div class="relative mt-1.5">
<input id="password" v-model="password" :type="showPassword ? 'text' : 'password'"
autocomplete="new-password" placeholder="Minimal 8 karakter" :aria-invalid="!!errors.password"
@input="errors.password = undefined"
class="h-10 w-full rounded-lg border bg-surface px-3.5 pr-10 text-sm text-primary placeholder:text-secondary focus:outline-none focus:ring-1"
:class="errors.password ? 'border-danger focus:ring-danger' : 'border-border focus:ring-action'" />
<button type="button" tabindex="-1" @click="showPassword = !showPassword"
class="absolute inset-y-0 right-0 flex items-center px-3 text-secondary">
<component :is="showPassword ? EyeOff : Eye" :size="16" />
</button>
</div>
<p v-if="errors.password" class="mt-1.5 text-xs text-danger">{{ errors.password }}</p>
</div>
<div>
<label for="password_confirmation" class="text-sm font-medium text-primary">Konfirmasi Kata Sandi <span
class="text-red-500">*</span></label>
<input id="password_confirmation" v-model="passwordConfirmation" :type="showPassword ? 'text' : 'password'"
autocomplete="new-password" placeholder="Ulangi kata sandi baru"
class="mt-1.5 h-10 w-full rounded-lg border border-border bg-surface px-3.5 text-sm text-primary placeholder:text-secondary focus:outline-none focus:ring-1 focus:ring-action" />
</div>
<button type="submit" :disabled="isSubmitting"
class="h-10 w-full rounded-lg bg-action text-sm font-semibold text-action-text disabled:opacity-60">
{{ isSubmitting ? 'Menyimpan…' : 'Simpan Kata Sandi' }}
</button>
</form>
</div>
</div>
<AuthHeroPanel />
</div>
</template>