Skip to content

[UX] customer-portal — Contacts ​

Draft from /ux-audit on 2026-07-30 (unattended batch run). Not filed. Repo: Adalen-Truck/customer-portal · Branch: develop @ 693a76c · Files reviewed: 23 Patterns: forms/phone-number

Summary ​

Contacts is a small card-list page over a shared DataTable, plus a five-field create/edit sheet. The list half is unremarkable and inherits the known packages/ui defects. The write half is not: two independent Blockers mean the feature cannot work today — the route and sidebar gate on a CASL subject ("Contact") that the permission system never grants, so every non-admin user is bounced to /forbidden; and the edit call sends a Dataverse externalId where the API looks up an internal uuid, so saving an edit returns 404 every time. Beyond that, the phone field is a bare text input on a mobile-first app and the contact cards render phone numbers and email addresses as inert text while seven other screens in the same app already use tel: / mailto:.

Findings ​

1. The whole feature is unreachable for every non-admin user — the ability gate checks a subject that does not exist — Blocker · proposed NAV-GATE-SUBJECT ​

Where: apps/web/src/router/index.ts:295, apps/web/src/layouts/AppLayout.vue:140 (and :104) What: Both gates ask for the subject "Contact": abilityCheck: { action: "read", subject: "Contact" } and visible: custVisible(can("read", "Contact")). The backend's subject list (apps/api/src/permissions/ability.ts:6-47) contains CustomerContact, not Contact; the customer role is granted read/create/update CustomerContact (apps/api/src/permissions/roles.ts:75,102,115). Both evaluators compare the subject by exact string — checkAbility at router/index.ts:569 (rule.subject === "all" || rule.subject === subject) and matchesPermission at packages/domain-helpers/src/permissions.ts:29 — with one escape hatch: the admin role short-circuits to true (router/index.ts:562, permissions.ts:60). So can("read","Contact") is false for every customer, partner and logistics user, and true only for admins. Every sibling route uses the correct name (CustomerAsset, Booking, TradeInMachine, Location), so this is a one-word slip, not a convention. Why it matters: For the users the page exists for — customers managing their own contact persons — the Contacts entry never appears in the sidebar, and a deep link or bookmark to /contacts redirects to /forbidden. The feature is invisible and inaccessible to its entire audience while looking fine to the admins who test it. Fix: Change the subject to "CustomerContact" in router/index.ts:295 and AppLayout.vue:104,140. Longer term, type the route/nav subject against the generated AppSubjects union so an unknown subject is a build error rather than a silent deny.

2. Saving an edit always fails — the page sends an externalId where the API looks up an internal uuid — Blocker · proposed DATA-ID-BOUNDARY ​

Where: apps/web/src/pages/ContactsPage.vue:54What: The page sends contactId: editingContact.value.id!. That id is the Dataverse GUID: toContactResponse maps id: contact.externalId (apps/api/src/modules/contacts/contacts.dto.ts:41). The command handler then does findContactById(body.contactId) (apps/api/src/modules/dataverse-commands/dataverse-commands.service.ts:781), which is prisma.customerContact.findUnique({ where: { id } }) (dataverse-commands.repository.prisma.ts:36-40) — the local primary key. Those are two different id spaces by construction: id String @id @default(uuid()) and externalId String @unique (packages/database/prisma/app/customer_contact.prisma:2-3), and the ingest never sets id explicitly — upsertByExternalId creates with { externalId, ...data } only (services/worker/src/worker/sync-events/utils.ts:111-116). The lookup therefore returns null 100% of the time and the service throws NotFoundException("Contact not found") (dataverse-commands.service.ts:783). The API tests do not catch this because they mock the repository (test/api/dataverse-commands.service.spec.ts:448). Create is unaffected (no lookup). Note this contradicts the repo's own rule in CLAUDE.md: boundary lookups for synced entities must resolve by externalId. Why it matters: A user edits a contact's phone number, presses Save, and gets an error toast — every time, for every contact. Editing is advertised on every card (the whole card is an edit button) and never succeeds. Fix: Resolve by externalId in findContactById — mirror findWorkOrderById (dataverse-commands.repository.prisma.ts:42-48), which already accepts either id space via an OR. The same defect exists in findLocationById (:30-34) with toLocationResponse also emitting externalId (locations.dto.ts:45), so Locations (queue #20) needs the same fix.

3. "Contact created successfully" is shown while the list still does not contain the contact, for up to five minutes — High · MSG-06 family, proposed MSG-EVENTUAL-CONSISTENCY ​

Where: apps/web/src/domains/contacts/contacts.mutations.ts:26 and apps/web/src/pages/ContactsPage.vue:71What: The write path is Dataverse-first: the command handler POSTs to Dataverse and writes nothing to the local database (services/worker/src/worker/dataverse-commands/handlers/contact-create.handler.ts:28-50 only patches the command record). The list, however, reads the local mirror (contacts.collection.ts:6 → ContactsService.list → Prisma). The mirror is refreshed by a cron: @Cron(CronExpression.EVERY_5_MINUTES) (services/sync-service/src/scheduler/dataverse-sync.scheduler.ts:28). The mutation nevertheless fires queryClient.invalidateQueries({ queryKey: ["contacts"] }) the instant the command reports SUCCEEDED, and the page shows notifySuccess(t("locationsContacts.contactCreated")) — "Contact created successfully." The sheet closes onto a list that is unchanged. Why it matters: The success message asserts a state the very next screen contradicts. The natural user response is to create the contact again — and each submit generates a fresh idempotency key (contacts.mutations.ts:14), so the second attempt is a genuinely new Dataverse record. Silent duplicates in the customer's contact list. Fix: Either write the created/updated row into the local table inside the command handler (the read model is already keyed by externalId, and the handler has the created id at contact-create.handler.ts:37), or make the copy honest: "Contact created. It will appear in the list shortly." plus a refresh control. Do not leave a success toast whose claim the adjacent list disproves. Severity call: High rather than Blocker — the write does reach the system of record, so nothing is lost; the harm is the false report and the duplicates it invites.

4. The phone field is a bare text input — no tel type, no autocomplete, no format guidance — Medium · FORM-04 ​

Where: apps/web/src/components/EditContactModal.vue:122-126 (and :86-90, :132-138) What: Phone is <InputText id="contact-modal-phone" v-model="form.phone"> with nothing else: no type="tel", no inputmode="tel", no autocomplete="tel", no placeholder or helper text showing an expected shape, and no country-code guidance. The forms/phone-number pattern's reference implementation is <input type="tel" placeholder="+1 (555) 123-4567"> with the helper "Include the country code when numbers may come from different regions", and it names "forgetting touch and autofill behaviour" as one of three anti-patterns. This app is explicitly mobile-first — apps/web/CLAUDE.md §12: "used primarily on mobile phones (PWA + Capacitor wrapper)" — so the missing type="tel" costs the user the numeric keypad on every entry. The repo already does this correctly one page over: apps/web/src/pages/SkillsPage.vue:463 uses type="tel" for the employee phone field. Name and email are similarly bare of autocomplete (name, email), though email at least sets type="email" (:137). Why it matters: On a phone, a text-typed input opens the full QWERTY keyboard for a numeric entry, and no autofill is offered. With no format hint, an Aadalen customer will type 95 12 34 56, +4795123456 and 0047 95123456 interchangeably; nothing normalises them, so search over the phone column (server-search) and any later export are inconsistent. Nothing warns that numbers stored for international contacts need a country code. Fix: type="tel" inputmode="tel" autocomplete="tel" on the phone input, autocomplete="name" / autocomplete="email" on the other two, and a helper line under the phone field giving the expected format and stating that a country code is required for non-Norwegian numbers. Normalise to E.164 on submit.

Where: apps/web/src/components/ContactCard.vue:68-81What: The card renders the phone and email rows as <HighlightText> inside a plain <div>. Neither is a link. This is the app's contact directory — the one surface whose entire purpose is "find the person and reach them" — and it is the only place in the app that does not link them: tel: appears at OrderDetailPage.vue:421, BookingDetailPage.vue:413, PartnerServiceOrderDetailPage.vue:351,599, PartnerBookingDetailMobile.vue:94,323 and BookServicePage.vue:590, and mailto: at ten further sites. Worse, the whole card is wrapped in a <button> whose click opens the edit sheet (ContactCard.vue:28-34), so tapping the phone number — the obvious gesture — opens an edit form instead of the dialer. Why it matters: On the Capacitor/PWA build a user who wants to call a site contact must long-press to select the number, copy it, leave the app and paste it into the dialer; the intuitive tap does the opposite of what they wanted. On mobile Safari/Chrome, unlinked numbers are also not offered to the OS for auto-detection inside a <button>. Fix: Render phone as <a :href="'tel:' + contact.phone"> and email as <a :href="'mailto:' + contact.email">, and stop the click propagating to the card button (@click.stop). Since the card is a <button>, nested links are invalid HTML — move the edit affordance to a dedicated icon button in the card header and make the card body a plain container. Proposed rule: MOBILE-01 — on a surface targeted at phones, a displayed phone number, email address or postal address is an actionable link (tel: / mailto: / maps), not inert text.

6. The create/edit form has no validation and never says which fields are required — Medium · FORM-04, FORM-05 ​

Where: apps/web/src/components/EditContactModal.vue:77-141What: Five inputs, no required, no asterisk, no constraint text, no validation state and no error slots. Save is always enabled (which is correct per FORM-05) but nothing explains a block, because the block only happens server-side: CreateContactCommandBody.name carries @IsNotEmpty() (apps/api/src/modules/contacts/contacts.dto.ts:53-57), so an empty form round-trips to a 400. Email is never validated beyond type="email" inside a form that never submits natively (see finding 8), so the browser's own constraint validation never runs either — an address with no @ is accepted and stored. Why it matters: The only way to discover that Name is mandatory is to fill in the other four fields, press Save, wait for the round trip and read an error toast — which, per the project-level getErrorMessage defect, is the raw class-validator string rather than a sentence. A mistyped email is stored silently and the contact becomes unreachable. Fix: Mark Name required in the label and validate it on blur; validate the email shape client-side; show the messages next to their fields rather than in a toast.

7. Save is disabled while the mutation runs, dropping keyboard focus — Medium · FORM-06 ​

Where: apps/web/src/components/EditContactModal.vue:151What: :disabled="loading" (plus :loading="loading") on the Save button. Disabling the element the user just activated removes it from the accessibility tree and drops focus to <body>. The handler already has a correct in-code double-submit guard (:64-66, if (props.loading) return;), so the disabled binding buys nothing. Asserted from source for the :disabled binding; whether PrimeVue's :loading also sets disabled is unverifiable here. Why it matters: A keyboard or screen-reader user loses their place mid-save and has to tab back in from the top of the sheet to reach Cancel or the fields. Fix: Drop :disabled, keep the handler guard, and add :aria-busy="loading".

8. Pressing Enter in the form does nothing — Medium · proposed FORM-IMPLICIT-SUBMIT ​

Where: apps/web/src/components/EditContactModal.vue:77-141 vs :143-157What: The <form @submit.prevent="handleSubmit"> contains five inputs and no submit button — Save lives in the dialog's #footer slot, which ResponsiveDialog renders outside the form element in both branches (ResponsiveDialog.vue:50-60 for the mobile BottomSheet, :76-79 for the desktop Dialog). Its type="submit" is therefore inert and only the @click works. Per the HTML implicit-submission rules, a form with more than one implicit-submission-blocking field and no submit button does not submit on Enter, so the @submit handler is only ever reachable by mouse/tap on Save. Why it matters: Finishing a short form with Enter is a reflex on desktop. Here it silently does nothing, which reads as a hung form. Fix: Give the form an id and point the footer button at it (<Button type="submit" form="contact-form">), or bind @keydown.enter="handleSubmit" on the form. Proposed rule: FORM-IMPLICIT-SUBMIT — Enter submits a form from any of its fields; a submit control rendered outside the <form> element is associated back to it with the form attribute.

9. Every contact update is sent with expectedEtag: "*", disabling the concurrency control the architecture mandates — Medium · proposed DATA-CONCURRENCY ​

Where: apps/web/src/pages/ContactsPage.vue:55What: The page hardcodes expectedEtag: "*". That value is forwarded verbatim into the command record (dataverse-commands.service.ts:800) and becomes If-Match: * on the Dataverse PATCH (services/worker/.../contact-update.handler.ts:30) — a wildcard that matches any version, i.e. an unconditional overwrite. apps/api/CLAUDE.md §4 states "Optimistic concurrency (ETags / If-Match) is mandatory". The page cannot do better today because ContactResponse exposes no etag or rowversion field (contacts.dto.ts:8-38), so the read model gives it nothing to send. The only other site doing this is LocationsPage.vue:55. Why it matters: Two people editing the same contact — plausible in a customer account with several administrators, and more so given the 5-minute mirror lag in finding 3 means both are editing stale data — silently overwrite each other with no warning and no way to recover the lost values. Severity call: Medium rather than High because it is currently latent behind finding 2 (no update ever reaches Dataverse). It becomes a live lost-update risk the moment finding 2 is fixed. Fix: Expose the Dataverse etag on ContactResponse, send the one that came with the row, and surface a "this contact changed while you were editing" conflict state on the 412.

10. Create and edit affordances are shown without consulting can() — Low · proposed PERM-AFFORDANCE ​

Where: apps/web/src/pages/ContactsPage.vue:88-94, :118-124, :128What: The "New contact" button (desktop and the mobile FAB) renders unconditionally, and ContactCard is passed a literal can-edit with no expression, so every card is an edit button for every user. The backend asserts create / update on CustomerContact (dataverse-commands.service.ts:740,776). usePermissions().can() exists and is used for exactly this elsewhere (PartnerServiceOrderDetailPage.vue:31, ShippingOrderReceiptPage.vue:721). Why it matters: A role granted read-only access to contacts gets the full create/edit UI and only discovers the truth as a 403 toast after filling the form. Low because the default customer role holds all three actions, so the affected population is custom roles only. Fix: v-if="can('create','CustomerContact')" on the two buttons and :can-edit="can('update','CustomerContact')" on the card.

11. The edit sheet opens with focus on the panel, not the first field — Low · FORM-11 ​

Where: apps/web/src/components/EditContactModal.vue:72-76 and packages/ui/src/components/BottomSheet.vue:78-83What: BottomSheet.focusInitial() focuses panel.value unless an initialFocus selector is supplied; ResponsiveDialog does not expose the prop and EditContactModal does not pass one. No autofocus on the Name input either. (The rest of the sheet's focus handling is good: role="dialog", aria-modal, a working Tab trap, Escape, and focus restore to the opener — BottomSheet.vue:110-157.) Why it matters: Every open costs an extra Tab, and a screen-reader user lands on an unnamed container rather than the first labelled field. Fix: Forward an initial-focus prop through ResponsiveDialog and pass #contact-modal-name.

Already covered elsewhere — not re-filed ​

  • Shared packages/ui DataTable defects — see PROJECT-LEVEL.md and drafts/customer-portal--catalog.md findings 4-7. This page is an instance of all four: the error copy at ContactsPage.vue:101 interpolates TanStack's status string, so a failed load reads "Could not load data (status: error)." and DataTable's 403 branch (DataTable.vue:271) is unreachable dead code (statusCode is only ever null here); the desktop search box has no label; the pagination strip is hardcoded English; and the empty state is the same "No contacts available." whether the account has no contacts or the search matched nothing, with no #empty-action and no desktop clear-search.
  • List state is component-local — no URL sync, no KeepAlive; search and page reset on any navigation away. Project-wide, see PROJECT-LEVEL.md.
  • NAV-03 — one static document title app-wide. Project-wide.
  • MSG-02 — getErrorMessage prefers the raw backend string, so the 404 from finding 2 and the 400 from finding 6 both surface as untranslated developer prose. Helper-level, see PROJECT-LEVEL.md.
  • CONTENT-01 — the page's own keys have full en/nb parity (i18n/messages/{en,nb}.yml, contacts.* and locationsContacts.*). The gaps are all in packages/ui, which has no i18n layer: BottomSheet.vue:243aria-label="Close", CompactSearch's default "Search", DataTable's pagination strip and its "No data available." / "Failed to load data." fallbacks. Worth one issue against packages/ui rather than per feature.
  • A11Y-04 — passes. PageLayout.vue:103 renders a real <h1>; the mobile duplicate in MobileListHeader is behind mobile:hidden on the desktop copy.
  • Archive/deactivate actually revokes access (PROJECT-LEVEL open question) — not applicable here: this feature has no delete, archive or deactivate action at all. CustomerContact.isActive exists in the schema (customer_contact.prisma:13) but is never read or written by this page, and contacts are not principals, so no session question arises.

Unverified ​

  • A11Y-01 (contrast) — the card uses text-text-2 / text-text-3 on accent-soft and paper (ContactCard.vue:55,63,65). Needs a contrast tool.
  • A11Y-06 (short viewport / open keyboard) — the edit sheet is capped at 86% of viewport height (ResponsiveDialog.vue:27) with a sticky footer; whether the five fields and the footer survive a 700px-tall viewport with the mobile keyboard open needs a rendered page.
  • MSG-01 for the save toasts — notifySuccess / notifyError delegate to PrimeVue's Toast (packages/ui/src/notifications/NotificationsHost.vue:2,64), whose ARIA role is a PrimeVue internal and node_modules is not installed. The wrapper adds no role/aria-live of its own.
  • A11Y-02 (24×24 targets) — the mobile FAB and the sheet's 30px close button look adequate from the classes, but PrimeVue Button padding is not readable here.

Baseline additions ​

Descriptive IDs with definitions; the orchestrator should renumber.

  • NAV-GATE-SUBJECT — a route guard or nav-visibility check must test a permission the authorization system can actually grant. A gate naming a non-existent action/subject silently denies everyone below the admin short-circuit. Where the subject vocabulary is generated (as it is here), the frontend check should be typed against it.
  • DATA-ID-BOUNDARY — an identifier crossing a module boundary must belong to the id space the receiver looks it up in. Where a synced entity has both a local key and an external key, the API contract names which one it exposes and every lookup honours it.
  • MSG-EVENTUAL-CONSISTENCY — success feedback must not assert a state the user's next screen contradicts. Where a write lands in a system of record that the read model mirrors asynchronously, either reconcile the local view or say the change is pending; never show "created" over a list that does not contain it.
  • MOBILE-01 — on a surface targeted at phones, a displayed phone number, email address or postal address is an actionable link (tel:/mailto:/maps), not inert text, and tapping it does not trigger an unrelated action.
  • FORM-IMPLICIT-SUBMIT — Enter submits a form from any of its fields; a submit control rendered outside the <form> element (a dialog footer) is associated back to it via the form attribute.
  • DATA-CONCURRENCY — an update sent on behalf of a record the user was shown must carry that record's version, and a version conflict must be surfaced as a conflict, not resolved by silent overwrite. If-Match: * is not a concurrency control.
  • PERM-AFFORDANCE — a control for an action the user's permissions do not allow is not rendered. Discovering a denial by submitting is not an acceptable substitute for hiding or disabling the control with an explanation.

Cross-project note ​

  • DATA-ID-BOUNDARY (finding 2) — the identical defect exists in customer-portal Locations (queue #20): findLocationById looks up the internal id while toLocationResponse emits externalId. Any project with a local mirror of an external system is exposed; tt-time-tracker and members do not sync from a third-party system of record, so this is likely customer-portal-only.
  • MSG-EVENTUAL-CONSISTENCY (finding 3) — customer-portal-wide: every Dataverse command page (Locations, Trade-in machines, Customer assets) shows a success toast and invalidates a query against the 5-minute-lagged mirror. It is also the same family as playout's "success feedback fires before the write resolves" already recorded in PROJECT-LEVEL.md, so the two should merge.
  • MOBILE-01 (finding 5) — check members (contact details in widgets) and tt-time-tracker (client/employee records); playout has no contact surface.
  • FORM-IMPLICIT-SUBMIT (finding 8) — any dialog-with-footer-button form in any of the four projects is a candidate; all four use a dialog component that renders the footer outside the content slot.
  • PERM-AFFORDANCE (finding 10) — customer-portal is the only one of the four with a real per-user permission model, so this is largely local to it.