feat: Add email verification and password reset (Phase 1)
- Add email_verified, verification_token, reset_token fields to User model - Create email_service.py with SendGrid integration - Add auth endpoints: verify-email, resend-verification, forgot-password, reset-password - Create frontend pages: /verify-email, /forgot-password, /reset-password - Add forgot password link to login page - Add PLATFORM_ENHANCEMENT_SPEC.md specification
This commit is contained in:
parent
fd81b38c0c
commit
f1d8a14f75
9 changed files with 956 additions and 5 deletions
98
backend/email_service.py
Normal file
98
backend/email_service.py
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
"""
|
||||||
|
Email Service - SendGrid integration for verification and password reset emails.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
SENDGRID_API_KEY = os.getenv("SENDGRID_API_KEY", "")
|
||||||
|
EMAIL_FROM = os.getenv("EMAIL_FROM", "noreply@elmeg.xyz")
|
||||||
|
FRONTEND_URL = os.getenv("FRONTEND_URL", "https://elmeg.runfoo.run")
|
||||||
|
|
||||||
|
async def send_email(to: str, subject: str, html_content: str) -> bool:
|
||||||
|
"""Send email via SendGrid API"""
|
||||||
|
if not SENDGRID_API_KEY:
|
||||||
|
print(f"[EMAIL DEV MODE] To: {to}, Subject: {subject}")
|
||||||
|
print(f"[EMAIL DEV MODE] Content: {html_content[:200]}...")
|
||||||
|
return True
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.post(
|
||||||
|
"https://api.sendgrid.com/v3/mail/send",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {SENDGRID_API_KEY}",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"personalizations": [{"to": [{"email": to}]}],
|
||||||
|
"from": {"email": EMAIL_FROM, "name": "Elmeg"},
|
||||||
|
"subject": subject,
|
||||||
|
"content": [{"type": "text/html", "value": html_content}]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return response.status_code in (200, 202)
|
||||||
|
|
||||||
|
def generate_token() -> str:
|
||||||
|
"""Generate a secure random token"""
|
||||||
|
return secrets.token_urlsafe(32)
|
||||||
|
|
||||||
|
async def send_verification_email(email: str, token: str) -> bool:
|
||||||
|
"""Send email verification link"""
|
||||||
|
verify_url = f"{FRONTEND_URL}/verify-email?token={token}"
|
||||||
|
|
||||||
|
html = f"""
|
||||||
|
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
|
||||||
|
<h1 style="color: #333;">Welcome to Elmeg!</h1>
|
||||||
|
<p>Please verify your email address by clicking the button below:</p>
|
||||||
|
<a href="{verify_url}"
|
||||||
|
style="display: inline-block; background: #4F46E5; color: white;
|
||||||
|
padding: 12px 24px; text-decoration: none; border-radius: 6px;
|
||||||
|
margin: 16px 0;">
|
||||||
|
Verify Email
|
||||||
|
</a>
|
||||||
|
<p style="color: #666; font-size: 14px;">
|
||||||
|
Or copy this link: {verify_url}
|
||||||
|
</p>
|
||||||
|
<p style="color: #999; font-size: 12px;">
|
||||||
|
This link expires in 48 hours.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
return await send_email(email, "Verify your Elmeg account", html)
|
||||||
|
|
||||||
|
async def send_password_reset_email(email: str, token: str) -> bool:
|
||||||
|
"""Send password reset link"""
|
||||||
|
reset_url = f"{FRONTEND_URL}/reset-password?token={token}"
|
||||||
|
|
||||||
|
html = f"""
|
||||||
|
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
|
||||||
|
<h1 style="color: #333;">Password Reset</h1>
|
||||||
|
<p>You requested a password reset. Click below to set a new password:</p>
|
||||||
|
<a href="{reset_url}"
|
||||||
|
style="display: inline-block; background: #4F46E5; color: white;
|
||||||
|
padding: 12px 24px; text-decoration: none; border-radius: 6px;
|
||||||
|
margin: 16px 0;">
|
||||||
|
Reset Password
|
||||||
|
</a>
|
||||||
|
<p style="color: #666; font-size: 14px;">
|
||||||
|
Or copy this link: {reset_url}
|
||||||
|
</p>
|
||||||
|
<p style="color: #999; font-size: 12px;">
|
||||||
|
This link expires in 1 hour. If you didn't request this, ignore this email.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
return await send_email(email, "Reset your Elmeg password", html)
|
||||||
|
|
||||||
|
# Token expiration helpers
|
||||||
|
def get_verification_expiry() -> datetime:
|
||||||
|
"""48 hour expiry for email verification"""
|
||||||
|
return datetime.utcnow() + timedelta(hours=48)
|
||||||
|
|
||||||
|
def get_reset_expiry() -> datetime:
|
||||||
|
"""1 hour expiry for password reset"""
|
||||||
|
return datetime.utcnow() + timedelta(hours=1)
|
||||||
34
backend/migrations/add_email_verification.py
Normal file
34
backend/migrations/add_email_verification.py
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
"""
|
||||||
|
Migration to add email verification and password reset columns to user table.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from sqlmodel import Session, create_engine, text
|
||||||
|
from database import DATABASE_URL
|
||||||
|
|
||||||
|
def add_email_verification_columns():
|
||||||
|
engine = create_engine(DATABASE_URL)
|
||||||
|
|
||||||
|
columns = [
|
||||||
|
('email_verified', 'BOOLEAN DEFAULT FALSE'),
|
||||||
|
('verification_token', 'VARCHAR'),
|
||||||
|
('verification_token_expires', 'TIMESTAMP'),
|
||||||
|
('reset_token', 'VARCHAR'),
|
||||||
|
('reset_token_expires', 'TIMESTAMP'),
|
||||||
|
]
|
||||||
|
|
||||||
|
with Session(engine) as session:
|
||||||
|
for col_name, col_type in columns:
|
||||||
|
try:
|
||||||
|
session.exec(text(f"""
|
||||||
|
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS {col_name} {col_type}
|
||||||
|
"""))
|
||||||
|
session.commit()
|
||||||
|
print(f"✅ Added {col_name} to user")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"⚠️ {col_name}: {e}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
add_email_verification_columns()
|
||||||
|
|
@ -167,6 +167,15 @@ class User(SQLModel, table=True):
|
||||||
bio: Optional[str] = Field(default=None)
|
bio: Optional[str] = Field(default=None)
|
||||||
avatar: Optional[str] = Field(default=None)
|
avatar: Optional[str] = Field(default=None)
|
||||||
|
|
||||||
|
# Email verification
|
||||||
|
email_verified: bool = Field(default=False)
|
||||||
|
verification_token: Optional[str] = Field(default=None)
|
||||||
|
verification_token_expires: Optional[datetime] = Field(default=None)
|
||||||
|
|
||||||
|
# Password reset
|
||||||
|
reset_token: Optional[str] = Field(default=None)
|
||||||
|
reset_token_expires: Optional[datetime] = Field(default=None)
|
||||||
|
|
||||||
# Multi-identity support: A user can have multiple Profiles
|
# Multi-identity support: A user can have multiple Profiles
|
||||||
profiles: List["Profile"] = Relationship(back_populates="user")
|
profiles: List["Profile"] = Relationship(back_populates="user")
|
||||||
comments: List["Comment"] = Relationship(back_populates="user")
|
comments: List["Comment"] = Relationship(back_populates="user")
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,56 @@
|
||||||
from datetime import timedelta
|
from datetime import timedelta, datetime
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
|
||||||
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
from pydantic import BaseModel, EmailStr
|
||||||
from database import get_session
|
from database import get_session
|
||||||
from models import User, Profile
|
from models import User, Profile
|
||||||
from schemas import UserCreate, Token, UserRead
|
from schemas import UserCreate, Token, UserRead
|
||||||
from auth import verify_password, get_password_hash, create_access_token, ACCESS_TOKEN_EXPIRE_MINUTES, get_current_user
|
from auth import verify_password, get_password_hash, create_access_token, ACCESS_TOKEN_EXPIRE_MINUTES, get_current_user
|
||||||
|
from email_service import (
|
||||||
|
send_verification_email, send_password_reset_email,
|
||||||
|
generate_token, get_verification_expiry, get_reset_expiry
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
|
# Request/Response schemas for new endpoints
|
||||||
|
class VerifyEmailRequest(BaseModel):
|
||||||
|
token: str
|
||||||
|
|
||||||
|
class ForgotPasswordRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
|
||||||
|
class ResetPasswordRequest(BaseModel):
|
||||||
|
token: str
|
||||||
|
new_password: str
|
||||||
|
|
||||||
|
class ResendVerificationRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
|
||||||
|
|
||||||
@router.post("/register", response_model=UserRead)
|
@router.post("/register", response_model=UserRead)
|
||||||
def register(user_in: UserCreate, session: Session = Depends(get_session)):
|
async def register(
|
||||||
|
user_in: UserCreate,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
session: Session = Depends(get_session)
|
||||||
|
):
|
||||||
user = session.exec(select(User).where(User.email == user_in.email)).first()
|
user = session.exec(select(User).where(User.email == user_in.email)).first()
|
||||||
if user:
|
if user:
|
||||||
raise HTTPException(status_code=400, detail="Email already registered")
|
raise HTTPException(status_code=400, detail="Email already registered")
|
||||||
|
|
||||||
# Create User
|
# Create User with verification token
|
||||||
hashed_password = get_password_hash(user_in.password)
|
hashed_password = get_password_hash(user_in.password)
|
||||||
db_user = User(email=user_in.email, hashed_password=hashed_password)
|
verification_token = generate_token()
|
||||||
|
|
||||||
|
db_user = User(
|
||||||
|
email=user_in.email,
|
||||||
|
hashed_password=hashed_password,
|
||||||
|
email_verified=False,
|
||||||
|
verification_token=verification_token,
|
||||||
|
verification_token_expires=get_verification_expiry()
|
||||||
|
)
|
||||||
session.add(db_user)
|
session.add(db_user)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(db_user)
|
session.refresh(db_user)
|
||||||
|
|
@ -28,8 +60,112 @@ def register(user_in: UserCreate, session: Session = Depends(get_session)):
|
||||||
session.add(profile)
|
session.add(profile)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
|
# Send verification email in background
|
||||||
|
background_tasks.add_task(send_verification_email, db_user.email, verification_token)
|
||||||
|
|
||||||
return db_user
|
return db_user
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/verify-email")
|
||||||
|
def verify_email(request: VerifyEmailRequest, session: Session = Depends(get_session)):
|
||||||
|
"""Verify user's email with token"""
|
||||||
|
user = session.exec(
|
||||||
|
select(User).where(User.verification_token == request.token)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid verification token")
|
||||||
|
|
||||||
|
if user.verification_token_expires and user.verification_token_expires < datetime.utcnow():
|
||||||
|
raise HTTPException(status_code=400, detail="Verification token expired")
|
||||||
|
|
||||||
|
if user.email_verified:
|
||||||
|
return {"message": "Email already verified"}
|
||||||
|
|
||||||
|
# Mark as verified
|
||||||
|
user.email_verified = True
|
||||||
|
user.verification_token = None
|
||||||
|
user.verification_token_expires = None
|
||||||
|
session.add(user)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
return {"message": "Email verified successfully"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/resend-verification")
|
||||||
|
async def resend_verification(
|
||||||
|
request: ResendVerificationRequest,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
session: Session = Depends(get_session)
|
||||||
|
):
|
||||||
|
"""Resend verification email"""
|
||||||
|
user = session.exec(select(User).where(User.email == request.email)).first()
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
# Don't reveal if email exists
|
||||||
|
return {"message": "If the email exists, a verification link has been sent"}
|
||||||
|
|
||||||
|
if user.email_verified:
|
||||||
|
return {"message": "Email already verified"}
|
||||||
|
|
||||||
|
# Generate new token
|
||||||
|
user.verification_token = generate_token()
|
||||||
|
user.verification_token_expires = get_verification_expiry()
|
||||||
|
session.add(user)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
background_tasks.add_task(send_verification_email, user.email, user.verification_token)
|
||||||
|
|
||||||
|
return {"message": "If the email exists, a verification link has been sent"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/forgot-password")
|
||||||
|
async def forgot_password(
|
||||||
|
request: ForgotPasswordRequest,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
session: Session = Depends(get_session)
|
||||||
|
):
|
||||||
|
"""Request password reset email"""
|
||||||
|
user = session.exec(select(User).where(User.email == request.email)).first()
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
# Don't reveal if email exists
|
||||||
|
return {"message": "If the email exists, a reset link has been sent"}
|
||||||
|
|
||||||
|
# Generate reset token
|
||||||
|
user.reset_token = generate_token()
|
||||||
|
user.reset_token_expires = get_reset_expiry()
|
||||||
|
session.add(user)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
background_tasks.add_task(send_password_reset_email, user.email, user.reset_token)
|
||||||
|
|
||||||
|
return {"message": "If the email exists, a reset link has been sent"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/reset-password")
|
||||||
|
def reset_password(request: ResetPasswordRequest, session: Session = Depends(get_session)):
|
||||||
|
"""Reset password with token"""
|
||||||
|
user = session.exec(
|
||||||
|
select(User).where(User.reset_token == request.token)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid reset token")
|
||||||
|
|
||||||
|
if user.reset_token_expires and user.reset_token_expires < datetime.utcnow():
|
||||||
|
raise HTTPException(status_code=400, detail="Reset token expired")
|
||||||
|
|
||||||
|
# Update password
|
||||||
|
user.hashed_password = get_password_hash(request.new_password)
|
||||||
|
user.reset_token = None
|
||||||
|
user.reset_token_expires = None
|
||||||
|
session.add(user)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
return {"message": "Password reset successfully"}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/token", response_model=Token)
|
@router.post("/token", response_model=Token)
|
||||||
def login_for_access_token(
|
def login_for_access_token(
|
||||||
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
|
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
|
||||||
|
|
@ -49,6 +185,8 @@ def login_for_access_token(
|
||||||
)
|
)
|
||||||
return {"access_token": access_token, "token_type": "bearer"}
|
return {"access_token": access_token, "token_type": "bearer"}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/users/me", response_model=UserRead)
|
@router.get("/users/me", response_model=UserRead)
|
||||||
def read_users_me(current_user: Annotated[User, Depends(get_current_user)]):
|
def read_users_me(current_user: Annotated[User, Depends(get_current_user)]):
|
||||||
return current_user
|
return current_user
|
||||||
|
|
||||||
|
|
|
||||||
323
docs/PLATFORM_ENHANCEMENT_SPEC.md
Normal file
323
docs/PLATFORM_ENHANCEMENT_SPEC.md
Normal file
|
|
@ -0,0 +1,323 @@
|
||||||
|
# Platform Enhancement Spec v2.0
|
||||||
|
|
||||||
|
> **Sprint Goal**: Complete user lifecycle management, robust admin tools, and enhanced content features.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: User Account Lifecycle
|
||||||
|
|
||||||
|
### 1.1 Email Verification
|
||||||
|
|
||||||
|
**Goal**: Ensure valid email addresses and reduce spam accounts.
|
||||||
|
|
||||||
|
#### User Stories
|
||||||
|
|
||||||
|
- As a **new user**, I want to receive a verification email so I can confirm my account.
|
||||||
|
- As a **user**, I want to resend verification if I didn't receive it.
|
||||||
|
- As an **unverified user**, I see limited functionality until verified.
|
||||||
|
|
||||||
|
#### Implementation
|
||||||
|
|
||||||
|
1. **Model Changes** - Add to `User`:
|
||||||
|
- `email_verified: bool = Field(default=False)`
|
||||||
|
- `verification_token: Optional[str]`
|
||||||
|
- `verification_token_expires: Optional[datetime]`
|
||||||
|
|
||||||
|
2. **API Endpoints**:
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| POST | `/auth/register` | Creates user + sends verification email |
|
||||||
|
| POST | `/auth/verify-email` | Verifies token, sets `email_verified=True` |
|
||||||
|
| POST | `/auth/resend-verification` | Generates new token, sends email |
|
||||||
|
|
||||||
|
3. **Frontend Pages**:
|
||||||
|
- `/verify-email?token=xxx` - Handles verification link
|
||||||
|
- Registration success page prompts to check email
|
||||||
|
|
||||||
|
4. **Email Template**: HTML email with verification link (48hr expiry)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.2 Password Reset
|
||||||
|
|
||||||
|
**Goal**: Self-service password recovery without admin intervention.
|
||||||
|
|
||||||
|
#### User Stories
|
||||||
|
|
||||||
|
- As a **user**, I want to reset my password if I forgot it.
|
||||||
|
- As a **user**, I want a secure, time-limited reset link.
|
||||||
|
|
||||||
|
#### Implementation
|
||||||
|
|
||||||
|
1. **Model Changes** - Add to `User`:
|
||||||
|
- `reset_token: Optional[str]`
|
||||||
|
- `reset_token_expires: Optional[datetime]`
|
||||||
|
|
||||||
|
2. **API Endpoints**:
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| POST | `/auth/forgot-password` | Sends reset email (rate limited) |
|
||||||
|
| POST | `/auth/reset-password` | Validates token + sets new password |
|
||||||
|
|
||||||
|
3. **Frontend Pages**:
|
||||||
|
- `/forgot-password` - Email input form
|
||||||
|
- `/reset-password?token=xxx` - New password form
|
||||||
|
|
||||||
|
4. **Security**:
|
||||||
|
- Tokens expire in 1 hour
|
||||||
|
- Single-use tokens (invalidated after use)
|
||||||
|
- Rate limiting on forgot-password endpoint
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.3 Email Service Abstraction
|
||||||
|
|
||||||
|
**Goal**: Provider-agnostic email sending.
|
||||||
|
|
||||||
|
#### Implementation
|
||||||
|
|
||||||
|
Create `backend/email_service.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class EmailService:
|
||||||
|
async def send_verification_email(user, token)
|
||||||
|
async def send_password_reset_email(user, token)
|
||||||
|
async def send_notification_email(user, subject, body)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Environment Variables
|
||||||
|
|
||||||
|
```env
|
||||||
|
SMTP_HOST=smtp.sendgrid.net
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=apikey
|
||||||
|
SMTP_PASSWORD=<secret>
|
||||||
|
EMAIL_FROM=noreply@elmeg.xyz
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Admin Panel (`/admin`)
|
||||||
|
|
||||||
|
### 2.1 Overview
|
||||||
|
|
||||||
|
**Goal**: Full content management for administrators.
|
||||||
|
|
||||||
|
#### User Stories
|
||||||
|
|
||||||
|
- As an **admin**, I want to manage all users (roles, bans, verification status).
|
||||||
|
- As an **admin**, I want to CRUD shows, songs, venues, and tours.
|
||||||
|
- As an **admin**, I want to see platform statistics.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.2 Features
|
||||||
|
|
||||||
|
#### Users Tab
|
||||||
|
|
||||||
|
- DataTable with search/filter
|
||||||
|
- Columns: Email, Username, Role, Verified, Active, Joined
|
||||||
|
- Actions: Edit role, Toggle ban, Force verify, View activity
|
||||||
|
|
||||||
|
#### Content Tabs (Shows, Songs, Venues, Tours)
|
||||||
|
|
||||||
|
- DataTable with CRUD actions
|
||||||
|
- Create/Edit modals with form validation
|
||||||
|
- YouTube link fields for Shows/Songs/Performances
|
||||||
|
- Bulk delete with confirmation
|
||||||
|
|
||||||
|
#### Stats Dashboard
|
||||||
|
|
||||||
|
- Total users, verified users
|
||||||
|
- Total shows, songs, venues
|
||||||
|
- Recent signups chart
|
||||||
|
- Activity heatmap
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.3 Access Control
|
||||||
|
|
||||||
|
```python
|
||||||
|
# backend/routers/admin.py
|
||||||
|
allow_admin = RoleChecker(["admin"])
|
||||||
|
|
||||||
|
@router.get("/users")
|
||||||
|
def list_users(user: User = Depends(allow_admin), ...):
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: Enhanced Mod Panel (`/mod`)
|
||||||
|
|
||||||
|
### 3.1 Current Features
|
||||||
|
|
||||||
|
- ✅ Nickname approval queue
|
||||||
|
- ✅ Report queue (comments, reviews)
|
||||||
|
|
||||||
|
### 3.2 New Features
|
||||||
|
|
||||||
|
#### User Lookup
|
||||||
|
|
||||||
|
- Search user by email/username
|
||||||
|
- View user's full activity history:
|
||||||
|
- Comments, reviews, ratings
|
||||||
|
- Attendance history
|
||||||
|
- Reports submitted/received
|
||||||
|
|
||||||
|
#### Temp Bans
|
||||||
|
|
||||||
|
- Ban duration selector (1hr, 24hr, 7d, 30d, permanent)
|
||||||
|
- Ban reason (required)
|
||||||
|
- Auto-unban via scheduled job
|
||||||
|
|
||||||
|
#### Bulk Actions
|
||||||
|
|
||||||
|
- Select multiple reports → Dismiss All / Resolve All
|
||||||
|
- Select multiple nicknames → Approve All / Reject All
|
||||||
|
|
||||||
|
#### Audit Log
|
||||||
|
|
||||||
|
- Recent moderation actions by all mods
|
||||||
|
- Who did what, when
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: YouTube Integration
|
||||||
|
|
||||||
|
### 4.1 Current State
|
||||||
|
|
||||||
|
- ✅ `youtube_link` field on Show, Song, Performance
|
||||||
|
- ✅ `YouTubeEmbed` component
|
||||||
|
- ✅ Show detail page displays embed
|
||||||
|
|
||||||
|
### 4.2 Enhancements
|
||||||
|
|
||||||
|
#### Song Page YouTube
|
||||||
|
|
||||||
|
- Display YouTube embed of **#1 Heady Version**
|
||||||
|
- Performance list shows YouTube icon if link exists
|
||||||
|
|
||||||
|
#### Admin Integration
|
||||||
|
|
||||||
|
- YouTube URL field in Show/Song/Performance edit forms
|
||||||
|
- URL validation (must be valid YouTube URL)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: Song Page Enhancement ("Heady Version")
|
||||||
|
|
||||||
|
### 5.1 Concept
|
||||||
|
|
||||||
|
A **Song** is the abstract composition (e.g., "Hungersite").
|
||||||
|
A **Performance** is a specific rendition (e.g., "Hungersite @ Red Rocks 2023").
|
||||||
|
|
||||||
|
The "Heady Version" is the highest-rated performance of that song.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.2 Song Page Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ 🎵 HUNGERSITE │
|
||||||
|
│ Original: Goose │ Times Played: 127 │
|
||||||
|
├─────────────────────────────────────────────────────┤
|
||||||
|
│ ▶ HEADY VERSION │
|
||||||
|
│ [YouTube Embed of #1 Performance] │
|
||||||
|
│ 2023-07-21 @ Red Rocks ★ 9.4 (47 ratings) │
|
||||||
|
├─────────────────────────────────────────────────────┤
|
||||||
|
│ 📊 HEADY LEADERBOARD │
|
||||||
|
│ 🥇 Red Rocks 2023-07-21 9.4★ (47) │
|
||||||
|
│ 🥈 MSG 2024-03-15 9.1★ (32) │
|
||||||
|
│ 🥉 Legend Valley 2022-09-10 8.9★ (28) │
|
||||||
|
│ 4. Dillon 2023-08-15 8.7★ (19) │
|
||||||
|
│ 5. The Capitol 2024-01-20 8.5★ (22) │
|
||||||
|
├─────────────────────────────────────────────────────┤
|
||||||
|
│ 📈 RATING TREND │
|
||||||
|
│ [Line chart: avg rating over time] │
|
||||||
|
├─────────────────────────────────────────────────────┤
|
||||||
|
│ 📅 ALL PERFORMANCES │
|
||||||
|
│ [Sortable by: Date | Rating] │
|
||||||
|
│ ┌─────────────────────────────────────────────────┐ │
|
||||||
|
│ │ 2024-11-15 @ Orpheum 8.2★ ▶ [YouTube] │ │
|
||||||
|
│ │ 2024-10-20 @ Red Rocks 9.4★ ▶ [YouTube] │ │
|
||||||
|
│ │ ... │ │
|
||||||
|
│ └─────────────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.3 API Endpoints
|
||||||
|
|
||||||
|
| Endpoint | Returns |
|
||||||
|
|----------|---------|
|
||||||
|
| `GET /songs/{id}` | Song + stats (times_played, avg_rating, heady_version) |
|
||||||
|
| `GET /songs/{id}/performances` | All performances with ratings, sorted |
|
||||||
|
| `GET /songs/{id}/heady-version` | Top-rated performance with YouTube link |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration
|
||||||
|
|
||||||
|
### Database Changes
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE "user" ADD COLUMN email_verified BOOLEAN DEFAULT FALSE;
|
||||||
|
ALTER TABLE "user" ADD COLUMN verification_token VARCHAR;
|
||||||
|
ALTER TABLE "user" ADD COLUMN verification_token_expires TIMESTAMP;
|
||||||
|
ALTER TABLE "user" ADD COLUMN reset_token VARCHAR;
|
||||||
|
ALTER TABLE "user" ADD COLUMN reset_token_expires TIMESTAMP;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Requirements
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Required for email
|
||||||
|
SMTP_HOST=smtp.sendgrid.net
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=apikey
|
||||||
|
SMTP_PASSWORD=<your-sendgrid-key>
|
||||||
|
EMAIL_FROM=noreply@elmeg.xyz
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
### Phase 1
|
||||||
|
|
||||||
|
- [ ] User registers → receives verification email
|
||||||
|
- [ ] User clicks link → account verified
|
||||||
|
- [ ] Unverified user sees "verify email" banner
|
||||||
|
- [ ] User requests password reset → receives email
|
||||||
|
- [ ] User resets password → can login with new password
|
||||||
|
|
||||||
|
### Phase 2
|
||||||
|
|
||||||
|
- [ ] Admin can list/search all users
|
||||||
|
- [ ] Admin can change user roles
|
||||||
|
- [ ] Admin can CRUD shows/songs/venues/tours
|
||||||
|
- [ ] Admin can add YouTube links via UI
|
||||||
|
|
||||||
|
### Phase 3
|
||||||
|
|
||||||
|
- [ ] Mod can lookup user activity
|
||||||
|
- [ ] Mod can temp ban users
|
||||||
|
- [ ] Mod can bulk approve/reject
|
||||||
|
|
||||||
|
### Phase 4
|
||||||
|
|
||||||
|
- [ ] YouTube embeds on song pages
|
||||||
|
- [ ] YouTube icons on performance lists
|
||||||
|
|
||||||
|
### Phase 5
|
||||||
|
|
||||||
|
- [ ] Song page shows Heady Version embed
|
||||||
|
- [ ] Song page shows Heady Leaderboard
|
||||||
|
- [ ] Song page shows rating trend chart
|
||||||
|
- [ ] Performance list sortable by date/rating
|
||||||
110
frontend/app/forgot-password/page.tsx
Normal file
110
frontend/app/forgot-password/page.tsx
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Mail, ArrowLeft, CheckCircle } from "lucide-react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { getApiUrl } from "@/lib/api-config"
|
||||||
|
|
||||||
|
export default function ForgotPasswordPage() {
|
||||||
|
const [email, setEmail] = useState("")
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [submitted, setSubmitted] = useState(false)
|
||||||
|
const [error, setError] = useState("")
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setLoading(true)
|
||||||
|
setError("")
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${getApiUrl()}/auth/forgot-password`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email })
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setSubmitted(true)
|
||||||
|
} else {
|
||||||
|
const data = await res.json()
|
||||||
|
setError(data.detail || "Failed to send reset email")
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError("An error occurred. Please try again.")
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (submitted) {
|
||||||
|
return (
|
||||||
|
<div className="container max-w-md mx-auto py-16 px-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<CheckCircle className="h-12 w-12 text-green-500 mx-auto mb-4" />
|
||||||
|
<CardTitle>Check Your Email</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
If an account exists with that email, we've sent password reset instructions.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-center">
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link href="/login">
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
Back to Login
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container max-w-md mx-auto py-16 px-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<Mail className="h-12 w-12 text-primary mx-auto mb-4" />
|
||||||
|
<CardTitle>Forgot Password?</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Enter your email and we'll send you a reset link.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email">Email</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-red-500">{error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full" disabled={loading}>
|
||||||
|
{loading ? "Sending..." : "Send Reset Link"}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<Link href="/login" className="text-sm text-muted-foreground hover:underline">
|
||||||
|
<ArrowLeft className="inline h-3 w-3 mr-1" />
|
||||||
|
Back to Login
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -85,6 +85,9 @@ export default function LoginPage() {
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
<Link href="/forgot-password" className="text-xs text-muted-foreground hover:underline">
|
||||||
|
Forgot password?
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardFooter className="flex flex-col gap-4">
|
<CardFooter className="flex flex-col gap-4">
|
||||||
|
|
|
||||||
150
frontend/app/reset-password/page.tsx
Normal file
150
frontend/app/reset-password/page.tsx
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { useSearchParams } from "next/navigation"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Lock, CheckCircle, XCircle } from "lucide-react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { getApiUrl } from "@/lib/api-config"
|
||||||
|
|
||||||
|
export default function ResetPasswordPage() {
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const token = searchParams.get("token")
|
||||||
|
|
||||||
|
const [password, setPassword] = useState("")
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState("")
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [success, setSuccess] = useState(false)
|
||||||
|
const [error, setError] = useState("")
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setError("")
|
||||||
|
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
setError("Passwords don't match")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 8) {
|
||||||
|
setError("Password must be at least 8 characters")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${getApiUrl()}/auth/reset-password`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ token, new_password: password })
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setSuccess(true)
|
||||||
|
} else {
|
||||||
|
const data = await res.json()
|
||||||
|
setError(data.detail || "Failed to reset password")
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
setError("An error occurred. Please try again.")
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return (
|
||||||
|
<div className="container max-w-md mx-auto py-16 px-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<XCircle className="h-12 w-12 text-red-500 mx-auto mb-4" />
|
||||||
|
<CardTitle>Invalid Link</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
This password reset link is invalid or expired.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-center">
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/forgot-password">Request New Link</Link>
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
return (
|
||||||
|
<div className="container max-w-md mx-auto py-16 px-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<CheckCircle className="h-12 w-12 text-green-500 mx-auto mb-4" />
|
||||||
|
<CardTitle>Password Reset!</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Your password has been successfully updated.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-center">
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/login">Continue to Login</Link>
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container max-w-md mx-auto py-16 px-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<Lock className="h-12 w-12 text-primary mx-auto mb-4" />
|
||||||
|
<CardTitle>Set New Password</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Enter your new password below.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">New Password</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="confirmPassword">Confirm Password</Label>
|
||||||
|
<Input
|
||||||
|
id="confirmPassword"
|
||||||
|
type="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-red-500">{error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button type="submit" className="w-full" disabled={loading}>
|
||||||
|
{loading ? "Resetting..." : "Reset Password"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
86
frontend/app/verify-email/page.tsx
Normal file
86
frontend/app/verify-email/page.tsx
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { useSearchParams } from "next/navigation"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { CheckCircle, XCircle, Loader2 } from "lucide-react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { getApiUrl } from "@/lib/api-config"
|
||||||
|
|
||||||
|
export default function VerifyEmailPage() {
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const [status, setStatus] = useState<"loading" | "success" | "error">("loading")
|
||||||
|
const [message, setMessage] = useState("")
|
||||||
|
|
||||||
|
const verifyEmail = async (token: string) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${getApiUrl()}/auth/verify-email`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ token })
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await res.json()
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setStatus("success")
|
||||||
|
setMessage(data.message || "Email verified successfully!")
|
||||||
|
} else {
|
||||||
|
setStatus("error")
|
||||||
|
setMessage(data.detail || "Verification failed")
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
setStatus("error")
|
||||||
|
setMessage("An error occurred during verification")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const token = searchParams.get("token")
|
||||||
|
if (!token) {
|
||||||
|
setStatus("error")
|
||||||
|
setMessage("No verification token provided")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
verifyEmail(token)
|
||||||
|
}, [searchParams])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container max-w-md mx-auto py-16 px-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<CardTitle className="flex items-center justify-center gap-2">
|
||||||
|
{status === "loading" && <Loader2 className="h-6 w-6 animate-spin" />}
|
||||||
|
{status === "success" && <CheckCircle className="h-6 w-6 text-green-500" />}
|
||||||
|
{status === "error" && <XCircle className="h-6 w-6 text-red-500" />}
|
||||||
|
Email Verification
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{status === "loading" && "Verifying your email..."}
|
||||||
|
{status === "success" && "Your email has been verified!"}
|
||||||
|
{status === "error" && "Verification failed"}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-center space-y-4">
|
||||||
|
<p className="text-muted-foreground">{message}</p>
|
||||||
|
|
||||||
|
{status === "success" && (
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/login">Continue to Login</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === "error" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Button variant="outline" asChild>
|
||||||
|
<Link href="/login">Go to Login</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue