Files
james 0ceb4a2b25 feat(mall): authenticate against the live API
Wave 2 of replacing the fixed-data mock adapter: the auth domain joins the live
list, so credentials, roles and tokens belong to the real user.

- session: validate a restored token through /auth/me instead of trusting
  localStorage, clearing it on 401/403 but keeping it when the API is merely
  unreachable; the route guard now uses the validated session
- login: report a 401 as invalid credentials rather than a generic failure, and
  drop the 6-character client rule so the API owns the password policy
- register: raise the rule to the API's 8 characters, remove the
  verification-code field (its button only counted down and the value was never
  sent), and report a duplicate email (409) distinctly
- TopBar and the user profile render their session-dependent branch client-only:
  validating the session before hydration made those localStorage-backed
  branches report hydration mismatches the previous code did not

Verified against the running backend: wrong password rejected, real JWT issued,
/user reachable, a short password refused with no network call, duplicate email
reported, a tampered token cleared and bounced to sign-in, a stale token kept
when the API is down, and the fixed-data rollback still signs in with the
backend stopped.

Also re-cuts docs/TBD-migrate-wave.md: auth is done, and cart, orders,
shipments and invoices must move together, because the mock adapter keeps their
state in one shared object and a partial flip fails at checkout.

OpenSpec change: openspec/changes/replace-mock-api-wave-2
2026-09-17 16:13:16 +00:00

98 lines
2.9 KiB
TypeScript

import { defineStore } from "pinia";
import { ApiError } from "@vmall/shared";
import type { AuthTokens, User } from "@vmall/shared";
const USER_KEY = "vmall.user";
const TOKEN_KEY = "vmall.token";
function isUser(v: unknown): v is User {
if (!v || typeof v !== "object") return false;
const o = v as Record<string, unknown>;
return (
typeof o.id === "string" &&
typeof o.email === "string" &&
typeof o.display_name === "string" &&
typeof o.role === "string"
);
}
export function readStoredUser(): User | null {
if (!import.meta.client) return null;
try {
const raw = localStorage.getItem(USER_KEY);
if (!raw) return null;
const parsed: unknown = JSON.parse(raw);
return isUser(parsed) ? parsed : null;
} catch {
return null;
}
}
interface SessionState {
user: User | null;
token: string | null;
/** Set once the session is trusted: by a fresh sign-in or a successful validate(). */
validated: boolean;
}
export const useSessionStore = defineStore("session", {
state: (): SessionState => ({ user: null, token: null, validated: false }),
getters: {
isLoggedIn: (s): boolean => s.token !== null,
},
actions: {
hydrate(): void {
if (!import.meta.client) return;
this.token = localStorage.getItem(TOKEN_KEY);
this.user = readStoredUser();
},
setAuth(auth: AuthTokens): void {
this.token = auth.token;
this.user = auth.user;
this.validated = true;
if (import.meta.client) {
localStorage.setItem(TOKEN_KEY, auth.token);
localStorage.setItem(USER_KEY, JSON.stringify(auth.user));
}
},
clear(): void {
this.token = null;
this.user = null;
this.validated = false;
if (import.meta.client) {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
}
},
/**
* Check a token restored from storage against the auth API, replacing the
* previous blind trust in localStorage. Only a rejected token clears the
* session - an unreachable API must not sign the shopper out. Runs at most
* once per load, because setAuth() marks a fresh sign-in as already trusted.
*/
async validate(): Promise<boolean> {
if (this.validated) return true;
if (!import.meta.client || !this.token) return false;
const { $api } = useNuxtApp();
try {
const user = await $api.me();
this.user = user;
this.validated = true;
localStorage.setItem(USER_KEY, JSON.stringify(user));
return true;
} catch (error) {
if (error instanceof ApiError && (error.status === 401 || error.status === 403)) {
this.clear();
return false;
}
// Network or server failure: keep the session rather than guess.
return this.token !== null;
}
},
logout(): void {
this.clear();
navigateTo("/login");
},
},
});