v0.23.0: server-side sign-in state resume (roadmap #29)
Track each authenticated user's last-viewed route + light view state server-side, and on next sign-in redirect them to that state, falling back to the empty-state home only when there's no recorded state. Backend: - migration 022_user_session_state.sql: one row per user (user_id PK/FK, last_route, last_route_state JSON-as-TEXT, resume_enabled default 1, last_updated_at). - PUT /api/me/last-state (require_user): upserts route + light state; no-ops when resume_enabled=0. Localized in the /me region. - /api/auth/me payload now carries resume_enabled + last_route + decoded last_route_state (no extra round-trip). - test_session_resume_vertical.py: auth-required, upsert/read-back, per-user isolation, resume_enabled=0 disable. Frontend: - lib/useLastState.js: debounced (~1s) route-change PUT for authenticated users; one-time resume redirect on sign-in, gated on identify having fired (preserves #21 Part C identify-then-track). - api.js: putLastState() client call. - App.jsx: import + call the hook; set identifyReady after identify. Header region untouched. Per-user (not per-device); profile-settings opt-out toggle UI deferred (column + default-on behavior ship now). Stored state is route + light view state ONLY, never draft buffers — documented in SPEC §6.8. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'
|
||||
import { Routes, Route, Link, Navigate, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { getMe, subscribeToNotifications } from './api'
|
||||
import { anonymize, EVENTS, identify, track } from './lib/analytics'
|
||||
import { useLastState } from './lib/useLastState'
|
||||
import Catalog from './components/Catalog.jsx'
|
||||
import Inbox from './components/Inbox.jsx'
|
||||
import RFCView from './components/RFCView.jsx'
|
||||
@@ -42,6 +43,12 @@ export default function App() {
|
||||
// "Privacy & cookies" tab dispatches a `rfc-app:cookie-consent-reopen`
|
||||
// event that bumps this.
|
||||
const [consentReopenTick, setConsentReopenTick] = useState(0)
|
||||
// v0.23.0 / item #29 — flips true once the #21-Part-C identify effect
|
||||
// has fired (or once we've confirmed there's no authenticated user to
|
||||
// identify). useLastState gates its resume redirect on this so the
|
||||
// redirect always happens AFTER identify, preserving identify-then-
|
||||
// track ordering.
|
||||
const [identifyReady, setIdentifyReady] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
// v0.15.0 — Page Viewed event taxonomy. We fire on every
|
||||
@@ -86,6 +93,9 @@ export default function App() {
|
||||
if (viewer?.first_sign_in_at) props.first_sign_in_at = ['__setOnce__', viewer.first_sign_in_at]
|
||||
if (viewer?.created_at) props.account_created_at = ['__setOnce__', viewer.created_at]
|
||||
identify({ user_id: String(uid), properties: props })
|
||||
// v0.23.0 / item #29 — identify has now fired for this sign-in;
|
||||
// release useLastState's resume redirect (it waits on this).
|
||||
setIdentifyReady(true)
|
||||
} else if (uid == null && lastUserIdRef.current != null) {
|
||||
// Sign-out edge — App-level reset is handled separately by the
|
||||
// sign-out gesture that fires User Signed Out. Clear our local
|
||||
@@ -94,6 +104,14 @@ export default function App() {
|
||||
}
|
||||
}, [me?.authenticated, me?.user?.id, me?.user?.role, me?.user?.permission_state, me?.user?.passcode_set, me?.user?.device_trusted])
|
||||
|
||||
// v0.23.0 / item #29 — once `me` has resolved, if there's no
|
||||
// authenticated user there is nothing to identify, so release the
|
||||
// resume gate immediately (anonymous boots have no resume to do, but
|
||||
// the hook still needs the gate resolved to be a clean no-op).
|
||||
useEffect(() => {
|
||||
if (me != null && !me.authenticated) setIdentifyReady(true)
|
||||
}, [me])
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => setConsentReopenTick(t => t + 1)
|
||||
window.addEventListener('rfc-app:cookie-consent-reopen', handler)
|
||||
@@ -107,6 +125,18 @@ export default function App() {
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
// v0.23.0 / item #29 — server-side sign-in state resume. The hook
|
||||
// debounce-posts the current route for authenticated users and, once
|
||||
// identify has fired, redirects a fresh sign-in (which hard-lands on
|
||||
// "/") to the user's stored last route. Anonymous users: no-op.
|
||||
useLastState({
|
||||
authenticated: !!me?.authenticated,
|
||||
pathname: location.pathname,
|
||||
identifyReady,
|
||||
lastRoute: me?.authenticated ? me.user?.last_route : null,
|
||||
navigate,
|
||||
})
|
||||
|
||||
// §15.3 — subscribe to the live SSE stream for authenticated viewers
|
||||
// so the badge counter and the toast surface stay in lockstep with
|
||||
// the inbox. Tabs that miss an event because they were closed pick
|
||||
|
||||
Reference in New Issue
Block a user