From 5d70492bd2a10e9d466a18c07b21bebb25bbfffd Mon Sep 17 00:00:00 2001 From: Estifo77 <139631617+Estifo77@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:15:28 +0300 Subject: [PATCH 001/187] test for claude --- .../analytics/pages/api/analytics-api.ts | 0 .../analytics/pages/types/analytics.ts | 0 package-lock.json | 57 +++++-------------- 3 files changed, 15 insertions(+), 42 deletions(-) create mode 100644 apps/backoffice/src/app/features/analytics/pages/api/analytics-api.ts create mode 100644 apps/backoffice/src/app/features/analytics/pages/types/analytics.ts diff --git a/apps/backoffice/src/app/features/analytics/pages/api/analytics-api.ts b/apps/backoffice/src/app/features/analytics/pages/api/analytics-api.ts new file mode 100644 index 000000000..e69de29bb diff --git a/apps/backoffice/src/app/features/analytics/pages/types/analytics.ts b/apps/backoffice/src/app/features/analytics/pages/types/analytics.ts new file mode 100644 index 000000000..e69de29bb diff --git a/package-lock.json b/package-lock.json index 7c702ace9..49e6d330a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3867,6 +3867,18 @@ } } }, + "node_modules/@mui/x-date-pickers/node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, "node_modules/@mui/x-date-pickers/node_modules/react-is": { "version": "19.2.7", "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz", @@ -3991,9 +4003,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4014,9 +4023,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4037,9 +4043,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4060,9 +4063,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4083,9 +4083,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -8327,9 +8324,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -8346,9 +8340,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -8365,9 +8356,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -8384,9 +8372,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -11949,7 +11934,7 @@ "version": "0.1.13", "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "iconv-lite": "^0.6.2" @@ -13740,7 +13725,7 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -14558,9 +14543,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -14581,9 +14563,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -14604,9 +14583,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -14627,9 +14603,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -17919,7 +17892,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/sax": { From c274690d04ff82ebe0210327ce1b4bac1a4e6945 Mon Sep 17 00:00:00 2001 From: estifanos Date: Tue, 21 Jul 2026 12:36:26 +0000 Subject: [PATCH 002/187] test push from claude --- .../src/app/features/analytics/pages/api/analytics-api.ts | 1 + .../src/app/features/analytics/pages/types/analytics.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/apps/backoffice/src/app/features/analytics/pages/api/analytics-api.ts b/apps/backoffice/src/app/features/analytics/pages/api/analytics-api.ts index e69de29bb..e53cb824c 100644 --- a/apps/backoffice/src/app/features/analytics/pages/api/analytics-api.ts +++ b/apps/backoffice/src/app/features/analytics/pages/api/analytics-api.ts @@ -0,0 +1 @@ +// Analytics API - Coming soon diff --git a/apps/backoffice/src/app/features/analytics/pages/types/analytics.ts b/apps/backoffice/src/app/features/analytics/pages/types/analytics.ts index e69de29bb..ee0d73141 100644 --- a/apps/backoffice/src/app/features/analytics/pages/types/analytics.ts +++ b/apps/backoffice/src/app/features/analytics/pages/types/analytics.ts @@ -0,0 +1 @@ +// Analytics types - Coming soon From abcb538e4fd9d68621b7dbd2676200541bebf145 Mon Sep 17 00:00:00 2001 From: estifanos Date: Tue, 21 Jul 2026 12:44:17 +0000 Subject: [PATCH 003/187] test push from claude --- .../src/app/features/analytics/pages/api/analytics-api.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/backoffice/src/app/features/analytics/pages/api/analytics-api.ts b/apps/backoffice/src/app/features/analytics/pages/api/analytics-api.ts index e53cb824c..67f2a62fe 100644 --- a/apps/backoffice/src/app/features/analytics/pages/api/analytics-api.ts +++ b/apps/backoffice/src/app/features/analytics/pages/api/analytics-api.ts @@ -1 +1,2 @@ // Analytics API - Coming soon +// test from claude From 75220e2b9a7685e3749b1a0711d003ab89d66c71 Mon Sep 17 00:00:00 2001 From: estifanos Date: Wed, 22 Jul 2026 09:31:10 +0000 Subject: [PATCH 004/187] feat: update authentication storage to use cookies and improve token retrieval --- .../user-management/UserManagementPage.tsx | 8 ++- apps/backoffice/src/app/store/index.ts | 2 +- libs/api/src/lib/session/index.ts | 13 ++++- libs/auth/src/lib/utils/auth-storage.ts | 58 ++++++++++++++----- package-lock.json | 26 +++------ package.json | 2 +- 6 files changed, 70 insertions(+), 39 deletions(-) diff --git a/apps/backoffice/src/app/features/user-management/UserManagementPage.tsx b/apps/backoffice/src/app/features/user-management/UserManagementPage.tsx index d6d89c47d..a79dc9ed0 100644 --- a/apps/backoffice/src/app/features/user-management/UserManagementPage.tsx +++ b/apps/backoffice/src/app/features/user-management/UserManagementPage.tsx @@ -138,8 +138,12 @@ export default function UserManagementPage() { style.textContent = UM_OVERRIDES; document.head.appendChild(style); - const token = localStorage.getItem('ema-backoffice-auth-token') ?? ''; - const refreshToken = localStorage.getItem('ema-backoffice-refresh-token') ?? undefined; + const readCookie = (name: string) => { + const m = document.cookie.match(new RegExp('(?:^|;\\s*)' + name + '=([^;]*)')); + return m ? decodeURIComponent(m[1]) : undefined; + }; + const token = readCookie('ema-backoffice-auth-token') ?? ''; + const refreshToken = readCookie('ema-backoffice-refresh-token'); const session: UserManagementSessionOptions = { initialSession: token diff --git a/apps/backoffice/src/app/store/index.ts b/apps/backoffice/src/app/store/index.ts index bb3ca733e..8f9908a95 100644 --- a/apps/backoffice/src/app/store/index.ts +++ b/apps/backoffice/src/app/store/index.ts @@ -11,7 +11,7 @@ import { import type { AuthUser, CurrentProfile } from '@ema-platform/auth'; import { preferencesReducer } from './preferences.slice'; -configureAuthStorage('ema-backoffice'); +configureAuthStorage('ema-backoffice', true); const preloadedAuth = (() => { const token = authStorage.getToken(); diff --git a/libs/api/src/lib/session/index.ts b/libs/api/src/lib/session/index.ts index 7f7a25284..7a3c1326d 100644 --- a/libs/api/src/lib/session/index.ts +++ b/libs/api/src/lib/session/index.ts @@ -11,13 +11,22 @@ const TOKEN_STORAGE_KEYS = [ 'auth-token', ] as const; +function getCookie(name: string): string | undefined { + const match = document.cookie.match( + new RegExp('(?:^|;\\s*)' + name + '=([^;]*)'), + ); + return match ? decodeURIComponent(match[1]) : undefined; +} + export function resolveTokenFromStorage(): string | undefined { + // cookie first (backoffice stores tokens there), then localStorage (portal / legacy) for (const key of TOKEN_STORAGE_KEYS) { + const cookie = getCookie(key); + if (cookie) return cookie; const stored = localStorage.getItem(key); if (stored) return stored; } - const match = document.cookie.match(/(?:^|;\s*)auth-token=([^;]*)/); - return match ? decodeURIComponent(match[1]) : undefined; + return undefined; } export function resolveSessionContext(state?: { auth?: { token?: string } }): { diff --git a/libs/auth/src/lib/utils/auth-storage.ts b/libs/auth/src/lib/utils/auth-storage.ts index 22fc1757f..06da91355 100644 --- a/libs/auth/src/lib/utils/auth-storage.ts +++ b/libs/auth/src/lib/utils/auth-storage.ts @@ -1,7 +1,37 @@ -let _prefix = 'ema-auth'; +const MAX_AGE = 60 * 60 * 24 * 7; // ponytail: cookie lifetime knob; JWT expiry + refresh govern real auth -export function configureAuthStorage(prefix: string) { +interface Backend { + get(k: string): string | null; + set(k: string, v: string): void; + remove(k: string): void; +} + +const localBackend: Backend = { + get: (k) => localStorage.getItem(k), + set: (k, v) => localStorage.setItem(k, v), + remove: (k) => localStorage.removeItem(k), +}; + +const cookieBackend: Backend = { + get: (k) => { + const m = document.cookie.match(new RegExp('(?:^|;\\s*)' + k + '=([^;]*)')); + return m ? decodeURIComponent(m[1]) : null; + }, + // ponytail: 4KB/cookie cap — auth-user/current-profile JSON must stay under it + set: (k, v) => { + document.cookie = `${k}=${encodeURIComponent(v)}; path=/; Secure; SameSite=Strict; Max-Age=${MAX_AGE}`; + }, + remove: (k) => { + document.cookie = `${k}=; path=/; Secure; SameSite=Strict; Max-Age=0`; + }, +}; + +let _prefix = 'ema-auth'; +let backend: Backend = localBackend; + +export function configureAuthStorage(prefix: string, useCookies = false) { _prefix = prefix; + backend = useCookies ? cookieBackend : localBackend; } function key(k: string) { @@ -9,32 +39,32 @@ function key(k: string) { } export const authStorage = { - getToken: () => localStorage.getItem(key('auth-token')) ?? undefined, - setToken: (token: string) => localStorage.setItem(key('auth-token'), token), - getRefreshToken: () => localStorage.getItem(key('refresh-token')) ?? undefined, - setRefreshToken: (t: string) => localStorage.setItem(key('refresh-token'), t), + getToken: () => backend.get(key('auth-token')) ?? undefined, + setToken: (token: string) => backend.set(key('auth-token'), token), + getRefreshToken: () => backend.get(key('refresh-token')) ?? undefined, + setRefreshToken: (t: string) => backend.set(key('refresh-token'), t), getUser: (): T | null => { try { - return JSON.parse(localStorage.getItem(key('auth-user')) ?? 'null') as T | null; + return JSON.parse(backend.get(key('auth-user')) ?? 'null') as T | null; } catch { return null; } }, - setUser: (u: T) => localStorage.setItem(key('auth-user'), JSON.stringify(u)), - getProfileId: () => localStorage.getItem(key('profile-id')) ?? undefined, - setProfileId: (id: string) => localStorage.setItem(key('profile-id'), id), + setUser: (u: T) => backend.set(key('auth-user'), JSON.stringify(u)), + getProfileId: () => backend.get(key('profile-id')) ?? undefined, + setProfileId: (id: string) => backend.set(key('profile-id'), id), getProfile: (): T | null => { try { - return JSON.parse(localStorage.getItem(key('current-profile')) ?? 'null') as T | null; + return JSON.parse(backend.get(key('current-profile')) ?? 'null') as T | null; } catch { return null; } }, - setProfile: (p: T) => localStorage.setItem(key('current-profile'), JSON.stringify(p)), - removeProfile: () => localStorage.removeItem(key('current-profile')), + setProfile: (p: T) => backend.set(key('current-profile'), JSON.stringify(p)), + removeProfile: () => backend.remove(key('current-profile')), clear: () => { [key('auth-token'), key('refresh-token'), key('auth-user'), key('profile-id'), key('current-profile')].forEach((k) => - localStorage.removeItem(k), + backend.remove(k), ); document.cookie = 'auth-token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC; SameSite=Lax'; diff --git a/package-lock.json b/package-lock.json index 49e6d330a..9049ab3fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,7 +43,7 @@ "@nx/vite": "^22.5.4", "@nx/vitest": "^22.5.4", "@types/node": "^22.0.0", - "@types/react": "^19.0.0", + "@types/react": "^19.2.17", "@types/react-dom": "^19.0.0", "@typescript-eslint/eslint-plugin": "^8.60.0", "@typescript-eslint/parser": "^8.60.0", @@ -3867,18 +3867,6 @@ } } }, - "node_modules/@mui/x-date-pickers/node_modules/@types/react": { - "version": "18.3.31", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", - "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@types/prop-types": "*", - "csstype": "^3.2.2" - } - }, "node_modules/@mui/x-date-pickers/node_modules/react-is": { "version": "19.2.7", "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz", @@ -9190,9 +9178,9 @@ "optional": true }, "node_modules/@types/react": { - "version": "19.2.15", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", - "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -11934,7 +11922,7 @@ "version": "0.1.13", "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "iconv-lite": "^0.6.2" @@ -13725,7 +13713,7 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -17892,7 +17880,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/sax": { diff --git a/package.json b/package.json index 7d291d660..8a7e31be1 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "@nx/vite": "^22.5.4", "@nx/vitest": "^22.5.4", "@types/node": "^22.0.0", - "@types/react": "^19.0.0", + "@types/react": "^19.2.17", "@types/react-dom": "^19.0.0", "@typescript-eslint/eslint-plugin": "^8.60.0", "@typescript-eslint/parser": "^8.60.0", From 2c2464d12a72bfd628e2bdf357f5c2e2ee4773a4 Mon Sep 17 00:00:00 2001 From: Estifo77 <139631617+Estifo77@users.noreply.github.com> Date: Wed, 22 Jul 2026 12:32:38 +0300 Subject: [PATCH 005/187] test --- .../pages/EndorsementQueuePage.tsx | 4 +- .../src/app/layouts/BackofficeLayout.tsx | 2 +- libs/auth/src/lib/utils/auth-storage.ts | 54 +++++++++++++------ package-lock.json | 9 ++++ package.json | 2 + 5 files changed, 52 insertions(+), 19 deletions(-) diff --git a/apps/backoffice/src/app/features/endorsement/pages/EndorsementQueuePage.tsx b/apps/backoffice/src/app/features/endorsement/pages/EndorsementQueuePage.tsx index a3c9ccef3..c4270e8ca 100644 --- a/apps/backoffice/src/app/features/endorsement/pages/EndorsementQueuePage.tsx +++ b/apps/backoffice/src/app/features/endorsement/pages/EndorsementQueuePage.tsx @@ -224,7 +224,7 @@ export function EndorsementQueuePage() { {/* Stats */} {[ - { label: 'Total Applications', value: stats.total, color: 'blue', icon: IconStamp }, + { label: 'Total Applications', value: stats.total, color: 'blue', icon: IconRubberStamp }, { label: 'Awaiting Review', value: stats.pending, color: 'yellow', icon: IconClock }, { label: 'Endorsed', value: stats.endorsed, color: 'teal', icon: IconCircleCheck }, { label: 'Rejected', value: stats.rejected, color: 'red', icon: IconShieldCheck }, @@ -264,7 +264,7 @@ export function EndorsementQueuePage() { {filtered.length === 0 ? ( - + No applications found ) : ( diff --git a/apps/backoffice/src/app/layouts/BackofficeLayout.tsx b/apps/backoffice/src/app/layouts/BackofficeLayout.tsx index 81c6e9840..00130ef27 100644 --- a/apps/backoffice/src/app/layouts/BackofficeLayout.tsx +++ b/apps/backoffice/src/app/layouts/BackofficeLayout.tsx @@ -34,7 +34,7 @@ const NAV_ITEMS: NavItem[] = [ { to: '/coc-queue', label: 'nav.cocQueue', icon: IconShieldCheck }, { to: '/endorsement-queue', label: 'nav.endorsementQueue', icon: IconRubberStamp }, { to: '/seafarer-registry', label: 'nav.seafarerRegistry', icon: IconUsers }, - { to: '/applications', label: 'nav.applications', icon: IconFileDescription }, + // { to: '/applications', label: 'nav.applications', icon: IconFileDescription }, { to: '/payment-config', label: 'nav.paymentConfig', icon: IconCreditCard }, { to: '/analytics', label: 'nav.analytics', icon: IconChartBar }, { to: '/medical-verification', label: 'nav.medicalVerification', icon: IconHeart }, diff --git a/libs/auth/src/lib/utils/auth-storage.ts b/libs/auth/src/lib/utils/auth-storage.ts index 22fc1757f..588191ab5 100644 --- a/libs/auth/src/lib/utils/auth-storage.ts +++ b/libs/auth/src/lib/utils/auth-storage.ts @@ -1,4 +1,6 @@ -let _prefix = 'ema-auth'; +import Cookies from "js-cookie"; + +let _prefix = "ema-auth"; export function configureAuthStorage(prefix: string) { _prefix = prefix; @@ -9,34 +11,54 @@ function key(k: string) { } export const authStorage = { - getToken: () => localStorage.getItem(key('auth-token')) ?? undefined, - setToken: (token: string) => localStorage.setItem(key('auth-token'), token), - getRefreshToken: () => localStorage.getItem(key('refresh-token')) ?? undefined, - setRefreshToken: (t: string) => localStorage.setItem(key('refresh-token'), t), + getToken: () => localStorage.getItem(key("auth-token")) ?? undefined, + setToken: (token: string) => localStorage.setItem(key("auth-token"), token), + getRefreshToken: () => + localStorage.getItem(key("refresh-token")) ?? undefined, + setRefreshToken: (t: string) => localStorage.setItem(key("refresh-token"), t), getUser: (): T | null => { try { - return JSON.parse(localStorage.getItem(key('auth-user')) ?? 'null') as T | null; + return JSON.parse( + localStorage.getItem(key("auth-user")) ?? "null", + ) as T | null; } catch { return null; } }, - setUser: (u: T) => localStorage.setItem(key('auth-user'), JSON.stringify(u)), - getProfileId: () => localStorage.getItem(key('profile-id')) ?? undefined, - setProfileId: (id: string) => localStorage.setItem(key('profile-id'), id), + setUser: (u: T) => + localStorage.setItem(key("auth-user"), JSON.stringify(u)), + getProfileId: () => localStorage.getItem(key("profile-id")) ?? undefined, + setProfileId: (id: string) => localStorage.setItem(key("profile-id"), id), getProfile: (): T | null => { try { - return JSON.parse(localStorage.getItem(key('current-profile')) ?? 'null') as T | null; + return JSON.parse( + localStorage.getItem(key("current-profile")) ?? "null", + ) as T | null; } catch { return null; } }, - setProfile: (p: T) => localStorage.setItem(key('current-profile'), JSON.stringify(p)), - removeProfile: () => localStorage.removeItem(key('current-profile')), + setProfile: (p: T) => + localStorage.setItem(key("current-profile"), JSON.stringify(p)), + removeProfile: () => localStorage.removeItem(key("current-profile")), clear: () => { - [key('auth-token'), key('refresh-token'), key('auth-user'), key('profile-id'), key('current-profile')].forEach((k) => - localStorage.removeItem(k), - ); + [ + key("auth-token"), + key("refresh-token"), + key("auth-user"), + key("profile-id"), + key("current-profile"), + ].forEach((k) => localStorage.removeItem(k)); document.cookie = - 'auth-token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC; SameSite=Lax'; + "auth-token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC; SameSite=Lax"; }, }; + +export const clearAllCookies = () => { + const cookies = Cookies.get(); + + Object.keys(cookies).forEach((name) => { + Cookies.remove(name); + Cookies.remove(name, { path: "/" }); + }); +}; diff --git a/package-lock.json b/package-lock.json index 49e6d330a..3ad395a6d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "date-fns": "^4.1.0", "dayjs": "^1.11.20", "i18next": "^25.6.0", + "js-cookie": "^3.0.8", "react": "^19.0.0", "react-dom": "^19.0.0", "react-hook-form": "^7.71.2", @@ -42,6 +43,7 @@ "@nx/react": "^22.5.4", "@nx/vite": "^22.5.4", "@nx/vitest": "^22.5.4", + "@types/js-cookie": "^3.0.6", "@types/node": "^22.0.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", @@ -9146,6 +9148,13 @@ "@types/sizzle": "*" } }, + "node_modules/@types/js-cookie": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.6.tgz", + "integrity": "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", diff --git a/package.json b/package.json index 7d291d660..45da06add 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "date-fns": "^4.1.0", "dayjs": "^1.11.20", "i18next": "^25.6.0", + "js-cookie": "^3.0.8", "react": "^19.0.0", "react-dom": "^19.0.0", "react-hook-form": "^7.71.2", @@ -47,6 +48,7 @@ "@nx/react": "^22.5.4", "@nx/vite": "^22.5.4", "@nx/vitest": "^22.5.4", + "@types/js-cookie": "^3.0.6", "@types/node": "^22.0.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", From a76549b018381d5bb08768ec9dba289d21a77e5e Mon Sep 17 00:00:00 2001 From: estifanos Date: Wed, 22 Jul 2026 09:51:00 +0000 Subject: [PATCH 006/187] feat: migrate authentication storage to use js-cookie for improved token management --- .../user-management/UserManagementPage.tsx | 9 ++---- libs/api/src/lib/session/index.ts | 11 ++----- .../src/lib/components/ProtectedRoute.tsx | 8 ++--- libs/auth/src/lib/store/auth.slice.ts | 3 +- libs/auth/src/lib/utils/auth-storage.ts | 32 +++++++++---------- 5 files changed, 25 insertions(+), 38 deletions(-) diff --git a/apps/backoffice/src/app/features/user-management/UserManagementPage.tsx b/apps/backoffice/src/app/features/user-management/UserManagementPage.tsx index a79dc9ed0..6a96bba84 100644 --- a/apps/backoffice/src/app/features/user-management/UserManagementPage.tsx +++ b/apps/backoffice/src/app/features/user-management/UserManagementPage.tsx @@ -1,3 +1,4 @@ +import Cookies from 'js-cookie'; import { useCallback, useEffect, useRef } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { useNavigate } from 'react-router-dom'; @@ -138,12 +139,8 @@ export default function UserManagementPage() { style.textContent = UM_OVERRIDES; document.head.appendChild(style); - const readCookie = (name: string) => { - const m = document.cookie.match(new RegExp('(?:^|;\\s*)' + name + '=([^;]*)')); - return m ? decodeURIComponent(m[1]) : undefined; - }; - const token = readCookie('ema-backoffice-auth-token') ?? ''; - const refreshToken = readCookie('ema-backoffice-refresh-token'); + const token = Cookies.get('ema-backoffice-auth-token') ?? ''; + const refreshToken = Cookies.get('ema-backoffice-refresh-token'); const session: UserManagementSessionOptions = { initialSession: token diff --git a/libs/api/src/lib/session/index.ts b/libs/api/src/lib/session/index.ts index 7a3c1326d..7a38f30e1 100644 --- a/libs/api/src/lib/session/index.ts +++ b/libs/api/src/lib/session/index.ts @@ -1,3 +1,5 @@ +import Cookies from 'js-cookie'; + export const SESSION_HEADER_KEYS = { tenantId: 'x-tenant-id', organizationUnitId: 'x-organization-unit-id', @@ -11,17 +13,10 @@ const TOKEN_STORAGE_KEYS = [ 'auth-token', ] as const; -function getCookie(name: string): string | undefined { - const match = document.cookie.match( - new RegExp('(?:^|;\\s*)' + name + '=([^;]*)'), - ); - return match ? decodeURIComponent(match[1]) : undefined; -} - export function resolveTokenFromStorage(): string | undefined { // cookie first (backoffice stores tokens there), then localStorage (portal / legacy) for (const key of TOKEN_STORAGE_KEYS) { - const cookie = getCookie(key); + const cookie = Cookies.get(key); if (cookie) return cookie; const stored = localStorage.getItem(key); if (stored) return stored; diff --git a/libs/auth/src/lib/components/ProtectedRoute.tsx b/libs/auth/src/lib/components/ProtectedRoute.tsx index 9eb4d0e8f..9c55d85ee 100644 --- a/libs/auth/src/lib/components/ProtectedRoute.tsx +++ b/libs/auth/src/lib/components/ProtectedRoute.tsx @@ -1,3 +1,4 @@ +import Cookies from 'js-cookie'; import { Navigate, Outlet, useLocation } from 'react-router-dom'; import type { ReactNode } from 'react'; import { authStorage } from '../utils/auth-storage'; @@ -7,14 +8,9 @@ interface ProtectedRouteProps { loginPath?: string; } -function getTokenFromCookie(): string | undefined { - const match = document.cookie.match(/(?:^|;\s*)auth-token=([^;]*)/); - return match ? decodeURIComponent(match[1]) : undefined; -} - export function ProtectedRoute({ children, loginPath = '/login' }: ProtectedRouteProps) { const location = useLocation(); - const token = authStorage.getToken() ?? getTokenFromCookie(); + const token = authStorage.getToken() ?? Cookies.get('auth-token'); if (!token) { return ; diff --git a/libs/auth/src/lib/store/auth.slice.ts b/libs/auth/src/lib/store/auth.slice.ts index 0a68218b8..9b64d9475 100644 --- a/libs/auth/src/lib/store/auth.slice.ts +++ b/libs/auth/src/lib/store/auth.slice.ts @@ -1,6 +1,6 @@ import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; import type { AuthState, AuthUser, CurrentProfile, LoginPayload } from '../types/auth.types'; -import { authStorage } from '../utils/auth-storage'; +import { authStorage, clearAllCookies } from '../utils/auth-storage'; const initialState: AuthState = { user: null, @@ -37,6 +37,7 @@ const authSlice = createSlice({ state.isAuthenticated = false; state.currentProfile = null; authStorage.clear(); + clearAllCookies() }, hydrateAuth(state) { const token = authStorage.getToken(); diff --git a/libs/auth/src/lib/utils/auth-storage.ts b/libs/auth/src/lib/utils/auth-storage.ts index 9b9f05af7..9354d5474 100644 --- a/libs/auth/src/lib/utils/auth-storage.ts +++ b/libs/auth/src/lib/utils/auth-storage.ts @@ -1,4 +1,6 @@ -const MAX_AGE = 60 * 60 * 24 * 7; // ponytail: cookie lifetime knob; JWT expiry + refresh govern real auth +import Cookies from 'js-cookie'; + +const COOKIE_EXPIRES_DAYS = 7; // ponytail: cookie lifetime knob; JWT expiry + refresh govern real auth interface Backend { get(k: string): string | null; @@ -13,27 +15,25 @@ const localBackend: Backend = { }; const cookieBackend: Backend = { - get: (k) => { - const m = document.cookie.match(new RegExp('(?:^|;\\s*)' + k + '=([^;]*)')); - return m ? decodeURIComponent(m[1]) : null; - }, + get: (k) => Cookies.get(k) ?? null, // ponytail: 4KB/cookie cap — auth-user/current-profile JSON must stay under it - set: (k, v) => { - document.cookie = `${k}=${encodeURIComponent(v)}; path=/; Secure; SameSite=Strict; Max-Age=${MAX_AGE}`; - }, - remove: (k) => { - document.cookie = `${k}=; path=/; Secure; SameSite=Strict; Max-Age=0`; - }, + set: (k, v) => + Cookies.set(k, v, { path: '/', secure: true, sameSite: 'strict', expires: COOKIE_EXPIRES_DAYS }), + remove: (k) => Cookies.remove(k, { path: '/' }), }; -import Cookies from "js-cookie"; +const AUTH_KEY_NAMES = ['auth-token', 'refresh-token', 'auth-user', 'profile-id', 'current-profile']; -let _prefix = "ema-auth"; +let _prefix = 'ema-auth'; let backend: Backend = localBackend; export function configureAuthStorage(prefix: string, useCookies = false) { _prefix = prefix; backend = useCookies ? cookieBackend : localBackend; + if (useCookies) { + // one-time cleanup: drop pre-migration localStorage leftovers so tokens live in cookies only + AUTH_KEY_NAMES.forEach((k) => localStorage.removeItem(`${prefix}-${k}`)); + } } function key(k: string) { @@ -68,16 +68,14 @@ export const authStorage = { [key('auth-token'), key('refresh-token'), key('auth-user'), key('profile-id'), key('current-profile')].forEach((k) => backend.remove(k), ); - document.cookie = - "auth-token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC; SameSite=Lax"; + Cookies.remove('auth-token', { path: '/' }); }, }; export const clearAllCookies = () => { const cookies = Cookies.get(); - Object.keys(cookies).forEach((name) => { Cookies.remove(name); - Cookies.remove(name, { path: "/" }); + Cookies.remove(name, { path: '/' }); }); }; From 7191bc6791efcdc763baed975bf6cc20d2c54404 Mon Sep 17 00:00:00 2001 From: Estifo77 <139631617+Estifo77@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:46:38 +0300 Subject: [PATCH 007/187] refactor: standardize import quotes and improve cookie management in auth storage --- libs/auth/src/lib/pages/LoginPage.tsx | 77 +++++++++++++---------- libs/auth/src/lib/store/auth.slice.ts | 24 ++++++-- libs/auth/src/lib/utils/auth-storage.ts | 81 ++++++++++++++----------- 3 files changed, 109 insertions(+), 73 deletions(-) diff --git a/libs/auth/src/lib/pages/LoginPage.tsx b/libs/auth/src/lib/pages/LoginPage.tsx index 142c7cdac..9be1b978b 100644 --- a/libs/auth/src/lib/pages/LoginPage.tsx +++ b/libs/auth/src/lib/pages/LoginPage.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState } from "react"; import { Alert, Anchor, @@ -11,29 +11,35 @@ import { Text, TextInput, Title, -} from '@mantine/core'; +} from "@mantine/core"; import { IconArrowRight, IconDeviceMobile, IconLock, IconMail, -} from '@tabler/icons-react'; -import { useForm } from 'react-hook-form'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { z } from 'zod'; -import { useNavigate, Link } from 'react-router-dom'; -import { useDispatch } from 'react-redux'; -import { useApiMutation } from '@ema-platform/api'; -import { notify } from '@ema-platform/ui'; -import { AuthShell } from '../components/AuthShell'; -import { loginSuccess, setUser, setCurrentProfile } from '../store/auth.slice'; -import type { LoginPayload, AuthUser, CurrentProfile } from '../types/auth.types'; -import { useAuthConfig } from '../AuthConfig'; -import { authStorage } from '../utils/auth-storage'; +} from "@tabler/icons-react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { useNavigate, Link } from "react-router-dom"; +import { useDispatch } from "react-redux"; +import { useApiMutation } from "@ema-platform/api"; +import { notify } from "@ema-platform/ui"; +import { AuthShell } from "../components/AuthShell"; +import { loginSuccess, setUser, setCurrentProfile } from "../store/auth.slice"; +import type { + LoginPayload, + AuthUser, + CurrentProfile, +} from "../types/auth.types"; +import { useAuthConfig } from "../AuthConfig"; +import { authStorage } from "../utils/auth-storage"; const schema = z.object({ - email: z.string().email({ message: 'Enter a valid email' }), - password: z.string().min(5, { message: 'Password must be at least 6 characters' }), + email: z.string().email({ message: "Enter a valid email" }), + password: z + .string() + .min(5, { message: "Password must be at least 6 characters" }), }); type FormValues = z.infer; @@ -48,7 +54,10 @@ export function LoginPage() { const [serverError, setServerError] = useState(null); const [loginTrigger] = useApiMutation(); const [meTrigger] = useApiMutation(); - const [profileCheckTrigger] = useApiMutation<{ total: number; items: CurrentProfile[] }>(); + const [profileCheckTrigger] = useApiMutation<{ + total: number; + items: CurrentProfile[]; + }>(); const { register, @@ -62,15 +71,15 @@ export function LoginPage() { setIsLoading(true); try { const data = await loginTrigger({ - url: '/auth/login', - method: 'POST', + url: "/auth/login", + method: "POST", body: values, }).unwrap(); dispatch(loginSuccess(data)); const me = await meTrigger({ - url: '/auth/me', - method: 'GET', + url: "/auth/me", + method: "GET", }).unwrap(); dispatch(setUser(me)); @@ -79,7 +88,7 @@ export function LoginPage() { const q = `w=user_id:=:${me.id}&i=user,address,profession`; const result = await profileCheckTrigger({ url: `/profiles?q=${encodeURIComponent(q)}`, - method: 'GET', + method: "GET", }).unwrap(); if (result.total > 0 && result.items.length > 0) { const profile = result.items[0]; @@ -92,7 +101,7 @@ export function LoginPage() { } if (!me.isPhoneNumberVerified) { - navigate('/otp-verify', { + navigate("/otp-verify", { state: { email: me.email, phoneNumber: me.phoneNumber, @@ -103,7 +112,7 @@ export function LoginPage() { } if (!hasProfile) { - navigate('/profile-setup'); + navigate("/profile-setup"); return; } @@ -111,10 +120,11 @@ export function LoginPage() { } catch (err: unknown) { const msg = (err as { data?: { message?: string } })?.data?.message ?? - (err instanceof Error ? err.message : 'Something went wrong'); + (err instanceof Error ? err.message : "Something went wrong"); setServerError(msg); notify.error(msg); } finally { + localStorage.setItem("rememberMe", String(rememberMe)); setIsLoading(false); } }; @@ -132,7 +142,12 @@ export function LoginPage() { {serverError && ( - setServerError(null)}> + setServerError(null)} + > {serverError} )} @@ -145,7 +160,7 @@ export function LoginPage() { size="md" leftSection={} error={errors.email?.message} - {...register('email')} + {...register("email")} /> } error={errors.password?.message} - {...register('password')} + {...register("password")} /> @@ -194,14 +209,14 @@ export function LoginPage() { fullWidth size="md" leftSection={} - onClick={() => notify.info('Phone sign-in is coming soon.')} + onClick={() => notify.info("Phone sign-in is coming soon.")} > Sign in with phone number {enableSignup && ( - Don't have an account?{' '} + Don't have an account?{" "} Create one diff --git a/libs/auth/src/lib/store/auth.slice.ts b/libs/auth/src/lib/store/auth.slice.ts index 9b64d9475..5ae8afb6c 100644 --- a/libs/auth/src/lib/store/auth.slice.ts +++ b/libs/auth/src/lib/store/auth.slice.ts @@ -1,6 +1,11 @@ -import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; -import type { AuthState, AuthUser, CurrentProfile, LoginPayload } from '../types/auth.types'; -import { authStorage, clearAllCookies } from '../utils/auth-storage'; +import { createSlice, type PayloadAction } from "@reduxjs/toolkit"; +import type { + AuthState, + AuthUser, + CurrentProfile, + LoginPayload, +} from "../types/auth.types"; +import { authStorage } from "../utils/auth-storage"; const initialState: AuthState = { user: null, @@ -10,7 +15,7 @@ const initialState: AuthState = { }; const authSlice = createSlice({ - name: 'auth', + name: "auth", initialState, reducers: { loginSuccess(state, action: PayloadAction) { @@ -37,7 +42,7 @@ const authSlice = createSlice({ state.isAuthenticated = false; state.currentProfile = null; authStorage.clear(); - clearAllCookies() + authStorage.clear(); }, hydrateAuth(state) { const token = authStorage.getToken(); @@ -55,5 +60,12 @@ const authSlice = createSlice({ }, }); -export const { loginSuccess, setUser, setCurrentProfile, clearCurrentProfile, logout, hydrateAuth } = authSlice.actions; +export const { + loginSuccess, + setUser, + setCurrentProfile, + clearCurrentProfile, + logout, + hydrateAuth, +} = authSlice.actions; export const authReducer = authSlice.reducer; diff --git a/libs/auth/src/lib/utils/auth-storage.ts b/libs/auth/src/lib/utils/auth-storage.ts index 9354d5474..27e546b4f 100644 --- a/libs/auth/src/lib/utils/auth-storage.ts +++ b/libs/auth/src/lib/utils/auth-storage.ts @@ -1,35 +1,45 @@ -import Cookies from 'js-cookie'; - -const COOKIE_EXPIRES_DAYS = 7; // ponytail: cookie lifetime knob; JWT expiry + refresh govern real auth - +import Cookies from "js-cookie"; +const rememberMe = localStorage.getItem("rememberMe") === "true"; +const COOKIE_EXPIRES_DAYS = rememberMe ? 15 : 1; interface Backend { get(k: string): string | null; set(k: string, v: string): void; remove(k: string): void; } -const localBackend: Backend = { +const localStore: Backend = { get: (k) => localStorage.getItem(k), set: (k, v) => localStorage.setItem(k, v), remove: (k) => localStorage.removeItem(k), }; -const cookieBackend: Backend = { +const cookieStore: Backend = { get: (k) => Cookies.get(k) ?? null, - // ponytail: 4KB/cookie cap — auth-user/current-profile JSON must stay under it + set: (k, v) => - Cookies.set(k, v, { path: '/', secure: true, sameSite: 'strict', expires: COOKIE_EXPIRES_DAYS }), - remove: (k) => Cookies.remove(k, { path: '/' }), + Cookies.set(k, v, { + path: "/", + secure: true, + sameSite: "strict", + expires: COOKIE_EXPIRES_DAYS, + }), + remove: (k) => Cookies.remove(k, { path: "/" }), }; -const AUTH_KEY_NAMES = ['auth-token', 'refresh-token', 'auth-user', 'profile-id', 'current-profile']; +const AUTH_KEY_NAMES = [ + "auth-token", + "refresh-token", + "auth-user", + "profile-id", + "current-profile", +]; -let _prefix = 'ema-auth'; -let backend: Backend = localBackend; +let _prefix = "ema-auth"; +let backend: Backend = localStore; export function configureAuthStorage(prefix: string, useCookies = false) { _prefix = prefix; - backend = useCookies ? cookieBackend : localBackend; + backend = useCookies ? cookieStore : localStore; if (useCookies) { // one-time cleanup: drop pre-migration localStorage leftovers so tokens live in cookies only AUTH_KEY_NAMES.forEach((k) => localStorage.removeItem(`${prefix}-${k}`)); @@ -41,41 +51,40 @@ function key(k: string) { } export const authStorage = { - getToken: () => backend.get(key('auth-token')) ?? undefined, - setToken: (token: string) => backend.set(key('auth-token'), token), - getRefreshToken: () => backend.get(key('refresh-token')) ?? undefined, - setRefreshToken: (t: string) => backend.set(key('refresh-token'), t), + getToken: () => backend.get(key("auth-token")) ?? undefined, + setToken: (token: string) => backend.set(key("auth-token"), token), + getRefreshToken: () => backend.get(key("refresh-token")) ?? undefined, + setRefreshToken: (t: string) => backend.set(key("refresh-token"), t), getUser: (): T | null => { try { - return JSON.parse(backend.get(key('auth-user')) ?? 'null') as T | null; + return JSON.parse(backend.get(key("auth-user")) ?? "null") as T | null; } catch { return null; } }, - setUser: (u: T) => backend.set(key('auth-user'), JSON.stringify(u)), - getProfileId: () => backend.get(key('profile-id')) ?? undefined, - setProfileId: (id: string) => backend.set(key('profile-id'), id), + setUser: (u: T) => backend.set(key("auth-user"), JSON.stringify(u)), + getProfileId: () => backend.get(key("profile-id")) ?? undefined, + setProfileId: (id: string) => backend.set(key("profile-id"), id), getProfile: (): T | null => { try { - return JSON.parse(backend.get(key('current-profile')) ?? 'null') as T | null; + return JSON.parse( + backend.get(key("current-profile")) ?? "null", + ) as T | null; } catch { return null; } }, - setProfile: (p: T) => backend.set(key('current-profile'), JSON.stringify(p)), - removeProfile: () => backend.remove(key('current-profile')), + setProfile: (p: T) => + backend.set(key("current-profile"), JSON.stringify(p)), + removeProfile: () => backend.remove(key("current-profile")), clear: () => { - [key('auth-token'), key('refresh-token'), key('auth-user'), key('profile-id'), key('current-profile')].forEach((k) => - backend.remove(k), - ); - Cookies.remove('auth-token', { path: '/' }); + [ + key("auth-token"), + key("refresh-token"), + key("auth-user"), + key("profile-id"), + key("current-profile"), + ].forEach((k) => backend.remove(k)); + Cookies.remove("auth-token", { path: "/" }); }, }; - -export const clearAllCookies = () => { - const cookies = Cookies.get(); - Object.keys(cookies).forEach((name) => { - Cookies.remove(name); - Cookies.remove(name, { path: '/' }); - }); -}; From 7e2186e758e0081c744cde63db75e7cc5ab55f28 Mon Sep 17 00:00:00 2001 From: Estifo77 <139631617+Estifo77@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:32:30 +0300 Subject: [PATCH 008/187] feat: implement Telebirr payment API and types, enhance auth storage with token management --- .../pages/EndorsementReviewPage.tsx | 8 ++-- apps/backoffice/src/styles.css | 26 ++++++++++ .../app/features/payment/api/payment-api.ts | 33 +++++++++++++ .../src/app/features/payment/types/payment.ts | 10 ++++ apps/portal/src/app/store/index.ts | 29 ++++++++---- libs/auth/src/index.ts | 47 ++++++++++++++----- libs/auth/src/lib/store/auth.slice.ts | 6 ++- libs/auth/src/lib/utils/refresh-token.ts | 17 +++---- 8 files changed, 142 insertions(+), 34 deletions(-) create mode 100644 apps/portal/src/app/features/payment/api/payment-api.ts create mode 100644 apps/portal/src/app/features/payment/types/payment.ts diff --git a/apps/backoffice/src/app/features/endorsement/pages/EndorsementReviewPage.tsx b/apps/backoffice/src/app/features/endorsement/pages/EndorsementReviewPage.tsx index b99106295..39ad590b7 100644 --- a/apps/backoffice/src/app/features/endorsement/pages/EndorsementReviewPage.tsx +++ b/apps/backoffice/src/app/features/endorsement/pages/EndorsementReviewPage.tsx @@ -233,7 +233,7 @@ function ActionModal({ Officer Action} + title={Officer Action} size="md" radius="lg" > @@ -373,7 +373,7 @@ export function EndorsementReviewPage() { Review all tabs, then take an action: - @@ -401,7 +401,7 @@ export function EndorsementReviewPage() { }>Foreign CoC }>Documents }>Payment - }>Endorsement + }>Endorsement {/* ── Applicant ─────────────────────────────────────────── */} @@ -522,7 +522,7 @@ export function EndorsementReviewPage() { - +
Issued Endorsement STCW Reg I/10 — Flag State Endorsement diff --git a/apps/backoffice/src/styles.css b/apps/backoffice/src/styles.css index ad50c6afc..53f8105c8 100644 --- a/apps/backoffice/src/styles.css +++ b/apps/backoffice/src/styles.css @@ -4,3 +4,29 @@ @tailwind utilities; *, *::before, *::after { box-sizing: border-box; } + +:root { + --ema-scrollbar-light: #c1c1c1; + --ema-scrollbar-dark: #374151; + --ema-scrollbar-track-light: #f5f8fc; + --ema-scrollbar-track-dark: #0e1521; +} + +[data-mantine-color-scheme='light'] body { + --ema-scrollbar-thumb: var(--ema-scrollbar-light); + --ema-scrollbar-track: var(--ema-scrollbar-track-light); +} +[data-mantine-color-scheme='dark'] body { + --ema-scrollbar-thumb: var(--ema-scrollbar-dark); + --ema-scrollbar-track: var(--ema-scrollbar-track-dark); +} + +html { + scrollbar-color: var(--ema-scrollbar-thumb) var(--ema-scrollbar-track); + scrollbar-width: thin; +} + +::-webkit-scrollbar { width: 8px; height: 8px; } +::-webkit-scrollbar-track { background: var(--ema-scrollbar-track); } +::-webkit-scrollbar-thumb { background: var(--ema-scrollbar-thumb); border-radius: 4px; } +::-webkit-scrollbar-thumb:hover { opacity: 0.8; } diff --git a/apps/portal/src/app/features/payment/api/payment-api.ts b/apps/portal/src/app/features/payment/api/payment-api.ts new file mode 100644 index 000000000..9fa9eddf1 --- /dev/null +++ b/apps/portal/src/app/features/payment/api/payment-api.ts @@ -0,0 +1,33 @@ +import { baseApi } from "@ema-platform/api"; +import { + TelebirrCreatePaymentResponse, + TelebirrPayload, + TelebirrResponse, +} from "../types/payment"; +const paymentApi = baseApi.injectEndpoints({ + endpoints: (builder) => ({ + getPaymentStatusTelebirr: builder.query({ + query: (orderId) => `/payments/${orderId}/status`, + providesTags: ["Api"], + }), + getPaymentDetail: builder.query({ + query: (paymentId) => `/payments/${paymentId}`, + providesTags: ["Api"], + }), + createPaymentTelebirr: builder.mutation< + TelebirrCreatePaymentResponse, + TelebirrPayload + >({ + query: (body) => ({ + url: "/payments/telebirr/create", + method: "POST", + body, + }), + }), + }), +}); +export const { + useCreatePaymentTelebirrMutation, + useGetPaymentStatusTelebirrQuery, + useGetPaymentDetailQuery, +} = paymentApi; diff --git a/apps/portal/src/app/features/payment/types/payment.ts b/apps/portal/src/app/features/payment/types/payment.ts new file mode 100644 index 000000000..0cf92ca8e --- /dev/null +++ b/apps/portal/src/app/features/payment/types/payment.ts @@ -0,0 +1,10 @@ +export interface TelebirrPayload { + orderid: string; +} +export interface TelebirrCreatePaymentResponse { + status: string; + paymentUrl: string; +} +export interface TelebirrResponse { + status: string; +} diff --git a/apps/portal/src/app/store/index.ts b/apps/portal/src/app/store/index.ts index a48e46406..88a04e5aa 100644 --- a/apps/portal/src/app/store/index.ts +++ b/apps/portal/src/app/store/index.ts @@ -1,5 +1,6 @@ -import { configureStore } from '@reduxjs/toolkit'; -import { baseApi, configureTokenRefresh } from '@ema-platform/api'; +import { configureStore } from "@reduxjs/toolkit"; +import { baseApi, configureTokenRefresh } from "@ema-platform/api"; +import { setToken } from "@ema-platform/auth"; import { authReducer, signupReducer, @@ -7,17 +8,22 @@ import { authStorage, refreshAccessToken, logout, -} from '@ema-platform/auth'; -import type { AuthUser, CurrentProfile } from '@ema-platform/auth'; +} from "@ema-platform/auth"; +import type { AuthUser, CurrentProfile } from "@ema-platform/auth"; -configureAuthStorage('ema-portal'); +configureAuthStorage("ema-portal"); const preloadedAuth = (() => { const token = authStorage.getToken(); const user = authStorage.getUser(); const profile = authStorage.getProfile(); if (token && user) { - return { token, user, isAuthenticated: true, currentProfile: profile ?? null }; + return { + token, + user, + isAuthenticated: true, + currentProfile: profile ?? null, + }; } return undefined; })(); @@ -34,10 +40,17 @@ export const store = configureStore({ }); configureTokenRefresh({ - onTokenExpired: refreshAccessToken, + onTokenExpired: async () => { + const token = await refreshAccessToken(); + + store.dispatch(setToken(token)); + + return token; + }, + onAuthFailure: () => { store.dispatch(logout()); - window.location.href = '/login'; + window.location.href = "/login"; }, }); diff --git a/libs/auth/src/index.ts b/libs/auth/src/index.ts index f1919dd50..31b81284d 100644 --- a/libs/auth/src/index.ts +++ b/libs/auth/src/index.ts @@ -1,13 +1,34 @@ -export { AuthConfigProvider, useAuthConfig } from './lib/AuthConfig'; -export type { AuthConfigValue } from './lib/AuthConfig'; -export { AuthShell, BrandMark } from './lib/components/AuthShell'; -export { ProtectedRoute } from './lib/components/ProtectedRoute'; -export { LoginPage } from './lib/pages/LoginPage'; -export { SignupPage } from './lib/pages/SignupPage'; -export { ForgotPasswordPage } from './lib/pages/ForgotPasswordPage'; -export { OTPVerificationPage } from './lib/pages/OTPVerificationPage'; -export { authReducer, loginSuccess, setUser, setCurrentProfile, clearCurrentProfile, logout, hydrateAuth } from './lib/store/auth.slice'; -export { signupReducer, setSignupData, setSignupStep, resetSignup } from './lib/store/signup.slice'; -export { configureAuthStorage, authStorage } from './lib/utils/auth-storage'; -export { refreshAccessToken } from './lib/utils/refresh-token'; -export type { AuthUser, AuthState, LoginPayload, CurrentProfile, CurrentProfileAddress, CurrentProfileProfession } from './lib/types/auth.types'; +export { AuthConfigProvider, useAuthConfig } from "./lib/AuthConfig"; +export type { AuthConfigValue } from "./lib/AuthConfig"; +export { AuthShell, BrandMark } from "./lib/components/AuthShell"; +export { ProtectedRoute } from "./lib/components/ProtectedRoute"; +export { LoginPage } from "./lib/pages/LoginPage"; +export { SignupPage } from "./lib/pages/SignupPage"; +export { ForgotPasswordPage } from "./lib/pages/ForgotPasswordPage"; +export { OTPVerificationPage } from "./lib/pages/OTPVerificationPage"; +export { + authReducer, + loginSuccess, + setUser, + setCurrentProfile, + clearCurrentProfile, + logout, + hydrateAuth, + setToken, +} from "./lib/store/auth.slice"; +export { + signupReducer, + setSignupData, + setSignupStep, + resetSignup, +} from "./lib/store/signup.slice"; +export { configureAuthStorage, authStorage } from "./lib/utils/auth-storage"; +export { refreshAccessToken } from "./lib/utils/refresh-token"; +export type { + AuthUser, + AuthState, + LoginPayload, + CurrentProfile, + CurrentProfileAddress, + CurrentProfileProfession, +} from "./lib/types/auth.types"; diff --git a/libs/auth/src/lib/store/auth.slice.ts b/libs/auth/src/lib/store/auth.slice.ts index 5ae8afb6c..1fb70a533 100644 --- a/libs/auth/src/lib/store/auth.slice.ts +++ b/libs/auth/src/lib/store/auth.slice.ts @@ -42,7 +42,10 @@ const authSlice = createSlice({ state.isAuthenticated = false; state.currentProfile = null; authStorage.clear(); - authStorage.clear(); + }, + setToken(state, action: PayloadAction) { + state.token = action.payload; + authStorage.setToken(action.payload); }, hydrateAuth(state) { const token = authStorage.getToken(); @@ -67,5 +70,6 @@ export const { clearCurrentProfile, logout, hydrateAuth, + setToken, } = authSlice.actions; export const authReducer = authSlice.reducer; diff --git a/libs/auth/src/lib/utils/refresh-token.ts b/libs/auth/src/lib/utils/refresh-token.ts index a809ad268..91daa7f1a 100644 --- a/libs/auth/src/lib/utils/refresh-token.ts +++ b/libs/auth/src/lib/utils/refresh-token.ts @@ -1,8 +1,9 @@ -import { authStorage } from './auth-storage'; +import { authStorage } from "./auth-storage"; const BASE_API_URL = - (import.meta as { env?: Record }).env?.['VITE_BASE_API_URL'] ?? - 'http://localhost:3001/api'; + (import.meta as { env?: Record }).env?.[ + "VITE_BASE_API_URL" + ] ?? "http://localhost:3001/api"; interface RefreshResponse { token: string; @@ -11,17 +12,17 @@ interface RefreshResponse { export async function refreshAccessToken(): Promise { const refreshToken = authStorage.getRefreshToken(); - if (!refreshToken) throw new Error('No refresh token available'); + if (!refreshToken) throw new Error("No refresh token available"); - const response = await fetch(`${BASE_API_URL}/auth/refresh`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + const response = await fetch(`${BASE_API_URL}/auth/refresh-token`, { + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ refreshToken }), }); if (!response.ok) { authStorage.clear(); - throw new Error('Token refresh failed'); + throw new Error("Token refresh failed"); } const data: RefreshResponse = await response.json(); From fb1ea8f51119e0ad258a8a24b3a5d387cf27b91a Mon Sep 17 00:00:00 2001 From: estifanos Date: Thu, 23 Jul 2026 11:33:25 +0000 Subject: [PATCH 009/187] feat: implement Telebirr payment integration and update related components --- .../certificates/pages/CoCApplicationPage.tsx | 24 ++++- .../payment/components/TelebirrPayment.tsx | 95 +++++++++++++++++++ .../pages/SeamanBookApplicationPage.tsx | 25 ++++- package-lock.json | 20 ++++ package.json | 1 + 5 files changed, 160 insertions(+), 5 deletions(-) create mode 100644 apps/portal/src/app/features/payment/components/TelebirrPayment.tsx diff --git a/apps/portal/src/app/features/certificates/pages/CoCApplicationPage.tsx b/apps/portal/src/app/features/certificates/pages/CoCApplicationPage.tsx index 667077cc3..dcbf801b9 100644 --- a/apps/portal/src/app/features/certificates/pages/CoCApplicationPage.tsx +++ b/apps/portal/src/app/features/certificates/pages/CoCApplicationPage.tsx @@ -39,6 +39,7 @@ import { IconUpload, } from '@tabler/icons-react'; import { notify } from '@ema-platform/ui'; +import { TelebirrPayment } from '../../payment/components/TelebirrPayment'; // --------------------------------------------------------------------------- // STCW certificate catalog — sourced from the STCW Convention & Code @@ -650,8 +651,13 @@ export function CoCApplicationPage() { const [paymentDate, setPaymentDate] = useState(''); const [paymentFile, setPaymentFile] = useState(null); + // Telebirr order id, set once TelebirrPayment confirms — shown as the ref in Step 3 + const [telebirrOrderId, setTelebirrOrderId] = useState(null); + const docsStepOk = docSlots.filter((d) => d.required).every((d) => !!docs[d.key]); - const payOk = !!paymentMethod && paymentRef.trim().length > 0 && paymentDate.length > 0 && !!paymentFile; + const payOk = paymentMethod === 'telebirr' + ? !!telebirrOrderId + : !!paymentMethod && paymentRef.trim().length > 0 && paymentDate.length > 0 && !!paymentFile; const FEES = [ { label: 'Application Fee', amount: selectedCert?.type === 'CoC' ? 800 : 300 }, @@ -976,7 +982,7 @@ export function CoCApplicationPage() { ))} - {paymentMethod && ( + {paymentMethod === 'cbe' && ( @@ -1011,6 +1017,18 @@ export function CoCApplicationPage() { )} + {paymentMethod === 'telebirr' && ( + + + { + setTelebirrOrderId(orderId); + setStep(3); + }} + /> + + )} + + + + )} + + {choice === 'qr' && paymentUrl && ( + + + + )} + + {status === 'polling' && ( + + + Waiting for payment confirmation… + + )} + + {status === 'confirmed' && ( + }> + Payment confirmed + + )} + + ); +} diff --git a/apps/portal/src/app/features/seaman-book/pages/SeamanBookApplicationPage.tsx b/apps/portal/src/app/features/seaman-book/pages/SeamanBookApplicationPage.tsx index e71f1df71..61f0f08fe 100644 --- a/apps/portal/src/app/features/seaman-book/pages/SeamanBookApplicationPage.tsx +++ b/apps/portal/src/app/features/seaman-book/pages/SeamanBookApplicationPage.tsx @@ -33,6 +33,7 @@ import { } from '@tabler/icons-react'; import { useNavigate } from 'react-router-dom'; import { notify } from '@ema-platform/ui'; +import { TelebirrPayment } from '../../payment/components/TelebirrPayment'; // --------------------------------------------------------------------------- // Steps @@ -234,6 +235,8 @@ export function SeamanBookApplicationPage() { const [paymentDate, setPaymentDate] = useState(''); const [paymentFile, setPaymentFile] = useState(null); const payResetRef = useRef<() => void>(null); + // Telebirr order id, set once TelebirrPayment confirms — real gateway flow, no manual ref for telebirr + const [telebirrOrderId, setTelebirrOrderId] = useState(null); // Validation const bstComplete = BST_SLOTS.every((s) => { @@ -241,7 +244,10 @@ export function SeamanBookApplicationPage() { return !!d.file && !!d.certNumber.trim() && !!d.issuer.trim() && !!d.issueDate; }); const medComplete = !!medFile && !!medCertNumber.trim() && !!medIssuer.trim() && !!medIssueDate && !!medExpiryDate; - const payComplete = !!paymentMethod && !!paymentRef.trim() && !!paymentDate; + // const payComplete = !!paymentMethod && !!paymentRef.trim() && !!paymentDate; // old: telebirr used to require a manual ref too + const payComplete = paymentMethod === 'telebirr' + ? !!telebirrOrderId + : !!paymentMethod && !!paymentRef.trim() && !!paymentDate; const canNext = () => { if (active === 0) return bstComplete; @@ -523,6 +529,7 @@ export function SeamanBookApplicationPage() { )} + {/* old manual Telebirr flow — replaced by the real gateway component below {paymentMethod === 'telebirr' && ( <> }> @@ -534,6 +541,19 @@ export function SeamanBookApplicationPage() { )} + */} + + {paymentMethod === 'telebirr' && ( + <> + + { + setTelebirrOrderId(orderId); + setActive(3); + }} + /> + + )} {paymentMethod && ( <> @@ -622,7 +642,8 @@ export function SeamanBookApplicationPage() { Payment - + {/* old: telebirr no longer collects a manual ref */} + diff --git a/package-lock.json b/package-lock.json index 0d01c8092..019356f9d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "react-dom": "^19.0.0", "react-hook-form": "^7.71.2", "react-i18next": "^15.7.4", + "react-qr-code": "^2.2.0", "react-redux": "^9.2.0", "react-router": "^7.12.0", "react-router-dom": "^7.13.1", @@ -16542,6 +16543,12 @@ "node": ">=6" } }, + "node_modules/qrcode-generator": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-2.0.4.tgz", + "integrity": "sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g==", + "license": "MIT" + }, "node_modules/qs": { "version": "6.15.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", @@ -16880,6 +16887,19 @@ "@napi-rs/canvas": "^0.1.80" } }, + "node_modules/react-qr-code": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/react-qr-code/-/react-qr-code-2.2.0.tgz", + "integrity": "sha512-e5nS0UUN22K3Nf8KBRUzemfdJ6OmnN5w+kbnj1lvJaol9RyVRFeGl05bCkxSN2ZegbLxjjYjX1+mmAoX9+fAhw==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.8.1", + "qrcode-generator": "^2.0.4" + }, + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-redux": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", diff --git a/package.json b/package.json index a37f5a120..50536ea24 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "react-dom": "^19.0.0", "react-hook-form": "^7.71.2", "react-i18next": "^15.7.4", + "react-qr-code": "^2.2.0", "react-redux": "^9.2.0", "react-router": "^7.12.0", "react-router-dom": "^7.13.1", From 875b56de8a5b7c47e2a333c03f0b15436fe29bfb Mon Sep 17 00:00:00 2001 From: estifanos Date: Thu, 23 Jul 2026 11:46:26 +0000 Subject: [PATCH 010/187] refresh token fix --- apps/backoffice/src/app/store/index.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/backoffice/src/app/store/index.ts b/apps/backoffice/src/app/store/index.ts index 8f9908a95..66f11f041 100644 --- a/apps/backoffice/src/app/store/index.ts +++ b/apps/backoffice/src/app/store/index.ts @@ -7,6 +7,7 @@ import { authStorage, refreshAccessToken, logout, + setToken, } from '@ema-platform/auth'; import type { AuthUser, CurrentProfile } from '@ema-platform/auth'; import { preferencesReducer } from './preferences.slice'; @@ -36,7 +37,11 @@ export const store = configureStore({ }); configureTokenRefresh({ - onTokenExpired: refreshAccessToken, + onTokenExpired: async () => { + const token = await refreshAccessToken(); + store.dispatch(setToken(token)); + return token; + }, onAuthFailure: () => { store.dispatch(logout()); window.location.href = '/login'; From 60ef3fc8b8bf97bc11d203d405753e3944c2fc07 Mon Sep 17 00:00:00 2001 From: estifanos Date: Thu, 23 Jul 2026 12:56:16 +0000 Subject: [PATCH 011/187] feat: implement vessel ownership transfer feature in portal and backoffice - Added VesselTransferReviewPage for reviewing and managing vessel transfer requests in backoffice. - Created VesselTransferApplicationPage for submitting new ownership transfer requests in portal. - Introduced mock data for vessels and transfer requests in both portal and backoffice. - Updated navigation and layout to include vessel transfer sections in both applications. - Added internationalization support for vessel transfer labels in English and Amharic. - Documented the implementation details and suggested API surface for future integration. --- .../pages/VesselTransferQueuePage.tsx | 349 ++++++++++++++++++ .../pages/VesselTransferReviewPage.tsx | 252 +++++++++++++ apps/backoffice/src/app/i18n/locales/am.ts | 1 + apps/backoffice/src/app/i18n/locales/en.ts | 1 + .../src/app/layouts/BackofficeLayout.tsx | 2 + apps/backoffice/src/app/router/index.tsx | 4 + .../src/app/features/vessel-transfer/mock.ts | 93 +++++ .../pages/VesselTransferApplicationPage.tsx | 199 ++++++++++ .../pages/VesselTransferPage.tsx | 81 ++++ apps/portal/src/app/i18n/locales/am.ts | 1 + apps/portal/src/app/i18n/locales/en.ts | 1 + apps/portal/src/app/layouts/PortalLayout.tsx | 3 + apps/portal/src/app/router.tsx | 8 + docs/vessel-ownership-transfer.md | 39 ++ 14 files changed, 1034 insertions(+) create mode 100644 apps/backoffice/src/app/features/vessel-transfer/pages/VesselTransferQueuePage.tsx create mode 100644 apps/backoffice/src/app/features/vessel-transfer/pages/VesselTransferReviewPage.tsx create mode 100644 apps/portal/src/app/features/vessel-transfer/mock.ts create mode 100644 apps/portal/src/app/features/vessel-transfer/pages/VesselTransferApplicationPage.tsx create mode 100644 apps/portal/src/app/features/vessel-transfer/pages/VesselTransferPage.tsx create mode 100644 docs/vessel-ownership-transfer.md diff --git a/apps/backoffice/src/app/features/vessel-transfer/pages/VesselTransferQueuePage.tsx b/apps/backoffice/src/app/features/vessel-transfer/pages/VesselTransferQueuePage.tsx new file mode 100644 index 000000000..fc7940b75 --- /dev/null +++ b/apps/backoffice/src/app/features/vessel-transfer/pages/VesselTransferQueuePage.tsx @@ -0,0 +1,349 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + ActionIcon, + Badge, + Box, + Button, + Card, + Drawer, + Group, + Paper, + Select, + SimpleGrid, + Stack, + Table, + Text, + TextInput, + ThemeIcon, + Title, + rem, +} from '@mantine/core'; +import { + IconAnchor, + IconCircleCheck, + IconClock, + IconEye, + IconSearch, + IconShip, + IconX, +} from '@tabler/icons-react'; + +// --------------------------------------------------------------------------- +// Types & mock data +// --------------------------------------------------------------------------- +export type TransferStatus = 'Pending' | 'Under Review' | 'Approved' | 'Rejected'; + +export const STATUS_COLOR: Record = { + Pending: 'gray', + 'Under Review': 'blue', + Approved: 'teal', + Rejected: 'red', +}; + +export interface TransferParty { + name: string; + idOrTin: string; + phone: string; + email: string; + address: string; +} + +export interface TransferRequest { + id: string; + vesselName: string; + vesselRegNo: string; + currentOwner: TransferParty; + newOwner: TransferParty; + reason: string; + status: TransferStatus; + submitted: string; + remarks?: string; + approvedOn?: string; + document: { fileName: string; fileType: 'pdf' | 'image' }; +} + +export const MOCK_REQUESTS: TransferRequest[] = [ + { + id: 'VT-2025-0001', + vesselName: 'MV Abay', + vesselRegNo: 'ET-VSL-2021-014', + currentOwner: { name: 'Solomon Bekele', idOrTin: 'ETH-ID-0012345', phone: '+251 911 234 567', email: 'solomon.b@email.com', address: 'Bole, Addis Ababa' }, + newOwner: { name: 'Bahir Dar Shipping PLC', idOrTin: 'TIN-0044556677', phone: '+251 58 220 1234', email: 'info@bdshipping.com', address: 'Bahir Dar, Ethiopia' }, + reason: 'Corporate Restructuring', + status: 'Under Review', + submitted: '2025-06-30', + document: { fileName: 'bill_of_sale_abay.pdf', fileType: 'pdf' }, + }, + { + id: 'VT-2025-0002', + vesselName: 'MV Tana', + vesselRegNo: 'ET-VSL-2022-031', + currentOwner: { name: 'Solomon Bekele', idOrTin: 'ETH-ID-0012345', phone: '+251 911 234 567', email: 'solomon.b@email.com', address: 'Bole, Addis Ababa' }, + newOwner: { name: 'Hana Girma', idOrTin: 'ETH-ID-0098765', phone: '+251 922 345 678', email: 'hana.girma@email.com', address: 'Piazza, Addis Ababa' }, + reason: 'Sale', + status: 'Approved', + submitted: '2025-05-12', + approvedOn: '2025-05-28', + document: { fileName: 'bill_of_sale_tana.pdf', fileType: 'pdf' }, + }, + { + id: 'VT-2025-0003', + vesselName: 'MV Abay', + vesselRegNo: 'ET-VSL-2021-014', + currentOwner: { name: 'Solomon Bekele', idOrTin: 'ETH-ID-0012345', phone: '+251 911 234 567', email: 'solomon.b@email.com', address: 'Bole, Addis Ababa' }, + newOwner: { name: 'Dawit Alemu', idOrTin: 'ETH-ID-0011223', phone: '+251 933 456 789', email: 'dawit.alemu@email.com', address: 'Mekelle, Ethiopia' }, + reason: 'Gift', + status: 'Rejected', + submitted: '2025-04-02', + remarks: 'Bill of Sale document illegible — please resubmit a clearer scan.', + document: { fileName: 'transfer_doc_scan.jpg', fileType: 'image' }, + }, + { + id: 'VT-2025-0004', + vesselName: 'MV Zeway', + vesselRegNo: 'ET-VSL-2020-007', + currentOwner: { name: 'Meskerem Assefa', idOrTin: 'ETH-ID-0055667', phone: '+251 944 567 890', email: 'meskerem.a@email.com', address: 'Hawassa, Ethiopia' }, + newOwner: { name: 'Yonas Tesfaye', idOrTin: 'ETH-ID-0077889', phone: '+251 955 678 901', email: 'yonas.t@email.com', address: 'Adama, Ethiopia' }, + reason: 'Inheritance', + status: 'Pending', + submitted: '2025-07-10', + document: { fileName: 'inheritance_transfer.pdf', fileType: 'pdf' }, + }, + { + id: 'VT-2025-0005', + vesselName: 'MV Awash', + vesselRegNo: 'ET-VSL-2023-009', + currentOwner: { name: 'Kidist Worku', idOrTin: 'ETH-ID-0033445', phone: '+251 966 789 012', email: 'kidist.w@email.com', address: 'Dire Dawa, Ethiopia' }, + newOwner: { name: 'Nile Logistics PLC', idOrTin: 'TIN-0099887766', phone: '+251 11 550 4321', email: 'contact@nilelogistics.com', address: 'Addis Ababa, Ethiopia' }, + reason: 'Court Order', + status: 'Pending', + submitted: '2025-07-15', + document: { fileName: 'court_order.pdf', fileType: 'pdf' }, + }, + { + id: 'VT-2025-0006', + vesselName: 'MV Genale', + vesselRegNo: 'ET-VSL-2019-002', + currentOwner: { name: 'Tesfaye Kebede', idOrTin: 'ETH-ID-0022334', phone: '+251 977 890 123', email: 'tesfaye.k@email.com', address: 'Gondar, Ethiopia' }, + newOwner: { name: 'Almaz Hailu', idOrTin: 'ETH-ID-0088990', phone: '+251 988 901 234', email: 'almaz.h@email.com', address: 'Bahir Dar, Ethiopia' }, + reason: 'Other', + status: 'Under Review', + submitted: '2025-07-05', + document: { fileName: 'transfer_agreement.pdf', fileType: 'pdf' }, + }, +]; + +// --------------------------------------------------------------------------- +// Quick-review drawer +// --------------------------------------------------------------------------- +function QuickReviewDrawer({ + request, + opened, + onClose, + onFullReview, +}: { + request: TransferRequest | null; + opened: boolean; + onClose: () => void; + onFullReview: (id: string) => void; +}) { + if (!request) return null; + + return ( + + + + + + + {request.vesselName} + {request.status} + + {request.vesselRegNo} + + + + From → To + + + Current Owner + {request.currentOwner.name} + + + New Owner + {request.newOwner.name} + + + Reason + {request.reason} + + + Submitted + {request.submitted} + + + + + {request.status === 'Rejected' && request.remarks && ( + + Rejection Reason + {request.remarks} + + )} + {request.status === 'Approved' && request.approvedOn && ( + + Transfer approved on {request.approvedOn}. New certificates issued to {request.newOwner.name}. + + )} + + + ); +} + +// --------------------------------------------------------------------------- +// Main page +// --------------------------------------------------------------------------- +export function VesselTransferQueuePage() { + const navigate = useNavigate(); + const [requests] = useState(MOCK_REQUESTS); + const [search, setSearch] = useState(''); + const [statusFilter, setStatusFilter] = useState(null); + const [selected, setSelected] = useState(null); + const [drawerOpen, setDrawerOpen] = useState(false); + + const stats = { + total: requests.length, + pending: requests.filter((r) => r.status === 'Pending').length, + underReview: requests.filter((r) => r.status === 'Under Review').length, + approved: requests.filter((r) => r.status === 'Approved').length, + }; + + const filtered = requests.filter((r) => { + const q = search.toLowerCase(); + const matchSearch = !q + || r.vesselName.toLowerCase().includes(q) + || r.id.toLowerCase().includes(q) + || r.currentOwner.name.toLowerCase().includes(q) + || r.newOwner.name.toLowerCase().includes(q); + const matchStatus = !statusFilter || r.status === statusFilter; + return matchSearch && matchStatus; + }); + + const openDrawer = (req: TransferRequest) => { + setSelected(req); + setDrawerOpen(true); + }; + + return ( + +
+ Vessel Ownership Transfers + Review and process vessel ownership transfer requests +
+ + {/* Stats */} + + {[ + { label: 'Total', value: stats.total, color: 'blue', icon: IconShip }, + { label: 'Pending', value: stats.pending, color: 'gray', icon: IconClock }, + { label: 'Under Review', value: stats.underReview, color: 'blue', icon: IconEye }, + { label: 'Approved', value: stats.approved, color: 'teal', icon: IconCircleCheck }, + ].map(({ label, value, color, icon: Icon }) => ( + + + +
{value}{label}
+
+
+ ))} +
+ + {/* Table */} + + + Transfer Queue + + } + value={search} + onChange={(e) => setSearch(e.currentTarget.value)} + size="sm" + style={{ minWidth: rem(280) }} + rightSection={search ? setSearch('')}> : null} + /> +