From 95992cc4663a5ad5838ee01383bf4417d7a04f6a Mon Sep 17 00:00:00 2001 From: Olaf Date: Mon, 24 Aug 2026 21:24:39 +0200 Subject: [PATCH] Label editing --- README.md | 10 +++-- VIBE/CHAT_LOG.md | 6 +++ VIBE/PROMPTS.md | 1 + backend/app/api/admin.py | 21 ++++++++++ backend/app/api/user_config.py | 36 ++++++++++++++++ backend/app/database.py | 4 ++ backend/app/main.py | 5 +++ backend/app/services/link_service.py | 62 ++++++++++++++++++++++++++++ backend/tests/test_api.py | 26 ++++++++++++ backend/tests/test_database.py | 6 ++- frontend/static/admin.js | 29 ++++++++++++- frontend/static/auth-header.js | 5 ++- frontend/templates/admin.html | 5 +++ frontend/templates/feed.html | 1 + frontend/templates/labels.html | 47 +++++++++++++++++++++ frontend/templates/login.html | 1 + frontend/templates/user_profile.html | 1 + 17 files changed, 259 insertions(+), 7 deletions(-) create mode 100644 frontend/templates/labels.html diff --git a/README.md b/README.md index 912de90..23d2b3a 100644 --- a/README.md +++ b/README.md @@ -44,15 +44,17 @@ On Windows PowerShell, activate the environment with: The SQLite database is created automatically at `backend/data/linklog.db` when the application starts or when the auth router is imported. The starter accounts are: -The database schema is versioned with SQLite `PRAGMA user_version`. Application startup applies all pending migrations in order, so updating the application does not require deleting an existing database. New schema changes should be added as a new numbered migration in `backend/app/database.py`; existing migration entries must remain unchanged. + | Username | Password | Role | | --- | --- | --- | | `alice` | `secret123` | administrator | | `bob` | `secret123` | standard user | + These credentials are for development only. Change the authentication and seeding design before deploying publicly. +The database schema is versioned with SQLite `PRAGMA user_version`. Application startup applies all pending migrations in order, so updating the application does not require deleting an existing database. New schema changes should be added as a new numbered migration in `backend/app/database.py`; existing migration entries must remain unchanged. ## Run The Backend Activate the virtual environment, then run uvicorn from the repository root: @@ -67,6 +69,7 @@ Open these URLs: - Public feed: - User feed: - Profile settings: +- Labels: - Admin page: - Web login: - Health check: @@ -147,9 +150,9 @@ docker compose up --build The services are available at: -- LinkLog through Traefik: +- LinkLog through Traefik: - Direct application port: -- Traefik 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. @@ -209,6 +212,7 @@ Feed items also expose `is_owner`; it is `true` only for entries owned by the au Links support zero to ten tags. Tags are trimmed, deduplicated case-insensitively, and retain their original casing for display. The Firefox capture popup shows existing server tags as checkboxes and accepts new comma-separated tags. The home page displays tags and provides a case-insensitive tag filter; editing a link replaces its complete tag set. The inline link editor also allows multiple existing tags to be selected and new tags to be entered. New tag values receive a leading `#` automatically, and the interface prevents saving more than ten tags. The installation seeds these available tags: `#Internet`, `#Cybersecurity`, `#Fediverse`, `#Food`, `#Photography`, `#Music`, and `#AI`. +Users can create, edit, and delete their own labels from `/labels`. Administrators can delete any label from `/admin`; system-seeded labels have no user owner. Admin plugin requests must include the administrator's token: diff --git a/VIBE/CHAT_LOG.md b/VIBE/CHAT_LOG.md index 2081f6a..50b8a7c 100644 --- a/VIBE/CHAT_LOG.md +++ b/VIBE/CHAT_LOG.md @@ -367,3 +367,9 @@ Display the date as "2026 June 18 - 20:22" ### Assistant outcome Updated feed metadata rendering to format timestamps as `YYYY Month D - HH:mm`, such as `2026 June 18 - 20:22`, using zero-padded 24-hour time. Invalid timestamps retain the existing fallback text. The feed script and strict backend suite passed with 13 tests. + +### User +Create a page where users can add, delete, or edit labels - Labels can be edited and deleted by the same user that created them. The administrator can delete any label. + +### Assistant outcome +Added schema migration 4 with label ownership, authenticated user label CRUD endpoints and a `/labels` management page. Label creators can edit/delete their own labels, non-owners are denied, and administrators can delete any label including another user's labels. Added administrator label listing/deletion controls and regression coverage. The strict backend suite passed with 15 tests. diff --git a/VIBE/PROMPTS.md b/VIBE/PROMPTS.md index a2c0def..fb511c4 100644 --- a/VIBE/PROMPTS.md +++ b/VIBE/PROMPTS.md @@ -64,6 +64,7 @@ 60. when editing lables I want to be able to selec multiple tags or add one myself. 61. Make the tags filtering case incensitive (but maintain case of tags) 62. Display the date as "2026 June 18 - 20:22" +63. Create a page where users can add, delete, or edit labels - Labels can be edited and deleted by the same user that created them. The administrator can delete any label. ## Future entries diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py index 02e337d..4497103 100644 --- a/backend/app/api/admin.py +++ b/backend/app/api/admin.py @@ -6,6 +6,7 @@ from pydantic import BaseModel from backend.app.api.dependencies import require_admin from backend.app.database import get_connection, hash_password +from backend.app.services.link_service import delete_label router = APIRouter() @@ -127,6 +128,26 @@ def delete_user(user_id: str, current_user: dict = Depends(require_admin)): return {'status': 'deleted', 'id': user_id} +@router.delete('/labels/{label_id}') +def admin_delete_label(label_id: str, _: dict = Depends(require_admin)): + if not delete_label(label_id, is_admin=True): + raise HTTPException(status_code=404, detail='Label not found') + return {'status': 'deleted', 'id': label_id} + + +@router.get('/labels') +def admin_list_labels(_: dict = Depends(require_admin)): + with get_connection() as conn: + rows = conn.execute( + ''' + SELECT tags.id, tags.name, tags.created_by, users.username AS creator + FROM tags LEFT JOIN users ON users.id = tags.created_by + ORDER BY tags.name + ''' + ).fetchall() + return [dict(row) for row in rows] + + @router.get('/plugins') def list_plugins(_: dict = Depends(require_admin)): with get_connection() as conn: diff --git a/backend/app/api/user_config.py b/backend/app/api/user_config.py index 924c4ca..0e3a406 100644 --- a/backend/app/api/user_config.py +++ b/backend/app/api/user_config.py @@ -6,6 +6,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 router = APIRouter() @@ -27,6 +28,10 @@ class UserPluginConfigUpdate(BaseModel): hashtag: str | None = None +class LabelUpdate(BaseModel): + name: str + + @router.get('/me') def get_current_user_profile(user: dict = Depends(get_current_user)): with get_connection() as conn: @@ -81,6 +86,37 @@ def update_password(payload: PasswordUpdate, user: dict = Depends(get_current_us return {'status': 'password_updated'} +@router.get('/labels') +def get_labels(user: dict = Depends(get_current_user)): + return list_user_labels(user['id']) + + +@router.post('/labels', status_code=201) +def add_label(payload: LabelUpdate, user: dict = Depends(get_current_user)): + try: + return create_label(user['id'], payload.name) + except ValueError as error: + raise HTTPException(status_code=409, detail=str(error)) from error + + +@router.put('/labels/{label_id}') +def edit_label(label_id: str, payload: LabelUpdate, user: dict = Depends(get_current_user)): + try: + result = update_label(label_id, user['id'], payload.name) + except ValueError as error: + raise HTTPException(status_code=409, detail=str(error)) from error + if result is None: + raise HTTPException(status_code=404, detail='Label not found or not owned by user') + return result + + +@router.delete('/labels/{label_id}') +def remove_label(label_id: str, user: dict = Depends(get_current_user)): + if not delete_label(label_id, user['id']): + raise HTTPException(status_code=404, detail='Label not found or not owned by user') + return {'status': 'deleted', 'id': label_id} + + @router.post('/avatar') async def upload_avatar( avatar: UploadFile = File(...), diff --git a/backend/app/database.py b/backend/app/database.py index 9efd838..fa05d2b 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -94,6 +94,10 @@ CREATE INDEX IF NOT EXISTS idx_link_tags_tag_id ON link_tags(tag_id); '''), (3, ''' UPDATE tags SET name = '#' || name WHERE name NOT LIKE '#%'; +'''), + (4, ''' +ALTER TABLE tags ADD COLUMN created_by TEXT REFERENCES users(id) ON DELETE SET NULL; +CREATE INDEX IF NOT EXISTS idx_tags_created_by ON tags(created_by); '''), ] diff --git a/backend/app/main.py b/backend/app/main.py index a0e517d..0ec2d17 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -40,6 +40,11 @@ async def user_profile_page(request: Request): return templates.TemplateResponse(request, 'user_profile.html', {}) +@app.get('/labels', response_class=HTMLResponse) +async def labels_page(request: Request): + return templates.TemplateResponse(request, 'labels.html', {}) + + @app.get('/login', response_class=HTMLResponse) async def login_page(request: Request): return templates.TemplateResponse(request, 'login.html', {}) diff --git a/backend/app/services/link_service.py b/backend/app/services/link_service.py index fbb452d..8270ede 100644 --- a/backend/app/services/link_service.py +++ b/backend/app/services/link_service.py @@ -172,3 +172,65 @@ def list_tags(): tags.append(row['name']) seen.add(row['name'].casefold()) return tags + + +def list_user_labels(user_id: str): + with get_connection() as conn: + rows = conn.execute( + 'SELECT id, name, created_by FROM tags WHERE created_by = ? ORDER BY name', + (user_id,), + ).fetchall() + return [dict(row) for row in rows] + + +def create_label(user_id: str, name: str): + normalized = normalize_tags([name]) + label = normalized[0] if normalized else '' + if not label: + raise ValueError('Label cannot be empty') + with get_connection() as conn: + existing = conn.execute( + 'SELECT id FROM tags WHERE lower(name) = lower(?)', (label,) + ).fetchone() + if existing: + raise ValueError('Label already exists') + label_id = str(uuid4()) + conn.execute( + 'INSERT INTO tags (id, name, created_by) VALUES (?, ?, ?)', + (label_id, label, user_id), + ) + conn.commit() + return {'id': label_id, 'name': label, 'created_by': user_id} + + +def update_label(label_id: str, user_id: str, name: str): + normalized = normalize_tags([name]) + if not normalized: + raise ValueError('Label cannot be empty') + with get_connection() as conn: + current = conn.execute( + 'SELECT id, name, created_by FROM tags WHERE id = ?', (label_id,) + ).fetchone() + if current is None or current['created_by'] != user_id: + return None + duplicate = conn.execute( + 'SELECT id FROM tags WHERE lower(name) = lower(?) AND id != ?', + (normalized[0], label_id), + ).fetchone() + if duplicate: + raise ValueError('Label already exists') + conn.execute('UPDATE tags SET name = ? WHERE id = ?', (normalized[0], label_id)) + conn.commit() + return {'id': label_id, 'name': normalized[0], 'created_by': user_id} + + +def delete_label(label_id: str, user_id: str | None = None, is_admin: bool = False): + with get_connection() as conn: + current = conn.execute( + 'SELECT id, created_by FROM tags WHERE id = ?', (label_id,) + ).fetchone() + if current is None or (not is_admin and current['created_by'] != user_id): + return False + conn.execute('DELETE FROM tags WHERE id = ?', (label_id,)) + conn.commit() + return True diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 3be18cc..5a14b22 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -84,6 +84,32 @@ def test_admin_can_toggle_privileges_without_removing_last_admin(): assert last_admin.status_code == 400 +def test_users_manage_owned_labels_and_admin_can_delete_any_label(): + alice_headers = login_headers('alice') + created = client.post('/api/user/labels', headers=alice_headers, json={'name': 'My Label'}) + assert created.status_code == 201 + label = created.json() + assert label['name'] == '#My Label' + + edited = client.put(f"/api/user/labels/{label['id']}", headers=alice_headers, json={'name': '#Renamed'}) + assert edited.status_code == 200 + assert edited.json()['name'] == '#Renamed' + + denied = client.put(f"/api/user/labels/{label['id']}", headers=login_headers('bob'), json={'name': '#Nope'}) + assert denied.status_code == 404 + assert client.delete(f"/api/user/labels/{label['id']}", headers=login_headers('bob')).status_code == 404 + + admin_delete = client.delete(f"/api/admin/labels/{label['id']}", headers=alice_headers) + assert admin_delete.status_code == 200 + + +def test_labels_page_renders_authenticated_management_shell(): + page = client.get('/labels') + assert page.status_code == 200 + assert 'Labels' in page.text + assert 'labels.js?v=1' in page.text + + def test_submit_link_stores_cleaned_url_and_public_feed(): response = client.post('/api/links', headers=login_headers(), json={ 'title': 'Example page', diff --git a/backend/tests/test_database.py b/backend/tests/test_database.py index 26c21a1..e90fd56 100644 --- a/backend/tests/test_database.py +++ b/backend/tests/test_database.py @@ -7,7 +7,7 @@ def test_database_migrations_are_versioned_and_idempotent(): connection = sqlite3.connect(':memory:') apply_migrations(connection) - assert get_schema_version(connection) == 3 + assert get_schema_version(connection) == 4 tables = { row[0] for row in connection.execute( @@ -17,11 +17,13 @@ def test_database_migrations_are_versioned_and_idempotent(): assert { 'users', 'tokens', 'links', 'plugins', 'user_plugin_config', 'tags', 'link_tags' } <= tables + columns = {row[1] for row in connection.execute('PRAGMA table_info(tags)')} + assert 'created_by' in columns seed_default_tags(connection) seeded_tags = {row[0] for row in connection.execute('SELECT name FROM tags')} assert set(DEFAULT_TAGS) <= seeded_tags apply_migrations(connection) - assert get_schema_version(connection) == 3 + assert get_schema_version(connection) == 4 connection.close() \ No newline at end of file diff --git a/frontend/static/admin.js b/frontend/static/admin.js index f7c5d49..cdfb09d 100644 --- a/frontend/static/admin.js +++ b/frontend/static/admin.js @@ -1,5 +1,6 @@ (() => { const pluginList = document.querySelector('#plugin-list'); +const adminLabelList = document.querySelector('#admin-label-list'); const userList = document.querySelector('#user-list'); const userForm = document.querySelector('#user-form'); const adminControls = document.querySelector('#admin-controls'); @@ -31,6 +32,31 @@ function renderPlugins(plugins) { })); } +function renderLabels(labels) { + adminLabelList.replaceChildren(...labels.map((label) => { + const row = document.createElement('div'); + row.className = 'plugin-row'; + const text = document.createElement('span'); + text.textContent = `${label.name}${label.creator ? ` (${label.creator})` : ' (default)'}`; + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'danger-button'; + button.textContent = 'Delete'; + button.addEventListener('click', async () => { + const response = await fetch(`/api/admin/labels/${label.id}`, {method: 'DELETE', headers: authHeaders()}); + if (response.ok) loadLabels(); + }); + row.append(text, button); + return row; + })); +} + +async function loadLabels() { + const response = await fetch('/api/admin/labels', {headers: authHeaders()}); + if (!response.ok) throw new Error('Could not load labels'); + renderLabels(await response.json()); +} + function renderUsers(users) { userList.replaceChildren(...users.map((user) => { const row = document.createElement('div'); @@ -95,7 +121,7 @@ async function loadAdminState() { return; } - await Promise.all([loadUsers(), loadPlugins()]); + await Promise.all([loadUsers(), loadPlugins(), loadLabels()]); showAdminState(true); } @@ -174,5 +200,6 @@ loadAdminState().catch((error) => { adminAuthNotice.classList.remove('hidden'); userList.textContent = ''; pluginList.textContent = ''; + adminLabelList.textContent = ''; }); })(); diff --git a/frontend/static/auth-header.js b/frontend/static/auth-header.js index f2b89cd..4bc34e3 100644 --- a/frontend/static/auth-header.js +++ b/frontend/static/auth-header.js @@ -1,6 +1,7 @@ (() => { const loginButton = document.querySelector('#auth-login-button'); const profileLink = document.querySelector('#auth-profile-link'); + const labelsLink = document.querySelector('#auth-labels-link'); const adminLink = document.querySelector('#auth-admin-link'); const session = document.querySelector('#auth-session'); const username = document.querySelector('#auth-username'); @@ -10,7 +11,7 @@ const menuToggle = document.querySelector('.menu-toggle'); const logoutButton = document.querySelector('#logout-button'); - if (!loginButton || !profileLink || !adminLink || !session || !username || !menu || !menuToggle || !logoutButton) return; + if (!loginButton || !profileLink || !labelsLink || !adminLink || !session || !username || !menu || !menuToggle || !logoutButton) return; menuToggle.addEventListener('click', () => { const isOpen = !menu.classList.contains('hidden'); @@ -21,6 +22,7 @@ function showSignedOut() { loginButton.classList.remove('hidden'); profileLink.classList.add('hidden'); + labelsLink.classList.add('hidden'); adminLink.classList.add('hidden'); session.classList.add('hidden'); logoutButton.classList.add('hidden'); @@ -29,6 +31,7 @@ function showSignedIn(user) { loginButton.classList.add('hidden'); profileLink.classList.remove('hidden'); + labelsLink.classList.remove('hidden'); adminLink.classList.toggle('hidden', !user.is_admin); session.classList.remove('hidden'); logoutButton.classList.remove('hidden'); diff --git a/frontend/templates/admin.html b/frontend/templates/admin.html index 4f406f9..b763f87 100644 --- a/frontend/templates/admin.html +++ b/frontend/templates/admin.html @@ -20,6 +20,7 @@ Home Sign in + diff --git a/frontend/templates/feed.html b/frontend/templates/feed.html index 1c630ee..fddf4ae 100644 --- a/frontend/templates/feed.html +++ b/frontend/templates/feed.html @@ -20,6 +20,7 @@ Home Sign in +