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
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:
@ -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")
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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}>"
|
||||||
|
|||||||
@ -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 — 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 }} <{{ email }}></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 }} <{{ email }}></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 24–48 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. 1–2 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>
|
||||||
""",
|
""",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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(() => {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user