From 1d681b483ee61686d4630b185bbe8a7f69de710c Mon Sep 17 00:00:00 2001 From: micko samawa Date: Sun, 28 Jun 2026 14:54:49 +0700 Subject: [PATCH] feat(mobile): implement real email verification screen and flows --- .../Http/Controllers/Api/AuthController.php | 3 + spk_kontrakan/app/Models/User.php | 4 +- .../views/auth/verified_success.blade.php | 205 ++++++++++ spk_kontrakan/routes/api.php | 17 + spk_kontrakan/routes/web.php | 26 ++ spk_mobile/lib/login.dart | 8 +- spk_mobile/lib/main.dart | 5 +- spk_mobile/lib/models/user.dart | 9 + spk_mobile/lib/register.dart | 5 +- .../screens/email_verification_screen.dart | 355 ++++++++++++++++++ spk_mobile/lib/services/auth_service.dart | 41 +- 11 files changed, 669 insertions(+), 9 deletions(-) create mode 100644 spk_kontrakan/resources/views/auth/verified_success.blade.php create mode 100644 spk_mobile/lib/screens/email_verification_screen.dart diff --git a/spk_kontrakan/app/Http/Controllers/Api/AuthController.php b/spk_kontrakan/app/Http/Controllers/Api/AuthController.php index 85002c0..96fb083 100644 --- a/spk_kontrakan/app/Http/Controllers/Api/AuthController.php +++ b/spk_kontrakan/app/Http/Controllers/Api/AuthController.php @@ -32,6 +32,9 @@ public function register(RegisterRequest $request) // Refresh user to ensure all fields are loaded from DB $user->refresh(); + // Fire registered event to trigger verification email notification + event(new \Illuminate\Auth\Events\Registered($user)); + $token = $user->createToken('mobile-app-token')->plainTextToken; Log::info('Registration successful', ['user_id' => $user->id]); diff --git a/spk_kontrakan/app/Models/User.php b/spk_kontrakan/app/Models/User.php index b6eed1b..f5fd943 100644 --- a/spk_kontrakan/app/Models/User.php +++ b/spk_kontrakan/app/Models/User.php @@ -2,13 +2,13 @@ namespace App\Models; -// use Illuminate\Contracts\Auth\MustVerifyEmail; +use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Laravel\Sanctum\HasApiTokens; -class User extends Authenticatable +class User extends Authenticatable implements MustVerifyEmail { /** @use HasFactory<\Database\Factories\UserFactory> */ use HasFactory, Notifiable, HasApiTokens; diff --git a/spk_kontrakan/resources/views/auth/verified_success.blade.php b/spk_kontrakan/resources/views/auth/verified_success.blade.php new file mode 100644 index 0000000..f337cb4 --- /dev/null +++ b/spk_kontrakan/resources/views/auth/verified_success.blade.php @@ -0,0 +1,205 @@ + + + + + + Email Berhasil Diverifikasi + + + + + + + +
+
+
+ + + +
+

Email Terverifikasi!

+

Terima kasih. Email Anda telah berhasil diverifikasi secara aman. Akun Anda kini telah aktif sepenuhnya.

+ +
+
{{ $name }}
+
{{ $email }}
+
+ + + +
+
+ + + + diff --git a/spk_kontrakan/routes/api.php b/spk_kontrakan/routes/api.php index b0a781d..3ac49ec 100644 --- a/spk_kontrakan/routes/api.php +++ b/spk_kontrakan/routes/api.php @@ -167,6 +167,23 @@ }); Route::post('/logout', [AuthController::class, 'logout']); Route::put('/profile/update', [AuthController::class, 'updateProfile']); + + // Send/Resend Email Verification Notification + Route::post('/email/verification-notification', function (Request $request) { + if ($request->user()->hasVerifiedEmail()) { + return response()->json([ + 'success' => false, + 'message' => 'Email Anda sudah diverifikasi.', + ], 400); + } + + $request->user()->sendEmailVerificationNotification(); + + return response()->json([ + 'success' => true, + 'message' => 'Tautan verifikasi baru telah dikirim ke email Anda.', + ]); + })->middleware('throttle:3,1'); // Booking Routes Route::prefix('bookings')->group(function () { diff --git a/spk_kontrakan/routes/web.php b/spk_kontrakan/routes/web.php index 3ae543f..723497c 100644 --- a/spk_kontrakan/routes/web.php +++ b/spk_kontrakan/routes/web.php @@ -212,3 +212,29 @@ Route::post('/admin/logout', [AdminAuthController::class, 'logout'])->name('admin.logout'); }); + +// ------------------------------------------------- +// EMAIL VERIFICATION LANDING PAGE (PUBLIC - SIGNED) +// ------------------------------------------------- +Route::get('/email/verify/{id}/{hash}', function ($id, $hash, \Illuminate\Http\Request $request) { + // Temukan user berdasarkan ID + $user = \App\Models\User::findOrFail($id); + + // Verifikasi hash email + if (! hash_equals((string) $hash, sha1($user->getEmailForVerification()))) { + abort(403, 'Tautan verifikasi tidak valid.'); + } + + // Tandai email sebagai terverifikasi + if (! $user->hasVerifiedEmail()) { + $user->markEmailAsVerified(); + event(new \Illuminate\Auth\Events\Verified($user)); + } + + // Kembalikan view sukses yang indah + return view('auth.verified_success', [ + 'name' => $user->name, + 'email' => $user->email + ]); +})->middleware(['signed', 'throttle:6,1'])->name('verification.verify'); + diff --git a/spk_mobile/lib/login.dart b/spk_mobile/lib/login.dart index 566df98..901941f 100644 --- a/spk_mobile/lib/login.dart +++ b/spk_mobile/lib/login.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_spinkit/flutter_spinkit.dart'; import 'screens/improved_home_screen.dart'; +import 'screens/email_verification_screen.dart'; import 'register.dart'; import 'services/auth_service.dart'; @@ -234,10 +235,15 @@ class _LoginScreenState extends State { final success = result['success'] ?? false; if (success == true) { + final user = _authService.currentUser; if (mounted) { Navigator.pushReplacement( context, - MaterialPageRoute(builder: (context) => const ImprovedHomeScreen()), + MaterialPageRoute( + builder: (context) => user?.isEmailVerified == true + ? const ImprovedHomeScreen() + : const EmailVerificationScreen(), + ), ); } } else { diff --git a/spk_mobile/lib/main.dart b/spk_mobile/lib/main.dart index 852c209..a10af38 100644 --- a/spk_mobile/lib/main.dart +++ b/spk_mobile/lib/main.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'login.dart'; import 'screens/improved_home_screen.dart'; +import 'screens/email_verification_screen.dart'; import 'services/auth_service.dart'; import 'services/server_discovery_service.dart'; @@ -311,7 +312,9 @@ class _SplashScreenState extends State if (!mounted) return; final destination = _authService.isAuthenticated - ? const ImprovedHomeScreen() + ? (_authService.currentUser?.isEmailVerified == true + ? const ImprovedHomeScreen() + : const EmailVerificationScreen()) : const LoginScreen(); Navigator.pushReplacement( diff --git a/spk_mobile/lib/models/user.dart b/spk_mobile/lib/models/user.dart index 47fa628..1ac78b2 100644 --- a/spk_mobile/lib/models/user.dart +++ b/spk_mobile/lib/models/user.dart @@ -7,6 +7,7 @@ class User { final String? roleLabel; final String? userType; final DateTime? createdAt; + final DateTime? emailVerifiedAt; User({ required this.id, @@ -17,6 +18,7 @@ class User { this.roleLabel, this.userType, this.createdAt, + this.emailVerifiedAt, }); factory User.fromJson(Map json) { @@ -31,6 +33,9 @@ class User { createdAt: json['created_at'] != null ? DateTime.parse(json['created_at']) : null, + emailVerifiedAt: json['email_verified_at'] != null + ? DateTime.parse(json['email_verified_at']) + : null, ); } @@ -61,6 +66,9 @@ class User { /// Check if user is super admin bool isSuperAdmin() => role == 'super_admin'; + /// Check if email is verified + bool get isEmailVerified => emailVerifiedAt != null; + Map toJson() { return { 'id': id, @@ -71,6 +79,7 @@ class User { 'role_label': roleLabel, 'user_type': userType, 'created_at': createdAt?.toIso8601String(), + 'email_verified_at': emailVerifiedAt?.toIso8601String(), }; } } diff --git a/spk_mobile/lib/register.dart b/spk_mobile/lib/register.dart index a5e4e2e..8dc13c0 100644 --- a/spk_mobile/lib/register.dart +++ b/spk_mobile/lib/register.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_spinkit/flutter_spinkit.dart'; import 'login.dart'; +import 'screens/email_verification_screen.dart'; import 'services/auth_service.dart'; class RegisterScreen extends StatefulWidget { @@ -493,7 +494,7 @@ class _RegisterScreenState extends State { size: 20, ), SizedBox(width: 10), - Text('Registrasi berhasil! Silakan login.'), + Text('Registrasi berhasil! Silakan verifikasi email Anda.'), ], ), backgroundColor: const Color(0xFF2E7D32), @@ -512,7 +513,7 @@ class _RegisterScreenState extends State { Navigator.pushReplacement( context, MaterialPageRoute( - builder: (_) => LoginScreen(initialEmail: _emailController.text), + builder: (_) => const EmailVerificationScreen(), ), ); } diff --git a/spk_mobile/lib/screens/email_verification_screen.dart b/spk_mobile/lib/screens/email_verification_screen.dart new file mode 100644 index 0000000..c00a07f --- /dev/null +++ b/spk_mobile/lib/screens/email_verification_screen.dart @@ -0,0 +1,355 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_spinkit/flutter_spinkit.dart'; +import '../services/auth_service.dart'; +import 'improved_home_screen.dart'; +import '../login.dart'; + +class EmailVerificationScreen extends StatefulWidget { + const EmailVerificationScreen({super.key}); + + @override + State createState() => _EmailVerificationScreenState(); +} + +class _EmailVerificationScreenState extends State { + final _authService = AuthService(); + bool _isLoading = false; + bool _isResending = false; + int _cooldownSeconds = 0; + Timer? _cooldownTimer; + String? _message; + bool _isSuccessMessage = true; + + @override + void initState() { + super.initState(); + // Auto-check once on load + _checkVerificationStatus(silent: true); + } + + @override + void dispose() { + _cooldownTimer?.cancel(); + super.dispose(); + } + + void _startCooldown() { + setState(() => _cooldownSeconds = 60); + _cooldownTimer?.cancel(); + _cooldownTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (_cooldownSeconds > 0) { + setState(() => _cooldownSeconds--); + } else { + _cooldownTimer?.cancel(); + } + }); + } + + Future _checkVerificationStatus({bool silent = false}) async { + if (!mounted) return; + if (!silent) { + setState(() { + _isLoading = true; + _message = null; + }); + } + + // Refresh user data from API + final user = await _authService.getCurrentUser(); + + if (!mounted) return; + setState(() => _isLoading = false); + + if (user != null && user.isEmailVerified) { + // Success! Go to Home Screen + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: [ + const Icon(Icons.check_circle, color: Colors.white), + const SizedBox(width: 8), + Text('Selamat, email Anda telah terverifikasi!'), + ], + ), + backgroundColor: Colors.green[700], + ), + ); + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (_) => const ImprovedHomeScreen()), + ); + } + } else { + if (!silent) { + setState(() { + _message = 'Email belum diverifikasi. Silakan klik tautan di inbox Anda lalu coba lagi.'; + _isSuccessMessage = false; + }); + } + } + } + + Future _handleResendEmail() async { + if (_cooldownSeconds > 0) return; + + setState(() { + _isResending = true; + _message = null; + }); + + final result = await _authService.resendVerificationEmail(); + + if (!mounted) return; + setState(() => _isResending = false); + + if (result['success'] == true) { + _startCooldown(); + setState(() { + _message = 'Link verifikasi baru telah dikirim ke email Anda.'; + _isSuccessMessage = true; + }); + } else { + setState(() { + _message = result['message'] ?? 'Gagal mengirim email verifikasi.'; + _isSuccessMessage = false; + }); + } + } + + Future _handleLogout() async { + setState(() => _isLoading = true); + await _authService.logout(); + if (!mounted) return; + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (_) => const LoginScreen()), + ); + } + + @override + Widget build(BuildContext context) { + final user = _authService.currentUser; + final email = user?.email ?? ''; + + return Scaffold( + backgroundColor: const Color(0xFFF3F7FB), + body: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Icon Animation / Header + Container( + padding: const EdgeInsets.all(28), + decoration: BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 20, + offset: const Offset(0, 10), + ), + ], + ), + child: const SpinKitDoubleBounce( + color: Color(0xFF1565C0), + size: 60, + ), + ), + const SizedBox(height: 32), + + // Card Container + Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(24), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.03), + blurRadius: 15, + offset: const Offset(0, 5), + ), + ], + ), + child: Column( + children: [ + const Text( + 'Verifikasi Email Anda', + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.w800, + color: Color(0xFF1A1A2E), + letterSpacing: -0.5, + ), + ), + const SizedBox(height: 12), + RichText( + textAlign: TextAlign.center, + text: TextSpan( + style: const TextStyle( + fontSize: 14, + color: Color(0xFF5A6B85), + height: 1.5, + ), + children: [ + const TextSpan(text: 'Kami telah mengirimkan link verifikasi ke email: \n'), + TextSpan( + text: email, + style: const TextStyle( + fontWeight: FontWeight.bold, + color: Color(0xFF1565C0), + ), + ), + const TextSpan( + text: '\n\nSilakan buka kotak masuk (atau folder spam) Anda, klik link tersebut, kemudian kembali ke aplikasi ini.', + ), + ], + ), + ), + const SizedBox(height: 24), + + // Status Message Box if any + if (_message != null) ...[ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: _isSuccessMessage ? Colors.green[50] : Colors.orange[50], + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: _isSuccessMessage + ? Colors.green[200]! + : Colors.orange[200]!, + ), + ), + child: Row( + children: [ + Icon( + _isSuccessMessage + ? Icons.check_circle_outline + : Icons.info_outline, + color: _isSuccessMessage ? Colors.green[700] : Colors.orange[700], + size: 20, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + _message!, + style: TextStyle( + fontSize: 12, + color: _isSuccessMessage + ? Colors.green[800] + : Colors.orange[800], + fontWeight: FontWeight.w500, + height: 1.4, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 24), + ], + + // Button Saya Sudah Verifikasi + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: _isLoading ? null : () => _checkVerificationStatus(), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF1565C0), + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + child: _isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2.5, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.verified, size: 18, color: Colors.white), + SizedBox(width: 8), + Text( + 'SAYA SUDAH VERIFIKASI', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ], + ), + ), + ), + const SizedBox(height: 12), + + // Button Resend Email + SizedBox( + width: double.infinity, + child: OutlinedButton( + onPressed: (_isResending || _cooldownSeconds > 0) + ? null + : _handleResendEmail, + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + side: const BorderSide(color: Color(0xFFE2EAF3)), + ), + child: _isResending + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text( + _cooldownSeconds > 0 + ? 'KIRIM ULANG EMAIL ($_cooldownSeconds s)' + : 'KIRIM ULANG EMAIL VERIFIKASI', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + color: _cooldownSeconds > 0 ? Colors.grey : const Color(0xFF1565C0), + ), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 24), + + // Button Logout/Back to Login + TextButton( + onPressed: _isLoading ? null : _handleLogout, + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.logout, size: 16), + SizedBox(width: 6), + Text('Keluar / Ganti Akun'), + ], + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/spk_mobile/lib/services/auth_service.dart b/spk_mobile/lib/services/auth_service.dart index e29b7b2..f29e0f5 100644 --- a/spk_mobile/lib/services/auth_service.dart +++ b/spk_mobile/lib/services/auth_service.dart @@ -1,4 +1,4 @@ -import 'dart:async'; +import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'dart:math' as math; @@ -547,8 +547,10 @@ class AuthService { if (user.name.trim().isNotEmpty || user.email.trim().isNotEmpty) { _currentUser = user; - final prefs = await SharedPreferences.getInstance(); - await prefs.setString(AppConfig.userKey, jsonEncode(user.toJson())); + await _secureStorage.write( + key: AppConfig.userKey, + value: jsonEncode(user.toJson()), + ); return user; } @@ -562,6 +564,39 @@ class AuthService { } } + /// Mengirim ulang email verifikasi + Future> resendVerificationEmail() async { + if (_token == null) { + return {'success': false, 'message': 'Sesi telah habis. Silakan login kembali.'}; + } + + try { + final response = await http.post( + Uri.parse('${AppConfig.baseUrl}/email/verification-notification'), + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'Authorization': 'Bearer $_token', + }, + ).timeout(AppConfig.connectionTimeout); + + final decoded = jsonDecode(response.body); + if (response.statusCode == 200) { + return { + 'success': true, + 'message': decoded['message'] ?? 'Tautan verifikasi terkirim!' + }; + } else { + return { + 'success': false, + 'message': decoded['message'] ?? 'Gagal mengirim email verifikasi.' + }; + } + } catch (e) { + return {'success': false, 'message': 'Koneksi bermasalah: $e'}; + } + } + // Update profile Future> updateProfile({ required String name,