From 805d8d19028be4d591ed2d5d7f7485f4ec386e72 Mon Sep 17 00:00:00 2001 From: Tenzin khenrab Date: Tue, 1 Sep 2026 10:03:48 +0530 Subject: [PATCH] Fixed new basic feature --- .../Controllers/Api/AuditLogController.php | 57 +++++ .../Http/Controllers/Api/AuthController.php | 70 ++++-- backend/app/Models/MonthlyBill.php | 11 + backend/app/Models/UserMonthlyBill.php | 11 + backend/app/Providers/AppServiceProvider.php | 6 + backend/bootstrap/app.php | 4 + backend/composer.json | 3 +- backend/composer.lock | 93 +++++++- ...08_31_164940_create_activity_log_table.php | 27 +++ ...add_event_column_to_activity_log_table.php | 22 ++ ...atch_uuid_column_to_activity_log_table.php | 22 ++ backend/routes/api.php | 19 +- frontend/app/dashboard/page.tsx | 15 +- frontend/app/forgot-password/page.tsx | 130 ++++++++++++ frontend/app/login/page.tsx | 4 +- frontend/app/page.tsx | 1 - frontend/app/profile/page.tsx | 6 +- frontend/app/reset-password/page.tsx | 199 ++++++++++++++++++ frontend/app/suggestions/page.tsx | 38 ++-- frontend/app/user_dashboard/billing/page.tsx | 3 +- frontend/app/user_dashboard/history/page.tsx | 3 +- frontend/app/user_dashboard/page.tsx | 3 +- frontend/app/vote/page.tsx | 24 ++- .../billing/accountant-billing-panel.tsx | 10 +- .../components/billing/user-billing-panel.tsx | 2 +- frontend/components/header.tsx | 16 +- frontend/lib/api-url.ts | 15 +- frontend/lib/api.ts | 9 +- frontend/lib/billing.ts | 39 +++- 29 files changed, 760 insertions(+), 102 deletions(-) create mode 100644 backend/app/Http/Controllers/Api/AuditLogController.php create mode 100644 backend/database/migrations/2026_08_31_164940_create_activity_log_table.php create mode 100644 backend/database/migrations/2026_08_31_164941_add_event_column_to_activity_log_table.php create mode 100644 backend/database/migrations/2026_08_31_164942_add_batch_uuid_column_to_activity_log_table.php create mode 100644 frontend/app/forgot-password/page.tsx create mode 100644 frontend/app/reset-password/page.tsx diff --git a/backend/app/Http/Controllers/Api/AuditLogController.php b/backend/app/Http/Controllers/Api/AuditLogController.php new file mode 100644 index 0000000..4662a68 --- /dev/null +++ b/backend/app/Http/Controllers/Api/AuditLogController.php @@ -0,0 +1,57 @@ +authorize('view', $monthlyBill); + + // Get logs for the MonthlyBill itself + $billLogs = Activity::where('subject_type', MonthlyBill::class) + ->where('subject_id', $monthlyBill->id) + ->with('causer') + ->get(); + + // Get logs for the related UserMonthlyBills + $userBillIds = $monthlyBill->userBills()->pluck('id'); + + $userBillLogs = Activity::where('subject_type', 'App\Models\UserMonthlyBill') + ->whereIn('subject_id', $userBillIds) + ->with(['causer', 'subject.user']) + ->get(); + + $allLogs = $billLogs->concat($userBillLogs)->sortByDesc('created_at')->values(); + + $formattedLogs = $allLogs->map(function ($log) { + $subjectName = $log->subject_type === MonthlyBill::class + ? 'Monthly Bill' + : 'User Bill (' . ($log->subject->user->name ?? 'Unknown') . ')'; + + return [ + 'id' => $log->id, + 'description' => $log->description, + 'event' => $log->event, + 'subject' => $subjectName, + 'causer' => $log->causer ? $log->causer->name : 'System', + 'properties' => $log->properties, + 'created_at' => $log->created_at, + ]; + }); + + return response()->json([ + 'data' => $formattedLogs, + ]); + } +} diff --git a/backend/app/Http/Controllers/Api/AuthController.php b/backend/app/Http/Controllers/Api/AuthController.php index 377cae1..020c54c 100644 --- a/backend/app/Http/Controllers/Api/AuthController.php +++ b/backend/app/Http/Controllers/Api/AuthController.php @@ -6,7 +6,9 @@ use App\Http\Controllers\Controller; use App\Models\User; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Facades\Password; use Illuminate\Validation\ValidationException; use Illuminate\Support\Facades\Storage; @@ -27,53 +29,50 @@ public function register(Request $request) 'name' => 'required|string|max:255', 'email' => 'required|string|email|max:255|unique:users', 'password' => $passwordRules, - 'role' => 'nullable|string|in:employee,chef,accountant', ]); $user = User::create([ 'name' => $request->name, 'email' => $request->email, 'password' => Hash::make($request->password), - 'role' => $request->role ?? 'employee', + 'role' => 'employee', 'is_active' => true, ]); - $token = $user->createToken('auth_token')->plainTextToken; + Auth::guard('web')->login($user); + $request->session()->regenerate(); return response()->json([ - 'access_token' => $token, - 'token_type' => 'Bearer', 'user' => $user, ]); } public function login(Request $request) { - $request->validate([ + $credentials = $request->validate([ 'email' => 'required|string|email', 'password' => 'required|string', ]); - $user = User::where('email', $request->email)->first(); + if (Auth::guard('web')->attempt($credentials)) { + $request->session()->regenerate(); - if (! $user || ! Hash::check($request->password, $user->password)) { - throw ValidationException::withMessages([ - 'email' => ['The provided credentials are incorrect.'], + return response()->json([ + 'user' => Auth::guard('web')->user(), ]); } - $token = $user->createToken('auth_token')->plainTextToken; - - return response()->json([ - 'access_token' => $token, - 'token_type' => 'Bearer', - 'user' => $user, + throw ValidationException::withMessages([ + 'email' => ['The provided credentials are incorrect.'], ]); } public function logout(Request $request) { - $request->user()->currentAccessToken()->delete(); + Auth::guard('web')->logout(); + + $request->session()->invalidate(); + $request->session()->regenerateToken(); return response()->json([ 'message' => 'Successfully logged out', @@ -125,4 +124,41 @@ public function updateProfile(Request $request) 'user' => $user->fresh(), ]); } + + public function forgotPassword(Request $request) + { + $request->validate(['email' => 'required|email']); + + $status = Password::broker()->sendResetLink( + $request->only('email') + ); + + return $status === Password::RESET_LINK_SENT + ? response()->json(['message' => __($status)]) + : response()->json(['message' => __($status)], 400); + } + + public function resetPassword(Request $request) + { + $request->validate([ + 'token' => 'required', + 'email' => 'required|email', + 'password' => 'required|min:8|confirmed', + ]); + + $status = Password::broker()->reset( + $request->only('email', 'password', 'password_confirmation', 'token'), + function ($user, $password) { + $user->forceFill([ + 'password' => Hash::make($password) + ])->setRememberToken(\Illuminate\Support\Str::random(60)); + + $user->save(); + } + ); + + return $status === Password::PASSWORD_RESET + ? response()->json(['message' => __($status)]) + : response()->json(['message' => __($status)], 400); + } } diff --git a/backend/app/Models/MonthlyBill.php b/backend/app/Models/MonthlyBill.php index 15cf09d..e3f8fed 100644 --- a/backend/app/Models/MonthlyBill.php +++ b/backend/app/Models/MonthlyBill.php @@ -6,9 +6,12 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Support\Facades\Storage; +use Spatie\Activitylog\LogOptions; +use Spatie\Activitylog\Traits\LogsActivity; class MonthlyBill extends Model { + use LogsActivity; protected $fillable = [ 'month', 'year', @@ -21,6 +24,14 @@ class MonthlyBill extends Model 'status', ]; + public function getActivitylogOptions(): LogOptions + { + return LogOptions::defaults() + ->logFillable() + ->logOnlyDirty() + ->dontSubmitEmptyLogs(); + } + protected function casts(): array { return [ diff --git a/backend/app/Models/UserMonthlyBill.php b/backend/app/Models/UserMonthlyBill.php index 4714ea4..31507bf 100644 --- a/backend/app/Models/UserMonthlyBill.php +++ b/backend/app/Models/UserMonthlyBill.php @@ -4,9 +4,12 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Spatie\Activitylog\LogOptions; +use Spatie\Activitylog\Traits\LogsActivity; class UserMonthlyBill extends Model { + use LogsActivity; protected $fillable = [ 'monthly_bill_id', 'user_id', @@ -16,6 +19,14 @@ class UserMonthlyBill extends Model 'paid_at', ]; + public function getActivitylogOptions(): LogOptions + { + return LogOptions::defaults() + ->logFillable() + ->logOnlyDirty() + ->dontSubmitEmptyLogs(); + } + protected function casts(): array { return [ diff --git a/backend/app/Providers/AppServiceProvider.php b/backend/app/Providers/AppServiceProvider.php index 73aa2cb..b2939ad 100644 --- a/backend/app/Providers/AppServiceProvider.php +++ b/backend/app/Providers/AppServiceProvider.php @@ -13,6 +13,7 @@ use Illuminate\Support\Facades\Gate; use Illuminate\Support\ServiceProvider; use Illuminate\Validation\Rules\Password; +use Illuminate\Auth\Notifications\ResetPassword; class AppServiceProvider extends ServiceProvider { @@ -36,6 +37,11 @@ public function boot(): void Gate::policy(MonthlyBill::class, MonthlyBillPolicy::class); Gate::policy(UserMonthlyBill::class, UserMonthlyBillPolicy::class); + ResetPassword::createUrlUsing(function ($notifiable, string $token) { + $frontendUrl = env('FRONTEND_URL', 'http://localhost:3000'); + return "{$frontendUrl}/reset-password?token={$token}&email={$notifiable->getEmailForPasswordReset()}"; + }); + $this->configureDefaults(); } diff --git a/backend/bootstrap/app.php b/backend/bootstrap/app.php index a5814e2..67223a6 100644 --- a/backend/bootstrap/app.php +++ b/backend/bootstrap/app.php @@ -28,6 +28,10 @@ $middleware->encryptCookies(except: ['appearance', 'sidebar_state']); + $middleware->api(prepend: [ + \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class, + ]); + $middleware->web(append: [ HandleAppearance::class, HandleInertiaRequests::class, diff --git a/backend/composer.json b/backend/composer.json index 9d9cc07..5ecbc84 100644 --- a/backend/composer.json +++ b/backend/composer.json @@ -13,7 +13,8 @@ "laravel/framework": "^13.0", "laravel/sanctum": "^4.3", "laravel/tinker": "^3.0", - "laravel/wayfinder": "^0.1.14" + "laravel/wayfinder": "^0.1.14", + "spatie/laravel-activitylog": "^4.12" }, "require-dev": { "fakerphp/faker": "^1.24", diff --git a/backend/composer.lock b/backend/composer.lock index f1a3cd2..c72b51a 100644 --- a/backend/composer.lock +++ b/backend/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "88b498cc64cbfcb2a2fee4175ffcd411", + "content-hash": "29ef855598e23f713b68facb02949ab7", "packages": [ { "name": "bacon/bacon-qr-code", @@ -5862,6 +5862,97 @@ ], "time": "2024-05-17T09:06:10+00:00" }, + { + "name": "spatie/laravel-activitylog", + "version": "4.12.3", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-activitylog.git", + "reference": "2a2024fcac05628b0d1bfdbb1b94dda8b0661dc0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-activitylog/zipball/2a2024fcac05628b0d1bfdbb1b94dda8b0661dc0", + "reference": "2a2024fcac05628b0d1bfdbb1b94dda8b0661dc0", + "shasum": "" + }, + "require": { + "illuminate/config": "^8.0 || ^9.0 || ^10.0 || ^11.0 || ^12.0 || ^13.0", + "illuminate/database": "^8.69 || ^9.27 || ^10.0 || ^11.0 || ^12.0 || ^13.0", + "illuminate/support": "^8.0 || ^9.0 || ^10.0 || ^11.0 || ^12.0 || ^13.0", + "php": "^8.1", + "spatie/laravel-package-tools": "^1.6.3" + }, + "require-dev": { + "ext-json": "*", + "orchestra/testbench": "^6.23 || ^7.0 || ^8.0 || ^9.6 || ^10.0 || ^11.0", + "pestphp/pest": "^1.20 || ^2.0 || ^3.0 || ^4.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Spatie\\Activitylog\\ActivitylogServiceProvider" + ] + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Spatie\\Activitylog\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + }, + { + "name": "Sebastian De Deyne", + "email": "sebastian@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + }, + { + "name": "Tom Witkowski", + "email": "dev.gummibeer@gmail.com", + "homepage": "https://gummibeer.de", + "role": "Developer" + } + ], + "description": "A very simple activity logger to monitor the users of your website or application", + "homepage": "https://github.com/spatie/activitylog", + "keywords": [ + "activity", + "laravel", + "log", + "spatie", + "user" + ], + "support": { + "issues": "https://github.com/spatie/laravel-activitylog/issues", + "source": "https://github.com/spatie/laravel-activitylog/tree/4.12.3" + }, + "funding": [ + { + "url": "https://spatie.be/open-source/support-us", + "type": "custom" + }, + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2026-03-24T12:33:53+00:00" + }, { "name": "spatie/laravel-package-tools", "version": "1.93.1", diff --git a/backend/database/migrations/2026_08_31_164940_create_activity_log_table.php b/backend/database/migrations/2026_08_31_164940_create_activity_log_table.php new file mode 100644 index 0000000..7c05bc8 --- /dev/null +++ b/backend/database/migrations/2026_08_31_164940_create_activity_log_table.php @@ -0,0 +1,27 @@ +create(config('activitylog.table_name'), function (Blueprint $table) { + $table->bigIncrements('id'); + $table->string('log_name')->nullable(); + $table->text('description'); + $table->nullableMorphs('subject', 'subject'); + $table->nullableMorphs('causer', 'causer'); + $table->json('properties')->nullable(); + $table->timestamps(); + $table->index('log_name'); + }); + } + + public function down() + { + Schema::connection(config('activitylog.database_connection'))->dropIfExists(config('activitylog.table_name')); + } +} diff --git a/backend/database/migrations/2026_08_31_164941_add_event_column_to_activity_log_table.php b/backend/database/migrations/2026_08_31_164941_add_event_column_to_activity_log_table.php new file mode 100644 index 0000000..7b797fd --- /dev/null +++ b/backend/database/migrations/2026_08_31_164941_add_event_column_to_activity_log_table.php @@ -0,0 +1,22 @@ +table(config('activitylog.table_name'), function (Blueprint $table) { + $table->string('event')->nullable()->after('subject_type'); + }); + } + + public function down() + { + Schema::connection(config('activitylog.database_connection'))->table(config('activitylog.table_name'), function (Blueprint $table) { + $table->dropColumn('event'); + }); + } +} diff --git a/backend/database/migrations/2026_08_31_164942_add_batch_uuid_column_to_activity_log_table.php b/backend/database/migrations/2026_08_31_164942_add_batch_uuid_column_to_activity_log_table.php new file mode 100644 index 0000000..8f7db66 --- /dev/null +++ b/backend/database/migrations/2026_08_31_164942_add_batch_uuid_column_to_activity_log_table.php @@ -0,0 +1,22 @@ +table(config('activitylog.table_name'), function (Blueprint $table) { + $table->uuid('batch_uuid')->nullable()->after('properties'); + }); + } + + public function down() + { + Schema::connection(config('activitylog.database_connection'))->table(config('activitylog.table_name'), function (Blueprint $table) { + $table->dropColumn('batch_uuid'); + }); + } +} diff --git a/backend/routes/api.php b/backend/routes/api.php index b4ddc0b..ac29a43 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -22,6 +22,12 @@ Route::post('/register', [AuthController::class, 'register']) ->middleware('throttle:5,1'); + Route::post('/forgot-password', [AuthController::class, 'forgotPassword']) + ->middleware('throttle:5,1'); + + Route::post('/reset-password', [AuthController::class, 'resetPassword']) + ->middleware('throttle:5,1'); + /* |-------------------------------------------------------------------------- | Public Routes @@ -36,7 +42,7 @@ |-------------------------------------------------------------------------- */ - Route::middleware('auth:sanctum')->group(function () { + Route::middleware(['auth:sanctum', 'throttle:60,1'])->group(function () { // Auth User Route::get('/user', [AuthController::class, 'user']); @@ -63,7 +69,7 @@ |-------------------------------------------------------------------------- */ - Route::post('/poll', [LunchController::class, 'poll']); + Route::post('/poll', [LunchController::class, 'poll'])->middleware('throttle:10,1'); /* |-------------------------------------------------------------------------- @@ -77,7 +83,7 @@ Route::get('/user/stats', [LunchController::class, 'userStats']); Route::get('/today-poll', [LunchController::class, 'todayPoll']); Route::get('/calendar-poll', [LunchController::class, 'calendarPoll']); - Route::post('/calendar-poll/batch', [LunchController::class, 'batchPoll']); + Route::post('/calendar-poll/batch', [LunchController::class, 'batchPoll'])->middleware('throttle:10,1'); /* |-------------------------------------------------------------------------- @@ -118,6 +124,7 @@ Route::get('/monthly-bills', [MonthlyBillController::class, 'index']); Route::post('/monthly-bills', [MonthlyBillController::class, 'store']); Route::get('/monthly-bills/{monthlyBill}', [MonthlyBillController::class, 'show']); + Route::get('/monthly-bills/{monthlyBill}/logs', [\App\Http\Controllers\Api\AuditLogController::class, 'monthlyBillLogs']); Route::patch('/monthly-bills/{monthlyBill}/user-bills/{userMonthlyBill}', [MonthlyBillController::class, 'updatePayment']); Route::delete('/monthly-bills/{monthlyBill}', [MonthlyBillController::class, 'destroy']); @@ -128,9 +135,9 @@ */ Route::get('/suggestions', [\App\Http\Controllers\Api\SuggestionController::class, 'index']); - Route::post('/suggestions', [\App\Http\Controllers\Api\SuggestionController::class, 'store']); - Route::post('/suggestions/{id}/vote', [\App\Http\Controllers\Api\SuggestionController::class, 'toggleVote']); - Route::patch('/suggestions/{id}/status', [\App\Http\Controllers\Api\SuggestionController::class, 'updateStatus']); + Route::post('/suggestions', [\App\Http\Controllers\Api\SuggestionController::class, 'store'])->middleware('throttle:10,1'); + Route::post('/suggestions/{id}/vote', [\App\Http\Controllers\Api\SuggestionController::class, 'toggleVote'])->middleware('throttle:30,1'); + Route::patch('/suggestions/{id}/status', [\App\Http\Controllers\Api\SuggestionController::class, 'updateStatus'])->middleware('throttle:10,1'); Route::delete('/suggestions/{id}', [\App\Http\Controllers\Api\SuggestionController::class, 'destroy']); }); }); diff --git a/frontend/app/dashboard/page.tsx b/frontend/app/dashboard/page.tsx index 738b99d..feea8c1 100644 --- a/frontend/app/dashboard/page.tsx +++ b/frontend/app/dashboard/page.tsx @@ -162,7 +162,7 @@ export default function ChefDashboard() { } } - const token = localStorage.getItem('token'); + const token = localStorage.getItem('user'); if (!token) { router.push('/login'); return; @@ -236,12 +236,13 @@ export default function ChefDashboard() { const status = choice === 'yes' ? 'opted_in' : 'opted_out'; try { - const token = localStorage.getItem('token'); + const token = localStorage.getItem('user'); const res = await fetch(apiUrl('/v1/poll'), { method: 'POST', + credentials: 'include', headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}` + ...authHeaders(), + 'Content-Type': 'application/json' }, body: JSON.stringify({ lunch_day_id: todayPoll.lunch_day_id, @@ -298,11 +299,12 @@ export default function ChefDashboard() { if (!broadcastMessage.trim()) return; setIsBroadcasting(true); try { - const token = localStorage.getItem('token'); + const token = localStorage.getItem('user'); const res = await fetch(apiUrl('/v1/broadcasts'), { method: 'POST', headers: { - ...authHeaders(), + credentials: 'include', + ...authHeaders(), 'Content-Type': 'application/json', }, body: JSON.stringify({ message: broadcastMessage, type: 'info' }) @@ -347,7 +349,6 @@ export default function ChefDashboard() {
{ - localStorage.removeItem('token'); localStorage.removeItem('user'); router.push('/'); }} onNavigateHome={() => router.push('/')} /> diff --git a/frontend/app/forgot-password/page.tsx b/frontend/app/forgot-password/page.tsx new file mode 100644 index 0000000..fd86dbd --- /dev/null +++ b/frontend/app/forgot-password/page.tsx @@ -0,0 +1,130 @@ +"use client"; + +import React, { useState } from "react"; +import { useRouter } from "next/navigation"; +import { motion } from "framer-motion"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { ArrowLeft, Mail, AlertCircle, CheckCircle2 } from "lucide-react"; +import { api, getCsrfCookie } from "@/lib/api"; +import { useLanguage, LanguageSwitcher } from "@/components/providers/language-provider"; +import { ThemeSwitcher } from "@/components/providers/theme-provider"; + +export default function ForgotPasswordPage() { + const router = useRouter(); + const { t } = useLanguage(); + const [loading, setLoading] = useState(false); + const [email, setEmail] = useState(""); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(null); + setSuccess(null); + + try { + await getCsrfCookie(); + const response = await api.post("/v1/forgot-password", { email }); + setSuccess(response.data.message || "Reset link sent to your email."); + } catch (err: any) { + console.error("Forgot password error:", err); + setError( + err.response?.data?.message || + "An error occurred. Please check your email and try again." + ); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+ + +
+ + + + +
+

+ Forgot Password +

+

+ Enter your email address and we will send you a link to reset your password. +

+
+ + {error && ( + + + {error} + + )} + + {success && ( + + + {success} + + )} + +
+
+ +
+ + setEmail(e.target.value)} + /> +
+
+ + +
+
+
+
+ ); +} diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx index 1bf9d32..99a6471 100644 --- a/frontend/app/login/page.tsx +++ b/frontend/app/login/page.tsx @@ -48,7 +48,6 @@ export default function AuthPage() { }); const { access_token, user } = response.data; - localStorage.setItem("token", access_token); localStorage.setItem("user", JSON.stringify(user)); if (user.role === 'chef') { @@ -65,7 +64,6 @@ export default function AuthPage() { }); const { access_token, user } = response.data; - localStorage.setItem("token", access_token); localStorage.setItem("user", JSON.stringify(user)); if (user.role === 'chef') { @@ -248,7 +246,7 @@ export default function AuthPage() {
- +
diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 90c5f5a..5ccee3a 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -55,7 +55,6 @@ export default function HomePage() { }, []); const handleLogout = () => { - localStorage.removeItem('token'); localStorage.removeItem('user'); setUser(null); router.refresh(); diff --git a/frontend/app/profile/page.tsx b/frontend/app/profile/page.tsx index e0b671c..7028adb 100644 --- a/frontend/app/profile/page.tsx +++ b/frontend/app/profile/page.tsx @@ -37,7 +37,7 @@ export default function ProfilePage() { const fileInputRef = useRef(null); useEffect(() => { - const token = localStorage.getItem('token'); + const token = localStorage.getItem('user'); const savedUser = localStorage.getItem('user'); if (!token || !savedUser) { @@ -86,7 +86,7 @@ export default function ProfilePage() { setSaving(true); try { - const token = localStorage.getItem('token'); + const token = localStorage.getItem('user'); const formData = new FormData(); formData.append('name', name); formData.append('name_bo', nameBo); @@ -100,6 +100,7 @@ export default function ProfilePage() { const res = await fetch(apiUrl('/v1/user/profile'), { method: 'POST', + credentials: 'include', headers: authHeaders(true), body: formData }); @@ -149,7 +150,6 @@ export default function ProfilePage() {
{ - localStorage.removeItem('token'); localStorage.removeItem('user'); router.push('/'); }} onNavigateHome={() => router.push('/')} /> diff --git a/frontend/app/reset-password/page.tsx b/frontend/app/reset-password/page.tsx new file mode 100644 index 0000000..a5f8804 --- /dev/null +++ b/frontend/app/reset-password/page.tsx @@ -0,0 +1,199 @@ +"use client"; + +import React, { useState, useEffect, Suspense } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { motion } from "framer-motion"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Lock, AlertCircle, CheckCircle2, EyeOff, Eye } from "lucide-react"; +import { api, getCsrfCookie } from "@/lib/api"; +import { LanguageSwitcher } from "@/components/providers/language-provider"; +import { ThemeSwitcher } from "@/components/providers/theme-provider"; + +function ResetPasswordForm() { + const router = useRouter(); + const searchParams = useSearchParams(); + const token = searchParams.get("token"); + const emailParam = searchParams.get("email"); + + const [loading, setLoading] = useState(false); + const [email, setEmail] = useState(emailParam || ""); + const [password, setPassword] = useState(""); + const [passwordConfirmation, setPasswordConfirmation] = useState(""); + const [showPassword, setShowPassword] = useState(false); + + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + useEffect(() => { + if (emailParam) { + setEmail(emailParam); + } + }, [emailParam]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(null); + setSuccess(null); + + if (password !== passwordConfirmation) { + setError("Passwords do not match."); + setLoading(false); + return; + } + + if (!token) { + setError("Invalid reset token."); + setLoading(false); + return; + } + + try { + await getCsrfCookie(); + const response = await api.post("/v1/reset-password", { + email, + password, + password_confirmation: passwordConfirmation, + token + }); + setSuccess(response.data.message || "Password successfully reset. Redirecting to login..."); + setTimeout(() => { + router.push("/login"); + }, 3000); + } catch (err: any) { + console.error("Reset password error:", err); + setError( + err.response?.data?.message || + "An error occurred. Please try again." + ); + } finally { + setLoading(false); + } + }; + + return ( + +
+

+ Reset Password +

+

+ Create a new password for your account. +

+
+ + {error && ( + + + {error} + + )} + + {success && ( + + + {success} + + )} + +
+
+ +
+ +
+
+ +
+ +
+ + setPassword(e.target.value)} + /> + +
+
+ +
+ +
+ + setPasswordConfirmation(e.target.value)} + /> +
+
+ + +
+
+ ); +} + +export default function ResetPasswordPage() { + return ( +
+
+
+ + +
+ + }> + + +
+
+ ); +} diff --git a/frontend/app/suggestions/page.tsx b/frontend/app/suggestions/page.tsx index f69073c..c822b0e 100644 --- a/frontend/app/suggestions/page.tsx +++ b/frontend/app/suggestions/page.tsx @@ -6,6 +6,7 @@ import Header from '@/components/header'; import { MessageSquare, ThumbsUp, Plus, ChefHat, Check, X, Award } from 'lucide-react'; import { motion, AnimatePresence } from 'framer-motion'; import { useRouter } from 'next/navigation'; +import { apiUrl, authHeaders } from '@/lib/api-url'; export default function SuggestionsPage() { const { t } = useLanguage(); @@ -32,9 +33,10 @@ export default function SuggestionsPage() { const fetchSuggestions = async () => { try { - const token = localStorage.getItem('token'); - const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/v1/suggestions`, { - headers: { 'Authorization': `Bearer ${token}` } + const token = localStorage.getItem('user'); + const res = await fetch(apiUrl('/v1/suggestions'), { + credentials: 'include', + headers: authHeaders() }); if (res.ok) { const data = await res.json(); @@ -49,7 +51,7 @@ export default function SuggestionsPage() { const handleUpvote = async (id: number) => { try { - const token = localStorage.getItem('token'); + const token = localStorage.getItem('user'); // Optimistic update setSuggestions(prev => prev.map(s => { if (s.id === id) { @@ -63,9 +65,10 @@ export default function SuggestionsPage() { return s; })); - const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/v1/suggestions/${id}/vote`, { + const res = await fetch(apiUrl(`/v1/suggestions/${id}/vote`), { method: 'POST', - headers: { 'Authorization': `Bearer ${token}` } + credentials: 'include', + headers: authHeaders() }); if (!res.ok) { // Revert on error @@ -79,11 +82,12 @@ export default function SuggestionsPage() { const handleStatusChange = async (id: number, status: string) => { try { - const token = localStorage.getItem('token'); - const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/v1/suggestions/${id}/status`, { + const token = localStorage.getItem('user'); + const res = await fetch(apiUrl(`/v1/suggestions/${id}/status`), { method: 'PATCH', + credentials: 'include', headers: { - 'Authorization': `Bearer ${token}`, + ...authHeaders(), 'Content-Type': 'application/json' }, body: JSON.stringify({ status }) @@ -99,12 +103,11 @@ export default function SuggestionsPage() { const handleDelete = async (id: number) => { if (!confirm('Are you sure you want to delete this suggestion?')) return; try { - const token = localStorage.getItem('token'); - const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/v1/suggestions/${id}`, { + const token = localStorage.getItem('user'); + const res = await fetch(apiUrl(`/v1/suggestions/${id}`), { method: 'DELETE', - headers: { - 'Authorization': `Bearer ${token}` - } + credentials: 'include', + headers: authHeaders() }); if (res.ok) { setSuggestions(prev => prev.filter(s => s.id !== id)); @@ -118,11 +121,12 @@ export default function SuggestionsPage() { e.preventDefault(); setIsSubmitting(true); try { - const token = localStorage.getItem('token'); - const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/v1/suggestions`, { + const token = localStorage.getItem('user'); + const res = await fetch(apiUrl('/v1/suggestions'), { method: 'POST', + credentials: 'include', headers: { - 'Authorization': `Bearer ${token}`, + ...authHeaders(), 'Content-Type': 'application/json' }, body: JSON.stringify({ title, description }) diff --git a/frontend/app/user_dashboard/billing/page.tsx b/frontend/app/user_dashboard/billing/page.tsx index 6895235..a2551ef 100644 --- a/frontend/app/user_dashboard/billing/page.tsx +++ b/frontend/app/user_dashboard/billing/page.tsx @@ -15,7 +15,7 @@ export default function BillingPage() { const [loading, setLoading] = useState(true); useEffect(() => { - const token = localStorage.getItem('token'); + const token = localStorage.getItem('user'); if (!token) { router.push('/login'); return; @@ -43,7 +43,6 @@ export default function BillingPage() { return (
{ - localStorage.removeItem('token'); localStorage.removeItem('user'); router.push('/'); }} onNavigateHome={() => router.push('/')} /> diff --git a/frontend/app/user_dashboard/history/page.tsx b/frontend/app/user_dashboard/history/page.tsx index 4f45fa8..261a367 100644 --- a/frontend/app/user_dashboard/history/page.tsx +++ b/frontend/app/user_dashboard/history/page.tsx @@ -27,7 +27,7 @@ export default function ActivityHistoryPage() { useEffect(() => { const fetchHistory = async () => { try { - const token = localStorage.getItem('token'); + const token = localStorage.getItem('user'); // Check if user is logged in if (!token) { router.push('/login'); @@ -35,6 +35,7 @@ export default function ActivityHistoryPage() { } const res = await fetch(apiUrl('/v1/user/activity'), { + credentials: 'include', headers: authHeaders() }); diff --git a/frontend/app/user_dashboard/page.tsx b/frontend/app/user_dashboard/page.tsx index 7441f11..942b1d4 100644 --- a/frontend/app/user_dashboard/page.tsx +++ b/frontend/app/user_dashboard/page.tsx @@ -82,6 +82,7 @@ export default function UserDashboard() { const fetchActivity = async () => { try { const res = await fetch(apiUrl('/v1/user/activity?limit=3'), { + credentials: 'include', headers: authHeaders() }); if (res.ok) { @@ -98,6 +99,7 @@ export default function UserDashboard() { const fetchStats = async () => { try { const res = await fetch(apiUrl('/v1/user/stats'), { + credentials: 'include', headers: authHeaders() }); if (res.ok) { @@ -130,7 +132,6 @@ export default function UserDashboard() {
{ - localStorage.removeItem('token'); localStorage.removeItem('user'); router.push('/'); }} onNavigateHome={() => router.push('/')} /> diff --git a/frontend/app/vote/page.tsx b/frontend/app/vote/page.tsx index d3abe0a..e42b989 100644 --- a/frontend/app/vote/page.tsx +++ b/frontend/app/vote/page.tsx @@ -87,16 +87,16 @@ export default function LunchVotePage() { const fetchCalendar = async (year: number, month: number) => { setIsCalendarLoading(true); try { - const token = localStorage.getItem('token'); + const token = localStorage.getItem('user'); if (!token) return; const res = await fetch(apiUrl(`/v1/calendar-poll?year=${year}&month=${month}`), { - headers: authHeaders() + credentials: 'include', + headers: authHeaders() }); if (res.ok) { const data = await res.json(); setCalendarDays(data.days); } else if (res.status === 401) { - localStorage.removeItem('token'); localStorage.removeItem('user'); router.push('/login'); } @@ -121,11 +121,12 @@ export default function LunchVotePage() { setCalendarDays(prev => prev.map(d => d.date === day.date ? { ...d, status: nextStatus } : d)); try { - const token = localStorage.getItem('token'); + const token = localStorage.getItem('user'); const res = await fetch(apiUrl('/v1/calendar-poll/batch'), { method: 'POST', headers: { 'Content-Type': 'application/json', + credentials: 'include', ...authHeaders() }, body: JSON.stringify({ @@ -157,11 +158,12 @@ export default function LunchVotePage() { setCalendarDays(prev => prev.map(d => dates.includes(d.date) ? { ...d, status } : d)); try { - const token = localStorage.getItem('token'); + const token = localStorage.getItem('user'); const res = await fetch(apiUrl('/v1/calendar-poll/batch'), { method: 'POST', headers: { 'Content-Type': 'application/json', + credentials: 'include', ...authHeaders() }, body: JSON.stringify({ @@ -283,11 +285,12 @@ export default function LunchVotePage() { // Fetch Today's Poll Status const fetchTodayPoll = async () => { try { - const token = localStorage.getItem('token'); + const token = localStorage.getItem('user'); if (!token) return; // Wait for auth check to redirect const res = await fetch(apiUrl('/v1/today-poll'), { - headers: authHeaders() + credentials: 'include', + headers: authHeaders() }); if (res.ok) { @@ -308,7 +311,6 @@ export default function LunchVotePage() { setIsDeadlineMet(true); } } else if (res.status === 401) { - localStorage.removeItem('token'); localStorage.removeItem('user'); router.push('/login'); } @@ -324,7 +326,7 @@ export default function LunchVotePage() { }, []); useEffect(() => { - const token = localStorage.getItem('token'); + const token = localStorage.getItem('user'); if (!token) { router.push('/login'); return; @@ -347,13 +349,14 @@ export default function LunchVotePage() { setAttendance(choice); // Optimistic update try { - const token = localStorage.getItem('token'); + const token = localStorage.getItem('user'); const status = choice === 'yes' ? 'opted_in' : 'opted_out'; const res = await fetch(apiUrl('/v1/poll'), { method: 'POST', headers: { 'Content-Type': 'application/json', + credentials: 'include', ...authHeaders() }, body: JSON.stringify({ @@ -378,7 +381,6 @@ export default function LunchVotePage() {
{ - localStorage.removeItem('token'); localStorage.removeItem('user'); setUser(null); router.push('/'); diff --git a/frontend/components/billing/accountant-billing-panel.tsx b/frontend/components/billing/accountant-billing-panel.tsx index f9c77aa..f4e03ab 100644 --- a/frontend/components/billing/accountant-billing-panel.tsx +++ b/frontend/components/billing/accountant-billing-panel.tsx @@ -28,6 +28,7 @@ import { PaymentStatus, updateUserBillPayment, UserMonthlyBill, + authHeaders, } from '@/lib/billing'; import { useToast } from '@/components/providers/toast-provider'; import { useLanguage, toTibetanDigits } from '@/components/providers/language-provider'; @@ -209,7 +210,7 @@ export default function AccountantBillingPanel() { setReportLoading(true); setReportData(null); try { - const token = localStorage.getItem('token'); + const token = localStorage.getItem('user'); if (!token) return; let url = `${process.env.NEXT_PUBLIC_API_URL || ''}/v1/accountant/participation-report?type=${participationView}`; @@ -220,10 +221,11 @@ export default function AccountantBillingPanel() { } const res = await fetch(url, { + credentials: 'include', headers: { - 'Authorization': `Bearer ${token}`, - 'Accept': 'application/json' - } + ...authHeaders(), + 'Accept': 'application/json', + }, }); if (res.ok) { diff --git a/frontend/components/billing/user-billing-panel.tsx b/frontend/components/billing/user-billing-panel.tsx index b7e299b..ab76dac 100644 --- a/frontend/components/billing/user-billing-panel.tsx +++ b/frontend/components/billing/user-billing-panel.tsx @@ -85,7 +85,7 @@ export default function UserBillingPanel() { }; useEffect(() => { - const token = localStorage.getItem('token'); + const token = localStorage.getItem('user'); if (!token) { setLoading(false); return; diff --git a/frontend/components/header.tsx b/frontend/components/header.tsx index 50647ca..9a9a5ce 100644 --- a/frontend/components/header.tsx +++ b/frontend/components/header.tsx @@ -16,6 +16,7 @@ import { useRouter, usePathname } from 'next/navigation'; import { motion, AnimatePresence } from 'framer-motion'; import { useLanguage, LanguageSwitcher } from '@/components/providers/language-provider'; import { ThemeSwitcher } from '@/components/providers/theme-provider'; +import { apiUrl, authHeaders } from '@/lib/api-url'; interface HeaderProps { user?: any; @@ -64,10 +65,11 @@ export default function Header({ user, onLogout, onNavigateHome }: HeaderProps) useEffect(() => { const fetchBroadcasts = async () => { try { - const token = localStorage.getItem('token'); + const token = localStorage.getItem('user'); if (!token) return; - const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || ''}/v1/broadcasts`, { - headers: { 'Authorization': `Bearer ${token}` } + const res = await fetch(apiUrl('/v1/broadcasts'), { + credentials: 'include', + headers: authHeaders() }); if (res.ok) { const data = await res.json(); @@ -90,12 +92,13 @@ export default function Header({ user, onLogout, onNavigateHome }: HeaderProps) const handleMarkAsRead = async (e: React.MouseEvent, id: number) => { e.stopPropagation(); try { - const token = localStorage.getItem('token'); + const token = localStorage.getItem('user'); setBroadcasts(prev => prev.map(b => b.id === id ? { ...b, is_read: true } : b)); - await fetch(`${process.env.NEXT_PUBLIC_API_URL || ''}/v1/broadcasts/${id}/read`, { + await fetch(apiUrl(`/v1/broadcasts/${id}/read`), { method: 'POST', - headers: { 'Authorization': `Bearer ${token}` } + credentials: 'include', + headers: authHeaders() }); } catch (err) { console.error("Failed to mark as read", err); @@ -139,7 +142,6 @@ export default function Header({ user, onLogout, onNavigateHome }: HeaderProps) }, [currentUser]); const handleLogout = () => { - localStorage.removeItem('token'); localStorage.removeItem('user'); setCurrentUser(null); setIsSidebarOpen(false); diff --git a/frontend/lib/api-url.ts b/frontend/lib/api-url.ts index 5733047..acdf664 100644 --- a/frontend/lib/api-url.ts +++ b/frontend/lib/api-url.ts @@ -6,14 +6,25 @@ export function apiUrl(path: string): string { return `${base}${normalized}`; } +export function getXsrfToken(): string | null { + if (typeof window === 'undefined') return null; + const match = document.cookie.match(new RegExp('(^| )XSRF-TOKEN=([^;]+)')); + if (match) { + return decodeURIComponent(match[2]); + } + return null; +} + export function authHeaders(json = true): HeadersInit { - const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null; const headers: Record = {}; if (json) { headers['Accept'] = 'application/json'; } + + const token = getXsrfToken(); if (token) { - headers['Authorization'] = `Bearer ${token}`; + headers['X-XSRF-TOKEN'] = token; } + return headers; } diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index adae005..54cf64a 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -8,14 +8,7 @@ export const api = axios.create({ }, }); -api.interceptors.request.use((config) => { - const token = typeof window !== "undefined" ? localStorage.getItem("token") : null; - if (token) { - config.headers.Authorization = `Bearer ${token}`; - } - return config; -}); - +// Interceptors removed in favor of stateful cookie auth export async function getCsrfCookie() { const baseUrl = process.env.NEXT_PUBLIC_API_URL?.replace("/api", ""); await axios.get(`${baseUrl}/sanctum/csrf-cookie`, { withCredentials: true }); diff --git a/frontend/lib/billing.ts b/frontend/lib/billing.ts index 48405fa..6287f2c 100644 --- a/frontend/lib/billing.ts +++ b/frontend/lib/billing.ts @@ -77,14 +77,30 @@ export function monthLabel(month: number, year: number): string { }); } +export function getXsrfToken(): string | null { + if (typeof window === 'undefined') return null; + const match = document.cookie.match(new RegExp('(^| )XSRF-TOKEN=([^;]+)')); + if (match) { + return decodeURIComponent(match[2]); + } + return null; +} + export function authHeaders(): HeadersInit { - const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null; - return token ? { Authorization: `Bearer ${token}` } : {}; + const headers: Record = { Accept: 'application/json' }; + + const token = getXsrfToken(); + if (token) { + headers['X-XSRF-TOKEN'] = token; + } + + return headers; } export async function fetchMonthlyBill(id: number): Promise { const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || ''}/v1/monthly-bills/${id}`, { - headers: authHeaders(), + credentials: 'include', + headers: authHeaders(), }); if (!res.ok) { const err = await res.json().catch(() => ({})); @@ -100,7 +116,8 @@ export async function fetchMonthlyBills(month?: number, year?: number): Promise< if (year) params.set('year', String(year)); const res = await fetch( `${process.env.NEXT_PUBLIC_API_URL || ''}/v1/monthly-bills?${params.toString()}`, - { headers: authHeaders() } + { credentials: 'include', + headers: authHeaders() } ); if (!res.ok) throw new Error('Failed to load monthly bills'); const json = await res.json(); @@ -110,7 +127,8 @@ export async function fetchMonthlyBills(month?: number, year?: number): Promise< export async function createMonthlyBill(formData: FormData): Promise { const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || ''}/v1/monthly-bills`, { method: 'POST', - headers: authHeaders(), + credentials: 'include', + headers: authHeaders(), body: formData, }); const json = await res.json().catch(() => ({})); @@ -127,8 +145,9 @@ export async function updateUserBillPayment( `${process.env.NEXT_PUBLIC_API_URL || ''}/v1/monthly-bills/${monthlyBillId}/user-bills/${userBillId}`, { method: 'PATCH', + credentials: 'include', headers: { - ...authHeaders(), + ...authHeaders(), 'Content-Type': 'application/json', Accept: 'application/json', }, @@ -147,12 +166,13 @@ const emptyUserBilling: UserBillingResponse = { }; export async function fetchUserBilling(): Promise { - const headers = authHeaders(); - if (!('Authorization' in headers)) { + if (typeof window !== 'undefined' && !localStorage.getItem('user')) { return emptyUserBilling; } + const headers = authHeaders(); const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || ''}/v1/user/monthly-bills`, { + credentials: 'include', headers, }); @@ -175,7 +195,8 @@ export async function fetchUserBilling(): Promise { export async function deleteMonthlyBill(id: number): Promise { const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL || ''}/v1/monthly-bills/${id}`, { method: 'DELETE', - headers: authHeaders(), + credentials: 'include', + headers: authHeaders(), }); if (!res.ok) { const err = await res.json().catch(() => ({}));