mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-09 04:48:19 +00:00
Merge branch 'feature/authentication' into feature/scalfolding
This commit is contained in:
68
.github/workflows/deploy.yml
vendored
Normal file
68
.github/workflows/deploy.yml
vendored
Normal file
@@ -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
|
||||||
22
.gitignore
vendored
22
.gitignore
vendored
@@ -1,7 +1,23 @@
|
|||||||
|
# dependencies
|
||||||
node_modules/
|
node_modules/
|
||||||
.nx
|
|
||||||
dist/
|
# build output
|
||||||
|
**/dist/
|
||||||
|
.next/
|
||||||
|
.nx/
|
||||||
|
coverage/
|
||||||
|
*.tsbuildinfo
|
||||||
|
**/*.tsbuildinfo
|
||||||
|
|
||||||
|
# env
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
|
||||||
|
# logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# OS/editor
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
.npmrc
|
||||||
|
|||||||
2
.npmrc
Normal file
2
.npmrc
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
@tria-plc:registry=https://npm.pkg.github.com
|
||||||
|
//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
FROM node:24-alpine AS deps
|
FROM node:24-alpine AS deps
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json package-lock.json* ./
|
COPY package.json package-lock.json* ./
|
||||||
RUN npm ci
|
RUN npm install --legacy-peer-deps
|
||||||
|
|
||||||
FROM deps AS base
|
FROM deps AS base
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|||||||
BIN
apps/backoffice/public/assets/emaLogo.jpg
Normal file
BIN
apps/backoffice/public/assets/emaLogo.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 89 KiB |
@@ -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<LoginPayload, LoginArgs>({
|
|
||||||
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;
|
|
||||||
@@ -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<typeof schema>;
|
|
||||||
|
|
||||||
export function LoginForm() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { login } = useAuth();
|
|
||||||
const [loginMutate, { isLoading }] = useLoginMutation();
|
|
||||||
|
|
||||||
const {
|
|
||||||
register,
|
|
||||||
handleSubmit,
|
|
||||||
formState: { errors },
|
|
||||||
} = useForm<FormValues>({
|
|
||||||
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 (
|
|
||||||
<Paper p="xl" shadow="md" radius="md">
|
|
||||||
<Stack gap="lg">
|
|
||||||
<Stack gap={4}>
|
|
||||||
<Title order={2}>Sign in</Title>
|
|
||||||
<Text c="dimmed" size="sm">
|
|
||||||
Enter your credentials to access the backoffice
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
|
||||||
<Stack gap="md">
|
|
||||||
<TextInput
|
|
||||||
label="Email"
|
|
||||||
placeholder="you@example.com"
|
|
||||||
error={errors.email?.message}
|
|
||||||
{...register('email')}
|
|
||||||
/>
|
|
||||||
<PasswordInput
|
|
||||||
label="Password"
|
|
||||||
placeholder="Your password"
|
|
||||||
error={errors.password?.message}
|
|
||||||
{...register('password')}
|
|
||||||
/>
|
|
||||||
<Button type="submit" loading={isLoading} fullWidth mt="sm">
|
|
||||||
Sign in
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</form>
|
|
||||||
<Text ta="center" size="sm" c="dimmed">
|
|
||||||
Don't have an account?{' '}
|
|
||||||
<Anchor component={Link} to="/signup">
|
|
||||||
Sign up
|
|
||||||
</Anchor>
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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()),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { LoginForm } from '../components/LoginForm';
|
|
||||||
|
|
||||||
export function LoginPage() {
|
|
||||||
return <LoginForm />;
|
|
||||||
}
|
|
||||||
@@ -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<string, string> }).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<typeof schema>;
|
|
||||||
|
|
||||||
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<FormValues>({
|
|
||||||
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 (
|
|
||||||
<Paper p="xl" shadow="md" radius="md">
|
|
||||||
<Stack gap="lg">
|
|
||||||
<Stack gap={4}>
|
|
||||||
<Title order={2}>Set your password</Title>
|
|
||||||
<Text c="dimmed" size="sm">
|
|
||||||
A verification code has been sent to your email. Enter it below along
|
|
||||||
with your new password to complete registration.
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
|
||||||
<Stack gap="md">
|
|
||||||
<TextInput
|
|
||||||
label="User ID"
|
|
||||||
placeholder="Enter your user ID"
|
|
||||||
error={errors.userId?.message}
|
|
||||||
{...register('userId')}
|
|
||||||
/>
|
|
||||||
<TextInput
|
|
||||||
label="Email"
|
|
||||||
placeholder="you@example.com"
|
|
||||||
error={errors.email?.message}
|
|
||||||
{...register('email')}
|
|
||||||
/>
|
|
||||||
<TextInput
|
|
||||||
label="Verification Code"
|
|
||||||
placeholder="Enter the code from your email"
|
|
||||||
error={errors.verificationCode?.message}
|
|
||||||
{...register('verificationCode')}
|
|
||||||
/>
|
|
||||||
<PasswordInput
|
|
||||||
label="New Password"
|
|
||||||
placeholder="Enter a new password"
|
|
||||||
error={errors.newPassword?.message}
|
|
||||||
{...register('newPassword')}
|
|
||||||
/>
|
|
||||||
<PasswordInput
|
|
||||||
label="Confirm Password"
|
|
||||||
placeholder="Confirm your new password"
|
|
||||||
error={errors.confirmPassword?.message}
|
|
||||||
{...register('confirmPassword')}
|
|
||||||
/>
|
|
||||||
<Button type="submit" loading={loading} fullWidth mt="sm">
|
|
||||||
Set Password
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</form>
|
|
||||||
</Stack>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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<string, string> }).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<typeof schema>;
|
|
||||||
|
|
||||||
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<FormValues>({
|
|
||||||
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 (
|
|
||||||
<Paper p="xl" shadow="md" radius="md">
|
|
||||||
<Stack gap="lg">
|
|
||||||
<Stack gap={4}>
|
|
||||||
<Title order={2}>Create an account</Title>
|
|
||||||
<Text c="dimmed" size="sm">
|
|
||||||
Fill in your details to get started
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
|
||||||
<Stack gap="md">
|
|
||||||
<TextInput
|
|
||||||
label="Full Name"
|
|
||||||
placeholder="Your full name"
|
|
||||||
error={errors.name?.message}
|
|
||||||
{...register('name')}
|
|
||||||
/>
|
|
||||||
<TextInput
|
|
||||||
label="Email"
|
|
||||||
placeholder="you@example.com"
|
|
||||||
error={errors.email?.message}
|
|
||||||
{...register('email')}
|
|
||||||
/>
|
|
||||||
<TextInput
|
|
||||||
label="Username"
|
|
||||||
placeholder="Choose a username"
|
|
||||||
error={errors.username?.message}
|
|
||||||
{...register('username')}
|
|
||||||
/>
|
|
||||||
<TextInput
|
|
||||||
label="Phone Number"
|
|
||||||
placeholder="+251 911 234 567"
|
|
||||||
error={errors.phoneNumber?.message}
|
|
||||||
{...register('phoneNumber')}
|
|
||||||
/>
|
|
||||||
<TextInput
|
|
||||||
label="User Type"
|
|
||||||
placeholder="e.g. admin, manager"
|
|
||||||
error={errors.userType?.message}
|
|
||||||
{...register('userType')}
|
|
||||||
/>
|
|
||||||
<Button type="submit" loading={loading} fullWidth mt="sm">
|
|
||||||
Sign up
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</form>
|
|
||||||
<Text ta="center" size="sm" c="dimmed">
|
|
||||||
Already have an account?{' '}
|
|
||||||
<Anchor component={Link} to="/login">
|
|
||||||
Sign in
|
|
||||||
</Anchor>
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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<LoginPayload>) {
|
|
||||||
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;
|
|
||||||
@@ -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<string>) {
|
|
||||||
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;
|
|
||||||
@@ -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<string>) {
|
|
||||||
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;
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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');
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
|
import type { ElementType } from 'react';
|
||||||
import { Grid, Paper, Text, Title, Stack, Group } from '@mantine/core';
|
import { Grid, Paper, Text, Title, Stack, Group } from '@mantine/core';
|
||||||
import { IconBox, IconUsers, IconActivity } from '@tabler/icons-react';
|
import { IconBox, IconUsers, IconActivity } from '@tabler/icons-react';
|
||||||
|
|
||||||
interface StatCardProps {
|
interface StatCardProps {
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
icon: React.ElementType;
|
icon: ElementType;
|
||||||
color: string;
|
color: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,3 +29,13 @@ export function IamProviders({ children }: IamProvidersProps) {
|
|||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function AuthProviders({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
<BrandingProvider>
|
||||||
|
{children}
|
||||||
|
</BrandingProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,5 @@
|
|||||||
import { Center, Box } from '@mantine/core';
|
|
||||||
import { Outlet } from 'react-router-dom';
|
import { Outlet } from 'react-router-dom';
|
||||||
|
|
||||||
export function AuthLayout() {
|
export function AuthLayout() {
|
||||||
return (
|
return <Outlet />;
|
||||||
<Center h="100vh" bg="gray.0">
|
|
||||||
<Box w={420}>
|
|
||||||
<Outlet />
|
|
||||||
</Box>
|
|
||||||
</Center>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { Group, Text, ActionIcon, Burger } from '@mantine/core';
|
import { Group, Text, ActionIcon, Burger } from '@mantine/core';
|
||||||
import { IconLogout } from '@tabler/icons-react';
|
import { IconLogout } from '@tabler/icons-react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../../features/auth/hooks/useAuth';
|
import { useAuthUser } from '@tria-plc/iamui-common';
|
||||||
|
|
||||||
interface AppHeaderProps {
|
interface AppHeaderProps {
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppHeader({ onToggle }: AppHeaderProps) {
|
export function AppHeader({ onToggle }: AppHeaderProps) {
|
||||||
const { logout } = useAuth();
|
const { logout } = useAuthUser();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { createBrowserRouter, RouterProvider, Navigate } from 'react-router-dom';
|
import { createBrowserRouter, RouterProvider, Navigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
|
Login,
|
||||||
|
SetPasswordPage,
|
||||||
UserManagementLayout,
|
UserManagementLayout,
|
||||||
UserManagementPage,
|
UserManagementPage,
|
||||||
BulkUploadPage,
|
BulkUploadPage,
|
||||||
@@ -11,17 +13,18 @@ import {
|
|||||||
import { ProtectedRoute } from './ProtectedRoute';
|
import { ProtectedRoute } from './ProtectedRoute';
|
||||||
import { BackofficeLayout } from '../layouts/BackofficeLayout';
|
import { BackofficeLayout } from '../layouts/BackofficeLayout';
|
||||||
import { AuthLayout } from '../layouts/AuthLayout';
|
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 { DashboardPage } from '../features/dashboard/pages/DashboardPage';
|
||||||
import { ItemPage } from '../features/item/pages/ItemPage';
|
import { ItemPage } from '../features/item/pages/ItemPage';
|
||||||
import { AuditLogPageWrapper } from '../iam/pages/AuditLogPage';
|
import { AuditLogPageWrapper } from '../iam/pages/AuditLogPage';
|
||||||
import { IamProviders } from '../iam/IamProviders';
|
import { IamProviders, AuthProviders } from '../iam/IamProviders';
|
||||||
|
|
||||||
const router = createBrowserRouter([
|
const router = createBrowserRouter([
|
||||||
{
|
{
|
||||||
element: <ProtectedRoute />,
|
element: (
|
||||||
|
<AuthProviders>
|
||||||
|
<ProtectedRoute />
|
||||||
|
</AuthProviders>
|
||||||
|
),
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
element: <BackofficeLayout />,
|
element: <BackofficeLayout />,
|
||||||
@@ -51,11 +54,14 @@ const router = createBrowserRouter([
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
element: <AuthLayout />,
|
element: (
|
||||||
|
<AuthProviders>
|
||||||
|
<AuthLayout />
|
||||||
|
</AuthProviders>
|
||||||
|
),
|
||||||
children: [
|
children: [
|
||||||
{ path: '/login', element: <LoginPage /> },
|
{ path: '/login', element: <Login /> },
|
||||||
{ path: '/signup', element: <SignupPage /> },
|
{ path: '/otp-verify', element: <SetPasswordPage /> },
|
||||||
{ path: '/set-password', element: <SetPasswordPage /> },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ path: '/404', element: <div>Page not found</div> },
|
{ path: '/404', element: <div>Page not found</div> },
|
||||||
|
|||||||
@@ -1,14 +1,8 @@
|
|||||||
import { configureStore } from '@reduxjs/toolkit';
|
import { configureStore } from '@reduxjs/toolkit';
|
||||||
import { baseApi } from '@ema-platform/api';
|
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({
|
export const store = configureStore({
|
||||||
reducer: {
|
reducer: {
|
||||||
auth: authReducer,
|
|
||||||
signup: signupReducer,
|
|
||||||
setPassword: setPasswordReducer,
|
|
||||||
[baseApi.reducerPath]: baseApi.reducer,
|
[baseApi.reducerPath]: baseApi.reducer,
|
||||||
},
|
},
|
||||||
middleware: (getDefaultMiddleware) =>
|
middleware: (getDefaultMiddleware) =>
|
||||||
|
|||||||
@@ -7,6 +7,42 @@ import '@tria-plc/iamui-common/styles.css';
|
|||||||
import './styles.css';
|
import './styles.css';
|
||||||
import { App } from './app/app';
|
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');
|
const root = document.getElementById('root');
|
||||||
if (!root) throw new Error('Root element not found');
|
if (!root) throw new Error('Root element not found');
|
||||||
|
|
||||||
|
|||||||
50
apps/portal/src/app/components/ErrorBoundary.tsx
Normal file
50
apps/portal/src/app/components/ErrorBoundary.tsx
Normal file
@@ -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<Props, State> {
|
||||||
|
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 (
|
||||||
|
<Center h="100vh">
|
||||||
|
<Paper p="xl" shadow="md" radius="md" w={400}>
|
||||||
|
<Title order={3} mb="sm">Something went wrong</Title>
|
||||||
|
<Text c="dimmed" size="sm" mb="lg">
|
||||||
|
{this.state.error?.message || 'An unexpected error occurred.'}
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
onClick={() => {
|
||||||
|
this.setState({ hasError: false, error: null });
|
||||||
|
window.location.href = '/';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reload page
|
||||||
|
</Button>
|
||||||
|
</Paper>
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -14,24 +14,25 @@ import { useForm } from 'react-hook-form';
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { useNavigate, Link } from 'react-router-dom';
|
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';
|
import { notify } from '@ema-platform/ui';
|
||||||
|
|
||||||
const BASE_API_URL =
|
|
||||||
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
|
|
||||||
'http://localhost:3001/api';
|
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
email: z.string().email('Enter a valid email'),
|
email: z.string().email({ message: 'Enter a valid email' }),
|
||||||
password: z.string().min(0, 'Password must be at least 6 characters'),
|
password: z.string().min(5, { message: 'Password must be at least 6 characters' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
type FormValues = z.infer<typeof schema>;
|
type FormValues = z.infer<typeof schema>;
|
||||||
|
|
||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { login } = useAuth();
|
const dispatch = useAppDispatch();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [loginTrigger] = useApiMutation<LoginPayload>();
|
||||||
|
const [meTrigger] = useApiMutation<AuthUser>();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
@@ -44,15 +45,26 @@ export function LoginPage() {
|
|||||||
const onSubmit = async (values: FormValues) => {
|
const onSubmit = async (values: FormValues) => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${BASE_API_URL}/auth/login`, {
|
const data = await loginTrigger({
|
||||||
|
url: '/auth/login',
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
body: values,
|
||||||
body: JSON.stringify(values),
|
}).unwrap();
|
||||||
});
|
dispatch(loginSuccess(data));
|
||||||
if (!res.ok) throw new Error('Login failed');
|
|
||||||
const data = (await res.json()) as Parameters<typeof login>[0];
|
const me = await meTrigger({
|
||||||
login(data);
|
url: '/auth/me',
|
||||||
navigate('/dashboard');
|
method: 'GET',
|
||||||
|
}).unwrap();
|
||||||
|
dispatch(setUser(me));
|
||||||
|
|
||||||
|
if (me.status === 'accepted') {
|
||||||
|
navigate('/dashboard');
|
||||||
|
} else {
|
||||||
|
navigate('/otp-verify', {
|
||||||
|
state: { email: me.email, phoneNumber: me.phoneNumber },
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
notify.error('Invalid email or password');
|
notify.error('Invalid email or password');
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
109
apps/portal/src/app/features/auth/pages/OTPVerificationPage.tsx
Normal file
109
apps/portal/src/app/features/auth/pages/OTPVerificationPage.tsx
Normal file
@@ -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<typeof schema>;
|
||||||
|
|
||||||
|
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<FormValues>({
|
||||||
|
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 (
|
||||||
|
<Center h="100vh">
|
||||||
|
<Paper p="xl" shadow="md" radius="md" w={400}>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Title order={3}>Verify your email</Title>
|
||||||
|
<Text c="dimmed" size="sm">
|
||||||
|
A verification code has been sent to {email || 'your email'}. Enter it
|
||||||
|
below to complete your registration.
|
||||||
|
</Text>
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<Stack gap="sm">
|
||||||
|
<TextInput
|
||||||
|
label="Verification Code"
|
||||||
|
placeholder="Enter the code from your email"
|
||||||
|
error={errors.verificationCode?.message}
|
||||||
|
{...register('verificationCode')}
|
||||||
|
/>
|
||||||
|
<Button type="submit" loading={loading} fullWidth mt="sm">
|
||||||
|
Verify
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
size="sm"
|
||||||
|
loading={resending}
|
||||||
|
onClick={handleResendOtp}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
Send OTP again
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string, string> }).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<typeof schema>;
|
|
||||||
|
|
||||||
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<FormValues>({
|
|
||||||
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 (
|
|
||||||
<Center h="100vh">
|
|
||||||
<Paper p="xl" shadow="md" radius="md" w={400}>
|
|
||||||
<Stack gap="md">
|
|
||||||
<Title order={3}>Set your password</Title>
|
|
||||||
<Text c="dimmed" size="sm">
|
|
||||||
A verification code has been sent to your email. Enter it below along
|
|
||||||
with your new password to complete registration.
|
|
||||||
</Text>
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
|
||||||
<Stack gap="sm">
|
|
||||||
<TextInput
|
|
||||||
label="User ID"
|
|
||||||
placeholder="Enter your user ID"
|
|
||||||
error={errors.userId?.message}
|
|
||||||
{...register('userId')}
|
|
||||||
/>
|
|
||||||
<TextInput
|
|
||||||
label="Email"
|
|
||||||
placeholder="you@example.com"
|
|
||||||
error={errors.email?.message}
|
|
||||||
{...register('email')}
|
|
||||||
/>
|
|
||||||
<TextInput
|
|
||||||
label="Verification Code"
|
|
||||||
placeholder="Enter the code from your email"
|
|
||||||
error={errors.verificationCode?.message}
|
|
||||||
{...register('verificationCode')}
|
|
||||||
/>
|
|
||||||
<PasswordInput
|
|
||||||
label="New Password"
|
|
||||||
placeholder="Enter a new password"
|
|
||||||
error={errors.newPassword?.message}
|
|
||||||
{...register('newPassword')}
|
|
||||||
/>
|
|
||||||
<PasswordInput
|
|
||||||
label="Confirm Password"
|
|
||||||
placeholder="Confirm your new password"
|
|
||||||
error={errors.confirmPassword?.message}
|
|
||||||
{...register('confirmPassword')}
|
|
||||||
/>
|
|
||||||
<Button type="submit" loading={loading} fullWidth mt="sm">
|
|
||||||
Set Password
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</form>
|
|
||||||
</Stack>
|
|
||||||
</Paper>
|
|
||||||
</Center>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
Paper,
|
Paper,
|
||||||
TextInput,
|
TextInput,
|
||||||
|
PasswordInput,
|
||||||
Button,
|
Button,
|
||||||
Stack,
|
Stack,
|
||||||
Title,
|
Title,
|
||||||
@@ -12,23 +13,26 @@ import { useForm } from 'react-hook-form';
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { useNavigate, Link } from 'react-router-dom';
|
import { useNavigate, Link } from 'react-router-dom';
|
||||||
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
|
import { useAppDispatch } from '../../../store/hooks';
|
||||||
import { signupStart, signupSuccess, signupFailure } from '../store/signup.slice';
|
import { loginSuccess } from '../store/auth.slice';
|
||||||
import { hydrateAuth } from '../store/auth.slice';
|
import { useApiMutation } from '@ema-platform/api';
|
||||||
import { authStorage } from '../utils/auth-storage';
|
|
||||||
import { notify } from '@ema-platform/ui';
|
import { notify } from '@ema-platform/ui';
|
||||||
|
|
||||||
const BASE_API_URL =
|
const schema = z
|
||||||
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
|
.object({
|
||||||
'http://localhost:3001/api';
|
email: z.string().email(),
|
||||||
|
username: z.string().min(3, { message: 'Username must be at least 3 characters' }),
|
||||||
const schema = z.object({
|
phoneNumber: z.string().min(1, { message: 'Phone number is required' }),
|
||||||
email: z.string().email('Enter a valid email'),
|
userType: z.literal('individual'),
|
||||||
username: z.string().min(3, 'Username must be at least 3 characters'),
|
nameEn: z.string().min(1, { message: 'Name (English) is required' }),
|
||||||
phoneNumber: z.string().min(1, 'Phone number is required'),
|
nameAm: z.string().optional(),
|
||||||
userType: z.string().min(1, 'User type is required'),
|
password: z.string().min(8, { message: 'Password must be at least 8 characters' }),
|
||||||
name: z.string().min(1, 'Name is required'),
|
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<typeof schema>;
|
type FormValues = z.infer<typeof schema>;
|
||||||
|
|
||||||
@@ -41,12 +45,18 @@ interface SignupPayload {
|
|||||||
am: string;
|
am: string;
|
||||||
en: string;
|
en: string;
|
||||||
};
|
};
|
||||||
|
password: string;
|
||||||
|
confirmPassword: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SignupPage() {
|
export function SignupPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const { loading } = useAppSelector((s) => s.signup);
|
const [signupTrigger, { isLoading: loading }] = useApiMutation<{
|
||||||
|
token: string;
|
||||||
|
refreshToken: string;
|
||||||
|
isPhoneNumberVerified: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
@@ -54,44 +64,40 @@ export function SignupPage() {
|
|||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useForm<FormValues>({
|
} = useForm<FormValues>({
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
|
defaultValues: { userType: 'individual' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = async (values: FormValues) => {
|
const onSubmit = async (values: FormValues) => {
|
||||||
dispatch(signupStart());
|
|
||||||
try {
|
try {
|
||||||
const payload: SignupPayload = {
|
const payload: SignupPayload = {
|
||||||
email: values.email,
|
email: values.email,
|
||||||
username: values.username,
|
username: values.username,
|
||||||
phoneNumber: values.phoneNumber,
|
phoneNumber: values.phoneNumber,
|
||||||
userType: values.userType,
|
userType: values.userType,
|
||||||
name: {
|
name: { en: values.nameEn, am: values.nameAm ?? '' },
|
||||||
am: '',
|
password: values.password,
|
||||||
en: values.name,
|
confirmPassword: values.confirmPassword,
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const res = await fetch(`${BASE_API_URL}/auth/signup`, {
|
const data = await signupTrigger({
|
||||||
|
url: '/auth/signup-with-pwd',
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
body: payload,
|
||||||
body: JSON.stringify(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) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : 'Something went wrong';
|
const msg = err instanceof Error ? err.message : 'Something went wrong';
|
||||||
dispatch(signupFailure(msg));
|
|
||||||
notify.error(msg);
|
notify.error(msg);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -104,10 +110,16 @@ export function SignupPage() {
|
|||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Full Name"
|
label="Name (English)"
|
||||||
placeholder="Your full name"
|
placeholder="Your name in English"
|
||||||
error={errors.name?.message}
|
error={errors.nameEn?.message}
|
||||||
{...register('name')}
|
{...register('nameEn')}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Name (Amharic)"
|
||||||
|
placeholder="Your name in Amharic"
|
||||||
|
error={errors.nameAm?.message}
|
||||||
|
{...register('nameAm')}
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Email"
|
label="Email"
|
||||||
@@ -127,11 +139,17 @@ export function SignupPage() {
|
|||||||
error={errors.phoneNumber?.message}
|
error={errors.phoneNumber?.message}
|
||||||
{...register('phoneNumber')}
|
{...register('phoneNumber')}
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<PasswordInput
|
||||||
label="User Type"
|
label="Password"
|
||||||
placeholder="e.g. customer, admin"
|
placeholder="Enter a password"
|
||||||
error={errors.userType?.message}
|
error={errors.password?.message}
|
||||||
{...register('userType')}
|
{...register('password')}
|
||||||
|
/>
|
||||||
|
<PasswordInput
|
||||||
|
label="Confirm Password"
|
||||||
|
placeholder="Confirm your password"
|
||||||
|
error={errors.confirmPassword?.message}
|
||||||
|
{...register('confirmPassword')}
|
||||||
/>
|
/>
|
||||||
<Button type="submit" loading={loading} fullWidth mt="sm">
|
<Button type="submit" loading={loading} fullWidth mt="sm">
|
||||||
Sign up
|
Sign up
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
|
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
|
||||||
import type { AuthState, LoginPayload } from '../types/auth.types';
|
import type { AuthState, AuthUser, LoginPayload } from '../types/auth.types';
|
||||||
import { authStorage } from '../utils/auth-storage';
|
import { authStorage } from '../utils/auth-storage';
|
||||||
|
|
||||||
const initialState: AuthState = {
|
const initialState: AuthState = {
|
||||||
@@ -13,12 +13,14 @@ const authSlice = createSlice({
|
|||||||
initialState,
|
initialState,
|
||||||
reducers: {
|
reducers: {
|
||||||
loginSuccess(state, action: PayloadAction<LoginPayload>) {
|
loginSuccess(state, action: PayloadAction<LoginPayload>) {
|
||||||
state.user = action.payload.user;
|
|
||||||
state.token = action.payload.token;
|
state.token = action.payload.token;
|
||||||
state.isAuthenticated = true;
|
state.isAuthenticated = true;
|
||||||
authStorage.setToken(action.payload.token);
|
authStorage.setToken(action.payload.token);
|
||||||
authStorage.setRefreshToken(action.payload.refreshToken);
|
authStorage.setRefreshToken(action.payload.refreshToken);
|
||||||
authStorage.setUser(action.payload.user);
|
},
|
||||||
|
setUser(state, action: PayloadAction<AuthUser>) {
|
||||||
|
state.user = action.payload;
|
||||||
|
authStorage.setUser(action.payload);
|
||||||
},
|
},
|
||||||
logout(state) {
|
logout(state) {
|
||||||
state.user = null;
|
state.user = null;
|
||||||
@@ -38,5 +40,5 @@ const authSlice = createSlice({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const { loginSuccess, logout, hydrateAuth } = authSlice.actions;
|
export const { loginSuccess, setUser, logout, hydrateAuth } = authSlice.actions;
|
||||||
export const authReducer = authSlice.reducer;
|
export const authReducer = authSlice.reducer;
|
||||||
|
|||||||
@@ -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<string>) {
|
|
||||||
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;
|
|
||||||
@@ -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<string>) {
|
|
||||||
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;
|
|
||||||
@@ -2,8 +2,16 @@ export interface AuthUser {
|
|||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
username: string;
|
username: string;
|
||||||
roles: string[];
|
phoneNumber: string;
|
||||||
permissions: string[];
|
name: {
|
||||||
|
am: string;
|
||||||
|
en: string;
|
||||||
|
};
|
||||||
|
status: string;
|
||||||
|
sharepointId: string | null;
|
||||||
|
hasSetPassword: boolean;
|
||||||
|
hasFinishedRegistration: boolean;
|
||||||
|
hasFinishedDMSOnboarding: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthState {
|
export interface AuthState {
|
||||||
@@ -13,7 +21,7 @@ export interface AuthState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface LoginPayload {
|
export interface LoginPayload {
|
||||||
user: AuthUser;
|
|
||||||
token: string;
|
token: string;
|
||||||
refreshToken: string;
|
refreshToken: string;
|
||||||
|
isPhoneNumberVerified: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { AuthProvider } from '@tria-plc/iamui-common';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { store } from '../store';
|
import { store } from '../store';
|
||||||
import { MantineThemeProvider } from './MantineThemeProvider';
|
import { MantineThemeProvider } from './MantineThemeProvider';
|
||||||
|
import { ErrorBoundary } from '../components/ErrorBoundary';
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: { queries: { retry: 1, staleTime: 1000 * 60 * 5 } },
|
defaultOptions: { queries: { retry: 1, staleTime: 1000 * 60 * 5 } },
|
||||||
@@ -10,10 +12,14 @@ const queryClient = new QueryClient({
|
|||||||
|
|
||||||
export function AppProviders({ children }: { children: ReactNode }) {
|
export function AppProviders({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<Provider store={store}>
|
<ErrorBoundary>
|
||||||
<QueryClientProvider client={queryClient}>
|
<Provider store={store}>
|
||||||
<MantineThemeProvider>{children}</MantineThemeProvider>
|
<QueryClientProvider client={queryClient}>
|
||||||
</QueryClientProvider>
|
<AuthProvider>
|
||||||
</Provider>
|
<MantineThemeProvider>{children}</MantineThemeProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</Provider>
|
||||||
|
</ErrorBoundary>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,16 @@ import { authReducer } from '../features/auth/store/auth.slice';
|
|||||||
import { signupReducer } from '../features/auth/store/signup.slice';
|
import { signupReducer } from '../features/auth/store/signup.slice';
|
||||||
import { setPasswordReducer } from '../features/auth/store/set-password.slice';
|
import { setPasswordReducer } from '../features/auth/store/set-password.slice';
|
||||||
import { licensesReducer } from '../features/licenses/store/licenses.slice';
|
import { licensesReducer } from '../features/licenses/store/licenses.slice';
|
||||||
|
import { authStorage } from '../features/auth/utils/auth-storage';
|
||||||
|
|
||||||
|
const preloadedAuth = (() => {
|
||||||
|
const token = authStorage.getToken();
|
||||||
|
const user = authStorage.getUser();
|
||||||
|
if (token && user) {
|
||||||
|
return { token, user, isAuthenticated: true };
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
})();
|
||||||
|
|
||||||
export const store = configureStore({
|
export const store = configureStore({
|
||||||
reducer: {
|
reducer: {
|
||||||
@@ -13,6 +23,7 @@ export const store = configureStore({
|
|||||||
licenses: licensesReducer,
|
licenses: licensesReducer,
|
||||||
[baseApi.reducerPath]: baseApi.reducer,
|
[baseApi.reducerPath]: baseApi.reducer,
|
||||||
},
|
},
|
||||||
|
preloadedState: preloadedAuth ? { auth: preloadedAuth } : undefined,
|
||||||
middleware: (getDefaultMiddleware) =>
|
middleware: (getDefaultMiddleware) =>
|
||||||
getDefaultMiddleware().concat(baseApi.middleware),
|
getDefaultMiddleware().concat(baseApi.middleware),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,19 +1,28 @@
|
|||||||
version: "3.9"
|
|
||||||
services:
|
services:
|
||||||
portal:
|
ema-portal:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
target: portal
|
target: portal
|
||||||
|
secrets:
|
||||||
|
- npmrc
|
||||||
ports:
|
ports:
|
||||||
- "${PORTAL_PORT:-4200}:80"
|
- "${EMA_PORTAL_PORT:-8006}:80"
|
||||||
env_file: .env
|
env_file:
|
||||||
|
- apps/portal/.env
|
||||||
|
|
||||||
backoffice:
|
ema-backoffice:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
target: backoffice
|
target: backoffice
|
||||||
|
secrets:
|
||||||
|
- npmrc
|
||||||
ports:
|
ports:
|
||||||
- "${BACKOFFICE_PORT:-4201}:80"
|
- "${EMA_BACKOFFICE_PORT:-8007}:80"
|
||||||
env_file: .env
|
env_file:
|
||||||
|
- apps/backoffice/.env
|
||||||
|
|
||||||
|
secrets:
|
||||||
|
npmrc:
|
||||||
|
file: .npmrc
|
||||||
|
|||||||
@@ -37,8 +37,22 @@ export function useApiQuery<TData = unknown>(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useApiMutation<TData = unknown>() {
|
type UseApiMutationResult<TData> = {
|
||||||
return useApiMutationMutation() as ReturnType<typeof useApiMutationMutation> & {
|
data: TData | undefined;
|
||||||
data: TData | undefined;
|
isLoading: boolean;
|
||||||
};
|
isError: boolean;
|
||||||
|
error: unknown;
|
||||||
|
isSuccess: boolean;
|
||||||
|
status: 'uninitialized' | 'pending' | 'fulfilled' | 'rejected';
|
||||||
|
reset: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useApiMutation<TData = unknown>(): [
|
||||||
|
(args: ApiQueryArgs) => Promise<{ data: TData }> & { unwrap: () => Promise<TData> },
|
||||||
|
UseApiMutationResult<TData>,
|
||||||
|
] {
|
||||||
|
return useApiMutationMutation() as unknown as [
|
||||||
|
(args: ApiQueryArgs) => Promise<{ data: TData }> & { unwrap: () => Promise<TData> },
|
||||||
|
UseApiMutationResult<TData>,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
1201
package-lock.json
generated
1201
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -28,7 +28,6 @@
|
|||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"dayjs": "^1.11.20",
|
"dayjs": "^1.11.20",
|
||||||
"i18next": "^25.6.0",
|
"i18next": "^25.6.0",
|
||||||
"mantine-react-table": "^2.0.0-beta.9",
|
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-hook-form": "^7.71.2",
|
"react-hook-form": "^7.71.2",
|
||||||
|
|||||||
82
scripts/deploy/sync-env-from-server.sh
Normal file
82
scripts/deploy/sync-env-from-server.sh
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Sync .env files from the self-hosted runner filesystem into the repo.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# PROJECT=ema-portal BRANCH=main ./scripts/deploy/sync-env-from-server.sh ema-portal ema-api ema-backoffice
|
||||||
|
#
|
||||||
|
# Server layout (one file per service):
|
||||||
|
# /home/user/environmen/<project>/<branch-slug>/ema-api.env
|
||||||
|
# /home/user/environmen/<project>/<branch-slug>/ema-portal.env
|
||||||
|
# /home/user/environmen/<project>/<branch-slug>/ema-web.build.env
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
DEPLOY_USER="${DEPLOY_USER:-tria}"
|
||||||
|
BRANCH="${BRANCH:?BRANCH is required}"
|
||||||
|
BRANCH_SLUG="${BRANCH_SLUG:-$(echo "${BRANCH}" | tr "[:upper:]" "[:lower:]" | sed -E "s/[^a-z0-9]+/-/g; s/^-+//; s/-+$//")}"
|
||||||
|
ENV_ROOT="${ENV_ROOT:-/home/${DEPLOY_USER}/environment/ema/${BRANCH_SLUG}/${PROJECT:?PROJECT is required}}"
|
||||||
|
|
||||||
|
if [[ ! -d "${ENV_ROOT}" ]]; then
|
||||||
|
echo "Environment directory not found: ${ENV_ROOT}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Using environment directory: ${ENV_ROOT}"
|
||||||
|
|
||||||
|
declare -A SERVICE_ENV_TARGET=(
|
||||||
|
["ema-portal"]="apps/portal/.env"
|
||||||
|
["ema-backoffice"]="apps/backoffice/.env"
|
||||||
|
)
|
||||||
|
|
||||||
|
for service in "$@"; do
|
||||||
|
src="${ENV_ROOT}/${service}.env"
|
||||||
|
dest="${SERVICE_ENV_TARGET[${service}]:-}"
|
||||||
|
|
||||||
|
if [[ -z "${dest}" ]]; then
|
||||||
|
echo "Unknown service: ${service}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "${src}" ]]; then
|
||||||
|
echo "Missing env file: ${src}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "${dest}")"
|
||||||
|
cp "${src}" "${dest}"
|
||||||
|
echo "Synced ${src} -> ${dest}"
|
||||||
|
|
||||||
|
port_value=$(sed -n -E 's/^[[:space:]]*PORT[[:space:]]*=[[:space:]]*"?([^"#]+)"?[[:space:]]*(#.*)?$/\1/p' "${src}" | head -n1 | tr -d '[:space:]')
|
||||||
|
if [[ -z "${port_value}" ]]; then
|
||||||
|
echo "Missing required PORT in env file: ${src}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "${GITHUB_ENV:-}" ]]; then
|
||||||
|
service_var=$(echo "${service}" | tr '[:lower:]-' '[:upper:]_')
|
||||||
|
echo "${service_var}_PORT=${port_value}" >> "${GITHUB_ENV}"
|
||||||
|
echo "Exported ${service_var}_PORT from ${src}"
|
||||||
|
|
||||||
|
# Forward NEXT_PUBLIC_* vars so docker compose build can inject them as build args.
|
||||||
|
grep -E '^[[:space:]]*NEXT_PUBLIC_[A-Za-z0-9_]+=' "${src}" \
|
||||||
|
| sed -E 's/^[[:space:]]*//' >> "${GITHUB_ENV}" || true
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Optional build-time variables (VITE_API_URL, etc.)
|
||||||
|
# Set BUILD_ENV_FILE=freight-web.build.env or passenger-web.build.env per workflow.
|
||||||
|
build_env_file="${BUILD_ENV_FILE:-web.build.env}"
|
||||||
|
build_env="${ENV_ROOT}/${build_env_file}"
|
||||||
|
if [[ -f "${build_env}" ]]; then
|
||||||
|
echo "Loading build variables from ${build_env}"
|
||||||
|
set -a
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
source "${build_env}"
|
||||||
|
set +a
|
||||||
|
|
||||||
|
if [[ -n "${GITHUB_ENV:-}" ]]; then
|
||||||
|
grep -E '^[[:space:]]*export[[:space:]]+[A-Za-z_][A-Za-z0-9_]*=' "${build_env}" \
|
||||||
|
| sed -E 's/^[[:space:]]*export[[:space:]]+//' >> "${GITHUB_ENV}"
|
||||||
|
echo "Wrote build variables to GITHUB_ENV"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
Reference in New Issue
Block a user