Initial config with email service test
Build LinkLog Development Image / development-image (push) Successful in 11s
Build LinkLog Development Image / development-image (push) Successful in 11s
This commit is contained in:
@@ -19,6 +19,7 @@ LINKLOG_SMTP_PASSWORD=
|
|||||||
LINKLOG_SMTP_FROM=LinkLog <no-reply@localhost>
|
LINKLOG_SMTP_FROM=LinkLog <no-reply@localhost>
|
||||||
LINKLOG_SMTP_USE_TLS=true
|
LINKLOG_SMTP_USE_TLS=true
|
||||||
LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS=24
|
LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS=24
|
||||||
|
LINKLOG_PASSWORD_RESET_EXPIRY_HOURS=1
|
||||||
# Optional comma-separated override. Leave empty to use the built-in list.
|
# Optional comma-separated override. Leave empty to use the built-in list.
|
||||||
LINKLOG_TRACKING_PARAMS=
|
LINKLOG_TRACKING_PARAMS=
|
||||||
# Keep the default path when using the named linklog_data volume.
|
# Keep the default path when using the named linklog_data volume.
|
||||||
|
|||||||
@@ -183,12 +183,17 @@ The main configurable values are:
|
|||||||
| `LINKLOG_SMTP_FROM` | Sender address for verification mail | `LinkLog <no-reply@localhost>` |
|
| `LINKLOG_SMTP_FROM` | Sender address for verification mail | `LinkLog <no-reply@localhost>` |
|
||||||
| `LINKLOG_SMTP_USE_TLS` | Use STARTTLS for SMTP | `true` |
|
| `LINKLOG_SMTP_USE_TLS` | Use STARTTLS for SMTP | `true` |
|
||||||
| `LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS` | Verification-link lifetime | `24` |
|
| `LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS` | Verification-link lifetime | `24` |
|
||||||
|
| `LINKLOG_PASSWORD_RESET_EXPIRY_HOURS` | Password-reset-link lifetime | `1` |
|
||||||
| `LINKLOG_TRACKING_PARAMS` | comma-separated tracking parameters (stripped from logged URLs) | built-in list |
|
| `LINKLOG_TRACKING_PARAMS` | comma-separated tracking parameters (stripped from logged URLs) | built-in list |
|
||||||
| `TRAEFIK_HOST` | hostname routed by Traefik | `localhost` |
|
| `TRAEFIK_HOST` | hostname routed by Traefik | `localhost` |
|
||||||
| `APP_PORT` | direct host port for FastAPI | `8000` |
|
| `APP_PORT` | direct host port for FastAPI | `8000` |
|
||||||
|
|
||||||
New users created by an administrator are email-unverified and cannot sign in until they follow the verification link sent to their address. The link is valid for `LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS` hours and is handled by `/api/auth/verify-email`. Configure `LINKLOG_SMTP_HOST`, `LINKLOG_SMTP_FROM`, and the SMTP credentials for delivery; local development may leave the SMTP host empty, in which case accounts remain pending verification and no message is sent.
|
New users created by an administrator are email-unverified and cannot sign in until they follow the verification link sent to their address. The link is valid for `LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS` hours and is handled by `/api/auth/verify-email`. Configure `LINKLOG_SMTP_HOST`, `LINKLOG_SMTP_FROM`, and the SMTP credentials for delivery; local development may leave the SMTP host empty, in which case accounts remain pending verification and no message is sent.
|
||||||
|
|
||||||
|
On a fresh installation, the setup form is prefilled from the `LINKLOG_SMTP_*` environment values when available. After saving, the values stored in the database are used for subsequent setup-page loads and mail delivery.
|
||||||
|
|
||||||
|
When a verified user enters the wrong password, LinkLog keeps the response generic and sends a password-reset link to that account's email address when SMTP is configured. Reset links expire after `LINKLOG_PASSWORD_RESET_EXPIRY_HOURS` hours, can be used once, and revoke existing sessions after the password is changed.
|
||||||
|
|
||||||
The full set of supported variables is listed in `.env.example`. Application variables are passed into the container by Compose; Docker and Traefik variables are used by Compose itself.
|
The full set of supported variables is listed in `.env.example`. Application variables are passed into the container by Compose; Docker and Traefik variables are used by Compose itself.
|
||||||
|
|
||||||
Build and start the application:
|
Build and start the application:
|
||||||
|
|||||||
+26
-1
@@ -7,8 +7,11 @@ from fastapi import APIRouter, HTTPException
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from backend.app.database import get_connection, init_db
|
from backend.app.database import get_connection, init_db
|
||||||
from backend.app.services.auth_service import authenticate_user
|
from backend.app.core.config import settings
|
||||||
|
from backend.app.services.auth_service import authenticate_user, find_user
|
||||||
|
from backend.app.services.email_service import send_password_reset_email, smtp_configured
|
||||||
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.token_service import issue_token, revoke_token, validate_token
|
from backend.app.services.token_service import issue_token, revoke_token, validate_token
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -21,10 +24,23 @@ class LoginRequest(BaseModel):
|
|||||||
password: str
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordResetRequest(BaseModel):
|
||||||
|
token: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
@router.post('/login')
|
@router.post('/login')
|
||||||
def login(payload: LoginRequest):
|
def login(payload: LoginRequest):
|
||||||
user = authenticate_user(payload.username, payload.password)
|
user = authenticate_user(payload.username, payload.password)
|
||||||
if user is None:
|
if user is None:
|
||||||
|
reset_user = find_user(payload.username)
|
||||||
|
if reset_user and reset_user['email_verified'] and smtp_configured():
|
||||||
|
try:
|
||||||
|
token = create_reset_token(reset_user['id'])
|
||||||
|
reset_url = f'{settings.public_url}/reset-password?token={token}'
|
||||||
|
send_password_reset_email(reset_user['email'], reset_user['username'], reset_url)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
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')
|
||||||
@@ -46,6 +62,15 @@ def verify_email_address(token: str):
|
|||||||
return {'status': 'verified', 'message': 'Email address verified. You can now sign in.'}
|
return {'status': 'verified', 'message': 'Email address verified. You can now sign in.'}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post('/reset-password')
|
||||||
|
def reset_password_endpoint(payload: PasswordResetRequest):
|
||||||
|
if len(payload.password) < 8:
|
||||||
|
raise HTTPException(status_code=422, detail='Password must contain at least 8 characters')
|
||||||
|
if not reset_password(payload.token, payload.password):
|
||||||
|
raise HTTPException(status_code=400, detail='Password reset link is invalid or expired')
|
||||||
|
return {'status': 'password_reset', 'message': 'Password reset. You can now sign in.'}
|
||||||
|
|
||||||
|
|
||||||
@router.post('/logout')
|
@router.post('/logout')
|
||||||
def logout(payload: dict):
|
def logout(payload: dict):
|
||||||
token = payload.get('token')
|
token = payload.get('token')
|
||||||
|
|||||||
+128
-17
@@ -1,6 +1,8 @@
|
|||||||
## Copyright © 2026 Olaf Kolkman
|
## Copyright © 2026 Olaf Kolkman
|
||||||
## SPDX-License-Identifier: GPL-3.0-or-later
|
## SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
import json
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
@@ -8,7 +10,7 @@ from pydantic import BaseModel
|
|||||||
|
|
||||||
from backend.app.core.config import settings
|
from backend.app.core.config import settings
|
||||||
from backend.app.database import get_connection, hash_password
|
from backend.app.database import get_connection, hash_password
|
||||||
from backend.app.services.email_service import save_smtp_settings, send_test_email
|
from backend.app.services.email_service import get_smtp_settings, save_smtp_settings, send_test_email
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -25,13 +27,41 @@ class SetupRequest(BaseModel):
|
|||||||
smtp_use_tls: bool = True
|
smtp_use_tls: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class TestMailRequest(BaseModel):
|
||||||
|
email: str | None = None
|
||||||
|
|
||||||
|
|
||||||
def has_administrator() -> bool:
|
def has_administrator() -> bool:
|
||||||
with get_connection() as conn:
|
with get_connection() as conn:
|
||||||
return conn.execute('SELECT 1 FROM users WHERE is_admin = 1 LIMIT 1').fetchone() is not None
|
return conn.execute('SELECT 1 FROM users WHERE is_admin = 1 LIMIT 1').fetchone() is not None
|
||||||
|
|
||||||
|
|
||||||
@router.post('', status_code=201)
|
def get_pending_setup() -> dict | None:
|
||||||
def configure_application(payload: SetupRequest):
|
with get_connection() as conn:
|
||||||
|
row = conn.execute('SELECT value FROM app_settings WHERE name = ?', ('setup_pending',)).fetchone()
|
||||||
|
return json.loads(row['value']) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def save_pending_setup(values: dict) -> None:
|
||||||
|
with get_connection() as conn:
|
||||||
|
conn.execute(
|
||||||
|
'''INSERT INTO app_settings (name, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT(name) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP''',
|
||||||
|
('setup_pending', json.dumps(values)),
|
||||||
|
)
|
||||||
|
conn.execute('DELETE FROM app_settings WHERE name = ?', ('setup_mail_rate',))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def delete_pending_setup() -> None:
|
||||||
|
with get_connection() as conn:
|
||||||
|
conn.execute('DELETE FROM app_settings WHERE name = ?', ('setup_pending',))
|
||||||
|
conn.execute('DELETE FROM app_settings WHERE name = ?', ('setup_mail_rate',))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post('/configuration', status_code=200)
|
||||||
|
def save_configuration(payload: SetupRequest):
|
||||||
if has_administrator():
|
if has_administrator():
|
||||||
raise HTTPException(status_code=409, detail='LinkLog is already configured')
|
raise HTTPException(status_code=409, detail='LinkLog is already configured')
|
||||||
username = payload.username.strip()
|
username = payload.username.strip()
|
||||||
@@ -43,7 +73,6 @@ def configure_application(payload: SetupRequest):
|
|||||||
if not 1 <= payload.smtp_port <= 65535:
|
if not 1 <= payload.smtp_port <= 65535:
|
||||||
raise HTTPException(status_code=422, detail='SMTP port must be between 1 and 65535')
|
raise HTTPException(status_code=422, detail='SMTP port must be between 1 and 65535')
|
||||||
|
|
||||||
user_id = str(uuid4())
|
|
||||||
smtp_values = {
|
smtp_values = {
|
||||||
'smtp_host': smtp_host,
|
'smtp_host': smtp_host,
|
||||||
'smtp_port': payload.smtp_port,
|
'smtp_port': payload.smtp_port,
|
||||||
@@ -52,29 +81,111 @@ def configure_application(payload: SetupRequest):
|
|||||||
'smtp_from': smtp_from,
|
'smtp_from': smtp_from,
|
||||||
'smtp_use_tls': payload.smtp_use_tls,
|
'smtp_use_tls': payload.smtp_use_tls,
|
||||||
}
|
}
|
||||||
|
pending = {
|
||||||
|
'username': username,
|
||||||
|
'email': email,
|
||||||
|
'password_hash': hash_password(payload.password),
|
||||||
|
}
|
||||||
|
save_pending_setup(pending)
|
||||||
|
save_smtp_settings(smtp_values)
|
||||||
|
return {'status': 'saved', 'message': 'Configuration saved. Send a test mail to verify SMTP delivery.'}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post('/test-mail')
|
||||||
|
def test_mail(payload: TestMailRequest | None = None):
|
||||||
|
if has_administrator():
|
||||||
|
raise HTTPException(status_code=409, detail='LinkLog is already configured')
|
||||||
|
pending = get_pending_setup()
|
||||||
|
if pending is None:
|
||||||
|
raise HTTPException(status_code=400, detail='Save the configuration before sending test mail')
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
with get_connection() as conn:
|
||||||
|
row = conn.execute('SELECT value FROM app_settings WHERE name = ?', ('setup_mail_rate',)).fetchone()
|
||||||
|
rate = json.loads(row['value']) if row else {}
|
||||||
|
last_sent = datetime.fromisoformat(rate['last_sent']) if rate.get('last_sent') else None
|
||||||
|
cooldown_until = datetime.fromisoformat(rate['cooldown_until']) if rate.get('cooldown_until') else None
|
||||||
|
if cooldown_until and now >= cooldown_until:
|
||||||
|
rate = {}
|
||||||
|
last_sent = None
|
||||||
|
cooldown_until = None
|
||||||
|
if cooldown_until and now < cooldown_until:
|
||||||
|
retry_after = int((cooldown_until - now).total_seconds()) + 1
|
||||||
|
raise HTTPException(status_code=429, detail=f'Test mail limit reached. Try again in {retry_after} seconds.', headers={'Retry-After': str(retry_after)})
|
||||||
|
if last_sent and now - last_sent < timedelta(seconds=20):
|
||||||
|
retry_after = int((timedelta(seconds=20) - (now - last_sent)).total_seconds()) + 1
|
||||||
|
raise HTTPException(status_code=429, detail=f'Please wait {retry_after} seconds before sending another test mail.', headers={'Retry-After': str(retry_after)})
|
||||||
|
|
||||||
|
try:
|
||||||
|
send_test_email(payload.email if payload and payload.email else pending['email'])
|
||||||
|
except Exception as error:
|
||||||
|
raise HTTPException(status_code=503, detail=f'SMTP test mail could not be sent: {error}') from error
|
||||||
|
|
||||||
|
sends = int(rate.get('sends', 0)) + 1
|
||||||
|
updated_rate = {'sends': sends, 'last_sent': now.isoformat()}
|
||||||
|
if sends >= 5:
|
||||||
|
updated_rate['cooldown_until'] = (now + timedelta(minutes=2)).isoformat()
|
||||||
|
with get_connection() as conn:
|
||||||
|
conn.execute(
|
||||||
|
'''INSERT INTO app_settings (name, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT(name) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP''',
|
||||||
|
('setup_mail_rate', json.dumps(updated_rate)),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
next_allowed = datetime.fromisoformat(updated_rate.get('cooldown_until')) if sends >= 5 else now + timedelta(seconds=20)
|
||||||
|
return {
|
||||||
|
'status': 'sent',
|
||||||
|
'message': 'SMTP test mail sent.',
|
||||||
|
'sends_remaining': max(0, 5 - sends),
|
||||||
|
'cooldown_seconds': 120 if sends >= 5 else 0,
|
||||||
|
'next_allowed_at': next_allowed.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post('/complete', status_code=201)
|
||||||
|
def complete_setup():
|
||||||
|
if has_administrator():
|
||||||
|
raise HTTPException(status_code=409, detail='LinkLog is already configured')
|
||||||
|
pending = get_pending_setup()
|
||||||
|
if pending is None:
|
||||||
|
raise HTTPException(status_code=400, detail='Save the configuration before completing setup')
|
||||||
|
with get_connection() as conn:
|
||||||
|
row = conn.execute('SELECT value FROM app_settings WHERE name = ?', ('setup_mail_rate',)).fetchone()
|
||||||
|
if row is None or int(json.loads(row['value']).get('sends', 0)) < 1:
|
||||||
|
raise HTTPException(status_code=400, detail='Send a successful test mail before completing setup')
|
||||||
|
user_id = str(uuid4())
|
||||||
with get_connection() as conn:
|
with get_connection() as conn:
|
||||||
try:
|
try:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
'''INSERT INTO users
|
'''INSERT INTO users
|
||||||
(id, username, email, password_hash, is_admin, email_verified)
|
(id, username, email, password_hash, is_admin, email_verified)
|
||||||
VALUES (?, ?, ?, ?, 1, 1)''',
|
VALUES (?, ?, ?, ?, 1, 1)''',
|
||||||
(user_id, username, email, hash_password(payload.password)),
|
(user_id, pending['username'], pending['email'], pending['password_hash']),
|
||||||
)
|
)
|
||||||
|
conn.commit()
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
raise HTTPException(status_code=409, detail='Username or email already exists') from error
|
raise HTTPException(status_code=409, detail='Username or email already exists') from error
|
||||||
conn.commit()
|
delete_pending_setup()
|
||||||
save_smtp_settings(smtp_values)
|
return {'status': 'configured', 'message': 'LinkLog is configured.'}
|
||||||
try:
|
|
||||||
send_test_email(email)
|
|
||||||
except Exception as error:
|
|
||||||
with get_connection() as conn:
|
|
||||||
conn.execute('DELETE FROM users WHERE id = ?', (user_id,))
|
|
||||||
conn.execute('DELETE FROM app_settings WHERE name = ?', ('smtp',))
|
|
||||||
conn.commit()
|
|
||||||
raise HTTPException(status_code=503, detail=f'SMTP test mail could not be sent: {error}') from error
|
|
||||||
return {'status': 'configured', 'message': 'LinkLog is configured and the SMTP test mail was sent.'}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get('/status')
|
@router.get('/status')
|
||||||
def setup_status():
|
def setup_status():
|
||||||
return {'configured': has_administrator()}
|
rate = {}
|
||||||
|
with get_connection() as conn:
|
||||||
|
row = conn.execute('SELECT value FROM app_settings WHERE name = ?', ('setup_mail_rate',)).fetchone()
|
||||||
|
if row:
|
||||||
|
rate = json.loads(row['value'])
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
cooldown_until = datetime.fromisoformat(rate['cooldown_until']) if rate.get('cooldown_until') else None
|
||||||
|
last_sent = datetime.fromisoformat(rate['last_sent']) if rate.get('last_sent') else None
|
||||||
|
next_allowed = cooldown_until if cooldown_until and cooldown_until > now else (
|
||||||
|
last_sent + timedelta(seconds=20) if last_sent else None
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
'configured': has_administrator(),
|
||||||
|
'pending': get_pending_setup() is not None,
|
||||||
|
'smtp_defaults': get_smtp_settings(),
|
||||||
|
'sends_remaining': max(0, 5 - int(rate.get('sends', 0))),
|
||||||
|
'next_allowed_at': next_allowed.isoformat() if next_allowed and next_allowed > now else None,
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ class Settings:
|
|||||||
smtp_from: str = os.getenv('LINKLOG_SMTP_FROM', 'LinkLog <no-reply@localhost>')
|
smtp_from: str = os.getenv('LINKLOG_SMTP_FROM', 'LinkLog <no-reply@localhost>')
|
||||||
smtp_use_tls: bool = os.getenv('LINKLOG_SMTP_USE_TLS', 'true').lower() in {'1', 'true', 'yes'}
|
smtp_use_tls: bool = os.getenv('LINKLOG_SMTP_USE_TLS', 'true').lower() in {'1', 'true', 'yes'}
|
||||||
email_verification_expiry_hours: int = int(os.getenv('LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS', '24'))
|
email_verification_expiry_hours: int = int(os.getenv('LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS', '24'))
|
||||||
|
password_reset_expiry_hours: int = int(os.getenv('LINKLOG_PASSWORD_RESET_EXPIRY_HOURS', '1'))
|
||||||
tracking_params: list[str] = None
|
tracking_params: list[str] = None
|
||||||
|
|
||||||
def __post_init__(self):
|
def __post_init__(self):
|
||||||
|
|||||||
@@ -123,6 +123,18 @@ CREATE TABLE IF NOT EXISTS app_settings (
|
|||||||
value TEXT NOT NULL,
|
value TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
'''),
|
||||||
|
(7, '''
|
||||||
|
CREATE TABLE IF NOT EXISTS password_reset_tokens (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
token_hash TEXT NOT NULL UNIQUE,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_user_id
|
||||||
|
ON password_reset_tokens(user_id);
|
||||||
'''),
|
'''),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,11 @@ async def login_page(request: Request):
|
|||||||
return templates.TemplateResponse(request, 'login.html', {})
|
return templates.TemplateResponse(request, 'login.html', {})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get('/reset-password', response_class=HTMLResponse)
|
||||||
|
async def reset_password_page(request: Request):
|
||||||
|
return templates.TemplateResponse(request, 'reset-password.html', {})
|
||||||
|
|
||||||
|
|
||||||
@app.get('/setup', response_class=HTMLResponse)
|
@app.get('/setup', response_class=HTMLResponse)
|
||||||
async def setup_page(request: Request):
|
async def setup_page(request: Request):
|
||||||
if has_administrator():
|
if has_administrator():
|
||||||
|
|||||||
@@ -19,3 +19,9 @@ def authenticate_user(username: str, password: str):
|
|||||||
(username, password_hash),
|
(username, password_hash),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
return dict(row) if row else None
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def find_user(username: str):
|
||||||
|
with get_connection() as conn:
|
||||||
|
row = conn.execute('SELECT * FROM users WHERE username = ?', (username,)).fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|||||||
@@ -71,3 +71,13 @@ def send_verification_email(email: str, username: str, verification_url: str) ->
|
|||||||
|
|
||||||
def send_test_email(email: str) -> None:
|
def send_test_email(email: str) -> None:
|
||||||
send_message(email, 'LinkLog SMTP test', 'This is a test message from LinkLog. SMTP is configured correctly.\n')
|
send_message(email, 'LinkLog SMTP test', 'This is a test message from LinkLog. SMTP is configured correctly.\n')
|
||||||
|
|
||||||
|
|
||||||
|
def send_password_reset_email(email: str, username: str, reset_url: str) -> None:
|
||||||
|
send_message(
|
||||||
|
email,
|
||||||
|
'Reset your LinkLog password',
|
||||||
|
f'Hello {username},\n\n'
|
||||||
|
f'Reset your LinkLog password by opening this link:\n{reset_url}\n\n'
|
||||||
|
f'This link expires in {settings.password_reset_expiry_hours} hours.\n',
|
||||||
|
)
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
## Copyright © 2026 Olaf Kolkman
|
||||||
|
## SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from hashlib import sha256
|
||||||
|
from secrets import token_urlsafe
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from backend.app.core.config import settings
|
||||||
|
from backend.app.database import get_connection, hash_password
|
||||||
|
|
||||||
|
|
||||||
|
def hash_reset_token(token: str) -> str:
|
||||||
|
return sha256(token.encode('utf-8')).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def create_reset_token(user_id: str) -> str:
|
||||||
|
token = token_urlsafe(32)
|
||||||
|
expires_at = datetime.now(timezone.utc) + timedelta(hours=settings.password_reset_expiry_hours)
|
||||||
|
with get_connection() as conn:
|
||||||
|
conn.execute('DELETE FROM password_reset_tokens WHERE user_id = ?', (user_id,))
|
||||||
|
conn.execute(
|
||||||
|
'''INSERT INTO password_reset_tokens
|
||||||
|
(id, user_id, token_hash, expires_at) VALUES (?, ?, ?, ?)''',
|
||||||
|
(str(uuid4()), user_id, hash_reset_token(token), expires_at.isoformat()),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def reset_password(token: str, password: str) -> bool:
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
with get_connection() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
'''SELECT user_id FROM password_reset_tokens
|
||||||
|
WHERE token_hash = ? AND expires_at > ?''',
|
||||||
|
(hash_reset_token(token), now),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return False
|
||||||
|
conn.execute(
|
||||||
|
'UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||||
|
(hash_password(password), row['user_id']),
|
||||||
|
)
|
||||||
|
conn.execute('DELETE FROM password_reset_tokens WHERE user_id = ?', (row['user_id'],))
|
||||||
|
conn.execute('DELETE FROM tokens WHERE user_id = ?', (row['user_id'],))
|
||||||
|
conn.commit()
|
||||||
|
return True
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
## Copyright © 2026 Olaf Kolkman
|
||||||
|
## SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
TEST_DATABASE_DIRECTORY = tempfile.TemporaryDirectory(prefix='linklog-tests-')
|
||||||
|
TEST_DATABASE_PATH = os.path.join(TEST_DATABASE_DIRECTORY.name, 'linklog.db')
|
||||||
|
os.environ['LINKLOG_DATABASE_PATH'] = TEST_DATABASE_PATH
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope='session', autouse=True)
|
||||||
|
def test_users():
|
||||||
|
from backend.app.database import get_connection, hash_password, init_db
|
||||||
|
|
||||||
|
init_db()
|
||||||
|
with get_connection() as conn:
|
||||||
|
conn.executemany(
|
||||||
|
'''INSERT INTO users
|
||||||
|
(id, username, email, password_hash, is_admin, email_verified)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 1)''',
|
||||||
|
[
|
||||||
|
('user-1', 'alice', 'alice@example.com', hash_password('secret123'), 1),
|
||||||
|
('user-2', 'bob', 'bob@example.com', hash_password('secret123'), 0),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
yield
|
||||||
|
TEST_DATABASE_DIRECTORY.cleanup()
|
||||||
@@ -5,10 +5,13 @@ import json
|
|||||||
import threading
|
import threading
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
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.database import get_connection
|
||||||
|
from backend.app.services.password_reset import create_reset_token
|
||||||
from backend.app.services.token_service import issue_token
|
from backend.app.services.token_service import issue_token
|
||||||
|
|
||||||
|
|
||||||
@@ -110,6 +113,49 @@ def test_email_verification_link_enables_login():
|
|||||||
assert client.get('/api/auth/verify-email', params={'token': verification_token}).status_code == 400
|
assert client.get('/api/auth/verify-email', params={'token': verification_token}).status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_mistyped_password_sends_reset_link_without_changing_login_error():
|
||||||
|
username = f'mistyped-{uuid4().hex}'
|
||||||
|
admin_headers = {'Authorization': f"Bearer {issue_token('user-1', 'alice')['access_token']}"}
|
||||||
|
created = client.post('/api/admin/users', headers=admin_headers, json={
|
||||||
|
'username': username,
|
||||||
|
'email': f'{username}@example.com',
|
||||||
|
'password': 'secret123',
|
||||||
|
})
|
||||||
|
user_id = created.json()['id']
|
||||||
|
with get_connection() as conn:
|
||||||
|
conn.execute('UPDATE users SET email_verified = 1 WHERE id = ?', (user_id,))
|
||||||
|
conn.commit()
|
||||||
|
with patch('backend.app.api.auth.smtp_configured', return_value=True), \
|
||||||
|
patch('backend.app.api.auth.create_reset_token', return_value='reset-token') as create_token, \
|
||||||
|
patch('backend.app.api.auth.send_password_reset_email') as send_email:
|
||||||
|
response = client.post('/api/auth/login', json={'username': username, 'password': 'wrong-password'})
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert response.json()['detail'] == 'Invalid username or password'
|
||||||
|
create_token.assert_called_once_with(user_id)
|
||||||
|
send_email.assert_called_once()
|
||||||
|
assert send_email.call_args.args[2].endswith('/reset-password?token=reset-token')
|
||||||
|
|
||||||
|
|
||||||
|
def test_password_reset_is_single_use_and_revokes_sessions():
|
||||||
|
username = f'reset-owner-{uuid4().hex}'
|
||||||
|
admin_headers = {'Authorization': f"Bearer {issue_token('user-1', 'alice')['access_token']}"}
|
||||||
|
created = client.post('/api/admin/users', headers=admin_headers, json={
|
||||||
|
'username': username,
|
||||||
|
'email': f'{username}@example.com',
|
||||||
|
'password': 'secret123',
|
||||||
|
})
|
||||||
|
user_id = created.json()['id']
|
||||||
|
with get_connection() as conn:
|
||||||
|
conn.execute('UPDATE users SET email_verified = 1 WHERE id = ?', (user_id,))
|
||||||
|
conn.commit()
|
||||||
|
token = create_reset_token(user_id)
|
||||||
|
reset = client.post('/api/auth/reset-password', json={'token': token, 'password': 'new-secret123'})
|
||||||
|
assert reset.status_code == 200
|
||||||
|
assert client.post('/api/auth/reset-password', json={'token': token, 'password': 'another-secret'}).status_code == 400
|
||||||
|
assert client.post('/api/auth/login', json={'username': username, 'password': 'new-secret123'}).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
def test_admin_can_remove_user_with_owned_data():
|
def test_admin_can_remove_user_with_owned_data():
|
||||||
headers = login_headers()
|
headers = login_headers()
|
||||||
username = f'data-owner-{uuid4().hex}'
|
username = f'data-owner-{uuid4().hex}'
|
||||||
|
|||||||
@@ -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) == 6
|
assert get_schema_version(connection) == 7
|
||||||
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) == 6
|
assert get_schema_version(connection) == 7
|
||||||
|
|
||||||
connection.close()
|
connection.close()
|
||||||
@@ -22,6 +22,7 @@ services:
|
|||||||
LINKLOG_SMTP_FROM: ${LINKLOG_SMTP_FROM:-LinkLog <no-reply@example.com>}
|
LINKLOG_SMTP_FROM: ${LINKLOG_SMTP_FROM:-LinkLog <no-reply@example.com>}
|
||||||
LINKLOG_SMTP_USE_TLS: ${LINKLOG_SMTP_USE_TLS:-true}
|
LINKLOG_SMTP_USE_TLS: ${LINKLOG_SMTP_USE_TLS:-true}
|
||||||
LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS: ${LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS:-24}
|
LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS: ${LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS:-24}
|
||||||
|
LINKLOG_PASSWORD_RESET_EXPIRY_HOURS: ${LINKLOG_PASSWORD_RESET_EXPIRY_HOURS:-1}
|
||||||
LINKLOG_TRACKING_PARAMS: ${LINKLOG_TRACKING_PARAMS:-}
|
LINKLOG_TRACKING_PARAMS: ${LINKLOG_TRACKING_PARAMS:-}
|
||||||
restart: ${APP_RESTART_POLICY:-unless-stopped}
|
restart: ${APP_RESTART_POLICY:-unless-stopped}
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ services:
|
|||||||
LINKLOG_SMTP_FROM: ${LINKLOG_SMTP_FROM:-LinkLog <no-reply@localhost>}
|
LINKLOG_SMTP_FROM: ${LINKLOG_SMTP_FROM:-LinkLog <no-reply@localhost>}
|
||||||
LINKLOG_SMTP_USE_TLS: ${LINKLOG_SMTP_USE_TLS:-true}
|
LINKLOG_SMTP_USE_TLS: ${LINKLOG_SMTP_USE_TLS:-true}
|
||||||
LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS: ${LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS:-24}
|
LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS: ${LINKLOG_EMAIL_VERIFICATION_EXPIRY_HOURS:-24}
|
||||||
|
LINKLOG_PASSWORD_RESET_EXPIRY_HOURS: ${LINKLOG_PASSWORD_RESET_EXPIRY_HOURS:-1}
|
||||||
LINKLOG_TRACKING_PARAMS: ${LINKLOG_TRACKING_PARAMS:-}
|
LINKLOG_TRACKING_PARAMS: ${LINKLOG_TRACKING_PARAMS:-}
|
||||||
restart: ${APP_RESTART_POLICY:-unless-stopped}
|
restart: ${APP_RESTART_POLICY:-unless-stopped}
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// Copyright © 2026 Olaf Kolkman
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
const resetForm = document.querySelector('#reset-password-form');
|
||||||
|
const resetStatus = document.querySelector('#reset-password-status');
|
||||||
|
const resetToken = new URLSearchParams(window.location.search).get('token');
|
||||||
|
|
||||||
|
resetForm.addEventListener('submit', async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const response = await fetch('/api/auth/reset-password', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({token: resetToken, password: new FormData(resetForm).get('password')}),
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
resetStatus.textContent = response.ok ? `${result.message} Redirecting...` : (result.detail || 'Reset failed.');
|
||||||
|
resetStatus.style.color = response.ok ? '#166534' : '#b91c1c';
|
||||||
|
if (response.ok) window.setTimeout(() => window.location.assign('/login'), 1000);
|
||||||
|
});
|
||||||
+105
-8
@@ -1,26 +1,123 @@
|
|||||||
// Copyright © 2026 Olaf Kolkman
|
// Copyright © 2026 Olaf Kolkman
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
document.querySelector('#setup-form').addEventListener('submit', async (event) => {
|
const setupForm = document.querySelector('#setup-form');
|
||||||
|
const testMailButton = document.querySelector('#test-mail-button');
|
||||||
|
const completeSetupButton = document.querySelector('#complete-setup-button');
|
||||||
|
const setupStatus = document.querySelector('#setup-status');
|
||||||
|
const setupTimer = document.querySelector('#setup-timer');
|
||||||
|
let nextAllowedAt = null;
|
||||||
|
let timerHandle = null;
|
||||||
|
|
||||||
|
function updateTimer() {
|
||||||
|
if (timerHandle) window.clearTimeout(timerHandle);
|
||||||
|
if (!nextAllowedAt) {
|
||||||
|
setupTimer.textContent = '';
|
||||||
|
testMailButton.disabled = !setupForm.dataset.saved;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const seconds = Math.max(0, Math.ceil((nextAllowedAt - Date.now()) / 1000));
|
||||||
|
if (seconds === 0) {
|
||||||
|
nextAllowedAt = null;
|
||||||
|
updateTimer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
setupTimer.textContent = `Next test mail available in ${minutes ? `${minutes}m ` : ''}${seconds % 60}s.`;
|
||||||
|
testMailButton.disabled = true;
|
||||||
|
timerHandle = window.setTimeout(updateTimer, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyStatus(result) {
|
||||||
|
setupForm.dataset.saved = result.pending ? 'true' : '';
|
||||||
|
testMailButton.disabled = !result.pending;
|
||||||
|
const smtpDefaults = result.smtp_defaults || {};
|
||||||
|
for (const [name, value] of Object.entries(smtpDefaults)) {
|
||||||
|
const field = setupForm.elements[name];
|
||||||
|
if (!field || field.value || field.type === 'checkbox') continue;
|
||||||
|
field.value = value;
|
||||||
|
}
|
||||||
|
if (typeof smtpDefaults.smtp_use_tls === 'boolean') {
|
||||||
|
setupForm.elements.smtp_use_tls.checked = smtpDefaults.smtp_use_tls;
|
||||||
|
}
|
||||||
|
if (result.next_allowed_at) nextAllowedAt = Date.parse(result.next_allowed_at);
|
||||||
|
updateTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSetupStatus() {
|
||||||
|
const response = await fetch('/api/setup/status');
|
||||||
|
if (!response.ok) return;
|
||||||
|
applyStatus(await response.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
setupForm.addEventListener('submit', async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const form = event.currentTarget;
|
const form = event.currentTarget;
|
||||||
const status = document.querySelector('#setup-status');
|
|
||||||
const values = Object.fromEntries(new FormData(form));
|
const values = Object.fromEntries(new FormData(form));
|
||||||
values.smtp_port = Number(values.smtp_port);
|
values.smtp_port = Number(values.smtp_port);
|
||||||
values.smtp_use_tls = form.elements.smtp_use_tls.checked;
|
values.smtp_use_tls = form.elements.smtp_use_tls.checked;
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/setup', {
|
const response = await fetch('/api/setup/configuration', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: JSON.stringify(values),
|
body: JSON.stringify(values),
|
||||||
});
|
});
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
if (!response.ok) throw new Error(result.detail || 'Setup failed');
|
if (!response.ok) throw new Error(result.detail || 'Setup failed');
|
||||||
status.textContent = result.message;
|
setupStatus.textContent = result.message;
|
||||||
status.style.color = '#94e2d5';
|
setupStatus.style.color = '#94e2d5';
|
||||||
form.replaceChildren(status);
|
form.dataset.saved = 'true';
|
||||||
|
testMailButton.disabled = false;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
status.textContent = error.message;
|
setupStatus.textContent = error.message;
|
||||||
status.style.color = '#f38ba8';
|
setupStatus.style.color = '#f38ba8';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testMailButton.addEventListener('click', async () => {
|
||||||
|
testMailButton.disabled = true;
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/setup/test-mail', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({email: setupForm.elements.email.value}),
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 429 && response.headers.get('Retry-After')) {
|
||||||
|
nextAllowedAt = Date.now() + Number(response.headers.get('Retry-After')) * 1000;
|
||||||
|
updateTimer();
|
||||||
|
}
|
||||||
|
throw new Error(result.detail || 'Test mail failed');
|
||||||
|
}
|
||||||
|
setupStatus.textContent = `${result.message} ${result.sends_remaining} sends remaining.`;
|
||||||
|
setupStatus.style.color = '#94e2d5';
|
||||||
|
if (result.next_allowed_at) nextAllowedAt = Date.parse(result.next_allowed_at);
|
||||||
|
updateTimer();
|
||||||
|
completeSetupButton.hidden = false;
|
||||||
|
} catch (error) {
|
||||||
|
setupStatus.textContent = error.message;
|
||||||
|
setupStatus.style.color = '#f38ba8';
|
||||||
|
if (!nextAllowedAt) testMailButton.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
completeSetupButton.addEventListener('click', async () => {
|
||||||
|
const response = await fetch('/api/setup/complete', {method: 'POST'});
|
||||||
|
const result = await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
setupStatus.textContent = result.detail || 'Could not complete setup.';
|
||||||
|
setupStatus.style.color = '#f38ba8';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setupStatus.style.color = '#94e2d5';
|
||||||
|
setupStatus.textContent = '';
|
||||||
|
setupStatus.append(document.createTextNode(`${result.message} `));
|
||||||
|
const homeLink = document.createElement('a');
|
||||||
|
homeLink.href = '/';
|
||||||
|
homeLink.textContent = 'Home';
|
||||||
|
setupStatus.append(homeLink);
|
||||||
|
setupForm.replaceChildren(setupStatus);
|
||||||
|
});
|
||||||
|
|
||||||
|
loadSetupStatus();
|
||||||
@@ -47,6 +47,7 @@
|
|||||||
</label>
|
</label>
|
||||||
<button type="submit">Sign in</button>
|
<button type="submit">Sign in</button>
|
||||||
<p id="login-status" class="status" role="status"></p>
|
<p id="login-status" class="status" role="status"></p>
|
||||||
|
<p><small>A mistyped password sends a reset link to your verified email address.</small></p>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<!-- Copyright © 2026 Olaf Kolkman -->
|
||||||
|
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Reset password - LinkLog</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<link rel="stylesheet" href="/static/style.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="container">
|
||||||
|
<section class="link-item settings-panel">
|
||||||
|
<h1>Reset password</h1>
|
||||||
|
<form id="reset-password-form">
|
||||||
|
<label>New password<input name="password" type="password" minlength="8" autocomplete="new-password" required /></label>
|
||||||
|
<button type="submit">Reset password</button>
|
||||||
|
<p id="reset-password-status" class="status" role="status"></p>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<script src="/static/reset-password.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -29,13 +29,16 @@
|
|||||||
<label>Password<input name="password" type="password" autocomplete="new-password" minlength="8" required /></label>
|
<label>Password<input name="password" type="password" autocomplete="new-password" minlength="8" required /></label>
|
||||||
<h2>SMTP</h2>
|
<h2>SMTP</h2>
|
||||||
<label>SMTP host<input name="smtp_host" type="text" required /></label>
|
<label>SMTP host<input name="smtp_host" type="text" required /></label>
|
||||||
<label>SMTP port<input name="smtp_port" type="number" min="1" max="65535" value="587" required /></label>
|
<label>SMTP port<input name="smtp_port" type="number" min="1" max="65535" required /></label>
|
||||||
<label>SMTP username<input name="smtp_username" type="text" autocomplete="off" /></label>
|
<label>SMTP username<input name="smtp_username" type="text" autocomplete="off" /></label>
|
||||||
<label>SMTP password<input name="smtp_password" type="password" autocomplete="new-password" /></label>
|
<label>SMTP password<input name="smtp_password" type="password" autocomplete="new-password" /></label>
|
||||||
<label>From address<input name="smtp_from" type="text" value="LinkLog <no-reply@example.com>" required /></label>
|
<label>From address<input name="smtp_from" type="text" required /></label>
|
||||||
<label class="checkbox-label"><input name="smtp_use_tls" type="checkbox" checked /> Use STARTTLS</label>
|
<label class="checkbox-label"><input name="smtp_use_tls" type="checkbox" /> Use STARTTLS</label>
|
||||||
<button type="submit">Save and send test mail</button>
|
<button type="submit">Save configuration</button>
|
||||||
|
<button id="test-mail-button" type="button" disabled>Send test mail</button>
|
||||||
|
<button id="complete-setup-button" type="button" hidden>Complete setup</button>
|
||||||
<p id="setup-status" class="status" role="status"></p>
|
<p id="setup-status" class="status" role="status"></p>
|
||||||
|
<p id="setup-timer" class="status" role="timer" aria-live="polite"></p>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
Reference in New Issue
Block a user