refactor(auth): migrate fully to HTTP-only cookie sessions

Remove client-side token storage and Authorization header injection
Backend login now only returns user data, omitting the session token
Remove Authorization from backend CORS allowed headers
Clean up obsolete VITE_* environment variable fallbacks
Update Modal component to use Vue useId() instead of Math.random()
This commit is contained in:
2026-05-06 17:15:46 +08:00
parent f26cfdc830
commit fa656a8d02
24 changed files with 123 additions and 357 deletions

View File

@@ -10,8 +10,6 @@ BACKEND_PUBLIC_ORIGIN=http://localhost:20016
NUXT_PUBLIC_API_BASE_URL=http://localhost:20016
NUXT_SERVER_API_BASE_URL=http://localhost:3001
NUXT_PUBLIC_SITE_URL=https://pokopiawiki.tootaio.com
VITE_API_BASE_URL=http://localhost:20016
VITE_SITE_URL=https://pokopiawiki.tootaio.com
RESEND_API_KEY=
EMAIL_FROM="Pokopia Wiki <onboarding@resend.dev>"
RESEND_DAILY_QUOTA_LIMIT=100
@@ -33,4 +31,3 @@ AI_MODERATION_API_KEY=
# NUXT_PUBLIC_API_BASE_URL=https://api-pokopiawiki.tootaio.com
# NUXT_SERVER_API_BASE_URL=http://backend:3001
# NUXT_PUBLIC_SITE_URL=https://pokopiawiki.tootaio.com
# VITE_API_BASE_URL=https://api-pokopiawiki.tootaio.com

View File

@@ -123,12 +123,13 @@
- 登录页提供 Remember me
- 未勾选时 session 有效期为 1 天。
- 勾选时 session 有效期为 30 天。
- SSR 迁移期认证使用 hybrid session model
- SSR 认证使用 HTTP-only cookie session
- 登录成功后后端设置 HTTP-only `pokopia_session` cookiecookie 只保存明文 session token数据库只保存 session token hash。
- 迁移期登录响应返回明文 session token,前端继续按 Remember me 语义保存到 `sessionStorage``localStorage``pokopia_auth_token`,用于保持现有 SPA 客户端流程兼容
- 受保护 API 优先接受 HTTP-only cookie session并继续兼容 `Authorization: Bearer` legacy token
- 登录响应只返回当前用户必要字段,不返回明文 session tokensession token hash 或内部 session 元数据
- Remember me 通过 HTTP-only session cookie 有效期实现:未勾选时有效期为 1 天,勾选时有效期为 30 天
- 受保护 API 只接受 HTTP-only cookie session不接受前端 JavaScript 保存的 legacy Bearer token。
- 前端 API 请求携带 credentials以便浏览器自动发送 HTTP-only session cookieJavaScript 不读取该 cookie。
- 用户可退出登录,退出时删除对应 session清除 HTTP-only session cookie,并清理前端 legacy token storage
- 用户可退出登录,退出时删除对应 session清除 HTTP-only session cookie。
- 对外用户字段只包含必要信息:
- 当前用户:`id``email``displayName``emailVerified`
- 编辑署名:`id``displayName`
@@ -1041,7 +1042,7 @@ API 暴露边界:
- `favicon.ico`
- 默认社交分享图
- 品牌 Logo 素材
- `NUXT_PUBLIC_SITE_URL` 定义 canonical、Open Graph URL、robots sitemap 地址和 sitemap URL 的站点根地址;当前公开站点为 `https://pokopiawiki.tootaio.com`,本地前端端口默认使用 `http://localhost:20015`Nuxt 配置仍兼容读取旧的 `VITE_SITE_URL` 作为 fallback。
- `NUXT_PUBLIC_SITE_URL` 定义 canonical、Open Graph URL、robots sitemap 地址和 sitemap URL 的站点根地址;当前公开站点为 `https://pokopiawiki.tootaio.com`,本地前端端口默认使用 `http://localhost:20015`
- 前端 Nuxt app head 配置提供默认 title、description、robots、canonical、Open Graph、Twitter card 和 favicon路由 metadata 与详情页公开业务数据通过 Nuxt `useHead` 更新页面 metadata避免直接操作 `document.head`
- 主要公开浏览入口可索引:
- `/pokemon`
@@ -1066,7 +1067,7 @@ API 暴露边界:
## 部署与升级维护
- Docker 部署时公开前端端口由 `frontend_gateway` 承载,正常流量代理到 `frontend` 服务。
- 前端浏览器 API base URL 由 `NUXT_PUBLIC_API_BASE_URL` 提供Nuxt 配置仍兼容读取旧的 `VITE_API_BASE_URL` 作为 fallback
- 前端浏览器 API base URL 由 `NUXT_PUBLIC_API_BASE_URL` 提供。
- Nuxt 服务端 API base URL 由 `NUXT_SERVER_API_BASE_URL` 提供;在 Docker 内默认使用 `http://backend:3001`,本地非 Docker 运行可使用 `http://localhost:3001`。服务端公开数据读取使用该内部地址,浏览器请求继续使用 `NUXT_PUBLIC_API_BASE_URL`
- 前端 Docker 构建使用 Nuxt server output`frontend` 服务通过 Node 运行 `.output/server/index.mjs`Nuxt SSR server 监听容器内 `0.0.0.0:20015`,公开流量仍由 `frontend_gateway` 代理。
- `frontend``docker compose up -d --build` 重建、启动中或临时不可达时,`frontend_gateway` 返回静态升级维护页并保持公开端口可访问;后端 `/health` 不可用时,前端网关也返回同一维护页,避免用户看到静态页面后遇到 API 不可用。

View File

@@ -1,176 +1,60 @@
# SSR Migration Task List
# SSR Migration Remaining Tasks
This temporary task list tracks the work required to move the frontend from the current Nuxt SPA migration state to a complete SSR-capable Nuxt deployment.
This temporary file tracks only the work still required before the Nuxt SSR migration can be considered complete.
Keep this file aligned with implementation progress while the SSR migration is in flight. When SSR migration is complete and validated, delete this file and remove the related temporary instruction from `AGENTS.md`.
Delete this file only after all items below are complete and `AGENTS.md` no longer needs the temporary SSR migration workflow.
## Target State
## Remaining Work
- [x] Nuxt runs with `ssr: true` for production.
- [ ] Public browsing routes render meaningful HTML on the server, including localized metadata and public business data where practical.
- [ ] Authenticated, management, edit, and modal workflows remain functionally equivalent to the current SPA behavior.
- [ ] No password hashes, session token hashes, verification/reset token hashes, role internals, permission internals, audit payloads, debug fields, or implementation notes are exposed through SSR payloads, API responses, generated HTML, metadata, logs, or UI.
- [ ] `DESIGN.md`, Docker configuration, environment variable documentation, and runtime behavior agree.
## Phase 1: Baseline Audit
- [x] Read `DESIGN.md`, `DesignGuidelines.html` when UI behavior is touched, `AGENTS.md`, and this task list before making SSR migration changes.
- [x] Inventory all browser-only access in `frontend/src`, `frontend/app.vue`, `frontend/plugins`, `frontend/middleware`, and `frontend/pages`: `window`, `document`, `localStorage`, `sessionStorage`, DOM measurement, event listeners, timers, clipboard, `confirm`, `matchMedia`, and direct head mutation.
- [x] Classify each browser-only usage as client-only component behavior, SSR-safe fallback behavior, or logic that must be moved into Nuxt composables/plugins.
- [x] Inventory route-level data loading across public list/detail pages, authenticated pages, management pages, and route-backed modal pages.
- [x] Identify public routes that should SSR first: Home, Pokemon/Event Pokemon lists, Habitat/Event Habitat lists, Items/Event Items lists, Ancient Artifacts, Recipes, Daily CheckList, Dish, Life list/detail, Project Updates, legal pages, and public Profile.
- [x] Identify routes that should remain client-only or mostly client-rendered initially: Login, Register, Forgot/Reset Password, Verify Email, Admin, `/profile`, notification UI, upload/edit forms, and route-backed edit/create modals.
### Phase 1 Audit Notes
- Browser-only access is concentrated in client interactions: modal focus/body locking, dropdown positioning, sidebar/mobile drawer behavior, search debounce, infinite-scroll sentinels, upload/download helpers, clipboard copy, local checklist state, route-backed form defaults, WebSocket notifications, moderation update events, and destructive-action `window.confirm` calls. These should stay in mounted/client-only lifecycle paths during SSR enablement.
- SSR-safe fallback candidates already guard storage or DOM access in `frontend/src/i18n.ts`, `frontend/src/services/api.ts`, and several views with `typeof window`, `typeof document`, `typeof localStorage`, `typeof sessionStorage`, or `typeof IntersectionObserver`.
- Logic that must move to SSR-aware Nuxt APIs in later phases: direct SEO mutation in `frontend/src/seo.ts` / `frontend/plugins/02-seo.client.ts`, global Vue I18n singleton request state, auth middleware's storage-only token model, and Nuxt config analytics script injection.
- Current route data loading is client-mounted in views rather than route-level `useAsyncData` / `useFetch`. Public list/detail candidates load through `api.*Page`, `api.*Detail`, `api.dish`, `api.dailyChecklistPage`, `api.lifePosts`, `api.lifePost`, `api.projectUpdates`, and public profile/activity endpoints. Auth, admin, edit/create modal, notification, upload, comment/reaction, and profile account flows depend on client auth state or browser APIs and should not be first-wave SSR data routes.
- First SSR data groups should be low-risk public reads: Home/project update preview, legal/static pages, Pokemon/Event Pokemon lists and details, Habitat/Event Habitat lists and details, Items/Event Items/Ancient Artifacts lists and details, Recipes list/details, Daily CheckList, Dish, Life public feed/detail, Project Updates, and public Profile.
## Phase 2: Runtime Config And API Layer
- [x] Replace client-only API base URL setup with an SSR-safe runtime config helper that works in server and client contexts.
- [x] Define separate public/browser API origin and internal server API origin if Docker networking requires different URLs for server-side fetches and browser fetches.
- [x] Ensure every server-side API read sends the correct `X-Locale` and never sends browser-only bearer tokens unless a secure SSR auth mechanism is implemented.
- [x] Add a small SSR-safe fetch wrapper or adapt `frontend/src/services/api.ts` so public reads can be called from server-side setup without depending on `window`, storage, or DOM APIs.
- [x] Keep frontend API response types consistent with `frontend/src/services/api.ts`.
- [ ] Ensure API errors used for SSR public routes degrade to intended empty/error states without leaking stack traces or internal fields into rendered HTML.
- [x] Pokemon and Event Pokemon list SSR reads degrade to null initial data and existing skeleton/empty UI without rendering raw backend errors.
## Phase 3: Authentication And Session Model
- [x] Decide and document the SSR-compatible auth model in `DESIGN.md` before implementation.
- [x] Migrate auth from `localStorage` / `sessionStorage` bearer-token-only behavior to an HTTP-only cookie/session model, or explicitly document a hybrid model if Remember me must preserve current storage behavior.
- [x] Update backend login/logout/session endpoints to support the chosen cookie/session model without exposing session token hashes or internal session metadata.
- [x] Preserve Remember me semantics: 1 day for non-remembered sessions, 30 days for remembered sessions.
- [x] Preserve email verification as the base requirement for protected writes.
- [ ] Ensure current-user SSR reads expose only the allowed current-user fields defined in `DESIGN.md`.
- [ ] Update route middleware so server-side redirects for authenticated and permissioned routes match current client-side behavior.
- [x] Server-side route middleware forwards the incoming HTTP-only session cookie to `api.me()` for authenticated, verified, and permissioned route checks.
- [ ] Ensure public SSR pages never render private current-user data into HTML meant for anonymous users.
- [x] Pokemon and Event Pokemon list SSR reads do not call `api.me()` or forward cookies; create actions remain client-hydrated after mount.
- [x] Add a clear logout flow that clears both server cookies and any legacy client storage during the transition.
### Phase 3 Auth Notes
- The migration now uses a hybrid session model: backend login sets an HTTP-only `pokopia_session` cookie and still returns the legacy bearer token so existing SPA storage behavior keeps working during the transition.
- Protected backend reads and writes accept the HTTP-only cookie first and remain compatible with `Authorization: Bearer` tokens.
- Frontend API requests use `credentials: 'include'` so browser requests can carry the cookie without exposing it to JavaScript.
- Login still stores the legacy token according to Remember me semantics; logout deletes the server session, clears the cookie, and clears legacy frontend storage.
- Server-side auth middleware forwards the incoming SSR request cookie only for `api.me()` checks, allowing HTTP-only session cookies to participate in SSR route redirects without adding private auth headers to public page data requests.
## Phase 4: Nuxt SSR Enablement
- [x] Change Nuxt config from `ssr: false` to `ssr: true` only after browser-only usage and auth strategy are ready.
- [ ] Split plugins by runtime where needed: `.client.ts` for DOM/event/storage logic, `.server.ts` for SSR-only initialization, and universal plugins only for code safe in both contexts.
- [x] Ensure Vue I18n is installed safely for SSR and does not share mutable per-request state across users.
- [x] Move direct `document.head` SEO mutation to Nuxt `useHead` / `useSeoMeta` or another SSR-aware head strategy.
- [x] Ensure route metadata remains the source for default SEO, required auth, required permission, editor modal behavior, and noindex rules.
- [ ] Confirm route-backed modal pages still preserve underlying page context and avoid unwanted scroll jumps.
- [ ] Keep UI business text localized through Vue I18n/system wordings; do not add implementation notes or debug text to the UI.
### Phase 4 SEO Notes
- `frontend/src/seo.ts` now resolves SEO state without mutating `document.head` or `document.title`.
- `frontend/plugins/02-seo.ts` is a universal Nuxt plugin that binds route metadata and client-side detail overrides to `useHead`.
- The Nuxt config analytics script is declarative and no longer injects a script with `document.head.appendChild`.
### Phase 4 I18n Notes
- `frontend/src/i18n.ts` now exports a Vue I18n factory instead of a module-level singleton.
- `frontend/plugins/01-i18n.ts` creates and installs one I18n instance per Nuxt app/request; only the browser instance is registered for legacy helpers that need localStorage and locale-change events.
- SEO route metadata translation uses the current Nuxt app's I18n translator instead of importing a shared global I18n instance.
### Phase 4 SSR Config Notes
- `frontend/nuxt.config.ts` now uses `ssr: true`.
- `pnpm --filter @pokopia/frontend build` completed with Nuxt SSR enabled and generated Nuxt server output at `.output/server/index.mjs`.
- Production container now targets the Nuxt server entry point; Docker runtime validation remains tracked in Phase 8.
## Phase 5: Server-Side Data And SEO
- [ ] Implement SSR data loading for stable public routes in small groups, starting with low-risk public pages.
- [x] Pokemon and Event Pokemon list routes SSR-load shared options and the first public list page.
- [x] Habitat and Event Habitat list routes SSR-load the first public list page.
- [x] Items, Event Items, Ancient Artifacts, and Recipes list routes SSR-load shared options and the first public list page.
- [x] Daily CheckList and Dish routes SSR-load their public business data.
- [x] Home and Project Updates SSR-load the public project update preview/feed.
- [ ] For each SSR-enabled public route, render title, description, canonical URL, robots value, Open Graph, Twitter card, and structured data from public business data and system wording only.
- [x] Route-level SEO output now owns dynamic title, description, canonical, robots, Open Graph, Twitter card, and valid inline JSON-LD without duplicate static Nuxt head metadata.
- [x] Public detail pages can now override route-level head from SSR-loaded public business data when the corresponding API read succeeds.
- [ ] For detail pages, use entity names, public images, localized public fields, and canonical detail URLs after public API data loads server-side.
- [x] Pokemon, Habitat, Item, Ancient Artifact, Recipe, Life Post, and public Profile detail routes SSR-load public data and bind detail-specific head tags.
- [ ] Preserve `noindex` on auth, admin, new, edit, and in-development routes.
- [x] SEO resolver defaults authenticated, verified, and permissioned routes to `noindex`, while existing route metadata continues to mark auth, edit/create modal, and in-development pages as `noindex`.
- [ ] Keep `robots.txt` and `sitemap.xml` generated from the same stable public route set documented in `DESIGN.md`.
- [x] Sitemap includes Home, public index sections, Project Updates, and legal pages; robots keeps auth, admin, edit/create, and in-development routes disallowed.
- [ ] Avoid serializing private auth state, raw permissions, internal audit payloads, or unneeded API payload fields into Nuxt payloads.
- [ ] Confirm localized reads follow the fallback order in `DESIGN.md`: requested locale, default-language translation, base field.
### Phase 5 Public Data Notes
- Pokemon and Event Pokemon list routes now SSR-load the shared options payload and first public list page through `useAsyncData`; filter changes, infinite loading, and route-backed create modals continue to use the existing client behavior.
- Pokemon list SSR API failures are contained to null initial data so rendered HTML falls back to the existing skeleton/empty behavior without exposing backend stack traces, raw errors, or internal fields.
- Public Pokemon list SSR data does not request `api.me()` or forward cookies; create actions remain client-hydrated from the current user after mount.
- Habitat/Event Habitat, Items/Event Items, Ancient Artifacts, Recipes, Daily CheckList, Dish, Home, and Project Updates now use contained `useAsyncData` public reads for SSR initial content. Client-side auth reads, editor-only options, filters, infinite loading, ordering, and route-backed modals remain hydrated after mount.
- Pokemon, Habitat, Item, Ancient Artifact, Recipe, Life Post, and public Profile detail views now use contained SSR public reads for initial content and detail-specific SEO head output. Auth-only permissions, profile account data, reactions, comments, follow state, editor controls, and moderation actions remain client-hydrated.
- The static fallback SEO tags in Nuxt config were reduced to non-route-specific defaults so route-level SSR SEO is the single source for canonical, robots, social metadata, and JSON-LD.
## Phase 6: Browser-Only UI Isolation
- [ ] Move DOM event listeners, resize/scroll handlers, focus traps, modal body locking, clipboard behavior, and `window.confirm` calls into client-only lifecycle paths.
- [ ] Ensure components with DOM measurement render stable SSR placeholders or no-op behavior until mounted.
- [ ] Keep loading states as Skeleton loaders where existing page patterns support them.
- [ ] Validate that notification WebSocket setup only runs on the client and never during SSR.
- [ ] Validate upload widgets and file APIs only run on the client.
- [ ] Ensure route transitions and scroll behavior remain consistent with the current route-backed modal rules.
## Phase 7: Docker And Deployment
- [x] Update frontend Docker image from static `.output/public` serving to Nuxt server output when SSR is enabled.
- [ ] Run the production container with the Nuxt server entry point rather than the current static server.
- [x] Update `frontend_gateway` proxy behavior if SSR server health, fallback, or cache behavior changes.
- [x] Document required environment variables, including public browser API URL, internal server API URL, site URL, and any cookie/session settings.
- [x] Keep the upgrade maintenance page independent from Nuxt, backend API, and database.
- [x] Preserve public frontend port `20015` unless `DESIGN.md` and compose configuration are intentionally updated together.
### Phase 7 Deployment Notes
- `frontend/package.json` now uses `nuxt build` so the production build emits Nitro server output.
- `frontend/Dockerfile` now runs `node .output/server/index.mjs` with `HOST=0.0.0.0` and `PORT=20015`; the obsolete lightweight static server file was removed.
- `frontend_gateway` continues to proxy `frontend:20015` and keep the backend health-gated maintenance fallback independent from Nuxt.
- `DESIGN.md` now documents the Nuxt server output deployment model and the existing browser API, server API, site URL, origin, and proxy environment variables.
- `docker-compose.debug.yml` provides a separate local debug path that runs Nuxt dev server and backend `tsx watch` with bind-mounted source; default `docker compose up --build` remains the production SSR runtime validation path.
- A local smoke check of `node frontend/.output/server/index.mjs` on port `20115` returned SSR HTML for `/` and `200` for `/robots.txt`; Docker compose runtime validation is still pending.
## Phase 8: Validation
- [x] Run `pnpm --filter @pokopia/frontend typecheck`.
- [x] Run `pnpm --filter @pokopia/frontend lint`.
- [x] Run `pnpm --filter @pokopia/frontend build`.
- [ ] Do not run tests in WSL unless explicitly requested.
- [ ] Ask the user to run `docker compose up --build` for runtime validation, then fix any pasted Docker output in follow-up passes.
- [ ] Verify anonymous SSR HTML for public routes includes meaningful public content and metadata.
- [ ] Verify authenticated routes redirect correctly when unauthenticated, unverified, or missing permissions.
- [ ] Verify logged-in flows still work after hydration: login, logout, Remember me, Profile, notifications, create/edit modals, uploads, comments, reactions, and admin access.
- [ ] Verify generated HTML and Nuxt payloads do not contain forbidden internal data.
- [ ] Verify `robots.txt`, `sitemap.xml`, canonical URLs, noindex routes, and public detail metadata.
### Phase 8 Validation Notes
- 2026-05-06: After SSR auth cookie forwarding and Pokemon/Event Pokemon first-page SSR data, `pnpm --filter @pokopia/frontend typecheck`, `pnpm --filter @pokopia/frontend lint`, and `pnpm --filter @pokopia/frontend build` passed. The current `lint` script runs `nuxt typecheck`.
- 2026-05-06: After SEO foundation updates, `pnpm --filter @pokopia/frontend typecheck`, `pnpm --filter @pokopia/frontend lint`, and `pnpm --filter @pokopia/frontend build` passed. Local built-server smoke on port `20116` verified `/pokemon` route-level canonical/meta/JSON-LD, `sitemap.xml`, and `robots.txt`.
- 2026-05-06: After the first public list SSR data expansion, `pnpm --filter @pokopia/frontend typecheck`, `pnpm --filter @pokopia/frontend lint`, and `pnpm --filter @pokopia/frontend build` passed. The build completed with existing Nuxt/Nitro warnings only.
- 2026-05-06: After public detail SSR data and head expansion, `pnpm --filter @pokopia/frontend typecheck`, `pnpm --filter @pokopia/frontend lint`, and `pnpm --filter @pokopia/frontend build` passed. Local built-server smoke on port `20117` verified `robots.txt` and `sitemap.xml`; business-data HTML verification still needs Docker/backend runtime because the local Nuxt server could not reach `localhost:3001`.
## Phase 9: Cleanup
- [ ] Remove legacy SPA-only compatibility paths once SSR behavior is stable and no longer needed.
- [x] Remove obsolete static server usage if the production frontend container runs the Nuxt server.
- [ ] Remove obsolete `VITE_*` fallback support only after deployment configuration has fully moved to `NUXT_PUBLIC_*` or documented replacement variables.
- [ ] Update `DESIGN.md` from "Nuxt SPA mode" to the final SSR deployment model.
- [ ] Update `AGENTS.md` frontend stack and workflow notes to the final SSR state.
- [ ] Run production Docker validation with `docker compose up --build`.
- [ ] Fix any Docker runtime errors from the production SSR container, frontend gateway, backend API, or SSR server-to-backend API connection.
- [ ] Verify anonymous SSR HTML for public routes contains meaningful public business content and route/detail metadata:
- `/`
- `/pokemon`
- `/event-pokemon`
- `/habitats`
- `/event-habitats`
- `/items`
- `/event-items`
- `/ancient-artifacts`
- `/recipes`
- `/checklist`
- `/dish`
- `/life`
- `/life/:id`
- `/profile/:id`
- `/project-updates`
- [ ] Verify generated HTML, Nuxt payloads, API responses used by SSR, metadata, and logs do not expose password hashes, session token hashes, verification/reset token hashes, private current-user data on public pages, role internals, permission internals, internal audit payloads, debug fields, stack traces, or implementation notes.
- [ ] Verify localized SSR reads and metadata follow the `DESIGN.md` fallback order: requested locale, default-language translation, then base field.
- [ ] Verify auth and permission route behavior with SSR enabled:
- anonymous users redirect from protected routes to login
- unverified users cannot access verified-only write flows
- users missing permissions cannot access permissioned routes
- current-user reads expose only fields allowed by `DESIGN.md`
- [ ] Verify hydrated logged-in flows still work:
- login
- logout
- Remember me
- `/profile`
- notifications
- route-backed create/edit modals
- uploads
- Life comments/reactions
- entity discussion comments
- admin access
- [ ] Verify browser-only UI behavior runs only on the client and remains stable after hydration:
- modal focus and body locking
- dropdown positioning
- scroll/resize listeners
- infinite-scroll sentinels
- clipboard actions
- `window.confirm` actions
- notification WebSocket
- upload file APIs
- [ ] Verify route-backed modal pages preserve underlying page context and avoid unwanted scroll jumps.
- [ ] Verify `robots.txt`, `sitemap.xml`, canonical URLs, `noindex` routes, Open Graph, Twitter card, and public detail metadata in the production runtime.
- [x] Remove legacy SPA-only compatibility paths once SSR behavior is stable.
- [x] Remove obsolete `VITE_*` fallback support after deployment has fully moved to documented `NUXT_*` variables.
- [x] Update `DESIGN.md` if final behavior differs from the current documented SSR deployment, auth, SEO, or environment-variable model.
- [ ] Update `AGENTS.md` to remove the temporary SSR migration workflow and the requirement to read this task list.
- [ ] Delete `SSR_MIGRATION_TASKLIST.md`.
- [ ] Remove the temporary `AGENTS.md` instruction that requires reading and maintaining this task list.

View File

@@ -185,7 +185,7 @@ function configuredCorsOrigin(): true | string | string[] {
}
await app.register(cors, {
allowedHeaders: ['Authorization', 'Content-Type', 'X-Locale'],
allowedHeaders: ['Content-Type', 'X-Locale'],
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
origin: configuredCorsOrigin()
@@ -247,11 +247,6 @@ app.get('/api/search', async (request) =>
globalSearch(request.query as Record<string, string | string[] | undefined>, requestLocale(request))
);
function getBearerToken(authorization: string | undefined): string | null {
const [scheme, token] = authorization?.split(' ') ?? [];
return scheme === 'Bearer' && token ? token : null;
}
function getCookieValue(cookieHeader: string | undefined, name: string): string | null {
if (!cookieHeader) {
return null;
@@ -272,7 +267,7 @@ function getCookieValue(cookieHeader: string | undefined, name: string): string
}
function getSessionToken(request: FastifyRequest): string | null {
return getCookieValue(request.headers.cookie, sessionCookieName) ?? getBearerToken(request.headers.authorization);
return getCookieValue(request.headers.cookie, sessionCookieName);
}
function sessionCookieSecure(): boolean {
@@ -1038,7 +1033,7 @@ app.post('/api/auth/login', async (request, reply) => {
const payload = request.body as Record<string, unknown>;
const response = await loginUser(payload, requestLocale(request));
setSessionCookie(reply, response.token, payload.rememberMe === true);
return response;
return { user: response.user };
});
app.post('/api/auth/request-password-reset', async (request, reply) => {

View File

@@ -81,8 +81,6 @@ services:
NUXT_PUBLIC_API_BASE_URL: ${NUXT_PUBLIC_API_BASE_URL:-http://localhost:20016}
NUXT_SERVER_API_BASE_URL: ${NUXT_SERVER_API_BASE_URL:-http://backend:3001}
NUXT_PUBLIC_SITE_URL: ${NUXT_PUBLIC_SITE_URL:-http://localhost:20015}
VITE_API_BASE_URL: ${VITE_API_BASE_URL:-http://localhost:20016}
VITE_SITE_URL: ${VITE_SITE_URL:-http://localhost:20015}
ports:
- "20015:20015"
volumes:

View File

@@ -21,7 +21,7 @@ import {
type AppIcon
} from './src/icons';
import { getCurrentLocale, loadSystemWordings, onLocaleChange, setCurrentLocale } from './src/i18n';
import { api, getAuthToken, onAuthTokenChange, setAuthToken, type AuthUser, type Language } from './src/services/api';
import { api, notifyAuthChange, onAuthChange, type AuthUser, type Language } from './src/services/api';
const { t, locale } = useI18n();
const router = useRouter();
@@ -117,9 +117,6 @@ async function loadCurrentUser() {
currentUser.value = response.user;
} catch {
currentUser.value = null;
if (getAuthToken()) {
setAuthToken(null);
}
}
}
@@ -131,7 +128,7 @@ async function logout() {
}
currentUser.value = null;
setAuthToken(null);
notifyAuthChange();
await router.push('/');
}
@@ -160,7 +157,7 @@ async function updateLocale(value: string) {
onMounted(() => {
void loadLanguages();
void loadCurrentUser();
removeAuthListener = onAuthTokenChange(() => {
removeAuthListener = onAuthChange(() => {
void loadCurrentUser();
});
removeLocaleListener = onLocaleChange(() => {

View File

@@ -1,4 +1,4 @@
import { api, setAuthToken } from '../src/services/api';
import { api } from '../src/services/api';
export default defineNuxtRouteMiddleware(async (to) => {
const requiredPermissions = to.matched
@@ -30,7 +30,6 @@ export default defineNuxtRouteMiddleware(async (to) => {
return navigateTo('/pokemon');
}
} catch {
setAuthToken(null);
return navigateTo({ path: '/login', query: { redirect: to.fullPath } });
}
});

View File

@@ -12,13 +12,11 @@ export default defineNuxtConfig({
runtimeConfig: {
serverApiBaseUrl:
process.env.NUXT_SERVER_API_BASE_URL ??
process.env.NUXT_API_BASE_URL ??
process.env.NUXT_PUBLIC_API_BASE_URL ??
process.env.VITE_API_BASE_URL ??
'http://localhost:3001',
public: {
apiBaseUrl: process.env.NUXT_PUBLIC_API_BASE_URL ?? process.env.VITE_API_BASE_URL ?? 'http://localhost:3001',
siteUrl: normalizeSiteUrl(process.env.NUXT_PUBLIC_SITE_URL ?? process.env.VITE_SITE_URL)
apiBaseUrl: process.env.NUXT_PUBLIC_API_BASE_URL ?? 'http://localhost:3001',
siteUrl: normalizeSiteUrl(process.env.NUXT_PUBLIC_SITE_URL)
}
},
app: {

View File

@@ -8,10 +8,8 @@ import Tabs, { type TabOption } from './Tabs.vue';
import { iconCancel, iconComment, iconDelete, iconReactionLike, iconReply, iconWarning } from '../icons';
import {
api,
getAuthToken,
moderationUpdateEvent,
onAuthTokenChange,
setAuthToken,
onAuthChange,
type AiModerationStatus,
type AuthUser,
type CommentSort,
@@ -77,18 +75,11 @@ const sortOptions = computed<Array<{ value: CommentSort; label: string }>>(() =>
async function loadCurrentUser() {
authReady.value = false;
if (!getAuthToken()) {
currentUser.value = null;
authReady.value = true;
return;
}
try {
const response = await api.me();
currentUser.value = response.user;
} catch {
currentUser.value = null;
setAuthToken(null);
} finally {
authReady.value = true;
}
@@ -515,7 +506,7 @@ onMounted(() => {
void loadCurrentUser();
void loadLanguages();
void loadDiscussion();
removeAuthListener = onAuthTokenChange(() => {
removeAuthListener = onAuthChange(() => {
void loadCurrentUser();
});
});

View File

@@ -4,7 +4,7 @@ let openModalCount = 0;
<script setup lang="ts">
import { Icon } from '@iconify/vue';
import { nextTick, onBeforeUnmount, onMounted, onUpdated, ref, watch } from 'vue';
import { nextTick, onBeforeUnmount, onMounted, onUpdated, ref, useId, watch } from 'vue';
import { iconClose } from '../icons';
const props = withDefaults(
@@ -29,7 +29,7 @@ const emit = defineEmits<{
close: [];
}>();
const titleId = `modal-title-${Math.random().toString(36).slice(2)}`;
const titleId = useId();
const dialog = ref<HTMLElement | null>(null);
const modalBody = ref<HTMLElement | null>(null);
const closeButton = ref<HTMLButtonElement | null>(null);

View File

@@ -17,7 +17,6 @@ import {
} from '../icons';
import {
api,
getAuthToken,
moderationUpdateEvent,
notificationWebSocketUrl,
type AuthUser,
@@ -92,7 +91,7 @@ function disconnectNotifications() {
function scheduleReconnect() {
clearReconnectTimer();
if (stopped || !props.currentUser || !getAuthToken()) {
if (stopped || !props.currentUser) {
return;
}
@@ -118,7 +117,7 @@ function isNotificationWsMessage(value: unknown): value is NotificationWsMessage
}
async function connectNotifications() {
if (!props.currentUser || !getAuthToken() || typeof WebSocket === 'undefined') {
if (!props.currentUser || typeof WebSocket === 'undefined') {
return;
}

View File

@@ -2,7 +2,6 @@ import { getCurrentLocale } from '../i18n';
let browserApiBaseUrl = 'http://localhost:3001';
let serverApiBaseUrl = 'http://localhost:3001';
const authTokenKey = 'pokopia_auth_token';
const authChangeEvent = 'pokopia-auth-change';
export interface ApiRequestOptions {
@@ -807,7 +806,6 @@ export interface RegisterPayload extends LoginPayload {
}
export interface AuthResponse {
token: string;
user: AuthUser;
}
@@ -1061,40 +1059,7 @@ export function buildQuery(params: Record<string, string | number | boolean | nu
return query ? `?${query}` : '';
}
function authStorage(type: 'local' | 'session'): Storage | null {
if (typeof window === 'undefined') {
return null;
}
return type === 'local' ? window.localStorage : window.sessionStorage;
}
export function getAuthToken(): string | null {
const sessionToken = authStorage('session')?.getItem(authTokenKey);
return sessionToken ?? authStorage('local')?.getItem(authTokenKey) ?? null;
}
export function setAuthToken(token: string | null, options: { persistent?: boolean } = {}): void {
const local = authStorage('local');
const session = authStorage('session');
if (token) {
if (options.persistent === false) {
session?.setItem(authTokenKey, token);
local?.removeItem(authTokenKey);
} else {
local?.setItem(authTokenKey, token);
session?.removeItem(authTokenKey);
}
} else {
local?.removeItem(authTokenKey);
session?.removeItem(authTokenKey);
}
notifyAuthChange();
}
export function onAuthTokenChange(callback: () => void): () => void {
export function onAuthChange(callback: () => void): () => void {
if (typeof window === 'undefined') {
return () => {};
}
@@ -1111,12 +1076,7 @@ export function notifyAuthChange(): void {
function requestHeaders(extraHeaders?: HeadersInit): Headers {
const headers = new Headers(extraHeaders);
const token = getAuthToken();
headers.set('X-Locale', headers.get('X-Locale') ?? getCurrentLocale());
if (token && !headers.has('Authorization')) {
headers.set('Authorization', `Bearer ${token}`);
}
return headers;
}

View File

@@ -11,7 +11,7 @@ import Skeleton from '../components/Skeleton.vue';
import Tabs, { type TabOption } from '../components/Tabs.vue';
import TagsSelect from '../components/TagsSelect.vue';
import { iconAdd, iconArtifact } from '../icons';
import { api, getAuthToken, type AncientArtifact, type AuthUser, type ListPage, type Options } from '../services/api';
import { api, type AncientArtifact, type AuthUser, type ListPage, type Options } from '../services/api';
import ItemEdit from './ItemEdit.vue';
const route = useRoute();
@@ -150,13 +150,11 @@ function loadMoreArtifacts() {
}
onMounted(async () => {
if (getAuthToken()) {
try {
currentUser.value = (await api.me()).user;
} catch {
currentUser.value = null;
}
}
if (!options.value) {
try {
options.value = await api.options();

View File

@@ -13,7 +13,6 @@ import TranslationFields from '../components/TranslationFields.vue';
import { iconAdd, iconCancel, iconDelete, iconDish, iconEdit, iconItem, iconSave } from '../icons';
import {
api,
getAuthToken,
type AuthUser,
type Dish,
type DishCategory,
@@ -301,13 +300,11 @@ async function loadEditorOptions() {
async function loadPage() {
loading.value = true;
if (getAuthToken()) {
try {
currentUser.value = (await api.me()).user;
} catch {
currentUser.value = null;
}
}
await Promise.all([initialCategoriesLoaded.value ? Promise.resolve() : loadDish(), loadEditorOptions()]);
}

View File

@@ -8,7 +8,7 @@ import LoadMoreSentinel from '../components/LoadMoreSentinel.vue';
import PageHeader from '../components/PageHeader.vue';
import Skeleton from '../components/Skeleton.vue';
import { iconAdd, iconHabitat } from '../icons';
import { api, getAuthToken, type AuthUser, type Habitat, type ListPage } from '../services/api';
import { api, type AuthUser, type Habitat, type ListPage } from '../services/api';
import HabitatEdit from './HabitatEdit.vue';
const props = defineProps<{
@@ -125,13 +125,11 @@ function loadMoreHabitats() {
}
onMounted(async () => {
if (getAuthToken()) {
try {
currentUser.value = (await api.me()).user;
} catch {
currentUser.value = null;
}
}
if (!initialPageLoaded.value) {
await loadHabitats();
}

View File

@@ -11,7 +11,7 @@ import Skeleton from '../components/Skeleton.vue';
import Tabs, { type TabOption } from '../components/Tabs.vue';
import TagsSelect from '../components/TagsSelect.vue';
import { iconAdd, iconChevronDown, iconChevronUp, iconItem } from '../icons';
import { api, getAuthToken, type AuthUser, type Item, type ListPage, type Options } from '../services/api';
import { api, type AuthUser, type Item, type ListPage, type Options } from '../services/api';
import ItemEdit from './ItemEdit.vue';
const props = defineProps<{
@@ -527,13 +527,11 @@ function loadMoreItems() {
onMounted(async () => {
document.addEventListener('pointerdown', onCreateDefaultsDocumentPointerDown);
document.addEventListener('keydown', onDocumentKeydown);
if (getAuthToken()) {
try {
currentUser.value = (await api.me()).user;
} catch {
currentUser.value = null;
}
}
if (!options.value) {
try {
options.value = await api.options();

View File

@@ -28,10 +28,8 @@ import {
} from '../icons';
import {
api,
getAuthToken,
moderationUpdateEvent,
onAuthTokenChange,
setAuthToken,
onAuthChange,
type AiModerationStatus,
type AuthUser,
type CommentSort,
@@ -112,17 +110,11 @@ function summaryText(value: string, maxLength: number) {
}
async function loadCurrentUser() {
if (!getAuthToken()) {
currentUser.value = null;
return;
}
try {
const response = await api.me();
currentUser.value = response.user;
} catch {
currentUser.value = null;
setAuthToken(null);
}
}
@@ -840,14 +832,13 @@ onMounted(() => {
document.addEventListener('click', closeReactionPickerFromDocument);
document.addEventListener('keydown', closeReactionPickerFromKeyboard);
window.addEventListener(moderationUpdateEvent, handleModerationUpdate);
const hadAuthToken = getAuthToken() !== null;
void (async () => {
await loadCurrentUser();
if (!initialPostLoaded.value || hadAuthToken) {
if (!initialPostLoaded.value || currentUser.value) {
await loadPost();
}
})();
removeAuthListener = onAuthTokenChange(() => {
removeAuthListener = onAuthChange(() => {
void loadCurrentUser();
void loadPost();
});

View File

@@ -35,10 +35,8 @@ import {
} from '../icons';
import {
api,
getAuthToken,
moderationUpdateEvent,
onAuthTokenChange,
setAuthToken,
onAuthChange,
type AiModerationStatus,
type AuthUser,
type CommentSort,
@@ -252,20 +250,12 @@ const submitLabel = computed(() => {
async function loadCurrentUser() {
authReady.value = false;
if (!getAuthToken()) {
currentUser.value = null;
activeFeedScope.value = 'all';
authReady.value = true;
return;
}
try {
const response = await api.me();
currentUser.value = response.user;
} catch {
currentUser.value = null;
activeFeedScope.value = 'all';
setAuthToken(null);
} finally {
authReady.value = true;
}
@@ -1376,7 +1366,6 @@ onMounted(() => {
document.addEventListener('click', closeReactionPickerFromDocument);
document.addEventListener('keydown', closeReactionPickerFromKeyboard);
window.addEventListener(moderationUpdateEvent, handleModerationUpdate);
const hadAuthToken = getAuthToken() !== null;
void (async () => {
await loadCurrentUser();
if (!initialLanguagesLoaded.value) {
@@ -1387,12 +1376,12 @@ onMounted(() => {
await loadLifeCategories();
initialOptionsLoaded.value = true;
}
if (!initialPostsLoaded.value || hadAuthToken) {
if (!initialPostsLoaded.value || currentUser.value) {
await loadPosts();
initialPostsLoaded.value = true;
}
})();
removeAuthListener = onAuthTokenChange(() => {
removeAuthListener = onAuthChange(() => {
void (async () => {
await loadCurrentUser();
await loadPosts();

View File

@@ -6,7 +6,7 @@ import { useRoute, useRouter } from 'vue-router';
import PageHeader from '../components/PageHeader.vue';
import StatusMessage from '../components/StatusMessage.vue';
import { iconLogin } from '../icons';
import { api, setAuthToken } from '../services/api';
import { api, notifyAuthChange } from '../services/api';
const route = useRoute();
const router = useRouter();
@@ -22,12 +22,12 @@ async function submitLogin() {
errorMessage.value = '';
try {
const response = await api.login({
await api.login({
email: email.value,
password: password.value,
rememberMe: rememberMe.value
});
setAuthToken(response.token, { persistent: rememberMe.value });
notifyAuthChange();
const redirect =
typeof route.query.redirect === 'string' && route.query.redirect.startsWith('/')

View File

@@ -10,7 +10,7 @@ import PageHeader from '../components/PageHeader.vue';
import Skeleton from '../components/Skeleton.vue';
import TagsSelect from '../components/TagsSelect.vue';
import { iconAdd } from '../icons';
import { api, getAuthToken, type AuthUser, type ListPage, type Options, type Pokemon } from '../services/api';
import { api, type AuthUser, type ListPage, type Options, type Pokemon } from '../services/api';
import PokemonEdit from './PokemonEdit.vue';
const props = defineProps<{
@@ -158,13 +158,11 @@ function pokemonCardImage(item: Pokemon) {
}
onMounted(async () => {
if (getAuthToken()) {
try {
currentUser.value = (await api.me()).user;
} catch {
currentUser.value = null;
}
}
if (!options.value) {
try {
options.value = await api.options();

View File

@@ -12,7 +12,7 @@ import Skeleton from '../components/Skeleton.vue';
import Tabs, { type TabOption } from '../components/Tabs.vue';
import { iconBack, iconEdit, iconRecipe } from '../icons';
import { applySeo, resolvedSeoHead, resolveSeo } from '../seo';
import { api, getAuthToken, type AuthUser, type RecipeDetail } from '../services/api';
import { api, type AuthUser, type RecipeDetail } from '../services/api';
import RecipeEdit from './RecipeEdit.vue';
const route = useRoute();
@@ -96,13 +96,11 @@ async function loadRecipeDetail() {
}
onMounted(async () => {
if (getAuthToken()) {
try {
currentUser.value = (await api.me()).user;
} catch {
currentUser.value = null;
}
}
if (!initialRecipeLoaded.value) {
await loadRecipeDetail();
}

View File

@@ -8,7 +8,7 @@ import Skeleton from '../components/Skeleton.vue';
import StatusMessage from '../components/StatusMessage.vue';
import TagsSelect from '../components/TagsSelect.vue';
import { iconAdd, iconCancel, iconDelete, iconSave } from '../icons';
import { api, getAuthToken, type AuthUser, type ConfigType, type Item, type Options, type RecipePayload } from '../services/api';
import { api, type AuthUser, type ConfigType, type Item, type Options, type RecipePayload } from '../services/api';
const route = useRoute();
const router = useRouter();
@@ -105,11 +105,6 @@ async function loadOptions() {
}
async function loadCurrentUser() {
if (!getAuthToken()) {
currentUser.value = null;
return;
}
try {
currentUser.value = (await api.me()).user;
} catch {

View File

@@ -11,7 +11,7 @@ import Skeleton from '../components/Skeleton.vue';
import Tabs, { type TabOption } from '../components/Tabs.vue';
import TagsSelect from '../components/TagsSelect.vue';
import { iconAdd, iconNoRecipe, iconRecipe } from '../icons';
import { api, getAuthToken, type AuthUser, type Item, type ListPage, type Options } from '../services/api';
import { api, type AuthUser, type Item, type ListPage, type Options } from '../services/api';
import RecipeEdit from './RecipeEdit.vue';
const options = ref<Options | null>(null);
@@ -170,13 +170,11 @@ function loadMoreItems() {
}
onMounted(async () => {
if (getAuthToken()) {
try {
currentUser.value = (await api.me()).user;
} catch {
currentUser.value = null;
}
}
if (!options.value) {
try {
options.value = await api.options();

View File

@@ -26,9 +26,7 @@ import {
} from '../icons';
import {
api,
getAuthToken,
notifyAuthChange,
setAuthToken,
type AuthUser,
type DiscussionEntityType,
type LifePost,
@@ -229,7 +227,6 @@ feeds.value = initialPublicProfile.value.feeds?.items ?? [];
feedsCursor.value = initialPublicProfile.value.feeds?.nextCursor ?? null;
feedsHasMore.value = initialPublicProfile.value.feeds?.hasMore ?? false;
const initialPublicProfileLoaded = ref(initialPublicProfile.value.profile !== null);
const initialFeedsLoaded = ref(initialPublicProfile.value.feeds !== null);
loading.value = !initialPublicProfileLoaded.value;
const profileSeo = computed(() =>
profile.value && !isAccountRoute.value
@@ -324,18 +321,12 @@ function resetActivity() {
}
async function loadOptionalCurrentUser() {
if (!getAuthToken()) {
currentUser.value = null;
return null;
}
try {
const response = await api.me();
currentUser.value = response.user;
return response.user;
} catch {
currentUser.value = null;
setAuthToken(null);
return null;
}
}
@@ -723,11 +714,7 @@ function commentTargetTitle(comment: UserCommentActivity): string {
}
onMounted(() => {
if (isAccountRoute.value || getAuthToken() || !initialPublicProfileLoaded.value) {
void loadProfile();
} else if (!initialFeedsLoaded.value) {
void loadFeeds(true);
}
});
</script>