From 689dbc48e787d9d427aa945e67933ed422a26d26 Mon Sep 17 00:00:00 2001 From: ssbg04 Date: Mon, 21 Sep 2026 17:51:33 +0800 Subject: [PATCH] fix: self-deactivation, UI overflow, Open With, archive nav, schedule modal Settings - Delete Account -> Self Deactivate: - backend: add POST /api/auth/self-deactivate (immediate, no email link) Guards against last-admin and hidden super-admin. Revokes FCM tokens, sends notification email, logs activity - auth.js route: add /self-deactivate route - auth_repository.dart: add requestSelfDeactivation() - auth_provider.dart: add requestSelfDeactivation() (calls logout() after) - settings_screen.dart: 2-step _handleDeactivateAccount: Step 1 confirm, Step 2 type DEACTIVATE. Danger zone card updated to deactivation theme Report screen - compliance filter button label overflow: - Wrap label Text in Flexible to ellipsize instead of clip Settings - Edit Schedule Dates modal button overflow: - Column layout on narrow screens so buttons are never squished Document screen - Open With not working on Windows: - Use openwith.exe (Windows 8+) with fallback to rundll32 OpenAs_RunDLL Windows sidebar - Archive tab: - Move Archives from ACCOUNT group to OVERVIEW group --- backend/src/controllers/authController.js | 57 +++++ backend/src/routes/auth.js | 3 + frontend/AGENTS.md | 11 + .../domain/repositories/auth_repository.dart | 17 ++ .../ui/layouts/windows_sidebar_layout.dart | 2 +- frontend/lib/ui/providers/auth_provider.dart | 8 + .../widgets/document_preview_modal.dart | 18 +- .../ui/screens/reports/reports_screen.dart | 10 +- .../ui/screens/settings/settings_screen.dart | 226 ++++++++++++++---- 9 files changed, 293 insertions(+), 59 deletions(-) diff --git a/backend/src/controllers/authController.js b/backend/src/controllers/authController.js index d180fde..2974691 100644 --- a/backend/src/controllers/authController.js +++ b/backend/src/controllers/authController.js @@ -833,6 +833,63 @@ exports.completePasswordReset = (req, res) => { } }; +// POST /api/auth/self-deactivate +// Lets the authenticated user deactivate their own account immediately. +// No email link is required — just 2-step client-side confirmation. +exports.selfDeactivateAccount = async (req, res) => { + const userId = req.user?.id; + if (!userId) return res.status(401).json({ message: 'Authentication required' }); + + try { + const user = db.prepare('SELECT id, username, first_name, last_name, email, role, is_active, is_hidden FROM users WHERE id = ?').get(userId); + if (!user) return res.status(404).json({ message: 'User account not found' }); + + if (user.is_hidden === 1) { + return res.status(403).json({ message: 'Developer super administrator account cannot be deactivated.' }); + } + + if (user.role === 'admin') { + const otherActiveAdmins = db.prepare( + "SELECT COUNT(*) as count FROM users WHERE role = 'admin' AND is_active = 1 AND (is_hidden = 0 OR is_hidden IS NULL) AND id != ?" + ).get(user.id); + if (!otherActiveAdmins || otherActiveAdmins.count < 1) { + return res.status(403).json({ message: 'Cannot deactivate the only active administrator account. Please promote another admin first.' }); + } + } + + // Deactivate the account + db.prepare("UPDATE users SET is_active = 0, updated_at = (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) WHERE id = ?").run(userId); + + // Revoke all FCM push tokens + db.prepare('DELETE FROM fcm_tokens WHERE user_id = ?').run(userId); + + // Log activity + try { + db.prepare('INSERT INTO activity_log (user_id, action, entity_type, entity_id, description) VALUES (?, ?, ?, ?, ?)') + .run(userId, 'UPDATE', 'user', userId, `Self-deactivated account @${user.username}`); + } catch (_) {} + + // Send email notification if available (async, fire-and-forget) + const { sendAccountStatusEmail } = require('../services/emailService'); + if (user.email && user.email.trim()) { + sendAccountStatusEmail({ + to: user.email.trim(), + username: user.username, + fullName: [user.first_name, user.last_name].filter(Boolean).join(' '), + isActivated: false, + }).catch(() => {}); + } + + res.json({ + success: true, + message: 'Your account has been deactivated. You will be signed out.', + }); + } catch (error) { + console.error('selfDeactivateAccount error:', error); + res.status(500).json({ message: 'Failed to deactivate account', error: error.message }); + } +}; + // POST /api/auth/request-delete-account exports.requestAccountDeletion = async (req, res) => { const userId = req.user?.id; diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index 2c0ddd8..3794f00 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -24,5 +24,8 @@ router.post('/request-delete-account', authenticateToken, authController.request router.get('/confirm-delete-account-web', authController.confirmDeleteAccountWebPage); router.post('/confirm-delete-account', authController.confirmDeleteAccount); +// Self-Service Account Deactivation (immediate, 2-step client confirmation) +router.post('/self-deactivate', authenticateToken, authController.selfDeactivateAccount); + module.exports = router; diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 5f96da4..02f4f7d 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -749,6 +749,17 @@ Files backup/restore - mirror mode or zip --> # HISTORY SCREEN - "F:\SumbrerongBato\tis_rms_server\screenshots\Screenshot 2026-09-19 164307.png" fix this windows app cant list the widgets because and fix too overflow and cant list the widgets in android app F:\SumbrerongBato\tis_rms_server\screenshots\history.png --> +# SETTINGS SCREEN +- Delete account -> update to self deactivates, remove email function for deletion, use email for deactivation add client side 2 times confirmation dialog + +- report screen -> compliance -> fix fliter button label is in half +- settings screen -> acadmeic year and auto graduation -> edit schedule date modal -> fix button labels is in half + +# DOCUMENT SCREEN +- file preview screen -> open with is not working + +# WINDOWS SIDE NAV BAR +- windows app -> side bar nav move the archive tab to overview group --- diff --git a/frontend/lib/domain/repositories/auth_repository.dart b/frontend/lib/domain/repositories/auth_repository.dart index e1d9e53..fd93181 100644 --- a/frontend/lib/domain/repositories/auth_repository.dart +++ b/frontend/lib/domain/repositories/auth_repository.dart @@ -271,5 +271,22 @@ class AuthRepository { throw Exception(errorMessage); } } + + /// Immediately deactivate own account (no email required — 2-step client confirmation) + Future requestSelfDeactivation() async { + try { + final options = await _getAuthOptions(); + final response = await _dio.post( + '/auth/self-deactivate', + options: options, + ); + return response.data['message'] as String? ?? + 'Your account has been deactivated.'; + } on DioException catch (e) { + final errorMessage = + e.response?.data['message'] ?? 'Failed to deactivate account.'; + throw Exception(errorMessage); + } + } } diff --git a/frontend/lib/ui/layouts/windows_sidebar_layout.dart b/frontend/lib/ui/layouts/windows_sidebar_layout.dart index 27f230c..67672a4 100644 --- a/frontend/lib/ui/layouts/windows_sidebar_layout.dart +++ b/frontend/lib/ui/layouts/windows_sidebar_layout.dart @@ -303,7 +303,7 @@ class _WindowsSidebarLayoutState extends ConsumerState { 'roles': ['admin', 'teacher'], }, { - 'category': 'ACCOUNT', + 'category': 'OVERVIEW', 'label': 'Archives', 'icon': Icons.archive_outlined, 'activeIcon': Icons.archive, diff --git a/frontend/lib/ui/providers/auth_provider.dart b/frontend/lib/ui/providers/auth_provider.dart index 2819f7b..3100042 100644 --- a/frontend/lib/ui/providers/auth_provider.dart +++ b/frontend/lib/ui/providers/auth_provider.dart @@ -121,6 +121,14 @@ class AuthNotifier extends AsyncNotifier { final repository = ref.read(authRepositoryProvider); return await repository.requestAccountDeletion(); } + + Future requestSelfDeactivation() async { + final repository = ref.read(authRepositoryProvider); + final msg = await repository.requestSelfDeactivation(); + // Sign out locally after deactivation + await logout(); + return msg; + } } // Full profile data provider diff --git a/frontend/lib/ui/screens/documents/widgets/document_preview_modal.dart b/frontend/lib/ui/screens/documents/widgets/document_preview_modal.dart index 67f4229..8cf547d 100644 --- a/frontend/lib/ui/screens/documents/widgets/document_preview_modal.dart +++ b/frontend/lib/ui/screens/documents/widgets/document_preview_modal.dart @@ -308,12 +308,20 @@ class _DocumentPreviewDialogState if (Platform.isWindows) { if (openWith) { - // Show Windows "Open With" dialog - await Process.run( - 'cmd', - ['/c', 'rundll32.exe', 'shell32.dll,OpenAs_RunDLL', filePath], - runInShell: true, + // Use openwith.exe (built-in since Windows 8) for the native "Open With" dialog + final result = await Process.run( + 'openwith.exe', + [filePath], + runInShell: false, ); + if (result.exitCode != 0) { + // Fallback: use rundll32 shell verb + await Process.run( + 'cmd', + ['/c', 'rundll32.exe', 'shell32.dll,OpenAs_RunDLL', filePath], + runInShell: true, + ); + } } else { await Process.run( 'cmd', diff --git a/frontend/lib/ui/screens/reports/reports_screen.dart b/frontend/lib/ui/screens/reports/reports_screen.dart index 590d03b..cf69e6b 100644 --- a/frontend/lib/ui/screens/reports/reports_screen.dart +++ b/frontend/lib/ui/screens/reports/reports_screen.dart @@ -843,9 +843,13 @@ class _ReportsScreenState extends ConsumerState { child: OutlinedButton.icon( onPressed: () => _showComplianceFilterModal(context), icon: const Icon(Icons.filter_list_rounded, size: 16), - label: Text( - activeFilterCount > 0 ? 'Filter ($activeFilterCount)' : 'Filter', - style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold), + label: Flexible( + child: Text( + activeFilterCount > 0 ? 'Filter ($activeFilterCount)' : 'Filter', + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), ), style: OutlinedButton.styleFrom( foregroundColor: activeFilterCount > 0 diff --git a/frontend/lib/ui/screens/settings/settings_screen.dart b/frontend/lib/ui/screens/settings/settings_screen.dart index 21c65d5..41a6a91 100644 --- a/frontend/lib/ui/screens/settings/settings_screen.dart +++ b/frontend/lib/ui/screens/settings/settings_screen.dart @@ -398,24 +398,23 @@ class _SettingsScreenState extends ConsumerState { } } - Future _handleDeleteAccount(BuildContext context, WidgetRef ref) async { - final confirmed = await showDialog( + Future _handleDeactivateAccount(BuildContext context, WidgetRef ref) async { + // Step 1: First confirmation + final step1 = await showDialog( context: context, builder: (ctx) { final isNarrow = MediaQuery.of(ctx).size.width < 600 || Theme.of(ctx).platform == TargetPlatform.android; return AlertDialog( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), title: const Row( children: [ Icon(Icons.warning_amber_rounded, color: AppColors.error, size: 28), SizedBox(width: 8), Expanded( child: Text( - 'Delete Account', + 'Deactivate Account', style: TextStyle( fontSize: 18, fontWeight: FontWeight.bold, @@ -426,9 +425,9 @@ class _SettingsScreenState extends ConsumerState { ], ), content: const Text( - 'To permanently delete your account, a verification link will be sent to your registered email address.\n\n' - 'The link will expire in 15 minutes. Once confirmed via email, your account cannot be recovered.\n\n' - 'Do you want to proceed?', + 'Deactivating your account will immediately revoke your access to the system.\n\n' + 'Your data and records will be preserved. An administrator can reactivate your account later.\n\n' + 'Are you sure you want to continue?', ), actions: [ if (isNarrow) @@ -441,7 +440,7 @@ class _SettingsScreenState extends ConsumerState { foregroundColor: Colors.white, ), onPressed: () => Navigator.pop(ctx, true), - child: const Text('SEND VERIFICATION EMAIL'), + child: const Text('YES, CONTINUE'), ), const SizedBox(height: 8), OutlinedButton( @@ -456,10 +455,7 @@ class _SettingsScreenState extends ConsumerState { children: [ TextButton( onPressed: () => Navigator.pop(ctx, false), - child: const Text( - 'CANCEL', - style: TextStyle(color: Colors.grey), - ), + child: const Text('CANCEL', style: TextStyle(color: Colors.grey)), ), const SizedBox(width: 8), ElevatedButton( @@ -468,7 +464,7 @@ class _SettingsScreenState extends ConsumerState { foregroundColor: Colors.white, ), onPressed: () => Navigator.pop(ctx, true), - child: const Text('SEND VERIFICATION EMAIL'), + child: const Text('YES, CONTINUE'), ), ], ), @@ -477,24 +473,119 @@ class _SettingsScreenState extends ConsumerState { }, ); - if (confirmed != true || !context.mounted) return; + if (step1 != true || !context.mounted) return; + + // Step 2: Type-to-confirm + final confirmController = TextEditingController(); + final step2 = await showDialog( + context: context, + barrierDismissible: false, + builder: (ctx) { + return StatefulBuilder( + builder: (ctx, setState) { + final isNarrow = + MediaQuery.of(ctx).size.width < 600 || + Theme.of(ctx).platform == TargetPlatform.android; + return AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + title: const Row( + children: [ + Icon(Icons.lock_outline, color: AppColors.error, size: 26), + SizedBox(width: 8), + Expanded( + child: Text( + 'Final Confirmation', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColors.error, + ), + ), + ), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'To confirm, type DEACTIVATE in the field below:', + style: TextStyle(fontWeight: FontWeight.w500), + ), + const SizedBox(height: 12), + TextField( + controller: confirmController, + autofocus: true, + decoration: const InputDecoration( + hintText: 'Type DEACTIVATE', + border: OutlineInputBorder(), + isDense: true, + ), + onChanged: (_) => setState(() {}), + ), + ], + ), + actions: [ + if (isNarrow) + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.error, + foregroundColor: Colors.white, + ), + onPressed: confirmController.text.trim() == 'DEACTIVATE' + ? () => Navigator.pop(ctx, true) + : null, + child: const Text('DEACTIVATE MY ACCOUNT'), + ), + const SizedBox(height: 8), + OutlinedButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('CANCEL'), + ), + ], + ) + else + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('CANCEL', style: TextStyle(color: Colors.grey)), + ), + const SizedBox(width: 8), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.error, + foregroundColor: Colors.white, + ), + onPressed: confirmController.text.trim() == 'DEACTIVATE' + ? () => Navigator.pop(ctx, true) + : null, + child: const Text('DEACTIVATE'), + ), + ], + ), + ], + ); + }, + ); + }, + ); + confirmController.dispose(); + + if (step2 != true || !context.mounted) return; try { - final msg = - await ref.read(authProvider.notifier).requestAccountDeletion(); - if (!context.mounted) return; - showSuccessDialog( - context, - title: 'Verification Email Sent', - message: msg, - notes: - 'Please check your registered email inbox to permanently delete your account.', - ); + await ref.read(authProvider.notifier).requestSelfDeactivation(); + // requestSelfDeactivation calls logout() internally — app will navigate to login } catch (e) { if (!context.mounted) return; showErrorDialog( context, - 'Request Failed', + 'Deactivation Failed', e.toString().replaceAll('Exception: ', ''), ); } @@ -1833,7 +1924,7 @@ class _SettingsScreenState extends ConsumerState { borderRadius: BorderRadius.circular(8), ), child: const Icon( - Icons.delete_forever_outlined, + Icons.person_off_outlined, color: AppColors.error, size: 22, ), @@ -1844,7 +1935,7 @@ class _SettingsScreenState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( - 'Danger Zone: Delete Account', + 'Danger Zone: Deactivate Account', style: TextStyle( fontSize: 16, fontWeight: FontWeight.bold, @@ -1853,7 +1944,7 @@ class _SettingsScreenState extends ConsumerState { ), const SizedBox(height: 4), Text( - 'Permanently delete your account. A verification link will be sent to your registered email to confirm this action.', + 'Temporarily deactivate your account. Your data is preserved and an administrator can reactivate it later.', style: TextStyle( fontSize: 13, color: Theme.of(context) @@ -1882,12 +1973,12 @@ class _SettingsScreenState extends ConsumerState { borderRadius: BorderRadius.circular(8), ), ), - icon: const Icon(Icons.delete_outline, size: 18), + icon: const Icon(Icons.person_off_outlined, size: 18), label: const Text( - 'Delete Account', + 'Deactivate Account', style: TextStyle(fontWeight: FontWeight.w600), ), - onPressed: () => _handleDeleteAccount(context, ref), + onPressed: () => _handleDeactivateAccount(context, ref), ), ), ], @@ -2627,20 +2718,11 @@ class _EditScheduleDatesDialogState ), ), const SizedBox(height: 24), - Row( - children: [ - Expanded( - child: SizedBox( - height: 44, - child: OutlinedButton( - onPressed: () => Navigator.pop(context), - child: const Text('Cancel'), - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: SizedBox( + if (isNarrow) + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox( height: 44, child: ElevatedButton( onPressed: _isLoading ? null : _handleSave, @@ -2660,9 +2742,53 @@ class _EditScheduleDatesDialogState : const Text('Save Dates'), ), ), - ), - ], - ), + const SizedBox(height: 10), + SizedBox( + height: 44, + child: OutlinedButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + ), + ], + ) + else + Row( + children: [ + Expanded( + child: SizedBox( + height: 44, + child: OutlinedButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: SizedBox( + height: 44, + child: ElevatedButton( + onPressed: _isLoading ? null : _handleSave, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primaryGreen, + foregroundColor: Colors.white, + ), + child: _isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Text('Save Dates'), + ), + ), + ), + ], + ), ], ), ),