Remove mastodon posts on delete and OTP
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 `/<user>/` 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Binary file not shown.
@@ -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'])}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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;
|
||||
'''),
|
||||
]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -77,6 +77,27 @@
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="link-item settings-panel">
|
||||
<h2>One-time password</h2>
|
||||
<p>Use an authenticator app to add a second sign-in step.</p>
|
||||
<div id="otp-disabled">
|
||||
<button id="otp-setup" type="button">Set up one-time password</button>
|
||||
<div id="otp-provisioning" class="hidden">
|
||||
<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>
|
||||
<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>
|
||||
<button id="otp-disable" type="button">Disable one-time password</button>
|
||||
</div>
|
||||
<p id="otp-status" class="status" role="status"></p>
|
||||
</section>
|
||||
|
||||
<section class="link-item settings-panel">
|
||||
<h2>Mastodon</h2>
|
||||
<form id="mastodon-form">
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
"usernameLabel": {"message": "Benutzername"},
|
||||
"usernamePlaceholder": {"message": "alice"},
|
||||
"passwordLabel": {"message": "Passwort"},
|
||||
"otpLabel": {"message": "Einmalpasswort"},
|
||||
"otpPlaceholder": {"message": "123456"},
|
||||
"saveAndLogIn": {"message": "Speichern und anmelden"},
|
||||
"thisPlugin": {"message": "Dieses Add-on"},
|
||||
"pluginDescription": {"message": "Dieses Add-on protokolliert Links auf einem LinkLog-Server, der sie auf einer Website und je nach Konfiguration der Add-ons möglicherweise auch in einem Fediverse-Dienst oder an anderen Orten veröffentlicht."},
|
||||
|
||||
@@ -50,6 +50,12 @@
|
||||
"passwordLabel": {
|
||||
"message": "Password"
|
||||
},
|
||||
"otpLabel": {
|
||||
"message": "One-time password"
|
||||
},
|
||||
"otpPlaceholder": {
|
||||
"message": "123456"
|
||||
},
|
||||
"saveAndLogIn": {
|
||||
"message": "Save and log in"
|
||||
},
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
"usernameLabel": {"message": "Nombre de usuario"},
|
||||
"usernamePlaceholder": {"message": "alice"},
|
||||
"passwordLabel": {"message": "Contraseña"},
|
||||
"otpLabel": {"message": "Contraseña de un solo uso"},
|
||||
"otpPlaceholder": {"message": "123456"},
|
||||
"saveAndLogIn": {"message": "Guardar e iniciar sesión"},
|
||||
"thisPlugin": {"message": "Este complemento"},
|
||||
"pluginDescription": {"message": "Este complemento registra enlaces en un servidor LinkLog, que los publicará en un sitio web y posiblemente en un servicio fediverso u otros destinos, según los complementos configurados."},
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
"usernameLabel": {"message": "Nom d’utilisateur"},
|
||||
"usernamePlaceholder": {"message": "alice"},
|
||||
"passwordLabel": {"message": "Mot de passe"},
|
||||
"otpLabel": {"message": "Mot de passe à usage unique"},
|
||||
"otpPlaceholder": {"message": "123456"},
|
||||
"saveAndLogIn": {"message": "Enregistrer et se connecter"},
|
||||
"thisPlugin": {"message": "Cette extension"},
|
||||
"pluginDescription": {"message": "Cette extension enregistre les liens sur un serveur LinkLog, qui les publiera sur un site web et éventuellement sur un service du fédivers ou ailleurs, selon les extensions configurées."},
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
"usernameLabel": {"message": "Gebruikersnaam"},
|
||||
"usernamePlaceholder": {"message": "alice"},
|
||||
"passwordLabel": {"message": "Wachtwoord"},
|
||||
"otpLabel": {"message": "Eenmalig wachtwoord"},
|
||||
"otpPlaceholder": {"message": "123456"},
|
||||
"saveAndLogIn": {"message": "Opslaan en inloggen"},
|
||||
"thisPlugin": {"message": "Deze plug-in"},
|
||||
"pluginDescription": {"message": "Deze plug-in legt koppelingen vast op een LinkLog-server, die ze publiceert op een website en mogelijk op een fediverse-dienst of andere locaties, afhankelijk van de geconfigureerde plug-ins."},
|
||||
|
||||
@@ -37,6 +37,11 @@
|
||||
<input id="password" type="password" />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span data-i18n="otpLabel">One-time password</span>
|
||||
<input id="otp" type="text" inputmode="numeric" autocomplete="one-time-code" data-i18n-placeholder="otpPlaceholder" placeholder="123456" />
|
||||
</label>
|
||||
|
||||
<button type="submit" data-i18n="saveAndLogIn">Save and log in</button>
|
||||
</form>
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ const form = document.getElementById('settings-form');
|
||||
const backendUrlInput = document.getElementById('backend-url');
|
||||
const usernameInput = document.getElementById('username');
|
||||
const passwordInput = document.getElementById('password');
|
||||
const otpInput = document.getElementById('otp');
|
||||
const session = document.getElementById('logged-in');
|
||||
const sessionSummary = document.getElementById('session-summary');
|
||||
const signOutButton = document.getElementById('sign-out');
|
||||
@@ -74,7 +75,7 @@ async function saveSettingsAndLogin(event) {
|
||||
const response = await fetch(`${backendUrl}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password })
|
||||
body: JSON.stringify({ username, password, otp: otpInput.value.trim() || null })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -93,6 +94,7 @@ async function saveSettingsAndLogin(event) {
|
||||
|
||||
showLoggedIn(data.user?.username || username, backendUrl);
|
||||
passwordInput.value = '';
|
||||
otpInput.value = '';
|
||||
setStatus(t('loggedInSuccessfully'));
|
||||
} catch (error) {
|
||||
setStatus(t('unableToLogIn'), true);
|
||||
|
||||
Reference in New Issue
Block a user