Merge pull request #1215 from Tria-plc/staging

Staging
This commit is contained in:
Nathnael Wondisha
2026-08-10 12:35:50 +03:00
committed by GitHub
30 changed files with 630 additions and 433 deletions

View File

@@ -456,6 +456,36 @@ export class CompaniesController {
return this.companiesService.clearGmIdentity(user.id); return this.companiesService.clearGmIdentity(user.id);
} }
@Post("identity/poa/same-as-owner")
@PortalCustomer()
@ApiOperation({
summary:
"Declare the Power of Attorney is the company's owner, copying the owner's identity across. " +
"Waives the DARS delegation paper — nobody delegates to themselves. " +
"Refused for an Ethiopian company whose owner is not Fayda-verified yet: its representative must be verified, and there would be nothing proven to copy.",
})
async setPoaSameAsOwner(
@CurrentUser() user: CurrentIamUser,
): Promise<CompanyIdentityStateDto> {
return this.companiesService.setPoaSameAsOwner(user.id, {
email: user.email,
phoneNumber: user.phoneNumber,
});
}
@Delete("identity/poa/same-as-owner")
@PortalCustomer()
@ApiOperation({
summary:
"Undo the Power of Attorney \"same as owner\" declaration and the identity it copied, leaving the representative open to be verified in their own right. " +
"Unlike DELETE identity/fayda/poa this is allowed for a freight forwarder — it is how they change who represents them — and leaves the delegation paper on file.",
})
async clearPoaSameAsOwner(
@CurrentUser() user: CurrentIamUser,
): Promise<CompanyIdentityStateDto> {
return this.companiesService.clearPoaSameAsOwner(user.id);
}
@Delete("identity/fayda/poa") @Delete("identity/fayda/poa")
@PortalCustomer() @PortalCustomer()
@ApiOperation({ @ApiOperation({

View File

@@ -229,8 +229,10 @@ describe("Fayda identity verification binds a person to the company", () => {
expect(state.owner.verified).toBe(true); expect(state.owner.verified).toBe(true);
}); });
it("refuses to make one identity both owner and PoA", async () => { it("lets one identity be both owner and PoA", async () => {
const { service } = makeService({ // An owner who represents their own company is the ordinary small-business
// case, not a conflict — the same answer the GM has always been allowed.
const { service, ctx } = makeService({
attributes: { ownerFaydaSub: "same-person" }, attributes: { ownerFaydaSub: "same-person" },
verification: { verification: {
purpose: "VERIFY", purpose: "VERIFY",
@@ -240,13 +242,14 @@ describe("Fayda identity verification binds a person to the company", () => {
}, },
}); });
await expect( const state = await service.completeIdentityVerification("user-1", {
service.completeIdentityVerification("user-1", { subject: "poa",
subject: "poa", code: "c",
code: "c", state: "s",
state: "s", });
}),
).rejects.toBeInstanceOf(BadRequestException); expect(state.poa.verified).toBe(true);
expect(ctx.attributes.poaFaydaSub).toBe("same-person");
}); });
it("stages an owner re-verification for review on an approved company", async () => { it("stages an owner re-verification for review on an approved company", async () => {
@@ -701,9 +704,8 @@ describe("Ethiopian companies verify with Fayda; foreign companies verify identi
}); });
it("lets the GM verify as the same human as the owner", async () => { it("lets the GM verify as the same human as the owner", async () => {
// The owner/PoA collision check exists because self-delegation is not // One human in every role is the ordinary small-business shape, so
// delegation. It must not fire here: the GM being the owner is a supported // verifying with the owner's own Fayda sub has to succeed.
// answer, so verifying with the owner's own Fayda sub has to succeed.
const { service, ctx } = makeService({ const { service, ctx } = makeService({
attributes: { ...OWNER_VERIFIED }, attributes: { ...OWNER_VERIFIED },
verification: { verification: {
@@ -727,25 +729,42 @@ describe("Ethiopian companies verify with Fayda; foreign companies verify identi
expect(ctx.attributes.generalManagerName).toBe("Abebe Bikila"); expect(ctx.attributes.generalManagerName).toBe("Abebe Bikila");
}); });
it("still refuses a PoA who is the owner", async () => { it("declares the PoA is the owner, copying the verified identity across", async () => {
// The GM exemption above must not have widened into the PoA. const { service, ctx } = makeService({ attributes: { ...OWNER_VERIFIED } });
const { service } = makeService({
attributes: { ...OWNER_VERIFIED }, const state = await service.setPoaSameAsOwner("user-1");
verification: {
purpose: "VERIFY", expect(state.poaSameAsOwner).toBe(true);
verified: true, expect(state.poa.verified).toBe(true);
sub: "owner-sub", expect(ctx.attributes.poaFaydaSub).toBe(OWNER_VERIFIED.ownerFaydaSub);
fullName: "Abebe Bikila", expect(ctx.attributes.poaName).toBe(OWNER_VERIFIED.ownerName);
}, });
it("refuses to declare the PoA is the owner while an Ethiopian owner is unverified", async () => {
// Its representative must be Fayda-verified, so a declaration here would
// record one that could never satisfy the gate.
const { service } = makeService({ attributes: {} });
await expect(service.setPoaSameAsOwner("user-1")).rejects.toBeInstanceOf(
BadRequestException,
);
});
it("undoes the PoA \"same as owner\" declaration without touching a real verification", async () => {
const { service, ctx } = makeService({
attributes: { ...OWNER_VERIFIED, ...POA_VERIFIED },
}); });
await expect( // No declaration in place: the verified representative must survive.
service.completeIdentityVerification("user-1", { await service.clearPoaSameAsOwner("user-1");
subject: "poa", expect(ctx.attributes.poaFaydaSub).toBe(POA_VERIFIED.poaFaydaSub);
code: "c",
state: "s", await service.setPoaSameAsOwner("user-1");
}), const state = await service.clearPoaSameAsOwner("user-1");
).rejects.toBeInstanceOf(BadRequestException);
expect(state.poaSameAsOwner).toBe(false);
expect(state.poa.verified).toBe(false);
expect(ctx.attributes.poaFaydaSub).toBeNull();
}); });
it("reports a pre-existing typed GM as unverified rather than blank", async () => { it("reports a pre-existing typed GM as unverified rather than blank", async () => {

View File

@@ -174,6 +174,31 @@ describe("PoA delegation paper is enforced wherever PoA state changes", () => {
).resolves.toBeDefined(); ).resolves.toBeDefined();
}); });
it("waives the paper when the owner represents the company themselves", async () => {
// Nobody delegates to themselves, so a self-declared PoA owes no DARS
// paper — the representative's own details are still required.
const { service } = makeService({
attributes: { ...VERIFIED_IDENTITIES, poaSameAsOwner: true },
});
await expect(
service.updateProfile("user-1", POA as never),
).resolves.toBeDefined();
});
it("grants the forwarder role to a self-represented company with no paper", async () => {
const { service } = makeService({
attributes: { ...VERIFIED_IDENTITIES, ...POA, poaSameAsOwner: true },
});
await expect(
service.createCompanyProfileForUser(
"user-1",
ProfileType.freightForwarder,
),
).resolves.toBeDefined();
});
it("rejects a paper the reviewer sent back for correction", async () => { it("rejects a paper the reviewer sent back for correction", async () => {
const { service } = makeService({ files: [paper("change_requested")] }); const { service } = makeService({ files: [paper("change_requested")] });

View File

@@ -133,12 +133,6 @@ const IDENTITY_PREFIX: Record<IdentitySubject, string> = {
gm: "gm", gm: "gm",
}; };
const IDENTITY_LABEL: Record<IdentitySubject, string> = {
owner: "owner",
poa: "Power of Attorney",
gm: "General Manager",
};
/** /**
* Typed GM columns a GM verification also writes. Three notifier services mail * Typed GM columns a GM verification also writes. Three notifier services mail
* `company.generalManagerEmail` directly, so leaving these behind would mean a * `company.generalManagerEmail` directly, so leaving these behind would mean a
@@ -2046,7 +2040,13 @@ export class CompaniesService {
(company.attributes?.[k] as string | undefined)?.trim(), (company.attributes?.[k] as string | undefined)?.trim(),
); );
const delegation = await this.getPoaDelegationState(company.id); const delegation = await this.getPoaDelegationState(company.id);
const delegationDue = poaRequired || poaProvided; // "There is a representative" and "a paper is owed for them" used to be the
// same condition. They part company once the owner represents the company
// themselves: the representative's details are still required, but nobody
// delegates to themselves, so no DARS paper is due (`assertPoaDelegationSatisfied`
// returns on the same flag — the two must agree).
const poaDue = poaRequired || poaProvided;
const delegationDue = poaDue && !identity.poaSameAsOwner;
// The representative's details normally arrive from their Fayda // The representative's details normally arrive from their Fayda
// verification — but Fayda's email and phone claims are optional and // verification — but Fayda's email and phone claims are optional and
// routinely come back empty, and the PoA step renders an input for whatever // routinely come back empty, and the PoA step renders an input for whatever
@@ -2055,7 +2055,7 @@ export class CompaniesService {
// nothing here let a freight forwarder finish onboarding with a // nothing here let a freight forwarder finish onboarding with a
// representative the API's own `REQUIRED_POA_FIELDS` calls incomplete, then // representative the API's own `REQUIRED_POA_FIELDS` calls incomplete, then
// 400'd their next PoA edit for it. // 400'd their next PoA edit for it.
const missingPoaFields = delegationDue const missingPoaFields = poaDue
? REQUIRED_POA_FIELDS.filter( ? REQUIRED_POA_FIELDS.filter(
(f) => !(company.attributes?.[f.key] as string | undefined)?.trim(), (f) => !(company.attributes?.[f.key] as string | undefined)?.trim(),
) )
@@ -2115,7 +2115,8 @@ export class CompaniesService {
// The delegation paper plus the representative's own required details — // The delegation paper plus the representative's own required details —
// `completed` below subtracts every one of those it is still missing, so // `completed` below subtracts every one of those it is still missing, so
// leaving them out of the total would make the bar understate progress. // leaving them out of the total would make the bar understate progress.
const poaItemCount = delegationDue ? 1 + REQUIRED_POA_FIELDS.length : 0; const poaItemCount =
(poaDue ? REQUIRED_POA_FIELDS.length : 0) + (delegationDue ? 1 : 0);
// One item per identity credential the company has to prove: the owner // One item per identity credential the company has to prove: the owner
// always (Fayda for Ethiopian, passport for foreign), plus the PoA once // always (Fayda for Ethiopian, passport for foreign), plus the PoA once
// there is one — Fayda for an Ethiopian company, a named representative // there is one — Fayda for an Ethiopian company, a named representative
@@ -2127,11 +2128,10 @@ export class CompaniesService {
const ownerCredentialProven = identity.faydaRequired const ownerCredentialProven = identity.faydaRequired
? identity.owner.verified ? identity.owner.verified
: Boolean(identity.owner.passportNumber); : Boolean(identity.owner.passportNumber);
const identityItemCount = const identityItemCount = (ownerCredentialDue ? 1 : 0) + (poaDue ? 1 : 0);
(ownerCredentialDue ? 1 : 0) + (delegationDue ? 1 : 0);
const missingIdentityCount = const missingIdentityCount =
(ownerCredentialDue && !ownerCredentialProven ? 1 : 0) + (ownerCredentialDue && !ownerCredentialProven ? 1 : 0) +
(delegationDue && !poaProven ? 1 : 0); (poaDue && !poaProven ? 1 : 0);
const total = const total =
requiredInfo.length + requiredInfo.length +
requiredDocCount + requiredDocCount +
@@ -2159,6 +2159,7 @@ export class CompaniesService {
poa: { poa: {
required: poaRequired, required: poaRequired,
provided: poaProvided, provided: poaProvided,
delegationLetterRequired: delegationDue,
delegationLetterUploaded: delegation.onFile, delegationLetterUploaded: delegation.onFile,
delegationLetterFlagged: delegation.flagged, delegationLetterFlagged: delegation.flagged,
missingFields: missingPoaFields, missingFields: missingPoaFields,
@@ -2707,6 +2708,12 @@ export class CompaniesService {
} }
} }
// Nobody delegates to themselves: an owner representing their own company
// has no delegation to evidence, so the DARS paper is not owed. The
// representative's own details are still required above — a forwarder's
// counterparties need someone to contact either way.
if (attributes?.poaSameAsOwner) return;
const { onFile, flagged } = await this.getPoaDelegationState( const { onFile, flagged } = await this.getPoaDelegationState(
companyId, companyId,
opts.ignoreFileIds, opts.ignoreFileIds,
@@ -2783,21 +2790,11 @@ export class CompaniesService {
); );
} }
// The owner delegating power of attorney to themselves is not a // An owner who is also the company's representative is a supported answer,
// delegation — it would let one identity satisfy both halves of the check. // not a conflict — the same way the GM is very often the owner. Small
// Only owner/PoA collide this way: the GM is very often the owner, and // companies routinely have one human in all three roles, and the portal's
// saying so is a supported answer rather than a conflict, so it is left out // "same as owner" cards exist precisely so they can say so. No identity
// of this check entirely. // here is refused for colliding with another.
if (dto.subject === "owner" || dto.subject === "poa") {
const other: IdentitySubject = dto.subject === "poa" ? "owner" : "poa";
const otherSub =
company.attributes?.[`${IDENTITY_PREFIX[other]}FaydaSub`];
if (otherSub && otherSub === result.sub) {
throw new BadRequestException(
`This identity is already registered as the company's ${IDENTITY_LABEL[other]}. The Power of Attorney must be a different person from the owner.`,
);
}
}
const now = new Date().toISOString(); const now = new Date().toISOString();
@@ -2837,6 +2834,12 @@ export class CompaniesService {
// mail `generalManagerEmail`), and clears any earlier "same as owner" // mail `generalManagerEmail`), and clears any earlier "same as owner"
// declaration — verifying in their own right is the GM answering for // declaration — verifying in their own right is the GM answering for
// themselves. // themselves.
// Verifying the representative in their own right answers the question the
// "same as owner" declaration answered, so the declaration goes.
if (dto.subject === "poa") {
identity.poaSameAsOwner = false;
}
if (dto.subject === "gm") { if (dto.subject === "gm") {
identity.gmSameAsOwner = false; identity.gmSameAsOwner = false;
if (result.fullName) identity.generalManagerName = result.fullName; if (result.fullName) identity.generalManagerName = result.fullName;
@@ -2953,6 +2956,118 @@ export class CompaniesService {
return this.getCompanyIdentityState(updated); return this.getCompanyIdentityState(updated);
} }
/**
* Declare that the company's Power of Attorney is its owner.
*
* An owner representing their own company is the ordinary case for a small
* business, so this is a supported answer rather than the conflict it used to
* be refused as. Two shapes, matching {@link setGmSameAsOwner}:
*
* - A Fayda-verified owner is a proven identity, so it is copied outright —
* the representative inherits the verification instead of the same human
* being sent through Fayda a second time.
* - A foreign company's owner is backed by a typed passport, so there is
* nothing proven to copy. The declaration is still recorded (it is what
* waives the DARS paper) and whatever owner details exist come across; the
* portal types the rest, which `poaProven` accepts for a foreign company.
*
* Refused for an Ethiopian company whose owner is not verified yet: Fayda is
* mandatory for its representative, so a declaration there would record a
* representative that could never satisfy the gate.
*/
async setPoaSameAsOwner(
userId: string,
/** Same fallback as {@link completeIdentityVerification} — an owner whose
* Fayda claims carried no email/phone has none stored, and copying blanks
* onto a freight forwarder's PoA would block the submit on
* `REQUIRED_POA_FIELDS`. */
account?: { email?: string; phoneNumber?: string },
): Promise<CompanyIdentityStateDto> {
const { company } = await this.getCompanyInfoByUserId(userId);
const attrs = company.attributes ?? {};
const state = buildCompanyIdentityState(company);
const ownerSub = attrs.ownerFaydaSub as string | undefined;
if (state.faydaRequired && !ownerSub) {
throw new BadRequestException(
"Verify the company owner with Fayda first — there is no proven identity to reuse yet.",
);
}
const ownerEmail = (attrs.ownerEmail as string | undefined) || account?.email;
const ownerPhone =
(attrs.ownerPhone as string | undefined) || account?.phoneNumber;
// Only non-blank values are copied: a blank here would overwrite something
// the portal typed for a foreign company, whose owner has no verified
// claims to draw on.
const copied: Record<string, unknown> = { poaSameAsOwner: true };
const copy = (key: string, value: unknown) => {
if (value !== null && value !== undefined && value !== "")
copied[key] = value;
};
copy("poaName", attrs.ownerName);
copy("poaEmail", ownerEmail);
copy("poaPhone", ownerPhone ? normalizeE164(ownerPhone) : undefined);
copy("poaAddress", attrs.ownerAddress);
if (ownerSub) {
copied.poaFaydaSub = ownerSub;
copied.poaFaydaVerifiedAt =
attrs.ownerFaydaVerifiedAt ?? new Date().toISOString();
copy("poaBirthdate", attrs.ownerBirthdate);
copy("poaGender", attrs.ownerGender);
}
const updated = await this.companiesRepo.update(company.id, {
attributes: { ...attrs, ...copied },
});
if (!updated)
throw new NotFoundException(`Company ${company.id} not found`);
updated.companyProfiles = company.companyProfiles;
return this.getCompanyIdentityState(updated);
}
/**
* Undo the PoA "same as owner" declaration, clearing the identity it copied
* so a different representative can be verified (or typed, for a foreign
* company).
*
* Separate from {@link removePoaIdentity}, which drops the representative and
* their paper and is refused to a freight forwarder. Undoing a declaration is
* how a forwarder changes its mind about who represents it, so it must stay
* open to them — the submit gate still refuses a forwarder that never names a
* replacement. The delegation paper is left alone for the same reason: the
* company still owes one, now for whoever comes next.
*
* A no-op when no declaration is in place: a stray call must not wipe a
* representative who verified in their own right.
*/
async clearPoaSameAsOwner(userId: string): Promise<CompanyIdentityStateDto> {
const { company } = await this.getCompanyInfoByUserId(userId);
const attrs = { ...(company.attributes ?? {}) };
if (!attrs.poaSameAsOwner) return this.getCompanyIdentityState(company);
attrs.poaSameAsOwner = false;
for (const key of [
...POA_ATTRIBUTES,
"poaFaydaSub",
"poaFaydaVerifiedAt",
"poaBirthdate",
"poaGender",
]) {
attrs[key] = null;
}
const updated = await this.companiesRepo.update(company.id, {
attributes: attrs,
});
if (!updated)
throw new NotFoundException(`Company ${company.id} not found`);
updated.companyProfiles = company.companyProfiles;
return this.getCompanyIdentityState(updated);
}
/** /**
* Drop the Power of Attorney entirely — the verified identity, the details it * Drop the Power of Attorney entirely — the verified identity, the details it
* wrote and the delegation paper together. * wrote and the delegation paper together.
@@ -2974,7 +3089,7 @@ export class CompaniesService {
); );
} }
const cleared: Record<string, unknown> = {}; const cleared: Record<string, unknown> = { poaSameAsOwner: false };
for (const key of [ for (const key of [
...POA_ATTRIBUTES, ...POA_ATTRIBUTES,
"poaFaydaSub", "poaFaydaSub",

View File

@@ -75,6 +75,12 @@ export class CompanyIdentityStateDto {
@ApiProperty({ type: IdentityVerificationStateDto }) @ApiProperty({ type: IdentityVerificationStateDto })
poa!: IdentityVerificationStateDto; poa!: IdentityVerificationStateDto;
@ApiProperty({
description:
"True when the Power of Attorney is the company's owner, declared through the portal's \"same as owner\" copy. Waives the DARS delegation paper — nobody delegates to themselves.",
})
poaSameAsOwner!: boolean;
@ApiProperty({ @ApiProperty({
type: IdentityVerificationStateDto, type: IdentityVerificationStateDto,
description: description:
@@ -196,6 +202,7 @@ export function buildCompanyIdentityState(
const gm = stateFor(attrs, "gm"); const gm = stateFor(attrs, "gm");
const gmSameAsOwner = Boolean(attrs.gmSameAsOwner); const gmSameAsOwner = Boolean(attrs.gmSameAsOwner);
const poaSameAsOwner = Boolean(attrs.poaSameAsOwner);
const ownerProven = faydaRequired const ownerProven = faydaRequired
? owner.verified ? owner.verified
@@ -220,6 +227,7 @@ export function buildCompanyIdentityState(
passportRequired, passportRequired,
owner, owner,
poa, poa,
poaSameAsOwner,
gm, gm,
gmSameAsOwner, gmSameAsOwner,
complete, complete,

View File

@@ -42,6 +42,12 @@ export interface OnboardingPoaState {
required: boolean; required: boolean;
/** True once any PoA detail has been entered. */ /** True once any PoA detail has been entered. */
provided: boolean; provided: boolean;
/**
* True when the DARS delegation paper is owed — a PoA exists (or is
* mandatory) and is not the owner themselves. An owner representing their own
* company delegates to nobody, so there is no delegation to evidence.
*/
delegationLetterRequired: boolean;
/** True when the DARS delegation paper is stored for the company. */ /** True when the DARS delegation paper is stored for the company. */
delegationLetterUploaded: boolean; delegationLetterUploaded: boolean;
/** True when a reviewer sent the paper back for correction. */ /** True when a reviewer sent the paper back for correction. */

View File

@@ -1,11 +1,13 @@
@import "tailwindcss"; @import "tailwindcss";
@import "@edr/ui-common/theme.css" layer(theme); @import "@edr/ui-common/theme.css" layer(theme);
/* The app toggles dark mode by setting the `dark` class on <html> (see /* The backoffice is light-only — there is no theme toggle and nothing sets the
main.tsx / FreightDashboardLayout). Without this, Tailwind v4 compiles `dark` class. This override is load-bearing: without it Tailwind v4 compiles
`dark:` utilities to `@media (prefers-color-scheme: dark)` and they follow `dark:` utilities to `@media (prefers-color-scheme: dark)`, so every
the OS setting instead of the in-app toggle. */ leftover `dark:` class (the vendored IAM UI is full of them) would activate
@custom-variant dark (&:where(.dark, .dark *)); on an OS-level dark setting. Binding the variant to a class that is never
rendered keeps those utilities inert no matter what the OS says. */
@custom-variant dark (&:where(.edr-dark-disabled));
/* Bridge the central Mantine theme into Tailwind. freightMantineTheme /* Bridge the central Mantine theme into Tailwind. freightMantineTheme
(createTheme) is the single source of truth; these just alias its generated (createTheme) is the single source of truth; these just alias its generated

View File

@@ -143,7 +143,6 @@ const DashboardShell = () => {
sidebarSections={sidebarSections} sidebarSections={sidebarSections}
activeHref={location.pathname} activeHref={location.pathname}
onNavigate={navigate} onNavigate={navigate}
enableThemeToggle
userName={displayName} userName={displayName}
userEmail={user?.email} userEmail={user?.email}
onLogout={logout} onLogout={logout}

View File

@@ -15,9 +15,7 @@ import {
FileSignature, FileSignature,
Languages, Languages,
LogOut, LogOut,
Moon,
Search, Search,
Sun,
User, User,
} from "lucide-react"; } from "lucide-react";
import { type ReactNode } from "react"; import { type ReactNode } from "react";
@@ -31,13 +29,10 @@ import type { PageMeta } from "./types";
export interface FreightDashboardHeaderProps { export interface FreightDashboardHeaderProps {
pageMeta: PageMeta; pageMeta: PageMeta;
headerRight?: ReactNode; headerRight?: ReactNode;
enableThemeToggle?: boolean;
userName?: string; userName?: string;
userEmail?: string; userEmail?: string;
userInitials?: string; userInitials?: string;
onLogout?: () => void; onLogout?: () => void;
theme: "light" | "dark";
onToggleTheme: () => void;
mobileOpened: boolean; mobileOpened: boolean;
onToggleMobile: () => void; onToggleMobile: () => void;
/** Hide the mobile burger when the shell has no sidebar to open. */ /** Hide the mobile burger when the shell has no sidebar to open. */
@@ -51,13 +46,10 @@ const ISLAND =
const FreightDashboardHeader = ({ const FreightDashboardHeader = ({
headerRight, headerRight,
enableThemeToggle = false,
userName = "User", userName = "User",
userEmail, userEmail,
userInitials, userInitials,
onLogout, onLogout,
theme,
onToggleTheme,
mobileOpened, mobileOpened,
onToggleMobile, onToggleMobile,
hideSidebarBurger = false, hideSidebarBurger = false,
@@ -129,26 +121,6 @@ const FreightDashboardHeader = ({
<NotificationBellContainer /> <NotificationBellContainer />
{enableThemeToggle && (
<Tooltip
label={theme === "dark" ? "Light mode" : "Dark mode"}
withArrow
openDelay={300}
>
<UnstyledButton
className={ISLAND}
onClick={onToggleTheme}
aria-label="Toggle theme"
>
{theme === "dark" ? (
<Sun size={17} strokeWidth={1.8} />
) : (
<Moon size={17} strokeWidth={1.8} />
)}
</UnstyledButton>
</Tooltip>
)}
{/* Avatar pill */} {/* Avatar pill */}
<Menu width={240} position="bottom-end" withinPortal shadow="md" offset={8} radius="md"> <Menu width={240} position="bottom-end" withinPortal shadow="md" offset={8} radius="md">
<Menu.Target> <Menu.Target>

View File

@@ -1,26 +1,15 @@
import { AppShell, Box } from "@mantine/core"; import { AppShell, Box } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";
import { type ReactNode, useEffect, useState } from "react"; import { type ReactNode } from "react";
import FreightDashboardHeader from "./FreightDashboardHeader"; import FreightDashboardHeader from "./FreightDashboardHeader";
import FreightSidebar from "./FreightSidebar"; import FreightSidebar from "./FreightSidebar";
import { getPageMeta } from "./route-meta"; import { getPageMeta } from "./route-meta";
import type { SidebarSection } from "./types"; import type { SidebarSection } from "./types";
type Theme = "light" | "dark";
const THEME_STORAGE_KEY = "edr-theme";
const HEADER_HEIGHT = 64; const HEADER_HEIGHT = 64;
const NAVBAR_WIDTH = 280; const NAVBAR_WIDTH = 280;
function getInitialTheme(): Theme {
if (typeof window === "undefined") return "light";
const stored = window.localStorage.getItem(THEME_STORAGE_KEY);
if (stored === "dark" || stored === "light") return stored;
return window.matchMedia?.("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
}
export interface FreightDashboardLayoutProps { export interface FreightDashboardLayoutProps {
sidebarSections: SidebarSection[]; sidebarSections: SidebarSection[];
/** Render the shell with no navbar at all (used by GL clearance-only users). */ /** Render the shell with no navbar at all (used by GL clearance-only users). */
@@ -28,7 +17,6 @@ export interface FreightDashboardLayoutProps {
activeHref?: string; activeHref?: string;
onNavigate?: (href: string) => void; onNavigate?: (href: string) => void;
headerRight?: ReactNode; headerRight?: ReactNode;
enableThemeToggle?: boolean;
userName?: string; userName?: string;
userEmail?: string; userEmail?: string;
userInitials?: string; userInitials?: string;
@@ -42,7 +30,6 @@ const FreightDashboardLayout = ({
activeHref = "", activeHref = "",
onNavigate, onNavigate,
headerRight, headerRight,
enableThemeToggle = false,
userName, userName,
userEmail, userEmail,
userInitials, userInitials,
@@ -53,20 +40,6 @@ const FreightDashboardLayout = ({
const [mobileOpened, { toggle: toggleMobile, close: closeMobile }] = const [mobileOpened, { toggle: toggleMobile, close: closeMobile }] =
useDisclosure(false); useDisclosure(false);
const [theme, setTheme] = useState<Theme>(() =>
enableThemeToggle ? getInitialTheme() : "light",
);
useEffect(() => {
if (!enableThemeToggle) return;
const root = document.documentElement;
root.classList.toggle("dark", theme === "dark");
window.localStorage.setItem(THEME_STORAGE_KEY, theme);
}, [theme, enableThemeToggle]);
const toggleTheme = () =>
setTheme((current) => (current === "dark" ? "light" : "dark"));
const navigate = (href: string) => { const navigate = (href: string) => {
closeMobile(); closeMobile();
onNavigate?.(href); onNavigate?.(href);
@@ -93,13 +66,10 @@ const FreightDashboardLayout = ({
<FreightDashboardHeader <FreightDashboardHeader
pageMeta={pageMeta} pageMeta={pageMeta}
headerRight={headerRight} headerRight={headerRight}
enableThemeToggle={enableThemeToggle}
userName={userName} userName={userName}
userEmail={userEmail} userEmail={userEmail}
userInitials={userInitials} userInitials={userInitials}
onLogout={onLogout} onLogout={onLogout}
theme={theme}
onToggleTheme={toggleTheme}
mobileOpened={mobileOpened} mobileOpened={mobileOpened}
onToggleMobile={toggleMobile} onToggleMobile={toggleMobile}
hideSidebarBurger={hideSidebar} hideSidebarBurger={hideSidebar}

View File

@@ -6,8 +6,6 @@ import {
LogOut, LogOut,
User, User,
Key, Key,
Moon,
Sun,
Home, Home,
Menu, Menu,
} from "lucide-react"; } from "lucide-react";
@@ -25,7 +23,6 @@ import { NavLink, useNavigate, useLocation } from "react-router-dom";
import { useRef, useEffect, useState } from "react"; import { useRef, useEffect, useState } from "react";
import { FiBell } from "react-icons/fi"; import { FiBell } from "react-icons/fi";
import { useUser } from "@/shared/context/UserContext"; import { useUser } from "@/shared/context/UserContext";
import { useDarkMode } from "@/shared/hooks/useDarkMode";
import { import {
UI_LANGUAGE_OPTIONS, UI_LANGUAGE_OPTIONS,
getUiLanguageLabel, getUiLanguageLabel,
@@ -49,7 +46,6 @@ export const TopBar = () => {
const { pathname } = useLocation(); const { pathname } = useLocation();
const { logout } = useAuthUser(); const { logout } = useAuthUser();
const userDetails = useUser(); const userDetails = useUser();
const { isDarkMode, toggleDarkMode } = useDarkMode();
const { config: tenantConfig } = useTenantConfig(); const { config: tenantConfig } = useTenantConfig();
const moduleConfig = resolveModuleConfig(tenantConfig); const moduleConfig = resolveModuleConfig(tenantConfig);
const isAdminModuleVisible = userDetails?.roles?.some( const isAdminModuleVisible = userDetails?.roles?.some(
@@ -66,7 +62,6 @@ export const TopBar = () => {
moduleConfig.siteManagement && isAdminModuleVisible, moduleConfig.siteManagement && isAdminModuleVisible,
].filter(Boolean).length; ].filter(Boolean).length;
const shouldShowHomePageLink = visibleModuleCount > 1; const shouldShowHomePageLink = visibleModuleCount > 1;
const isObjectiveManagementRoute = pathname.startsWith("/objective-management");
const fullName = userDetails?.name?.en || t("user"); const fullName = userDetails?.name?.en || t("user");
const splittedName = fullName.trim().split(" "); const splittedName = fullName.trim().split(" ");
const initials = const initials =
@@ -269,19 +264,6 @@ export const TopBar = () => {
)} )}
</div> </div>
<Button
onClick={toggleDarkMode}
aria-label="Toggle Dark Mode"
className="p-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors duration-200"
title={isDarkMode ? "Switch to light mode" : "Switch to dark mode"}
style={{ display: isObjectiveManagementRoute ? "none" : "block" }}>
{isDarkMode ? (
<Sun className="h-5 w-5 text-yellow-500" />
) : (
<Moon className="h-5 w-5 text-gray-600" />
)}
</Button>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button

View File

@@ -18,11 +18,8 @@ import {
Info, Info,
Menu, Menu,
X, X,
Moon,
Sun,
} from "lucide-react"; } from "lucide-react";
import { useUser } from "@/shared/context/UserContext"; import { useUser } from "@/shared/context/UserContext";
import { useDarkMode } from "@/shared/hooks/useDarkMode";
import { useTenantConfig, resolveModuleConfig } from "@/layout/components/TenantConfig"; import { useTenantConfig, resolveModuleConfig } from "@/layout/components/TenantConfig";
import { import {
UI_LANGUAGE_OPTIONS, UI_LANGUAGE_OPTIONS,
@@ -55,7 +52,6 @@ const Header = () => {
const location = useLocation(); const location = useLocation();
const isComplaintContext = isComplaintAuthContext(location.pathname); const isComplaintContext = isComplaintAuthContext(location.pathname);
const { isAuthenticated, showExternalPortalChrome } = useExternalPortalSession(); const { isAuthenticated, showExternalPortalChrome } = useExternalPortalSession();
const { isDarkMode, toggleDarkMode } = useDarkMode();
const { config: tenantConfig } = useTenantConfig(); const { config: tenantConfig } = useTenantConfig();
const moduleConfig = resolveModuleConfig(tenantConfig); const moduleConfig = resolveModuleConfig(tenantConfig);
@@ -480,22 +476,6 @@ const Header = () => {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
{/* Dark Mode Toggle */}
<motion.button
onClick={toggleDarkMode}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
className="p-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors duration-200"
title={
isDarkMode ? "Switch to light mode" : "Switch to dark mode"
}
>
{isDarkMode ? (
<Sun className="h-5 w-5 text-yellow-500" />
) : (
<Moon className="h-5 w-5 text-gray-600" />
)}
</motion.button>
<motion.button <motion.button
onClick={handleSignIn} onClick={handleSignIn}
whileHover={{ whileHover={{
@@ -646,26 +626,6 @@ const Header = () => {
</Select> </Select>
</div> </div>
{/* Dark Mode Toggle for Mobile */}
<motion.button
onClick={toggleDarkMode}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
className="block w-full px-4 py-3 text-base font-medium text-center text-gray-700 dark:text-gray-200 bg-gray-100 dark:bg-gray-700 rounded-lg shadow hover:bg-gray-200 dark:hover:bg-gray-600 transition-all duration-200 flex items-center justify-center gap-2"
>
{isDarkMode ? (
<>
<Sun className="h-5 w-5 text-yellow-500" />
{t("landingPage.lightMode")}
</>
) : (
<>
<Moon className="h-5 w-5 text-gray-600" />
{t("landingPage.darkMode")}
</>
)}
</motion.button>
<motion.button <motion.button
onClick={() => { onClick={() => {
handleSignIn(); handleSignIn();

View File

@@ -31,24 +31,19 @@ import { QueryClientProvider } from "@tanstack/react-query";
const THEME_STORAGE_KEY = "edr-theme"; const THEME_STORAGE_KEY = "edr-theme";
const applyStoredTheme = () => { /**
const storedTheme = window.localStorage.getItem(THEME_STORAGE_KEY); * The backoffice is light-only. This runs before render for two reasons:
const prefersDark = window.matchMedia?.("(prefers-color-scheme: dark)").matches; * strip the `dark` class in case anything ever adds it, and evict the
const theme = storedTheme === "dark" || storedTheme === "light" * `edr-theme` key that earlier builds persisted — without the eviction, a user
? storedTheme * who had toggled dark before this change keeps a "dark" value in
: prefersDark * localStorage that any future re-read would resurrect.
? "dark" */
: "light"; const forceLightTheme = () => {
if (theme === "dark") {
document.documentElement.classList.add("dark");
return;
}
document.documentElement.classList.remove("dark"); document.documentElement.classList.remove("dark");
window.localStorage.removeItem(THEME_STORAGE_KEY);
}; };
applyStoredTheme(); forceLightTheme();
// Must run before render so replay and exception capture cover startup errors. // Must run before render so replay and exception capture cover startup errors.
// No-ops when VITE_POSTHOG_KEY is unset. // No-ops when VITE_POSTHOG_KEY is unset.
@@ -63,7 +58,7 @@ if (!rootElement) {
createRoot(rootElement).render( createRoot(rootElement).render(
<PostHogProvider client={posthog}> <PostHogProvider client={posthog}>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<MantineProvider theme={freightMantineTheme}> <MantineProvider theme={freightMantineTheme} forceColorScheme="light">
<StrictMode> <StrictMode>
<BrowserRouter> <BrowserRouter>
<AuthProvider> <AuthProvider>

View File

@@ -25,8 +25,6 @@ import {
Cog, Cog,
Bolt, Bolt,
Globe, Globe,
Moon,
Sun,
} from "lucide-react"; } from "lucide-react";
import { import {
DropdownMenu, DropdownMenu,
@@ -39,7 +37,6 @@ import { Button } from "@/shared/common/ui/button";
import { useTheme } from "@/user-management/web-Management/hooks/useTheme"; import { useTheme } from "@/user-management/web-Management/hooks/useTheme";
import { useLocalizedName } from "@/shared/common/localizedName"; import { useLocalizedName } from "@/shared/common/localizedName";
import { useModules } from "@/user-management/web-Management/hooks/useModules"; import { useModules } from "@/user-management/web-Management/hooks/useModules";
import { useDarkMode } from "@/shared/hooks/useDarkMode";
import { import {
useTenantConfig, useTenantConfig,
resolveModuleConfig, resolveModuleConfig,
@@ -98,7 +95,6 @@ const Header = () => {
logout(); logout();
}; };
const { isDarkMode, toggleDarkMode } = useDarkMode();
useEffect(() => { useEffect(() => {
if (!localStorage.getItem("i18nextLng")) { if (!localStorage.getItem("i18nextLng")) {
@@ -440,22 +436,6 @@ const Header = () => {
))} ))}
</div> </div>
<div className="hidden md:flex items-center gap-3 lg:gap-4"> <div className="hidden md:flex items-center gap-3 lg:gap-4">
<motion.button
onClick={toggleDarkMode}
className="p-2 rounded-lg text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800 transition-colors"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
aria-label={
isDarkMode ? t("header.lightMode") : t("header.darkMode")
}
>
{isDarkMode ? (
<Sun className="w-5 h-5" />
) : (
<Moon className="w-5 h-5" />
)}
</motion.button>
<div className="w-32 lg:w-36"> <div className="w-32 lg:w-36">
<Select <Select
value={currentLanguage} value={currentLanguage}
@@ -544,21 +524,6 @@ const Header = () => {
</div> </div>
<div className="flex items-center md:hidden gap-2"> <div className="flex items-center md:hidden gap-2">
<motion.button
onClick={toggleDarkMode}
className="p-2 rounded-lg text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800 transition-colors"
whileTap={{ scale: 0.9 }}
aria-label={
isDarkMode ? t("header.lightMode") : t("header.darkMode")
}
>
{isDarkMode ? (
<Sun className="h-5 w-5" />
) : (
<Moon className="h-5 w-5" />
)}
</motion.button>
<motion.button <motion.button
onClick={() => setMobileMenuOpen(!mobileMenuOpen)} onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
className="inline-flex items-center justify-center p-2 rounded-lg text-gray-600 hover:text-gray-900 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-primary-500 transition-all" className="inline-flex items-center justify-center p-2 rounded-lg text-gray-600 hover:text-gray-900 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-primary-500 transition-all"

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef } from "react";
import { Editor } from "@tinymce/tinymce-react"; import { Editor } from "@tinymce/tinymce-react";
import type { Editor as TinyMCEEditor } from "tinymce"; import type { Editor as TinyMCEEditor } from "tinymce";
@@ -34,16 +34,10 @@ export default function RichTextEditor({
shouldClear?: boolean; shouldClear?: boolean;
}) { }) {
const editorRef = useRef<TinyMCEEditor | null>(null); const editorRef = useRef<TinyMCEEditor | null>(null);
const [isDarkMode, setIsDarkMode] = useState(() => { // The app is light-only — nothing sets `dark` / data-theme on <html>, so this
if (typeof window === "undefined") { // is a constant rather than observed state. Kept as a named flag so the
return false; // TinyMCE skin/content-style branches below stay readable.
} const isDarkMode = false;
const root = document.documentElement;
return (
root.classList.contains("dark") || root.getAttribute("data-theme") === "dark"
);
});
// ✅ When shouldClear becomes true → clear editor + remove draft // ✅ When shouldClear becomes true → clear editor + remove draft
useEffect(() => { useEffect(() => {
@@ -54,28 +48,6 @@ export default function RichTextEditor({
} }
}, [shouldClear]); }, [shouldClear]);
useEffect(() => {
const root = document.documentElement;
const updateThemeMode = () => {
setIsDarkMode(
root.classList.contains("dark") ||
root.getAttribute("data-theme") === "dark",
);
};
updateThemeMode();
const observer = new MutationObserver(updateThemeMode);
observer.observe(root, {
attributes: true,
attributeFilter: ["class", "data-theme"],
});
return () => {
observer.disconnect();
};
}, []);
const init = useMemo( const init = useMemo(
() => ({ () => ({
// Point self-hosted TinyMCE at the static assets copied into /public/tinymce. // Point self-hosted TinyMCE at the static assets copied into /public/tinymce.

View File

@@ -21,8 +21,6 @@ import {
Key, Key,
LogOut, LogOut,
LucideIcon, LucideIcon,
Moon,
Sun,
User, User,
UserPen, UserPen,
UsersRound, UsersRound,
@@ -37,7 +35,6 @@ import UserApprovalDropdown from "../PendingUsersList";
import { usePendingUsers } from "@/user-management/userManagement/hooks/usePendingUsersHook"; import { usePendingUsers } from "@/user-management/userManagement/hooks/usePendingUsersHook";
import { useUserDetail } from "../hooks/useUserDetail"; import { useUserDetail } from "../hooks/useUserDetail";
import { MeDto } from "@/shared/dto/user/meDto"; import { MeDto } from "@/shared/dto/user/meDto";
import { useDarkMode } from "@/shared/hooks/useDarkMode";
import { import {
UI_LANGUAGE_OPTIONS, UI_LANGUAGE_OPTIONS,
getUiLanguageLabel, getUiLanguageLabel,
@@ -133,7 +130,6 @@ const Top: React.FC<HeaderProps> = ({
logout(); logout();
}; };
const { isDarkMode, toggleDarkMode } = useDarkMode();
const { pendingUsers } = usePendingUsers({ take: 5, skip: 0 }); const { pendingUsers } = usePendingUsers({ take: 5, skip: 0 });
const pendingUsersCount = pendingUsers?.count || 0; const pendingUsersCount = pendingUsers?.count || 0;
@@ -359,21 +355,6 @@ const Top: React.FC<HeaderProps> = ({
</div> </div>
<div className="flex max-w-[56vw] flex-shrink-0 items-center space-x-1.5 sm:max-w-none sm:space-x-2.5"> <div className="flex max-w-[56vw] flex-shrink-0 items-center space-x-1.5 sm:max-w-none sm:space-x-2.5">
<Button
variant="ghost"
size="icon"
className="h-9 w-9 rounded-xl bg-white/90 text-gray-600 shadow-sm ring-1 ring-inset ring-black/5 transition-colors hover:bg-primary-50 hover:text-primary-700 hover:ring-primary-200 dark:bg-gray-800/90 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-primary-900/20 dark:hover:text-primary-300 dark:hover:ring-primary-700/40 sm:h-10 sm:w-10"
aria-label={isDarkMode ? "Light mode" : "Dark mode"}
onClick={toggleDarkMode}
title={isDarkMode ? "Switch to light mode" : "Switch to dark mode"}
>
{isDarkMode ? (
<Sun className="h-4 w-4 text-yellow-500" />
) : (
<Moon className="h-4 w-4 text-gray-700 dark:text-gray-300" />
)}
</Button>
{canActivateUsers && ( {canActivateUsers && (
<div className="relative"> <div className="relative">
<Button <Button

View File

@@ -1,12 +1,13 @@
import { useTheme } from "next-themes"
import { Toaster as Sonner, ToasterProps } from "sonner" import { Toaster as Sonner, ToasterProps } from "sonner"
// Pinned to "light": the app is light-only. This previously read next-themes'
// useTheme, which falls back to "system" when no ThemeProvider is mounted (and
// none is) — sonner then resolves "system" against prefers-color-scheme and
// rendered dark toasts over a light UI on any OS set to dark.
const Toaster = ({ ...props }: ToasterProps) => { const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return ( return (
<Sonner <Sonner
theme={theme as ToasterProps["theme"]} theme="light"
className="toaster group" className="toaster group"
toastOptions={{ toastOptions={{
classNames: { classNames: {

View File

@@ -13,8 +13,6 @@ import {
Mail, Mail,
Phone, Phone,
User, User,
Moon,
Sun,
} from "lucide-react"; } from "lucide-react";
import { useAuthUser } from "@/shared/hooks/useAuthUser"; import { useAuthUser } from "@/shared/hooks/useAuthUser";
import { useTenantConfig } from "@/layout/components/TenantConfig"; import { useTenantConfig } from "@/layout/components/TenantConfig";
@@ -26,13 +24,11 @@ import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useAuth } from "@/shared/context/AuthContext"; import { useAuth } from "@/shared/context/AuthContext";
import OTPModal from "./OTPModal"; import OTPModal from "./OTPModal";
import { useDarkMode } from "@/shared/hooks/useDarkMode";
import { getRememberMePreference } from "@/shared/utils/authPersistence"; import { getRememberMePreference } from "@/shared/utils/authPersistence";
export const Login = () => { export const Login = () => {
const { login: authLogin, isLoggingIn } = useAuthUser(); const { login: authLogin, isLoggingIn } = useAuthUser();
const { config } = useTenantConfig(); const { config } = useTenantConfig();
const { isDarkMode, toggleDarkMode } = useDarkMode();
const detectLoginMethod = (value: string): "email" | "phone" | "username" => { const detectLoginMethod = (value: string): "email" | "phone" | "username" => {
if (value.includes("@") && value.includes(".com")) return "email"; if (value.includes("@") && value.includes(".com")) return "email";
if (/^\+?\d+$/.test(value)) return "phone"; if (/^\+?\d+$/.test(value)) return "phone";
@@ -121,19 +117,6 @@ export const Login = () => {
</span> </span>
</Link> </Link>
{/* Dark Mode Toggle */}
<button
onClick={toggleDarkMode}
className="fixed top-4 right-4 md:top-6 md:right-6 z-50 p-2 rounded-full bg-white dark:bg-gray-800 shadow-md hover:shadow-lg transition-all duration-300 hover:scale-105 border border-gray-200 dark:border-gray-700"
title={isDarkMode ? "Switch to light mode" : "Switch to dark mode"}
>
{isDarkMode ? (
<Sun className="w-5 h-5 text-yellow-500" />
) : (
<Moon className="w-5 h-5 text-gray-600 dark:text-gray-400" />
)}
</button>
<div className="w-full max-w-6xl bg-white dark:bg-gray-800 rounded-2xl shadow-2xl overflow-hidden flex flex-col lg:flex-row"> <div className="w-full max-w-6xl bg-white dark:bg-gray-800 rounded-2xl shadow-2xl overflow-hidden flex flex-col lg:flex-row">
{/* Left Panel - Login Form */} {/* Left Panel - Login Form */}
<div className="lg:w-1/2 w-full p-6 sm:p-8 md:p-10 lg:p-12 xl:p-16 flex flex-col justify-center"> <div className="lg:w-1/2 w-full p-6 sm:p-8 md:p-10 lg:p-12 xl:p-16 flex flex-col justify-center">

View File

@@ -1,33 +0,0 @@
import { useEffect, useState } from "react";
// Same storage key/values as main.tsx and FreightDashboardLayout so the
// vendored IAM UI toggle and the host dashboard toggle stay in sync.
const THEME_STORAGE_KEY = "edr-theme";
export const useDarkMode = () => {
const [isDarkMode, setIsDarkMode] = useState(() => {
// Initialize from localStorage or system preference
const savedTheme = localStorage.getItem(THEME_STORAGE_KEY);
if (savedTheme === "dark" || savedTheme === "light") {
return savedTheme === "dark";
}
return window.matchMedia("(prefers-color-scheme: dark)").matches;
});
useEffect(() => {
// Apply dark mode class on mount and when isDarkMode changes
if (isDarkMode) {
document.documentElement.classList.add("dark");
localStorage.setItem(THEME_STORAGE_KEY, "dark");
} else {
document.documentElement.classList.remove("dark");
localStorage.setItem(THEME_STORAGE_KEY, "light");
}
}, [isDarkMode]);
const toggleDarkMode = () => {
setIsDarkMode((prev) => !prev);
};
return { isDarkMode, toggleDarkMode };
};

View File

@@ -1,4 +1,4 @@
import React, { useMemo, useState, useEffect } from "react"; import React, { useMemo } from "react";
import { Editor as TinyMCEEditor } from "@/record-management/common/editor/rte"; import { Editor as TinyMCEEditor } from "@/record-management/common/editor/rte";
import type { Editor as TinyMCEEditorType } from "tinymce"; import type { Editor as TinyMCEEditorType } from "tinymce";
@@ -21,18 +21,11 @@ export const TemplateEditor: React.FC<TemplateEditorProps> = ({
placeholders, placeholders,
}) => { }) => {
const hasPlaceholders = placeholders && placeholders.length > 0; const hasPlaceholders = placeholders && placeholders.length > 0;
const [isDarkMode, setIsDarkMode] = useState(() => // The app is light-only — nothing sets the `dark` class on <html>, so this is
typeof window !== "undefined" && document.documentElement.classList.contains("dark") // a constant rather than observed state. Kept as a named flag so the TinyMCE
); // skin/content-style branches below stay readable.
const isDarkMode = false;
useEffect(() => {
const observer = new MutationObserver(() => {
setIsDarkMode(document.documentElement.classList.contains("dark"));
});
observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
return () => observer.disconnect();
}, []);
const init = useMemo(() => { const init = useMemo(() => {
return { return {
height: 600, height: 600,

View File

@@ -1,10 +1,13 @@
@import "tailwindcss"; @import "tailwindcss";
@import "@edr/ui-common/theme.css" layer(theme); @import "@edr/ui-common/theme.css" layer(theme);
/* Dark mode is toggled via the `dark` class on <html>. Without this, Tailwind /* The portal is light-only — there is no theme toggle and nothing sets the
v4 compiles `dark:` utilities to `@media (prefers-color-scheme: dark)` and `dark` class. This override is load-bearing: without it Tailwind v4 compiles
they follow the OS setting instead of the in-app toggle. */ `dark:` utilities to `@media (prefers-color-scheme: dark)`, so every
@custom-variant dark (&:where(.dark, .dark *)); leftover `dark:` class in the tree would activate on an OS-level dark
setting. Binding the variant to a class that is never rendered keeps those
utilities inert no matter what the OS (or a stray `.dark` class) says. */
@custom-variant dark (&:where(.edr-dark-disabled));
/* Bridge the central Mantine theme into Tailwind. Mantine (createTheme) is the /* Bridge the central Mantine theme into Tailwind. Mantine (createTheme) is the
single source of truth; these just alias its generated CSS variables so single source of truth; these just alias its generated CSS variables so

View File

@@ -317,7 +317,6 @@ const App = () => {
sidebarItems={sidebarItems} sidebarItems={sidebarItems}
activeHref={location.pathname} activeHref={location.pathname}
onNavigate={navigate} onNavigate={navigate}
enableThemeToggle
userName={displayName} userName={displayName}
userEmail={userEmail} userEmail={userEmail}
companyProfiles={companyProfiles} companyProfiles={companyProfiles}

View File

@@ -15,8 +15,6 @@ import {
Stack, Stack,
Text, Text,
UnstyledButton, UnstyledButton,
useComputedColorScheme,
useMantineColorScheme,
useMantineTheme, useMantineTheme,
} from "@mantine/core"; } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";
@@ -26,17 +24,15 @@ import {
FileSignature, FileSignature,
LogOut, LogOut,
Menu as MenuIcon, Menu as MenuIcon,
Moon,
Plus, Plus,
RefreshCw, RefreshCw,
Search, Search,
Settings, Settings,
Sun,
Upload, Upload,
User, User,
X, X,
} from "lucide-react"; } from "lucide-react";
import { type CSSProperties, Fragment, type ReactNode, useState } from "react"; import { Fragment, type ReactNode, useState } from "react";
import { PROFILE_TYPE_LABELS } from "@/constants/profileMode"; import { PROFILE_TYPE_LABELS } from "@/constants/profileMode";
import NotificationBellContainer from "@/features/notifications/NotificationBellContainer"; import NotificationBellContainer from "@/features/notifications/NotificationBellContainer";
import SupportWidget from "@/features/support/SupportWidget"; import SupportWidget from "@/features/support/SupportWidget";
@@ -59,7 +55,6 @@ export interface AppLayoutProps {
* to the new-booking wizard. Compatible with react-router's `navigate`. * to the new-booking wizard. Compatible with react-router's `navigate`.
*/ */
onNavigate?: (href: string, options?: { state?: unknown }) => void; onNavigate?: (href: string, options?: { state?: unknown }) => void;
enableThemeToggle?: boolean;
userName?: string; userName?: string;
userEmail?: string; userEmail?: string;
/** Operational profiles for the company — surfaced as reference chips in the account menu. */ /** Operational profiles for the company — surfaced as reference chips in the account menu. */
@@ -154,7 +149,6 @@ export function AppLayout({
sidebarItems, sidebarItems,
activeHref = "", activeHref = "",
onNavigate, onNavigate,
enableThemeToggle = false,
userName = "User", userName = "User",
userEmail, userEmail,
companyProfiles = [], companyProfiles = [],
@@ -165,9 +159,6 @@ export function AppLayout({
}: AppLayoutProps) { }: AppLayoutProps) {
const [mobileOpen, { toggle: toggleMobile }] = useDisclosure(); const [mobileOpen, { toggle: toggleMobile }] = useDisclosure();
const theme = useMantineTheme(); const theme = useMantineTheme();
const { setColorScheme } = useMantineColorScheme();
const computedColorScheme = useComputedColorScheme("light");
const borderColor = theme.colors["edr-border"][6]; const borderColor = theme.colors["edr-border"][6];
const mutedColor = theme.colors["edr-muted"][6]; const mutedColor = theme.colors["edr-muted"][6];
const textColor = theme.colors["edr-text"][6]; const textColor = theme.colors["edr-text"][6];
@@ -178,10 +169,6 @@ export function AppLayout({
const navigate = (href: string, options?: { state?: unknown }) => const navigate = (href: string, options?: { state?: unknown }) =>
onNavigate?.(href, options); onNavigate?.(href, options);
const toggleTheme = () => {
setColorScheme(computedColorScheme === "dark" ? "light" : "dark");
};
const initials = getInitials(userName); const initials = getInitials(userName);
const activePage = getActivePage(sidebarItems, activePath); const activePage = getActivePage(sidebarItems, activePath);
@@ -279,20 +266,6 @@ export function AppLayout({
activePath === item.href.toLowerCase() || activePath === item.href.toLowerCase() ||
activePath.startsWith(item.href.toLowerCase() + "/"); activePath.startsWith(item.href.toLowerCase() + "/");
// Shared header "island" styles — every control is a consistent 36px chip.
const islandStyle: CSSProperties = {
width: 36,
height: 36,
borderRadius: 999,
border: `1px solid ${borderColor}`,
backgroundColor: "#fff",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
cursor: "pointer",
};
return ( return (
<AppShell <AppShell
layout="alt" layout="alt"
@@ -457,20 +430,6 @@ export function AppLayout({
{/* Notifications */} {/* Notifications */}
<NotificationBellContainer /> <NotificationBellContainer />
{enableThemeToggle && (
<UnstyledButton
onClick={toggleTheme}
style={islandStyle}
aria-label="Toggle theme"
>
{computedColorScheme === "dark" ? (
<Sun size={17} color={textColor} strokeWidth={1.8} />
) : (
<Moon size={17} color={textColor} strokeWidth={1.8} />
)}
</UnstyledButton>
)}
{/* Avatar pill */} {/* Avatar pill */}
<Menu <Menu
width={220} width={220}

View File

@@ -48,7 +48,10 @@ if (!rootElement) {
createRoot(document.getElementById("root")!).render( createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
<PostHogProvider client={posthog}> <PostHogProvider client={posthog}>
<MantineProvider theme={mantineTheme} defaultColorScheme="light"> {/* forceColorScheme (not defaultColorScheme) — the app is light-only.
`default` still lets Mantine flip to dark via useMantineColorScheme
or a persisted color-scheme value; `force` pins it. */}
<MantineProvider theme={mantineTheme} forceColorScheme="light">
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<BrowserRouter> <BrowserRouter>
<PostHogErrorBoundary fallback={<AppErrorFallback />}> <PostHogErrorBoundary fallback={<AppErrorFallback />}>

View File

@@ -355,28 +355,33 @@ export default function CompanyProfileForm({
// in flight), so the initial state above freezes at `false` — adopt the // in flight), so the initial state above freezes at `false` — adopt the
// server's declaration the moment it lands, or a resumed draft shows an // server's declaration the moment it lands, or a resumed draft shows an
// unticked box over a GM that is linked server-side. // unticked box over a GM that is linked server-side.
const [poaSameAsOwner, setPoaSameAsOwner] = useState(
identity?.poaSameAsOwner ?? false,
);
const identityLoaded = useRef(false); const identityLoaded = useRef(false);
useEffect(() => { useEffect(() => {
if (!identity || identityLoaded.current) return; if (!identity || identityLoaded.current) return;
identityLoaded.current = true; identityLoaded.current = true;
setGmSameAsOwner(identity.gmSameAsOwner); setGmSameAsOwner(identity.gmSameAsOwner);
setPoaSameAsOwner(identity.poaSameAsOwner);
}, [identity]); }, [identity]);
const [contactSameAsGm, setContactSameAsGm] = useState(false); const [contactSameAsGm, setContactSameAsGm] = useState(false);
// A Fayda-verified owner outranks eTrade's registered owner — it's the // Where the owner's details come from when they are copied onto someone else
// higher-trust source, and the whole point of proving identity is to stop // — the GM, or the representative. A Fayda-verified owner outranks eTrade's
// trusting typed/looked-up data for this. // registered owner: it's the higher-trust source, and the whole point of
const gmSourceName = firstPresent( // proving identity is to stop trusting typed/looked-up data for this.
const ownerSourceName = firstPresent(
identity?.owner.name, identity?.owner.name,
etradeOwner?.name, etradeOwner?.name,
user.name?.en, user.name?.en,
); );
const gmSourceEmail = firstValidEmail(identity?.owner.email, user.email); const ownerSourceEmail = firstValidEmail(identity?.owner.email, user.email);
// Same reason as `derivedPhone`: this value is written into // Same reason as `derivedPhone`: this value is written into
// `generalManagerPhone`, which the API validates with `@IsValidPhone()`, so an // `generalManagerPhone` / `poaPhone`, which the API validates with
// unusable eTrade number here 400s the personnel step instead. // `@IsValidPhone()`, so an unusable eTrade number here 400s the step instead.
const gmSourcePhone = firstValidPhone( const ownerSourcePhone = firstValidPhone(
identity?.owner.phone, identity?.owner.phone,
etradeOwner?.phone, etradeOwner?.phone,
user.phoneNumber, user.phoneNumber,
@@ -388,13 +393,30 @@ export default function CompanyProfileForm({
// `identity.gm`; mirroring it into form fields here would send typed // `identity.gm`; mirroring it into form fields here would send typed
// values for something the API already owns. // values for something the API already owns.
if (identity?.owner.verified) return; if (identity?.owner.verified) return;
setValue("generalManagerName", gmSourceName, { shouldValidate: true }); setValue("generalManagerName", ownerSourceName, { shouldValidate: true });
setValue("generalManagerEmail", gmSourceEmail, { shouldValidate: true }); setValue("generalManagerEmail", ownerSourceEmail, { shouldValidate: true });
setValue("generalManagerPhone", gmSourcePhone, { setValue("generalManagerPhone", ownerSourcePhone, {
shouldValidate: true, shouldValidate: true,
}); });
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [gmSameAsOwner, gmSourceName, gmSourceEmail, gmSourcePhone]); }, [gmSameAsOwner, ownerSourceName, ownerSourceEmail, ownerSourcePhone]);
// The representative's half of the same copy. A verified owner's identity is
// copied server-side and read back from `identity.poa`, so only an owner
// backed by a typed passport is mirrored into form fields here — the same
// split the GM makes above, for the same reason.
//
// Only non-empty sources are written. A source the owner does not have is a
// gap the step renders an input for (see `poaGaps`), and this effect re-runs
// whenever any *other* source changes — so blanking here would wipe what the
// customer is typing into that input the moment an eTrade lookup lands.
useEffect(() => {
if (!poaSameAsOwner || identity?.owner.verified) return;
if (ownerSourceName) setValue("poaName", ownerSourceName, { shouldValidate: true });
if (ownerSourceEmail) setValue("poaEmail", ownerSourceEmail, { shouldValidate: true });
if (ownerSourcePhone) setValue("poaPhone", ownerSourcePhone, { shouldValidate: true });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [poaSameAsOwner, ownerSourceName, ownerSourceEmail, ownerSourcePhone]);
/** /**
* "Same as owner" has two meanings depending on what backs the owner. * "Same as owner" has two meanings depending on what backs the owner.
@@ -439,6 +461,44 @@ export default function CompanyProfileForm({
} }
}; };
/**
* The representative is the owner. Unlike the GM's card this always goes to
* the API, whichever backs the owner: the declaration itself is what waives
* the DARS delegation paper, so it has to be recorded server-side even when
* there is no proven identity to copy and the details are mirrored locally.
*/
const [poaLinkPending, setPoaLinkPending] = useState(false);
const togglePoaSameAsOwner = async (checked: boolean) => {
setSaveError(null);
setPoaSameAsOwner(checked);
setPoaLinkPending(true);
try {
if (checked) await verifaydaService.setPoaSameAsOwner();
else {
await verifaydaService.clearPoaSameAsOwner();
// Only the locally mirrored values are ours to clear; a copied identity
// is cleared by the call above.
if (!identity?.owner.verified) {
setValue("poaName", "");
setValue("poaEmail", "");
setValue("poaPhone", "");
}
}
onIdentityChange?.();
} catch (err) {
setPoaSameAsOwner(!checked);
setSaveError(
(err as { response?: { data?: { message?: string } } })?.response?.data
?.message ??
(err instanceof Error
? err.message
: "Could not update the Power of Attorney"),
);
} finally {
setPoaLinkPending(false);
}
};
// Where the GM's details come from depends on how they were established: a // Where the GM's details come from depends on how they were established: a
// Fayda verification (or a "same as owner" declaration) owns them outright, // Fayda verification (or a "same as owner" declaration) owns them outright,
// and only a company that may still type them falls back to form state. // and only a company that may still type them falls back to form state.
@@ -704,7 +764,12 @@ export default function CompanyProfileForm({
// verify with Fayda — the API demands it at completion either way. Keying // verify with Fayda — the API demands it at completion either way. Keying
// this on the verification alone hid the upload from a foreign forwarder and // this on the verification alone hid the upload from a foreign forwarder and
// then failed them on submit for a file they were never shown. // then failed them on submit for a file they were never shown.
const delegationRequired = poaProvided || requirePoa; //
// Unless the owner represents the company themselves: nobody delegates to
// themselves, so there is no delegation to evidence. Mirrors the API's own
// waiver in `assertPoaDelegationSatisfied` — the two must agree, or this
// demands a file the server would accept the submission without.
const delegationRequired = (poaProvided || requirePoa) && !poaSameAsOwner;
const delegationPresent = const delegationPresent =
(uploadedDocumentKeys ?? []).includes(POA_DELEGATION_FILE_KEY) || (uploadedDocumentKeys ?? []).includes(POA_DELEGATION_FILE_KEY) ||
(() => { (() => {
@@ -735,12 +800,38 @@ export default function CompanyProfileForm({
// which is also the only case the API refuses to let anyone overwrite. // which is also the only case the API refuses to let anyone overwrite.
const poaGap = (v?: string | null) => const poaGap = (v?: string | null) =>
poaTypedAllowed && (!identity?.poa.verified || !v?.trim()); poaTypedAllowed && (!identity?.poa.verified || !v?.trim());
const poaGaps = { /**
name: poaGap(identity?.poa.name), * "Same as owner" answers each field only as far as the owner actually has
email: poaGap(identity?.poa.email), * one. Fayda's name, email and phone claims are all optional, the account and
phone: poaGap(identity?.poa.phone), * eTrade fallbacks can be empty or unusable, and `REQUIRED_POA_FIELDS` still
address: poaGap(identity?.poa.address), * demands a name, an email and a phone — so anything the copy could not
}; * supply stays askable. Assuming the copy filled everything is what dead-ends
* the submit on "Add your poa phone" with no input anywhere to satisfy it.
*
* Keyed on the *source*, never on the field's current value: an input that
* disappears the moment the first character is typed into it is unusable.
* A verified owner's identity is copied server-side, so `identity.poa` is the
* source there; otherwise it is the same owner-derived values the mirror
* effect writes.
*/
const poaCopyGap = (copied?: string | null, mirrored?: string | null) =>
identity?.owner.verified ? !copied?.trim() : !mirrored?.trim();
const poaGaps = poaSameAsOwner
? {
name: poaCopyGap(identity?.poa.name, ownerSourceName),
email: poaCopyGap(identity?.poa.email, ownerSourceEmail),
phone: poaCopyGap(identity?.poa.phone, ownerSourcePhone),
// The location is the one detail the API never demands, so a blank one
// dead-ends nothing — and asking for the owner's city under a card that
// says "same as owner" reads as a contradiction.
address: false,
}
: {
name: poaGap(identity?.poa.name),
email: poaGap(identity?.poa.email),
phone: poaGap(identity?.poa.phone),
address: poaGap(identity?.poa.address),
};
// The GM's own verification never falls back to the signed-in account — that // The GM's own verification never falls back to the signed-in account — that
// account is the person onboarding, not necessarily the manager — so a GM // account is the person onboarding, not necessarily the manager — so a GM
// verified with no email claim has nowhere else for one to come from. The // verified with no email claim has nowhere else for one to come from. The
@@ -1024,6 +1115,10 @@ export default function CompanyProfileForm({
form={form} form={form}
identity={identity} identity={identity}
requirePoa={requirePoa} requirePoa={requirePoa}
poaSameAsOwner={poaSameAsOwner}
onTogglePoaSameAsOwner={togglePoaSameAsOwner}
poaLinkPending={poaLinkPending}
etradeOwner={etradeOwner}
gaps={poaGaps} gaps={poaGaps}
onRemovePoa={removePoa} onRemovePoa={removePoa}
removePending={poaRemovePending} removePending={poaRemovePending}

View File

@@ -10,20 +10,27 @@ export function LinkCheckboxCard({
onToggle, onToggle,
title, title,
description, description,
disabled = false,
}: { }: {
checked: boolean; checked: boolean;
onToggle: (checked: boolean) => void; onToggle: (checked: boolean) => void;
title: string; title: string;
description: string; description: string;
/** Greys the card out and refuses the toggle — the link is not available yet. */
disabled?: boolean;
}) { }) {
return ( return (
<UnstyledButton <UnstyledButton
onClick={() => onToggle(!checked)} onClick={() => !disabled && onToggle(!checked)}
role="checkbox" role="checkbox"
aria-checked={checked} aria-checked={checked}
className={`w-full rounded-lg border! p-3! text-left transition-colors! ${checked aria-disabled={disabled}
className={`w-full rounded-lg border! p-3! text-left transition-colors! ${disabled ? "cursor-not-allowed! opacity-60!" : ""
} ${checked
? "border-[var(--mantine-color-edr-green-6)]! bg-[var(--mantine-color-edr-green-0)]!" ? "border-[var(--mantine-color-edr-green-6)]! bg-[var(--mantine-color-edr-green-0)]!"
: "border-[var(--mantine-color-gray-3)]! hover:border-[var(--mantine-color-edr-green-4)]! hover:bg-[var(--mantine-color-edr-green-0)]!" : disabled
? "border-[var(--mantine-color-gray-3)]!"
: "border-[var(--mantine-color-gray-3)]! hover:border-[var(--mantine-color-edr-green-4)]! hover:bg-[var(--mantine-color-edr-green-0)]!"
}`} }`}
> >
<Group gap="sm" wrap="nowrap" align="flex-start"> <Group gap="sm" wrap="nowrap" align="flex-start">

View File

@@ -9,12 +9,20 @@ import type { FileUploadSetting } from "@/types/fileUploadSettings";
import type { CompanyIdentityState } from "@/services/verifayda.service"; import type { CompanyIdentityState } from "@/services/verifayda.service";
import type { FormData } from "../schema"; import type { FormData } from "../schema";
import { LinkCheckboxCard } from "../LinkCheckboxCard";
export interface PoaStepProps { export interface PoaStepProps {
form: UseFormReturn<FormData>; form: UseFormReturn<FormData>;
identity?: CompanyIdentityState; identity?: CompanyIdentityState;
/** This company holds a freight-forwarder profile, so the PoA is mandatory. */ /** This company holds a freight-forwarder profile, so the PoA is mandatory. */
requirePoa: boolean; requirePoa: boolean;
/** The owner represents the company themselves. */
poaSameAsOwner: boolean;
onTogglePoaSameAsOwner: (checked: boolean) => void;
/** A server-side "same as owner" declaration is in flight. */
poaLinkPending: boolean;
/** eTrade-registered owner, once a TIN lookup has succeeded. */
etradeOwner: { name: string; phone: string } | null;
/** /**
* Which of the representative's details the Fayda verification did not * Which of the representative's details the Fayda verification did not
* supply, and are therefore typed here. Computed by CompanyProfileForm, which * supply, and are therefore typed here. Computed by CompanyProfileForm, which
@@ -39,6 +47,10 @@ export default function PoaStep({
form, form,
identity, identity,
requirePoa, requirePoa,
poaSameAsOwner,
onTogglePoaSameAsOwner,
poaLinkPending,
etradeOwner,
gaps, gaps,
onRemovePoa, onRemovePoa,
removePending, removePending,
@@ -65,26 +77,64 @@ export default function PoaStep({
const needsEmail = gaps.email; const needsEmail = gaps.email;
const needsPhone = gaps.phone; const needsPhone = gaps.phone;
// Fayda is mandatory for an Ethiopian company's representative, so there the
// link can only reuse a proven owner — with none there would be nothing to
// copy and the declaration could never satisfy the gate. A foreign company's
// owner is backed by a typed passport, so it prefills instead.
const linkNeedsVerifiedOwner =
(identity?.faydaRequired ?? false) && !identity?.owner.verified;
return ( return (
<> <>
<Text size="sm" c="edr-muted"> <Text size="sm" c="edr-muted">
{requirePoa {requirePoa
? "As a freight forwarder you act on other companies' behalf, so Power of Attorney details and the DARS delegation paper are required." ? "As a freight forwarder you act on other companies' behalf, so Power of Attorney details are required."
: "Power of Attorney details are optional. Fill them in if you have them, or skip to continue. If you do enter a representative, upload the delegation paper authenticated by DARS."}{" "} : "Power of Attorney details are optional. Fill them in if you have them, or skip to continue."}{" "}
{/* The API refuses an owner who delegates to themselves — say so here, {poaSameAsOwner
or the customer only finds out after being sent to Fayda and back. */} ? "You represent the company yourself, so no delegation paper is needed."
The representative must be someone other than the company's owner. : "If the representative is someone other than the owner, upload the delegation paper authenticated by DARS."}
</Text> </Text>
{/* An owner who represents their own company is the ordinary
small-business case. Where the owner is Fayda-verified this reuses
that proven identity outright rather than sending the same human
through Fayda twice; where they are backed by a typed passport there
is nothing proven to copy, so it stays a local prefill. Either way it
is the declaration that waives the DARS paper. */}
{identity && (
<LinkCheckboxCard
checked={poaSameAsOwner}
onToggle={onTogglePoaSameAsOwner}
disabled={poaLinkPending || (linkNeedsVerifiedOwner && !poaSameAsOwner)}
title={
identity.owner.verified
? "Same as verified owner"
: "Same as business owner"
}
description={
linkNeedsVerifiedOwner
? "Verify the company owner with Fayda first — then you can reuse that identity here."
: identity.owner.verified
? "You represent the company yourself. Reuses the Fayda-verified owner's identity, and no DARS delegation paper is needed. Uncheck to name someone else."
: etradeOwner
? "You represent the company yourself. Reuses the eTrade-registered owner's name plus the company email and phone as you entered them, and no DARS delegation paper is needed. Uncheck to name someone else."
: "You represent the company yourself. Reuses your account's name, email and phone, and no DARS delegation paper is needed. Uncheck to name someone else."
}
/>
)}
{/* A representative acts for the company inside Ethiopia {/* A representative acts for the company inside Ethiopia
whoever owns it, so the PoA is proven with Fayda regardless of whoever owns it, so the PoA is proven with Fayda regardless of
nationality — their name, email, phone and address all come nationality — their name, email, phone and address all come
from the verification and are never typed here. */} from the verification and are never typed here. Verifying a second
{identity && ( person is only meaningful when the representative is not the owner. */}
{identity && !poaSameAsOwner && (
<FaydaVerifyPanel <FaydaVerifyPanel
subject="poa" subject="poa"
title="Power of Attorney" title="Power of Attorney"
state={identity.poa} state={identity.poa}
required={requirePoa} required={requirePoa}
disabled={poaLinkPending}
/> />
)} )}
{/* A verification cannot be undone by clearing the form — it owns those {/* A verification cannot be undone by clearing the form — it owns those
@@ -92,7 +142,7 @@ export default function PoaStep({
then blocks the submit. So an optional representative needs a way then blocks the submit. So an optional representative needs a way
back out, here rather than only in settings (unreachable until back out, here rather than only in settings (unreachable until
onboarding finishes). */} onboarding finishes). */}
{identity?.poa.verified && !requirePoa && ( {identity?.poa.verified && !requirePoa && !poaSameAsOwner && (
<Group justify="flex-end"> <Group justify="flex-end">
<Button <Button
type="button" type="button"

View File

@@ -41,6 +41,7 @@ import {
} from "@/services/companies.service"; } from "@/services/companies.service";
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel"; import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
import { verifaydaService } from "@/services/verifayda.service"; import { verifaydaService } from "@/services/verifayda.service";
import { LinkCheckboxCard } from "@/pages/accounts/companyProfileForm/LinkCheckboxCard";
import type { ProfileResponse } from "@/types/profile"; import type { ProfileResponse } from "@/types/profile";
// The representative's name, email, phone and address all come from their // The representative's name, email, phone and address all come from their
@@ -135,13 +136,21 @@ export default function TabPowerOfAttorney({
// company inside Ethiopia either way. A PoA therefore exists exactly when one // company inside Ethiopia either way. A PoA therefore exists exactly when one
// has been verified. // has been verified.
const identity = profile.identity; const identity = profile.identity;
const owner = identity?.owner;
const poaProvided = identity?.poa.verified ?? false; const poaProvided = identity?.poa.verified ?? false;
const [poaSameAsOwner, setPoaSameAsOwner] = useState(
identity?.poaSameAsOwner ?? false,
);
// The paper authorises the representative named above, so there is nothing // The paper authorises the representative named above, so there is nothing
// for it to authorise until one has been verified — the upload is hidden // for it to authorise until one has been verified — the upload is hidden
// until then, and requiring it while hidden would block the save on a // until then, and requiring it while hidden would block the save on a
// control the customer cannot see. A freight forwarder is still held to // control the customer cannot see. A freight forwarder is still held to
// having a PoA at all, by the verification gate on the panel and by the API. // having a PoA at all, by the verification gate on the panel and by the API.
const letterRequired = poaProvided; //
// And nobody delegates to themselves: an owner representing their own company
// has no delegation to evidence, which is the same waiver the API applies in
// `assertPoaDelegationSatisfied`.
const letterRequired = poaProvided && !poaSameAsOwner;
const letterMissing = letterRequired && !hasLetterAfterSave; const letterMissing = letterRequired && !hasLetterAfterSave;
const fileDirty = Boolean(pickedFile) || removeIds.length > 0; const fileDirty = Boolean(pickedFile) || removeIds.length > 0;
@@ -198,6 +207,46 @@ export default function TabPowerOfAttorney({
}, },
}); });
/**
* "Same as owner": the owner represents the company themselves. Always goes
* to the API, whichever credential backs the owner — the declaration is what
* waives the DARS paper, so it has to be recorded server-side even when there
* is no proven identity to copy.
*
* Unchecking undoes the declaration only. It leaves the paper on file and is
* allowed for a freight forwarder, which is how one changes who represents
* it; "Remove representative" below is the harder action that takes the paper
* with it and is refused to a forwarder.
*/
const [linkPending, setLinkPending] = useState(false);
const [linkError, setLinkError] = useState<string | null>(null);
const togglePoaSameAsOwner = async (checked: boolean) => {
setPoaSameAsOwner(checked);
setLinkError(null);
setLinkPending(true);
try {
if (checked) await verifaydaService.setPoaSameAsOwner();
else await verifaydaService.clearPoaSameAsOwner();
queryClient.invalidateQueries({
queryKey: api.companies.getProfile.queryKey(),
});
queryClient.invalidateQueries({
queryKey: api.companies.poaDelegation.queryKey(),
});
} catch (err) {
setPoaSameAsOwner(!checked);
setLinkError(
(err as { response?: { data?: { message?: string } } })?.response?.data
?.message ??
(err instanceof Error
? err.message
: "Could not update the Power of Attorney"),
);
} finally {
setLinkPending(false);
}
};
const onSubmit = (data: FormData) => { const onSubmit = (data: FormData) => {
// The letter lives outside the form state, so it's gated here rather than // The letter lives outside the form state, so it's gated here rather than
// in the zod resolver. // in the zod resolver.
@@ -247,17 +296,60 @@ export default function TabPowerOfAttorney({
</Group> </Group>
<Text c="edr-muted" size="sm" mb="lg"> <Text c="edr-muted" size="sm" mb="lg">
{requirePoa {requirePoa
? "As a freight forwarder you act on other companies' behalf, so a Power of Attorney and its DARS delegation paper are required." ? "As a freight forwarder you act on other companies' behalf, so a Power of Attorney is required."
: "Power of Attorney details are optional. If you name a representative, upload the DARS delegation paper authorising them."} : "Power of Attorney details are optional."}{" "}
{poaSameAsOwner
? "You represent the company yourself, so no delegation paper is needed."
: "If you name a representative, upload the DARS delegation paper authorising them."}
</Text> </Text>
{/* The owner representing their own company is the ordinary
small-business case: a verified owner's identity is reused outright,
and either way the declaration waives the DARS paper. Where Fayda is
mandatory it needs a verified owner first — there would be nothing
proven to copy, and a representative who could never satisfy the
gate. */}
{identity && ( {identity && (
<LinkCheckboxCard
checked={poaSameAsOwner}
onToggle={togglePoaSameAsOwner}
disabled={
linkPending ||
mutation.isPending ||
(!poaSameAsOwner &&
(identity.faydaRequired ?? false) &&
!owner?.verified)
}
title={
owner?.verified
? "Same as verified owner"
: "Same as business owner"
}
description={
(identity.faydaRequired ?? false) && !owner?.verified
? "Verify the company owner with Fayda first — then you can reuse that identity here."
: owner?.verified
? "You represent the company yourself. Reuses the Fayda-verified owner's identity, and no DARS delegation paper is needed. Uncheck to name someone else."
: "You represent the company yourself. Reuses the owner's name, email and phone, and no DARS delegation paper is needed. Uncheck to name someone else."
}
/>
)}
{linkError && (
<Alert color="red" variant="light" icon={<XCircle size={18} />} mt="md">
{linkError}
</Alert>
)}
{/* Verifying a second person only means something when the
representative is someone other than the owner. */}
{identity && !poaSameAsOwner && (
<FaydaVerifyPanel <FaydaVerifyPanel
subject="poa" subject="poa"
title="Power of Attorney" title="Power of Attorney"
state={identity.poa} state={identity.poa}
required={requirePoa} required={requirePoa}
disabled={mutation.isPending} disabled={mutation.isPending || linkPending}
/> />
)} )}
@@ -284,7 +376,7 @@ export default function TabPowerOfAttorney({
{/* ------------------------ Delegation letter ------------------------ */} {/* ------------------------ Delegation letter ------------------------ */}
{/* The paper authorises the representative the verification named, {/* The paper authorises the representative the verification named,
so it only has meaning once one exists. */} so it only has meaning once one exists. */}
{poaProvided && ( {poaProvided && !poaSameAsOwner && (
<Stack gap="sm" mt="xl"> <Stack gap="sm" mt="xl">
<Group justify="space-between" align="center"> <Group justify="space-between" align="center">
<Group gap="sm"> <Group gap="sm">
@@ -459,8 +551,12 @@ export default function TabPowerOfAttorney({
)} )}
</Group> </Group>
<Group gap="md"> <Group gap="md">
{/* Not offered against a "same as owner" declaration: unchecking
the card above is the way out of that one, and it leaves the
paper alone. */}
{mode === "edit" && {mode === "edit" &&
identity?.poa.verified && identity?.poa.verified &&
!poaSameAsOwner &&
!requirePoa && ( !requirePoa && (
<Button <Button
type="button" type="button"

View File

@@ -162,6 +162,11 @@ export interface OnboardingLicenseProfile {
export interface OnboardingPoaState { export interface OnboardingPoaState {
required: boolean; required: boolean;
provided: boolean; provided: boolean;
/**
* True when the DARS delegation paper is owed. False when the owner
* represents the company themselves — nobody delegates to themselves.
*/
delegationLetterRequired: boolean;
delegationLetterUploaded: boolean; delegationLetterUploaded: boolean;
/** True when a reviewer sent the DARS delegation paper back for correction. */ /** True when a reviewer sent the DARS delegation paper back for correction. */
delegationLetterFlagged: boolean; delegationLetterFlagged: boolean;

View File

@@ -41,6 +41,13 @@ export interface CompanyIdentityState {
passportRequired: boolean; passportRequired: boolean;
owner: OwnerIdentityState; owner: OwnerIdentityState;
poa: IdentityVerificationState; poa: IdentityVerificationState;
/**
* True when the representative is the owner themselves, declared through
* "same as owner". Waives the DARS delegation paper — nobody delegates to
* themselves — and, where the owner is Fayda-verified, backs `poa.verified`
* with the owner's sub.
*/
poaSameAsOwner: boolean;
/** /**
* General manager. `verified` covers both routes: the GM verifying in their * General manager. `verified` covers both routes: the GM verifying in their
* own right, and the company declaring the GM is the owner (in which case * own right, and the company declaring the GM is the owner (in which case
@@ -143,6 +150,34 @@ export const verifaydaService = {
return unwrap(response.data); return unwrap(response.data);
}, },
/**
* Declare the Power of Attorney is the company's owner. A Fayda-verified
* owner's identity is copied server-side (the portal never supplies it); a
* foreign company's owner has nothing proven to copy, so the API records the
* declaration and the form types the representative's details as usual.
*
* Either way the declaration is what waives the DARS delegation paper.
*/
setPoaSameAsOwner: async (): Promise<CompanyIdentityState> => {
const response = await client.post<ApiResponse<CompanyIdentityState>>(
"/api/companies/identity/poa/same-as-owner",
);
return unwrap(response.data);
},
/**
* Undo that declaration and the identity it copied, leaving the
* representative open to be verified in their own right. Unlike
* {@link removePoa} this is allowed for a freight forwarder — it is how they
* change who represents them — and leaves the delegation paper on file.
*/
clearPoaSameAsOwner: async (): Promise<CompanyIdentityState> => {
const response = await client.delete<ApiResponse<CompanyIdentityState>>(
"/api/companies/identity/poa/same-as-owner",
);
return unwrap(response.data);
},
/** /**
* Drop the Power of Attorney — verified identity, details and delegation * Drop the Power of Attorney — verified identity, details and delegation
* paper together. A verified person's fields are locked, so blanking the form * paper together. A verified person's fields are locked, so blanking the form