diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 000000000..66521fdb1 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,68 @@ +name: Deploy Stacks + +on: + push: + branches: + - main + - dev + - staging + workflow_dispatch: + +concurrency: + group: deploy-${{ github.ref_name }} + cancel-in-progress: true + +jobs: + deploy: + name: Deploy ${{ matrix.service }} + runs-on: self-hosted + strategy: + fail-fast: false + matrix: + include: + - project: ema-dev + build_env_file: portal-web.build.env + service: ema-portal + - project: ema-dev + build_env_file: backoffice-web.build.env + service: ema-backoffice + env: + PROJECT: ${{ matrix.project }} + BRANCH: ${{ github.ref_name }} + DEPLOY_USER: tria + BUILD_ENV_FILE: ${{ matrix.build_env_file }} + DOCKER_BUILDKIT: "1" + COMPOSE_DOCKER_CLI_BUILD: "1" + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Sync environment from server + run: | + chmod +x scripts/deploy/*.sh + ./scripts/deploy/sync-env-from-server.sh "${{ matrix.service }}" + + - name: Set compose project name + run: | + set -euo pipefail + branch_slug=$(echo "${BRANCH}" | tr "[:upper:]" "[:lower:]" | sed -E "s/[^a-z0-9]+/-/g; s/^-+//; s/-+$//") + echo "COMPOSE_PROJECT_NAME=${PROJECT}-${branch_slug}" >> "${GITHUB_ENV}" + + # - name: Configure npm auth for Docker builds + # env: + # NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + # run: ./scripts/deploy/create-npmrc.sh + + - name: Build ${{ matrix.service }} + run: | + set -euo pipefail + docker compose --project-name "${COMPOSE_PROJECT_NAME}" build --no-cache "${{ matrix.service }}" + + - name: Deploy ${{ matrix.service }} + run: | + set -euo pipefail + docker compose --project-name "${COMPOSE_PROJECT_NAME}" up -d "${{ matrix.service }}" + + - name: Remove npm credentials from workspace + if: always() + run: rm -f .npmrc .npmrc_temp diff --git a/.gitignore b/.gitignore index 482dad8a6..199e08c7a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,23 @@ +# dependencies node_modules/ -.nx -dist/ + +# build output +**/dist/ +.next/ +.nx/ +coverage/ +*.tsbuildinfo +**/*.tsbuildinfo + +# env .env .env.* -!.env.example + +# logs +*.log + +# OS/editor .DS_Store +.idea/ +.vscode/ +.npmrc diff --git a/.npmrc b/.npmrc new file mode 100644 index 000000000..11a7adb31 --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +@tria-plc:registry=https://npm.pkg.github.com +//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN} diff --git a/Dockerfile b/Dockerfile index 8b252b8c3..f29502bd4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ FROM node:24-alpine AS deps WORKDIR /app COPY package.json package-lock.json* ./ -RUN npm ci +RUN npm install --legacy-peer-deps FROM deps AS base COPY . . diff --git a/apps/backoffice/public/assets/emaLogo.jpg b/apps/backoffice/public/assets/emaLogo.jpg new file mode 100644 index 000000000..92310f682 Binary files /dev/null and b/apps/backoffice/public/assets/emaLogo.jpg differ diff --git a/apps/backoffice/src/app/features/auth/api/auth-api.ts b/apps/backoffice/src/app/features/auth/api/auth-api.ts deleted file mode 100644 index 198865d69..000000000 --- a/apps/backoffice/src/app/features/auth/api/auth-api.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { baseApi } from '@ema-platform/api'; -import type { LoginPayload } from '../types/auth.types'; - -interface LoginArgs { - email: string; - password: string; -} - -interface RefreshArgs { - refreshToken: string; -} - -const authApi = baseApi.injectEndpoints({ - endpoints: (builder) => ({ - login: builder.mutation({ - query: (body) => ({ url: '/auth/login', method: 'POST', body }), - }), - refresh: builder.mutation<{ token: string }, RefreshArgs>({ - query: (body) => ({ url: '/auth/refresh', method: 'POST', body }), - }), - logout: builder.mutation<{ message: string }, void>({ - query: () => ({ url: '/auth/logout', method: 'POST' }), - }), - }), - overrideExisting: false, -}); - -export const { useLoginMutation, useRefreshMutation, useLogoutMutation } = authApi; diff --git a/apps/backoffice/src/app/features/auth/components/LoginForm.tsx b/apps/backoffice/src/app/features/auth/components/LoginForm.tsx deleted file mode 100644 index 57e463101..000000000 --- a/apps/backoffice/src/app/features/auth/components/LoginForm.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { - TextInput, - PasswordInput, - Button, - Stack, - Title, - Paper, - Text, - Anchor, -} from '@mantine/core'; -import { useForm } from 'react-hook-form'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { z } from 'zod'; -import { useNavigate, Link } from 'react-router-dom'; -import { useLoginMutation } from '../api/auth-api'; -import { useAuth } from '../hooks/useAuth'; -import { notify } from '@ema-platform/ui'; - -const schema = z.object({ - email: z.string().email('Enter a valid email'), - password: z.string().min(6, 'Password must be at least 6 characters'), -}); - -type FormValues = z.infer; - -export function LoginForm() { - const navigate = useNavigate(); - const { login } = useAuth(); - const [loginMutate, { isLoading }] = useLoginMutation(); - - const { - register, - handleSubmit, - formState: { errors }, - } = useForm({ - resolver: zodResolver(schema), - }); - - const onSubmit = async (values: FormValues) => { - try { - const result = await loginMutate(values).unwrap(); - login(result); - navigate('/dashboard'); - } catch { - notify.error('Invalid email or password'); - } - }; - - return ( - - - - Sign in - - Enter your credentials to access the backoffice - - -
- - - - - -
- - Don't have an account?{' '} - - Sign up - - -
-
- ); -} diff --git a/apps/backoffice/src/app/features/auth/hooks/useAuth.ts b/apps/backoffice/src/app/features/auth/hooks/useAuth.ts deleted file mode 100644 index b1a7924d2..000000000 --- a/apps/backoffice/src/app/features/auth/hooks/useAuth.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { useAppDispatch, useAppSelector } from '../../../store/hooks'; -import { loginSuccess, logout as logoutAction, hydrateAuth } from '../store/auth.slice'; -import type { LoginPayload } from '../types/auth.types'; - -export function useAuth() { - const dispatch = useAppDispatch(); - const { user, token, isAuthenticated } = useAppSelector((s) => s.auth); - - return { - user, - token, - isAuthenticated, - login: (payload: LoginPayload) => dispatch(loginSuccess(payload)), - logout: () => dispatch(logoutAction()), - hydrate: () => dispatch(hydrateAuth()), - }; -} diff --git a/apps/backoffice/src/app/features/auth/pages/LoginPage.tsx b/apps/backoffice/src/app/features/auth/pages/LoginPage.tsx deleted file mode 100644 index b4545349a..000000000 --- a/apps/backoffice/src/app/features/auth/pages/LoginPage.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { LoginForm } from '../components/LoginForm'; - -export function LoginPage() { - return ; -} diff --git a/apps/backoffice/src/app/features/auth/pages/SetPasswordPage.tsx b/apps/backoffice/src/app/features/auth/pages/SetPasswordPage.tsx deleted file mode 100644 index 41e311be9..000000000 --- a/apps/backoffice/src/app/features/auth/pages/SetPasswordPage.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import { - Paper, - TextInput, - PasswordInput, - Button, - Stack, - Title, - Text, -} from '@mantine/core'; -import { useForm } from 'react-hook-form'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { z } from 'zod'; -import { useNavigate, useLocation } from 'react-router-dom'; -import { useAppDispatch, useAppSelector } from '../../../store/hooks'; -import { - setPasswordStart, - setPasswordSuccess, - setPasswordFailure, -} from '../store/set-password.slice'; -import { notify } from '@ema-platform/ui'; - -const BASE_API_URL = - (import.meta as { env?: Record }).env?.['VITE_BASE_API_URL'] ?? - 'http://localhost:3001/api'; - -const schema = z - .object({ - userId: z.string().min(1, 'User ID is required'), - email: z.string().email('Enter a valid email'), - verificationCode: z.string().min(1, 'Verification code is required'), - newPassword: z.string().min(6, 'Password must be at least 6 characters'), - confirmPassword: z.string().min(6, 'Confirm your password'), - }) - .refine((data) => data.newPassword === data.confirmPassword, { - message: 'Passwords do not match', - path: ['confirmPassword'], - }); - -type FormValues = z.infer; - -export function SetPasswordPage() { - const navigate = useNavigate(); - const dispatch = useAppDispatch(); - const location = useLocation(); - const { loading } = useAppSelector((s) => s.setPassword); - const signupEmail = (location.state as { email?: string } | null)?.email ?? ''; - - const { - register, - handleSubmit, - formState: { errors }, - } = useForm({ - resolver: zodResolver(schema), - defaultValues: { email: signupEmail }, - }); - - const onSubmit = async (values: FormValues) => { - dispatch(setPasswordStart()); - try { - const res = await fetch(`${BASE_API_URL}/auth/set-password`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - userId: values.userId, - email: values.email, - verificationCode: values.verificationCode, - newPassword: values.newPassword, - confirmPassword: values.confirmPassword, - }), - }); - - if (!res.ok) { - const errorData = await res.json().catch(() => null); - throw new Error(errorData?.message ?? 'Failed to set password'); - } - - dispatch(setPasswordSuccess()); - notify.success('Password set successfully'); - navigate('/dashboard'); - } catch (err) { - const msg = err instanceof Error ? err.message : 'Something went wrong'; - dispatch(setPasswordFailure(msg)); - notify.error(msg); - } - }; - - return ( - - - - Set your password - - A verification code has been sent to your email. Enter it below along - with your new password to complete registration. - - -
- - - - - - - - -
-
-
- ); -} diff --git a/apps/backoffice/src/app/features/auth/pages/SignupPage.tsx b/apps/backoffice/src/app/features/auth/pages/SignupPage.tsx deleted file mode 100644 index 05ce9eae0..000000000 --- a/apps/backoffice/src/app/features/auth/pages/SignupPage.tsx +++ /dev/null @@ -1,153 +0,0 @@ -import { - Paper, - TextInput, - Button, - Stack, - Title, - Text, - Anchor, -} from '@mantine/core'; -import { useForm } from 'react-hook-form'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { z } from 'zod'; -import { useNavigate, Link } from 'react-router-dom'; -import { useAppDispatch, useAppSelector } from '../../../store/hooks'; -import { signupStart, signupSuccess, signupFailure } from '../store/signup.slice'; -import { hydrateAuth } from '../store/auth.slice'; -import { authStorage } from '../utils/auth-storage'; -import { notify } from '@ema-platform/ui'; - -const BASE_API_URL = - (import.meta as { env?: Record }).env?.['VITE_BASE_API_URL'] ?? - 'http://localhost:3001/api'; - -const schema = z.object({ - email: z.string().email('Enter a valid email'), - username: z.string().min(3, 'Username must be at least 3 characters'), - phoneNumber: z.string().min(1, 'Phone number is required'), - userType: z.string().min(1, 'User type is required'), - name: z.string().min(1, 'Name is required'), -}); - -type FormValues = z.infer; - -interface SignupPayload { - email: string; - username: string; - phoneNumber: string; - userType: string; - name: { - am: string; - en: string; - }; -} - -export function SignupPage() { - const navigate = useNavigate(); - const dispatch = useAppDispatch(); - const { loading } = useAppSelector((s) => s.signup); - - const { - register, - handleSubmit, - formState: { errors }, - } = useForm({ - resolver: zodResolver(schema), - }); - - const onSubmit = async (values: FormValues) => { - dispatch(signupStart()); - try { - const payload: SignupPayload = { - email: values.email, - username: values.username, - phoneNumber: values.phoneNumber, - userType: values.userType, - name: { - am: '', - en: values.name, - }, - }; - - const res = await fetch(`${BASE_API_URL}/auth/signup`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - - if (!res.ok) { - const errorData = await res.json().catch(() => null); - throw new Error(errorData?.message ?? 'Signup failed'); - } - - const data = (await res.json()) as { token: string; refreshToken: string }; - - authStorage.setToken(data.token); - authStorage.setRefreshToken(data.refreshToken); - dispatch(hydrateAuth()); - dispatch(signupSuccess()); - - navigate('/set-password', { state: { email: values.email } }); - } catch (err) { - const msg = err instanceof Error ? err.message : 'Something went wrong'; - dispatch(signupFailure(msg)); - notify.error(msg); - } - }; - - return ( - - - - Create an account - - Fill in your details to get started - - -
- - - - - - - - -
- - Already have an account?{' '} - - Sign in - - -
-
- ); -} diff --git a/apps/backoffice/src/app/features/auth/store/auth.slice.ts b/apps/backoffice/src/app/features/auth/store/auth.slice.ts deleted file mode 100644 index 5c9dbb9fb..000000000 --- a/apps/backoffice/src/app/features/auth/store/auth.slice.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; -import type { AuthState, LoginPayload } from '../types/auth.types'; -import { authStorage } from '../utils/auth-storage'; - -const initialState: AuthState = { - user: null, - token: null, - isAuthenticated: false, -}; - -const authSlice = createSlice({ - name: 'auth', - initialState, - reducers: { - loginSuccess(state, action: PayloadAction) { - state.user = action.payload.user; - state.token = action.payload.token; - state.isAuthenticated = true; - authStorage.setToken(action.payload.token); - authStorage.setRefreshToken(action.payload.refreshToken); - authStorage.setUser(action.payload.user); - }, - logout(state) { - state.user = null; - state.token = null; - state.isAuthenticated = false; - authStorage.clear(); - }, - hydrateAuth(state) { - const token = authStorage.getToken(); - const user = authStorage.getUser(); - if (token && user) { - state.token = token; - state.user = user; - state.isAuthenticated = true; - } - }, - }, -}); - -export const { loginSuccess, logout, hydrateAuth } = authSlice.actions; -export const authReducer = authSlice.reducer; diff --git a/apps/backoffice/src/app/features/auth/store/set-password.slice.ts b/apps/backoffice/src/app/features/auth/store/set-password.slice.ts deleted file mode 100644 index bdcf8d745..000000000 --- a/apps/backoffice/src/app/features/auth/store/set-password.slice.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; - -interface SetPasswordState { - loading: boolean; - success: boolean; - error: string | null; -} - -const initialState: SetPasswordState = { - loading: false, - success: false, - error: null, -}; - -const setPasswordSlice = createSlice({ - name: 'setPassword', - initialState, - reducers: { - setPasswordStart(state) { - state.loading = true; - state.error = null; - state.success = false; - }, - setPasswordSuccess(state) { - state.loading = false; - state.success = true; - }, - setPasswordFailure(state, action: PayloadAction) { - state.loading = false; - state.error = action.payload; - }, - resetSetPassword(state) { - state.loading = false; - state.success = false; - state.error = null; - }, - }, -}); - -export const { setPasswordStart, setPasswordSuccess, setPasswordFailure, resetSetPassword } = - setPasswordSlice.actions; -export const setPasswordReducer = setPasswordSlice.reducer; diff --git a/apps/backoffice/src/app/features/auth/store/signup.slice.ts b/apps/backoffice/src/app/features/auth/store/signup.slice.ts deleted file mode 100644 index 92a5f4294..000000000 --- a/apps/backoffice/src/app/features/auth/store/signup.slice.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; - -interface SignupState { - loading: boolean; - success: boolean; - error: string | null; -} - -const initialState: SignupState = { - loading: false, - success: false, - error: null, -}; - -const signupSlice = createSlice({ - name: 'signup', - initialState, - reducers: { - signupStart(state) { - state.loading = true; - state.error = null; - state.success = false; - }, - signupSuccess(state) { - state.loading = false; - state.success = true; - }, - signupFailure(state, action: PayloadAction) { - state.loading = false; - state.error = action.payload; - }, - resetSignup(state) { - state.loading = false; - state.success = false; - state.error = null; - }, - }, -}); - -export const { signupStart, signupSuccess, signupFailure, resetSignup } = signupSlice.actions; -export const signupReducer = signupSlice.reducer; diff --git a/apps/backoffice/src/app/features/auth/types/auth.types.ts b/apps/backoffice/src/app/features/auth/types/auth.types.ts deleted file mode 100644 index 2f45a66a3..000000000 --- a/apps/backoffice/src/app/features/auth/types/auth.types.ts +++ /dev/null @@ -1,19 +0,0 @@ -export interface AuthUser { - id: string; - email: string; - username: string; - roles: string[]; - permissions: string[]; -} - -export interface AuthState { - user: AuthUser | null; - token: string | null; - isAuthenticated: boolean; -} - -export interface LoginPayload { - user: AuthUser; - token: string; - refreshToken: string; -} diff --git a/apps/backoffice/src/app/features/auth/utils/auth-storage.ts b/apps/backoffice/src/app/features/auth/utils/auth-storage.ts deleted file mode 100644 index dc4aa3010..000000000 --- a/apps/backoffice/src/app/features/auth/utils/auth-storage.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { AuthUser } from '../types/auth.types'; - -const KEYS = { - token: 'ema-backoffice-auth-token', - refreshToken: 'ema-backoffice-refresh-token', - user: 'ema-backoffice-auth-user', -} as const; - -const COOKIE_ATTRS = 'path=/;max-age=86400'; - -function setCookie(name: string, value: string) { - document.cookie = `${name}=${encodeURIComponent(value)};${COOKIE_ATTRS}`; -} - -function removeCookie(name: string) { - document.cookie = `${name}=;path=/;max-age=0`; -} - -export const authStorage = { - getToken: () => localStorage.getItem(KEYS.token) ?? undefined, - setToken: (token: string) => { - localStorage.setItem(KEYS.token, token); - setCookie('auth-token', token); - }, - removeToken: () => { - localStorage.removeItem(KEYS.token); - removeCookie('auth-token'); - }, - - getRefreshToken: () => localStorage.getItem(KEYS.refreshToken) ?? undefined, - setRefreshToken: (token: string) => { - localStorage.setItem(KEYS.refreshToken, token); - setCookie('refresh-token', token); - }, - - getUser: (): AuthUser | null => { - const raw = localStorage.getItem(KEYS.user); - try { - return raw ? (JSON.parse(raw) as AuthUser) : null; - } catch { - return null; - } - }, - setUser: (user: AuthUser) => localStorage.setItem(KEYS.user, JSON.stringify(user)), - - clear: () => { - Object.values(KEYS).forEach((k) => localStorage.removeItem(k)); - removeCookie('auth-token'); - removeCookie('refresh-token'); - }, -}; diff --git a/apps/backoffice/src/app/features/dashboard/pages/DashboardPage.tsx b/apps/backoffice/src/app/features/dashboard/pages/DashboardPage.tsx index 5bf5604c4..cd3b72ccc 100644 --- a/apps/backoffice/src/app/features/dashboard/pages/DashboardPage.tsx +++ b/apps/backoffice/src/app/features/dashboard/pages/DashboardPage.tsx @@ -1,10 +1,11 @@ +import type { ElementType } from 'react'; import { Grid, Paper, Text, Title, Stack, Group } from '@mantine/core'; import { IconBox, IconUsers, IconActivity } from '@tabler/icons-react'; interface StatCardProps { label: string; value: string; - icon: React.ElementType; + icon: ElementType; color: string; } diff --git a/apps/backoffice/src/app/iam/IamProviders.tsx b/apps/backoffice/src/app/iam/IamProviders.tsx index 2fd3ef6e0..2646c545a 100644 --- a/apps/backoffice/src/app/iam/IamProviders.tsx +++ b/apps/backoffice/src/app/iam/IamProviders.tsx @@ -29,3 +29,13 @@ export function IamProviders({ children }: IamProvidersProps) { ); } + +export function AuthProviders({ children }: { children: ReactNode }) { + return ( + + + {children} + + + ); +} diff --git a/apps/backoffice/src/app/layouts/AuthLayout.tsx b/apps/backoffice/src/app/layouts/AuthLayout.tsx index b058d16b7..2004d76b8 100644 --- a/apps/backoffice/src/app/layouts/AuthLayout.tsx +++ b/apps/backoffice/src/app/layouts/AuthLayout.tsx @@ -1,12 +1,5 @@ -import { Center, Box } from '@mantine/core'; import { Outlet } from 'react-router-dom'; export function AuthLayout() { - return ( -
- - - -
- ); + return ; } diff --git a/apps/backoffice/src/app/layouts/components/AppHeader.tsx b/apps/backoffice/src/app/layouts/components/AppHeader.tsx index fd61de307..f9ec47ee3 100644 --- a/apps/backoffice/src/app/layouts/components/AppHeader.tsx +++ b/apps/backoffice/src/app/layouts/components/AppHeader.tsx @@ -1,14 +1,14 @@ import { Group, Text, ActionIcon, Burger } from '@mantine/core'; import { IconLogout } from '@tabler/icons-react'; import { useNavigate } from 'react-router-dom'; -import { useAuth } from '../../features/auth/hooks/useAuth'; +import { useAuthUser } from '@tria-plc/iamui-common'; interface AppHeaderProps { onToggle: () => void; } export function AppHeader({ onToggle }: AppHeaderProps) { - const { logout } = useAuth(); + const { logout } = useAuthUser(); const navigate = useNavigate(); const handleLogout = () => { diff --git a/apps/backoffice/src/app/router/index.tsx b/apps/backoffice/src/app/router/index.tsx index 35bbc46f4..4518adb80 100644 --- a/apps/backoffice/src/app/router/index.tsx +++ b/apps/backoffice/src/app/router/index.tsx @@ -1,5 +1,7 @@ import { createBrowserRouter, RouterProvider, Navigate } from 'react-router-dom'; import { + Login, + SetPasswordPage, UserManagementLayout, UserManagementPage, BulkUploadPage, @@ -11,17 +13,18 @@ import { import { ProtectedRoute } from './ProtectedRoute'; import { BackofficeLayout } from '../layouts/BackofficeLayout'; import { AuthLayout } from '../layouts/AuthLayout'; -import { LoginPage } from '../features/auth/pages/LoginPage'; -import { SignupPage } from '../features/auth/pages/SignupPage'; -import { SetPasswordPage } from '../features/auth/pages/SetPasswordPage'; import { DashboardPage } from '../features/dashboard/pages/DashboardPage'; import { ItemPage } from '../features/item/pages/ItemPage'; import { AuditLogPageWrapper } from '../iam/pages/AuditLogPage'; -import { IamProviders } from '../iam/IamProviders'; +import { IamProviders, AuthProviders } from '../iam/IamProviders'; const router = createBrowserRouter([ { - element: , + element: ( + + + + ), children: [ { element: , @@ -51,11 +54,14 @@ const router = createBrowserRouter([ ], }, { - element: , + element: ( + + + + ), children: [ - { path: '/login', element: }, - { path: '/signup', element: }, - { path: '/set-password', element: }, + { path: '/login', element: }, + { path: '/otp-verify', element: }, ], }, { path: '/404', element:
Page not found
}, diff --git a/apps/backoffice/src/app/store/index.ts b/apps/backoffice/src/app/store/index.ts index bed66a931..4a2291664 100644 --- a/apps/backoffice/src/app/store/index.ts +++ b/apps/backoffice/src/app/store/index.ts @@ -1,14 +1,8 @@ import { configureStore } from '@reduxjs/toolkit'; import { baseApi } from '@ema-platform/api'; -import { authReducer } from '../features/auth/store/auth.slice'; -import { signupReducer } from '../features/auth/store/signup.slice'; -import { setPasswordReducer } from '../features/auth/store/set-password.slice'; export const store = configureStore({ reducer: { - auth: authReducer, - signup: signupReducer, - setPassword: setPasswordReducer, [baseApi.reducerPath]: baseApi.reducer, }, middleware: (getDefaultMiddleware) => diff --git a/apps/backoffice/src/main.tsx b/apps/backoffice/src/main.tsx index 7d9ec4e29..1d967ab5b 100644 --- a/apps/backoffice/src/main.tsx +++ b/apps/backoffice/src/main.tsx @@ -7,6 +7,42 @@ import '@tria-plc/iamui-common/styles.css'; import './styles.css'; import { App } from './app/app'; +window.__USER_MANAGEMENT_BRANDING__ = { + appName: 'Ethiopian Maritime Licence', + organizationName: 'Ethiopian Maritime Authority', + logoSrc: '/assets/emaLogo.jpg', + logoAlt: 'EMA Logo', + homePath: '/', + moduleBasePath: '/user-management', + backToAppPath: '/dashboard', + backToAppLabel: 'Back to dashboard', + cssVariables: { + '--primary': '#2563eb', + '--primary-foreground': 'oklch(0.99 0 0)', + '--secondary': '#1d4ed8', + '--secondary-foreground': 'oklch(0.99 0 0)', + '--accent': '#60a5fa', + '--accent-foreground': 'oklch(0.99 0 0)', + '--ring': '#2563eb', + '--sidebar': 'oklch(0.21 0.04 265)', + '--sidebar-foreground': 'oklch(0.96 0.01 255)', + '--sidebar-primary': '#3b82f6', + '--sidebar-primary-foreground': 'oklch(0.99 0 0)', + '--sidebar-accent': '#60a5fa', + '--sidebar-accent-foreground': 'oklch(0.96 0.01 255)', + '--sidebar-border': 'oklch(0.3 0.04 265)', + '--sidebar-ring': '#3b82f6', + '--brand-shell-bg': '#eff6ff', + '--brand-sidebar-bg': '#0f172a', + '--brand-sidebar-border': '#1e293b', + '--brand-sidebar-muted': '#94a3b8', + '--brand-primary-solid': '#2563eb', + '--brand-primary-hover': '#1d4ed8', + '--brand-hero-from': '#2563eb', + '--brand-hero-to': '#1e40af', + }, +}; + const root = document.getElementById('root'); if (!root) throw new Error('Root element not found'); diff --git a/apps/portal/src/app/components/ErrorBoundary.tsx b/apps/portal/src/app/components/ErrorBoundary.tsx new file mode 100644 index 000000000..d8b892333 --- /dev/null +++ b/apps/portal/src/app/components/ErrorBoundary.tsx @@ -0,0 +1,50 @@ +import { Component } from 'react'; +import type { ReactNode, ErrorInfo } from 'react'; +import { Center, Paper, Title, Text, Button } from '@mantine/core'; + +interface Props { + children: ReactNode; +} + +interface State { + hasError: boolean; + error: Error | null; +} + +export class ErrorBoundary extends Component { + state: State = { hasError: false, error: null }; + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, info: ErrorInfo) { + console.error('ErrorBoundary caught:', error, info); + } + + render() { + if (this.state.hasError) { + return ( +
+ + Something went wrong + + {this.state.error?.message || 'An unexpected error occurred.'} + + + +
+ ); + } + + return this.props.children; + } +} diff --git a/apps/portal/src/app/features/auth/hooks/useAuth.ts b/apps/portal/src/app/features/auth/hooks/useAuth.ts deleted file mode 100644 index 260f46bd0..000000000 --- a/apps/portal/src/app/features/auth/hooks/useAuth.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { useAppDispatch, useAppSelector } from '../../../store/hooks'; -import { loginSuccess, logout as logoutAction, hydrateAuth } from '../store/auth.slice'; -import type { LoginPayload } from '../types/auth.types'; - -export function useAuth() { - const dispatch = useAppDispatch(); - const { user, token, isAuthenticated } = useAppSelector((s) => s.auth); - - return { - user, - token, - isAuthenticated, - login: (p: LoginPayload) => dispatch(loginSuccess(p)), - logout: () => dispatch(logoutAction()), - hydrate: () => dispatch(hydrateAuth()), - }; -} diff --git a/apps/portal/src/app/features/auth/pages/LoginPage.tsx b/apps/portal/src/app/features/auth/pages/LoginPage.tsx index f0153f2ef..5419d9d1e 100644 --- a/apps/portal/src/app/features/auth/pages/LoginPage.tsx +++ b/apps/portal/src/app/features/auth/pages/LoginPage.tsx @@ -14,24 +14,25 @@ import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { useNavigate, Link } from 'react-router-dom'; -import { useAuth } from '../hooks/useAuth'; +import { useAppDispatch } from '../../../store/hooks'; +import { loginSuccess, setUser } from '../store/auth.slice'; +import type { LoginPayload, AuthUser } from '../types/auth.types'; +import { useApiMutation } from '@ema-platform/api'; import { notify } from '@ema-platform/ui'; -const BASE_API_URL = - (import.meta as { env?: Record }).env?.['VITE_BASE_API_URL'] ?? - 'http://localhost:3001/api'; - const schema = z.object({ - email: z.string().email('Enter a valid email'), - password: z.string().min(0, '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; export function LoginPage() { const navigate = useNavigate(); - const { login } = useAuth(); + const dispatch = useAppDispatch(); const [isLoading, setIsLoading] = useState(false); + const [loginTrigger] = useApiMutation(); + const [meTrigger] = useApiMutation(); const { register, @@ -44,15 +45,26 @@ export function LoginPage() { const onSubmit = async (values: FormValues) => { setIsLoading(true); try { - const res = await fetch(`${BASE_API_URL}/auth/login`, { + const data = await loginTrigger({ + url: '/auth/login', method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(values), - }); - if (!res.ok) throw new Error('Login failed'); - const data = (await res.json()) as Parameters[0]; - login(data); - navigate('/dashboard'); + body: values, + }).unwrap(); + dispatch(loginSuccess(data)); + + const me = await meTrigger({ + url: '/auth/me', + method: 'GET', + }).unwrap(); + dispatch(setUser(me)); + + if (me.status === 'accepted') { + navigate('/dashboard'); + } else { + navigate('/otp-verify', { + state: { email: me.email, phoneNumber: me.phoneNumber }, + }); + } } catch { notify.error('Invalid email or password'); } finally { diff --git a/apps/portal/src/app/features/auth/pages/OTPVerificationPage.tsx b/apps/portal/src/app/features/auth/pages/OTPVerificationPage.tsx new file mode 100644 index 000000000..c786d40fe --- /dev/null +++ b/apps/portal/src/app/features/auth/pages/OTPVerificationPage.tsx @@ -0,0 +1,109 @@ +import { + Paper, + TextInput, + Button, + Stack, + Title, + Center, + Text, +} from '@mantine/core'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { useNavigate, useLocation } from 'react-router-dom'; +import { useApiMutation } from '@ema-platform/api'; +import { notify } from '@ema-platform/ui'; + +const schema = z.object({ + verificationCode: z.string().min(1, { message: 'Verification code is required' }), +}); + +type FormValues = z.infer; + +export function OTPVerificationPage() { + const navigate = useNavigate(); + const location = useLocation(); + const state = location.state as + | { email?: string; phoneNumber?: string } + | null; + const email = state?.email ?? ''; + const phoneNumber = state?.phoneNumber ?? ''; + + const [verifyTrigger, { isLoading: loading }] = useApiMutation(); + const [resendTrigger, { isLoading: resending }] = useApiMutation(); + + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(schema), + }); + + const onSubmit = async (values: FormValues) => { + try { + await verifyTrigger({ + url: '/auth/verify-phone-number', + method: 'PATCH', + body: { email, phoneNumber, verificationCode: values.verificationCode }, + }).unwrap(); + + notify.success('Phone number verified successfully'); + navigate('/dashboard'); + } catch (err) { + const msg = err instanceof Error ? err.message : 'Something went wrong'; + notify.error(msg); + } + }; + + const handleResendOtp = async () => { + try { + await resendTrigger({ + url: '/auth/resend-otp', + method: 'POST', + body: { email }, + }).unwrap(); + + notify.success('Verification code resent to your email'); + } catch (err) { + const msg = err instanceof Error ? err.message : 'Something went wrong'; + notify.error(msg); + } + }; + + return ( +
+ + + Verify your email + + A verification code has been sent to {email || 'your email'}. Enter it + below to complete your registration. + +
+ + + + +
+ +
+
+
+ ); +} diff --git a/apps/portal/src/app/features/auth/pages/SetPasswordPage.tsx b/apps/portal/src/app/features/auth/pages/SetPasswordPage.tsx deleted file mode 100644 index eb2837420..000000000 --- a/apps/portal/src/app/features/auth/pages/SetPasswordPage.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import { - Paper, - TextInput, - PasswordInput, - Button, - Stack, - Title, - Center, - Text, -} from '@mantine/core'; -import { useForm } from 'react-hook-form'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { z } from 'zod'; -import { useNavigate, useLocation } from 'react-router-dom'; -import { useAppDispatch, useAppSelector } from '../../../store/hooks'; -import { - setPasswordStart, - setPasswordSuccess, - setPasswordFailure, -} from '../store/set-password.slice'; -import { notify } from '@ema-platform/ui'; - -const BASE_API_URL = - (import.meta as { env?: Record }).env?.['VITE_BASE_API_URL'] ?? - 'http://localhost:3001/api'; - -const schema = z - .object({ - userId: z.string().min(1, 'User ID is required'), - email: z.string().email('Enter a valid email'), - verificationCode: z.string().min(1, 'Verification code is required'), - newPassword: z.string().min(6, 'Password must be at least 6 characters'), - confirmPassword: z.string().min(6, 'Confirm your password'), - }) - .refine((data) => data.newPassword === data.confirmPassword, { - message: 'Passwords do not match', - path: ['confirmPassword'], - }); - -type FormValues = z.infer; - -export function SetPasswordPage() { - const navigate = useNavigate(); - const dispatch = useAppDispatch(); - const location = useLocation(); - const { loading } = useAppSelector((s) => s.setPassword); - const signupEmail = (location.state as { email?: string } | null)?.email ?? ''; - - const { - register, - handleSubmit, - formState: { errors }, - } = useForm({ - resolver: zodResolver(schema), - defaultValues: { email: signupEmail }, - }); - - const onSubmit = async (values: FormValues) => { - dispatch(setPasswordStart()); - try { - const res = await fetch(`${BASE_API_URL}/auth/set-password`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - userId: values.userId, - email: values.email, - verificationCode: values.verificationCode, - newPassword: values.newPassword, - confirmPassword: values.confirmPassword, - }), - }); - - if (!res.ok) { - const errorData = await res.json().catch(() => null); - throw new Error(errorData?.message ?? 'Failed to set password'); - } - - dispatch(setPasswordSuccess()); - notify.success('Password set successfully'); - navigate('/dashboard'); - } catch (err) { - const msg = err instanceof Error ? err.message : 'Something went wrong'; - dispatch(setPasswordFailure(msg)); - notify.error(msg); - } - }; - - return ( -
- - - Set your password - - A verification code has been sent to your email. Enter it below along - with your new password to complete registration. - -
- - - - - - - - -
-
-
-
- ); -} diff --git a/apps/portal/src/app/features/auth/pages/SignupPage.tsx b/apps/portal/src/app/features/auth/pages/SignupPage.tsx index 2a277e457..5edd9cbac 100644 --- a/apps/portal/src/app/features/auth/pages/SignupPage.tsx +++ b/apps/portal/src/app/features/auth/pages/SignupPage.tsx @@ -1,6 +1,7 @@ import { Paper, TextInput, + PasswordInput, Button, Stack, Title, @@ -12,23 +13,26 @@ import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { useNavigate, Link } from 'react-router-dom'; -import { useAppDispatch, useAppSelector } from '../../../store/hooks'; -import { signupStart, signupSuccess, signupFailure } from '../store/signup.slice'; -import { hydrateAuth } from '../store/auth.slice'; -import { authStorage } from '../utils/auth-storage'; +import { useAppDispatch } from '../../../store/hooks'; +import { loginSuccess } from '../store/auth.slice'; +import { useApiMutation } from '@ema-platform/api'; import { notify } from '@ema-platform/ui'; -const BASE_API_URL = - (import.meta as { env?: Record }).env?.['VITE_BASE_API_URL'] ?? - 'http://localhost:3001/api'; - -const schema = z.object({ - email: z.string().email('Enter a valid email'), - username: z.string().min(3, 'Username must be at least 3 characters'), - phoneNumber: z.string().min(1, 'Phone number is required'), - userType: z.string().min(1, 'User type is required'), - name: z.string().min(1, 'Name is required'), -}); +const schema = z + .object({ + email: z.string().email(), + username: z.string().min(3, { message: 'Username must be at least 3 characters' }), + phoneNumber: z.string().min(1, { message: 'Phone number is required' }), + userType: z.literal('individual'), + nameEn: z.string().min(1, { message: 'Name (English) is required' }), + nameAm: z.string().optional(), + password: z.string().min(8, { message: 'Password must be at least 8 characters' }), + confirmPassword: z.string().min(8, { message: 'Confirm your password' }), + }) + .refine((data) => data.password === data.confirmPassword, { + message: 'Passwords do not match', + path: ['confirmPassword'], + }); type FormValues = z.infer; @@ -41,12 +45,18 @@ interface SignupPayload { am: string; en: string; }; + password: string; + confirmPassword: string; } export function SignupPage() { const navigate = useNavigate(); const dispatch = useAppDispatch(); - const { loading } = useAppSelector((s) => s.signup); + const [signupTrigger, { isLoading: loading }] = useApiMutation<{ + token: string; + refreshToken: string; + isPhoneNumberVerified: boolean; + }>(); const { register, @@ -54,44 +64,40 @@ export function SignupPage() { formState: { errors }, } = useForm({ resolver: zodResolver(schema), + defaultValues: { userType: 'individual' }, }); const onSubmit = async (values: FormValues) => { - dispatch(signupStart()); try { const payload: SignupPayload = { email: values.email, username: values.username, phoneNumber: values.phoneNumber, userType: values.userType, - name: { - am: '', - en: values.name, - }, + name: { en: values.nameEn, am: values.nameAm ?? '' }, + password: values.password, + confirmPassword: values.confirmPassword, }; - const res = await fetch(`${BASE_API_URL}/auth/signup`, { + const data = await signupTrigger({ + url: '/auth/signup-with-pwd', method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), + body: payload, + }).unwrap(); + + dispatch( + loginSuccess({ + token: data.token, + refreshToken: data.refreshToken, + isPhoneNumberVerified: data.isPhoneNumberVerified, + }), + ); + + navigate('/otp-verify', { + state: { email: values.email, phoneNumber: values.phoneNumber }, }); - - if (!res.ok) { - const errorData = await res.json().catch(() => null); - throw new Error(errorData?.message ?? 'Signup failed'); - } - - const data = (await res.json()) as { token: string; refreshToken: string }; - - authStorage.setToken(data.token); - authStorage.setRefreshToken(data.refreshToken); - dispatch(hydrateAuth()); - dispatch(signupSuccess()); - - navigate('/set-password', { state: { email: values.email } }); } catch (err) { const msg = err instanceof Error ? err.message : 'Something went wrong'; - dispatch(signupFailure(msg)); notify.error(msg); } }; @@ -104,10 +110,16 @@ export function SignupPage() {
+ - +