Просмотр исходного кода

One way to add somebody, and a technician sign-in that fits a workshop (#269)

* Put a technician's phone on the workshop with a QR

Setup was a URL, an email and a password, typed on a phone by someone
with dirty hands while the desk watched. Now the desk taps Set up app on
the team row, a QR goes up, the technician scans it, and they are in.

The code is a bearer credential in a picture, so it is treated as one.
Ten minutes, single use, bound to one person in one workshop, checked
again at redemption in case they stopped being a technician in between,
and burned before a session is minted so a failure spends it rather than
leaving it live. Only a hash is stored, so the table hands over nobody's
session. Closing the dialog revokes it, and issuing a second invalidates
the first.

Eight characters rather than the six the note proposed. The redeem
endpoint has to be reachable without credentials, and six is about a
billion, which a botnet can dent inside ten minutes. Eight is a thousand
times more and still two groups of four read down a phone. The alphabet
drops O/0, I/1 and S/5 so nothing has to be spelled out, and nothing is
folded back on input: with neither half of a confusable pair in the
alphabet there is no character a typed O could safely be turned into.

The typed fallback joins the cloud, since a code carries no address of
its own. Self-hosted technicians scan, or set the address up first.

Reachable from sign-in too, not just first run. A technician locked out
on a Monday has the same problem and the desk is standing right there.

The camera permission lands earlier than anywhere else in the app and
before anyone has signed in, so the reason is on screen before the system
asks. Someone who declines a prompt they did not expect has no way back
except reinstalling.

* Make the setup QR survive being scanned with the phone camera

The QR carried a JSON payload, so pointing a phone's own camera at it
opened a text editor full of braces. That is the first thing anybody
does with a QR, so it is the first thing the design has to answer.

It is now a URL on the workshop's own domain, with the code in the
fragment: the origin is the address to connect to, so nothing else has
to be encoded, and the fragment reaches no server log or Referer on the
way. Behind it is a page telling the technician to open Torqvoice Tech
and scan it there, showing the code to type instead, and offering a deep
link into the app when it is installed.

The dialog says it three times over now, because one sentence was not
enough: numbered steps, step two naming the button verbatim in the
technician's own language, and a line saying the phone's camera app will
not do. Getting that wrong is what sends somebody to a web page and
convinces them the thing is broken.

* Sign a technician back in with their phone number

A mechanic has no email the workshop gave them and no password they
chose, because the desk made their account while they stood at the
counter. What they have is the phone in their hand, so that is the way
back in: a number, a six digit code, done. Email works the same way for
anyone who does have an address.

Scoped to one workshop from end to end. The organisation is in the path,
never in the body and never inferred from the number, so the lookup is
this phone in this shop and cannot be anything else. The technician is
re-read at the moment of verification rather than trusted from when the
code was sent, so somebody deactivated in between does not get in, and
the code is burned before a session is minted.

Answers identically whether the number belongs to anybody or not, or the
workshop exists at all. Otherwise this is a way to ask a shop whether it
employs a given phone number.

Six digits is a million, which rate limiting by address does not protect
against a hundred machines sharing the work, so a wrong guess ages every
live code in the workshop and five wrong guesses burn them.

Phone lives on the technician rather than the user, so the same person
working Saturdays somewhere else is two employment records and never one
account two workshops can both reach.

* Create a mechanic's account at the counter, and revoke it properly

The phone moves from the technician record to the person, which is where
their name and their email already live. Storing it on the employment
record made it something a promotion could detach: move somebody to the
desk, deactivate their technician row, and the one thing they sign in
with goes with it. The workshop scoping was never coming from the column
anyway, it comes from the query, and that is unchanged.

createTechnicianAccount makes the account outright. A name and a mobile,
which is everything a workshop knows about somebody on their first
morning. No email, no password, no acceptance step, no waiting on an
inbox the shop cannot see. The address it invents ends in .invalid so
nothing can ever be sent to it by accident, and giving them a real one
later is what promotion looks like rather than a second account.

removeTechnicianAccess cuts the phone off for real. Deactivating the
technician row was already most of it, since every request re-reads it,
but the session outlived it: the token stayed real, and reactivating
somebody months later brought it back. Sessions, outstanding codes and
push devices now go together, and sessions only when the person is not a
member of another branch, so one shop letting them go does not sign them
out of the other.

* Add a mechanic from the team page, in one pass

The two actions existed and nothing called them. The team page now has
the other door next to the invite form: a name, a mobile, Create. The
account is real immediately and the setup code comes up the moment it is
made, because the person it is for is standing at the counter and
splitting that into two jobs is how the second one gets forgotten.

The handoff dialog gained Copy link, which is the option that works for
every workshop. SMS needs a provider configured and plenty of shops will
never have one, while every shop already has some way of messaging its
own staff.

Turning the technician switch off now revokes rather than deactivates,
and asks first, because it signs somebody out of the phone in their hand
while they are using it. Their finished work and their hours are
untouched, and the confirmation says so.

* Close what the audit found in the technician sign-in

Four real ones, two of them serious, all mine.

The request endpoint reported whether a technician exists. It returned
channel:"sms" on a match and channel:null on a miss, so anyone could ask a
workshop whether it employs a given phone number. I had claimed in a
comment that it answered identically and written a test asserting the
leak. It now echoes what was asked for and never what was found.

The rate limit came off with a made-up token. rateLimit prefers the
Authorization header when there is one, which is right for authenticated
traffic and exactly wrong on endpoints anybody can call: rotate a
meaningless bearer value and every request gets a fresh budget. That was
the only thing between a guesser and a six digit code. Anonymous
endpoints now key on the address alone.

A wrong guess punished the workshop rather than the guesser. Matching on
the hash alone meant a miss belonged to nobody, so it had to age every
live code in the organisation to cost anything, and five wrong guesses
from anyone holding a workshop id locked out every technician in the
building. The identifier now travels with the code, attempts land on one
row, and the comparison is timing safe.

Delivery is no longer awaited. Reaching a provider takes a few hundred
milliseconds and not reaching one takes none, which handed back on the
clock what the body had stopped saying. Provider errors are logged by
name only, because providers quote the request back and the request
contains a live code.

Also removed an unused capability probe, which was surface with no
caller.

* Stop letting callers pick their own rate-limit identity

cf-connecting-ip was consulted first and nothing strips it. Production
runs grey-clouded, so Cloudflare is not in front of anything, and the
proxy that is (nginxproxy/nginx-proxy) has no reason to remove a header
from a CDN it does not sit behind. The header therefore arrived exactly
as written by whoever sent it, and changing one digit per request bought
a fresh budget every time. That defeated every per-address limit in the
product, not only the technician sign-in ones.

x-forwarded-for had the same shape for the same reason: proxies append,
so the trustworthy entry is the last one and everything before it is
whatever the caller typed. It read the first.

Now only headers a hop we control has overwritten count. x-real-ip is
set by the proxy from the real connection. cf-connecting-ip is consulted
only when TRUST_CF_CONNECTING_IP says Cloudflare genuinely fronts every
request, and defaults off, because guessing wrong in that direction
fails silently and completely.

Better Auth was configured with the same header order for its own
limiting and its login audit trail, so it follows the same rule.

* Say so when the Cloudflare header is arriving untrusted

Production is orange-clouded, which I had recorded as the opposite. That
matters more than a note being stale: behind Cloudflare the proxy sets
X-Real-IP from the connection it can see, which is an edge address shared
by thousands of visitors. Leaving cf-connecting-ip untrusted there does
not just lose precision, it collapses the whole userbase into a few
hundred buckets and starts refusing real people.

So the flag is required in that deployment rather than optional, and both
ways of getting it wrong are silent. Seeing the header while not trusting
it now says so once, with what to do about it, and .env.example explains
the condition that makes trusting it safe rather than only the switch.

* One question on the team page: what is this person

Technician was a toggle, permissions were a dropdown, and the technician
record was a third thing on the work board. Three controls for one fact,
and getting any of them wrong produced an app that answered "Your role
does not allow this" to every screen. That is exactly what happened:
accounts were created with no role on the belief that the technician API
was gated on the technician record alone. It is not. withApiAuth enforces
requiredPermissions exactly as withAuth does, deliberately, so that an API
cannot disagree with the web app about who may do what.

Technician is now an answer in the role dropdown beside Admin and Member.
Choosing it creates the technician record and grants what the app needs;
choosing anything else takes both away, after asking, because it signs
somebody out of the phone in their hand. The toggle is gone, and so is
the role of the same name listed separately underneath it.

The permission set lives in one place and a test walks every route under
/api/v1/tech and fails if one of them needs something the role does not
carry. It holds three permissions and nothing else: read and update
services, read inventory.

The audit test only ever matched double-quoted actions, so it had been
blind to every action written since the codebase moved to single quotes.
Fixing the pattern surfaced nine actions with no label in any locale,
five of them mine and four older. All twelve locales now have them.

* Put both ways of adding somebody in one card

The email invite and the technician form answered the same question from
two different cards, which made them read as separate decisions rather
than two doors into the same room. One is for the office and one is for
somebody holding a spanner, and the desk should see that choice in one
place.

* One Add button, and a flow that explains itself

The team page had two forms permanently open and a toggle that meant a
third thing. A desk operator had to know which form applied before they
could start, and the answer depended on facts about the person that
neither form asked about.

Now there is one button and one question. The two people being added are
genuinely different: one works in the office, has an email and picks
their own password; the other works in the bay, has a phone in their
pocket, and needs a code. So the flow asks which, and the steps after it
follow from the answer.

Every step says what it is and why before it asks for anything, the
progress row names the steps rather than showing anonymous dots, and the
copy avoids account, credentials, permissions and provision throughout.
It is written for somebody who books cars in for a living. The last
screen says what happens next rather than just closing, because "the
dialog went away" is not an answer to "did that work".

Reachable from Quick Add too, under a Shop actions heading, since adding
a person is a different kind of act from booking a car in and deserves
its own group. It lands on the question rather than on the page.

The member row now shows a technician's mobile where it used to show the
generated .invalid placeholder, which was noise at best and looked broken
at worst. The QR step is one component both dialogs render, so first-time
setup and a replacement code are the same screen.

* Let a technician who left come back

Removing one deactivates the row rather than deleting it, because past
jobs, inspections and clocked hours all point at it. The duplicate check
did not know that, so it saw the deactivated row, said the number was
taken, and a mechanic who left could never be added again.

Adding a second account would have been worse than the error: two
technicians holding one phone number makes the sign-in lookup ambiguous,
and splits one person's hours across two of them.

So the row comes back instead. Their name is taken as typed now, in case
it changed. Their membership is rebuilt if the team page had removed it
outright, and their role is restored if they came back without one, which
since the permission fix would otherwise mean an app that refuses them on
every screen.

* Never let a taken number be a dead end

Refusing the number outright was a wall: a workshop with a recycled
mobile, or a mechanic whose name was typed differently the second time,
had no way forward from that screen at all. On an onboarding flow that is
a showstopper, and the customer is right that it should never happen.

The clash is a question now, with both honest answers on it. Same person
coming back reuses their record, so their jobs and hours stay with them
and the name just typed replaces the old one. A different person who now
holds that number gets a fresh account, and the previous holder keeps
everything they ever did here and loses only the way in, which the copy
says in as many words. Neither answer fits, change the number and carry
on.

Two causes behind the report, both fixed. Removing somebody with the
trash icon deleted their membership and left an active technician row
behind: still drawn on the work board, still holding the phone number so
they could never come back, and with a live session nobody had revoked.
Removing somebody now removes them. And an active row with no membership
behind it no longer counts as somebody being here, because they are not.

The revocation itself is one function with two callers rather than two
copies that could drift.

* Type the mobile the short way, and see what gets saved

Making the desk type +47 on every mechanic is a small tax paid many
times, and getting it wrong produces somebody who can never sign in
without saying so. The field takes 91131664 now, and shows the result
underneath as it is typed, so the country code being applied the way they
meant is confirmed before it becomes the thing somebody signs in with.

A workshop with no country code on file is asked which country it is in,
by name rather than by dial code, and the answer is stored the first time
so it is only ever answered once. It is the same setting the customer
portal already reads to make sense of a locally typed number, so filling
it in here fixes that too, and it was empty by default, which is why a
local number silently matched nobody.

Country names come from Intl.DisplayNames rather than a translated list,
so forty of them read correctly in all twelve languages without forty
times twelve strings to keep current, and sort under a collator so
accented names land where somebody scanning for them looks.

* Close the QR dialog when the phone actually arrives

The desk was watching a technician's phone from the wrong side, with no
way to know whether the scan worked except asking. Closing on a guess is
how somebody ends up unsure whether to issue a second code.

Redeeming now says so on the bus the work board already uses, and the
dialog listens while a code is on screen. The scan ends the step and the
last screen says they are in, rather than that a code was made.

The socket is open only while a code is showing, and does not reconnect:
it lives as long as one dialog and should not outlive it.

* Key the country list on the country, not its dial code

Canada and the United States are both +1, and the Select keys its options
on their value, so the list carried two options claiming to be the same
one. React said so.

The region identifies a row now and the dial code is only what gets
stored, which is the distinction that was missing. A test asserts the
regions are unique and, deliberately, that the dial codes are not, so the
next person to reach for the shorter field finds out why it is not the
key.

* Make the country field searchable, and say where to change it

A select was the wrong control for forty options. It resized as it
scrolled, could not be typed into, and finding Lithuania meant dragging
past thirty countries nobody in that workshop will ever pick.

It is a combobox now, on the primitives already in the project. Fixed
height, so the list stops growing and shrinking underneath the cursor,
and a search box that matches on the country name, the dial code and the
region, so norw, 47 and NO all find Norway.

The hint says where the answer lives afterwards and links to it, because
picking the wrong country once should not feel permanent.

* Show the right role on a technician added a moment ago

Who counts as a technician was useState seeded from a prop, and a state
initialiser only runs on mount. Adding somebody refreshed the page, the
prop came back with them in it, and the set ignored it. Their dropdown
then fell through to a role id that is deliberately absent from the list,
because the Technician entry above it is what grants that role, so the
trigger rendered blank until a manual reload.

Blank is the worst thing it could have said: it reads as somebody having
no role at all, about the one person who definitely does.

Membership is read from the server now rather than remembered, and the
selected value can no longer be something the list does not offer. A test
walks the cases, including a role deleted from under somebody.

* Stop three screens all offering to make somebody a technician

The work board's dialog offered linking a technician to an org member,
which is the same decision the team page makes, on a screen about
scheduling. The work order picker listed office staff under the same
heading as mechanics, so choosing one silently created a technician for
an account nobody had said was one. Between them and the team page, three
screens answered the same question differently.

The board adds a name now: somebody scheduled who does not sign in, which
is the one thing only the board can do. An existing link survives an edit
and says where it is managed. The picker splits its two groups and the
second says what choosing from it does.

This is a first pass at the tangle, not the end of it.

* Add a board-only name from the same place as everybody else

Three kinds of person, one question. Somebody in the office with an email
and a computer, a mechanic with a phone and the app, and a name on the
board who never signs into anything: an apprentice, a contractor, or a
mechanic whose phone the app does not support yet.

The third used to be reachable only from the work board, so a workshop
had two screens for adding people and nothing to say which one applied.
It is an answer in the Add flow now, with its own step: a name, a colour,
done.

* Let a board-only name become an app technician later

A name on the board is often temporary. An apprentice stays. A mechanic
whose phone the app does not support yet gets one that it does. Both need
a way forward that keeps every job, inspection and clocked hour already
recorded against them, which means attaching an account to the row rather
than starting a second one beside it and splitting the person in half.

Giving them a mobile number is the whole of it, so that is the whole
dialog, and it ends on the same handoff as adding a mechanic from
scratch.

The team page lists them too, which it had to: the Add flow creates these
and a page that hides what it just made is most of the reason none of
this hung together.

* One workflow, reached from wherever the need comes up

The work order picker created people itself: choosing an office colleague
quietly made them a technician, and typing a name made a board-only one
nobody had asked about. Two more ways to do the thing the Add flow does,
on a screen about scheduling a job.

It picks now, and Add someone new opens the same dialog the team page
does. The dialog fetches what it needs when a caller has nothing to give,
so it can be opened from anywhere without threading server data through
screens that have no business holding it.

Existing installs were the risk in this. A workshop that already has
technicians has them holding all sorts of roles, and the role dropdown
read Technician for anybody on the board regardless. That both misreported
what they could do and would have overwritten it the moment anybody
touched the field. It reads from the role they actually hold now, and a
badge beside it says they are also on the board, so nothing is hidden and
nothing is lost on the way in.

* Stop the work board's delete from destroying clocked hours

TimeEntry cascades from Technician, and the board deleted the row
outright, so removing somebody took every hour they had ever clocked with
them. The service records survived, because those only null the
reference, which made it worse: the work stayed and the evidence for what
it cost went. In Germany those are statutory records. Proven against a
scratch database before changing anything.

It deactivates now, which looks identical from the board since the board
only reads active technicians, and matches what every other removal path
already does. It revokes their phone at the same time, which the board
never did.

The customer portal's sign-in endpoints had the rate limiter keyed the
same wrong way as the technician ones did: an unauthenticated endpoint
that reads the Authorization header hands a fresh budget to anyone who
changes it. All three now key on the address.

* Put translation keys where the code looks for them, and test it

workBoard.technician.standaloneOnly sat one level too high, so the work
board threw MISSING_MESSAGE on a dialog it had rendered fine the day
before. Parity could not catch it: all twelve locales agreed about a key
nothing asked for. Four of mine were wrong the same way, two in
service.schedule and two never written at all.

So there is a test now. It reads every useTranslations binding that is
unambiguous in its file, collects the literal keys asked for through it,
and fails if one does not resolve to an English string. Files that bind
the same name twice are skipped rather than guessed at, because a test
that invents failures gets deleted.

It found two more that were not mine: the public invoice and quote share
pages ask for a heading with a defaultValue, which next-intl does not
honour, so both threw for every customer who opened one with custom
fields on it. Written now, in all twelve.

* Make a customer's portal code die after five wrong guesses

It had none. The lookup matched on the digits as part of the query, so a
wrong guess found no row and belonged to nobody, and nothing could count
it. That left an address-keyed limit as the only thing between a guesser
and a six digit space, which a hundred machines sharing the work walk
straight through. Fifteen minutes is a long time to be doing that against
a login that reaches a customer's invoices.

The row is found by phone and workshop first, so the attempt lands on it.
Five wrong guesses spend the code rather than leaving it to expire, or
waiting out the rate limiter would simply buy five more.

The digits are hashed at rest too, and compared in constant time. A code
belongs in a text message and in somebody's head, not in a table where
reading a row hands over their session for the next quarter of an hour.
Codes issued before this deploy stop working, which costs whoever is
mid-login one more text message.

* Remove imports left behind by the refactors

The org member Select went when the board dialog stopped handing out
accounts, and the inline name field went when the technician picker
stopped creating people. Their imports stayed.

biome check, which I had been running on touched files, does not flag an
unused import. biome lint does, and npm run lint is the one CI runs.
Bernt Christian Egeland 1 месяц назад
Родитель
Сommit
aa6a32d49a
100 измененных файлов с 4055 добавлено и 326 удалено
  1. 13 0
      .env.example
  2. 11 1
      messages/de/audit.json
  3. 3 1
      messages/de/navigation.json
  4. 5 1
      messages/de/service.json
  5. 88 1
      messages/de/settings.json
  6. 4 2
      messages/de/share.json
  7. 4 1
      messages/de/workBoard.json
  8. 11 1
      messages/en/audit.json
  9. 3 1
      messages/en/navigation.json
  10. 5 1
      messages/en/service.json
  11. 88 1
      messages/en/settings.json
  12. 4 2
      messages/en/share.json
  13. 4 1
      messages/en/workBoard.json
  14. 11 1
      messages/es/audit.json
  15. 3 1
      messages/es/navigation.json
  16. 5 1
      messages/es/service.json
  17. 88 1
      messages/es/settings.json
  18. 4 2
      messages/es/share.json
  19. 4 1
      messages/es/workBoard.json
  20. 11 1
      messages/fr/audit.json
  21. 3 1
      messages/fr/navigation.json
  22. 5 1
      messages/fr/service.json
  23. 88 1
      messages/fr/settings.json
  24. 4 2
      messages/fr/share.json
  25. 4 1
      messages/fr/workBoard.json
  26. 11 1
      messages/it/audit.json
  27. 3 1
      messages/it/navigation.json
  28. 5 1
      messages/it/service.json
  29. 88 1
      messages/it/settings.json
  30. 4 2
      messages/it/share.json
  31. 4 1
      messages/it/workBoard.json
  32. 11 1
      messages/lt/audit.json
  33. 3 1
      messages/lt/navigation.json
  34. 5 1
      messages/lt/service.json
  35. 88 1
      messages/lt/settings.json
  36. 4 2
      messages/lt/share.json
  37. 4 1
      messages/lt/workBoard.json
  38. 11 1
      messages/nb/audit.json
  39. 3 1
      messages/nb/navigation.json
  40. 5 1
      messages/nb/service.json
  41. 88 1
      messages/nb/settings.json
  42. 4 2
      messages/nb/share.json
  43. 4 1
      messages/nb/workBoard.json
  44. 11 1
      messages/nl/audit.json
  45. 3 1
      messages/nl/navigation.json
  46. 5 1
      messages/nl/service.json
  47. 88 1
      messages/nl/settings.json
  48. 4 2
      messages/nl/share.json
  49. 4 1
      messages/nl/workBoard.json
  50. 11 1
      messages/pl/audit.json
  51. 3 1
      messages/pl/navigation.json
  52. 5 1
      messages/pl/service.json
  53. 88 1
      messages/pl/settings.json
  54. 4 2
      messages/pl/share.json
  55. 4 1
      messages/pl/workBoard.json
  56. 11 1
      messages/pt-BR/audit.json
  57. 3 1
      messages/pt-BR/navigation.json
  58. 5 1
      messages/pt-BR/service.json
  59. 88 1
      messages/pt-BR/settings.json
  60. 4 2
      messages/pt-BR/share.json
  61. 4 1
      messages/pt-BR/workBoard.json
  62. 11 1
      messages/ru/audit.json
  63. 3 1
      messages/ru/navigation.json
  64. 5 1
      messages/ru/service.json
  65. 88 1
      messages/ru/settings.json
  66. 4 2
      messages/ru/share.json
  67. 4 1
      messages/ru/workBoard.json
  68. 11 1
      messages/tr/audit.json
  69. 3 1
      messages/tr/navigation.json
  70. 5 1
      messages/tr/service.json
  71. 88 1
      messages/tr/settings.json
  72. 4 2
      messages/tr/share.json
  73. 4 1
      messages/tr/workBoard.json
  74. 28 0
      prisma/migrations/20260829074551_technician_setup_codes/migration.sql
  75. 32 0
      prisma/migrations/20260829085021_technician_phone_login/migration.sql
  76. 14 0
      prisma/migrations/20260829091507_user_phone/migration.sql
  77. 2 0
      prisma/migrations/20260829122700_customer_code_attempts/migration.sql
  78. 105 2
      prisma/schema.prisma
  79. 132 0
      src/__tests__/api/portal-sms-attempts.test.ts
  80. 401 0
      src/__tests__/api/tech-phone-login.test.ts
  81. 216 0
      src/__tests__/api/tech-setup-redeem.test.ts
  82. 85 0
      src/__tests__/auth/technician-role-covers-app.test.ts
  83. 39 0
      src/__tests__/features/dial-codes.test.ts
  84. 75 0
      src/__tests__/features/team-role-value.test.ts
  85. 440 0
      src/__tests__/features/technician-account.test.ts
  86. 39 36
      src/__tests__/i18n/audit-actions.test.ts
  87. 99 0
      src/__tests__/i18n/keys-resolve.test.ts
  88. 88 0
      src/__tests__/lib/rate-limit-identity.test.ts
  89. 24 7
      src/app/(authenticated)/settings/team/page.tsx
  90. 193 172
      src/app/(authenticated)/settings/team/team-settings.tsx
  91. 73 0
      src/app/(public)/app-setup/app-setup-landing.tsx
  92. 21 0
      src/app/(public)/app-setup/page.tsx
  93. 14 17
      src/app/api/public/portal/[orgId]/auth/logout/route.ts
  94. 1 1
      src/app/api/public/portal/[orgId]/auth/request/route.ts
  95. 4 4
      src/app/api/public/portal/[orgId]/auth/sms-request/route.ts
  96. 36 2
      src/app/api/public/portal/[orgId]/auth/sms-verify/route.ts
  97. 1 1
      src/app/api/v1/tech/health/route.ts
  98. 199 0
      src/app/api/v1/tech/org/[orgId]/auth/request/route.ts
  99. 181 0
      src/app/api/v1/tech/org/[orgId]/auth/verify/route.ts
  100. 120 0
      src/app/api/v1/tech/setup/redeem/route.ts

+ 13 - 0
.env.example

@@ -6,3 +6,16 @@ BETTER_AUTH_SECRET="your-secret-here"
 
 # App URL (used by both the app and Better Auth)
 NEXT_PUBLIC_APP_URL="http://localhost:3000"
+
+# Set to true only when Cloudflare proxies every request AND the origin
+# refuses traffic that did not come through it (Cloudflare IP ranges allowed
+# at the firewall or in nginx).
+#
+# Behind Cloudflare this is required, not optional: the proxy sets X-Real-IP
+# from the connection it sees, which is a Cloudflare edge address shared by
+# thousands of visitors, so leaving this off collapses everyone into a handful
+# of rate-limit buckets and real users start getting 429s.
+#
+# Without the origin lock it is worse than useless, because cf-connecting-ip is
+# then just a header anyone reaching the box directly can write for themselves.
+TRUST_CF_CONNECTING_IP=false

+ 11 - 1
messages/de/audit.json

@@ -139,7 +139,17 @@
     "whatsapp_send": "WhatsApp-Nachricht gesendet",
     "work_bay_create": "Arbeitsplatz erstellt",
     "work_bay_update": "Arbeitsplatz aktualisiert",
-    "work_bay_delete": "Arbeitsplatz gelöscht"
+    "work_bay_delete": "Arbeitsplatz gelöscht",
+    "auth_appSetupRedeemed": "Mit Einrichtungscode angemeldet",
+    "auth_technicianCodeSignIn": "Mit Einmalcode angemeldet",
+    "team_createAppSetupCode": "App-Einrichtungscode ausgestellt",
+    "team_createTechnicianAccount": "Technikerkonto angelegt",
+    "team_removeTechnicianAccess": "Techniker entfernt und Zugriff entzogen",
+    "team_setMemberTechnician": "Techniker-Status eines Mitglieds geändert",
+    "settings_whatsappUpdated": "WhatsApp-Einstellungen aktualisiert",
+    "settings_whatsappDisconnected": "WhatsApp getrennt",
+    "settings_whatsappNumberRegistered": "WhatsApp-Nummer registriert",
+    "team_giveTechnicianTheApp": "Board-Techniker ein Konto gegeben"
   },
   "summary": {
     "customField_create": "Benutzerdefiniertes Feld „{name}“ erstellt",

+ 3 - 1
messages/de/navigation.json

@@ -72,7 +72,9 @@
     "vehicle": "Fahrzeug",
     "quote": "Angebot",
     "part": "Ersatzteil",
-    "inspection": "Inspektion"
+    "inspection": "Inspektion",
+    "shopActions": "Werkstatt",
+    "person": "Person hinzufügen"
   },
   "purchaseWhiteLabel": "White-Label erwerben",
   "licenseExpired": "Ihre Lizenz ist abgelaufen. Funktionen können eingeschränkt sein.",

+ 5 - 1
messages/de/service.json

@@ -344,7 +344,11 @@
     "setDefaults": "Standardwerte",
     "workBay": "Arbeitsplatz",
     "selectWorkBay": "Arbeitsplatz wählen …",
-    "noWorkBay": "Kein Arbeitsplatz"
+    "noWorkBay": "Kein Arbeitsplatz",
+    "technicians": "Techniker",
+    "otherTeamMembers": "Weitere Teammitglieder",
+    "makeTechnician": "Macht sie zum Techniker",
+    "addSomeoneNew": "Jemanden hinzufügen"
   },
   "notifications": {
     "title": "Kundenbenachrichtigungen",

+ 88 - 1
messages/de/settings.json

@@ -395,7 +395,94 @@
     "editRole": "Edit role",
     "technician": "Techniker",
     "technicianHint": "Kann Aufträge zugewiesen bekommen und die Techniker-App nutzen",
-    "technicianFailed": "Konnte nicht geändert werden"
+    "technicianFailed": "Konnte nicht geändert werden",
+    "setupApp": "Für App einrichten",
+    "setupAppTitle": "Techniker-App einrichten",
+    "setupAppInstruction": "Bitten Sie {name}, dies auf dem Telefon zu tun:",
+    "setupAppOrType": "Oder lesen Sie ihm diesen Code vor:",
+    "setupAppExpiry": "Gilt einmalig und läuft in {time} ab.",
+    "setupAppExpired": "Dieser Code ist abgelaufen. Schließen und neu beginnen.",
+    "setupAppDone": "Fertig",
+    "setupAppFailed": "Einrichtungscode konnte nicht erstellt werden",
+    "setupPageTitle": "Techniker-App einrichten",
+    "setupPageBody": "Öffnen Sie Torqvoice Tech auf diesem Telefon, tippen Sie auf Einrichtungscode scannen und halten Sie es erneut auf denselben Code.",
+    "setupPageCode": "Oder geben Sie diesen Code in der App ein",
+    "setupPageOpenApp": "Torqvoice Tech öffnen",
+    "setupPageExpiry": "Dieser Code gilt einmalig und läuft zehn Minuten nach der Erstellung durch Ihre Werkstatt ab.",
+    "setupAppStep1": "Torqvoice Tech installieren",
+    "setupAppStep2": "Sie öffnen und auf „Einrichtungscode scannen“ tippen",
+    "setupAppStep3": "Das Telefon auf diesen Code halten",
+    "setupAppNotCamera": "Die normale Kamera-App des Telefons funktioniert nicht. Es muss in Torqvoice Tech gescannt werden.",
+    "addTechnician": "Techniker hinzufügen",
+    "addTechnicianHint": "Für einen Mechaniker, der am Tresen steht. Ohne E-Mail, ohne Wartezeit.",
+    "technicianName": "Name",
+    "technicianNamePlaceholder": "Vollständiger Name",
+    "technicianPhone": "Handynummer",
+    "technicianPhonePlaceholder": "Das Telefon in seiner Tasche",
+    "addTechnicianSubmit": "Anlegen",
+    "addTechnicianNote": "Ab dann meldet er sich mit dieser Nummer an. Kein Passwort zum Merken und nichts, was auf dem ersten Bildschirm der App eingetippt werden muss.",
+    "addTechnicianDone": "{name} ist angelegt. Zeigen Sie ihm den Code.",
+    "addTechnicianFailed": "Techniker konnte nicht angelegt werden",
+    "setupAppCopyLink": "Link zum Senden kopieren",
+    "setupAppCopied": "Kopiert",
+    "revokeTechnicianTitle": "Aus der App abmelden?",
+    "revokeTechnicianBody": "{name} verliert sofort den Zugriff auf dem Telefon, und jeder Code wird ungültig. Erledigte Arbeit und Stunden bleiben unverändert.",
+    "revokeTechnicianConfirm": "Abmelden",
+    "addPerson": "Hinzufügen",
+    "addPersonTitle": "Jemanden zur Werkstatt hinzufügen",
+    "addPersonWho": "Die beiden werden unterschiedlich eingerichtet. Sagen Sie uns zuerst, um wen es geht.",
+    "choiceTechnician": "Ein Mechaniker",
+    "choiceTechnicianHint": "Arbeitet an den Autos und nutzt Torqvoice auf dem Telefon. Am einfachsten, solange er neben Ihnen steht, und dauert etwa eine Minute.",
+    "choiceMember": "Jemand im Büro",
+    "choiceMemberHint": "Nimmt Autos an, bestellt Teile und schreibt Rechnungen, am Computer. Sie senden eine E-Mail und er wählt sein eigenes Passwort.",
+    "stepLabelWho": "Wer",
+    "stepLabelDetails": "Angaben",
+    "stepLabelPhone": "Sein Telefon",
+    "techDetailsTitle": "Name und Handynummer",
+    "techDetailsBlurb": "Über die Handynummer meldet er sich künftig an, nehmen Sie also das Telefon, das er wirklich dabeihat. Ein Passwort, das er sich merken oder vergessen könnte, gibt es nicht.",
+    "techHandoffTitle": "Jetzt auf sein Telefon bringen",
+    "techHandoffBlurb": "Dieser Code verbindet sein Telefon mit Ihrer Werkstatt. Er gilt einmal und nur zehn Minuten lang, machen Sie es also, solange er hier ist.",
+    "memberDetailsTitle": "Seine E-Mail-Adresse",
+    "memberDetailsBlurb": "Er bekommt eine E-Mail mit einem Link. Beim Öffnen wählt er sein eigenes Passwort und erscheint danach in der Liste oben.",
+    "roleHint": "Administratoren können Einstellungen ändern und Personen hinzufügen. Alle anderen erledigen die tägliche Arbeit.",
+    "stepBack": "Zurück",
+    "stepHandoffDone": "Er hat ihn gescannt",
+    "stepDoneTitle": "{name} ist startklar",
+    "stepDoneBody": "Er kann die App öffnen und seine Aufträge sehen. Beim nächsten Mal meldet er sich nur mit seiner Handynummer an, ohne Code.",
+    "stepInvitedTitle": "Einladung an {email} gesendet",
+    "stepInvitedBody": "Er erscheint in der Liste, sobald er die E-Mail geöffnet und angenommen hat. Der Link gilt sieben Tage.",
+    "stepDoneAnother": "Noch jemanden hinzufügen",
+    "clashTitle": "{name} hat diese Nummer bereits",
+    "clashBlurb": "Zwei Personen können sich keine Handynummer teilen, weil sie sich damit anmelden. Welcher Fall ist es?",
+    "clashSamePerson": "Das ist {name}",
+    "clashSamePersonHint": "Dieselbe Person kommt zurück oder der Name war anders geschrieben. Aufträge und Stunden bleiben erhalten, und der eben eingegebene Name ersetzt den alten.",
+    "clashTakeover": "Nein, das ist {name}",
+    "clashTakeoverHint": "Eine andere Person, die diese Nummer jetzt hat. {name} behält alles Geleistete, kann sich aber nicht mehr anmelden, und Sie können später eine neue Nummer hinterlegen.",
+    "clashDifferentNumber": "Keins davon, Nummer ändern",
+    "workshopCountry": "In welchem Land ist Ihre Werkstatt?",
+    "workshopCountryPlaceholder": "Land auswählen",
+    "workshopCountryHint": "Wird nur einmal gefragt. Danach können Handynummern kurz eingegeben werden. Sie können es später unter <link>Spracheinstellungen</link> ändern.",
+    "phonePreview": "Gespeichert als",
+    "stepScannedTitle": "{name} ist drin",
+    "countrySearch": "Länder durchsuchen",
+    "countryNoMatch": "Kein Land passt dazu",
+    "choiceStandalone": "Nur ein Name fürs Board",
+    "choiceStandaloneHint": "Ein Azubi, ein Subunternehmer, jeder, den Sie einplanen, der sich aber nie anmeldet. Kein Telefon, keine E-Mail, kein Konto. Sie können das später ergänzen.",
+    "standaloneDetailsTitle": "Name und Farbe",
+    "standaloneDetailsBlurb": "Mehr braucht es nicht. Die Person erscheint auf dem Board und auf Aufträgen und hat nichts, wo sie sich anmelden müsste.",
+    "boardColour": "Farbe auf dem Board",
+    "stepStandaloneTitle": "{name} steht auf dem Board",
+    "stepStandaloneBody": "Sie können sie sofort einplanen und Aufträgen zuweisen. Wird die App später gebraucht, ergänzen Sie hier einfach eine Handynummer.",
+    "boardOnly": "Nur auf dem Board",
+    "boardOnlyHint": "Werden eingeplant und Aufträgen zugewiesen, melden sich aber nie an. Geben Sie ihnen eine Handynummer, wenn die App gebraucht wird.",
+    "boardOnlyNoAccount": "Kein Konto",
+    "giveApp": "App geben",
+    "giveAppTitle": "{name} die App geben",
+    "giveAppBlurb": "Es fehlt nur eine Handynummer. Damit meldet sich die Person künftig an.",
+    "giveAppKeepsHistory": "Alles, was bereits auf sie gebucht ist, bleibt erhalten.",
+    "giveAppSubmit": "Konto anlegen",
+    "role": "Rolle",
+    "inviteMemberHint": "Die Person erhält eine E-Mail und wählt ihr eigenes Passwort."
   },
   "invoice": {
     "title": "Rechnungslayout",

+ 4 - 2
messages/de/share.json

@@ -71,7 +71,8 @@
     "findingSeverityUrgent": "Hoch",
     "findingSeverityNeedsWork": "Mittel",
     "findingSeverityMonitor": "Niedrig",
-    "tel": "Tel: {phone}"
+    "tel": "Tel: {phone}",
+    "customFields": "Weitere Angaben"
   },
   "quote": {
     "title": "Angebot",
@@ -125,7 +126,8 @@
       "converted": "Umgewandelt",
       "changes_requested": "Änderungen angefordert"
     },
-    "tel": "Tel: {phone}"
+    "tel": "Tel: {phone}",
+    "customFields": "Weitere Angaben"
   },
   "inspection": {
     "title": "Fahrzeuginspektion",

+ 4 - 1
messages/de/workBoard.json

@@ -75,7 +75,10 @@
     "standalone": "Unabhängiger Techniker",
     "deleteTitle": "Techniker löschen",
     "deleteDescription": "\"{name}\" von der Arbeitsplatine entfernen? Ihre bestehenden Zuweisungen werden ebenfalls entfernt.",
-    "addTechnician": "Techniker hinzufügen"
+    "addTechnician": "Techniker hinzufügen",
+    "standaloneOnly": "Ein Name auf dem Board für jemanden ohne Anmeldung, etwa einen Azubi oder Subunternehmer. Einen Mechaniker mit App fügen Sie auf der Team-Seite hinzu.",
+    "linkedTo": "Meldet sich als {name} an",
+    "linkedManagedOnTeam": "Wird auf der Team-Seite verwaltet, zusammen mit dem App-Zugang."
   },
   "jobDetail": {
     "viewDetails": "Details anzeigen",

+ 11 - 1
messages/en/audit.json

@@ -139,7 +139,17 @@
     "whatsapp_send": "Sent WhatsApp Message",
     "work_bay_create": "Created Work Bay",
     "work_bay_update": "Updated Work Bay",
-    "work_bay_delete": "Deleted Work Bay"
+    "work_bay_delete": "Deleted Work Bay",
+    "auth_appSetupRedeemed": "Signed in with a setup code",
+    "auth_technicianCodeSignIn": "Signed in with a one-time code",
+    "team_createAppSetupCode": "Issued an app setup code",
+    "team_createTechnicianAccount": "Created a technician account",
+    "team_removeTechnicianAccess": "Removed a technician and revoked their access",
+    "team_setMemberTechnician": "Changed whether a member is a technician",
+    "settings_whatsappUpdated": "Updated WhatsApp settings",
+    "settings_whatsappDisconnected": "Disconnected WhatsApp",
+    "settings_whatsappNumberRegistered": "Registered a WhatsApp number",
+    "team_giveTechnicianTheApp": "Gave a board-only technician an account"
   },
   "summary": {
     "customField_create": "Created custom field \"{name}\"",

+ 3 - 1
messages/en/navigation.json

@@ -72,7 +72,9 @@
     "vehicle": "Vehicle",
     "quote": "Quote",
     "part": "Part",
-    "inspection": "Inspection"
+    "inspection": "Inspection",
+    "shopActions": "Shop actions",
+    "person": "Add a person"
   },
   "purchaseWhiteLabel": "Purchase White-Label",
   "licenseExpired": "Your license has expired. Features may be limited.",

+ 5 - 1
messages/en/service.json

@@ -344,7 +344,11 @@
     "setDefaults": "Set Defaults",
     "workBay": "Work bay",
     "selectWorkBay": "Select work bay...",
-    "noWorkBay": "No work bay"
+    "noWorkBay": "No work bay",
+    "technicians": "Technicians",
+    "otherTeamMembers": "Other team members",
+    "makeTechnician": "Makes them a technician",
+    "addSomeoneNew": "Add someone new"
   },
   "notifications": {
     "title": "Customer Notifications",

+ 88 - 1
messages/en/settings.json

@@ -395,7 +395,94 @@
     "editRole": "Edit role",
     "technician": "Technician",
     "technicianHint": "Can be assigned jobs and use the technician app",
-    "technicianFailed": "Could not change that"
+    "technicianFailed": "Could not change that",
+    "setupApp": "Set up app",
+    "setupAppTitle": "Set up the technician app",
+    "setupAppInstruction": "Ask {name} to do this on their phone:",
+    "setupAppOrType": "Or read them this code:",
+    "setupAppExpiry": "Works once, and expires in {time}.",
+    "setupAppExpired": "This code has expired. Close and start again.",
+    "setupAppDone": "Done",
+    "setupAppFailed": "Could not create a setup code",
+    "setupPageTitle": "Set up the technician app",
+    "setupPageBody": "Open Torqvoice Tech on this phone, tap Scan setup code, and point it at the same code again.",
+    "setupPageCode": "Or type this code in the app",
+    "setupPageOpenApp": "Open Torqvoice Tech",
+    "setupPageExpiry": "This code works once and expires ten minutes after your workshop created it.",
+    "setupAppStep1": "Install Torqvoice Tech",
+    "setupAppStep2": "Open it and tap “Scan setup code”",
+    "setupAppStep3": "Point the phone at this code",
+    "setupAppNotCamera": "The phone's own camera app will not work. It has to be scanned from inside Torqvoice Tech.",
+    "addTechnician": "Add a technician",
+    "addTechnicianHint": "For a mechanic standing at the counter. No email, no waiting.",
+    "technicianName": "Name",
+    "technicianNamePlaceholder": "Their full name",
+    "technicianPhone": "Mobile number",
+    "technicianPhonePlaceholder": "The phone in their pocket",
+    "addTechnicianSubmit": "Create",
+    "addTechnicianNote": "They sign in with that number from then on. No password to remember, and nothing to type on the app’s first screen.",
+    "addTechnicianDone": "{name} is set up. Show them the code.",
+    "addTechnicianFailed": "Could not create that technician",
+    "setupAppCopyLink": "Copy link to send them",
+    "setupAppCopied": "Copied",
+    "revokeTechnicianTitle": "Sign them out of the app?",
+    "revokeTechnicianBody": "{name} loses access on their phone straight away, and any code they have stops working. Their finished work and hours stay exactly as they are.",
+    "revokeTechnicianConfirm": "Sign them out",
+    "addPerson": "Add",
+    "addPersonTitle": "Add someone to the workshop",
+    "addPersonWho": "These two are set up in different ways, so start by telling us which one this is.",
+    "choiceTechnician": "A mechanic",
+    "choiceTechnicianHint": "Works on the cars and uses Torqvoice on their phone. Easiest to do while they are standing here with you, and it takes about a minute.",
+    "choiceMember": "Someone in the office",
+    "choiceMemberHint": "Books cars in, orders parts and sends invoices, on a computer. You send them an email and they pick their own password.",
+    "stepLabelWho": "Who",
+    "stepLabelDetails": "Details",
+    "stepLabelPhone": "Their phone",
+    "techDetailsTitle": "Their name and mobile number",
+    "techDetailsBlurb": "The mobile number is how they sign in from now on, so use the phone they actually carry. There is no password for them to remember or forget.",
+    "techHandoffTitle": "Now get it onto their phone",
+    "techHandoffBlurb": "This code joins their phone to your workshop. It works once, and only for the next ten minutes, so do it while they are here.",
+    "memberDetailsTitle": "Their email address",
+    "memberDetailsBlurb": "They get an email with a link. When they open it they choose their own password, and then they appear in the list above.",
+    "roleHint": "Admins can change settings and add people. Everyone else does the day-to-day work.",
+    "stepBack": "Back",
+    "stepHandoffDone": "They have scanned it",
+    "stepDoneTitle": "{name} is ready",
+    "stepDoneBody": "They can open the app and see their jobs. Next time they sign in it is just their mobile number, no code needed.",
+    "stepInvitedTitle": "Invitation sent to {email}",
+    "stepInvitedBody": "They will show up in the list once they have opened the email and accepted. The link works for seven days.",
+    "stepDoneAnother": "Add someone else",
+    "clashTitle": "{name} already has that number",
+    "clashBlurb": "Two people cannot share a mobile number, because it is what they sign in with. Which of these is it?",
+    "clashSamePerson": "This is {name}",
+    "clashSamePersonHint": "The same person coming back, or a name spelled differently. Their jobs and hours stay with them, and the name you just typed replaces the old one.",
+    "clashTakeover": "No, this is {name}",
+    "clashTakeoverHint": "A different person who now has that number. {name} keeps everything they did here but can no longer sign in, and you can give them a new number later.",
+    "clashDifferentNumber": "Neither, let me change the number",
+    "workshopCountry": "Which country is your workshop in?",
+    "workshopCountryPlaceholder": "Choose a country",
+    "workshopCountryHint": "Asked once. After this, mobile numbers can be typed the short way. You can change it later in <link>Localisation settings</link>.",
+    "phonePreview": "Saved as",
+    "stepScannedTitle": "{name} is in",
+    "countrySearch": "Search countries",
+    "countryNoMatch": "No country matches that",
+    "choiceStandalone": "A name for the board only",
+    "choiceStandaloneHint": "An apprentice, a contractor, anyone you schedule but who never signs in. No phone, no email, no account. You can give them one later.",
+    "standaloneDetailsTitle": "Their name, and a colour",
+    "standaloneDetailsBlurb": "This is all they get, and all they need. They appear on the work board and on jobs, and there is nothing for them to sign into.",
+    "boardColour": "Colour on the board",
+    "stepStandaloneTitle": "{name} is on the board",
+    "stepStandaloneBody": "You can schedule them and put them on jobs straight away. If they ever need the app, add a mobile number to them from this list.",
+    "boardOnly": "On the board only",
+    "boardOnlyHint": "Scheduled and put on jobs, but they never sign in. Give them a mobile number when they need the app.",
+    "boardOnlyNoAccount": "No account",
+    "giveApp": "Give them the app",
+    "giveAppTitle": "Give {name} the app",
+    "giveAppBlurb": "A mobile number is all that is missing. They sign in with it from then on.",
+    "giveAppKeepsHistory": "Everything already recorded against them stays where it is.",
+    "giveAppSubmit": "Create their account",
+    "role": "Role",
+    "inviteMemberHint": "They get an email and choose their own password."
   },
   "invoice": {
     "title": "Invoice & Quotes",

+ 4 - 2
messages/en/share.json

@@ -71,7 +71,8 @@
     "findingSeverityUrgent": "High",
     "findingSeverityNeedsWork": "Medium",
     "findingSeverityMonitor": "Low",
-    "tel": "Tel: {phone}"
+    "tel": "Tel: {phone}",
+    "customFields": "Additional information"
   },
   "quote": {
     "title": "Quote",
@@ -125,7 +126,8 @@
       "converted": "Converted",
       "changes_requested": "Changes Requested"
     },
-    "tel": "Tel: {phone}"
+    "tel": "Tel: {phone}",
+    "customFields": "Additional information"
   },
   "inspection": {
     "title": "Vehicle Inspection",

+ 4 - 1
messages/en/workBoard.json

@@ -75,7 +75,10 @@
     "standalone": "Standalone technician",
     "deleteTitle": "Delete Technician",
     "deleteDescription": "Remove \"{name}\" from the work board? Their existing assignments will also be removed.",
-    "addTechnician": "Add Technician"
+    "addTechnician": "Add Technician",
+    "standaloneOnly": "A name on the board for somebody who does not sign in, like an apprentice or a contractor. To add a mechanic who uses the app, add them on the Team page.",
+    "linkedTo": "Signs in as {name}",
+    "linkedManagedOnTeam": "Managed on the Team page, along with their app access."
   },
   "jobDetail": {
     "viewDetails": "View Details",

+ 11 - 1
messages/es/audit.json

@@ -139,7 +139,17 @@
     "whatsapp_send": "Mensaje de WhatsApp enviado",
     "work_bay_create": "Puesto de trabajo creado",
     "work_bay_update": "Puesto de trabajo actualizado",
-    "work_bay_delete": "Puesto de trabajo eliminado"
+    "work_bay_delete": "Puesto de trabajo eliminado",
+    "auth_appSetupRedeemed": "Sesión iniciada con código de configuración",
+    "auth_technicianCodeSignIn": "Sesión iniciada con código de un solo uso",
+    "team_createAppSetupCode": "Código de configuración de la aplicación emitido",
+    "team_createTechnicianAccount": "Cuenta de técnico creada",
+    "team_removeTechnicianAccess": "Técnico eliminado y acceso revocado",
+    "team_setMemberTechnician": "Cambiado si un miembro es técnico",
+    "settings_whatsappUpdated": "Ajustes de WhatsApp actualizados",
+    "settings_whatsappDisconnected": "WhatsApp desconectado",
+    "settings_whatsappNumberRegistered": "Número de WhatsApp registrado",
+    "team_giveTechnicianTheApp": "Cuenta dada a un técnico solo de tablero"
   },
   "summary": {
     "customField_create": "Campo personalizado «{name}» creado",

+ 3 - 1
messages/es/navigation.json

@@ -72,7 +72,9 @@
     "vehicle": "Vehículo",
     "quote": "Presupuesto",
     "part": "Pieza",
-    "inspection": "Inspección"
+    "inspection": "Inspección",
+    "shopActions": "Taller",
+    "person": "Añadir a una persona"
   },
   "purchaseWhiteLabel": "Adquirir marca blanca",
   "licenseExpired": "Su licencia ha expirado. Las funciones pueden estar limitadas.",

+ 5 - 1
messages/es/service.json

@@ -344,7 +344,11 @@
     "setDefaults": "Configurar valores",
     "workBay": "Puesto de trabajo",
     "selectWorkBay": "Seleccionar puesto…",
-    "noWorkBay": "Sin puesto"
+    "noWorkBay": "Sin puesto",
+    "technicians": "Técnicos",
+    "otherTeamMembers": "Otros miembros del equipo",
+    "makeTechnician": "Lo convierte en técnico",
+    "addSomeoneNew": "Añadir a alguien nuevo"
   },
   "notifications": {
     "title": "Notificaciones al cliente",

+ 88 - 1
messages/es/settings.json

@@ -395,7 +395,94 @@
     "editRole": "Edit role",
     "technician": "Técnico",
     "technicianHint": "Puede recibir trabajos asignados y usar la aplicación de técnico",
-    "technicianFailed": "No se pudo cambiar"
+    "technicianFailed": "No se pudo cambiar",
+    "setupApp": "Configurar la aplicación",
+    "setupAppTitle": "Configurar la aplicación de técnico",
+    "setupAppInstruction": "Pida a {name} que haga esto en su teléfono:",
+    "setupAppOrType": "O léale este código:",
+    "setupAppExpiry": "Se usa una sola vez y caduca en {time}.",
+    "setupAppExpired": "Este código ha caducado. Cierre y empiece de nuevo.",
+    "setupAppDone": "Hecho",
+    "setupAppFailed": "No se pudo crear un código de configuración",
+    "setupPageTitle": "Configurar la aplicación de técnico",
+    "setupPageBody": "Abra Torqvoice Tech en este teléfono, toque Escanear código de configuración y apunte otra vez al mismo código.",
+    "setupPageCode": "O escriba este código en la aplicación",
+    "setupPageOpenApp": "Abrir Torqvoice Tech",
+    "setupPageExpiry": "Este código se usa una sola vez y caduca diez minutos después de que su taller lo creara.",
+    "setupAppStep1": "Instalar Torqvoice Tech",
+    "setupAppStep2": "Abrirla y tocar «Escanear código de configuración»",
+    "setupAppStep3": "Apuntar el teléfono a este código",
+    "setupAppNotCamera": "La cámara normal del teléfono no funciona. Hay que escanearlo desde dentro de Torqvoice Tech.",
+    "addTechnician": "Añadir un técnico",
+    "addTechnicianHint": "Para un mecánico que está en el mostrador. Sin correo y sin esperas.",
+    "technicianName": "Nombre",
+    "technicianNamePlaceholder": "Su nombre completo",
+    "technicianPhone": "Número de móvil",
+    "technicianPhonePlaceholder": "El teléfono que lleva encima",
+    "addTechnicianSubmit": "Crear",
+    "addTechnicianNote": "A partir de ahí entra con ese número. Sin contraseña que recordar y sin nada que escribir en la primera pantalla.",
+    "addTechnicianDone": "{name} está listo. Enséñele el código.",
+    "addTechnicianFailed": "No se pudo crear ese técnico",
+    "setupAppCopyLink": "Copiar el enlace para enviárselo",
+    "setupAppCopied": "Copiado",
+    "revokeTechnicianTitle": "¿Cerrarle la sesión en la aplicación?",
+    "revokeTechnicianBody": "{name} pierde el acceso en su teléfono al instante y cualquier código deja de funcionar. El trabajo hecho y las horas se quedan igual.",
+    "revokeTechnicianConfirm": "Cerrar sesión",
+    "addPerson": "Añadir",
+    "addPersonTitle": "Añadir a alguien al taller",
+    "addPersonWho": "Estos dos se configuran de forma distinta, así que empiece diciéndonos de quién se trata.",
+    "choiceTechnician": "Un mecánico",
+    "choiceTechnicianHint": "Trabaja en los coches y usa Torqvoice en el móvil. Lo más fácil es hacerlo mientras está aquí con usted, y lleva un minuto.",
+    "choiceMember": "Alguien de la oficina",
+    "choiceMemberHint": "Recibe coches, pide piezas y envía facturas, en un ordenador. Usted le envía un correo y él elige su propia contraseña.",
+    "stepLabelWho": "Quién",
+    "stepLabelDetails": "Datos",
+    "stepLabelPhone": "Su teléfono",
+    "techDetailsTitle": "Nombre y número de móvil",
+    "techDetailsBlurb": "Con ese móvil entrará a partir de ahora, así que use el teléfono que lleva de verdad. No hay contraseña que recordar ni que olvidar.",
+    "techHandoffTitle": "Ahora pásselo a su teléfono",
+    "techHandoffBlurb": "Este código conecta su teléfono con su taller. Sirve una vez y solo durante diez minutos, así que hágalo mientras está aquí.",
+    "memberDetailsTitle": "Su dirección de correo",
+    "memberDetailsBlurb": "Recibe un correo con un enlace. Al abrirlo elige su contraseña y después aparece en la lista de arriba.",
+    "roleHint": "Los administradores pueden cambiar ajustes y añadir personas. Los demás hacen el trabajo del día a día.",
+    "stepBack": "Atrás",
+    "stepHandoffDone": "Ya lo ha escaneado",
+    "stepDoneTitle": "{name} ya está listo",
+    "stepDoneBody": "Puede abrir la aplicación y ver sus trabajos. La próxima vez entra solo con su móvil, sin código.",
+    "stepInvitedTitle": "Invitación enviada a {email}",
+    "stepInvitedBody": "Aparecerá en la lista en cuanto abra el correo y acepte. El enlace vale siete días.",
+    "stepDoneAnother": "Añadir a otra persona",
+    "clashTitle": "{name} ya tiene ese número",
+    "clashBlurb": "Dos personas no pueden compartir un móvil, porque es con lo que entran. ¿Cuál de estos casos es?",
+    "clashSamePerson": "Es {name}",
+    "clashSamePersonHint": "La misma persona que vuelve, o un nombre escrito de otra forma. Sus trabajos y horas siguen con ella, y el nombre que acaba de escribir sustituye al anterior.",
+    "clashTakeover": "No, es {name}",
+    "clashTakeoverHint": "Otra persona que ahora tiene ese número. {name} conserva todo lo que hizo aquí pero ya no puede entrar, y más adelante puede darle un número nuevo.",
+    "clashDifferentNumber": "Ninguno, quiero cambiar el número",
+    "workshopCountry": "¿En qué país está su taller?",
+    "workshopCountryPlaceholder": "Elija un país",
+    "workshopCountryHint": "Se pregunta una sola vez. Después los móviles se pueden escribir en formato corto. Puede cambiarlo más adelante en <link>Ajustes de localización</link>.",
+    "phonePreview": "Se guarda como",
+    "stepScannedTitle": "{name} ya está dentro",
+    "countrySearch": "Buscar países",
+    "countryNoMatch": "Ningún país coincide",
+    "choiceStandalone": "Solo un nombre para el tablero",
+    "choiceStandaloneHint": "Un aprendiz, un externo, cualquiera a quien planifique pero que nunca entra. Sin teléfono, sin correo, sin cuenta. Puede dársela más adelante.",
+    "standaloneDetailsTitle": "Su nombre y un color",
+    "standaloneDetailsBlurb": "Es todo lo que recibe y todo lo que necesita. Aparece en el tablero y en los trabajos, y no hay nada donde iniciar sesión.",
+    "boardColour": "Color en el tablero",
+    "stepStandaloneTitle": "{name} ya está en el tablero",
+    "stepStandaloneBody": "Puede planificarlo y asignarle trabajos de inmediato. Si algún día necesita la aplicación, añádale un móvil desde esta lista.",
+    "boardOnly": "Solo en el tablero",
+    "boardOnlyHint": "Se planifican y se les asignan trabajos, pero nunca entran. Deles un móvil cuando necesiten la aplicación.",
+    "boardOnlyNoAccount": "Sin cuenta",
+    "giveApp": "Darle la aplicación",
+    "giveAppTitle": "Dar la aplicación a {name}",
+    "giveAppBlurb": "Solo falta un número de móvil. Con él entrará a partir de ahora.",
+    "giveAppKeepsHistory": "Todo lo que ya tiene registrado se queda como está.",
+    "giveAppSubmit": "Crear su cuenta",
+    "role": "Rol",
+    "inviteMemberHint": "Recibe un correo y elige su propia contraseña."
   },
   "invoice": {
     "title": "Diseño de factura",

+ 4 - 2
messages/es/share.json

@@ -71,7 +71,8 @@
     "findingSeverityUrgent": "Alta",
     "findingSeverityNeedsWork": "Media",
     "findingSeverityMonitor": "Baja",
-    "tel": "Tel: {phone}"
+    "tel": "Tel: {phone}",
+    "customFields": "Información adicional"
   },
   "quote": {
     "title": "Presupuesto",
@@ -125,7 +126,8 @@
       "converted": "Convertido",
       "changes_requested": "Cambios solicitados"
     },
-    "tel": "Tel: {phone}"
+    "tel": "Tel: {phone}",
+    "customFields": "Información adicional"
   },
   "inspection": {
     "title": "Inspección de vehículo",

+ 4 - 1
messages/es/workBoard.json

@@ -75,7 +75,10 @@
     "standalone": "Técnico independiente",
     "deleteTitle": "Eliminar Técnico",
     "deleteDescription": "¿Desea eliminar a \"{name}\" del tablero de trabajo? Sus asignaciones existentes también se eliminarán.",
-    "addTechnician": "Agregar Técnico"
+    "addTechnician": "Agregar Técnico",
+    "standaloneOnly": "Un nombre en el tablero para alguien que no inicia sesión, como un aprendiz o un externo. Para un mecánico que usa la aplicación, añádalo en la página de Equipo.",
+    "linkedTo": "Entra como {name}",
+    "linkedManagedOnTeam": "Se gestiona en la página de Equipo, junto con su acceso a la aplicación."
   },
   "jobDetail": {
     "viewDetails": "Ver Detalles",

+ 11 - 1
messages/fr/audit.json

@@ -139,7 +139,17 @@
     "whatsapp_send": "Message WhatsApp envoyé",
     "work_bay_create": "Poste de travail créé",
     "work_bay_update": "Poste de travail mis à jour",
-    "work_bay_delete": "Poste de travail supprimé"
+    "work_bay_delete": "Poste de travail supprimé",
+    "auth_appSetupRedeemed": "Connexion avec un code de configuration",
+    "auth_technicianCodeSignIn": "Connexion avec un code à usage unique",
+    "team_createAppSetupCode": "Code de configuration de l'application émis",
+    "team_createTechnicianAccount": "Compte technicien créé",
+    "team_removeTechnicianAccess": "Technicien retiré et accès révoqué",
+    "team_setMemberTechnician": "Statut de technicien d'un membre modifié",
+    "settings_whatsappUpdated": "Paramètres WhatsApp mis à jour",
+    "settings_whatsappDisconnected": "WhatsApp déconnecté",
+    "settings_whatsappNumberRegistered": "Numéro WhatsApp enregistré",
+    "team_giveTechnicianTheApp": "Compte donné à un technicien du tableau"
   },
   "summary": {
     "customField_create": "Champ personnalisé « {name} » créé",

+ 3 - 1
messages/fr/navigation.json

@@ -72,7 +72,9 @@
     "vehicle": "Véhicule",
     "quote": "Devis",
     "part": "Pièce",
-    "inspection": "Inspection"
+    "inspection": "Inspection",
+    "shopActions": "Atelier",
+    "person": "Ajouter une personne"
   },
   "purchaseWhiteLabel": "Acheter la marque blanche",
   "licenseExpired": "Votre licence a expiré. Les fonctionnalités peuvent être limitées.",

+ 5 - 1
messages/fr/service.json

@@ -344,7 +344,11 @@
     "setDefaults": "Paramètres par défaut",
     "workBay": "Poste de travail",
     "selectWorkBay": "Choisir un poste…",
-    "noWorkBay": "Aucun poste"
+    "noWorkBay": "Aucun poste",
+    "technicians": "Techniciens",
+    "otherTeamMembers": "Autres membres de l’équipe",
+    "makeTechnician": "En fait un technicien",
+    "addSomeoneNew": "Ajouter quelqu’un"
   },
   "notifications": {
     "title": "Notifications client",

+ 88 - 1
messages/fr/settings.json

@@ -395,7 +395,94 @@
     "editRole": "Edit role",
     "technician": "Technicien",
     "technicianHint": "Peut se voir attribuer des travaux et utiliser l’application technicien",
-    "technicianFailed": "Modification impossible"
+    "technicianFailed": "Modification impossible",
+    "setupApp": "Configurer l'application",
+    "setupAppTitle": "Configurer l'application technicien",
+    "setupAppInstruction": "Demandez à {name} de faire ceci sur son téléphone :",
+    "setupAppOrType": "Ou communiquez-lui ce code :",
+    "setupAppExpiry": "Utilisable une seule fois, expire dans {time}.",
+    "setupAppExpired": "Ce code a expiré. Fermez et recommencez.",
+    "setupAppDone": "Terminé",
+    "setupAppFailed": "Impossible de créer un code de configuration",
+    "setupPageTitle": "Configurer l'application technicien",
+    "setupPageBody": "Ouvrez Torqvoice Tech sur ce téléphone, appuyez sur Scanner le code de configuration, et visez à nouveau le même code.",
+    "setupPageCode": "Ou saisissez ce code dans l'application",
+    "setupPageOpenApp": "Ouvrir Torqvoice Tech",
+    "setupPageExpiry": "Ce code est utilisable une seule fois et expire dix minutes après sa création par votre atelier.",
+    "setupAppStep1": "Installer Torqvoice Tech",
+    "setupAppStep2": "L’ouvrir et appuyer sur « Scanner le code de configuration »",
+    "setupAppStep3": "Viser ce code avec le téléphone",
+    "setupAppNotCamera": "L'appareil photo du téléphone ne fonctionnera pas. Le code doit être scanné depuis Torqvoice Tech.",
+    "addTechnician": "Ajouter un technicien",
+    "addTechnicianHint": "Pour un mécanicien présent au comptoir. Sans e-mail, sans attente.",
+    "technicianName": "Nom",
+    "technicianNamePlaceholder": "Son nom complet",
+    "technicianPhone": "Numéro de mobile",
+    "technicianPhonePlaceholder": "Le téléphone qu’il a sur lui",
+    "addTechnicianSubmit": "Créer",
+    "addTechnicianNote": "Il se connectera ensuite avec ce numéro. Aucun mot de passe à retenir et rien à saisir sur le premier écran de l'application.",
+    "addTechnicianDone": "{name} est créé. Montrez-lui le code.",
+    "addTechnicianFailed": "Impossible de créer ce technicien",
+    "setupAppCopyLink": "Copier le lien à envoyer",
+    "setupAppCopied": "Copié",
+    "revokeTechnicianTitle": "Le déconnecter de l'application ?",
+    "revokeTechnicianBody": "{name} perd immédiatement l'accès sur son téléphone et tout code cesse de fonctionner. Le travail terminé et les heures restent tels quels.",
+    "revokeTechnicianConfirm": "Le déconnecter",
+    "addPerson": "Ajouter",
+    "addPersonTitle": "Ajouter quelqu'un à l'atelier",
+    "addPersonWho": "Ces deux profils se configurent différemment, alors commencez par nous dire de qui il s'agit.",
+    "choiceTechnician": "Un mécanicien",
+    "choiceTechnicianHint": "Travaille sur les voitures et utilise Torqvoice sur son téléphone. Le plus simple pendant qu'il est là avec vous, et cela prend environ une minute.",
+    "choiceMember": "Quelqu’un au bureau",
+    "choiceMemberHint": "Réceptionne les voitures, commande les pièces et envoie les factures, sur un ordinateur. Vous envoyez un e-mail et il choisit son propre mot de passe.",
+    "stepLabelWho": "Qui",
+    "stepLabelDetails": "Détails",
+    "stepLabelPhone": "Son téléphone",
+    "techDetailsTitle": "Nom et numéro de mobile",
+    "techDetailsBlurb": "C'est avec ce numéro qu'il se connectera désormais, alors prenez le téléphone qu'il a vraiment sur lui. Il n'y a aucun mot de passe à retenir ni à oublier.",
+    "techHandoffTitle": "Maintenant, sur son téléphone",
+    "techHandoffBlurb": "Ce code relie son téléphone à votre atelier. Il ne fonctionne qu'une fois et seulement pendant dix minutes, alors faites-le tant qu'il est là.",
+    "memberDetailsTitle": "Son adresse e-mail",
+    "memberDetailsBlurb": "Il reçoit un e-mail avec un lien. En l'ouvrant, il choisit son mot de passe et apparaît ensuite dans la liste ci-dessus.",
+    "roleHint": "Les administrateurs peuvent modifier les réglages et ajouter des personnes. Les autres font le travail quotidien.",
+    "stepBack": "Retour",
+    "stepHandoffDone": "Il l'a scanné",
+    "stepDoneTitle": "{name} est prêt",
+    "stepDoneBody": "Il peut ouvrir l'application et voir ses travaux. La prochaine fois, il se connecte avec son seul numéro de mobile, sans code.",
+    "stepInvitedTitle": "Invitation envoyée à {email}",
+    "stepInvitedBody": "Il apparaîtra dans la liste dès qu'il aura ouvert l'e-mail et accepté. Le lien reste valable sept jours.",
+    "stepDoneAnother": "Ajouter quelqu’un d’autre",
+    "clashTitle": "{name} a déjà ce numéro",
+    "clashBlurb": "Deux personnes ne peuvent pas partager un numéro de mobile, puisque c'est avec lui qu'elles se connectent. Duquel s'agit-il ?",
+    "clashSamePerson": "C'est {name}",
+    "clashSamePersonHint": "La même personne qui revient, ou un nom orthographié autrement. Ses travaux et ses heures restent avec elle, et le nom que vous venez de saisir remplace l'ancien.",
+    "clashTakeover": "Non, il s’agit de {name}",
+    "clashTakeoverHint": "Une autre personne qui a désormais ce numéro. {name} conserve tout ce qu'elle a fait ici mais ne peut plus se connecter, et vous pourrez lui donner un nouveau numéro plus tard.",
+    "clashDifferentNumber": "Ni l’un ni l’autre, je corrige le numéro",
+    "workshopCountry": "Dans quel pays se trouve votre atelier ?",
+    "workshopCountryPlaceholder": "Choisissez un pays",
+    "workshopCountryHint": "Demandé une seule fois. Ensuite, les numéros de mobile peuvent être saisis en format court. Vous pourrez le modifier dans les <link>paramètres de localisation</link>.",
+    "phonePreview": "Enregistré comme",
+    "stepScannedTitle": "{name} est connecté",
+    "countrySearch": "Rechercher un pays",
+    "countryNoMatch": "Aucun pays ne correspond",
+    "choiceStandalone": "Un nom pour le tableau seulement",
+    "choiceStandaloneHint": "Un apprenti, un sous-traitant, toute personne que vous planifiez mais qui ne se connecte jamais. Ni téléphone, ni e-mail, ni compte. Vous pourrez lui en donner un plus tard.",
+    "standaloneDetailsTitle": "Son nom, et une couleur",
+    "standaloneDetailsBlurb": "C'est tout ce qu'il obtient, et tout ce qu'il lui faut. Il apparaît sur le tableau et sur les travaux, et n'a rien où se connecter.",
+    "boardColour": "Couleur sur le tableau",
+    "stepStandaloneTitle": "{name} est sur le tableau",
+    "stepStandaloneBody": "Vous pouvez le planifier et l'affecter à des travaux tout de suite. S'il lui faut un jour l'application, ajoutez-lui un numéro de mobile depuis cette liste.",
+    "boardOnly": "Sur le tableau uniquement",
+    "boardOnlyHint": "Planifiés et affectés à des travaux, mais ne se connectent jamais. Donnez-leur un numéro de mobile quand ils auront besoin de l'application.",
+    "boardOnlyNoAccount": "Aucun compte",
+    "giveApp": "Donner l'application",
+    "giveAppTitle": "Donner l'application à {name}",
+    "giveAppBlurb": "Il ne manque qu'un numéro de mobile. C'est avec lui qu'il se connectera ensuite.",
+    "giveAppKeepsHistory": "Tout ce qui lui est déjà attribué reste en place.",
+    "giveAppSubmit": "Créer son compte",
+    "role": "Rôle",
+    "inviteMemberHint": "La personne reçoit un e-mail et choisit son propre mot de passe."
   },
   "invoice": {
     "title": "Mise en page de facture",

+ 4 - 2
messages/fr/share.json

@@ -71,7 +71,8 @@
     "findingSeverityUrgent": "Haute",
     "findingSeverityNeedsWork": "Moyenne",
     "findingSeverityMonitor": "Basse",
-    "tel": "Tél. : {phone}"
+    "tel": "Tél. : {phone}",
+    "customFields": "Informations complémentaires"
   },
   "quote": {
     "title": "Devis",
@@ -125,7 +126,8 @@
       "converted": "Converti",
       "changes_requested": "Modifications demandées"
     },
-    "tel": "Tél. : {phone}"
+    "tel": "Tél. : {phone}",
+    "customFields": "Informations complémentaires"
   },
   "inspection": {
     "title": "Inspection du véhicule",

+ 4 - 1
messages/fr/workBoard.json

@@ -75,7 +75,10 @@
     "standalone": "Technicien autonome",
     "deleteTitle": "Supprimer le Technicien",
     "deleteDescription": "Supprimer \"{name}\" du tableau de bord des travaux? Ses assignations existantes seront également supprimées.",
-    "addTechnician": "Ajouter un Technicien"
+    "addTechnician": "Ajouter un Technicien",
+    "standaloneOnly": "Un nom sur le tableau pour quelqu'un qui ne se connecte pas, un apprenti ou un sous-traitant. Pour un mécanicien qui utilise l'application, passez par la page Équipe.",
+    "linkedTo": "Se connecte en tant que {name}",
+    "linkedManagedOnTeam": "Géré depuis la page Équipe, avec son accès à l'application."
   },
   "jobDetail": {
     "viewDetails": "Voir les Détails",

+ 11 - 1
messages/it/audit.json

@@ -139,7 +139,17 @@
     "whatsapp_send": "Messaggio WhatsApp inviato",
     "work_bay_create": "Postazione creata",
     "work_bay_update": "Postazione aggiornata",
-    "work_bay_delete": "Postazione eliminata"
+    "work_bay_delete": "Postazione eliminata",
+    "auth_appSetupRedeemed": "Accesso con codice di configurazione",
+    "auth_technicianCodeSignIn": "Accesso con codice monouso",
+    "team_createAppSetupCode": "Codice di configurazione dell'app emesso",
+    "team_createTechnicianAccount": "Account tecnico creato",
+    "team_removeTechnicianAccess": "Tecnico rimosso e accesso revocato",
+    "team_setMemberTechnician": "Modificato se un membro è tecnico",
+    "settings_whatsappUpdated": "Impostazioni WhatsApp aggiornate",
+    "settings_whatsappDisconnected": "WhatsApp disconnesso",
+    "settings_whatsappNumberRegistered": "Numero WhatsApp registrato",
+    "team_giveTechnicianTheApp": "Account dato a un tecnico solo da lavagna"
   },
   "summary": {
     "customField_create": "Campo personalizzato «{name}» creato",

+ 3 - 1
messages/it/navigation.json

@@ -72,7 +72,9 @@
     "vehicle": "Veicolo",
     "quote": "Preventivo",
     "part": "Ricambio",
-    "inspection": "Ispezione"
+    "inspection": "Ispezione",
+    "shopActions": "Officina",
+    "person": "Aggiungi una persona"
   },
   "purchaseWhiteLabel": "Acquista White-Label",
   "licenseExpired": "La tua licenza è scaduta. Le funzionalità potrebbero essere limitate.",

+ 5 - 1
messages/it/service.json

@@ -344,7 +344,11 @@
     "setDefaults": "Valori predefiniti",
     "workBay": "Postazione",
     "selectWorkBay": "Seleziona postazione…",
-    "noWorkBay": "Nessuna postazione"
+    "noWorkBay": "Nessuna postazione",
+    "technicians": "Tecnici",
+    "otherTeamMembers": "Altri membri del team",
+    "makeTechnician": "Lo rende un tecnico",
+    "addSomeoneNew": "Aggiungi qualcuno"
   },
   "notifications": {
     "title": "Notifiche al cliente",

+ 88 - 1
messages/it/settings.json

@@ -395,7 +395,94 @@
     "editRole": "Edit role",
     "technician": "Tecnico",
     "technicianHint": "Può ricevere lavori assegnati e usare l’app tecnico",
-    "technicianFailed": "Impossibile modificare"
+    "technicianFailed": "Impossibile modificare",
+    "setupApp": "Configura l'app",
+    "setupAppTitle": "Configura l'app tecnico",
+    "setupAppInstruction": "Chiedi a {name} di fare questo sul telefono:",
+    "setupAppOrType": "Oppure leggigli questo codice:",
+    "setupAppExpiry": "Utilizzabile una sola volta, scade tra {time}.",
+    "setupAppExpired": "Questo codice è scaduto. Chiudi e ricomincia.",
+    "setupAppDone": "Fatto",
+    "setupAppFailed": "Impossibile creare un codice di configurazione",
+    "setupPageTitle": "Configura l'app tecnico",
+    "setupPageBody": "Apri Torqvoice Tech su questo telefono, tocca Scansiona il codice di configurazione e inquadra di nuovo lo stesso codice.",
+    "setupPageCode": "Oppure inserisci questo codice nell'app",
+    "setupPageOpenApp": "Apri Torqvoice Tech",
+    "setupPageExpiry": "Questo codice è utilizzabile una sola volta e scade dieci minuti dopo che l'officina lo ha creato.",
+    "setupAppStep1": "Installare Torqvoice Tech",
+    "setupAppStep2": "Aprirla e toccare “Scansiona il codice di configurazione”",
+    "setupAppStep3": "Inquadrare questo codice col telefono",
+    "setupAppNotCamera": "La normale fotocamera del telefono non funziona. Va scansionato dentro Torqvoice Tech.",
+    "addTechnician": "Aggiungi un tecnico",
+    "addTechnicianHint": "Per un meccanico al banco. Senza email e senza attese.",
+    "technicianName": "Nome",
+    "technicianNamePlaceholder": "Nome e cognome",
+    "technicianPhone": "Numero di cellulare",
+    "technicianPhonePlaceholder": "Il telefono che ha in tasca",
+    "addTechnicianSubmit": "Crea",
+    "addTechnicianNote": "Da lì in poi accede con quel numero. Nessuna password da ricordare e niente da digitare nella prima schermata dell'app.",
+    "addTechnicianDone": "{name} è pronto. Mostragli il codice.",
+    "addTechnicianFailed": "Impossibile creare quel tecnico",
+    "setupAppCopyLink": "Copia il link da inviargli",
+    "setupAppCopied": "Copiato",
+    "revokeTechnicianTitle": "Disconnetterlo dall'app?",
+    "revokeTechnicianBody": "{name} perde subito l’accesso sul telefono e ogni codice smette di funzionare. Il lavoro svolto e le ore restano come sono.",
+    "revokeTechnicianConfirm": "Disconnetti",
+    "addPerson": "Aggiungi",
+    "addPersonTitle": "Aggiungi qualcuno all'officina",
+    "addPersonWho": "I due si configurano in modo diverso, quindi comincia dicendoci di chi si tratta.",
+    "choiceTechnician": "Un meccanico",
+    "choiceTechnicianHint": "Lavora sulle auto e usa Torqvoice sul telefono. Più semplice mentre è qui con te, e richiede circa un minuto.",
+    "choiceMember": "Qualcuno in ufficio",
+    "choiceMemberHint": "Accetta le auto, ordina i ricambi e invia le fatture, al computer. Tu invii un’email e lui sceglie la propria password.",
+    "stepLabelWho": "Chi",
+    "stepLabelDetails": "Dati",
+    "stepLabelPhone": "Il suo telefono",
+    "techDetailsTitle": "Nome e numero di cellulare",
+    "techDetailsBlurb": "Da adesso accederà con quel numero, quindi usa il telefono che ha davvero con sé. Non c’è nessuna password da ricordare o dimenticare.",
+    "techHandoffTitle": "Ora portalo sul suo telefono",
+    "techHandoffBlurb": "Questo codice collega il suo telefono alla tua officina. Vale una volta sola e solo per dieci minuti, quindi fallo mentre è qui.",
+    "memberDetailsTitle": "Il suo indirizzo email",
+    "memberDetailsBlurb": "Riceve un’email con un link. Aprendolo sceglie la propria password e poi compare nell’elenco qui sopra.",
+    "roleHint": "Gli amministratori possono cambiare le impostazioni e aggiungere persone. Gli altri fanno il lavoro quotidiano.",
+    "stepBack": "Indietro",
+    "stepHandoffDone": "L’ha scansionato",
+    "stepDoneTitle": "{name} è pronto",
+    "stepDoneBody": "Può aprire l’app e vedere i suoi lavori. La prossima volta accede con il solo numero di cellulare, senza codice.",
+    "stepInvitedTitle": "Invito inviato a {email}",
+    "stepInvitedBody": "Comparirà nell’elenco appena aprirà l’email e accetterà. Il link vale sette giorni.",
+    "stepDoneAnother": "Aggiungi qualcun altro",
+    "clashTitle": "{name} ha già quel numero",
+    "clashBlurb": "Due persone non possono condividere un numero di cellulare, perché è con quello che accedono. Quale dei due casi è?",
+    "clashSamePerson": "È {name}",
+    "clashSamePersonHint": "La stessa persona che torna, o un nome scritto diversamente. I suoi lavori e le sue ore restano con lei, e il nome appena inserito sostituisce il vecchio.",
+    "clashTakeover": "No, è {name}",
+    "clashTakeoverHint": "Un’altra persona che ora ha quel numero. {name} conserva tutto ciò che ha fatto qui ma non può più accedere, e potrai dargli un nuovo numero più avanti.",
+    "clashDifferentNumber": "Nessuno dei due, cambio il numero",
+    "workshopCountry": "In quale paese si trova la tua officina?",
+    "workshopCountryPlaceholder": "Scegli un paese",
+    "workshopCountryHint": "Chiesto una sola volta. Dopo, i numeri di cellulare si possono scrivere in forma breve. Puoi cambiarlo più avanti in <link>Impostazioni di localizzazione</link>.",
+    "phonePreview": "Salvato come",
+    "stepScannedTitle": "{name} è dentro",
+    "countrySearch": "Cerca paesi",
+    "countryNoMatch": "Nessun paese corrisponde",
+    "choiceStandalone": "Solo un nome per la lavagna",
+    "choiceStandaloneHint": "Un apprendista, un esterno, chiunque pianifichi ma che non accede mai. Nessun telefono, nessuna email, nessun account. Potrai dargliene uno più avanti.",
+    "standaloneDetailsTitle": "Nome e colore",
+    "standaloneDetailsBlurb": "È tutto ciò che riceve e tutto ciò che serve. Compare sulla lavagna e sui lavori, e non ha nulla in cui accedere.",
+    "boardColour": "Colore sulla lavagna",
+    "stepStandaloneTitle": "{name} è sulla lavagna",
+    "stepStandaloneBody": "Puoi pianificarlo e assegnargli lavori subito. Se un giorno gli servirà l'app, aggiungi un numero di cellulare da questo elenco.",
+    "boardOnly": "Solo sulla lavagna",
+    "boardOnlyHint": "Vengono pianificati e assegnati ai lavori, ma non accedono mai. Dagli un numero di cellulare quando serviranno l'app.",
+    "boardOnlyNoAccount": "Nessun account",
+    "giveApp": "Dagli l'app",
+    "giveAppTitle": "Dai l'app a {name}",
+    "giveAppBlurb": "Manca solo un numero di cellulare. Da lì in poi accederà con quello.",
+    "giveAppKeepsHistory": "Tutto ciò che è già registrato a suo nome resta dov’è.",
+    "giveAppSubmit": "Crea il suo account",
+    "role": "Ruolo",
+    "inviteMemberHint": "Riceve un’email e sceglie la propria password."
   },
   "invoice": {
     "title": "Layout fattura",

+ 4 - 2
messages/it/share.json

@@ -71,7 +71,8 @@
     "findingSeverityUrgent": "Alta",
     "findingSeverityNeedsWork": "Media",
     "findingSeverityMonitor": "Bassa",
-    "tel": "Tel: {phone}"
+    "tel": "Tel: {phone}",
+    "customFields": "Informazioni aggiuntive"
   },
   "quote": {
     "title": "Preventivo",
@@ -125,7 +126,8 @@
       "converted": "Convertito",
       "changes_requested": "Modifiche Richieste"
     },
-    "tel": "Tel: {phone}"
+    "tel": "Tel: {phone}",
+    "customFields": "Informazioni aggiuntive"
   },
   "inspection": {
     "title": "Ispezione Veicolo",

+ 4 - 1
messages/it/workBoard.json

@@ -75,7 +75,10 @@
     "standalone": "Tecnico indipendente",
     "deleteTitle": "Elimina Tecnico",
     "deleteDescription": "Rimuovere \"{name}\" dalla lavagna di lavoro? Anche i loro incarichi esistenti verranno rimossi.",
-    "addTechnician": "Aggiungi Tecnico"
+    "addTechnician": "Aggiungi Tecnico",
+    "standaloneOnly": "Un nome sulla lavagna per chi non accede, come un apprendista o un esterno. Per un meccanico che usa l'app, aggiungilo dalla pagina Team.",
+    "linkedTo": "Accede come {name}",
+    "linkedManagedOnTeam": "Gestito dalla pagina Team, insieme all'accesso all'app."
   },
   "jobDetail": {
     "viewDetails": "Visualizza Dettagli",

+ 11 - 1
messages/lt/audit.json

@@ -139,7 +139,17 @@
     "whatsapp_send": "Išsiųsta „WhatsApp“ žinutė",
     "work_bay_create": "Sukurta darbo vieta",
     "work_bay_update": "Atnaujinta darbo vieta",
-    "work_bay_delete": "Ištrinta darbo vieta"
+    "work_bay_delete": "Ištrinta darbo vieta",
+    "auth_appSetupRedeemed": "Prisijungta su nustatymo kodu",
+    "auth_technicianCodeSignIn": "Prisijungta su vienkartiniu kodu",
+    "team_createAppSetupCode": "Išduotas programėlės nustatymo kodas",
+    "team_createTechnicianAccount": "Sukurta techniko paskyra",
+    "team_removeTechnicianAccess": "Technikas pašalintas, prieiga atšaukta",
+    "team_setMemberTechnician": "Pakeista, ar narys yra technikas",
+    "settings_whatsappUpdated": "Atnaujinti „WhatsApp“ nustatymai",
+    "settings_whatsappDisconnected": "Atjungtas „WhatsApp“",
+    "settings_whatsappNumberRegistered": "Užregistruotas „WhatsApp“ numeris",
+    "team_giveTechnicianTheApp": "Lentos technikui suteikta paskyra"
   },
   "summary": {
     "customField_create": "Sukurtas pasirinktinis laukas „{name}“",

+ 3 - 1
messages/lt/navigation.json

@@ -72,7 +72,9 @@
     "vehicle": "Transporto priemonė",
     "quote": "Pasiūlymas",
     "part": "Dalis",
-    "inspection": "Apžiūra"
+    "inspection": "Apžiūra",
+    "shopActions": "Servisas",
+    "person": "Pridėti žmogų"
   },
   "purchaseWhiteLabel": "Įsigyti \"White-Label\"",
   "licenseExpired": "Jūsų licencija pasibaigė. Funkcijos gali būti ribotos.",

+ 5 - 1
messages/lt/service.json

@@ -344,7 +344,11 @@
     "setDefaults": "Nustatyti numatytuosius",
     "workBay": "Darbo vieta",
     "selectWorkBay": "Pasirinkite darbo vietą…",
-    "noWorkBay": "Be darbo vietos"
+    "noWorkBay": "Be darbo vietos",
+    "technicians": "Technikai",
+    "otherTeamMembers": "Kiti komandos nariai",
+    "makeTechnician": "Padarys jį techniku",
+    "addSomeoneNew": "Pridėti naują žmogų"
   },
   "notifications": {
     "title": "Kliento pranešimai",

+ 88 - 1
messages/lt/settings.json

@@ -395,7 +395,94 @@
     "editRole": "Edit role",
     "technician": "Technikas",
     "technicianHint": "Gali gauti priskirtus darbus ir naudotis techniko programa",
-    "technicianFailed": "Nepavyko pakeisti"
+    "technicianFailed": "Nepavyko pakeisti",
+    "setupApp": "Nustatyti programėlę",
+    "setupAppTitle": "Nustatyti techniko programėlę",
+    "setupAppInstruction": "Paprašykite {name} tai atlikti telefone:",
+    "setupAppOrType": "Arba padiktuokite šį kodą:",
+    "setupAppExpiry": "Galioja vieną kartą, baigia galioti po {time}.",
+    "setupAppExpired": "Šis kodas nebegalioja. Uždarykite ir pradėkite iš naujo.",
+    "setupAppDone": "Atlikta",
+    "setupAppFailed": "Nepavyko sukurti nustatymo kodo",
+    "setupPageTitle": "Nustatyti techniko programėlę",
+    "setupPageBody": "Atidarykite „Torqvoice Tech“ šiame telefone, bakstelėkite „Nuskaityti nustatymo kodą“ ir vėl nukreipkite į tą patį kodą.",
+    "setupPageCode": "Arba įveskite šį kodą programėlėje",
+    "setupPageOpenApp": "Atidaryti „Torqvoice Tech“",
+    "setupPageExpiry": "Šis kodas galioja vieną kartą ir baigia galioti praėjus dešimčiai minučių nuo serviso sukūrimo.",
+    "setupAppStep1": "Įdiegti „Torqvoice Tech“",
+    "setupAppStep2": "Ją atidaryti ir bakstelėti „Nuskaityti nustatymo kodą“",
+    "setupAppStep3": "Nukreipti telefoną į šį kodą",
+    "setupAppNotCamera": "Įprasta telefono kamera neveiks. Nuskaityti reikia „Torqvoice Tech“ programėlėje.",
+    "addTechnician": "Pridėti techniką",
+    "addTechnicianHint": "Mechanikui, stovinčiam prie registratūros. Be el. pašto ir be laukimo.",
+    "technicianName": "Vardas",
+    "technicianNamePlaceholder": "Pilnas vardas",
+    "technicianPhone": "Mobiliojo numeris",
+    "technicianPhonePlaceholder": "Telefonas, kurį jie turi",
+    "addTechnicianSubmit": "Sukurti",
+    "addTechnicianNote": "Nuo tada jis prisijungia su tuo numeriu. Nereikia atsiminti slaptažodžio ir nieko rašyti pirmame programėlės ekrane.",
+    "addTechnicianDone": "{name} paruoštas. Parodykite jam kodą.",
+    "addTechnicianFailed": "Nepavyko sukurti techniko",
+    "setupAppCopyLink": "Kopijuoti nuorodą siuntimui",
+    "setupAppCopied": "Nukopijuota",
+    "revokeTechnicianTitle": "Atjungti jį nuo programėlės?",
+    "revokeTechnicianBody": "{name} iškart praranda prieigą telefone, o visi kodai nustoja veikti. Atliktas darbas ir valandos lieka kaip buvę.",
+    "revokeTechnicianConfirm": "Atjungti",
+    "addPerson": "Pridėti",
+    "addPersonTitle": "Pridėti žmogų į servisą",
+    "addPersonWho": "Šie du nustatomi skirtingai, tad pirmiausia pasakykite, kas tai.",
+    "choiceTechnician": "Mechanikas",
+    "choiceTechnicianHint": "Dirba su automobiliais ir naudoja „Torqvoice“ telefone. Lengviausia, kol jis stovi šalia, užtrunka apie minutę.",
+    "choiceMember": "Kažkas biure",
+    "choiceMemberHint": "Priima automobilius, užsako dalis ir siunčia sąskaitas kompiuteriu. Jūs išsiunčiate el. laišką, o jis pats pasirenka slaptažodį.",
+    "stepLabelWho": "Kas",
+    "stepLabelDetails": "Duomenys",
+    "stepLabelPhone": "Jo telefonas",
+    "techDetailsTitle": "Vardas ir mobiliojo numeris",
+    "techDetailsBlurb": "Nuo šiol jis jungsis su šiuo numeriu, tad nurodykite telefoną, kurį jis tikrai nešiojasi. Slaptažodžio, kurį reikėtų atsiminti, nėra.",
+    "techHandoffTitle": "Dabar perkelkite į jo telefoną",
+    "techHandoffBlurb": "Šis kodas prijungia jo telefoną prie jūsų serviso. Galioja vieną kartą ir tik dešimt minučių, tad padarykite tai, kol jis čia.",
+    "memberDetailsTitle": "Jo el. pašto adresas",
+    "memberDetailsBlurb": "Jis gaus laišką su nuoroda. Ją atidaręs pasirinks savo slaptažodį ir tada atsiras sąraše viršuje.",
+    "roleHint": "Administratoriai gali keisti nustatymus ir pridėti žmonių. Visi kiti dirba kasdienį darbą.",
+    "stepBack": "Atgal",
+    "stepHandoffDone": "Jis nuskaitė",
+    "stepDoneTitle": "{name} paruoštas",
+    "stepDoneBody": "Jis gali atidaryti programėlę ir matyti savo darbus. Kitą kartą prisijungs tik su mobiliojo numeriu, be kodo.",
+    "stepInvitedTitle": "Kvietimas išsiųstas į {email}",
+    "stepInvitedBody": "Jis atsiras sąraše, kai atidarys laišką ir priims kvietimą. Nuoroda galioja septynias dienas.",
+    "stepDoneAnother": "Pridėti dar vieną",
+    "clashTitle": "{name} jau turi šį numerį",
+    "clashBlurb": "Du žmonės negali dalytis vienu numeriu, nes su juo jie prisijungia. Kuris variantas?",
+    "clashSamePerson": "Tai {name}",
+    "clashSamePersonHint": "Tas pats žmogus grįžta arba vardas parašytas kitaip. Jo darbai ir valandos lieka su juo, o ką tik įvestas vardas pakeis senąjį.",
+    "clashTakeover": "Ne, tai {name}",
+    "clashTakeoverHint": "Kitas žmogus, kuris dabar turi šį numerį. {name} išlaiko viską, ką čia atliko, bet nebegali prisijungti, o naują numerį galėsite suteikti vėliau.",
+    "clashDifferentNumber": "Nei vienas, pakeisiu numerį",
+    "workshopCountry": "Kurioje šalyje yra jūsų servisas?",
+    "workshopCountryPlaceholder": "Pasirinkite šalį",
+    "workshopCountryHint": "Klausiama vieną kartą. Vėliau numerius galima rašyti trumpai. Tai galėsite pakeisti <link>Lokalizacijos nustatymuose</link>.",
+    "phonePreview": "Išsaugoma kaip",
+    "stepScannedTitle": "{name} prisijungė",
+    "countrySearch": "Ieškoti šalies",
+    "countryNoMatch": "Nėra atitinkančios šalies",
+    "choiceStandalone": "Tik vardas lentoje",
+    "choiceStandaloneHint": "Praktikantas, rangovas, bet kas, ką planuojate, bet kas niekada neprisijungia. Be telefono, be el. pašto, be paskyros. Ją galėsite suteikti vėliau.",
+    "standaloneDetailsTitle": "Vardas ir spalva",
+    "standaloneDetailsBlurb": "Tai viskas, ką jis gauna, ir viskas, ko reikia. Jis matomas lentoje ir darbuose, o prisijungti niekur nereikia.",
+    "boardColour": "Spalva lentoje",
+    "stepStandaloneTitle": "{name} yra lentoje",
+    "stepStandaloneBody": "Jį galite iškart planuoti ir skirti darbams. Jei kada prireiks programėlės, iš šio sąrašo pridėkite mobiliojo numerį.",
+    "boardOnly": "Tik lentoje",
+    "boardOnlyHint": "Juos planuojate ir skiriate darbams, bet jie niekada neprisijungia. Suteikite mobiliojo numerį, kai prireiks programėlės.",
+    "boardOnlyNoAccount": "Nėra paskyros",
+    "giveApp": "Suteikti programėlę",
+    "giveAppTitle": "Suteikti {name} programėlę",
+    "giveAppBlurb": "Trūksta tik mobiliojo numerio. Nuo tada su juo jis ir prisijungs.",
+    "giveAppKeepsHistory": "Viskas, kas jam jau užfiksuota, lieka savo vietoje.",
+    "giveAppSubmit": "Sukurti paskyrą",
+    "role": "Vaidmuo",
+    "inviteMemberHint": "Jis gaus laišką ir pats pasirinks slaptažodį."
   },
   "invoice": {
     "title": "Sąskaitos ir pasiūlymai",

+ 4 - 2
messages/lt/share.json

@@ -71,7 +71,8 @@
     "findingSeverityUrgent": "Aukšta",
     "findingSeverityNeedsWork": "Vidutinė",
     "findingSeverityMonitor": "Žema",
-    "tel": "Tel.: {phone}"
+    "tel": "Tel.: {phone}",
+    "customFields": "Papildoma informacija"
   },
   "quote": {
     "title": "Pasiūlymas",
@@ -125,7 +126,8 @@
       "converted": "Konvertuotas",
       "changes_requested": "Prašomi pakeitimai"
     },
-    "tel": "Tel.: {phone}"
+    "tel": "Tel.: {phone}",
+    "customFields": "Papildoma informacija"
   },
   "inspection": {
     "title": "Transporto priemonės apžiūra",

+ 4 - 1
messages/lt/workBoard.json

@@ -75,7 +75,10 @@
     "standalone": "Atskiras technikas",
     "deleteTitle": "Ištrinti techniką",
     "deleteDescription": "Pašalinti \"{name}\" iš darbų lentos? Esami priskyrimai taip pat bus pašalinti.",
-    "addTechnician": "Pridėti techniką"
+    "addTechnician": "Pridėti techniką",
+    "standaloneOnly": "Vardas lentoje tam, kuris neprisijungia: praktikantui ar rangovui. Mechaniką, kuris naudoja programėlę, pridėkite Komandos puslapyje.",
+    "linkedTo": "Prisijungia kaip {name}",
+    "linkedManagedOnTeam": "Valdoma Komandos puslapyje kartu su prieiga prie programėlės."
   },
   "jobDetail": {
     "viewDetails": "Peržiūrėti informaciją",

+ 11 - 1
messages/nb/audit.json

@@ -139,7 +139,17 @@
     "whatsapp_send": "WhatsApp-melding sendt",
     "work_bay_create": "Opprettet arbeidsplass",
     "work_bay_update": "Oppdaterte arbeidsplass",
-    "work_bay_delete": "Slettet arbeidsplass"
+    "work_bay_delete": "Slettet arbeidsplass",
+    "auth_appSetupRedeemed": "Logget inn med oppsettskode",
+    "auth_technicianCodeSignIn": "Logget inn med engangskode",
+    "team_createAppSetupCode": "Utstedte en oppsettskode for appen",
+    "team_createTechnicianAccount": "Opprettet en teknikerkonto",
+    "team_removeTechnicianAccess": "Fjernet en tekniker og trakk tilbake tilgangen",
+    "team_setMemberTechnician": "Endret om et medlem er tekniker",
+    "settings_whatsappUpdated": "Oppdaterte WhatsApp-innstillinger",
+    "settings_whatsappDisconnected": "Koblet fra WhatsApp",
+    "settings_whatsappNumberRegistered": "Registrerte et WhatsApp-nummer",
+    "team_giveTechnicianTheApp": "Ga en tavle-tekniker en konto"
   },
   "summary": {
     "customField_create": "Opprettet egendefinert felt «{name}»",

+ 3 - 1
messages/nb/navigation.json

@@ -72,7 +72,9 @@
     "vehicle": "Kjøretøy",
     "quote": "Tilbud",
     "part": "Del",
-    "inspection": "Inspeksjon"
+    "inspection": "Inspeksjon",
+    "shopActions": "Verkstedet",
+    "person": "Legg til en person"
   },
   "purchaseWhiteLabel": "Kjøp White-Label",
   "licenseExpired": "Lisensen din har utløpt. Funksjoner kan være begrenset.",

+ 5 - 1
messages/nb/service.json

@@ -344,7 +344,11 @@
     "setDefaults": "Standardinnstillinger",
     "workBay": "Arbeidsplass",
     "selectWorkBay": "Velg arbeidsplass …",
-    "noWorkBay": "Ingen arbeidsplass"
+    "noWorkBay": "Ingen arbeidsplass",
+    "technicians": "Teknikere",
+    "otherTeamMembers": "Andre i teamet",
+    "makeTechnician": "Gjør dem til tekniker",
+    "addSomeoneNew": "Legg til noen ny"
   },
   "notifications": {
     "title": "Kundevarsler",

+ 88 - 1
messages/nb/settings.json

@@ -395,7 +395,94 @@
     "editRole": "Edit role",
     "technician": "Tekniker",
     "technicianHint": "Kan tildeles jobber og bruke teknikerappen",
-    "technicianFailed": "Kunne ikke endres"
+    "technicianFailed": "Kunne ikke endres",
+    "setupApp": "Sett opp app",
+    "setupAppTitle": "Sett opp teknikerappen",
+    "setupAppInstruction": "Be {name} gjøre dette på telefonen:",
+    "setupAppOrType": "Eller les opp denne koden:",
+    "setupAppExpiry": "Virker én gang og utløper om {time}.",
+    "setupAppExpired": "Denne koden er utløpt. Lukk og start på nytt.",
+    "setupAppDone": "Ferdig",
+    "setupAppFailed": "Kunne ikke lage en oppsettskode",
+    "setupPageTitle": "Sett opp teknikerappen",
+    "setupPageBody": "Åpne Torqvoice Tech på denne telefonen, trykk Skann oppsettskode, og rett den mot den samme koden igjen.",
+    "setupPageCode": "Eller skriv inn denne koden i appen",
+    "setupPageOpenApp": "Åpne Torqvoice Tech",
+    "setupPageExpiry": "Denne koden virker én gang og utløper ti minutter etter at verkstedet laget den.",
+    "setupAppStep1": "Installer Torqvoice Tech",
+    "setupAppStep2": "Åpne den og trykke «Skann oppsettskode»",
+    "setupAppStep3": "Rette telefonen mot denne koden",
+    "setupAppNotCamera": "Telefonens vanlige kameraapp virker ikke. Det må skannes inne i Torqvoice Tech.",
+    "addTechnician": "Legg til en tekniker",
+    "addTechnicianHint": "For en mekaniker som står i skranken. Ingen e-post, ingen venting.",
+    "technicianName": "Navn",
+    "technicianNamePlaceholder": "Fullt navn",
+    "technicianPhone": "Mobilnummer",
+    "technicianPhonePlaceholder": "Telefonen i lomma",
+    "addTechnicianSubmit": "Opprett",
+    "addTechnicianNote": "Fra da av logger han inn med det nummeret. Ingen passord å huske, og ingenting å skrive på appens første skjerm.",
+    "addTechnicianDone": "{name} er opprettet. Vis ham koden.",
+    "addTechnicianFailed": "Kunne ikke opprette teknikeren",
+    "setupAppCopyLink": "Kopier lenke å sende",
+    "setupAppCopied": "Kopiert",
+    "revokeTechnicianTitle": "Logge ham ut av appen?",
+    "revokeTechnicianBody": "{name} mister tilgangen på telefonen umiddelbart, og alle koder slutter å virke. Utført arbeid og timer blir stående som de er.",
+    "revokeTechnicianConfirm": "Logg ut",
+    "addPerson": "Legg til",
+    "addPersonTitle": "Legg noen til i verkstedet",
+    "addPersonWho": "De to settes opp på hver sin måte, så si først hvem dette er.",
+    "choiceTechnician": "En mekaniker",
+    "choiceTechnicianHint": "Jobber på bilene og bruker Torqvoice på telefonen. Enklest mens han står her hos deg, og tar omtrent ett minutt.",
+    "choiceMember": "Noen på kontoret",
+    "choiceMemberHint": "Tar imot biler, bestiller deler og sender fakturaer, på datamaskin. Du sender en e-post, og han velger sitt eget passord.",
+    "stepLabelWho": "Hvem",
+    "stepLabelDetails": "Detaljer",
+    "stepLabelPhone": "Telefonen hans",
+    "techDetailsTitle": "Navn og mobilnummer",
+    "techDetailsBlurb": "Mobilnummeret er det han logger inn med fra nå av, så bruk telefonen han faktisk har med seg. Det finnes ikke noe passord å huske eller glemme.",
+    "techHandoffTitle": "Få det over på telefonen hans",
+    "techHandoffBlurb": "Denne koden kobler telefonen hans til verkstedet ditt. Den virker én gang og bare de neste ti minuttene, så gjør det mens han er her.",
+    "memberDetailsTitle": "E-postadressen hans",
+    "memberDetailsBlurb": "Han får en e-post med en lenke. Når han åpner den, velger han sitt eget passord og dukker deretter opp i listen over.",
+    "roleHint": "Administratorer kan endre innstillinger og legge til folk. Alle andre gjør det daglige arbeidet.",
+    "stepBack": "Tilbake",
+    "stepHandoffDone": "Han har skannet den",
+    "stepDoneTitle": "{name} er klar",
+    "stepDoneBody": "Han kan åpne appen og se jobbene sine. Neste gang logger han inn med bare mobilnummeret, uten kode.",
+    "stepInvitedTitle": "Invitasjon sendt til {email}",
+    "stepInvitedBody": "Han dukker opp i listen så snart han har åpnet e-posten og takket ja. Lenken virker i sju dager.",
+    "stepDoneAnother": "Legg til en til",
+    "clashTitle": "{name} har allerede det nummeret",
+    "clashBlurb": "To personer kan ikke dele et mobilnummer, siden det er det de logger inn med. Hvilket av disse er det?",
+    "clashSamePerson": "Dette er {name}",
+    "clashSamePersonHint": "Samme person som kommer tilbake, eller et navn stavet annerledes. Jobbene og timene følger dem, og navnet du nettopp skrev erstatter det gamle.",
+    "clashTakeover": "Nei, dette er {name}",
+    "clashTakeoverHint": "En annen person som nå har det nummeret. {name} beholder alt de har gjort her, men kan ikke lenger logge inn, og du kan gi dem et nytt nummer senere.",
+    "clashDifferentNumber": "Ingen av delene, la meg endre nummeret",
+    "workshopCountry": "Hvilket land ligger verkstedet i?",
+    "workshopCountryPlaceholder": "Velg land",
+    "workshopCountryHint": "Spørres bare én gang. Etter dette kan mobilnumre skrives kort. Du kan endre det senere under <link>Språkinnstillinger</link>.",
+    "phonePreview": "Lagres som",
+    "stepScannedTitle": "{name} er inne",
+    "countrySearch": "Søk etter land",
+    "countryNoMatch": "Ingen land passer",
+    "choiceStandalone": "Bare et navn på tavlen",
+    "choiceStandaloneHint": "En lærling, en innleid, alle du planlegger inn men som aldri logger inn. Ingen telefon, ingen e-post, ingen konto. Du kan gi dem det senere.",
+    "standaloneDetailsTitle": "Navn og farge",
+    "standaloneDetailsBlurb": "Dette er alt de får, og alt de trenger. De vises på tavlen og på jobber, og har ingenting å logge inn på.",
+    "boardColour": "Farge på tavlen",
+    "stepStandaloneTitle": "{name} er på tavlen",
+    "stepStandaloneBody": "Du kan planlegge dem og sette dem på jobber med en gang. Trenger de appen senere, legger du til et mobilnummer herfra.",
+    "boardOnly": "Bare på tavlen",
+    "boardOnlyHint": "Planlegges og settes på jobber, men logger aldri inn. Gi dem et mobilnummer når de trenger appen.",
+    "boardOnlyNoAccount": "Ingen konto",
+    "giveApp": "Gi dem appen",
+    "giveAppTitle": "Gi {name} appen",
+    "giveAppBlurb": "Det eneste som mangler er et mobilnummer. Med det logger de inn fra nå av.",
+    "giveAppKeepsHistory": "Alt som allerede er ført på dem blir stående.",
+    "giveAppSubmit": "Opprett kontoen",
+    "role": "Rolle",
+    "inviteMemberHint": "Personen får en e-post og velger sitt eget passord."
   },
   "invoice": {
     "title": "Faktura og tilbud",

+ 4 - 2
messages/nb/share.json

@@ -71,7 +71,8 @@
     "findingSeverityUrgent": "Høy",
     "findingSeverityNeedsWork": "Middels",
     "findingSeverityMonitor": "Lav",
-    "tel": "Tlf: {phone}"
+    "tel": "Tlf: {phone}",
+    "customFields": "Tilleggsinformasjon"
   },
   "quote": {
     "title": "Tilbud",
@@ -125,7 +126,8 @@
       "converted": "Konvertert",
       "changes_requested": "Endringer forespurt"
     },
-    "tel": "Tlf: {phone}"
+    "tel": "Tlf: {phone}",
+    "customFields": "Tilleggsinformasjon"
   },
   "inspection": {
     "title": "Kjøretøykontroll",

+ 4 - 1
messages/nb/workBoard.json

@@ -75,7 +75,10 @@
     "standalone": "Uavhengig tekniker",
     "deleteTitle": "Slett tekniker",
     "deleteDescription": "Fjern \"{name}\" fra arbeidstavlen? Deres eksisterende tilordninger vil også bli fjernet.",
-    "addTechnician": "Legg til tekniker"
+    "addTechnician": "Legg til tekniker",
+    "standaloneOnly": "Et navn på tavlen for noen som ikke logger inn, for eksempel en lærling eller innleid. En mekaniker som bruker appen legger du til på Team-siden.",
+    "linkedTo": "Logger inn som {name}",
+    "linkedManagedOnTeam": "Styres fra Team-siden, sammen med apptilgangen."
   },
   "jobDetail": {
     "viewDetails": "Vis detaljer",

+ 11 - 1
messages/nl/audit.json

@@ -139,7 +139,17 @@
     "whatsapp_send": "WhatsApp-bericht verzonden",
     "work_bay_create": "Werkplek aangemaakt",
     "work_bay_update": "Werkplek bijgewerkt",
-    "work_bay_delete": "Werkplek verwijderd"
+    "work_bay_delete": "Werkplek verwijderd",
+    "auth_appSetupRedeemed": "Ingelogd met installatiecode",
+    "auth_technicianCodeSignIn": "Ingelogd met eenmalige code",
+    "team_createAppSetupCode": "Installatiecode voor de app uitgegeven",
+    "team_createTechnicianAccount": "Monteursaccount aangemaakt",
+    "team_removeTechnicianAccess": "Monteur verwijderd en toegang ingetrokken",
+    "team_setMemberTechnician": "Gewijzigd of een lid monteur is",
+    "settings_whatsappUpdated": "WhatsApp-instellingen bijgewerkt",
+    "settings_whatsappDisconnected": "WhatsApp losgekoppeld",
+    "settings_whatsappNumberRegistered": "WhatsApp-nummer geregistreerd",
+    "team_giveTechnicianTheApp": "Bordmonteur een account gegeven"
   },
   "summary": {
     "customField_create": "Aangepast veld \"{name}\" aangemaakt",

+ 3 - 1
messages/nl/navigation.json

@@ -72,7 +72,9 @@
     "vehicle": "Voertuig",
     "quote": "Offerte",
     "part": "Onderdeel",
-    "inspection": "Keuring"
+    "inspection": "Keuring",
+    "shopActions": "Werkplaats",
+    "person": "Persoon toevoegen"
   },
   "purchaseWhiteLabel": "White-label aanschaffen",
   "licenseExpired": "Uw licentie is verlopen. Functies kunnen beperkt zijn.",

+ 5 - 1
messages/nl/service.json

@@ -344,7 +344,11 @@
     "setDefaults": "Standaardwaarden",
     "workBay": "Werkplek",
     "selectWorkBay": "Werkplek kiezen…",
-    "noWorkBay": "Geen werkplek"
+    "noWorkBay": "Geen werkplek",
+    "technicians": "Monteurs",
+    "otherTeamMembers": "Andere teamleden",
+    "makeTechnician": "Maakt hen monteur",
+    "addSomeoneNew": "Iemand nieuw toevoegen"
   },
   "notifications": {
     "title": "Klantmeldingen",

+ 88 - 1
messages/nl/settings.json

@@ -395,7 +395,94 @@
     "editRole": "Edit role",
     "technician": "Technicus",
     "technicianHint": "Kan opdrachten toegewezen krijgen en de technicus-app gebruiken",
-    "technicianFailed": "Kon niet worden gewijzigd"
+    "technicianFailed": "Kon niet worden gewijzigd",
+    "setupApp": "App instellen",
+    "setupAppTitle": "Monteurs-app instellen",
+    "setupAppInstruction": "Vraag {name} om dit op de telefoon te doen:",
+    "setupAppOrType": "Of lees deze code voor:",
+    "setupAppExpiry": "Werkt eenmalig en verloopt over {time}.",
+    "setupAppExpired": "Deze code is verlopen. Sluit af en begin opnieuw.",
+    "setupAppDone": "Klaar",
+    "setupAppFailed": "Kon geen installatiecode aanmaken",
+    "setupPageTitle": "Monteurs-app instellen",
+    "setupPageBody": "Open Torqvoice Tech op deze telefoon, tik op Installatiecode scannen en richt hem opnieuw op dezelfde code.",
+    "setupPageCode": "Of voer deze code in de app in",
+    "setupPageOpenApp": "Torqvoice Tech openen",
+    "setupPageExpiry": "Deze code werkt eenmalig en verloopt tien minuten nadat uw werkplaats hem heeft aangemaakt.",
+    "setupAppStep1": "Torqvoice Tech installeren",
+    "setupAppStep2": "Hem openen en op “Installatiecode scannen” tikken",
+    "setupAppStep3": "De telefoon op deze code richten",
+    "setupAppNotCamera": "De gewone camera-app van de telefoon werkt niet. Het moet in Torqvoice Tech worden gescand.",
+    "addTechnician": "Monteur toevoegen",
+    "addTechnicianHint": "Voor een monteur die aan de balie staat. Geen e-mail, geen wachten.",
+    "technicianName": "Naam",
+    "technicianNamePlaceholder": "Volledige naam",
+    "technicianPhone": "Mobiel nummer",
+    "technicianPhonePlaceholder": "De telefoon in hun zak",
+    "addTechnicianSubmit": "Aanmaken",
+    "addTechnicianNote": "Vanaf dan logt hij in met dat nummer. Geen wachtwoord om te onthouden en niets te typen op het eerste scherm van de app.",
+    "addTechnicianDone": "{name} is aangemaakt. Laat de code zien.",
+    "addTechnicianFailed": "Kon die monteur niet aanmaken",
+    "setupAppCopyLink": "Link kopiëren om te sturen",
+    "setupAppCopied": "Gekopieerd",
+    "revokeTechnicianTitle": "Uitloggen uit de app?",
+    "revokeTechnicianBody": "{name} verliest direct toegang op de telefoon en elke code werkt niet meer. Afgerond werk en uren blijven precies zoals ze zijn.",
+    "revokeTechnicianConfirm": "Uitloggen",
+    "addPerson": "Toevoegen",
+    "addPersonTitle": "Iemand aan de werkplaats toevoegen",
+    "addPersonWho": "Deze twee worden verschillend ingesteld, dus begin met wie dit is.",
+    "choiceTechnician": "Een monteur",
+    "choiceTechnicianHint": "Werkt aan de auto’s en gebruikt Torqvoice op de telefoon. Het makkelijkst terwijl hij hier bij u staat, en het duurt ongeveer een minuut.",
+    "choiceMember": "Iemand op kantoor",
+    "choiceMemberHint": "Neemt auto’s aan, bestelt onderdelen en verstuurt facturen, op een computer. U stuurt een e-mail en hij kiest zelf een wachtwoord.",
+    "stepLabelWho": "Wie",
+    "stepLabelDetails": "Gegevens",
+    "stepLabelPhone": "Zijn telefoon",
+    "techDetailsTitle": "Naam en mobiel nummer",
+    "techDetailsBlurb": "Met dat mobiele nummer logt hij voortaan in, dus gebruik de telefoon die hij echt bij zich heeft. Er is geen wachtwoord om te onthouden of te vergeten.",
+    "techHandoffTitle": "Nu op zijn telefoon zetten",
+    "techHandoffBlurb": "Deze code verbindt zijn telefoon met uw werkplaats. Hij werkt eenmalig en alleen de komende tien minuten, dus doe het nu hij er is.",
+    "memberDetailsTitle": "Zijn e-mailadres",
+    "memberDetailsBlurb": "Hij krijgt een e-mail met een link. Als hij die opent kiest hij zelf een wachtwoord en verschijnt daarna in de lijst hierboven.",
+    "roleHint": "Beheerders kunnen instellingen wijzigen en mensen toevoegen. Alle anderen doen het dagelijkse werk.",
+    "stepBack": "Terug",
+    "stepHandoffDone": "Hij heeft hem gescand",
+    "stepDoneTitle": "{name} is klaar",
+    "stepDoneBody": "Hij kan de app openen en zijn opdrachten zien. De volgende keer logt hij in met alleen zijn mobiele nummer, zonder code.",
+    "stepInvitedTitle": "Uitnodiging verstuurd naar {email}",
+    "stepInvitedBody": "Hij verschijnt in de lijst zodra hij de e-mail heeft geopend en geaccepteerd. De link werkt zeven dagen.",
+    "stepDoneAnother": "Nog iemand toevoegen",
+    "clashTitle": "{name} heeft dat nummer al",
+    "clashBlurb": "Twee mensen kunnen geen mobiel nummer delen, want daarmee loggen ze in. Welke van deze is het?",
+    "clashSamePerson": "Dit is {name}",
+    "clashSamePersonHint": "Dezelfde persoon die terugkomt, of een naam anders geschreven. Hun opdrachten en uren blijven bij hen, en de naam die u net typte vervangt de oude.",
+    "clashTakeover": "Nee, dit is {name}",
+    "clashTakeoverHint": "Een andere persoon die dat nummer nu heeft. {name} behoudt alles wat ze hier deden maar kan niet meer inloggen, en u kunt later een nieuw nummer geven.",
+    "clashDifferentNumber": "Geen van beide, ik pas het nummer aan",
+    "workshopCountry": "In welk land staat uw werkplaats?",
+    "workshopCountryPlaceholder": "Kies een land",
+    "workshopCountryHint": "Wordt één keer gevraagd. Daarna kunnen mobiele nummers kort worden ingevoerd. U kunt het later wijzigen bij <link>Taalinstellingen</link>.",
+    "phonePreview": "Opgeslagen als",
+    "stepScannedTitle": "{name} is binnen",
+    "countrySearch": "Landen zoeken",
+    "countryNoMatch": "Geen land komt overeen",
+    "choiceStandalone": "Alleen een naam voor het bord",
+    "choiceStandaloneHint": "Een leerling, een externe, iedereen die u inplant maar die nooit inlogt. Geen telefoon, geen e-mail, geen account. U kunt dat later geven.",
+    "standaloneDetailsTitle": "Naam en een kleur",
+    "standaloneDetailsBlurb": "Dit is alles wat ze krijgen en alles wat nodig is. Ze verschijnen op het bord en op opdrachten, en er is niets om op in te loggen.",
+    "boardColour": "Kleur op het bord",
+    "stepStandaloneTitle": "{name} staat op het bord",
+    "stepStandaloneBody": "U kunt hen meteen inplannen en op opdrachten zetten. Hebben ze later de app nodig, voeg dan hier een mobiel nummer toe.",
+    "boardOnly": "Alleen op het bord",
+    "boardOnlyHint": "Worden ingepland en op opdrachten gezet, maar loggen nooit in. Geef ze een mobiel nummer wanneer ze de app nodig hebben.",
+    "boardOnlyNoAccount": "Geen account",
+    "giveApp": "Geef ze de app",
+    "giveAppTitle": "Geef {name} de app",
+    "giveAppBlurb": "Alleen een mobiel nummer ontbreekt nog. Daarmee logt hij voortaan in.",
+    "giveAppKeepsHistory": "Alles wat al op hen staat blijft staan.",
+    "giveAppSubmit": "Account aanmaken",
+    "role": "Rol",
+    "inviteMemberHint": "Deze persoon krijgt een e-mail en kiest zelf een wachtwoord."
   },
   "invoice": {
     "title": "Factuurindeling",

+ 4 - 2
messages/nl/share.json

@@ -71,7 +71,8 @@
     "findingSeverityUrgent": "Hoog",
     "findingSeverityNeedsWork": "Gemiddeld",
     "findingSeverityMonitor": "Laag",
-    "tel": "Tel: {phone}"
+    "tel": "Tel: {phone}",
+    "customFields": "Aanvullende informatie"
   },
   "quote": {
     "title": "Offerte",
@@ -125,7 +126,8 @@
       "converted": "Omgezet",
       "changes_requested": "Wijzigingen aangevraagd"
     },
-    "tel": "Tel: {phone}"
+    "tel": "Tel: {phone}",
+    "customFields": "Aanvullende informatie"
   },
   "inspection": {
     "title": "Voertuiginspectie",

+ 4 - 1
messages/nl/workBoard.json

@@ -75,7 +75,10 @@
     "standalone": "Onafhankelijke technicus",
     "deleteTitle": "Technicus verwijderen",
     "deleteDescription": "\"{name}\" van het werkbord verwijderen? Hun bestaande toewijzingen worden ook verwijderd.",
-    "addTechnician": "Technicus toevoegen"
+    "addTechnician": "Technicus toevoegen",
+    "standaloneOnly": "Een naam op het bord voor iemand die niet inlogt, zoals een leerling of een externe. Een monteur die de app gebruikt voegt u toe op de Team-pagina.",
+    "linkedTo": "Logt in als {name}",
+    "linkedManagedOnTeam": "Wordt beheerd op de Team-pagina, samen met de app-toegang."
   },
   "jobDetail": {
     "viewDetails": "Details weergeven",

+ 11 - 1
messages/pl/audit.json

@@ -139,7 +139,17 @@
     "whatsapp_send": "Wysłano wiadomość WhatsApp",
     "work_bay_create": "Utworzono stanowisko",
     "work_bay_update": "Zaktualizowano stanowisko",
-    "work_bay_delete": "Usunięto stanowisko"
+    "work_bay_delete": "Usunięto stanowisko",
+    "auth_appSetupRedeemed": "Zalogowano kodem konfiguracji",
+    "auth_technicianCodeSignIn": "Zalogowano kodem jednorazowym",
+    "team_createAppSetupCode": "Wydano kod konfiguracji aplikacji",
+    "team_createTechnicianAccount": "Utworzono konto technika",
+    "team_removeTechnicianAccess": "Usunięto technika i cofnięto dostęp",
+    "team_setMemberTechnician": "Zmieniono, czy członek jest technikiem",
+    "settings_whatsappUpdated": "Zaktualizowano ustawienia WhatsApp",
+    "settings_whatsappDisconnected": "Odłączono WhatsApp",
+    "settings_whatsappNumberRegistered": "Zarejestrowano numer WhatsApp",
+    "team_giveTechnicianTheApp": "Nadano konto technikowi z tablicy"
   },
   "summary": {
     "customField_create": "Utworzono pole niestandardowe „{name}”",

+ 3 - 1
messages/pl/navigation.json

@@ -72,7 +72,9 @@
     "vehicle": "Pojazd",
     "quote": "Wycena",
     "part": "Część",
-    "inspection": "Przegląd"
+    "inspection": "Przegląd",
+    "shopActions": "Warsztat",
+    "person": "Dodaj osobę"
   },
   "purchaseWhiteLabel": "Kup White-Label",
   "licenseExpired": "Twoja licencja wygasła. Funkcje mogą być ograniczone.",

+ 5 - 1
messages/pl/service.json

@@ -344,7 +344,11 @@
     "setDefaults": "Ustaw domyślne",
     "workBay": "Stanowisko",
     "selectWorkBay": "Wybierz stanowisko…",
-    "noWorkBay": "Bez stanowiska"
+    "noWorkBay": "Bez stanowiska",
+    "technicians": "Technicy",
+    "otherTeamMembers": "Pozostali członkowie zespołu",
+    "makeTechnician": "Uczyni tę osobę technikiem",
+    "addSomeoneNew": "Dodaj nową osobę"
   },
   "notifications": {
     "title": "Powiadomienia klienta",

+ 88 - 1
messages/pl/settings.json

@@ -395,7 +395,94 @@
     "editRole": "Edit role",
     "technician": "Technik",
     "technicianHint": "Może otrzymywać zlecenia i korzystać z aplikacji technika",
-    "technicianFailed": "Nie udało się zmienić"
+    "technicianFailed": "Nie udało się zmienić",
+    "setupApp": "Skonfiguruj aplikację",
+    "setupAppTitle": "Skonfiguruj aplikację technika",
+    "setupAppInstruction": "Poproś {name}, aby zrobił to na telefonie:",
+    "setupAppOrType": "Albo podaj ten kod:",
+    "setupAppExpiry": "Działa raz i wygasa za {time}.",
+    "setupAppExpired": "Ten kod wygasł. Zamknij i zacznij od nowa.",
+    "setupAppDone": "Gotowe",
+    "setupAppFailed": "Nie udało się utworzyć kodu konfiguracji",
+    "setupPageTitle": "Skonfiguruj aplikację technika",
+    "setupPageBody": "Otwórz Torqvoice Tech na tym telefonie, dotknij Zeskanuj kod konfiguracji i ponownie wyceluj w ten sam kod.",
+    "setupPageCode": "Albo wpisz ten kod w aplikacji",
+    "setupPageOpenApp": "Otwórz Torqvoice Tech",
+    "setupPageExpiry": "Ten kod działa raz i wygasa dziesięć minut po utworzeniu go przez warsztat.",
+    "setupAppStep1": "Zainstaluj Torqvoice Tech",
+    "setupAppStep2": "Otworzyć ją i dotknąć „Zeskanuj kod konfiguracji”",
+    "setupAppStep3": "Wycelować telefon w ten kod",
+    "setupAppNotCamera": "Zwykły aparat telefonu nie zadziała. Trzeba zeskanować z poziomu Torqvoice Tech.",
+    "addTechnician": "Dodaj technika",
+    "addTechnicianHint": "Dla mechanika stojącego przy ladzie. Bez e-maila i bez czekania.",
+    "technicianName": "Imię i nazwisko",
+    "technicianNamePlaceholder": "Imię i nazwisko",
+    "technicianPhone": "Numer telefonu",
+    "technicianPhonePlaceholder": "Telefon, który ma przy sobie",
+    "addTechnicianSubmit": "Utwórz",
+    "addTechnicianNote": "Od tej pory loguje się tym numerem. Bez hasła do zapamiętania i bez wpisywania czegokolwiek na pierwszym ekranie aplikacji.",
+    "addTechnicianDone": "{name} jest gotowy. Pokaż mu kod.",
+    "addTechnicianFailed": "Nie udało się utworzyć technika",
+    "setupAppCopyLink": "Skopiuj link do wysłania",
+    "setupAppCopied": "Skopiowano",
+    "revokeTechnicianTitle": "Wylogować go z aplikacji?",
+    "revokeTechnicianBody": "{name} natychmiast traci dostęp na telefonie, a każdy kod przestaje działać. Wykonana praca i godziny zostają bez zmian.",
+    "revokeTechnicianConfirm": "Wyloguj",
+    "addPerson": "Dodaj",
+    "addPersonTitle": "Dodaj kogoś do warsztatu",
+    "addPersonWho": "Te dwie osoby konfiguruje się inaczej, więc zacznij od tego, kim jest.",
+    "choiceTechnician": "Mechanik",
+    "choiceTechnicianHint": "Pracuje przy samochodach i używa Torqvoice w telefonie. Najłatwiej, gdy stoi obok ciebie, i zajmuje około minuty.",
+    "choiceMember": "Ktoś w biurze",
+    "choiceMemberHint": "Przyjmuje samochody, zamawia części i wystawia faktury, na komputerze. Wysyłasz e-mail, a on sam wybiera hasło.",
+    "stepLabelWho": "Kto",
+    "stepLabelDetails": "Dane",
+    "stepLabelPhone": "Jego telefon",
+    "techDetailsTitle": "Imię i numer telefonu",
+    "techDetailsBlurb": "Tym numerem będzie się od teraz logował, więc podaj telefon, który naprawdę nosi. Nie ma hasła do zapamiętania ani do zapomnienia.",
+    "techHandoffTitle": "Teraz przenieś to na jego telefon",
+    "techHandoffBlurb": "Ten kod łączy jego telefon z twoim warsztatem. Działa raz i tylko przez dziesięć minut, więc zrób to, póki tu jest.",
+    "memberDetailsTitle": "Jego adres e-mail",
+    "memberDetailsBlurb": "Dostanie e-mail z linkiem. Po otwarciu wybierze własne hasło i pojawi się na liście powyżej.",
+    "roleHint": "Administratorzy mogą zmieniać ustawienia i dodawać osoby. Pozostali wykonują codzienną pracę.",
+    "stepBack": "Wstecz",
+    "stepHandoffDone": "Zeskanował go",
+    "stepDoneTitle": "{name} jest gotowy",
+    "stepDoneBody": "Może otworzyć aplikację i zobaczyć swoje zlecenia. Następnym razem loguje się samym numerem telefonu, bez kodu.",
+    "stepInvitedTitle": "Zaproszenie wysłane do {email}",
+    "stepInvitedBody": "Pojawi się na liście, gdy otworzy e-mail i zaakceptuje. Link działa siedem dni.",
+    "stepDoneAnother": "Dodaj kolejną osobę",
+    "clashTitle": "{name} ma już ten numer",
+    "clashBlurb": "Dwie osoby nie mogą dzielić numeru telefonu, bo to nim się logują. Który to przypadek?",
+    "clashSamePerson": "To jest {name}",
+    "clashSamePersonHint": "Ta sama osoba wraca albo imię zapisane inaczej. Jej zlecenia i godziny zostają przy niej, a wpisane teraz imię zastąpi stare.",
+    "clashTakeover": "Nie, to {name}",
+    "clashTakeoverHint": "Inna osoba, która ma teraz ten numer. {name} zachowuje wszystko, co tu zrobił, ale nie może się już logować, a nowy numer możesz nadać później.",
+    "clashDifferentNumber": "Żadne, chcę zmienić numer",
+    "workshopCountry": "W jakim kraju jest twój warsztat?",
+    "workshopCountryPlaceholder": "Wybierz kraj",
+    "workshopCountryHint": "Pytamy tylko raz. Potem numery można wpisywać w skróconej formie. Możesz to zmienić później w <link>Ustawieniach lokalizacji</link>.",
+    "phonePreview": "Zapisany jako",
+    "stepScannedTitle": "{name} jest w środku",
+    "countrySearch": "Szukaj krajów",
+    "countryNoMatch": "Żaden kraj nie pasuje",
+    "choiceStandalone": "Tylko nazwisko na tablicy",
+    "choiceStandaloneHint": "Uczeń, podwykonawca, każdy, kogo planujesz, ale kto nigdy się nie loguje. Bez telefonu, bez e-maila, bez konta. Możesz je dodać później.",
+    "standaloneDetailsTitle": "Imię i kolor",
+    "standaloneDetailsBlurb": "To wszystko, co dostaje, i wszystko, czego trzeba. Pojawia się na tablicy i przy zleceniach, i nie ma się gdzie logować.",
+    "boardColour": "Kolor na tablicy",
+    "stepStandaloneTitle": "{name} jest na tablicy",
+    "stepStandaloneBody": "Możesz go od razu planować i przypisywać do zleceń. Jeśli kiedyś będzie potrzebował aplikacji, dodaj mu tu numer telefonu.",
+    "boardOnly": "Tylko na tablicy",
+    "boardOnlyHint": "Są planowani i przypisywani do zleceń, ale nigdy się nie logują. Nadaj im numer telefonu, gdy będą potrzebować aplikacji.",
+    "boardOnlyNoAccount": "Brak konta",
+    "giveApp": "Daj aplikację",
+    "giveAppTitle": "Daj {name} aplikację",
+    "giveAppBlurb": "Brakuje tylko numeru telefonu. Od tej pory nim będzie się logować.",
+    "giveAppKeepsHistory": "Wszystko, co już zapisano na tę osobę, zostaje.",
+    "giveAppSubmit": "Utwórz konto",
+    "role": "Rola",
+    "inviteMemberHint": "Otrzyma e-mail i sam wybierze hasło."
   },
   "invoice": {
     "title": "Układ faktury",

+ 4 - 2
messages/pl/share.json

@@ -71,7 +71,8 @@
     "findingSeverityUrgent": "Wysoki",
     "findingSeverityNeedsWork": "Średni",
     "findingSeverityMonitor": "Niski",
-    "tel": "Tel: {phone}"
+    "tel": "Tel: {phone}",
+    "customFields": "Informacje dodatkowe"
   },
   "quote": {
     "title": "Wycena",
@@ -125,7 +126,8 @@
       "converted": "Przekonwertowano",
       "changes_requested": "Zgłoszono zmiany"
     },
-    "tel": "Tel: {phone}"
+    "tel": "Tel: {phone}",
+    "customFields": "Informacje dodatkowe"
   },
   "inspection": {
     "title": "Inspekcja pojazdu",

+ 4 - 1
messages/pl/workBoard.json

@@ -75,7 +75,10 @@
     "standalone": "Niezależny technik",
     "deleteTitle": "Usuń Technika",
     "deleteDescription": "Usunąć \"{name}\" z tablicy pracy? Jego istniejące przydzielenia również zostaną usunięte.",
-    "addTechnician": "Dodaj Technika"
+    "addTechnician": "Dodaj Technika",
+    "standaloneOnly": "Nazwisko na tablicy dla kogoś, kto się nie loguje, na przykład ucznia lub podwykonawcy. Mechanika korzystającego z aplikacji dodasz na stronie Zespół.",
+    "linkedTo": "Loguje się jako {name}",
+    "linkedManagedOnTeam": "Zarządzane na stronie Zespół, razem z dostępem do aplikacji."
   },
   "jobDetail": {
     "viewDetails": "Wyświetl Szczegóły",

+ 11 - 1
messages/pt-BR/audit.json

@@ -139,7 +139,17 @@
     "whatsapp_send": "Mensagem de WhatsApp enviada",
     "work_bay_create": "Baia criada",
     "work_bay_update": "Baia atualizada",
-    "work_bay_delete": "Baia excluída"
+    "work_bay_delete": "Baia excluída",
+    "auth_appSetupRedeemed": "Entrou com código de configuração",
+    "auth_technicianCodeSignIn": "Entrou com código de uso único",
+    "team_createAppSetupCode": "Código de configuração do aplicativo emitido",
+    "team_createTechnicianAccount": "Conta de técnico criada",
+    "team_removeTechnicianAccess": "Técnico removido e acesso revogado",
+    "team_setMemberTechnician": "Alterado se um membro é técnico",
+    "settings_whatsappUpdated": "Configurações do WhatsApp atualizadas",
+    "settings_whatsappDisconnected": "WhatsApp desconectado",
+    "settings_whatsappNumberRegistered": "Número do WhatsApp registrado",
+    "team_giveTechnicianTheApp": "Conta dada a um técnico só do quadro"
   },
   "summary": {
     "customField_create": "Campo personalizado \"{name}\" criado",

+ 3 - 1
messages/pt-BR/navigation.json

@@ -72,7 +72,9 @@
     "vehicle": "Veículo",
     "quote": "Orçamento",
     "part": "Peça",
-    "inspection": "Inspeção"
+    "inspection": "Inspeção",
+    "shopActions": "Oficina",
+    "person": "Adicionar uma pessoa"
   },
   "purchaseWhiteLabel": "Adquirir White-Label",
   "licenseExpired": "Sua licença expirou. Os recursos podem estar limitados.",

+ 5 - 1
messages/pt-BR/service.json

@@ -344,7 +344,11 @@
     "setDefaults": "Valores padrão",
     "workBay": "Baia",
     "selectWorkBay": "Selecionar baia…",
-    "noWorkBay": "Sem baia"
+    "noWorkBay": "Sem baia",
+    "technicians": "Técnicos",
+    "otherTeamMembers": "Outros membros da equipe",
+    "makeTechnician": "Torna essa pessoa técnica",
+    "addSomeoneNew": "Adicionar alguém novo"
   },
   "notifications": {
     "title": "Notificações ao cliente",

+ 88 - 1
messages/pt-BR/settings.json

@@ -395,7 +395,94 @@
     "editRole": "Edit role",
     "technician": "Técnico",
     "technicianHint": "Pode receber serviços atribuídos e usar o aplicativo do técnico",
-    "technicianFailed": "Não foi possível alterar"
+    "technicianFailed": "Não foi possível alterar",
+    "setupApp": "Configurar o aplicativo",
+    "setupAppTitle": "Configurar o aplicativo do técnico",
+    "setupAppInstruction": "Peça a {name} para fazer isto no celular:",
+    "setupAppOrType": "Ou informe este código:",
+    "setupAppExpiry": "Funciona uma vez e expira em {time}.",
+    "setupAppExpired": "Este código expirou. Feche e comece de novo.",
+    "setupAppDone": "Concluído",
+    "setupAppFailed": "Não foi possível criar um código de configuração",
+    "setupPageTitle": "Configurar o aplicativo do técnico",
+    "setupPageBody": "Abra o Torqvoice Tech neste telefone, toque em Escanear código de configuração e aponte novamente para o mesmo código.",
+    "setupPageCode": "Ou digite este código no aplicativo",
+    "setupPageOpenApp": "Abrir o Torqvoice Tech",
+    "setupPageExpiry": "Este código funciona uma vez e expira dez minutos depois que sua oficina o criou.",
+    "setupAppStep1": "Instalar o Torqvoice Tech",
+    "setupAppStep2": "Abrir e tocar em “Escanear código de configuração”",
+    "setupAppStep3": "Apontar o celular para este código",
+    "setupAppNotCamera": "A câmera comum do celular não funciona. Precisa ser escaneado dentro do Torqvoice Tech.",
+    "addTechnician": "Adicionar um técnico",
+    "addTechnicianHint": "Para um mecânico no balcão. Sem e-mail e sem espera.",
+    "technicianName": "Nome",
+    "technicianNamePlaceholder": "Nome completo",
+    "technicianPhone": "Número do celular",
+    "technicianPhonePlaceholder": "O celular que ele tem no bolso",
+    "addTechnicianSubmit": "Criar",
+    "addTechnicianNote": "A partir daí ele entra com esse número. Sem senha para lembrar e sem nada para digitar na primeira tela do aplicativo.",
+    "addTechnicianDone": "{name} está pronto. Mostre o código a ele.",
+    "addTechnicianFailed": "Não foi possível criar esse técnico",
+    "setupAppCopyLink": "Copiar o link para enviar",
+    "setupAppCopied": "Copiado",
+    "revokeTechnicianTitle": "Desconectar do aplicativo?",
+    "revokeTechnicianBody": "{name} perde o acesso no celular na hora e qualquer código para de funcionar. O trabalho concluído e as horas ficam como estão.",
+    "revokeTechnicianConfirm": "Desconectar",
+    "addPerson": "Adicionar",
+    "addPersonTitle": "Adicionar alguém à oficina",
+    "addPersonWho": "Os dois são configurados de formas diferentes, então comece dizendo quem é.",
+    "choiceTechnician": "Um mecânico",
+    "choiceTechnicianHint": "Trabalha nos carros e usa o Torqvoice no celular. Mais fácil enquanto ele está aqui com você, e leva cerca de um minuto.",
+    "choiceMember": "Alguém do escritório",
+    "choiceMemberHint": "Recebe carros, pede peças e envia faturas, no computador. Você envia um e-mail e ele escolhe a própria senha.",
+    "stepLabelWho": "Quem",
+    "stepLabelDetails": "Dados",
+    "stepLabelPhone": "O celular dele",
+    "techDetailsTitle": "Nome e número do celular",
+    "techDetailsBlurb": "É com esse número que ele vai entrar de agora em diante, então use o celular que ele realmente carrega. Não há senha para lembrar nem esquecer.",
+    "techHandoffTitle": "Agora passe para o celular dele",
+    "techHandoffBlurb": "Este código liga o celular dele à sua oficina. Vale uma vez só e por dez minutos, então faça enquanto ele está aqui.",
+    "memberDetailsTitle": "O e-mail dele",
+    "memberDetailsBlurb": "Ele recebe um e-mail com um link. Ao abrir, escolhe a própria senha e depois aparece na lista acima.",
+    "roleHint": "Administradores podem mudar configurações e adicionar pessoas. Os demais fazem o trabalho do dia a dia.",
+    "stepBack": "Voltar",
+    "stepHandoffDone": "Ele já escaneou",
+    "stepDoneTitle": "{name} está pronto",
+    "stepDoneBody": "Ele pode abrir o aplicativo e ver os serviços dele. Na próxima vez entra só com o celular, sem código.",
+    "stepInvitedTitle": "Convite enviado para {email}",
+    "stepInvitedBody": "Ele aparece na lista assim que abrir o e-mail e aceitar. O link vale sete dias.",
+    "stepDoneAnother": "Adicionar outra pessoa",
+    "clashTitle": "{name} já tem esse número",
+    "clashBlurb": "Duas pessoas não podem dividir um número de celular, porque é com ele que entram. Qual destes casos é?",
+    "clashSamePerson": "É {name}",
+    "clashSamePersonHint": "A mesma pessoa voltando, ou um nome escrito de outro jeito. Os serviços e as horas continuam com ela, e o nome que você digitou substitui o antigo.",
+    "clashTakeover": "Não, é {name}",
+    "clashTakeoverHint": "Outra pessoa que agora tem esse número. {name} mantém tudo o que fez aqui mas não pode mais entrar, e você pode dar um número novo depois.",
+    "clashDifferentNumber": "Nenhum, quero mudar o número",
+    "workshopCountry": "Em que país fica sua oficina?",
+    "workshopCountryPlaceholder": "Escolha um país",
+    "workshopCountryHint": "Perguntado uma vez só. Depois os celulares podem ser digitados na forma curta. Você pode mudar isso depois em <link>Configurações de localização</link>.",
+    "phonePreview": "Salvo como",
+    "stepScannedTitle": "{name} entrou",
+    "countrySearch": "Buscar países",
+    "countryNoMatch": "Nenhum país corresponde",
+    "choiceStandalone": "Só um nome para o quadro",
+    "choiceStandaloneHint": "Um aprendiz, um terceirizado, qualquer pessoa que você agenda mas que nunca entra. Sem celular, sem e-mail, sem conta. Você pode dar uma depois.",
+    "standaloneDetailsTitle": "O nome e uma cor",
+    "standaloneDetailsBlurb": "É tudo o que recebe e tudo o que precisa. Aparece no quadro e nos serviços, e não há onde fazer login.",
+    "boardColour": "Cor no quadro",
+    "stepStandaloneTitle": "{name} está no quadro",
+    "stepStandaloneBody": "Você já pode agendar e colocar essa pessoa em serviços. Se um dia precisar do aplicativo, adicione um celular por esta lista.",
+    "boardOnly": "Apenas no quadro",
+    "boardOnlyHint": "São agendados e colocados em serviços, mas nunca entram. Dê um celular a eles quando precisarem do aplicativo.",
+    "boardOnlyNoAccount": "Sem conta",
+    "giveApp": "Dar o aplicativo",
+    "giveAppTitle": "Dar o aplicativo a {name}",
+    "giveAppBlurb": "Só falta um número de celular. É com ele que a pessoa vai entrar daí em diante.",
+    "giveAppKeepsHistory": "Tudo o que já está registrado para essa pessoa continua como está.",
+    "giveAppSubmit": "Criar a conta",
+    "role": "Função",
+    "inviteMemberHint": "Ela recebe um e-mail e escolhe a própria senha."
   },
   "invoice": {
     "title": "Layout da fatura",

+ 4 - 2
messages/pt-BR/share.json

@@ -71,7 +71,8 @@
     "findingSeverityUrgent": "Alta",
     "findingSeverityNeedsWork": "Média",
     "findingSeverityMonitor": "Baixa",
-    "tel": "Tel: {phone}"
+    "tel": "Tel: {phone}",
+    "customFields": "Informações adicionais"
   },
   "quote": {
     "title": "Orçamento",
@@ -125,7 +126,8 @@
       "converted": "Convertido",
       "changes_requested": "Alterações Solicitadas"
     },
-    "tel": "Tel: {phone}"
+    "tel": "Tel: {phone}",
+    "customFields": "Informações adicionais"
   },
   "inspection": {
     "title": "Inspeção Veicular",

+ 4 - 1
messages/pt-BR/workBoard.json

@@ -75,7 +75,10 @@
     "standalone": "Técnico independente",
     "deleteTitle": "Deletar Técnico",
     "deleteDescription": "Remover \"{name}\" do quadro de trabalho? Suas atribuições existentes também serão removidas.",
-    "addTechnician": "Adicionar Técnico"
+    "addTechnician": "Adicionar Técnico",
+    "standaloneOnly": "Um nome no quadro para alguém que não faz login, como um aprendiz ou terceirizado. Para um mecânico que usa o aplicativo, adicione na página Equipe.",
+    "linkedTo": "Entra como {name}",
+    "linkedManagedOnTeam": "Gerenciado na página Equipe, junto com o acesso ao aplicativo."
   },
   "jobDetail": {
     "viewDetails": "Ver Detalhes",

+ 11 - 1
messages/ru/audit.json

@@ -139,7 +139,17 @@
     "whatsapp_send": "Отправлено сообщение WhatsApp",
     "work_bay_create": "Создан пост",
     "work_bay_update": "Обновлён пост",
-    "work_bay_delete": "Удалён пост"
+    "work_bay_delete": "Удалён пост",
+    "auth_appSetupRedeemed": "Вход по коду настройки",
+    "auth_technicianCodeSignIn": "Вход по одноразовому коду",
+    "team_createAppSetupCode": "Выдан код настройки приложения",
+    "team_createTechnicianAccount": "Создана учётная запись механика",
+    "team_removeTechnicianAccess": "Механик удалён, доступ отозван",
+    "team_setMemberTechnician": "Изменён статус механика у участника",
+    "settings_whatsappUpdated": "Обновлены настройки WhatsApp",
+    "settings_whatsappDisconnected": "WhatsApp отключён",
+    "settings_whatsappNumberRegistered": "Зарегистрирован номер WhatsApp",
+    "team_giveTechnicianTheApp": "Механику с доски выдана учётная запись"
   },
   "summary": {
     "customField_create": "Создано настраиваемое поле «{name}»",

+ 3 - 1
messages/ru/navigation.json

@@ -72,7 +72,9 @@
     "vehicle": "Транспорт",
     "quote": "Предложение",
     "part": "Запчасть",
-    "inspection": "Осмотр"
+    "inspection": "Осмотр",
+    "shopActions": "Мастерская",
+    "person": "Добавить человека"
   },
   "purchaseWhiteLabel": "Купить White-Label",
   "licenseExpired": "Ваша лицензия истекла. Функции могут быть ограничены.",

+ 5 - 1
messages/ru/service.json

@@ -344,7 +344,11 @@
     "setDefaults": "Настройки по умолчанию",
     "workBay": "Пост",
     "selectWorkBay": "Выберите пост…",
-    "noWorkBay": "Без поста"
+    "noWorkBay": "Без поста",
+    "technicians": "Механики",
+    "otherTeamMembers": "Другие участники",
+    "makeTechnician": "Сделает механиком",
+    "addSomeoneNew": "Добавить человека"
   },
   "notifications": {
     "title": "Уведомления клиента",

+ 88 - 1
messages/ru/settings.json

@@ -395,7 +395,94 @@
     "editRole": "Edit role",
     "technician": "Механик",
     "technicianHint": "Может получать заказы и пользоваться приложением механика",
-    "technicianFailed": "Не удалось изменить"
+    "technicianFailed": "Не удалось изменить",
+    "setupApp": "Настроить приложение",
+    "setupAppTitle": "Настройка приложения механика",
+    "setupAppInstruction": "Попросите {name} сделать это на телефоне:",
+    "setupAppOrType": "Или продиктуйте этот код:",
+    "setupAppExpiry": "Действует один раз, истекает через {time}.",
+    "setupAppExpired": "Код истёк. Закройте и начните заново.",
+    "setupAppDone": "Готово",
+    "setupAppFailed": "Не удалось создать код настройки",
+    "setupPageTitle": "Настройка приложения механика",
+    "setupPageBody": "Откройте Torqvoice Tech на этом телефоне, нажмите «Отсканировать код настройки» и снова наведите на тот же код.",
+    "setupPageCode": "Или введите этот код в приложении",
+    "setupPageOpenApp": "Открыть Torqvoice Tech",
+    "setupPageExpiry": "Код действует один раз и истекает через десять минут после создания мастерской.",
+    "setupAppStep1": "Установить Torqvoice Tech",
+    "setupAppStep2": "Открыть его и нажать «Отсканировать код настройки»",
+    "setupAppStep3": "Навести телефон на этот код",
+    "setupAppNotCamera": "Обычная камера телефона не подойдёт. Сканировать нужно в приложении Torqvoice Tech.",
+    "addTechnician": "Добавить механика",
+    "addTechnicianHint": "Для механика, который стоит у стойки. Без почты и без ожидания.",
+    "technicianName": "Имя",
+    "technicianNamePlaceholder": "Полное имя",
+    "technicianPhone": "Номер телефона",
+    "technicianPhonePlaceholder": "Телефон, который у него с собой",
+    "addTechnicianSubmit": "Создать",
+    "addTechnicianNote": "Дальше он входит по этому номеру. Пароль запоминать не нужно, и на первом экране приложения ничего вводить не придётся.",
+    "addTechnicianDone": "{name} создан. Покажите ему код.",
+    "addTechnicianFailed": "Не удалось создать механика",
+    "setupAppCopyLink": "Скопировать ссылку для отправки",
+    "setupAppCopied": "Скопировано",
+    "revokeTechnicianTitle": "Выйти из приложения за него?",
+    "revokeTechnicianBody": "{name} сразу теряет доступ на телефоне, и все коды перестают работать. Выполненная работа и часы остаются как есть.",
+    "revokeTechnicianConfirm": "Выйти",
+    "addPerson": "Добавить",
+    "addPersonTitle": "Добавить человека в мастерскую",
+    "addPersonWho": "Этих двоих настраивают по-разному, поэтому начните с того, кто это.",
+    "choiceTechnician": "Механик",
+    "choiceTechnicianHint": "Работает с машинами и пользуется Torqvoice на телефоне. Проще всего, пока он рядом с вами, и занимает около минуты.",
+    "choiceMember": "Кто-то в офисе",
+    "choiceMemberHint": "Принимает машины, заказывает запчасти и выставляет счета, на компьютере. Вы отправляете письмо, а он сам выбирает пароль.",
+    "stepLabelWho": "Кто",
+    "stepLabelDetails": "Данные",
+    "stepLabelPhone": "Его телефон",
+    "techDetailsTitle": "Имя и номер телефона",
+    "techDetailsBlurb": "По этому номеру он и будет входить, поэтому укажите телефон, который он действительно носит. Пароля, который можно забыть, здесь нет.",
+    "techHandoffTitle": "Теперь перенесите на его телефон",
+    "techHandoffBlurb": "Этот код связывает его телефон с вашей мастерской. Он действует один раз и только десять минут, поэтому сделайте это, пока он здесь.",
+    "memberDetailsTitle": "Его адрес почты",
+    "memberDetailsBlurb": "Ему придёт письмо со ссылкой. Открыв её, он выберет свой пароль и появится в списке выше.",
+    "roleHint": "Администраторы могут менять настройки и добавлять людей. Остальные делают повседневную работу.",
+    "stepBack": "Назад",
+    "stepHandoffDone": "Он отсканировал",
+    "stepDoneTitle": "{name} готов",
+    "stepDoneBody": "Он может открыть приложение и увидеть свои заказы. В следующий раз он войдёт просто по номеру телефона, без кода.",
+    "stepInvitedTitle": "Приглашение отправлено на {email}",
+    "stepInvitedBody": "Он появится в списке, как только откроет письмо и примет приглашение. Ссылка действует семь дней.",
+    "stepDoneAnother": "Добавить ещё",
+    "clashTitle": "У {name} уже есть этот номер",
+    "clashBlurb": "Два человека не могут делить один номер, ведь по нему они и входят. Какой это случай?",
+    "clashSamePerson": "Это {name}",
+    "clashSamePersonHint": "Тот же человек вернулся или имя написано иначе. Его заказы и часы остаются при нём, а введённое имя заменит старое.",
+    "clashTakeover": "Нет, это {name}",
+    "clashTakeoverHint": "Другой человек, у которого теперь этот номер. {name} сохраняет всё сделанное, но больше не сможет войти, а новый номер вы дадите позже.",
+    "clashDifferentNumber": "Ни то ни другое, изменю номер",
+    "workshopCountry": "В какой стране ваша мастерская?",
+    "workshopCountryPlaceholder": "Выберите страну",
+    "workshopCountryHint": "Спрашиваем один раз. Дальше номера можно вводить коротко. Позже это можно изменить в <link>настройках локализации</link>.",
+    "phonePreview": "Сохранится как",
+    "stepScannedTitle": "{name} вошёл",
+    "countrySearch": "Поиск страны",
+    "countryNoMatch": "Ничего не найдено",
+    "choiceStandalone": "Только имя на доске",
+    "choiceStandaloneHint": "Ученик, подрядчик, любой, кого вы ставите в план, но кто не входит в систему. Без телефона, без почты, без учётной записи. Дать её можно позже.",
+    "standaloneDetailsTitle": "Имя и цвет",
+    "standaloneDetailsBlurb": "Это всё, что нужно. Человек появляется на доске и в заказах, и входить ему некуда.",
+    "boardColour": "Цвет на доске",
+    "stepStandaloneTitle": "{name} на доске",
+    "stepStandaloneBody": "Его сразу можно ставить в план и назначать на заказы. Если однажды понадобится приложение, добавьте ему номер отсюда.",
+    "boardOnly": "Только на доске",
+    "boardOnlyHint": "Их ставят в план и назначают на заказы, но они не входят в систему. Дайте им номер, когда понадобится приложение.",
+    "boardOnlyNoAccount": "Нет учётной записи",
+    "giveApp": "Дать приложение",
+    "giveAppTitle": "Дать {name} приложение",
+    "giveAppBlurb": "Не хватает только номера телефона. По нему он и будет входить.",
+    "giveAppKeepsHistory": "Всё, что уже записано на этого человека, остаётся на месте.",
+    "giveAppSubmit": "Создать учётную запись",
+    "role": "Роль",
+    "inviteMemberHint": "Ему придёт письмо, и он выберет пароль сам."
   },
   "invoice": {
     "title": "Счета и предложения",

+ 4 - 2
messages/ru/share.json

@@ -71,7 +71,8 @@
     "findingSeverityUrgent": "Высокий",
     "findingSeverityNeedsWork": "Средний",
     "findingSeverityMonitor": "Низкий",
-    "tel": "Тел: {phone}"
+    "tel": "Тел: {phone}",
+    "customFields": "Дополнительные сведения"
   },
   "quote": {
     "title": "Предложение",
@@ -125,7 +126,8 @@
       "converted": "Конвертировано",
       "changes_requested": "Запрошены изменения"
     },
-    "tel": "Тел: {phone}"
+    "tel": "Тел: {phone}",
+    "customFields": "Дополнительные сведения"
   },
   "inspection": {
     "title": "Осмотр транспортного средства",

+ 4 - 1
messages/ru/workBoard.json

@@ -75,7 +75,10 @@
     "standalone": "Независимый механик",
     "deleteTitle": "Удалить механика",
     "deleteDescription": "Удалить \"{name}\" с доски работ? Все существующие назначения также будут удалены.",
-    "addTechnician": "Добавить механика"
+    "addTechnician": "Добавить механика",
+    "standaloneOnly": "Имя на доске для того, кто не входит в систему: ученика или подрядчика. Механика с приложением добавьте на странице «Команда».",
+    "linkedTo": "Входит как {name}",
+    "linkedManagedOnTeam": "Управляется на странице «Команда» вместе с доступом к приложению."
   },
   "jobDetail": {
     "viewDetails": "Просмотреть детали",

+ 11 - 1
messages/tr/audit.json

@@ -139,7 +139,17 @@
     "whatsapp_send": "WhatsApp mesajı gönderildi",
     "work_bay_create": "Çalışma alanı oluşturuldu",
     "work_bay_update": "Çalışma alanı güncellendi",
-    "work_bay_delete": "Çalışma alanı silindi"
+    "work_bay_delete": "Çalışma alanı silindi",
+    "auth_appSetupRedeemed": "Kurulum koduyla giriş",
+    "auth_technicianCodeSignIn": "Tek kullanımlık kodla giriş",
+    "team_createAppSetupCode": "Uygulama kurulum kodu verildi",
+    "team_createTechnicianAccount": "Teknisyen hesabı oluşturuldu",
+    "team_removeTechnicianAccess": "Teknisyen kaldırıldı ve erişimi iptal edildi",
+    "team_setMemberTechnician": "Bir üyenin teknisyen olup olmadığı değiştirildi",
+    "settings_whatsappUpdated": "WhatsApp ayarları güncellendi",
+    "settings_whatsappDisconnected": "WhatsApp bağlantısı kesildi",
+    "settings_whatsappNumberRegistered": "WhatsApp numarası kaydedildi",
+    "team_giveTechnicianTheApp": "Pano teknisyenine hesap verildi"
   },
   "summary": {
     "customField_create": "\"{name}\" özel alanı oluşturuldu",

+ 3 - 1
messages/tr/navigation.json

@@ -72,7 +72,9 @@
     "vehicle": "Araç",
     "quote": "Teklif",
     "part": "Parça",
-    "inspection": "Muayene"
+    "inspection": "Muayene",
+    "shopActions": "Servis",
+    "person": "Kişi ekle"
   },
   "purchaseWhiteLabel": "Beyaz Etiket Satın Al",
   "licenseExpired": "Lisansınız sona erdi. Özellikler sınırlı olabilir.",

+ 5 - 1
messages/tr/service.json

@@ -344,7 +344,11 @@
     "setDefaults": "Varsayılanlar",
     "workBay": "Çalışma alanı",
     "selectWorkBay": "Çalışma alanı seçin…",
-    "noWorkBay": "Çalışma alanı yok"
+    "noWorkBay": "Çalışma alanı yok",
+    "technicians": "Teknisyenler",
+    "otherTeamMembers": "Diğer ekip üyeleri",
+    "makeTechnician": "Onu teknisyen yapar",
+    "addSomeoneNew": "Yeni birini ekle"
   },
   "notifications": {
     "title": "Müşteri bildirimleri",

+ 88 - 1
messages/tr/settings.json

@@ -395,7 +395,94 @@
     "editRole": "Edit role",
     "technician": "Teknisyen",
     "technicianHint": "İş atanabilir ve teknisyen uygulamasını kullanabilir",
-    "technicianFailed": "Değiştirilemedi"
+    "technicianFailed": "Değiştirilemedi",
+    "setupApp": "Uygulamayı kur",
+    "setupAppTitle": "Teknisyen uygulamasını kur",
+    "setupAppInstruction": "{name} kişisinden telefonunda şunu yapmasını isteyin:",
+    "setupAppOrType": "Ya da bu kodu okuyun:",
+    "setupAppExpiry": "Bir kez geçerlidir, {time} sonra sona erer.",
+    "setupAppExpired": "Bu kodun süresi doldu. Kapatıp yeniden başlayın.",
+    "setupAppDone": "Tamam",
+    "setupAppFailed": "Kurulum kodu oluşturulamadı",
+    "setupPageTitle": "Teknisyen uygulamasını kur",
+    "setupPageBody": "Bu telefonda Torqvoice Tech uygulamasını açın, Kurulum kodunu tara seçeneğine dokunun ve aynı koda tekrar doğrultun.",
+    "setupPageCode": "Ya da bu kodu uygulamaya yazın",
+    "setupPageOpenApp": "Torqvoice Tech'i aç",
+    "setupPageExpiry": "Bu kod bir kez geçerlidir ve servisiniz oluşturduktan on dakika sonra sona erer.",
+    "setupAppStep1": "Torqvoice Tech'i kurmak",
+    "setupAppStep2": "Uygulamayı açıp “Kurulum kodunu tara”ya dokunmak",
+    "setupAppStep3": "Telefonu bu koda doğrultmak",
+    "setupAppNotCamera": "Telefonun normal kamera uygulaması işe yaramaz. Torqvoice Tech içinden taranmalıdır.",
+    "addTechnician": "Teknisyen ekle",
+    "addTechnicianHint": "Bankoda duran bir tamirci için. E-posta yok, bekleme yok.",
+    "technicianName": "Ad",
+    "technicianNamePlaceholder": "Tam adı",
+    "technicianPhone": "Cep numarası",
+    "technicianPhonePlaceholder": "Cebindeki telefon",
+    "addTechnicianSubmit": "Oluştur",
+    "addTechnicianNote": "Bundan sonra bu numarayla giriş yapar. Hatırlanacak parola yok ve uygulamanın ilk ekranında yazılacak bir şey yok.",
+    "addTechnicianDone": "{name} hazır. Kodu gösterin.",
+    "addTechnicianFailed": "Teknisyen oluşturulamadı",
+    "setupAppCopyLink": "Göndermek için bağlantıyı kopyala",
+    "setupAppCopied": "Kopyalandı",
+    "revokeTechnicianTitle": "Uygulamadan çıkışı yapılsın mı?",
+    "revokeTechnicianBody": "{name} telefonundaki erişimi hemen kaybeder ve tüm kodlar geçersiz olur. Tamamlanan işler ve saatler olduğu gibi kalır.",
+    "revokeTechnicianConfirm": "Çıkışı yap",
+    "addPerson": "Ekle",
+    "addPersonTitle": "Servise birini ekle",
+    "addPersonWho": "Bu ikisi farklı şekilde kurulur, o yüzden önce kim olduğunu söyleyin.",
+    "choiceTechnician": "Bir tamirci",
+    "choiceTechnicianHint": "Arabalarda çalışır ve telefonunda Torqvoice kullanır. En kolayı o yanınızdayken yapmaktır, yaklaşık bir dakika sürer.",
+    "choiceMember": "Ofisten biri",
+    "choiceMemberHint": "Araçları kabul eder, parça sipariş eder ve fatura gönderir, bilgisayarda. Siz e-posta gönderirsiniz, o kendi parolasını seçer.",
+    "stepLabelWho": "Kim",
+    "stepLabelDetails": "Bilgiler",
+    "stepLabelPhone": "Telefonu",
+    "techDetailsTitle": "Ad ve cep numarası",
+    "techDetailsBlurb": "Bundan sonra bu numarayla giriş yapacak, o yüzden gerçekten yanında taşıdığı telefonu yazın. Hatırlanacak ya da unutulacak bir parola yok.",
+    "techHandoffTitle": "Şimdi telefonuna aktarın",
+    "techHandoffBlurb": "Bu kod telefonunu servisinize bağlar. Bir kez ve sadece on dakika geçerlidir, bu yüzden o buradayken yapın.",
+    "memberDetailsTitle": "E-posta adresi",
+    "memberDetailsBlurb": "Bağlantı içeren bir e-posta alır. Açtığında kendi parolasını seçer ve sonra yukarıdaki listede görünür.",
+    "roleHint": "Yöneticiler ayarları değiştirebilir ve kişi ekleyebilir. Diğerleri günlük işi yapar.",
+    "stepBack": "Geri",
+    "stepHandoffDone": "Taradı",
+    "stepDoneTitle": "{name} hazır",
+    "stepDoneBody": "Uygulamayı açıp işlerini görebilir. Bir dahaki sefere sadece cep numarasıyla, koda gerek kalmadan girer.",
+    "stepInvitedTitle": "{email} adresine davet gönderildi",
+    "stepInvitedBody": "E-postayı açıp kabul ettiğinde listede görünür. Bağlantı yedi gün geçerlidir.",
+    "stepDoneAnother": "Başka birini ekle",
+    "clashTitle": "{name} bu numaraya zaten sahip",
+    "clashBlurb": "İki kişi bir cep numarasını paylaşamaz, çünkü giriş bununla yapılır. Hangisi geçerli?",
+    "clashSamePerson": "Bu {name}",
+    "clashSamePersonHint": "Aynı kişi geri dönüyor ya da adı farklı yazılmış. İşleri ve saatleri kendisinde kalır, az önce yazdığınız ad eskisinin yerini alır.",
+    "clashTakeover": "Hayır, bu {name}",
+    "clashTakeoverHint": "Bu numaraya artık sahip olan başka biri. {name} burada yaptığı her şeyi korur ama artık giriş yapamaz, sonra yeni bir numara verebilirsiniz.",
+    "clashDifferentNumber": "Hiçbiri, numarayı değiştireyim",
+    "workshopCountry": "Servisiniz hangi ülkede?",
+    "workshopCountryPlaceholder": "Bir ülke seçin",
+    "workshopCountryHint": "Sadece bir kez sorulur. Sonrasında cep numaraları kısa şekilde yazılabilir. Daha sonra <link>Yerelleştirme ayarları</link> bölümünden değiştirebilirsiniz.",
+    "phonePreview": "Şöyle kaydedilir",
+    "stepScannedTitle": "{name} girdi",
+    "countrySearch": "Ülke ara",
+    "countryNoMatch": "Eşleşen ülke yok",
+    "choiceStandalone": "Sadece pano için bir isim",
+    "choiceStandaloneHint": "Bir çırak, bir taşeron, planladığınız ama hiç giriş yapmayan herkes. Telefon yok, e-posta yok, hesap yok. Sonra verebilirsiniz.",
+    "standaloneDetailsTitle": "Adı ve bir renk",
+    "standaloneDetailsBlurb": "Aldığı ve ihtiyaç duyduğu tek şey bu. Panoda ve işlerde görünür, giriş yapacağı bir yer yoktur.",
+    "boardColour": "Panodaki renk",
+    "stepStandaloneTitle": "{name} panoda",
+    "stepStandaloneBody": "Onu hemen planlayabilir ve işlere atayabilirsiniz. İleride uygulamaya ihtiyaç duyarsa, bu listeden bir cep numarası ekleyin.",
+    "boardOnly": "Sadece panoda",
+    "boardOnlyHint": "Planlanır ve işlere atanır ama hiç giriş yapmazlar. Uygulamaya ihtiyaç duyduklarında bir cep numarası verin.",
+    "boardOnlyNoAccount": "Hesap yok",
+    "giveApp": "Uygulamayı ver",
+    "giveAppTitle": "{name} kişisine uygulamayı ver",
+    "giveAppBlurb": "Eksik olan tek şey bir cep numarası. Bundan sonra onunla giriş yapar.",
+    "giveAppKeepsHistory": "Onun adına kayıtlı olan her şey olduğu gibi kalır.",
+    "giveAppSubmit": "Hesabını oluştur",
+    "role": "Rol",
+    "inviteMemberHint": "Bir e-posta alır ve kendi parolasını seçer."
   },
   "invoice": {
     "title": "Fatura Düzeni",

+ 4 - 2
messages/tr/share.json

@@ -71,7 +71,8 @@
     "findingSeverityUrgent": "Yüksek",
     "findingSeverityNeedsWork": "Orta",
     "findingSeverityMonitor": "Düşük",
-    "tel": "Tel: {phone}"
+    "tel": "Tel: {phone}",
+    "customFields": "Ek bilgiler"
   },
   "quote": {
     "title": "Teklif",
@@ -125,7 +126,8 @@
       "converted": "Dönüştürüldü",
       "changes_requested": "Değişiklik Talep Edildi"
     },
-    "tel": "Tel: {phone}"
+    "tel": "Tel: {phone}",
+    "customFields": "Ek bilgiler"
   },
   "inspection": {
     "title": "Araç Muayenesi",

+ 4 - 1
messages/tr/workBoard.json

@@ -75,7 +75,10 @@
     "standalone": "Bağımsız teknisyen",
     "deleteTitle": "Teknisyeni Sil",
     "deleteDescription": "\"{name}\" öğesini iş panosundan kaldırılsın mı? Mevcut atamaları da kaldırılacaktır.",
-    "addTechnician": "Teknisyen Ekle"
+    "addTechnician": "Teknisyen Ekle",
+    "standaloneOnly": "Panoda giriş yapmayan biri için bir isim, örneğin çırak ya da taşeron. Uygulamayı kullanan bir tamirciyi Ekip sayfasından ekleyin.",
+    "linkedTo": "{name} olarak giriş yapar",
+    "linkedManagedOnTeam": "Uygulama erişimiyle birlikte Ekip sayfasından yönetilir."
   },
   "jobDetail": {
     "viewDetails": "Ayrıntıları Görüntüle",

+ 28 - 0
prisma/migrations/20260829074551_technician_setup_codes/migration.sql

@@ -0,0 +1,28 @@
+-- CreateTable
+CREATE TABLE "technician_setup_codes" (
+    "id" TEXT NOT NULL,
+    "codeHash" TEXT NOT NULL,
+    "expiresAt" TIMESTAMP(3) NOT NULL,
+    "usedAt" TIMESTAMP(3),
+    "userId" TEXT NOT NULL,
+    "organizationId" TEXT NOT NULL,
+    "issuedByUserId" TEXT NOT NULL,
+    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+    CONSTRAINT "technician_setup_codes_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "technician_setup_codes_codeHash_key" ON "technician_setup_codes"("codeHash");
+
+-- CreateIndex
+CREATE INDEX "technician_setup_codes_organizationId_expiresAt_idx" ON "technician_setup_codes"("organizationId", "expiresAt");
+
+-- CreateIndex
+CREATE INDEX "technician_setup_codes_userId_idx" ON "technician_setup_codes"("userId");
+
+-- AddForeignKey
+ALTER TABLE "technician_setup_codes" ADD CONSTRAINT "technician_setup_codes_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "technician_setup_codes" ADD CONSTRAINT "technician_setup_codes_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;

+ 32 - 0
prisma/migrations/20260829085021_technician_phone_login/migration.sql

@@ -0,0 +1,32 @@
+-- AlterTable
+ALTER TABLE "technicians" ADD COLUMN     "phone" TEXT;
+
+-- CreateTable
+CREATE TABLE "technician_login_codes" (
+    "id" TEXT NOT NULL,
+    "codeHash" TEXT NOT NULL,
+    "channel" TEXT NOT NULL,
+    "attempts" INTEGER NOT NULL DEFAULT 0,
+    "expiresAt" TIMESTAMP(3) NOT NULL,
+    "usedAt" TIMESTAMP(3),
+    "technicianId" TEXT NOT NULL,
+    "organizationId" TEXT NOT NULL,
+    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+    CONSTRAINT "technician_login_codes_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "technician_login_codes_organizationId_technicianId_idx" ON "technician_login_codes"("organizationId", "technicianId");
+
+-- CreateIndex
+CREATE INDEX "technician_login_codes_expiresAt_idx" ON "technician_login_codes"("expiresAt");
+
+-- CreateIndex
+CREATE INDEX "technicians_organizationId_phone_idx" ON "technicians"("organizationId", "phone");
+
+-- AddForeignKey
+ALTER TABLE "technician_login_codes" ADD CONSTRAINT "technician_login_codes_technicianId_fkey" FOREIGN KEY ("technicianId") REFERENCES "technicians"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "technician_login_codes" ADD CONSTRAINT "technician_login_codes_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;

+ 14 - 0
prisma/migrations/20260829091507_user_phone/migration.sql

@@ -0,0 +1,14 @@
+/*
+  Warnings:
+
+  - You are about to drop the column `phone` on the `technicians` table. All the data in the column will be lost.
+
+*/
+-- DropIndex
+DROP INDEX "technicians_organizationId_phone_idx";
+
+-- AlterTable
+ALTER TABLE "technicians" DROP COLUMN "phone";
+
+-- AlterTable
+ALTER TABLE "users" ADD COLUMN     "phone" TEXT;

+ 2 - 0
prisma/migrations/20260829122700_customer_code_attempts/migration.sql

@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "customer_sms_codes" ADD COLUMN     "attempts" INTEGER NOT NULL DEFAULT 0;

+ 105 - 2
prisma/schema.prisma

@@ -12,6 +12,15 @@ model User {
   name          String
   email         String   @unique
   emailVerified Boolean  @default(false)
+  /// The mobile a technician signs in with, and the number a workshop holds
+  /// for whoever this is.
+  ///
+  /// Deliberately not unique. When a desk creates an account for a mechanic
+  /// standing at the counter, a second workshop may create another for the
+  /// same human, and neither should be able to block the other. Every lookup
+  /// reaches it through a technician row and so is already scoped to one
+  /// workshop; the number alone is never the question being asked.
+  phone         String?
   image         String?
   isSuperAdmin  Boolean  @default(false)
   createdAt     DateTime @default(now())
@@ -31,6 +40,7 @@ model User {
   sessions               Session[]
   accounts               Account[]
   pushDevices            PushDevice[]
+  appSetupCodes          TechnicianSetupCode[]
   vehicles               Vehicle[]
   customers              Customer[]
   settings               AppSetting[]
@@ -860,6 +870,8 @@ model Organization {
   workBays               WorkBay[]
   timeEntries            TimeEntry[]
   pushDevices            PushDevice[]
+  appSetupCodes          TechnicianSetupCode[]
+  technicianLoginCodes   TechnicianLoginCode[]
   auditLogs              AuditLog[]
   statusReports          StatusReport[]
   reportSchedules        ReportSchedule[]
@@ -1372,6 +1384,7 @@ model Technician {
   organizationId String
   organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
 
+  loginCodes     TechnicianLoginCode[]
   serviceRecords ServiceRecord[]
   inspections    Inspection[]
   statusReports  StatusReport[]
@@ -1449,6 +1462,83 @@ model TimeEntry {
 /// The token is a routing address, not a secret, but it is still deleted on
 /// sign-out: a device that has signed out must stop receiving a workshop's job
 /// details, and expiry alone would leave a window where it still did.
+/// A one-time code that puts a technician's phone onto a workshop.
+///
+/// Typing a URL and a password is the worst moment in the app, and it lands on
+/// the person least equipped for it: a technician with dirty hands, on a
+/// phone, being watched. The desk shows a QR instead and the phone reads it.
+///
+/// It is a bearer credential in a picture, so it is deliberately short-lived,
+/// single-use, and bound to one person in one workshop. It exchanges for a
+/// normal session and is then dead; it is never itself a way to authenticate.
+/// A one-time code that signs a technician back in.
+///
+/// Modelled on CustomerSmsCode, which does the same job for the customer
+/// portal: org-scoped, single use, short-lived. The organisation is not
+/// optional and is never inferred from the phone number. A technician's phone
+/// is only ever looked up inside the workshop their app already belongs to,
+/// so nothing here can reach across workshops even if a person works at two.
+model TechnicianLoginCode {
+  id String @id @default(cuid())
+
+  /// SHA-256 of the digits. The code exists in a text message and in the
+  /// technician's head, and nowhere that can be read back out of a database.
+  codeHash String
+
+  /// How it was sent, so the app can say "check your messages" or "check your
+  /// email" rather than guessing on the technician's behalf.
+  channel String
+
+  /// Wrong guesses so far. Six digits is a million, which a rate limiter alone
+  /// does not protect: this burns the code after a handful of attempts.
+  attempts Int @default(0)
+
+  expiresAt DateTime
+  usedAt    DateTime?
+
+  technicianId String
+  technician   Technician @relation(fields: [technicianId], references: [id], onDelete: Cascade)
+
+  organizationId String
+  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+
+  createdAt DateTime @default(now())
+
+  @@index([organizationId, technicianId])
+  @@index([expiresAt])
+  @@map("technician_login_codes")
+}
+
+model TechnicianSetupCode {
+  id String @id @default(cuid())
+
+  /// SHA-256 of the code. The code itself is shown once, on the screen that
+  /// asked for it, and is never written down anywhere it could be read back.
+  codeHash String @unique
+
+  expiresAt DateTime
+  /// Set on redemption. Kept rather than deleted so a second scan can say the
+  /// code was already used, which is a different problem from a wrong code.
+  usedAt    DateTime?
+
+  /// Who the code signs in.
+  userId String
+  user   User   @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+  organizationId String
+  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+
+  /// Who issued it, for the audit trail. Not a relation: the issuer may be
+  /// removed from the workshop later and that must not delete the record.
+  issuedByUserId String
+
+  createdAt DateTime @default(now())
+
+  @@index([organizationId, expiresAt])
+  @@index([userId])
+  @@map("technician_setup_codes")
+}
+
 model PushDevice {
   id String @id @default(cuid())
 
@@ -1562,8 +1652,21 @@ model CustomerMagicLink {
 }
 
 model CustomerSmsCode {
-  id             String    @id @default(cuid())
-  code           String
+  id String @id @default(cuid())
+
+  /// SHA-256 of the digits, not the digits.
+  ///
+  /// The code reaches the customer in a text message and lives in their head
+  /// for a minute. It has no business also sitting in a table where reading a
+  /// row hands over somebody's portal session.
+  code String
+
+  /// Wrong guesses so far. Six digits is a million, which sounds like plenty
+  /// and is not: limiting by address does nothing about a hundred machines
+  /// sharing the work. Five attempts against one code makes the space
+  /// irrelevant, because the code is gone long before the guessing arrives.
+  attempts Int @default(0)
+
   phone          String
   organizationId String
   expiresAt      DateTime

+ 132 - 0
src/__tests__/api/portal-sms-attempts.test.ts

@@ -0,0 +1,132 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+/**
+ * Guessing a customer's portal code.
+ *
+ * Six digits is a million, which sounds like plenty until a hundred machines
+ * share the work between them and the only defence is a limit keyed on the
+ * address each of them is using. What actually protects it is the code dying
+ * after a handful of wrong guesses, which is what these cover.
+ */
+
+vi.mock('@/lib/db', () => ({
+  db: {
+    customerSmsCode: { findFirst: vi.fn(), update: vi.fn() },
+    appSetting: { findUnique: vi.fn() },
+    customer: { findFirst: vi.fn(), findMany: vi.fn() },
+    customerSession: { create: vi.fn() },
+  },
+}))
+vi.mock('@/lib/rate-limit', () => ({ rateLimit: vi.fn(() => null) }))
+vi.mock('@/lib/portal-slug', () => ({ resolvePortalOrg: vi.fn() }))
+vi.mock('next/headers', () => ({ cookies: vi.fn(async () => ({ set: vi.fn() })) }))
+
+import { db } from '@/lib/db'
+import { resolvePortalOrg } from '@/lib/portal-slug'
+import { POST } from '@/app/api/public/portal/[orgId]/auth/sms-verify/route'
+import {
+  generatePortalCode,
+  hashPortalCode,
+  portalCodeMatches,
+  PORTAL_CODE_MAX_ATTEMPTS,
+} from '@/lib/portal-code'
+
+const ORG = 'org-a'
+const CODE = '123456'
+
+function verify(body: unknown) {
+  return POST(
+    new Request(`https://shop.example.com/api/public/portal/${ORG}/auth/sms-verify`, {
+      method: 'POST',
+      body: JSON.stringify(body),
+    }),
+    { params: Promise.resolve({ orgId: ORG }) }
+  )
+}
+
+const live = (overrides: Record<string, unknown> = {}) => ({
+  id: 'code-1',
+  code: hashPortalCode(CODE),
+  attempts: 0,
+  phone: '+4791234567',
+  organizationId: ORG,
+  expiresAt: new Date(Date.now() + 60_000),
+  usedAt: null,
+  ...overrides,
+})
+
+beforeEach(() => {
+  vi.clearAllMocks()
+  vi.mocked(resolvePortalOrg).mockResolvedValue({ id: ORG, name: 'Shop' } as never)
+  vi.mocked(db.appSetting.findUnique).mockResolvedValue({ value: '+47' } as never)
+  vi.mocked(db.customerSmsCode.update).mockResolvedValue({ attempts: 1 } as never)
+})
+
+describe('a wrong portal code', () => {
+  it('is charged to that code, not merely to the address', async () => {
+    vi.mocked(db.customerSmsCode.findFirst).mockResolvedValue(live() as never)
+
+    const res = await verify({ phone: '+4791234567', code: '999999' })
+
+    expect(res.status).toBe(400)
+    expect(db.customerSmsCode.update).toHaveBeenCalledWith(
+      expect.objectContaining({ where: { id: 'code-1' }, data: { attempts: { increment: 1 } } })
+    )
+  })
+
+  it('spends the code once the guesses run out', async () => {
+    vi.mocked(db.customerSmsCode.findFirst).mockResolvedValue(live() as never)
+    vi.mocked(db.customerSmsCode.update).mockResolvedValue({
+      attempts: PORTAL_CODE_MAX_ATTEMPTS,
+    } as never)
+
+    await verify({ phone: '+4791234567', code: '999999' })
+
+    // Marked used, so waiting out the rate limiter does not buy five more.
+    expect(db.customerSmsCode.update).toHaveBeenCalledWith(
+      expect.objectContaining({ data: { usedAt: expect.any(Date) } })
+    )
+  })
+
+  it('refuses a code that has already run out, without another guess', async () => {
+    vi.mocked(db.customerSmsCode.findFirst).mockResolvedValue(
+      live({ attempts: PORTAL_CODE_MAX_ATTEMPTS }) as never
+    )
+
+    const res = await verify({ phone: '+4791234567', code: CODE })
+
+    expect(res.status).toBe(400)
+    expect(db.customerSmsCode.update).not.toHaveBeenCalled()
+  })
+
+  it('looks the row up by phone and workshop, never by the digits', async () => {
+    vi.mocked(db.customerSmsCode.findFirst).mockResolvedValue(live() as never)
+    await verify({ phone: '+4791234567', code: '999999' })
+
+    const where = vi.mocked(db.customerSmsCode.findFirst).mock.calls[0]?.[0]?.where
+    expect(where).toEqual(expect.objectContaining({ organizationId: ORG, usedAt: null }))
+    // Matching on the code made a miss belong to nobody, so nothing counted it.
+    expect(JSON.stringify(where)).not.toContain('999999')
+  })
+})
+
+describe('portal codes at rest', () => {
+  it('are stored as a hash, not as the digits', () => {
+    const code = generatePortalCode()
+    const stored = hashPortalCode(code)
+    expect(stored).toMatch(/^[a-f0-9]{64}$/)
+    expect(stored).not.toContain(code)
+  })
+
+  it('still match the code the customer types', () => {
+    const code = generatePortalCode()
+    expect(portalCodeMatches(hashPortalCode(code), code)).toBe(true)
+    expect(portalCodeMatches(hashPortalCode(code), '000000')).toBe(false)
+  })
+
+  it('are six digits, from a real source of randomness', () => {
+    const seen = new Set(Array.from({ length: 500 }, () => generatePortalCode()))
+    expect(seen.size).toBeGreaterThan(450)
+    for (const c of seen) expect(c).toMatch(/^\d{6}$/)
+  })
+})

+ 401 - 0
src/__tests__/api/tech-phone-login.test.ts

@@ -0,0 +1,401 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+/**
+ * Signing a technician back in with a code. Everything here is scoped to one
+ * workshop, and the tests that matter are the ones proving it cannot reach
+ * past that.
+ */
+
+vi.mock('@/lib/auth', () => ({
+  auth: { $context: Promise.resolve({ internalAdapter: { createSession: vi.fn() } }) },
+}))
+
+vi.mock('@/lib/db', () => ({
+  db: {
+    organization: { findUnique: vi.fn() },
+    technician: { findFirst: vi.fn(), findMany: vi.fn() },
+    technicianLoginCode: {
+      findFirst: vi.fn(),
+      create: vi.fn(),
+      delete: vi.fn(),
+      deleteMany: vi.fn(),
+      update: vi.fn(),
+      updateMany: vi.fn(),
+    },
+    organizationMember: { findFirst: vi.fn() },
+  },
+}))
+
+vi.mock('@/lib/sms', () => ({
+  sendOrgSms: vi.fn(),
+  getOrgSmsProvider: vi.fn(),
+  normalizeOrgPhone: vi.fn(),
+}))
+vi.mock('@/lib/email', () => ({ sendOrgMail: vi.fn(), getOrgFromAddress: vi.fn() }))
+vi.mock('@/lib/audit', () => ({ logAudit: vi.fn().mockResolvedValue(undefined) }))
+vi.mock('@/lib/rate-limit', () => ({ rateLimit: vi.fn(() => null) }))
+
+import { auth } from '@/lib/auth'
+import { db } from '@/lib/db'
+import { normalizeOrgPhone, sendOrgSms } from '@/lib/sms'
+import { sendOrgMail } from '@/lib/email'
+import { rateLimit } from '@/lib/rate-limit'
+import { POST as requestCode } from '@/app/api/v1/tech/org/[orgId]/auth/request/route'
+import { POST as verifyCode } from '@/app/api/v1/tech/org/[orgId]/auth/verify/route'
+import { hashLoginCode, MAX_ATTEMPTS } from '@/features/technician-auth/Lib/loginCode'
+
+const createSession = vi.mocked(
+  (await auth.$context).internalAdapter.createSession as ReturnType<typeof vi.fn>
+)
+const ORG = 'org-a'
+const OTHER_ORG = 'org-b'
+const CODE = '123456'
+
+function call(handler: typeof requestCode, orgId: string, body: unknown) {
+  return handler(
+    new Request(`https://shop.example.com/api/v1/tech/org/${orgId}/auth/request`, {
+      method: 'POST',
+      body: JSON.stringify(body),
+    }),
+    { params: Promise.resolve({ orgId }) }
+  )
+}
+
+beforeEach(() => {
+  vi.clearAllMocks()
+  vi.mocked(rateLimit).mockReturnValue(null)
+  vi.mocked(db.organization.findUnique).mockResolvedValue({ id: ORG, name: 'Bay Street' } as never)
+  vi.mocked(db.technicianLoginCode.deleteMany).mockResolvedValue({ count: 0 } as never)
+  vi.mocked(db.technicianLoginCode.updateMany).mockResolvedValue({ count: 1 } as never)
+  vi.mocked(db.technicianLoginCode.create).mockResolvedValue({} as never)
+  vi.mocked(db.organizationMember.findFirst).mockResolvedValue({ id: 'mem-1' } as never)
+  createSession.mockResolvedValue({ token: 'session-token' })
+  vi.mocked(normalizeOrgPhone).mockImplementation(async (_org, phone) =>
+    phone.replace(/\s/g, '').startsWith('+')
+      ? phone.replace(/\s/g, '')
+      : `+47${phone.replace(/\D/g, '')}`
+  )
+})
+
+describe('requesting a code', () => {
+  it('sends one to a technician of this workshop', async () => {
+    vi.mocked(db.technician.findMany).mockResolvedValue([
+      { id: 'tech-1', user: { email: 'a@x.test', phone: '+4791234567' } },
+    ] as never)
+
+    const res = await call(requestCode, ORG, { phone: '+47 912 34 567' })
+
+    expect(res.status).toBe(200)
+    expect(sendOrgSms).toHaveBeenCalledWith(ORG, expect.objectContaining({ to: '+47 912 34 567' }))
+    expect(db.technicianLoginCode.create).toHaveBeenCalled()
+  })
+
+  it('matches a number however either side wrote it', async () => {
+    // Desk stored local digits, technician typed full international.
+    vi.mocked(db.technician.findMany).mockResolvedValue([
+      { id: 'tech-1', user: { email: 'a@x.test', phone: '912 34 567' } },
+    ] as never)
+
+    await call(requestCode, ORG, { phone: '+4791234567' })
+    expect(db.technicianLoginCode.create).toHaveBeenCalled()
+  })
+
+  it('answers a match and a miss with the same bytes', async () => {
+    // The whole point. This response used to carry channel:null on a miss and
+    // channel:'sms' on a match, which made the endpoint a way of asking a
+    // workshop whether it employs a given phone number.
+    vi.mocked(db.technician.findMany).mockResolvedValue([
+      { id: 'tech-1', user: { email: 'a@x.test', phone: '+4791234567' } },
+    ] as never)
+    const hit = await (await call(requestCode, ORG, { phone: '+4791234567' })).json()
+
+    vi.mocked(db.technician.findMany).mockResolvedValue([] as never)
+    const miss = await (await call(requestCode, ORG, { phone: '+4700000000' })).json()
+
+    expect(hit).toEqual(miss)
+    expect(hit).toEqual({ data: { sent: true, channel: 'sms' } })
+  })
+
+  it('does no work on a miss, however identical the answer', async () => {
+    vi.mocked(db.technician.findMany).mockResolvedValue([] as never)
+
+    await call(requestCode, ORG, { phone: '+4700000000' })
+    expect(sendOrgSms).not.toHaveBeenCalled()
+    expect(db.technicianLoginCode.create).not.toHaveBeenCalled()
+  })
+
+  it('answers the same for a workshop that does not exist', async () => {
+    vi.mocked(db.organization.findUnique).mockResolvedValue(null as never)
+
+    const res = await call(requestCode, 'made-up', { phone: '+4791234567' })
+    expect(await res.json()).toEqual({ data: { sent: true, channel: 'sms' } })
+  })
+
+  it('answers a junk body the same way too', async () => {
+    const res = await call(requestCode, ORG, { nothing: 'useful' })
+    expect(await res.json()).toEqual({ data: { sent: true, channel: 'sms' } })
+    expect(db.technicianLoginCode.create).not.toHaveBeenCalled()
+  })
+
+  it('does not wait on the provider before answering', async () => {
+    // Awaiting delivery makes a match measurably slower than a miss, which
+    // gives back on the clock what the body refuses to say.
+    vi.mocked(db.technician.findMany).mockResolvedValue([
+      { id: 'tech-1', user: { email: 'a@x.test', phone: '+4791234567' } },
+    ] as never)
+    let released: (() => void) | undefined
+    vi.mocked(sendOrgSms).mockReturnValue(
+      new Promise((resolve) => {
+        released = () => resolve({ providerMsgId: 'm', to: '+4791234567' })
+      }) as never
+    )
+
+    const res = await call(requestCode, ORG, { phone: '+4791234567' })
+    expect(res.status).toBe(200)
+    released?.()
+  })
+
+  it('never looks a technician up outside the workshop in the path', async () => {
+    vi.mocked(db.technician.findMany).mockResolvedValue([] as never)
+    await call(requestCode, ORG, { phone: '+4791234567' })
+
+    const where = vi.mocked(db.technician.findMany).mock.calls[0]?.[0]?.where
+    // The number lives on the user, so the workshop scoping has to come from
+    // this query. If it ever stops doing so, a phone becomes a global lookup.
+    expect(where).toEqual(expect.objectContaining({ organizationId: ORG, isActive: true }))
+  })
+
+  it('will not send to somebody who has been deactivated', async () => {
+    // isActive is part of the query, so a deactivated technician is simply
+    // never a candidate.
+    vi.mocked(db.technician.findMany).mockResolvedValue([] as never)
+    await call(requestCode, ORG, { phone: '+4791234567' })
+    expect(sendOrgSms).not.toHaveBeenCalled()
+  })
+
+  it('sends by email when asked by email', async () => {
+    vi.mocked(db.technician.findFirst).mockResolvedValue({
+      id: 'tech-1',
+      user: { email: 'a@x.test' },
+    } as never)
+
+    const res = await call(requestCode, ORG, { email: 'A@X.test' })
+
+    // Echoes what was asked for, not what was found.
+    expect((await res.json()).data.channel).toBe('email')
+    expect(sendOrgMail).toHaveBeenCalledWith(ORG, expect.objectContaining({ to: 'a@x.test' }))
+    expect(sendOrgSms).not.toHaveBeenCalled()
+  })
+
+  it('replaces an outstanding code rather than adding one', async () => {
+    vi.mocked(db.technician.findMany).mockResolvedValue([
+      { id: 'tech-1', user: { email: 'a@x.test', phone: '+4791234567' } },
+    ] as never)
+
+    await call(requestCode, ORG, { phone: '+4791234567' })
+
+    expect(db.technicianLoginCode.deleteMany).toHaveBeenCalledWith({
+      where: { technicianId: 'tech-1', usedAt: null },
+    })
+  })
+
+  it('still answers success when delivery throws', async () => {
+    vi.mocked(db.technician.findMany).mockResolvedValue([
+      { id: 'tech-1', user: { email: 'a@x.test', phone: '+4791234567' } },
+    ] as never)
+    vi.mocked(sendOrgSms).mockRejectedValue(new Error('provider down'))
+
+    const res = await call(requestCode, ORG, { phone: '+4791234567' })
+    expect(res.status).toBe(200)
+  })
+})
+
+describe('verifying a code', () => {
+  const live = (overrides: Record<string, unknown> = {}) => ({
+    id: 'code-1',
+    codeHash: hashLoginCode(CODE),
+    expiresAt: new Date(Date.now() + 60_000),
+    attempts: 0,
+    technician: { id: 'tech-1', isActive: true, userId: 'user-1', organizationId: ORG },
+    ...overrides,
+  })
+
+  /** Whoever the caller claims to be resolves to this technician. */
+  function claiming(technicianId: string | null = 'tech-1') {
+    vi.mocked(db.technician.findMany).mockResolvedValue(
+      (technicianId ? [{ id: technicianId, user: { phone: '+4791234567' } }] : []) as never
+    )
+  }
+
+  beforeEach(() => {
+    claiming()
+    vi.mocked(db.technicianLoginCode.update).mockResolvedValue({ attempts: 1 } as never)
+    vi.mocked(db.technicianLoginCode.delete).mockResolvedValue({} as never)
+  })
+
+  const verify = (body: unknown) => call(verifyCode, ORG, body)
+
+  it('exchanges a good code for a session', async () => {
+    vi.mocked(db.technicianLoginCode.findFirst).mockResolvedValue(live() as never)
+
+    const res = await verify({ code: CODE, phone: '+4791234567' })
+
+    expect(await res.json()).toEqual({ data: { token: 'session-token', organizationId: ORG } })
+    expect(createSession).toHaveBeenCalledWith('user-1', false)
+  })
+
+  it('finds the code by who is claiming it, inside this workshop', async () => {
+    vi.mocked(db.technicianLoginCode.findFirst).mockResolvedValue(live() as never)
+    await verify({ code: CODE, phone: '+4791234567' })
+
+    expect(db.technicianLoginCode.findFirst).toHaveBeenCalledWith(
+      expect.objectContaining({
+        where: { organizationId: ORG, technicianId: 'tech-1', usedAt: null },
+      })
+    )
+  })
+
+  it('accepts a code with the spaces a keyboard adds', async () => {
+    vi.mocked(db.technicianLoginCode.findFirst).mockResolvedValue(live() as never)
+    const res = await verify({ code: ' 123 456 ', phone: '+4791234567' })
+    expect(res.status).toBe(200)
+  })
+
+  it('charges a wrong guess to that one code, and nobody else', async () => {
+    // The finding this replaces: a miss used to age every live code in the
+    // workshop, so five wrong guesses from anyone who knew the workshop id
+    // locked out every technician in the building.
+    vi.mocked(db.technicianLoginCode.findFirst).mockResolvedValue(live() as never)
+
+    const res = await verify({ code: '999999', phone: '+4791234567' })
+
+    expect((await res.json()).error.code).toBe('invalid_code')
+    expect(db.technicianLoginCode.update).toHaveBeenCalledWith(
+      expect.objectContaining({ where: { id: 'code-1' }, data: { attempts: { increment: 1 } } })
+    )
+    expect(db.technicianLoginCode.updateMany).not.toHaveBeenCalled()
+    expect(db.technicianLoginCode.deleteMany).not.toHaveBeenCalled()
+    expect(createSession).not.toHaveBeenCalled()
+  })
+
+  it('spends the code once the guesses run out, rather than letting it cool off', async () => {
+    vi.mocked(db.technicianLoginCode.findFirst).mockResolvedValue(live() as never)
+    vi.mocked(db.technicianLoginCode.update).mockResolvedValue({ attempts: MAX_ATTEMPTS } as never)
+
+    await verify({ code: '999999', phone: '+4791234567' })
+    expect(db.technicianLoginCode.delete).toHaveBeenCalledWith({ where: { id: 'code-1' } })
+  })
+
+  it('refuses when the caller does not resolve to anybody here', async () => {
+    claiming(null)
+
+    const res = await verify({ code: CODE, phone: '+4700000000' })
+    expect((await res.json()).error.code).toBe('invalid_code')
+    expect(db.technicianLoginCode.findFirst).not.toHaveBeenCalled()
+  })
+
+  it('refuses without an identifier at all', async () => {
+    const res = await verify({ code: CODE })
+    expect((await res.json()).error.code).toBe('invalid_code')
+    expect(db.technician.findMany).not.toHaveBeenCalled()
+  })
+
+  it('refuses a code belonging to another workshop', async () => {
+    vi.mocked(db.technicianLoginCode.findFirst).mockResolvedValue(
+      live({
+        technician: { id: 't', isActive: true, userId: 'u', organizationId: OTHER_ORG },
+      }) as never
+    )
+
+    const res = await verify({ code: CODE, phone: '+4791234567' })
+    expect((await res.json()).error.code).toBe('invalid_code')
+    expect(createSession).not.toHaveBeenCalled()
+  })
+
+  it('refuses somebody deactivated since the code was sent', async () => {
+    vi.mocked(db.technicianLoginCode.findFirst).mockResolvedValue(
+      live({ technician: { id: 't', isActive: false, userId: 'u', organizationId: ORG } }) as never
+    )
+
+    const res = await verify({ code: CODE, phone: '+4791234567' })
+    expect((await res.json()).error.code).toBe('not_technician')
+    expect(createSession).not.toHaveBeenCalled()
+  })
+
+  it('refuses somebody removed from the workshop since the code was sent', async () => {
+    vi.mocked(db.technicianLoginCode.findFirst).mockResolvedValue(live() as never)
+    vi.mocked(db.organizationMember.findFirst).mockResolvedValue(null as never)
+
+    const res = await verify({ code: CODE, phone: '+4791234567' })
+    expect((await res.json()).error.code).toBe('not_technician')
+  })
+
+  it('refuses an expired code', async () => {
+    vi.mocked(db.technicianLoginCode.findFirst).mockResolvedValue(
+      live({ expiresAt: new Date(Date.now() - 1) }) as never
+    )
+    const res = await verify({ code: CODE, phone: '+4791234567' })
+    expect((await res.json()).error.code).toBe('code_expired')
+  })
+
+  it('refuses once the attempt limit is reached', async () => {
+    vi.mocked(db.technicianLoginCode.findFirst).mockResolvedValue(
+      live({ attempts: MAX_ATTEMPTS }) as never
+    )
+    const res = await verify({ code: CODE, phone: '+4791234567' })
+    expect((await res.json()).error.code).toBe('too_many_attempts')
+  })
+
+  it('burns the code before minting the session', async () => {
+    vi.mocked(db.technicianLoginCode.findFirst).mockResolvedValue(live() as never)
+    await verify({ code: CODE, phone: '+4791234567' })
+
+    expect(db.technicianLoginCode.updateMany).toHaveBeenCalledWith(
+      expect.objectContaining({ where: { id: 'code-1', usedAt: null } })
+    )
+    expect(vi.mocked(db.technicianLoginCode.updateMany).mock.invocationCallOrder[0]).toBeLessThan(
+      createSession.mock.invocationCallOrder[0]
+    )
+  })
+
+  it('lets only one of two racing attempts win', async () => {
+    vi.mocked(db.technicianLoginCode.findFirst).mockResolvedValue(live() as never)
+    vi.mocked(db.technicianLoginCode.updateMany).mockResolvedValue({ count: 0 } as never)
+
+    const res = await verify({ code: CODE, phone: '+4791234567' })
+    expect((await res.json()).error.code).toBe('invalid_code')
+    expect(createSession).not.toHaveBeenCalled()
+  })
+
+  it('refuses anything that is not six digits without a lookup', async () => {
+    for (const code of ['', '12345', '1234567', 'abcdef', null]) {
+      const res = await verify({ code, phone: '+4791234567' })
+      expect((await res.json()).error.code).toBe('invalid_code')
+    }
+    expect(db.technician.findMany).not.toHaveBeenCalled()
+  })
+})
+
+describe('the rate limit on the way in', () => {
+  it('cannot be shaken off with a made-up bearer token', async () => {
+    // rateLimit prefers the Authorization header when there is one, which is
+    // right for authenticated traffic and exactly wrong here: an endpoint
+    // anybody can call will accept any token, so a caller rotating that value
+    // used to get a fresh budget on every request. These endpoints ask for the
+    // address alone.
+    const { rateLimit: realRateLimit } =
+      await vi.importActual<typeof import('@/lib/rate-limit')>('@/lib/rate-limit')
+
+    const withToken = (token: string) =>
+      new Request('https://shop.example.com/api/v1/tech/org/org-a/auth/verify', {
+        method: 'POST',
+        headers: { 'x-real-ip': '203.0.113.9', authorization: `Bearer ${token}` },
+      })
+
+    const opts = { limit: 2, windowMs: 60_000, anonymous: true }
+    expect(realRateLimit(withToken('one'), opts)).toBeNull()
+    expect(realRateLimit(withToken('two'), opts)).toBeNull()
+    // Third call, third distinct token, and it is still refused.
+    expect(realRateLimit(withToken('three'), opts)?.status).toBe(429)
+  })
+})

+ 216 - 0
src/__tests__/api/tech-setup-redeem.test.ts

@@ -0,0 +1,216 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+/**
+ * The one endpoint on the tech API anybody on the internet can reach without
+ * credentials, and it hands back a session. These are the ways it must refuse.
+ */
+
+// vi.mock is hoisted, so the spy has to be created inside the factory and
+// pulled back out afterwards.
+vi.mock('@/lib/auth', () => ({
+  auth: {
+    $context: Promise.resolve({ internalAdapter: { createSession: vi.fn() } }),
+  },
+}))
+
+vi.mock('@/lib/db', () => ({
+  db: {
+    technicianSetupCode: { findUnique: vi.fn(), updateMany: vi.fn() },
+    technician: { findFirst: vi.fn() },
+  },
+}))
+
+vi.mock('@/lib/audit', () => ({ logAudit: vi.fn().mockResolvedValue(undefined) }))
+vi.mock('@/lib/rate-limit', () => ({ rateLimit: vi.fn(() => null) }))
+
+import { auth } from '@/lib/auth'
+import { db } from '@/lib/db'
+import { rateLimit } from '@/lib/rate-limit'
+import { POST } from '@/app/api/v1/tech/setup/redeem/route'
+import { generateSetupCode, hashSetupCode } from '@/features/team/Lib/appSetupCode'
+
+const mockCreateSession = vi.mocked(
+  (await auth.$context).internalAdapter.createSession as ReturnType<typeof vi.fn>
+)
+const findCode = vi.mocked(db.technicianSetupCode.findUnique)
+const burnCode = vi.mocked(db.technicianSetupCode.updateMany)
+const findTechnician = vi.mocked(db.technician.findFirst)
+
+const CODE = 'ABCD2345'
+
+function post(body: unknown) {
+  return POST(
+    new Request('https://shop.example.com/api/v1/tech/setup/redeem', {
+      method: 'POST',
+      body: JSON.stringify(body),
+    })
+  )
+}
+
+/** A live, unredeemed code for an active technician. */
+function live(overrides: Record<string, unknown> = {}) {
+  return {
+    id: 'code-1',
+    userId: 'user-1',
+    organizationId: 'org-1',
+    expiresAt: new Date(Date.now() + 60_000),
+    usedAt: null,
+    organization: { name: 'Bay Street Motors' },
+    ...overrides,
+  }
+}
+
+beforeEach(() => {
+  vi.clearAllMocks()
+  vi.mocked(rateLimit).mockReturnValue(null)
+  findTechnician.mockResolvedValue({ id: 'tech-1' } as never)
+  burnCode.mockResolvedValue({ count: 1 } as never)
+  mockCreateSession.mockResolvedValue({ token: 'session-token-abc' })
+})
+
+describe('POST /api/v1/tech/setup/redeem', () => {
+  it('exchanges a live code for a session', async () => {
+    findCode.mockResolvedValue(live() as never)
+
+    const res = await post({ code: CODE })
+    const body = await res.json()
+
+    expect(res.status).toBe(200)
+    expect(body.data).toEqual({
+      token: 'session-token-abc',
+      organizationId: 'org-1',
+      workshop: 'Bay Street Motors',
+    })
+    expect(mockCreateSession).toHaveBeenCalledWith('user-1', false)
+  })
+
+  it('looks the code up by hash, never by its value', async () => {
+    findCode.mockResolvedValue(live() as never)
+    await post({ code: CODE })
+
+    expect(findCode).toHaveBeenCalledWith(
+      expect.objectContaining({ where: { codeHash: hashSetupCode(CODE) } })
+    )
+    const query = JSON.stringify(findCode.mock.calls[0]?.[0])
+    expect(query).not.toContain(CODE)
+  })
+
+  it('accepts the code as it is displayed, lower case and grouped', async () => {
+    findCode.mockResolvedValue(live() as never)
+    const res = await post({ code: ' abcd-2345 ' })
+
+    expect(res.status).toBe(200)
+    expect(findCode).toHaveBeenCalledWith(
+      expect.objectContaining({ where: { codeHash: hashSetupCode(CODE) } })
+    )
+  })
+
+  it('refuses a code that does not exist', async () => {
+    findCode.mockResolvedValue(null as never)
+
+    const res = await post({ code: CODE })
+    expect(res.status).toBe(400)
+    expect((await res.json()).error.code).toBe('invalid_code')
+    expect(mockCreateSession).not.toHaveBeenCalled()
+  })
+
+  it('refuses a code that has already been redeemed', async () => {
+    findCode.mockResolvedValue(live({ usedAt: new Date() }) as never)
+
+    const res = await post({ code: CODE })
+    expect((await res.json()).error.code).toBe('code_used')
+    expect(mockCreateSession).not.toHaveBeenCalled()
+  })
+
+  it('refuses a code that has expired', async () => {
+    findCode.mockResolvedValue(live({ expiresAt: new Date(Date.now() - 1) }) as never)
+
+    const res = await post({ code: CODE })
+    expect((await res.json()).error.code).toBe('code_expired')
+    expect(mockCreateSession).not.toHaveBeenCalled()
+  })
+
+  it('refuses when the person is no longer an active technician', async () => {
+    // Issued this morning, deactivated at lunchtime, scanned this afternoon.
+    findCode.mockResolvedValue(live() as never)
+    findTechnician.mockResolvedValue(null as never)
+
+    const res = await post({ code: CODE })
+    expect((await res.json()).error.code).toBe('not_technician')
+    expect(mockCreateSession).not.toHaveBeenCalled()
+  })
+
+  it('lets exactly one of two phones scanning the same screen win', async () => {
+    findCode.mockResolvedValue(live() as never)
+    // The other phone got there first, so the conditional update matches nothing.
+    burnCode.mockResolvedValue({ count: 0 } as never)
+
+    const res = await post({ code: CODE })
+    expect((await res.json()).error.code).toBe('code_used')
+    expect(mockCreateSession).not.toHaveBeenCalled()
+  })
+
+  it('burns the code before minting anything', async () => {
+    findCode.mockResolvedValue(live() as never)
+    await post({ code: CODE })
+
+    // Conditional on it still being unused, which is what makes the race safe.
+    expect(burnCode).toHaveBeenCalledWith(
+      expect.objectContaining({ where: { id: 'code-1', usedAt: null } })
+    )
+    expect(burnCode.mock.invocationCallOrder[0]).toBeLessThan(
+      mockCreateSession.mock.invocationCallOrder[0]
+    )
+  })
+
+  it('refuses an empty or missing code without touching the database', async () => {
+    for (const body of [{}, { code: '' }, { code: '  -- ' }, { code: 42 }]) {
+      const res = await post(body)
+      expect(res.status).toBe(400)
+      expect((await res.json()).error.code).toBe('invalid_code')
+    }
+    expect(findCode).not.toHaveBeenCalled()
+  })
+
+  it('obeys the rate limiter before doing anything else', async () => {
+    vi.mocked(rateLimit).mockReturnValue(
+      new Response(null, { status: 429 }) as unknown as ReturnType<typeof rateLimit>
+    )
+
+    const res = await post({ code: CODE })
+    expect(res.status).toBe(429)
+    expect(findCode).not.toHaveBeenCalled()
+  })
+
+  it('never reveals which workshop or person a bad code was for', async () => {
+    findCode.mockResolvedValue(null as never)
+    const body = JSON.stringify(await (await post({ code: CODE })).json())
+
+    expect(body).not.toContain('org-1')
+    expect(body).not.toContain('user-1')
+    expect(body).not.toContain('Bay Street Motors')
+  })
+})
+
+describe('setup codes', () => {
+  it('generates from an alphabet with no character anybody has to ask about', () => {
+    for (let i = 0; i < 200; i++) {
+      // No O/0, I/1 or S/5 — the pairs that get misheard across a workshop.
+      expect(generateSetupCode()).toMatch(/^[ABCDEFGHJKLMNPQRTUVWXYZ2346789]{8}$/)
+    }
+  })
+
+  it('does not repeat itself', () => {
+    const seen = new Set(Array.from({ length: 500 }, () => generateSetupCode()))
+    expect(seen.size).toBe(500)
+  })
+
+  it('hashes rather than stores, and the hash does not contain the code', () => {
+    const code = generateSetupCode()
+    const hash = hashSetupCode(code)
+    expect(hash).toMatch(/^[a-f0-9]{64}$/)
+    expect(hash).not.toContain(code)
+    expect(hashSetupCode(code)).toBe(hash)
+    expect(hashSetupCode(generateSetupCode())).not.toBe(hash)
+  })
+})

+ 85 - 0
src/__tests__/auth/technician-role-covers-app.test.ts

@@ -0,0 +1,85 @@
+import fs from 'node:fs'
+import path from 'node:path'
+import { describe, expect, it } from 'vitest'
+import { TECHNICIAN_PERMISSIONS } from '@/features/team/Lib/technicianRole'
+
+/**
+ * The technician role has to carry whatever the technician app asks for.
+ *
+ * withApiAuth enforces requiredPermissions exactly as withAuth does, so an
+ * endpoint that asks for something the role does not hold answers "Your role
+ * does not allow this" to a technician doing their job. That failed silently
+ * once already: accounts were created with no role at all, on the belief that
+ * the API was gated on the technician record alone, and every screen in the
+ * app returned 403.
+ *
+ * Reading the routes rather than a list somebody maintains, because the list
+ * is the thing that goes stale.
+ */
+
+const TECH_API = path.join(process.cwd(), 'src/app/api/v1/tech')
+
+function routeFiles(dir: string): string[] {
+  return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
+    const full = path.join(dir, entry.name)
+    if (entry.isDirectory()) return routeFiles(full)
+    return entry.name === 'route.ts' ? [full] : []
+  })
+}
+
+/** Every `{ action: PermissionAction.X, subject: PermissionSubject.Y }` in a file. */
+function demandedBy(source: string): { action: string; subject: string }[] {
+  const pattern =
+    /\{\s*action:\s*PermissionAction\.([A-Z_]+),\s*subject:\s*PermissionSubject\.([A-Z_]+)\s*\}/g
+  return [...source.matchAll(pattern)].map((m) => ({
+    action: m[1].toLowerCase(),
+    subject: m[2].toLowerCase(),
+  }))
+}
+
+describe('the technician role covers the technician app', () => {
+  const held = new Set(TECHNICIAN_PERMISSIONS.map((p) => `${p.action}:${p.subject}`))
+
+  it('holds every permission the routes ask for', () => {
+    const missing = new Map<string, string[]>()
+
+    for (const file of routeFiles(TECH_API)) {
+      const source = fs.readFileSync(file, 'utf-8')
+      // Only what is actually enforced, not permissions mentioned in passing.
+      if (!source.includes('requiredPermissions')) continue
+
+      for (const { action, subject } of demandedBy(source)) {
+        const key = `${action}:${subject}`
+        if (held.has(key)) continue
+        const route = path.relative(TECH_API, file).replace(/\/route\.ts$/, '')
+        missing.set(key, [...(missing.get(key) ?? []), route])
+      }
+    }
+
+    expect(
+      Object.fromEntries(missing),
+      'Add these to TECHNICIAN_PERMISSIONS, or the app answers "Your role does not allow this"'
+    ).toEqual({})
+  })
+
+  it('holds nothing the routes do not ask for', () => {
+    // A role handed to every mechanic in every workshop should carry exactly
+    // what the app needs and not a permission more.
+    const demanded = new Set<string>()
+    for (const file of routeFiles(TECH_API)) {
+      const source = fs.readFileSync(file, 'utf-8')
+      if (!source.includes('requiredPermissions')) continue
+      for (const { action, subject } of demandedBy(source)) demanded.add(`${action}:${subject}`)
+    }
+
+    const spare = [...held].filter((p) => !demanded.has(p))
+    expect(spare, 'Granted to every technician and used by nothing').toEqual([])
+  })
+
+  it('reads more than one route, so a broken walk fails loudly', () => {
+    const withPermissions = routeFiles(TECH_API).filter((f) =>
+      fs.readFileSync(f, 'utf-8').includes('requiredPermissions')
+    )
+    expect(withPermissions.length).toBeGreaterThan(5)
+  })
+})

+ 39 - 0
src/__tests__/features/dial-codes.test.ts

@@ -0,0 +1,39 @@
+import { describe, expect, it } from 'vitest'
+import { countriesFor, DIAL_CODES } from '@/features/team/Lib/dialCodes'
+
+/**
+ * The country list feeds a Select, which keys its options on their value.
+ */
+describe('dial codes', () => {
+  it('has one row per country', () => {
+    const regions = DIAL_CODES.map((c) => c.region)
+    expect(new Set(regions).size).toBe(regions.length)
+  })
+
+  it('does not assume a dial code identifies a country', () => {
+    // Canada and the United States are both +1, which is why the Select is
+    // keyed on the region. A list keyed on the dial code had two options
+    // claiming to be the same one, and React said so.
+    const dials = DIAL_CODES.map((c) => c.dial)
+    expect(new Set(dials).size).toBeLessThan(dials.length)
+  })
+
+  it('gives every country a real dial code', () => {
+    for (const { region, dial } of DIAL_CODES) {
+      expect(region, `${region} is not a region code`).toMatch(/^[A-Z]{2}$/)
+      expect(dial, `${region} has a bad dial code`).toMatch(/^\+[1-9]\d{0,2}$/)
+    }
+  })
+
+  it('names them in the reader’s own language', () => {
+    const norwegian = countriesFor('nb').find((c) => c.region === 'DE')
+    const german = countriesFor('de').find((c) => c.region === 'DE')
+    expect(norwegian?.name).toBe('Tyskland')
+    expect(german?.name).toBe('Deutschland')
+  })
+
+  it('sorts by that language, not by code point', () => {
+    const names = countriesFor('nb').map((c) => c.name)
+    expect(names).toEqual([...names].sort(new Intl.Collator('nb').compare))
+  })
+})

+ 75 - 0
src/__tests__/features/team-role-value.test.ts

@@ -0,0 +1,75 @@
+import { describe, expect, it } from 'vitest'
+import { TECHNICIAN_ROLE_NAME } from '@/features/team/Lib/technicianRole'
+
+/**
+ * Which option the role dropdown shows as selected.
+ *
+ * The trigger renders blank when the value matches no option, and a blank
+ * trigger reads as "this person has no role", which is the one thing it must
+ * never say about somebody who does. That happened for a newly added
+ * technician: their role id was real but deliberately absent from the list,
+ * because the Technician entry above it is what grants it.
+ */
+function selectedValue(
+  member: { user: { id: string }; role: string; roleId: string | null },
+  technicians: Set<string>,
+  roles: { id: string; name: string }[]
+): string {
+  if (technicians.has(member.user.id)) return 'technician'
+  if (roles.some((r) => r.id === member.roleId && r.name !== TECHNICIAN_ROLE_NAME)) {
+    return member.roleId as string
+  }
+  return member.role
+}
+
+const OFFERED = ['admin', 'member', 'technician']
+
+describe('the role dropdown value', () => {
+  const roles = [
+    { id: 'role-tech', name: TECHNICIAN_ROLE_NAME },
+    { id: 'role-desk', name: 'Front desk' },
+  ]
+  const options = [
+    ...OFFERED,
+    ...roles.filter((r) => r.name !== TECHNICIAN_ROLE_NAME).map((r) => r.id),
+  ]
+
+  it('always matches something the list actually offers', () => {
+    const cases = [
+      { user: { id: 'u1' }, role: 'member', roleId: 'role-tech' },
+      { user: { id: 'u2' }, role: 'member', roleId: 'role-desk' },
+      { user: { id: 'u3' }, role: 'admin', roleId: null },
+      { user: { id: 'u4' }, role: 'member', roleId: null },
+      // A role deleted from under them, which the list no longer carries.
+      { user: { id: 'u5' }, role: 'member', roleId: 'role-gone' },
+    ]
+    for (const member of cases) {
+      const value = selectedValue(member, new Set(['u1']), roles)
+      expect(options, `${member.user.id} selected "${value}"`).toContain(value)
+    }
+  })
+
+  it('shows Technician for somebody on the board', () => {
+    expect(
+      selectedValue(
+        { user: { id: 'u1' }, role: 'member', roleId: 'role-tech' },
+        new Set(['u1']),
+        roles
+      )
+    ).toBe('technician')
+  })
+
+  it('does not fall through to a role id the list hides', () => {
+    // The bug: a technician missing from the set fell through to role-tech,
+    // which is filtered out of the options, so the trigger rendered empty.
+    expect(
+      selectedValue({ user: { id: 'u1' }, role: 'member', roleId: 'role-tech' }, new Set(), roles)
+    ).toBe('member')
+  })
+
+  it('keeps a real custom role selected', () => {
+    expect(
+      selectedValue({ user: { id: 'u2' }, role: 'member', roleId: 'role-desk' }, new Set(), roles)
+    ).toBe('role-desk')
+  })
+})

+ 440 - 0
src/__tests__/features/technician-account.test.ts

@@ -0,0 +1,440 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+/**
+ * Creating a mechanic's account at the counter, and cutting one off.
+ */
+
+vi.mock('@/lib/cached-session', () => ({
+  getCachedSession: vi.fn(),
+  getCachedMembership: vi.fn(),
+}))
+vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
+vi.mock('@/lib/notification-bus', () => ({ notificationBus: { emit: vi.fn() } }))
+vi.mock('@/lib/sms', () => ({ normalizeOrgPhone: vi.fn() }))
+vi.mock('@/lib/auth', () => ({
+  auth: { $context: Promise.resolve({ internalAdapter: { deleteSession: vi.fn() } }) },
+}))
+vi.mock('@/lib/db', () => ({
+  db: {
+    role: { findFirst: vi.fn(), create: vi.fn() },
+    technician: {
+      findFirst: vi.fn(),
+      findMany: vi.fn(),
+      create: vi.fn(),
+      update: vi.fn(),
+      aggregate: vi.fn(),
+    },
+    organizationMember: {
+      create: vi.fn(),
+      findFirst: vi.fn(),
+      update: vi.fn(),
+      delete: vi.fn(),
+      count: vi.fn(),
+    },
+    user: { findUnique: vi.fn(), create: vi.fn(), update: vi.fn() },
+    technicianLoginCode: { deleteMany: vi.fn() },
+    technicianSetupCode: { deleteMany: vi.fn() },
+    pushDevice: { updateMany: vi.fn() },
+    session: { findMany: vi.fn() },
+    $transaction: vi.fn(),
+  },
+}))
+
+import { auth } from '@/lib/auth'
+import { getCachedMembership, getCachedSession } from '@/lib/cached-session'
+import { db } from '@/lib/db'
+import { normalizeOrgPhone } from '@/lib/sms'
+import { createTechnicianAccount } from '@/features/team/Actions/createTechnicianAccount'
+import { removeTechnicianAccess } from '@/features/team/Actions/removeTechnicianAccess'
+import { removeMember } from '@/features/team/Actions/teamActions'
+
+const deleteSession = vi.mocked(
+  (await auth.$context).internalAdapter.deleteSession as ReturnType<typeof vi.fn>
+)
+const ORG = 'org-a'
+
+beforeEach(() => {
+  vi.clearAllMocks()
+  vi.mocked(getCachedSession).mockResolvedValue({ user: { id: 'desk-1' } } as never)
+  vi.mocked(getCachedMembership).mockResolvedValue({
+    organizationId: ORG,
+    role: 'owner',
+    roleId: null,
+    customRole: null,
+  } as never)
+  vi.mocked(db.user.findUnique).mockResolvedValue(null as never)
+  vi.mocked(normalizeOrgPhone).mockResolvedValue('+4791234567')
+  vi.mocked(db.technician.findFirst).mockResolvedValue(null as never)
+  vi.mocked(db.technician.aggregate).mockResolvedValue({ _max: { sortOrder: 2 } } as never)
+  vi.mocked(db.technician.create).mockResolvedValue({
+    id: 'tech-1',
+    userId: 'user-1',
+  } as never)
+  vi.mocked(db.user.create).mockResolvedValue({ id: 'user-1' } as never)
+  vi.mocked(db.role.findFirst).mockResolvedValue({ id: 'role-tech' } as never)
+  vi.mocked(db.organizationMember.count).mockResolvedValue(0 as never)
+  vi.mocked(db.technician.findMany).mockResolvedValue([] as never)
+  vi.mocked(db.technicianLoginCode.deleteMany).mockResolvedValue({ count: 0 } as never)
+  vi.mocked(db.technicianSetupCode.deleteMany).mockResolvedValue({ count: 0 } as never)
+  vi.mocked(db.$transaction).mockImplementation(async (arg: unknown) =>
+    typeof arg === 'function' ? (arg as (tx: unknown) => unknown)(db) : arg
+  )
+})
+
+describe('creating a technician account at the counter', () => {
+  it('makes a user, a membership and a technician from a name and a number', async () => {
+    const result = await createTechnicianAccount({ name: 'Ola Nordmann', phone: '912 34 567' })
+
+    expect(result.success).toBe(true)
+    expect(db.user.create).toHaveBeenCalledWith(
+      expect.objectContaining({
+        data: expect.objectContaining({ name: 'Ola Nordmann', phone: '+4791234567' }),
+      })
+    )
+    expect(db.organizationMember.create).toHaveBeenCalledWith(
+      expect.objectContaining({
+        data: expect.objectContaining({ organizationId: ORG, role: 'member', roleId: 'role-tech' }),
+      })
+    )
+    expect(db.technician.create).toHaveBeenCalled()
+  })
+
+  it('stores the number in the workshop’s own canonical form', async () => {
+    await createTechnicianAccount({ name: 'Ola', phone: '  912 34 567 ' })
+    const data = vi.mocked(db.user.create).mock.calls[0]?.[0]?.data as { phone: string }
+    expect(data.phone).toBe('+4791234567')
+  })
+
+  it('invents an unroutable address rather than asking for one', async () => {
+    await createTechnicianAccount({ name: 'Ola', phone: '912 34 567' })
+    const data = vi.mocked(db.user.create).mock.calls[0]?.[0]?.data as { email: string }
+
+    expect(data.email).toMatch(/@technician\.torqvoice\.invalid$/)
+    // .invalid can never resolve, so nothing can ever be sent there by mistake.
+    expect(data.email.endsWith('.invalid')).toBe(true)
+  })
+
+  it('gives them the role the app needs, and reuses the workshop’s own', async () => {
+    // Creating them with no role at all was the bug: withApiAuth enforces
+    // permissions exactly as the web app does, so every screen in the
+    // technician app answered "Your role does not allow this".
+    await createTechnicianAccount({ name: 'Ola', phone: '912 34 567' })
+
+    const data = vi.mocked(db.organizationMember.create).mock.calls[0]?.[0]?.data as {
+      roleId: string
+    }
+    expect(data.roleId).toBe('role-tech')
+    // Found rather than made, so five mechanics share one role instead of
+    // filling the team page with five identical ones.
+    expect(db.role.create).not.toHaveBeenCalled()
+  })
+
+  it('creates the technician role once, when the workshop has none yet', async () => {
+    vi.mocked(db.role.findFirst).mockResolvedValue(null as never)
+    vi.mocked(db.role.create).mockResolvedValue({ id: 'role-new' } as never)
+
+    await createTechnicianAccount({ name: 'Ola', phone: '912 34 567' })
+
+    const data = vi.mocked(db.role.create).mock.calls[0]?.[0]?.data as {
+      isAdmin: boolean
+      permissions: { create: { action: string; subject: string }[] }
+    }
+    // Not isAdmin: that bypasses permission checks entirely and hides what the
+    // account can reach behind a flag, which is the shape of the bug this
+    // product just finished removing.
+    expect(data.isAdmin).toBe(false)
+    expect(data.permissions.create).toEqual([
+      { action: 'read', subject: 'services' },
+      { action: 'update', subject: 'services' },
+      { action: 'read', subject: 'inventory' },
+    ])
+  })
+
+  it('reports a clash rather than refusing, so the desk is never stuck', async () => {
+    // A dead end here is a showstopper: a recycled number or a name typed
+    // differently has to have a way forward.
+    vi.mocked(db.technician.findFirst).mockResolvedValue({
+      id: 'tech-9',
+      name: 'Kari',
+      isActive: true,
+      userId: 'user-9',
+    } as never)
+    vi.mocked(db.organizationMember.count).mockResolvedValue(1 as never)
+
+    const result = await createTechnicianAccount({ name: 'Ola', phone: '912 34 567' })
+
+    expect(result.success).toBe(true)
+    expect((result.data as { conflict: { name: string } }).conflict.name).toBe('Kari')
+    expect(db.user.create).not.toHaveBeenCalled()
+  })
+
+  it('reuses the clashing record when the desk says it is the same person', async () => {
+    vi.mocked(db.technician.findFirst).mockResolvedValue({
+      id: 'tech-9',
+      name: 'Kari',
+      isActive: true,
+      userId: 'user-9',
+    } as never)
+    vi.mocked(db.organizationMember.count).mockResolvedValue(1 as never)
+    vi.mocked(db.technician.update).mockResolvedValue({ id: 'tech-9', userId: 'user-9' } as never)
+    vi.mocked(db.organizationMember.findFirst).mockResolvedValue({
+      id: 'mem-9',
+      roleId: 'role-tech',
+    } as never)
+
+    const result = await createTechnicianAccount({
+      name: 'Kari Nordmann',
+      phone: '912 34 567',
+      resolve: 'reuse',
+    })
+
+    expect((result.data as { reinstated: boolean }).reinstated).toBe(true)
+    expect(db.technician.create).not.toHaveBeenCalled()
+  })
+
+  it('frees the number and starts fresh when the desk says it is somebody else', async () => {
+    vi.mocked(db.technician.findFirst).mockResolvedValue({
+      id: 'tech-9',
+      name: 'Kari',
+      isActive: true,
+      userId: 'user-9',
+    } as never)
+    vi.mocked(db.organizationMember.count).mockResolvedValue(1 as never)
+
+    await createTechnicianAccount({ name: 'Ola', phone: '912 34 567', resolve: 'takeover' })
+
+    // Kari keeps everything she did and loses only the way in.
+    expect(db.user.update).toHaveBeenCalledWith({
+      where: { id: 'user-9' },
+      data: { phone: null },
+    })
+    expect(db.user.create).toHaveBeenCalled()
+  })
+
+  it('never refuses outright', async () => {
+    vi.mocked(db.technician.findFirst).mockResolvedValue({
+      id: 'tech-9',
+      name: 'Kari',
+      isActive: true,
+      userId: 'user-9',
+    } as never)
+    vi.mocked(db.organizationMember.count).mockResolvedValue(1 as never)
+
+    const result = await createTechnicianAccount({ name: 'Ola', phone: '912 34 567' })
+    // Whatever the state, the answer is a question and not a wall.
+    expect(result.success).toBe(true)
+  })
+
+  describe('somebody who worked here before', () => {
+    // Removing a technician deactivates the row rather than deleting it, so a
+    // mechanic who leaves and comes back is a row that already exists. Refusing
+    // them meant they could never return.
+    beforeEach(() => {
+      vi.mocked(db.technician.findFirst).mockResolvedValue({
+        id: 'tech-old',
+        name: 'Petter',
+        isActive: false,
+        userId: 'user-old',
+      } as never)
+      vi.mocked(db.technician.update).mockResolvedValue({
+        id: 'tech-old',
+        userId: 'user-old',
+      } as never)
+      vi.mocked(db.organizationMember.findFirst).mockResolvedValue({
+        id: 'mem-old',
+        roleId: 'role-tech',
+      } as never)
+    })
+
+    it('brings them back rather than refusing them', async () => {
+      const result = await createTechnicianAccount({ name: 'Petter', phone: '912 34 567' })
+
+      expect(result.success).toBe(true)
+      expect((result.data as { reinstated: boolean }).reinstated).toBe(true)
+      expect(db.technician.update).toHaveBeenCalledWith(
+        expect.objectContaining({
+          where: { id: 'tech-old' },
+          data: { isActive: true, name: 'Petter' },
+        })
+      )
+    })
+
+    it('keeps their history by reusing the row, not making a second one', async () => {
+      await createTechnicianAccount({ name: 'Petter', phone: '912 34 567' })
+
+      expect(db.technician.create).not.toHaveBeenCalled()
+      expect(db.user.create).not.toHaveBeenCalled()
+    })
+
+    it('takes the name as typed now, in case it changed', async () => {
+      await createTechnicianAccount({ name: 'Petter Stordalen', phone: '912 34 567' })
+
+      expect(db.user.update).toHaveBeenCalledWith(
+        expect.objectContaining({ data: expect.objectContaining({ name: 'Petter Stordalen' }) })
+      )
+    })
+
+    it('rebuilds the membership when they were removed from the team entirely', async () => {
+      // The trash icon deletes the membership and leaves the technician row.
+      vi.mocked(db.organizationMember.findFirst).mockResolvedValue(null as never)
+
+      await createTechnicianAccount({ name: 'Petter', phone: '912 34 567' })
+
+      expect(db.organizationMember.create).toHaveBeenCalledWith(
+        expect.objectContaining({
+          data: expect.objectContaining({ userId: 'user-old', roleId: 'role-tech' }),
+        })
+      )
+    })
+
+    it('gives back the role if they came back without one', async () => {
+      vi.mocked(db.organizationMember.findFirst).mockResolvedValue({
+        id: 'mem-old',
+        roleId: null,
+      } as never)
+
+      await createTechnicianAccount({ name: 'Petter', phone: '912 34 567' })
+
+      expect(db.organizationMember.update).toHaveBeenCalledWith(
+        expect.objectContaining({ where: { id: 'mem-old' }, data: { roleId: 'role-tech' } })
+      )
+    })
+  })
+
+  it('looks for that number only inside this workshop', async () => {
+    await createTechnicianAccount({ name: 'Ola', phone: '912 34 567' })
+    expect(db.technician.findFirst).toHaveBeenCalledWith(
+      expect.objectContaining({ where: expect.objectContaining({ organizationId: ORG }) })
+    )
+  })
+
+  it('refuses a number it cannot make sense of', async () => {
+    vi.mocked(normalizeOrgPhone).mockResolvedValue(null)
+
+    const result = await createTechnicianAccount({ name: 'Ola', phone: 'not a number' })
+    expect(result.success).toBe(false)
+    expect(db.user.create).not.toHaveBeenCalled()
+  })
+
+  it('refuses an email somebody already has', async () => {
+    vi.mocked(db.user.findUnique).mockResolvedValue({ id: 'other' } as never)
+
+    const result = await createTechnicianAccount({
+      name: 'Ola',
+      phone: '912 34 567',
+      email: 'taken@x.test',
+    })
+    expect(result.success).toBe(false)
+    expect(db.user.create).not.toHaveBeenCalled()
+  })
+})
+
+describe('removing a technician', () => {
+  beforeEach(() => {
+    vi.mocked(db.technician.findFirst).mockResolvedValue({
+      id: 'tech-1',
+      name: 'Ola',
+      userId: 'user-1',
+    } as never)
+    vi.mocked(db.organizationMember.count).mockResolvedValue(0 as never)
+    // The real deleteSession is async and the code chains .catch onto it.
+    deleteSession.mockResolvedValue(undefined)
+    vi.mocked(db.technician.findMany).mockResolvedValue([{ id: 'tech-1' }] as never)
+    vi.mocked(db.session.findMany).mockResolvedValue([
+      { token: 'tok-1' },
+      { token: 'tok-2' },
+    ] as never)
+  })
+
+  it('deactivates rather than deletes, so the work still has an author', async () => {
+    await removeTechnicianAccess({ userId: 'user-1' })
+    expect(db.technician.update).toHaveBeenCalledWith(
+      expect.objectContaining({ data: { isActive: false } })
+    )
+  })
+
+  it('kills every session, so the token on the phone stops being one', async () => {
+    await removeTechnicianAccess({ userId: 'user-1' })
+    expect(deleteSession).toHaveBeenCalledWith('tok-1')
+    expect(deleteSession).toHaveBeenCalledWith('tok-2')
+  })
+
+  it('leaves sessions alone when they still work at another branch', async () => {
+    vi.mocked(db.organizationMember.count).mockResolvedValue(1 as never)
+
+    await removeTechnicianAccess({ userId: 'user-1' })
+    expect(deleteSession).not.toHaveBeenCalled()
+  })
+
+  it('destroys anything outstanding that could still be redeemed', async () => {
+    await removeTechnicianAccess({ userId: 'user-1' })
+    expect(db.technicianLoginCode.deleteMany).toHaveBeenCalledWith({
+      where: { technicianId: { in: ['tech-1'] } },
+    })
+    expect(db.technicianSetupCode.deleteMany).toHaveBeenCalledWith({
+      where: { organizationId: ORG, userId: 'user-1' },
+    })
+  })
+
+  it('stops the wrong phone being told about jobs', async () => {
+    await removeTechnicianAccess({ userId: 'user-1' })
+    expect(db.pushDevice.updateMany).toHaveBeenCalledWith({
+      where: { userId: 'user-1', organizationId: ORG },
+      data: { isActive: false },
+    })
+  })
+
+  it('refuses a technician from another workshop', async () => {
+    vi.mocked(db.technician.findFirst).mockResolvedValue(null as never)
+
+    const result = await removeTechnicianAccess({ userId: 'user-elsewhere' })
+    expect(result.success).toBe(false)
+    expect(db.technician.update).not.toHaveBeenCalled()
+    expect(deleteSession).not.toHaveBeenCalled()
+  })
+})
+
+describe('removing somebody from the team entirely', () => {
+  // The trash icon used to delete only the membership, leaving an active
+  // technician on the work board pointing at an account that no longer
+  // belonged to the workshop, holding their phone number so they could never
+  // be added back, and with a live session nobody had revoked.
+  beforeEach(() => {
+    vi.mocked(getCachedMembership).mockResolvedValue({
+      organizationId: ORG,
+      role: 'owner',
+      roleId: null,
+      customRole: null,
+    } as never)
+    vi.mocked(db.organizationMember.findFirst)
+      .mockResolvedValueOnce({ id: 'mine', role: 'owner', userId: 'desk-1' } as never)
+      .mockResolvedValueOnce({ id: 'theirs', role: 'member', userId: 'user-1' } as never)
+    vi.mocked(db.technician.findFirst).mockResolvedValue({ id: 'tech-1' } as never)
+    vi.mocked(db.technician.findMany).mockResolvedValue([{ id: 'tech-1' }] as never)
+    vi.mocked(db.technician.update).mockResolvedValue({ id: 'tech-1' } as never)
+    vi.mocked(db.organizationMember.delete).mockResolvedValue({} as never)
+    vi.mocked(db.session.findMany).mockResolvedValue([{ token: 'tok-1' }] as never)
+    deleteSession.mockResolvedValue(undefined)
+  })
+
+  it('takes their technician standing with them', async () => {
+    await removeMember('theirs')
+
+    expect(db.technician.update).toHaveBeenCalledWith(
+      expect.objectContaining({ data: { isActive: false } })
+    )
+    expect(db.organizationMember.delete).toHaveBeenCalledWith({ where: { id: 'theirs' } })
+  })
+
+  it('revokes the session on their phone', async () => {
+    await removeMember('theirs')
+    expect(deleteSession).toHaveBeenCalledWith('tok-1')
+  })
+
+  it('destroys anything outstanding they could still redeem', async () => {
+    await removeMember('theirs')
+    expect(db.technicianSetupCode.deleteMany).toHaveBeenCalledWith({
+      where: { organizationId: ORG, userId: 'user-1' },
+    })
+  })
+})

+ 39 - 36
src/__tests__/i18n/audit-actions.test.ts

@@ -1,6 +1,6 @@
-import { describe, it, expect } from "vitest";
-import fs from "fs";
-import path from "path";
+import { describe, it, expect } from 'vitest'
+import fs from 'fs'
+import path from 'path'
 
 /**
  * Every audit action the code writes must have a label in every locale.
@@ -10,59 +10,62 @@ import path from "path";
  * is exactly how `scheduled_message.create` shipped, so this closes the gap
  * rather than the individual keys.
  */
-const ROOT = process.cwd();
+const ROOT = process.cwd()
 const LOCALES = fs
-  .readdirSync(path.join(ROOT, "messages"))
-  .filter((d) => fs.statSync(path.join(ROOT, "messages", d)).isDirectory());
+  .readdirSync(path.join(ROOT, 'messages'))
+  .filter((d) => fs.statSync(path.join(ROOT, 'messages', d)).isDirectory())
 
 /** Audit actions are namespaced with a dot or an underscore; bare words in the
  *  `action:` position belong to permissions, API payloads and test fixtures. */
 function emittedActions(): string[] {
-  const found = new Set<string>();
+  const found = new Set<string>()
   const walk = (dir: string) => {
     for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
-      const full = path.join(dir, entry.name);
+      const full = path.join(dir, entry.name)
       if (entry.isDirectory()) {
-        if (entry.name !== "node_modules" && entry.name !== "__tests__") walk(full);
+        if (entry.name !== 'node_modules' && entry.name !== '__tests__') walk(full)
       } else if (/\.tsx?$/.test(entry.name)) {
-        const src = fs.readFileSync(full, "utf-8");
-        for (const m of src.matchAll(/action:\s*"([a-zA-Z][\w]*\.[\w.]+)"/g)) {
-          found.add(m[1]);
+        const src = fs.readFileSync(full, 'utf-8')
+        // Either quote. The codebase is mid-migration between the two and
+        // biome rewrites a file's quotes the first time anybody touches it, so
+        // a pattern that only knows about double quotes quietly stops seeing
+        // actions that are still very much being emitted.
+        for (const m of src.matchAll(/action:\s*['"]([a-zA-Z][\w]*\.[\w.]+)['"]/g)) {
+          found.add(m[1])
         }
       }
     }
-  };
-  walk(path.join(ROOT, "src"));
-  return [...found].sort();
+  }
+  walk(path.join(ROOT, 'src'))
+  return [...found].sort()
 }
 
-const ACTIONS = emittedActions();
+const ACTIONS = emittedActions()
 
-describe("audit action labels", () => {
-  it("finds the audit actions the code emits", () => {
-    expect(ACTIONS.length).toBeGreaterThan(50);
-    expect(ACTIONS).toContain("inspection.create");
-  });
+describe('audit action labels', () => {
+  it('finds the audit actions the code emits', () => {
+    expect(ACTIONS.length).toBeGreaterThan(50)
+    expect(ACTIONS).toContain('inspection.create')
+  })
 
-  it.each(LOCALES)("%s has a label for every emitted action", (locale) => {
+  it.each(LOCALES)('%s has a label for every emitted action', (locale) => {
     const actions = JSON.parse(
-      fs.readFileSync(path.join(ROOT, "messages", locale, "audit.json"), "utf-8")
-    ).actions as Record<string, string>;
+      fs.readFileSync(path.join(ROOT, 'messages', locale, 'audit.json'), 'utf-8')
+    ).actions as Record<string, string>
 
-    const missing = ACTIONS.filter((a) => !(a.replaceAll(".", "_") in actions));
-    expect(missing, `missing in ${locale}: ${missing.join(", ")}`).toEqual([]);
-  });
+    const missing = ACTIONS.filter((a) => !(a.replaceAll('.', '_') in actions))
+    expect(missing, `missing in ${locale}: ${missing.join(', ')}`).toEqual([])
+  })
 
-  it("keeps every locale on the same set of action keys as English", () => {
+  it('keeps every locale on the same set of action keys as English', () => {
     const keysFor = (locale: string) =>
       Object.keys(
-        JSON.parse(
-          fs.readFileSync(path.join(ROOT, "messages", locale, "audit.json"), "utf-8")
-        ).actions
-      ).sort();
-    const en = keysFor("en");
+        JSON.parse(fs.readFileSync(path.join(ROOT, 'messages', locale, 'audit.json'), 'utf-8'))
+          .actions
+      ).sort()
+    const en = keysFor('en')
     for (const locale of LOCALES) {
-      expect(keysFor(locale), `${locale} drifted from en`).toEqual(en);
+      expect(keysFor(locale), `${locale} drifted from en`).toEqual(en)
     }
-  });
-});
+  })
+})

+ 99 - 0
src/__tests__/i18n/keys-resolve.test.ts

@@ -0,0 +1,99 @@
+import fs from 'node:fs'
+import path from 'node:path'
+import { describe, expect, it } from 'vitest'
+
+/**
+ * Every translation key the code asks for has to exist.
+ *
+ * Parity already proves the twelve locales carry the same keys. It cannot
+ * prove they are the keys anybody wants: a string written into the wrong
+ * namespace passes parity in all twelve and throws MISSING_MESSAGE on the
+ * screen that uses it. That happened to workBoard.technician.standaloneOnly,
+ * which sat one level too high in a file every locale agreed about.
+ */
+
+const ROOT = process.cwd()
+const MESSAGES = path.join(ROOT, 'messages', 'en')
+
+const bundle: Record<string, unknown> = {}
+for (const file of fs.readdirSync(MESSAGES)) {
+  if (file.endsWith('.json')) {
+    bundle[file.replace(/\.json$/, '')] = JSON.parse(
+      fs.readFileSync(path.join(MESSAGES, file), 'utf-8')
+    )
+  }
+}
+
+function resolves(dotted: string): boolean {
+  let node: unknown = bundle
+  for (const part of dotted.split('.')) {
+    if (node == null || typeof node !== 'object' || !(part in (node as object))) return false
+    node = (node as Record<string, unknown>)[part]
+  }
+  return typeof node === 'string'
+}
+
+function sourceFiles(dir: string, out: string[] = []): string[] {
+  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
+    const full = path.join(dir, entry.name)
+    if (entry.isDirectory()) {
+      if (!['node_modules', '__tests__', 'generated'].includes(entry.name)) sourceFiles(full, out)
+    } else if (/\.tsx?$/.test(entry.name)) {
+      out.push(full)
+    }
+  }
+  return out
+}
+
+/**
+ * Keys asked for through a namespace binding that is unambiguous in its file.
+ *
+ * A file that binds the same name twice, usually two components sharing a
+ * module, cannot be read without following scope, so it is skipped rather
+ * than guessed at. Skipping loses coverage; guessing invents failures, and a
+ * test nobody trusts gets deleted.
+ */
+function requestedKeys(): { file: string; key: string }[] {
+  const found: { file: string; key: string }[] = []
+
+  for (const file of sourceFiles(path.join(ROOT, 'src'))) {
+    const src = fs.readFileSync(file, 'utf-8')
+    const counts = new Map<string, number>()
+    const namespaces = new Map<string, string>()
+
+    for (const m of src.matchAll(
+      /const\s+(\w+)\s*=\s*useTranslations\(\s*['"]([\w.]+)['"]\s*\)/g
+    )) {
+      counts.set(m[1], (counts.get(m[1]) ?? 0) + 1)
+      namespaces.set(m[1], m[2])
+    }
+
+    for (const [binding, namespace] of namespaces) {
+      if (counts.get(binding) !== 1) continue
+      const call = new RegExp(`\\b${binding}(?:\\.rich|\\.raw)?\\(\\s*['"]([\\w.]+)['"]`, 'g')
+      for (const m of src.matchAll(call)) {
+        // `t(`prefix.${code}`)` leaves a trailing dot on the literal part.
+        // The key is assembled at runtime and there is nothing to check.
+        if (m[1].endsWith('.')) continue
+        found.push({ file: path.relative(ROOT, file), key: `${namespace}.${m[1]}` })
+      }
+    }
+  }
+  return found
+}
+
+const REQUESTED = requestedKeys()
+
+describe('translation keys the code asks for', () => {
+  it('finds enough of them to be worth running', () => {
+    expect(REQUESTED.length).toBeGreaterThan(200)
+  })
+
+  it('all resolve to a string in English', () => {
+    const missing = [...new Set(REQUESTED.filter((r) => !resolves(r.key)).map((r) => r.key))].sort()
+    expect(
+      missing,
+      `Written into the wrong namespace, or never written:\n  ${missing.join('\n  ')}`
+    ).toEqual([])
+  })
+})

+ 88 - 0
src/__tests__/lib/rate-limit-identity.test.ts

@@ -0,0 +1,88 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+/**
+ * Who a request gets counted as.
+ *
+ * The limiter is only worth anything if a caller cannot choose their own
+ * identity. Production sits behind nginxproxy/nginx-proxy, which overwrites
+ * X-Real-IP from the real connection and appends to X-Forwarded-For. It has no
+ * reason to strip CF-Connecting-IP, so that header arrives exactly as written
+ * by whoever sent it.
+ */
+
+const ATTACKER = { limit: 2, windowMs: 60_000, anonymous: true }
+
+function req(headers: Record<string, string>, path = '/api/v1/tech/org/o/auth/verify') {
+  return new Request(`https://app.torqvoice.com${path}`, { method: 'POST', headers })
+}
+
+async function freshLimiter() {
+  vi.resetModules()
+  return (await import('@/lib/rate-limit')).rateLimit
+}
+
+beforeEach(() => {
+  vi.unstubAllEnvs()
+})
+afterEach(() => {
+  vi.unstubAllEnvs()
+})
+
+describe('client address', () => {
+  it('cannot be chosen by the caller with cf-connecting-ip', async () => {
+    const rateLimit = await freshLimiter()
+    const spoof = (n: number) =>
+      req({ 'x-real-ip': '203.0.113.9', 'cf-connecting-ip': `10.0.0.${n}` })
+
+    expect(rateLimit(spoof(1), ATTACKER)).toBeNull()
+    expect(rateLimit(spoof(2), ATTACKER)).toBeNull()
+    // Third request, third invented address, still the same real caller.
+    expect(rateLimit(spoof(3), ATTACKER)?.status).toBe(429)
+  })
+
+  it('cannot be chosen by prepending to x-forwarded-for either', async () => {
+    const rateLimit = await freshLimiter()
+    // Proxies append, so the entry the proxy added is the last one and
+    // everything before it is whatever the caller typed.
+    const spoof = (n: number) => req({ 'x-forwarded-for': `10.0.0.${n}, 203.0.113.9` })
+
+    expect(rateLimit(spoof(1), ATTACKER)).toBeNull()
+    expect(rateLimit(spoof(2), ATTACKER)).toBeNull()
+    expect(rateLimit(spoof(3), ATTACKER)?.status).toBe(429)
+  })
+
+  it('honours cf-connecting-ip only where Cloudflare really is in front', async () => {
+    vi.stubEnv('TRUST_CF_CONNECTING_IP', 'true')
+    const rateLimit = await freshLimiter()
+
+    // Two genuinely different visitors behind the CDN keep separate budgets.
+    expect(rateLimit(req({ 'cf-connecting-ip': '198.51.100.1' }), ATTACKER)).toBeNull()
+    expect(rateLimit(req({ 'cf-connecting-ip': '198.51.100.2' }), ATTACKER)).toBeNull()
+    expect(rateLimit(req({ 'cf-connecting-ip': '198.51.100.1' }), ATTACKER)).toBeNull()
+    expect(rateLimit(req({ 'cf-connecting-ip': '198.51.100.1' }), ATTACKER)?.status).toBe(429)
+  })
+
+  it('keeps separate budgets for genuinely separate callers', async () => {
+    const rateLimit = await freshLimiter()
+
+    expect(rateLimit(req({ 'x-real-ip': '203.0.113.1' }), ATTACKER)).toBeNull()
+    expect(rateLimit(req({ 'x-real-ip': '203.0.113.2' }), ATTACKER)).toBeNull()
+    expect(rateLimit(req({ 'x-real-ip': '203.0.113.1' }), ATTACKER)).toBeNull()
+    expect(rateLimit(req({ 'x-real-ip': '203.0.113.1' }), ATTACKER)?.status).toBe(429)
+  })
+
+  it('counts one workshop’s technicians separately once they are signed in', async () => {
+    const rateLimit = await freshLimiter()
+    // Eight technicians share one public address on the shop wifi, so an
+    // address-keyed budget would be divided between them.
+    const tech = (token: string) =>
+      req({ 'x-real-ip': '203.0.113.7', authorization: `Bearer ${token}` })
+
+    const opts = { limit: 2, windowMs: 60_000 }
+    expect(rateLimit(tech('alice'), opts)).toBeNull()
+    expect(rateLimit(tech('alice'), opts)).toBeNull()
+    expect(rateLimit(tech('alice'), opts)?.status).toBe(429)
+    // Bob is unaffected by Alice being busy.
+    expect(rateLimit(tech('bob'), opts)).toBeNull()
+  })
+})

+ 24 - 7
src/app/(authenticated)/settings/team/page.tsx

@@ -2,15 +2,27 @@ import { getOrganization } from '@/features/team/Actions/teamActions'
 import { getRoles } from '@/features/team/Actions/getRoles'
 import { getPendingInvitations } from '@/features/team/Actions/getPendingInvitations'
 import { getTechnicianUserIds } from '@/features/team/Actions/setMemberTechnician'
+import { getWorkshopDialCode } from '@/features/team/Actions/createTechnicianAccount'
+import { getStandaloneTechnicians } from '@/features/team/Actions/giveTechnicianTheApp'
 import { TeamSettings } from './team-settings'
 
-export default async function TeamPage() {
-  const [result, rolesResult, invitationsResult, technicianResult] = await Promise.all([
-    getOrganization(),
-    getRoles(),
-    getPendingInvitations(),
-    getTechnicianUserIds(),
-  ])
+export default async function TeamPage({
+  searchParams,
+}: {
+  searchParams: Promise<{ add?: string }>
+}) {
+  // Quick Add sends people here to add somebody, so land them on the question
+  // rather than on a page where they have to find the button again.
+  const { add } = await searchParams
+  const [result, rolesResult, invitationsResult, technicianResult, dialResult, standaloneResult] =
+    await Promise.all([
+      getOrganization(),
+      getRoles(),
+      getPendingInvitations(),
+      getTechnicianUserIds(),
+      getWorkshopDialCode(),
+      getStandaloneTechnicians(),
+    ])
   const orgData = result.success ? result.data : null
   const roles = rolesResult.success && rolesResult.data ? rolesResult.data : []
   const pendingInvitations =
@@ -25,6 +37,11 @@ export default async function TeamPage() {
       roles={roles}
       pendingInvitations={pendingInvitations}
       technicianUserIds={technicianUserIds}
+      startAdding={add === 'true'}
+      dialCode={(dialResult.success && dialResult.data?.dialCode) || ''}
+      standaloneTechnicians={
+        standaloneResult.success && standaloneResult.data ? standaloneResult.data : []
+      }
     />
   )
 }

+ 193 - 172
src/app/(authenticated)/settings/team/team-settings.tsx

@@ -1,6 +1,6 @@
 'use client'
 
-import { useState } from 'react'
+import { useMemo, useState } from 'react'
 import { useRouter } from 'next/navigation'
 import { useTranslations } from 'next-intl'
 import { toast } from 'sonner'
@@ -10,7 +10,6 @@ import { Label } from '@/components/ui/label'
 import { AppCard } from '@/components/app-card'
 import { Badge } from '@/components/ui/badge'
 import { Checkbox } from '@/components/ui/checkbox'
-import { Switch } from '@/components/ui/switch'
 import {
   Select,
   SelectContent,
@@ -21,14 +20,17 @@ import {
 } from '@/components/ui/select'
 import { useGlassModal } from '@/components/glass-modal'
 import { useConfirm } from '@/components/confirm-dialog'
-import { createOrganization, inviteMember, removeMember } from '@/features/team/Actions/teamActions'
-import { sendInvitation } from '@/features/team/Actions/sendInvitation'
+import { createOrganization, removeMember } from '@/features/team/Actions/teamActions'
 import { cancelInvitation } from '@/features/team/Actions/cancelInvitation'
 import { createRole } from '@/features/team/Actions/createRole'
 import { updateRole } from '@/features/team/Actions/updateRole'
 import { deleteRole } from '@/features/team/Actions/deleteRole'
 import { assignRole } from '@/features/team/Actions/assignRole'
-import { setMemberTechnician } from '@/features/team/Actions/setMemberTechnician'
+import { AddPersonDialog } from '@/features/team/Components/AddPersonDialog'
+import { contactFor, TECHNICIAN_ROLE_NAME } from '@/features/team/Lib/technicianRole'
+import { AppSetupCodeDialog } from '@/features/team/Components/AppSetupCodeDialog'
+import { GiveAppDialog } from '@/features/team/Components/GiveAppDialog'
+import { removeTechnicianAccess } from '@/features/team/Actions/removeTechnicianAccess'
 import { permissionGroups, PermissionAction } from '@/lib/permissions'
 
 /**
@@ -55,6 +57,8 @@ import {
   Plus,
   Shield,
   ShieldCheck,
+  Smartphone,
+  Wrench,
   Trash2,
   User,
   Users,
@@ -66,7 +70,7 @@ interface Member {
   role: string
   roleId: string | null
   customRoleName: string | null
-  user: { id: string; name: string; email: string }
+  user: { id: string; name: string; email: string; phone?: string | null }
 }
 
 interface Organization {
@@ -111,6 +115,9 @@ export function TeamSettings({
   currentRole,
   roles = [],
   technicianUserIds = [],
+  startAdding = false,
+  dialCode = '',
+  standaloneTechnicians = [],
   pendingInvitations = [],
 }: {
   organization: Organization | null
@@ -118,6 +125,12 @@ export function TeamSettings({
   roles?: RoleData[]
   /** User ids that already have an active technician record. */
   technicianUserIds?: string[]
+  /** Arrived from Quick Add, so open the dialog rather than the page. */
+  startAdding?: boolean
+  /** The workshop's country code, empty until somebody has supplied one. */
+  dialCode?: string
+  /** On the board, with nobody behind them. */
+  standaloneTechnicians?: { id: string; name: string; color: string }[]
   pendingInvitations?: PendingInvitation[]
 }) {
   const router = useRouter()
@@ -127,41 +140,37 @@ export function TeamSettings({
   const confirm = useConfirm()
   const [loading, setLoading] = useState(false)
   const [orgName, setOrgName] = useState('')
-  const [inviteEmail, setInviteEmail] = useState('')
-  const [inviteRole, setInviteRole] = useState<string>('member')
-  const [inviteRoleId, setInviteRoleId] = useState<string | null>(null)
 
   // Role form state
   const [showRoleForm, setShowRoleForm] = useState(false)
   const [editingRole, setEditingRole] = useState<RoleData | null>(null)
   // Tracked locally so the switch answers the tap immediately. A revalidate
   // round trip is a long time to sit on a toggle that has already moved.
-  const [technicians, setTechnicians] = useState<Set<string>>(() => new Set(technicianUserIds))
-  const [technicianBusy, setTechnicianBusy] = useState<string | null>(null)
-
-  const handleTechnicianToggle = async (userId: string, enabled: boolean) => {
-    setTechnicianBusy(userId)
-    setTechnicians((prev) => {
-      const next = new Set(prev)
-      if (enabled) next.add(userId)
-      else next.delete(userId)
-      return next
-    })
-    const result = await setMemberTechnician({ userId, enabled })
-    if (!result.success) {
-      // Put it back where it was; the server is the one that decides.
-      setTechnicians((prev) => {
-        const next = new Set(prev)
-        if (enabled) next.delete(userId)
-        else next.add(userId)
-        return next
-      })
-      toast.error(result.error || t('team.technicianFailed'))
-    } else {
-      router.refresh()
-    }
-    setTechnicianBusy(null)
-  }
+  /**
+   * Who is a technician, read from the server rather than remembered.
+   *
+   * This was useState seeded from the prop, which only runs on mount. Adding
+   * somebody refreshed the page, the prop arrived with them in it, and the set
+   * ignored it: the new technician's role dropdown then fell through to a role
+   * id with no matching option and rendered blank until a manual reload.
+   */
+  const technicians = useMemo(() => new Set(technicianUserIds), [technicianUserIds])
+  /** The workshop's technician role, if it has one yet. */
+  const technicianRoleId = roles.find((r) => r.name === TECHNICIAN_ROLE_NAME)?.id ?? null
+  /** The member whose app is being set up, and their name for the copy. */
+  const [settingUp, setSettingUp] = useState<{ userId: string; name: string } | null>(null)
+  const [adding, setAdding] = useState(startAdding)
+  const [givingApp, setGivingApp] = useState<{ id: string; name: string } | null>(null)
+
+  /**
+   * The address the technician's app should connect to.
+   *
+   * Configured first, current origin second. They agree in production; in
+   * development the origin is localhost, which is an address the technician's
+   * phone cannot reach.
+   */
+  const workshopUrl =
+    process.env.NEXT_PUBLIC_APP_URL || (typeof window === 'undefined' ? '' : window.location.origin)
 
   const [roleName, setRoleName] = useState('')
   const [roleIsAdmin, setRoleIsAdmin] = useState(false)
@@ -254,15 +263,12 @@ export function TeamSettings({
     }
   }
 
-  const handleAssignRole = async (memberId: string, value: string) => {
-    let role: 'admin' | 'member'
+  const handleAssignRole = async (memberId: string, value: string, member?: Member) => {
+    let role: 'admin' | 'member' | 'technician'
     let roleId: string | null
 
-    if (value === 'admin') {
-      role = 'admin'
-      roleId = null
-    } else if (value === 'member') {
-      role = 'member'
+    if (value === 'admin' || value === 'member' || value === 'technician') {
+      role = value
       roleId = null
     } else {
       // Custom role ID
@@ -270,6 +276,22 @@ export function TeamSettings({
       roleId = value
     }
 
+    // Moving somebody off Technician takes their phone with them, which is not
+    // something to discover from a dropdown.
+    const wasTechnician = member ? technicians.has(member.user.id) : false
+    if (wasTechnician && value !== 'technician') {
+      const ok = await confirm({
+        title: t('team.revokeTechnicianTitle'),
+        description: t('team.revokeTechnicianBody', {
+          name: member?.user.name || member?.user.email || '',
+        }),
+        confirmLabel: t('team.revokeTechnicianConfirm'),
+        destructive: true,
+      })
+      if (!ok) return
+      if (member) await removeTechnicianAccess({ userId: member.user.id })
+    }
+
     const result = await assignRole({ memberId, role, roleId })
     if (result.success) {
       toast.success(t('team.roleAssigned'))
@@ -292,58 +314,6 @@ export function TeamSettings({
     setLoading(false)
   }
 
-  const handleInvite = async (e: React.FormEvent) => {
-    e.preventDefault()
-    if (!inviteEmail.trim()) return
-    setLoading(true)
-    const result = await inviteMember({
-      email: inviteEmail,
-      role: inviteRole,
-      roleId: inviteRoleId || undefined,
-    })
-    if (result.success) {
-      const data = result.data as { invited: boolean; userNotFound?: boolean }
-      if (data.userNotFound) {
-        // User doesn't exist — ask to send invitation email
-        const emailToInvite = inviteEmail
-        const roleToInvite = inviteRole
-        setLoading(false)
-        const ok = await confirm({
-          title: t('team.userNotFoundTitle'),
-          description: t('team.userNotFoundDescription', { email: emailToInvite }),
-          confirmLabel: t('team.sendInvitation'),
-        })
-        if (ok) {
-          setLoading(true)
-          const sendResult = await sendInvitation({
-            email: emailToInvite,
-            role: roleToInvite,
-            roleId: inviteRoleId || undefined,
-          })
-          if (sendResult.success) {
-            setInviteEmail('')
-            router.refresh()
-            modal.open(
-              'success',
-              t('team.invitationSentTitle'),
-              t('team.invitationSentDescription', { email: emailToInvite })
-            )
-          } else {
-            modal.open('error', 'Error', sendResult.error || t('team.failedSendInvitation'))
-          }
-          setLoading(false)
-        }
-      } else {
-        setInviteEmail('')
-        router.refresh()
-        modal.open('success', t('team.invite'), t('team.invited'))
-      }
-    } else {
-      modal.open('error', 'Error', result.error || t('team.failedInvite'))
-    }
-    setLoading(false)
-  }
-
   const handleCancelInvitation = async (invitation: PendingInvitation) => {
     const ok = await confirm({
       title: t('team.cancelInvitation'),
@@ -429,6 +399,14 @@ export function TeamSettings({
           </>
         }
         contentClassName="space-y-4"
+        action={
+          isAdmin ? (
+            <Button size="sm" onClick={() => setAdding(true)}>
+              <Plus className="mr-1 h-4 w-4" />
+              {t('team.addPerson')}
+            </Button>
+          ) : undefined
+        }
       >
         <div className="space-y-2">
           {organization.members.map((member) => (
@@ -438,33 +416,59 @@ export function TeamSettings({
               </div>
               <div className="min-w-0 flex-1">
                 <p className="truncate font-medium text-sm">{member.user.name}</p>
-                <p className="truncate text-xs text-muted-foreground">{member.user.email}</p>
+                {/* The mobile for a mechanic set up at the counter, whose
+                    address is a placeholder nobody can act on, and the email
+                    for everybody else. Whichever one identifies them. */}
+                <p className="truncate text-xs text-muted-foreground">{contactFor(member.user)}</p>
               </div>
               <div className="flex items-center gap-2">
-                {/* The other place this lives is the work board's technician
-                    dialog, which also carries colour, capacity and technicians
-                    with no login. This is the yes-or-no version, on the screen
-                    where someone adds the person in the first place. */}
-                {isAdmin && (
-                  <label className="flex cursor-pointer items-center gap-2 pr-1">
-                    <Switch
-                      checked={technicians.has(member.user.id)}
-                      disabled={technicianBusy === member.user.id}
-                      onCheckedChange={(v) => handleTechnicianToggle(member.user.id, v)}
-                      aria-label={t('team.technician')}
-                    />
-                    <span
-                      className="hidden text-muted-foreground text-xs sm:inline"
-                      title={t('team.technicianHint')}
-                    >
-                      {t('team.technician')}
-                    </span>
-                  </label>
+                {technicians.has(member.user.id) && member.roleId !== technicianRoleId && (
+                  <Badge variant="outline" className="text-xs">
+                    <Wrench className="mr-1 h-3 w-3" />
+                    {t('team.technician')}
+                  </Badge>
+                )}
+                {isAdmin && technicians.has(member.user.id) && (
+                  <Button
+                    variant="ghost"
+                    size="icon"
+                    className="h-8 w-8 text-muted-foreground hover:text-foreground"
+                    onClick={() =>
+                      setSettingUp({
+                        userId: member.user.id,
+                        name: member.user.name || member.user.email,
+                      })
+                    }
+                    aria-label={t('team.setupApp')}
+                    title={t('team.setupApp')}
+                  >
+                    <Smartphone className="h-4 w-4" />
+                  </Button>
                 )}
                 {isOwner && member.role !== 'owner' ? (
                   <Select
-                    value={member.roleId || member.role}
-                    onValueChange={(v) => handleAssignRole(member.id, v)}
+                    value={
+                      // Read from the role they hold, not from whether they
+                      // are on the board.
+                      //
+                      // An install that predates this has technicians holding
+                      // all sorts of roles. Showing Technician for them would
+                      // both misreport what they can do and overwrite it the
+                      // moment anybody touched the field. Their real role shows
+                      // here; the badge beside it says they are also on the
+                      // board.
+                      member.roleId === technicianRoleId
+                        ? 'technician'
+                        : // Never a value with no option behind it, or the
+                          // trigger renders empty and the member looks
+                          // roleless when they are not.
+                          roles.some(
+                              (r) => r.id === member.roleId && r.name !== TECHNICIAN_ROLE_NAME
+                            )
+                          ? (member.roleId as string)
+                          : member.role
+                    }
+                    onValueChange={(v) => handleAssignRole(member.id, v, member)}
                   >
                     <SelectTrigger className="h-8 w-36 text-xs">
                       <SelectValue />
@@ -472,14 +476,23 @@ export function TeamSettings({
                     <SelectContent>
                       <SelectItem value="admin">{t('team.admin')}</SelectItem>
                       <SelectItem value="member">{t('team.member')}</SelectItem>
+                      {/* One answer to one question. Choosing this puts them on
+                          the work board and gives them what the app needs;
+                          choosing anything else takes both away. */}
+                      <SelectItem value="technician">{t('team.technician')}</SelectItem>
                       {roles.length > 0 && (
                         <>
                           <SelectSeparator />
-                          {roles.map((r) => (
-                            <SelectItem key={r.id} value={r.id}>
-                              {r.name}
-                            </SelectItem>
-                          ))}
+                          {roles
+                            // The technician permissions are what the dropdown
+                            // entry above grants, so offering the role again
+                            // underneath is the same choice listed twice.
+                            .filter((r) => r.name !== TECHNICIAN_ROLE_NAME)
+                            .map((r) => (
+                              <SelectItem key={r.id} value={r.id}>
+                                {r.name}
+                              </SelectItem>
+                            ))}
                         </>
                       )}
                     </SelectContent>
@@ -506,58 +519,39 @@ export function TeamSettings({
           ))}
         </div>
 
-        {isAdmin && (
-          <form onSubmit={handleInvite} className="flex items-end gap-3 border-t pt-4">
-            <div className="flex-1 space-y-2">
-              <Label>{t('team.inviteByEmail')}</Label>
-              <Input
-                type="email"
-                placeholder={t('team.inviteEmailPlaceholder')}
-                value={inviteEmail}
-                onChange={(e) => setInviteEmail(e.target.value)}
-                required
-              />
-            </div>
-            <Select
-              value={inviteRoleId ? `custom:${inviteRoleId}` : inviteRole}
-              onValueChange={(v) => {
-                if (v.startsWith('custom:')) {
-                  const id = v.replace('custom:', '')
-                  setInviteRole('member')
-                  setInviteRoleId(id)
-                } else {
-                  setInviteRole(v)
-                  setInviteRoleId(null)
-                }
-              }}
-            >
-              <SelectTrigger className="w-36">
-                <SelectValue />
-              </SelectTrigger>
-              <SelectContent>
-                <SelectItem value="admin">{t('team.admin')}</SelectItem>
-                <SelectItem value="member">{t('team.member')}</SelectItem>
-                {roles.length > 0 && (
-                  <>
-                    <SelectSeparator />
-                    {roles.map((r) => (
-                      <SelectItem key={r.id} value={`custom:${r.id}`}>
-                        {r.name}
-                      </SelectItem>
-                    ))}
-                  </>
+        {/* On the board with nobody behind them.
+            The Add flow creates these, so the page has to show them, or the
+            desk adds somebody and watches them vanish. */}
+        {standaloneTechnicians.length > 0 && (
+          <div className="space-y-2 border-t pt-4">
+            <p className="font-medium text-sm">{t('team.boardOnly')}</p>
+            <p className="text-muted-foreground text-xs">{t('team.boardOnlyHint')}</p>
+            {standaloneTechnicians.map((tech) => (
+              <div key={tech.id} className="flex items-center gap-3 rounded-lg border p-3">
+                <div
+                  className="h-9 w-9 shrink-0 rounded-full"
+                  style={{ backgroundColor: tech.color }}
+                  aria-hidden
+                />
+                <div className="min-w-0 flex-1">
+                  <p className="truncate font-medium text-sm">{tech.name}</p>
+                  <p className="truncate text-muted-foreground text-xs">
+                    {t('team.boardOnlyNoAccount')}
+                  </p>
+                </div>
+                {isAdmin && (
+                  <Button
+                    variant="outline"
+                    size="sm"
+                    onClick={() => setGivingApp({ id: tech.id, name: tech.name })}
+                  >
+                    <Smartphone className="mr-1 h-4 w-4" />
+                    {t('team.giveApp')}
+                  </Button>
                 )}
-              </SelectContent>
-            </Select>
-            <Button type="submit" disabled={loading || !inviteEmail.trim()}>
-              {loading ? (
-                <Loader2 className="mr-2 h-4 w-4 animate-spin" />
-              ) : (
-                <Plus className="mr-1 h-4 w-4" />
-              )}
-              {t('team.invite')}
-            </Button>
-          </form>
+              </div>
+            ))}
+          </div>
         )}
       </AppCard>
 
@@ -857,6 +851,33 @@ export function TeamSettings({
           </div>
         </div>
       </AppCard>
+
+      {/* Configured address first, current origin second. They agree in
+          production; in development the origin is localhost, which is an
+          address the technician's phone cannot reach. */}
+      <AddPersonDialog
+        open={adding}
+        onOpenChange={setAdding}
+        workshopUrl={workshopUrl}
+        dialCode={dialCode}
+        roles={roles}
+        onChanged={() => router.refresh()}
+      />
+
+      <GiveAppDialog
+        technician={givingApp}
+        workshopUrl={workshopUrl}
+        dialCode={dialCode}
+        onClose={() => setGivingApp(null)}
+        onChanged={() => router.refresh()}
+      />
+
+      <AppSetupCodeDialog
+        userId={settingUp?.userId ?? null}
+        memberName={settingUp?.name ?? ''}
+        workshopUrl={workshopUrl}
+        onClose={() => setSettingUp(null)}
+      />
     </div>
   )
 }

+ 73 - 0
src/app/(public)/app-setup/app-setup-landing.tsx

@@ -0,0 +1,73 @@
+'use client'
+
+import { useEffect, useState } from 'react'
+import Image from 'next/image'
+import { useTranslations } from 'next-intl'
+import { Smartphone } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+
+/**
+ * Reads the code out of the fragment and tells the technician what to do.
+ *
+ * The fragment, deliberately: it never leaves the browser, so the code stays
+ * out of server logs, out of any Referer, and out of the analytics on this
+ * page. That also means this has to be a client component, because the server
+ * rendering it cannot see the code at all.
+ */
+export function AppSetupLanding() {
+  const t = useTranslations('settings')
+  const [code, setCode] = useState<string | null>(null)
+
+  useEffect(() => {
+    const raw = decodeURIComponent(window.location.hash.replace(/^#/, '')).trim()
+    // Only what a code can look like. A fragment carrying anything else is
+    // not something to render back onto the page.
+    setCode(/^[A-Z0-9]{6,12}$/i.test(raw) ? raw.toUpperCase() : null)
+  }, [])
+
+  const display = code ? `${code.slice(0, 4)}-${code.slice(4)}` : null
+
+  return (
+    <div className="mx-auto flex min-h-screen max-w-md flex-col justify-center p-6">
+      <div className="rounded-xl border bg-background p-6 shadow-sm">
+        <div className="flex items-center gap-3 border-b pb-4">
+          <Image
+            src="/torqvoice_app_logo.png"
+            alt="Torqvoice"
+            width={32}
+            height={32}
+            className="object-contain"
+          />
+          <span className="font-bold text-lg uppercase tracking-wider">Torqvoice</span>
+        </div>
+
+        <h1 className="pt-6 font-semibold text-xl">{t('team.setupPageTitle')}</h1>
+        <p className="pt-2 text-muted-foreground text-sm">{t('team.setupPageBody')}</p>
+
+        {display && (
+          <div className="mt-6 rounded-lg border bg-muted/40 p-4 text-center">
+            <p className="text-muted-foreground text-xs">{t('team.setupPageCode')}</p>
+            <p className="pt-1 font-mono font-semibold text-2xl tracking-[0.2em]">{display}</p>
+          </div>
+        )}
+
+        {/* Opens the app when it is installed, and does nothing visible when it
+            is not, which is why the instruction above stands on its own and
+            this is the second thing on the page rather than the first. */}
+        {code && (
+          <Button
+            className="mt-4 w-full"
+            onClick={() => {
+              window.location.href = `torqvoicetech://setup-code?code=${encodeURIComponent(code)}&url=${encodeURIComponent(window.location.origin)}`
+            }}
+          >
+            <Smartphone className="mr-2 h-4 w-4" />
+            {t('team.setupPageOpenApp')}
+          </Button>
+        )}
+
+        <p className="pt-6 text-muted-foreground text-xs">{t('team.setupPageExpiry')}</p>
+      </div>
+    </div>
+  )
+}

+ 21 - 0
src/app/(public)/app-setup/page.tsx

@@ -0,0 +1,21 @@
+import type { Metadata } from 'next'
+import { AppSetupLanding } from './app-setup-landing'
+
+export const metadata: Metadata = {
+  title: 'Set up Torqvoice Tech',
+  // Nothing here is worth indexing, and the page only means anything to
+  // somebody holding a code that dies in ten minutes.
+  robots: { index: false, follow: false },
+}
+
+/**
+ * Where a technician's own camera lands when they scan the desk's QR.
+ *
+ * The app's scanner reads that QR directly and never comes here. This page
+ * exists because the first thing anybody does with a QR is point their phone's
+ * camera at it, and without a page behind the URL that gesture produces a
+ * text editor full of JSON and a technician who thinks it is broken.
+ */
+export default function AppSetupPage() {
+  return <AppSetupLanding />
+}

+ 14 - 17
src/app/api/public/portal/[orgId]/auth/logout/route.ts

@@ -1,26 +1,23 @@
-import { NextResponse } from "next/server";
-import { cookies } from "next/headers";
-import { db } from "@/lib/db";
-import { CUSTOMER_SESSION_COOKIE } from "@/lib/customer-session";
+import { NextResponse } from 'next/server'
+import { cookies } from 'next/headers'
+import { db } from '@/lib/db'
+import { CUSTOMER_SESSION_COOKIE } from '@/lib/customer-session'
 
-export async function POST(
-  _request: Request,
-  { params }: { params: Promise<{ orgId: string }> },
-) {
-  await params;
-  const cookieStore = await cookies();
-  const token = cookieStore.get(CUSTOMER_SESSION_COOKIE)?.value;
+export async function POST(_request: Request, { params }: { params: Promise<{ orgId: string }> }) {
+  await params
+  const cookieStore = await cookies()
+  const token = cookieStore.get(CUSTOMER_SESSION_COOKIE)?.value
 
   if (token) {
-    await db.customerSession
-      .delete({ where: { token } })
-      .catch(() => { /* session may already be deleted */ });
+    await db.customerSession.delete({ where: { token } }).catch(() => {
+      /* session may already be deleted */
+    })
   }
 
   cookieStore.delete({
     name: CUSTOMER_SESSION_COOKIE,
-    path: "/portal",
-  });
+    path: '/portal',
+  })
 
-  return NextResponse.json({ success: true });
+  return NextResponse.json({ success: true })
 }

+ 1 - 1
src/app/api/public/portal/[orgId]/auth/request/route.ts

@@ -17,7 +17,7 @@ function escapeHtml(value: string): string {
 }
 
 export async function POST(request: Request, { params }: { params: Promise<{ orgId: string }> }) {
-  const rateLimitResponse = rateLimit(request, { limit: 5, windowMs: 60_000 })
+  const rateLimitResponse = rateLimit(request, { limit: 5, windowMs: 60_000, anonymous: true })
   if (rateLimitResponse) return rateLimitResponse
 
   const { orgId: orgParam } = await params

+ 4 - 4
src/app/api/public/portal/[orgId]/auth/sms-request/route.ts

@@ -1,6 +1,6 @@
 import { NextResponse } from 'next/server'
-import { randomInt } from 'crypto'
 import { db } from '@/lib/db'
+import { generatePortalCode, hashPortalCode } from '@/lib/portal-code'
 import { rateLimit } from '@/lib/rate-limit'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { resolvePortalOrg } from '@/lib/portal-slug'
@@ -8,7 +8,7 @@ import { getOrgSmsProvider, sendOrgSms } from '@/lib/sms'
 import { getPhoneLookupVariants, normalizePortalPhone } from '@/lib/portal-phone'
 
 export async function POST(request: Request, { params }: { params: Promise<{ orgId: string }> }) {
-  const rateLimitResponse = rateLimit(request, { limit: 5, windowMs: 60_000 })
+  const rateLimitResponse = rateLimit(request, { limit: 5, windowMs: 60_000, anonymous: true })
   if (rateLimitResponse) return rateLimitResponse
 
   const { orgId: orgParam } = await params
@@ -79,11 +79,11 @@ export async function POST(request: Request, { params }: { params: Promise<{ org
     }
 
     // Generate a 6-digit code
-    const code = String(randomInt(0, 1_000_000)).padStart(6, '0')
+    const code = generatePortalCode()
 
     await db.customerSmsCode.create({
       data: {
-        code,
+        code: hashPortalCode(code),
         phone: e164,
         organizationId: orgId,
         expiresAt: new Date(Date.now() + 15 * 60 * 1000),

+ 36 - 2
src/app/api/public/portal/[orgId]/auth/sms-verify/route.ts

@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
 import { randomBytes } from 'crypto'
 import { cookies } from 'next/headers'
 import { db } from '@/lib/db'
+import { PORTAL_CODE_MAX_ATTEMPTS, portalCodeMatches } from '@/lib/portal-code'
 import { rateLimit } from '@/lib/rate-limit'
 import { CUSTOMER_SESSION_COOKIE, CUSTOMER_SESSION_DURATION } from '@/lib/customer-session'
 import { resolvePortalOrg } from '@/lib/portal-slug'
@@ -9,7 +10,7 @@ import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { getPhoneLookupVariants, normalizePortalPhone } from '@/lib/portal-phone'
 
 export async function POST(request: Request, { params }: { params: Promise<{ orgId: string }> }) {
-  const rateLimitResponse = rateLimit(request, { limit: 5, windowMs: 60_000 })
+  const rateLimitResponse = rateLimit(request, { limit: 5, windowMs: 60_000, anonymous: true })
   if (rateLimitResponse) return rateLimitResponse
 
   const { orgId: orgParam } = await params
@@ -55,10 +56,17 @@ export async function POST(request: Request, { params }: { params: Promise<{ org
       )
     }
 
+    /**
+     * The live code for this number, found before the digits are looked at.
+     *
+     * Matching on the code as part of the query meant a wrong guess belonged
+     * to nobody, so nothing could count it, and the only thing standing
+     * between a guesser and a six digit space was a limit keyed on their
+     * address. Finding the row first lets the attempt land on it.
+     */
     const codeRow = await db.customerSmsCode.findFirst({
       where: {
         phone: e164,
-        code,
         organizationId: orgId,
         usedAt: null,
         expiresAt: { gt: new Date() },
@@ -73,6 +81,32 @@ export async function POST(request: Request, { params }: { params: Promise<{ org
       )
     }
 
+    if (codeRow.attempts >= PORTAL_CODE_MAX_ATTEMPTS) {
+      return NextResponse.json(
+        { success: false, error: 'Invalid or expired code' },
+        { status: 400 }
+      )
+    }
+
+    if (!portalCodeMatches(codeRow.code, code)) {
+      const { attempts } = await db.customerSmsCode.update({
+        where: { id: codeRow.id },
+        data: { attempts: { increment: 1 } },
+        select: { attempts: true },
+      })
+      // Spent rather than left to expire, so waiting out the rate limiter
+      // does not buy another five guesses at the same code.
+      if (attempts >= PORTAL_CODE_MAX_ATTEMPTS) {
+        await db.customerSmsCode
+          .update({ where: { id: codeRow.id }, data: { usedAt: new Date() } })
+          .catch(() => undefined)
+      }
+      return NextResponse.json(
+        { success: false, error: 'Invalid or expired code' },
+        { status: 400 }
+      )
+    }
+
     // Mark the code as used
     await db.customerSmsCode.update({
       where: { id: codeRow.id },

+ 1 - 1
src/app/api/v1/tech/health/route.ts

@@ -13,7 +13,7 @@ import { MIN_APP_VERSION } from '@/lib/tech-app-version'
  * of server, and it speaks v1".
  */
 export async function GET(request: Request) {
-  const limited = rateLimit(request, { limit: 20, windowMs: 60_000 })
+  const limited = rateLimit(request, { limit: 20, windowMs: 60_000, anonymous: true })
   if (limited) return limited
 
   return NextResponse.json({

+ 199 - 0
src/app/api/v1/tech/org/[orgId]/auth/request/route.ts

@@ -0,0 +1,199 @@
+import { NextResponse } from 'next/server'
+import { db } from '@/lib/db'
+import {
+  generateLoginCode,
+  hashLoginCode,
+  LOGIN_CODE_TTL_MS,
+  loginMessage,
+} from '@/features/technician-auth/Lib/loginCode'
+import { getOrgFromAddress, sendOrgMail } from '@/lib/email'
+import { rateLimit } from '@/lib/rate-limit'
+import { normalizeOrgPhone, sendOrgSms } from '@/lib/sms'
+
+/**
+ * Sends a technician a code to sign back in with.
+ *
+ * The organisation is in the path, not in the body and never inferred from
+ * the number, so the lookup is `this phone, in this workshop` and cannot be
+ * anything else. A technician's app knows which workshop it belongs to from
+ * the day the desk set it up, and keeps knowing through a sign-out.
+ *
+ * Answers identically whether the number belongs to anybody or not. Otherwise
+ * this is a way to ask a workshop whether it employs a given phone number.
+ */
+
+/** One shape for both lookups, so the send below does not have to care which
+ * of them found the technician. */
+const TECHNICIAN_SELECT = { id: true, user: { select: { email: true } } } as const
+
+export async function POST(request: Request, { params }: { params: Promise<{ orgId: string }> }) {
+  const limited = rateLimit(request, { limit: 5, windowMs: 60_000, anonymous: true })
+  if (limited) return limited
+
+  const { orgId } = await params
+
+  let identifier = ''
+  let channel: 'sms' | 'email' = 'sms'
+  try {
+    const body = (await request.json()) as { phone?: unknown; email?: unknown }
+    if (typeof body.email === 'string' && body.email.trim()) {
+      identifier = body.email.trim().toLowerCase()
+      channel = 'email'
+    } else if (typeof body.phone === 'string' && body.phone.trim()) {
+      identifier = body.phone.trim()
+    }
+  } catch {
+    /* nothing usable in the body, answered exactly like anything else */
+  }
+  if (!identifier) return ok(channel)
+
+  const org = await db.organization.findUnique({
+    where: { id: orgId },
+    select: { id: true, name: true },
+  })
+  if (!org) return ok(channel)
+
+  const technician =
+    channel === 'email'
+      ? await db.technician.findFirst({
+          where: {
+            organizationId: org.id,
+            isActive: true,
+            userId: { not: null },
+            user: { email: identifier },
+          },
+          select: TECHNICIAN_SELECT,
+        })
+      : await findByPhone(org.id, identifier)
+
+  // Nothing found, or found and unreachable. Same answer either way.
+  if (!technician) return ok(channel)
+
+  const code = generateLoginCode()
+
+  // One live code per technician. Asking again should replace, not accumulate:
+  // two valid codes for one account is one more than anybody asked for, and a
+  // technician who taps resend expects the newest message to be the one that
+  // works.
+  await db.technicianLoginCode.deleteMany({
+    where: { technicianId: technician.id, usedAt: null },
+  })
+  await db.technicianLoginCode.create({
+    data: {
+      codeHash: hashLoginCode(code),
+      channel,
+      expiresAt: new Date(Date.now() + LOGIN_CODE_TTL_MS),
+      technicianId: technician.id,
+      organizationId: org.id,
+    },
+  })
+
+  // Deliberately not awaited.
+  //
+  // Reaching a provider takes a few hundred milliseconds and not reaching one
+  // takes none, so awaiting it makes a match measurably slower than a miss,
+  // and hands back on the clock exactly what the response body refuses to say.
+  // Nothing after this needs the result: the code is stored, so a message that
+  // arrives late still works.
+  void deliver(channel, org.id, org.name, identifier, technician.user?.email ?? null, code)
+
+  return ok(channel)
+}
+
+/**
+ * Finds a technician by the number they were given, however it was written.
+ *
+ * A desk types `912 34 567` and a technician types `+4791234567`, and both
+ * mean the same person. The workshop's own default country code is what
+ * bridges them, which is why this compares normalised numbers rather than
+ * strings.
+ *
+ * The number lives on the person, next to their name and their email. The
+ * scoping is in this query instead: only technicians of this workshop, only
+ * active ones, so a number is never asked about on its own.
+ */
+async function findByPhone(organizationId: string, phone: string) {
+  const e164 = await normalizeOrgPhone(organizationId, phone)
+  if (!e164) return null
+
+  const candidates = await db.technician.findMany({
+    where: {
+      organizationId,
+      isActive: true,
+      userId: { not: null },
+      user: { phone: { not: null } },
+    },
+    select: { ...TECHNICIAN_SELECT, user: { select: { email: true, phone: true } } },
+  })
+
+  const matches = await Promise.all(
+    candidates.map(async (t) => ({
+      technician: t,
+      e164: t.user?.phone ? await normalizeOrgPhone(organizationId, t.user.phone) : null,
+    }))
+  )
+  return matches.find((m) => m.e164 === e164)?.technician ?? null
+}
+
+async function deliver(
+  channel: 'sms' | 'email',
+  organizationId: string,
+  workshop: string,
+  phone: string,
+  email: string | null,
+  code: string
+) {
+  try {
+    if (channel === 'email') {
+      if (!email) return
+      await sendOrgMail(organizationId, {
+        from: await getOrgFromAddress(organizationId),
+        to: email,
+        // The code in the subject, so it is readable from the notification
+        // without opening anything.
+        subject: `${code} is your Torqvoice Tech sign-in code`,
+        html: emailBody(code, workshop),
+      })
+    } else {
+      await sendOrgSms(organizationId, { to: phone, body: loginMessage(code, workshop) })
+    }
+  } catch (error) {
+    // Never the error object itself. Providers quote the request back in their
+    // failure messages, and the request contains a live code.
+    console.error(
+      `[tech-auth] could not deliver a ${channel} login code:`,
+      error instanceof Error ? error.name : 'unknown error'
+    )
+  }
+}
+
+/** Plain and short. A sign-in code that arrives dressed as marketing is a
+ * sign-in code somebody hesitates over. */
+function emailBody(code: string, workshop: string): string {
+  return [
+    '<div style="font-family:system-ui,-apple-system,sans-serif;font-size:15px;line-height:1.5">',
+    `<p>Your Torqvoice Tech sign-in code for <strong>${escapeHtml(workshop)}</strong>:</p>`,
+    `<p style="font-size:30px;font-weight:600;letter-spacing:6px;margin:20px 0">${code}</p>`,
+    '<p>It expires in five minutes and can be used once.</p>',
+    '<p style="color:#666;font-size:13px">If you did not ask for this, you can ignore it.</p>',
+    '</div>',
+  ].join('')
+}
+
+function escapeHtml(value: string): string {
+  return value.replace(
+    /[&<>"']/g,
+    (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c] as string
+  )
+}
+
+/**
+ * The same answer for every outcome, byte for byte.
+ *
+ * `channel` echoes what was asked for and never what was found. It used to be
+ * null when nothing matched, which made this endpoint a way of asking a
+ * workshop whether it employs a given phone number.
+ */
+function ok(channel: 'sms' | 'email') {
+  return NextResponse.json({ data: { sent: true, channel } })
+}

+ 181 - 0
src/app/api/v1/tech/org/[orgId]/auth/verify/route.ts

@@ -0,0 +1,181 @@
+import { timingSafeEqual } from 'node:crypto'
+import { NextResponse } from 'next/server'
+import { auth } from '@/lib/auth'
+import { db } from '@/lib/db'
+import {
+  hashLoginCode,
+  MAX_ATTEMPTS,
+  normalizeLoginCode,
+} from '@/features/technician-auth/Lib/loginCode'
+import { logAudit } from '@/lib/audit'
+import { rateLimit } from '@/lib/rate-limit'
+import { normalizeOrgPhone } from '@/lib/sms'
+
+/**
+ * Turns a code into a session.
+ *
+ * Scoped to the organisation in the path throughout, so a code issued by one
+ * workshop cannot be presented to another even if the same person works at
+ * both. The technician is re-read at this moment rather than trusted from
+ * when the code was sent: somebody deactivated in between must not get in.
+ */
+
+export async function POST(request: Request, { params }: { params: Promise<{ orgId: string }> }) {
+  const limited = rateLimit(request, { limit: 10, windowMs: 60_000, anonymous: true })
+  if (limited) return limited
+
+  const { orgId } = await params
+
+  let code = ''
+  let identifier = ''
+  let channel: 'sms' | 'email' = 'sms'
+  try {
+    const body = (await request.json()) as { code?: unknown; phone?: unknown; email?: unknown }
+    code = normalizeLoginCode(typeof body.code === 'string' ? body.code : '')
+    if (typeof body.email === 'string' && body.email.trim()) {
+      identifier = body.email.trim().toLowerCase()
+      channel = 'email'
+    } else if (typeof body.phone === 'string' && body.phone.trim()) {
+      identifier = body.phone.trim()
+    }
+  } catch {
+    return bad('invalid_code')
+  }
+  if (code.length !== 6 || !identifier) return bad('invalid_code')
+
+  /**
+   * Whose code this is meant to be, before looking at the code at all.
+   *
+   * The identifier is what makes a wrong guess cost the guesser rather than
+   * everybody. Matching on the hash alone meant a miss belonged to nobody, so
+   * a wrong guess had to age every live code in the workshop to cost anything,
+   * and five wrong guesses from anyone who knew the workshop id locked out
+   * every technician in the building. Now an attempt lands on one code: the
+   * one belonging to the person the caller claims to be.
+   */
+  const technicianId = await resolveTechnician(orgId, channel, identifier)
+  if (!technicianId) return bad('invalid_code')
+
+  const candidate = await db.technicianLoginCode.findFirst({
+    where: { organizationId: orgId, technicianId, usedAt: null },
+    select: {
+      id: true,
+      codeHash: true,
+      expiresAt: true,
+      attempts: true,
+      technician: {
+        select: { id: true, isActive: true, userId: true, organizationId: true },
+      },
+    },
+  })
+
+  if (!candidate) return bad('invalid_code')
+  if (candidate.expiresAt.getTime() < Date.now()) return bad('code_expired')
+  if (candidate.attempts >= MAX_ATTEMPTS) return bad('too_many_attempts')
+
+  if (!timingSafeEqualHex(candidate.codeHash, hashLoginCode(code))) {
+    const { attempts } = await db.technicianLoginCode.update({
+      where: { id: candidate.id },
+      data: { attempts: { increment: 1 } },
+      select: { attempts: true },
+    })
+    // Spent rather than left to expire, so a guesser cannot start again on the
+    // same code by waiting out the rate limiter.
+    if (attempts >= MAX_ATTEMPTS) {
+      await db.technicianLoginCode.delete({ where: { id: candidate.id } }).catch(() => undefined)
+    }
+    return bad('invalid_code')
+  }
+
+  const technician = candidate.technician
+  // Belt and braces on the scoping. The query above is already org-scoped;
+  // this makes a future refactor that loosens it fail loudly here.
+  if (technician.organizationId !== orgId) return bad('invalid_code')
+  if (!technician.isActive || !technician.userId) return bad('not_technician')
+
+  const membership = await db.organizationMember.findFirst({
+    where: { userId: technician.userId, organizationId: orgId },
+    select: { id: true },
+  })
+  if (!membership) return bad('not_technician')
+
+  // Spent before anything is minted. A failure after this point costs the
+  // technician one more text message, which is the safe direction to fail in.
+  const burned = await db.technicianLoginCode.updateMany({
+    where: { id: candidate.id, usedAt: null },
+    data: { usedAt: new Date() },
+  })
+  if (burned.count === 0) return bad('invalid_code')
+
+  const ctx = await auth.$context
+  const session = await ctx.internalAdapter.createSession(technician.userId, false)
+
+  logAudit(
+    { userId: technician.userId, organizationId: orgId },
+    {
+      action: 'auth.technicianCodeSignIn',
+      message: 'Signed in to the technician app with a one-time code',
+      metadata: { technicianId: technician.id },
+    }
+  ).catch(() => {
+    /* best-effort, as everywhere else */
+  })
+
+  return NextResponse.json({
+    data: { token: session.token, organizationId: orgId },
+  })
+}
+
+/**
+ * Turns whoever the caller says they are into a technician of this workshop.
+ *
+ * Org-scoped like everything else here, so an identifier is never a question
+ * asked of the whole platform.
+ */
+async function resolveTechnician(
+  organizationId: string,
+  channel: 'sms' | 'email',
+  identifier: string
+): Promise<string | null> {
+  if (channel === 'email') {
+    const found = await db.technician.findFirst({
+      where: { organizationId, isActive: true, userId: { not: null }, user: { email: identifier } },
+      select: { id: true },
+    })
+    return found?.id ?? null
+  }
+
+  const e164 = await normalizeOrgPhone(organizationId, identifier)
+  if (!e164) return null
+
+  const candidates = await db.technician.findMany({
+    where: {
+      organizationId,
+      isActive: true,
+      userId: { not: null },
+      user: { phone: { not: null } },
+    },
+    select: { id: true, user: { select: { phone: true } } },
+  })
+  const matches = await Promise.all(
+    candidates.map(async (t) => ({
+      id: t.id,
+      e164: t.user?.phone ? await normalizeOrgPhone(organizationId, t.user.phone) : null,
+    }))
+  )
+  return matches.find((m) => m.e164 === e164)?.id ?? null
+}
+
+/**
+ * Compares two hex digests without giving away where they start to differ.
+ *
+ * Overkill against a six digit code guessed over a network, and free.
+ */
+function timingSafeEqualHex(a: string, b: string): boolean {
+  if (a.length !== b.length) return false
+  return timingSafeEqual(Buffer.from(a, 'hex'), Buffer.from(b, 'hex'))
+}
+
+function bad(code: string) {
+  return NextResponse.json({ error: { code } }, { status: 400 })
+}

+ 120 - 0
src/app/api/v1/tech/setup/redeem/route.ts

@@ -0,0 +1,120 @@
+import { NextResponse } from 'next/server'
+import { auth } from '@/lib/auth'
+import { db } from '@/lib/db'
+import { hashSetupCode, normalizeSetupCode } from '@/features/team/Lib/appSetupCode'
+import { logAudit } from '@/lib/audit'
+import { notificationBus } from '@/lib/notification-bus'
+import { rateLimit } from '@/lib/rate-limit'
+
+/**
+ * Exchanges a one-time setup code for a session, so a technician's phone can
+ * join a workshop without typing a URL or a password.
+ *
+ * Unauthenticated by necessity: the phone has nothing to authenticate with
+ * yet, which is the entire problem being solved. That makes this the one
+ * endpoint on the tech API that anybody on the internet can reach with a
+ * guess, so it is rate limited harder than anything else and says as little
+ * as it can get away with.
+ */
+
+/** Deliberately tight. Nobody types a code five times in a minute by hand, and
+ * an attacker guessing at 6.5e11 possibilities needs rather more than that. */
+const LIMIT = { limit: 5, windowMs: 60_000, anonymous: true }
+
+export async function POST(request: Request) {
+  const limited = rateLimit(request, LIMIT)
+  if (limited) return limited
+
+  let code: string
+  try {
+    const body = (await request.json()) as { code?: unknown }
+    code = normalizeSetupCode(typeof body.code === 'string' ? body.code : '')
+  } catch {
+    return bad('invalid_code')
+  }
+
+  if (!code) return bad('invalid_code')
+
+  const record = await db.technicianSetupCode.findUnique({
+    where: { codeHash: hashSetupCode(code) },
+    select: {
+      id: true,
+      userId: true,
+      organizationId: true,
+      expiresAt: true,
+      usedAt: true,
+      organization: { select: { name: true } },
+    },
+  })
+
+  // Three different answers, because they need three different actions from
+  // the person holding the phone: ask for a new one, ask for a new one, or
+  // check what they typed. None of them says whether a code exists for
+  // somebody else, which is the only thing worth hiding here.
+  if (!record) return bad('invalid_code')
+  if (record.usedAt) return bad('code_used')
+  if (record.expiresAt.getTime() < Date.now()) return bad('code_expired')
+
+  // Still a technician, still in the workshop. A code issued this morning
+  // should not sign somebody in this afternoon if they were removed in
+  // between, and checking at redemption is the only moment that can catch it.
+  const technician = await db.technician.findFirst({
+    where: { userId: record.userId, organizationId: record.organizationId, isActive: true },
+    select: { id: true, name: true },
+  })
+  if (!technician) return bad('not_technician')
+
+  // Burn it first. If the session mint below fails, the code is still spent,
+  // which is the safe direction to fail in: the desk issues another one.
+  const burned = await db.technicianSetupCode.updateMany({
+    where: { id: record.id, usedAt: null },
+    data: { usedAt: new Date() },
+  })
+  // Two phones scanning the same screen at once. Exactly one of them wins.
+  if (burned.count === 0) return bad('code_used')
+
+  const ctx = await auth.$context
+  const session = await ctx.internalAdapter.createSession(record.userId, false)
+
+  logAudit(
+    { userId: record.userId, organizationId: record.organizationId },
+    {
+      action: 'auth.appSetupRedeemed',
+      message: 'Signed in to the technician app with a setup code',
+      metadata: { technicianId: technician.id },
+    }
+  ).catch(() => {
+    /* best-effort, as everywhere else */
+  })
+
+  /**
+   * Tells the desk the phone is in.
+   *
+   * The screen holding the QR has no other way to know it worked: the desk
+   * operator is watching a technician's phone from the wrong side, and closing
+   * the dialog on a guess is how somebody ends up unsure whether to issue
+   * another code. The scan itself is the confirmation, so it should be the
+   * thing that ends the dialog.
+   */
+  notificationBus.emit('workboard', {
+    type: 'technician_app_connected',
+    organizationId: record.organizationId,
+    userId: record.userId,
+    technicianId: technician.id,
+    name: technician.name,
+  })
+
+  return NextResponse.json({
+    data: {
+      token: session.token,
+      organizationId: record.organizationId,
+      workshop: record.organization.name,
+    },
+  })
+}
+
+function bad(code: string) {
+  // 400 rather than 401 throughout: there is no credential to re-present, so
+  // nothing here is a challenge to authenticate.
+  return NextResponse.json({ error: { code } }, { status: 400 })
+}

Некоторые файлы не были показаны из-за большого количества измененных файлов