The identity cookie stores only
token / user_id / has_login / id. Other profile fields use encrypteduser-profilelocalStorage. Client-sideinfomerges both parts; SSR exposes identity fields only.setInforeplaces the full profile, splitting every login and automatic re-login before writing the cookie.setTokenpreserves profile fields. BothexpireLoginandlogoutclear the profile. Legacy full cookies migrate on the client. If nginx already rejects the request, clear the affected site identity cookie first: page scripts cannot bypass an entry-point 400.
userStore
The framework's built-in user-identity Pinia store. Composable name is useUserStore (NOT useUserInfoStore; source: src/runtime/store/user.ts).
Login credentials are persisted as an atomic tokenInfo object in cookies: refresh / SSR auto-restore, "token present means user_id present" — never split.
Basic usage
<script setup lang="ts">
const userStore = useUserStore()
// All state is computed, use directly in templates
console.log(userStore.isLoggedIn, userStore.info?.nickname)
// Actions
async function onLogin() {
await userStore.loginByUsername(form.username, form.password)
await navigateTo(route.query.redirect as string || '/')
}
async function onLogout() {
await userStore.logout() // revokes the server-side session first, then clears local state; local cleanup is still synchronous without await
navigateTo('/login')
}
</script>
The framework never calls
userStore.autoLogin()during SSR. Logged-in users' cookies pass through transparently via useCookie, and therequestutil auto-injectsauthToken; device login for new visitors is established byauth.client.tsafter client hydrate. See Rendering Quick Guide.
State (computed, read-only)
Identity state derives from tokenInfoCookie; client-side info also merges encrypted localStorage profile fields.
| Field | Type | Description |
|---|---|---|
info | Ref<UserInfo | null> | Full login response (user_id / nickname / vip / has_login / …). null = not logged in |
token | Ref<string> | Pure token string (no user_id). Empty '' when not logged in |
authToken | Ref<string> | Request credential: logged-in = ${token}_${user_id}; device-login = token; not-logged-in = ''. framework request util uses this automatically |
isLoggedIn | Ref<boolean> | True only for real user login (has_login === 'y'). Device login does NOT count |
Device-login users (has_login = 'n') can hit anonymous endpoints and do tracking, but
isLoggedInstays false.
Actions
| Method | Signature | Description |
|---|---|---|
getDeviceId() | () => string | Get the device ID. On first call generates one via framework randomUuid() + writes a 10-year cookie; falls back automatically in non-secure contexts (LAN HTTP), not relying on bare crypto.randomUUID |
getSessionId() | () => string | Get the tracking session ID (framework#415). Reused if the last call was under 30 minutes ago, otherwise a new ID is generated; every call refreshes the activity time. Stored in localStorage (shared across tabs); used by the self-hosted tracker as context.session_id |
setInfo(data) | (data: UserInfo) => void | Replace user information: identity fields go to cookies, other fields to encrypted localStorage. Throws when a token is already set and the new data has none; to add or change profile fields, merge first: setInfo({ ...userStore.info, ...data }) |
setToken(value) | (value: string) => void | Only update tokenInfo.token, other fields preserved |
setAccount(data) | (data: AccountInfo) => void | Save login credentials. data.remember === false clears accountLocal |
getAccountInfo() | () => AccountInfo | null | Read saved credentials |
clearAccount() | () => void | Clear AccountInfo only (leaves tokenInfo untouched) |
autoLogin() | () => Promise<boolean> | Auto-login orchestrator: routing decided by saved AccountInfo.remember; no credentials → falls back to loginByDevice() |
loginByDevice() | () => Promise<Record> | Device login: anonymous identity for credential-less users (identified by deviceId, no AccountInfo written); used by autoLogin fallback or called explicitly |
loginByUsername(username, password, options?) | (string, string, LoginOptions?) => Promise<Record> | Username/password login. options.remember (default true) decides whether AccountInfo persists. options.extra is passed through to the backend |
loginByQrcode(code, options?) | (string, LoginOptions?) => Promise<Record> | QR-code login. options.remember / options.extra same as above |
register(loginName, password, options?) | (string, string, LoginOptions?) => Promise<Record> | Self-serve sign-up (POST /login/register; backend returns the same response shape as /login/username). On success the user is logged in immediately (reuses the setInfo/setAccount chain); options.remember / options.extra same as above. Failures (duplicate name, etc.) throw an Error and leave auth state untouched |
expireLogin() | () => void | Minimal cleanup on login exhaustion (fw#302): called by the request plugin when the silent 2002 re-login retry fails again — clears tokenInfo and profile data (isLoggedIn becomes false); keeps deviceId / accountLocal / channel codes / IDB. Business code rarely calls this directly — listen to the swift-web:auth-expired hook instead |
logout() | () => Promise<void> | Revokes the server-side session first (POST /login/logout; failures are swallowed and never block local cleanup), then clears tokenInfo / channel_code / share_code cookies + accountLocal / user-profile (localStorage) + IDB cache. Keeps deviceId. Local cleanup happens synchronously, so it takes effect even without await |
UserInfo type
interface UserInfo {
user_id?: number | string
username?: string
nickname?: string
headico?: string // avatar URL
token?: string
has_login?: 'y' | 'n' // 'y' real user login; 'n' device login
vip?: number // VIP tier, 0 = regular
vip_expire?: string
balance?: number
websocket?: string // WebSocket connection URL
[key: string]: unknown // Backend may extend with arbitrary fields
}
Persistent storage
Allocated by "SSR readability" + "travels with every request" + "sensitivity":
| Key | Medium | TTL | Content |
|---|---|---|---|
${tokenKey} | Cookie (AES encrypted + secure) | 30 days | token / user_id / has_login / id |
${deviceKey} | Cookie (AES encrypted + secure) | 10 years | deviceId UUID |
${accountKey} | localStorage (AES encrypted) | No TTL | AccountInfo object (lives in localStorage, never in cookies) |
channel_code / share_code | Cookie (AES encrypted + secure) | 30 days | Channel / referral codes |
- All cookies have
secure: trueby default (localhost / 127.0.0.1 auto-exempted) +sameSite: lax. - AES encryption is handled transparently by the framework
useCookie/useLocalwrappers. - ⚠️ Encryption ≠ security barrier. See Client-side Encryption — The Security Boundary.
- AccountInfo in localStorage has no TTL; "Remember me" means "remembered until logout or user opt-out" (aligned with browser password managers).
Cross-tab sync
Cookies are natively cross-tab (same origin + path). One tab logout() clears cookies → other tabs' useCookie refs return empty on next access → state becomes "not logged in".
But already-loaded pages don't auto re-render — listen to storage events or use BroadcastChannel for real-time sync. The framework doesn't bundle this layer (avoiding redundant subscriptions).
Identity establishment & security model
Device + auth identity is established entirely client-only (design: framework#44; security hardening: framework#45):
- The framework never calls mutating methods during SSR (
autoLogin/loginByDevice/getDeviceId/setAccount/setInfo, …) — no useCookie-writing mutation runs server-side, so the Pinia payload is never baked into ISR caches; these methods carry anassertClientOnly()guard and throw when called on the server - Device login is established by the client-side
auth.client.tsplugin after hydrate; a logged-in user's token passes to SSR naturally via cookies; business calls torequest()automatically await auth-ready - Password-class credentials never enter cookies: AccountInfo lives in encrypted localStorage — invisible in the DevTools Cookies panel and never sent with requests
remembersemantics: theoptions.rememberofloginByUsername/loginByQrcode(default true) decides whether AccountInfo persists;remember: falseskips saving; callclearAccount()when the user unticks "remember me"- All core cookies are AES-encrypted +
secure: trueby default (localhost exempt) +sameSite: lax
Recipes
<!-- Recipe 1: LoginForm passes the "remember me" checkbox through (STORE-002) -->
<script setup lang="ts">
const isRemember = ref(true)
async function onSubmit() {
await useUserStore().loginByUsername(form.username, form.password, {
remember: isRemember.value,
})
}
</script>
// Recipe 2: wait for the auth chain, then check login state (UAR-001)
const userStore = useUserStore()
onMounted(async () => {
await useAuthReady()
if (!userStore.isLoggedIn) return useLoginCheck()
console.log(userStore.info?.nickname)
})
// Recipe 3: extend the store by wrapping, not rewriting (zero-conflict upgrades)
export const useAppUserStore = defineStore('app-user', () => {
const base = useUserStore()
const { info, isLoggedIn } = storeToRefs(base)
const vipLevel = computed(() => (info.value?.vip as number) ?? 0)
return { info, isLoggedIn, vipLevel, logout: base.logout }
})
// Recipe 4: SSR read/write boundary (STORE-001)
const userStore = useUserStore()
const logged = userStore.isLoggedIn // ✅ read-only members are SSR-safe
// userStore.getDeviceId() // ❌ mutating call in sync setup → server throw
onMounted(() => userStore.getDeviceId()) // ✅ move writes to client lifecycle
Related
useAuthReady— wait for client-side device + auth establishmentuseLoginCheck— login validator (page-redirect / dialog modes)useLoginBy— smart login method picker (device capability + UA)- auth ModuleOptions — route guards + client auth init toggle
Caveats
- State-mutating methods are client-only and throw when called during SSR (Rule STORE-001) —
getDeviceId/getSessionId/setInfo/setToken/setAccount/clearAccount/loginByUsername/register/loginByQrcode/loginByDevice/autoLogin/expireLogin/logoutall start with anassertClientOnly()guard. The reason: device id and token are established entirely on the client, so an SSR-time call writes Set-Cookie and bakes into the Pinia payload — which ISR then caches and broadcasts to every visitor, handing them all the same identity (a security-grade incident). Move such calls intoonMounted, an event handler, or<ClientOnly>. - Read-only members are SSR-safe —
info/token/authToken/isLoggedIn/getAccountInfoare exempt from the above. - "Remember me" must be passed through explicitly (Rule STORE-002) —
rememberdefaults totrueonloginByUsername/loginByQrcode. If your LoginForm doesn't forward the checkbox state, users who unchecked it are still persisted (account plus encrypted password in localStorage). WriteloginByUsername(u, p, { remember: isRemember.value }). - Don't bypass the store to read core cookies (the
swift-web/no-direct-cookie-in-appeslint rule blocks this) — note the two direct-read paths fail for different reasons:document.cookie.match(...)really does yield AES ciphertext;useCookie(...), however, yields plaintext (the framework overrides Nuxt's built-in useCookie under the same name, auto-prefixingcachePrefixand decrypting transparently). What's wrong with it is different — the real key isruntimeConfig.public.tokenKey(e.g.tt), souseCookie('token')reads a different, non-existent cookie and silently returnsnull; and even with the right key you bypass the store's write guards, auth-ready ordering, and field normalization, welding business code to the framework's internal storage layout.