From cf4435580da0bf85d96c78b8c41fd4dbc60a94bb Mon Sep 17 00:00:00 2001 From: micko samawa Date: Fri, 12 Jun 2026 22:46:44 +0700 Subject: [PATCH] final update web and mobile --- .../MigratePaymentProofsToPrivate.php | 111 +++++++++ spk_kontrakan/app/Console/Kernel.php | 1 + .../Controllers/ActivityLogController.php | 72 +++--- .../Controllers/AdminProfileController.php | 2 +- .../Http/Controllers/Api/AuthController.php | 6 +- .../Controllers/Api/BookingController.php | 83 ++++++- .../Http/Controllers/Api/SAWController.php | 4 +- .../Http/Controllers/BookingController.php | 57 +++++ .../app/Http/Controllers/SAWController.php | 2 +- .../app/Http/Requests/RegisterRequest.php | 6 +- spk_kontrakan/app/Models/ActivityLog.php | 8 +- spk_kontrakan/app/Models/Admin.php | 8 + spk_kontrakan/config/filesystems.php | 9 + ...01_make_activity_logs_user_id_nullable.php | 50 ++++ .../views/admin/bookings/index.blade.php | 2 +- .../views/admin/bookings/show.blade.php | 6 +- .../views/auth/admin-login.blade.php | 2 +- spk_kontrakan/routes/api.php | 10 +- spk_kontrakan/routes/web.php | 13 +- .../tests/Feature/AdminSecurityRoutesTest.php | 16 +- spk_mobile/lib/config/app_config.dart | 89 ++++--- spk_mobile/lib/config/environment.dart | 4 +- spk_mobile/lib/main.dart | 19 +- spk_mobile/lib/models/kontrakan.dart | 48 ++-- spk_mobile/lib/register.dart | 13 +- .../lib/screens/booking_history_screen.dart | 39 +++- spk_mobile/lib/screens/home_screen.dart | 1 - spk_mobile/lib/services/auth_service.dart | 217 +++++++++++++++--- spk_mobile/lib/services/booking_service.dart | 34 +++ .../services/server_discovery_service.dart | 2 +- spk_mobile/lib/widgets/app_placeholder.dart | 1 - spk_mobile/lib/widgets/kontrakan_card.dart | 1 - spk_mobile/lib/widgets/laundry_card.dart | 1 - spk_mobile/pubspec.lock | 64 ++++++ spk_mobile/pubspec.yaml | 3 + spk_mobile/test/widget_test.dart | 17 +- 36 files changed, 839 insertions(+), 182 deletions(-) create mode 100644 spk_kontrakan/app/Console/Commands/MigratePaymentProofsToPrivate.php create mode 100644 spk_kontrakan/database/migrations/2026_06_05_000001_make_activity_logs_user_id_nullable.php diff --git a/spk_kontrakan/app/Console/Commands/MigratePaymentProofsToPrivate.php b/spk_kontrakan/app/Console/Commands/MigratePaymentProofsToPrivate.php new file mode 100644 index 0000000..31532b3 --- /dev/null +++ b/spk_kontrakan/app/Console/Commands/MigratePaymentProofsToPrivate.php @@ -0,0 +1,111 @@ +option('dry-run'); + $limit = $this->option('limit'); + $limit = is_numeric($limit) ? (int) $limit : null; + + $query = Booking::query()->whereNotNull('payment_proof')->orderBy('id'); + if ($limit && $limit > 0) { + $query->limit($limit); + } + + $total = (clone $query)->count(); + if ($total === 0) { + $this->info('No bookings with payment_proof found.'); + return self::SUCCESS; + } + + $migrated = 0; + $skipped = 0; + $missing = 0; + $failed = 0; + + $this->info(($dryRun ? '[DRY RUN] ' : '') . "Processing {$total} booking(s)..."); + + $bar = $this->output->createProgressBar($total); + $bar->start(); + + $query->chunkById(200, function ($bookings) use ($public, $private, $dryRun, &$migrated, &$skipped, &$missing, &$failed, $bar) { + foreach ($bookings as $booking) { + $path = $booking->payment_proof; + if (!$path) { + $skipped++; + $bar->advance(); + continue; + } + + try { + if ($private->exists($path)) { + // Already private + $skipped++; + $bar->advance(); + continue; + } + + if (!$public->exists($path)) { + $missing++; + $bar->advance(); + continue; + } + + if ($dryRun) { + $migrated++; + $bar->advance(); + continue; + } + + $readStream = $public->readStream($path); + if ($readStream === false) { + $failed++; + $bar->advance(); + continue; + } + + $private->writeStream($path, $readStream); + if (is_resource($readStream)) { + fclose($readStream); + } + + $public->delete($path); + $migrated++; + } catch (\Throwable $e) { + $failed++; + } + + $bar->advance(); + } + }); + + $bar->finish(); + $this->newLine(2); + + $this->line('Done.'); + $this->line('Migrated : ' . $migrated); + $this->line('Skipped : ' . $skipped); + $this->line('Missing : ' . $missing); + $this->line('Failed : ' . $failed); + + if ($dryRun) { + $this->comment('Dry-run mode: no files were changed.'); + } + + return $failed > 0 ? self::FAILURE : self::SUCCESS; + } +} diff --git a/spk_kontrakan/app/Console/Kernel.php b/spk_kontrakan/app/Console/Kernel.php index a57fade..69a32b2 100644 --- a/spk_kontrakan/app/Console/Kernel.php +++ b/spk_kontrakan/app/Console/Kernel.php @@ -27,6 +27,7 @@ protected function commands(): void \App\Console\Commands\SyncAdminsFromUsers::class, \App\Console\Commands\DedupeAdmins::class, \App\Console\Commands\AdminSetPassword::class, + \App\Console\Commands\MigratePaymentProofsToPrivate::class, ]); require base_path('routes/console.php'); diff --git a/spk_kontrakan/app/Http/Controllers/ActivityLogController.php b/spk_kontrakan/app/Http/Controllers/ActivityLogController.php index 2d4caf0..315c451 100644 --- a/spk_kontrakan/app/Http/Controllers/ActivityLogController.php +++ b/spk_kontrakan/app/Http/Controllers/ActivityLogController.php @@ -11,7 +11,6 @@ public function index(Request $request) { $query = ActivityLog::with('user'); - // Use enhanced scopes for better performance if ($request->filled('action')) { $query->byAction($request->action); } @@ -28,7 +27,6 @@ public function index(Request $request) $query->recent($request->days); } - // Filter by date range if ($request->filled('date_from')) { $query->whereDate('created_at', '>=', $request->date_from); } @@ -37,9 +35,9 @@ public function index(Request $request) $query->whereDate('created_at', '<=', $request->date_to); } - // Security check for suspicious activity - if (auth()->user()->hasRole('super_admin') && $request->filled('security_check')) { + if (auth()->user()->role === 'super_admin' && $request->filled('security_check')) { $suspiciousUsers = ActivityLog::getSecurityStats()['suspicious_users']; + if (!empty($suspiciousUsers)) { session()->flash('warning', 'Detected suspicious activity from ' . count($suspiciousUsers) . ' users'); } @@ -47,22 +45,21 @@ public function index(Request $request) $logs = $query->orderBy('created_at', 'desc')->paginate(50); - // Get enhanced statistics $stats = ActivityLog::getSummaryStats(); $modelStats = ActivityLog::getModelStats(); - $securityStats = auth()->user()->hasRole('super_admin') ? ActivityLog::getSecurityStats() : []; + $securityStats = auth()->user()->role === 'super_admin' + ? ActivityLog::getSecurityStats() + : []; return view('admin.activity-logs.index', compact('logs', 'stats', 'modelStats', 'securityStats')); } public function show(ActivityLog $activityLog) { - // Load polymorphic relationship $activityLog->load(['user', 'loggable']); - - // Access the polymorphic related model + $relatedModel = $activityLog->loggable; - + return view('admin.activity-logs.show', compact('activityLog', 'relatedModel')); } @@ -88,12 +85,12 @@ public function export(Request $request) $logs = $query->orderBy('created_at', 'desc')->get(); - // Generate CSV $csv = "Tanggal,User,Action,Deskripsi,Model Type,Model ID,IP Address\n"; + foreach ($logs as $log) { $csv .= sprintf( '"%s","%s","%s","%s","%s","%s","%s"' . "\n", - $log->created_at->format('d/m/Y H:i:s'), + $log->created_at ? $log->created_at->format('d/m/Y H:i:s') : '-', $log->user->name ?? 'Unknown', $log->action, $log->description, @@ -110,59 +107,62 @@ public function export(Request $request) public function clear(Request $request) { - if (!auth()->user()->hasRole('super_admin')) { + if (auth()->user()->role !== 'super_admin') { abort(403); } $days = $request->input('days', 30); + $deleted = ActivityLog::where('created_at', '<', now()->subDays($days))->delete(); return redirect()->back()->with('success', "Dihapus $deleted log aktivitas lebih dari $days hari."); } - /** - * Enhanced dashboard with analytics - */ public function dashboard() { + if (auth()->user()->role !== 'super_admin') { + abort(403); + } + $stats = ActivityLog::getSummaryStats(); $modelStats = ActivityLog::getModelStats(); $securityStats = ActivityLog::getSecurityStats(); $topUsers = ActivityLog::getTopUsers(); - // Get activity by time range for charts $dailyActivity = ActivityLog::getActivityByTimeRange( - now()->subDays(30), - now(), + now()->subDays(30), + now(), 'day' ); $hourlyActivity = ActivityLog::getActivityByTimeRange( - now()->subDay(), - now(), + now()->subDay(), + now(), 'hour' ); return view('admin.activity-logs.dashboard', compact( - 'stats', 'modelStats', 'securityStats', 'topUsers', - 'dailyActivity', 'hourlyActivity' + 'stats', + 'modelStats', + 'securityStats', + 'topUsers', + 'dailyActivity', + 'hourlyActivity' )); } - /** - * Archive old logs to files - */ public function archive(Request $request) { - if (!auth()->user()->hasRole('super_admin')) { + if (auth()->user()->role !== 'super_admin') { abort(403); } $keepDays = $request->input('keep_days', 365); + $result = ActivityLog::archiveOldLogs($keepDays); if ($result['archived_count'] > 0) { - return redirect()->back()->with('success', + return redirect()->back()->with('success', "Successfully archived {$result['archived_count']} logs to {$result['archived_file']} and deleted {$result['deleted_count']} records from database." ); } @@ -170,17 +170,14 @@ public function archive(Request $request) return redirect()->back()->with('info', 'No old logs found to archive.'); } - /** - * Security monitoring - */ public function security() { - if (!auth()->user()->hasRole('super_admin')) { + if (auth()->user()->role !== 'super_admin') { abort(403); } $securityStats = ActivityLog::getSecurityStats(); - + $suspiciousActivity = ActivityLog::select('user_id', 'ip_address') ->selectRaw('COUNT(*) as activity_count') ->where('created_at', '>=', now()->subHour()) @@ -196,13 +193,12 @@ public function security() ->get(); return view('admin.activity-logs.security', compact( - 'securityStats', 'suspiciousActivity', 'failedActions' + 'securityStats', + 'suspiciousActivity', + 'failedActions' )); } - /** - * API endpoint for real-time stats - */ public function apiStats() { return response()->json([ @@ -215,4 +211,4 @@ public function apiStats() 'last_updated' => now()->toISOString() ]); } -} +} \ No newline at end of file diff --git a/spk_kontrakan/app/Http/Controllers/AdminProfileController.php b/spk_kontrakan/app/Http/Controllers/AdminProfileController.php index 0aa01ad..9ab60e2 100644 --- a/spk_kontrakan/app/Http/Controllers/AdminProfileController.php +++ b/spk_kontrakan/app/Http/Controllers/AdminProfileController.php @@ -20,7 +20,7 @@ public function update(Request $request) $validated = $request->validate([ 'name' => 'required|string|min:3|max:255', - 'email' => 'required|email|unique:users,email,' . $user->id, + 'email' => 'required|email|unique:admins,email,' . $user->id, 'phone' => 'nullable|string|min:8|max:20', 'password' => 'nullable|string|min:8|confirmed', ], [ diff --git a/spk_kontrakan/app/Http/Controllers/Api/AuthController.php b/spk_kontrakan/app/Http/Controllers/Api/AuthController.php index 8b9ffe0..85002c0 100644 --- a/spk_kontrakan/app/Http/Controllers/Api/AuthController.php +++ b/spk_kontrakan/app/Http/Controllers/Api/AuthController.php @@ -53,7 +53,8 @@ public function register(RegisterRequest $request) ]); return response()->json([ 'success' => false, - 'message' => 'Database error: ' . $e->getMessage(), + // ✅ Do not leak DB details to clients + 'message' => 'Terjadi kesalahan saat registrasi. Silakan coba lagi.', 'error_code' => 'DB_ERROR', ], 500); } catch (\Exception $e) { @@ -65,7 +66,8 @@ public function register(RegisterRequest $request) ]); return response()->json([ 'success' => false, - 'message' => 'Terjadi kesalahan saat registrasi: ' . $e->getMessage(), + // ✅ Do not leak internal exception details to clients + 'message' => 'Terjadi kesalahan saat registrasi. Silakan coba lagi.', 'error_code' => 'REGISTRATION_FAILED', ], 500); } diff --git a/spk_kontrakan/app/Http/Controllers/Api/BookingController.php b/spk_kontrakan/app/Http/Controllers/Api/BookingController.php index 100bd11..db6b68e 100644 --- a/spk_kontrakan/app/Http/Controllers/Api/BookingController.php +++ b/spk_kontrakan/app/Http/Controllers/Api/BookingController.php @@ -7,11 +7,16 @@ use App\Models\Kontrakan; use Carbon\Carbon; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Validator; class BookingController extends Controller { + private const PAYMENT_PROOF_DIR = 'payment_proofs'; + private const PAYMENT_PROOF_PRIVATE_DISK = 'private'; + private const PAYMENT_PROOF_PUBLIC_DISK = 'public'; + /** * List bookings user (history) */ @@ -110,7 +115,8 @@ public function store(Request $request) // Handle payment proof upload if ($request->hasFile('payment_proof')) { - $path = $request->file('payment_proof')->store('payment_proofs', 'public'); + // ✅ Store sensitive payment proof in PRIVATE storage + $path = $request->file('payment_proof')->store(self::PAYMENT_PROOF_DIR, self::PAYMENT_PROOF_PRIVATE_DISK); $bookingData['payment_proof'] = $path; $bookingData['payment_status'] = 'paid'; $bookingData['payment_method'] = 'transfer'; @@ -283,11 +289,14 @@ public function uploadPaymentProof(Request $request, $id) // Hapus bukti lama jika ada if ($booking->payment_proof) { - Storage::disk('public')->delete($booking->payment_proof); + // ✅ Delete from both disks to support migration from old public storage + Storage::disk(self::PAYMENT_PROOF_PUBLIC_DISK)->delete($booking->payment_proof); + Storage::disk(self::PAYMENT_PROOF_PRIVATE_DISK)->delete($booking->payment_proof); } // Simpan gambar baru - $path = $request->file('payment_proof')->store('payment_proofs', 'public'); + // ✅ Store sensitive payment proof in PRIVATE storage + $path = $request->file('payment_proof')->store(self::PAYMENT_PROOF_DIR, self::PAYMENT_PROOF_PRIVATE_DISK); // Update booking $booking->update([ @@ -303,4 +312,72 @@ public function uploadPaymentProof(Request $request, $id) 'data' => $booking->fresh()->load('kontrakan'), ], 200); } + + /** + * Securely stream payment proof file (AUTH required). + * - Only booking owner can access. + * - Migrates legacy public-stored files to private storage on first access. + */ + public function getPaymentProof(Request $request, $id) + { + $booking = Booking::query() + ->where('id', $id) + ->where('user_id', $request->user()->id) + ->first(); + + if (!$booking) { + return response()->json([ + 'success' => false, + 'message' => 'Booking tidak ditemukan', + 'error_code' => 'NOT_FOUND', + ], 404); + } + + if (!$booking->payment_proof) { + return response()->json([ + 'success' => false, + 'message' => 'Bukti pembayaran tidak tersedia', + 'error_code' => 'NO_PAYMENT_PROOF', + ], 404); + } + + $path = $booking->payment_proof; + + // If file is still on public disk (legacy), migrate to private. + if (!Storage::disk(self::PAYMENT_PROOF_PRIVATE_DISK)->exists($path) && Storage::disk(self::PAYMENT_PROOF_PUBLIC_DISK)->exists($path)) { + try { + $readStream = Storage::disk(self::PAYMENT_PROOF_PUBLIC_DISK)->readStream($path); + if ($readStream !== false) { + Storage::disk(self::PAYMENT_PROOF_PRIVATE_DISK)->writeStream($path, $readStream); + if (is_resource($readStream)) { + fclose($readStream); + } + Storage::disk(self::PAYMENT_PROOF_PUBLIC_DISK)->delete($path); + } + } catch (\Throwable $e) { + Log::warning('Failed to migrate payment proof to private storage', [ + 'booking_id' => $booking->id, + 'path' => $path, + 'error' => $e->getMessage(), + ]); + } + } + + if (!Storage::disk(self::PAYMENT_PROOF_PRIVATE_DISK)->exists($path)) { + return response()->json([ + 'success' => false, + 'message' => 'File bukti pembayaran tidak ditemukan', + 'error_code' => 'FILE_NOT_FOUND', + ], 404); + } + + $absolutePath = Storage::disk(self::PAYMENT_PROOF_PRIVATE_DISK)->path($path); + + // Stream file. Browser/app will infer mime. + return response()->file($absolutePath, [ + 'Content-Disposition' => 'inline; filename="payment-proof-' . $booking->id . '"', + 'Cache-Control' => 'no-store, no-cache, must-revalidate, max-age=0', + 'Pragma' => 'no-cache', + ]); + } } diff --git a/spk_kontrakan/app/Http/Controllers/Api/SAWController.php b/spk_kontrakan/app/Http/Controllers/Api/SAWController.php index 2e7d7ca..b66551d 100644 --- a/spk_kontrakan/app/Http/Controllers/Api/SAWController.php +++ b/spk_kontrakan/app/Http/Controllers/Api/SAWController.php @@ -260,7 +260,9 @@ public function calculateKontrakan(Request $request) public function calculateLaundry(Request $request) { $validator = Validator::make($request->all(), [ - 'jenis_layanan' => 'nullable|string|in:harian,jam,reguler,express,kiloan,satuan,kilat', + // NOTE: Mobile app can send dynamic values sourced from DB. + // We accept any short string and normalize it in mapJenisLayananQueryValues(). + 'jenis_layanan' => 'nullable|string|max:50', 'harga_min' => 'nullable|numeric', 'harga_max' => 'nullable|numeric', 'jarak_max' => 'nullable|numeric', diff --git a/spk_kontrakan/app/Http/Controllers/BookingController.php b/spk_kontrakan/app/Http/Controllers/BookingController.php index f6e267a..81777df 100644 --- a/spk_kontrakan/app/Http/Controllers/BookingController.php +++ b/spk_kontrakan/app/Http/Controllers/BookingController.php @@ -8,10 +8,14 @@ use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Storage; use Exception; class BookingController extends Controller { + private const PAYMENT_PROOF_PRIVATE_DISK = 'private'; + private const PAYMENT_PROOF_PUBLIC_DISK = 'public'; + /** * Tampilkan daftar semua booking (admin) */ @@ -245,6 +249,59 @@ public function show(Booking $booking) return view('admin.bookings.show', compact('booking')); } + /** + * Stream payment proof securely for admin (NOT public storage link). + * Also migrates legacy public-stored files to private storage on first access. + */ + public function paymentProof(Booking $booking) + { + // ========== AUTHORIZATION: Cek apakah admin punya akses ke booking ini ========== + $admin = auth()->guard('admin')->user(); + if ($admin && $admin->role !== 'super_admin') { + if ($booking->kontrakan->admin_id !== $admin->id) { + abort(403, 'Anda tidak memiliki akses ke booking ini.'); + } + } + + if (!$booking->payment_proof) { + abort(404, 'Bukti pembayaran tidak tersedia.'); + } + + $path = $booking->payment_proof; + + // Migrate legacy public file to private storage + if (!Storage::disk(self::PAYMENT_PROOF_PRIVATE_DISK)->exists($path) && Storage::disk(self::PAYMENT_PROOF_PUBLIC_DISK)->exists($path)) { + try { + $readStream = Storage::disk(self::PAYMENT_PROOF_PUBLIC_DISK)->readStream($path); + if ($readStream !== false) { + Storage::disk(self::PAYMENT_PROOF_PRIVATE_DISK)->writeStream($path, $readStream); + if (is_resource($readStream)) { + fclose($readStream); + } + Storage::disk(self::PAYMENT_PROOF_PUBLIC_DISK)->delete($path); + } + } catch (\Throwable $e) { + Log::warning('Failed to migrate admin payment proof to private storage', [ + 'booking_id' => $booking->id, + 'path' => $path, + 'error' => $e->getMessage(), + ]); + } + } + + if (!Storage::disk(self::PAYMENT_PROOF_PRIVATE_DISK)->exists($path)) { + abort(404, 'File bukti pembayaran tidak ditemukan.'); + } + + $absolutePath = Storage::disk(self::PAYMENT_PROOF_PRIVATE_DISK)->path($path); + + return response()->file($absolutePath, [ + 'Content-Disposition' => 'inline; filename="payment-proof-' . $booking->id . '"', + 'Cache-Control' => 'no-store, no-cache, must-revalidate, max-age=0', + 'Pragma' => 'no-cache', + ]); + } + /** * Tampilkan form edit booking */ diff --git a/spk_kontrakan/app/Http/Controllers/SAWController.php b/spk_kontrakan/app/Http/Controllers/SAWController.php index ad5fcd1..9cbfec4 100644 --- a/spk_kontrakan/app/Http/Controllers/SAWController.php +++ b/spk_kontrakan/app/Http/Controllers/SAWController.php @@ -165,7 +165,7 @@ public function proses(Request $request) ]); return redirect()->route('saw.index') - ->with('error', 'Terjadi kesalahan saat memproses data: ' . $e->getMessage()); + ->with('error', 'Terjadi kesalahan saat memproses data. Silakan coba lagi.'); } } diff --git a/spk_kontrakan/app/Http/Requests/RegisterRequest.php b/spk_kontrakan/app/Http/Requests/RegisterRequest.php index 0312fb7..345f0df 100644 --- a/spk_kontrakan/app/Http/Requests/RegisterRequest.php +++ b/spk_kontrakan/app/Http/Requests/RegisterRequest.php @@ -16,7 +16,8 @@ public function rules(): array return [ 'name' => 'required|string|max:255', 'email' => 'required|string|email|max:255|unique:users', - 'password' => 'required|string|min:6|confirmed', + // ✅ Updated: minimum 8 characters + must have letters and numbers + 'password' => 'required|string|min:8|confirmed|regex:/^(?=.*[a-zA-Z])(?=.*[0-9])/i', 'phone' => 'nullable|string|max:20', ]; } @@ -30,7 +31,8 @@ public function messages(): array 'email.email' => 'Format email tidak valid', 'email.unique' => 'Email sudah terdaftar', 'password.required' => 'Password wajib diisi', - 'password.min' => 'Password minimal 6 karakter', + 'password.min' => 'Password minimal 8 karakter', // ✅ Updated + 'password.regex' => 'Password harus kombinasi huruf dan angka', // ✅ New 'password.confirmed' => 'Konfirmasi password tidak cocok', 'phone.max' => 'Nomor telepon maksimal 20 karakter', ]; diff --git a/spk_kontrakan/app/Models/ActivityLog.php b/spk_kontrakan/app/Models/ActivityLog.php index c3e96b5..fcc1aa9 100644 --- a/spk_kontrakan/app/Models/ActivityLog.php +++ b/spk_kontrakan/app/Models/ActivityLog.php @@ -62,8 +62,14 @@ public function scopeByModel($query, $modelType) public static function log($action, $description, $modelType = null, $modelId = null, $oldValues = null, $newValues = null) { + $userId = auth()->id(); + + if ($userId && ! User::query()->whereKey($userId)->exists()) { + $userId = null; + } + return self::create([ - 'user_id' => auth()->id(), + 'user_id' => $userId, 'action' => $action, 'description' => $description, 'model_type' => $modelType, diff --git a/spk_kontrakan/app/Models/Admin.php b/spk_kontrakan/app/Models/Admin.php index 9e19df5..5661fcb 100644 --- a/spk_kontrakan/app/Models/Admin.php +++ b/spk_kontrakan/app/Models/Admin.php @@ -72,4 +72,12 @@ public function isAdmin() { return $this->role === 'admin'; } + + /** + * Helper: Label human-readable untuk role admin. + */ + public function getRoleLabel() + { + return $this->role === 'super_admin' ? 'Super Admin' : 'Admin'; + } } diff --git a/spk_kontrakan/config/filesystems.php b/spk_kontrakan/config/filesystems.php index 3d671bd..93f64e7 100644 --- a/spk_kontrakan/config/filesystems.php +++ b/spk_kontrakan/config/filesystems.php @@ -38,6 +38,15 @@ 'report' => false, ], + // ✅ Private disk for sensitive documents (NOT publicly accessible) + 'private' => [ + 'driver' => 'local', + 'root' => storage_path('app/private'), + 'serve' => false, + 'throw' => false, + 'report' => false, + ], + 'public' => [ 'driver' => 'local', 'root' => storage_path('app/public'), diff --git a/spk_kontrakan/database/migrations/2026_06_05_000001_make_activity_logs_user_id_nullable.php b/spk_kontrakan/database/migrations/2026_06_05_000001_make_activity_logs_user_id_nullable.php new file mode 100644 index 0000000..2f700d7 --- /dev/null +++ b/spk_kontrakan/database/migrations/2026_06_05_000001_make_activity_logs_user_id_nullable.php @@ -0,0 +1,50 @@ +dropForeign(['user_id']); + }); + + DB::statement('ALTER TABLE activity_logs MODIFY user_id BIGINT UNSIGNED NULL'); + + Schema::table('activity_logs', function (Blueprint $table) { + $table->foreign('user_id')->references('id')->on('users')->nullOnDelete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + if (! Schema::hasTable('activity_logs')) { + return; + } + + Schema::table('activity_logs', function (Blueprint $table) { + $table->dropForeign(['user_id']); + }); + + DB::table('activity_logs')->whereNull('user_id')->delete(); + DB::statement('ALTER TABLE activity_logs MODIFY user_id BIGINT UNSIGNED NOT NULL'); + + Schema::table('activity_logs', function (Blueprint $table) { + $table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete(); + }); + } +}; \ No newline at end of file diff --git a/spk_kontrakan/resources/views/admin/bookings/index.blade.php b/spk_kontrakan/resources/views/admin/bookings/index.blade.php index b1604e5..e04357d 100644 --- a/spk_kontrakan/resources/views/admin/bookings/index.blade.php +++ b/spk_kontrakan/resources/views/admin/bookings/index.blade.php @@ -190,7 +190,7 @@ {{ $booking->payment_status_label }} @if($booking->payment_proof) - + @endif diff --git a/spk_kontrakan/resources/views/admin/bookings/show.blade.php b/spk_kontrakan/resources/views/admin/bookings/show.blade.php index 968b3ae..e00016d 100644 --- a/spk_kontrakan/resources/views/admin/bookings/show.blade.php +++ b/spk_kontrakan/resources/views/admin/bookings/show.blade.php @@ -216,14 +216,14 @@ class="rounded me-3" style="width: 120px; height: 90px; object-fit: cover;"> @if($booking->payment_proof)

Bukti Pembayaran:

- - + Bukti Pembayaran
- Lihat Full diff --git a/spk_kontrakan/resources/views/auth/admin-login.blade.php b/spk_kontrakan/resources/views/auth/admin-login.blade.php index 7447f58..996f1c4 100644 --- a/spk_kontrakan/resources/views/auth/admin-login.blade.php +++ b/spk_kontrakan/resources/views/auth/admin-login.blade.php @@ -197,7 +197,7 @@
diff --git a/spk_kontrakan/routes/api.php b/spk_kontrakan/routes/api.php index e801b09..b0a781d 100644 --- a/spk_kontrakan/routes/api.php +++ b/spk_kontrakan/routes/api.php @@ -70,6 +70,7 @@ 'bookings' => [ 'GET /api/bookings' => 'History booking [AUTH]', 'GET /api/bookings/{id}' => 'Detail booking [AUTH]', + 'GET /api/bookings/{id}/payment-proof' => 'Lihat bukti bayar (secure) [AUTH]', 'POST /api/bookings' => 'Create booking [AUTH]', 'POST /api/bookings/{id}/cancel' => 'Cancel booking [AUTH]', 'POST /api/bookings/{id}/extend' => 'Extend booking [AUTH]', @@ -111,10 +112,11 @@ }); // ------------------------------------------------- -// AUTH ROUTES (PUBLIC - DEVELOPMENT MODE - NO RATE LIMITING) +// AUTH ROUTES (PUBLIC - WITH RATE LIMITING) // ------------------------------------------------- -Route::post('/register', [AuthController::class, 'register']); -Route::post('/login', [AuthController::class, 'login']); +// ✅ FIXED: Added rate limiting to prevent brute force attacks +Route::post('/register', [AuthController::class, 'register'])->middleware('throttle:3,1'); // 3 attempts per 1 minute +Route::post('/login', [AuthController::class, 'login'])->middleware('throttle:5,1'); // 5 attempts per 1 minute // ------------------------------------------------- // KONTRAKAN ROUTES (PUBLIC - TANPA AUTH) @@ -170,6 +172,8 @@ Route::prefix('bookings')->group(function () { Route::get('/', [BookingController::class, 'index']); Route::get('/{id}', [BookingController::class, 'show']); + // ✅ Secure access to payment proof (no public storage URL) + Route::get('/{id}/payment-proof', [BookingController::class, 'getPaymentProof']); Route::post('/', [BookingController::class, 'store']); Route::post('/{id}/cancel', [BookingController::class, 'cancel']); Route::post('/{id}/extend', [BookingController::class, 'extend']); diff --git a/spk_kontrakan/routes/web.php b/spk_kontrakan/routes/web.php index 96d1681..3ae543f 100644 --- a/spk_kontrakan/routes/web.php +++ b/spk_kontrakan/routes/web.php @@ -35,8 +35,15 @@ // ------------------------------------------------- Route::get('/admin/login', [AdminAuthController::class, 'loginPage'])->name('admin.login'); Route::post('/admin/login', [AdminAuthController::class, 'login'])->middleware('throttle:10,1')->name('admin.login.post'); -Route::get('/admin/register', [AdminAuthController::class, 'registerPage'])->name('admin.register'); -Route::post('/admin/register', [AdminAuthController::class, 'register'])->middleware('throttle:10,1')->name('admin.register.post'); + +// Admin register: +// - Default: disabled in production (security) +// - Enabled for local dev or when ENABLE_ADMIN_SELF_REGISTER=true +if (app()->environment('local') || env('ENABLE_ADMIN_SELF_REGISTER', false)) { + Route::get('/admin/register', [AdminAuthController::class, 'registerPage'])->name('admin.register'); + Route::post('/admin/register', [AdminAuthController::class, 'register'])->middleware('throttle:3,1')->name('admin.register.post'); +} + // Local-only helper: reset demo admin passwords Route::get('/admin/reset-demo-passwords', function () { @@ -124,6 +131,8 @@ Route::post('/', [BookingController::class, 'store'])->name('store'); Route::post('/bulk-destroy', [BookingController::class, 'bulkDestroy'])->name('bulk-destroy'); Route::get('/{booking}', [BookingController::class, 'show'])->name('show'); + // ✅ Secure payment proof view (not public storage link) + Route::get('/{booking}/payment-proof', [BookingController::class, 'paymentProof'])->name('payment-proof'); Route::get('/{booking}/edit', [BookingController::class, 'edit'])->name('edit'); Route::put('/{booking}', [BookingController::class, 'update'])->name('update'); Route::delete('/{booking}', [BookingController::class, 'destroy'])->name('destroy'); diff --git a/spk_kontrakan/tests/Feature/AdminSecurityRoutesTest.php b/spk_kontrakan/tests/Feature/AdminSecurityRoutesTest.php index 97ca451..ff54cf3 100644 --- a/spk_kontrakan/tests/Feature/AdminSecurityRoutesTest.php +++ b/spk_kontrakan/tests/Feature/AdminSecurityRoutesTest.php @@ -19,7 +19,9 @@ public function test_guest_is_redirected_from_admin_register_page(): void public function test_ensure_role_redirects_guest_to_login(): void { - Auth::shouldReceive('check')->once()->andReturn(false); + $guard = \Mockery::mock(\Illuminate\Contracts\Auth\StatefulGuard::class); + Auth::shouldReceive('guard')->once()->with('admin')->andReturn($guard); + $guard->shouldReceive('check')->once()->andReturn(false); $middleware = new EnsureRole(); $response = $middleware->handle(Request::create('/admin/register', 'GET'), fn () => response('ok'), 'super_admin'); @@ -30,8 +32,10 @@ public function test_ensure_role_redirects_guest_to_login(): void public function test_ensure_role_forbids_non_matching_role(): void { - Auth::shouldReceive('check')->once()->andReturn(true); - Auth::shouldReceive('user')->once()->andReturn((object) ['role' => 'admin']); + $guard = \Mockery::mock(\Illuminate\Contracts\Auth\StatefulGuard::class); + Auth::shouldReceive('guard')->twice()->with('admin')->andReturn($guard); + $guard->shouldReceive('check')->once()->andReturn(true); + $guard->shouldReceive('user')->once()->andReturn((object) ['role' => 'admin']); $this->expectException(HttpException::class); $this->expectExceptionMessage('Akses ditolak. Anda tidak memiliki izin untuk halaman ini.'); @@ -42,8 +46,10 @@ public function test_ensure_role_forbids_non_matching_role(): void public function test_ensure_role_allows_matching_role(): void { - Auth::shouldReceive('check')->once()->andReturn(true); - Auth::shouldReceive('user')->once()->andReturn((object) ['role' => 'super_admin']); + $guard = \Mockery::mock(\Illuminate\Contracts\Auth\StatefulGuard::class); + Auth::shouldReceive('guard')->twice()->with('admin')->andReturn($guard); + $guard->shouldReceive('check')->once()->andReturn(true); + $guard->shouldReceive('user')->once()->andReturn((object) ['role' => 'super_admin']); $middleware = new EnsureRole(); $response = $middleware->handle(Request::create('/admin/register', 'GET'), fn () => response('ok'), 'super_admin'); diff --git a/spk_mobile/lib/config/app_config.dart b/spk_mobile/lib/config/app_config.dart index 0577c8e..5c453a2 100644 --- a/spk_mobile/lib/config/app_config.dart +++ b/spk_mobile/lib/config/app_config.dart @@ -1,45 +1,74 @@ +import 'package:flutter/foundation.dart'; + class AppConfig { // ============================================================================ - // 🔧 BASE URL CONFIGURATION - FIXED IP (NOT AUTO-DETECT) - // ============================================================================ - // PENTING: Update IP ini sesuai dengan server backend Anda! - // Format: http://[IP_ADDRESS]:[PORT] - // - // Contoh: - // - Local: http://localhost:8000 - // - Network: http://192.168.1.100:8000 - // - Remote: http://your-server.com - // - // ⚠️ JIKA CONNECTION TIMEOUT: - // - Pastikan backend server running - // - Pastikan device/emulator connect ke WiFi yang sama - // - Pastikan port sudah benar + // BASE URL CONFIGURATION // ============================================================================ - /// Backend server URL - DIUBAH MENJADI FIXED (tidak auto-detect) - /// Alasan: ServerDiscoveryService scanning port yang salah - /// - /// UPDATE INI SESUAI DENGAN BACKEND ANDA! - /// Jika backend di: php artisan serve - /// Maka gunakan: http://127.0.0.1:8000 (localhost) atau http://[IP]:8000 (network) - static const String _defaultServer = 'http://192.168.18.16:8000'; + /// Host server untuk production (override via --dart-define=PROD_SERVER_URL=...). + static const String _productionServer = String.fromEnvironment( + 'PROD_SERVER_URL', + // defaultValue: 'http://10.133.61.99:8000', + defaultValue: 'https://spk-kontrakan.taskbuddy.web.id', + ); - // Runtime values — bisa di-override via setServerUrl() jika perlu - static String _serverUrl = _defaultServer; + /// Host server untuk development (override via --dart-define=DEV_SERVER_URL=...). + static const String _developmentServer = String.fromEnvironment( + 'DEV_SERVER_URL', + // defaultValue: 'http://10.133.61.99:8000', + defaultValue: 'https://spk-kontrakan.taskbuddy.web.id', + ); + + static String _serverUrl = _normalizeServerUrl( + kReleaseMode ? _productionServer : _developmentServer, + ); static String get serverUrl => _serverUrl; - static String get baseUrl => '$_serverUrl/api'; - static String get storageUrl => '$_serverUrl/storage'; - /// Override server URL jika perlu (untuk testing atau switching server) + /// API Laravel + static String get baseUrl => '$_serverUrl/api'; + + /// URL gambar/file public + static String get storageUrl => _serverUrl; + + /// Override server URL jika perlu static void setServerUrl(String url) { - _serverUrl = url; + _serverUrl = _normalizeServerUrl(url); } - // Timeouts - Increased untuk network yang lambat dan operations yang heavy (password hashing, db insert) - static const Duration connectionTimeout = Duration(seconds: 30); + /// Normalisasi URL agar selalu konsisten untuk request API. + static String _normalizeServerUrl(String url) { + final raw = url.trim(); + if (raw.isEmpty) return _developmentServer; - // ✅ UPDATED: Backend base URL sesuai IP hotspot HP (10.119.236.99:8000) + var normalized = raw; + + // Default ke HTTP jika skema tidak dicantumkan. + if (!normalized.startsWith('http://') && + !normalized.startsWith('https://')) { + normalized = 'http://$normalized'; + } + + // Hilangkan trailing slash agar baseUrl tidak menjadi //api. + while (normalized.endsWith('/')) { + normalized = normalized.substring(0, normalized.length - 1); + } + + final uri = Uri.tryParse(normalized); + if (uri == null) return _developmentServer; + + // Untuk host lokal/IP tanpa port, default ke 8000. + if (!uri.hasPort && + (uri.host == 'localhost' || + RegExp(r'^\d+\.\d+\.\d+\.\d+$').hasMatch(uri.host))) { + normalized = '${uri.scheme}://${uri.host}:8000'; + } + + return normalized; + } + + // Timeouts + static const Duration connectionTimeout = Duration(seconds: 30); static const Duration receiveTimeout = Duration(seconds: 30); // Local Storage Keys diff --git a/spk_mobile/lib/config/environment.dart b/spk_mobile/lib/config/environment.dart index 1bf3d2a..241bc4f 100644 --- a/spk_mobile/lib/config/environment.dart +++ b/spk_mobile/lib/config/environment.dart @@ -8,12 +8,12 @@ class Environment { // Pilih sesuai platform dan environment saat development static const String apiBaseUrl = String.fromEnvironment( 'API_BASE_URL', - defaultValue: 'http://10.119.236.99:8000', // Default IP lokal - Updated + defaultValue: 'http://10.133.61.99:8000', ); static const String storageBaseUrl = String.fromEnvironment( 'STORAGE_BASE_URL', - defaultValue: 'http://10.119.236.99:8000/storage', + defaultValue: 'http://10.133.61.99:8000/storage', ); // Mode debugging diff --git a/spk_mobile/lib/main.dart b/spk_mobile/lib/main.dart index b07387c..852c209 100644 --- a/spk_mobile/lib/main.dart +++ b/spk_mobile/lib/main.dart @@ -3,6 +3,7 @@ import 'package:flutter/services.dart'; import 'login.dart'; import 'screens/improved_home_screen.dart'; import 'services/auth_service.dart'; +import 'services/server_discovery_service.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -12,6 +13,11 @@ Future main() async { statusBarIconBrightness: Brightness.light, ), ); + + // ✅ Load persisted auth session (token/user) from secure storage + final authService = AuthService(); + await authService.loadToken(); + runApp(const MyApp()); } @@ -284,24 +290,19 @@ class _SplashScreenState extends State } Future _checkAuth() async { - // ⚠️ DISABLED: Auto-discovery scanning wrong ports - // Sekarang menggunakan fixed IP dari AppConfig - // Jika perlu auto-discovery, uncomment kode di bawah dan configure timeout - - /* - // 🔍 Auto-detect server IP (disabled - causing port scan issues) + // 🔍 Auto-detect server IP dengan fallback ke konfigurasi default. + // Ini membantu APK tetap bisa konek saat IP LAN berubah. await ServerDiscoveryService.discover( onStatus: (status) { if (mounted) setState(() => _statusText = status); }, ).timeout( - const Duration(seconds: 5), + const Duration(seconds: 8), onTimeout: () { - debugPrint('Server discovery timeout, using configured IP'); + debugPrint('Server discovery timeout, using configured server URL'); return false; }, ); - */ if (mounted) setState(() => _statusText = 'Memeriksa sesi...'); await _authService.loadToken(); diff --git a/spk_mobile/lib/models/kontrakan.dart b/spk_mobile/lib/models/kontrakan.dart index c3bb75b..5a77e27 100644 --- a/spk_mobile/lib/models/kontrakan.dart +++ b/spk_mobile/lib/models/kontrakan.dart @@ -11,7 +11,7 @@ class Kontrakan { final String? deskripsi; final String status; final String? fotoUtama; - final String? foto; // Field foto dari API + final String? foto; final String? noWhatsapp; final List galeri; final double? avgRating; @@ -40,26 +40,22 @@ class Kontrakan { }); factory Kontrakan.fromJson(Map json) { - // Try to get jarak from different possible field names double jarak = 0; + if (json['jarak_kampus'] != null) { jarak = double.tryParse(json['jarak_kampus'].toString()) ?? 0; } else if (json['jarak'] != null) { - // Backend uses 'jarak' column, convert meters to km jarak = (double.tryParse(json['jarak'].toString()) ?? 0) / 1000; } - // Get foto from API and build gallery list final List galleryList = []; - // Parse galeri array if exists if (json['galeri'] != null && (json['galeri'] as List).isNotEmpty) { galleryList.addAll( (json['galeri'] as List).map((g) => Galeri.fromJson(g)).toList(), ); } - // If gallery is empty but foto field exists, create a galeri item from it if (galleryList.isEmpty && json['foto'] != null && json['foto'].toString().isNotEmpty) { @@ -84,7 +80,7 @@ class Kontrakan { deskripsi: json['deskripsi'], status: json['status'] ?? 'tersedia', fotoUtama: json['foto_utama'], - foto: json['foto'], // Store raw foto field + foto: json['foto'], noWhatsapp: json['no_whatsapp'], galeri: galleryList, avgRating: json['avg_rating'] != null @@ -100,7 +96,6 @@ class Kontrakan { ); } - // Check if kontrakan is available bool get isAvailable => status == 'available' || status == 'tersedia'; bool get hasPhoto { @@ -113,44 +108,49 @@ class Kontrakan { if (isValidUrl(foto)) return true; if (galeri.any((g) => isValidUrl(g.foto))) return true; + return false; } - // Get primary photo URL - // Prioritize 'foto' field (Admin-set primary photo) over galeri entries, - // because galeri items may reference files that have been deleted/replaced. String get primaryPhoto { - // 1. Prefer the explicit 'foto' field (most up-to-date primary photo) if (foto != null && foto!.isNotEmpty) { if (foto!.startsWith('http')) { return foto!; } - return '${AppConfig.serverUrl}/uploads/Kontrakan/$foto'; + + if (foto!.startsWith('uploads/')) { + return '${AppConfig.serverUrl}/$foto'; + } + + return '${AppConfig.serverUrl}/uploads/kontrakan/$foto'; } - // 2. Fall back to galeri items if foto is not set + if (galeri.isNotEmpty) { final primary = galeri.firstWhere( (g) => g.isPrimary, orElse: () => galeri.first, ); + if (primary.foto.isNotEmpty && primary.foto.startsWith('http')) { return primary.foto; } + if (primary.foto.isNotEmpty) { - return '${AppConfig.serverUrl}/uploads/Kontrakan/${primary.foto}'; + if (primary.foto.startsWith('uploads/')) { + return '${AppConfig.serverUrl}/${primary.foto}'; + } + + return '${AppConfig.serverUrl}/uploads/kontrakan/${primary.foto}'; } } - // Return empty string when no photo available to avoid unnecessary - // network requests to placeholder services (prevents TLS/handshake errors) + return ''; } - // Format harga dengan Rupiah String get formattedHarga { return 'Rp ${harga.toStringAsFixed(0).replaceAllMapped(RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), (Match m) => '${m[1]}.')}'; } - // Get fasilitas as list List get fasilitasList { return fasilitas.split(',').map((f) => f.trim()).toList(); } @@ -178,13 +178,15 @@ class Galeri { ); } - // Get full photo URL String get photoUrl { - // Check if it's already a full URL if (foto.startsWith('http')) { return foto; } - // Build full URL from uploads - return '${AppConfig.serverUrl}/uploads/Kontrakan/$foto'; + + if (foto.startsWith('uploads/')) { + return '${AppConfig.serverUrl}/$foto'; + } + + return '${AppConfig.serverUrl}/uploads/kontrakan/$foto'; } } diff --git a/spk_mobile/lib/register.dart b/spk_mobile/lib/register.dart index 4c92f72..a5e4e2e 100644 --- a/spk_mobile/lib/register.dart +++ b/spk_mobile/lib/register.dart @@ -21,6 +21,14 @@ class _RegisterScreenState extends State { bool _isLoading = false; final _authService = AuthService(); + // ✅ NEW: Helper function to check password strength + bool _isPasswordStrong(String password) { + // Requirements: 8+ chars + mix of letters and numbers + bool hasLetters = password.contains(RegExp(r'[a-zA-Z]')); + bool hasNumbers = password.contains(RegExp(r'[0-9]')); + return hasLetters && hasNumbers; + } + @override void dispose() { _nameController.dispose(); @@ -159,7 +167,10 @@ class _RegisterScreenState extends State { validator: (v) { if (v == null || v.isEmpty) return 'Password tidak boleh kosong'; - if (v.length < 6) return 'Password minimal 6 karakter'; + if (v.length < 8) return 'Password minimal 8 karakter'; // ✅ Updated to 8 + if (!_isPasswordStrong(v)) { + return 'Password harus kombinasi huruf dan angka'; + } return null; }, ), diff --git a/spk_mobile/lib/screens/booking_history_screen.dart b/spk_mobile/lib/screens/booking_history_screen.dart index 9e341c3..0658811 100644 --- a/spk_mobile/lib/screens/booking_history_screen.dart +++ b/spk_mobile/lib/screens/booking_history_screen.dart @@ -1,7 +1,7 @@ import 'dart:io'; +import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; -import '../config/app_config.dart'; import '../services/booking_service.dart'; import '../models/booking.dart'; @@ -726,8 +726,8 @@ class _BookingHistoryScreenState extends State const SizedBox(height: 12), GestureDetector( onTap: () { - final url = - '${AppConfig.storageUrl}/${booking.paymentProof}'; + final Future future = + _bookingService.getPaymentProofBytes(booking.id); showDialog( context: context, builder: (ctx) => Dialog( @@ -792,7 +792,38 @@ class _BookingHistoryScreenState extends State bottomLeft: Radius.circular(16), bottomRight: Radius.circular(16), ), - child: Image.network(url, fit: BoxFit.contain), + child: FutureBuilder( + future: future, + builder: (context, snapshot) { + if (snapshot.connectionState == + ConnectionState.waiting) { + return const Padding( + padding: EdgeInsets.all(24), + child: Center( + child: CircularProgressIndicator(), + ), + ); + } + + final bytes = snapshot.data; + if (bytes == null || bytes.isEmpty) { + return const Padding( + padding: EdgeInsets.all(24), + child: Center( + child: Text( + 'Gagal memuat bukti pembayaran', + textAlign: TextAlign.center, + ), + ), + ); + } + + return Image.memory( + bytes, + fit: BoxFit.contain, + ); + }, + ), ), ], ), diff --git a/spk_mobile/lib/screens/home_screen.dart b/spk_mobile/lib/screens/home_screen.dart index d5f5cd3..b4511af 100644 --- a/spk_mobile/lib/screens/home_screen.dart +++ b/spk_mobile/lib/screens/home_screen.dart @@ -6,7 +6,6 @@ import '../models/laundry.dart'; import '../services/kontrakan_service.dart'; import '../services/laundry_service.dart'; import '../services/auth_service.dart'; -import '../config/app_config.dart'; import 'search_screen.dart'; import 'kontrakan_detail_screen.dart'; import 'laundry_list_screen.dart'; diff --git a/spk_mobile/lib/services/auth_service.dart b/spk_mobile/lib/services/auth_service.dart index dbc3a23..494b029 100644 --- a/spk_mobile/lib/services/auth_service.dart +++ b/spk_mobile/lib/services/auth_service.dart @@ -3,9 +3,11 @@ import 'dart:convert'; import 'dart:io'; import 'dart:math' as math; import 'package:flutter/foundation.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:http/http.dart' as http; import 'package:shared_preferences/shared_preferences.dart'; import '../config/app_config.dart'; +import '../services/server_discovery_service.dart'; import '../models/user.dart'; class AuthService { @@ -19,6 +21,14 @@ class AuthService { // HttpClient - lazy initialization HttpClient? _httpClient; + // ✅ Secure storage for sensitive data (token & user) + static const FlutterSecureStorage _secureStorage = FlutterSecureStorage( + aOptions: AndroidOptions(encryptedSharedPreferences: true), + iOptions: IOSOptions( + accessibility: KeychainAccessibility.first_unlock_this_device, + ), + ); + AuthService._internal() { _token = null; _currentUser = null; @@ -28,55 +38,133 @@ class AuthService { HttpClient _getHttpClient() { if (_httpClient == null) { _httpClient = HttpClient(); - _httpClient!.badCertificateCallback = (cert, host, port) => - true; // Accept all certs (development only) + + // ✅ Security: NEVER bypass certificate validation in release builds. + // In debug builds, allow self-signed certs only for local dev hosts. + if (kDebugMode) { + _httpClient!.badCertificateCallback = (cert, host, port) { + return host == 'localhost' || + host == '127.0.0.1' || + host.startsWith('192.168.') || + host.startsWith('10.') || + host.startsWith('172.16.') || + host.startsWith('172.17.') || + host.startsWith('172.18.') || + host.startsWith('172.19.') || + host.startsWith('172.2') || + host.startsWith('172.30.') || + host.startsWith('172.31.'); + }; + } + _httpClient!.connectionTimeout = const Duration(seconds: 30); } return _httpClient!; } + String _redactSensitive(String input) { + // Redact common sensitive fields from JSON-ish strings. + var out = input; + out = out.replaceAll( + RegExp(r'("token"\s*:\s*")([^"]+)(")', caseSensitive: false), + r'$1***$3', + ); + out = out.replaceAll( + RegExp(r'("password"\s*:\s*")([^"]+)(")', caseSensitive: false), + r'$1***$3', + ); + out = out.replaceAll( + RegExp(r'("authorization"\s*:\s*")([^"]+)(")', caseSensitive: false), + r'$1***$3', + ); + return out; + } + String? get token => _token; User? get currentUser => _currentUser; bool get isAuthenticated => _token != null; // Load token from local storage Future loadToken() async { - final prefs = await SharedPreferences.getInstance(); - _token = prefs.getString(AppConfig.tokenKey); + try { + _token = await _secureStorage.read(key: AppConfig.tokenKey); + final userJson = await _secureStorage.read(key: AppConfig.userKey); + if (userJson != null) { + _currentUser = User.fromJson(jsonDecode(userJson)); + } - final userJson = prefs.getString(AppConfig.userKey); - if (userJson != null) { - _currentUser = User.fromJson(jsonDecode(userJson)); + // ✅ Migration: if secure storage empty, try legacy SharedPreferences once. + if (_token == null) { + final prefs = await SharedPreferences.getInstance(); + final legacyToken = prefs.getString(AppConfig.tokenKey); + final legacyUserJson = prefs.getString(AppConfig.userKey); + + if (legacyToken != null) { + await _secureStorage.write( + key: AppConfig.tokenKey, + value: legacyToken, + ); + await prefs.remove(AppConfig.tokenKey); + _token = legacyToken; + } + + if (legacyUserJson != null) { + await _secureStorage.write( + key: AppConfig.userKey, + value: legacyUserJson, + ); + await prefs.remove(AppConfig.userKey); + try { + _currentUser = User.fromJson(jsonDecode(legacyUserJson)); + } catch (_) { + // ignore parse errors + } + } + } + } catch (e) { + debugPrint('Failed to load auth session: $e'); + _token = null; + _currentUser = null; } } // Save token to local storage Future _saveToken(String token, User user) async { - final prefs = await SharedPreferences.getInstance(); - await prefs.setString(AppConfig.tokenKey, token); - await prefs.setString(AppConfig.userKey, jsonEncode(user.toJson())); - _token = token; - _currentUser = user; + try { + await _secureStorage.write(key: AppConfig.tokenKey, value: token); + await _secureStorage.write( + key: AppConfig.userKey, + value: jsonEncode(user.toJson()), + ); + _token = token; + _currentUser = user; + } catch (e) { + debugPrint('Failed to save auth session: $e'); + _token = token; + _currentUser = user; + } } // Clear token (logout) Future clearToken() async { - final prefs = await SharedPreferences.getInstance(); - await prefs.remove(AppConfig.tokenKey); - await prefs.remove(AppConfig.userKey); - await prefs.remove(AppConfig.deviceTokenKey); - _token = null; - _currentUser = null; + try { + await _secureStorage.delete(key: AppConfig.tokenKey); + await _secureStorage.delete(key: AppConfig.userKey); + await _secureStorage.delete(key: AppConfig.deviceTokenKey); + } catch (e) { + debugPrint('Failed to clear auth session: $e'); + } finally { + _token = null; + _currentUser = null; + } } Future saveDeviceToken(String token) async { - final prefs = await SharedPreferences.getInstance(); - await prefs.setString(AppConfig.deviceTokenKey, token); + await _secureStorage.write(key: AppConfig.deviceTokenKey, value: token); } Future loadDeviceToken() async { - final prefs = await SharedPreferences.getInstance(); - return prefs.getString(AppConfig.deviceTokenKey); + return _secureStorage.read(key: AppConfig.deviceTokenKey); } /// Tangani response 401 secara terpusat. @@ -96,8 +184,11 @@ class AuthService { required String body, }) async { debugPrint('[HTTP] POST $uri'); - debugPrint('[HTTP] Headers: $headers'); - debugPrint('[HTTP] Body: $body'); + if (kDebugMode) { + // Avoid printing secrets in logs. + debugPrint('[HTTP] Headers: $headers'); + debugPrint('[HTTP] Body: (redacted)'); + } try { final client = _getHttpClient(); @@ -113,7 +204,13 @@ class AuthService { final responseBody = await response.transform(utf8.decoder).join(); debugPrint('[HTTP] Status: ${response.statusCode}'); - debugPrint('[HTTP] Response: $responseBody'); + if (kDebugMode) { + final preview = responseBody.substring( + 0, + math.min(responseBody.length, 500), + ); + debugPrint('[HTTP] Response: ${_redactSensitive(preview)}'); + } return http.Response(responseBody, response.statusCode); } on SocketException catch (e) { @@ -135,6 +232,7 @@ class AuthService { required String password, required String passwordConfirmation, String? phone, + bool allowRetry = true, }) async { try { final url = '${AppConfig.baseUrl}/register'; @@ -156,9 +254,13 @@ class AuthService { ); debugPrint('Register response status: ${response.statusCode}'); - debugPrint( - 'Register response body: ${response.body.substring(0, math.min(response.body.length, 300))}', - ); + if (kDebugMode) { + final preview = response.body.substring( + 0, + math.min(response.body.length, 300), + ); + debugPrint('Register response preview: ${_redactSensitive(preview)}'); + } Map? data; String? rawBody; @@ -214,12 +316,41 @@ class AuthService { }; } on SocketException catch (e) { debugPrint('SocketException in register: $e'); + + // Coba temukan server lagi lalu retry sekali. + if (allowRetry && await _rediscoverServer()) { + try { + return await register( + name: name, + email: email, + password: password, + passwordConfirmation: passwordConfirmation, + phone: phone, + allowRetry: false, + ); + } catch (_) {} + } + return { 'success': false, 'message': 'Gagal terhubung ke server. Periksa koneksi internet.', }; } on TimeoutException catch (e) { debugPrint('TimeoutException in register: $e'); + + if (allowRetry && await _rediscoverServer()) { + try { + return await register( + name: name, + email: email, + password: password, + passwordConfirmation: passwordConfirmation, + phone: phone, + allowRetry: false, + ); + } catch (_) {} + } + return { 'success': false, 'message': 'Registrasi timeout. Server tidak merespons.', @@ -236,6 +367,7 @@ class AuthService { Future> login({ required String email, required String password, + bool allowRetry = true, }) async { try { final url = '${AppConfig.baseUrl}/login'; @@ -251,9 +383,13 @@ class AuthService { ); debugPrint('Login response status: ${response.statusCode}'); - debugPrint( - 'Login response body: ${response.body.substring(0, math.min(response.body.length, 300))}', - ); + if (kDebugMode) { + final preview = response.body.substring( + 0, + math.min(response.body.length, 300), + ); + debugPrint('Login response preview: ${_redactSensitive(preview)}'); + } Map? data; try { @@ -300,11 +436,19 @@ class AuthService { }; } } on SocketException { + if (allowRetry && await _rediscoverServer()) { + return login(email: email, password: password, allowRetry: false); + } + return { 'success': false, 'message': 'Gagal terhubung ke server. Periksa koneksi internet.', }; } on TimeoutException { + if (allowRetry && await _rediscoverServer()) { + return login(email: email, password: password, allowRetry: false); + } + return { 'success': false, 'message': 'Login timeout. Server tidak merespons.', @@ -315,6 +459,17 @@ class AuthService { } } + /// Coba temukan kembali server lokal/LAN jika URL tersimpan sudah tidak valid. + Future _rediscoverServer() async { + try { + await ServerDiscoveryService.resetCache(); + return await ServerDiscoveryService.discover(); + } catch (e) { + debugPrint('Server rediscovery failed: $e'); + return false; + } + } + // Logout Future> logout() async { try { diff --git a/spk_mobile/lib/services/booking_service.dart b/spk_mobile/lib/services/booking_service.dart index 91accab..dad0b7a 100644 --- a/spk_mobile/lib/services/booking_service.dart +++ b/spk_mobile/lib/services/booking_service.dart @@ -1,5 +1,6 @@ import 'dart:convert'; import 'dart:io'; +import 'dart:typed_data'; import 'package:http/http.dart' as http; import '../config/app_config.dart'; import '../models/booking.dart'; @@ -223,4 +224,37 @@ class BookingService { return {'success': false, 'message': 'Error: $e'}; } } + + // Fetch payment proof image bytes (secure, authenticated) + Future getPaymentProofBytes(int bookingId) async { + try { + final headers = { + 'Accept': 'image/*', + }; + + if (_authService.token != null) { + headers['Authorization'] = 'Bearer ${_authService.token}'; + } + + final response = await http + .get( + Uri.parse('${AppConfig.baseUrl}/bookings/$bookingId/payment-proof'), + headers: headers, + ) + .timeout(const Duration(seconds: 30)); + + if (response.statusCode == 401) { + await _authService.handleUnauthorized(response.statusCode); + return null; + } + + if (response.statusCode == 200) { + return response.bodyBytes; + } + + return null; + } catch (e) { + return null; + } + } } diff --git a/spk_mobile/lib/services/server_discovery_service.dart b/spk_mobile/lib/services/server_discovery_service.dart index 6a54170..73b312a 100644 --- a/spk_mobile/lib/services/server_discovery_service.dart +++ b/spk_mobile/lib/services/server_discovery_service.dart @@ -11,7 +11,7 @@ import '../config/app_config.dart'; /// 3. Localhost (desktop / iOS simulator) /// 4. Scan subnet lokal HP secara paralel class ServerDiscoveryService { - static const int _port = 41197; + static const int _port = 8000; static const String _cacheKey = 'discovered_server_url'; static const Duration _probeTimeout = Duration(seconds: 2); static const int _scanBatchSize = 24; diff --git a/spk_mobile/lib/widgets/app_placeholder.dart b/spk_mobile/lib/widgets/app_placeholder.dart index 7b0a1f4..49ede3a 100644 --- a/spk_mobile/lib/widgets/app_placeholder.dart +++ b/spk_mobile/lib/widgets/app_placeholder.dart @@ -1,4 +1,3 @@ -import 'dart:convert'; import 'dart:typed_data'; import 'package:flutter/widgets.dart'; import 'package:flutter/services.dart'; diff --git a/spk_mobile/lib/widgets/kontrakan_card.dart b/spk_mobile/lib/widgets/kontrakan_card.dart index b41a935..970f0c1 100644 --- a/spk_mobile/lib/widgets/kontrakan_card.dart +++ b/spk_mobile/lib/widgets/kontrakan_card.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:cached_network_image/cached_network_image.dart'; import '../models/kontrakan.dart'; import '../screens/kontrakan_detail_screen.dart'; -import 'app_placeholder.dart'; class KontrakanCard extends StatelessWidget { final Kontrakan kontrakan; diff --git a/spk_mobile/lib/widgets/laundry_card.dart b/spk_mobile/lib/widgets/laundry_card.dart index c70116e..e51fc68 100644 --- a/spk_mobile/lib/widgets/laundry_card.dart +++ b/spk_mobile/lib/widgets/laundry_card.dart @@ -3,7 +3,6 @@ import 'package:cached_network_image/cached_network_image.dart'; import '../models/laundry.dart'; import '../screens/laundry_detail_screen.dart'; import '../services/location_service.dart'; -import 'app_placeholder.dart'; class LaundryCard extends StatelessWidget { final Laundry laundry; diff --git a/spk_mobile/pubspec.lock b/spk_mobile/pubspec.lock index 9ca2a2b..8ebb2f7 100644 --- a/spk_mobile/pubspec.lock +++ b/spk_mobile/pubspec.lock @@ -286,6 +286,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.33" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" + url: "https://pub.dev" + source: hosted + version: "9.2.4" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688 + url: "https://pub.dev" + source: hosted + version: "1.2.3" + flutter_secure_storage_macos: + dependency: transitive + description: + name: flutter_secure_storage_macos + sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247" + url: "https://pub.dev" + source: hosted + version: "3.1.3" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 + url: "https://pub.dev" + source: hosted + version: "3.1.2" flutter_spinkit: dependency: "direct main" description: @@ -456,6 +504,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.19.0" + js: + dependency: transitive + description: + name: js + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + url: "https://pub.dev" + source: hosted + version: "0.6.7" leak_tracker: dependency: transitive description: @@ -933,6 +989,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" xdg_directories: dependency: transitive description: diff --git a/spk_mobile/pubspec.yaml b/spk_mobile/pubspec.yaml index c9c7755..eba1fc4 100644 --- a/spk_mobile/pubspec.yaml +++ b/spk_mobile/pubspec.yaml @@ -40,6 +40,9 @@ dependencies: # Local storage untuk token & preferences shared_preferences: ^2.2.2 + + # Secure storage untuk token (encrypted at rest) + flutter_secure_storage: ^9.2.2 # Loading indicator flutter_spinkit: ^5.2.0 diff --git a/spk_mobile/test/widget_test.dart b/spk_mobile/test/widget_test.dart index fff00b6..3692485 100644 --- a/spk_mobile/test/widget_test.dart +++ b/spk_mobile/test/widget_test.dart @@ -11,20 +11,11 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:spk_mobile/main.dart'; void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. + testWidgets('App builds (smoke test)', (WidgetTester tester) async { await tester.pumpWidget(const MyApp()); - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); - - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); - - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); + // Basic sanity checks: app root widgets are present. + expect(find.byType(MaterialApp), findsOneWidget); + expect(find.byType(SplashScreen), findsOneWidget); }); }