Compare commits
5
Commits
4854eb8df6
...
b3383e29a7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3383e29a7 | ||
|
|
d18acf813a | ||
|
|
6f1fbaa5ea | ||
|
|
f503dbaef2 | ||
|
|
80a3a3d541 |
@@ -71,7 +71,7 @@ Open these URLs:
|
||||
- Health check: <http://localhost:8000/health>
|
||||
- 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 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 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
|
||||
@@ -138,7 +140,7 @@ The manifest includes stable Firefox extension metadata and references the packa
|
||||
3. Select **Load Temporary Add-on**.
|
||||
4. Choose `webextension/manifest.json`.
|
||||
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`
|
||||
- Password: `secret123`
|
||||
6. Save the settings and login.
|
||||
|
||||
@@ -310,6 +310,36 @@ Decrease the space between LinkLog items.
|
||||
### 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.
|
||||
|
||||
### 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
|
||||
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
|
||||
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.
|
||||
|
||||
@@ -138,6 +138,16 @@
|
||||
129. Do not show 'no comment provided' but leave empty when no comment has been provided.
|
||||
130. Decrease the space between link-log items.
|
||||
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
|
||||
|
||||
|
||||
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():
|
||||
@@ -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-admin-link" class="hidden"' 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 client.get('/alice').status_code == 200
|
||||
assert client.get('/alice/').status_code == 200
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -185,7 +185,34 @@ body::selection {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
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 {
|
||||
@@ -325,6 +352,11 @@ main.container {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.site-header .toolbar {
|
||||
margin-top: 18px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.toolbar label,
|
||||
.settings-panel label {
|
||||
display: grid;
|
||||
@@ -735,9 +767,20 @@ button:disabled {
|
||||
|
||||
.header-row {
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.header-tools {
|
||||
order: 3;
|
||||
flex-basis: 100%;
|
||||
justify-items: stretch;
|
||||
}
|
||||
|
||||
.header-tools .header-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
<img class="site-logo" src="/static/logo.svg" alt="LinkLog" />
|
||||
<p>Public link feed</p>
|
||||
</div>
|
||||
<div class="header-tools">
|
||||
<div class="header-actions">
|
||||
<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">
|
||||
@@ -31,26 +32,6 @@
|
||||
<button id="logout-button" class="logout-button hidden" type="button">Sign out</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="container">
|
||||
{% if profile %}
|
||||
<section class="link-item profile-summary">
|
||||
<div class="link-header">
|
||||
<div class="avatar">
|
||||
{% if profile.avatar_url %}
|
||||
<img src="{{ profile.avatar_url }}" alt="{{ profile.username }} avatar" />
|
||||
{% else %}
|
||||
{{ profile.username[:1].upper() }}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="user-name">{{ profile.username }}</div>
|
||||
</div>
|
||||
<p>{{ profile.bio or 'No profile information provided.' }}</p>
|
||||
</section>
|
||||
{% endif %}
|
||||
<section class="toolbar">
|
||||
<label>
|
||||
Sort
|
||||
@@ -72,7 +53,27 @@
|
||||
</select>
|
||||
</label>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="container">
|
||||
{% if profile %}
|
||||
<section class="link-item profile-summary">
|
||||
<div class="link-header">
|
||||
<div class="avatar">
|
||||
{% if profile.avatar_url %}
|
||||
<img src="{{ profile.avatar_url }}" alt="{{ profile.username }} avatar" />
|
||||
{% else %}
|
||||
{{ profile.username[:1].upper() }}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="user-name">{{ profile.username }}</div>
|
||||
</div>
|
||||
<p>{{ profile.bio or 'No profile information provided.' }}</p>
|
||||
</section>
|
||||
{% endif %}
|
||||
<section id="feed" class="feed" aria-live="polite"></section>
|
||||
</main>
|
||||
<footer class="site-footer">Copyright © 2026 Olaf Kolkman · <a href="https://git.kolkman.org/olaf/Link-Log">git.kolkman.org/LinkLog</a></footer>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -11,11 +11,15 @@
|
||||
"newTagsPlaceholder": {"message": "#neuer-tag, #anderer-tag"},
|
||||
"saveLink": {"message": "Link speichern"},
|
||||
"settings": {"message": "Einstellungen"},
|
||||
"authRequired": {"message": "Melde dich an, um LinkLog zu verwenden."},
|
||||
"openSettings": {"message": "Einstellungen öffnen"},
|
||||
"signOut": {"message": "Abmelden"},
|
||||
"backendUrlLabel": {"message": "Backend-URL"},
|
||||
"usernameLabel": {"message": "Benutzername"},
|
||||
"usernamePlaceholder": {"message": "alice"},
|
||||
"passwordLabel": {"message": "Passwort"},
|
||||
"otpLabel": {"message": "Einmalpasswort (falls konfiguriert)"},
|
||||
"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."},
|
||||
|
||||
@@ -35,6 +35,12 @@
|
||||
"settings": {
|
||||
"message": "Settings"
|
||||
},
|
||||
"authRequired": {
|
||||
"message": "Please sign in to use LinkLog."
|
||||
},
|
||||
"openSettings": {
|
||||
"message": "Open settings"
|
||||
},
|
||||
"signOut": {
|
||||
"message": "Sign out"
|
||||
},
|
||||
@@ -50,6 +56,12 @@
|
||||
"passwordLabel": {
|
||||
"message": "Password"
|
||||
},
|
||||
"otpLabel": {
|
||||
"message": "One-time password (when configured)"
|
||||
},
|
||||
"otpPlaceholder": {
|
||||
"message": "123456"
|
||||
},
|
||||
"saveAndLogIn": {
|
||||
"message": "Save and log in"
|
||||
},
|
||||
|
||||
@@ -11,11 +11,15 @@
|
||||
"newTagsPlaceholder": {"message": "#nueva-etiqueta, #otra-etiqueta"},
|
||||
"saveLink": {"message": "Guardar enlace"},
|
||||
"settings": {"message": "Configuración"},
|
||||
"authRequired": {"message": "Inicia sesión para usar LinkLog."},
|
||||
"openSettings": {"message": "Abrir configuración"},
|
||||
"signOut": {"message": "Cerrar sesión"},
|
||||
"backendUrlLabel": {"message": "URL del servidor"},
|
||||
"usernameLabel": {"message": "Nombre de usuario"},
|
||||
"usernamePlaceholder": {"message": "alice"},
|
||||
"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"},
|
||||
"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."},
|
||||
|
||||
@@ -11,11 +11,15 @@
|
||||
"newTagsPlaceholder": {"message": "#nouvelle-étiquette, #autre-étiquette"},
|
||||
"saveLink": {"message": "Enregistrer le lien"},
|
||||
"settings": {"message": "Paramètres"},
|
||||
"authRequired": {"message": "Connectez-vous pour utiliser LinkLog."},
|
||||
"openSettings": {"message": "Ouvrir les paramètres"},
|
||||
"signOut": {"message": "Se déconnecter"},
|
||||
"backendUrlLabel": {"message": "URL du serveur"},
|
||||
"usernameLabel": {"message": "Nom d’utilisateur"},
|
||||
"usernamePlaceholder": {"message": "alice"},
|
||||
"passwordLabel": {"message": "Mot de passe"},
|
||||
"otpLabel": {"message": "Mot de passe à usage unique (si configuré)"},
|
||||
"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."},
|
||||
|
||||
@@ -11,11 +11,15 @@
|
||||
"newTagsPlaceholder": {"message": "#nieuwe-tag, #andere-tag"},
|
||||
"saveLink": {"message": "Koppeling opslaan"},
|
||||
"settings": {"message": "Instellingen"},
|
||||
"authRequired": {"message": "Log in om LinkLog te gebruiken."},
|
||||
"openSettings": {"message": "Instellingen openen"},
|
||||
"signOut": {"message": "Uitloggen"},
|
||||
"backendUrlLabel": {"message": "Backend-URL"},
|
||||
"usernameLabel": {"message": "Gebruikersnaam"},
|
||||
"usernamePlaceholder": {"message": "alice"},
|
||||
"passwordLabel": {"message": "Wachtwoord"},
|
||||
"otpLabel": {"message": "Eenmalig wachtwoord (indien geconfigureerd)"},
|
||||
"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>
|
||||
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
// Copyright © 2026 Olaf Kolkman
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
const DEFAULT_BACKEND = 'https://linklog.example.com';
|
||||
|
||||
const statusEl = document.getElementById('status');
|
||||
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');
|
||||
@@ -23,7 +22,7 @@ function setStatus(message, isError = false) {
|
||||
|
||||
async function loadSettings() {
|
||||
const settings = await browser.storage.local.get(['backendUrl', 'username', 'accessToken']);
|
||||
backendUrlInput.value = settings.backendUrl || DEFAULT_BACKEND;
|
||||
backendUrlInput.value = settings.backendUrl || '';
|
||||
usernameInput.value = settings.username || '';
|
||||
|
||||
if (settings.accessToken && settings.backendUrl) {
|
||||
@@ -64,6 +63,7 @@ async function saveSettingsAndLogin(event) {
|
||||
const backendUrl = backendUrlInput.value.trim();
|
||||
const username = usernameInput.value.trim();
|
||||
const password = passwordInput.value;
|
||||
const otp = otpInput.value.trim();
|
||||
|
||||
if (!backendUrl || !username || !password) {
|
||||
setStatus(t('fillAllFields'), true);
|
||||
@@ -74,7 +74,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: otp || null })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -93,6 +93,7 @@ async function saveSettingsAndLogin(event) {
|
||||
|
||||
showLoggedIn(data.user?.username || username, backendUrl);
|
||||
passwordInput.value = '';
|
||||
otpInput.value = '';
|
||||
setStatus(t('loggedInSuccessfully'));
|
||||
} catch (error) {
|
||||
setStatus(t('unableToLogIn'), true);
|
||||
|
||||
@@ -149,6 +149,15 @@ button {
|
||||
color: #f38ba8;
|
||||
}
|
||||
|
||||
#auth-warning {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
#auth-warning button {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -15,8 +15,12 @@
|
||||
</header>
|
||||
|
||||
<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>
|
||||
<span data-i18n="titleLabel">Title</span>
|
||||
<input id="title" name="title" type="text" />
|
||||
|
||||
+17
-1
@@ -10,6 +10,8 @@ const openSettingsButton = document.getElementById('open-settings');
|
||||
const existingTags = document.getElementById('existing-tags');
|
||||
const newTagsInput = document.getElementById('new-tags');
|
||||
const feedLink = document.getElementById('feed-link');
|
||||
const authWarning = document.getElementById('auth-warning');
|
||||
const warningSettingsButton = document.getElementById('warning-settings');
|
||||
|
||||
const t = window.linklogI18n;
|
||||
|
||||
@@ -29,6 +31,16 @@ async function getSettings() {
|
||||
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() {
|
||||
const settings = await browser.storage.local.get(['backendUrl', 'username', 'accessToken']);
|
||||
if (!settings.backendUrl || !settings.username || !settings.accessToken) return;
|
||||
@@ -44,13 +56,15 @@ async function updateFeedLink() {
|
||||
async function loadExistingTags() {
|
||||
const settings = await getSettings();
|
||||
if (!settings.backendUrl || !settings.accessToken) {
|
||||
existingTags.textContent = t('signInToSelectTags');
|
||||
showSignedOutState();
|
||||
return;
|
||||
}
|
||||
showSignedInState();
|
||||
const response = await fetch(`${settings.backendUrl}/api/tags`, {
|
||||
headers: {'Authorization': `Bearer ${settings.accessToken}`},
|
||||
});
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) showSignedOutState();
|
||||
existingTags.textContent = t('loadTagsFailed');
|
||||
return;
|
||||
}
|
||||
@@ -127,6 +141,7 @@ async function handleSubmit(event) {
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
showSignedOutState();
|
||||
setStatus(t('sessionExpired'), true);
|
||||
browser.runtime.openOptionsPage();
|
||||
return;
|
||||
@@ -143,6 +158,7 @@ async function handleSubmit(event) {
|
||||
}
|
||||
|
||||
openSettingsButton.addEventListener('click', () => browser.runtime.openOptionsPage());
|
||||
warningSettingsButton.addEventListener('click', () => browser.runtime.openOptionsPage());
|
||||
form.addEventListener('submit', handleSubmit);
|
||||
populateCurrentTab();
|
||||
loadExistingTags();
|
||||
|
||||
Reference in New Issue
Block a user