Label editing

This commit is contained in:
Olaf
2026-08-24 21:24:39 +02:00
parent 2810fd914d
commit 95992cc466
17 changed files with 259 additions and 7 deletions
+7 -3
View File
@@ -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 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 | | Username | Password | Role |
| --- | --- | --- | | --- | --- | --- |
| `alice` | `secret123` | administrator | | `alice` | `secret123` | administrator |
| `bob` | `secret123` | standard user | | `bob` | `secret123` | standard user |
These credentials are for development only. Change the authentication and seeding design before deploying publicly. 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 ## Run The Backend
Activate the virtual environment, then run uvicorn from the repository root: Activate the virtual environment, then run uvicorn from the repository root:
@@ -67,6 +69,7 @@ Open these URLs:
- Public feed: <http://localhost:8000/> - Public feed: <http://localhost:8000/>
- User feed: <http://localhost:8000/alice> - User feed: <http://localhost:8000/alice>
- Profile settings: <http://localhost:8000/profile> - Profile settings: <http://localhost:8000/profile>
- Labels: <http://localhost:8000/labels>
- Admin page: <http://localhost:8000/admin> - Admin page: <http://localhost:8000/admin>
- Web login: <http://localhost:8000/login> - Web login: <http://localhost:8000/login>
- Health check: <http://localhost:8000/health> - Health check: <http://localhost:8000/health>
@@ -147,9 +150,9 @@ docker compose up --build
The services are available at: The services are available at:
- LinkLog through Traefik: <http://localhost/> - LinkLog through Traefik: <http://example.com/>
- Direct application port: <http://localhost:8000/> - Direct application port: <http://localhost:8000/>
- 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 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. 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. 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 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`. 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: Admin plugin requests must include the administrator's token:
+6
View File
@@ -367,3 +367,9 @@ Display the date as "2026 June 18 - 20:22"
### Assistant outcome ### 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. 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.
+1
View File
@@ -64,6 +64,7 @@
60. when editing lables I want to be able to selec multiple tags or add one myself. 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) 61. Make the tags filtering case incensitive (but maintain case of tags)
62. Display the date as "2026 June 18 - 20:22" 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 ## Future entries
+21
View File
@@ -6,6 +6,7 @@ from pydantic import BaseModel
from backend.app.api.dependencies import require_admin from backend.app.api.dependencies import require_admin
from backend.app.database import get_connection, hash_password from backend.app.database import get_connection, hash_password
from backend.app.services.link_service import delete_label
router = APIRouter() router = APIRouter()
@@ -127,6 +128,26 @@ def delete_user(user_id: str, current_user: dict = Depends(require_admin)):
return {'status': 'deleted', 'id': user_id} 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') @router.get('/plugins')
def list_plugins(_: dict = Depends(require_admin)): def list_plugins(_: dict = Depends(require_admin)):
with get_connection() as conn: with get_connection() as conn:
+36
View File
@@ -6,6 +6,7 @@ from pydantic import BaseModel
from backend.app.api.dependencies import get_current_user from backend.app.api.dependencies import get_current_user
from backend.app.database import AVATARS_DIR, get_connection, hash_password from backend.app.database import AVATARS_DIR, get_connection, hash_password
from backend.app.services.link_service import create_label, delete_label, list_user_labels, update_label
router = APIRouter() router = APIRouter()
@@ -27,6 +28,10 @@ class UserPluginConfigUpdate(BaseModel):
hashtag: str | None = None hashtag: str | None = None
class LabelUpdate(BaseModel):
name: str
@router.get('/me') @router.get('/me')
def get_current_user_profile(user: dict = Depends(get_current_user)): def get_current_user_profile(user: dict = Depends(get_current_user)):
with get_connection() as conn: with get_connection() as conn:
@@ -81,6 +86,37 @@ def update_password(payload: PasswordUpdate, user: dict = Depends(get_current_us
return {'status': 'password_updated'} 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') @router.post('/avatar')
async def upload_avatar( async def upload_avatar(
avatar: UploadFile = File(...), avatar: UploadFile = File(...),
+4
View File
@@ -94,6 +94,10 @@ CREATE INDEX IF NOT EXISTS idx_link_tags_tag_id ON link_tags(tag_id);
'''), '''),
(3, ''' (3, '''
UPDATE tags SET name = '#' || name WHERE name NOT LIKE '#%'; 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);
'''), '''),
] ]
+5
View File
@@ -40,6 +40,11 @@ async def user_profile_page(request: Request):
return templates.TemplateResponse(request, 'user_profile.html', {}) 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) @app.get('/login', response_class=HTMLResponse)
async def login_page(request: Request): async def login_page(request: Request):
return templates.TemplateResponse(request, 'login.html', {}) return templates.TemplateResponse(request, 'login.html', {})
+62
View File
@@ -172,3 +172,65 @@ def list_tags():
tags.append(row['name']) tags.append(row['name'])
seen.add(row['name'].casefold()) seen.add(row['name'].casefold())
return tags 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
+26
View File
@@ -84,6 +84,32 @@ def test_admin_can_toggle_privileges_without_removing_last_admin():
assert last_admin.status_code == 400 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(): def test_submit_link_stores_cleaned_url_and_public_feed():
response = client.post('/api/links', headers=login_headers(), json={ response = client.post('/api/links', headers=login_headers(), json={
'title': 'Example page', 'title': 'Example page',
+4 -2
View File
@@ -7,7 +7,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) == 3 assert get_schema_version(connection) == 4
tables = { tables = {
row[0] row[0]
for row in connection.execute( for row in connection.execute(
@@ -17,11 +17,13 @@ def test_database_migrations_are_versioned_and_idempotent():
assert { assert {
'users', 'tokens', 'links', 'plugins', 'user_plugin_config', 'tags', 'link_tags' 'users', 'tokens', 'links', 'plugins', 'user_plugin_config', 'tags', 'link_tags'
} <= tables } <= tables
columns = {row[1] for row in connection.execute('PRAGMA table_info(tags)')}
assert 'created_by' in columns
seed_default_tags(connection) seed_default_tags(connection)
seeded_tags = {row[0] for row in connection.execute('SELECT name FROM tags')} seeded_tags = {row[0] for row in connection.execute('SELECT name FROM tags')}
assert set(DEFAULT_TAGS) <= seeded_tags assert set(DEFAULT_TAGS) <= seeded_tags
apply_migrations(connection) apply_migrations(connection)
assert get_schema_version(connection) == 3 assert get_schema_version(connection) == 4
connection.close() connection.close()
+28 -1
View File
@@ -1,5 +1,6 @@
(() => { (() => {
const pluginList = document.querySelector('#plugin-list'); const pluginList = document.querySelector('#plugin-list');
const adminLabelList = document.querySelector('#admin-label-list');
const userList = document.querySelector('#user-list'); const userList = document.querySelector('#user-list');
const userForm = document.querySelector('#user-form'); const userForm = document.querySelector('#user-form');
const adminControls = document.querySelector('#admin-controls'); 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) { function renderUsers(users) {
userList.replaceChildren(...users.map((user) => { userList.replaceChildren(...users.map((user) => {
const row = document.createElement('div'); const row = document.createElement('div');
@@ -95,7 +121,7 @@ async function loadAdminState() {
return; return;
} }
await Promise.all([loadUsers(), loadPlugins()]); await Promise.all([loadUsers(), loadPlugins(), loadLabels()]);
showAdminState(true); showAdminState(true);
} }
@@ -174,5 +200,6 @@ loadAdminState().catch((error) => {
adminAuthNotice.classList.remove('hidden'); adminAuthNotice.classList.remove('hidden');
userList.textContent = ''; userList.textContent = '';
pluginList.textContent = ''; pluginList.textContent = '';
adminLabelList.textContent = '';
}); });
})(); })();
+4 -1
View File
@@ -1,6 +1,7 @@
(() => { (() => {
const loginButton = document.querySelector('#auth-login-button'); const loginButton = document.querySelector('#auth-login-button');
const profileLink = document.querySelector('#auth-profile-link'); const profileLink = document.querySelector('#auth-profile-link');
const labelsLink = document.querySelector('#auth-labels-link');
const adminLink = document.querySelector('#auth-admin-link'); const adminLink = document.querySelector('#auth-admin-link');
const session = document.querySelector('#auth-session'); const session = document.querySelector('#auth-session');
const username = document.querySelector('#auth-username'); const username = document.querySelector('#auth-username');
@@ -10,7 +11,7 @@
const menuToggle = document.querySelector('.menu-toggle'); const menuToggle = document.querySelector('.menu-toggle');
const logoutButton = document.querySelector('#logout-button'); 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', () => { menuToggle.addEventListener('click', () => {
const isOpen = !menu.classList.contains('hidden'); const isOpen = !menu.classList.contains('hidden');
@@ -21,6 +22,7 @@
function showSignedOut() { function showSignedOut() {
loginButton.classList.remove('hidden'); loginButton.classList.remove('hidden');
profileLink.classList.add('hidden'); profileLink.classList.add('hidden');
labelsLink.classList.add('hidden');
adminLink.classList.add('hidden'); adminLink.classList.add('hidden');
session.classList.add('hidden'); session.classList.add('hidden');
logoutButton.classList.add('hidden'); logoutButton.classList.add('hidden');
@@ -29,6 +31,7 @@
function showSignedIn(user) { function showSignedIn(user) {
loginButton.classList.add('hidden'); loginButton.classList.add('hidden');
profileLink.classList.remove('hidden'); profileLink.classList.remove('hidden');
labelsLink.classList.remove('hidden');
adminLink.classList.toggle('hidden', !user.is_admin); adminLink.classList.toggle('hidden', !user.is_admin);
session.classList.remove('hidden'); session.classList.remove('hidden');
logoutButton.classList.remove('hidden'); logoutButton.classList.remove('hidden');
+5
View File
@@ -20,6 +20,7 @@
<a id="auth-home-link" href="/">Home</a> <a id="auth-home-link" href="/">Home</a>
<a id="auth-login-button" href="/login">Sign in</a> <a id="auth-login-button" href="/login">Sign in</a>
<a id="auth-profile-link" class="hidden" href="/profile">Profile</a> <a id="auth-profile-link" class="hidden" href="/profile">Profile</a>
<a id="auth-labels-link" class="hidden" href="/labels">Labels</a>
<a id="auth-admin-link" class="hidden" href="/admin">Admin</a> <a id="auth-admin-link" class="hidden" href="/admin">Admin</a>
<div id="auth-session" class="auth-session hidden"> <div id="auth-session" class="auth-session hidden">
<a id="auth-username" class="user-name" href="/"></a> <a id="auth-username" class="user-name" href="/"></a>
@@ -63,6 +64,10 @@
<h2>Plugins</h2> <h2>Plugins</h2>
<div id="plugin-list" class="plugin-list" aria-live="polite">Loading plugins...</div> <div id="plugin-list" class="plugin-list" aria-live="polite">Loading plugins...</div>
</section> </section>
<section class="link-item settings-panel">
<h2>Labels</h2>
<div id="admin-label-list" class="plugin-list" aria-live="polite">Loading labels...</div>
</section>
</div> </div>
</main> </main>
<script src="/static/auth-header.js?v=3"></script> <script src="/static/auth-header.js?v=3"></script>
+1
View File
@@ -20,6 +20,7 @@
<a id="auth-home-link" href="/">Home</a> <a id="auth-home-link" href="/">Home</a>
<a id="auth-login-button" href="/login">Sign in</a> <a id="auth-login-button" href="/login">Sign in</a>
<a id="auth-profile-link" class="hidden" href="/profile">Profile</a> <a id="auth-profile-link" class="hidden" href="/profile">Profile</a>
<a id="auth-labels-link" class="hidden" href="/labels">Labels</a>
<a id="auth-admin-link" class="hidden" href="/admin">Admin</a> <a id="auth-admin-link" class="hidden" href="/admin">Admin</a>
<div id="auth-session" class="auth-session hidden"> <div id="auth-session" class="auth-session hidden">
<a id="auth-username" class="user-name" href="/"></a> <a id="auth-username" class="user-name" href="/"></a>
+47
View File
@@ -0,0 +1,47 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Labels - LinkLog</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="/static/style.css" />
</head>
<body>
<header class="site-header">
<div class="container">
<div class="header-row">
<div>
<h1>Labels</h1>
<p>Manage your link labels</p>
</div>
<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">
<a id="auth-home-link" href="/">Home</a>
<a id="auth-login-button" href="/login">Sign in</a>
<a id="auth-profile-link" class="hidden" href="/profile">Profile</a>
<a id="auth-labels-link" class="hidden" href="/labels">Labels</a>
<a id="auth-admin-link" class="hidden" href="/admin">Admin</a>
<div id="auth-session" class="auth-session hidden"><a id="auth-username" class="user-name" href="/"></a></div>
<button id="logout-button" class="logout-button hidden" type="button">Sign out</button>
</nav>
</div>
</div>
</div>
</header>
<main class="container">
<p id="label-auth-notice" class="auth-notice hidden">Sign in to manage your labels. <a href="/login">Sign in</a></p>
<section id="label-controls" class="link-item settings-panel hidden">
<form id="label-form">
<label>Label <input id="label-name" name="name" type="text" placeholder="#Example" required /></label>
<button type="submit">Add label</button>
<p id="label-status" class="status" role="status"></p>
</form>
<div id="label-list" class="plugin-list"></div>
</section>
</main>
<script src="/static/auth-header.js?v=3"></script>
<script src="/static/logout.js?v=3"></script>
<script src="/static/labels.js?v=1"></script>
</body>
</html>
+1
View File
@@ -20,6 +20,7 @@
<a id="auth-home-link" href="/">Home</a> <a id="auth-home-link" href="/">Home</a>
<a id="auth-login-button" href="/login">Sign in</a> <a id="auth-login-button" href="/login">Sign in</a>
<a id="auth-profile-link" class="hidden" href="/profile">Profile</a> <a id="auth-profile-link" class="hidden" href="/profile">Profile</a>
<a id="auth-labels-link" class="hidden" href="/labels">Labels</a>
<a id="auth-admin-link" class="hidden" href="/admin">Admin</a> <a id="auth-admin-link" class="hidden" href="/admin">Admin</a>
<div id="auth-session" class="auth-session hidden"> <div id="auth-session" class="auth-session hidden">
<a id="auth-username" class="user-name" href="/"></a> <a id="auth-username" class="user-name" href="/"></a>
+1
View File
@@ -17,6 +17,7 @@
<a id="auth-home-link" href="/">Home</a> <a id="auth-home-link" href="/">Home</a>
<a id="auth-login-button" href="/login">Sign in</a> <a id="auth-login-button" href="/login">Sign in</a>
<a id="auth-profile-link" class="hidden" href="/profile">Profile</a> <a id="auth-profile-link" class="hidden" href="/profile">Profile</a>
<a id="auth-labels-link" class="hidden" href="/labels">Labels</a>
<a id="auth-admin-link" class="hidden" href="/admin">Admin</a> <a id="auth-admin-link" class="hidden" href="/admin">Admin</a>
<div id="auth-session" class="auth-session hidden"> <div id="auth-session" class="auth-session hidden">
<a id="auth-username" class="user-name" href="/"></a> <a id="auth-username" class="user-name" href="/"></a>