diff --git a/README.md b/README.md index e25f9df..6fa54f8 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/Security-audit.md b/Security-audit.md index aee7693..cd3a98f 100644 --- a/Security-audit.md +++ b/Security-audit.md @@ -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. diff --git a/VIBE/CHAT_LOG.md b/VIBE/CHAT_LOG.md index d4b06a6..ac3f780 100644 --- a/VIBE/CHAT_LOG.md +++ b/VIBE/CHAT_LOG.md @@ -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. diff --git a/VIBE/PROMPTS.md b/VIBE/PROMPTS.md index 63234c3..9b89f8a 100644 --- a/VIBE/PROMPTS.md +++ b/VIBE/PROMPTS.md @@ -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 emailLabel should read: Email +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 diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index c75c0b1..ea3e6d3 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -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: diff --git a/backend/app/api/user_config.py b/backend/app/api/user_config.py index eea58b8..2a97819 100644 --- a/backend/app/api/user_config.py +++ b/backend/app/api/user_config.py @@ -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']) diff --git a/backend/app/database.py b/backend/app/database.py index ef7639a..f9b8a5c 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -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 +); +''') ] diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py index a4ee156..b84d4db 100644 --- a/backend/app/services/auth_service.py +++ b/backend/app/services/auth_service.py @@ -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 diff --git a/backend/app/services/email_addresses.py b/backend/app/services/email_addresses.py new file mode 100644 index 0000000..fe81da1 --- /dev/null +++ b/backend/app/services/email_addresses.py @@ -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 \ No newline at end of file diff --git a/backend/tests/test_database.py b/backend/tests/test_database.py index 6f85d62..dbe822b 100644 --- a/backend/tests/test_database.py +++ b/backend/tests/test_database.py @@ -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() \ No newline at end of file diff --git a/backend/tests/test_user_config.py b/backend/tests/test_user_config.py index 234a308..9f7f6d0 100644 --- a/backend/tests/test_user_config.py +++ b/backend/tests/test_user_config.py @@ -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 '' 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() diff --git a/frontend/static/profile.js b/frontend/static/profile.js index e85e498..cdeb1c5 100644 --- a/frontend/static/profile.js +++ b/frontend/static/profile.js @@ -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); }); })(); diff --git a/frontend/static/style.css b/frontend/static/style.css index 34de175..1fb58fa 100644 --- a/frontend/static/style.css +++ b/frontend/static/style.css @@ -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; diff --git a/frontend/templates/user_profile.html b/frontend/templates/user_profile.html index d5edce2..3604004 100644 --- a/frontend/templates/user_profile.html +++ b/frontend/templates/user_profile.html @@ -2,127 +2,148 @@ - - - LinkLog Profile - - - - - -
- +
+ - + - - -
- - - - - - - + +
+ + + + + + + + \ No newline at end of file