diff --git a/commit_snapshot.md b/commit_snapshot.md index 0178bfd..955482d 100644 --- a/commit_snapshot.md +++ b/commit_snapshot.md @@ -1,40 +1,42 @@ -# Commit Snapshot (`commits.snapshot_json`) - Chuẩn Hiện Tại (FrontEndAdmin) +# Commit Snapshot (`commits.snapshot_json`) - Chuẩn Hiện Tại (FrontEndUser / UHM) -Tài liệu này mô tả **commit snapshot** được `FrontEndAdmin` tạo ra khi bấm **Commit** trong `/editor`, và được lưu vào `BackEndGo.commits.snapshot_json` (JSONB). +Tài liệu này mô tả **snapshot_json** mà `FrontEndUser` (module UHM editor) tạo ra khi bấm **Commit** trong `/editor/[id]`, và gửi lên endpoint `POST /projects/{id}/commits`. -Nguồn tham chiếu trong code: +Nguồn tham chiếu trong code (FrontEndUser): -- Type snapshot: `FrontEndAdmin/src/uhm/types/sections.ts` (`EditorSnapshot`) -- Build snapshot: `FrontEndAdmin/src/uhm/lib/editor/snapshot/editorSnapshot.ts` (`buildEditorSnapshot`) +- Types: + - `src/uhm/types/sections.ts` (`EditorSnapshot`, `EntityWikiLinkSnapshot`) + - `src/uhm/types/geo.ts` (`FeatureCollection`, `GeometrySnapshot`, `GeometryEntitySnapshot`) + - `src/uhm/types/entities.ts` (`EntitySnapshot`) + - `src/uhm/types/wiki.ts` (`WikiSnapshot`) +- Build/normalize snapshot: + - `src/uhm/lib/editor/snapshot/editorSnapshot.ts` (`buildEditorSnapshot`, `normalizeEditorSnapshot`) -## 1) Tổng Quan Schema +## 1) Root Shape -Snapshot hiện tại: - -- Không có `schema_version`. -- Không lưu `section` (entity trong DB là `projects`; project được xác định bằng context record `commits.project_id`). -- Không dùng `ref:{id}` nữa: **`id` là canonical**, `source:"ref"` nghĩa là tham chiếu theo `id`. +FE hiện tại không dùng `schema_version`. `snapshot_json` là một object có các phần sau: ```ts -export type CommitSnapshot = { +export type EditorSnapshot = { editor_feature_collection?: FeatureCollection; - entities?: EntitySnapshot[]; geometries?: GeometrySnapshot[]; + geometry_entity?: GeometryEntitySnapshot[]; wikis?: WikiSnapshot[]; - - geometry_entity?: GeometryEntitySnapshot[]; // geometry ↔ entity (many-to-many) - entity_wiki?: EntityWikiLinkSnapshot[]; // entity ↔ wiki + entity_wiki?: EntityWikiLinkSnapshot[]; }; ``` -## 1.1 Type đầy đủ (TypeScript) +Lưu ý: -Đây là bản type "đúng để BEGo implement chuyển đổi snapshot → DB". FE có thể gửi thêm field legacy (xem mục 6), nhưng BE nên normalize theo các type dưới đây. +- FE có thể **đọc** cả `entity_wiki` và legacy alias `entity_wikis` khi load snapshot (normalize), nhưng khi commit FE ghi `entity_wiki`. +- `editor_feature_collection` là nguồn để render editor/map. Các join table (`geometry_entity`, `entity_wiki`) là nguồn quan hệ. + +## 2) Types (TypeScript) - Đúng Theo FE Hiện Tại + +### 2.1 GeoJSON (editor_feature_collection) ```ts -// ---- GeoJSON ---- - export type Geometry = | { type: "Point"; coordinates: [number, number] } | { type: "MultiPoint"; coordinates: [number, number][] } @@ -43,7 +45,7 @@ export type Geometry = | { type: "Polygon"; coordinates: [number, number][][] } | { type: "MultiPolygon"; coordinates: [number, number][][][] }; -export type FeatureId = string | number; // FE hiện dùng UUIDv7 string +export type FeatureId = string | number; export type FeatureProperties = { id: FeatureId; @@ -51,10 +53,9 @@ export type FeatureProperties = { geometry_preset?: string | null; time_start?: number | null; time_end?: number | null; - binding?: string[]; // entity ids used as "binding filter" + binding?: string[]; - // Legacy UI fields. FE persist snapshot hiện tại KHONG gửi các field này, - // nhưng BE nên ignore nếu gặp trong snapshot cũ: + // UI-only / legacy fields (FE sẽ strip khi persist snapshot): entity_id?: string | null; entity_ids?: string[]; entity_name?: string | null; @@ -72,9 +73,11 @@ export type FeatureCollection = { type: "FeatureCollection"; features: Feature[]; }; +``` -// ---- Snapshot rows ---- +### 2.2 Snapshot rows +```ts export type SnapshotSource = "inline" | "ref"; export type EntitySnapshotOperation = "create" | "update" | "delete" | "reference"; @@ -82,7 +85,7 @@ export type GeometrySnapshotOperation = "create" | "update" | "delete" | "refere export type WikiSnapshotOperation = "create" | "update" | "delete" | "reference"; export type EntitySnapshot = { - id: string; // UUIDv7 string (canonical) + id: string; source: SnapshotSource; operation?: EntitySnapshotOperation; name?: string; @@ -94,13 +97,12 @@ export type EntitySnapshot = { }; export type GeometrySnapshot = { - id: string; // UUIDv7 string (canonical) + id: string; source: SnapshotSource; operation?: GeometrySnapshotOperation; - - // Present when source:"inline" (draft features) type?: string | null; draw_geometry?: Geometry; + geometry?: Geometry; // legacy binding?: string[]; time_start?: number | null; time_end?: number | null; @@ -110,22 +112,27 @@ export type GeometrySnapshot = { max_lng: number; max_lat: number; } | null; - base_updated_at?: string; base_hash?: string; }; +// FE stores wiki doc as a string (commonly HTML; in some flows it may be a JSON-stringified editor payload). +export type WikiDoc = string | null; + export type WikiSnapshot = { - id: string; // UUIDv7 string (canonical) + id: string; source: SnapshotSource; operation?: WikiSnapshotOperation; title: string; - doc: unknown; // tiptap JSON (inline) hoặc null (ref) + slug?: string | null; + doc: WikiDoc; updated_at?: string; }; +``` -// ---- Join tables ---- +### 2.3 Join tables +```ts export type GeometryEntitySnapshot = { geometry_id: string; entity_id: string; @@ -135,149 +142,73 @@ export type GeometryEntitySnapshot = { export type EntityWikiLinkSnapshot = { entity_id: string; wiki_id: string; - // If missing, BE should treat as "reference" (active link) for backwards-compat. - operation?: "reference" | "delete"; -}; - -// ---- Root ---- - -export type CommitSnapshot = { - editor_feature_collection?: FeatureCollection; - entities?: EntitySnapshot[]; - geometries?: GeometrySnapshot[]; - wikis?: WikiSnapshot[]; - geometry_entity?: GeometryEntitySnapshot[]; - entity_wiki?: EntityWikiLinkSnapshot[]; + operation?: "reference" | "binding" | "delete"; }; ``` -## 2) Quy Ước `source` và `operation` +## 3) Quy Ước FE Khi Build Snapshot (buildEditorSnapshot) -### 2.1 `source` (bắt buộc) +### 3.1 Feature.properties entity fields bị strip -`source` bắt buộc là một trong: +Khi persist snapshot, FE chủ động xoá các field denormalize trên feature properties: +`entity_id`, `entity_ids`, `entity_name`, `entity_names`, `entity_type_id`. -- `inline`: dữ liệu được embed trong snapshot_json. -- `ref`: dữ liệu là tham chiếu (theo `id`), cần fetch bên ngoài nếu muốn đầy đủ. +Quan hệ geometry ↔ entity chỉ nằm ở `geometry_entity[]`. -FE hiện tại luôn ghi `source` cho `entities[]`, `geometries[]`, `wikis[]`. +### 3.2 entities[] -### 2.2 `operation` (tùy chọn) +FE cố gắng đảm bảo mọi entity có `name` không rỗng (fallback sang `id`) và có `source`. -`operation` là tùy chọn. Khi **không có** `operation` thì hiểu là: +`operation` được dùng như "delta" trong commit: -- row được đưa vào snapshot như **project context** (hoặc không đổi trong commit này), -- commit này không sửa record, và cũng không cần đánh dấu là `"reference"` để làm “đầu mối nối”. +- `"create"|"update"|"delete"`: thay đổi record entity +- `"reference"`: đưa entity vào context snapshot (pin/link) nhưng commit không sửa record entity -`operation` có thể xuất hiện ở: +### 3.3 geometries[] -- `entities[].operation`: `create` | `update` | `delete` | `reference` -- `geometries[].operation`: `create` | `update` | `delete` | `reference` -- `wikis[].operation`: `create` | `update` | `delete` | `reference` - -`geometry_entity[]` không có `operation` (join table state). - -`entity_wiki[]` dùng `operation:"binding"|"delete"` để biểu diễn link/unlink **trong snapshot** (không phải delete trong DB). - -## 3) Ý Nghĩa Từng Phần - -### 3.1 `editor_feature_collection` - -GeoJSON `FeatureCollection` là nguồn để: - -- render map trong editor, -- làm cơ sở build `geometries[]` và join table `geometry_entity[]`. - -Lưu ý quan trọng: - -- Snapshot persist **không lưu** các field entity denormalize trên `feature.properties`: - `entity_id/entity_ids/entity_name/entity_names/entity_type_id`. -- Quan hệ geometry ↔ entity nằm ở `geometry_entity[]`. -- Khi load commit vào editor, FE có thể rehydrate `entity_ids/entity_id` lên features từ `geometry_entity[]` để UI hoạt động, nhưng đó không phải dữ liệu persist. - -### 3.2 `entities[]` - -`entities[]` là danh sách entity liên quan tới project/commit. Mỗi row có `source` và có thể có/không có `operation`. - -FE build `entities[]` từ: - -1. Pending entities tạo mới trong editor: -`source:"inline"`, `operation:"create"`. - -2. Entity được user “pin” vào project từ search (không gắn geometry, không link wiki): -`source:"ref"`, không có `operation`. - -3. Entities xuất hiện trong `geometry_entity[]`: -`source:"ref"`, `operation:"reference"`. - -4. Entities xuất hiện trong `entity_wiki[]`: -`source:"ref"`, `operation:"reference"`. - -### 3.3 `geometries[]` - -Mỗi `Feature` trong `editor_feature_collection.features[]` sinh 1 `GeometrySnapshot` row: +FE sinh 1 `GeometrySnapshot` cho mỗi feature đang tồn tại trong `editor_feature_collection.features[]`: - `id = String(feature.properties.id)` - `source:"inline"` - `draw_geometry = feature.geometry` -- kèm `type`, `binding`, `time_start/time_end`, `bbox` (nếu tính được) +- `binding`, `time_start`, `time_end`, `bbox` (nếu tính được) +- `type`: FE hiện gửi **string code** (geo_type smallint) dưới dạng string +- `operation`: + - `"create"` nếu geometry mới + - `"update"` nếu geometry thay đổi + - `undefined` nếu geometry không đổi -`operation` cho geometry: - -- `create`: feature mới -- `update`: feature thay đổi -- (không có `operation`): feature không đổi (không delta trong commit) - -Nếu feature bị xoá khỏi draft, FE thêm 1 delete row: +Nếu feature bị xoá khỏi draft, FE thêm 1 row: ```json -{ "id": "g_1", "source": "ref", "operation": "delete" } +{ "id": "…", "source": "ref", "operation": "delete" } ``` -Lưu ý: geometry `operation:"delete"` **không xuất hiện trên map**, vì map render theo `editor_feature_collection.features[]`. +### 3.4 geometry_entity[] -Gợi ý cho BE khi apply vào DB: - -- Có thể coi `editor_feature_collection` là state hiện tại để render/map. -- `geometries[]` là "rows + deltas": sẽ có 1 row cho mỗi feature đang tồn tại trong draft (có/không có `operation`), và có thể có thêm các row `operation:"delete"` để xoá geometry khỏi project state. - -### 3.4 `geometry_entity[]` (join table Geometry ↔ Entity) - -Join table many-to-many giữa geometry và entity. Mỗi cặp geometry↔entity là một row: +`geometry_entity` là danh sách quan hệ many-to-many geometry ↔ entity. Mỗi row là một cặp: ```ts { geometry_id: string; entity_id: string } ``` -### 3.5 `wikis[]` +### 3.5 wikis[] -Danh sách wiki của project tại thời điểm commit: +- Wiki `source:"ref"` (được add từ search): FE set `operation:"reference"` và `doc:null`. +- Wiki `source:"inline"` (được tạo/sửa trong editor): + - nếu UI set explicit `create|update|delete` thì giữ nguyên + - nếu không có operation: + - wiki mới: FE coi là `"create"` + - wiki cũ không đổi: FE gán `"reference"` + - wiki cũ có đổi nội dung: FE gán `"update"` -- Wiki tạo mới: `source:"inline"`, `operation:"create"`, `doc` là HTML string (Quill). -- Wiki sửa: `source:"inline"`, `operation:"update"`, `doc` là HTML string (Quill). -- Wiki không đổi: thường không có `operation`. -- Wiki add từ search (wiki đã có trong DB): `source:"ref"`, `operation:"reference"`, `doc` có thể là `null`. +### 3.6 entity_wiki[] -### 3.6 `entity_wiki[]` (join table Entity ↔ Wiki) +Type trong FE cho UI state cho phép `"binding"` và `"delete"`. -```ts -export type EntityWikiLinkSnapshot = { - entity_id: string; - wiki_id: string; - // New semantics: - // - binding: link active - // - delete: link removed in this snapshot - // Backwards-compat: older snapshots may use "reference" meaning link active. - operation?: "binding" | "delete" | "reference"; -}; -``` +Khi build snapshot để commit, FE map link “đang bật” về `"reference"` để tương thích với backend (một số backend chỉ chấp nhận `"reference"|"delete"`). -Toggle link trong UI: - -- Toggle ON (bind): `{ operation: "binding" }` (or legacy `"reference"`) -- Untick checkbox: `{ operation: "delete" }` - -## 4) Ví Dụ JSON (rút gọn) +## 4) Ví Dụ snapshot_json (rút gọn) ```json { @@ -286,73 +217,32 @@ Toggle link trong UI: "features": [ { "type": "Feature", - "properties": { - "id": "g_1", - "type": "city", - "time_start": 1200, - "time_end": 1300, - "binding": [] - }, - "geometry": { "type": "Point", "coordinates": [105.8, 21.0] } + "properties": { "id": "019e…", "type": "country", "time_start": 1000, "time_end": 1500 }, + "geometry": { "type": "Polygon", "coordinates": [[[100, 10], [101, 10], [101, 11], [100, 10]]] } } ] }, "entities": [ - { "id": "e_2", "source": "ref", "name": "Pinned Entity" }, - { "id": "e_1", "source": "ref", "operation": "reference", "name": "Ha Noi", "status": 1 } + { "id": "019e…", "source": "inline", "operation": "reference", "name": "ent1", "description": null, "status": 1 } ], "geometries": [ - { - "id": "g_1", - "source": "inline", - "operation": "update", - "type": "city", - "draw_geometry": { "type": "Point", "coordinates": [105.8, 21.0] }, - "binding": [], - "time_start": 1200, - "time_end": 1300, - "bbox": { "min_lng": 105.8, "min_lat": 21.0, "max_lng": 105.8, "max_lat": 21.0 } - } + { "id": "019e…", "source": "inline", "operation": "update", "type": "9", "draw_geometry": { "type": "Polygon", "coordinates": [] }, "binding": [], "time_start": 1000, "time_end": 1500, "bbox": null } ], "geometry_entity": [ - { "geometry_id": "g_1", "entity_id": "e_1" } + { "geometry_id": "019e…", "entity_id": "019e…" } ], - "wikis": [ - { - "id": "w_inline_1", - "source": "inline", - "operation": "create", - "title": "Overview", - "doc": "

Overview

" - }, - { - "id": "019d...wiki_from_db", - "source": "ref", - "operation": "reference", - "title": "Existing Wiki (DB)", - "doc": null - } + "wikis": [ + { "id": "019e…", "source": "ref", "operation": "reference", "title": "Existing wiki", "doc": null, "updated_at": "2026-05-08T00:00:00.000Z" } ], - "entity_wiki": [ - { "entity_id": "e_1", "wiki_id": "w_inline_1", "operation": "binding" } - ] - } - ``` + "entity_wiki": [ + { "entity_id": "019e…", "wiki_id": "019e…", "operation": "reference" } + ] +} +``` -## 5) Notes Cho BackEnd (Normalize + Compat) +## 5) Compat Notes (khi load snapshot cũ) -BE nên normalize trước khi convert snapshot → DB: +FE normalize khi load snapshot: -- Ignore toàn bộ field entity denormalize trên `feature.properties` (nếu có): `entity_id/entity_ids/entity_name/entity_names/entity_type_id`. Quan hệ geometry↔entity lấy từ `geometry_entity[]`. -- `entity_wiki[].operation`: - - `"binding"` (or legacy `"reference"`): link active - - `"delete"`: link removed trong snapshot - - missing: treat as `"binding"` (compat) - -## 6) Legacy Compatibility (nếu gặp snapshot cũ) - -FE đã từng gửi các field legacy; BE có thể gặp nếu đang xử lý commit cũ: - -- `entity_wikis` (plural) thay vì `entity_wiki` (singular): treat như nhau. -- `ref:{id}` trong `entities/geometries/wikis`: ignore (id canonical). -- `is_deleted` trong join table entity↔wiki: map sang `operation:"delete"` khi `is_deleted==1`, ngược lại `"binding"` (or legacy `"reference"`). +- Nếu thấy `entity_wikis` (plural) sẽ đọc như `entity_wiki`. +- Nếu join link có `operation:"reference"` thì FE coi như link active (UI biểu diễn như “binding”). diff --git a/src/app/editor/[id]/page.tsx b/src/app/editor/[id]/page.tsx index ab4ad7b..9164233 100644 --- a/src/app/editor/[id]/page.tsx +++ b/src/app/editor/[id]/page.tsx @@ -78,6 +78,7 @@ export default function Page() { const [blockedPendingSubmissionId, setBlockedPendingSubmissionId] = useState(null); const [searchKind, setSearchKind] = useState("entity"); const [searchQuery, setSearchQuery] = useState(""); + const [searchQueryDraft, setSearchQueryDraft] = useState(""); const [wikiSearchResults, setWikiSearchResults] = useState([]); const [isWikiSearching, setIsWikiSearching] = useState(false); const [geoSearchResults, setGeoSearchResults] = useState([]); @@ -181,6 +182,10 @@ export default function Page() { () => mergeEntitySearchResults(entityCatalog, snapshotEntitiesAsEntities), [entityCatalog, snapshotEntitiesAsEntities] ); + const entitiesRef = useRef(entities); + useEffect(() => { + entitiesRef.current = entities; + }, [entities]); const snapshotEntitiesVisible = useMemo(() => { const byId = new globalThis.Map(); @@ -347,7 +352,9 @@ export default function Page() { setEntityStatus(null); } catch (err) { if (err instanceof ApiError) { - if (err.status === 401 || err.status === 400) { + // Only bounce to login when the session is truly unauthenticated. + // Token refresh is handled centrally; if we still get 401 here, refresh likely failed/expired. + if (err.status === 401) { router.replace("/signin"); return; } @@ -401,10 +408,14 @@ export default function Page() { async function ensureAuthenticated() { try { await fetchCurrentUser(); - } catch { + } catch (err) { if (disposed) return; - // Follow the same behavior as the rest of FrontEndUser: unauthenticated -> /signin. - router.replace("/signin"); + if (err instanceof ApiError && err.status === 401) { + // Only redirect when refresh token/session is no longer usable. + router.replace("/signin"); + return; + } + console.error("Ensure authenticated failed", err); } } @@ -482,7 +493,7 @@ export default function Page() { const requestId = ++entitySearchRequestRef.current; const timeoutId = window.setTimeout(async () => { const keywordLower = keyword.toLowerCase(); - const localMatches = entities + const localMatches = entitiesRef.current .filter((entity) => entity.name.toLowerCase().includes(keywordLower) || (entity.description || "").toLowerCase().includes(keywordLower) @@ -530,7 +541,6 @@ export default function Page() { }, [ searchKind, searchQuery, - entities, setEntityCatalog, setEntitySearchResults, setIsEntitySearchLoading, @@ -954,11 +964,6 @@ export default function Page() { })); setEntityStatus(null); setEntityFormStatus("Đã tạo entity mới (local). Commit khi sẵn sàng."); - - if (selectedFeature) { - setSearchKind("entity"); - setSearchQuery(name); - } } finally { setIsEntitySubmitting(false); } @@ -1104,12 +1109,14 @@ export default function Page() { onKindChange={(next) => { setSearchKind(next); setSearchQuery(""); + setSearchQueryDraft(""); }} query={searchQuery} onQueryChange={setSearchQuery} + onLocalQueryChange={setSearchQueryDraft} /> - {searchKind === "entity" && searchQuery.trim().length > 0 ? ( + {searchKind === "entity" && searchQueryDraft.trim().length > 0 ? (
Entity Results
@@ -1165,7 +1172,7 @@ export default function Page() {
) : null} - {searchKind === "wiki" && searchQuery.trim().length > 0 ? ( + {searchKind === "wiki" && searchQueryDraft.trim().length > 0 ? (
Wiki Results
@@ -1221,7 +1228,7 @@ export default function Page() {
) : null} - {searchKind === "geo" && searchQuery.trim().length > 0 ? ( + {searchKind === "geo" && searchQueryDraft.trim().length > 0 ? (
Geo Results
diff --git a/src/auth/tokenStore.ts b/src/auth/tokenStore.ts new file mode 100644 index 0000000..a4b867e --- /dev/null +++ b/src/auth/tokenStore.ts @@ -0,0 +1,78 @@ +export type StoredTokens = { + access_token: string; + refresh_token: string; +}; + +const LS_KEY = "uhm_auth_tokens_v1"; + +let cached: StoredTokens | null = null; + +function safeParseTokens(raw: string | null): StoredTokens | null { + if (!raw) return null; + try { + const v = JSON.parse(raw) as Partial; + if (!v || typeof v !== "object") return null; + if (typeof v.access_token !== "string" || typeof v.refresh_token !== "string") return null; + if (!v.access_token.trim() || !v.refresh_token.trim()) return null; + return { access_token: v.access_token, refresh_token: v.refresh_token }; + } catch { + return null; + } +} + +export function getStoredTokens(): StoredTokens | null { + if (cached) return cached; + if (typeof window === "undefined") return null; + cached = safeParseTokens(window.localStorage.getItem(LS_KEY)); + return cached; +} + +export function setStoredTokens(tokens: StoredTokens | null): void { + cached = tokens; + if (typeof window === "undefined") return; + if (!tokens) { + window.localStorage.removeItem(LS_KEY); + return; + } + window.localStorage.setItem(LS_KEY, JSON.stringify(tokens)); +} + +export function getAccessToken(): string | null { + return getStoredTokens()?.access_token ?? null; +} + +export function getRefreshToken(): string | null { + return getStoredTokens()?.refresh_token ?? null; +} + +export function clearStoredTokens(): void { + setStoredTokens(null); +} + +// Helper for dealing with CommonResponse where token payload shape is not strictly typed. +export function extractTokensFromResponsePayload(payload: any): StoredTokens | null { + const data = payload?.data ?? payload; + // Common shapes observed in various backends: + // - { status: true, data: { access_token, refresh_token } } + // - { data: { tokens: { access_token, refresh_token } } } + // - { data: { token: , refresh_token } } + // - { accessToken, refreshToken } + const tokenContainer = data?.tokens ?? data?.token_set ?? data; + + const access = + tokenContainer?.access_token ?? + tokenContainer?.accessToken ?? + tokenContainer?.token ?? + tokenContainer?.access ?? + null; + + const refresh = + tokenContainer?.refresh_token ?? + tokenContainer?.refreshToken ?? + tokenContainer?.refresh ?? + null; + if (typeof access === "string" && typeof refresh === "string" && access.trim() && refresh.trim()) { + return { access_token: access, refresh_token: refresh }; + } + return null; +} diff --git a/src/config/config.ts b/src/config/config.ts index a961321..b8f6313 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -1,10 +1,18 @@ import axios from "axios" import { API_URL_ROOT } from "../../api" +import { + clearStoredTokens, + extractTokensFromResponsePayload, + getAccessToken, + getRefreshToken, + setStoredTokens, +} from "@/auth/tokenStore" const baseURL = API_URL_ROOT || "https://history-api.kain.id.vn" const api = axios.create({ baseURL, + // Support both cookie-based auth (httpOnly) and Bearer JWT. withCredentials: true }) @@ -19,12 +27,36 @@ const processQueue = (error?: any) => { queue = [] } +api.interceptors.request.use((config) => { + const token = getAccessToken() + if (token) { + const headers: any = config.headers || {} + // Do not override if caller set Authorization explicitly (case-insensitive). + const already = + typeof headers.get === "function" + ? headers.get("Authorization") + : headers.Authorization || headers.authorization + if (!already) { + if (typeof headers.set === "function") headers.set("Authorization", `Bearer ${token}`) + else headers.Authorization = `Bearer ${token}` + } + config.headers = headers + } + return config +}) + api.interceptors.response.use( - (res) => res, + (res) => { + // Opportunistically persist tokens from signin/refresh responses. + const tokens = extractTokensFromResponsePayload(res?.data) + if (tokens) setStoredTokens(tokens) + return res + }, async (err) => { const originalRequest = err.config - if (err.response?.status === 401 && !originalRequest._retry) { + const url = String(originalRequest?.url || "") + if (err.response?.status === 401 && !originalRequest._retry && !url.includes("/auth/")) { if (isRefreshing) { return new Promise((resolve, reject) => { queue.push({ @@ -38,19 +70,55 @@ api.interceptors.response.use( isRefreshing = true try { - await axios.post( - `${baseURL}/auth/refresh`, - {}, - { withCredentials: true } - ) + const refreshToken = getRefreshToken() + + const tryHeaderRefresh = async () => { + if (!refreshToken) return null + return axios.post( + `${baseURL}/auth/refresh`, + {}, + { headers: { Authorization: `Bearer ${refreshToken}` } } + ) + } + + const tryCookieRefresh = async () => { + return axios.post(`${baseURL}/auth/refresh`, {}, { withCredentials: true }) + } + + let refreshRes: any = null + try { + refreshRes = (await tryHeaderRefresh()) || (await tryCookieRefresh()) + } catch (e: any) { + // If header-based refresh fails (wrong token type), fall back to cookie refresh. + if (refreshToken && e?.response?.status === 401) { + refreshRes = await tryCookieRefresh() + } else { + throw e + } + } + + const nextTokens = extractTokensFromResponsePayload(refreshRes?.data) + if (nextTokens) setStoredTokens(nextTokens) + // Some backends may return only a new access token; keep refresh token. + else { + const maybeAccess = (refreshRes?.data?.data?.access_token ?? + refreshRes?.data?.access_token) as unknown + if (typeof maybeAccess === "string" && maybeAccess.trim()) { + // Keep refresh token if we have one; otherwise rely on cookies. + if (refreshToken) setStoredTokens({ access_token: maybeAccess, refresh_token: refreshToken }) + } + } processQueue() return api(originalRequest) - } catch (refreshErr) { + } catch (refreshErr: any) { processQueue(refreshErr) - - window.location.href = "/signin" + // Only force logout when refresh token/session is truly invalid (401). + if (refreshErr?.response?.status === 401) { + clearStoredTokens() + window.location.href = "/signin" + } return Promise.reject(refreshErr) } finally { @@ -62,4 +130,4 @@ api.interceptors.response.use( } ) -export default api \ No newline at end of file +export default api diff --git a/src/service/auth.ts b/src/service/auth.ts index e4798d5..ec8b37a 100644 --- a/src/service/auth.ts +++ b/src/service/auth.ts @@ -1,5 +1,6 @@ import api from "@/config/config"; import { API } from "../../api"; +import { clearStoredTokens, extractTokensFromResponsePayload, setStoredTokens } from "@/auth/tokenStore"; export const apiCreateOTP = async (email: string) => { const token_type = 2; @@ -23,11 +24,14 @@ export const apiSignUp = async (payload: any) => { export const apiLogout = async () => { const response = await api.post(API.Auth.LOGOUT); + clearStoredTokens(); return response.data; }; export const apiSignIn = async (payload: any) => { const response = await api.post(API.Auth.SIGNIN, payload); + const tokens = extractTokensFromResponsePayload(response?.data); + if (tokens) setStoredTokens(tokens); return response.data; }; diff --git a/src/uhm/api/auth.ts b/src/uhm/api/auth.ts index 0f763ee..e739de2 100644 --- a/src/uhm/api/auth.ts +++ b/src/uhm/api/auth.ts @@ -1,5 +1,6 @@ import { API_ENDPOINTS } from "@/uhm/api/config"; import { jsonRequestInit, requestJson } from "@/uhm/api/http"; +import { clearStoredTokens, setStoredTokens } from "@/auth/tokenStore"; export type AuthTokens = { access_token: string; @@ -18,14 +19,15 @@ export async function signIn(email: string, password: string): Promise( API_ENDPOINTS.authSignin, jsonRequestInit("POST", { email, password }), - // Sign-in sets httpOnly cookies in BackEndGo. { skipAuth: true } ); + if (res?.access_token && res?.refresh_token) setStoredTokens(res); return res; } export async function logout(): Promise { await requestJson(API_ENDPOINTS.authLogout, { method: "POST" }); + clearStoredTokens(); } export async function fetchCurrentUser(): Promise { diff --git a/src/uhm/api/http.ts b/src/uhm/api/http.ts index 2122382..bfeef03 100644 --- a/src/uhm/api/http.ts +++ b/src/uhm/api/http.ts @@ -1,5 +1,6 @@ import type { ApiEnvelope } from "@/uhm/types/api"; import { API_ENDPOINTS } from "@/uhm/api/config"; +import { getAccessToken, getRefreshToken, setStoredTokens, type StoredTokens, extractTokensFromResponsePayload } from "@/auth/tokenStore"; export class ApiError extends Error { status: number; @@ -15,12 +16,12 @@ export class ApiError extends Error { } } -// BackEndGo auth flow: cookie-based (httpOnly access_token/refresh_token). -// We intentionally do not store bearer tokens in localStorage in this FE. +// History API auth flow supports Bearer JWT and (in some deployments) cookie-based sessions. type RequestJsonOptions = { skipAuth?: boolean; skipRefresh?: boolean; + authToken?: string | null; // Override bearer token (used for refresh). }; export async function requestJson( @@ -142,25 +143,76 @@ function stringifyPayload(payload: unknown): string { function withAuthHeaders(init: RequestInit | undefined, options?: RequestJsonOptions): RequestInit | undefined { const baseInit: RequestInit = { ...init, - // Always include cookies (BackEndGo sets httpOnly access_token/refresh_token cookies). credentials: init?.credentials ?? "include", }; - // Cookie-based auth only. - // Keep the function so call sites don't change, but never inject Authorization headers. + const headers = new Headers(baseInit.headers || undefined); + + const override = options?.authToken; + if (override) { + headers.set("Authorization", `Bearer ${override}`); + return { ...baseInit, headers }; + } + if (options?.skipAuth) return baseInit; - return baseInit; + + const access = getAccessToken(); + if (access) headers.set("Authorization", `Bearer ${access}`); + return { ...baseInit, headers }; } +let refreshInFlight: Promise | null = null; + async function tryRefreshTokens(): Promise { + // Single-flight refresh for concurrent 401s. + if (refreshInFlight) return refreshInFlight; + refreshInFlight = (async () => { try { - await requestJson( - API_ENDPOINTS.authRefresh, - { method: "POST" }, - { skipAuth: true, skipRefresh: true } - ); - return true; + const refreshToken = getRefreshToken(); + + // Try header-based refresh first (per swagger), but fall back to cookie-based refresh if needed. + let payload: unknown; + try { + payload = await requestJsonInternal( + API_ENDPOINTS.authRefresh, + { method: "POST" }, + refreshToken + ? { skipRefresh: true, authToken: refreshToken } + : { skipRefresh: true, skipAuth: true } + ); + } catch (err) { + if (refreshToken && err instanceof ApiError && err.status === 401) { + payload = await requestJsonInternal( + API_ENDPOINTS.authRefresh, + { method: "POST" }, + { skipRefresh: true, skipAuth: true } + ); + } else { + throw err; + } + } + + const next = extractTokensFromResponsePayload(payload) as StoredTokens | null; + if (next) { + setStoredTokens(next); + return true; + } + + // Fallback: if server returns only access_token, keep existing refresh token (if any). + const maybeAccess = (payload as any)?.access_token ?? (payload as any)?.data?.access_token; + if (typeof maybeAccess === "string" && maybeAccess.trim()) { + if (refreshToken) setStoredTokens({ access_token: maybeAccess, refresh_token: refreshToken }); + return true; + } + + return false; } catch { return false; } + })(); + try { + return await refreshInFlight; + } finally { + refreshInFlight = null; + } } diff --git a/src/uhm/components/Map.tsx b/src/uhm/components/Map.tsx index fc965c5..0f993ff 100644 --- a/src/uhm/components/Map.tsx +++ b/src/uhm/components/Map.tsx @@ -60,6 +60,12 @@ type EngineBinding = { clearSelection?: () => void; }; +const MAP_PROJECTION_STORAGE_KEY = "uhm:mapProjection"; + +function applyMapProjection(map: maplibregl.Map, isGlobe: boolean) { + map.setProjection({ type: isGlobe ? "globe" : "mercator" }); +} + export default function Map({ mode, draft, @@ -106,6 +112,15 @@ export default function Map({ const [zoomLevel, setZoomLevel] = useState(2); // Min/max zoom dùng cho slider và clamp thao tác zoom. const [zoomBounds, setZoomBounds] = useState({ min: MAP_MIN_ZOOM, max: MAP_MAX_ZOOM }); + // Projection mode: phang (mercator) vs hinh cau (globe). + const [isGlobeProjection, setIsGlobeProjection] = useState(() => { + if (typeof window === "undefined") return false; + try { + return window.localStorage.getItem(MAP_PROJECTION_STORAGE_KEY) === "globe"; + } catch { + return false; + } + }); // Engine chỉnh sửa polygon (kéo đỉnh/insert đỉnh), chỉ khởi tạo 1 lần. const editingEngineRef = useRef | null>(null); @@ -120,6 +135,18 @@ export default function Map({ // Lưu mode trước đó để cancel engine đúng lúc khi switch mode. const previousModeRef = useRef(mode); + useEffect(() => { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem( + MAP_PROJECTION_STORAGE_KEY, + isGlobeProjection ? "globe" : "mercator" + ); + } catch { + // ignore + } + }, [isGlobeProjection]); + useEffect(() => { fitToDraftBoundsRef.current = fitToDraftBounds; }, [fitToDraftBounds]); @@ -1034,6 +1061,32 @@ export default function Map({ }; }, [allowGeometryEditing, applyDraftToMap, tryCenterToUserLocation]); + useEffect(() => { + const map = mapRef.current; + if (!map) return; + const apply = () => { + // Map instance có thể đã bị replace/unmount trước khi event fire. + if (mapRef.current !== map) return; + // setProjection sẽ throw nếu style chưa load xong. + if (typeof map.isStyleLoaded === "function" && !map.isStyleLoaded()) return; + applyMapProjection(map, isGlobeProjection); + }; + + // Nếu style đã sẵn sàng thì apply ngay. + if (typeof map.isStyleLoaded === "function" && map.isStyleLoaded()) { + apply(); + return; + } + + // Chưa load xong: đợi load/style.load. + map.once("load", apply); + map.once("style.load", apply); + return () => { + map.off("load", apply); + map.off("style.load", apply); + }; + }, [isGlobeProjection]); + const handleZoomByStep = (delta: number) => { const map = mapRef.current; if (!map) return; @@ -1122,6 +1175,69 @@ export default function Map({ pointerEvents: "auto", }} > + +