diff --git a/app/Http/Controllers/SponsorLoginController.php b/app/Http/Controllers/SponsorLoginController.php new file mode 100644 index 000000000..86df049a2 --- /dev/null +++ b/app/Http/Controllers/SponsorLoginController.php @@ -0,0 +1,140 @@ +validate([ + 'email' => [ + 'required', + 'string', + 'email:rfc,strict,dns,spoof', + 'max:255', + ], + ]); + + // Read value - cast to string since validation guarantees it + $email = (string) $request->input('email'); + + // Check if domain is valid and sponsor is active; if not, return JSON error + if (! $this->isValidSponsorDomain($email)) { + return $this->errorResponse( + 'Authentication Error', + 'Could not validate email or sponsor is no longer active. '. + 'Contact hello@robojackets.org if the issue persists.' + ); + } + + $sponsorUser = SponsorUser::where('email', $email)->first(); + if (! $sponsorUser) { + $sponsorUser = new SponsorUser(); + $sponsorUser->email = $email; + $sponsorUser->sponsor_id = Sponsor::whereHas( + 'domainNames', + static fn ($q) => $q->where('domain_name', substr(strrchr($email, '@'), 1)) + ) + ->firstOrFail() + ->id; + $sponsorUser->save(); + } + + // Generate and dispatch OTP using Spatie + $sponsorUser->sendOneTimePassword(); + + // Cache minimal state for OTP verification + session([ + 'sponsor_email_pending' => $email, + ]); + + return response()->json([ + 'success' => true, + 'message' => 'One-Time Password Sent! Please type the one-time password sent to your email.', + ], 200); + } + + public function verifyOtp(Request $request): JsonResponse + { + // Laravel will automatically throw an error if OTP is invalid + $request->validate([ + 'otp' => ['required', 'string', 'digits:6'], + ]); + + // Validate session and retrieve user + $email = session('sponsor_email_pending'); + if (! is_string($email) || $email === '') { + return $this->errorResponse( + 'Session Expired', + 'Your session has expired. Please start the login process again.' + ); + } + + // Retrieve existing user for OTP verification + // sole() ensures exactly one user exists (throws exception if 0 or >1 found) + $sponsorUser = SponsorUser::where('email', $email)->sole(); + + // Verify sponsor domain is still valid and active BEFORE verifying OTP + if (! $this->isValidSponsorDomain($email)) { + return $this->errorResponse( + 'Authentication Error', + 'Could not validate email or sponsor is no longer active. '. + 'Please contact hello@robojackets.org if the issue persists.' + ); + } + + // Verify OTP using Spatie + $otp = (string) $request->input('otp'); + $result = $sponsorUser->attemptLoginUsingOneTimePassword($otp); + if (! $result->isOk()) { + return $this->errorResponse('Invalid OTP', $result->validationMessage()); + } + + Auth::guard('sponsor')->login($sponsorUser); + + session()->forget('sponsor_email_pending'); + + return response()->json([ + 'success' => true, + 'message' => 'Login successful! Redirecting to dashboard...', + 'redirect' => route('sponsor.home'), + ]); + } + + private function isValidSponsorDomain(string $email): bool + { + $domain = substr(strrchr($email, '@'), 1); + $sponsorDomain = SponsorDomain::where('domain_name', $domain)->first(); + + if (! $sponsorDomain) { + return false; + } + + return $sponsorDomain->sponsor && $sponsorDomain->sponsor->active(); + } + + private function errorResponse(string $title, string $message, int $status = 422): JsonResponse + { + // Dummy comment to force a build on Github site. + return response()->json([ + 'error' => true, + 'title' => $title, + 'message' => $message, + ], $status); + } +} diff --git a/app/Models/SponsorUser.php b/app/Models/SponsorUser.php index e32714251..b73dd642d 100644 --- a/app/Models/SponsorUser.php +++ b/app/Models/SponsorUser.php @@ -5,9 +5,10 @@ namespace App\Models; use Illuminate\Database\Eloquent\Factories\HasFactory; -use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\SoftDeletes; +use Illuminate\Foundation\Auth\User as Authenticatable; +use Illuminate\Notifications\Notifiable; use Laravel\Scout\Searchable; use Spatie\OneTimePasswords\Models\Concerns\HasOneTimePasswords; @@ -16,14 +17,16 @@ * * @property int $id * @property string $email + * @property int|null $sponsor_id * @property \Illuminate\Support\Carbon|null $created_at * @property \Illuminate\Support\Carbon|null $updated_at * @property \Illuminate\Support\Carbon|null $deleted_at */ -class SponsorUser extends Model +class SponsorUser extends Authenticatable { use HasFactory; use HasOneTimePasswords; + use Notifiable; use Searchable; use SoftDeletes; @@ -47,4 +50,12 @@ public function company(): BelongsTo { return $this->belongsTo(Sponsor::class, 'sponsor_id'); } + + /** + * Get a calculated uid based on the user portion of the sponsor's email. + */ + public function getUidAttribute(): string + { + return explode('@', (string) $this->email, 2)[0]; + } } diff --git a/config/auth.php b/config/auth.php index 25bde6c33..f2a3b6d8c 100644 --- a/config/auth.php +++ b/config/auth.php @@ -9,6 +9,17 @@ 'driver' => 'passport', 'provider' => 'users', ], + 'sponsor' => [ + 'driver' => 'session', + 'provider' => 'sponsor_users', + ], + ], + 'providers' => [ + 'sponsor_users' => [ + 'driver' => 'eloquent', + 'model' => App\Models\SponsorUser::class, + ], + ], ]; diff --git a/psalm.xml b/psalm.xml index cb009751a..a468b7c97 100644 --- a/psalm.xml +++ b/psalm.xml @@ -37,11 +37,6 @@ - - - - - diff --git a/resources/js/components/sponsors/SponsorLogin.vue b/resources/js/components/sponsors/SponsorLogin.vue index c14c5f100..7500c803c 100644 --- a/resources/js/components/sponsors/SponsorLogin.vue +++ b/resources/js/components/sponsors/SponsorLogin.vue @@ -48,7 +48,7 @@ @@ -73,6 +73,10 @@ export default { resendCooldown: 30, }; }, + beforeUnmount() { + // Clean up interval timer when app is exited to prevent memory leaks + clearInterval(this.resendTimer); + }, methods: { showToast(message, icon = 'info') { Swal.fire({ @@ -85,30 +89,42 @@ export default { timerProgressBar: true }); }, + handleApiError(error, validationField = null) { + if (error.response?.data) { + const data = error.response.data; + + // Custom error response from controller + if (data.error && data.title && data.message) { + Swal.fire(data.title, data.message, 'error'); + } + // Laravel validation error + else if (validationField && data.errors?.[validationField]) { + Swal.fire('Validation Error', data.errors[validationField][0], 'error'); + } + // Any other server error + else { + Swal.fire('Error', 'Something went wrong. Please try again. If the issue persists, contact hello@robojackets.org.', 'error'); + } + } else { + // Network error (no response from server) + Swal.fire('Connection Error', 'Unable to reach the server. Please check your connection. If the issue persists, contact hello@robojackets.org.', 'error'); + } + }, validateEmail() { - // axios.post('/sponsor/check-email', { email: this.email }) - // .then(res => { - // if (res.data.valid) { - // this.emailValidated = true; - // this.showToast('A one-time password has been sent to your email.', 'info'); - // } else { - // this.showToast('Email domain not approved.', 'error'); - // } - // }) - // .catch(() => { - // this.showToast('Something went wrong. Please try again.', 'error'); - // }); - if (this.email === 'gpburdell3@gatech.edu') { + axios.post('/sponsor/validate-email', { email: this.email }) + .then(res => { + if (res.data.success) { // else need to throw error this.emailValidated = true; - this.sendOTP(); - } else { - // NOTE: hello@robojackets.org is a placeholder for now; will change when I find out - // who is a good point of contact - Swal.fire('Authentication Error', 'Could not validate email domain. Please try again, or contact hello@robojackets.org if issues persist.', 'error'); - } + this.beginResendCooldown(); + + } + }) + .catch(error => { + this.handleApiError(error, 'email'); + }); }, beginResendCooldown() { - this.resendCooldown = 30; + this.resendCooldown = 60; // may want to increase to 2 mins in future this.canResend = false; clearInterval(this.resendTimer); this.resendTimer = setInterval(() => { @@ -120,30 +136,18 @@ export default { } }, 1000); }, - sendOTP() { - //TODO: OTP Logic. Rate-limiting should be implemented on backend. - this.beginResendCooldown(); - }, handleSubmit() { - // axios.post('/sponsor/login', { email: this.email, password: this.password }) - // .then(res => { - // if (res.data.success) { - // window.location.href = '/sponsor/dashboard'; - // } else { - // this.showToast('Invalid password.', 'error'); - // } - // }) - // .catch(() => { - // this.showToast('Something went wrong. Please try again.', 'error'); - // }); - if (this.password === 'hello') { - window.location.href='/'; - } else { - Swal.fire( - 'Authentication Error', - 'Could not validate password. Please try again or contact hello@robojackets.org if the issue persists.', - 'error'); - } + // TODO: ensure correct route + axios.post('/sponsor/verify-otp', { otp: this.password }) + .then(res => { + if (res.data.success) { + // Redirect to the URL provided by the controller + window.location.href = res.data.redirect; + } + }) + .catch(error => { + this.handleApiError(error, 'otp'); + }); } } }; diff --git a/resources/views/mail/sponsor-otp.blade.php b/resources/views/mail/sponsor-otp.blade.php new file mode 100644 index 000000000..b7322283d --- /dev/null +++ b/resources/views/mail/sponsor-otp.blade.php @@ -0,0 +1,13 @@ +Hello, + +You requested a one-time password to log in to the RoboJackets Sponsor Portal. + +Your one-time password is: {{ $otp }} + +This password will expire in 10 minutes. + +If you did not request this password, please contact hello@robojackets.org. + +---- + +To stop receiving emails from {{ config('app.name') }}, visit @{{{ pm:unsubscribe }}}. diff --git a/resources/views/sponsors/home.blade.php b/resources/views/sponsors/home.blade.php new file mode 100644 index 000000000..29d8fcd54 --- /dev/null +++ b/resources/views/sponsors/home.blade.php @@ -0,0 +1,9 @@ + + + + Hello World + + + Hello World + + \ No newline at end of file diff --git a/routes/web.php b/routes/web.php index acc8ed1cb..c0fdc5edf 100644 --- a/routes/web.php +++ b/routes/web.php @@ -14,6 +14,7 @@ use App\Http\Controllers\RemoteAttendanceController; use App\Http\Controllers\ResumeController; use App\Http\Controllers\RsvpController; +use App\Http\Controllers\SponsorLoginController; use App\Http\Controllers\SquareCheckoutController; use App\Http\Controllers\SUMSController; use App\Http\Controllers\TeamController; @@ -132,6 +133,13 @@ ->name('passport.authorizations.authorize') ->middleware('auth'); +Route::prefix('sponsor')->name('sponsor.')->group(static function (): void { + Route::get('/', static fn () => view('sponsors.home'))->name('home')->middleware('auth:sponsor'); + Route::get('/login', [SponsorLoginController::class, 'showLoginForm'])->name('login'); + Route::post('/validate-email', [SponsorLoginController::class, 'validateEmail'])->name('validate-email'); + Route::post('/verify-otp', [SponsorLoginController::class, 'verifyOtp'])->name('verify-otp'); +}); + Route::get('oauth/jwks', JwksController::class) ->name('passport.jwks');