Spaces:
Running
Running
Anish commited on
Commit ·
a16341c
1
Parent(s): ee7c0fb
[UI/UX] Added Account deletion, with some new features too.
Browse files- backend/app/api/auth_routes.py +5 -3
- backend/app/api/user_routes.py +15 -1
- backend/app/services/auth_service.py +3 -3
- backend/app/services/email_service.py +40 -24
- backend/app/services/file_delete_service.py +4 -0
- backend/app/services/user_service.py +25 -2
- backend/app/worker/tasks.py +8 -1
- backend/main.py +1 -0
- frontend/app/favicon.ico +0 -0
- frontend/app/layout.tsx +1 -1
- frontend/app/login/page.tsx +9 -9
- frontend/app/page.tsx +23 -1
- frontend/app/profile/page.tsx +73 -2
- frontend/app/verify-email/page.tsx +117 -0
- frontend/lib/api.ts +4 -1
backend/app/api/auth_routes.py
CHANGED
|
@@ -8,6 +8,7 @@ from app.services.oauth_service import handle_oauth_login
|
|
| 8 |
from app.services.email_service import send_reset_password_email, send_verification_email
|
| 9 |
from app.security.turnstile import verify_turnstile_token
|
| 10 |
from app.core.auth_dependancy import get_current_user
|
|
|
|
| 11 |
|
| 12 |
router = APIRouter(prefix="/auth", tags=["Auth"])
|
| 13 |
|
|
@@ -108,10 +109,11 @@ def reset_password(data: PasswordResetConfirm, request: Request, db: Session = D
|
|
| 108 |
|
| 109 |
@router.post("/verify-email")
|
| 110 |
def verify_email(data: VerifyEmailConfirm, db: Session = Depends(get_db)):
|
| 111 |
-
|
| 112 |
-
if not
|
| 113 |
raise HTTPException(status_code=400, detail="Invalid or expired verification token")
|
| 114 |
-
|
|
|
|
| 115 |
|
| 116 |
@router.post("/resend-verification")
|
| 117 |
def resend_verification_endpoint(
|
|
|
|
| 8 |
from app.services.email_service import send_reset_password_email, send_verification_email
|
| 9 |
from app.security.turnstile import verify_turnstile_token
|
| 10 |
from app.core.auth_dependancy import get_current_user
|
| 11 |
+
from app.utils.jwt_handler import create_access_token
|
| 12 |
|
| 13 |
router = APIRouter(prefix="/auth", tags=["Auth"])
|
| 14 |
|
|
|
|
| 109 |
|
| 110 |
@router.post("/verify-email")
|
| 111 |
def verify_email(data: VerifyEmailConfirm, db: Session = Depends(get_db)):
|
| 112 |
+
user = confirm_email_verification(db, data.token)
|
| 113 |
+
if not user:
|
| 114 |
raise HTTPException(status_code=400, detail="Invalid or expired verification token")
|
| 115 |
+
token = create_access_token({"user_id": user.id})
|
| 116 |
+
return {"message": "Email successfully verified!", "access_token": token}
|
| 117 |
|
| 118 |
@router.post("/resend-verification")
|
| 119 |
def resend_verification_endpoint(
|
backend/app/api/user_routes.py
CHANGED
|
@@ -5,6 +5,9 @@ from app.services.user_service import create_user
|
|
| 5 |
from app.db.session import get_db
|
| 6 |
from app.services.email_service import send_verification_email
|
| 7 |
from app.security.turnstile import verify_turnstile_token
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
router = APIRouter(prefix="/users", tags=["Users"])
|
| 10 |
|
|
@@ -39,4 +42,15 @@ async def register(
|
|
| 39 |
return {
|
| 40 |
"message": "User Created Successfully! Please check your email to verify.",
|
| 41 |
"user_id": new_user.id
|
| 42 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
from app.db.session import get_db
|
| 6 |
from app.services.email_service import send_verification_email
|
| 7 |
from app.security.turnstile import verify_turnstile_token
|
| 8 |
+
from app.core.auth_dependancy import get_current_user
|
| 9 |
+
from app.models.user_model import User
|
| 10 |
+
from app.services.user_service import delete_user_account
|
| 11 |
|
| 12 |
router = APIRouter(prefix="/users", tags=["Users"])
|
| 13 |
|
|
|
|
| 42 |
return {
|
| 43 |
"message": "User Created Successfully! Please check your email to verify.",
|
| 44 |
"user_id": new_user.id
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
@router.delete("/delete")
|
| 48 |
+
def delete_account(
|
| 49 |
+
db: Session = Depends(get_db),
|
| 50 |
+
current_user: User = Depends(get_current_user)
|
| 51 |
+
):
|
| 52 |
+
try:
|
| 53 |
+
delete_user_account(db, current_user.id)
|
| 54 |
+
return {"message": "Account successfully deleted"}
|
| 55 |
+
except Exception as e:
|
| 56 |
+
raise HTTPException(status_code=500, detail=str(e))
|
backend/app/services/auth_service.py
CHANGED
|
@@ -102,17 +102,17 @@ def confirm_email_verification(db: Session, token: str) -> bool:
|
|
| 102 |
break
|
| 103 |
|
| 104 |
if not target_user:
|
| 105 |
-
return
|
| 106 |
|
| 107 |
if datetime.now(UTC) > target_user.verification_token_expire_at.replace(tzinfo=UTC):
|
| 108 |
-
return
|
| 109 |
|
| 110 |
target_user.is_verified = True
|
| 111 |
target_user.verification_token_hash = None
|
| 112 |
target_user.verification_token_expire_at = None
|
| 113 |
db.commit()
|
| 114 |
|
| 115 |
-
return
|
| 116 |
|
| 117 |
def resend_verification(db: Session, email: str) -> str | None:
|
| 118 |
user = db.query(User).filter(User.email == email).first()
|
|
|
|
| 102 |
break
|
| 103 |
|
| 104 |
if not target_user:
|
| 105 |
+
return None
|
| 106 |
|
| 107 |
if datetime.now(UTC) > target_user.verification_token_expire_at.replace(tzinfo=UTC):
|
| 108 |
+
return None
|
| 109 |
|
| 110 |
target_user.is_verified = True
|
| 111 |
target_user.verification_token_hash = None
|
| 112 |
target_user.verification_token_expire_at = None
|
| 113 |
db.commit()
|
| 114 |
|
| 115 |
+
return target_user
|
| 116 |
|
| 117 |
def resend_verification(db: Session, email: str) -> str | None:
|
| 118 |
user = db.query(User).filter(User.email == email).first()
|
backend/app/services/email_service.py
CHANGED
|
@@ -7,19 +7,24 @@ logger = logging.getLogger(__name__)
|
|
| 7 |
async def send_reset_password_email(email_to: str, raw_token: str):
|
| 8 |
reset_link = f"{settings.FRONTEND_URL}/reset-password?token={raw_token}"
|
| 9 |
html_content = f"""
|
| 10 |
-
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; color: #
|
| 11 |
-
<
|
| 12 |
-
|
| 13 |
-
<
|
| 14 |
-
<div style="
|
| 15 |
-
<
|
| 16 |
-
|
| 17 |
-
</
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
</div>
|
| 19 |
-
<p>If you did not request a password reset, please ignore this email or contact support if you have concerns.</p>
|
| 20 |
-
<p style="font-size: 12px; color: #777; margin-top: 40px;">
|
| 21 |
-
This is an automated message from our system.
|
| 22 |
-
</p>
|
| 23 |
</div>
|
| 24 |
"""
|
| 25 |
|
|
@@ -27,14 +32,15 @@ async def send_reset_password_email(email_to: str, raw_token: str):
|
|
| 27 |
logger.warning(f"[DEV MODE] SMTP credentials missing. To manually test, use this link: {reset_link}")
|
| 28 |
return
|
| 29 |
try:
|
|
|
|
| 30 |
conf = ConnectionConfig(
|
| 31 |
MAIL_USERNAME=settings.SMTP_USER,
|
| 32 |
MAIL_PASSWORD=settings.SMTP_PASSWORD,
|
| 33 |
MAIL_FROM=settings.SMTP_SENDER_EMAIL or "noreply@localhost.com",
|
| 34 |
MAIL_PORT=settings.SMTP_PORT,
|
| 35 |
MAIL_SERVER=settings.SMTP_SERVER,
|
| 36 |
-
MAIL_STARTTLS=
|
| 37 |
-
MAIL_SSL_TLS=
|
| 38 |
USE_CREDENTIALS=True,
|
| 39 |
VALIDATE_CERTS=True
|
| 40 |
)
|
|
@@ -57,14 +63,23 @@ async def send_verification_email(email_to: str, raw_token: str):
|
|
| 57 |
verify_link = f"{settings.FRONTEND_URL}/verify-email?token={raw_token}"
|
| 58 |
|
| 59 |
html_content = f"""
|
| 60 |
-
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; color: #
|
| 61 |
-
<
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
<div
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
</
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
</div>
|
| 69 |
</div>
|
| 70 |
"""
|
|
@@ -74,14 +89,15 @@ async def send_verification_email(email_to: str, raw_token: str):
|
|
| 74 |
return
|
| 75 |
|
| 76 |
try:
|
|
|
|
| 77 |
conf = ConnectionConfig(
|
| 78 |
MAIL_USERNAME=settings.SMTP_USER,
|
| 79 |
MAIL_PASSWORD=settings.SMTP_PASSWORD,
|
| 80 |
MAIL_FROM=settings.SMTP_SENDER_EMAIL or "noreply@localhost.com",
|
| 81 |
MAIL_PORT=settings.SMTP_PORT,
|
| 82 |
MAIL_SERVER=settings.SMTP_SERVER,
|
| 83 |
-
MAIL_STARTTLS=
|
| 84 |
-
MAIL_SSL_TLS=
|
| 85 |
USE_CREDENTIALS=True,
|
| 86 |
VALIDATE_CERTS=True
|
| 87 |
)
|
|
|
|
| 7 |
async def send_reset_password_email(email_to: str, raw_token: str):
|
| 8 |
reset_link = f"{settings.FRONTEND_URL}/reset-password?token={raw_token}"
|
| 9 |
html_content = f"""
|
| 10 |
+
<div style="font-family: 'Inter', Arial, sans-serif; max-width: 600px; margin: 0 auto; background-color: #080c14; border: 1px solid #1f2937; border-radius: 16px; overflow: hidden;">
|
| 11 |
+
<div style="background-color: #11141d; padding: 40px; text-align: center; border-bottom: 1px solid #1f2937;">
|
| 12 |
+
<h1 style="color: #fde8d6; margin: 0; font-size: 28px; letter-spacing: 2px; text-transform: uppercase; font-weight: 900;">SPOTIX</h1>
|
| 13 |
+
</div>
|
| 14 |
+
<div style="padding: 40px; color: #d0c4bb; line-height: 1.6;">
|
| 15 |
+
<h2 style="color: #fde8d6; margin-top: 0; font-size: 20px;">Password Reset Request</h2>
|
| 16 |
+
<p>You recently requested to reset your password for your Spotix account.</p>
|
| 17 |
+
<p>Click the secure link below to reset it. <strong style="color: #fde8d6;">This link will expire perfectly in 15 minutes.</strong></p>
|
| 18 |
+
<div style="text-align: center; margin: 40px 0;">
|
| 19 |
+
<a href="{reset_link}" style="background-color: #fde8d6; color: #080c14; padding: 14px 32px; text-decoration: none; border-radius: 8px; font-weight: bold; display: inline-block; text-transform: uppercase; letter-spacing: 1px; font-size: 14px;">
|
| 20 |
+
Reset Password
|
| 21 |
+
</a>
|
| 22 |
+
</div>
|
| 23 |
+
<p style="font-size: 14px; opacity: 0.8;">If you did not request a password reset, please ignore this email or contact support if you have concerns.</p>
|
| 24 |
+
<div style="margin-top: 40px; padding-top: 20px; border-top: 1px solid #1f2937; font-size: 11px; text-align: center; opacity: 0.5; text-transform: uppercase; letter-spacing: 1px;">
|
| 25 |
+
© 2026 SPOTIX KINETIC. ENGINEERED FOR THE ETHEREAL.
|
| 26 |
+
</div>
|
| 27 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
</div>
|
| 29 |
"""
|
| 30 |
|
|
|
|
| 32 |
logger.warning(f"[DEV MODE] SMTP credentials missing. To manually test, use this link: {reset_link}")
|
| 33 |
return
|
| 34 |
try:
|
| 35 |
+
is_ssl = int(settings.SMTP_PORT) == 465
|
| 36 |
conf = ConnectionConfig(
|
| 37 |
MAIL_USERNAME=settings.SMTP_USER,
|
| 38 |
MAIL_PASSWORD=settings.SMTP_PASSWORD,
|
| 39 |
MAIL_FROM=settings.SMTP_SENDER_EMAIL or "noreply@localhost.com",
|
| 40 |
MAIL_PORT=settings.SMTP_PORT,
|
| 41 |
MAIL_SERVER=settings.SMTP_SERVER,
|
| 42 |
+
MAIL_STARTTLS=not is_ssl,
|
| 43 |
+
MAIL_SSL_TLS=is_ssl,
|
| 44 |
USE_CREDENTIALS=True,
|
| 45 |
VALIDATE_CERTS=True
|
| 46 |
)
|
|
|
|
| 63 |
verify_link = f"{settings.FRONTEND_URL}/verify-email?token={raw_token}"
|
| 64 |
|
| 65 |
html_content = f"""
|
| 66 |
+
<div style="font-family: 'Inter', Arial, sans-serif; max-width: 600px; margin: 0 auto; background-color: #080c14; border: 1px solid #1f2937; border-radius: 16px; overflow: hidden;">
|
| 67 |
+
<div style="background-color: #11141d; padding: 40px; text-align: center; border-bottom: 1px solid #1f2937;">
|
| 68 |
+
<h1 style="color: #fde8d6; margin: 0; font-size: 28px; letter-spacing: 2px; text-transform: uppercase; font-weight: 900;">SPOTIX</h1>
|
| 69 |
+
<p style="color: #d0c4bb; margin: 10px 0 0 0; font-size: 12px; letter-spacing: 3px; text-transform: uppercase; opacity: 0.7;">Identity Uplink</p>
|
| 70 |
+
</div>
|
| 71 |
+
<div style="padding: 40px; color: #d0c4bb; line-height: 1.6;">
|
| 72 |
+
<h2 style="color: #fde8d6; margin-top: 0; font-size: 20px;">Welcome to the Engine.</h2>
|
| 73 |
+
<p>Please confirm your neural-link authorization by verifying your email address.</p>
|
| 74 |
+
<p>Click the secure uplink button below to activate your account. <strong style="color: #fde8d6;">This link will expire in 24 hours.</strong></p>
|
| 75 |
+
<div style="text-align: center; margin: 40px 0;">
|
| 76 |
+
<a href="{verify_link}" style="background-color: #fde8d6; color: #080c14; padding: 14px 32px; text-decoration: none; border-radius: 8px; font-weight: bold; display: inline-block; text-transform: uppercase; letter-spacing: 1px; font-size: 14px;">
|
| 77 |
+
Verify Authorization
|
| 78 |
+
</a>
|
| 79 |
+
</div>
|
| 80 |
+
<div style="margin-top: 40px; padding-top: 20px; border-top: 1px solid #1f2937; font-size: 11px; text-align: center; opacity: 0.5; text-transform: uppercase; letter-spacing: 1px;">
|
| 81 |
+
© 2026 SPOTIX KINETIC. ENGINEERED FOR THE ETHEREAL.
|
| 82 |
+
</div>
|
| 83 |
</div>
|
| 84 |
</div>
|
| 85 |
"""
|
|
|
|
| 89 |
return
|
| 90 |
|
| 91 |
try:
|
| 92 |
+
is_ssl = int(settings.SMTP_PORT) == 465
|
| 93 |
conf = ConnectionConfig(
|
| 94 |
MAIL_USERNAME=settings.SMTP_USER,
|
| 95 |
MAIL_PASSWORD=settings.SMTP_PASSWORD,
|
| 96 |
MAIL_FROM=settings.SMTP_SENDER_EMAIL or "noreply@localhost.com",
|
| 97 |
MAIL_PORT=settings.SMTP_PORT,
|
| 98 |
MAIL_SERVER=settings.SMTP_SERVER,
|
| 99 |
+
MAIL_STARTTLS=not is_ssl,
|
| 100 |
+
MAIL_SSL_TLS=is_ssl,
|
| 101 |
USE_CREDENTIALS=True,
|
| 102 |
VALIDATE_CERTS=True
|
| 103 |
)
|
backend/app/services/file_delete_service.py
CHANGED
|
@@ -15,6 +15,10 @@ def delete_file_service(db: Session, file_id: int, user_id: int):
|
|
| 15 |
|
| 16 |
active_storage.delete(file.filepath)
|
| 17 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
db.delete(file)
|
| 19 |
db.commit()
|
| 20 |
|
|
|
|
| 15 |
|
| 16 |
active_storage.delete(file.filepath)
|
| 17 |
|
| 18 |
+
# Delete related jobs first to prevent FK IntegrityError in SQLite
|
| 19 |
+
from app.models.job_model import Job
|
| 20 |
+
db.query(Job).filter(Job.file_id == file.id).delete()
|
| 21 |
+
|
| 22 |
db.delete(file)
|
| 23 |
db.commit()
|
| 24 |
|
backend/app/services/user_service.py
CHANGED
|
@@ -2,7 +2,10 @@ import secrets
|
|
| 2 |
from datetime import timedelta, datetime, UTC
|
| 3 |
from sqlalchemy.orm import Session
|
| 4 |
from app.models.user_model import User
|
|
|
|
|
|
|
| 5 |
from app.utils.security import hash_password
|
|
|
|
| 6 |
|
| 7 |
def create_user(db: Session, email: str, username: str, password: str) -> tuple[User | None, str | None]:
|
| 8 |
if db.query(User).filter(User.email == email).first():
|
|
@@ -29,5 +32,25 @@ def create_user(db: Session, email: str, username: str, password: str) -> tuple[
|
|
| 29 |
db.add(new_user)
|
| 30 |
db.commit()
|
| 31 |
db.refresh(new_user)
|
| 32 |
-
|
| 33 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
from datetime import timedelta, datetime, UTC
|
| 3 |
from sqlalchemy.orm import Session
|
| 4 |
from app.models.user_model import User
|
| 5 |
+
from app.models.file_model import File
|
| 6 |
+
from app.models.user_issue_model import UserIssue
|
| 7 |
from app.utils.security import hash_password
|
| 8 |
+
from app.services.file_delete_service import delete_file_service
|
| 9 |
|
| 10 |
def create_user(db: Session, email: str, username: str, password: str) -> tuple[User | None, str | None]:
|
| 11 |
if db.query(User).filter(User.email == email).first():
|
|
|
|
| 32 |
db.add(new_user)
|
| 33 |
db.commit()
|
| 34 |
db.refresh(new_user)
|
| 35 |
+
return new_user, raw_token
|
| 36 |
+
|
| 37 |
+
def delete_user_account(db: Session, user_id: int):
|
| 38 |
+
# 1. Fetch user
|
| 39 |
+
user = db.query(User).filter(User.id == user_id).first()
|
| 40 |
+
if not user:
|
| 41 |
+
raise ValueError("User not found")
|
| 42 |
+
|
| 43 |
+
# 2. Delete all files (and Cloudinary storage media)
|
| 44 |
+
files = db.query(File).filter(File.owner_id == user_id).all()
|
| 45 |
+
for file in files:
|
| 46 |
+
try:
|
| 47 |
+
delete_file_service(db, file.id, user_id)
|
| 48 |
+
except Exception:
|
| 49 |
+
pass # ignore if already deleted or error, ensure we keep deleting others
|
| 50 |
+
|
| 51 |
+
# 3. Delete all User Issues
|
| 52 |
+
db.query(UserIssue).filter(UserIssue.user_id == user_id).delete()
|
| 53 |
+
|
| 54 |
+
# 4. Delete the user
|
| 55 |
+
db.delete(user)
|
| 56 |
+
db.commit()
|
backend/app/worker/tasks.py
CHANGED
|
@@ -46,7 +46,14 @@ def process_file_task(self, file_id: int):
|
|
| 46 |
from app.models.file_model import File
|
| 47 |
db_file = db.query(File).filter(File.id == file_id).first()
|
| 48 |
|
| 49 |
-
if
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
from app.services.video_processor import process_video_pipeline
|
| 51 |
process_video_pipeline(file_id, db_file.filepath, db)
|
| 52 |
else:
|
|
|
|
| 46 |
from app.models.file_model import File
|
| 47 |
db_file = db.query(File).filter(File.id == file_id).first()
|
| 48 |
|
| 49 |
+
if not db_file:
|
| 50 |
+
logger.info(f"File {file_id} not found (possibly user deleted account). Aborting task.")
|
| 51 |
+
job.status = "CANCELLED"
|
| 52 |
+
job.finished_at = datetime.now(UTC)
|
| 53 |
+
db.commit()
|
| 54 |
+
return
|
| 55 |
+
|
| 56 |
+
if db_file.filetype and db_file.filetype.startswith('video/'):
|
| 57 |
from app.services.video_processor import process_video_pipeline
|
| 58 |
process_video_pipeline(file_id, db_file.filepath, db)
|
| 59 |
else:
|
backend/main.py
CHANGED
|
@@ -38,6 +38,7 @@ app.add_middleware(
|
|
| 38 |
allow_headers=["*"],
|
| 39 |
)
|
| 40 |
|
|
|
|
| 41 |
app.mount("/static", StaticFiles(directory="."), name="static")
|
| 42 |
|
| 43 |
app.state.limiter = limiter
|
|
|
|
| 38 |
allow_headers=["*"],
|
| 39 |
)
|
| 40 |
|
| 41 |
+
# Trigger reload
|
| 42 |
app.mount("/static", StaticFiles(directory="."), name="static")
|
| 43 |
|
| 44 |
app.state.limiter = limiter
|
frontend/app/favicon.ico
CHANGED
|
|
Git LFS Details
|
|
|
Git LFS Details
|
frontend/app/layout.tsx
CHANGED
|
@@ -19,7 +19,7 @@ const geistMono = Geist_Mono({
|
|
| 19 |
|
| 20 |
export const metadata: Metadata = {
|
| 21 |
title: "Spotix",
|
| 22 |
-
description: "
|
| 23 |
};
|
| 24 |
|
| 25 |
import { AuthProvider } from "@/contexts/AuthContext";
|
|
|
|
| 19 |
|
| 20 |
export const metadata: Metadata = {
|
| 21 |
title: "Spotix",
|
| 22 |
+
description: "Spotix is a modern, dark-themed SaaS platform that helps users detect whether images, videos, and audio are AI-generated or authentic. It uses multi-signal analysis—including noise patterns, frequency characteristics, and temporal consistency—to produce a confidence score along with visual explanations like heatmaps. The experience is smooth and minimal, with real-time processing, a unified upload-to-result flow, and a dashboard for tracking past analyses. Spotix focuses on clarity, trust, and explainability rather than black-box predictions.",
|
| 23 |
};
|
| 24 |
|
| 25 |
import { AuthProvider } from "@/contexts/AuthContext";
|
frontend/app/login/page.tsx
CHANGED
|
@@ -132,7 +132,7 @@ export default function LoginPage() {
|
|
| 132 |
</button>
|
| 133 |
</div>
|
| 134 |
|
| 135 |
-
<div className="z-10 w-full max-w-md relative mt-
|
| 136 |
<div className="bg-[var(--theme-bg)]/80 backdrop-blur-3xl border border-theme-border shadow-[0_0_80px_rgba(0,0,0,0.8)] rounded-3xl p-8 relative overflow-hidden">
|
| 137 |
<div className="absolute -top-40 -right-40 w-80 h-80 bg-theme-text/5 rounded-full blur-[80px] pointer-events-none"></div>
|
| 138 |
|
|
@@ -155,51 +155,51 @@ export default function LoginPage() {
|
|
| 155 |
<form onSubmit={handleSubmit} className="space-y-4">
|
| 156 |
{mode === 'signup' && (
|
| 157 |
<div className="relative group">
|
| 158 |
-
<User className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-[#d0c4bb]/40 group-focus-within:text-[var(--theme-
|
| 159 |
<input
|
| 160 |
type="text"
|
| 161 |
required={mode === 'signup'}
|
| 162 |
value={username}
|
| 163 |
onChange={(e) => setUsername(e.target.value)}
|
| 164 |
placeholder="Username"
|
| 165 |
-
className="w-full bg-theme-text/5 border border-theme-border rounded-xl py-4 pl-12 pr-4 text-[var(--theme-text)] placeholder-[#d0c4bb]/
|
| 166 |
/>
|
| 167 |
</div>
|
| 168 |
)}
|
| 169 |
{mode === 'login' ? (
|
| 170 |
<div className="relative group">
|
| 171 |
-
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-[#d0c4bb]/40 group-focus-within:text-[var(--theme-
|
| 172 |
<input
|
| 173 |
type="text"
|
| 174 |
required={mode === 'login'}
|
| 175 |
value={identifier}
|
| 176 |
onChange={(e) => setIdentifier(e.target.value)}
|
| 177 |
placeholder="Email or Username"
|
| 178 |
-
className="w-full bg-theme-text/5 border border-theme-border rounded-xl py-4 pl-12 pr-4 text-[var(--theme-text)] placeholder-[#d0c4bb]/
|
| 179 |
/>
|
| 180 |
</div>
|
| 181 |
) : (
|
| 182 |
<div className="relative group">
|
| 183 |
-
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-[#d0c4bb]/40 group-focus-within:text-[var(--theme-
|
| 184 |
<input
|
| 185 |
type="email"
|
| 186 |
required={mode === 'signup'}
|
| 187 |
value={email}
|
| 188 |
onChange={(e) => setEmail(e.target.value)}
|
| 189 |
placeholder="Email"
|
| 190 |
-
className="w-full bg-theme-text/5 border border-theme-border rounded-xl py-4 pl-12 pr-4 text-[var(--theme-text)] placeholder-[#d0c4bb]/
|
| 191 |
/>
|
| 192 |
</div>
|
| 193 |
)}
|
| 194 |
<div className="relative group">
|
| 195 |
-
<KeyRound className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-[#d0c4bb]/40 group-focus-within:text-[var(--theme-
|
| 196 |
<input
|
| 197 |
type="password"
|
| 198 |
required
|
| 199 |
value={password}
|
| 200 |
onChange={(e) => setPassword(e.target.value)}
|
| 201 |
placeholder="Password"
|
| 202 |
-
className="w-full bg-theme-text/5 border border-theme-border rounded-xl py-4 pl-12 pr-4 text-[var(--theme-text)] placeholder-[#d0c4bb]/
|
| 203 |
/>
|
| 204 |
</div>
|
| 205 |
|
|
|
|
| 132 |
</button>
|
| 133 |
</div>
|
| 134 |
|
| 135 |
+
<div className="z-10 w-full max-w-md relative mt-0 mb-2">
|
| 136 |
<div className="bg-[var(--theme-bg)]/80 backdrop-blur-3xl border border-theme-border shadow-[0_0_80px_rgba(0,0,0,0.8)] rounded-3xl p-8 relative overflow-hidden">
|
| 137 |
<div className="absolute -top-40 -right-40 w-80 h-80 bg-theme-text/5 rounded-full blur-[80px] pointer-events-none"></div>
|
| 138 |
|
|
|
|
| 155 |
<form onSubmit={handleSubmit} className="space-y-4">
|
| 156 |
{mode === 'signup' && (
|
| 157 |
<div className="relative group">
|
| 158 |
+
<User className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-[#d0c4bb]/40 group-focus-within:text-[var(--theme-text)] transition-colors" />
|
| 159 |
<input
|
| 160 |
type="text"
|
| 161 |
required={mode === 'signup'}
|
| 162 |
value={username}
|
| 163 |
onChange={(e) => setUsername(e.target.value)}
|
| 164 |
placeholder="Username"
|
| 165 |
+
className="w-full bg-theme-text/5 border border-theme-border rounded-xl py-4 pl-12 pr-4 text-[var(--theme-text)] placeholder-[#d0c4bb]/60 focus:outline-none focus:border-theme-border/50 focus:bg-theme-text/10 transition-all font-mono text-sm"
|
| 166 |
/>
|
| 167 |
</div>
|
| 168 |
)}
|
| 169 |
{mode === 'login' ? (
|
| 170 |
<div className="relative group">
|
| 171 |
+
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-[#d0c4bb]/40 group-focus-within:text-[var(--theme-text)] transition-colors" />
|
| 172 |
<input
|
| 173 |
type="text"
|
| 174 |
required={mode === 'login'}
|
| 175 |
value={identifier}
|
| 176 |
onChange={(e) => setIdentifier(e.target.value)}
|
| 177 |
placeholder="Email or Username"
|
| 178 |
+
className="w-full bg-theme-text/5 border border-theme-border rounded-xl py-4 pl-12 pr-4 text-[var(--theme-text)] placeholder-[#d0c4bb]/60 focus:outline-none focus:border-theme-border/50 focus:bg-theme-text/10 transition-all font-mono text-sm"
|
| 179 |
/>
|
| 180 |
</div>
|
| 181 |
) : (
|
| 182 |
<div className="relative group">
|
| 183 |
+
<Mail className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-[#d0c4bb]/40 group-focus-within:text-[var(--theme-text)] transition-colors" />
|
| 184 |
<input
|
| 185 |
type="email"
|
| 186 |
required={mode === 'signup'}
|
| 187 |
value={email}
|
| 188 |
onChange={(e) => setEmail(e.target.value)}
|
| 189 |
placeholder="Email"
|
| 190 |
+
className="w-full bg-theme-text/5 border border-theme-border rounded-xl py-4 pl-12 pr-4 text-[var(--theme-text)] placeholder-[#d0c4bb]/60 focus:outline-none focus:border-theme-border/50 focus:bg-theme-text/10 transition-all font-mono text-sm"
|
| 191 |
/>
|
| 192 |
</div>
|
| 193 |
)}
|
| 194 |
<div className="relative group">
|
| 195 |
+
<KeyRound className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-[#d0c4bb]/40 group-focus-within:text-[var(--theme-text)] transition-colors" />
|
| 196 |
<input
|
| 197 |
type="password"
|
| 198 |
required
|
| 199 |
value={password}
|
| 200 |
onChange={(e) => setPassword(e.target.value)}
|
| 201 |
placeholder="Password"
|
| 202 |
+
className="w-full bg-theme-text/5 border border-theme-border rounded-xl py-4 pl-12 pr-4 text-[var(--theme-text)] placeholder-[#d0c4bb]/60 focus:outline-none focus:border-theme-border/50 focus:bg-theme-text/10 transition-all font-mono text-sm"
|
| 203 |
/>
|
| 204 |
</div>
|
| 205 |
|
frontend/app/page.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
import React, { useEffect, useRef, useState, useCallback } from "react";
|
| 4 |
-
import { useRouter } from "next/navigation";
|
| 5 |
import axios from "axios";
|
| 6 |
import { ArrowRight, CloudUpload, CheckCircle, Menu, X, Fingerprint, Activity, Network } from "lucide-react";
|
| 7 |
import UploadZone from "@/components/upload/UploadZone";
|
|
@@ -11,6 +11,20 @@ export default function LandingPage() {
|
|
| 11 |
const router = useRouter();
|
| 12 |
|
| 13 |
const [isUploadModalOpen, setIsUploadModalOpen] = useState(false);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
// --- Footer Copyright and Dynamic Date ---
|
| 16 |
const [year, setYear] = useState(new Date().getFullYear());
|
|
@@ -299,6 +313,14 @@ export default function LandingPage() {
|
|
| 299 |
</div>
|
| 300 |
)}
|
| 301 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 302 |
{/* Global Navbar */}
|
| 303 |
<Navbar />
|
| 304 |
|
|
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
import React, { useEffect, useRef, useState, useCallback } from "react";
|
| 4 |
+
import { useRouter, useSearchParams } from "next/navigation";
|
| 5 |
import axios from "axios";
|
| 6 |
import { ArrowRight, CloudUpload, CheckCircle, Menu, X, Fingerprint, Activity, Network } from "lucide-react";
|
| 7 |
import UploadZone from "@/components/upload/UploadZone";
|
|
|
|
| 11 |
const router = useRouter();
|
| 12 |
|
| 13 |
const [isUploadModalOpen, setIsUploadModalOpen] = useState(false);
|
| 14 |
+
const searchParams = useSearchParams();
|
| 15 |
+
const [deletedToast, setDeletedToast] = useState(false);
|
| 16 |
+
|
| 17 |
+
useEffect(() => {
|
| 18 |
+
if (searchParams.get("deleted") === "true") {
|
| 19 |
+
setDeletedToast(true);
|
| 20 |
+
setTimeout(() => setDeletedToast(false), 5000);
|
| 21 |
+
|
| 22 |
+
// Clean up URL
|
| 23 |
+
const url = new URL(window.location.href);
|
| 24 |
+
url.searchParams.delete("deleted");
|
| 25 |
+
window.history.replaceState({}, '', url.toString());
|
| 26 |
+
}
|
| 27 |
+
}, [searchParams]);
|
| 28 |
|
| 29 |
// --- Footer Copyright and Dynamic Date ---
|
| 30 |
const [year, setYear] = useState(new Date().getFullYear());
|
|
|
|
| 313 |
</div>
|
| 314 |
)}
|
| 315 |
|
| 316 |
+
{/* Deleted Account Toast */}
|
| 317 |
+
{deletedToast && (
|
| 318 |
+
<div className="fixed top-24 left-1/2 -translate-x-1/2 z-[100] bg-green-500/10 border border-green-500/20 text-green-400 px-6 py-3 rounded-full flex items-center gap-3 shadow-[0_0_40px_rgba(34,197,94,0.2)] animate-[fade-in_0.5s_ease-out]">
|
| 319 |
+
<CheckCircle className="w-5 h-5" />
|
| 320 |
+
<span className="font-bold tracking-widest uppercase text-sm">Account Deleted Successfully</span>
|
| 321 |
+
</div>
|
| 322 |
+
)}
|
| 323 |
+
|
| 324 |
{/* Global Navbar */}
|
| 325 |
<Navbar />
|
| 326 |
|
frontend/app/profile/page.tsx
CHANGED
|
@@ -16,6 +16,11 @@ export default function ProfilePage() {
|
|
| 16 |
const [statusMsg, setStatusMsg] = useState<{type: "error" | "success", text: string} | null>(null);
|
| 17 |
const [localAvatar, setLocalAvatar] = useState<string | null>(null);
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
// Handle cursor physics (Globally handled now, we just need the local parallax if any, but profile page doesn't have a dash-grid)
|
| 20 |
|
| 21 |
useEffect(() => {
|
|
@@ -80,6 +85,21 @@ export default function ProfilePage() {
|
|
| 80 |
reader.readAsDataURL(file);
|
| 81 |
};
|
| 82 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
if (loading) return null;
|
| 84 |
|
| 85 |
return (
|
|
@@ -185,9 +205,9 @@ export default function ProfilePage() {
|
|
| 185 |
<div className="border border-red-500/20 bg-red-500/5 rounded-2xl p-6 flex justify-between items-center">
|
| 186 |
<div>
|
| 187 |
<h4 className="text-red-400 font-semibold mb-1">Delete Account</h4>
|
| 188 |
-
<p className="text-sm text-[var(--theme-text)]/50">
|
| 189 |
</div>
|
| 190 |
-
<button className="bg-red-500/10 text-red-400 border border-red-500/20 px-4 py-2 rounded-lg font-bold text-sm uppercase hover:bg-red-500/20 transition-colors !cursor-none" onClick={() =>
|
| 191 |
Delete
|
| 192 |
</button>
|
| 193 |
</div>
|
|
@@ -195,6 +215,57 @@ export default function ProfilePage() {
|
|
| 195 |
</div>
|
| 196 |
</div>
|
| 197 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
</div>
|
| 199 |
);
|
| 200 |
}
|
|
|
|
| 16 |
const [statusMsg, setStatusMsg] = useState<{type: "error" | "success", text: string} | null>(null);
|
| 17 |
const [localAvatar, setLocalAvatar] = useState<string | null>(null);
|
| 18 |
|
| 19 |
+
// Delete Modal States
|
| 20 |
+
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
| 21 |
+
const [deleteInput, setDeleteInput] = useState("");
|
| 22 |
+
const [isDeleting, setIsDeleting] = useState(false);
|
| 23 |
+
|
| 24 |
// Handle cursor physics (Globally handled now, we just need the local parallax if any, but profile page doesn't have a dash-grid)
|
| 25 |
|
| 26 |
useEffect(() => {
|
|
|
|
| 85 |
reader.readAsDataURL(file);
|
| 86 |
};
|
| 87 |
|
| 88 |
+
const handleDeleteAccount = async () => {
|
| 89 |
+
if (deleteInput !== "DELETE") return;
|
| 90 |
+
setIsDeleting(true);
|
| 91 |
+
try {
|
| 92 |
+
await apiLayer.deleteAccount();
|
| 93 |
+
updateUser(null);
|
| 94 |
+
localStorage.removeItem("access_token");
|
| 95 |
+
window.location.href = "/?deleted=true";
|
| 96 |
+
} catch (err: any) {
|
| 97 |
+
setStatusMsg({ type: "error", text: err.response?.data?.detail || "Failed to delete account." });
|
| 98 |
+
setIsDeleting(false);
|
| 99 |
+
setIsDeleteModalOpen(false);
|
| 100 |
+
}
|
| 101 |
+
};
|
| 102 |
+
|
| 103 |
if (loading) return null;
|
| 104 |
|
| 105 |
return (
|
|
|
|
| 205 |
<div className="border border-red-500/20 bg-red-500/5 rounded-2xl p-6 flex justify-between items-center">
|
| 206 |
<div>
|
| 207 |
<h4 className="text-red-400 font-semibold mb-1">Delete Account</h4>
|
| 208 |
+
<p className="text-sm text-[var(--theme-text)]/50">This will permanently delete your account, all analyses, and uploaded media. This action cannot be undone.</p>
|
| 209 |
</div>
|
| 210 |
+
<button className="bg-red-500/10 text-red-400 border border-red-500/20 px-4 py-2 rounded-lg font-bold text-sm uppercase hover:bg-red-500/20 transition-colors !cursor-none" onClick={() => setIsDeleteModalOpen(true)}>
|
| 211 |
Delete
|
| 212 |
</button>
|
| 213 |
</div>
|
|
|
|
| 215 |
</div>
|
| 216 |
</div>
|
| 217 |
</div>
|
| 218 |
+
|
| 219 |
+
{/* Delete Account Modal */}
|
| 220 |
+
{isDeleteModalOpen && (
|
| 221 |
+
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
| 222 |
+
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm transition-opacity" onClick={() => setIsDeleteModalOpen(false)}></div>
|
| 223 |
+
<div className="relative bg-[#080c13] border border-red-500/20 rounded-2xl w-full max-w-md p-8 shadow-[0_0_40px_rgba(239,68,68,0.15)] flex flex-col items-center text-center transform transition-all">
|
| 224 |
+
<div className="w-16 h-16 rounded-full bg-red-500/10 flex items-center justify-center mb-6">
|
| 225 |
+
<Trash2 className="w-8 h-8 text-red-500" />
|
| 226 |
+
</div>
|
| 227 |
+
<h2 className="text-2xl font-black text-white uppercase tracking-widest mb-2">Delete Account</h2>
|
| 228 |
+
<p className="text-white/60 text-sm mb-6">
|
| 229 |
+
This will permanently delete your account, all analyses, and uploaded media. This action cannot be undone.
|
| 230 |
+
</p>
|
| 231 |
+
|
| 232 |
+
<div className="w-full mb-8">
|
| 233 |
+
<label className="block text-left text-xs uppercase tracking-widest text-red-400/80 mb-2 font-bold">Type "DELETE" to confirm</label>
|
| 234 |
+
<input
|
| 235 |
+
type="text"
|
| 236 |
+
value={deleteInput}
|
| 237 |
+
onChange={(e) => setDeleteInput(e.target.value.toUpperCase())}
|
| 238 |
+
placeholder="DELETE"
|
| 239 |
+
className="w-full bg-red-500/5 border border-red-500/20 rounded-lg p-3 text-center outline-none focus:border-red-500/50 text-white transition-colors !cursor-none uppercase"
|
| 240 |
+
/>
|
| 241 |
+
</div>
|
| 242 |
+
|
| 243 |
+
<div className="flex w-full gap-4">
|
| 244 |
+
<button
|
| 245 |
+
onClick={() => {
|
| 246 |
+
setIsDeleteModalOpen(false);
|
| 247 |
+
setDeleteInput("");
|
| 248 |
+
}}
|
| 249 |
+
disabled={isDeleting}
|
| 250 |
+
className="flex-1 border border-white/10 text-white/70 py-3 rounded-lg font-bold text-xs uppercase hover:bg-white/5 hover:text-white transition-colors !cursor-none disabled:opacity-50"
|
| 251 |
+
>
|
| 252 |
+
Cancel
|
| 253 |
+
</button>
|
| 254 |
+
<button
|
| 255 |
+
onClick={handleDeleteAccount}
|
| 256 |
+
disabled={deleteInput !== "DELETE" || isDeleting}
|
| 257 |
+
className="flex-1 bg-red-500 text-white py-3 rounded-lg font-bold text-xs uppercase hover:bg-red-600 transition-colors !cursor-none disabled:opacity-50 disabled:cursor-not-allowed flex justify-center items-center"
|
| 258 |
+
>
|
| 259 |
+
{isDeleting ? (
|
| 260 |
+
<div className="w-4 h-4 border-2 border-white/20 border-t-white rounded-full animate-spin"></div>
|
| 261 |
+
) : (
|
| 262 |
+
"Confirm Delete"
|
| 263 |
+
)}
|
| 264 |
+
</button>
|
| 265 |
+
</div>
|
| 266 |
+
</div>
|
| 267 |
+
</div>
|
| 268 |
+
)}
|
| 269 |
</div>
|
| 270 |
);
|
| 271 |
}
|
frontend/app/verify-email/page.tsx
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import React, { useEffect, useState } from "react";
|
| 4 |
+
import { useSearchParams, useRouter } from "next/navigation";
|
| 5 |
+
import axios from "axios";
|
| 6 |
+
import { CheckCircle, XCircle, ArrowRight, ArrowLeft } from "lucide-react";
|
| 7 |
+
import { useRef } from "react";
|
| 8 |
+
|
| 9 |
+
export default function VerifyEmailPage() {
|
| 10 |
+
const searchParams = useSearchParams();
|
| 11 |
+
const router = useRouter();
|
| 12 |
+
const token = searchParams.get("token");
|
| 13 |
+
|
| 14 |
+
const [status, setStatus] = useState<"verifying" | "success" | "error">("verifying");
|
| 15 |
+
const [message, setMessage] = useState("Establishing Neural Uplink...");
|
| 16 |
+
const hasAttempted = useRef(false);
|
| 17 |
+
|
| 18 |
+
useEffect(() => {
|
| 19 |
+
if (!token) {
|
| 20 |
+
setStatus("error");
|
| 21 |
+
setMessage("Invalid Authorization Link.");
|
| 22 |
+
return;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
if (hasAttempted.current) return;
|
| 26 |
+
hasAttempted.current = true;
|
| 27 |
+
|
| 28 |
+
const verifyToken = async () => {
|
| 29 |
+
try {
|
| 30 |
+
const res = await axios.post("http://localhost:8000/auth/verify-email", { token });
|
| 31 |
+
setStatus("success");
|
| 32 |
+
setMessage("Identity Verified. Re-routing to Dashboard...");
|
| 33 |
+
|
| 34 |
+
// Automatically log the user in
|
| 35 |
+
if (res.data.access_token) {
|
| 36 |
+
localStorage.setItem("access_token", res.data.access_token);
|
| 37 |
+
// Slight delay for cinematic effect before redirecting
|
| 38 |
+
setTimeout(() => {
|
| 39 |
+
window.location.href = "/dashboard";
|
| 40 |
+
}, 2000);
|
| 41 |
+
}
|
| 42 |
+
} catch (err: any) {
|
| 43 |
+
setStatus("error");
|
| 44 |
+
setMessage(err.response?.data?.detail || "Verification failed. The link may have expired.");
|
| 45 |
+
}
|
| 46 |
+
};
|
| 47 |
+
|
| 48 |
+
verifyToken();
|
| 49 |
+
}, [token]);
|
| 50 |
+
|
| 51 |
+
return (
|
| 52 |
+
<div className="min-h-screen bg-[var(--theme-bg)] text-[var(--theme-text)] font-sans relative flex items-center justify-center p-6 overflow-hidden">
|
| 53 |
+
{/* Base Styles & Cursor */}
|
| 54 |
+
<style dangerouslySetInnerHTML={{
|
| 55 |
+
__html: `
|
| 56 |
+
html, body, a, button, [role="button"], input, select, textarea, .cursor-pointer { cursor: none !important; }
|
| 57 |
+
body { background-color: var(--theme-bg); }
|
| 58 |
+
`
|
| 59 |
+
}} />
|
| 60 |
+
|
| 61 |
+
{/* Background Effects */}
|
| 62 |
+
<div className="absolute inset-0 bg-[var(--theme-bg)] z-0"></div>
|
| 63 |
+
<div className="absolute inset-0 opacity-40 pointer-events-none bg-[radial-gradient(circle_at_50%_50%,_#11141d_0%,_var(--theme-bg)_100%)] z-[1]"></div>
|
| 64 |
+
<div className="absolute inset-0 z-[2] opacity-[0.03] pointer-events-none" style={{ backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.90' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E")` }}></div>
|
| 65 |
+
|
| 66 |
+
{/* Content Container */}
|
| 67 |
+
<div className="relative z-10 w-full max-w-md bg-[var(--theme-text)]/5 p-10 rounded-[2rem] border border-[var(--theme-border)] backdrop-blur-xl text-center shadow-[0_0_80px_rgba(0,0,0,0.5)]">
|
| 68 |
+
|
| 69 |
+
{status === "verifying" && (
|
| 70 |
+
<div className="flex flex-col items-center animate-pulse">
|
| 71 |
+
<div className="w-16 h-16 rounded-full border-4 border-[var(--theme-text)]/20 border-t-[var(--theme-text)] animate-spin mb-6"></div>
|
| 72 |
+
<h2 className="text-2xl font-black uppercase tracking-widest mb-2">Analyzing Node</h2>
|
| 73 |
+
<p className="text-[var(--theme-text)]/60 text-sm font-mono uppercase">{message}</p>
|
| 74 |
+
</div>
|
| 75 |
+
)}
|
| 76 |
+
|
| 77 |
+
{status === "success" && (
|
| 78 |
+
<div className="flex flex-col items-center">
|
| 79 |
+
<div className="w-16 h-16 rounded-full bg-green-500/10 flex items-center justify-center border border-green-500/30 mb-6 shadow-[0_0_40px_rgba(34,197,94,0.3)]">
|
| 80 |
+
<CheckCircle className="w-8 h-8 text-green-400" />
|
| 81 |
+
</div>
|
| 82 |
+
<h2 className="text-2xl font-black uppercase tracking-widest mb-2 text-green-400">Uplink Established</h2>
|
| 83 |
+
<p className="text-[var(--theme-text)]/60 text-sm font-mono uppercase mb-8">{message}</p>
|
| 84 |
+
|
| 85 |
+
<div className="w-full bg-[var(--theme-text)]/10 h-1 rounded-full overflow-hidden">
|
| 86 |
+
<div className="bg-[var(--theme-text)] h-full animate-[progress_2s_ease-in-out_forwards]"></div>
|
| 87 |
+
</div>
|
| 88 |
+
<style dangerouslySetInnerHTML={{__html: `
|
| 89 |
+
@keyframes progress {
|
| 90 |
+
0% { width: 0%; }
|
| 91 |
+
100% { width: 100%; }
|
| 92 |
+
}
|
| 93 |
+
`}} />
|
| 94 |
+
</div>
|
| 95 |
+
)}
|
| 96 |
+
|
| 97 |
+
{status === "error" && (
|
| 98 |
+
<div className="flex flex-col items-center">
|
| 99 |
+
<div className="w-16 h-16 rounded-full bg-red-500/10 flex items-center justify-center border border-red-500/30 mb-6 shadow-[0_0_40px_rgba(239,68,68,0.3)]">
|
| 100 |
+
<XCircle className="w-8 h-8 text-red-400" />
|
| 101 |
+
</div>
|
| 102 |
+
<h2 className="text-2xl font-black uppercase tracking-widest mb-2 text-red-400">Uplink Failed</h2>
|
| 103 |
+
<p className="text-[var(--theme-text)]/60 text-sm font-mono uppercase mb-8">{message}</p>
|
| 104 |
+
|
| 105 |
+
<button
|
| 106 |
+
onClick={() => router.push('/login')}
|
| 107 |
+
className="w-full group relative overflow-hidden rounded-full bg-[var(--theme-text)] text-[var(--theme-bg)] px-8 py-4 font-bold tracking-widest text-xs uppercase transition-all duration-300 hover:shadow-[0_0_40px_rgba(253,232,214,0.3)] hover:scale-105 flex items-center justify-center gap-3 !cursor-none"
|
| 108 |
+
>
|
| 109 |
+
<ArrowLeft className="w-4 h-4 transition-transform duration-300 group-hover:-translate-x-1" />
|
| 110 |
+
<span>Return to Access Portal</span>
|
| 111 |
+
</button>
|
| 112 |
+
</div>
|
| 113 |
+
)}
|
| 114 |
+
</div>
|
| 115 |
+
</div>
|
| 116 |
+
);
|
| 117 |
+
}
|
frontend/lib/api.ts
CHANGED
|
@@ -37,5 +37,8 @@ export const apiLayer = {
|
|
| 37 |
updateUsername: (username: string) => api.put('/profile/username', { username }),
|
| 38 |
|
| 39 |
// Feedback
|
| 40 |
-
submitFeedback: (data: { issue_type: string, message: string }) => api.post('/issues/', data)
|
|
|
|
|
|
|
|
|
|
| 41 |
};
|
|
|
|
| 37 |
updateUsername: (username: string) => api.put('/profile/username', { username }),
|
| 38 |
|
| 39 |
// Feedback
|
| 40 |
+
submitFeedback: (data: { issue_type: string, message: string }) => api.post('/issues/', data),
|
| 41 |
+
|
| 42 |
+
// Account deletion
|
| 43 |
+
deleteAccount: () => api.delete('/users/delete')
|
| 44 |
};
|