feat: implement password reset functionality with email notifications
This commit is contained in:
parent
3182a8570c
commit
9837e027df
@ -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());
|
||||
|
||||
@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
37
api.profitra.id/app/Notifications/QueuedResetPassword.php
Normal file
37
api.profitra.id/app/Notifications/QueuedResetPassword.php
Normal 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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
|
||||
123
api.profitra.id/resources/views/emails/reset-password.blade.php
Normal file
123
api.profitra.id/resources/views/emails/reset-password.blade.php
Normal 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;">© {{ date('Y') }} Profitra. Hak
|
||||
cipta dilindungi.</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@ -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');
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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>
|
||||
|
||||
107
profitra.id/src/views/auth/ResetPasswordView.vue
Normal file
107
profitra.id/src/views/auth/ResetPasswordView.vue
Normal 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>
|
||||
Loading…
Reference in New Issue
Block a user