feat: Add user deletion in admin panel and fix OAuth authentication
Some checks failed
CI / Frontend Lint & Type Check (push) Has been cancelled
CI / Frontend Build (push) Has been cancelled
CI / Backend Lint (push) Has been cancelled
CI / Backend Tests (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
Deploy / Build & Push Images (push) Has been cancelled
Deploy / Deploy to Server (push) Has been cancelled
Deploy / Notify (push) Has been cancelled

- Add delete user functionality with cascade deletion of all user data
- Fix OAuth URLs to include /api/v1 path
- Fix token storage key consistency in OAuth callback
- Update user model to cascade delete price alerts
- Improve email templates with minimalist design
- Add confirmation dialog for user deletion
- Prevent deletion of admin users
This commit is contained in:
2025-12-09 21:45:40 +01:00
parent 3f456658ee
commit 0582b26be7
7 changed files with 209 additions and 196 deletions

View File

@ -390,6 +390,9 @@ async def delete_user(
admin: User = Depends(require_admin),
):
"""Delete a user and all their data."""
from app.models.blog import BlogPost
from app.models.admin_log import AdminActivityLog
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
@ -399,10 +402,29 @@ async def delete_user(
if user.is_admin:
raise HTTPException(status_code=400, detail="Cannot delete admin user")
user_email = user.email
# Delete user's blog posts (or set author_id to NULL if you want to keep them)
await db.execute(
BlogPost.__table__.delete().where(BlogPost.author_id == user_id)
)
# Delete user's admin activity logs (if any)
await db.execute(
AdminActivityLog.__table__.delete().where(AdminActivityLog.admin_id == user_id)
)
# Now delete the user (cascades to domains, subscriptions, portfolio, price_alerts)
await db.delete(user)
await db.commit()
return {"message": f"User {user.email} deleted"}
# Log this action
await log_admin_activity(
db, admin.id, "user_delete",
f"Deleted user {user_email} and all their data"
)
return {"message": f"User {user_email} and all their data have been deleted"}
@router.post("/users/{user_id}/upgrade")

View File

@ -48,7 +48,7 @@ class PriceAlert(Base):
)
# Relationship to user
user: Mapped["User"] = relationship("User", backref="price_alerts")
user: Mapped["User"] = relationship("User", back_populates="price_alerts")
def __repr__(self) -> str:
status = "active" if self.is_active else "paused"

View File

@ -57,6 +57,9 @@ class User(Base):
portfolio_domains: Mapped[List["PortfolioDomain"]] = relationship(
"PortfolioDomain", back_populates="user", cascade="all, delete-orphan"
)
price_alerts: Mapped[List["PriceAlert"]] = relationship(
"PriceAlert", cascade="all, delete-orphan", passive_deletes=True
)
def __repr__(self) -> str:
return f"<User {self.email}>"

View File

@ -48,114 +48,33 @@ SMTP_CONFIG = {
CONTACT_EMAIL = os.getenv("CONTACT_EMAIL", "hello@pounce.ch")
# Base email wrapper template
# Minimalistic Professional Email Template
BASE_TEMPLATE = """
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0a0a0a;
color: #e5e5e5;
padding: 20px;
margin: 0;
}
.container {
max-width: 600px;
margin: 0 auto;
background: #1a1a1a;
border-radius: 12px;
padding: 32px;
}
.logo {
color: #00d4aa;
font-size: 24px;
font-weight: bold;
margin-bottom: 24px;
}
h1 { color: #fff; margin: 0 0 16px 0; }
h2 { color: #fff; margin: 24px 0 16px 0; }
p { color: #e5e5e5; line-height: 1.6; }
.highlight {
font-family: monospace;
font-size: 24px;
color: #00d4aa;
margin: 16px 0;
}
.cta {
display: inline-block;
background: #00d4aa;
color: #0a0a0a;
padding: 14px 28px;
border-radius: 8px;
text-decoration: none;
font-weight: 600;
margin-top: 16px;
}
.cta:hover { background: #00c49a; }
.secondary-cta {
display: inline-block;
background: transparent;
color: #00d4aa;
padding: 12px 24px;
border-radius: 8px;
border: 1px solid #00d4aa;
text-decoration: none;
font-weight: 500;
margin-top: 16px;
margin-left: 8px;
}
.info-box {
background: #252525;
padding: 16px;
border-radius: 8px;
margin: 16px 0;
}
.stat {
background: #252525;
padding: 16px;
border-radius: 8px;
margin: 8px 0;
display: flex;
justify-content: space-between;
}
.stat-value { color: #00d4aa; font-size: 20px; font-weight: bold; }
.warning { color: #f59e0b; }
.success { color: #00d4aa; }
.decrease { color: #00d4aa; }
.increase { color: #ef4444; }
.footer {
margin-top: 32px;
padding-top: 16px;
border-top: 1px solid #333;
color: #888;
font-size: 12px;
}
.footer a { color: #00d4aa; text-decoration: none; }
ul { padding-left: 20px; }
li { margin: 8px 0; }
code {
background: #252525;
padding: 2px 6px;
border-radius: 4px;
font-family: monospace;
color: #00d4aa;
}
</style>
</head>
<body>
<div class="container">
<div class="logo">🐆 pounce</div>
{{ content }}
<div class="footer">
<p>© {{ year }} pounce. All rights reserved.</p>
<p>
<a href="https://pounce.ch">pounce.ch</a> ·
<a href="https://pounce.ch/privacy">Privacy</a> ·
<a href="https://pounce.ch/terms">Terms</a>
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; background-color: #f5f5f5;">
<div style="max-width: 580px; margin: 40px auto; background: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.05);">
<!-- Header -->
<div style="padding: 32px 40px; border-bottom: 1px solid #e5e5e5;">
<h1 style="margin: 0; font-size: 24px; font-weight: 600; color: #000000; letter-spacing: -0.5px;">
pounce
</h1>
</div>
<!-- Content -->
<div style="padding: 40px;">
{{ content }}
</div>
<!-- Footer -->
<div style="padding: 24px 40px; background: #fafafa; border-top: 1px solid #e5e5e5;">
<p style="margin: 0; font-size: 13px; color: #666666; line-height: 1.6;">
pounce &mdash; Domain Intelligence Platform<br>
<a href="https://pounce.ch" style="color: #000000; text-decoration: none;">pounce.ch</a>
</p>
</div>
</div>
@ -167,34 +86,52 @@ BASE_TEMPLATE = """
# Email Templates (content only, wrapped in BASE_TEMPLATE)
TEMPLATES = {
"domain_available": """
<h1>Time to pounce.</h1>
<p>A domain you're tracking just dropped:</p>
<div class="highlight">{{ domain }}</div>
<p>It's available right now. Move fast—others are watching too.</p>
<a href="{{ register_url }}" class="cta">Grab It Now →</a>
<p style="margin-top: 24px; color: #888; font-size: 14px;">
You're tracking this domain on POUNCE.
<h2 style="margin: 0 0 24px 0; font-size: 20px; font-weight: 600; color: #000000;">
Domain available
</h2>
<p style="margin: 0 0 24px 0; font-size: 15px; color: #333333; line-height: 1.6;\">
A domain you're monitoring is now available:
</p>
<div style="margin: 24px 0; padding: 20px; background: #fafafa; border-radius: 6px; border-left: 3px solid #000000;\">
<p style="margin: 0; font-size: 18px; font-weight: 600; color: #000000; font-family: monospace;\">
{{ domain }}
</p>
</div>
<div style="margin: 32px 0 0 0;\">
<a href="{{ register_url }}" style="display: inline-block; padding: 12px 32px; background: #000000; color: #ffffff; text-decoration: none; border-radius: 6px; font-size: 15px; font-weight: 500;\">
Register Domain
</a>
</div>
""",
"price_alert": """
<h1>.{{ tld }} just moved.</h1>
<p style="font-size: 20px;">
{% if change_percent < 0 %}
<span class="decrease">↓ Down {{ change_percent|abs }}%</span>
{% else %}
<span class="increase">↑ Up {{ change_percent }}%</span>
{% endif %}
<h2 style="margin: 0 0 24px 0; font-size: 20px; font-weight: 600; color: #000000;">
Price alert: .{{ tld }}
</h2>
<p style="margin: 0 0 24px 0; font-size: 15px; color: #333333; line-height: 1.6;\">
The price for .{{ tld }} has changed:
</p>
<div class="info-box">
<p><strong>Was:</strong> ${{ old_price }}</p>
<p><strong>Now:</strong> ${{ new_price }}</p>
<p><strong>Cheapest at:</strong> {{ registrar }}</p>
<div style="margin: 24px 0; padding: 24px; background: #fafafa; border-radius: 6px;\">
<div style="margin-bottom: 16px;\">
<p style="margin: 0 0 4px 0; font-size: 13px; color: #666666;\">Previous Price</p>
<p style="margin: 0; font-size: 18px; color: #999999; text-decoration: line-through;\">\${{ old_price }}</p>
</div>
<div style="margin-bottom: 16px;\">
<p style="margin: 0 0 4px 0; font-size: 13px; color: #666666;\">New Price</p>
<p style="margin: 0; font-size: 24px; font-weight: 600; color: #000000;\">\${{ new_price }}</p>
</div>
<p style="margin: 16px 0 0 0; font-size: 14px; {% if change_percent < 0 %}color: #10b981;{% else %}color: #ef4444;{% endif %}\">
{% if change_percent < 0 %}↓{% else %}↑{% endif %} {{ change_percent|abs }}%
</p>
</div>
<a href="{{ tld_url }}" class="cta">See Details →</a>
<p style="margin-top: 24px; color: #888; font-size: 14px;">
You set an alert for .{{ tld }} on POUNCE.
<p style="margin: 24px 0; font-size: 14px; color: #666666;\">
Cheapest at: <strong style="color: #000000;\">{{ registrar }}</strong>
</p>
<div style="margin: 32px 0 0 0;\">
<a href="{{ tld_url }}" style="display: inline-block; padding: 12px 32px; background: #000000; color: #ffffff; text-decoration: none; border-radius: 6px; font-size: 15px; font-weight: 500;\">
View Details
</a>
</div>
""",
"subscription_confirmed": """
@ -243,81 +180,99 @@ TEMPLATES = {
""",
"password_reset": """
<h1>Reset your password.</h1>
<p>Hey {{ user_name }},</p>
<p>Someone requested a password reset. If that was you, click below:</p>
<a href="{{ reset_url }}" class="cta">Reset Password →</a>
<p style="margin-top: 24px;">Or copy this link:</p>
<code style="word-break: break-all;">{{ reset_url }}</code>
<div class="info-box" style="margin-top: 24px;">
<p class="warning" style="margin: 0;">Link expires in 1 hour.</p>
<h2 style="margin: 0 0 24px 0; font-size: 20px; font-weight: 600; color: #000000;">
Reset your password
</h2>
<p style="margin: 0 0 24px 0; font-size: 15px; color: #333333; line-height: 1.6;">
Hi {{ user_name }},
</p>
<p style="margin: 0 0 32px 0; font-size: 15px; color: #333333; line-height: 1.6;">
We received a request to reset your password. Click the button below to create a new password.
</p>
<div style="margin: 0 0 32px 0;">
<a href="{{ reset_url }}" style="display: inline-block; padding: 12px 32px; background: #000000; color: #ffffff; text-decoration: none; border-radius: 6px; font-size: 15px; font-weight: 500;">
Reset Password
</a>
</div>
<p style="margin-top: 24px; color: #888; font-size: 14px;">
Didn't request this? Ignore it. Nothing changes.
<p style="margin: 0 0 16px 0; font-size: 14px; color: #666666; line-height: 1.6;">
This link expires in 1 hour.
</p>
<p style="margin: 32px 0 0 0; padding-top: 24px; border-top: 1px solid #e5e5e5; font-size: 13px; color: #999999; line-height: 1.6;">
If you didn't request this, you can safely ignore this email.
</p>
""",
"email_verification": """
<h1>One click to start hunting.</h1>
<p>Hey {{ user_name }},</p>
<p>Welcome to POUNCE. Verify your email to activate your account:</p>
<a href="{{ verification_url }}" class="cta">Verify & Start →</a>
<p style="margin-top: 24px;">Or copy this link:</p>
<code style="word-break: break-all;">{{ verification_url }}</code>
<div class="info-box" style="margin-top: 24px;">
<p style="margin: 0;">Link expires in 24 hours.</p>
<h2 style="margin: 0 0 24px 0; font-size: 20px; font-weight: 600; color: #000000;">
Verify your email
</h2>
<p style="margin: 0 0 24px 0; font-size: 15px; color: #333333; line-height: 1.6;">
Hi {{ user_name }},
</p>
<p style="margin: 0 0 32px 0; font-size: 15px; color: #333333; line-height: 1.6;">
Thanks for signing up. Click the button below to verify your email and activate your account.
</p>
<div style="margin: 0 0 32px 0;">
<a href="{{ verification_url }}" style="display: inline-block; padding: 12px 32px; background: #000000; color: #ffffff; text-decoration: none; border-radius: 6px; font-size: 15px; font-weight: 500;">
Verify Email
</a>
</div>
<p style="margin-top: 24px; color: #888; font-size: 14px;">
Didn't sign up? Just ignore this.
<p style="margin: 0 0 16px 0; font-size: 14px; color: #666666; line-height: 1.6;">
This link expires in 24 hours.
</p>
<p style="margin: 32px 0 0 0; padding-top: 24px; border-top: 1px solid #e5e5e5; font-size: 13px; color: #999999; line-height: 1.6;">
If you didn't sign up, you can safely ignore this email.
</p>
""",
"contact_form": """
<h1>New message from the wild.</h1>
<div class="info-box">
<p><strong>From:</strong> {{ name }} &lt;{{ email }}&gt;</p>
<p><strong>Subject:</strong> {{ subject }}</p>
<p><strong>Date:</strong> {{ timestamp }}</p>
<h2 style="margin: 0 0 24px 0; font-size: 20px; font-weight: 600; color: #000000;">
New Contact Form Submission
</h2>
<div style="margin: 24px 0; padding: 20px; background: #fafafa; border-radius: 6px;\">
<p style="margin: 0 0 12px 0; font-size: 14px; color: #666666;\">From</p>
<p style="margin: 0 0 16px 0; font-size: 15px; color: #000000;\">{{ name }} &lt;{{ email }}&gt;</p>
<p style="margin: 16px 0 12px 0; font-size: 14px; color: #666666;\">Subject</p>
<p style="margin: 0; font-size: 15px; color: #000000;\">{{ subject }}</p>
</div>
<h2>Message</h2>
<div class="info-box">
<p style="white-space: pre-wrap;">{{ message }}</p>
<p style="margin: 24px 0 12px 0; font-size: 14px; color: #666666;\">Message</p>
<p style="margin: 0; font-size: 15px; color: #333333; line-height: 1.6; white-space: pre-wrap;\">{{ message }}</p>
<div style="margin: 32px 0 0 0;\">
<a href="mailto:{{ email }}" style="display: inline-block; padding: 12px 32px; background: #000000; color: #ffffff; text-decoration: none; border-radius: 6px; font-size: 15px; font-weight: 500;\">
Reply
</a>
</div>
<p style="margin-top: 24px;">
<a href="mailto:{{ email }}" class="cta">Reply →</a>
</p>
<p style="margin: 24px 0 0 0; font-size: 13px; color: #999999;\">Sent: {{ timestamp }}</p>
""",
"contact_confirmation": """
<h1>Got it.</h1>
<p>Hey {{ name }},</p>
<p>Your message landed. We'll get back to you soon.</p>
<div class="info-box">
<p><strong>Subject:</strong> {{ subject }}</p>
<p><strong>Your message:</strong></p>
<p style="white-space: pre-wrap; color: #888;">{{ message }}</p>
<h2 style="margin: 0 0 24px 0; font-size: 20px; font-weight: 600; color: #000000;">
Message received
</h2>
<p style="margin: 0 0 24px 0; font-size: 15px; color: #333333; line-height: 1.6;">
Hi {{ name }},
</p>
<p style="margin: 0 0 32px 0; font-size: 15px; color: #333333; line-height: 1.6;">
Thanks for reaching out. We've received your message and will get back to you within 2448 hours.
</p>
<div style="margin: 24px 0; padding: 20px; background: #fafafa; border-radius: 6px;">
<p style="margin: 0 0 8px 0; font-size: 14px; color: #666666;">Your message</p>
<p style="margin: 0; font-size: 14px; color: #999999; white-space: pre-wrap;">{{ message }}</p>
</div>
<p>Expect a reply within 24-48 hours.</p>
<a href="https://pounce.ch" class="secondary-cta">Back to POUNCE →</a>
""",
"newsletter_welcome": """
<h1>You're on the list.</h1>
<p>Welcome to POUNCE Insights.</p>
<p>Here's what you'll get:</p>
<div class="info-box">
<ul>
<li>TLD market moves & analysis</li>
<li>Domain investing strategies</li>
<li>New feature drops</li>
<li>Exclusive deals</li>
</ul>
</div>
<p>1-2 emails per month. No spam. Ever.</p>
<a href="https://pounce.ch" class="cta">Start Exploring →</a>
<p style="margin-top: 24px; color: #888; font-size: 14px;">
Unsubscribe anytime with one click.
<h2 style="margin: 0 0 24px 0; font-size: 20px; font-weight: 600; color: #000000;">
Welcome to pounce insights
</h2>
<p style="margin: 0 0 32px 0; font-size: 15px; color: #333333; line-height: 1.6;">
You'll receive updates about TLD market trends, domain investment strategies, and new features. 12 emails per month. No spam.
</p>
<div style="margin: 32px 0 0 0;">
<a href="https://pounce.ch" style="display: inline-block; padding: 12px 32px; background: #000000; color: #ffffff; text-decoration: none; border-radius: 6px; font-size: 15px; font-weight: 500;">
Visit pounce.ch
</a>
</div>
""",
}

View File

@ -333,6 +333,20 @@ export default function AdminPage() {
}
}
const handleDeleteUser = async (userId: number, userEmail: string) => {
if (!confirm(`Are you sure you want to delete user "${userEmail}" and ALL their data?\n\nThis action cannot be undone.`)) {
return
}
try {
await api.deleteAdminUser(userId)
setSuccess(`User ${userEmail} and all their data have been deleted`)
setSelectedUser(null)
loadAdminData()
} catch (err) {
setError(err instanceof Error ? err.message : 'Delete failed')
}
}
const handleBulkUpgrade = async () => {
if (selectedUsers.length === 0) {
setError('Please select users to upgrade')
@ -678,6 +692,14 @@ export default function AdminPage() {
>
<Shield className="w-4 h-4" />
</button>
<button
onClick={() => handleDeleteUser(u.id, u.email)}
disabled={u.is_admin}
className="p-1.5 rounded-lg bg-danger/10 text-danger hover:bg-danger/20 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
title={u.is_admin ? 'Cannot delete admin users' : 'Delete user and all data'}
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
@ -1284,22 +1306,33 @@ export default function AdminPage() {
</div>
)}
</div>
<div className="p-6 border-t border-border flex justify-end gap-3">
<div className="p-6 border-t border-border flex justify-between gap-3">
<button
onClick={() => setSelectedUser(null)}
className="px-4 py-2 text-foreground-muted hover:text-foreground transition-colors"
onClick={() => handleDeleteUser(selectedUser.id, selectedUser.email)}
disabled={selectedUser.is_admin}
className="px-4 py-2 bg-danger/10 text-danger rounded-lg font-medium hover:bg-danger/20 disabled:opacity-30 disabled:cursor-not-allowed transition-all flex items-center gap-2"
title={selectedUser.is_admin ? 'Cannot delete admin users' : 'Delete user and all data'}
>
Close
</button>
<button
onClick={() => {
handleToggleAdmin(selectedUser.id, selectedUser.is_admin)
setSelectedUser(null)
}}
className="px-4 py-2 bg-accent text-background rounded-lg font-medium hover:bg-accent-hover transition-all"
>
{selectedUser.is_admin ? 'Remove Admin' : 'Make Admin'}
<Trash2 className="w-4 h-4" />
Delete User
</button>
<div className="flex gap-3">
<button
onClick={() => setSelectedUser(null)}
className="px-4 py-2 text-foreground-muted hover:text-foreground transition-colors"
>
Close
</button>
<button
onClick={() => {
handleToggleAdmin(selectedUser.id, selectedUser.is_admin)
setSelectedUser(null)
}}
className="px-4 py-2 bg-accent text-background rounded-lg font-medium hover:bg-accent-hover transition-all"
>
{selectedUser.is_admin ? 'Remove Admin' : 'Make Admin'}
</button>
</div>
</div>
</div>
</div>

View File

@ -22,8 +22,8 @@ function OAuthCallbackContent() {
}
if (token) {
// Store the token
localStorage.setItem('auth_token', token)
// Store the token (using 'token' key to match api.ts)
localStorage.setItem('token', token)
// Update auth state
checkAuth().then(() => {

View File

@ -189,12 +189,12 @@ class ApiClient {
getGoogleLoginUrl(redirect?: string) {
const params = redirect ? `?redirect=${encodeURIComponent(redirect)}` : ''
return `${this.baseUrl}/oauth/google/login${params}`
return `${getApiBaseUrl()}/oauth/google/login${params}`
}
getGitHubLoginUrl(redirect?: string) {
const params = redirect ? `?redirect=${encodeURIComponent(redirect)}` : ''
return `${this.baseUrl}/oauth/github/login${params}`
return `${getApiBaseUrl()}/oauth/github/login${params}`
}
// Contact Form