primary email change functionality
Build LinkLog Development Image / development-image (push) Successful in 12s
Build LinkLog Development Image / development-image (push) Successful in 12s
This commit is contained in:
@@ -14,6 +14,7 @@ from backend.app.services.email_verification import verify_email
|
||||
from backend.app.services.password_reset import create_reset_token, reset_password
|
||||
from backend.app.services.token_service import issue_token, revoke_token, validate_token
|
||||
from backend.app.services.otp_service import verify_code
|
||||
from backend.app.services.email_addresses import verify_user_email_address
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -66,6 +67,14 @@ def verify_email_address(token: str):
|
||||
return {'status': 'verified', 'message': 'Email address verified. You can now sign in.'}
|
||||
|
||||
|
||||
@router.get('/verify-additional-email')
|
||||
def verify_additional_email_address(token: str):
|
||||
if not verify_user_email_address(token):
|
||||
raise HTTPException(status_code=400, detail='Email verification link is invalid or expired')
|
||||
return {'status': 'verified', 'message': 'Email address verified. You can now sign in.'}
|
||||
|
||||
|
||||
|
||||
@router.post('/reset-password')
|
||||
def reset_password_endpoint(payload: PasswordResetRequest):
|
||||
if len(payload.password) < 8:
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
## SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
||||
@@ -11,12 +12,14 @@ from backend.app.api.dependencies import get_current_user
|
||||
from backend.app.database import AVATARS_DIR, get_connection, hash_password, verify_password
|
||||
from backend.app.services.link_service import create_label, delete_label, list_user_labels, update_label
|
||||
from backend.app.services.otp_service import create_secret, provisioning_uri, verify_code
|
||||
from backend.app.services.email_addresses import add_user_email_address, create_email_verification, list_user_email_addresses
|
||||
from backend.app.services.email_service import send_verification_email, smtp_configured
|
||||
from backend.app.core.config import settings
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class UserConfigUpdate(BaseModel):
|
||||
email: str | None = None
|
||||
bio: str | None = None
|
||||
|
||||
|
||||
@@ -30,6 +33,11 @@ class OtpUpdate(BaseModel):
|
||||
code: str | None = None
|
||||
|
||||
|
||||
class AdditionalEmail(BaseModel):
|
||||
email: str
|
||||
|
||||
|
||||
|
||||
class UserPluginConfigUpdate(BaseModel):
|
||||
instance: str | None = None
|
||||
access_token: str | None = None
|
||||
@@ -66,7 +74,6 @@ def update_current_user_profile(
|
||||
if current is None:
|
||||
raise HTTPException(status_code=404, detail='User not found')
|
||||
|
||||
email = payload.email or current['email']
|
||||
bio = payload.bio if payload.bio is not None else current['bio']
|
||||
|
||||
conn.execute(
|
||||
@@ -75,7 +82,7 @@ def update_current_user_profile(
|
||||
SET email = ?, bio = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
''',
|
||||
(email, bio, user['id']),
|
||||
(current['email'], bio, user['id']),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
@@ -129,6 +136,121 @@ def update_otp(payload: OtpUpdate, user: dict = Depends(get_current_user)):
|
||||
return {'status': 'updated', 'enabled': payload.action == 'enable'}
|
||||
|
||||
|
||||
@router.get('/emails')
|
||||
def get_additional_emails(user: dict = Depends(get_current_user)):
|
||||
return [{'email': user['email'], 'verified': bool(user['email_verified']), 'primary': True}] + list_user_email_addresses(user['id'])
|
||||
|
||||
|
||||
@router.post('/emails', status_code=201)
|
||||
def add_additional_email(payload: AdditionalEmail, user: dict = Depends(get_current_user)):
|
||||
email = payload.email.strip().lower()
|
||||
if email == user['email'].lower():
|
||||
raise HTTPException(status_code=409, detail='This is already the primary email address')
|
||||
with get_connection() as conn:
|
||||
if conn.execute('SELECT 1 FROM users WHERE lower(email) = ?', (email,)).fetchone():
|
||||
raise HTTPException(status_code=409, detail='Email address already exists')
|
||||
additional_count = conn.execute(
|
||||
'SELECT COUNT(*) AS count FROM user_email_addresses WHERE user_id = ?',
|
||||
(user['id'],),
|
||||
).fetchone()['count']
|
||||
if additional_count >= 5:
|
||||
raise HTTPException(status_code=422, detail='You can add at most five additional email addresses')
|
||||
try:
|
||||
address = add_user_email_address(user['id'], email)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=409, detail=str(error)) from error
|
||||
if smtp_configured():
|
||||
email_address, token = create_email_verification(user['id'], address['id'])
|
||||
verification_url = f'{settings.public_url}/api/auth/verify-additional-email?token={token}'
|
||||
try:
|
||||
send_verification_email(email_address, user['username'], verification_url)
|
||||
except Exception as error:
|
||||
raise HTTPException(status_code=503, detail=f'Email address added but verification email could not be sent: {error}') from error
|
||||
with get_connection() as conn:
|
||||
conn.execute(
|
||||
'''INSERT INTO app_settings (name, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(name) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP''',
|
||||
(f'email_verify_rate:{address["id"]}', json.dumps({'sends': 1, 'last_sent': datetime.now(timezone.utc).isoformat()})),
|
||||
)
|
||||
conn.commit()
|
||||
return address
|
||||
|
||||
|
||||
@router.post('/emails/{address_id}/resend')
|
||||
def resend_additional_email(address_id: str, user: dict = Depends(get_current_user)):
|
||||
now = datetime.now(timezone.utc)
|
||||
setting_name = f'email_verify_rate:{address_id}'
|
||||
with get_connection() as conn:
|
||||
row = conn.execute('SELECT value FROM app_settings WHERE name = ?', (setting_name,)).fetchone()
|
||||
address = conn.execute('SELECT email, verified FROM user_email_addresses WHERE id = ? AND user_id = ?', (address_id, user['id'])).fetchone()
|
||||
if address is None:
|
||||
raise HTTPException(status_code=404, detail='Email address not found')
|
||||
if address['verified']:
|
||||
raise HTTPException(status_code=409, detail='Email address is already verified')
|
||||
rate = json.loads(row['value']) if row else {}
|
||||
last_sent = datetime.fromisoformat(rate['last_sent']) if rate.get('last_sent') else None
|
||||
cooldown_until = datetime.fromisoformat(rate['cooldown_until']) if rate.get('cooldown_until') else None
|
||||
if cooldown_until and now < cooldown_until:
|
||||
retry_after = int((cooldown_until - now).total_seconds()) + 1
|
||||
raise HTTPException(status_code=429, detail=f'Please wait {retry_after} seconds before resending verification email.', headers={'Retry-After': str(retry_after)})
|
||||
if last_sent and now - last_sent < timedelta(seconds=20):
|
||||
retry_after = int((timedelta(seconds=20) - (now - last_sent)).total_seconds()) + 1
|
||||
raise HTTPException(status_code=429, detail=f'Please wait {retry_after} seconds before resending verification email.', headers={'Retry-After': str(retry_after)})
|
||||
email, token = create_email_verification(user['id'], address_id)
|
||||
verification_url = f'{settings.public_url}/api/auth/verify-additional-email?token={token}'
|
||||
try:
|
||||
send_verification_email(email, user['username'], verification_url)
|
||||
except Exception as error:
|
||||
raise HTTPException(status_code=503, detail=f'Verification email could not be sent: {error}') from error
|
||||
sends = int(rate.get('sends', 0)) + 1
|
||||
updated = {'sends': sends, 'last_sent': now.isoformat()}
|
||||
if sends >= 5:
|
||||
updated['cooldown_until'] = (now + timedelta(minutes=2)).isoformat()
|
||||
with get_connection() as conn:
|
||||
conn.execute('''INSERT INTO app_settings (name, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(name) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP''', (setting_name, json.dumps(updated)))
|
||||
conn.commit()
|
||||
return {'status': 'sent', 'message': f'Verification email sent to {email}.', 'next_allowed_at': (now + timedelta(seconds=20)).isoformat()}
|
||||
|
||||
|
||||
@router.delete('/emails/{address_id}')
|
||||
def remove_additional_email(address_id: str, user: dict = Depends(get_current_user)):
|
||||
with get_connection() as conn:
|
||||
cursor = conn.execute('DELETE FROM user_email_addresses WHERE id = ? AND user_id = ?', (address_id, user['id']))
|
||||
conn.commit()
|
||||
if cursor.rowcount == 0:
|
||||
raise HTTPException(status_code=404, detail='Email address not found')
|
||||
return {'status': 'deleted', 'id': address_id}
|
||||
|
||||
|
||||
@router.post('/emails/{address_id}/make-primary')
|
||||
def make_email_primary(address_id: str, user: dict = Depends(get_current_user)):
|
||||
with get_connection() as conn:
|
||||
address = conn.execute(
|
||||
'SELECT email, verified FROM user_email_addresses WHERE id = ? AND user_id = ?',
|
||||
(address_id, user['id']),
|
||||
).fetchone()
|
||||
if address is None:
|
||||
raise HTTPException(status_code=404, detail='Email address not found')
|
||||
if not address['verified']:
|
||||
raise HTTPException(status_code=400, detail='Email address must be validated before it can become primary')
|
||||
old_email = user['email']
|
||||
conn.execute(
|
||||
'DELETE FROM user_email_addresses WHERE id = ? AND user_id = ?',
|
||||
(address_id, user['id']),
|
||||
)
|
||||
conn.execute(
|
||||
'INSERT INTO user_email_addresses (id, user_id, email, verified) VALUES (?, ?, ?, 1)',
|
||||
(str(uuid4()), user['id'], old_email),
|
||||
)
|
||||
conn.execute(
|
||||
'UPDATE users SET email = ?, email_verified = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||
(address['email'], user['id']),
|
||||
)
|
||||
conn.commit()
|
||||
return {'status': 'updated', 'email': address['email']}
|
||||
|
||||
|
||||
@router.get('/labels')
|
||||
def get_labels(user: dict = Depends(get_current_user)):
|
||||
return list_user_labels(user['id'])
|
||||
|
||||
Reference in New Issue
Block a user