Add guest links for temporary shared access

Handing a dog sitter the ability to log a pee meant handing them the account
password: permanent, total control, revocable only by changing it. Settings →
Guest access now mints a URL that does the one thing instead.

A link is a session, not an account. Opening /guest/<token> inserts an ordinary
session row against the owner's user_id, tagged with the link it came from, so
every data path downstream — sync, photos, the profile — stays scoped by
user_id exactly as before and needed no changes at all. Only the capability
checks differ by role, which is what kept this from touching the sync contract.
Redemption is a plain GET so tapping the link in a message works, and the 303
to / leaves the token out of the address bar, bookmarks and the PWA start URL.

What a guest cannot change is enforced in the upsert, not in the UI. The WHERE
clause gains a logged_by_share test: an owner (empty share id) may change
anything, a guest only rows carrying their own link's id. A sitter can fix up
their own entries and cannot rewrite or delete one of the owner's, including
everything logged before this existed, since those rows carry the empty id too.
Deletes come along free, being tombstones. The test is on the link id rather
than its label because two links can easily both be "Sitter", and the id is
also why /api/me hands the guest its share id: the client needs it to know what
to grey out. The exercise library is the owner's on the same reasoning — a
guest trains against it but the server drops any exercise a guest sends.

Attribution is stamped from the session on insert and left out of DO UPDATE
SET, so it is decided once by whoever logged the event and survives every later
edit. It never comes off the wire, so it cannot be forged — a guest re-POSTs
the owner's whole event list on every sync, but those rows already exist and
keep their stored values.

Expiry is a date the owner picks; the link dies at the end of that day in their
own timezone, which the client computes because the server has no way to know
it. Sessions are capped at the link's own end, and every request re-checks the
link is live rather than trusting the session row, so revoking kicks a guest
out on their next request instead of whenever their session happens to lapse.
Only the token hash is stored, as with session tokens, so the URL is shown once
at creation and cannot be read back.

The client side follows from that. A guest opening someone else's entry gets
the edit dialog read-only rather than a form that would silently discard what
they typed, and mergeSynced takes the server's copy for anything they may not
change — otherwise a refused write would sit in their cache forever showing an
edit that never happened. An ended link wipes their cached copy of someone
else's history and says so, rather than offering a sign-in form they have no
password for.
This commit is contained in:
Alexander Heldt
2026-09-07 11:19:20 +00:00
parent 103a5f9937
commit e22031ed4f
9 changed files with 1698 additions and 110 deletions
+78
View File
@@ -933,6 +933,84 @@ button.linklike:hover { text-decoration: underline; filter: none; }
}
.danger-text strong { color: var(--danger); }
/* ---------- guest links ---------- */
/* The one-time URL. Shown once and never again, so it gets a box of its own
rather than sitting inline where it could be missed. */
.guest-new {
background: var(--accent-soft);
border-radius: var(--radius);
padding: 10px 12px;
margin: 12px 0;
}
.guest-new .settings-hint { margin: 0 0 6px; }
.guest-url {
display: block;
font-size: 0.8rem;
word-break: break-all;
margin-bottom: 8px;
line-height: 1.4;
}
.guest-list {
list-style: none;
margin: 12px 0 0;
padding: 0;
}
.guest-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 0;
border-top: 1px solid var(--border);
}
.guest-item-main {
flex: 1;
min-width: 0;
}
.guest-item-label {
display: block;
font-weight: 600;
overflow-wrap: anywhere;
}
.guest-item-sub {
display: block;
color: var(--muted);
font-size: 0.8rem;
}
button.guest-revoke { color: var(--danger); flex: none; }
/* Only a guest ever sees this, directly under the header. No bottom margin:
main's own top padding provides the gap to the first panel. */
.guest-banner {
margin: 4px 0 0;
padding: 8px 12px;
border-radius: var(--radius);
background: var(--accent-soft);
color: var(--text);
font-size: 0.85rem;
line-height: 1.4;
}
.guest-banner[hidden] { display: none; }
/* Who logged an event, when it came in on a guest link. Small caps so it reads
as a margin note against the event label rather than competing with it.
The row is a single non-wrapping line, so the badge is capped and ellipsised:
a long label ("Anna the neighbour's daughter") must not squeeze the note out. */
.event .by {
flex: none;
max-width: 10ch;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.7rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--muted);
background: var(--accent-soft);
border-radius: 999px;
padding: 2px 8px;
}
/* ---------- settings toggle switch ---------- */
.toggle-row {
display: flex;