Admin Delete User Feature Spec

Feature: GDPR-compliant user deletion from admin panel
App: CalWizz (Flask + SQLAlchemy + PostgreSQL)
Status: Planning
Created: 2026-02-20


Overview

Add a “Delete User” button to the admin panel that:

  1. Cancels any active Stripe subscription
  2. Deletes all user data (user record, sessions, calendar cache, OAuth tokens, feature votes)
  3. Logs the deletion for audit purposes
  4. Complies with GDPR right to erasure (Article 17)

1. UI Changes (admin.html)

1.1 Add Delete Button to User Table

Add a new column “Delete” after “Actions” column:

<th>Delete</th>

Add delete button in each row:

<td>
    <button class="delete-btn" 
            data-user-id="{{ user.id }}"
            data-user-email="{{ user.email }}"
            data-has-stripe="{{ 'true' if user.stripe_subscription_id else 'false' }}"
            {% if user.is_admin %}disabled title="Cannot delete admin users"{% endif %}>
        🗑️ Delete
    </button>
</td>

1.2 CSS Styling

.delete-btn {
    padding: 6px 12px;
    background: #dc3545;
    color: white;
    border: none;
    border-radius: 6px;
    cursor: pointer;
    font-weight: 500;
    transition: background 0.2s;
}
 
.delete-btn:hover:not(:disabled) {
    background: #c82333;
}
 
.delete-btn:disabled {
    opacity: 0.5;
    cursor: not-allowed;
    background: #6c757d;
}
 
/* Confirmation modal */
.modal-overlay {
    position: fixed;
    inset: 0;
    background: rgba(0, 0, 0, 0.5);
    display: flex;
    align-items: center;
    justify-content: center;
    z-index: 1000;
}
 
.modal-content {
    background: white;
    padding: 30px;
    border-radius: 12px;
    max-width: 500px;
    width: 90%;
}
 
.modal-warning {
    background: #fff3cd;
    border: 1px solid #ffc107;
    border-radius: 8px;
    padding: 15px;
    margin: 15px 0;
}
 
.confirm-email-input {
    width: 100%;
    padding: 10px;
    border: 2px solid #ccc;
    border-radius: 6px;
    margin-top: 10px;
}

1.3 JavaScript Handler

function deleteUser(userId, userEmail, hasStripe) {
    // Build confirmation message
    let message = `⚠️ PERMANENT DELETION\n\n`;
    message += `You are about to delete user: ${userEmail}\n\n`;
    message += `This will permanently delete:\n`;
    message += `• User account and profile\n`;
    message += `• All calendar data cache\n`;
    message += `• OAuth tokens\n`;
    message += `• Session data\n`;
    message += `• Feature votes\n`;
    
    if (hasStripe === 'true') {
        message += `\n⚡ This user has an active Stripe subscription which will be CANCELLED.\n`;
    }
    
    message += `\nType the user's email to confirm: `;
    
    const confirmEmail = prompt(message);
    
    if (confirmEmail !== userEmail) {
        if (confirmEmail !== null) {
            alert('Email does not match. Deletion cancelled.');
        }
        return;
    }
    
    // Final confirmation
    if (!confirm(`FINAL CONFIRMATION: Delete ${userEmail}? This cannot be undone.`)) {
        return;
    }
    
    // Send delete request
    const button = document.querySelector(`button.delete-btn[data-user-id="${userId}"]`);
    button.disabled = true;
    button.textContent = 'Deleting...';
    
    fetch(`/admin/delete-user/${userId}`, {
        method: 'DELETE',
        headers: {
            'Content-Type': 'application/json',
            'X-CSRFToken': getCsrfToken()
        }
    })
    .then(response => response.json())
    .then(data => {
        if (data.success) {
            alert(`User ${userEmail} deleted successfully.`);
            // Remove row from table
            document.querySelector(`tr[data-user-id="${userId}"]`).remove();
        } else {
            alert('Error: ' + (data.error || 'Failed to delete user'));
            button.disabled = false;
            button.textContent = '🗑️ Delete';
        }
    })
    .catch(error => {
        alert('Error: ' + error.message);
        button.disabled = false;
        button.textContent = '🗑️ Delete';
    });
}
 
// Event listener
document.addEventListener('DOMContentLoaded', function() {
    document.querySelectorAll('.delete-btn').forEach(button => {
        button.addEventListener('click', function() {
            const userId = this.dataset.userId;
            const userEmail = this.dataset.userEmail;
            const hasStripe = this.dataset.hasStripe;
            deleteUser(userId, userEmail, hasStripe);
        });
    });
});

2. Backend Route

2.1 Route Definition

File: app.py or routes/admin.py

from datetime import datetime
import stripe
from flask import jsonify, request, current_app
from functools import wraps
 
# Audit log model (see section 5)
from models import User, UserSession, CalendarCache, FeatureVote, AuditLog
 
@app.route('/admin/delete-user/<int:user_id>', methods=['DELETE'])
@login_required
@admin_required
def admin_delete_user(user_id):
    """
    GDPR-compliant user deletion endpoint.
    
    Steps:
    1. Validate request and permissions
    2. Cancel Stripe subscription (if exists)
    3. Delete all user data
    4. Log deletion for audit
    """
    try:
        # Get current admin user for audit log
        admin_user = get_current_user()
        
        # Fetch target user
        user = db.session.query(User).filter_by(id=user_id).first()
        
        if not user:
            return jsonify({'success': False, 'error': 'User not found'}), 404
        
        # Prevent self-deletion
        if user.id == admin_user.id:
            return jsonify({'success': False, 'error': 'Cannot delete your own account'}), 400
        
        # Prevent deletion of other admins (safety measure)
        if user.is_admin:
            return jsonify({'success': False, 'error': 'Cannot delete admin users'}), 403
        
        # Store user info for audit log before deletion
        deleted_user_email = user.email
        deleted_user_id = user.id
        had_stripe = bool(user.stripe_subscription_id)
        stripe_sub_id = user.stripe_subscription_id
        stripe_customer_id = user.stripe_customer_id
        
        # Step 1: Cancel Stripe subscription if exists
        stripe_cancelled = False
        stripe_error = None
        
        if user.stripe_subscription_id:
            try:
                stripe.api_key = current_app.config['STRIPE_SECRET_KEY']
                stripe.Subscription.cancel(user.stripe_subscription_id)
                stripe_cancelled = True
            except stripe.error.InvalidRequestError as e:
                # Subscription already cancelled or doesn't exist
                if 'No such subscription' in str(e):
                    stripe_cancelled = True  # Already gone, proceed
                else:
                    stripe_error = str(e)
            except stripe.error.StripeError as e:
                stripe_error = str(e)
        
        # If Stripe cancellation failed with real error, abort
        if stripe_error and not stripe_cancelled:
            # Log the failure
            audit_log = AuditLog(
                action='user_deletion_failed',
                admin_user_id=admin_user.id,
                target_user_id=deleted_user_id,
                target_user_email=deleted_user_email,
                details=f"Stripe cancellation failed: {stripe_error}",
                created_at=datetime.utcnow()
            )
            db.session.add(audit_log)
            db.session.commit()
            
            return jsonify({
                'success': False, 
                'error': f'Failed to cancel Stripe subscription: {stripe_error}. User NOT deleted.'
            }), 500
        
        # Step 2: Delete user data
        # Note: Cascades handle sessions, calendar_cache automatically
        # But we need to handle FeatureVotes separately
        
        # Delete feature votes (not cascaded)
        db.session.query(FeatureVote).filter_by(user_id=user.id).delete()
        
        # Delete the user (cascades: sessions, calendar_cache)
        db.session.delete(user)
        
        # Step 3: Create audit log
        audit_log = AuditLog(
            action='user_deleted',
            admin_user_id=admin_user.id,
            target_user_id=deleted_user_id,
            target_user_email=deleted_user_email,
            details={
                'stripe_subscription_cancelled': stripe_cancelled,
                'stripe_subscription_id': stripe_sub_id,
                'stripe_customer_id': stripe_customer_id,
                'deletion_reason': 'admin_initiated',
                'gdpr_compliant': True
            },
            ip_address=request.remote_addr,
            user_agent=request.user_agent.string[:500] if request.user_agent else None,
            created_at=datetime.utcnow()
        )
        db.session.add(audit_log)
        
        # Commit all changes
        db.session.commit()
        
        current_app.logger.info(
            f"User deleted: {deleted_user_email} (ID: {deleted_user_id}) "
            f"by admin {admin_user.email} (ID: {admin_user.id})"
        )
        
        return jsonify({
            'success': True,
            'message': f'User {deleted_user_email} deleted successfully',
            'stripe_cancelled': stripe_cancelled
        })
        
    except Exception as e:
        db.session.rollback()
        current_app.logger.error(f"Error deleting user {user_id}: {str(e)}")
        return jsonify({'success': False, 'error': 'Internal server error'}), 500

3. Database Operations

3.1 Data to Delete

TableDeletion MethodNotes
usersDirect deletePrimary record
user_sessionsCASCADEAutomatic via relationship
calendar_cacheCASCADEAutomatic via relationship
feature_votesManual deleteNot cascaded, need explicit deletion

3.2 Cascade Verification

Existing model already has proper cascades:

# In User model
sessions = relationship('UserSession', cascade='all, delete-orphan')
calendar_cache = relationship('CalendarCache', cascade='all, delete-orphan')

3.3 Missing: FeatureVote Cascade

The FeatureVote model doesn’t have a backref with cascade. Options:

  1. Recommended: Manually delete before user deletion (shown in route)
  2. Alternative: Add cascade to User model:
feature_votes = relationship('FeatureVote', cascade='all, delete-orphan')

4. Stripe Subscription Cancellation

4.1 Flow

1. Check if user.stripe_subscription_id exists
2. If yes, call stripe.Subscription.cancel(subscription_id)
3. Handle errors:
   - "No such subscription" → Proceed (already cancelled)
   - Other StripeError → Abort deletion, log error
4. If cancellation succeeds, proceed with user deletion

4.2 Stripe API Errors to Handle

ErrorAction
InvalidRequestError: No such subscriptionProceed (subscription already cancelled)
InvalidRequestError: Subscription has already been canceledProceed
AuthenticationErrorAbort, log, return 500
APIConnectionErrorAbort, log, return 500
RateLimitErrorAbort, log, suggest retry

4.3 Optional: Delete Stripe Customer

After subscription cancellation, optionally delete the Stripe customer record:

if user.stripe_customer_id:
    try:
        stripe.Customer.delete(user.stripe_customer_id)
    except stripe.error.StripeError:
        pass  # Non-critical, log but don't abort

Recommendation: Don’t delete Stripe customer immediately. Keep for:

  • Invoice history
  • Refund processing
  • Dispute resolution

Stripe customers can be cleaned up separately after 90+ days.


5. Audit Logging

5.1 New AuditLog Model

Add to models.py:

class AuditLog(Base):
    """
    Audit log for admin actions.
    GDPR requires logging of data access and deletions.
    Retain for minimum 2 years.
    """
    __tablename__ = 'audit_logs'
    
    id = Column(Integer, primary_key=True)
    
    # What happened
    action = Column(String(100), nullable=False, index=True)
    # Examples: 'user_deleted', 'user_deletion_failed', 'tier_changed', 'admin_granted'
    
    # Who did it
    admin_user_id = Column(Integer, nullable=False, index=True)
    # Note: Not a FK - admin might be deleted later
    
    # Who was affected
    target_user_id = Column(Integer, nullable=True, index=True)
    target_user_email = Column(String(255), nullable=True)
    # Store email separately since user record will be deleted
    
    # Details (JSON)
    details = Column(Text, nullable=True)  # JSON blob with action-specific data
    
    # Request metadata
    ip_address = Column(String(45), nullable=True)
    user_agent = Column(String(500), nullable=True)
    
    # Timestamp
    created_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True)
    
    def __repr__(self):
        return f"<AuditLog(id={self.id}, action='{self.action}', target='{self.target_user_email}')>"

5.2 Migration

# Alembic migration
def upgrade():
    op.create_table(
        'audit_logs',
        sa.Column('id', sa.Integer(), primary_key=True),
        sa.Column('action', sa.String(100), nullable=False, index=True),
        sa.Column('admin_user_id', sa.Integer(), nullable=False, index=True),
        sa.Column('target_user_id', sa.Integer(), nullable=True, index=True),
        sa.Column('target_user_email', sa.String(255), nullable=True),
        sa.Column('details', sa.Text(), nullable=True),
        sa.Column('ip_address', sa.String(45), nullable=True),
        sa.Column('user_agent', sa.String(500), nullable=True),
        sa.Column('created_at', sa.DateTime(), default=datetime.utcnow, nullable=False, index=True),
    )
 
def downgrade():
    op.drop_table('audit_logs')

5.3 What to Log

{
  "action": "user_deleted",
  "admin_user_id": 1,
  "target_user_id": 42,
  "target_user_email": "deleted@example.com",
  "details": {
    "stripe_subscription_cancelled": true,
    "stripe_subscription_id": "sub_xxx",
    "stripe_customer_id": "cus_xxx",
    "deletion_reason": "admin_initiated",
    "gdpr_compliant": true,
    "data_deleted": ["user", "sessions", "calendar_cache", "feature_votes"]
  },
  "ip_address": "192.168.1.1",
  "created_at": "2026-02-20T22:14:00Z"
}

6. Confirmation Flow

6.1 Two-Step Confirmation

  1. Step 1: Prompt user to type the target email address

    • Prevents accidental clicks
    • Ensures admin knows which user they’re deleting
  2. Step 2: Final confirm() dialog

    • “FINAL CONFIRMATION: Delete user@example.com? This cannot be undone.”

6.2 Visual Warnings

  • Red delete button
  • Warning icon (⚠️) in confirmation
  • Yellow warning box if user has Stripe subscription
  • Disabled button for admin users

7. Edge Cases & Error Handling

ScenarioHandling
User not foundReturn 404, “User not found”
Delete own accountBlock with 400, “Cannot delete your own account”
Delete other adminBlock with 403, “Cannot delete admin users”
Stripe subscription doesn’t existProceed (subscription may have been cancelled externally)
Stripe API timeoutAbort deletion, return 500, suggest retry
Stripe API errorAbort deletion, log error, return detailed error message
Database error mid-deletionRollback transaction, return 500
User has pending Stripe invoiceCancel subscription (invoice auto-voided), proceed
Network failure after Stripe cancel but before DB deleteRetry: user exists but subscription cancelled - admin can retry deletion

7.1 Recovery Scenarios

Stripe cancelled but user not deleted:

  • Safe to retry - Stripe will return “already cancelled”
  • Admin can click delete again

Partial database deletion:

  • Transaction rollback handles this
  • CASCADE ensures consistency within transaction

8. Security Considerations

8.1 Authorization

  • Route requires @login_required
  • Route requires @admin_required decorator
  • CSRF token validation on DELETE request
  • Prevent self-deletion
  • Prevent deletion of other admins

8.2 Rate Limiting

Consider adding rate limiting to prevent abuse:

from flask_limiter import Limiter
 
@app.route('/admin/delete-user/<int:user_id>', methods=['DELETE'])
@limiter.limit("10 per minute")  # Max 10 deletions per minute
@admin_required
def admin_delete_user(user_id):
    ...

8.3 Audit Trail

  • All deletions logged with admin ID, timestamp, IP
  • Logs retained for minimum 2 years (GDPR compliance)
  • Logs stored in separate table (not deleted with user)

8.4 Data Exposure Prevention

  • Don’t return sensitive user data in response
  • Only return success/failure + email
  • Log details stored server-side only

8.5 Input Validation

  • user_id is integer (enforced by Flask route)
  • No user-provided data in SQL queries (ORM handles)
  • Email confirmation in frontend only (UX, not security)

8.6 Additional Recommendations

  1. Two-admin approval (optional): For extra safety, require a second admin to approve deletions
  2. Soft delete first: Consider 30-day soft delete before permanent deletion
  3. Email notification: Send email to deleted user confirming data deletion (GDPR best practice)
  4. Export before delete: Offer admin option to export user data before deletion

9. GDPR Compliance Notes

9.1 Right to Erasure (Article 17)

  • âś… Delete all personal data: email, name, picture, tokens
  • âś… Delete derived data: calendar cache
  • âś… Retain audit log (legitimate interest, security)
  • âś… 30-day compliance window (feature enables immediate deletion)
DataRetainedLegal Basis
User email in audit logYesLegitimate interest (security, legal compliance)
Deletion timestampYesLegal requirement
Admin who deletedYesAccountability
Stripe transaction historyYes (in Stripe)Financial records, tax compliance

9.3 User Notification

Optional but recommended:

# Send deletion confirmation email
send_email(
    to=deleted_user_email,
    subject="Your CalWizz account has been deleted",
    body="Your account and all associated data has been permanently deleted..."
)

10. Implementation Checklist

  • Add AuditLog model to models.py
  • Create database migration for audit_logs table
  • Add delete button column to admin.html
  • Add CSS styles for delete button and modal
  • Add JavaScript handler for delete confirmation
  • Add /admin/delete-user/<id> route
  • Add FeatureVote deletion to route
  • Add Stripe subscription cancellation logic
  • Add audit logging
  • Add rate limiting (optional)
  • Test: normal user deletion
  • Test: user with Stripe subscription
  • Test: prevent self-deletion
  • Test: prevent admin deletion
  • Test: Stripe API failure handling
  • Test: database rollback on error

11. Future Enhancements

  1. User-initiated deletion: Add self-service deletion on account page
  2. Bulk deletion: Select multiple users for batch deletion
  3. Soft delete: 30-day retention with “restore” option
  4. Deletion queue: Background job for slow deletions
  5. Admin audit log viewer: UI to browse audit logs
  6. Two-factor for deletion: Require 2FA to delete users