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:
@@ -114,6 +114,8 @@ Users can change their password from the profile page. The current password is r
|
||||
|
||||
Users can configure a time-based one-time password from the profile page using an authenticator app. The profile displays a provisioning secret and authenticator URI during setup, then requires a current six-digit code to enable or disable OTP. When OTP is enabled, both the web login and Firefox extension settings login require the code. The TOTP secret is never returned by the profile API after setup.
|
||||
|
||||
Users can add up to five additional email addresses from the profile page. Each additional address must be validated through a verification email before it can be used for authentication. A verified alternative can be promoted to primary; the previous primary remains as a verified alternative. The profile displays validation status and offers resend controls subject to the same 20-second interval, five-send limit, and two-minute cooldown used during initial setup.
|
||||
|
||||
On the profile page, the authenticated username is displayed as read-only. Users can upload a PNG, JPEG, GIF, or WebP avatar up to 2 MB; uploaded files are stored in the persistent data volume and served by the application. Bio and email fields remain empty until the user provides values. Mastodon settings default to the `mastodon.social` instance and the `From my #LinkLog: ` post prefix.
|
||||
|
||||
## Run Tests
|
||||
|
||||
@@ -195,6 +195,8 @@ These findings are prioritized below. Severity describes the potential security
|
||||
## Authentication and Authorization Review
|
||||
|
||||
- **Authentication transport:** Bearer headers are used by most APIs, but query-string tokens remain a leakage risk. There is no cookie session, which reduces CSRF exposure for current bearer-only API calls. Credentials are now email-based; usernames remain presentation identities.
|
||||
- **Email authentication:** Primary and additional addresses are checked independently; additional addresses are unusable for login until their verification token is consumed. The profile exposes status but not verification secrets.
|
||||
- **Primary email changes:** A replacement remains pending until its verification link is consumed. The existing primary address remains usable during that period, and the same user row retains account permissions and active sessions.
|
||||
- **Password policy:** New and reset passwords require at least eight characters. This is better than no policy but should be replaced with a longer passphrase-oriented policy and breached-password screening after a proper password hash migration.
|
||||
- **Email verification:** New administrator-created users cannot log in until verified. The setup-created first administrator is marked verified, which is appropriate for bootstrap but should be protected by the setup controls above.
|
||||
- **Password reset:** Tokens are random, hashed, expiring, single-use, and revoke existing access tokens after reset. Reset-email generation errors are intentionally swallowed to preserve generic login behavior, but this should be paired with server-side monitoring.
|
||||
|
||||
@@ -878,3 +878,33 @@ The login page should ask for OTP password.
|
||||
|
||||
### Assistant outcome
|
||||
Added an optional “One-time password (when configured)” field to the web login page. The form sends a trimmed OTP value to the existing OTP-aware login endpoint and displays the backend’s OTP error when required or invalid.
|
||||
|
||||
### User
|
||||
Allow secondary or tertiary email addresses, validate them before authentication, show their status in the profile, allow resending validation mail, and retain a holdback period.
|
||||
|
||||
### Assistant outcome
|
||||
Added independently tracked secondary and tertiary addresses with verification tokens, email-based authentication only for validated addresses, profile status/add/resend/remove controls, a maximum of two additional addresses, and persistent resend throttling with 20-second intervals, five sends, and a two-minute cooldown.
|
||||
|
||||
### User
|
||||
Enable users to change the primary email address and remove the original one while maintaining access and rights.
|
||||
|
||||
### Assistant outcome
|
||||
Added a pending primary-email replacement workflow. The original address remains active until the new address is validated, preserving the same account, privileges, and sessions; after validation the new address replaces the original. Added profile controls, verification/resend handling, migration 13, tests, documentation, and security-audit notes.
|
||||
|
||||
### User
|
||||
Choose the primary email from already verified alternative addresses and increase the allowed alternatives to five.
|
||||
|
||||
### Assistant outcome
|
||||
Verified alternative addresses can now be promoted to primary from the profile page. The previous primary is retained as a verified alternative, preserving the account identity and access. The maximum number of additional addresses was increased from two to five, with test and documentation updates.
|
||||
|
||||
### User
|
||||
Make sure an email can only be selected when it has been validated.
|
||||
|
||||
### Assistant outcome
|
||||
Made validated status explicit in the email-address API and profile UI. Only verified alternatives expose and render the primary-selection action, while the backend rejects any unverified promotion attempt.
|
||||
|
||||
### User
|
||||
Remove the entire “New primary email address” block; keep only selecting an existing alternative as primary.
|
||||
|
||||
### Assistant outcome
|
||||
Removed the pending primary-email replacement form, client handlers, API routes, service, and obsolete test. The profile now retains only verified alternative promotion, with unverified addresses unable to become primary.
|
||||
|
||||
@@ -157,6 +157,11 @@
|
||||
146. Change so that authentication is based on email address rather than username - maintain the username for presentation purposes
|
||||
147. Make sure the web plugin follows same logic
|
||||
148. In the plugin <span data-i18n="emailLabel">emailLabel</span> should read: <span data-i18n="emailLabel">Email</span>
|
||||
149. Allow addition of secondary or tertiary email addresses; validate them before authentication and support profile status/resend controls with holdback.
|
||||
150. Enable the user to change primary email address and remove the original one while maintaining access and rights.
|
||||
151. Choose primary email from already verified alternative email addresses and increase the number of alternative email addresses allowed to 5.
|
||||
152. Make sure an email can only be selected when it has been validated.
|
||||
153. Remove the entire "New primary email address" block; keep only selecting an existing alternative as primary.
|
||||
|
||||
## Future entries
|
||||
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -192,6 +192,38 @@ WHERE mastodon_post_ids IS NULL;
|
||||
ALTER TABLE users ADD COLUMN otp_secret TEXT;
|
||||
ALTER TABLE users ADD COLUMN otp_enabled INTEGER NOT NULL DEFAULT 0;
|
||||
'''),
|
||||
(12, '''
|
||||
CREATE TABLE IF NOT EXISTS user_email_addresses (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
verified INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS email_address_verification_tokens (
|
||||
id TEXT PRIMARY KEY,
|
||||
email_address_id TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(email_address_id) REFERENCES user_email_addresses(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_email_addresses_user_id ON user_email_addresses(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_email_address_verification_tokens_address_id ON email_address_verification_tokens(email_address_id);
|
||||
'''),
|
||||
(13, '''
|
||||
CREATE TABLE IF NOT EXISTS pending_primary_email_changes (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL UNIQUE,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
''')
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -7,7 +7,15 @@ from backend.app.database import get_connection, hash_password, verify_password
|
||||
|
||||
def authenticate_user(email: str, password: str):
|
||||
with get_connection() as conn:
|
||||
row = conn.execute('SELECT * FROM users WHERE email = ?', (email,)).fetchone()
|
||||
row = conn.execute(
|
||||
'''SELECT * FROM users WHERE email = ?
|
||||
UNION ALL
|
||||
SELECT users.* FROM users JOIN user_email_addresses
|
||||
ON user_email_addresses.user_id = users.id
|
||||
WHERE user_email_addresses.email = ? AND user_email_addresses.verified = 1
|
||||
LIMIT 1''',
|
||||
(email, email),
|
||||
).fetchone()
|
||||
if row is None or not verify_password(password, row['password_hash']):
|
||||
return None
|
||||
user = dict(row)
|
||||
@@ -26,4 +34,11 @@ def authenticate_user(email: str, password: str):
|
||||
def find_user(email: str):
|
||||
with get_connection() as conn:
|
||||
row = conn.execute('SELECT * FROM users WHERE email = ?', (email,)).fetchone()
|
||||
if row is None:
|
||||
row = conn.execute(
|
||||
'''SELECT users.* FROM users JOIN user_email_addresses
|
||||
ON user_email_addresses.user_id = users.id
|
||||
WHERE user_email_addresses.email = ?''',
|
||||
(email,),
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
## Copyright © 2026 Olaf Kolkman
|
||||
## SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from hashlib import sha256
|
||||
from secrets import token_urlsafe
|
||||
from uuid import uuid4
|
||||
|
||||
from backend.app.core.config import settings
|
||||
from backend.app.database import get_connection
|
||||
from backend.app.services.email_service import send_verification_email
|
||||
|
||||
|
||||
def list_user_email_addresses(user_id: str) -> list[dict]:
|
||||
with get_connection() as conn:
|
||||
rows = conn.execute(
|
||||
'SELECT id, email, verified, created_at FROM user_email_addresses WHERE user_id = ? ORDER BY created_at',
|
||||
(user_id,),
|
||||
).fetchall()
|
||||
return [dict(row) | {'verified': bool(row['verified']), 'can_be_primary': bool(row['verified'])} for row in rows]
|
||||
|
||||
|
||||
def add_user_email_address(user_id: str, email: str) -> dict:
|
||||
email = email.strip().lower()
|
||||
if not email:
|
||||
raise ValueError('Email address is required')
|
||||
with get_connection() as conn:
|
||||
try:
|
||||
row = conn.execute(
|
||||
'INSERT INTO user_email_addresses (id, user_id, email) VALUES (?, ?, ?) RETURNING id, email, verified, created_at',
|
||||
(str(uuid4()), user_id, email),
|
||||
).fetchone()
|
||||
conn.commit()
|
||||
except Exception as error:
|
||||
if 'UNIQUE constraint failed' in str(error):
|
||||
raise ValueError('Email address already exists') from error
|
||||
raise
|
||||
return dict(row) | {'verified': bool(row['verified']), 'can_be_primary': bool(row['verified'])}
|
||||
|
||||
|
||||
def create_email_verification(user_id: str, address_id: str) -> tuple[str, str]:
|
||||
token = token_urlsafe(32)
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(hours=settings.email_verification_expiry_hours)
|
||||
with get_connection() as conn:
|
||||
address = conn.execute(
|
||||
'SELECT email FROM user_email_addresses WHERE id = ? AND user_id = ?',
|
||||
(address_id, user_id),
|
||||
).fetchone()
|
||||
if address is None:
|
||||
raise ValueError('Email address not found')
|
||||
conn.execute('DELETE FROM email_address_verification_tokens WHERE email_address_id = ?', (address_id,))
|
||||
conn.execute(
|
||||
'INSERT INTO email_address_verification_tokens (id, email_address_id, token_hash, expires_at) VALUES (?, ?, ?, ?)',
|
||||
(str(uuid4()), address_id, sha256(token.encode()).hexdigest(), expires_at.isoformat()),
|
||||
)
|
||||
conn.commit()
|
||||
return address['email'], token
|
||||
|
||||
|
||||
def verify_user_email_address(token: str) -> bool:
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
with get_connection() as conn:
|
||||
row = conn.execute(
|
||||
'''SELECT email_address_id FROM email_address_verification_tokens
|
||||
WHERE token_hash = ? AND expires_at > ?''',
|
||||
(sha256(token.encode()).hexdigest(), now),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return False
|
||||
conn.execute('UPDATE user_email_addresses SET verified = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?', (row['email_address_id'],))
|
||||
conn.execute('DELETE FROM email_address_verification_tokens WHERE email_address_id = ?', (row['email_address_id'],))
|
||||
conn.commit()
|
||||
return True
|
||||
@@ -10,7 +10,7 @@ def test_database_migrations_are_versioned_and_idempotent():
|
||||
connection = sqlite3.connect(':memory:')
|
||||
|
||||
apply_migrations(connection)
|
||||
assert get_schema_version(connection) == 11
|
||||
assert get_schema_version(connection) == 13
|
||||
tables = {
|
||||
row[0]
|
||||
for row in connection.execute(
|
||||
@@ -27,6 +27,6 @@ def test_database_migrations_are_versioned_and_idempotent():
|
||||
assert set(DEFAULT_TAGS) <= seeded_tags
|
||||
|
||||
apply_migrations(connection)
|
||||
assert get_schema_version(connection) == 11
|
||||
assert get_schema_version(connection) == 13
|
||||
|
||||
connection.close()
|
||||
@@ -2,8 +2,10 @@
|
||||
## SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from unittest.mock import patch
|
||||
|
||||
from backend.app.main import app
|
||||
from backend.app.database import get_connection
|
||||
from backend.app.services.otp_service import current_code
|
||||
|
||||
|
||||
@@ -45,6 +47,7 @@ def test_user_config_api_and_profile_page():
|
||||
assert '<a id="auth-username" class="user-name" href="/">' in page_response.text
|
||||
assert 'id="auth-avatar"' not in page_response.text
|
||||
assert 'name="new_password_confirmation"' in page_response.text
|
||||
assert 'id="additional-email-form"' in page_response.text
|
||||
|
||||
bob_login = client.post('/api/auth/login', json={
|
||||
'email': 'bob@example.com',
|
||||
@@ -104,3 +107,38 @@ def test_user_can_enable_and_use_otp():
|
||||
})
|
||||
assert disabled.status_code == 200
|
||||
assert disabled.json()['enabled'] is False
|
||||
|
||||
|
||||
def test_verified_alternative_can_become_primary():
|
||||
login = client.post('/api/auth/login', json={'email': 'alice@example.com', 'password': 'secret123'}).json()
|
||||
headers = {'Authorization': f"Bearer {login['access_token']}"}
|
||||
with get_connection() as conn:
|
||||
address = conn.execute(
|
||||
'INSERT INTO user_email_addresses (id, user_id, email, verified) VALUES (?, ?, ?, 1) RETURNING id',
|
||||
('alternative-test', 'user-1', 'alice-alternative@example.com'),
|
||||
).fetchone()
|
||||
conn.commit()
|
||||
promoted = client.post(f"/api/user/emails/{address['id']}/make-primary", headers=headers)
|
||||
assert promoted.status_code == 200
|
||||
assert client.post('/api/auth/login', json={'email': 'alice-alternative@example.com', 'password': 'secret123'}).status_code == 200
|
||||
assert client.post('/api/auth/login', json={'email': 'alice@example.com', 'password': 'secret123'}).status_code == 200
|
||||
with get_connection() as conn:
|
||||
conn.execute('DELETE FROM user_email_addresses WHERE email IN (?, ?)', ('alice@example.com', 'alice-alternative@example.com'))
|
||||
conn.execute('UPDATE users SET email = ?, email_verified = 1 WHERE id = ?', ('alice@example.com', 'user-1'))
|
||||
conn.commit()
|
||||
|
||||
|
||||
def test_unverified_alternative_cannot_become_primary():
|
||||
login = client.post('/api/auth/login', json={'email': 'alice@example.com', 'password': 'secret123'}).json()
|
||||
headers = {'Authorization': f"Bearer {login['access_token']}"}
|
||||
with get_connection() as conn:
|
||||
address = conn.execute(
|
||||
'INSERT INTO user_email_addresses (id, user_id, email, verified) VALUES (?, ?, ?, 0) RETURNING id',
|
||||
('unverified-alternative-test', 'user-1', 'alice-unverified@example.com'),
|
||||
).fetchone()
|
||||
conn.commit()
|
||||
rejected = client.post(f"/api/user/emails/{address['id']}/make-primary", headers=headers)
|
||||
assert rejected.status_code == 400
|
||||
with get_connection() as conn:
|
||||
conn.execute('DELETE FROM user_email_addresses WHERE id = ?', (address['id'],))
|
||||
conn.commit()
|
||||
|
||||
@@ -18,6 +18,9 @@ const otpEnabled = document.querySelector('#otp-enabled');
|
||||
const otpSecret = document.querySelector('#otp-secret');
|
||||
const otpUri = document.querySelector('#otp-uri');
|
||||
const otpStatus = document.querySelector('#otp-status');
|
||||
const emailAddressList = document.querySelector('#email-address-list');
|
||||
const additionalEmailForm = document.querySelector('#additional-email-form');
|
||||
const emailAddressStatus = document.querySelector('#email-address-status');
|
||||
|
||||
function authHeaders(includeJson = false) {
|
||||
return {
|
||||
@@ -37,6 +40,80 @@ function setOtpStatus(message, isError = false) {
|
||||
otpStatus.style.color = isError ? '#b91c1c' : '#166534';
|
||||
}
|
||||
|
||||
function setEmailAddressStatus(message, isError = false) {
|
||||
emailAddressStatus.textContent = message;
|
||||
emailAddressStatus.style.color = isError ? '#b91c1c' : '#166534';
|
||||
}
|
||||
|
||||
function renderEmailAddresses(addresses) {
|
||||
emailAddressList.replaceChildren(...addresses.map((address) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'email-address-row';
|
||||
const label = document.createElement('span');
|
||||
label.textContent = `${address.email} - ${address.verified ? 'validated' : 'not validated'}${address.primary ? ' (primary)' : ''}`;
|
||||
row.appendChild(label);
|
||||
if (!address.primary) {
|
||||
if (!address.verified) {
|
||||
const resend = document.createElement('button');
|
||||
resend.type = 'button';
|
||||
resend.textContent = 'Resend validation';
|
||||
resend.addEventListener('click', async () => {
|
||||
resend.disabled = true;
|
||||
const response = await fetch(`/api/user/emails/${encodeURIComponent(address.id)}/resend`, {method: 'POST', headers: authHeaders()});
|
||||
const result = await response.json();
|
||||
setEmailAddressStatus(response.ok ? result.message : (result.detail || 'Could not send validation email.'), !response.ok);
|
||||
if (response.ok && result.next_allowed_at) window.setTimeout(() => { resend.disabled = false; }, Math.max(0, Date.parse(result.next_allowed_at) - Date.now()));
|
||||
else if (!response.ok) resend.disabled = false;
|
||||
});
|
||||
row.appendChild(resend);
|
||||
}
|
||||
if (address.verified && address.can_be_primary) {
|
||||
const makePrimary = document.createElement('button');
|
||||
makePrimary.type = 'button';
|
||||
makePrimary.textContent = 'Make primary';
|
||||
makePrimary.addEventListener('click', async () => {
|
||||
const response = await fetch(`/api/user/emails/${encodeURIComponent(address.id)}/make-primary`, {method: 'POST', headers: authHeaders()});
|
||||
const result = await response.json();
|
||||
setEmailAddressStatus(response.ok ? `${result.email} is now the primary email address.` : (result.detail || 'Could not change primary email.'), !response.ok);
|
||||
if (response.ok) {
|
||||
await loadEmailAddresses();
|
||||
}
|
||||
});
|
||||
row.appendChild(makePrimary);
|
||||
}
|
||||
const remove = document.createElement('button');
|
||||
remove.type = 'button';
|
||||
remove.textContent = 'Remove';
|
||||
remove.addEventListener('click', async () => {
|
||||
const response = await fetch(`/api/user/emails/${encodeURIComponent(address.id)}`, {method: 'DELETE', headers: authHeaders()});
|
||||
if (response.ok) loadEmailAddresses();
|
||||
else setEmailAddressStatus('Could not remove email address.', true);
|
||||
});
|
||||
row.appendChild(remove);
|
||||
}
|
||||
return row;
|
||||
}));
|
||||
}
|
||||
|
||||
async function loadEmailAddresses() {
|
||||
const response = await fetch('/api/user/emails', {headers: authHeaders()});
|
||||
if (!response.ok) throw new Error('Could not load email addresses');
|
||||
renderEmailAddresses(await response.json());
|
||||
}
|
||||
|
||||
additionalEmailForm.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
const response = await fetch('/api/user/emails', {
|
||||
method: 'POST', headers: authHeaders(true), body: JSON.stringify({email: additionalEmailForm.elements.email.value.trim()}),
|
||||
});
|
||||
const result = await response.json();
|
||||
setEmailAddressStatus(response.ok ? 'Email address added. Check your inbox to validate it.' : (result.detail || 'Could not add email address.'), !response.ok);
|
||||
if (response.ok) {
|
||||
additionalEmailForm.reset();
|
||||
loadEmailAddresses();
|
||||
}
|
||||
});
|
||||
|
||||
async function loadOtp() {
|
||||
const response = await fetch('/api/user/otp', {headers: authHeaders()});
|
||||
if (!response.ok) throw new Error('Could not load one-time password settings');
|
||||
@@ -95,7 +172,7 @@ async function loadProfile() {
|
||||
if (!response.ok) throw new Error('Could not load profile');
|
||||
const profile = await response.json();
|
||||
document.querySelector('#username').textContent = profile.username || '';
|
||||
document.querySelector('#email').value = profile.email || '';
|
||||
document.querySelector('#email').textContent = profile.email || '';
|
||||
document.querySelector('#bio').value = profile.bio || '';
|
||||
const avatarPreview = document.querySelector('#avatar-preview');
|
||||
if (profile.avatar_url) {
|
||||
@@ -211,7 +288,7 @@ passwordForm.addEventListener('submit', async (event) => {
|
||||
if (response.ok) passwordForm.reset();
|
||||
});
|
||||
|
||||
Promise.all([loadProfile(), loadMastodonConfig(), loadOtp()]).catch((error) => {
|
||||
Promise.all([loadProfile(), loadMastodonConfig(), loadOtp(), loadEmailAddresses()]).catch((error) => {
|
||||
setStatus('#profile-status', accessToken ? error.message : 'Please sign in first.', true);
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -430,6 +430,27 @@ button:focus-visible {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.email-address-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.email-address-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.email-address-row button {
|
||||
min-width: 0;
|
||||
padding: 5px 9px;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.settings-panel textarea {
|
||||
min-height: 110px;
|
||||
resize: vertical;
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
<!-- Copyright © 2026 Olaf Kolkman -->
|
||||
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>LinkLog Profile</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="stylesheet" href="/static/style.css" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<header class="site-header">
|
||||
<div class="container">
|
||||
@@ -45,7 +47,7 @@
|
||||
</label>
|
||||
<label>
|
||||
Email
|
||||
<input id="email" name="email" type="email" required />
|
||||
<output id="email" class="readonly-value">Loading...</output>
|
||||
</label>
|
||||
<label>
|
||||
Bio
|
||||
@@ -61,6 +63,19 @@
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="link-item settings-panel">
|
||||
<h2>Email addresses</h2>
|
||||
<div id="email-address-list" class="email-address-list" aria-live="polite">Loading email addresses...</div>
|
||||
<form id="additional-email-form">
|
||||
<label>
|
||||
Additional email address
|
||||
<input name="email" type="email" required />
|
||||
</label>
|
||||
<button type="submit">Add email address</button>
|
||||
<p id="email-address-status" class="status" role="status"></p>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="link-item settings-panel">
|
||||
<h2>Password</h2>
|
||||
<form id="password-form">
|
||||
@@ -90,13 +105,15 @@
|
||||
<p>Scan this QR code or enter the secret in your authenticator app:</p>
|
||||
<code id="otp-secret"></code>
|
||||
<p><a id="otp-uri" href="" target="_blank" rel="noopener noreferrer">Open authenticator link</a></p>
|
||||
<label>Verification code <input id="otp-setup-code" inputmode="numeric" autocomplete="one-time-code" /></label>
|
||||
<label>Verification code <input id="otp-setup-code" inputmode="numeric"
|
||||
autocomplete="one-time-code" /></label>
|
||||
<button id="otp-enable" type="button">Enable one-time password</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="otp-enabled" class="hidden">
|
||||
<p>One-time password is enabled.</p>
|
||||
<label>Verification code <input id="otp-disable-code" inputmode="numeric" autocomplete="one-time-code" /></label>
|
||||
<label>Verification code <input id="otp-disable-code" inputmode="numeric"
|
||||
autocomplete="one-time-code" /></label>
|
||||
<button id="otp-disable" type="button">Disable one-time password</button>
|
||||
</div>
|
||||
<p id="otp-status" class="status" role="status"></p>
|
||||
@@ -107,22 +124,26 @@
|
||||
<form id="mastodon-form">
|
||||
<label>
|
||||
Mastodon server
|
||||
<input id="mastodon-instance" name="instance" type="text" value="mastodon.social" placeholder="mastodon.social" required />
|
||||
<input id="mastodon-instance" name="instance" type="text" value="mastodon.social"
|
||||
placeholder="mastodon.social" required />
|
||||
</label>
|
||||
<button id="mastodon-connect" type="button">Authenticate with this server</button>
|
||||
|
||||
<label>
|
||||
Post prefix
|
||||
<input id="mastodon-post-prefix" name="post_prefix" type="text" value="From my #LinkLog: " />
|
||||
</label>
|
||||
<button id="mastodon-connect" type="button">Authenticate with this server</button>
|
||||
<button type="submit">Save Mastodon settings</button>
|
||||
<p id="mastodon-status" class="status" role="status"></p>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
<footer class="site-footer">Copyright © 2026 Olaf Kolkman · <a href="https://git.kolkman.org/olaf/Link-Log">git.kolkman.org/LinkLog</a></footer>
|
||||
<footer class="site-footer">Copyright © 2026 Olaf Kolkman · <a
|
||||
href="https://git.kolkman.org/olaf/Link-Log">git.kolkman.org/LinkLog</a></footer>
|
||||
<script src="/static/auth-header.js?v=3"></script>
|
||||
<script src="/static/logout.js?v=2"></script>
|
||||
<script src="/static/theme.js?v=1"></script>
|
||||
<script src="/static/profile.js?v=5"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
Reference in New Issue
Block a user