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:
+5
-1
@@ -2,7 +2,11 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><text y='14' font-size='14'>🌸</text></svg>" />
|
||||
<!-- A drawn sakura rather than the 🌸 emoji: the emoji renders as whatever
|
||||
each platform's font decides, which on some is not pink and on others
|
||||
is not a blossom. This one is Petal's own rose palette everywhere, and
|
||||
it doubles as the app tile in Authentik. -->
|
||||
<link rel="icon" type="image/svg+xml" href="/petal.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Petal</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Petal">
|
||||
<title>Petal</title>
|
||||
<defs>
|
||||
<!-- Each petal is lighter at the tip and deepens toward the middle, the way
|
||||
a real sakura petal does. Petal's own rose tokens: #E8A0BF / #D98AAF. -->
|
||||
<radialGradient id="petal-fill" cx="50%" cy="88%" r="82%">
|
||||
<stop offset="0%" stop-color="#D98AAF" />
|
||||
<stop offset="45%" stop-color="#E8A0BF" />
|
||||
<stop offset="100%" stop-color="#FBDDE7" />
|
||||
</radialGradient>
|
||||
<radialGradient id="petal-heart" cx="50%" cy="42%" r="65%">
|
||||
<stop offset="0%" stop-color="#FFF3D6" />
|
||||
<stop offset="100%" stop-color="#F2C878" />
|
||||
</radialGradient>
|
||||
<!-- One petal, pointing up from the flower's centre, notched at the tip. -->
|
||||
<path id="petal-leaf"
|
||||
d="M32 34
|
||||
C 21.5 31.5, 15.5 23, 18 14.6
|
||||
C 20 8.2, 26 5.4, 29.6 9.8
|
||||
L 32 12.8
|
||||
L 34.4 9.8
|
||||
C 38 5.4, 44 8.2, 46 14.6
|
||||
C 48.5 23, 42.5 31.5, 32 34 Z" />
|
||||
</defs>
|
||||
|
||||
<g>
|
||||
<!-- Five petals around the centre, each a rotated copy of the same shape.
|
||||
They overlap generously and each keeps its own outline, so the petals
|
||||
stay individually readable rather than merging into one silhouette. -->
|
||||
<g fill="url(#petal-fill)" stroke="#D98AAF" stroke-width="1.4" stroke-linejoin="round">
|
||||
<use href="#petal-leaf" transform="rotate(0 32 32)" />
|
||||
<use href="#petal-leaf" transform="rotate(72 32 32)" />
|
||||
<use href="#petal-leaf" transform="rotate(144 32 32)" />
|
||||
<use href="#petal-leaf" transform="rotate(216 32 32)" />
|
||||
<use href="#petal-leaf" transform="rotate(288 32 32)" />
|
||||
</g>
|
||||
|
||||
<!-- Stamens: little dots on short stalks, kept inside the centre. -->
|
||||
<g stroke="#E7B15F" stroke-width="1.3" stroke-linecap="round">
|
||||
<path d="M32 32 L32 25.2" />
|
||||
<path d="M32 32 L38.4 27.4" />
|
||||
<path d="M32 32 L36 35.6" />
|
||||
<path d="M32 32 L28 35.6" />
|
||||
<path d="M32 32 L25.6 27.4" />
|
||||
</g>
|
||||
<g fill="#FFE9AE">
|
||||
<circle cx="32" cy="24.6" r="1.9" />
|
||||
<circle cx="38.9" cy="26.9" r="1.9" />
|
||||
<circle cx="36.4" cy="36.2" r="1.9" />
|
||||
<circle cx="27.6" cy="36.2" r="1.9" />
|
||||
<circle cx="25.1" cy="26.9" r="1.9" />
|
||||
</g>
|
||||
|
||||
<!-- The flower's heart. -->
|
||||
<circle cx="32" cy="32" r="5.1" fill="url(#petal-heart)" stroke="#E7B15F" stroke-width="1.1" />
|
||||
<circle cx="30.3" cy="30.4" r="1.5" fill="#FFF8E6" opacity="0.9" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
+45
-2
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { api, type DocSummary, type Document, type Suggestion, type Tag, type TagColor } from './api/client'
|
||||
import { api, type DocSummary, type DocUpdate, type Document, type Suggestion, type Tag, type TagColor } from './api/client'
|
||||
import { useAutoSave } from './hooks/useAutoSave'
|
||||
import { useCheckpoint } from './hooks/useCheckpoint'
|
||||
import { useSpellChecker } from './hooks/useSpellChecker'
|
||||
@@ -13,6 +13,9 @@ import { GardenPanel } from './components/Garden/GardenPanel'
|
||||
import { StatusBar } from './components/StatusBar/StatusBar'
|
||||
import { PetalCompanion } from './components/Companion/PetalCompanion'
|
||||
import { UpdateBanner } from './components/UpdateBanner/UpdateBanner'
|
||||
import { SignInOverlay } from './components/Auth/SignInOverlay'
|
||||
import { useSession } from './hooks/useSession'
|
||||
import { takeDraft } from './lib/drafts'
|
||||
import { useVersionWatch } from './hooks/useVersionWatch'
|
||||
import { PetalFall } from './effects/PetalFall'
|
||||
import { useNightMode } from './hooks/useNightMode'
|
||||
@@ -24,6 +27,12 @@ export default function App() {
|
||||
// toggles the `petal-night` class on <html>; we pass the flag to the ambient
|
||||
// layer so the petals become stars.
|
||||
const night = useNightMode()
|
||||
// Who's writing, and whether the server still recognises them. `signedOut`
|
||||
// flips the moment any call comes back 401.
|
||||
const { me, signedOut } = useSession()
|
||||
// A real account to sign out of, as opposed to the hardcoded local user a
|
||||
// build without auth configured runs as.
|
||||
const account = me && me.id !== 'local' ? { name: me.display_name || me.email } : null
|
||||
const [docs, setDocs] = useState<DocSummary[]>([])
|
||||
const [currentDoc, setCurrentDoc] = useState<Document | null>(null)
|
||||
const [title, setTitle] = useState('')
|
||||
@@ -132,6 +141,35 @@ export default function App() {
|
||||
[createTag, setDocTag],
|
||||
)
|
||||
|
||||
// If the session lapsed while she was writing, the body that couldn't be
|
||||
// saved was stashed on this device. Opening the document again is where it
|
||||
// comes back: the stashed fields win over the server's older copy, and a save
|
||||
// is scheduled straight away so it stops being local-only. Version history
|
||||
// makes this safe to do silently — the server's copy is one restore away.
|
||||
const pendingRescueRef = useRef<{ id: string; body: DocUpdate } | null>(null)
|
||||
const rescueDraft = useCallback((doc: Document): Document => {
|
||||
const stashed = takeDraft(doc.id)
|
||||
if (!stashed) return doc
|
||||
const patch = Object.fromEntries(
|
||||
Object.entries(stashed.body).filter(([, v]) => v !== undefined),
|
||||
)
|
||||
if (Object.keys(patch).length === 0) return doc
|
||||
const merged = { ...doc, ...patch } as Document
|
||||
if (merged.content === doc.content && merged.title === doc.title) return doc
|
||||
// Save it, but only once this really is the open document — the auto-save
|
||||
// writes to whichever doc is current when its timer fires.
|
||||
pendingRescueRef.current = { id: doc.id, body: stashed.body }
|
||||
return merged
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const rescued = pendingRescueRef.current
|
||||
if (rescued && currentDoc?.id === rescued.id) {
|
||||
pendingRescueRef.current = null
|
||||
schedule(rescued.body)
|
||||
}
|
||||
}, [currentDoc?.id, schedule])
|
||||
|
||||
const openDoc = useCallback(
|
||||
async (id: string) => {
|
||||
setDrawerOpen(false) // close the mobile drawer when a doc is chosen
|
||||
@@ -139,7 +177,7 @@ export default function App() {
|
||||
const leaving = currentDocRef.current
|
||||
const leavingBlank = isBlankDraft()
|
||||
await saveNow() // flush any pending edits to the doc we're leaving
|
||||
const doc = await api.getDoc(id)
|
||||
const doc = rescueDraft(await api.getDoc(id))
|
||||
setCurrentDoc(doc)
|
||||
setTitle(doc.title)
|
||||
setWordCount(doc.word_count)
|
||||
@@ -432,6 +470,7 @@ export default function App() {
|
||||
onDuplicate={handleDuplicate}
|
||||
onToggleTag={handleToggleTag}
|
||||
onCreateTag={handleCreateTag}
|
||||
account={account}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -543,6 +582,10 @@ export default function App() {
|
||||
|
||||
{updateAvailable && <UpdateBanner />}
|
||||
|
||||
{/* The session lapsed. The editor stays visible behind this — nothing has
|
||||
been taken away — and anything unsaved is already on disk. */}
|
||||
{signedOut && <SignInOverlay hasDraft={status === 'signed-out'} />}
|
||||
|
||||
<div className="petal-no-print">
|
||||
<PetalCompanion
|
||||
wordCount={wordCount}
|
||||
|
||||
@@ -150,11 +150,45 @@ export interface MechanicsFinding {
|
||||
explanation: string
|
||||
}
|
||||
|
||||
// Who's writing. Mirrors the backend db.User.
|
||||
export interface Me {
|
||||
id: string
|
||||
email: string
|
||||
display_name: string
|
||||
created_at: string
|
||||
pair_lang: string
|
||||
}
|
||||
|
||||
// Thrown when the server says the session is gone. Callers can tell it apart
|
||||
// from a real failure — losing your session is not the same as a save going
|
||||
// wrong, and the auto-save has to treat them very differently.
|
||||
export class UnauthorizedError extends Error {
|
||||
constructor() {
|
||||
super('not signed in')
|
||||
this.name = 'UnauthorizedError'
|
||||
}
|
||||
}
|
||||
|
||||
// Sessions expire, so *any* call can come back 401 — including the auto-save
|
||||
// that fires 1.5s after every keystroke. One place notices, and the app reacts
|
||||
// once, rather than each call site inventing its own answer.
|
||||
let unauthorizedHandler: (() => void) | null = null
|
||||
|
||||
export function onUnauthorized(handler: () => void) {
|
||||
unauthorizedHandler = handler
|
||||
}
|
||||
|
||||
function signedOut(): UnauthorizedError {
|
||||
unauthorizedHandler?.()
|
||||
return new UnauthorizedError()
|
||||
}
|
||||
|
||||
async function req<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`/api${path}`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...init,
|
||||
})
|
||||
if (res.status === 401) throw signedOut()
|
||||
if (!res.ok) {
|
||||
const detail = await res.text().catch(() => '')
|
||||
throw new Error(`${res.status} ${res.statusText}${detail ? `: ${detail}` : ''}`)
|
||||
@@ -164,6 +198,10 @@ async function req<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
}
|
||||
|
||||
export const api = {
|
||||
// The signed-in writer. With auth unconfigured (local development) this is
|
||||
// the hardcoded local user, so the frontend needs no separate mode for it.
|
||||
me: () => req<Me>('/me'),
|
||||
|
||||
listDocs: () => req<DocSummary[]>('/docs'),
|
||||
createDoc: () => req<Document>('/docs', { method: 'POST' }),
|
||||
getDoc: (id: string) => req<Document>(`/docs/${id}`),
|
||||
@@ -254,6 +292,7 @@ export const api = {
|
||||
const form = new FormData()
|
||||
form.append('image', file)
|
||||
const res = await fetch('/api/images', { method: 'POST', body: form })
|
||||
if (res.status === 401) throw signedOut()
|
||||
if (!res.ok) {
|
||||
const detail = await res.text().catch(() => '')
|
||||
throw new Error(`${res.status} ${res.statusText}${detail ? `: ${detail}` : ''}`)
|
||||
@@ -370,6 +409,7 @@ export async function streamSuggestionChat(
|
||||
body: JSON.stringify({ messages }),
|
||||
signal,
|
||||
})
|
||||
if (res.status === 401) throw signedOut()
|
||||
if (!res.ok || !res.body) {
|
||||
const detail = await res.text().catch(() => '')
|
||||
throw new Error(`${res.status} ${res.statusText}${detail ? `: ${detail}` : ''}`)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { api, type DocUpdate } from '../api/client'
|
||||
import { api, UnauthorizedError, type DocUpdate } from '../api/client'
|
||||
import { clearDraft, stashDraft } from '../lib/drafts'
|
||||
|
||||
export type SaveStatus = 'idle' | 'pending' | 'saving' | 'saved' | 'error'
|
||||
export type SaveStatus = 'idle' | 'pending' | 'saving' | 'saved' | 'error' | 'signed-out'
|
||||
|
||||
const DEBOUNCE_MS = 1500
|
||||
const SAVED_FADE_MS = 3000
|
||||
@@ -19,20 +20,43 @@ export function useAutoSave(docId: string | null) {
|
||||
const docIdRef = useRef(docId)
|
||||
docIdRef.current = docId
|
||||
|
||||
// Set once the server stops recognising the session. Further saves are
|
||||
// pointless (every one would 401) and would keep overwriting the stash, so
|
||||
// the loop stops here and the writing waits in localStorage instead.
|
||||
const signedOutRef = useRef(false)
|
||||
|
||||
const flush = useCallback(async () => {
|
||||
const id = docIdRef.current
|
||||
const body = pendingRef.current
|
||||
pendingRef.current = null
|
||||
if (!id || !body) return
|
||||
|
||||
if (signedOutRef.current) {
|
||||
stashDraft(id, body)
|
||||
setStatus('signed-out')
|
||||
return
|
||||
}
|
||||
|
||||
setStatus('saving')
|
||||
try {
|
||||
await api.updateDoc(id, body)
|
||||
clearDraft(id) // it's on the server now; the rescue copy is redundant
|
||||
setStatus('saved')
|
||||
clearTimeout(fadeRef.current)
|
||||
fadeRef.current = setTimeout(() => setStatus('idle'), SAVED_FADE_MS)
|
||||
} catch (err) {
|
||||
if (err instanceof UnauthorizedError) {
|
||||
// The session went away mid-draft. Keep the body — on disk, where a
|
||||
// full-page trip through the identity provider can't take it with it —
|
||||
// and stop trying until there's a session again.
|
||||
signedOutRef.current = true
|
||||
stashDraft(id, body)
|
||||
setStatus('signed-out')
|
||||
return
|
||||
}
|
||||
console.error('auto-save failed', err)
|
||||
// Put the body back so the next edit retries it rather than dropping it.
|
||||
pendingRef.current = { ...body, ...(pendingRef.current ?? {}) }
|
||||
setStatus('error')
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api, onUnauthorized, type Me } from '../api/client'
|
||||
|
||||
// useSession tracks who is writing, and notices the moment the server stops
|
||||
// recognising them.
|
||||
//
|
||||
// `signedOut` going true is not an error state to report — it's a state to
|
||||
// recover from: the app stops auto-saving, keeps the draft, and shows a warm
|
||||
// invitation to sign in again. Every API call routes its 401 here through the
|
||||
// client's single interceptor, so it fires once no matter which call noticed.
|
||||
export function useSession() {
|
||||
const [me, setMe] = useState<Me | null>(null)
|
||||
const [signedOut, setSignedOut] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
onUnauthorized(() => setSignedOut(true))
|
||||
let cancelled = false
|
||||
api
|
||||
.me()
|
||||
.then((user) => {
|
||||
if (!cancelled) setMe(user)
|
||||
})
|
||||
.catch(() => {
|
||||
// A 401 has already flipped signedOut through the interceptor; anything
|
||||
// else (the server briefly down) leaves `me` null, which only costs the
|
||||
// display name.
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { me, signedOut }
|
||||
}
|
||||
+1
-1
@@ -388,7 +388,7 @@ button, a, input {
|
||||
canvas (centered, max-width) re-centers into the full pane. Width + transform
|
||||
animate together for a smooth slide. (Spec → Distraction-free mode.) */
|
||||
.petal-sidebar {
|
||||
width: 260px;
|
||||
width: 280px;
|
||||
overflow: hidden;
|
||||
transition: width 280ms ease, transform 280ms ease, opacity 200ms ease;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { clearDraft, peekDraft, stashDraft, takeDraft } from './drafts'
|
||||
|
||||
// The draft stash is the last thing between an expired session and lost
|
||||
// writing, so these tests care about two things above all: that a rescued body
|
||||
// comes back intact, and that nothing here can ever throw into a save handler.
|
||||
|
||||
// A minimal in-memory Storage, since vitest runs these in node.
|
||||
function fakeStorage(): Storage {
|
||||
const map = new Map<string, string>()
|
||||
return {
|
||||
get length() {
|
||||
return map.size
|
||||
},
|
||||
key: (i: number) => [...map.keys()][i] ?? null,
|
||||
getItem: (k: string) => map.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => void map.set(k, v),
|
||||
removeItem: (k: string) => void map.delete(k),
|
||||
clear: () => map.clear(),
|
||||
} as Storage
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('localStorage', fakeStorage())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('draft rescue', () => {
|
||||
it('round-trips an unsaved body', () => {
|
||||
stashDraft('doc-1', { content: '{"type":"doc"}', content_text: '春天来了', word_count: 4 })
|
||||
|
||||
const draft = peekDraft('doc-1')
|
||||
expect(draft?.body.content_text).toBe('春天来了')
|
||||
expect(draft?.body.word_count).toBe(4)
|
||||
})
|
||||
|
||||
it('keeps documents apart', () => {
|
||||
stashDraft('doc-1', { content_text: 'mine' })
|
||||
expect(peekDraft('doc-2')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps only the newest attempt', () => {
|
||||
stashDraft('doc-1', { content_text: 'first' })
|
||||
stashDraft('doc-1', { content_text: 'second' })
|
||||
expect(peekDraft('doc-1')?.body.content_text).toBe('second')
|
||||
})
|
||||
|
||||
// A rescue must not be applied twice — the second application would overwrite
|
||||
// whatever was written after the first.
|
||||
it('take consumes the draft', () => {
|
||||
stashDraft('doc-1', { content_text: 'rescued' })
|
||||
expect(takeDraft('doc-1')?.body.content_text).toBe('rescued')
|
||||
expect(takeDraft('doc-1')).toBeNull()
|
||||
})
|
||||
|
||||
it('clears on a successful save', () => {
|
||||
stashDraft('doc-1', { content_text: 'rescued' })
|
||||
clearDraft('doc-1')
|
||||
expect(peekDraft('doc-1')).toBeNull()
|
||||
})
|
||||
|
||||
// A draft surfacing a fortnight later is a surprise, not a save.
|
||||
it('expires stale drafts', () => {
|
||||
stashDraft('doc-1', { content_text: 'ancient' })
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(Date.now() + 8 * 24 * 60 * 60 * 1000)
|
||||
expect(peekDraft('doc-1')).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores a corrupted entry rather than throwing', () => {
|
||||
localStorage.setItem('petal.draft.doc-1', 'not json at all')
|
||||
expect(peekDraft('doc-1')).toBeNull()
|
||||
})
|
||||
|
||||
// Storage can be full, disabled, or absent. Losing the safety net is bad;
|
||||
// throwing from inside a failed save is worse.
|
||||
it('survives storage that refuses to write', () => {
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: () => {
|
||||
throw new Error('nope')
|
||||
},
|
||||
setItem: () => {
|
||||
throw new Error('nope')
|
||||
},
|
||||
removeItem: () => {
|
||||
throw new Error('nope')
|
||||
},
|
||||
} as unknown as Storage)
|
||||
|
||||
expect(() => stashDraft('doc-1', { content_text: 'x' })).not.toThrow()
|
||||
expect(() => clearDraft('doc-1')).not.toThrow()
|
||||
expect(peekDraft('doc-1')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
// Local draft rescue: the last thing standing between an expired session and
|
||||
// lost writing.
|
||||
//
|
||||
// Petal auto-saves 1.5s after every keystroke, which is exactly what makes a
|
||||
// surprise 401 expensive — the writer has no idea the save stopped landing, and
|
||||
// signing in again means a full-page trip through Authentik that throws away
|
||||
// everything the editor is holding in memory. So when a save comes back
|
||||
// unauthorized, the body it failed to send is written to localStorage first,
|
||||
// and reclaimed when the document is opened again after signing in.
|
||||
//
|
||||
// The stash is deliberately per-document and short-lived: it is a rescue, not a
|
||||
// second source of truth. Anything reclaimed is immediately written back to the
|
||||
// server, and the entry is cleared the moment a normal save succeeds.
|
||||
|
||||
import type { DocUpdate } from '../api/client'
|
||||
|
||||
const PREFIX = 'petal.draft.'
|
||||
|
||||
// A rescued draft, plus when it was stashed (shown to the writer, and used to
|
||||
// let anything implausibly old expire rather than resurface).
|
||||
export interface StashedDraft {
|
||||
body: DocUpdate
|
||||
stashedAt: number
|
||||
}
|
||||
|
||||
// MAX_AGE_MS bounds how long a rescue is worth honouring. A draft recovered a
|
||||
// week later is more likely to be a surprise than a save.
|
||||
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
|
||||
|
||||
function key(docId: string): string {
|
||||
return PREFIX + docId
|
||||
}
|
||||
|
||||
// stashDraft records the unsaved body for a document, replacing any earlier one
|
||||
// (the newest attempt is always the most complete).
|
||||
export function stashDraft(docId: string, body: DocUpdate): void {
|
||||
try {
|
||||
const draft: StashedDraft = { body, stashedAt: Date.now() }
|
||||
localStorage.setItem(key(docId), JSON.stringify(draft))
|
||||
} catch {
|
||||
// A full or unavailable localStorage must never break the editor. Losing
|
||||
// the safety net is bad; throwing from a failed save handler is worse.
|
||||
}
|
||||
}
|
||||
|
||||
// peekDraft returns a stashed draft without consuming it, or null if there
|
||||
// isn't a usable one.
|
||||
export function peekDraft(docId: string): StashedDraft | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(key(docId))
|
||||
if (!raw) return null
|
||||
const draft = JSON.parse(raw) as StashedDraft
|
||||
if (!draft?.body || typeof draft.stashedAt !== 'number') return null
|
||||
if (Date.now() - draft.stashedAt > MAX_AGE_MS) {
|
||||
clearDraft(docId)
|
||||
return null
|
||||
}
|
||||
return draft
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// takeDraft returns a stashed draft and removes it in the same breath, so a
|
||||
// rescue can't be applied twice.
|
||||
export function takeDraft(docId: string): StashedDraft | null {
|
||||
const draft = peekDraft(docId)
|
||||
if (draft) clearDraft(docId)
|
||||
return draft
|
||||
}
|
||||
|
||||
export function clearDraft(docId: string): void {
|
||||
try {
|
||||
localStorage.removeItem(key(docId))
|
||||
} catch {
|
||||
// See stashDraft.
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user