final update web and mobile

This commit is contained in:
micko samawa 2026-06-12 22:46:44 +07:00
parent bcd994b19d
commit cf4435580d
36 changed files with 839 additions and 182 deletions

View File

@ -0,0 +1,111 @@
<?php
namespace App\Console\Commands;
use App\Models\Booking;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
class MigratePaymentProofsToPrivate extends Command
{
protected $signature = 'bookings:migrate-payment-proofs {--dry-run : Only report what would change} {--limit= : Max number of bookings to process}';
protected $description = 'Move legacy payment proof files from public disk to private disk';
public function handle(): int
{
$public = Storage::disk('public');
$private = Storage::disk('private');
$dryRun = (bool) $this->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;
}
}

View File

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

View File

@ -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()
]);
}
}
}

View File

@ -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',
], [

View File

@ -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);
}

View File

@ -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',
]);
}
}

View File

@ -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',

View File

@ -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
*/

View File

@ -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.');
}
}

View File

@ -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',
];

View File

@ -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,

View File

@ -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';
}
}

View File

@ -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'),

View File

@ -0,0 +1,50 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
if (! Schema::hasTable('activity_logs')) {
return;
}
Schema::table('activity_logs', function (Blueprint $table) {
$table->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();
});
}
};

View File

@ -190,7 +190,7 @@
{{ $booking->payment_status_label }}
</span>
@if($booking->payment_proof)
<a href="{{ asset('storage/' . $booking->payment_proof) }}" target="_blank" class="btn btn-sm btn-outline-success ms-1" title="Lihat Bukti Transfer">
<a href="{{ route('admin.bookings.payment-proof', $booking->id) }}" target="_blank" class="btn btn-sm btn-outline-success ms-1" title="Lihat Bukti Transfer">
<i class="bi bi-image"></i>
</a>
@endif

View File

@ -216,14 +216,14 @@ class="rounded me-3" style="width: 120px; height: 90px; object-fit: cover;">
@if($booking->payment_proof)
<div class="mt-3">
<p class="text-muted small mb-2"><i class="bi bi-image me-1"></i>Bukti Pembayaran:</p>
<a href="{{ asset('storage/' . $booking->payment_proof) }}" target="_blank">
<img src="{{ asset('storage/' . $booking->payment_proof) }}"
<a href="{{ route('admin.bookings.payment-proof', $booking->id) }}" target="_blank">
<img src="{{ route('admin.bookings.payment-proof', $booking->id) }}"
alt="Bukti Pembayaran"
class="img-fluid rounded border"
style="max-height: 250px; object-fit: contain; width: 100%;">
</a>
<div class="text-center mt-1">
<a href="{{ asset('storage/' . $booking->payment_proof) }}" target="_blank"
<a href="{{ route('admin.bookings.payment-proof', $booking->id) }}" target="_blank"
class="btn btn-sm btn-outline-success mt-1">
<i class="bi bi-arrows-fullscreen me-1"></i>Lihat Full
</a>

View File

@ -197,7 +197,7 @@
<div class="auth-footer">
<p>Belum punya akun bisnis?</p>
<a class="btn-register-link" href="/admin/register" onclick="window.location.href='/admin/register'; return false;">Daftar di sini</a>
<a class="btn-register-link" href="/admin/register">Daftar di sini</a>
</div>
</div>
</div>

View File

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

View File

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

View File

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

View File

@ -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

View File

@ -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

View File

@ -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<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
@ -12,6 +13,11 @@ Future<void> 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<SplashScreen>
}
Future<void> _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();

View File

@ -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> galeri;
final double? avgRating;
@ -40,26 +40,22 @@ class Kontrakan {
});
factory Kontrakan.fromJson(Map<String, dynamic> 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<Galeri> 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<String> 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';
}
}

View File

@ -21,6 +21,14 @@ class _RegisterScreenState extends State<RegisterScreen> {
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<RegisterScreen> {
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;
},
),

View File

@ -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<BookingHistoryScreen>
const SizedBox(height: 12),
GestureDetector(
onTap: () {
final url =
'${AppConfig.storageUrl}/${booking.paymentProof}';
final Future<Uint8List?> future =
_bookingService.getPaymentProofBytes(booking.id);
showDialog(
context: context,
builder: (ctx) => Dialog(
@ -792,7 +792,38 @@ class _BookingHistoryScreenState extends State<BookingHistoryScreen>
bottomLeft: Radius.circular(16),
bottomRight: Radius.circular(16),
),
child: Image.network(url, fit: BoxFit.contain),
child: FutureBuilder<Uint8List?>(
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,
);
},
),
),
],
),

View File

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

View File

@ -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<void> 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<void> _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<void> 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<void> saveDeviceToken(String token) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(AppConfig.deviceTokenKey, token);
await _secureStorage.write(key: AppConfig.deviceTokenKey, value: token);
}
Future<String?> 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<String, dynamic>? 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<Map<String, dynamic>> 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<String, dynamic>? 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<bool> _rediscoverServer() async {
try {
await ServerDiscoveryService.resetCache();
return await ServerDiscoveryService.discover();
} catch (e) {
debugPrint('Server rediscovery failed: $e');
return false;
}
}
// Logout
Future<Map<String, dynamic>> logout() async {
try {

View File

@ -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<Uint8List?> getPaymentProofBytes(int bookingId) async {
try {
final headers = <String, String>{
'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;
}
}
}

View File

@ -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;

View File

@ -1,4 +1,3 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/widgets.dart';
import 'package:flutter/services.dart';

View File

@ -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;

View File

@ -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;

View File

@ -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:

View File

@ -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

View File

@ -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);
});
}