mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 17:45:42 +00:00
@@ -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({
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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")] });
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
};
|
|
||||||
@@ -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,17 +21,10 @@ 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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 />}>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user