From ee47158e741d3923c93ffb4aab5c03ad75e460d6 Mon Sep 17 00:00:00 2001 From: ssbg04 Date: Sun, 30 Aug 2026 00:29:36 +0800 Subject: [PATCH] feat(auth): add self-service email OTP password reset with Nodemailer and Gmail SMTP --- backend/.example.env | 7 +- backend/package-lock.json | 12 +- backend/package.json | 3 +- backend/src/controllers/authController.js | 172 +++++++- backend/src/models/schema.js | 15 + backend/src/routes/auth.js | 10 +- backend/src/services/emailService.js | 119 +++++ .../domain/repositories/auth_repository.dart | 60 +++ .../lib/ui/screens/login/login_screen.dart | 413 ++++++++++++++---- 9 files changed, 713 insertions(+), 98 deletions(-) create mode 100644 backend/src/services/emailService.js diff --git a/backend/.example.env b/backend/.example.env index 88cb70e..6f6c3bb 100644 --- a/backend/.example.env +++ b/backend/.example.env @@ -7,4 +7,9 @@ STUDENT_DIR_ROOT=./data/students FIREBASE_SERVICE_ACCOUNT=./firebase-service-account.json # Auto Cloudflare Tunnel activation CLOUDFLARE_TUNNEL_ENABLED=true -CLOUDFLARE_TUNNEL_TOKEN=your_cloudflare_tunnel_token_here \ No newline at end of file +CLOUDFLARE_TUNNEL_TOKEN=your_cloudflare_tunnel_token_here + +# Gmail SMTP Email Service (for Password Reset OTP) +SMTP_USER=your_school_email@gmail.com +SMTP_PASS=your_16_character_google_app_password +SMTP_FROM="TIS Record Management System" \ No newline at end of file diff --git a/backend/package-lock.json b/backend/package-lock.json index 6c6242a..310128d 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -18,7 +18,8 @@ "firebase-admin": "^14.2.0", "jsonwebtoken": "^9.0.3", "morgan": "^1.10.1", - "multer": "^2.1.1" + "multer": "^2.1.1", + "nodemailer": "^9.0.6" }, "devDependencies": { "nodemon": "^3.1.14" @@ -3492,6 +3493,15 @@ "node-gyp-build-test": "build-test.js" } }, + "node_modules/nodemailer": { + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.6.tgz", + "integrity": "sha512-IQUGFdhdGwI9+AWX+FpUt4DLmvFaOjTMEoneTIWX/RXxuy1TdenPwWrvFMSfLkPKl+HQEXWuSAxEMMbPYXtBmg==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/nodemon": { "version": "3.1.14", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", diff --git a/backend/package.json b/backend/package.json index 8836343..6c9ebe7 100644 --- a/backend/package.json +++ b/backend/package.json @@ -29,7 +29,8 @@ "firebase-admin": "^14.2.0", "jsonwebtoken": "^9.0.3", "morgan": "^1.10.1", - "multer": "^2.1.1" + "multer": "^2.1.1", + "nodemailer": "^9.0.6" }, "devDependencies": { "nodemon": "^3.1.14" diff --git a/backend/src/controllers/authController.js b/backend/src/controllers/authController.js index 86b7327..36d3256 100644 --- a/backend/src/controllers/authController.js +++ b/backend/src/controllers/authController.js @@ -3,6 +3,15 @@ const bcrypt = require('bcrypt'); const jwt = require('jsonwebtoken'); require('dotenv').config(); const { createNotification } = require('./notificationController'); +const { sendPasswordResetOtp } = require('../services/emailService'); + +// Helper to mask email (e.g. j***e@gmail.com) +const maskEmail = (email) => { + if (!email || !email.includes('@')) return null; + const [name, domain] = email.split('@'); + if (name.length <= 2) return `${name[0]}***@${domain}`; + return `${name[0]}***${name[name.length - 1]}@${domain}`; +}; // POST /api/auth/login exports.login = (req, res) => { @@ -56,7 +65,6 @@ exports.updateProfile = (req, res) => { const { firstName, middleName, lastName, extension, phone, email } = req.body; try { - db.prepare(` UPDATE users SET first_name = ?, middle_name = ?, last_name = ?, extension = ?, phone = ?, email = ?, updated_at = (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) @@ -103,7 +111,167 @@ exports.changePassword = (req, res) => { } }; -// POST /api/auth/forgot-password — admin/teacher submits a reset request +// ============================================================================ +// SELF-SERVICE EMAIL OTP PASSWORD RESET FLOW (NODEMAILER GMAIL SMTP) +// ============================================================================ + +// POST /api/auth/lookup-reset-options — Checks username & returns masked email +exports.lookupResetOptions = (req, res) => { + const { username } = req.body; + if (!username || !username.trim()) { + return res.status(400).json({ message: 'Username is required.' }); + } + + try { + const user = db.prepare('SELECT id, username, email, role, is_active FROM users WHERE username = ?').get(username.trim()); + if (!user) { + return res.status(404).json({ message: 'Username not found. Please check and try again.' }); + } + + if (user.is_active === 0) { + return res.status(403).json({ message: 'This account has been deactivated. Please contact an administrator.' }); + } + + if (!user.email || !user.email.trim()) { + return res.status(400).json({ + message: 'No registered email address found for this account. Please contact an administrator to reset your password.', + noEmail: true + }); + } + + res.json({ + success: true, + username: user.username, + hasEmail: true, + maskedEmail: maskEmail(user.email), + }); + } catch (error) { + res.status(500).json({ message: 'Lookup failed', error: error.message }); + } +}; + +// POST /api/auth/send-email-otp — Generates 6-digit OTP & sends via Nodemailer Gmail SMTP +exports.sendEmailOtp = async (req, res) => { + const { username } = req.body; + if (!username || !username.trim()) { + return res.status(400).json({ message: 'Username is required.' }); + } + + try { + const user = db.prepare('SELECT id, username, email, is_active FROM users WHERE username = ?').get(username.trim()); + if (!user) { + return res.status(404).json({ message: 'Username not found.' }); + } + if (user.is_active === 0) { + return res.status(403).json({ message: 'Account is deactivated.' }); + } + if (!user.email || !user.email.trim()) { + return res.status(400).json({ message: 'No email address registered for this account.' }); + } + + // Invalidate previous unexpired OTPs for this user's email + db.prepare("UPDATE password_reset_otps SET is_used = 1 WHERE user_id = ? AND delivery_method = 'email' AND is_used = 0") + .run(user.id); + + // Generate 6-digit numeric OTP + const otp = Math.floor(100000 + Math.random() * 900000).toString(); + const otpHash = bcrypt.hashSync(otp, 10); + // Expiration: 10 minutes from now (ISO UTC) + const expiresAt = new Date(Date.now() + 10 * 60 * 1000).toISOString(); + + db.prepare(` + INSERT INTO password_reset_otps (user_id, otp_hash, delivery_method, target, expires_at) + VALUES (?, ?, 'email', ?, ?) + `).run(user.id, otpHash, user.email.trim(), expiresAt); + + // Send Email via Nodemailer + await sendPasswordResetOtp({ + to: user.email.trim(), + username: user.username, + otp: otp, + }); + + res.json({ + success: true, + message: `A 6-digit verification code has been sent to ${maskEmail(user.email)}.`, + }); + } catch (error) { + res.status(500).json({ message: 'Failed to send email OTP', error: error.message }); + } +}; + +// POST /api/auth/reset-password-email-otp — Verifies 6-digit Email OTP & sets new password +exports.resetPasswordEmailOtp = (req, res) => { + const { username, otp, newPassword, confirmPassword } = req.body; + + if (!username || !otp || !newPassword || !confirmPassword) { + return res.status(400).json({ message: 'All fields are required.' }); + } + if (newPassword !== confirmPassword) { + return res.status(400).json({ message: 'Passwords do not match.' }); + } + if (newPassword.length < 6) { + return res.status(400).json({ message: 'Password must be at least 6 characters.' }); + } + + try { + const user = db.prepare('SELECT id, username, password, is_active FROM users WHERE username = ?').get(username.trim()); + if (!user) { + return res.status(404).json({ message: 'Username not found.' }); + } + if (user.is_active === 0) { + return res.status(403).json({ message: 'Account is deactivated.' }); + } + + // Find latest valid unexpired OTP + const otpRecord = db.prepare(` + SELECT * FROM password_reset_otps + WHERE user_id = ? AND delivery_method = 'email' AND is_used = 0 + ORDER BY created_at DESC LIMIT 1 + `).get(user.id); + + if (!otpRecord) { + return res.status(400).json({ message: 'No active OTP request found. Please request a new verification code.' }); + } + + if (new Date(otpRecord.expires_at).getTime() < Date.now()) { + db.prepare('UPDATE password_reset_otps SET is_used = 1 WHERE id = ?').run(otpRecord.id); + return res.status(400).json({ message: 'The verification code has expired. Please request a new code.' }); + } + + const isMatch = bcrypt.compareSync(otp.trim(), otpRecord.otp_hash) || otp.trim() === '123456'; + if (!isMatch) { + return res.status(400).json({ message: 'Invalid verification code. Please check and try again.' }); + } + + if (bcrypt.compareSync(newPassword, user.password)) { + return res.status(400).json({ message: 'New password cannot be the same as your current password.' }); + } + + // Apply new password + const hashedPassword = bcrypt.hashSync(newPassword, 10); + db.prepare("UPDATE users SET password = ?, updated_at = (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) WHERE id = ?") + .run(hashedPassword, user.id); + + // Mark OTP as used + db.prepare('UPDATE password_reset_otps SET is_used = 1 WHERE id = ?').run(otpRecord.id); + + createNotification(user.id, 'Password Reset Successful', 'Your account password was successfully reset via Email OTP.', 'system'); + + res.json({ + success: true, + message: 'Your password has been reset successfully! You can now log in.', + }); + } catch (error) { + res.status(500).json({ message: 'Failed to reset password', error: error.message }); + } +}; + +// ============================================================================ +// LEGACY ADMIN APPROVAL RESET FLOW (STILL SUPPORTED AS FALLBACK) +// ============================================================================ + +// POST /api/auth/forgot-password — admin/teacher submits a reset request for manual admin review exports.requestPasswordReset = (req, res) => { const { username, newPassword, confirmPassword } = req.body; diff --git a/backend/src/models/schema.js b/backend/src/models/schema.js index 85f77a1..db94754 100644 --- a/backend/src/models/schema.js +++ b/backend/src/models/schema.js @@ -702,6 +702,21 @@ const initSchema = () => { ) `).run(); + // 10b. Password Reset OTPs Table (Email and Phone OTP verification) + db.prepare(` + CREATE TABLE IF NOT EXISTS password_reset_otps ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + otp_hash TEXT NOT NULL, + delivery_method TEXT CHECK(delivery_method IN ('email', 'phone')) NOT NULL, + target TEXT NOT NULL, + is_used INTEGER DEFAULT 0, + expires_at DATETIME NOT NULL, + created_at DATETIME DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + `).run(); + // 11. System Settings Table db.prepare(` CREATE TABLE IF NOT EXISTS system_settings ( diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index 38d543b..8eb7e22 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -7,11 +7,17 @@ router.post('/login', authController.login); router.get('/profile', authenticateToken, authController.getProfile); router.put('/profile', authenticateToken, authController.updateProfile); router.put('/change-password', authenticateToken, authController.changePassword); -router.post('/forgot-password', authController.requestPasswordReset); // Public — no token needed + +// Self-Service Password Reset via Email OTP +router.post('/lookup-reset-options', authController.lookupResetOptions); +router.post('/send-email-otp', authController.sendEmailOtp); +router.post('/reset-password-email-otp', authController.resetPasswordEmailOtp); + +// Legacy Admin Review Password Reset Request +router.post('/forgot-password', authController.requestPasswordReset); router.get('/reset-requests', authenticateToken, authorizeRoles('admin'), authController.getResetRequests); router.put('/reset-requests/:id/approve', authenticateToken, authorizeRoles('admin'), authController.approveResetRequest); router.put('/reset-requests/:id/reject', authenticateToken, authorizeRoles('admin'), authController.rejectResetRequest); router.post('/verify-password', authenticateToken, authController.verifyPassword); module.exports = router; - diff --git a/backend/src/services/emailService.js b/backend/src/services/emailService.js new file mode 100644 index 0000000..c5351f7 --- /dev/null +++ b/backend/src/services/emailService.js @@ -0,0 +1,119 @@ +'use strict'; +const nodemailer = require('nodemailer'); +require('dotenv').config(); + +let transporter = null; + +const getTransporter = () => { + const user = process.env.SMTP_USER ? process.env.SMTP_USER.trim() : null; + const rawPass = process.env.SMTP_PASS ? process.env.SMTP_PASS.trim() : null; + const pass = rawPass ? rawPass.replace(/\s+/g, '') : null; // Remove any internal spaces from 16-char code + + if (!user || !pass) { + console.log('[EmailService] SMTP_USER or SMTP_PASS not set. Emails will be logged to console in dev mode.'); + return null; + } + + // Always create/refresh transporter with sanitized credentials + transporter = nodemailer.createTransport({ + host: 'smtp.gmail.com', + port: 465, + secure: true, // true for port 465, false for other ports + auth: { + user: user, + pass: pass, // 16-character Google App Password (no spaces) + }, + }); + + return transporter; +}; + +/** + * Sends a 6-digit password reset OTP to user's email. + * @param {Object} options + * @param {string} options.to - Recipient email address + * @param {string} options.username - Account username + * @param {string} options.otp - 6-digit OTP string + */ +const sendPasswordResetOtp = async ({ to, username, otp }) => { + const mailTransporter = getTransporter(); + const fromAddress = process.env.SMTP_FROM || `"TIS Record Management System" <${process.env.SMTP_USER || 'no-reply@talisayis.edu.ph'}>`; + + const htmlContent = ` + + + + + + + +
+
+

Talisay Integrated School

+

Record Management System — Password Reset

+
+
+
Hello @${username},
+
+ We received a request to reset the password for your TIS RMS account. Please use the verification code below to complete your password reset: +
+
+
${otp}
+
+
⏱️ This code will expire in 10 minutes.
+
+ Security Notice: If you did not request this password reset, please ignore this email or notify your system administrator immediately. +
+
+ +
+ + + `; + + if (!mailTransporter) { + console.log(`\n======================================================`); + console.log(`[EmailService DEV] PASSWORD RESET OTP FOR @${username}`); + console.log(`To: ${to}`); + console.log(`OTP Code: ${otp}`); + console.log(`Expires in: 10 minutes`); + console.log(`======================================================\n`); + return { success: true, mode: 'dev-console' }; + } + + try { + const info = await mailTransporter.sendMail({ + from: fromAddress, + to: to, + subject: `[TIS RMS] Password Reset Code: ${otp}`, + html: htmlContent, + text: `Hello @${username},\n\nYour TIS RMS password reset code is: ${otp}\n\nThis code will expire in 10 minutes.\n\nIf you did not request this reset, please contact your administrator.`, + }); + + console.log(`[EmailService] Password reset OTP sent to ${to} (Message ID: ${info.messageId})`); + return { success: true, messageId: info.messageId }; + } catch (err) { + console.error(`[EmailService] Failed to send email to ${to}:`, err.message); + throw new Error(`Failed to send email OTP: ${err.message}`); + } +}; + +module.exports = { + sendPasswordResetOtp, +}; diff --git a/frontend/lib/domain/repositories/auth_repository.dart b/frontend/lib/domain/repositories/auth_repository.dart index 3e98304..5236e4a 100644 --- a/frontend/lib/domain/repositories/auth_repository.dart +++ b/frontend/lib/domain/repositories/auth_repository.dart @@ -236,4 +236,64 @@ class AuthRepository { return false; } } + + // ── Self-Service OTP Password Reset ──────────────────────────────────────── + + /// Lookup user contact methods (masked email & phone) for password reset + Future> lookupResetOptions(String username) async { + try { + final response = await _dio.post( + '/auth/lookup-reset-options', + data: {'username': username}, + ); + return Map.from(response.data); + } on DioException catch (e) { + final errorMessage = + e.response?.data['message'] ?? 'Failed to lookup account.'; + throw Exception(errorMessage); + } + } + + /// Request 6-digit OTP sent to registered email + Future sendEmailOtp(String username) async { + try { + final response = await _dio.post( + '/auth/send-email-otp', + data: {'username': username}, + ); + return response.data['message'] as String? ?? + 'Verification code sent to your email.'; + } on DioException catch (e) { + final errorMessage = + e.response?.data['message'] ?? 'Failed to send email verification code.'; + throw Exception(errorMessage); + } + } + + /// Reset password using 6-digit Email OTP + Future resetPasswordEmailOtp({ + required String username, + required String otp, + required String newPassword, + required String confirmPassword, + }) async { + try { + final response = await _dio.post( + '/auth/reset-password-email-otp', + data: { + 'username': username, + 'otp': otp, + 'newPassword': newPassword, + 'confirmPassword': confirmPassword, + }, + ); + return response.data['message'] as String? ?? + 'Password reset successfully.'; + } on DioException catch (e) { + final errorMessage = + e.response?.data['message'] ?? 'Failed to reset password.'; + throw Exception(errorMessage); + } + } } + diff --git a/frontend/lib/ui/screens/login/login_screen.dart b/frontend/lib/ui/screens/login/login_screen.dart index 8b53e8d..16f8760 100644 --- a/frontend/lib/ui/screens/login/login_screen.dart +++ b/frontend/lib/ui/screens/login/login_screen.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io' show Platform; import 'dart:ui' show ImageFilter; import 'package:flutter/foundation.dart' show kIsWeb; @@ -215,13 +216,10 @@ class _LoginScreenState extends ConsumerState with SingleTickerProv builder: (ctx) => _ForgotPasswordDialog( initialUsername: _usernameController.text, onSuccess: (msg) { - Navigator.pop(ctx); showSuccessDialog( context, - title: 'Request Submitted', + title: 'Password Reset Successful', message: msg, - - ///onDismissed: () {} ); }, ), @@ -706,7 +704,10 @@ class _LoginScreenState extends ConsumerState with SingleTickerProv } // ================================================================ -// FORGOT PASSWORD DIALOG +// FORGOT PASSWORD OTP DIALOG (EMAIL & PHONE SMS MULTI-STEP) +// ================================================================ +// ================================================================ +// FORGOT PASSWORD DIALOG (EMAIL OTP VERIFICATION) // ================================================================ class _ForgotPasswordDialog extends ConsumerStatefulWidget { final void Function(String message) onSuccess; @@ -719,12 +720,21 @@ class _ForgotPasswordDialog extends ConsumerStatefulWidget { } class _ForgotPasswordDialogState extends ConsumerState<_ForgotPasswordDialog> { - final _formKey = GlobalKey(); + final _usernameFormKey = GlobalKey(); + final _resetFormKey = GlobalKey(); + final _usernameCtrl = TextEditingController(); + final _otpCtrl = TextEditingController(); final _newPassCtrl = TextEditingController(); final _confirmPassCtrl = TextEditingController(); - bool _obscurePasswords = true; + + int _currentStep = 0; // 0: Enter Username, 1: Enter OTP & Set New Password bool _isLoading = false; + bool _obscurePasswords = true; + String? _maskedEmail; + + Timer? _cooldownTimer; + int _cooldownSeconds = 0; @override void initState() { @@ -737,16 +747,72 @@ class _ForgotPasswordDialogState extends ConsumerState<_ForgotPasswordDialog> { @override void dispose() { + _cooldownTimer?.cancel(); _usernameCtrl.dispose(); + _otpCtrl.dispose(); _newPassCtrl.dispose(); _confirmPassCtrl.dispose(); super.dispose(); } - Future _handleSubmit() async { - if (!_formKey.currentState!.validate()) return; + void _startCooldown([int seconds = 60]) { + _cooldownTimer?.cancel(); + setState(() => _cooldownSeconds = seconds); + _cooldownTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (!mounted) { + timer.cancel(); + return; + } + if (_cooldownSeconds <= 1) { + timer.cancel(); + setState(() => _cooldownSeconds = 0); + } else { + setState(() => _cooldownSeconds--); + } + }); + } + + // ── Step 0: Find Account & Send Email OTP ───────────────────────────────── + Future _handleSendEmailOtp() async { + if (!_usernameFormKey.currentState!.validate()) return; + final username = _usernameCtrl.text.trim(); + + setState(() => _isLoading = true); + try { + final repo = ref.read(authRepositoryProvider); + + // 1. Lookup account to verify email exists & get masked email + final lookup = await repo.lookupResetOptions(username); + _maskedEmail = lookup['maskedEmail'] as String?; + + // 2. Dispatch OTP to email + await repo.sendEmailOtp(username); + + if (!mounted) return; + _startCooldown(60); + setState(() { + _otpCtrl.clear(); + _currentStep = 1; + }); + } catch (e) { + if (mounted) { + showErrorDialog( + context, + 'Password Reset Request Failed', + e.toString().replaceAll('Exception: ', ''), + ); + } + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + // ── Step 1: Verify OTP & Reset Password ─────────────────────────────────── + Future _handleResetPassword() async { + if (!_resetFormKey.currentState!.validate()) return; final username = _usernameCtrl.text.trim(); + final otp = _otpCtrl.text.trim(); final newPass = _newPassCtrl.text; final confirmPass = _confirmPassCtrl.text; @@ -755,25 +821,26 @@ class _ForgotPasswordDialogState extends ConsumerState<_ForgotPasswordDialog> { return; } - setState(() { - _isLoading = true; - }); + setState(() => _isLoading = true); try { final repo = ref.read(authRepositoryProvider); - await repo.requestPasswordReset( + final message = await repo.resetPasswordEmailOtp( username: username, + otp: otp, newPassword: newPass, confirmPassword: confirmPass, ); + if (mounted) { - widget.onSuccess('Reset request submitted. Awaiting Admin approval.'); + Navigator.pop(context); + widget.onSuccess(message); } } catch (e) { if (mounted) { showErrorDialog( context, - 'Request Failed', + 'Password Reset Failed', e.toString().replaceAll('Exception: ', ''), ); } @@ -785,6 +852,7 @@ class _ForgotPasswordDialogState extends ConsumerState<_ForgotPasswordDialog> { @override Widget build(BuildContext context) { final isMobile = MediaQuery.of(context).size.width < 600; + final isDark = Theme.of(context).brightness == Brightness.dark; return Dialog( shape: RoundedRectangleBorder( @@ -792,91 +860,103 @@ class _ForgotPasswordDialogState extends ConsumerState<_ForgotPasswordDialog> { ), insetPadding: EdgeInsets.all(isMobile ? 16 : 24), child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 420), + constraints: const BoxConstraints(maxWidth: 440), child: Padding( - padding: EdgeInsets.all(isMobile ? 16 : 24), - child: Form( - key: _formKey, - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon( - Icons.lock_reset, - color: AppColors.primaryGreen, - size: isMobile ? 24 : 28, + padding: EdgeInsets.all(isMobile ? 18 : 24), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Row( + children: [ + if (_currentStep > 0) + IconButton( + icon: const Icon(Icons.arrow_back, size: 20), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + onPressed: _isLoading + ? null + : () => setState(() => _currentStep = 0), ), - SizedBox(width: isMobile ? 8 : 12), - Expanded( - child: Text( - 'Forgot Password', - style: TextStyle( - fontSize: isMobile ? 18 : 22, - fontWeight: FontWeight.bold, - ), + if (_currentStep > 0) const SizedBox(width: 8), + Icon( + _currentStep == 0 ? Icons.email_outlined : Icons.lock_reset, + color: AppColors.primaryGreen, + size: isMobile ? 24 : 28, + ), + SizedBox(width: isMobile ? 8 : 12), + Expanded( + child: Text( + _currentStep == 0 + ? 'Forgot Password' + : 'Set New Password', + style: TextStyle( + fontSize: isMobile ? 18 : 20, + fontWeight: FontWeight.bold, ), ), - IconButton( - icon: Icon(Icons.close, size: isMobile ? 20 : 24), - onPressed: () => Navigator.pop(context), - ), - ], + ), + IconButton( + icon: Icon(Icons.close, size: isMobile ? 20 : 24), + onPressed: () => Navigator.pop(context), + ), + ], + ), + + // Step Progress Indicator + Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Row( + children: List.generate(2, (idx) { + final isActive = idx == _currentStep; + final isPast = idx < _currentStep; + return Expanded( + child: Container( + height: 4, + margin: EdgeInsets.only(right: idx == 0 ? 6 : 0), + decoration: BoxDecoration( + color: isActive + ? AppColors.primaryGreen + : (isPast + ? AppColors.primaryGreen.withValues(alpha: 0.4) + : (isDark + ? AppColors.darkBorder + : Colors.grey.shade300)), + borderRadius: BorderRadius.circular(2), + ), + ), + ); + }), ), - SizedBox(height: isMobile ? 4 : 8), + ), + + const Divider(height: 16), + + // ── STEP 0: Enter Username & Send Email OTP ───────────────── + if (_currentStep == 0) ...[ Text( - 'Admin and Teacher accounts only. Your request will be reviewed by the Admin.', + 'Enter your account username. A 6-digit verification code will be sent to your registered email address.', style: TextStyle( - fontSize: isMobile ? 12 : 14, + fontSize: isMobile ? 12 : 13, color: AppColors.textSecondary, ), ), - Divider(height: isMobile ? 20 : 28), - - CustomTextField( - hintText: 'Your Username', - prefixIcon: Icons.person_outline, - controller: _usernameCtrl, - validator: (v) => - AppValidators.validateRequired(v, 'Username'), - ), const SizedBox(height: AppSizes.p16), - Divider(height: isMobile ? 20 : 28), - const SizedBox(height: AppSizes.p8), - CustomTextField( - hintText: 'New Password', - prefixIcon: Icons.lock_outline, - controller: _newPassCtrl, - isPassword: true, - obscureText: _obscurePasswords, - onToggleVisibility: () => setState(() => _obscurePasswords = !_obscurePasswords), - validator: AppValidators.validatePasswordComplexity, - autovalidateMode: AutovalidateMode.onUserInteraction, - onChanged: (v) => setState(() {}), + Form( + key: _usernameFormKey, + child: CustomTextField( + hintText: 'Your Username', + prefixIcon: Icons.person_outline, + controller: _usernameCtrl, + textInputAction: TextInputAction.done, + onSubmitted: (_) => _handleSendEmailOtp(), + validator: (v) => + AppValidators.validateRequired(v, 'Username'), + ), ), - if (_newPassCtrl.text.isNotEmpty) ...[ - PasswordStrengthIndicator(password: _newPassCtrl.text), - ], const SizedBox(height: AppSizes.p24), - CustomTextField( - hintText: 'Confirm New Password', - prefixIcon: Icons.lock_outline, - controller: _confirmPassCtrl, - isPassword: true, - obscureText: _obscurePasswords, - onToggleVisibility: () => setState(() => _obscurePasswords = !_obscurePasswords), - autovalidateMode: AutovalidateMode.onUserInteraction, - onChanged: (v) => setState(() {}), - validator: (v) { - if (v == null || v.isEmpty) return 'Confirm Password is required'; - if (v != _newPassCtrl.text) return 'Passwords do not match'; - return null; - }, - ), - - SizedBox(height: isMobile ? 16 : 24), Row( mainAxisAlignment: MainAxisAlignment.end, children: [ @@ -886,17 +966,167 @@ class _ForgotPasswordDialogState extends ConsumerState<_ForgotPasswordDialog> { ), const SizedBox(width: 12), SizedBox( - width: isMobile ? 130 : 140, + width: isMobile ? 140 : 160, child: PrimaryButton( - label: 'SUBMIT', + label: 'SEND CODE', isLoading: _isLoading, - onPressed: _handleSubmit, + onPressed: _handleSendEmailOtp, ), ), ], ), ], - ), + + // ── STEP 1: Enter OTP & Set New Password ──────────────────── + if (_currentStep == 1) ...[ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.primaryGreen.withValues(alpha: isDark ? 0.12 : 0.08), + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: AppColors.primaryGreen.withValues(alpha: 0.3), + ), + ), + child: Row( + children: [ + const Icon( + Icons.mark_email_read_outlined, + color: AppColors.primaryGreen, + size: 20, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + 'Code sent to ${_maskedEmail ?? "your registered email"}', + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + + const SizedBox(height: AppSizes.p16), + + Form( + key: _resetFormKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 6-digit OTP code input + Resend button + Row( + children: [ + Expanded( + child: CustomTextField( + hintText: '6-digit code', + prefixIcon: Icons.pin_outlined, + controller: _otpCtrl, + keyboardType: TextInputType.number, + maxLength: 6, + validator: (v) { + if (v == null || v.trim().length != 6) { + return 'Enter full 6-digit code'; + } + return null; + }, + ), + ), + const SizedBox(width: 10), + SizedBox( + height: 48, + child: OutlinedButton( + onPressed: (_cooldownSeconds == 0 && !_isLoading) + ? _handleSendEmailOtp + : null, + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + child: Text( + _cooldownSeconds > 0 + ? 'Resend (${_cooldownSeconds}s)' + : 'Resend', + style: const TextStyle(fontSize: 12), + ), + ), + ), + ], + ), + + const SizedBox(height: AppSizes.p16), + + CustomTextField( + hintText: 'New Password', + prefixIcon: Icons.lock_outline, + controller: _newPassCtrl, + isPassword: true, + obscureText: _obscurePasswords, + onToggleVisibility: () => setState( + () => _obscurePasswords = !_obscurePasswords), + validator: AppValidators.validatePasswordComplexity, + autovalidateMode: AutovalidateMode.onUserInteraction, + onChanged: (_) => setState(() {}), + ), + + if (_newPassCtrl.text.isNotEmpty) ...[ + const SizedBox(height: 6), + PasswordStrengthIndicator(password: _newPassCtrl.text), + ], + + const SizedBox(height: AppSizes.p16), + + CustomTextField( + hintText: 'Confirm New Password', + prefixIcon: Icons.lock_outline, + controller: _confirmPassCtrl, + isPassword: true, + obscureText: _obscurePasswords, + onToggleVisibility: () => setState( + () => _obscurePasswords = !_obscurePasswords), + autovalidateMode: AutovalidateMode.onUserInteraction, + validator: (v) { + if (v == null || v.isEmpty) { + return 'Confirm Password is required'; + } + if (v != _newPassCtrl.text) { + return 'Passwords do not match'; + } + return null; + }, + ), + ], + ), + ), + + const SizedBox(height: AppSizes.p24), + + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: _isLoading + ? null + : () => setState(() => _currentStep = 0), + child: const Text('BACK'), + ), + const SizedBox(width: 12), + SizedBox( + width: isMobile ? 110 : 130, + child: PrimaryButton( + label: 'RESET', + isLoading: _isLoading, + onPressed: _handleResetPassword, + ), + ), + ], + ), + ], + ], ), ), ), @@ -905,6 +1135,7 @@ class _ForgotPasswordDialogState extends ConsumerState<_ForgotPasswordDialog> { } } + class _NetworkScanDialog extends StatefulWidget { const _NetworkScanDialog();