diff --git a/README.md b/README.md index 8892945..12c2040 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,8 @@ Configuration APIs require a bearer token returned by the login endpoint. User c Users can change their password from the profile page. The current password is required, new passwords must contain at least 8 characters, and the endpoint is `PUT /api/user/password`. +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. + 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/VIBE/CHAT_LOG.md b/VIBE/CHAT_LOG.md index df71ae6..14ae757 100644 --- a/VIBE/CHAT_LOG.md +++ b/VIBE/CHAT_LOG.md @@ -334,6 +334,12 @@ Correction: the feed header should have the sort and filter bar to the right of ### Assistant outcome Corrected the feed header so the logo remains on the left and the theme selector/menu sit above the sort and filter bar in a right-side control column. The control column remains responsive on mobile. +### User +When a log entry is deleted, delete all Mastodon posts too. + +### Assistant outcome +Added migration-backed tracking for all Mastodon post IDs, including reposts. Deleting a link now removes every associated remote Mastodon status before deleting the local entry, and preserves the local link with an actionable error if remote cleanup fails. + ### User Perform the next items on the todo list. @@ -798,3 +804,11 @@ When an authorized user is on their `//` page, show a button to post on Ma ### Assistant outcome Added persisted Mastodon post state, an owner-scoped repost endpoint, and an owner-only Mastodon button on user-specific pages. The button includes a bundled Mastodon logo, says `Post to Mastodon` before posting, changes to the posted teal state and `Post again` afterward, and remains functional for reposting. Home-page entries remain free of controls. The complete Dockerized backend suite passed with 23 tests and the running container is healthy. + +## 2026-08-26 + +### User +Don't forget to update the plugin to work with OTP. + +### Assistant outcome +Updated the Firefox extension settings login to accept and submit a six-digit one-time password. The backend supports TOTP setup, enable/disable confirmation, and OTP-enforced login, with the stored secret protected from profile responses. diff --git a/VIBE/PROMPTS.md b/VIBE/PROMPTS.md index 519dc7c..62b3465 100644 --- a/VIBE/PROMPTS.md +++ b/VIBE/PROMPTS.md @@ -142,6 +142,8 @@ 133. Run frontend and style checks. 134. Keep the sort and filter bar left of the logo and pack it snug against the menu and theme selector. 135. Correction: the feed header should have the sort and filter bar to the right of the LinkLog logo, compact and underneath the theme selector and menu. +136. When a log entry is deleted then all mastodon posts are deleted too. +137. Don't forget to update the plugin to work with OTP. ## Future entries diff --git a/XPI/unsigned/LinkLog-0.1.0.xpi b/XPI/unsigned/LinkLog-0.1.0.xpi index 0fe8b22..19a7937 100644 Binary files a/XPI/unsigned/LinkLog-0.1.0.xpi and b/XPI/unsigned/LinkLog-0.1.0.xpi differ diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 3fa10d3..ca5efdb 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -13,6 +13,7 @@ from backend.app.services.email_service import send_password_reset_email, smtp_c 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 router = APIRouter() @@ -22,6 +23,7 @@ init_db() class LoginRequest(BaseModel): username: str password: str + otp: str | None = None class PasswordResetRequest(BaseModel): @@ -44,6 +46,8 @@ def login(payload: LoginRequest): raise HTTPException(status_code=401, detail='Invalid username or password') if not user['email_verified']: raise HTTPException(status_code=403, detail='Email address is not verified') + if user['otp_enabled'] and not verify_code(user['otp_secret'], payload.otp): + raise HTTPException(status_code=401, detail='One-time password required or invalid') token_data = issue_token(user['id'], user['username']) return { @@ -51,7 +55,7 @@ def login(payload: LoginRequest): 'token_type': 'bearer', 'expires_at': token_data['expires_at'], 'refresh_token': token_data['refresh_token'], - 'user': {'id': user['id'], 'username': user['username'], 'email': user['email']} + 'user': {'id': user['id'], 'username': user['username'], 'email': user['email'], 'otp_enabled': bool(user['otp_enabled'])} } diff --git a/backend/app/api/links.py b/backend/app/api/links.py index 2564050..f3310ed 100644 --- a/backend/app/api/links.py +++ b/backend/app/api/links.py @@ -1,11 +1,12 @@ ## Copyright © 2026 Olaf Kolkman ## SPDX-License-Identifier: GPL-3.0-or-later +import json from fastapi import APIRouter, Header, HTTPException, status import logging from pydantic import BaseModel -from backend.app.services.link_service import create_link, delete_link, get_link_tags, list_public_links, list_tags, mark_mastodon_posted, update_link +from backend.app.services.link_service import create_link, delete_link, get_link_tags, get_owned_link, list_public_links, list_tags, mark_mastodon_posted, update_link from backend.app.database import get_connection from backend.app.services.plugin_manager import plugin_manager from backend.app.services.token_service import validate_token @@ -87,6 +88,16 @@ def delete_link_endpoint( info = validate_token(authorization.replace('Bearer ', '', 1)) if info is None: raise HTTPException(status_code=401, detail='Token expired or invalid') + link = get_owned_link(link_id, info['user_id']) + if link is None: + raise HTTPException(status_code=404, detail='Link not found or not owned by user') + post_ids = json.loads(link['mastodon_post_ids']) if link.get('mastodon_post_ids') else [] + if not post_ids and link.get('mastodon_post_id'): + post_ids = [link['mastodon_post_id']] + if post_ids: + result = plugin_manager.delete_mastodon_posts({**link, 'mastodon_post_ids': post_ids}) + if result.get('status') != 'deleted': + raise HTTPException(status_code=502, detail=result.get('reason', 'Could not delete Mastodon posts')) if not delete_link(link_id, info['user_id']): raise HTTPException(status_code=404, detail='Link not found or not owned by user') return {'status': 'deleted', 'id': link_id} diff --git a/backend/app/api/user_config.py b/backend/app/api/user_config.py index 83f778d..f154481 100644 --- a/backend/app/api/user_config.py +++ b/backend/app/api/user_config.py @@ -10,6 +10,7 @@ from pydantic import BaseModel from backend.app.api.dependencies import get_current_user from backend.app.database import AVATARS_DIR, get_connection, hash_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 router = APIRouter() @@ -24,6 +25,11 @@ class PasswordUpdate(BaseModel): new_password: str +class OtpUpdate(BaseModel): + action: str + code: str | None = None + + class UserPluginConfigUpdate(BaseModel): instance: str | None = None access_token: str | None = None @@ -44,7 +50,10 @@ def get_current_user_profile(user: dict = Depends(get_current_user)): ).fetchone() if row is None: raise HTTPException(status_code=404, detail='User not found') - return dict(row) + profile = dict(row) + profile.pop('otp_secret', None) + profile.pop('password_hash', None) + return profile @router.put('/me') @@ -89,6 +98,37 @@ def update_password(payload: PasswordUpdate, user: dict = Depends(get_current_us return {'status': 'password_updated'} +@router.get('/otp') +def get_otp(user: dict = Depends(get_current_user)): + return {'enabled': bool(user['otp_enabled'])} + + +@router.post('/otp/setup') +def setup_otp(user: dict = Depends(get_current_user)): + if user['otp_enabled']: + raise HTTPException(status_code=409, detail='One-time password is already enabled') + secret = create_secret() + with get_connection() as conn: + conn.execute('UPDATE users SET otp_secret = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', (secret, user['id'])) + conn.commit() + return {'secret': secret, 'otpauth_url': provisioning_uri(secret, user['username'])} + + +@router.post('/otp') +def update_otp(payload: OtpUpdate, user: dict = Depends(get_current_user)): + if payload.action not in {'enable', 'disable'}: + raise HTTPException(status_code=422, detail='OTP action must be enable or disable') + if not verify_code(user['otp_secret'], payload.code): + raise HTTPException(status_code=400, detail='Invalid one-time password') + with get_connection() as conn: + if payload.action == 'enable': + conn.execute('UPDATE users SET otp_enabled = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?', (user['id'],)) + else: + conn.execute('UPDATE users SET otp_enabled = 0, otp_secret = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?', (user['id'],)) + conn.commit() + return {'status': 'updated', 'enabled': payload.action == 'enable'} + + @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 25300f2..6613660 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -156,6 +156,19 @@ CREATE INDEX IF NOT EXISTS idx_mastodon_oauth_states_state_hash ALTER TABLE links ADD COLUMN mastodon_posted INTEGER NOT NULL DEFAULT 0; ALTER TABLE links ADD COLUMN mastodon_post_id TEXT; ALTER TABLE links ADD COLUMN mastodon_posted_at TEXT; +'''), + (10, ''' +ALTER TABLE links ADD COLUMN mastodon_post_ids TEXT; +UPDATE links +SET mastodon_post_ids = CASE + WHEN mastodon_post_id IS NOT NULL THEN json_array(mastodon_post_id) + ELSE '[]' +END +WHERE mastodon_post_ids IS NULL; +'''), + (11, ''' +ALTER TABLE users ADD COLUMN otp_secret TEXT; +ALTER TABLE users ADD COLUMN otp_enabled INTEGER NOT NULL DEFAULT 0; '''), ] diff --git a/backend/app/services/link_service.py b/backend/app/services/link_service.py index 6dda9a1..40a9ab6 100644 --- a/backend/app/services/link_service.py +++ b/backend/app/services/link_service.py @@ -2,6 +2,7 @@ ## SPDX-License-Identifier: GPL-3.0-or-later from datetime import datetime, timezone +import json from uuid import uuid4 from backend.app.core.security import clean_url @@ -176,13 +177,29 @@ def delete_link(link_id: str, user_id: str) -> bool: return cursor.rowcount > 0 +def get_owned_link(link_id: str, user_id: str) -> dict | None: + with get_connection() as conn: + row = conn.execute( + 'SELECT * FROM links WHERE id = ? AND user_id = ?', + (link_id, user_id), + ).fetchone() + return dict(row) if row else None + + def mark_mastodon_posted(link_id: str, user_id: str, post_id: str | None) -> bool: with get_connection() as conn: + current = conn.execute( + 'SELECT mastodon_post_ids FROM links WHERE id = ? AND user_id = ?', + (link_id, user_id), + ).fetchone() + post_ids = json.loads(current['mastodon_post_ids']) if current and current['mastodon_post_ids'] else [] + if post_id and post_id not in post_ids: + post_ids.append(post_id) cursor = conn.execute( '''UPDATE links - SET mastodon_posted = 1, mastodon_post_id = ?, mastodon_posted_at = CURRENT_TIMESTAMP + SET mastodon_posted = 1, mastodon_post_id = ?, mastodon_post_ids = ?, mastodon_posted_at = CURRENT_TIMESTAMP WHERE id = ? AND user_id = ?''', - (post_id, link_id, user_id), + (post_id, json.dumps(post_ids), link_id, user_id), ) conn.commit() return cursor.rowcount > 0 diff --git a/backend/app/services/otp_service.py b/backend/app/services/otp_service.py new file mode 100644 index 0000000..f969117 --- /dev/null +++ b/backend/app/services/otp_service.py @@ -0,0 +1,48 @@ +## Copyright © 2026 Olaf Kolkman +## SPDX-License-Identifier: GPL-3.0-or-later + +import base64 +import hashlib +import hmac +import secrets +import time +from urllib.parse import quote + + +def create_secret() -> str: + return base64.b32encode(secrets.token_bytes(20)).decode('ascii').rstrip('=') + + +def provisioning_uri(secret: str, username: str, issuer: str = 'LinkLog') -> str: + return f'otpauth://totp/{quote(issuer)}:{quote(username)}?secret={secret}&issuer={quote(issuer)}' + + +def current_code(secret: str, timestamp: float | None = None) -> str: + padded_secret = secret + '=' * (-len(secret) % 8) + key = base64.b32decode(padded_secret, casefold=True) + counter = int(timestamp if timestamp is not None else time.time()) // 30 + digest = hmac.new(key, counter.to_bytes(8, 'big'), hashlib.sha1).digest() + index = digest[-1] & 0x0f + value = (int.from_bytes(digest[index:index + 4], 'big') & 0x7fffffff) % 1_000_000 + return f'{value:06d}' + + +def verify_code(secret: str | None, code: str | None) -> bool: + if not secret or not code: + return False + normalized_code = code.strip() + if len(normalized_code) != 6 or not normalized_code.isdigit(): + return False + padded_secret = secret + '=' * (-len(secret) % 8) + try: + key = base64.b32decode(padded_secret, casefold=True) + except (ValueError, base64.binascii.Error): + return False + counter = int(time.time()) // 30 + for offset in (-1, 0, 1): + digest = hmac.new(key, (counter + offset).to_bytes(8, 'big'), hashlib.sha1).digest() + index = digest[-1] & 0x0f + value = (int.from_bytes(digest[index:index + 4], 'big') & 0x7fffffff) % 1_000_000 + if hmac.compare_digest(f'{value:06d}', normalized_code): + return True + return False \ No newline at end of file diff --git a/backend/app/services/plugin_manager.py b/backend/app/services/plugin_manager.py index a8175ed..d383303 100644 --- a/backend/app/services/plugin_manager.py +++ b/backend/app/services/plugin_manager.py @@ -122,6 +122,46 @@ class MastodonPlugin(BasePlugin): 'reason': str(error), } + def delete_posts(self, event): + config = dict(self.config) + user_id = event.get('user_id') + if user_id: + from backend.app.database import get_connection + + with get_connection() as conn: + row = conn.execute( + 'SELECT config FROM user_plugin_config WHERE user_id = ? AND plugin_name = ?', + (user_id, self.name), + ).fetchone() + if row and row['config']: + config.update(json.loads(row['config'])) + + instance = str(config.get('instance', '')).strip().rstrip('/') + if instance and '://' not in instance: + instance = f'https://{instance}' + access_token = str(config.get('access_token', '')).strip() + post_ids = event.get('mastodon_post_ids') or [] + if not post_ids and event.get('mastodon_post_id'): + post_ids = [event['mastodon_post_id']] + if not instance or not access_token: + return {'status': 'failed', 'plugin': self.name, 'reason': 'Mastodon is not configured'} + + try: + for post_id in post_ids: + request = Request( + f'{instance}/api/v1/statuses/{post_id}', + headers={'Authorization': f'Bearer {access_token}', 'User-Agent': 'LinkLog/1.0'}, + method='DELETE', + ) + with urlopen(request, timeout=5) as response: + response.read() + return {'status': 'deleted', 'plugin': self.name, 'count': len(post_ids)} + except HTTPError as error: + response_body = error.read().decode('utf-8', errors='replace') + return {'status': 'failed', 'plugin': self.name, 'reason': f'HTTP {error.code}: {response_body[:500]}'} + except (URLError, TimeoutError, OSError) as error: + return {'status': 'failed', 'plugin': self.name, 'reason': str(error)} + class PluginManager: def __init__(self): @@ -157,5 +197,10 @@ class PluginManager: return {'status': 'skipped', 'plugin': 'mastodon', 'reason': 'disabled'} return plugin.handle_event(event) + def delete_mastodon_posts(self, event): + self.refresh_from_db() + plugin = next(plugin for plugin in self.plugins if plugin.name == 'mastodon') + return plugin.delete_posts(event) + plugin_manager = PluginManager() diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index abf7770..6c89bef 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -273,7 +273,50 @@ def test_admin_can_remove_user_with_owned_data(): removed = client.delete(f'/api/admin/users/{user_id}', headers=headers) assert removed.status_code == 200 - assert client.get('/api/auth/me', params={'token': user_headers['Authorization'].removeprefix('Bearer ')}).status_code == 401 + assert client.get('/api/auth/me', params={'token': user_token}).status_code == 401 + + +def test_deleting_link_removes_all_mastodon_posts_first(): + owner_headers = login_headers('alice') + created = client.post('/api/links', headers=owner_headers, json={ + 'title': 'Remote cleanup', + 'url': 'https://example.com/remote-cleanup', + }) + assert created.status_code == 201 + link_id = created.json()['id'] + with get_connection() as conn: + conn.execute( + 'UPDATE links SET mastodon_posted = 1, mastodon_post_id = ?, mastodon_post_ids = ? WHERE id = ?', + ('post-2', json.dumps(['post-1', 'post-2']), link_id), + ) + conn.commit() + + with patch('backend.app.api.links.plugin_manager.delete_mastodon_posts', return_value={'status': 'deleted', 'count': 2}) as delete_posts: + deleted = client.delete(f'/api/links/{link_id}', headers=owner_headers) + assert deleted.status_code == 200 + delete_posts.assert_called_once() + assert delete_posts.call_args.args[0]['mastodon_post_ids'] == ['post-1', 'post-2'] + assert client.delete(f'/api/links/{link_id}', headers=owner_headers).status_code == 404 + + +def test_link_is_kept_when_mastodon_cleanup_fails(): + owner_headers = login_headers('alice') + created = client.post('/api/links', headers=owner_headers, json={ + 'title': 'Failed remote cleanup', + 'url': 'https://example.com/failed-remote-cleanup', + }) + link_id = created.json()['id'] + with get_connection() as conn: + conn.execute( + 'UPDATE links SET mastodon_posted = 1, mastodon_post_id = ?, mastodon_post_ids = ? WHERE id = ?', + ('post-failed', json.dumps(['post-failed']), link_id), + ) + conn.commit() + with patch('backend.app.api.links.plugin_manager.delete_mastodon_posts', return_value={'status': 'failed', 'reason': 'remote refused'}): + deleted = client.delete(f'/api/links/{link_id}', headers=owner_headers) + assert deleted.status_code == 502 + assert 'remote refused' in deleted.json()['detail'] + assert client.get('/api/links').json() def test_admin_can_toggle_privileges_without_removing_last_admin(): diff --git a/backend/tests/test_database.py b/backend/tests/test_database.py index b1fdf95..6f85d62 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) == 9 + assert get_schema_version(connection) == 11 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) == 9 + assert get_schema_version(connection) == 11 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 0336a21..9abc373 100644 --- a/backend/tests/test_user_config.py +++ b/backend/tests/test_user_config.py @@ -4,6 +4,7 @@ from fastapi.testclient import TestClient from backend.app.main import app +from backend.app.services.otp_service import current_code client = TestClient(app) @@ -75,3 +76,30 @@ def test_user_config_api_and_profile_page(): updated_profile = client.get('/api/user/me', headers=headers).json() assert updated_profile['avatar_url'] == avatar_url + + +def test_user_can_enable_and_use_otp(): + login = client.post('/api/auth/login', json={'username': 'alice', 'password': 'secret123'}).json() + headers = {'Authorization': f"Bearer {login['access_token']}"} + setup = client.post('/api/user/otp/setup', headers=headers) + assert setup.status_code == 200 + secret = setup.json()['secret'] + assert setup.json()['otpauth_url'].startswith('otpauth://totp/') + + enabled = client.post('/api/user/otp', headers=headers, json={ + 'action': 'enable', 'code': current_code(secret), + }) + assert enabled.status_code == 200 + assert enabled.json()['enabled'] is True + assert client.post('/api/auth/login', json={'username': 'alice', 'password': 'secret123'}).status_code == 401 + + otp_login = client.post('/api/auth/login', json={ + 'username': 'alice', 'password': 'secret123', 'otp': current_code(secret), + }) + assert otp_login.status_code == 200 + + disabled = client.post('/api/user/otp', headers=headers, json={ + 'action': 'disable', 'code': current_code(secret), + }) + assert disabled.status_code == 200 + assert disabled.json()['enabled'] is False diff --git a/frontend/static/login.js b/frontend/static/login.js index ab760b8..e160416 100644 --- a/frontend/static/login.js +++ b/frontend/static/login.js @@ -16,7 +16,8 @@ form.addEventListener('submit', async (event) => { }); if (!response.ok) { - status.textContent = 'Sign-in failed.'; + const result = await response.json().catch(() => ({})); + status.textContent = result.detail || 'Sign-in failed.'; status.style.color = '#b91c1c'; return; } diff --git a/frontend/static/profile.js b/frontend/static/profile.js index 2de2216..56ea276 100644 --- a/frontend/static/profile.js +++ b/frontend/static/profile.js @@ -9,6 +9,15 @@ const profileLogoutButton = document.querySelector('#logout-button'); const accessToken = localStorage.getItem('linklogAccessToken'); const defaultPostPrefix = 'From my #LinkLog: '; const mastodonConnectButton = document.querySelector('#mastodon-connect'); +const otpSetupButton = document.querySelector('#otp-setup'); +const otpEnableButton = document.querySelector('#otp-enable'); +const otpDisableButton = document.querySelector('#otp-disable'); +const otpProvisioning = document.querySelector('#otp-provisioning'); +const otpDisabled = document.querySelector('#otp-disabled'); +const otpEnabled = document.querySelector('#otp-enabled'); +const otpSecret = document.querySelector('#otp-secret'); +const otpUri = document.querySelector('#otp-uri'); +const otpStatus = document.querySelector('#otp-status'); function authHeaders(includeJson = false) { return { @@ -23,6 +32,64 @@ function setStatus(selector, message, isError = false) { status.style.color = isError ? '#b91c1c' : '#166534'; } +function setOtpStatus(message, isError = false) { + otpStatus.textContent = message; + otpStatus.style.color = isError ? '#b91c1c' : '#166534'; +} + +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'); + const result = await response.json(); + otpDisabled.classList.toggle('hidden', result.enabled); + otpEnabled.classList.toggle('hidden', !result.enabled); +} + +otpSetupButton.addEventListener('click', async () => { + const response = await fetch('/api/user/otp/setup', {method: 'POST', headers: authHeaders()}); + const result = await response.json(); + if (!response.ok) { + setOtpStatus(result.detail || 'Could not start one-time password setup.', true); + return; + } + otpSecret.textContent = result.secret; + otpUri.href = result.otpauth_url; + otpProvisioning.classList.remove('hidden'); + setOtpStatus('Enter a code from your authenticator app to confirm setup.'); +}); + +otpEnableButton.addEventListener('click', async () => { + const code = document.querySelector('#otp-setup-code').value.trim(); + const response = await fetch('/api/user/otp', { + method: 'POST', headers: authHeaders(true), body: JSON.stringify({action: 'enable', code}), + }); + const result = await response.json(); + if (!response.ok) { + setOtpStatus(result.detail || 'Could not enable one-time password.', true); + return; + } + otpDisabled.classList.add('hidden'); + otpEnabled.classList.remove('hidden'); + otpProvisioning.classList.add('hidden'); + setOtpStatus('One-time password enabled.'); +}); + +otpDisableButton.addEventListener('click', async () => { + const code = document.querySelector('#otp-disable-code').value.trim(); + const response = await fetch('/api/user/otp', { + method: 'POST', headers: authHeaders(true), body: JSON.stringify({action: 'disable', code}), + }); + const result = await response.json(); + if (!response.ok) { + setOtpStatus(result.detail || 'Could not disable one-time password.', true); + return; + } + otpDisabled.classList.remove('hidden'); + otpEnabled.classList.add('hidden'); + document.querySelector('#otp-disable-code').value = ''; + setOtpStatus('One-time password disabled.'); +}); + async function loadProfile() { const response = await fetch('/api/user/me', {headers: authHeaders()}); if (!response.ok) throw new Error('Could not load profile'); @@ -136,7 +203,7 @@ passwordForm.addEventListener('submit', async (event) => { if (response.ok) passwordForm.reset(); }); -Promise.all([loadProfile(), loadMastodonConfig()]).catch((error) => { +Promise.all([loadProfile(), loadMastodonConfig(), loadOtp()]).catch((error) => { setStatus('#profile-status', accessToken ? error.message : 'Please sign in first.', true); }); })(); diff --git a/frontend/templates/user_profile.html b/frontend/templates/user_profile.html index 39398dd..4b5f862 100644 --- a/frontend/templates/user_profile.html +++ b/frontend/templates/user_profile.html @@ -77,6 +77,27 @@ + +