mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 11:55:42 +00:00
feat: allow owner to represent company as power of attorney
This commit is contained in:
@@ -355,28 +355,33 @@ export default function CompanyProfileForm({
|
||||
// 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
|
||||
// unticked box over a GM that is linked server-side.
|
||||
const [poaSameAsOwner, setPoaSameAsOwner] = useState(
|
||||
identity?.poaSameAsOwner ?? false,
|
||||
);
|
||||
const identityLoaded = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!identity || identityLoaded.current) return;
|
||||
identityLoaded.current = true;
|
||||
setGmSameAsOwner(identity.gmSameAsOwner);
|
||||
setPoaSameAsOwner(identity.poaSameAsOwner);
|
||||
}, [identity]);
|
||||
const [contactSameAsGm, setContactSameAsGm] = useState(false);
|
||||
|
||||
// A Fayda-verified owner outranks eTrade's registered owner — it's the
|
||||
// higher-trust source, and the whole point of proving identity is to stop
|
||||
// trusting typed/looked-up data for this.
|
||||
const gmSourceName = firstPresent(
|
||||
// Where the owner's details come from when they are copied onto someone else
|
||||
// — the GM, or the representative. A Fayda-verified owner outranks eTrade's
|
||||
// registered owner: it's the higher-trust source, and the whole point of
|
||||
// proving identity is to stop trusting typed/looked-up data for this.
|
||||
const ownerSourceName = firstPresent(
|
||||
identity?.owner.name,
|
||||
etradeOwner?.name,
|
||||
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
|
||||
// `generalManagerPhone`, which the API validates with `@IsValidPhone()`, so an
|
||||
// unusable eTrade number here 400s the personnel step instead.
|
||||
const gmSourcePhone = firstValidPhone(
|
||||
// `generalManagerPhone` / `poaPhone`, which the API validates with
|
||||
// `@IsValidPhone()`, so an unusable eTrade number here 400s the step instead.
|
||||
const ownerSourcePhone = firstValidPhone(
|
||||
identity?.owner.phone,
|
||||
etradeOwner?.phone,
|
||||
user.phoneNumber,
|
||||
@@ -388,13 +393,30 @@ export default function CompanyProfileForm({
|
||||
// `identity.gm`; mirroring it into form fields here would send typed
|
||||
// values for something the API already owns.
|
||||
if (identity?.owner.verified) return;
|
||||
setValue("generalManagerName", gmSourceName, { shouldValidate: true });
|
||||
setValue("generalManagerEmail", gmSourceEmail, { shouldValidate: true });
|
||||
setValue("generalManagerPhone", gmSourcePhone, {
|
||||
setValue("generalManagerName", ownerSourceName, { shouldValidate: true });
|
||||
setValue("generalManagerEmail", ownerSourceEmail, { shouldValidate: true });
|
||||
setValue("generalManagerPhone", ownerSourcePhone, {
|
||||
shouldValidate: true,
|
||||
});
|
||||
// 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.
|
||||
@@ -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
|
||||
// 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.
|
||||
@@ -704,7 +764,12 @@ export default function CompanyProfileForm({
|
||||
// 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
|
||||
// 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 =
|
||||
(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.
|
||||
const poaGap = (v?: string | null) =>
|
||||
poaTypedAllowed && (!identity?.poa.verified || !v?.trim());
|
||||
const poaGaps = {
|
||||
name: poaGap(identity?.poa.name),
|
||||
email: poaGap(identity?.poa.email),
|
||||
phone: poaGap(identity?.poa.phone),
|
||||
address: poaGap(identity?.poa.address),
|
||||
};
|
||||
/**
|
||||
* "Same as owner" answers each field only as far as the owner actually has
|
||||
* one. Fayda's name, email and phone claims are all optional, the account and
|
||||
* eTrade fallbacks can be empty or unusable, and `REQUIRED_POA_FIELDS` still
|
||||
* 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
|
||||
// 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
|
||||
@@ -1024,6 +1115,10 @@ export default function CompanyProfileForm({
|
||||
form={form}
|
||||
identity={identity}
|
||||
requirePoa={requirePoa}
|
||||
poaSameAsOwner={poaSameAsOwner}
|
||||
onTogglePoaSameAsOwner={togglePoaSameAsOwner}
|
||||
poaLinkPending={poaLinkPending}
|
||||
etradeOwner={etradeOwner}
|
||||
gaps={poaGaps}
|
||||
onRemovePoa={removePoa}
|
||||
removePending={poaRemovePending}
|
||||
|
||||
@@ -10,20 +10,27 @@ export function LinkCheckboxCard({
|
||||
onToggle,
|
||||
title,
|
||||
description,
|
||||
disabled = false,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onToggle: (checked: boolean) => void;
|
||||
title: string;
|
||||
description: string;
|
||||
/** Greys the card out and refuses the toggle — the link is not available yet. */
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<UnstyledButton
|
||||
onClick={() => onToggle(!checked)}
|
||||
onClick={() => !disabled && onToggle(!checked)}
|
||||
role="checkbox"
|
||||
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-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">
|
||||
|
||||
@@ -9,12 +9,20 @@ import type { FileUploadSetting } from "@/types/fileUploadSettings";
|
||||
import type { CompanyIdentityState } from "@/services/verifayda.service";
|
||||
|
||||
import type { FormData } from "../schema";
|
||||
import { LinkCheckboxCard } from "../LinkCheckboxCard";
|
||||
|
||||
export interface PoaStepProps {
|
||||
form: UseFormReturn<FormData>;
|
||||
identity?: CompanyIdentityState;
|
||||
/** This company holds a freight-forwarder profile, so the PoA is mandatory. */
|
||||
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
|
||||
* supply, and are therefore typed here. Computed by CompanyProfileForm, which
|
||||
@@ -39,6 +47,10 @@ export default function PoaStep({
|
||||
form,
|
||||
identity,
|
||||
requirePoa,
|
||||
poaSameAsOwner,
|
||||
onTogglePoaSameAsOwner,
|
||||
poaLinkPending,
|
||||
etradeOwner,
|
||||
gaps,
|
||||
onRemovePoa,
|
||||
removePending,
|
||||
@@ -65,26 +77,64 @@ export default function PoaStep({
|
||||
const needsEmail = gaps.email;
|
||||
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 (
|
||||
<>
|
||||
<Text size="sm" c="edr-muted">
|
||||
{requirePoa
|
||||
? "As a freight forwarder you act on other companies' behalf, so Power of Attorney details and the DARS delegation paper 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."}{" "}
|
||||
{/* The API refuses an owner who delegates to themselves — say so here,
|
||||
or the customer only finds out after being sent to Fayda and back. */}
|
||||
The representative must be someone other than the company's owner.
|
||||
? "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."}{" "}
|
||||
{poaSameAsOwner
|
||||
? "You represent the company yourself, so no delegation paper is needed."
|
||||
: "If the representative is someone other than the owner, upload the delegation paper authenticated by DARS."}
|
||||
</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
|
||||
whoever owns it, so the PoA is proven with Fayda regardless of
|
||||
nationality — their name, email, phone and address all come
|
||||
from the verification and are never typed here. */}
|
||||
{identity && (
|
||||
from the verification and are never typed here. Verifying a second
|
||||
person is only meaningful when the representative is not the owner. */}
|
||||
{identity && !poaSameAsOwner && (
|
||||
<FaydaVerifyPanel
|
||||
subject="poa"
|
||||
title="Power of Attorney"
|
||||
state={identity.poa}
|
||||
required={requirePoa}
|
||||
disabled={poaLinkPending}
|
||||
/>
|
||||
)}
|
||||
{/* 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
|
||||
back out, here rather than only in settings (unreachable until
|
||||
onboarding finishes). */}
|
||||
{identity?.poa.verified && !requirePoa && (
|
||||
{identity?.poa.verified && !requirePoa && !poaSameAsOwner && (
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
} from "@/services/companies.service";
|
||||
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
|
||||
import { verifaydaService } from "@/services/verifayda.service";
|
||||
import { LinkCheckboxCard } from "@/pages/accounts/companyProfileForm/LinkCheckboxCard";
|
||||
import type { ProfileResponse } from "@/types/profile";
|
||||
|
||||
// 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
|
||||
// has been verified.
|
||||
const identity = profile.identity;
|
||||
const owner = identity?.owner;
|
||||
const poaProvided = identity?.poa.verified ?? false;
|
||||
const [poaSameAsOwner, setPoaSameAsOwner] = useState(
|
||||
identity?.poaSameAsOwner ?? false,
|
||||
);
|
||||
// The paper authorises the representative named above, so there is nothing
|
||||
// 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
|
||||
// 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.
|
||||
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 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) => {
|
||||
// The letter lives outside the form state, so it's gated here rather than
|
||||
// in the zod resolver.
|
||||
@@ -247,17 +296,60 @@ export default function TabPowerOfAttorney({
|
||||
</Group>
|
||||
<Text c="edr-muted" size="sm" mb="lg">
|
||||
{requirePoa
|
||||
? "As a freight forwarder you act on other companies' behalf, so a Power of Attorney and its DARS delegation paper are required."
|
||||
: "Power of Attorney details are optional. If you name a representative, upload the DARS delegation paper authorising them."}
|
||||
? "As a freight forwarder you act on other companies' behalf, so a Power of Attorney is required."
|
||||
: "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>
|
||||
|
||||
{/* 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 && (
|
||||
<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
|
||||
subject="poa"
|
||||
title="Power of Attorney"
|
||||
state={identity.poa}
|
||||
required={requirePoa}
|
||||
disabled={mutation.isPending}
|
||||
disabled={mutation.isPending || linkPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -284,7 +376,7 @@ export default function TabPowerOfAttorney({
|
||||
{/* ------------------------ Delegation letter ------------------------ */}
|
||||
{/* The paper authorises the representative the verification named,
|
||||
so it only has meaning once one exists. */}
|
||||
{poaProvided && (
|
||||
{poaProvided && !poaSameAsOwner && (
|
||||
<Stack gap="sm" mt="xl">
|
||||
<Group justify="space-between" align="center">
|
||||
<Group gap="sm">
|
||||
@@ -459,8 +551,12 @@ export default function TabPowerOfAttorney({
|
||||
)}
|
||||
</Group>
|
||||
<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" &&
|
||||
identity?.poa.verified &&
|
||||
!poaSameAsOwner &&
|
||||
!requirePoa && (
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -162,6 +162,11 @@ export interface OnboardingLicenseProfile {
|
||||
export interface OnboardingPoaState {
|
||||
required: 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;
|
||||
/** True when a reviewer sent the DARS delegation paper back for correction. */
|
||||
delegationLetterFlagged: boolean;
|
||||
|
||||
@@ -41,6 +41,13 @@ export interface CompanyIdentityState {
|
||||
passportRequired: boolean;
|
||||
owner: OwnerIdentityState;
|
||||
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
|
||||
* 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);
|
||||
},
|
||||
|
||||
/**
|
||||
* 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
|
||||
* paper together. A verified person's fields are locked, so blanking the form
|
||||
|
||||
Reference in New Issue
Block a user