diff --git a/b0esche_cloud/lib/services/api_client.dart b/b0esche_cloud/lib/services/api_client.dart index a1595b6..1e1af5e 100644 --- a/b0esche_cloud/lib/services/api_client.dart +++ b/b0esche_cloud/lib/services/api_client.dart @@ -228,6 +228,15 @@ class ApiClient { } } + Future> deleteAccount() async { + try { + final response = await _dio.delete('/user/account'); + return response.data; + } on DioException catch (e) { + throw _handleError(e); + } + } + ApiError _handleError(DioException e) { final status = e.response?.statusCode; final data = e.response?.data; diff --git a/b0esche_cloud/lib/widgets/account_settings_dialog.dart b/b0esche_cloud/lib/widgets/account_settings_dialog.dart index 4a09c79..a9708a4 100644 --- a/b0esche_cloud/lib/widgets/account_settings_dialog.dart +++ b/b0esche_cloud/lib/widgets/account_settings_dialog.dart @@ -193,6 +193,152 @@ class _AccountSettingsDialogState extends State { Navigator.of(context).pop(); } + void _showDeleteAccountConfirmation() { + showDialog( + context: context, + builder: (BuildContext context) { + return Dialog( + backgroundColor: AppTheme.primaryBackground, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Container( + width: 400, + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Warning Icon + Icon( + Icons.delete_forever, + color: AppTheme.errorColor, + size: 48, + ), + const SizedBox(height: 16), + + // Title + Text( + 'Delete Account', + style: TextStyle( + color: AppTheme.primaryText, + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + + // Warning Message + Text( + 'Are you sure you want to delete your account?', + textAlign: TextAlign.center, + style: TextStyle( + color: AppTheme.primaryText, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 8), + Text( + 'This will permanently delete your account and remove all your data from our servers. This action cannot be undone.', + textAlign: TextAlign.center, + style: TextStyle( + color: AppTheme.secondaryText, + fontSize: 14, + height: 1.4, + ), + ), + const SizedBox(height: 24), + + // Buttons + Row( + children: [ + Expanded( + child: TextButton( + onPressed: () => Navigator.of(context).pop(), + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide( + color: AppTheme.secondaryText.withValues( + alpha: 0.3, + ), + ), + ), + ), + child: Text( + 'Cancel', + style: TextStyle( + color: AppTheme.secondaryText, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton( + onPressed: () { + Navigator.of(context).pop(); + _deleteAccount(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.errorColor, + foregroundColor: Colors.white, + elevation: 0, + shadowColor: Colors.transparent, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Text( + 'Delete Account', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + ), + ], + ), + ), + ); + }, + ); + } + + Future _deleteAccount() async { + setState(() => _isLoading = true); + try { + final apiClient = GetIt.I(); + await apiClient.deleteAccount(); + + if (mounted) { + // Log out the user + context.read().add(const LogoutRequested()); + Navigator.of(context).pop(); + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Account deleted successfully')), + ); + } + } catch (e) { + setState(() => _error = e.toString()); + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Failed to delete account: $e'))); + } + } finally { + setState(() => _isLoading = false); + } + } + @override Widget build(BuildContext context) { return Dialog( @@ -200,7 +346,7 @@ class _AccountSettingsDialogState extends State { shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), child: Container( width: 500, - height: 600, + height: 700, padding: const EdgeInsets.all(24), child: Column( children: [ @@ -231,6 +377,7 @@ class _AccountSettingsDialogState extends State { children: [ _buildTabButton('Profile', 0), _buildTabButton('Security', 1), + _buildTabButton('Subscription', 2), ], ), const SizedBox(height: 16), @@ -320,6 +467,8 @@ class _AccountSettingsDialogState extends State { return _buildProfileTab(); case 1: return _buildSecurityTab(); + case 2: + return _buildSubscriptionTab(); default: return const SizedBox.shrink(); } @@ -549,6 +698,546 @@ class _AccountSettingsDialogState extends State { ), ), ), + const SizedBox(height: 48), + + // Danger Zone + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: AppTheme.errorColor.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: AppTheme.errorColor.withValues(alpha: 0.3), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.warning_amber_rounded, + color: AppTheme.errorColor, + size: 24, + ), + const SizedBox(width: 12), + Text( + 'Danger Zone', + style: TextStyle( + color: AppTheme.errorColor, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + 'Once you delete your account, there is no going back. This will permanently delete your account and remove all your data from our servers.', + style: TextStyle( + color: AppTheme.secondaryText, + fontSize: 14, + height: 1.4, + ), + ), + const SizedBox(height: 20), + Center( + child: SizedBox( + width: 160, + child: ElevatedButton( + onPressed: _showDeleteAccountConfirmation, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.errorColor, + foregroundColor: Colors.white, + elevation: 0, + shadowColor: Colors.transparent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.symmetric(vertical: 12), + ), + child: const Text( + 'Delete Account', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildSubscriptionTab() { + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Subscription Status Card + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: AppTheme.primaryBackground.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: AppTheme.accentColor.withValues(alpha: 0.3), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.star, color: AppTheme.accentColor, size: 24), + const SizedBox(width: 12), + Text( + 'Current Plan', + style: TextStyle( + color: AppTheme.primaryText, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 16), + Text( + 'Free Plan', + style: TextStyle( + color: AppTheme.accentColor, + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + 'Perfect for getting started with b0esche.cloud', + style: TextStyle(color: AppTheme.secondaryText, fontSize: 14), + ), + const SizedBox(height: 16), + Row( + children: [ + Icon( + Icons.check_circle, + color: AppTheme.accentColor, + size: 16, + ), + const SizedBox(width: 8), + Text( + '5 GB Storage', + style: TextStyle( + color: AppTheme.primaryText, + fontSize: 14, + ), + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + Icon( + Icons.check_circle, + color: AppTheme.accentColor, + size: 16, + ), + const SizedBox(width: 8), + Text( + 'File Sharing', + style: TextStyle( + color: AppTheme.primaryText, + fontSize: 14, + ), + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + Icon( + Icons.check_circle, + color: AppTheme.accentColor, + size: 16, + ), + const SizedBox(width: 8), + Text( + 'Basic Collaboration', + style: TextStyle( + color: AppTheme.primaryText, + fontSize: 14, + ), + ), + ], + ), + ], + ), + ), + const SizedBox(height: 24), + + // Upgrade Section + Text( + 'Upgrade Options', + style: TextStyle( + color: AppTheme.primaryText, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + + // Pro Plan Card + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: AppTheme.primaryBackground.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: AppTheme.secondaryText.withValues(alpha: 0.2), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.workspace_premium, + color: AppTheme.accentColor, + size: 24, + ), + const SizedBox(width: 12), + Text( + 'Pro Plan', + style: TextStyle( + color: AppTheme.primaryText, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: AppTheme.accentColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + '€9.99/month', + style: TextStyle( + color: AppTheme.accentColor, + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + const SizedBox(height: 16), + Text( + 'Advanced features for power users', + style: TextStyle(color: AppTheme.secondaryText, fontSize: 14), + ), + const SizedBox(height: 16), + Row( + children: [ + Icon( + Icons.check_circle, + color: AppTheme.accentColor, + size: 16, + ), + const SizedBox(width: 8), + Text( + '100 GB Storage', + style: TextStyle( + color: AppTheme.primaryText, + fontSize: 14, + ), + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + Icon( + Icons.check_circle, + color: AppTheme.accentColor, + size: 16, + ), + const SizedBox(width: 8), + Text( + 'Advanced Sharing', + style: TextStyle( + color: AppTheme.primaryText, + fontSize: 14, + ), + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + Icon( + Icons.check_circle, + color: AppTheme.accentColor, + size: 16, + ), + const SizedBox(width: 8), + Text( + 'Priority Support', + style: TextStyle( + color: AppTheme.primaryText, + fontSize: 14, + ), + ), + ], + ), + const SizedBox(height: 16), + Center( + child: SizedBox( + width: 120, + child: ModernGlassButton( + onPressed: () { + // TODO: Implement upgrade functionality + }, + child: const Text('Upgrade'), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + + // Enterprise Plan Card + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + AppTheme.accentColor.withValues(alpha: 0.1), + AppTheme.primaryBackground.withValues(alpha: 0.3), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: AppTheme.accentColor.withValues(alpha: 0.4), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.business, color: AppTheme.accentColor, size: 24), + const SizedBox(width: 12), + Text( + 'Enterprise Plan', + style: TextStyle( + color: AppTheme.primaryText, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: AppTheme.accentColor.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + 'Contact Us', + style: TextStyle( + color: AppTheme.accentColor, + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + const SizedBox(height: 16), + Text( + 'Custom solutions for organizations', + style: TextStyle(color: AppTheme.secondaryText, fontSize: 14), + ), + const SizedBox(height: 16), + Row( + children: [ + Icon( + Icons.check_circle, + color: AppTheme.accentColor, + size: 16, + ), + const SizedBox(width: 8), + Text( + 'Unlimited Storage', + style: TextStyle( + color: AppTheme.primaryText, + fontSize: 14, + ), + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + Icon( + Icons.check_circle, + color: AppTheme.accentColor, + size: 16, + ), + const SizedBox(width: 8), + Text( + 'Advanced Security', + style: TextStyle( + color: AppTheme.primaryText, + fontSize: 14, + ), + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + Icon( + Icons.check_circle, + color: AppTheme.accentColor, + size: 16, + ), + const SizedBox(width: 8), + Text( + 'Dedicated Support', + style: TextStyle( + color: AppTheme.primaryText, + fontSize: 14, + ), + ), + ], + ), + const SizedBox(height: 16), + Center( + child: SizedBox( + width: 140, + child: ModernGlassButton( + onPressed: () { + // TODO: Implement contact functionality + }, + child: const Text('Contact Sales'), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 24), + + // Usage Stats + Text( + 'Usage Statistics', + style: TextStyle( + color: AppTheme.primaryText, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: AppTheme.primaryBackground.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: AppTheme.secondaryText.withValues(alpha: 0.2), + ), + ), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Storage Used', + style: TextStyle( + color: AppTheme.primaryText, + fontSize: 14, + ), + ), + Text( + '2.3 GB / 5 GB', + style: TextStyle( + color: AppTheme.secondaryText, + fontSize: 14, + ), + ), + ], + ), + const SizedBox(height: 12), + LinearProgressIndicator( + value: 0.46, // 2.3GB / 5GB + backgroundColor: AppTheme.secondaryText.withValues( + alpha: 0.2, + ), + valueColor: AlwaysStoppedAnimation( + AppTheme.accentColor, + ), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Files Shared', + style: TextStyle( + color: AppTheme.primaryText, + fontSize: 14, + ), + ), + Text( + '47 files', + style: TextStyle( + color: AppTheme.secondaryText, + fontSize: 14, + ), + ), + ], + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Active Links', + style: TextStyle( + color: AppTheme.primaryText, + fontSize: 14, + ), + ), + Text( + '12 links', + style: TextStyle( + color: AppTheme.secondaryText, + fontSize: 14, + ), + ), + ], + ), + ], + ), + ), ], ), ); diff --git a/go_cloud/internal/http/routes.go b/go_cloud/internal/http/routes.go index cd8cf06..3c97095 100644 --- a/go_cloud/internal/http/routes.go +++ b/go_cloud/internal/http/routes.go @@ -247,6 +247,9 @@ func NewRouter(cfg *config.Config, db *database.DB, jwtManager *jwt.Manager, aut r.Post("/user/avatar", func(w http.ResponseWriter, req *http.Request) { uploadUserAvatarHandler(w, req, db, auditLogger, cfg) }) + r.Delete("/user/account", func(w http.ResponseWriter, req *http.Request) { + deleteUserAccountHandler(w, req, db, auditLogger, cfg) + }) // Org routes r.Get("/orgs", func(w http.ResponseWriter, req *http.Request) { @@ -4056,3 +4059,124 @@ func uploadUserAvatarHandler(w http.ResponseWriter, r *http.Request, db *databas "avatarUrl": publicURL, }) } + +// deleteUserAccountHandler handles user account deletion +func deleteUserAccountHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger, cfg *config.Config) { + userIDStr, ok := middleware.GetUserID(r.Context()) + if !ok { + errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized) + return + } + + userID, err := uuid.Parse(userIDStr) + if err != nil { + errors.WriteError(w, errors.CodeInvalidArgument, "Invalid user ID", http.StatusBadRequest) + return + } + + // Start transaction for atomic deletion + tx, err := db.BeginTx(r.Context(), nil) + if err != nil { + errors.LogError(r, err, "Failed to start transaction") + errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError) + return + } + defer tx.Rollback() + + // Get user details for audit logging + var username string + err = tx.QueryRowContext(r.Context(), "SELECT username FROM users WHERE id = $1", userID).Scan(&username) + if err != nil { + errors.LogError(r, err, "Failed to get user details") + errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError) + return + } + + // Delete file shares (both personal and org shares) + _, err = tx.ExecContext(r.Context(), ` + DELETE FROM file_share_links + WHERE created_by_user_id = $1 + `, userID) + if err != nil { + errors.LogError(r, err, "Failed to delete file shares") + errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError) + return + } + + // Delete user files and their Nextcloud data + rows, err := tx.QueryContext(r.Context(), ` + SELECT path FROM files WHERE user_id = $1 AND org_id IS NULL + `, userID) + if err != nil { + errors.LogError(r, err, "Failed to get user files") + errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError) + return + } + defer rows.Close() + + // Delete files from Nextcloud storage + client := storage.NewWebDAVClient(cfg) + for rows.Next() { + var filePath string + if err := rows.Scan(&filePath); err != nil { + continue // Skip on error + } + + // Try to delete from Nextcloud (ignore errors as files might not exist) + client.Delete(r.Context(), filePath) + } + + // Delete database records + _, err = tx.ExecContext(r.Context(), "DELETE FROM files WHERE user_id = $1 AND org_id IS NULL", userID) + if err != nil { + errors.LogError(r, err, "Failed to delete user files") + errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError) + return + } + + // Remove user from all organizations (this will cascade to org files if needed) + _, err = tx.ExecContext(r.Context(), "DELETE FROM memberships WHERE user_id = $1", userID) + if err != nil { + errors.LogError(r, err, "Failed to remove user from organizations") + errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError) + return + } + + // Delete user sessions + _, err = tx.ExecContext(r.Context(), "DELETE FROM sessions WHERE user_id = $1", userID) + if err != nil { + errors.LogError(r, err, "Failed to delete user sessions") + errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError) + return + } + + // Finally, delete the user account + _, err = tx.ExecContext(r.Context(), "DELETE FROM users WHERE id = $1", userID) + if err != nil { + errors.LogError(r, err, "Failed to delete user account") + errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError) + return + } + + // Commit transaction + if err = tx.Commit(); err != nil { + errors.LogError(r, err, "Failed to commit transaction") + errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError) + return + } + + // Audit log the account deletion + auditLogger.Log(r.Context(), audit.Entry{ + UserID: &userID, + Action: "account_delete", + Success: true, + Metadata: map[string]interface{}{ + "username": username, + }, + }) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "message": "Account deleted successfully", + }) +}