user account create

This commit is contained in:
yaschalew
2026-05-26 05:40:53 +03:00
parent 69db7cd960
commit 5a4d6baec2
24 changed files with 3387 additions and 1817 deletions

View File

@@ -26,6 +26,7 @@
"@tria-plc/iamapi-common": "^0.1.6",
"amqp-connection-manager": "^5.0.0",
"amqplib": "^2.0.1",
"axios": "^1.7.7",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"dotenv": "^17.4.2",

View File

@@ -16,6 +16,7 @@ import { BillingModule } from "./modules/billing/billing.module";
import { NotificationsModule } from "./modules/notifications/notifications.module";
import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module";
import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module";
import { OtpModule } from './modules/otp/otp.module';
@Module({
imports: [
@@ -39,6 +40,7 @@ import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-set
NotificationsModule,
FileUploadSettingsModule,
DropdownSettingsModule,
OtpModule,
],
})
export class AppModule implements OnApplicationBootstrap {

View File

@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { OtpController } from './otp.controller';
describe('OtpController', () => {
let controller: OtpController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [OtpController],
}).compile();
controller = module.get<OtpController>(OtpController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});

View File

@@ -0,0 +1,53 @@
// otp.controller.ts
import {
Body,
Controller,
Post,
} from "@nestjs/common";
import { OtpService } from "./otp.service";
import { Public } from "@edr/api-common";
@Controller("otp")
@Public()
export class OtpController {
constructor(
private readonly otpService: OtpService
) {}
// ---------------------------------------------------------------------------
// Send OTP
// ---------------------------------------------------------------------------
@Post("send")
async sendOtp(
@Body("phone")
phone: string,
@Body("otp")
otp: string
) {
return this.otpService.sendOtp(
phone,otp
);
}
// ---------------------------------------------------------------------------
// Verify OTP
// ---------------------------------------------------------------------------
@Post("verify")
async verifyOtp(
@Body("phone")
phone: string,
@Body("otp")
otp: string
) {
return this.otpService.verifyOtp(
phone,
otp
);
}
}

View File

@@ -0,0 +1,25 @@
// otp.entity.ts
import {
Column,
Entity,
} from "typeorm";
import { BaseEntity } from "@edr/api-common";
@Entity({
name: "otp_verifications",
})
export class OtpVerification extends BaseEntity{
@Column({
unique: true,
})
phone!: string;
@Column()
otp!: string;
@Column({
default: false,
})
verified!: boolean;
}

View File

@@ -0,0 +1,33 @@
// otp.module.ts
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { OtpVerification } from "./otp.entity";
import { OtpController } from "./otp.controller";
import { OtpService } from "./otp.service";
import { OtpRepository } from "./otp.repository";
@Module({
imports: [
TypeOrmModule.forFeature([
OtpVerification,
]),
],
controllers: [OtpController],
providers: [
OtpService,
OtpRepository,
],
exports: [
OtpRepository,
],
})
export class OtpModule {}

View File

@@ -0,0 +1,86 @@
// otp.repository.ts
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { OtpVerification } from "./otp.entity";
@Injectable()
export class OtpRepository {
constructor(
@InjectRepository(
OtpVerification
)
private readonly repository: Repository<OtpVerification>
) {}
// ---------------------------------------------------------------------------
// Find By Phone
// ---------------------------------------------------------------------------
async findByPhone(
phone: string
) {
return this.repository.findOne({
where: {
phone,
},
});
}
// ---------------------------------------------------------------------------
// Create OTP
// ---------------------------------------------------------------------------
async createOtp(
phone: string,
otp: string
) {
const entity =
this.repository.create({
phone,
otp,
verified: false,
});
return this.repository.save(
entity
);
}
// ---------------------------------------------------------------------------
// Update OTP
// ---------------------------------------------------------------------------
async updateOtp(
otpVerification: OtpVerification,
otp: string
) {
otpVerification.otp = otp;
otpVerification.verified =
false;
return this.repository.save(
otpVerification
);
}
// ---------------------------------------------------------------------------
// Verify Phone
// ---------------------------------------------------------------------------
async verifyPhone(
otpVerification: OtpVerification
) {
otpVerification.verified =
true;
return this.repository.save(
otpVerification
);
}
}

View File

@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { OtpService } from './otp.service';
describe('OtpService', () => {
let service: OtpService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [OtpService],
}).compile();
service = module.get<OtpService>(OtpService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@@ -0,0 +1,141 @@
// otp.service.ts
import {
BadRequestException,
Injectable,
} from "@nestjs/common";
import axios from "axios";
import { OtpRepository } from "./otp.repository";
@Injectable()
export class OtpService {
constructor(
private readonly otpRepository: OtpRepository
) {}
// ---------------------------------------------------------------------------
// Generate OTP
// ---------------------------------------------------------------------------
generateOtp(): string {
return Math.floor(
100000 + Math.random() * 900000
).toString();
}
// ---------------------------------------------------------------------------
// Send OTP
// ---------------------------------------------------------------------------
async sendOtp(phone: string, otp: string) {
try {
// generate otp
// const otp =
// this.generateOtp();
// find existing phone
const existingPhone =
await this.otpRepository.findByPhone(
phone
);
// update existing otp
if (existingPhone) {
await this.otpRepository.updateOtp(
existingPhone,
otp
);
} else {
// create new otp
await this.otpRepository.createOtp(
phone,
otp
);
}
// send sms
await axios.post(
"https://notification-dev.license.aafda.gov.et/api/sms-services/ozeking/sms",
{
to: phone,
sourceId: "EDR",
sourceName:
"EDR Freight",
appKey:
"YOUR_APP_KEY",
text: `Your verification code is ${otp}`,
callbackUrl: "",
},
{
headers: {
accept: "*/*",
"Content-Type":
"application/json",
},
}
);
return {
success: true,
message:
"OTP sent successfully",
};
} catch (error) {
console.log(error);
throw new BadRequestException(
"Failed to send OTP"
);
}
}
// ---------------------------------------------------------------------------
// Verify OTP
// ---------------------------------------------------------------------------
async verifyOtp(
phone: string,
otp: string
) {
// find phone
const otpData =
await this.otpRepository.findByPhone(
phone
);
// phone not found
if (!otpData) {
throw new BadRequestException(
"Phone number not found"
);
}
// invalid otp
if (otpData.otp !== otp) {
throw new BadRequestException(
"Invalid OTP"
);
}
// verify phone
await this.otpRepository.verifyPhone(
otpData
);
return {
success: true,
message:
"Phone verified successfully",
};
}
}

View File

@@ -44,10 +44,12 @@ import FileUploadSettingsPage from "./pages/admin/FileUploadSettingsPage";
import MyPortalPage from "./pages/portal/MyPortalPage";
import EDRFreightLandingPage from "./pages/EDRFreightLandingPage";
import SignupPage from "./pages/accounts/SignupPage";
import VerificationOtpPage from "./pages/accounts/VerificationOtpPage";
import SetPasswordPage from "./pages/accounts/SetPasswordPage";
const sidebarItems: SidebarItem[] = [
{ label: "My Portal", href: "/portal", icon: <UserCircle /> },
{ label: "Dashboard", href: "/", icon: <LayoutDashboard /> },
{ label: "My Portal", href: "/", icon: <UserCircle /> },
{ label: "Dashboard", href: "/dashboard", icon: <LayoutDashboard /> },
{ label: "Customers", href: "/customers", icon: <Users /> },
{ label: "Bookings", href: "/bookings", icon: <CalendarCheck /> },
{ label: "Consignments", href: "/consignments", icon: <Package /> },
@@ -78,6 +80,8 @@ const App = () => {
<Routes>
<Route path="/" element={<EDRFreightLandingPage />} />
<Route path="/signup" element={<SignupPage />} />
<Route path="/otp" element={<VerificationOtpPage />} />
<Route path="/set-password" element={<SetPasswordPage />} />
<Route path="/auth" element={<IamLoginPage />} />
{/* <Route path="*" element={<Navigate to="/auth" replace />} /> */}
</Routes>
@@ -114,8 +118,8 @@ const App = () => {
onLogout={handleLogout}
>
<Routes>
<Route path="/" element={<DashboardPage />} />
<Route path="/portal" element={<MyPortalPage />} />
<Route path="/" element={<MyPortalPage />} />
<Route path="/dashboard" element={<DashboardPage />} />
<Route path="/bookings" element={<BookingsPage />} />
<Route path="/customers" element={<CustomersPage />} />
<Route path="/customers/:id" element={<CustomerDetailPage />} />

View File

@@ -1,5 +1,6 @@
export const QUERY_KEYS = {
USERS: "users",
ADD_USER: "add_user",
CUSTOMER: "Customers",
FILES: {
FILE_UPLOAD_SETTINGS: "file-upload-settings",

View File

@@ -8,8 +8,11 @@ export const URL_CONSTANTS = {
},
USERS: {
SIGN_UP: "/api/auth/signup",
GENERATE_VERIFICATION_CODE: "/api/auth/generate-verification-code",
BASE: "/users",
BY_ID: (id: string | number) => `/users/${id}`,
SET_PASSWORD: "/api/auth/set-password"
},
ROLES: {
@@ -76,4 +79,9 @@ export const URL_CONSTANTS = {
CANCEL: (id: string | number) => `/bookings/${id}/cancel`,
CONFIRM: (id: string | number) => `/bookings/${id}/confirm`,
},
OTP: {
SEND: "/api/otp/send",
VERIFY: "/api/otp/verify",
}
};

View File

@@ -0,0 +1,5 @@
export enum userType {
externalOrganization = "external_organization",
employee = "employee",
individual = "individual"
}

View File

@@ -0,0 +1,6 @@
export enum verificationCodeType {
setPassword = "set-password",
resetPassword = "reset-password",
verifyPhoneNumber = "verify-phone-number",
mfaLogin = "mfa-login",
}

View File

@@ -0,0 +1,418 @@
import { setPassword } from "@/services/account";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
import {
ArrowRight,
LockKeyhole,
ShieldCheck,
Train,
Eye,
EyeOff,
} from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { useNavigate } from "react-router-dom";
import { z } from "zod";
// -----------------------------------------------------------------------------
// Schema
// -----------------------------------------------------------------------------
const passwordSchema = z
.object({
password: z
.string()
.min(
8,
"Password must be at least 8 characters"
),
confirmPassword: z
.string()
.min(
8,
"Confirm password is required"
),
})
.refine(
(data) =>
data.password ===
data.confirmPassword,
{
message:
"Passwords do not match",
path: ["confirmPassword"],
}
);
type FormData = z.infer<
typeof passwordSchema
>;
// -----------------------------------------------------------------------------
// Component
// -----------------------------------------------------------------------------
export default function SetPasswordPage() {
const [
showPassword,
setShowPassword,
] = useState(false);
const [
showConfirmPassword,
setShowConfirmPassword,
] = useState(false);
const {
register,
handleSubmit,
formState: { errors },
reset,
} = useForm<FormData>({
resolver:
zodResolver(passwordSchema),
defaultValues: {
password: "",
confirmPassword: "",
},
});
const naviagte = useNavigate();
// ---------------------------------------------------------------------------
// Mutation
// ---------------------------------------------------------------------------
const setPasswordMutation =
useMutation({
mutationFn: async (
data: FormData
) => setPassword({
newPassword: data?.password,
confirmPassword: data?.confirmPassword,
userId: localStorage.getItem("userId"),
email: localStorage.getItem("otp-email"),
verificationCode: localStorage.getItem("otp"),
}),
onSuccess: () => {
naviagte("/auth");
reset();
},
});
// ---------------------------------------------------------------------------
// Submit
// ---------------------------------------------------------------------------
const onSubmit = async (
data: FormData
) => {
try {
await setPasswordMutation.mutateAsync(
data
);
} catch (err) {
console.error(err);
}
};
// ---------------------------------------------------------------------------
// UI
// ---------------------------------------------------------------------------
return (
<div className="min-h-screen bg-background text-foreground">
<div className="grid min-h-screen lg:grid-cols-2">
{/* ------------------------------------------------------------------ */}
{/* Left Side */}
{/* ------------------------------------------------------------------ */}
<div className="relative hidden overflow-hidden bg-primary p-12 text-primary-foreground lg:flex lg:flex-col lg:justify-between">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(255,255,255,0.14),transparent_35%)]" />
<div className="relative z-10">
{/* Logo */}
<div className="flex items-center gap-3">
<div className="flex size-14 items-center justify-center rounded-3xl bg-white/10 backdrop-blur">
<Train className="size-7" />
</div>
<div>
<h1 className="text-3xl font-black tracking-tight">
EDR Freight
</h1>
<p className="mt-1 text-sm opacity-80">
Railway Logistics
Platform
</p>
</div>
</div>
{/* Hero */}
<div className="mt-20 max-w-lg">
<div className="inline-flex rounded-full bg-white/10 px-4 py-2 text-sm font-semibold backdrop-blur">
Account Security
</div>
<h2 className="mt-6 text-5xl font-black leading-tight tracking-tight">
Set your secure
password
</h2>
<p className="mt-6 text-lg leading-8 opacity-85">
Create a strong
password to secure
your EDR Freight
account and protect
railway logistics
operations and shipment
data.
</p>
</div>
{/* Features */}
<div className="mt-14 grid gap-5">
{[
"Enterprise-grade security",
"Protected account access",
"Secure freight operations",
"Advanced authentication system",
].map((item) => (
<div
key={item}
className="flex items-center gap-3"
>
<div className="flex size-10 items-center justify-center rounded-2xl bg-white/10">
<ShieldCheck className="size-5" />
</div>
<span className="font-medium">
{item}
</span>
</div>
))}
</div>
</div>
{/* Stats */}
<div className="relative z-10 rounded-[32px] border border-white/10 bg-white/10 p-6 backdrop-blur-xl">
<div className="flex items-center justify-between">
<div>
<p className="text-sm opacity-80">
Security Protection
</p>
<h3 className="mt-2 text-4xl font-black">
256-bit
</h3>
</div>
<div className="rounded-2xl bg-white/10 px-4 py-2 text-sm font-semibold">
Encrypted
</div>
</div>
<div className="mt-6 h-3 overflow-hidden rounded-full bg-white/10">
<div className="h-full w-[98%] rounded-full bg-white" />
</div>
</div>
</div>
{/* ------------------------------------------------------------------ */}
{/* Right Side */}
{/* ------------------------------------------------------------------ */}
<div className="flex items-center justify-center p-6 md:p-10">
<div className="w-full max-w-xl">
{/* Mobile Logo */}
<div className="mb-8 flex items-center gap-3 lg:hidden">
<div className="flex size-12 items-center justify-center rounded-2xl bg-primary text-primary-foreground">
<Train className="size-6" />
</div>
<div>
<h1 className="text-2xl font-bold">
EDR Freight
</h1>
<p className="text-sm text-muted-foreground">
Railway Logistics
Platform
</p>
</div>
</div>
{/* Card */}
<div className="rounded-[36px] border border-border bg-card p-8 shadow-2xl md:p-10">
{/* Header */}
<div className="mb-8">
<div className="mb-4 flex size-16 items-center justify-center rounded-3xl bg-primary/10 text-primary">
<LockKeyhole className="size-8" />
</div>
<h2 className="text-4xl font-black tracking-tight">
Set Password
</h2>
<p className="mt-3 text-lg text-muted-foreground">
Create a secure
password for your
EDR Freight account.
</p>
</div>
{/* Success */}
{setPasswordMutation.isSuccess && (
<div className="mb-6 rounded-2xl border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-700">
Password updated
successfully.
</div>
)}
{/* Error */}
{setPasswordMutation.isError && (
<div className="mb-6 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
Failed to set
password. Please try
again.
</div>
)}
{/* Form */}
<form
onSubmit={handleSubmit(
onSubmit
)}
className="space-y-6"
>
{/* Password */}
<div>
<label className="mb-2 block text-sm font-semibold">
Password
</label>
<div className="relative">
<input
type={
showPassword
? "text"
: "password"
}
placeholder="Enter password"
disabled={
setPasswordMutation.isPending
}
{...register(
"password"
)}
className="h-14 w-full rounded-2xl border border-input bg-background px-4 pr-14 outline-none transition focus:border-primary focus:ring-4 focus:ring-primary/10 disabled:opacity-60"
/>
<button
type="button"
onClick={() =>
setShowPassword(
!showPassword
)
}
className="absolute right-4 top-1/2 -translate-y-1/2 text-muted-foreground"
>
{showPassword ? (
<EyeOff className="size-5" />
) : (
<Eye className="size-5" />
)}
</button>
</div>
{errors.password && (
<p className="mt-1 text-sm text-red-500">
{
errors.password
.message
}
</p>
)}
</div>
{/* Confirm Password */}
<div>
<label className="mb-2 block text-sm font-semibold">
Confirm Password
</label>
<div className="relative">
<input
type={
showConfirmPassword
? "text"
: "password"
}
placeholder="Confirm password"
disabled={
setPasswordMutation.isPending
}
{...register(
"confirmPassword"
)}
className="h-14 w-full rounded-2xl border border-input bg-background px-4 pr-14 outline-none transition focus:border-primary focus:ring-4 focus:ring-primary/10 disabled:opacity-60"
/>
<button
type="button"
onClick={() =>
setShowConfirmPassword(
!showConfirmPassword
)
}
className="absolute right-4 top-1/2 -translate-y-1/2 text-muted-foreground"
>
{showConfirmPassword ? (
<EyeOff className="size-5" />
) : (
<Eye className="size-5" />
)}
</button>
</div>
{errors.confirmPassword && (
<p className="mt-1 text-sm text-red-500">
{
errors
.confirmPassword
.message
}
</p>
)}
</div>
{/* Submit */}
<button
type="submit"
disabled={
setPasswordMutation.isPending
}
className="flex h-14 w-full items-center justify-center gap-2 rounded-2xl bg-primary text-lg font-semibold text-primary-foreground shadow-lg transition hover:-translate-y-0.5 hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
>
{setPasswordMutation.isPending ? (
"Saving..."
) : (
<>
Save Password
<ArrowRight className="size-5" />
</>
)}
</button>
</form>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,76 +1,213 @@
import { ArrowRight, ShieldCheck, Train, UserPlus } from "lucide-react";
import { useState } from "react";
import { userType } from "@/enums/userType";
import { createOTP, createUser } from "@/services/account";
const userTypes = [
"Customer",
"Operator",
"Dispatcher",
"Admin",
];
import { CreateUserPayload } from "@/types/createUser";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
import {
ArrowRight,
ShieldCheck,
Train,
UserPlus,
} from "lucide-react";
import { useForm } from "react-hook-form";
import { useNavigate } from "react-router-dom";
import { z } from "zod";
// -----------------------------------------------------------------------------
// Schema
// -----------------------------------------------------------------------------
const userSchema = z.object({
email: z
.string()
.email("Invalid email address"),
username: z
.string()
.min(
3,
"Username must be at least 3 characters"
),
countryCode: z
.string()
.min(
1,
"Country code is required"
),
phone: z
.string()
.min(
9,
"Phone number is too short"
)
.max(
9,
"Phone number is too long"
),
userType: z.string(),
name: z.object({
en: z
.string()
.min(2, "Name is required"),
am: z.string().nullable(),
}),
});
type FormData = z.infer<
typeof userSchema
>;
// -----------------------------------------------------------------------------
// Component
// -----------------------------------------------------------------------------
export default function SignupPage() {
const [form, setForm] = useState({
email: "",
username: "",
phoneNumber: "",
userType: "",
name: {
am: "",
en: "",
const navigate = useNavigate();
const {
register,
handleSubmit,
formState: { errors },
reset,
} = useForm<FormData>({
resolver:
zodResolver(userSchema),
defaultValues: {
email: "",
username: "",
countryCode: "+251",
phone: "",
userType:
userType.individual,
name: {
en: "",
am: "",
},
},
});
const handleChange = (
field: string,
value: string,
nested?: boolean
// ---------------------------------------------------------------------------
// Create User Mutation
// ---------------------------------------------------------------------------
const createUserMutation =
useMutation({
mutationFn: (
user: CreateUserPayload
) => createUser(user),
onSuccess: () => {
reset();
},
});
// ---------------------------------------------------------------------------
// Submit
// ---------------------------------------------------------------------------
const onSubmit = async (
data: FormData
) => {
if (nested) {
setForm((prev) => ({
...prev,
try {
const normalizedPhone =
data.phone.startsWith(
"0"
)
? data.phone.slice(1)
: data.phone;
const fullPhoneNumber = `${data.countryCode
}${normalizedPhone}`;
const payload: CreateUserPayload =
{
email: data.email,
username:
data.username,
phoneNumber:
fullPhoneNumber,
userType:
data.userType,
name: {
...prev.name,
[field]: value,
en: data.name.en,
am:
data.name.am ||
"",
},
}));
};
return;
}
const res =
await createUserMutation.mutateAsync(
payload
);
setForm((prev) => ({
...prev,
[field]: value,
}));
};
if (res?.success) {
// save auth token
// document.cookie = `auth-token=${res.data?.token}; path=/`;
localStorage.setItem(
"auth-token",
`auth-token=${res.data?.token}; path=/`
);
localStorage.setItem(
"userId",res.data?.userId
);
localStorage.setItem(
"otp",res.data?.otp?.split(" ")?.[6]
);
createOTP({ phone: payload.phoneNumber, otp:res.data?.otp?.split(" ")?.[6] })
// save phone for otp page
localStorage.setItem(
"otp-phone",
payload.phoneNumber
);
// save phone for set password page
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
console.log(form);
/*
Expected payload:
{
email: "",
username: "",
phoneNumber: "",
userType: "",
name: {
am: "",
en: ""
localStorage.setItem(
"otp-email",
payload.email
);
// navigate otp page
navigate("/otp");
}
} catch (err) {
console.error(err);
}
*/
};
// ---------------------------------------------------------------------------
// UI
// ---------------------------------------------------------------------------
return (
<div className="min-h-screen bg-background text-foreground">
<div className="grid min-h-screen lg:grid-cols-2">
{/* ------------------------------------------------------------------ */}
{/* Left Side */}
{/* ------------------------------------------------------------------ */}
<div className="relative hidden overflow-hidden bg-primary p-12 text-primary-foreground lg:flex lg:flex-col lg:justify-between">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(255,255,255,0.14),transparent_35%)]" />
<div className="relative z-10">
{/* Logo */}
<div className="flex items-center gap-3">
<div className="flex size-14 items-center justify-center rounded-3xl bg-white/10 backdrop-blur">
<Train className="size-7" />
@@ -82,27 +219,38 @@ export default function SignupPage() {
</h1>
<p className="mt-1 text-sm opacity-80">
Railway Logistics Platform
Railway Logistics
Platform
</p>
</div>
</div>
{/* Hero */}
<div className="mt-20 max-w-lg">
<div className="inline-flex rounded-full bg-white/10 px-4 py-2 text-sm font-semibold backdrop-blur">
Smart Freight Operations
Smart Freight
Operations
</div>
<h2 className="mt-6 text-5xl font-black leading-tight tracking-tight">
Create your freight operations account
Create your freight
operations account
</h2>
<p className="mt-6 text-lg leading-8 opacity-85">
Join EDR Freight to manage shipments, monitor railway
operations, track consignments, and streamline logistics
workflows across Ethiopia and Djibouti.
Join EDR Freight to
manage shipments,
monitor railway
operations, track
consignments, and
streamline logistics
workflows across
Ethiopia and
Djibouti.
</p>
</div>
{/* Features */}
<div className="mt-14 grid gap-5">
{[
"Real-time shipment tracking",
@@ -118,12 +266,15 @@ export default function SignupPage() {
<ShieldCheck className="size-5" />
</div>
<span className="font-medium">{item}</span>
<span className="font-medium">
{item}
</span>
</div>
))}
</div>
</div>
{/* Stats */}
<div className="relative z-10 rounded-[32px] border border-white/10 bg-white/10 p-6 backdrop-blur-xl">
<div className="flex items-center justify-between">
<div>
@@ -147,7 +298,10 @@ export default function SignupPage() {
</div>
</div>
{/* ------------------------------------------------------------------ */}
{/* Right Side */}
{/* ------------------------------------------------------------------ */}
<div className="flex items-center justify-center p-6 md:p-10">
<div className="w-full max-w-2xl">
{/* Mobile Logo */}
@@ -162,12 +316,15 @@ export default function SignupPage() {
</h1>
<p className="text-sm text-muted-foreground">
Railway Logistics Platform
Railway Logistics
Platform
</p>
</div>
</div>
{/* Form Card */}
<div className="rounded-[36px] border border-border bg-card p-8 shadow-2xl md:p-10">
{/* Header */}
<div className="mb-8">
<div className="mb-4 flex size-16 items-center justify-center rounded-3xl bg-primary/10 text-primary">
<UserPlus className="size-8" />
@@ -178,56 +335,63 @@ export default function SignupPage() {
</h2>
<p className="mt-3 text-lg text-muted-foreground">
Register to access EDR Freight services and
railway logistics operations.
Register to access
EDR Freight
services and railway
logistics operations.
</p>
</div>
{/* Success */}
{createUserMutation.isSuccess && (
<div className="mb-6 rounded-2xl border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-700">
Account created
successfully.
</div>
)}
{/* Error */}
{createUserMutation.isError && (
<div className="mb-6 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
Failed to create
account. Please try
again.
</div>
)}
{/* Form */}
<form
onSubmit={handleSubmit}
onSubmit={handleSubmit(
onSubmit
)}
className="space-y-6"
>
{/* Names */}
<div className="grid gap-5 md:grid-cols-2">
<div>
<label className="mb-2 block text-sm font-semibold">
Full Name (English)
</label>
{/* Full Name */}
<div>
<label className="mb-2 block text-sm font-semibold">
Full Name
</label>
<input
type="text"
value={form.name.en}
onChange={(e) =>
handleChange(
"en",
e.target.value,
true
)
<input
type="text"
placeholder="John Doe"
disabled={
createUserMutation.isPending
}
{...register(
"name.en"
)}
className="h-13 w-full rounded-2xl border border-input bg-background px-4 outline-none transition focus:border-primary focus:ring-4 focus:ring-primary/10 disabled:opacity-60"
/>
{errors.name?.en && (
<p className="mt-1 text-sm text-red-500">
{
errors.name.en
.message
}
placeholder="John Doe"
className="h-13 w-full rounded-2xl border border-input bg-background px-4 outline-none transition focus:border-primary focus:ring-4 focus:ring-primary/10"
/>
</div>
<div>
<label className="mb-2 block text-sm font-semibold">
Full Name (Amharic)
</label>
<input
type="text"
value={form.name.am}
onChange={(e) =>
handleChange(
"am",
e.target.value,
true
)
}
placeholder="ጆን ዶ"
className="h-13 w-full rounded-2xl border border-input bg-background px-4 outline-none transition focus:border-primary focus:ring-4 focus:ring-primary/10"
/>
</div>
</p>
)}
</div>
{/* Username */}
@@ -238,101 +402,121 @@ export default function SignupPage() {
<input
type="text"
value={form.username}
onChange={(e) =>
handleChange(
"username",
e.target.value
)
}
placeholder="john_doe"
className="h-13 w-full rounded-2xl border border-input bg-background px-4 outline-none transition focus:border-primary focus:ring-4 focus:ring-primary/10"
disabled={
createUserMutation.isPending
}
{...register(
"username"
)}
className="h-13 w-full rounded-2xl border border-input bg-background px-4 outline-none transition focus:border-primary focus:ring-4 focus:ring-primary/10 disabled:opacity-60"
/>
{errors.username && (
<p className="mt-1 text-sm text-red-500">
{
errors.username
.message
}
</p>
)}
</div>
{/* Email + Phone */}
<div className="grid gap-5 md:grid-cols-2">
<div>
<label className="mb-2 block text-sm font-semibold">
Email Address
</label>
{/* Email */}
<div>
<label className="mb-2 block text-sm font-semibold">
Email Address
</label>
<input
type="email"
value={form.email}
onChange={(e) =>
handleChange(
"email",
e.target.value
)
<input
type="email"
placeholder="john@example.com"
disabled={
createUserMutation.isPending
}
{...register(
"email"
)}
className="h-13 w-full rounded-2xl border border-input bg-background px-4 outline-none transition focus:border-primary focus:ring-4 focus:ring-primary/10 disabled:opacity-60"
/>
{errors.email && (
<p className="mt-1 text-sm text-red-500">
{
errors.email
.message
}
placeholder="john@example.com"
className="h-13 w-full rounded-2xl border border-input bg-background px-4 outline-none transition focus:border-primary focus:ring-4 focus:ring-primary/10"
/>
</div>
</p>
)}
</div>
<div>
<label className="mb-2 block text-sm font-semibold">
Phone Number
</label>
{/* Phone */}
<div>
<label className="mb-2 block text-sm font-semibold">
Phone Number
</label>
<div className="flex gap-2">
<input
type="text"
disabled={
createUserMutation.isPending
}
{...register(
"countryCode"
)}
className="h-13 w-28 rounded-2xl border border-input bg-background px-4 outline-none transition focus:border-primary focus:ring-4 focus:ring-primary/10 disabled:opacity-60"
/>
<input
type="tel"
value={form.phoneNumber}
onChange={(e) =>
handleChange(
"phoneNumber",
e.target.value
)
placeholder="912345678"
disabled={
createUserMutation.isPending
}
placeholder="+251 9xx xxx xxx"
className="h-13 w-full rounded-2xl border border-input bg-background px-4 outline-none transition focus:border-primary focus:ring-4 focus:ring-primary/10"
{...register(
"phone"
)}
className="h-13 flex-1 rounded-2xl border border-input bg-background px-4 outline-none transition focus:border-primary focus:ring-4 focus:ring-primary/10 disabled:opacity-60"
/>
</div>
</div>
{/* User Type */}
<div>
<label className="mb-2 block text-sm font-semibold">
User Type
</label>
<select
value={form.userType}
onChange={(e) =>
handleChange(
"userType",
e.target.value
)
}
className="h-13 w-full rounded-2xl border border-input bg-background px-4 outline-none transition focus:border-primary focus:ring-4 focus:ring-primary/10"
>
<option value="">
Select user type
</option>
{userTypes.map((type) => (
<option
key={type}
value={type}
>
{type}
</option>
))}
</select>
{(errors.countryCode ||
errors.phone) && (
<p className="mt-1 text-sm text-red-500">
{errors
.countryCode
?.message ||
errors.phone
?.message}
</p>
)}
</div>
{/* Submit */}
<button
type="submit"
className="flex h-14 w-full items-center justify-center gap-2 rounded-2xl bg-primary text-lg font-semibold text-primary-foreground shadow-lg transition hover:-translate-y-0.5 hover:opacity-90"
disabled={
createUserMutation.isPending
}
className="flex h-14 w-full items-center justify-center gap-2 rounded-2xl bg-primary text-lg font-semibold text-primary-foreground shadow-lg transition hover:-translate-y-0.5 hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
>
Create Account
<ArrowRight className="size-5" />
{createUserMutation.isPending ? (
"Creating..."
) : (
<>
Create Account
<ArrowRight className="size-5" />
</>
)}
</button>
{/* Footer */}
<p className="text-center text-sm text-muted-foreground">
Already have an account?
Already have an
account?
<button
type="button"
className="ml-2 font-semibold text-primary hover:underline"

View File

@@ -0,0 +1,454 @@
import { verificationCodeType } from "@/enums/verificationCodeType";
import {
generateVerificationCode,
verifyOTP,
} from "@/services/account";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import {
ArrowRight,
ShieldCheck,
Train,
MailCheck,
RotateCw,
} from "lucide-react";
import { useForm } from "react-hook-form";
import { z } from "zod";
// -----------------------------------------------------------------------------
// Schema
// -----------------------------------------------------------------------------
const otpSchema = z.object({
code: z
.string()
.regex(
/^\d{6}$/,
"OTP must be exactly 6 digits"
),
});
type FormData = z.infer<
typeof otpSchema
>;
// -----------------------------------------------------------------------------
// Component
// -----------------------------------------------------------------------------
export default function VerificationOtpPage() {
const navigate =
useNavigate();
// ---------------------------------------------------------------------------
// Local Storage Data
// ---------------------------------------------------------------------------
const phone =
localStorage.getItem(
"otp-phone"
) || "";
const email =
localStorage.getItem(
"otp-email"
) || "";
// ---------------------------------------------------------------------------
// Form
// ---------------------------------------------------------------------------
const {
register,
handleSubmit,
formState: { errors },
watch,
} = useForm<FormData>({
resolver:
zodResolver(otpSchema),
defaultValues: {
code: "",
},
});
const otpValue =
watch("code");
// ---------------------------------------------------------------------------
// Verify Mutation
// ---------------------------------------------------------------------------
const verifyMutation =
useMutation({
mutationFn: async (
data: {
phone: string;
otp: string;
}
) => verifyOTP(data),
onSuccess: () => {
navigate(
"/set-password"
);
},
});
// ---------------------------------------------------------------------------
// Resend Mutation
// ---------------------------------------------------------------------------
const resendMutation =
useMutation({
mutationFn: async () => {
return generateVerificationCode(
{
email,
phoneNumber:
phone,
type:
verificationCodeType.setPassword,
}
);
},
});
// ---------------------------------------------------------------------------
// Submit
// ---------------------------------------------------------------------------
const onSubmit = async (
data: FormData
) => {
try {
await verifyMutation.mutateAsync(
{
phone,
otp: data.code,
}
);
} catch (err) {
console.error(err);
}
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const maskedPhone =
phone.length > 4
? `${phone.slice(
0,
7
)}******`
: phone;
// ---------------------------------------------------------------------------
// UI
// ---------------------------------------------------------------------------
return (
<div className="min-h-screen bg-background text-foreground">
<div className="grid min-h-screen lg:grid-cols-2">
{/* ------------------------------------------------------------------ */}
{/* Left Side */}
{/* ------------------------------------------------------------------ */}
<div className="relative hidden overflow-hidden bg-primary p-12 text-primary-foreground lg:flex lg:flex-col lg:justify-between">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(255,255,255,0.14),transparent_35%)]" />
<div className="relative z-10">
{/* Logo */}
<div className="flex items-center gap-3">
<div className="flex size-14 items-center justify-center rounded-3xl bg-white/10 backdrop-blur">
<Train className="size-7" />
</div>
<div>
<h1 className="text-3xl font-black tracking-tight">
EDR Freight
</h1>
<p className="mt-1 text-sm opacity-80">
Railway Logistics
Platform
</p>
</div>
</div>
{/* Hero */}
<div className="mt-20 max-w-lg">
<div className="inline-flex rounded-full bg-white/10 px-4 py-2 text-sm font-semibold backdrop-blur">
Secure
Verification
</div>
<h2 className="mt-6 text-5xl font-black leading-tight tracking-tight">
Verify your
account securely
</h2>
<p className="mt-6 text-lg leading-8 opacity-85">
Enter the
verification code
sent to your phone
number to continue
using EDR Freight
logistics services.
</p>
</div>
{/* Features */}
<div className="mt-14 grid gap-5">
{[
"Secure OTP verification",
"Protected account access",
"Fast identity confirmation",
"Enterprise-grade security",
].map((item) => (
<div
key={item}
className="flex items-center gap-3"
>
<div className="flex size-10 items-center justify-center rounded-2xl bg-white/10">
<ShieldCheck className="size-5" />
</div>
<span className="font-medium">
{item}
</span>
</div>
))}
</div>
</div>
{/* Footer Stats */}
<div className="relative z-10 rounded-[32px] border border-white/10 bg-white/10 p-6 backdrop-blur-xl">
<div className="flex items-center justify-between">
<div>
<p className="text-sm opacity-80">
Verification
Security
</p>
<h3 className="mt-2 text-4xl font-black">
99.9%
</h3>
</div>
<div className="rounded-2xl bg-white/10 px-4 py-2 text-sm font-semibold">
Protected
</div>
</div>
<div className="mt-6 h-3 overflow-hidden rounded-full bg-white/10">
<div className="h-full w-[99%] rounded-full bg-white" />
</div>
</div>
</div>
{/* ------------------------------------------------------------------ */}
{/* Right Side */}
{/* ------------------------------------------------------------------ */}
<div className="flex items-center justify-center p-6 md:p-10">
<div className="w-full max-w-xl">
{/* Mobile Logo */}
<div className="mb-8 flex items-center gap-3 lg:hidden">
<div className="flex size-12 items-center justify-center rounded-2xl bg-primary text-primary-foreground">
<Train className="size-6" />
</div>
<div>
<h1 className="text-2xl font-bold">
EDR Freight
</h1>
<p className="text-sm text-muted-foreground">
Railway Logistics
Platform
</p>
</div>
</div>
{/* OTP Card */}
<div className="rounded-[36px] border border-border bg-card p-8 shadow-2xl md:p-10">
{/* Header */}
<div className="mb-8">
<div className="mb-4 flex size-16 items-center justify-center rounded-3xl bg-primary/10 text-primary">
<MailCheck className="size-8" />
</div>
<h2 className="text-4xl font-black tracking-tight">
OTP Verification
</h2>
<p className="mt-3 text-lg text-muted-foreground">
Enter the
6-digit code sent
to:
</p>
<div className="mt-4 rounded-2xl border border-border bg-muted/50 px-4 py-3">
<p className="font-semibold">
{maskedPhone}
</p>
</div>
</div>
{/* Success */}
{verifyMutation.isSuccess && (
<div className="mb-6 rounded-2xl border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-700">
Verification
successful.
</div>
)}
{/* Error */}
{verifyMutation.isError && (
<div className="mb-6 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
Invalid OTP
code. Please try
again.
</div>
)}
{/* Resend Success */}
{resendMutation.isSuccess && (
<div className="mb-6 rounded-2xl border border-blue-200 bg-blue-50 px-4 py-3 text-sm text-blue-700">
New OTP code sent
successfully.
</div>
)}
{/* Form */}
<form
onSubmit={handleSubmit(
onSubmit
)}
className="space-y-6"
>
{/* OTP */}
<div>
<label className="mb-2 block text-sm font-semibold">
Verification
Code
</label>
<input
type="text"
inputMode="numeric"
autoComplete="one-time-code"
maxLength={6}
placeholder="123456"
disabled={
verifyMutation.isPending
}
{...register(
"code"
)}
className="h-16 w-full rounded-2xl border border-input bg-background px-5 text-center text-3xl font-black tracking-[12px] outline-none transition focus:border-primary focus:ring-4 focus:ring-primary/10 disabled:opacity-60"
/>
<div className="mt-2 flex items-center justify-between">
{errors.code ? (
<p className="text-sm text-red-500">
{
errors.code
.message
}
</p>
) : (
<p className="text-sm text-muted-foreground">
Enter the OTP
sent to your
phone
</p>
)}
<span className="text-xs text-muted-foreground">
{
otpValue.length
}
/6
</span>
</div>
</div>
{/* Verify Button */}
<button
type="submit"
disabled={
verifyMutation.isPending ||
otpValue.length !==
6
}
className="flex h-14 w-full items-center justify-center gap-2 rounded-2xl bg-primary text-lg font-semibold text-primary-foreground shadow-lg transition hover:-translate-y-0.5 hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
>
{verifyMutation.isPending ? (
"Verifying..."
) : (
<>
Verify Account
<ArrowRight className="size-5" />
</>
)}
</button>
{/* Resend */}
<button
type="button"
onClick={() =>
resendMutation.mutate()
}
disabled={
resendMutation.isPending
}
className="flex h-14 w-full items-center justify-center gap-2 rounded-2xl border border-border bg-background text-base font-semibold transition hover:bg-muted disabled:cursor-not-allowed disabled:opacity-60"
>
{resendMutation.isPending ? (
"Sending..."
) : (
<>
<RotateCw className="size-5" />
Resend Code
</>
)}
</button>
{/* Footer */}
<p className="text-center text-sm text-muted-foreground">
Didnt receive
the code?
<button
type="button"
onClick={() =>
resendMutation.mutate()
}
className="ml-2 font-semibold text-primary hover:underline"
>
Send again
</button>
</p>
</form>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,81 @@
import { URL_CONSTANTS } from "@/constants/URLS";
import { CreateUserPayload } from "@/types/createUser";
import { VerificationCodePayload } from "@/types/generateVerificationCode";
import { UserTypeRequest } from "@/types/userTypeRequest";
import { client } from "@/utils/api";
import { ApiResponse } from "@edr/types";
import { GenerateVerifcationCodePayload } from "node_modules/@tria-plc/iamui-common/dist/types/shared/services/authService";
// -----------------------------------------------------------------------------
// API
// -----------------------------------------------------------------------------
export const createUser = async (
body: CreateUserPayload
) => {
const res =
await client.post<
ApiResponse<any>
>(
URL_CONSTANTS.USERS.SIGN_UP,
body
);
return res.data;
};
export const generateVerificationCode = async (
body: VerificationCodePayload
) => {
const res =
await client.patch<
ApiResponse<string>
>(
URL_CONSTANTS.USERS.GENERATE_VERIFICATION_CODE,
body
);
return res.data.data;
};
export const setPassword = async (
body: any
) => {
const res =
await client.patch<
ApiResponse<string>
>(
URL_CONSTANTS.USERS.SET_PASSWORD,
body
);
return res.data.data;
};
export const createOTP = async (
body: any
) => {
const res =
await client.post<
ApiResponse<any>
>(
URL_CONSTANTS.OTP.SEND,
body
);
return res.data;
};
export const verifyOTP = async (
body: any
) => {
const res =
await client.post<
ApiResponse<any>
>(
URL_CONSTANTS.OTP.VERIFY,
body
);
return res.data;
};

View File

@@ -133,5 +133,5 @@ export const getFileUploadSettingByCode = endpoint<string, FileUploadSetting>(
.get<
ApiResponse<FileUploadSetting>
>(`${URL_CONSTANTS.FILES.FILE_UPLOAD_SETTINGS_BY_CODE}/${code}`)
.then((res) => res.data.data),
.then((res: any) => res.data.data),
);

View File

@@ -0,0 +1,10 @@
export type CreateUserPayload = {
email: string;
username: string;
phoneNumber: string;
userType: string;
name: {
en: string;
am?: string;
};
};

View File

@@ -0,0 +1,5 @@
export type VerificationCodePayload = {
email: string;
phoneNumber: string;
type: string;
};

View File

@@ -0,0 +1,10 @@
export type UserTypeRequest {
email: string;
username: string;
phoneNumber: string;
userType: string;
name: {
am?: string;
en: string;
};
}

View File

@@ -1,5 +1,6 @@
import {
UseQueryOptions,
UseMutationOptions
} from "@tanstack/react-query";
// ---------------------------------------------------------------------------
@@ -70,10 +71,34 @@ export function endpoint<TInput, TResponse>(
};
};
const mutationOptions = (
config?: Omit<
UseMutationOptions<
TResponse,
Error,
TInput
>,
"mutationFn"
>,
): UseMutationOptions<
TResponse,
Error,
TInput
> => {
return {
...config,
mutationFn: (
variables: TInput,
): Promise<TResponse> =>
execute(variables),
};
};
return {
call,
queryKey,
queryOptions,
mutationOptions
};
}