Remove mastodon posts on delete and OTP

This commit is contained in:
2026-08-26 14:16:29 +02:00
parent 80a3a3d541
commit f503dbaef2
24 changed files with 388 additions and 11 deletions
+2
View File
@@ -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
+14
View File
@@ -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.
+2
View File
@@ -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.
+5 -1
View File
@@ -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'])}
}
+12 -1
View File
@@ -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}
+41 -1
View File
@@ -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'])
+13
View File
@@ -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;
'''),
]
+19 -2
View File
@@ -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
+48
View File
@@ -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
+45
View File
@@ -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()
+44 -1
View File
@@ -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():
+2 -2
View File
@@ -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()
+28
View File
@@ -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
+2 -1
View File
@@ -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;
}
+68 -1
View File
@@ -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);
});
})();
+21
View File
@@ -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">
+2
View File
@@ -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"
},
+2
View File
@@ -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."},
+2
View File
@@ -16,6 +16,8 @@
"usernameLabel": {"message": "Nom dutilisateur"},
"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."},
+2
View File
@@ -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."},
+5
View File
@@ -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>
+3 -1
View File
@@ -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);