This tutorial extends the Todo app from Tutorial 1 with login, registration, logout, and role-based access control. By the end every todo will belong to a user and an admin role will see an extra moderation page that regular users cannot reach.
References used here: auth, permissions, requires, and the auth attributes table, identity, password, login, logout, register, after_login, superuser.
Open app.kilnx from Tutorial 1 and add a user model above the todo model. Notice the password field type, which Kilnx automatically bcrypt-hashes on write and verifies on login:
model user
id: uuid auto
email: email required unique
password: password required
role: option [admin, member] default "member"
created_at: timestamp auto
The option [admin, member] field is what we will gate routes on later. See the model reference for the full list of field types.
Below the models, add an auth block. Each attribute is written name: value (note after login: is two words):
auth
table: user
identity: email
password: password
login: /login
logout: /logout
register: /register
after login: /todos
superuser: env SUPERUSER_EMAIL
What this gives you for free:
GET /loginandGET /registerrender forms.POST /loginissues a session cookie (HTTP-only, signed withSECRET_KEY).POST /logoutends the session.POST /registercreates auserrow with the password already bcrypt-hashed.- After a successful login the user lands on
/todos. - The address in
SUPERUSER_EMAIL(read at runtime) bypasses everyrequirescheck, so you can always recover access.
Run the server:
SUPERUSER_EMAIL=you@example.com kilnx run app.kilnxOpen http://localhost:8080/register, create an account, and you should be redirected to /todos after logging in. The session cookie is now set.
Right now any user sees every todo. Add a user_id reference to the Todo model so each row is owned by someone:
model todo
id: uuid auto
user_id: reference user required
title: text required min 1 max 200
done: bool default false
due_at: timestamp
created_at: timestamp auto
The due_at field is unused for now. Tutorial 3 will use it to send overdue reminders. Restart the server and the migration adds the new columns.
Add requires auth to every endpoint that touches todos so anonymous users get redirected to /login. We also filter every query by :current_user_id, the session-bound parameter Kilnx exposes inside route bodies:
page /todos title "Todos" requires auth
query todos: SELECT id, title, done FROM todo WHERE user_id = :current_user_id ORDER BY created_at DESC
html
<h1>Todos</h1>
<p>
<a href="/todos/new">New todo</a>
<form method="post" action="/logout" style="display:inline">
<button type="submit">Log out</button>
</form>
</p>
<ul id="todo-list">
{#each todos}
{> todo_row(todo=this)}
{/each}
</ul>
action /todos/create method POST requires auth
validate todo
query: INSERT INTO todo (title, user_id) VALUES (:title, :current_user_id)
redirect /todos
Apply the same pattern to update, toggle, and delete. The WHERE clause should always include user_id = :current_user_id so users cannot mutate other people's rows by guessing IDs:
action /todos/:id/update method POST requires auth
validate todo
query: UPDATE todo SET title = :title, done = :done
WHERE id = :id AND user_id = :current_user_id
redirect /todos
action /todos/:id/toggle method POST requires auth
query: UPDATE todo SET done = NOT done
WHERE id = :id AND user_id = :current_user_id
query todo: SELECT id, title, done FROM todo
WHERE id = :id AND user_id = :current_user_id
respond
html
{> todo_row(todo=todo)}
action /todos/:id/delete method POST requires auth
query: DELETE FROM todo WHERE id = :id AND user_id = :current_user_id
redirect /todos
The same requires auth line goes on /todos/new and /todos/:id/edit. See requires for the full grammar.
Try it: log out, hit http://localhost:8080/todos, and you should be bounced to the login form. Log in as a different user and confirm you only see your own todos.
Now we add role-based gating. Add a permissions block:
permissions
admin: all
member: read todo where user_id = current_user, write todo where user_id = current_user
What each rule means:
admin: all. Theadminrole can read and write every model.member: read todo where user_id = current_user, write todo where user_id = current_user. Members can only see and modify their own todos. Thewhereclause is enforced even when a route does not include the filter manually.
See permissions for the rule grammar.
Add an admin-only page that lists every todo across users. The route opts in via requires admin:
page /admin/todos title "All todos" requires admin
query rows: SELECT t.id, t.title, t.done, u.email
FROM todo t JOIN user u ON u.id = t.user_id
ORDER BY t.created_at DESC
html
<h1>All todos</h1>
<table>
<thead><tr><th>User</th><th>Title</th><th>Done</th></tr></thead>
<tbody>
{#each rows}
<tr>
<td>{this.email}</td>
<td>{this.title}</td>
<td>{#if this.done}yes{#else}no{/if}</td>
</tr>
{/each}
</tbody>
</table>
Since requires admin is checked before the body runs, a logged-in member who tries http://localhost:8080/admin/todos gets a 403. The user identified by SUPERUSER_EMAIL bypasses the check thanks to superuser.
To create your first admin, either:
- Log in as
SUPERUSER_EMAIL, or - Promote an existing user with one SQL statement:
sqlite3 adequa.db "UPDATE user SET role = 'admin' WHERE email = 'you@example.com';"- Register two accounts:
alice@example.comandbob@example.com. - Log in as Alice, create two todos.
- Log out, log in as Bob, hit
/todos. You should see an empty list. - Try
http://localhost:8080/admin/todosas Bob. 403. - Promote Bob to
adminwith the SQL above and revisit the page. Both users' todos appear.
The full app.kilnx after Tutorials 1 and 2:
model user
id: uuid auto
email: email required unique
password: password required
role: option [admin, member] default "member"
created_at: timestamp auto
model todo
id: uuid auto
user_id: reference user required
title: text required min 1 max 200
done: bool default false
due_at: timestamp
created_at: timestamp auto
auth
table: user
identity: email
password: password
login: /login
logout: /logout
register: /register
after login: /todos
superuser: env SUPERUSER_EMAIL
permissions
admin: all
member: read todo where user_id = current_user, write todo where user_id = current_user
page /todos title "Todos" requires auth
query todos: SELECT id, title, done FROM todo
WHERE user_id = :current_user_id
ORDER BY created_at DESC
html
<h1>Todos</h1>
<p>
<a href="/todos/new">New todo</a>
<form method="post" action="/logout" style="display:inline">
<button type="submit">Log out</button>
</form>
</p>
<ul id="todo-list">
{#each todos}
{> todo_row(todo=this)}
{/each}
</ul>
fragment todo_row(todo)
html
<li id="todo-{todo.id}" class="todo">
<input type="checkbox"
hx-post="/todos/{todo.id}/toggle"
hx-target="#todo-{todo.id}"
hx-swap="outerHTML"
{#if todo.done}checked{/if}>
<span class="{#if todo.done}done{/if}">{todo.title}</span>
<a href="/todos/{todo.id}/edit">edit</a>
<form method="post" action="/todos/{todo.id}/delete" style="display:inline">
<button type="submit">delete</button>
</form>
</li>
page /todos/new title "New todo" requires auth
html
<h1>New todo</h1>
<form method="post" action="/todos/create">
<label>Title <input name="title" required></label>
<button type="submit">Create</button>
<a href="/todos">Cancel</a>
</form>
action /todos/create method POST requires auth
validate todo
query: INSERT INTO todo (title, user_id) VALUES (:title, :current_user_id)
redirect /todos
page /todos/:id/edit title "Edit todo" requires auth
query todo: SELECT id, title, done FROM todo
WHERE id = :id AND user_id = :current_user_id
html
<h1>Edit todo</h1>
<form method="post" action="/todos/{todo.id}/update">
<label>Title <input name="title" value="{todo.title}" required></label>
<label><input type="checkbox" name="done" {#if todo.done}checked{/if}> Done</label>
<button type="submit">Save</button>
<a href="/todos">Cancel</a>
</form>
action /todos/:id/update method POST requires auth
validate todo
query: UPDATE todo SET title = :title, done = :done
WHERE id = :id AND user_id = :current_user_id
redirect /todos
action /todos/:id/toggle method POST requires auth
query: UPDATE todo SET done = NOT done
WHERE id = :id AND user_id = :current_user_id
query todo: SELECT id, title, done FROM todo
WHERE id = :id AND user_id = :current_user_id
respond
html
{> todo_row(todo=todo)}
action /todos/:id/delete method POST requires auth
query: DELETE FROM todo WHERE id = :id AND user_id = :current_user_id
redirect /todos
page /admin/todos title "All todos" requires admin
query rows: SELECT t.id, t.title, t.done, u.email
FROM todo t JOIN user u ON u.id = t.user_id
ORDER BY t.created_at DESC
html
<h1>All todos</h1>
<table>
<thead><tr><th>User</th><th>Title</th><th>Done</th></tr></thead>
<tbody>
{#each rows}
<tr>
<td>{this.email}</td>
<td>{this.title}</td>
<td>{#if this.done}yes{#else}no{/if}</td>
</tr>
{/each}
</tbody>
</table>
Add a background process that emails users when one of their todos is overdue: Tutorial 3.