5 Commits
Author SHA1 Message Date
olaf b3383e29a7 normalize user input
Build LinkLog Development Image / development-image (push) Successful in 10s
2026-08-26 14:35:41 +02:00
olaf d18acf813a webplugin settings defaults 2026-08-26 14:33:11 +02:00
olaf 6f1fbaa5ea Finetuning Plugin behavior when not logged in 2026-08-26 14:25:00 +02:00
olaf f503dbaef2 Remove mastodon posts on delete and OTP 2026-08-26 14:16:29 +02:00
olaf 80a3a3d541 filter and sort moved 2026-08-26 13:58:19 +02:00
29 changed files with 565 additions and 45 deletions
+4 -2
View File
@@ -71,7 +71,7 @@ Open these URLs:
- Health check: <http://localhost:8000/health> - Health check: <http://localhost:8000/health>
- OpenAPI documentation: <http://localhost:8000/docs> - OpenAPI documentation: <http://localhost:8000/docs>
The browser extension defaults to `http://localhost:8000`. The browser extension requires a backend URL to be entered during setup; it does not assume a default server.
The supplied `Logo.svg` is bundled as `frontend/static/logo.svg` for web pages and `webextension/logo.svg` for the Firefox popup and settings page. The supplied `Logo.svg` is bundled as `frontend/static/logo.svg` for web pages and `webextension/logo.svg` for the Firefox popup and settings page.
The visible LinkLog brand text uses the Google Foundry `Asset` font when available, with local fallbacks in the Firefox extension. The visible LinkLog brand text uses the Google Foundry `Asset` font when available, with local fallbacks in the Firefox extension.
@@ -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 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. 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 ## Run Tests
@@ -138,7 +140,7 @@ The manifest includes stable Firefox extension metadata and references the packa
3. Select **Load Temporary Add-on**. 3. Select **Load Temporary Add-on**.
4. Choose `webextension/manifest.json`. 4. Choose `webextension/manifest.json`.
5. Open the LinkLog extension options and enter: 5. Open the LinkLog extension options and enter:
- Backend URL: `http://localhost:8000` - Backend URL: the URL of your LinkLog server, such as `http://localhost:8000`
- Username: `alice` - Username: `alice`
- Password: `secret123` - Password: `secret123`
6. Save the settings and login. 6. Save the settings and login.
+62
View File
@@ -310,6 +310,36 @@ Decrease the space between LinkLog items.
### Assistant outcome ### Assistant outcome
Reduced the feed grid gap from 14px to 8px so link-log items sit closer together while preserving the existing item padding and responsive layout. Reduced the feed grid gap from 14px to 8px so link-log items sit closer together while preserving the existing item padding and responsive layout.
### User
Move the sort and filter toolbar underneath the theme and menu, in the header, on the home page and user pages.
### Assistant outcome
Moved the feed sort, user filter, and tag filter toolbar into the header beneath the theme picker and hamburger menu. The same header toolbar is used on both the home feed and `/<user>/` pages, with responsive styling preserved.
### User
Run frontend and style checks.
### Assistant outcome
Ran syntax checks for all frontend JavaScript files, editor diagnostics for the stylesheet and key frontend scripts, HTML parsing for all templates, and frontend whitespace validation. All checks passed.
### User
Keep the sort and filter bar left of the logo and pack it snug against the menu and theme selector.
### Assistant outcome
Reworked the feed header into a compact row with the sort and filter bar on the left, the LinkLog branding beside it, and the theme/menu controls on the right. The toolbar stacks cleanly on mobile.
### User
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.
### 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 ### User
Perform the next items on the todo list. Perform the next items on the todo list.
@@ -774,3 +804,35 @@ When an authorized user is on their `/<user>/` page, show a button to post on Ma
### Assistant outcome ### 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. 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.
### User
When the user is not logged in, the plugin should display no form fields and warn them that they must log in, with a link to settings.
### Assistant outcome
The Firefox popup now hides capture fields by default for signed-out users, shows a localized sign-in warning, and provides an Open settings action. The form appears only after backend credentials and a valid session are available; expired sessions return to the warning state.
### User
Remove `DEFAULT_BACKEND` setting in the plugin.
### Assistant outcome
Removed the hard-coded backend URL fallback from the Firefox extension. The backend URL is now empty until the user enters it, and the README setup instructions reflect that no default server is assumed.
### User
Change the title of the field “One-time password” to “One-time password (when configured)”.
### Assistant outcome
Updated the OTP field label in the English, Spanish, German, French, and Dutch extension locale catalogs to clarify that the code is needed only when OTP is configured.
### User
Remove any leading and trailing spaces when entering fields in the plugin settings page.
### Assistant outcome
The extension now trims leading and trailing whitespace from the backend URL, username, and OTP before validation, login, and storage. Password input is preserved exactly so intentional password characters are not changed.
+10
View File
@@ -138,6 +138,16 @@
129. Do not show 'no comment provided' but leave empty when no comment has been provided. 129. Do not show 'no comment provided' but leave empty when no comment has been provided.
130. Decrease the space between link-log items. 130. Decrease the space between link-log items.
131. Perform the next items on the todo list. 131. Perform the next items on the todo list.
132. Correct: move the sort and filter toolbar underneath the theme and menu, in the header, on the home page and on the <user> pages.
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.
138. When the user is not logged in then the plugin should just display no form fields but warn the user that they have to log in with a link to settings.
139. Remove DEFAULT_BACKEND setting in the plugin.
140. Change the title of the field "One-time password" to "One-time password (when configured)".
141. Remove any leading and trailing spaces when entering fields in the settings page of the plugin.
## Future entries ## 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.email_verification import verify_email
from backend.app.services.password_reset import create_reset_token, reset_password 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.token_service import issue_token, revoke_token, validate_token
from backend.app.services.otp_service import verify_code
router = APIRouter() router = APIRouter()
@@ -22,6 +23,7 @@ init_db()
class LoginRequest(BaseModel): class LoginRequest(BaseModel):
username: str username: str
password: str password: str
otp: str | None = None
class PasswordResetRequest(BaseModel): class PasswordResetRequest(BaseModel):
@@ -44,6 +46,8 @@ def login(payload: LoginRequest):
raise HTTPException(status_code=401, detail='Invalid username or password') raise HTTPException(status_code=401, detail='Invalid username or password')
if not user['email_verified']: if not user['email_verified']:
raise HTTPException(status_code=403, detail='Email address is not 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']) token_data = issue_token(user['id'], user['username'])
return { return {
@@ -51,7 +55,7 @@ def login(payload: LoginRequest):
'token_type': 'bearer', 'token_type': 'bearer',
'expires_at': token_data['expires_at'], 'expires_at': token_data['expires_at'],
'refresh_token': token_data['refresh_token'], '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 ## Copyright © 2026 Olaf Kolkman
## SPDX-License-Identifier: GPL-3.0-or-later ## SPDX-License-Identifier: GPL-3.0-or-later
import json
from fastapi import APIRouter, Header, HTTPException, status from fastapi import APIRouter, Header, HTTPException, status
import logging import logging
from pydantic import BaseModel 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.database import get_connection
from backend.app.services.plugin_manager import plugin_manager from backend.app.services.plugin_manager import plugin_manager
from backend.app.services.token_service import validate_token from backend.app.services.token_service import validate_token
@@ -87,6 +88,16 @@ def delete_link_endpoint(
info = validate_token(authorization.replace('Bearer ', '', 1)) info = validate_token(authorization.replace('Bearer ', '', 1))
if info is None: if info is None:
raise HTTPException(status_code=401, detail='Token expired or invalid') 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']): if not delete_link(link_id, info['user_id']):
raise HTTPException(status_code=404, detail='Link not found or not owned by user') raise HTTPException(status_code=404, detail='Link not found or not owned by user')
return {'status': 'deleted', 'id': link_id} 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.api.dependencies import get_current_user
from backend.app.database import AVATARS_DIR, get_connection, hash_password 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.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() router = APIRouter()
@@ -24,6 +25,11 @@ class PasswordUpdate(BaseModel):
new_password: str new_password: str
class OtpUpdate(BaseModel):
action: str
code: str | None = None
class UserPluginConfigUpdate(BaseModel): class UserPluginConfigUpdate(BaseModel):
instance: str | None = None instance: str | None = None
access_token: str | None = None access_token: str | None = None
@@ -44,7 +50,10 @@ def get_current_user_profile(user: dict = Depends(get_current_user)):
).fetchone() ).fetchone()
if row is None: if row is None:
raise HTTPException(status_code=404, detail='User not found') 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') @router.put('/me')
@@ -89,6 +98,37 @@ def update_password(payload: PasswordUpdate, user: dict = Depends(get_current_us
return {'status': 'password_updated'} 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') @router.get('/labels')
def get_labels(user: dict = Depends(get_current_user)): def get_labels(user: dict = Depends(get_current_user)):
return list_user_labels(user['id']) 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_posted INTEGER NOT NULL DEFAULT 0;
ALTER TABLE links ADD COLUMN mastodon_post_id TEXT; ALTER TABLE links ADD COLUMN mastodon_post_id TEXT;
ALTER TABLE links ADD COLUMN mastodon_posted_at 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 ## SPDX-License-Identifier: GPL-3.0-or-later
from datetime import datetime, timezone from datetime import datetime, timezone
import json
from uuid import uuid4 from uuid import uuid4
from backend.app.core.security import clean_url 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 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: def mark_mastodon_posted(link_id: str, user_id: str, post_id: str | None) -> bool:
with get_connection() as conn: 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( cursor = conn.execute(
'''UPDATE links '''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 = ?''', WHERE id = ? AND user_id = ?''',
(post_id, link_id, user_id), (post_id, json.dumps(post_ids), link_id, user_id),
) )
conn.commit() conn.commit()
return cursor.rowcount > 0 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), '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: class PluginManager:
def __init__(self): def __init__(self):
@@ -157,5 +197,10 @@ class PluginManager:
return {'status': 'skipped', 'plugin': 'mastodon', 'reason': 'disabled'} return {'status': 'skipped', 'plugin': 'mastodon', 'reason': 'disabled'}
return plugin.handle_event(event) 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() plugin_manager = PluginManager()
+45 -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) removed = client.delete(f'/api/admin/users/{user_id}', headers=headers)
assert removed.status_code == 200 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(): def test_admin_can_toggle_privileges_without_removing_last_admin():
@@ -459,6 +502,7 @@ def test_public_and_admin_pages_render_html():
assert 'id="auth-profile-link" class="hidden"' in root_page.text assert 'id="auth-profile-link" class="hidden"' in root_page.text
assert 'id="auth-admin-link" class="hidden"' in root_page.text assert 'id="auth-admin-link" class="hidden"' in root_page.text
assert '<select id="tag-filter">' in root_page.text assert '<select id="tag-filter">' in root_page.text
assert root_page.text.index('class="site-logo"') < root_page.text.index('<section class="toolbar">')
assert 'logout.js?v=3' in root_page.text assert 'logout.js?v=3' in root_page.text
assert client.get('/alice').status_code == 200 assert client.get('/alice').status_code == 200
assert client.get('/alice/').status_code == 200 assert client.get('/alice/').status_code == 200
+2 -2
View File
@@ -10,7 +10,7 @@ def test_database_migrations_are_versioned_and_idempotent():
connection = sqlite3.connect(':memory:') connection = sqlite3.connect(':memory:')
apply_migrations(connection) apply_migrations(connection)
assert get_schema_version(connection) == 9 assert get_schema_version(connection) == 11
tables = { tables = {
row[0] row[0]
for row in connection.execute( for row in connection.execute(
@@ -27,6 +27,6 @@ def test_database_migrations_are_versioned_and_idempotent():
assert set(DEFAULT_TAGS) <= seeded_tags assert set(DEFAULT_TAGS) <= seeded_tags
apply_migrations(connection) apply_migrations(connection)
assert get_schema_version(connection) == 9 assert get_schema_version(connection) == 11
connection.close() connection.close()
+28
View File
@@ -4,6 +4,7 @@
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from backend.app.main import app from backend.app.main import app
from backend.app.services.otp_service import current_code
client = TestClient(app) 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() updated_profile = client.get('/api/user/me', headers=headers).json()
assert updated_profile['avatar_url'] == avatar_url 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) { 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'; status.style.color = '#b91c1c';
return; return;
} }
+68 -1
View File
@@ -9,6 +9,15 @@ const profileLogoutButton = document.querySelector('#logout-button');
const accessToken = localStorage.getItem('linklogAccessToken'); const accessToken = localStorage.getItem('linklogAccessToken');
const defaultPostPrefix = 'From my #LinkLog: '; const defaultPostPrefix = 'From my #LinkLog: ';
const mastodonConnectButton = document.querySelector('#mastodon-connect'); 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) { function authHeaders(includeJson = false) {
return { return {
@@ -23,6 +32,64 @@ function setStatus(selector, message, isError = false) {
status.style.color = isError ? '#b91c1c' : '#166534'; 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() { async function loadProfile() {
const response = await fetch('/api/user/me', {headers: authHeaders()}); const response = await fetch('/api/user/me', {headers: authHeaders()});
if (!response.ok) throw new Error('Could not load profile'); if (!response.ok) throw new Error('Could not load profile');
@@ -136,7 +203,7 @@ passwordForm.addEventListener('submit', async (event) => {
if (response.ok) passwordForm.reset(); 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); setStatus('#profile-status', accessToken ? error.message : 'Please sign in first.', true);
}); });
})(); })();
+44 -1
View File
@@ -185,7 +185,34 @@ body::selection {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
justify-content: space-between; justify-content: space-between;
gap: 24px; gap: 18px;
}
.header-tools {
display: grid;
flex: 1 1 auto;
justify-items: end;
gap: 8px;
}
.header-tools .toolbar {
width: min(100%, 560px);
gap: 6px;
margin: 0;
padding: 0;
border: 0;
background: transparent;
}
.header-tools .toolbar label {
min-width: 0;
flex: 1 1 110px;
font-size: 0.68rem;
}
.header-tools .toolbar select {
min-width: 0;
padding: 6px 8px;
} }
.header-actions { .header-actions {
@@ -325,6 +352,11 @@ main.container {
border-radius: 12px; border-radius: 12px;
} }
.site-header .toolbar {
margin-top: 18px;
margin-bottom: 0;
}
.toolbar label, .toolbar label,
.settings-panel label { .settings-panel label {
display: grid; display: grid;
@@ -735,9 +767,20 @@ button:disabled {
.header-row { .header-row {
align-items: center; align-items: center;
flex-wrap: wrap;
gap: 12px; gap: 12px;
} }
.header-tools {
order: 3;
flex-basis: 100%;
justify-items: stretch;
}
.header-tools .header-actions {
justify-content: flex-end;
}
.header-actions { .header-actions {
align-items: flex-end; align-items: flex-end;
} }
+27 -26
View File
@@ -16,9 +16,10 @@
<img class="site-logo" src="/static/logo.svg" alt="LinkLog" /> <img class="site-logo" src="/static/logo.svg" alt="LinkLog" />
<p>Public link feed</p> <p>Public link feed</p>
</div> </div>
<div class="header-actions"> <div class="header-tools">
<button class="menu-toggle" type="button" aria-expanded="false" aria-controls="auth-menu">Menu</button> <div class="header-actions">
<nav id="auth-menu" class="auth-menu hidden" aria-label="Account menu"> <button class="menu-toggle" type="button" aria-expanded="false" aria-controls="auth-menu">Menu</button>
<nav id="auth-menu" class="auth-menu hidden" aria-label="Account menu">
<a id="auth-home-link" href="/">Home</a> <a id="auth-home-link" href="/">Home</a>
<a id="auth-about-link" href="/about">About</a> <a id="auth-about-link" href="/about">About</a>
<a id="auth-login-button" href="/login">Sign in</a> <a id="auth-login-button" href="/login">Sign in</a>
@@ -29,7 +30,29 @@
<a id="auth-username" class="user-name" href="/"></a> <a id="auth-username" class="user-name" href="/"></a>
</div> </div>
<button id="logout-button" class="logout-button hidden" type="button">Sign out</button> <button id="logout-button" class="logout-button hidden" type="button">Sign out</button>
</nav> </nav>
</div>
<section class="toolbar">
<label>
Sort
<select id="sort-select">
<option value="newest">Newest first</option>
<option value="oldest">Oldest first</option>
</select>
</label>
<label>
User filter
<select id="user-filter">
<option value="">All users</option>
</select>
</label>
<label>
Tag filter
<select id="tag-filter">
<option value="">All tags</option>
</select>
</label>
</section>
</div> </div>
</div> </div>
</div> </div>
@@ -51,28 +74,6 @@
<p>{{ profile.bio or 'No profile information provided.' }}</p> <p>{{ profile.bio or 'No profile information provided.' }}</p>
</section> </section>
{% endif %} {% endif %}
<section class="toolbar">
<label>
Sort
<select id="sort-select">
<option value="newest">Newest first</option>
<option value="oldest">Oldest first</option>
</select>
</label>
<label>
User filter
<select id="user-filter">
<option value="">All users</option>
</select>
</label>
<label>
Tag filter
<select id="tag-filter">
<option value="">All tags</option>
</select>
</label>
</section>
<section id="feed" class="feed" aria-live="polite"></section> <section id="feed" class="feed" aria-live="polite"></section>
</main> </main>
<footer class="site-footer">Copyright © 2026 Olaf Kolkman · <a href="https://git.kolkman.org/olaf/Link-Log">git.kolkman.org/LinkLog</a></footer> <footer class="site-footer">Copyright © 2026 Olaf Kolkman · <a href="https://git.kolkman.org/olaf/Link-Log">git.kolkman.org/LinkLog</a></footer>
+21
View File
@@ -77,6 +77,27 @@
</form> </form>
</section> </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"> <section class="link-item settings-panel">
<h2>Mastodon</h2> <h2>Mastodon</h2>
<form id="mastodon-form"> <form id="mastodon-form">
+4
View File
@@ -11,11 +11,15 @@
"newTagsPlaceholder": {"message": "#neuer-tag, #anderer-tag"}, "newTagsPlaceholder": {"message": "#neuer-tag, #anderer-tag"},
"saveLink": {"message": "Link speichern"}, "saveLink": {"message": "Link speichern"},
"settings": {"message": "Einstellungen"}, "settings": {"message": "Einstellungen"},
"authRequired": {"message": "Melde dich an, um LinkLog zu verwenden."},
"openSettings": {"message": "Einstellungen öffnen"},
"signOut": {"message": "Abmelden"}, "signOut": {"message": "Abmelden"},
"backendUrlLabel": {"message": "Backend-URL"}, "backendUrlLabel": {"message": "Backend-URL"},
"usernameLabel": {"message": "Benutzername"}, "usernameLabel": {"message": "Benutzername"},
"usernamePlaceholder": {"message": "alice"}, "usernamePlaceholder": {"message": "alice"},
"passwordLabel": {"message": "Passwort"}, "passwordLabel": {"message": "Passwort"},
"otpLabel": {"message": "Einmalpasswort (falls konfiguriert)"},
"otpPlaceholder": {"message": "123456"},
"saveAndLogIn": {"message": "Speichern und anmelden"}, "saveAndLogIn": {"message": "Speichern und anmelden"},
"thisPlugin": {"message": "Dieses Add-on"}, "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."}, "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."},
+12
View File
@@ -35,6 +35,12 @@
"settings": { "settings": {
"message": "Settings" "message": "Settings"
}, },
"authRequired": {
"message": "Please sign in to use LinkLog."
},
"openSettings": {
"message": "Open settings"
},
"signOut": { "signOut": {
"message": "Sign out" "message": "Sign out"
}, },
@@ -50,6 +56,12 @@
"passwordLabel": { "passwordLabel": {
"message": "Password" "message": "Password"
}, },
"otpLabel": {
"message": "One-time password (when configured)"
},
"otpPlaceholder": {
"message": "123456"
},
"saveAndLogIn": { "saveAndLogIn": {
"message": "Save and log in" "message": "Save and log in"
}, },
+4
View File
@@ -11,11 +11,15 @@
"newTagsPlaceholder": {"message": "#nueva-etiqueta, #otra-etiqueta"}, "newTagsPlaceholder": {"message": "#nueva-etiqueta, #otra-etiqueta"},
"saveLink": {"message": "Guardar enlace"}, "saveLink": {"message": "Guardar enlace"},
"settings": {"message": "Configuración"}, "settings": {"message": "Configuración"},
"authRequired": {"message": "Inicia sesión para usar LinkLog."},
"openSettings": {"message": "Abrir configuración"},
"signOut": {"message": "Cerrar sesión"}, "signOut": {"message": "Cerrar sesión"},
"backendUrlLabel": {"message": "URL del servidor"}, "backendUrlLabel": {"message": "URL del servidor"},
"usernameLabel": {"message": "Nombre de usuario"}, "usernameLabel": {"message": "Nombre de usuario"},
"usernamePlaceholder": {"message": "alice"}, "usernamePlaceholder": {"message": "alice"},
"passwordLabel": {"message": "Contraseña"}, "passwordLabel": {"message": "Contraseña"},
"otpLabel": {"message": "Contraseña de un solo uso (cuando está configurada)"},
"otpPlaceholder": {"message": "123456"},
"saveAndLogIn": {"message": "Guardar e iniciar sesión"}, "saveAndLogIn": {"message": "Guardar e iniciar sesión"},
"thisPlugin": {"message": "Este complemento"}, "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."}, "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."},
+4
View File
@@ -11,11 +11,15 @@
"newTagsPlaceholder": {"message": "#nouvelle-étiquette, #autre-étiquette"}, "newTagsPlaceholder": {"message": "#nouvelle-étiquette, #autre-étiquette"},
"saveLink": {"message": "Enregistrer le lien"}, "saveLink": {"message": "Enregistrer le lien"},
"settings": {"message": "Paramètres"}, "settings": {"message": "Paramètres"},
"authRequired": {"message": "Connectez-vous pour utiliser LinkLog."},
"openSettings": {"message": "Ouvrir les paramètres"},
"signOut": {"message": "Se déconnecter"}, "signOut": {"message": "Se déconnecter"},
"backendUrlLabel": {"message": "URL du serveur"}, "backendUrlLabel": {"message": "URL du serveur"},
"usernameLabel": {"message": "Nom d’utilisateur"}, "usernameLabel": {"message": "Nom d’utilisateur"},
"usernamePlaceholder": {"message": "alice"}, "usernamePlaceholder": {"message": "alice"},
"passwordLabel": {"message": "Mot de passe"}, "passwordLabel": {"message": "Mot de passe"},
"otpLabel": {"message": "Mot de passe à usage unique (si configuré)"},
"otpPlaceholder": {"message": "123456"},
"saveAndLogIn": {"message": "Enregistrer et se connecter"}, "saveAndLogIn": {"message": "Enregistrer et se connecter"},
"thisPlugin": {"message": "Cette extension"}, "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."}, "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."},
+4
View File
@@ -11,11 +11,15 @@
"newTagsPlaceholder": {"message": "#nieuwe-tag, #andere-tag"}, "newTagsPlaceholder": {"message": "#nieuwe-tag, #andere-tag"},
"saveLink": {"message": "Koppeling opslaan"}, "saveLink": {"message": "Koppeling opslaan"},
"settings": {"message": "Instellingen"}, "settings": {"message": "Instellingen"},
"authRequired": {"message": "Log in om LinkLog te gebruiken."},
"openSettings": {"message": "Instellingen openen"},
"signOut": {"message": "Uitloggen"}, "signOut": {"message": "Uitloggen"},
"backendUrlLabel": {"message": "Backend-URL"}, "backendUrlLabel": {"message": "Backend-URL"},
"usernameLabel": {"message": "Gebruikersnaam"}, "usernameLabel": {"message": "Gebruikersnaam"},
"usernamePlaceholder": {"message": "alice"}, "usernamePlaceholder": {"message": "alice"},
"passwordLabel": {"message": "Wachtwoord"}, "passwordLabel": {"message": "Wachtwoord"},
"otpLabel": {"message": "Eenmalig wachtwoord (indien geconfigureerd)"},
"otpPlaceholder": {"message": "123456"},
"saveAndLogIn": {"message": "Opslaan en inloggen"}, "saveAndLogIn": {"message": "Opslaan en inloggen"},
"thisPlugin": {"message": "Deze plug-in"}, "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."}, "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" /> <input id="password" type="password" />
</label> </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> <button type="submit" data-i18n="saveAndLogIn">Save and log in</button>
</form> </form>
+5 -4
View File
@@ -1,13 +1,12 @@
// Copyright © 2026 Olaf Kolkman // Copyright © 2026 Olaf Kolkman
// SPDX-License-Identifier: GPL-3.0-or-later // SPDX-License-Identifier: GPL-3.0-or-later
const DEFAULT_BACKEND = 'https://linklog.example.com';
const statusEl = document.getElementById('status'); const statusEl = document.getElementById('status');
const form = document.getElementById('settings-form'); const form = document.getElementById('settings-form');
const backendUrlInput = document.getElementById('backend-url'); const backendUrlInput = document.getElementById('backend-url');
const usernameInput = document.getElementById('username'); const usernameInput = document.getElementById('username');
const passwordInput = document.getElementById('password'); const passwordInput = document.getElementById('password');
const otpInput = document.getElementById('otp');
const session = document.getElementById('logged-in'); const session = document.getElementById('logged-in');
const sessionSummary = document.getElementById('session-summary'); const sessionSummary = document.getElementById('session-summary');
const signOutButton = document.getElementById('sign-out'); const signOutButton = document.getElementById('sign-out');
@@ -23,7 +22,7 @@ function setStatus(message, isError = false) {
async function loadSettings() { async function loadSettings() {
const settings = await browser.storage.local.get(['backendUrl', 'username', 'accessToken']); const settings = await browser.storage.local.get(['backendUrl', 'username', 'accessToken']);
backendUrlInput.value = settings.backendUrl || DEFAULT_BACKEND; backendUrlInput.value = settings.backendUrl || '';
usernameInput.value = settings.username || ''; usernameInput.value = settings.username || '';
if (settings.accessToken && settings.backendUrl) { if (settings.accessToken && settings.backendUrl) {
@@ -64,6 +63,7 @@ async function saveSettingsAndLogin(event) {
const backendUrl = backendUrlInput.value.trim(); const backendUrl = backendUrlInput.value.trim();
const username = usernameInput.value.trim(); const username = usernameInput.value.trim();
const password = passwordInput.value; const password = passwordInput.value;
const otp = otpInput.value.trim();
if (!backendUrl || !username || !password) { if (!backendUrl || !username || !password) {
setStatus(t('fillAllFields'), true); setStatus(t('fillAllFields'), true);
@@ -74,7 +74,7 @@ async function saveSettingsAndLogin(event) {
const response = await fetch(`${backendUrl}/api/auth/login`, { const response = await fetch(`${backendUrl}/api/auth/login`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }) body: JSON.stringify({ username, password, otp: otp || null })
}); });
if (!response.ok) { if (!response.ok) {
@@ -93,6 +93,7 @@ async function saveSettingsAndLogin(event) {
showLoggedIn(data.user?.username || username, backendUrl); showLoggedIn(data.user?.username || username, backendUrl);
passwordInput.value = ''; passwordInput.value = '';
otpInput.value = '';
setStatus(t('loggedInSuccessfully')); setStatus(t('loggedInSuccessfully'));
} catch (error) { } catch (error) {
setStatus(t('unableToLogIn'), true); setStatus(t('unableToLogIn'), true);
+9
View File
@@ -149,6 +149,15 @@ button {
color: #f38ba8; color: #f38ba8;
} }
#auth-warning {
display: grid;
gap: 8px;
}
#auth-warning button {
width: auto;
}
.hidden { .hidden {
display: none; display: none;
} }
+5 -1
View File
@@ -15,8 +15,12 @@
</header> </header>
<div id="status" class="status hidden" aria-live="polite"></div> <div id="status" class="status hidden" aria-live="polite"></div>
<div id="auth-warning" class="status error" aria-live="polite">
<span data-i18n="authRequired">Please sign in to use LinkLog.</span>
<button type="button" id="warning-settings" class="secondary" data-i18n="openSettings">Open settings</button>
</div>
<form id="link-form"> <form id="link-form" class="hidden">
<label> <label>
<span data-i18n="titleLabel">Title</span> <span data-i18n="titleLabel">Title</span>
<input id="title" name="title" type="text" /> <input id="title" name="title" type="text" />
+17 -1
View File
@@ -10,6 +10,8 @@ const openSettingsButton = document.getElementById('open-settings');
const existingTags = document.getElementById('existing-tags'); const existingTags = document.getElementById('existing-tags');
const newTagsInput = document.getElementById('new-tags'); const newTagsInput = document.getElementById('new-tags');
const feedLink = document.getElementById('feed-link'); const feedLink = document.getElementById('feed-link');
const authWarning = document.getElementById('auth-warning');
const warningSettingsButton = document.getElementById('warning-settings');
const t = window.linklogI18n; const t = window.linklogI18n;
@@ -29,6 +31,16 @@ async function getSettings() {
return result; return result;
} }
function showSignedOutState() {
form.classList.add('hidden');
authWarning.classList.remove('hidden');
}
function showSignedInState() {
authWarning.classList.add('hidden');
form.classList.remove('hidden');
}
async function updateFeedLink() { async function updateFeedLink() {
const settings = await browser.storage.local.get(['backendUrl', 'username', 'accessToken']); const settings = await browser.storage.local.get(['backendUrl', 'username', 'accessToken']);
if (!settings.backendUrl || !settings.username || !settings.accessToken) return; if (!settings.backendUrl || !settings.username || !settings.accessToken) return;
@@ -44,13 +56,15 @@ async function updateFeedLink() {
async function loadExistingTags() { async function loadExistingTags() {
const settings = await getSettings(); const settings = await getSettings();
if (!settings.backendUrl || !settings.accessToken) { if (!settings.backendUrl || !settings.accessToken) {
existingTags.textContent = t('signInToSelectTags'); showSignedOutState();
return; return;
} }
showSignedInState();
const response = await fetch(`${settings.backendUrl}/api/tags`, { const response = await fetch(`${settings.backendUrl}/api/tags`, {
headers: {'Authorization': `Bearer ${settings.accessToken}`}, headers: {'Authorization': `Bearer ${settings.accessToken}`},
}); });
if (!response.ok) { if (!response.ok) {
if (response.status === 401) showSignedOutState();
existingTags.textContent = t('loadTagsFailed'); existingTags.textContent = t('loadTagsFailed');
return; return;
} }
@@ -127,6 +141,7 @@ async function handleSubmit(event) {
}); });
if (response.status === 401) { if (response.status === 401) {
showSignedOutState();
setStatus(t('sessionExpired'), true); setStatus(t('sessionExpired'), true);
browser.runtime.openOptionsPage(); browser.runtime.openOptionsPage();
return; return;
@@ -143,6 +158,7 @@ async function handleSubmit(event) {
} }
openSettingsButton.addEventListener('click', () => browser.runtime.openOptionsPage()); openSettingsButton.addEventListener('click', () => browser.runtime.openOptionsPage());
warningSettingsButton.addEventListener('click', () => browser.runtime.openOptionsPage());
form.addEventListener('submit', handleSubmit); form.addEventListener('submit', handleSubmit);
populateCurrentTab(); populateCurrentTab();
loadExistingTags(); loadExistingTags();