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