From ecc02e10cc71eb9f5c9c312931597668248be8fa Mon Sep 17 00:00:00 2001 From: Patrick Britton Date: Sat, 7 Feb 2026 11:13:11 -0600 Subject: [PATCH] feat: implement account deactivation, deletion (14-day), and immediate destroy with email confirmation --- go-backend/cmd/api/main.go | 36 +++ .../internal/handlers/account_handler.go | 306 ++++++++++++++++++ go-backend/internal/handlers/auth_handler.go | 9 +- go-backend/internal/models/user.go | 11 +- .../internal/repository/user_repository.go | 163 ++++++++++ go-backend/internal/services/email_service.go | 5 + .../profile/profile_settings_screen.dart | 206 +++++++++++- 7 files changed, 726 insertions(+), 10 deletions(-) create mode 100644 go-backend/internal/handlers/account_handler.go diff --git a/go-backend/cmd/api/main.go b/go-backend/cmd/api/main.go index 370ccf1..fc4cd0f 100644 --- a/go-backend/cmd/api/main.go +++ b/go-backend/cmd/api/main.go @@ -160,6 +160,8 @@ func main() { adminHandler := handlers.NewAdminHandler(dbPool, moderationService, appealService, emailService, openRouterService, cfg.JWTSecret, cfg.TurnstileSecretKey, s3Client, cfg.R2MediaBucket, cfg.R2VideoBucket, cfg.R2ImgDomain, cfg.R2VidDomain) + accountHandler := handlers.NewAccountHandler(userRepo, emailService, cfg) + mediaHandler := handlers.NewMediaHandler( s3Client, cfg.R2AccountID, @@ -360,6 +362,16 @@ func main() { authorized.DELETE("/notifications/device", notificationHandler.UnregisterDevice) authorized.DELETE("/notifications/devices", notificationHandler.UnregisterAllDevices) + // Account Lifecycle routes + account := authorized.Group("/account") + { + account.GET("/status", accountHandler.GetAccountStatus) + account.POST("/deactivate", accountHandler.DeactivateAccount) + account.DELETE("", accountHandler.DeleteAccount) + account.POST("/cancel-deletion", accountHandler.CancelDeletion) + account.POST("/destroy", accountHandler.RequestImmediateDestroy) + } + // Appeal System routes appeals := authorized.Group("/appeals") { @@ -457,6 +469,9 @@ func main() { // Public claim request endpoint (no auth) r.POST("/api/v1/username-claim", adminHandler.SubmitClaimRequest) + // Account destroy confirmation (accessed via email link, no auth) + r.GET("/api/v1/account/destroy/confirm", accountHandler.ConfirmImmediateDestroy) + srv := &http.Server{ Addr: ":" + cfg.Port, Handler: r, @@ -468,6 +483,27 @@ func main() { } }() + // Background job: purge accounts past 14-day deletion window (runs every hour) + go func() { + ticker := time.NewTicker(1 * time.Hour) + defer ticker.Stop() + for range ticker.C { + ids, err := userRepo.GetAccountsPendingPurge(context.Background()) + if err != nil { + log.Error().Err(err).Msg("[Purge] Failed to fetch accounts pending purge") + continue + } + for _, id := range ids { + log.Warn().Str("user_id", id).Msg("[Purge] Auto-purging account past 14-day grace period") + if err := userRepo.CascadePurgeUser(context.Background(), id); err != nil { + log.Error().Err(err).Str("user_id", id).Msg("[Purge] FAILED to purge account") + } else { + log.Info().Str("user_id", id).Msg("[Purge] Account permanently destroyed") + } + } + } + }() + log.Info().Msgf("Server started on port %s", cfg.Port) quit := make(chan os.Signal, 1) diff --git a/go-backend/internal/handlers/account_handler.go b/go-backend/internal/handlers/account_handler.go new file mode 100644 index 0000000..0df5f56 --- /dev/null +++ b/go-backend/internal/handlers/account_handler.go @@ -0,0 +1,306 @@ +package handlers + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "fmt" + "net/http" + "time" + + "github.com/gin-gonic/gin" + "github.com/patbritton/sojorn-backend/internal/config" + "github.com/patbritton/sojorn-backend/internal/repository" + "github.com/patbritton/sojorn-backend/internal/services" + "github.com/rs/zerolog/log" +) + +type AccountHandler struct { + repo *repository.UserRepository + emailService *services.EmailService + config *config.Config +} + +func NewAccountHandler(repo *repository.UserRepository, emailService *services.EmailService, cfg *config.Config) *AccountHandler { + return &AccountHandler{repo: repo, emailService: emailService, config: cfg} +} + +// DeactivateAccount sets the user's status to deactivated. +// All data is preserved indefinitely. User can reactivate by logging in. +func (h *AccountHandler) DeactivateAccount(c *gin.Context) { + userIDStr, _ := c.Get("user_id") + userID := userIDStr.(string) + + user, err := h.repo.GetUserByID(c.Request.Context(), userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get account"}) + return + } + + if user.Status == "deactivated" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Account is already deactivated"}) + return + } + + if err := h.repo.DeactivateUser(c.Request.Context(), userID); err != nil { + log.Error().Err(err).Str("user_id", userID).Msg("Failed to deactivate account") + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to deactivate account"}) + return + } + + // Revoke all tokens so they're logged out everywhere + _ = h.repo.RevokeAllUserTokens(c.Request.Context(), userID) + + log.Info().Str("user_id", userID).Msg("Account deactivated") + c.JSON(http.StatusOK, gin.H{ + "message": "Your account has been deactivated. All your data is preserved. You can reactivate at any time by logging back in.", + "status": "deactivated", + }) +} + +// DeleteAccount schedules the account for deletion after 14 days. +// During the grace period, the user can cancel by logging back in. +func (h *AccountHandler) DeleteAccount(c *gin.Context) { + userIDStr, _ := c.Get("user_id") + userID := userIDStr.(string) + + if err := h.repo.ScheduleDeletion(c.Request.Context(), userID); err != nil { + log.Error().Err(err).Str("user_id", userID).Msg("Failed to schedule account deletion") + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to schedule deletion"}) + return + } + + // Revoke all tokens + _ = h.repo.RevokeAllUserTokens(c.Request.Context(), userID) + + deletionDate := time.Now().Add(14 * 24 * time.Hour).Format("January 2, 2006") + + log.Info().Str("user_id", userID).Msg("Account scheduled for deletion in 14 days") + c.JSON(http.StatusOK, gin.H{ + "message": fmt.Sprintf("Your account is scheduled for permanent deletion on %s. Log back in before then to cancel. After that date, all data will be irreversibly destroyed.", deletionDate), + "status": "pending_deletion", + "deletion_date": deletionDate, + }) +} + +// RequestImmediateDestroy initiates the super-delete flow. +// Sends a confirmation email with a one-time token. Nothing is deleted yet. +func (h *AccountHandler) RequestImmediateDestroy(c *gin.Context) { + userIDStr, _ := c.Get("user_id") + userID := userIDStr.(string) + + user, err := h.repo.GetUserByID(c.Request.Context(), userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get account"}) + return + } + + profile, _ := h.repo.GetProfileByID(c.Request.Context(), userID) + displayName := "there" + if profile != nil && profile.DisplayName != nil { + displayName = *profile.DisplayName + } + + // Generate a secure one-time token + rawToken, err := generateDestroyToken() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate confirmation token"}) + return + } + + hash := sha256.Sum256([]byte(rawToken)) + hashString := hex.EncodeToString(hash[:]) + + // Store as auth_token with type 'destroy_confirm', expires in 1 hour + if err := h.repo.CreateVerificationToken(c.Request.Context(), hashString, userID, 1*time.Hour); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to store confirmation token"}) + return + } + + // Send the confirmation email with strong language + go func() { + if err := h.sendDestroyConfirmationEmail(user.Email, displayName, rawToken); err != nil { + log.Error().Err(err).Str("user_id", userID).Msg("Failed to send destroy confirmation email") + } + }() + + log.Warn().Str("user_id", userID).Msg("Immediate destroy requested — confirmation email sent") + c.JSON(http.StatusOK, gin.H{ + "message": "A confirmation email has been sent. You must click the link in that email to permanently and immediately destroy your account. This action cannot be undone.", + }) +} + +// ConfirmImmediateDestroy is the endpoint hit from the email confirmation link. +// It verifies the token and permanently purges all user data. +func (h *AccountHandler) ConfirmImmediateDestroy(c *gin.Context) { + token := c.Query("token") + if token == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Missing confirmation token"}) + return + } + + hash := sha256.Sum256([]byte(token)) + hashString := hex.EncodeToString(hash[:]) + + userID, expiresAt, err := h.repo.GetVerificationToken(c.Request.Context(), hashString) + if err != nil { + c.Redirect(http.StatusFound, h.config.AppBaseURL+"/verify-error?reason=invalid") + return + } + + if time.Now().After(expiresAt) { + _ = h.repo.DeleteVerificationToken(c.Request.Context(), hashString) + c.Redirect(http.StatusFound, h.config.AppBaseURL+"/verify-error?reason=expired") + return + } + + // Clean up the token + _ = h.repo.DeleteVerificationToken(c.Request.Context(), hashString) + + // Revoke all tokens first + _ = h.repo.RevokeAllUserTokens(c.Request.Context(), userID) + + // CASCADE PURGE — point of no return + if err := h.repo.CascadePurgeUser(c.Request.Context(), userID); err != nil { + log.Error().Err(err).Str("user_id", userID).Msg("CRITICAL: Cascade purge failed") + c.JSON(http.StatusInternalServerError, gin.H{"error": "Account destruction failed. Please contact support."}) + return + } + + log.Warn().Str("user_id", userID).Msg("ACCOUNT DESTROYED — all data permanently purged") + + // Return a simple HTML goodbye page (this is accessed via browser from email link) + c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(` +Account Destroyed + +

Account Destroyed

+

Your account and all associated data have been permanently destroyed. This action cannot be undone.

+

Goodbye. — Sojorn

`)) +} + +// GetAccountStatus returns the current account lifecycle status +func (h *AccountHandler) GetAccountStatus(c *gin.Context) { + userIDStr, _ := c.Get("user_id") + userID := userIDStr.(string) + + user, err := h.repo.GetUserByID(c.Request.Context(), userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get account"}) + return + } + + result := gin.H{ + "status": user.Status, + } + + if user.DeletedAt != nil { + result["deletion_scheduled"] = user.DeletedAt.Format(time.RFC3339) + } + + c.JSON(http.StatusOK, result) +} + +// CancelDeletion allows a user to cancel a pending deletion +func (h *AccountHandler) CancelDeletion(c *gin.Context) { + userIDStr, _ := c.Get("user_id") + userID := userIDStr.(string) + + user, err := h.repo.GetUserByID(c.Request.Context(), userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get account"}) + return + } + + if string(user.Status) != "pending_deletion" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Account is not pending deletion"}) + return + } + + if err := h.repo.CancelDeletion(c.Request.Context(), userID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to cancel deletion"}) + return + } + + log.Info().Str("user_id", userID).Msg("Account deletion cancelled, reactivated") + c.JSON(http.StatusOK, gin.H{ + "message": "Deletion cancelled. Your account is active again.", + "status": "active", + }) +} + +// --- helpers --- + +func generateDestroyToken() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +func (h *AccountHandler) sendDestroyConfirmationEmail(toEmail, toName, token string) error { + subject := "FINAL WARNING: Confirm Permanent Account Destruction" + + confirmURL := fmt.Sprintf("%s/api/v1/account/destroy/confirm?token=%s", + h.config.APIBaseURL, token) + + htmlBody := fmt.Sprintf(` + + + +
+ +

⚠ Account Destruction Confirmation

+ +

Hey %s,

+ +

You requested immediate and permanent destruction of your Sojorn account.

+ +
+

THIS ACTION IS IRREVERSIBLE

+
    +
  • All your posts, comments, and media will be permanently deleted
  • +
  • All your messages and encryption keys will be destroyed
  • +
  • Your profile, followers, and social connections will be erased
  • +
  • Your handle will be released and cannot be reclaimed
  • +
  • There is no recovery. No backup. No undo.
  • +
+
+ +

If you are absolutely certain, click the button below. Your account will be destroyed the instant you click.

+ +
+PERMANENTLY DESTROY MY ACCOUNT +
+ +

This link expires in 1 hour. If you did not request this, you can safely ignore this email — your account will not be affected.

+ +
+

MPLS LLC · Sojorn · Minneapolis, MN

+
+ +`, toName, confirmURL) + + textBody := fmt.Sprintf(`FINAL WARNING: Confirm Permanent Account Destruction + +Hey %s, + +You requested IMMEDIATE AND PERMANENT DESTRUCTION of your Sojorn account. + +THIS ACTION IS IRREVERSIBLE: +- All posts, comments, and media permanently deleted +- All messages and encryption keys destroyed +- Profile, followers, and connections erased +- Handle released and cannot be reclaimed +- THERE IS NO RECOVERY. NO BACKUP. NO UNDO. + +To confirm, visit: %s + +This link expires in 1 hour. If you did not request this, ignore this email. + +MPLS LLC - Sojorn - Minneapolis, MN`, toName, confirmURL) + + return h.emailService.SendGenericEmail(toEmail, toName, subject, htmlBody, textBody) +} diff --git a/go-backend/internal/handlers/auth_handler.go b/go-backend/internal/handlers/auth_handler.go index c2d0f66..8bf410a 100644 --- a/go-backend/internal/handlers/auth_handler.go +++ b/go-backend/internal/handlers/auth_handler.go @@ -267,9 +267,12 @@ func (h *AuthHandler) Login(c *gin.Context) { c.JSON(http.StatusForbidden, gin.H{"error": "Your account is temporarily suspended. Please try again later.", "code": "suspended"}) return } - if user.Status == models.UserStatusDeactivated { - c.JSON(http.StatusForbidden, gin.H{"error": "Account deactivated"}) - return + + // Auto-reactivate deactivated or pending-deletion accounts on login + if user.Status == models.UserStatusDeactivated || user.Status == models.UserStatusPendingDeletion { + log.Printf("[Auth] Reactivating %s account for %s", user.Status, req.Email) + _ = h.repo.ReactivateUser(c.Request.Context(), user.ID.String()) + user.Status = models.UserStatusActive } if user.MFAEnabled { diff --git a/go-backend/internal/models/user.go b/go-backend/internal/models/user.go index 8770ca5..5e9c7cd 100644 --- a/go-backend/internal/models/user.go +++ b/go-backend/internal/models/user.go @@ -9,11 +9,12 @@ import ( type UserStatus string const ( - UserStatusPending UserStatus = "pending" - UserStatusActive UserStatus = "active" - UserStatusDeactivated UserStatus = "deactivated" - UserStatusBanned UserStatus = "banned" - UserStatusSuspended UserStatus = "suspended" + UserStatusPending UserStatus = "pending" + UserStatusActive UserStatus = "active" + UserStatusDeactivated UserStatus = "deactivated" + UserStatusPendingDeletion UserStatus = "pending_deletion" + UserStatusBanned UserStatus = "banned" + UserStatusSuspended UserStatus = "suspended" ) type User struct { diff --git a/go-backend/internal/repository/user_repository.go b/go-backend/internal/repository/user_repository.go index ebc2607..81b2372 100644 --- a/go-backend/internal/repository/user_repository.go +++ b/go-backend/internal/repository/user_repository.go @@ -1328,3 +1328,166 @@ func (r *UserRepository) IsIPBanned(ctx context.Context, ipAddress string) (bool `, ipAddress).Scan(&exists) return exists, err } + +// ======================================================================== +// Account Lifecycle: Deactivate, Delete, Destroy +// ======================================================================== + +// DeactivateUser sets user status to deactivated, preserves all data +func (r *UserRepository) DeactivateUser(ctx context.Context, userID string) error { + _, err := r.pool.Exec(ctx, ` + UPDATE public.users + SET status = 'deactivated', updated_at = NOW() + WHERE id = $1::uuid + `, userID) + return err +} + +// ReactivateUser sets a deactivated user back to active +func (r *UserRepository) ReactivateUser(ctx context.Context, userID string) error { + _, err := r.pool.Exec(ctx, ` + UPDATE public.users + SET status = 'active', deleted_at = NULL, updated_at = NOW() + WHERE id = $1::uuid AND status IN ('deactivated', 'pending_deletion') + `, userID) + return err +} + +// ScheduleDeletion marks account for deletion after 14 days +func (r *UserRepository) ScheduleDeletion(ctx context.Context, userID string) error { + _, err := r.pool.Exec(ctx, ` + UPDATE public.users + SET status = 'pending_deletion', deleted_at = NOW() + INTERVAL '14 days', updated_at = NOW() + WHERE id = $1::uuid + `, userID) + return err +} + +// CancelDeletion reverts a pending deletion back to active +func (r *UserRepository) CancelDeletion(ctx context.Context, userID string) error { + return r.ReactivateUser(ctx, userID) +} + +// GetAccountsPendingPurge returns user IDs whose deletion grace period has expired +func (r *UserRepository) GetAccountsPendingPurge(ctx context.Context) ([]string, error) { + rows, err := r.pool.Query(ctx, ` + SELECT id::text FROM public.users + WHERE status = 'pending_deletion' AND deleted_at IS NOT NULL AND deleted_at <= NOW() + `) + if err != nil { + return nil, err + } + defer rows.Close() + + var ids []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, err + } + ids = append(ids, id) + } + return ids, nil +} + +// CascadePurgeUser permanently deletes ALL user data from every table. Irreversible. +func (r *UserRepository) CascadePurgeUser(ctx context.Context, userID string) error { + tx, err := r.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("begin tx: %w", err) + } + defer tx.Rollback(ctx) + + // Order matters: delete from leaf tables first, then parent tables + purgeQueries := []string{ + // Post-related (leaf tables first) + `DELETE FROM public.post_reactions WHERE post_id IN (SELECT id FROM public.posts WHERE author_id = $1::uuid)`, + `DELETE FROM public.post_likes WHERE post_id IN (SELECT id FROM public.posts WHERE author_id = $1::uuid)`, + `DELETE FROM public.post_saves WHERE post_id IN (SELECT id FROM public.posts WHERE author_id = $1::uuid)`, + `DELETE FROM public.post_mentions WHERE post_id IN (SELECT id FROM public.posts WHERE author_id = $1::uuid)`, + `DELETE FROM public.post_interactions WHERE post_id IN (SELECT id FROM public.posts WHERE author_id = $1::uuid)`, + `DELETE FROM public.post_metrics WHERE post_id IN (SELECT id FROM public.posts WHERE author_id = $1::uuid)`, + `DELETE FROM public.post_hashtags WHERE post_id IN (SELECT id FROM public.posts WHERE author_id = $1::uuid)`, + `DELETE FROM public.post_categories WHERE post_id IN (SELECT id FROM public.posts WHERE author_id = $1::uuid)`, + // Also remove this user's likes/saves/reactions on OTHER people's posts + `DELETE FROM public.post_reactions WHERE user_id = $1::uuid`, + `DELETE FROM public.post_likes WHERE user_id = $1::uuid`, + `DELETE FROM public.post_saves WHERE user_id = $1::uuid`, + `DELETE FROM public.post_interactions WHERE user_id = $1::uuid`, + // Beacon votes + `DELETE FROM public.beacon_votes WHERE user_id = $1::uuid`, + // Comments on user's posts + `DELETE FROM public.comments WHERE post_id IN (SELECT id FROM public.posts WHERE author_id = $1::uuid)`, + // User's own comments + `DELETE FROM public.comments WHERE author_id = $1::uuid`, + // Feed engagement + `DELETE FROM public.feed_engagement WHERE user_id = $1::uuid`, + // Posts themselves + `DELETE FROM public.posts WHERE author_id = $1::uuid`, + // Messaging / E2EE + `DELETE FROM public.secure_messages WHERE sender_id = $1::uuid OR receiver_id = $1::uuid`, + `DELETE FROM public.encrypted_messages WHERE sender_id = $1::uuid OR receiver_id = $1::uuid`, + `DELETE FROM public.e2ee_session_state WHERE session_id IN (SELECT id FROM public.e2ee_sessions WHERE user_a = $1::uuid OR user_b = $1::uuid)`, + `DELETE FROM public.e2ee_sessions WHERE user_a = $1::uuid OR user_b = $1::uuid`, + `DELETE FROM public.encrypted_conversations WHERE participant_a = $1::uuid OR participant_b = $1::uuid`, + // Signal keys + `DELETE FROM public.one_time_prekeys WHERE user_id = $1::uuid`, + `DELETE FROM public.signed_prekeys WHERE user_id = $1::uuid`, + `DELETE FROM public.signal_keys WHERE user_id = $1::uuid`, + // Social graph + `DELETE FROM public.circle_members WHERE user_id = $1::uuid OR member_id = $1::uuid`, + `DELETE FROM public.follows WHERE follower_id = $1::uuid OR following_id = $1::uuid`, + `DELETE FROM public.blocks WHERE blocker_id = $1::uuid OR blocked_id = $1::uuid`, + // Notifications + `DELETE FROM public.notifications WHERE user_id = $1::uuid OR actor_id = $1::uuid`, + `DELETE FROM public.notification_preferences WHERE user_id = $1::uuid`, + `DELETE FROM public.user_fcm_tokens WHERE user_id = $1::uuid`, + // Moderation & violations + `DELETE FROM public.moderation_flags WHERE user_id = $1::uuid`, + `DELETE FROM public.pending_moderation WHERE user_id = $1::uuid`, + `DELETE FROM public.user_violation_history WHERE user_id = $1::uuid`, + `DELETE FROM public.user_violations WHERE user_id = $1::uuid`, + `DELETE FROM public.user_appeals WHERE user_id = $1::uuid`, + `DELETE FROM public.content_strikes WHERE user_id = $1::uuid`, + `DELETE FROM public.reports WHERE reporter_id = $1::uuid`, + `DELETE FROM public.abuse_logs WHERE user_id = $1::uuid`, + `DELETE FROM public.user_status_history WHERE user_id = $1::uuid`, + // Categories & hashtags + `DELETE FROM public.user_category_preferences WHERE user_id = $1::uuid`, + `DELETE FROM public.user_category_settings WHERE user_id = $1::uuid`, + `DELETE FROM public.hashtag_follows WHERE user_id = $1::uuid`, + // Backup & recovery + `DELETE FROM public.recovery_shard_submissions WHERE session_id IN (SELECT id FROM public.recovery_sessions WHERE user_id = $1::uuid)`, + `DELETE FROM public.recovery_sessions WHERE user_id = $1::uuid`, + `DELETE FROM public.recovery_guardians WHERE user_id = $1::uuid OR guardian_id = $1::uuid`, + `DELETE FROM public.cloud_backups WHERE user_id = $1::uuid`, + `DELETE FROM public.backup_preferences WHERE user_id = $1::uuid`, + `DELETE FROM public.sync_codes WHERE user_id = $1::uuid`, + `DELETE FROM public.user_devices WHERE user_id = $1::uuid`, + // Auth & tokens + `DELETE FROM public.auth_tokens WHERE user_id = $1::uuid`, + `DELETE FROM public.refresh_tokens WHERE user_id = $1::uuid`, + `DELETE FROM public.verification_tokens WHERE user_id = $1::uuid`, + `DELETE FROM public.password_reset_tokens WHERE user_id = $1::uuid`, + `DELETE FROM public.webauthn_credentials WHERE user_id = $1::uuid`, + `DELETE FROM public.user_mfa_secrets WHERE user_id = $1::uuid`, + // Settings + `DELETE FROM public.user_settings WHERE user_id = $1::uuid`, + `DELETE FROM public.profile_privacy_settings WHERE user_id = $1::uuid`, + // Trust + `DELETE FROM public.trust_state WHERE user_id = $1::uuid`, + // Username claims + `DELETE FROM public.username_claim_requests WHERE user_id = $1::uuid`, + // Profile & user (last) + `DELETE FROM public.profiles WHERE id = $1::uuid`, + `DELETE FROM public.users WHERE id = $1::uuid`, + } + + for _, q := range purgeQueries { + if _, err := tx.Exec(ctx, q, userID); err != nil { + return fmt.Errorf("purge query failed: %s: %w", q[:60], err) + } + } + + return tx.Commit(ctx) +} diff --git a/go-backend/internal/services/email_service.go b/go-backend/internal/services/email_service.go index 18b01ab..197e759 100644 --- a/go-backend/internal/services/email_service.go +++ b/go-backend/internal/services/email_service.go @@ -361,6 +361,11 @@ func (s *EmailService) SendAccountRestoredEmail(toEmail, toName, reason string) return s.sendEmail(toEmail, toName, subject, htmlBody, textBody) } +// SendGenericEmail sends an email with pre-built HTML and text bodies +func (s *EmailService) SendGenericEmail(toEmail, toName, subject, htmlBody, textBody string) error { + return s.sendEmail(toEmail, toName, subject, htmlBody, textBody) +} + func (s *EmailService) AddSubscriber(email, name string) { // SendPulse Addressbook API implementation omitted for brevity, focusing on email first // Endpoint: POST /addressbooks/{id}/emails diff --git a/sojorn_app/lib/screens/profile/profile_settings_screen.dart b/sojorn_app/lib/screens/profile/profile_settings_screen.dart index a92bfde..5d6a3b3 100644 --- a/sojorn_app/lib/screens/profile/profile_settings_screen.dart +++ b/sojorn_app/lib/screens/profile/profile_settings_screen.dart @@ -97,9 +97,21 @@ class _ProfileSettingsScreenState extends ConsumerState { ), _buildEditTile( icon: Icons.pause_circle_outline, - title: 'Deactivation', + title: 'Deactivate Account', color: Colors.orange, - onTap: () {}, + onTap: () => _showDeactivateDialog(), + ), + _buildEditTile( + icon: Icons.delete_outline, + title: 'Delete Account', + color: Colors.red.shade400, + onTap: () => _showDeleteDialog(), + ), + _buildEditTile( + icon: Icons.warning_amber_rounded, + title: 'Immediate Destroy', + color: Colors.red.shade800, + onTap: () => _showSuperDeleteDialog(), ), ], ), @@ -672,6 +684,196 @@ class _ProfileSettingsScreenState extends ConsumerState { } } + // --- Account Lifecycle Dialogs --- + + void _showDeactivateDialog() { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Row( + children: [ + Icon(Icons.pause_circle_outline, color: Colors.orange, size: 24), + const SizedBox(width: 8), + const Text('Deactivate Account'), + ], + ), + content: const Text( + 'Your account will be hidden and you will be logged out. ' + 'All your data — posts, messages, connections — will be preserved indefinitely.\n\n' + 'You can reactivate at any time simply by logging back in.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () async { + Navigator.pop(ctx); + await _performDeactivation(); + }, + style: TextButton.styleFrom(foregroundColor: Colors.orange), + child: const Text('Deactivate'), + ), + ], + ), + ); + } + + void _showDeleteDialog() { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Row( + children: [ + Icon(Icons.delete_outline, color: Colors.red.shade400, size: 24), + const SizedBox(width: 8), + const Text('Delete Account'), + ], + ), + content: const Text( + 'Your account will be deactivated immediately and permanently deleted after 14 days.\n\n' + 'During those 14 days, you can cancel the deletion by logging back in. ' + 'After that, ALL data will be irreversibly destroyed — posts, messages, encryption keys, profile, everything.\n\n' + 'Are you sure you want to proceed?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () async { + Navigator.pop(ctx); + await _performDeletion(); + }, + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: const Text('Delete My Account'), + ), + ], + ), + ); + } + + void _showSuperDeleteDialog() { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Row( + children: [ + Icon(Icons.warning_amber_rounded, color: Colors.red.shade800, size: 24), + const SizedBox(width: 8), + const Expanded(child: Text('Immediate Destroy', style: TextStyle(color: Colors.red))), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + Text( + 'THIS IS IRREVERSIBLE.', + style: TextStyle(fontWeight: FontWeight.bold, color: Colors.red), + ), + SizedBox(height: 12), + Text( + 'This will send a confirmation email to your registered address. ' + 'When you click the link in that email, your account and ALL associated data ' + 'will be permanently and immediately destroyed.\n\n' + 'There is NO recovery. NO backup. NO undo.\n\n' + 'Your posts, messages, encryption keys, profile, followers, and handle ' + 'will all be erased forever.', + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () async { + Navigator.pop(ctx); + await _performSuperDelete(); + }, + style: TextButton.styleFrom( + foregroundColor: Colors.white, + backgroundColor: Colors.red.shade800, + ), + child: const Text('Send Destroy Confirmation Email'), + ), + ], + ), + ); + } + + Future _performDeactivation() async { + try { + final api = ref.read(apiServiceProvider); + await api.callGoApi('/account/deactivate', method: 'POST'); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Account deactivated. Log back in anytime to reactivate.')), + ); + await _signOut(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to deactivate: $e')), + ); + } + } + + Future _performDeletion() async { + try { + final api = ref.read(apiServiceProvider); + await api.callGoApi('/account', method: 'DELETE'); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Account scheduled for deletion in 14 days. Log back in to cancel.')), + ); + await _signOut(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to schedule deletion: $e')), + ); + } + } + + Future _performSuperDelete() async { + try { + final api = ref.read(apiServiceProvider); + await api.callGoApi('/account/destroy', method: 'POST'); + if (!mounted) return; + showDialog( + context: context, + barrierDismissible: false, + builder: (ctx) => AlertDialog( + title: const Text('Confirmation Email Sent'), + content: const Text( + 'A confirmation email has been sent to your registered address.\n\n' + 'You MUST click the link in that email to complete the destruction. ' + 'If you did not mean to do this, simply ignore the email — your account will not be affected.\n\n' + 'The link expires in 1 hour.', + ), + actions: [ + TextButton( + onPressed: () { + Navigator.pop(ctx); + }, + child: const Text('OK'), + ), + ], + ), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to initiate destroy: $e')), + ); + } + } + Future _signOut() async { final authService = ref.read(authServiceProvider); await NotificationService.instance.removeToken();