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:
- Cancels any active Stripe subscription
- Deletes all user data (user record, sessions, calendar cache, OAuth tokens, feature votes)
- Logs the deletion for audit purposes
- 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'}), 5003. Database Operations
3.1 Data to Delete
| Table | Deletion Method | Notes |
|---|---|---|
users | Direct delete | Primary record |
user_sessions | CASCADE | Automatic via relationship |
calendar_cache | CASCADE | Automatic via relationship |
feature_votes | Manual delete | Not 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:
- Recommended: Manually delete before user deletion (shown in route)
- 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
| Error | Action |
|---|---|
InvalidRequestError: No such subscription | Proceed (subscription already cancelled) |
InvalidRequestError: Subscription has already been canceled | Proceed |
AuthenticationError | Abort, log, return 500 |
APIConnectionError | Abort, log, return 500 |
RateLimitError | Abort, 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 abortRecommendation: 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
-
Step 1: Prompt user to type the target email address
- Prevents accidental clicks
- Ensures admin knows which user they’re deleting
-
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
| Scenario | Handling |
|---|---|
| User not found | Return 404, “User not found” |
| Delete own account | Block with 400, “Cannot delete your own account” |
| Delete other admin | Block with 403, “Cannot delete admin users” |
| Stripe subscription doesn’t exist | Proceed (subscription may have been cancelled externally) |
| Stripe API timeout | Abort deletion, return 500, suggest retry |
| Stripe API error | Abort deletion, log error, return detailed error message |
| Database error mid-deletion | Rollback transaction, return 500 |
| User has pending Stripe invoice | Cancel subscription (invoice auto-voided), proceed |
| Network failure after Stripe cancel but before DB delete | Retry: 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_requireddecorator - 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_idis 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
- Two-admin approval (optional): For extra safety, require a second admin to approve deletions
- Soft delete first: Consider 30-day soft delete before permanent deletion
- Email notification: Send email to deleted user confirming data deletion (GDPR best practice)
- 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)
9.2 What We Retain (Legal Basis)
| Data | Retained | Legal Basis |
|---|---|---|
| User email in audit log | Yes | Legitimate interest (security, legal compliance) |
| Deletion timestamp | Yes | Legal requirement |
| Admin who deleted | Yes | Accountability |
| Stripe transaction history | Yes (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
AuditLogmodel tomodels.py - Create database migration for
audit_logstable - 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
- User-initiated deletion: Add self-service deletion on account page
- Bulk deletion: Select multiple users for batch deletion
- Soft delete: 30-day retention with “restore” option
- Deletion queue: Background job for slow deletions
- Admin audit log viewer: UI to browse audit logs
- Two-factor for deletion: Require 2FA to delete users