users: add anyone Tailscale lets in, keyed by login
Users are no longer configured: the first request from a login adds it, and X-User-Slug from caddy-tailscale names it. This edits 0001_init.sql rather than migrating, so a local docket.db from before has to be deleted.
Assisted-by: Claude Opus 5.5 via Claude Code
diff --git a/docs/DESIGN.md b/docs/DESIGN.md
index b55387a..d3d555c 100644
--- a/docs/DESIGN.md
+++ b/docs/DESIGN.md
@@ -19,8 +19,9 @@ A small self-hosted webapp for two people to jointly manage household email acco
## Users and access
- Two app users.
-- Access is via Tailscale. The app binds to localhost behind `tailscale serve` and identifies users from the `Tailscale-User-Login` header. Requests without a user identity are rejected.
-- Each user has a display name and a short slug.
+- Access is via Tailscale, which is the whole of authentication: anyone the tailnet lets reach Docket is a user, added on their first request. Docket keeps no list of people.
+- The app binds to localhost behind Caddy with [caddy-tailscale](https://github.com/tailscale/caddy-tailscale), which sets two headers from the tailnet identity: `Tailscale-User-Login` (the full login, `{http.auth.user.id}`) identifies the user, and `X-User-Slug` carries a short name the Caddyfile maps from the login. Requests without a login are rejected.
+- A user's slug is what Docket shows for them. It follows the latest `X-User-Slug`, falls back to the part of the login before the `@`, and need not be unique.
## Credentials and accounts
diff --git a/fly.kdl b/fly.kdl
index 429e2b8..090bcc5 100644
--- a/fly.kdl
+++ b/fly.kdl
@@ -2,8 +2,8 @@
// /etc/docket.kdl (fly.toml points DOCKET_CONFIG at it). Binds wide
// because Fly's edge routes to the machine, not to localhost. `dev` is
// the whole of what makes them demos: fixture data instead of JMAP, and
-// a stand-in identity in place of the Tailscale-User-Login header that
-// tailscale serve supplies in front of the real deployment. The database
+// a stand-in identity in place of the identity headers that Caddy
+// (caddy-tailscale) supplies in front of the real deployment. The database
// sits on the machine's own disk, which a deploy replaces, so each deploy
// starts from the fixtures again.
bind "0.0.0.0:3000"
diff --git a/fly.toml b/fly.toml
index 7c67ca8..ded0730 100644
--- a/fly.toml
+++ b/fly.toml
@@ -3,8 +3,8 @@
# review-app action in .github/workflows/preview.yml, which overrides
# the app name with the PR's). Both read the config baked into the image
# from fly.kdl: fixture data instead of JMAP, and a stand-in identity in
-# place of the Tailscale-User-Login header that tailscale serve supplies
-# in front of the real deployment. No volume, so every deploy starts
+# place of the identity headers that Caddy (caddy-tailscale) supplies in
+# front of the real deployment. No volume, so every deploy starts
# from the fixtures again.
#
# The app name is Fly's, not GitHub's, so it has to be unique across all
diff --git a/migrations/0001_init.sql b/migrations/0001_init.sql
index eb3adac..ac7c1e5 100644
--- a/migrations/0001_init.sql
+++ b/migrations/0001_init.sql
@@ -2,11 +2,12 @@
-- stays the source of truth for messages and folders; JMAP import fills
-- these tables and fixtures seed them in dev.
+-- Everyone Tailscale lets in, added on their first request.
CREATE TABLE users (
- slug TEXT PRIMARY KEY,
- name TEXT NOT NULL,
-- The Tailscale-User-Login value that identifies this user.
- login TEXT NOT NULL UNIQUE
+ login TEXT PRIMARY KEY,
+ -- The short name shown for them, from X-User-Slug; not unique.
+ slug TEXT NOT NULL
) STRICT;
CREATE TABLE accounts (
@@ -49,7 +50,7 @@ CREATE TABLE messages (
from_addr TEXT,
state TEXT CHECK (state IN ('inbox', 'do', 'wait', 'watch', 'done')),
folder TEXT REFERENCES folders (name),
- sent_by TEXT REFERENCES users (slug),
+ sent_by TEXT REFERENCES users (login),
sent_to TEXT,
UNIQUE (account, message_id),
CHECK (
@@ -67,13 +68,13 @@ CREATE INDEX messages_state ON messages (state);
CREATE TABLE assignees (
message INTEGER NOT NULL REFERENCES messages (id),
- user TEXT NOT NULL REFERENCES users (slug),
+ user TEXT NOT NULL REFERENCES users (login),
PRIMARY KEY (message, user)
) STRICT;
-- Per-person read tracking; $seen is shared and can't say who read what.
CREATE TABLE reads (
- user TEXT NOT NULL REFERENCES users (slug),
+ user TEXT NOT NULL REFERENCES users (login),
message INTEGER NOT NULL REFERENCES messages (id),
PRIMARY KEY (user, message)
) STRICT;
@@ -81,7 +82,7 @@ CREATE TABLE reads (
CREATE TABLE comments (
id INTEGER PRIMARY KEY,
thread INTEGER NOT NULL REFERENCES threads (id),
- author TEXT NOT NULL REFERENCES users (slug),
+ author TEXT NOT NULL REFERENCES users (login),
at TEXT NOT NULL,
text TEXT NOT NULL
) STRICT;
@@ -92,7 +93,7 @@ CREATE INDEX comments_thread ON comments (thread);
CREATE TABLE history (
id INTEGER PRIMARY KEY,
message INTEGER NOT NULL REFERENCES messages (id),
- user TEXT NOT NULL REFERENCES users (slug),
+ user TEXT NOT NULL REFERENCES users (login),
at TEXT NOT NULL,
event TEXT NOT NULL
) STRICT;
diff --git a/src/bin/docket/config.rs b/src/bin/docket/config.rs
index efe1aab..9934def 100644
--- a/src/bin/docket/config.rs
+++ b/src/bin/docket/config.rs
@@ -18,8 +18,8 @@ const DEFAULT_DATABASE: &str = "docket.db";
#[derive(Debug)]
pub struct Config {
- /// Address to listen on. Keep it on localhost; `tailscale serve`
- /// fronts it and supplies the user identity header.
+ /// Address to listen on. Keep it on localhost; Caddy (caddy-tailscale)
+ /// fronts it and supplies the user identity headers.
pub bind: SocketAddr,
/// Serve without Tailscale: requests with no identity header act as a
diff --git a/src/fixtures.rs b/src/fixtures.rs
index 3deac30..c0a2170 100644
--- a/src/fixtures.rs
+++ b/src/fixtures.rs
@@ -13,6 +13,9 @@ use crate::store::Store;
pub const WATER: MessageId = 7;
pub const ELI_PRACTICE: MessageId = 20;
+pub const ALEX: &str = "alex@example.com";
+pub const SAM: &str = "sam@example.com";
+
/// Thursday, 1 October 2026, at noon.
pub fn now() -> DateTime {
at(10, 1, 12, 0)
@@ -127,18 +130,7 @@ pub fn store() -> Result<Store, Error> {
/// Writes the fixtures into an empty database.
pub fn seed(store: &Store) -> Result<(), Error> {
- let users = [
- User {
- slug: "alex".into(),
- name: "Alex".into(),
- login: "alex@example.com".into(),
- },
- User {
- slug: "sam".into(),
- name: "Sam".into(),
- login: "sam@example.com".into(),
- },
- ];
+ let users = [User::new(ALEX, Some("Alex")), User::new(SAM, Some("Sam"))];
let accounts = [
Account {
slug: "household".into(),
@@ -172,7 +164,7 @@ pub fn seed(store: &Store) -> Result<(), Error> {
);
b.comment(
1,
- "alex",
+ ALEX,
at(9, 20, 19, 30),
"The Hendersons paid about $1,600 for theirs.",
);
@@ -180,7 +172,7 @@ pub fn seed(store: &Store) -> Result<(), Error> {
2,
1,
at(9, 21, 8, 40),
- ("sam", &["Northwind Roofing"]),
+ (SAM, &["Northwind Roofing"]),
"Does this include the downspout on the north side?",
(&["Alex"], &["Pat Lee"]),
);
@@ -210,7 +202,7 @@ pub fn seed(store: &Store) -> Result<(), Error> {
at(9, 30, 15, 30),
school,
"Please return the signed permission form by Friday.",
- (Do, Some("School"), &["sam"]),
+ (Do, Some("School"), &[SAM]),
&[],
);
b.recv(
@@ -219,10 +211,10 @@ pub fn seed(store: &Store) -> Result<(), Error> {
at(10, 1, 9, 12),
school,
"A schedule update for the museum trip: buses now return at 4:15, so pickup moves to 4:30 PM at the front entrance.",
- (Inbox, Some("School"), &["alex"]),
+ (Inbox, Some("School"), &[ALEX]),
&[],
);
- b.comment(2, "sam", at(10, 1, 9, 20), "You have pickup that day.");
+ b.comment(2, SAM, at(10, 1, 9, 20), "You have pickup that day.");
b.thread(3, "household", "Service interruption Oct 4");
b.recv(
@@ -264,10 +256,10 @@ pub fn seed(store: &Store) -> Result<(), Error> {
at(10, 1, 8, 15),
("Dr. Patel’s Office", "frontdesk@patelpeds.com"),
"This confirms Eli’s appointment on Thursday, October 8 at 3:00 PM. Please arrive ten minutes early.",
- (Inbox, Some("Medical"), &["alex"]),
+ (Inbox, Some("Medical"), &[ALEX]),
&[],
);
- b.comment(6, "sam", at(10, 1, 9, 41), "Thursday at 3, I’ll take him.");
+ b.comment(6, SAM, at(10, 1, 9, 41), "Thursday at 3, I’ll take him.");
b.thread(7, "household", "Claim 4471: adjuster visit");
b.recv(
@@ -276,14 +268,14 @@ pub fn seed(store: &Store) -> Result<(), Error> {
at(9, 23, 13, 0),
("State Farm", "claims@statefarm.com"),
"Claim 4471 is open. An adjuster will contact you within five business days to schedule a visit.",
- (Wait, Some("House"), &["alex"]),
+ (Wait, Some("House"), &[ALEX]),
&[],
);
b.sent(
12,
7,
at(9, 24, 9, 30),
- ("alex", &["State Farm"]),
+ (ALEX, &["State Farm"]),
"Thanks. Mornings work best for us, any day next week.",
(&[], &[]),
);
@@ -328,7 +320,7 @@ pub fn seed(store: &Store) -> Result<(), Error> {
at(9, 29, 17, 0),
("Coach Rivera", "rivera@lincolnhigh.org"),
"Practice moves to Thursday this week, 4 to 6 PM. Bring water.",
- (Watch, None, &["sam"]),
+ (Watch, None, &[SAM]),
&[],
);
@@ -338,9 +330,9 @@ pub fn seed(store: &Store) -> Result<(), Error> {
.messages
.iter()
.filter(|m| m.at < cutoff)
- .flat_map(|m| [("alex", m.id), ("sam", m.id)])
+ .flat_map(|m| [(ALEX, m.id), (SAM, m.id)])
.collect();
- reads.push(("sam", 5));
+ reads.push((SAM, 5));
store.import(|tx| {
for user in &users {
diff --git a/src/lists.rs b/src/lists.rs
index 66cbb92..791080c 100644
--- a/src/lists.rs
+++ b/src/lists.rs
@@ -49,7 +49,7 @@ pub fn sections(store: &Store, me: &User, view: &View) -> Result<Vec<Section>, E
View::ForMe => {
for state in State::LANES {
let msgs = store.messages(Filter::ForMe {
- user: &me.slug,
+ user: &me.login,
state,
})?;
let head = state.name().to_uppercase();
@@ -124,7 +124,7 @@ fn section(
#[cfg(test)]
mod tests {
use super::*;
- use crate::fixtures;
+ use crate::fixtures::{self, ALEX, SAM};
fn summary(store: &Store, user: &str, view: &View) -> Vec<(String, Vec<Vec<u32>>)> {
let me = store.user(user).unwrap().unwrap();
@@ -153,16 +153,16 @@ mod tests {
#[test]
fn for_me_groups_by_state_then_thread() {
let store = fixtures::store().unwrap();
- let sam = summary(&store, "sam", &View::ForMe);
- assert_eq!(heads(&store, "sam", &View::ForMe), ["INBOX", "DO", "WATCH"]);
+ let sam = summary(&store, SAM, &View::ForMe);
+ assert_eq!(heads(&store, SAM, &View::ForMe), ["INBOX", "DO", "WATCH"]);
let (_, groups) = &sam[0];
// Newest thread first; the roofing thread shows only its two Inbox emails.
assert_eq!(groups[0], vec![fixtures::WATER]);
assert!(groups.contains(&vec![3, 4]));
// Alex's own Inbox messages share the section with unassigned ones.
- let alex = summary(&store, "alex", &View::ForMe);
- assert_eq!(heads(&store, "alex", &View::ForMe), ["INBOX", "WAIT"]);
+ let alex = summary(&store, ALEX, &View::ForMe);
+ assert_eq!(heads(&store, ALEX, &View::ForMe), ["INBOX", "WAIT"]);
assert_eq!(
alex[0].1,
[vec![7], vec![6], vec![10], vec![8], vec![9], vec![3, 4]]
@@ -172,7 +172,7 @@ mod tests {
#[test]
fn sidebar_count() {
let store = fixtures::store().unwrap();
- let sam = store.user("sam").unwrap().unwrap();
+ let sam = store.user(SAM).unwrap().unwrap();
assert_eq!(count(&store, &sam, &View::ForMe).unwrap(), 7);
}
@@ -181,16 +181,16 @@ mod tests {
let store = fixtures::store().unwrap();
// Every assignee shares one unheaded section: Inbox newest first, Do
// oldest first.
- let inbox = summary(&store, "sam", &View::Lane(State::Inbox));
- assert_eq!(heads(&store, "sam", &View::Lane(State::Inbox)), [""]);
+ let inbox = summary(&store, SAM, &View::Lane(State::Inbox));
+ assert_eq!(heads(&store, SAM, &View::Lane(State::Inbox)), [""]);
assert_eq!(inbox[0].1[0], vec![fixtures::WATER]);
- let todo = summary(&store, "alex", &View::Lane(State::Do));
- assert_eq!(heads(&store, "alex", &View::Lane(State::Do)), [""]);
+ let todo = summary(&store, ALEX, &View::Lane(State::Do));
+ assert_eq!(heads(&store, ALEX, &View::Lane(State::Do)), [""]);
assert_eq!(todo[0].1, [vec![13], vec![5]]);
- let wait = summary(&store, "sam", &View::Lane(State::Wait));
+ let wait = summary(&store, SAM, &View::Lane(State::Wait));
assert_eq!(wait[0].1, vec![vec![11]]);
- let me = store.user("sam").unwrap().unwrap();
+ let me = store.user(SAM).unwrap().unwrap();
let watch = sections(&store, &me, &View::Lane(State::Watch)).unwrap();
assert!(watch[0].compact);
assert_eq!(watch[0].groups[0].rows[0].id, 14);
@@ -205,14 +205,14 @@ mod tests {
#[test]
fn search_covers_every_message() {
let store = fixtures::store().unwrap();
- let hits = summary(&store, "sam", &View::Search("downspout".into()));
+ let hits = summary(&store, SAM, &View::Search("downspout".into()));
assert_eq!(hits[0].1, vec![vec![2, 4]]);
- let by_sender = summary(&store, "sam", &View::Search("ALEX".into()));
+ let by_sender = summary(&store, SAM, &View::Search("ALEX".into()));
assert_eq!(by_sender[0].1, vec![vec![12]]);
- let by_subject = summary(&store, "sam", &View::Search("checkup".into()));
+ let by_subject = summary(&store, SAM, &View::Search("checkup".into()));
assert_eq!(by_subject[0].1, vec![vec![15]]);
- assert!(summary(&store, "sam", &View::Search(" ".into())).is_empty());
- assert!(summary(&store, "sam", &View::Search("zzz".into())).is_empty());
+ assert!(summary(&store, SAM, &View::Search(" ".into())).is_empty());
+ assert!(summary(&store, SAM, &View::Search("zzz".into())).is_empty());
}
#[test]
diff --git a/src/model.rs b/src/model.rs
index 37bc54e..603aeaf 100644
--- a/src/model.rs
+++ b/src/model.rs
@@ -63,12 +63,29 @@ impl fmt::Display for State {
}
}
-#[derive(Debug, Clone)]
+/// Someone Tailscale let in. Tailscale is the whole of authentication, so
+/// anyone who reaches Docket is a user.
+#[derive(Debug, Clone, PartialEq, Eq)]
pub struct User {
- pub slug: String,
- pub name: String,
/// The `Tailscale-User-Login` value that identifies this user.
pub login: String,
+ /// The short name shown for them. Not unique: two logins can share one.
+ pub slug: String,
+}
+
+impl User {
+ /// The slug the proxy sent, or the part of the login before the `@`.
+ pub fn new(login: &str, slug: Option<&str>) -> Self {
+ let fallback = login.split_once('@').map_or(login, |(name, _)| name);
+ let slug = slug
+ .map(str::trim)
+ .filter(|s| !s.is_empty())
+ .unwrap_or(fallback);
+ Self {
+ login: login.to_owned(),
+ slug: slug.to_owned(),
+ }
+ }
}
#[derive(Debug, Clone)]
@@ -91,7 +108,7 @@ pub struct Thread {
pub struct Values {
pub state: State,
pub folder: Option<String>,
- /// User slugs.
+ /// User logins.
pub assignees: BTreeSet<String>,
}
@@ -102,7 +119,7 @@ pub enum Kind {
addr: String,
values: Values,
},
- /// Sent by one of us. Sent messages carry no values.
+ /// Sent by one of us (`by` is a login). Sent messages carry no values.
Sent { by: String, to: Vec<String> },
}
@@ -133,6 +150,7 @@ impl Message {
pub struct Comment {
pub id: CommentId,
pub thread: ThreadId,
+ /// A login.
pub author: String,
pub at: DateTime,
pub text: String,
@@ -142,6 +160,7 @@ pub struct Comment {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Event {
pub message: MessageId,
+ /// A login.
pub user: String,
pub at: DateTime,
pub text: String,
@@ -151,6 +170,16 @@ pub struct Event {
mod tests {
use super::*;
+ #[test]
+ fn slugs_fall_back_to_the_login() {
+ let named = User::new("alex@example.com", Some(" Al "));
+ assert_eq!(named.login, "alex@example.com");
+ assert_eq!(named.slug, "Al");
+ assert_eq!(User::new("alex@example.com", None).slug, "alex");
+ assert_eq!(User::new("alex@example.com", Some(" ")).slug, "alex");
+ assert_eq!(User::new("tagged-device", None).slug, "tagged-device");
+ }
+
#[test]
fn state_slugs_round_trip() {
for state in State::ALL {
diff --git a/src/routes.rs b/src/routes.rs
index 4a8e212..aa41338 100644
--- a/src/routes.rs
+++ b/src/routes.rs
@@ -17,7 +17,10 @@ use crate::model::{MessageId, State, ThreadId, User};
use crate::store::{Change, Store};
use crate::views::{self, Page, ThreadView};
+// caddy-tailscale sets both from the tailnet identity, overwriting whatever
+// the client sent.
const IDENTITY_HEADER: &str = "Tailscale-User-Login";
+const SLUG_HEADER: &str = "X-User-Slug";
const DEV_COOKIE: &str = "docket_dev_user";
#[derive(Clone)]
@@ -60,7 +63,8 @@ async fn health() -> &'static str {
"ok"
}
-/// The requesting user, from Tailscale's identity header.
+/// The requesting user, from the identity headers caddy-tailscale adds. Anyone
+/// Tailscale lets through is a user; the first request adds them.
struct Me(User);
impl FromRequestParts<AppState> for Me {
@@ -73,17 +77,15 @@ impl FromRequestParts<AppState> for Me {
return Err(Error::Forbidden("cross-site request"));
}
let store = &state.store;
- if let Some(login) = header(&parts.headers, IDENTITY_HEADER) {
- return store
- .user_by_login(login)?
- .map(Me)
- .ok_or(Error::Unauthenticated);
+ if let Some(login) = header(&parts.headers, IDENTITY_HEADER).filter(|l| !l.is_empty()) {
+ let slug = header(&parts.headers, SLUG_HEADER);
+ return Ok(Me(store.sign_in(login, slug)?));
}
if !state.dev {
return Err(Error::Unauthenticated);
}
let chosen = match cookie(&parts.headers, DEV_COOKIE) {
- Some(slug) => store.user(slug)?,
+ Some(login) => store.user(login)?,
None => None,
};
let user = match chosen {
@@ -173,10 +175,10 @@ fn render(
folders: store.folders()?,
nav,
sections: lists::sections(store, me, view)?,
- unread: store.unread(&me.slug)?,
+ unread: store.unread(&me.login)?,
selected,
thread,
- flash: store.take_flash(&me.slug),
+ flash: store.take_flash(&me.login),
here,
dev: state.dev,
}))
@@ -190,8 +192,8 @@ fn open_thread(store: &Store, me: &User, id: MessageId) -> Result<ThreadView, Er
.thread_messages(msg.thread)?
.last()
.map_or(id, |m| m.id);
- store.mark_read(&me.slug, id)?;
- store.mark_read(&me.slug, latest)?;
+ store.mark_read(&me.login, id)?;
+ store.mark_read(&me.login, latest)?;
Ok(ThreadView {
thread: store.thread(msg.thread)?.ok_or(Error::NotFound("thread"))?,
read_only: store
@@ -272,7 +274,7 @@ async fn edit(
change: Change,
back: &str,
) -> Result<Redirect, Error> {
- state.store.edit(&me.slug, id, change)?;
+ state.store.edit(&me.login, id, change)?;
Ok(Redirect::to(safe_back(back)))
}
@@ -288,7 +290,7 @@ async fn add_comment(
Path(thread): Path<ThreadId>,
Form(form): Form<CommentForm>,
) -> Result<Redirect, Error> {
- state.store.add_comment(&me.slug, thread, &form.text)?;
+ state.store.add_comment(&me.login, thread, &form.text)?;
Ok(Redirect::to(safe_back(&form.back)))
}
@@ -302,7 +304,7 @@ async fn undo(
Me(me): Me,
Form(form): Form<BackForm>,
) -> Result<Redirect, Error> {
- state.store.undo(&me.slug)?;
+ state.store.undo(&me.login)?;
Ok(Redirect::to(safe_back(&form.back)))
}
diff --git a/src/store.rs b/src/store.rs
index 2a9412f..7e14034 100644
--- a/src/store.rs
+++ b/src/store.rs
@@ -122,27 +122,26 @@ impl Store {
let inner = self.lock();
let mut stmt = inner
.conn
- .prepare_cached("SELECT slug, name, login FROM users ORDER BY rowid")?;
+ .prepare_cached("SELECT login, slug FROM users ORDER BY rowid")?;
let users = stmt
.query_map([], user_from_row)?
.collect::<Result<_, _>>()?;
Ok(users)
}
- pub fn user(&self, slug: &str) -> Result<Option<User>, Error> {
- load_user(&self.lock().conn, slug)
+ pub fn user(&self, login: &str) -> Result<Option<User>, Error> {
+ load_user(&self.lock().conn, login)
}
- pub fn user_by_login(&self, login: &str) -> Result<Option<User>, Error> {
- let user = self
- .lock()
- .conn
- .query_row(
- "SELECT slug, name, login FROM users WHERE login = ?1",
- [login],
- user_from_row,
- )
- .optional()?;
+ /// The user behind a request Tailscale identified, added on their first
+ /// request. Their slug follows whatever the proxy sends.
+ pub fn sign_in(&self, login: &str, slug: Option<&str>) -> Result<User, Error> {
+ let user = User::new(login, slug);
+ self.lock().conn.execute(
+ "INSERT INTO users (login, slug) VALUES (?1, ?2)
+ ON CONFLICT (login) DO UPDATE SET slug = excluded.slug WHERE slug != excluded.slug",
+ params![user.login, user.slug],
+ )?;
Ok(user)
}
@@ -210,7 +209,7 @@ impl Store {
load_messages(
conn,
r"t.subject LIKE ?1 ESCAPE '\' OR m.from_name LIKE ?1 ESCAPE '\'
- OR u.name LIKE ?1 ESCAPE '\' OR m.body LIKE ?1 ESCAPE '\'",
+ OR u.slug LIKE ?1 ESCAPE '\' OR m.body LIKE ?1 ESCAPE '\'",
[pattern],
)
}
@@ -319,12 +318,12 @@ impl Store {
}
}
}
- Change::ToggleAssignee(slug) => {
- let name = load_user(&tx, &slug)?.ok_or(Error::NotFound("user"))?.name;
- if next.assignees.remove(&slug) {
+ Change::ToggleAssignee(login) => {
+ let name = load_user(&tx, &login)?.ok_or(Error::NotFound("user"))?.slug;
+ if next.assignees.remove(&login) {
format!("Unassigned {name}")
} else {
- next.assignees.insert(slug);
+ next.assignees.insert(login);
format!("Assigned {name}")
}
}
@@ -403,8 +402,8 @@ pub struct Import<'a> {
impl Import<'_> {
pub fn user(&self, user: &User) -> Result<(), Error> {
self.tx.execute(
- "INSERT INTO users (slug, name, login) VALUES (?1, ?2, ?3)",
- params![user.slug, user.name, user.login],
+ "INSERT INTO users (login, slug) VALUES (?1, ?2)",
+ params![user.login, user.slug],
)?;
Ok(())
}
@@ -519,17 +518,16 @@ fn migrate(conn: &mut Connection) -> Result<(), Error> {
fn user_from_row(r: &Row<'_>) -> rusqlite::Result<User> {
Ok(User {
- slug: r.get(0)?,
- name: r.get(1)?,
- login: r.get(2)?,
+ login: r.get(0)?,
+ slug: r.get(1)?,
})
}
-fn load_user(conn: &Connection, slug: &str) -> Result<Option<User>, Error> {
+fn load_user(conn: &Connection, login: &str) -> Result<Option<User>, Error> {
let user = conn
.query_row(
- "SELECT slug, name, login FROM users WHERE slug = ?1",
- [slug],
+ "SELECT login, slug FROM users WHERE login = ?1",
+ [login],
user_from_row,
)
.optional()?;
@@ -582,7 +580,7 @@ fn load_messages(
FROM (SELECT user FROM assignees WHERE message = m.id ORDER BY user))
FROM messages m
JOIN threads t ON t.id = m.thread
- LEFT JOIN users u ON u.slug = m.sent_by
+ LEFT JOIN users u ON u.login = m.sent_by
WHERE {filter}
ORDER BY m.at, m.id"
);
@@ -701,7 +699,7 @@ fn escape_like(text: &str) -> String {
#[cfg(test)]
mod tests {
use super::*;
- use crate::fixtures;
+ use crate::fixtures::{self, ALEX, SAM};
fn values(store: &Store, id: MessageId) -> Values {
store
@@ -716,21 +714,21 @@ mod tests {
#[test]
fn edits_record_undo_flash_and_history() {
let store = fixtures::store().unwrap();
- store.edit("sam", 4, Change::State(State::Do)).unwrap();
+ store.edit(SAM, 4, Change::State(State::Do)).unwrap();
assert_eq!(values(&store, 4).state, State::Do);
assert_eq!(
- store.take_flash("sam"),
+ store.take_flash(SAM),
Some(Flash {
text: "Moved to Do".into(),
undoable: true
})
);
- assert_eq!(store.take_flash("sam"), None);
+ assert_eq!(store.take_flash(SAM), None);
- store.undo("sam").unwrap();
+ store.undo(SAM).unwrap();
assert_eq!(values(&store, 4).state, State::Inbox);
- assert_eq!(store.take_flash("sam").unwrap().text, "Undone");
- assert!(matches!(store.undo("sam"), Err(Error::BadRequest(_))));
+ assert_eq!(store.take_flash(SAM).unwrap().text, "Undone");
+ assert!(matches!(store.undo(SAM), Err(Error::BadRequest(_))));
let history: Vec<_> = store
.history(4)
@@ -741,8 +739,8 @@ mod tests {
assert_eq!(
history,
[
- ("sam".into(), store.now, "Moved to Do".into()),
- ("sam".into(), store.now, "Undone".into()),
+ (SAM.into(), store.now, "Moved to Do".into()),
+ (SAM.into(), store.now, "Undone".into()),
]
);
}
@@ -751,14 +749,14 @@ mod tests {
fn folders() {
let store = fixtures::store().unwrap();
store
- .edit("sam", 4, Change::Folder(Some("Finance".into())))
+ .edit(SAM, 4, Change::Folder(Some("Finance".into())))
.unwrap();
assert_eq!(values(&store, 4).folder.as_deref(), Some("Finance"));
- store.edit("sam", 4, Change::Folder(None)).unwrap();
+ store.edit(SAM, 4, Change::Folder(None)).unwrap();
assert_eq!(values(&store, 4).folder, None);
- assert_eq!(store.take_flash("sam").unwrap().text, "Removed from folder");
+ assert_eq!(store.take_flash(SAM).unwrap().text, "Removed from folder");
assert!(matches!(
- store.edit("sam", 4, Change::Folder(Some("Nope".into()))),
+ store.edit(SAM, 4, Change::Folder(Some("Nope".into()))),
Err(Error::NotFound("folder"))
));
assert_eq!(
@@ -772,27 +770,27 @@ mod tests {
let store = fixtures::store().unwrap();
let eli = fixtures::ELI_PRACTICE;
assert!(matches!(
- store.edit("sam", eli, Change::Folder(None)),
+ store.edit(SAM, eli, Change::Folder(None)),
Err(Error::Forbidden(_))
));
- store.edit("sam", eli, Change::State(State::Done)).unwrap();
+ store.edit(SAM, eli, Change::State(State::Done)).unwrap();
}
#[test]
fn assignees_toggle() {
let store = fixtures::store().unwrap();
store
- .edit("alex", 4, Change::ToggleAssignee("alex".into()))
+ .edit(ALEX, 4, Change::ToggleAssignee(ALEX.into()))
.unwrap();
- assert!(values(&store, 4).assignees.contains("alex"));
- assert_eq!(store.take_flash("alex").unwrap().text, "Assigned Alex");
+ assert!(values(&store, 4).assignees.contains(ALEX));
+ assert_eq!(store.take_flash(ALEX).unwrap().text, "Assigned Alex");
store
- .edit("alex", 4, Change::ToggleAssignee("alex".into()))
+ .edit(ALEX, 4, Change::ToggleAssignee(ALEX.into()))
.unwrap();
assert!(values(&store, 4).assignees.is_empty());
- assert_eq!(store.take_flash("alex").unwrap().text, "Unassigned Alex");
+ assert_eq!(store.take_flash(ALEX).unwrap().text, "Unassigned Alex");
assert!(matches!(
- store.edit("alex", 4, Change::ToggleAssignee("eli".into())),
+ store.edit(ALEX, 4, Change::ToggleAssignee("eli".into())),
Err(Error::NotFound("user"))
));
}
@@ -801,11 +799,11 @@ mod tests {
fn bad_edits() {
let store = fixtures::store().unwrap();
assert!(matches!(
- store.edit("sam", 999, Change::State(State::Do)),
+ store.edit(SAM, 999, Change::State(State::Do)),
Err(Error::NotFound("message"))
));
assert!(matches!(
- store.edit("sam", 2, Change::State(State::Do)),
+ store.edit(SAM, 2, Change::State(State::Do)),
Err(Error::BadRequest(_))
));
}
@@ -814,16 +812,16 @@ mod tests {
fn comments() {
let store = fixtures::store().unwrap();
let before = store.timeline(1).unwrap().len();
- store.add_comment("sam", 1, " Called them. ").unwrap();
+ store.add_comment(SAM, 1, " Called them. ").unwrap();
let timeline = store.timeline(1).unwrap();
assert_eq!(timeline.len(), before + 1);
assert!(matches!(timeline.last(), Some(Item::Comment(c)) if c.text == "Called them."));
assert!(matches!(
- store.add_comment("sam", 1, " "),
+ store.add_comment(SAM, 1, " "),
Err(Error::BadRequest(_))
));
assert!(matches!(
- store.add_comment("sam", 999, "hi"),
+ store.add_comment(SAM, 999, "hi"),
Err(Error::NotFound("thread"))
));
}
@@ -846,13 +844,13 @@ mod tests {
#[test]
fn read_tracking() {
let store = fixtures::store().unwrap();
- assert!(store.unread("alex").unwrap().contains(&fixtures::WATER));
- store.mark_read("alex", fixtures::WATER).unwrap();
- store.mark_read("alex", fixtures::WATER).unwrap();
- assert!(!store.unread("alex").unwrap().contains(&fixtures::WATER));
- assert!(store.unread("sam").unwrap().contains(&fixtures::WATER));
+ assert!(store.unread(ALEX).unwrap().contains(&fixtures::WATER));
+ store.mark_read(ALEX, fixtures::WATER).unwrap();
+ store.mark_read(ALEX, fixtures::WATER).unwrap();
+ assert!(!store.unread(ALEX).unwrap().contains(&fixtures::WATER));
+ assert!(store.unread(SAM).unwrap().contains(&fixtures::WATER));
// Sent messages are never unread.
- assert!(!store.unread("sam").unwrap().contains(&2));
+ assert!(!store.unread(SAM).unwrap().contains(&2));
}
#[test]
@@ -863,7 +861,7 @@ mod tests {
assert_eq!(sent.cc, ["Alex"]);
assert_eq!(sent.bcc, ["Pat Lee"]);
assert!(
- matches!(&sent.kind, Kind::Sent { by, to } if by == "sam" && to == &["Northwind Roofing"])
+ matches!(&sent.kind, Kind::Sent { by, to } if by == SAM && to == &["Northwind Roofing"])
);
let got = store.message(5).unwrap().unwrap();
assert_eq!(got.at, jiff::civil::date(2026, 9, 30).at(15, 30, 0, 0));
@@ -872,7 +870,7 @@ mod tests {
Some(&Values {
state: State::Do,
folder: Some("School".into()),
- assignees: BTreeSet::from(["sam".to_owned()]),
+ assignees: BTreeSet::from([SAM.to_owned()]),
})
);
assert!(store.message(999).unwrap().is_none());
@@ -890,19 +888,15 @@ mod tests {
#[test]
fn lookups() {
let store = fixtures::store().unwrap();
- let slugs: Vec<_> = store.users().unwrap().into_iter().map(|u| u.slug).collect();
- assert_eq!(slugs, ["alex", "sam"]);
- assert_eq!(store.user("sam").unwrap().unwrap().name, "Sam");
- assert!(store.user("pat").unwrap().is_none());
- assert_eq!(
- store
- .user_by_login("alex@example.com")
- .unwrap()
- .unwrap()
- .slug,
- "alex"
- );
- assert!(store.user_by_login("nobody").unwrap().is_none());
+ let logins: Vec<_> = store
+ .users()
+ .unwrap()
+ .into_iter()
+ .map(|u| u.login)
+ .collect();
+ assert_eq!(logins, [ALEX, SAM]);
+ assert_eq!(store.user(SAM).unwrap().unwrap().slug, "Sam");
+ assert!(store.user("pat@example.com").unwrap().is_none());
assert_eq!(
store.thread(1).unwrap().unwrap().subject,
"Gutter repair estimate"
@@ -912,6 +906,23 @@ mod tests {
assert!(store.thread_account(999).unwrap().is_none());
}
+ #[test]
+ fn signing_in_adds_users_and_follows_their_slug() {
+ let store = Store::open_in_memory(fixtures::now()).unwrap();
+ assert!(store.is_empty().unwrap());
+ let pat = store.sign_in("pat@example.com", None).unwrap();
+ assert_eq!(pat, User::new("pat@example.com", None));
+ assert!(!store.is_empty().unwrap());
+ assert_eq!(store.user("pat@example.com").unwrap(), Some(pat));
+
+ store.sign_in("pat@example.com", Some("Pat")).unwrap();
+ store.sign_in("pat@example.com", Some("Pat")).unwrap();
+ assert_eq!(store.user("pat@example.com").unwrap().unwrap().slug, "Pat");
+ // Slugs are only for show, so two logins may share one.
+ store.sign_in("pat@work.example", Some("Pat")).unwrap();
+ assert_eq!(store.users().unwrap().len(), 2);
+ }
+
#[test]
fn importing_into_a_missing_thread_fails() {
let store = fixtures::store().unwrap();
@@ -933,28 +944,28 @@ mod tests {
assert!(store.is_empty().unwrap());
fixtures::seed(&store).unwrap();
assert!(!store.is_empty().unwrap());
- store.edit("sam", 4, Change::State(State::Wait)).unwrap();
+ store.edit(SAM, 4, Change::State(State::Wait)).unwrap();
store
- .edit("sam", 4, Change::ToggleAssignee("alex".into()))
+ .edit(SAM, 4, Change::ToggleAssignee(ALEX.into()))
.unwrap();
- store.add_comment("sam", 1, "Called them.").unwrap();
- store.mark_read("sam", fixtures::WATER).unwrap();
+ store.add_comment(SAM, 1, "Called them.").unwrap();
+ store.mark_read(SAM, fixtures::WATER).unwrap();
drop(store);
let store = Store::open(&path, fixtures::now()).unwrap();
assert!(!store.is_empty().unwrap());
let v = values(&store, 4);
assert_eq!(v.state, State::Wait);
- assert_eq!(v.assignees, BTreeSet::from(["alex".to_owned()]));
+ assert_eq!(v.assignees, BTreeSet::from([ALEX.to_owned()]));
assert!(matches!(
store.timeline(1).unwrap().last(),
Some(Item::Comment(c)) if c.text == "Called them."
));
- assert!(!store.unread("sam").unwrap().contains(&fixtures::WATER));
+ assert!(!store.unread(SAM).unwrap().contains(&fixtures::WATER));
assert_eq!(store.history(4).unwrap().len(), 2);
// Undo and toasts belong to the session that made the edit.
- assert!(store.take_flash("sam").is_none());
- assert!(matches!(store.undo("sam"), Err(Error::BadRequest(_))));
+ assert!(store.take_flash(SAM).is_none());
+ assert!(matches!(store.undo(SAM), Err(Error::BadRequest(_))));
}
#[test]
@@ -997,29 +1008,29 @@ mod tests {
);
// The state update goes in before the history row that fails.
assert!(matches!(
- store.edit("sam", 4, Change::State(State::Do)),
+ store.edit(SAM, 4, Change::State(State::Do)),
Err(Error::Db(_))
));
assert!(matches!(
- store.edit("sam", 4, Change::ToggleAssignee("alex".into())),
+ store.edit(SAM, 4, Change::ToggleAssignee(ALEX.into())),
Err(Error::Db(_))
));
assert_eq!(values(&store, 4).state, State::Inbox);
assert!(values(&store, 4).assignees.is_empty());
assert!(store.history(4).unwrap().is_empty());
- assert!(store.take_flash("sam").is_none());
+ assert!(store.take_flash(SAM).is_none());
// A failed undo stays undoable.
exec(&store, "DROP TRIGGER no_history;");
- store.edit("sam", 4, Change::State(State::Do)).unwrap();
+ store.edit(SAM, 4, Change::State(State::Do)).unwrap();
exec(
&store,
"CREATE TRIGGER no_history BEFORE INSERT ON history BEGIN SELECT RAISE(ABORT, 'no'); END;",
);
- assert!(matches!(store.undo("sam"), Err(Error::Db(_))));
+ assert!(matches!(store.undo(SAM), Err(Error::Db(_))));
assert_eq!(values(&store, 4).state, State::Do);
exec(&store, "DROP TRIGGER no_history;");
- store.undo("sam").unwrap();
+ store.undo(SAM).unwrap();
assert_eq!(values(&store, 4).state, State::Inbox);
}
@@ -1029,25 +1040,26 @@ mod tests {
let msg = store.message(4).unwrap().unwrap();
exec(&store, "PRAGMA query_only = ON;");
let db_err = |r: Result<(), Error>| assert!(matches!(r, Err(Error::Db(_))), "{r:?}");
- db_err(store.edit("sam", 4, Change::State(State::Do)));
- db_err(store.mark_read("sam", 4));
- db_err(store.add_comment("sam", 1, "hi"));
+ db_err(store.edit(SAM, 4, Change::State(State::Do)));
+ db_err(store.mark_read(SAM, 4));
+ db_err(store.sign_in("pat@example.com", None).map(|_| ()));
+ db_err(store.add_comment(SAM, 1, "hi"));
db_err(store.import(|tx| tx.folder("Travel")));
db_err(store.import(|tx| tx.message(&msg)));
exec(&store, "PRAGMA query_only = OFF;");
// Each table the store reads, gone in turn.
exec(&store, "ALTER TABLE folders RENAME TO gone_folders;");
- db_err(store.edit("sam", 4, Change::Folder(Some("House".into()))));
+ db_err(store.edit(SAM, 4, Change::Folder(Some("House".into()))));
assert!(store.folders().is_err());
exec(&store, "ALTER TABLE comments RENAME TO gone_comments;");
assert!(store.timeline(1).is_err());
exec(&store, "ALTER TABLE reads RENAME TO gone_reads;");
- assert!(store.unread("sam").is_err());
+ assert!(store.unread(SAM).is_err());
exec(&store, "ALTER TABLE history RENAME TO gone_history;");
assert!(store.history(4).is_err());
exec(&store, "ALTER TABLE threads RENAME TO gone_threads;");
- db_err(store.add_comment("sam", 1, "hi"));
+ db_err(store.add_comment(SAM, 1, "hi"));
exec(&store, "ALTER TABLE users RENAME TO gone_users;");
assert!(store.users().is_err());
assert!(store.is_empty().is_err());
diff --git a/src/views.rs b/src/views.rs
index e2d5751..2f7f534 100644
--- a/src/views.rs
+++ b/src/views.rs
@@ -92,12 +92,12 @@ fn badge(state: State) -> Markup {
html! { span.badge.(tone(state)) { span.mark {} (state.name()) } }
}
-/// The user's display name, or the slug when nobody has it.
-fn user_name<'a>(users: &'a [User], slug: &'a str) -> &'a str {
+/// The short name shown for a login, or the login when nobody has it.
+fn user_name<'a>(users: &'a [User], login: &'a str) -> &'a str {
users
.iter()
- .find(|u| u.slug == slug)
- .map_or(slug, |u| u.name.as_str())
+ .find(|u| u.login == login)
+ .map_or(login, |u| u.slug.as_str())
}
fn assignee_label(users: &[User], values: &Values) -> Option<String> {
@@ -170,7 +170,7 @@ fn sidebar(p: &Page<'_>) -> Markup {
fn whoami(p: &Page<'_>) -> Markup {
let who = html! {
- span.who-name { (p.me.name) }
+ span.who-name { (p.me.slug) }
span.type-label.who-login { (p.me.login) }
};
html! {
@@ -180,9 +180,9 @@ fn whoami(p: &Page<'_>) -> Markup {
form.menu.up method="post" action="/dev/user" x-show="open" x-cloak {
(back(p.here))
@for user in &p.users {
- @let on = user.slug == p.me.slug;
- button.opt.cur[on] type="submit" name="user" value=(user.slug) {
- span { (user.name) }
+ @let on = user.login == p.me.login;
+ button.opt.cur[on] type="submit" name="user" value=(user.login) {
+ span { (user.slug) }
span.check { @if on { "✓" } }
}
}
@@ -427,9 +427,9 @@ fn values_controls(p: &Page<'_>, id: u32, values: &Values, read_only: bool) -> M
let assigned = assignee_label(&p.users, values);
let people = html! {
@for user in &p.users {
- @let on = values.assignees.contains(&user.slug);
- button.opt.cur[on] type="submit" name="user" value=(user.slug) {
- span { (user.name) } (check(on))
+ @let on = values.assignees.contains(&user.login);
+ button.opt.cur[on] type="submit" name="user" value=(user.login) {
+ span { (user.slug) } (check(on))
}
}
};
diff --git a/tests/api.rs b/tests/api.rs
index b76b080..14d5fd1 100644
--- a/tests/api.rs
+++ b/tests/api.rs
@@ -66,14 +66,37 @@ async fn health_returns_ok() {
}
#[tokio::test]
-async fn requires_a_known_identity() {
+async fn requires_an_identity() {
let addr = spawn().await;
let res = reqwest::get(format!("http://{addr}/")).await.unwrap();
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
- let (status, _) = get(addr, "eve@example.com", "/").await;
+ let (status, _) = get(addr, "", "/").await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
}
+#[tokio::test]
+async fn anyone_tailscale_lets_in_is_a_user() {
+ let addr = spawn().await;
+ let (status, body) = get(addr, "eve@example.com", "/").await;
+ assert_eq!(status, StatusCode::OK);
+ assert!(body.contains(r#"<span class="who-name">eve</span>"#));
+
+ // The proxy names them with X-User-Slug, and the name follows it.
+ let body = client()
+ .get(format!("http://{addr}/?m=4"))
+ .header("Tailscale-User-Login", "eve@example.com")
+ .header("X-User-Slug", "Evie")
+ .send()
+ .await
+ .unwrap()
+ .text()
+ .await
+ .unwrap();
+ assert!(body.contains(r#"<span class="who-name">Evie</span>"#));
+ // Once in, they can be assigned like anyone else.
+ assert!(body.contains(r#"value="eve@example.com"><span>Evie</span>"#));
+}
+
#[tokio::test]
async fn for_me_lists_messages_grouped_by_state_and_thread() {
let addr = spawn().await;
@@ -95,7 +118,7 @@ async fn lanes_and_search() {
("/do", "Exemption renewal"),
("/wait", "Claim 4471"),
("/watch", "Shipped: furnace filters"),
- ("/search?q=downspout", "Sam → Northwind Roofing"),
+ ("/search?q=downspout", "sam → Northwind Roofing"),
] {
let (status, body) = get(addr, SAM, path).await;
assert_eq!(status, StatusCode::OK, "{path}");
@@ -232,7 +255,7 @@ async fn editing_values_with_undo() {
addr,
SAM,
"/messages/4/assignees",
- &[("user", "alex"), ("back", "/")],
+ &[("user", ALEX), ("back", "/")],
)
.await;
assert_eq!(res.status(), StatusCode::SEE_OTHER);
@@ -299,7 +322,7 @@ async fn cross_site_posts_are_refused() {
.post(format!("http://{addr}{path}"))
.header("Tailscale-User-Login", SAM)
.header("Origin", origin)
- .form(&[("state", "do"), ("user", "alex"), ("back", "/")])
+ .form(&[("state", "do"), ("user", ALEX), ("back", "/")])
.send()
};
let res = send("https://evil.example", "/messages/4/state")
@@ -332,18 +355,18 @@ async fn dev_mode_picks_a_user_from_a_cookie() {
let res = anon
.post(format!("http://{addr}/dev/user"))
- .form(&[("user", "sam"), ("back", "/do")])
+ .form(&[("user", SAM), ("back", "/do")])
.send()
.await
.unwrap();
assert_eq!(res.status(), StatusCode::SEE_OTHER);
assert_eq!(location(&res), "/do");
let cookie = res.headers()["set-cookie"].to_str().unwrap();
- assert!(cookie.starts_with("docket_dev_user=sam;"));
+ assert!(cookie.starts_with("docket_dev_user=sam@example.com;"));
let body = anon
.get(format!("http://{addr}/"))
- .header("Cookie", "theme=dark; docket_dev_user=sam")
+ .header("Cookie", "theme=dark; docket_dev_user=sam@example.com")
.send()
.await
.unwrap()
@@ -362,7 +385,7 @@ async fn dev_mode_picks_a_user_from_a_cookie() {
// Outside dev mode, the switch doesn't exist.
let prod = spawn().await;
- let res = post(prod, SAM, "/dev/user", &[("user", "alex"), ("back", "/")]).await;
+ let res = post(prod, SAM, "/dev/user", &[("user", ALEX), ("back", "/")]).await;
assert_eq!(res.status(), StatusCode::NOT_FOUND);
}