Phase 16: Petal authenticates for itself

Petal is now an OIDC client in its own right rather than trusting a header
from the proxy. The Phase-0 Resolver seam was the only integration point:
main.go picks the session store when Authentik is configured and the static
local user otherwise, and no handler or query moved for either.

internal/auth gains three pieces. session.go issues an opaque cookie token
and stores only its SHA-256, so a database copy yields nothing usable; the
30-day expiry slides on every request, throttled to one write an hour, and
logout deletes the row rather than just the cookie. oidc.go runs the
authorization-code flow with state, nonce and PKCE, and discovers the
provider lazily and on retry — an Authentik outage should block new logins
without stopping Petal booting or invalidating live sessions. users.go
provisions accounts from the token's claims and gates them on an allowlist
that matches emails as well as subject ids, since a subject is an opaque
uuid that doesn't exist until someone has already logged in once.

Migration 0010 lands sessions, images and users.pair_lang together. The
images table closes the capability-URL hole the Phase-0 audit flagged: a
hash was previously enough to fetch anyone's picture. Rows are keyed
(name, user_id) so one file can have several owners and deduplication
survives; a stranger gets 404 rather than 403, the cache header drops to
private, and files already on disk are claimed at startup or every image
already pasted into a document would 404.

On the frontend a single 401 interceptor feeds a warm bilingual sign-in
overlay, drawn over a still-visible editor because nothing has been taken
away. Behind it is the part that matters: a save that comes back 401
stashes its body to localStorage before anything else and stops the
auto-save loop, and reopening that document after signing in merges the
draft back and saves it. An expired session must not cost writing.

Writing the round-trip test against a stub identity provider turned up a
real bug: the one-shot state/nonce/PKCE cookies were cleared in a defer,
which runs after the redirect has written the response header, so the
clearing Set-Cookie was silently dropped and they lingered for their full
ten minutes.

Also swaps the emoji favicon for a drawn sakura, which renders as Petal's
own rose palette everywhere instead of whatever each platform's font
decides, and doubles as the app tile in Authentik.

Migration 0010 verified against a VACUUM INTO copy of the live millenia
database: counts intact, FTS still matching, the one existing image
claimed.

Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
This commit is contained in:
prosolis
2026-07-27 07:21:32 -07:00
parent 42d857a878
commit 1cf207d73f
30 changed files with 2407 additions and 97 deletions
+71
View File
@@ -0,0 +1,71 @@
// SignInOverlay appears when the session has lapsed mid-session.
//
// The tone matters more than usual here. Being logged out of a writing app is
// alarming — the first thing anyone wants to know is whether their words
// survived — so the overlay leads with the reassurance and treats signing in
// again as an errand, not an error. The editor stays visible behind the scrim
// (dimmed, still there) for the same reason: nothing has been taken away.
interface Props {
// Whether there is unsaved writing waiting on this device, which changes the
// reassurance from a promise to a statement of fact.
hasDraft: boolean
}
export function SignInOverlay({ hasDraft }: Props) {
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="petal-signin-title"
className="petal-no-print fixed inset-0 z-[60] flex items-center justify-center px-4"
style={{ background: 'color-mix(in srgb, var(--color-bg) 78%, transparent)', backdropFilter: 'blur(3px)' }}
>
<div
className="w-full max-w-md px-7 py-8 text-center"
style={{
background: 'var(--color-surface)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-lg)',
boxShadow: 'var(--shadow-soft)',
fontFamily: 'var(--font-ui)',
}}
>
<div aria-hidden style={{ fontSize: 34, lineHeight: 1 }}>
🌸
</div>
<h2
id="petal-signin-title"
className="mt-3 text-lg font-bold"
style={{ color: 'var(--color-plum)' }}
>
</h2>
<p className="text-sm font-semibold" style={{ color: 'var(--color-muted)' }}>
Please sign in again
</p>
<p className="mt-4 text-sm leading-relaxed" style={{ color: 'var(--color-plum)' }}>
{hasDraft
? '你刚写的内容已经安全地留在这台电脑上,登录后会自动接着保存。'
: '登录状态过期了。你的文字都已经保存好了。'}
</p>
<p className="mt-1 text-xs leading-relaxed" style={{ color: 'var(--color-muted)' }}>
{hasDraft
? "What you just wrote is safe on this device — it'll save itself once you're back in."
: 'Your session expired. Everything you wrote is already saved.'}
</p>
<a
href="/auth/login"
className="mt-6 inline-block rounded-full px-6 py-2.5 text-sm font-bold text-white transition-colors"
style={{ background: 'var(--color-accent)' }}
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-accent-hover)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-accent)')}
>
· Sign in
</a>
</div>
</div>
)
}
+25 -1
View File
@@ -14,6 +14,9 @@ interface Props {
onDuplicate: (id: string) => void
onToggleTag: (docId: string, tag: Tag) => void
onCreateTag: (docId: string, name: string, color: TagColor) => void
// The signed-in writer, when there is real auth to sign out of. Null in a
// local-dev build, where there is nothing to leave.
account: { name: string } | null
}
// Sidebar sort orders. 'recent' keeps the server's updated_at-desc ordering.
@@ -36,6 +39,7 @@ export function DocList({
onDuplicate,
onToggleTag,
onCreateTag,
account,
}: Props) {
// Active tag filter (null = show all). Cleared automatically if the tag
// disappears from the roster.
@@ -62,7 +66,7 @@ export function DocList({
return (
<aside
className="flex h-full w-[280px] flex-col gap-2 p-3"
className="flex h-full w-full flex-col gap-2 p-3"
style={{ borderRight: '1px solid var(--color-border)' }}
>
<SearchBox onSelect={onSelect} />
@@ -149,6 +153,26 @@ export function DocList({
</a>
</div>
)}
{/* Who's writing, and the way out. Shown only when there's a real account
behind the session — a local-dev build has nobody to sign out as. */}
{account && (
<div
className="flex items-center gap-2 px-1 text-xs"
style={{ color: 'var(--color-muted)' }}
>
<span className="min-w-0 flex-1 truncate" title={account.name}>
🌸 {account.name}
</span>
<a
href="/auth/logout"
className="shrink-0 font-bold hover:underline"
style={{ color: 'var(--color-accent-hover)' }}
>
退 · Sign out
</a>
</div>
)}
</aside>
)
}
@@ -26,6 +26,9 @@ const SAVE_LABEL: Record<SaveStatus, string> = {
saving: 'Saving…',
saved: 'Saved just now',
error: "Couldn't save",
// The session lapsed. Say where the writing is, not what failed — it's safe
// on this device and goes up the moment she signs back in.
'signed-out': '已保存在本机 · Kept on this device',
}
// StatusBar is the slim footer: word count on the left, save state and the