Working signout button

This commit is contained in:
Olaf
2026-08-24 19:56:28 +02:00
parent 28a01175f7
commit 12f3580f60
17 changed files with 252 additions and 10 deletions
+5
View File
@@ -3,6 +3,10 @@ APP_ENV=production
APP_CONTAINER_NAME=linklog-app
APP_PORT=8000
APP_RESTART_POLICY=unless-stopped
APP_HEALTHCHECK_INTERVAL=30s
APP_HEALTHCHECK_TIMEOUT=5s
APP_HEALTHCHECK_START_PERIOD=10s
APP_HEALTHCHECK_RETRIES=3
LINKLOG_APP_NAME=LinkLog
LINKLOG_SECRET_KEY=replace-with-a-long-random-secret
LINKLOG_TOKEN_EXPIRY_DAYS=30
@@ -18,6 +22,7 @@ TRAEFIK_HOST=localhost
TRAEFIK_HTTP_PORT=80
TRAEFIK_HTTP_INTERNAL_PORT=80
TRAEFIK_DASHBOARD_PORT=8080
TRAEFIK_DASHBOARD_BIND_ADDRESS=127.0.0.1
TRAEFIK_API_INSECURE=true
TRAEFIK_RESTART_POLICY=unless-stopped
DOCKER_SOCKET_PATH=/var/run/docker.sock
+11
View File
@@ -10,7 +10,18 @@ RUN pip install --no-cache-dir -r backend/requirements.txt
COPY backend ./backend
COPY frontend ./frontend
RUN useradd --create-home --uid 10001 linklog \
&& mkdir -p /app/backend/data \
&& chown -R linklog:linklog /app
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod 755 /usr/local/bin/docker-entrypoint.sh
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD python -c "from urllib.request import urlopen; urlopen('http://127.0.0.1:8000/health', timeout=3)"
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
CMD ["uvicorn", "backend.app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+4
View File
@@ -78,6 +78,8 @@ Appending a username to the root URL, such as `/alice`, opens that user's public
Configuration APIs require a bearer token returned by the login endpoint. User configuration uses the identity in that token. Plugin administration additionally requires an administrator account; the development `alice` account is seeded as an administrator, while `bob` is a standard user.
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`.
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
@@ -131,6 +133,7 @@ The main configurable values are:
| `TRAEFIK_HOST` | hostname routed by Traefik | `localhost` |
| `TRAEFIK_HTTP_PORT` | host port for the application proxy | `80` |
| `TRAEFIK_DASHBOARD_PORT` | host port for the dashboard | `8080` |
| `TRAEFIK_DASHBOARD_BIND_ADDRESS` | host address for the dashboard | `127.0.0.1` |
| `TRAEFIK_API_INSECURE` | enable the local dashboard API | `true` |
| `APP_PORT` | direct host port for FastAPI | `8000` |
@@ -149,6 +152,7 @@ The services are available at:
- Traefik dashboard: <http://localhost:8080/dashboard/>
The Compose configuration routes the hostname `localhost` through Traefik. The SQLite database is stored in the named Docker volume `linklog_data`, mounted at `/app/backend/data`.
The application runs as a non-root user and reports container health through `/health`; Traefik waits for the application health check before starting.
Stop the stack without deleting its database:
+24
View File
@@ -271,3 +271,27 @@ Render edit action for owned links
### Assistant outcome
Verified that the feed renders the Edit button only when `is_owner` is true, while the backend owner check remains enforced by `PUT /api/links/{link_id}`. The live Docker-served feed asset contains the owner-gated action, focused tests passed, and the full strict suite passed with 12 tests.
### User
On every page, when a user has authenticated show its avatar and name in the header next to a sign out putton.
### Assistant outcome
Added a shared authenticated header to the feed, login, admin, and profile pages. The shared script validates the stored token through `/api/auth/me`, displays the user's avatar and username beside `Sign out`, and shows `Sign in` when signed out. Uploaded avatars are used with an initial fallback. All pages include the shared header, the strict backend suite passed with 12 tests, and all browser scripts passed syntax checks.
### User
execute next todo
### Assistant outcome
Implemented the next unfinished requirements slice: authenticated password changes from the profile page. Added current-password verification, an 8-character minimum for new passwords, profile form controls, regression coverage, and README documentation. The strict backend suite passed with 12 tests.
### User
execute final todo (nummer 3)
### Assistant outcome
Completed the third remaining plan item by hardening Docker and Traefik: added non-root application execution, configurable health checks, health-gated Traefik startup, loopback dashboard binding, and a startup entrypoint that repairs ownership on existing SQLite volumes before dropping to UID 10001. Compose validation, a fresh image build, existing-volume startup, application health, and the strict backend suite all passed.
### User
Pressing signout (on the home page) does not log the user out
### Assistant outcome
Found that the home page rendered the shared authenticated session header but did not load `logout.js`, so its sign-out button had no event handler. Added the logout handler to the home and login pages with cache-busted asset references. Verified real token revocation and the full strict backend suite with 12 passing tests.
+4
View File
@@ -48,6 +48,10 @@
44. Add authenticated link editing
45. Expose ownership if feed data
46. Render edit action for owned links
47. On every page, when a user has authenticated show its avatar and name in the header next to a sign out putton.
48. execute next todo
49. execute final todo (nummer 3)
50. Pressing signout (on the home page) does not log the user out
## Future entries
+22 -1
View File
@@ -5,7 +5,7 @@ from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from pydantic import BaseModel
from backend.app.api.dependencies import get_current_user
from backend.app.database import AVATARS_DIR, get_connection
from backend.app.database import AVATARS_DIR, get_connection, hash_password
router = APIRouter()
@@ -15,6 +15,11 @@ class UserConfigUpdate(BaseModel):
bio: str | None = None
class PasswordUpdate(BaseModel):
current_password: str
new_password: str
class UserPluginConfigUpdate(BaseModel):
instance: str | None = None
access_token: str | None = None
@@ -60,6 +65,22 @@ def update_current_user_profile(
return {'status': 'updated'}
@router.put('/password')
def update_password(payload: PasswordUpdate, user: dict = Depends(get_current_user)):
if len(payload.new_password) < 8:
raise HTTPException(status_code=422, detail='New password must be at least 8 characters')
if hash_password(payload.current_password) != user['password_hash']:
raise HTTPException(status_code=400, detail='Current password is incorrect')
with get_connection() as conn:
conn.execute(
'UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
(hash_password(payload.new_password), user['id']),
)
conn.commit()
return {'status': 'password_updated'}
@router.post('/avatar')
async def upload_avatar(
avatar: UploadFile = File(...),
+7 -2
View File
@@ -173,6 +173,8 @@ def test_public_and_admin_pages_render_html():
root_page = client.get('/')
assert 'LinkLog' in root_page.text
assert 'class="login-button" href="/login"' in root_page.text
assert 'id="auth-session" class="auth-session hidden"' 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
user_page = client.get('/alice').text
@@ -182,14 +184,17 @@ def test_public_and_admin_pages_render_html():
feed_script = TestClient(app).get('/static/feed.js?v=4').text
assert 'window.location.assign(selectedUser ? `/${encodeURIComponent(selectedUser)}/` : \'/\')' in feed_script
assert client.get('/login').status_code == 200
assert 'Sign in' in client.get('/login').text
login_page = client.get('/login').text
assert 'Sign in' in login_page
assert 'id="auth-session" class="auth-session hidden"' in login_page
assert 'logout.js?v=3' in login_page
assert client.get('/admin').status_code == 200
admin_page = client.get('/admin').text
assert 'Admin' in admin_page
assert 'id="admin-controls" class="hidden"' in admin_page
assert 'id="admin-auth-notice" class="auth-notice hidden"' in admin_page
assert 'id="admin-login-button" class="login-button" href="/login"' in admin_page
assert 'id="logout-button" class="logout-button hidden"' in admin_page
assert 'id="auth-session" class="auth-session hidden"' in admin_page
feed_script = client.get('/static/feed.js?v=5').text
assert 'if (item.is_owner)' in feed_script
+20 -1
View File
@@ -37,7 +37,26 @@ def test_user_config_api_and_profile_page():
assert 'From my #LinkLog: &quot;' in page_response.text
assert 'id="admin-link"' in page_response.text
assert 'class="login-button hidden"' in page_response.text
assert 'id="logout-button" class="logout-button hidden"' in page_response.text
assert 'id="auth-session" class="auth-session hidden"' in page_response.text
bob_login = client.post('/api/auth/login', json={
'username': 'bob',
'password': 'secret123',
}).json()
bob_headers = {'Authorization': f"Bearer {bob_login['access_token']}"}
password_response = client.put('/api/user/password', json={
'current_password': 'secret123',
'new_password': 'new-secret-123',
}, headers=bob_headers)
assert password_response.status_code == 200
assert client.post('/api/auth/login', json={
'username': 'bob',
'password': 'new-secret-123',
}).status_code == 200
assert client.put('/api/user/password', json={
'current_password': 'new-secret-123',
'new_password': 'secret123',
}, headers=bob_headers).status_code == 200
upload_response = client.post(
'/api/user/avatar',
+10 -1
View File
@@ -16,6 +16,12 @@ services:
LINKLOG_TOKEN_EXPIRY_DAYS: ${LINKLOG_TOKEN_EXPIRY_DAYS:-30}
LINKLOG_TRACKING_PARAMS: ${LINKLOG_TRACKING_PARAMS:-}
restart: ${APP_RESTART_POLICY:-unless-stopped}
healthcheck:
test: ["CMD", "python", "-c", "from urllib.request import urlopen; urlopen('http://127.0.0.1:8000/health', timeout=3)"]
interval: ${APP_HEALTHCHECK_INTERVAL:-30s}
timeout: ${APP_HEALTHCHECK_TIMEOUT:-5s}
start_period: ${APP_HEALTHCHECK_START_PERIOD:-10s}
retries: ${APP_HEALTHCHECK_RETRIES:-3}
labels:
- "traefik.enable=true"
- "traefik.http.routers.linklog.rule=Host(`${TRAEFIK_HOST:-localhost}`)"
@@ -31,8 +37,11 @@ services:
- --api.insecure=${TRAEFIK_API_INSECURE:-true}
ports:
- "${TRAEFIK_HTTP_PORT:-80}:${TRAEFIK_HTTP_INTERNAL_PORT:-80}"
- "${TRAEFIK_DASHBOARD_PORT:-8080}:8080"
- "${TRAEFIK_DASHBOARD_BIND_ADDRESS:-127.0.0.1}:${TRAEFIK_DASHBOARD_PORT:-8080}:8080"
restart: ${TRAEFIK_RESTART_POLICY:-unless-stopped}
depends_on:
app:
condition: service_healthy
volumes:
- "${DOCKER_SOCKET_PATH:-/var/run/docker.sock}:/var/run/docker.sock:ro"
+5
View File
@@ -0,0 +1,5 @@
#!/bin/sh
set -eu
chown -R linklog:linklog /app/backend/data
exec su -s /bin/sh linklog -c "exec $*"
+49
View File
@@ -0,0 +1,49 @@
(() => {
const loginButton = document.querySelector('#auth-login-button');
const session = document.querySelector('#auth-session');
const avatar = document.querySelector('#auth-avatar');
const username = document.querySelector('#auth-username');
const token = localStorage.getItem('linklogAccessToken');
if (!loginButton || !session || !avatar || !username) return;
function showSignedOut() {
loginButton.classList.remove('hidden');
session.classList.add('hidden');
}
function showSignedIn(user) {
loginButton.classList.add('hidden');
session.classList.remove('hidden');
username.textContent = user.username || '';
const initial = (user.username || 'U').slice(0, 1).toUpperCase();
avatar.textContent = initial;
if (user.avatar_url) {
const image = document.createElement('img');
image.src = user.avatar_url;
image.alt = `${user.username || 'User'} avatar`;
image.addEventListener('error', () => {
image.remove();
avatar.textContent = initial;
});
avatar.textContent = '';
avatar.appendChild(image);
}
}
if (!token) {
showSignedOut();
return;
}
fetch(`/api/auth/me?token=${encodeURIComponent(token)}`)
.then((response) => {
if (!response.ok) throw new Error('Session expired');
return response.json();
})
.then(showSignedIn)
.catch(() => {
localStorage.removeItem('linklogAccessToken');
showSignedOut();
});
})();
+14
View File
@@ -1,5 +1,6 @@
(() => {
const profileForm = document.querySelector('#profile-form');
const passwordForm = document.querySelector('#password-form');
const mastodonForm = document.querySelector('#mastodon-form');
const profileLogoutButton = document.querySelector('#logout-button');
const accessToken = localStorage.getItem('linklogAccessToken');
@@ -90,6 +91,19 @@ mastodonForm.addEventListener('submit', async (event) => {
setStatus('#mastodon-status', response.ok ? 'Mastodon settings saved.' : 'Could not save Mastodon settings.', !response.ok);
});
passwordForm.addEventListener('submit', async (event) => {
event.preventDefault();
const response = await fetch('/api/user/password', {
method: 'PUT',
headers: authHeaders(true),
body: JSON.stringify(Object.fromEntries(new FormData(passwordForm))),
});
const status = document.querySelector('#password-status');
status.textContent = response.ok ? 'Password changed.' : 'Could not change password.';
status.style.color = response.ok ? '#94e2d5' : '#f38ba8';
if (response.ok) passwordForm.reset();
});
Promise.all([loadProfile(), loadMastodonConfig()]).catch((error) => {
setStatus('#profile-status', accessToken ? error.message : 'Please sign in first.', true);
});
+23
View File
@@ -105,6 +105,25 @@ body::selection {
gap: 8px;
}
.auth-session {
display: flex;
align-items: center;
gap: 9px;
}
.auth-session .avatar {
width: 32px;
height: 32px;
flex-basis: 32px;
}
.auth-session .user-name {
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.login-button {
flex: 0 0 auto;
margin-top: 2px;
@@ -447,6 +466,10 @@ button:disabled {
flex-direction: column;
}
.auth-session {
flex-direction: column;
}
.login-button {
padding: 8px 11px;
font-size: 0.9rem;
+6 -1
View File
@@ -16,7 +16,11 @@
</div>
<div class="header-actions">
<a id="admin-login-button" class="login-button" href="/login">Sign in</a>
<button id="logout-button" class="logout-button hidden" type="button">Sign out</button>
<div id="auth-session" class="auth-session hidden">
<div id="auth-avatar" class="avatar"></div>
<span id="auth-username" class="user-name"></span>
<button id="logout-button" class="logout-button" type="button">Sign out</button>
</div>
</div>
</div>
</div>
@@ -56,6 +60,7 @@
</section>
</div>
</main>
<script src="/static/auth-header.js?v=1"></script>
<script src="/static/logout.js?v=2"></script>
<script src="/static/admin.js?v=2"></script>
</body>
+10 -1
View File
@@ -14,7 +14,14 @@
<h1>LinkLog</h1>
<p>Public link feed</p>
</div>
<a class="login-button" href="/login">Sign in</a>
<div class="header-actions">
<a id="auth-login-button" class="login-button" href="/login">Sign in</a>
<div id="auth-session" class="auth-session hidden">
<div id="auth-avatar" class="avatar"></div>
<span id="auth-username" class="user-name"></span>
<button id="logout-button" class="logout-button" type="button">Sign out</button>
</div>
</div>
</div>
</div>
</header>
@@ -54,6 +61,8 @@
<section id="feed" class="feed" aria-live="polite"></section>
</main>
<script src="/static/auth-header.js?v=1"></script>
<script src="/static/logout.js?v=3"></script>
<script src="/static/feed.js?v=5"></script>
</body>
</html>
+14
View File
@@ -9,9 +9,21 @@
<body>
<header class="site-header">
<div class="container">
<div class="header-row">
<div>
<h1>Sign in</h1>
<p>Access your LinkLog settings</p>
</div>
<div class="header-actions">
<a id="auth-login-button" class="login-button" href="/login">Sign in</a>
<div id="auth-session" class="auth-session hidden">
<div id="auth-avatar" class="avatar"></div>
<span id="auth-username" class="user-name"></span>
<button id="logout-button" class="logout-button" type="button">Sign out</button>
</div>
</div>
</div>
</div>
</header>
<main class="container">
<section class="link-item settings-panel">
@@ -29,6 +41,8 @@
</form>
</section>
</main>
<script src="/static/auth-header.js?v=1"></script>
<script src="/static/logout.js?v=3"></script>
<script src="/static/login.js"></script>
</body>
</html>
+22 -1
View File
@@ -13,7 +13,11 @@
<h1>Profile</h1>
<div class="header-actions">
<a id="admin-link" class="login-button hidden" href="/admin">Admin</a>
<button id="logout-button" class="logout-button hidden" type="button">Sign out</button>
<div id="auth-session" class="auth-session hidden">
<div id="auth-avatar" class="avatar"></div>
<span id="auth-username" class="user-name"></span>
<button id="logout-button" class="logout-button" type="button">Sign out</button>
</div>
</div>
</div>
</div>
@@ -46,6 +50,22 @@
</form>
</section>
<section class="link-item settings-panel">
<h2>Password</h2>
<form id="password-form">
<label>
Current password
<input name="current_password" type="password" autocomplete="current-password" required />
</label>
<label>
New password
<input name="new_password" type="password" minlength="8" autocomplete="new-password" required />
</label>
<button type="submit">Change password</button>
<p id="password-status" class="status" role="status"></p>
</form>
</section>
<section class="link-item settings-panel">
<h2>Mastodon</h2>
<form id="mastodon-form">
@@ -66,6 +86,7 @@
</form>
</section>
</main>
<script src="/static/auth-header.js?v=1"></script>
<script src="/static/logout.js?v=2"></script>
<script src="/static/profile.js?v=2"></script>
</body>