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), admin: User = Depends(require_admin),
): ):
"""Delete a user and all their data.""" """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)) result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
@ -399,10 +402,29 @@ async def delete_user(
if user.is_admin: if user.is_admin:
raise HTTPException(status_code=400, detail="Cannot delete admin user") 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.delete(user)
await db.commit() 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") @router.post("/users/{user_id}/upgrade")

View File

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

View File

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

View File

@ -48,114 +48,33 @@ SMTP_CONFIG = {
CONTACT_EMAIL = os.getenv("CONTACT_EMAIL", "hello@pounce.ch") CONTACT_EMAIL = os.getenv("CONTACT_EMAIL", "hello@pounce.ch")
# Base email wrapper template # Minimalistic Professional Email Template
BASE_TEMPLATE = """ BASE_TEMPLATE = """
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <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> </head>
<body> <body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; background-color: #f5f5f5;">
<div class="container"> <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);">
<div class="logo">🐆 pounce</div> <!-- Header -->
{{ content }} <div style="padding: 32px 40px; border-bottom: 1px solid #e5e5e5;">
<div class="footer"> <h1 style="margin: 0; font-size: 24px; font-weight: 600; color: #000000; letter-spacing: -0.5px;">
<p>© {{ year }} pounce. All rights reserved.</p> pounce
<p> </h1>
<a href="https://pounce.ch">pounce.ch</a> · </div>
<a href="https://pounce.ch/privacy">Privacy</a> ·
<a href="https://pounce.ch/terms">Terms</a> <!-- 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> </p>
</div> </div>
</div> </div>
@ -167,34 +86,52 @@ BASE_TEMPLATE = """
# Email Templates (content only, wrapped in BASE_TEMPLATE) # Email Templates (content only, wrapped in BASE_TEMPLATE)
TEMPLATES = { TEMPLATES = {
"domain_available": """ "domain_available": """
<h1>Time to pounce.</h1> <h2 style="margin: 0 0 24px 0; font-size: 20px; font-weight: 600; color: #000000;">
<p>A domain you're tracking just dropped:</p> Domain available
<div class="highlight">{{ domain }}</div> </h2>
<p>It's available right now. Move fast—others are watching too.</p> <p style="margin: 0 0 24px 0; font-size: 15px; color: #333333; line-height: 1.6;\">
<a href="{{ register_url }}" class="cta">Grab It Now →</a> A domain you're monitoring is now available:
<p style="margin-top: 24px; color: #888; font-size: 14px;">
You're tracking this domain on POUNCE.
</p> </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": """ "price_alert": """
<h1>.{{ tld }} just moved.</h1> <h2 style="margin: 0 0 24px 0; font-size: 20px; font-weight: 600; color: #000000;">
<p style="font-size: 20px;"> Price alert: .{{ tld }}
{% if change_percent < 0 %} </h2>
<span class="decrease">↓ Down {{ change_percent|abs }}%</span> <p style="margin: 0 0 24px 0; font-size: 15px; color: #333333; line-height: 1.6;\">
{% else %} The price for .{{ tld }} has changed:
<span class="increase">↑ Up {{ change_percent }}%</span>
{% endif %}
</p> </p>
<div class="info-box"> <div style="margin: 24px 0; padding: 24px; background: #fafafa; border-radius: 6px;\">
<p><strong>Was:</strong> ${{ old_price }}</p> <div style="margin-bottom: 16px;\">
<p><strong>Now:</strong> ${{ new_price }}</p> <p style="margin: 0 0 4px 0; font-size: 13px; color: #666666;\">Previous Price</p>
<p><strong>Cheapest at:</strong> {{ registrar }}</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> </div>
<a href="{{ tld_url }}" class="cta">See Details →</a> <p style="margin: 24px 0; font-size: 14px; color: #666666;\">
<p style="margin-top: 24px; color: #888; font-size: 14px;"> Cheapest at: <strong style="color: #000000;\">{{ registrar }}</strong>
You set an alert for .{{ tld }} on POUNCE.
</p> </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": """ "subscription_confirmed": """
@ -243,81 +180,99 @@ TEMPLATES = {
""", """,
"password_reset": """ "password_reset": """
<h1>Reset your password.</h1> <h2 style="margin: 0 0 24px 0; font-size: 20px; font-weight: 600; color: #000000;">
<p>Hey {{ user_name }},</p> Reset your password
<p>Someone requested a password reset. If that was you, click below:</p> </h2>
<a href="{{ reset_url }}" class="cta">Reset Password →</a> <p style="margin: 0 0 24px 0; font-size: 15px; color: #333333; line-height: 1.6;">
<p style="margin-top: 24px;">Or copy this link:</p> Hi {{ user_name }},
<code style="word-break: break-all;">{{ reset_url }}</code> </p>
<div class="info-box" style="margin-top: 24px;"> <p style="margin: 0 0 32px 0; font-size: 15px; color: #333333; line-height: 1.6;">
<p class="warning" style="margin: 0;">Link expires in 1 hour.</p> 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> </div>
<p style="margin-top: 24px; color: #888; font-size: 14px;"> <p style="margin: 0 0 16px 0; font-size: 14px; color: #666666; line-height: 1.6;">
Didn't request this? Ignore it. Nothing changes. 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> </p>
""", """,
"email_verification": """ "email_verification": """
<h1>One click to start hunting.</h1> <h2 style="margin: 0 0 24px 0; font-size: 20px; font-weight: 600; color: #000000;">
<p>Hey {{ user_name }},</p> Verify your email
<p>Welcome to POUNCE. Verify your email to activate your account:</p> </h2>
<a href="{{ verification_url }}" class="cta">Verify & Start →</a> <p style="margin: 0 0 24px 0; font-size: 15px; color: #333333; line-height: 1.6;">
<p style="margin-top: 24px;">Or copy this link:</p> Hi {{ user_name }},
<code style="word-break: break-all;">{{ verification_url }}</code> </p>
<div class="info-box" style="margin-top: 24px;"> <p style="margin: 0 0 32px 0; font-size: 15px; color: #333333; line-height: 1.6;">
<p style="margin: 0;">Link expires in 24 hours.</p> 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> </div>
<p style="margin-top: 24px; color: #888; font-size: 14px;"> <p style="margin: 0 0 16px 0; font-size: 14px; color: #666666; line-height: 1.6;">
Didn't sign up? Just ignore this. 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> </p>
""", """,
"contact_form": """ "contact_form": """
<h1>New message from the wild.</h1> <h2 style="margin: 0 0 24px 0; font-size: 20px; font-weight: 600; color: #000000;">
<div class="info-box"> New Contact Form Submission
<p><strong>From:</strong> {{ name }} &lt;{{ email }}&gt;</p> </h2>
<p><strong>Subject:</strong> {{ subject }}</p> <div style="margin: 24px 0; padding: 20px; background: #fafafa; border-radius: 6px;\">
<p><strong>Date:</strong> {{ timestamp }}</p> <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> </div>
<h2>Message</h2> <p style="margin: 24px 0 12px 0; font-size: 14px; color: #666666;\">Message</p>
<div class="info-box"> <p style="margin: 0; font-size: 15px; color: #333333; line-height: 1.6; white-space: pre-wrap;\">{{ message }}</p>
<p style="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> </div>
<p style="margin-top: 24px;"> <p style="margin: 24px 0 0 0; font-size: 13px; color: #999999;\">Sent: {{ timestamp }}</p>
<a href="mailto:{{ email }}" class="cta">Reply →</a>
</p>
""", """,
"contact_confirmation": """ "contact_confirmation": """
<h1>Got it.</h1> <h2 style="margin: 0 0 24px 0; font-size: 20px; font-weight: 600; color: #000000;">
<p>Hey {{ name }},</p> Message received
<p>Your message landed. We'll get back to you soon.</p> </h2>
<div class="info-box"> <p style="margin: 0 0 24px 0; font-size: 15px; color: #333333; line-height: 1.6;">
<p><strong>Subject:</strong> {{ subject }}</p> Hi {{ name }},
<p><strong>Your message:</strong></p> </p>
<p style="white-space: pre-wrap; color: #888;">{{ message }}</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> </div>
<p>Expect a reply within 24-48 hours.</p>
<a href="https://pounce.ch" class="secondary-cta">Back to POUNCE →</a>
""", """,
"newsletter_welcome": """ "newsletter_welcome": """
<h1>You're on the list.</h1> <h2 style="margin: 0 0 24px 0; font-size: 20px; font-weight: 600; color: #000000;">
<p>Welcome to POUNCE Insights.</p> Welcome to pounce insights
<p>Here's what you'll get:</p> </h2>
<div class="info-box"> <p style="margin: 0 0 32px 0; font-size: 15px; color: #333333; line-height: 1.6;">
<ul> You'll receive updates about TLD market trends, domain investment strategies, and new features. 12 emails per month. No spam.
<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.
</p> </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 () => { const handleBulkUpgrade = async () => {
if (selectedUsers.length === 0) { if (selectedUsers.length === 0) {
setError('Please select users to upgrade') setError('Please select users to upgrade')
@ -678,6 +692,14 @@ export default function AdminPage() {
> >
<Shield className="w-4 h-4" /> <Shield className="w-4 h-4" />
</button> </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> </div>
</td> </td>
</tr> </tr>
@ -1284,22 +1306,33 @@ export default function AdminPage() {
</div> </div>
)} )}
</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 <button
onClick={() => setSelectedUser(null)} onClick={() => handleDeleteUser(selectedUser.id, selectedUser.email)}
className="px-4 py-2 text-foreground-muted hover:text-foreground transition-colors" 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 <Trash2 className="w-4 h-4" />
</button> Delete User
<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> </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> </div>
</div> </div>

View File

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

View File

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