feat: implement account deactivation, deletion (14-day), and immediate destroy with email confirmation

This commit is contained in:
Patrick Britton 2026-02-07 11:13:11 -06:00
parent 95056aee82
commit ecc02e10cc
7 changed files with 726 additions and 10 deletions

View file

@ -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) 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( mediaHandler := handlers.NewMediaHandler(
s3Client, s3Client,
cfg.R2AccountID, cfg.R2AccountID,
@ -360,6 +362,16 @@ func main() {
authorized.DELETE("/notifications/device", notificationHandler.UnregisterDevice) authorized.DELETE("/notifications/device", notificationHandler.UnregisterDevice)
authorized.DELETE("/notifications/devices", notificationHandler.UnregisterAllDevices) 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 // Appeal System routes
appeals := authorized.Group("/appeals") appeals := authorized.Group("/appeals")
{ {
@ -457,6 +469,9 @@ func main() {
// Public claim request endpoint (no auth) // Public claim request endpoint (no auth)
r.POST("/api/v1/username-claim", adminHandler.SubmitClaimRequest) 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{ srv := &http.Server{
Addr: ":" + cfg.Port, Addr: ":" + cfg.Port,
Handler: r, 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) log.Info().Msgf("Server started on port %s", cfg.Port)
quit := make(chan os.Signal, 1) quit := make(chan os.Signal, 1)

View file

@ -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(`<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>Account Destroyed</title>
<style>body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#1a1a2e;color:#e0e0e0;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;}
.card{max-width:480px;background:#16213e;border-radius:16px;padding:48px;text-align:center;}
h1{color:#dc2626;font-size:24px;}p{line-height:1.6;color:#a0a0b0;}</style></head>
<body><div class="card"><h1>Account Destroyed</h1>
<p>Your account and all associated data have been permanently destroyed. This action cannot be undone.</p>
<p style="margin-top:32px;color:#666;">Goodbye. Sojorn</p></div></body></html>`))
}
// 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(`<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #1a1a2e; color: #e0e0e0; padding: 40px;">
<div style="max-width: 560px; margin: 0 auto; background: #16213e; border-radius: 16px; padding: 40px; border: 2px solid #dc2626;">
<h1 style="color: #dc2626; font-size: 24px; margin: 0 0 24px 0;"> Account Destruction Confirmation</h1>
<p style="font-size: 16px; line-height: 1.6;">Hey %s,</p>
<p style="font-size: 16px; line-height: 1.6;">You requested <strong>immediate and permanent destruction</strong> of your Sojorn account.</p>
<div style="background: #2d0000; border: 1px solid #dc2626; border-radius: 12px; padding: 20px; margin: 24px 0;">
<p style="color: #fca5a5; font-size: 14px; font-weight: bold; margin: 0 0 8px 0;">THIS ACTION IS IRREVERSIBLE</p>
<ul style="color: #fca5a5; font-size: 14px; margin: 0; padding-left: 20px; line-height: 1.8;">
<li>All your posts, comments, and media will be permanently deleted</li>
<li>All your messages and encryption keys will be destroyed</li>
<li>Your profile, followers, and social connections will be erased</li>
<li>Your handle will be released and cannot be reclaimed</li>
<li><strong>There is no recovery. No backup. No undo.</strong></li>
</ul>
</div>
<p style="font-size: 16px; line-height: 1.6;">If you are absolutely certain, click the button below. <strong>Your account will be destroyed the instant you click.</strong></p>
<div style="text-align: center; margin: 32px 0;">
<a href="%s" style="background: #dc2626; color: white; padding: 16px 40px; border-radius: 8px; text-decoration: none; font-weight: bold; font-size: 16px; display: inline-block;">PERMANENTLY DESTROY MY ACCOUNT</a>
</div>
<p style="font-size: 13px; color: #888; line-height: 1.6;">This link expires in 1 hour. If you did not request this, you can safely ignore this email your account will not be affected.</p>
<hr style="border: none; border-top: 1px solid #333; margin: 32px 0;">
<p style="font-size: 12px; color: #666; text-align: center;">MPLS LLC &middot; Sojorn &middot; Minneapolis, MN</p>
</div>
</body>
</html>`, 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)
}

View file

@ -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"}) c.JSON(http.StatusForbidden, gin.H{"error": "Your account is temporarily suspended. Please try again later.", "code": "suspended"})
return return
} }
if user.Status == models.UserStatusDeactivated {
c.JSON(http.StatusForbidden, gin.H{"error": "Account deactivated"}) // Auto-reactivate deactivated or pending-deletion accounts on login
return 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 { if user.MFAEnabled {

View file

@ -12,6 +12,7 @@ const (
UserStatusPending UserStatus = "pending" UserStatusPending UserStatus = "pending"
UserStatusActive UserStatus = "active" UserStatusActive UserStatus = "active"
UserStatusDeactivated UserStatus = "deactivated" UserStatusDeactivated UserStatus = "deactivated"
UserStatusPendingDeletion UserStatus = "pending_deletion"
UserStatusBanned UserStatus = "banned" UserStatusBanned UserStatus = "banned"
UserStatusSuspended UserStatus = "suspended" UserStatusSuspended UserStatus = "suspended"
) )

View file

@ -1328,3 +1328,166 @@ func (r *UserRepository) IsIPBanned(ctx context.Context, ipAddress string) (bool
`, ipAddress).Scan(&exists) `, ipAddress).Scan(&exists)
return exists, err 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)
}

View file

@ -361,6 +361,11 @@ func (s *EmailService) SendAccountRestoredEmail(toEmail, toName, reason string)
return s.sendEmail(toEmail, toName, subject, htmlBody, textBody) 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) { func (s *EmailService) AddSubscriber(email, name string) {
// SendPulse Addressbook API implementation omitted for brevity, focusing on email first // SendPulse Addressbook API implementation omitted for brevity, focusing on email first
// Endpoint: POST /addressbooks/{id}/emails // Endpoint: POST /addressbooks/{id}/emails

View file

@ -97,9 +97,21 @@ class _ProfileSettingsScreenState extends ConsumerState<ProfileSettingsScreen> {
), ),
_buildEditTile( _buildEditTile(
icon: Icons.pause_circle_outline, icon: Icons.pause_circle_outline,
title: 'Deactivation', title: 'Deactivate Account',
color: Colors.orange, 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<ProfileSettingsScreen> {
} }
} }
// --- 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<void> _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<void> _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<void> _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<void> _signOut() async { Future<void> _signOut() async {
final authService = ref.read(authServiceProvider); final authService = ref.read(authServiceProvider);
await NotificationService.instance.removeToken(); await NotificationService.instance.removeToken();