Compare commits

...

37 Commits

Author SHA1 Message Date
github-actions[bot] 6fbe10fe63 chore: bump version to 5.1.3 [skip ci] 2026-11-10 09:56:54 +00:00
Maurice 278871a31e chore(theme): the login's fixed light colours move with its code step into MfaCodeStep
The login card is a fixed light surface whose text colours are written out,
not tokens. Four of them moved with the two-factor step, so the baseline moves
them too: 58 to 54 for the page, 4 for the new component, the same total.
2026-11-10 10:23:53 +01:00
Maurice bdcef33a3e test(bookings): two test names no longer break their string on an apostrophe 2026-11-10 10:10:24 +01:00
John Jahn 8e8cff8105 fix(reservations): keep day_id on partial updates of time-only bookings
A partial update that left day_id out (a notes-only PUT, an MCP
update_reservation or update_transport call) re-derived the day from the
stored reservation_time whenever one was set. A time-only value such as
"12:00" resolves to no day, so the booking's day_id was written as null and
it vanished from its day in the planner. The end_day_id branch did the same
when a time-only reservation_end_time was sent.

The day is now derived only when the time carries a date that resolves to a
trip day; otherwise an omitted day_id or end_day_id keeps the stored value.
A dated time still re-pins the day, an explicit day_id null still clears it,
and a cleared end time still clears end_day_id. REST and both MCP update
tools share updateInTx, so the fix covers all three.

Tests: service unit cases (RESV-SVC-044..048), an e2e case per behaviour
on PUT /api/trips/:tripId/reservations/:id, and an update_reservation MCP
case, all seeded with the ORM factories.

Fixes #3025
2026-11-10 10:05:22 +01:00
Maurice 6b086eaed5 refactor: the login's code step and the reservation row shapes get modules of their own
Both files had grown past their size limit with this round's fixes. The
two-factor step moves to pages/login/MfaCodeStep.tsx and the reservation row
types to reservation-rows.ts, re-exported from the repository, and both size
baselines come down to match.
2026-11-10 09:55:36 +01:00
Maurice 0ac06555cb test(native): reset the file preview mock between the file opening tests
The second test asserted the preview was not called while the first one's call
was still counted, so it failed whenever both ran in order.
2026-11-10 09:55:36 +01:00
Maurice 1b87c62847 chore: keep the API client within its size and the native API minimum module-private 2026-11-10 09:55:36 +01:00
Maurice 98eae61b90 chore(plugin-sdk): regenerate the host facts for the stay times on reservations 2026-11-10 09:50:30 +01:00
Maurice 19956f2742 fix(native): a leg without an arrival day lands on the day it leaves in the Live Activity 2026-11-10 09:50:30 +01:00
Maurice 7506eaa4aa fix(native): the sign-in bar's room for the keyboard holds, and Android 7 to 10 see the keyboard
On iOS the keyboard plugin sizes the body and zeroes the scroll inset in a
frame update that runs after the keyboard is up, so the room made for it was
taken back. The inset is now kept while a sign-in page shows, the body gets its
height back after that update, and with the keyboard down the page can scroll
past the bar. On Android before 11 the keyboard went unseen in the insets
Capacitor passes on, so the bar stayed over the form; it is read off the
window's own insets now.
2026-11-10 09:50:29 +01:00
Maurice 57c9c9d5f2 style: format the Japanese and Korean app strings and two tests as Prettier wants them 2026-11-10 09:43:01 +01:00
Maurice 0b3d2873c2 feat(native): a proxy's sign-in page in the app has a way out and says what cannot work there
Behind Pangolin or authentik the app shows the proxy's sign-in page itself, and
nothing on it led back to the address screen: a failed passkey left users stuck
on every launch. A bar over that page now offers to change the server and says
that passkeys and security keys do not work there, as a web view cannot use them
for a domain the app is not tied to. It steps aside while the keyboard is up.
On iOS the page also gets the room the keyboard takes, so a button under it can
be scrolled into view. The address screen hands the bar its words in the user's
language when it connects.
2026-11-10 09:36:50 +01:00
Maurice 1454b58ea1 fix(mobile): quick swipes through the days no longer stop after two
The pager held the selected day and two either side, and moved that window
only once the strip had come to rest. Swiping on before the snap settled ran
into the end of the strip after two days and sprang back until the animation
was over. Every day now has its page, only the selection and two either side
draw their content, so a run of swipes goes as far as the trip does.
2026-11-10 09:30:29 +01:00
Maurice 2ab4459bc3 fix(native): the Live Activity keeps a day with flights ahead open instead of calling it done
A booking with stopovers was one stop at its first departure, and a stop
counted as done the moment it began. With a car at one and a connecting flight
at two, the Live Activity said all done for today mid-afternoon. Each leg is
now a stop of its own, as the planner shows it, and a flight or anything else
with an end time counts as under way until it lands or ends.
2026-11-10 09:29:40 +01:00
Maurice 6ebb92af6c fix(bookings): a hotel booking's card shows check-in and check-out when only the stay has them
The edit form fell back to the linked stay's times, the cards read only the
booking's metadata, so a hotel showed 15:00 and 11:00 when edited and no times
in the list. The reservation list now carries the stay's check-in window and
check-out, and the desktop facts and the phone card fall back to them. On the
phone they come first, so a price or a seat cannot push them off the card.
2026-11-10 09:28:43 +01:00
Maurice 3ab4927cb7 fix(native): the app asks for notifications on iOS too, and again if the question went unanswered
The prompt looked at the app's features once, when the user counted as signed
in. After the sign-in the page loads with the session already there, and on
iOS the bridge had not named its features yet, so the prompt gave up for good.
It now waits for them. It also counted as asked the moment it opened, so a
sheet that went under another one never came back; it counts once answered.
2026-11-10 09:26:59 +01:00
Maurice 6a0ae482d7 fix(login): the two-factor code field is set up for one-time codes and a password manager
The code step drops the email field, so a password manager had no login to
match a one-time code to; the step now keeps the address in a hidden username
field. The code field names itself and sets its keyboard hints directly
(characters, no autocorrect), as autoFocus gave it focus before anything could
prepare it, and its placeholder is translated.
2026-11-10 09:23:16 +01:00
Maurice 20eeaeb61f fix(native): the iOS app builds again, with Quick Look in an extension of its own
The file preview kept its data source in a stored property inside an
extension, which Swift does not allow, so every iOS build since failed. It is
a static now, in its own extension, and the Wallet comment it had split is
whole again.
2026-11-10 09:18:20 +01:00
Maurice 5cbe4e2fa3 feat(native): the app needs TREK 5.1.3 on the server and checks a remembered one once after the update
The fixes that keep the app's sign-in in the app live in the server's UI,
so an updated app on a 5.1.2 server still ended in the browser. The server now
reports nativeApi 2 and the app refuses anything below it, naming 5.1.3.

A server the app remembers from before is not loaded unchecked: the first
launch after the update shows the address screen filled in with it, says why
and checks it right away. A server that is new enough loads again with its
session; an older one gets the update hint.
2026-11-10 09:17:26 +01:00
Maurice b124e1bc9c fix(mobile): a day tapped in the day rail moves the pager on short trips too
The strip re-centred only when its window of pages changed. On a trip of up
to five days a tap can keep the same pages, so the rail showed the new day
while the card below stayed on the old one.
2026-11-10 09:17:26 +01:00
Maurice 08034c4402 fix(map): the iOS app's tile store is allowed by the CSP, so its maps draw tiles again
The iOS app rewrites raster tiles to trek-tile:// and answers them from its
store. WebKit checks that scheme against img-src, which had only https:, so
every tile was refused and the app showed routes and markers on a blank map.
2026-11-10 09:17:25 +01:00
Maurice 0577a9ccb9 fix(native): the hand-off page goes straight to SSO instead of TREK's login first
The app's browser sign-in opens /native-handoff, which sends the user on to
the identity provider by itself. The page was not on the list of public auth
pages, so the 401 from loading the user sent the browser to /login with the
hand-off as its return path first. On a server that also offers passwords the
user then had to pick SSO a second time before getting back to the app.
2026-11-10 09:17:22 +01:00
Maurice 2f03554a60 feat(native): files open in the system viewer instead of the share sheet
A PDF, image or document opened in the app went straight to the share sheet.
It now opens in Quick Look on iOS, to page through and zoom with its own share
button, and in the app that views its type on Android. An app without the
viewer (feature filePreview), or a type nothing on the device shows, gets the
share sheet as before.
2026-11-10 00:21:28 +01:00
Maurice 28079197e7 fix(assistant): an answer that hands the talk on to a trip or journey chat ends the turn
The home assistant wrote its answer and offered the trip's own chat in the
same step. The loop asked the model once more after the card, and it wrote
the same answer a second time below it. A step that has its answer and only
hands the conversation on now finishes the turn; a hand-on without an answer,
or beside a read, still asks the model again.
2026-11-10 00:14:44 +01:00
Maurice 0b46b89b62 fix(mobile): swipe actions are rounded tiles beside the row, icon only; expenses keep their own buttons
The actions under a swiped row were square blocks to the row's edge, unlike
the rounded cards around them. They are now tiles of their own with the
card's radius and a gap, inset within a grouped card such as the packing
list, and show only their icon (the label names them for a screen reader).
An expense already shows edit and delete beside its card, so it no longer
swipes to a second, larger pair of the same buttons.
2026-11-10 00:11:50 +01:00
Maurice 05caf67bc4 chore(notices): the release popup comes back for 5.1.3 2026-11-09 23:43:24 +01:00
Maurice 6d3c400e4a ci(native): build the iOS app with Xcode 27.1 on the xcode-27 image
An app linked against an SDK older than iOS 27.1 runs letterboxed on the inner
display of an iPhone Duo. The iOS build and the release now run on GitHub's
xcode-27 image and select Xcode 27.1; the previews stay on Xcode 26.
2026-11-09 23:33:23 +01:00
Maurice 9932288b04 chore(deps): bump the npm minor and patch group, with React's override and one MikroORM
From #3032, adjusted where it broke:
- react and react-dom are pinned in the root overrides; the group raised the
  workspaces to 19.3.1 and left the override at 19.3.0, so two react-dom
  versions met in every client test. The override moves along.
- @mikro-orm/nestjs kept the old @mikro-orm/core at the root while the server
  got 7.2.8 of its own, two copies whose types do not match; the lockfile now
  holds one 7.2.8.
- mapbox-gl stays at 3.31: 3.34 drops setRTLTextPlugin from its types, which
  the map engine uses.
- the musl pins of rollup stay at 4.62, the rollup version the lockfile holds.
2026-11-09 23:31:38 +01:00
Maurice 56b13541bd chore(native): keep the handoff start path inside browserSignIn, nothing else reads it 2026-11-09 23:29:38 +01:00
Maurice d568e20f02 chore(plugin-sdk): bump vitest and @vitest/coverage-v8 to 5 together
The two only resolve as a pair; one at a time (#3028, #3029) npm refuses the
peer range.
2026-11-09 23:28:26 +01:00
Maurice f03d59bc22 chore(deps): bump @capacitor/cli to 8.6, vite-plugin-pwa to 2 and @nestjs/schedule to 12
@capacitor/cli 8.6 drops the old uuid 7 through its xcode dependency (#3012).
vite-plugin-pwa 2 keeps the registerSW.js in public/ in place of its own, as
1.x did (#3033). @nestjs/schedule 12 still runs on Nest 11 (#3035).
2026-11-09 23:27:18 +01:00
dependabot[bot] 0833ca28c2 chore(plugin-sdk): bump semver
Bumps the plugin-sdk-minor-and-patch group in /plugin-sdk with 1 update: [semver](https://github.com/npm/node-semver).


Updates `semver` from 7.8.5 to 7.8.6
- [Release notes](https://github.com/npm/node-semver/releases)
- [Changelog](https://github.com/npm/node-semver/blob/main/CHANGELOG.md)
- [Commits](https://github.com/npm/node-semver/compare/v7.8.5...v7.8.6)

---
updated-dependencies:
- dependency-name: semver
  dependency-version: 7.8.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: plugin-sdk-minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-11-09 23:26:18 +01:00
dependabot[bot] 3ed5f21a48 ci: bump actions/setup-java from 5 to 6
Bumps [actions/setup-java](https://github.com/actions/setup-java) from 5 to 6.
- [Release notes](https://github.com/actions/setup-java/releases)
- [Commits](https://github.com/actions/setup-java/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/setup-java
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-11-09 23:26:18 +01:00
dependabot[bot] 2eb6b79bea ci: bump the actions-minor-and-patch group with 2 updates
Bumps the actions-minor-and-patch group with 2 updates: [docker/login-action](https://github.com/docker/login-action) and [SonarSource/sonarqube-scan-action](https://github.com/sonarsource/sonarqube-scan-action).


Updates `docker/login-action` from 4.7.0 to 4.8.0
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/81c6f93d4bc991e510b342a00b419b3bb4efc5c6...8aa5831fb8d48d8f76594c7ddde61ae58019f550)

Updates `SonarSource/sonarqube-scan-action` from 8.4.0 to 8.4.1
- [Release notes](https://github.com/sonarsource/sonarqube-scan-action/releases)
- [Commits](https://github.com/sonarsource/sonarqube-scan-action/compare/4d1dc0c91ee400dcf8a6dddf2f705ac36542e304...e805d875275f728fac1885b413934be6d7c63f65)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions-minor-and-patch
- dependency-name: SonarSource/sonarqube-scan-action
  dependency-version: 8.4.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions-minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-11-09 23:26:17 +01:00
Maurice 8dd2e9716d test(native): fix the sign-in handoff tests' quoting and formatting 2026-11-09 23:15:09 +01:00
Maurice 1cdde6e9a4 fix(native): a field on a page as tall as the screen comes up above the keyboard on iOS
The login on an iPhone scrolls in no pane of its own, and the keyboard plugin
holds the body's height, so the page had nothing to scroll by and the password
field stayed under the keyboard, most of all with an error shown above it. Such
a page now gets room as tall as the keyboard at its end and scrolls the field
above it; the room goes with the keyboard.
2026-11-09 23:12:27 +01:00
Maurice 034cfb539a fix(native): the app's browser sign-in no longer lands on the dashboard when the browser still holds an older TREK
A browser that ran TREK before 5.1.1 keeps that version's app in its service
worker. When the app opened /native-handoff, the worker answered it from its
cache with an app that has no such page, which sent the user to /, and the
sign-in ended on the dashboard in the browser instead of in the app.

The app now opens /api/auth/native/start, which every TREK service worker
passes to the server. That page takes the workers off the browser and
forwards to the handoff page. Until the sign-in is handed back, the web app
registers no service worker and does not reload for a new version: a tab
still open on the older TREK keeps its worker alive, and a registration or a
reload in the middle of the sign-in brought it back to answer the next page.
2026-11-09 23:02:33 +01:00
140 changed files with 3106 additions and 2324 deletions
+2 -2
View File
@@ -175,7 +175,7 @@ jobs:
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
- uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -236,7 +236,7 @@ jobs:
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
- uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
+2 -2
View File
@@ -344,7 +344,7 @@ jobs:
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
- uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -403,7 +403,7 @@ jobs:
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
- uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
+8 -6
View File
@@ -49,7 +49,7 @@ jobs:
node-version-file: .nvmrc
cache: npm
- uses: actions/setup-java@v5
- uses: actions/setup-java@v6
with:
distribution: temurin
java-version: 21
@@ -111,19 +111,21 @@ jobs:
ios:
if: github.repository == 'liketrek/TREK'
runs-on: macos-15
runs-on: xcode-27
timeout-minutes: 60
environment: store
steps:
- uses: actions/checkout@v6
# App Store uploads must be built with Xcode 26; the image's default is older.
- name: Select Xcode 26
# The iOS 27.1 SDK, on the xcode-27 image: an app built against an older SDK
# runs letterboxed on the inner display of an iPhone Duo.
- name: Select Xcode 27.1
run: |
XCODE=$(ls -d /Applications/Xcode_26*.app | sort -V | tail -1)
test -n "$XCODE"
XCODE=/Applications/Xcode_27.1.app
test -d "$XCODE"
sudo xcode-select -s "$XCODE"
xcodebuild -version
xcodebuild -showsdks | grep iphoneos27.1
- uses: actions/setup-node@v6
with:
+8 -6
View File
@@ -60,7 +60,7 @@ jobs:
node-version-file: .nvmrc
cache: npm
- uses: actions/setup-java@v5
- uses: actions/setup-java@v6
with:
distribution: temurin
java-version: 21
@@ -90,18 +90,20 @@ jobs:
retention-days: 14
ios:
runs-on: macos-15
runs-on: xcode-27
timeout-minutes: 40
steps:
- uses: actions/checkout@v6
# App Store uploads must be built with Xcode 26; the image's default is older.
- name: Select Xcode 26
# The iOS 27.1 SDK, on the xcode-27 image: an app built against an older SDK
# runs letterboxed on the inner display of an iPhone Duo.
- name: Select Xcode 27.1
run: |
XCODE=$(ls -d /Applications/Xcode_26*.app | sort -V | tail -1)
test -n "$XCODE"
XCODE=/Applications/Xcode_27.1.app
test -d "$XCODE"
sudo xcode-select -s "$XCODE"
xcodebuild -version
xcodebuild -showsdks | grep iphoneos27.1
- name: Check the property lists
run: plutil -lint client/ios/App/App/Info.plist client/ios/ExportOptions.plist client/ios/App/NotificationService/Info.plist client/ios/App/NotificationService/NotificationService.entitlements client/ios/App/App/App.entitlements client/ios/App/TREKWidgets/Info.plist client/ios/App/TREKWidgets/TREKWidgets.entitlements client/ios/App/TREKWidgets/*.lproj/Localizable.strings client/ios/App/ShareExtension/Info.plist client/ios/App/ShareExtension/ShareExtension.entitlements client/ios/App/ShareExtension/*.lproj/Localizable.strings client/ios/App/TREKWatch/Info.plist client/ios/App/TREKWatch/TREKWatch.entitlements client/ios/App/TREKWatch/*.lproj/Localizable.strings client/ios/App/TREKWatchWidgets/Info.plist client/ios/App/TREKWatchWidgets/TREKWatchWidgets.entitlements
+1 -1
View File
@@ -74,7 +74,7 @@ jobs:
load: true
tags: trek:scan
- uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
- uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
+1 -1
View File
@@ -1100,7 +1100,7 @@ jobs:
path: plugin-sdk/coverage/
- name: Scan
uses: SonarSource/sonarqube-scan-action@4d1dc0c91ee400dcf8a6dddf2f705ac36542e304 # v8.4.0
uses: SonarSource/sonarqube-scan-action@e805d875275f728fac1885b413934be6d7c63f65 # v8.4.1
env:
# SonarQube Cloud is the scan action's default host, so no
# SONAR_HOST_URL — the token is a sonarcloud.io token for the
+2 -2
View File
@@ -1,5 +1,5 @@
apiVersion: v2
name: trek
version: 5.1.2
version: 5.1.3
description: Minimal Helm chart for TREK app
appVersion: "5.1.2"
appVersion: "5.1.3"
@@ -1,11 +1,19 @@
package com.liketrek.trek;
import android.content.Intent;
import android.graphics.drawable.GradientDrawable;
import android.os.Bundle;
import android.util.TypedValue;
import android.view.Gravity;
import android.view.KeyEvent;
import android.view.KeyboardShortcutGroup;
import android.view.Menu;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.FrameLayout;
import android.widget.LinearLayout;
import android.widget.TextView;
import com.getcapacitor.BridgeActivity;
import com.liketrek.trek.screen.TrekKeyCommands;
import com.liketrek.trek.tiles.TrekTileStore;
@@ -26,6 +34,7 @@ public class MainActivity extends BridgeActivity {
// runs and on its own origin. BridgeActivity reads `config` while it
// builds the bridge inside super.onCreate().
boolean restarting = TrekServerStore.consumeRestart(this);
TrekServerStore.recheckAfterUpdate(this);
String server = TrekServerStore.getUrl(this);
boolean onServer = server != null && !TrekServerStore.consumeShellOnce(this);
config = TrekServerStore.configFor(this, onServer ? server : null, restarting);
@@ -36,7 +45,11 @@ public class MainActivity extends BridgeActivity {
// scroller natively. It scales the whole view, top bar and bottom dock included.
bridge.getWebView().setOverScrollMode(View.OVER_SCROLL_NEVER);
// Set before any WebView callback can run: they are posted to this thread.
if (onServer) bridge.setWebViewClient(new TrekWebViewClient(bridge, this::showAddressScreen, TrekServerStore.getSignInHosts(this)));
if (onServer) {
bridge.setWebViewClient(
new TrekWebViewClient(bridge, this::showAddressScreen, this::showSignInBar, TrekServerStore.getSignInHosts(this))
);
}
TrekSharedFiles.receive(this, getIntent());
// Android 15's widget picker draws the widgets live from a sample trip.
TrekWidgetUpdater.publishPreviews(this);
@@ -68,6 +81,93 @@ public class MainActivity extends BridgeActivity {
private boolean leavingServer;
private View signInBar;
/** Whether the page is a proxy's sign-in page, and whether the keyboard is up over it. */
private boolean onSignInPage;
private boolean keyboardUp;
/**
* Over a proxy's sign-in page (Pangolin, authentik, ...): the server's UI is
* not there, so nothing else would lead back to the address screen, and a
* passkey cannot work in a web view for a domain the app is not tied to.
* The bar says so and offers to change the server.
*/
private void showSignInBar(boolean show) {
runOnUiThread(() -> {
onSignInPage = show;
if (show && signInBar == null) signInBar = buildSignInBar();
updateSignInBar();
});
}
/** Out of the way while the keyboard is up: the form needs the room, and the bar is there again once it closes. */
private void updateSignInBar() {
if (signInBar != null) signInBar.setVisibility(onSignInPage && !keyboardUp ? View.VISIBLE : View.GONE);
}
private View buildSignInBar() {
float dp = getResources().getDisplayMetrics().density;
LinearLayout bar = new LinearLayout(this);
bar.setOrientation(LinearLayout.HORIZONTAL);
bar.setGravity(Gravity.CENTER_VERTICAL);
bar.setPadding(Math.round(14 * dp), Math.round(10 * dp), Math.round(8 * dp), Math.round(10 * dp));
GradientDrawable background = new GradientDrawable();
background.setColor(0xF2111827);
background.setCornerRadius(16 * dp);
bar.setBackground(background);
bar.setElevation(6 * dp);
TextView hint = new TextView(this);
hint.setText(TrekServerStore.signInHintLabel(this));
hint.setTextColor(0xFFE5E7EB);
hint.setTextSize(TypedValue.COMPLEX_UNIT_SP, 12.5f);
bar.addView(hint, new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f));
Button change = new Button(this, null, android.R.attr.borderlessButtonStyle);
change.setText(TrekServerStore.changeServerLabel(this));
change.setAllCaps(false);
change.setTextColor(0xFFFFFFFF);
change.setOnClickListener((v) -> {
TrekServerStore.setUrl(this, null);
TrekServerStore.markRestart(this);
recreate();
});
bar.addView(change, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT));
int margin = Math.round(12 * dp);
FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
Gravity.BOTTOM
);
params.setMargins(margin, 0, margin, margin);
FrameLayout root = findViewById(android.R.id.content);
root.addView(bar, params);
// Above the gesture bar, and above the keyboard while it is up.
bar.setOnApplyWindowInsetsListener((v, insets) -> {
// Read off the window's own insets: Capacitor hands the views below the
// decor a copy with the system bars zeroed, and before Android 11 that
// takes the keyboard with it.
androidx.core.view.WindowInsetsCompat window = androidx.core.view.ViewCompat.getRootWindowInsets(getWindow().getDecorView());
int bottom = window != null
? window.getInsets(androidx.core.view.WindowInsetsCompat.Type.navigationBars()).bottom
: insets.getStableInsetBottom();
boolean imeVisible = window != null && window.isVisible(androidx.core.view.WindowInsetsCompat.Type.ime());
if (imeVisible != keyboardUp) {
keyboardUp = imeVisible;
updateSignInBar();
}
FrameLayout.LayoutParams p = (FrameLayout.LayoutParams) v.getLayoutParams();
if (p.bottomMargin != margin + bottom) {
p.bottomMargin = margin + bottom;
v.setLayoutParams(p);
}
return insets;
});
bar.requestApplyInsets();
return bar;
}
/**
* The server did not load: back to the address screen, which offers retry
* and change. A fresh task rather than recreate(), because this usually
@@ -22,6 +22,18 @@ final class TrekServerStore {
private static final String PREFS = "trek_shell";
private static final String KEY_URL = "server_url";
private static final String KEY_PREVIOUS_URL = "previous_server_url";
private static final String KEY_SERVER_CHECK = "server_check";
private static final String KEY_LABEL_SIGN_IN_HINT = "label_sign_in_hint";
private static final String KEY_LABEL_CHANGE_SERVER = "label_change_server";
/**
* The server API this build needs (MIN_NATIVE_API in serverAddress.ts). A
* server remembered under a lower value is checked once more: the first
* launch after an update that raised it puts the address screen back up,
* filled in with that server (getPreviousUrl). Raise both together.
*/
private static final int SERVER_CHECK = 2;
private static final String KEY_SIGN_IN_HOSTS = "sign_in_hosts";
private static final String KEY_SHELL_ONCE = "shell_once";
private static final String KEY_RESTART = "restart";
@@ -56,6 +68,44 @@ final class TrekServerStore {
return prefs(context).getString(KEY_URL, null);
}
/** Runs before the bridge is built, so a server picked under an older minimum is not loaded unchecked. */
static void recheckAfterUpdate(Context context) {
SharedPreferences prefs = prefs(context);
if (prefs.getInt(KEY_SERVER_CHECK, 0) >= SERVER_CHECK) return;
String server = getUrl(context);
SharedPreferences.Editor editor = prefs.edit().putInt(KEY_SERVER_CHECK, SERVER_CHECK);
if (server != null) editor.remove(KEY_URL).remove(KEY_SIGN_IN_HOSTS).putString(KEY_PREVIOUS_URL, server);
editor.commit();
}
/**
* The words of the bar over a proxy's sign-in page, in the user's language,
* as the address screen passed them (setServer's `labels`): the server's UI
* is not there to say them. Null keeps the English fallback.
*/
static void setLabels(Context context, String signInHint, String changeServer) {
SharedPreferences.Editor editor = prefs(context).edit();
if (signInHint != null) editor.putString(KEY_LABEL_SIGN_IN_HINT, signInHint);
if (changeServer != null) editor.putString(KEY_LABEL_CHANGE_SERVER, changeServer);
editor.commit();
}
static String signInHintLabel(Context context) {
return prefs(context).getString(
KEY_LABEL_SIGN_IN_HINT,
"Passkeys and security keys do not work on this page in the app. Sign in with a password, or let the app through your proxy."
);
}
static String changeServerLabel(Context context) {
return prefs(context).getString(KEY_LABEL_CHANGE_SERVER, "Change server");
}
/** The server an update asked to check again, until a server is set. */
static String getPreviousUrl(Context context) {
return prefs(context).getString(KEY_PREVIOUS_URL, null);
}
/** Remembers the server; its sign-in hosts go with it and are replaced or dropped too. */
static void setUrl(Context context, String url) {
setServer(context, url, null);
@@ -66,7 +116,7 @@ final class TrekServerStore {
if (url == null) {
editor.remove(KEY_URL);
} else {
editor.putString(KEY_URL, url);
editor.putString(KEY_URL, url).remove(KEY_PREVIOUS_URL);
}
if (signInHosts == null || signInHosts.isEmpty()) {
editor.remove(KEY_SIGN_IN_HOSTS);
@@ -1,6 +1,7 @@
package com.liketrek.trek;
import android.Manifest;
import android.content.ActivityNotFoundException;
import android.content.Intent;
import android.net.Uri;
import android.os.Build;
@@ -70,6 +71,7 @@ public class TrekShellPlugin extends Plugin {
"keyCommands",
"windowLayout",
"clientCertificate",
"filePreview",
};
/** liketrek's push relay: the endpoint for this device's FCM token (trek-hosted push-relay.ts). */
@@ -267,11 +269,51 @@ public class TrekShellPlugin extends Plugin {
call.resolve();
}
/**
* Opens a file the UI wrote to the cache in the app that views it, a PDF in
* the system's PDF viewer, instead of the share sheet. "unsupported" where no
* app on the device views the type; the UI then hands it to the share sheet.
*/
@PluginMethod
public void previewFile(PluginCall call) {
String path = call.getString("path");
Uri parsed = path == null ? null : Uri.parse(path);
java.io.File file = parsed != null && "file".equals(parsed.getScheme()) && parsed.getPath() != null ? new java.io.File(parsed.getPath()) : null;
java.io.File cache = getContext().getCacheDir();
boolean inCache;
try {
inCache = file != null && file.getCanonicalPath().startsWith(cache.getCanonicalPath() + java.io.File.separator);
} catch (java.io.IOException e) {
inCache = false;
}
if (!inCache || !file.isFile()) {
call.reject("a file in the app's cache is required", "INVALID");
return;
}
String type = call.getString("mimeType", "application/octet-stream");
Uri content = androidx.core.content.FileProvider.getUriForFile(getContext(), getContext().getPackageName() + ".fileprovider", file);
Intent view = new Intent(Intent.ACTION_VIEW)
.setDataAndType(content, type)
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_ACTIVITY_NEW_TASK);
getActivity().runOnUiThread(() -> {
JSObject result = new JSObject();
try {
getActivity().startActivity(view);
result.put("result", "shown");
} catch (ActivityNotFoundException e) {
result.put("result", "unsupported");
}
call.resolve(result);
});
}
@PluginMethod
public void getServer(PluginCall call) {
String url = TrekServerStore.getUrl(getContext());
String previous = TrekServerStore.getPreviousUrl(getContext());
JSObject result = new JSObject();
result.put("url", url == null ? JSONObject.NULL : url);
result.put("previousUrl", previous == null ? JSONObject.NULL : previous);
call.resolve(result);
}
@@ -284,6 +326,10 @@ public class TrekShellPlugin extends Plugin {
return;
}
TrekServerStore.setServer(getContext(), url, signInHostsOf(call));
JSObject labels = call.getObject("labels");
if (labels != null) {
TrekServerStore.setLabels(getContext(), labels.getString("signInHint"), labels.getString("changeServer"));
}
// A TREK Cloud purchase: the code that signs the buyer in, opened once after the first page.
String welcome = call.getString("welcome");
TrekServerStore.setWelcome(getContext(), welcome != null && welcome.matches("[A-Za-z0-9_-]{43}") ? welcome : null);
@@ -12,6 +12,7 @@ import com.liketrek.trek.clientcert.TrekClientIdentity;
import java.util.HashSet;
import java.util.Locale;
import java.util.Set;
import java.util.function.Consumer;
/**
* Used while the WebView shows the user's server. When that server cannot be
@@ -24,13 +25,16 @@ import java.util.Set;
class TrekWebViewClient extends BridgeWebViewClient {
private final Runnable onUnreachable;
/** Told whether the page now shown is a proxy's sign-in page, for the bar with the way out. */
private final Consumer<Boolean> onSignInPage;
private final Set<String> signInHosts;
/** A page of the server has shown since launch; from then on no sign-in host is learned (SignInRedirect). */
private boolean serverShown;
TrekWebViewClient(Bridge bridge, Runnable onUnreachable, Set<String> signInHosts) {
TrekWebViewClient(Bridge bridge, Runnable onUnreachable, Consumer<Boolean> onSignInPage, Set<String> signInHosts) {
super(bridge);
this.onUnreachable = onUnreachable;
this.onSignInPage = onSignInPage;
this.signInHosts = new HashSet<>(signInHosts);
}
@@ -61,6 +65,13 @@ class TrekWebViewClient extends BridgeWebViewClient {
return super.shouldOverrideUrlLoading(view, request);
}
/** Every page the main frame shows, pushState included: is it a proxy's sign-in page? */
@Override
public void doUpdateVisitedHistory(WebView view, String url, boolean isReload) {
super.doUpdateVisitedHistory(view, url, isReload);
onSignInPage.accept(url != null && isSignInHost(Uri.parse(url)));
}
/** After the server's first page, a pending welcome code opens the welcome page once (TrekServerStore.takeWelcomeUrl). */
@Override
public void onPageFinished(WebView view, String url) {
@@ -10,6 +10,7 @@ import WebKit
class TrekBridgeViewController: CAPBridgeViewController {
override func instanceDescriptor() -> InstanceDescriptor {
let descriptor = super.instanceDescriptor()
TrekServerStore.recheckAfterUpdate()
if let server = TrekServerStore.url {
// The trailing slash matters: Capacitor treats every URL that starts
// with this string as the app's own, and without it that would take
@@ -44,6 +45,7 @@ class TrekBridgeViewController: CAPBridgeViewController {
webView.navigationDelegate = proxy
}
guardBridgeMessages()
watchSignInPages()
bridge?.registerPluginInstance(TrekShellPlugin())
// WebKit's own swipe back slides away a screenshot of the whole page,
// closes no sheet and takes every drag near the edge from the UI. The UI
@@ -55,6 +57,155 @@ class TrekBridgeViewController: CAPBridgeViewController {
webView?.allowsLinkPreview = false
}
// MARK: - A proxy's sign-in page
/// Over a proxy's sign-in page (Pangolin, authentik, ...) the server's UI is
/// not there, so nothing else leads back to the address screen, and a passkey
/// cannot work in a web view for a domain the app is not tied to. A bar says
/// so and offers to change the server; the keyboard gets room on that page,
/// which TREK's own script makes everywhere else (native/keepFieldInView.ts).
private var signInBar: UIView?
private var urlObservation: NSKeyValueObservation?
private var insetObservation: NSKeyValueObservation?
private var onSignInPage = false
private var keyboardUp = false
/// How far the keyboard reaches into the view while it is up.
private var keyboardRoom: CGFloat = 0
/// Whether the bottom inset is this controller's, so leaving the page puts it back.
private var ownsInset = false
private var bodyReset: DispatchWorkItem?
private func watchSignInPages() {
urlObservation = webView?.observe(\.url, options: [.initial, .new]) { [weak self] webView, _ in
let signIn = TrekServerStore.isSignInHost(webView.url)
DispatchQueue.main.async { self?.signInPageChanged(signIn) }
}
// The keyboard plugin zeroes the scroll inset on every frame change it
// makes, the last one a while after the keyboard is up. On a sign-in
// page the inset wanted here is put back each time.
insetObservation = webView?.scrollView.observe(\.contentInset, options: [.new]) { [weak self] _, _ in
DispatchQueue.main.async { self?.applyInset() }
}
let center = NotificationCenter.default
center.addObserver(self, selector: #selector(keyboardWillShow(_:)), name: UIResponder.keyboardWillShowNotification, object: nil)
center.addObserver(self, selector: #selector(keyboardWillHide), name: UIResponder.keyboardWillHideNotification, object: nil)
}
private func signInPageChanged(_ signIn: Bool) {
onSignInPage = signIn
if signIn && signInBar == nil { signInBar = makeSignInBar() }
updateSignInBar()
}
/// Out of the way while the keyboard is up: the form needs the room.
private func updateSignInBar() {
signInBar?.isHidden = !(onSignInPage && !keyboardUp)
applyInset()
}
/// On a sign-in page the page can scroll past what covers its end: the
/// keyboard while it is up, the bar otherwise. TREK's own pages are left alone.
private func applyInset() {
guard let scrollView = webView?.scrollView else { return }
let wanted: CGFloat
if onSignInPage {
if keyboardUp {
wanted = keyboardRoom
} else if let bar = signInBar {
view.layoutIfNeeded()
wanted = max(0, view.bounds.height - bar.frame.minY)
} else {
wanted = 0
}
ownsInset = true
} else if ownsInset {
wanted = 0
ownsInset = false
} else {
return
}
if scrollView.contentInset.bottom != wanted { scrollView.contentInset.bottom = wanted }
if scrollView.verticalScrollIndicatorInsets.bottom != wanted { scrollView.verticalScrollIndicatorInsets.bottom = wanted }
}
/// The keyboard plugin also sizes the body to the area above the keyboard,
/// after the keyboard's animation and a further 0.2 s. On a sign-in page the
/// body gets its height back once that has run, so the button under the
/// keyboard can be scrolled into view through the inset above.
@objc private func keyboardWillShow(_ note: Notification) {
keyboardUp = true
if let frame = note.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect {
keyboardRoom = max(0, view.bounds.height - view.convert(frame, from: nil).minY)
}
bodyReset?.cancel()
if onSignInPage {
let duration = note.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double ?? 0.25
let reset = DispatchWorkItem { [weak self] in
guard let self, self.onSignInPage, self.keyboardUp, let webView = self.webView else { return }
webView.evaluateJavaScript("document.body.style.height = ''", completionHandler: nil)
self.applyInset()
}
bodyReset = reset
DispatchQueue.main.asyncAfter(deadline: .now() + duration + 0.35, execute: reset)
}
updateSignInBar()
}
@objc private func keyboardWillHide() {
keyboardUp = false
bodyReset?.cancel()
updateSignInBar()
}
private func makeSignInBar() -> UIView {
let labels = TrekServerStore.labels
let bar = UIView()
bar.backgroundColor = UIColor(red: 17 / 255, green: 24 / 255, blue: 39 / 255, alpha: 0.95)
bar.layer.cornerRadius = 16
bar.layer.cornerCurve = .continuous
bar.translatesAutoresizingMaskIntoConstraints = false
let hint = UILabel()
hint.text = labels["signInHint"]
?? "Passkeys and security keys do not work on this page in the app. Sign in with a password, or let the app through your proxy."
hint.textColor = UIColor(white: 0.9, alpha: 1)
hint.font = .preferredFont(forTextStyle: .footnote)
hint.adjustsFontForContentSizeCategory = true
hint.numberOfLines = 0
hint.translatesAutoresizingMaskIntoConstraints = false
let change = UIButton(type: .system)
change.setTitle(labels["changeServer"] ?? "Change server", for: .normal)
change.setTitleColor(.white, for: .normal)
change.titleLabel?.font = .systemFont(ofSize: 15, weight: .semibold)
change.addTarget(self, action: #selector(changeServerFromSignIn), for: .touchUpInside)
change.translatesAutoresizingMaskIntoConstraints = false
change.setContentHuggingPriority(.required, for: .horizontal)
change.setContentCompressionResistancePriority(.required, for: .horizontal)
bar.addSubview(hint)
bar.addSubview(change)
view.addSubview(bar)
NSLayoutConstraint.activate([
bar.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 12),
bar.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -12),
bar.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -8),
hint.leadingAnchor.constraint(equalTo: bar.leadingAnchor, constant: 14),
hint.topAnchor.constraint(equalTo: bar.topAnchor, constant: 10),
hint.bottomAnchor.constraint(equalTo: bar.bottomAnchor, constant: -10),
change.leadingAnchor.constraint(equalTo: hint.trailingAnchor, constant: 8),
change.trailingAnchor.constraint(equalTo: bar.trailingAnchor, constant: -12),
change.centerYAnchor.constraint(equalTo: bar.centerYAnchor),
])
return bar
}
/// What resetServer does from the UI: forget the server, back to the address screen.
@objc private func changeServerFromSignIn() {
TrekServerStore.url = nil
view.window?.rootViewController = TrekBridgeViewController()
}
/// Kept alive here: the content controller holds it, but only for as long as it is registered.
private var messageGuard: TrekBridgeMessageGuard?
@@ -157,13 +308,54 @@ enum TrekKeyCommands {
enum TrekServerStore {
private static let key = "TrekServerURL"
private static let previousKey = "TrekPreviousServerURL"
private static let checkedKey = "TrekServerCheck"
private static let labelsKey = "TrekShellLabels"
/// The words of the bar over a proxy's sign-in page, in the user's language,
/// as the address screen passed them (setServer's `labels`): the server's UI
/// is not there to say them. Empty keeps the English fallback.
static var labels: [String: String] {
get { UserDefaults.standard.dictionary(forKey: labelsKey) as? [String: String] ?? [:] }
set { UserDefaults.standard.set(newValue, forKey: labelsKey) }
}
/// The server API this build needs (MIN_NATIVE_API in serverAddress.ts).
/// A server remembered under a lower value is checked once more: the first
/// launch after an update that raised it puts the address screen back up,
/// filled in with that server (previousURL). Raise both together.
private static let serverCheck = 2
/// Runs before the first bridge is built, so a server picked under an older
/// minimum is not loaded unchecked.
static func recheckAfterUpdate() {
let defaults = UserDefaults.standard
guard defaults.integer(forKey: checkedKey) < serverCheck else { return }
defaults.set(serverCheck, forKey: checkedKey)
guard let server = url else { return }
url = nil
defaults.set(server, forKey: previousKey)
}
/// The server an update asked to check again, until a server is set.
static var previousURL: String? {
UserDefaults.standard.string(forKey: previousKey)
}
private static let signInHostsKey = "TrekServerSignInHosts"
/// Setting the server drops the sign-in hosts of the one before it.
static var url: String? {
get { UserDefaults.standard.string(forKey: key) }
set {
if let newValue { UserDefaults.standard.set(newValue, forKey: key) } else { UserDefaults.standard.removeObject(forKey: key) }
if let newValue {
UserDefaults.standard.set(newValue, forKey: key)
UserDefaults.standard.removeObject(forKey: previousKey)
} else {
UserDefaults.standard.removeObject(forKey: key)
}
UserDefaults.standard.removeObject(forKey: signInHostsKey)
try? FileManager.default.removeItem(at: offlineCopyURL)
}
+67 -2
View File
@@ -11,6 +11,7 @@ import ActivityKit
#endif
import LocalAuthentication
import PassKit
import QuickLook
import UIKit
import WebKit
@@ -55,6 +56,7 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
CAPPluginMethod(name: "clientCertificate", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "importClientCertificate", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "removeClientCertificate", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "previewFile", returnType: CAPPluginReturnPromise),
]
/// What this binary offers the server's UI, raised with every native feature
@@ -66,7 +68,7 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
/// builds a snapshot for nothing; "wallet" only where Wallet can take a pass (an
/// iPhone: an iPad has no Wallet app), so the UI offers no button that does nothing.
static var features: [String] {
var names = ["capabilities", "edgeBack", "tileCache", "biometricLock", "geolocation", "sharedFiles", "nativePush", "widgets", "liveActivity", "widgetTrips", "storeKit", "shareSheet", "keyCommands", "quickActions", "clientCertificate"]
var names = ["capabilities", "edgeBack", "tileCache", "biometricLock", "geolocation", "sharedFiles", "nativePush", "widgets", "liveActivity", "widgetTrips", "storeKit", "shareSheet", "keyCommands", "quickActions", "clientCertificate", "filePreview"]
if canAddWalletPasses { names.append("wallet") }
if TrekWatchLink.isAvailable { names.append("watch") }
return names
@@ -346,7 +348,10 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
private var authSession: ASWebAuthenticationSession?
@objc func getServer(_ call: CAPPluginCall) {
call.resolve(["url": TrekServerStore.url ?? NSNull()])
call.resolve([
"url": TrekServerStore.url ?? NSNull(),
"previousUrl": TrekServerStore.previousURL ?? NSNull(),
])
}
@objc func setServer(_ call: CAPPluginCall) {
@@ -355,6 +360,8 @@ public class TrekShellPlugin: CAPPlugin, CAPBridgedPlugin, ASWebAuthenticationPr
return
}
TrekServerStore.url = raw
let labels = (call.getObject("labels") ?? [:]).compactMapValues { $0 as? String }
if !labels.isEmpty { TrekServerStore.labels = labels }
// After the URL: setting it clears the hosts of the server before.
let hosts = (call.getArray("signInHosts", String.self) ?? []).filter { $0.range(of: "^[A-Za-z0-9.-]+(:[0-9]+)?$", options: .regularExpression) != nil }
TrekServerStore.signInHosts = Set(hosts.map { $0.lowercased() })
@@ -555,6 +562,41 @@ extension TrekShellPlugin {
/// Positions and errors for the UI, shaped like the Geolocation API's, with the
/// When-In-Use permission asked the first time.
// MARK: - Quick Look
extension TrekShellPlugin {
/// Kept while Quick Look shows a file: the controller holds its data source weakly.
/// One preview at a time, so a static: an extension cannot store per instance.
private static var previewSource: TrekFilePreviewSource?
/// Shows a file the UI wrote to the cache in Quick Look: a PDF to page through,
/// an image, a document, with the system's own share button. "unsupported" for
/// a file Quick Look cannot show, which the UI then hands to the share sheet.
@objc func previewFile(_ call: CAPPluginCall) {
guard let path = call.getString("path"), let url = URL(string: path), url.isFileURL,
FileManager.default.fileExists(atPath: url.path) else {
call.reject("a file in the app's cache is required", "INVALID")
return
}
DispatchQueue.main.async { [weak self] in
guard let self, let presenter = self.bridge?.viewController else {
call.reject("nothing to show the preview from", "UNAVAILABLE")
return
}
guard QLPreviewController.canPreview(url as QLPreviewItem) else {
call.resolve(["result": "unsupported"])
return
}
let source = TrekFilePreviewSource(url: url, title: call.getString("title"))
TrekShellPlugin.previewSource = source
let preview = QLPreviewController()
preview.dataSource = source
presenter.present(preview, animated: true)
call.resolve(["result": "shown"])
}
}
}
// MARK: - Apple Wallet
extension TrekShellPlugin {
@@ -957,3 +999,26 @@ enum TrekSharedKeychain {
return SecItemAdd(q as CFDictionary, nil) == errSecSuccess
}
}
/// The one file Quick Look shows, under the name the user knows it by.
final class TrekFilePreviewSource: NSObject, QLPreviewControllerDataSource {
private let item: Item
init(url: URL, title: String?) {
item = Item(url: url, title: title)
}
func numberOfPreviewItems(in controller: QLPreviewController) -> Int { 1 }
func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem { item }
final class Item: NSObject, QLPreviewItem {
let previewItemURL: URL?
let previewItemTitle: String?
init(url: URL, title: String?) {
previewItemURL = url
previewItemTitle = title
}
}
}
+15 -5
View File
@@ -275,7 +275,12 @@ function showWelcome(opts: { back?: boolean } = {}): void {
}
// ── Connect (address) ───────────────────────────────────────────────────────
function showConnect(prefill = '', opts: { back?: boolean } = {}): void {
/**
* `recheck`: an app update needs a newer server than the one it remembered, so
* the address screen comes back once with it filled in, says why and checks it
* right away. A server that is new enough loads again without another tap.
*/
function showConnect(prefill = '', opts: { back?: boolean; recheck?: boolean } = {}): void {
const input = el('input', {
type: 'url',
placeholder: t('native.connect.placeholder'),
@@ -344,14 +349,15 @@ function showConnect(prefill = '', opts: { back?: boolean } = {}): void {
[
topbar(() => showWelcome({ back: true })),
el('h1', { className: 'title-sm' }, t('native.connect.title')),
el('p', { className: 'sub' }, t('native.connect.hint')),
el('p', { className: 'sub' }, t(opts.recheck ? 'native.connect.recheckHint' : 'native.connect.hint')),
],
[form, footLinks(externalLink(t('native.connect.whatIsServer'), SERVER_DOCS_URL))]
)
),
opts
);
requestAnimationFrame(() => input.focus());
if (opts.recheck && prefill) form.requestSubmit();
else requestAnimationFrame(() => input.focus());
}
/**
@@ -1200,6 +1206,7 @@ async function connected(url: string, signInHosts: string[] = [], welcome?: stri
await new Promise((r) => setTimeout(r, 700));
await TrekShell.setServer({
url,
labels: { signInHint: t('native.signInBar.hint'), changeServer: t('native.changeServer') },
...(signInHosts.length ? { signInHosts } : {}),
...(welcome ? { welcome } : {}),
});
@@ -1322,8 +1329,9 @@ async function start(): Promise<void> {
followBackGesture();
letKeyboardClose();
let url: string | null;
let previousUrl: string | null | undefined;
try {
({ url } = await TrekShell.getServer());
({ url, previousUrl } = await TrekShell.getServer());
} catch {
// No native shell under us (a web preview, or a simulator without the
// plugin): there is no remembered server, so start at the welcome screen
@@ -1332,7 +1340,9 @@ async function start(): Promise<void> {
await SplashScreen.hide().catch(() => {});
return;
}
if (!url) {
if (!url && previousUrl) {
showConnect(previousUrl, { recheck: true });
} else if (!url) {
showWelcome();
} else {
// Only reached when loading the server failed. A proxy that wanted a client
+22 -22
View File
@@ -1,6 +1,6 @@
{
"name": "@trek/client",
"version": "5.1.2",
"version": "5.1.3",
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
},
@@ -52,15 +52,15 @@
"format:check": "prettier --check \"src/**/*.tsx\" \"src/**/*.css\""
},
"dependencies": {
"@assistant-ui/react": "0.15.25",
"@assistant-ui/react": "0.15.32",
"@capacitor/action-sheet": "^8.1.1",
"@capacitor/android": "^8.5.0",
"@capacitor/android": "^8.6.0",
"@capacitor/app": "^8.1.1",
"@capacitor/core": "^8.5.0",
"@capacitor/dialog": "^8.0.1",
"@capacitor/filesystem": "^8.1.3",
"@capacitor/haptics": "^8.0.2",
"@capacitor/ios": "^8.5.0",
"@capacitor/ios": "^8.6.0",
"@capacitor/keyboard": "^8.0.5",
"@capacitor/share": "^8.0.1",
"@capacitor/splash-screen": "^8.0.2",
@@ -105,19 +105,19 @@
"@noble/hashes": "^1.8.0",
"@simplewebauthn/browser": "^13.3.0",
"@trek/shared": "*",
"axios": "^1.20.0",
"dexie": "^4.4.6",
"axios": "^1.21.0",
"dexie": "^4.4.7",
"drag-drop-touch": "^1.3.1",
"heic-to": "^1.5.2",
"heic-to": "^1.6.7",
"iso-3166-2": "^1.0.0",
"leaflet": "^1.9.4",
"lucide-react": "^0.577.0",
"mapbox-gl": "^3.31.0",
"maplibre-gl": "6.15.0",
"maplibre-gl": "6.16.0",
"modern-screenshot": "4.7.0",
"plyr": "^3.8.4",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"plyr": "^3.8.5",
"react": "^19.3.1",
"react-dom": "^19.3.1",
"react-dropzone": "^14.4.1",
"react-leaflet": "^5.0.0",
"react-leaflet-cluster": "^4.1.3",
@@ -131,41 +131,41 @@
"zustand": "^5.0.15"
},
"devDependencies": {
"@capacitor/cli": "^8.5.0",
"@capacitor/cli": "^8.6.0",
"@eslint/js": "^10.0.1",
"@jscpd/core": "^4.2.5",
"@jscpd/tokenizer": "^4.2.6",
"@playwright/test": "^1.63.0",
"@playwright/test": "^1.65.0",
"@testing-library/jest-dom": "^6.10.0",
"@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.7",
"@testing-library/user-event": "^14.6.8",
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
"@types/leaflet": "^1.9.22",
"@types/node": "^25.9.8",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.1",
"@vitejs/plugin-react": "^6.2.1",
"@vitest/coverage-v8": "^4.1.12",
"autoprefixer": "^10.6.1",
"autoprefixer": "^10.6.2",
"eslint": "^10.11.0",
"eslint-config-flat-gitignore": "^2.4.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.7",
"fake-indexeddb": "^6.2.5",
"fake-indexeddb": "^6.2.7",
"jsdom": "^29.1.1",
"msw": "^2.15.0",
"postcss": "^8.5.28",
"prettier": "^3.9.8",
"prettier": "^3.9.10",
"prettier-plugin-organize-imports": "^4.3.0",
"prettier-plugin-tailwindcss": "^0.8.1",
"rollup-plugin-visualizer": "^7.1.1",
"rollup-plugin-visualizer": "^7.1.2",
"sharp": "^0.35.5",
"tailwindcss": "^3.4.19",
"typescript": "^6.0.3",
"typescript-eslint": "^8.70.0",
"vite": "8.3.5",
"vite-plugin-pwa": "^1.3.0",
"typescript-eslint": "^8.72.1",
"vite": "8.3.6",
"vite-plugin-pwa": "^2.0.0",
"vitest": "^4.1.12"
}
}
+43
View File
@@ -0,0 +1,43 @@
// The page /api/auth/native/start answers with runs this (see NativeAuthController.start).
// A browser that ran an older TREK keeps that version's app in its service worker, and
// the worker would answer the handoff page with an app that has no such page. So the
// worker goes first, and the handoff page loads from the server.
(function () {
var script = document.currentScript;
var target = script && script.getAttribute('data-target');
if (!target || target.indexOf('/native-handoff?') !== 0) return;
var gone = false;
var go = function () {
if (gone) return;
gone = true;
location.replace(target);
};
// Until the sign-in is handed back to the app, no worker is registered and no
// new version reloads the page (src/native/browserSignInFlag.ts): a tab still
// open on the older TREK keeps its worker alive, and either would bring it back.
try {
localStorage.setItem('trek_native_signin', String(Date.now()));
} catch (e) {
/* site data blocked: the worker is still taken off below */
}
if (!('serviceWorker' in navigator) || !navigator.serviceWorker.getRegistrations) {
go();
return;
}
// Never stuck here: whatever the browser does with the workers, the page moves on.
var timer = setTimeout(go, 3000);
navigator.serviceWorker
.getRegistrations()
.then(function (registrations) {
return Promise.all(
registrations.map(function (registration) {
return registration.unregister();
})
);
})
.catch(function () {})
.then(function () {
clearTimeout(timer);
go();
});
})();
+18
View File
@@ -0,0 +1,18 @@
// Registers the service worker, as vite-plugin-pwa's own registerSW.js does; this
// file in public/ takes its place. Not while the app's sign-in runs in this browser
// (src/native/browserSignInFlag.ts): a tab still open on an older TREK keeps that
// version's worker alive, and registering now would bring it back to answer the
// sign-in's next page with an app that has no handoff page.
(function () {
if (!('serviceWorker' in navigator)) return;
try {
var started = Number(localStorage.getItem('trek_native_signin'));
var age = Date.now() - started;
if (started > 0 && age >= 0 && age < 15 * 60 * 1000) return;
} catch (e) {
/* site data blocked: register as usual */
}
window.addEventListener('load', function () {
navigator.serviceWorker.register('/sw.js', { scope: '/' });
});
})();
+1 -1
View File
@@ -18,7 +18,7 @@
"src/index.css": 1636,
"src/mobile/screens/admin/MAdminPluginsPanel.tsx": 1506,
"src/pages/JourneyDetailPage.test.tsx": 4028,
"src/pages/LoginPage.tsx": 1347,
"src/pages/LoginPage.tsx": 1294,
"src/pages/TripPlannerPage.tsx": 1128,
"src/pages/atlas/useAtlas.ts": 1207,
"src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx": 3518,
+2 -1
View File
@@ -294,7 +294,7 @@
"pages/JourneyDetailPage.tsx": 53,
"pages/JourneyPage.tsx": 64,
"pages/JourneyPublicPage.tsx": 113,
"pages/LoginPage.tsx": 58,
"pages/LoginPage.tsx": 54,
"pages/OAuthAuthorizePage.tsx": 33,
"pages/PluginPage.tsx": 1,
"pages/ResetPasswordPage.tsx": 23,
@@ -302,6 +302,7 @@
"pages/VacayPage.tsx": 13,
"pages/atlas/useAtlas.ts": 2,
"pages/collections/useCollections.ts": 1,
"pages/login/MfaCodeStep.tsx": 4,
"pages/sharedTrip/SharedChrome.tsx": 2,
"pages/sharedTrip/SharedMap.tsx": 1,
"pages/tripPlanner/ListsContainer.tsx": 1,
+1 -1
View File
@@ -145,7 +145,7 @@ apiClient.interceptors.request.use(
)
export function isAuthPublicPath(pathname: string): boolean {
const publicPaths = ['/login', '/register', '/forgot-password', '/reset-password']
const publicPaths = ['/login', '/register', '/forgot-password', '/reset-password', '/native-handoff']
const publicPrefixes = ['/shared/', '/public/']
return publicPaths.includes(pathname) || publicPrefixes.some((p) => pathname.startsWith(p))
}
@@ -1,4 +1,4 @@
// FE-PLANNER-BKFACTS-001 to FE-PLANNER-BKFACTS-022
// FE-PLANNER-BKFACTS-001 to FE-PLANNER-BKFACTS-024
import { describe, it, expect } from 'vitest';
import { buildDay, buildReservation } from '../../../../tests/helpers/factories';
import type { ReservationEndpoint } from '../../../types';
@@ -83,6 +83,26 @@ describe('bookingFacts: day', () => {
});
});
describe('bookingFacts: hotel times', () => {
it('FE-PLANNER-BKFACTS-023: a hotel without times of its own shows the stay check-in and check-out', () => {
const f = bookingFacts(buildReservation({
type: 'hotel', accommodation_start_day_id: 701, accommodation_end_day_id: 702,
accommodation_check_in: '15:00', accommodation_check_in_end: '18:00', accommodation_check_out: '11:00',
}), ctx());
expect(cell(f, 'reservations.meta.checkIn')).toBe('15:00 → 18:00');
expect(cell(f, 'reservations.meta.checkOut')).toBe('11:00');
});
it('FE-PLANNER-BKFACTS-024: the booking own times win over the stay times', () => {
const f = bookingFacts(buildReservation({
type: 'hotel', metadata: JSON.stringify({ check_in_time: '14:00', check_out_time: '10:00' }),
accommodation_check_in: '15:00', accommodation_check_in_end: '18:00', accommodation_check_out: '11:00',
}), ctx());
expect(cell(f, 'reservations.meta.checkIn')).toBe('14:00');
expect(cell(f, 'reservations.meta.checkOut')).toBe('10:00');
});
});
describe('bookingFacts: time', () => {
it('FE-PLANNER-BKFACTS-008: start and end time with an arrow', () => {
const f = bookingFacts(buildReservation({ reservation_time: '2025-06-01T09:05', reservation_end_time: '2025-06-01T11:30' }), ctx());
@@ -2,7 +2,7 @@ import type { Day, Reservation, ReservationEndpoint } from '../../../types'
import { formatPriceText, formatTime, splitReservationDateTime } from '../../../utils/formatters'
import { getFlightLegs, getTrainLegs, usesStationRoute } from '../../../utils/flightLegs'
import { safeExternalHref } from '../../../utils/safeUrl'
import { daySpan, parseMeta, type AssignmentLookupEntry } from './bookingsModel'
import { daySpan, hotelStayTimes, parseMeta, type AssignmentLookupEntry } from './bookingsModel'
export interface FactsContext {
t: (key: string, params?: Record<string, string | number>) => string
@@ -105,10 +105,11 @@ export function bookingFacts(r: Reservation, c: FactsContext): BookingFacts {
if (!c.hasLinkedCost && meta.price != null && meta.price !== '') {
cells.push({ label: t('reservations.price'), value: formatPriceText(meta.price, meta.priceCurrency, c.tripCurrency, locale) })
}
if (meta.check_in_time) {
cells.push({ label: t('reservations.meta.checkIn'), value: formatTime(meta.check_in_time, locale, timeFormat) + (meta.check_in_end_time ? ` → ${formatTime(meta.check_in_end_time, locale, timeFormat)}` : '') })
const stay = hotelStayTimes(r, meta)
if (stay.checkIn) {
cells.push({ label: t('reservations.meta.checkIn'), value: formatTime(stay.checkIn, locale, timeFormat) + (stay.checkInEnd ? ` → ${formatTime(stay.checkInEnd, locale, timeFormat)}` : '') })
}
if (meta.check_out_time) cells.push({ label: t('reservations.meta.checkOut'), value: formatTime(meta.check_out_time, locale, timeFormat) })
if (stay.checkOut) cells.push({ label: t('reservations.meta.checkOut'), value: formatTime(stay.checkOut, locale, timeFormat) })
// Per-segment codes (#1943), only on a real stopover booking.
const legs = r.type === 'flight' ? getFlightLegs(r) : usesStationRoute(r.type) ? getTrainLegs(r) : []
@@ -103,6 +103,27 @@ export interface DaySpan {
}
/** The days a booking sits on. A hotel reads its stay from the accommodation, which is the source of truth for the range (#1383). */
/**
* A hotel's check-in window and check-out: the booking's own metadata first,
* then the stay it is linked to. Times often live only on the stay, and the
* edit form already falls back the same way (reservationFormModel.ts).
*/
export function hotelStayTimes(
r: Reservation,
meta: { check_in_time?: string | null; check_in_end_time?: string | null; check_out_time?: string | null },
): { checkIn: string | null; checkInEnd: string | null; checkOut: string | null } {
if (r.type !== 'hotel') {
return { checkIn: meta.check_in_time || null, checkInEnd: meta.check_in_end_time || null, checkOut: meta.check_out_time || null }
}
const checkIn = meta.check_in_time || r.accommodation_check_in || null
return {
checkIn,
// A window end belongs to the check-in it came with.
checkInEnd: meta.check_in_time ? meta.check_in_end_time || null : checkIn ? r.accommodation_check_in_end || null : null,
checkOut: meta.check_out_time || r.accommodation_check_out || null,
}
}
export function daySpan(r: Reservation, days: Day[]): DaySpan {
const byId = (id?: number | null) => (id != null ? days.find(d => d.id === id) : undefined)
if (r.type === 'hotel' && (r.accommodation_start_day_id || r.accommodation_end_day_id)) {
+52 -12
View File
@@ -39,19 +39,24 @@ beforeEach(() => {
});
describe('pagesFor', () => {
it('windows the selection with two days on either side, room for a second swipe', () => {
expect(pagesFor(DAYS, 3).map((p) => [p.dayId, p.active])).toEqual([
[1, false],
[2, false],
[3, true],
[4, false],
[5, false],
it('has a page for every day and draws the selection with two days on either side', () => {
expect(pagesFor(DAYS, 3).map((p) => [p.dayId, p.active, p.near])).toEqual([
[1, false, true],
[2, false, true],
[3, true, true],
[4, false, true],
[5, false, true],
[6, false, false],
]);
});
it('has no page beyond the first and last day', () => {
expect(pagesFor(DAYS, 1).map((p) => p.dayId)).toEqual([1, 2, 3]);
expect(pagesFor(DAYS, 6).map((p) => p.dayId)).toEqual([4, 5, 6]);
it('keeps the same pages whichever day is selected, so a run of swipes never meets an end', () => {
expect(pagesFor(DAYS, 1).map((p) => p.key)).toEqual(pagesFor(DAYS, 6).map((p) => p.key));
expect(
pagesFor(DAYS, 6)
.filter((p) => p.near)
.map((p) => p.dayId)
).toEqual([4, 5, 6]);
});
it('keeps a page for a selection that points at no day, with the first day as the way back', () => {
@@ -69,7 +74,9 @@ describe('MDayPager', () => {
expect(screen.getByText('page 2 live')).toBeInTheDocument();
const pages = Array.from(strip.children) as HTMLElement[];
expect(pages.map((p) => p.hasAttribute('inert'))).toEqual([true, false, true, true]);
expect(pages.map((p) => p.hasAttribute('inert'))).toEqual([true, false, true, true, true, true]);
// Day 6 is three away: its page is there to swipe onto, its content is not drawn yet.
expect(screen.queryByText('page 6')).not.toBeInTheDocument();
expect(pages[0]).toHaveAttribute('aria-hidden', 'true');
expect(pages[1]).not.toHaveAttribute('aria-hidden');
});
@@ -166,7 +173,7 @@ describe('MDayPager', () => {
}
});
it('puts the strip back on the current page when the window moves', () => {
it('puts the strip on the current page when the selection moves', () => {
const { strip, rerender, onSelectDay } = setup(2);
// jsdom keeps no scroll offset, so give the strip one to move.
let scrollLeft = 0;
@@ -199,6 +206,39 @@ describe('MDayPager', () => {
expect(strip.scrollLeft).toBe(390);
});
it('moves the strip to a day tapped inside the same window, as on a two-day trip', () => {
const twoDays = DAYS.slice(0, 2);
const { strip, rerender, onSelectDay } = setup(1, { days: twoDays });
let scrollLeft = 0;
Object.defineProperty(strip, 'scrollLeft', {
configurable: true,
get: () => scrollLeft,
set: (v) => {
scrollLeft = v;
},
});
// Both days are in the window either way; only the current page changes.
const realQuery = strip.querySelector.bind(strip);
vi.spyOn(strip, 'querySelector').mockImplementation((sel: string) => {
const el = realQuery(sel) as HTMLElement | null;
if (el && sel === '[data-page-active]') el.getBoundingClientRect = () => ({ left: 390 }) as DOMRect;
return el;
});
strip.getBoundingClientRect = () => ({ left: 0 }) as DOMRect;
rerender(
<MDayPager
days={twoDays}
selectedDayId={2}
onSelectDay={onSelectDay}
describeDay={() => ''}
renderDay={(dayId) => <p>{`page ${dayId}`}</p>}
/>
);
expect(strip.scrollLeft).toBe(390);
});
it('holds still while locked', () => {
const { strip } = setup(2, { locked: true });
expect(strip).toHaveClass('overflow-x-hidden');
+12 -10
View File
@@ -4,11 +4,11 @@ import { tickHaptic } from '../../native/haptics';
import { pagesFor, type PagerItem } from './dayPagerPages';
/**
* Pages through the trip's days the way a native pager does: the current day
* and two on either side sit side by side in a horizontal scroll-snap strip
* (dayPagerPages.ts), so the finger drags real paper, the neighbour slides in
* with it, and a second swipe made while the first still snaps carries on to
* the day after. The browser's compositor owns the gesture. Momentum, rubber-banding at the first
* Pages through the trip's days the way a native pager does: every day is a
* page of a horizontal scroll-snap strip (dayPagerPages.ts), the current one
* and two on either side drawn, so the finger drags real paper, the neighbour
* slides in with it, and swipes made while the last one still snaps carry on
* as far as the trip goes. The browser's compositor owns the gesture. Momentum, rubber-banding at the first
* and last day, axis locking against the vertical scroll and the swallowed tap
* after a swipe are all the platform's own, in the PWA and the app alike.
*
@@ -16,8 +16,7 @@ import { pagesFor, type PagerItem } from './dayPagerPages';
* the swipe is decided, not once the snap has settled, which on iOS comes
* a good while later. The day only changes once the strip has come to rest on
* another page. Then
* the selection moves, the window of three shifts by one and the strip is put
* back on the centre page before the next paint, so nothing visibly jumps.
* the selection moves and the days around it draw their content.
* Keys follow the day ids, so the page that slid in keeps its state (scroll
* position, fetched route and weather) as it becomes the current one.
*
@@ -71,7 +70,10 @@ export default function MDayPager({
const stripRef = useRef<HTMLDivElement>(null);
const [announcement, setAnnouncement] = useState('');
const pages = pagesFor(days, selectedDayId);
const windowKey = pages.map((p) => p.key).join(',');
// Which page is current belongs in the key: on a trip of up to five days a
// chip tap can keep the same pages, and the strip then stayed on the old day
// while the rail already showed the new one.
const windowKey = pages.map((p) => (p.active ? `*${p.key}` : p.key)).join(',');
// The handlers below are attached once, so they read the props through here.
const latest = useRef({ days, onSelectDay, describeDay, selectedDayId, onPreview });
@@ -91,7 +93,7 @@ export default function MDayPager({
if (Math.abs(offset) > 0.5) strip.scrollLeft += offset;
}, []);
// Before paint whenever the window moves: a landed swipe or a chip tap.
// Before paint whenever the window or its current page moves: a landed swipe or a chip tap.
useLayoutEffect(() => {
requested.current = null;
recentre();
@@ -212,7 +214,7 @@ export default function MDayPager({
aria-hidden={page.active ? undefined : true}
className={`relative w-full flex-none snap-center snap-always ${inFlow ? '' : 'h-full'}`}
>
{renderDay(page.dayId, page.active)}
{page.near ? renderDay(page.dayId, page.active) : null}
</div>
))}
</div>
@@ -46,10 +46,12 @@ describe('MPushPrompt', () => {
it('FE-MOB-PUSHPROMPT-001: a moment after signing in the app asks, and turning it on goes the settings card way', async () => {
await renderAndWait();
expect(screen.getByText('Turn on notifications?')).toBeInTheDocument();
expect(window.localStorage.getItem(PUSH_PROMPT_KEY)).not.toBeNull();
// Asked once answered, not once shown.
expect(window.localStorage.getItem(PUSH_PROMPT_KEY)).toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'Turn on' }));
expect(env.toggle).toHaveBeenCalledTimes(1);
expect(window.localStorage.getItem(PUSH_PROMPT_KEY)).not.toBeNull();
expect(screen.queryByText('Turn on notifications?')).not.toBeInTheDocument();
});
@@ -77,6 +79,18 @@ describe('MPushPrompt', () => {
expect(window.localStorage.getItem(PUSH_PROMPT_KEY)).toBeNull();
});
it('FE-MOB-PUSHPROMPT-005: a prompt left unanswered asks again on the next launch', async () => {
const first = render(<MPushPrompt />);
await act(async () => {
await vi.advanceTimersByTimeAsync(PUSH_PROMPT_DELAY_MS);
});
expect(screen.getByText('Turn on notifications?')).toBeInTheDocument();
first.unmount();
await renderAndWait();
expect(screen.getByText('Turn on notifications?')).toBeInTheDocument();
});
it('FE-MOB-PUSHPROMPT-004: signed out, it waits', async () => {
useAuthStore.setState({ isAuthenticated: false } as never);
await renderAndWait();
+13 -4
View File
@@ -2,6 +2,7 @@ import { BellRing } from 'lucide-react';
import React, { useEffect, useState } from 'react';
import { useWebPush } from '../../hooks/useWebPush';
import { useTranslation } from '../../i18n';
import { useNativeCapabilities } from '../../native/capabilities';
import { nativePushActive, nativePushEndpoint } from '../../native/push';
import { pushChannelOffered } from '../../push/webPush';
import { useAuthStore } from '../../store/authStore';
@@ -36,9 +37,14 @@ function rememberAsked(): void {
* server offers push, and only while this device is not signed up yet. Turning
* it on goes the way the settings' push card goes (useWebPush), which brings up
* the system's question.
*
* It counts as asked once the user answers it, not when it opens: a sheet that
* went under another one, or an app closed while it was up, asks again. And it
* waits for the app's capabilities, which can arrive after the sign-in.
*/
export default function MPushPrompt(): React.ReactElement | null {
const signedIn = useAuthStore((s) => s.isAuthenticated);
const { caps } = useNativeCapabilities();
const [open, setOpen] = useState(false);
useEffect(() => {
@@ -46,8 +52,7 @@ export default function MPushPrompt(): React.ReactElement | null {
let cancelled = false;
const timer = window.setTimeout(() => {
void pushChannelOffered().then((offered) => {
if (cancelled || !offered || nativePushEndpoint()) return;
rememberAsked();
if (cancelled || !offered || nativePushEndpoint() || alreadyAsked()) return;
setOpen(true);
});
}, PUSH_PROMPT_DELAY_MS);
@@ -55,9 +60,13 @@ export default function MPushPrompt(): React.ReactElement | null {
cancelled = true;
window.clearTimeout(timer);
};
}, [signedIn]);
}, [signedIn, caps]);
return open ? <PushPromptSheet onClose={() => setOpen(false)} /> : null;
const answered = () => {
rememberAsked();
setOpen(false);
};
return open ? <PushPromptSheet onClose={answered} /> : null;
}
function PushPromptSheet({ onClose }: { onClose: () => void }): React.ReactElement {
@@ -35,8 +35,8 @@ function setup() {
describe('MSwipeRow', () => {
it('FE-MOB-SWIPE-001: shows no action buttons until the row is swiped', () => {
setup();
expect(screen.queryByText('Delete')).toBeNull();
expect(screen.queryByText('Done')).toBeNull();
expect(screen.queryByLabelText('Delete')).toBeNull();
expect(screen.queryByLabelText('Done')).toBeNull();
});
it('FE-MOB-SWIPE-002: a swipe towards the start opens the trailing action, a tap on it runs it', () => {
@@ -45,7 +45,7 @@ describe('MSwipeRow', () => {
touch(row, 'touchmove', 160);
touch(row, 'touchmove', 120);
touch(row, 'touchend');
fireEvent.click(screen.getByText('Delete'));
fireEvent.click(screen.getByLabelText('Delete'));
expect(onDelete).toHaveBeenCalledOnce();
});
@@ -54,7 +54,7 @@ describe('MSwipeRow', () => {
touch(row, 'touchstart', 200, 100);
touch(row, 'touchmove', 190, 160);
touch(row, 'touchend');
expect(screen.queryByText('Delete')).toBeNull();
expect(screen.queryByLabelText('Delete')).toBeNull();
});
it('FE-MOB-SWIPE-004: a tap on an open row closes it instead of opening the row', () => {
@@ -72,7 +72,7 @@ describe('MSwipeRow', () => {
touch(row, 'touchstart', 10);
touch(row, 'touchmove', 120);
touch(row, 'touchend');
expect(screen.queryByText('Done')).toBeNull();
expect(screen.queryByLabelText('Done')).toBeNull();
});
it('FE-MOB-SWIPE-006: swiped through past most of the row, the first action runs at once', () => {
+21 -6
View File
@@ -34,10 +34,18 @@ interface MSwipeRowProps {
className?: string;
/** The row's own background: it covers the buttons until the row moves, so it must match what is around it. */
surface?: string;
/**
* How far the buttons sit in from the row's top and bottom edge. A row that is a
* card of its own leaves it at 0, so the buttons are as tall as the card; a row
* inside a grouped card insets them, so they do not touch its dividers.
*/
inset?: number;
children: ReactNode;
}
const BUTTON_W = 76;
/** One button, and the gap before it: the buttons are cards of their own beside the row. */
const BUTTON_W = 70;
const GAP = 6;
const EDGE_PX = 24;
const CLAIM_PX = 10;
const AXIS_BIAS = 1.5;
@@ -59,6 +67,7 @@ export default function MSwipeRow({
disabled = false,
className = '',
surface = 'bg-[color:var(--m-card)]',
inset = 0,
children,
}: MSwipeRowProps) {
const rowRef = useRef<HTMLDivElement>(null);
@@ -91,7 +100,11 @@ export default function MSwipeRow({
// Positive offset reveals the leading side in LTR; RTL mirrors it.
const leadSign = () => (rtl() ? -1 : 1);
const openWidth = (which: 'leading' | 'trailing') =>
which === 'leading' ? (latest.current.leading ? BUTTON_W : 0) : latest.current.trailing.length * BUTTON_W;
which === 'leading'
? latest.current.leading
? BUTTON_W + GAP
: 0
: latest.current.trailing.length * (BUTTON_W + GAP);
const close = () => {
place(0, true);
@@ -194,17 +207,18 @@ export default function MSwipeRow({
action.onAction();
};
const shown = open ?? side;
// The icon alone, as in Mail; the label names the button for a screen reader.
const button = (action: SwipeAction) => (
<button
key={action.label}
type="button"
data-swipe-action=""
tabIndex={open ? 0 : -1}
aria-label={action.label}
onClick={() => run(action)}
className={`flex h-full w-[76px] flex-none flex-col items-center justify-center gap-1 text-caption font-semibold ${TONE[action.tone]}`}
className={`flex h-full w-[70px] flex-none items-center justify-center ${inset ? 'rounded-xl' : 'rounded-2xl'} ${TONE[action.tone]}`}
>
<action.icon size={18} strokeWidth={2.2} aria-hidden="true" />
{action.label}
<action.icon size={16} strokeWidth={2.2} aria-hidden="true" />
</button>
);
@@ -213,7 +227,8 @@ export default function MSwipeRow({
{shown && (
<div
aria-hidden={!open}
className={`absolute inset-0 flex ${shown === 'leading' ? 'justify-start' : 'justify-end'}`}
className={`absolute inset-x-0 flex gap-[6px] ${shown === 'leading' ? 'justify-start pe-[6px]' : 'justify-end ps-[6px]'}`}
style={{ top: inset, bottom: inset }}
>
{shown === 'leading' ? leading && button(leading) : trailing.map(button)}
</div>
+13 -14
View File
@@ -7,29 +7,28 @@ interface Page {
key: string;
dayId: number | null;
active: boolean;
/** Drawn with its content; the pages further out are empty paper of the same width. */
near: boolean;
}
/**
* Pages kept either side of the selection. Two, not one: a second swipe that
* starts while the first is still snapping has to find a page beyond the
* neighbour, or it runs into the end of the strip and springs back instead of
* reaching the day after.
*/
/** Pages drawn with their content either side of the selection. */
const REACH = 2;
/** The window of pages around the selection. A selection that points at no
* day (the whole-trip view, or a day deleted remotely) still gets its page,
* with the first day next to it as a way back. */
/**
* A page for every day, so quick swipes in a row never run into the end of the
* strip before the first has settled (they used to spring back after two days).
* Only the selection and REACH days either side draw their content. A selection
* that points at no day (the whole-trip view, or a day deleted remotely) still
* gets its page, with the first day next to it as a way back.
*/
export function pagesFor(days: PagerItem[], selectedDayId: number | null): Page[] {
const i = days.findIndex((d) => d.id === selectedDayId);
if (i < 0) {
const first = days[0];
return [
{ key: 'none', dayId: selectedDayId, active: true },
...(first ? [{ key: `d${first.id}`, dayId: first.id, active: false }] : []),
{ key: 'none', dayId: selectedDayId, active: true, near: true },
...(first ? [{ key: `d${first.id}`, dayId: first.id, active: false, near: true }] : []),
];
}
return days
.slice(Math.max(0, i - REACH), i + REACH + 1)
.map((d) => ({ key: `d${d.id}`, dayId: d.id, active: d.id === days[i].id }));
return days.map((d, j) => ({ key: `d${d.id}`, dayId: d.id, active: j === i, near: Math.abs(j - i) <= REACH }));
}
@@ -8,7 +8,7 @@ import { CardActions, CardDeleteSheet, CardFiles, CardNotes, CardTitleButton, Ca
import { CardGrid, Field, ReservationPluginSlots, SectionHeader, StatusDot, TabScroller, TravelerAvatars, TravelerFilterRow } from './tabChrome'
import { STATUS_COLOR, type MTabScreenProps } from './tabModel'
import { cardWhen, parseTransportMeta, timeRange } from './transportsModel'
import { BOOKING_TYPE_COLOR } from '../../../../components/Planner/bookings/bookingsModel'
import { BOOKING_TYPE_COLOR, hotelStayTimes } from '../../../../components/Planner/bookings/bookingsModel'
import { useReservationListFilter } from '../../../../components/Planner/bookings/useReservationListFilter'
import { useReservationCard } from '../../../../components/Planner/bookings/useReservationCard'
import { orderedEndpoints } from '../../../../utils/flightLegs'
@@ -119,13 +119,18 @@ function BookingCard({ res, planner, canEdit, compact, onView }: {
if (meta.price != null && meta.price !== '') {
metaCells.push({ label: t('reservations.price'), value: formatPriceText(meta.price, meta.priceCurrency, planner.trip?.currency, locale) })
}
if (meta.check_in_time) {
metaCells.push({
const stay = hotelStayTimes(res, meta)
// A hotel's times are its check-in and check-out; they come first, so a price
// or a seat cannot push them off the card.
const stayCells: { label: string; value: string }[] = []
if (stay.checkIn) {
stayCells.push({
label: t('reservations.meta.checkIn'),
value: timeRange(meta.check_in_time, meta.check_in_end_time ?? null, locale, timeFormat),
value: timeRange(stay.checkIn, stay.checkInEnd, locale, timeFormat),
})
}
if (meta.check_out_time) metaCells.push({ label: t('reservations.meta.checkOut'), value: formatTime(meta.check_out_time, locale, timeFormat) })
if (stay.checkOut) stayCells.push({ label: t('reservations.meta.checkOut'), value: formatTime(stay.checkOut, locale, timeFormat) })
metaCells.unshift(...stayCells)
const files = filesFor(res, planner.files || [])
@@ -15,7 +15,6 @@ import { useExpenseLinks } from '../../../../components/Budget/useExpenseLinks'
import { finalBudgetFor, finalBudgetSources, formerParticipantRows, paidByUser, readUserNote } from '../../../../components/Budget/CostsPanel.helpers'
import { catMeta, COST_CAT_META } from '../../../../components/Budget/costsCategories'
import MConfirmSheet from '../../settings/MConfirmSheet'
import MSwipeRow, { type SwipeAction } from '../../../components/MSwipeRow'
import { CountPill, TabPanes } from './tabChrome'
import { AddPaymentSheet, MemberAvatar } from './MCostsPayment'
import { STATUS_COLOR, type MTabScreenProps } from './tabModel'
@@ -595,17 +594,10 @@ function ExpenseRow({ item, ctx, base, locale, t, canEdit, onEdit, onDelete, onT
const members = item.members || []
const note = readUserNote(item)
// A swipe offers what the buttons beside the card do; delete still asks first.
const swipe: SwipeAction[] = canEdit
? [
{ label: t('common.delete'), icon: Trash2, tone: 'danger', onAction: onDelete },
{ label: t('common.edit'), icon: Pencil, tone: 'neutral', onAction: onEdit },
]
: []
// No swipe here: edit and delete stand beside the card already, and a swipe
// put a second, larger pair of the same buttons next to them.
return (
<MSwipeRow trailing={swipe} disabled={!canEdit} surface="bg-[color:var(--m-bg)]" className="mt-2 rounded-2xl">
<div className="flex items-center gap-[6px]">
<div className="mt-2 flex items-center gap-[6px]">
<div className="relative min-w-0 flex-1 rounded-2xl bg-m-card px-3 pb-[10px] pt-[22px]" style={{ border: `1.5px solid ${borderColor}` }}>
<span
className="absolute -start-[1.5px] -top-[1.5px] flex items-center gap-1 rounded-es-none rounded-ee-[12px] rounded-ss-[15px] rounded-se-none px-[11px] pb-[4px] pt-[3px] font-geist text-[0.5625rem] font-extrabold uppercase tracking-[.05em] text-white"
@@ -688,7 +680,6 @@ function ExpenseRow({ item, ctx, base, locale, t, canEdit, onEdit, onDelete, onT
</div>
)}
</div>
</MSwipeRow>
)
}
@@ -714,6 +714,7 @@ function PackingItemRow({ item, planner, currentUserId, editMode, canEdit, bagTr
leading={swipeLeading}
trailing={swipeTrailing}
surface="bg-m-sheetop"
inset={4}
className="border-t border-[color:var(--m-rowbr)] first:border-t-0"
>
<div className="flex items-center gap-[9px] py-2">
+8 -4
View File
@@ -44,10 +44,14 @@ describe('requestHandoffCode', () => {
});
describe('handoffUrl', () => {
it('points at the hand-off page with the challenge and an optional start', () => {
expect(handoffUrl('https://trek.example.com', 'c')).toBe('https://trek.example.com/native-handoff?challenge=c');
it('starts at the server page in front of the hand-off page, with the challenge and an optional start', () => {
// Under /api, which every TREK service worker passes to the server: an older
// version's worker must not answer the hand-off page from its cache.
expect(handoffUrl('https://trek.example.com', 'c')).toBe(
'https://trek.example.com/api/auth/native/start?challenge=c'
);
expect(handoffUrl('https://trek.example.com', 'c', 'oidc')).toBe(
'https://trek.example.com/native-handoff?challenge=c&start=oidc'
'https://trek.example.com/api/auth/native/start?challenge=c&start=oidc'
);
});
});
@@ -59,7 +63,7 @@ describe('signInThroughBrowser', () => {
await signInThroughBrowser('oidc');
const opened = new URL(vi.mocked(TrekShell.authenticate).mock.calls[0][0].url);
expect(opened.pathname).toBe('/native-handoff');
expect(opened.pathname).toBe('/api/auth/native/start');
expect(opened.searchParams.get('start')).toBe('oidc');
expect(vi.mocked(TrekShell.authenticate).mock.calls[0][0].callbackScheme).toBe('com.liketrek.trek');
const { code, verifier } = vi.mocked(nativeAuthApi.exchange).mock.calls[0][0];
+8 -1
View File
@@ -21,6 +21,13 @@ import { APP_URL_SCHEME, TrekShell } from './trekShell';
export const HANDOFF_PATH = '/native-handoff';
/**
* Where the browser is sent first: a page of the server's that takes an older
* TREK's service worker off the browser and then opens HANDOFF_PATH with the
* same query. Under /api, which every TREK service worker passes to the server.
*/
const HANDOFF_START_PATH = '/api/auth/native/start';
/** Where the browser starts: straight into SSO, or straight at the passkey prompt. */
export type BrowserSignInStart = 'oidc' | 'passkey';
@@ -65,7 +72,7 @@ export function createHandoffSecret(): { verifier: string; challenge: string } {
}
export function handoffUrl(origin: string, challenge: string, start?: BrowserSignInStart, invite?: string): string {
const url = new URL(HANDOFF_PATH, origin);
const url = new URL(HANDOFF_START_PATH, origin);
url.searchParams.set('challenge', challenge);
if (start) url.searchParams.set('start', start);
if (invite) url.searchParams.set('invite', invite);
@@ -0,0 +1,34 @@
import { afterEach, describe, expect, it } from 'vitest';
import {
BROWSER_SIGN_IN_KEY,
BROWSER_SIGN_IN_TTL_MS,
browserSignInRunning,
endBrowserSignIn,
} from './browserSignInFlag';
afterEach(() => localStorage.clear());
describe('browserSignInRunning', () => {
it('FE-NATIVE-SIGNINFLAG-001: counts a mark from public/native-handoff.js for as long as a sign-in may take', () => {
const now = 1_000_000_000;
expect(browserSignInRunning(now)).toBe(false);
localStorage.setItem(BROWSER_SIGN_IN_KEY, String(now - 1000));
expect(browserSignInRunning(now)).toBe(true);
localStorage.setItem(BROWSER_SIGN_IN_KEY, String(now - BROWSER_SIGN_IN_TTL_MS));
expect(browserSignInRunning(now)).toBe(false);
// A clock set back, or anything that is no time at all.
localStorage.setItem(BROWSER_SIGN_IN_KEY, String(now + 60_000));
expect(browserSignInRunning(now)).toBe(false);
localStorage.setItem(BROWSER_SIGN_IN_KEY, 'soon');
expect(browserSignInRunning(now)).toBe(false);
});
it('FE-NATIVE-SIGNINFLAG-002: ends with the hand-back to the app', () => {
localStorage.setItem(BROWSER_SIGN_IN_KEY, String(Date.now()));
endBrowserSignIn();
expect(localStorage.getItem(BROWSER_SIGN_IN_KEY)).toBeNull();
expect(browserSignInRunning()).toBe(false);
});
});
+31
View File
@@ -0,0 +1,31 @@
/**
* Marks the app's sign-in in the system browser while it runs. The page in front
* of the handoff page (public/native-handoff.js) sets it, so that until the user
* is handed back to the app no service worker is registered (public/registerSW.js)
* and no new version reloads the page (utils/versionHandover.ts). A browser tab
* still open on an older TREK keeps that version's worker alive, and a worker
* registered or a reload in the middle of the sign-in would bring it back to
* answer the next page with an app that has no handoff page.
*/
export const BROWSER_SIGN_IN_KEY = 'trek_native_signin';
/** As long as a sign-in in the browser may take; the mark counts for no longer. */
export const BROWSER_SIGN_IN_TTL_MS = 15 * 60 * 1000;
export function browserSignInRunning(now = Date.now()): boolean {
try {
const started = Number(localStorage.getItem(BROWSER_SIGN_IN_KEY));
return Number.isFinite(started) && started > 0 && now - started >= 0 && now - started < BROWSER_SIGN_IN_TTL_MS;
} catch {
return false;
}
}
/** The sign-in is handed back to the app: the browser goes back to normal. */
export function endBrowserSignIn(): void {
try {
localStorage.removeItem(BROWSER_SIGN_IN_KEY);
} catch {
/* site data blocked: the mark runs out by itself */
}
}
+23
View File
@@ -54,6 +54,29 @@ describe('keepFieldInView', () => {
expect(scroller.style.paddingBottom).toBe('');
});
it('FE-NATIVE-KBFIELD-004: a page as tall as the screen gets room at its end and scrolls the field above the keyboard', () => {
// The login on an iPhone: no pane scrolls, and the keyboard plugin holds the body's height.
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 1000 });
const scrollBy = vi.spyOn(window, 'scrollBy').mockImplementation(() => {});
const form = document.createElement('form');
document.body.appendChild(form);
field(form, 'input', 760, 800);
revealFocusedField(KEYBOARD);
const room = document.body.lastElementChild as HTMLElement;
expect(room.getAttribute('aria-hidden')).toBe('true');
expect(room.style.height).toBe('300px');
expect(scrollBy).toHaveBeenCalledWith({ top: 800 - (700 - 24), behavior: 'smooth' });
// The next field asks again without stacking a second room.
revealFocusedField(KEYBOARD);
expect(document.querySelectorAll('[aria-hidden="true"]')).toHaveLength(1);
releaseRoom();
expect(room.isConnected).toBe(false);
scrollBy.mockRestore();
});
it('FE-NATIVE-KBFIELD-002: listens for the keyboard coming up and going', () => {
const addListener = vi.fn(async () => ({ remove: vi.fn() }));
keepFieldInView({ addListener } as never);
+17 -1
View File
@@ -29,6 +29,13 @@ interface KeyboardEvents {
/** The pane given room for the keyboard, and what its bottom padding was before. */
let padded: { pane: HTMLElement; before: string } | null = null;
/**
* The room at the end of a page that scrolls in no pane of its own, such as the
* login: as tall as the screen, it has nothing to scroll by, and padding on the
* body does not help while the keyboard plugin holds the body's height.
*/
let pageRoom: HTMLElement | null = null;
function scrollingPane(field: HTMLElement): HTMLElement | null {
for (let node = field.parentElement; node; node = node.parentElement) {
const overflow = getComputedStyle(node).overflowY;
@@ -48,7 +55,14 @@ export function revealFocusedField(keyboardHeight: number, doc: Document = docum
const pane = scrollingPane(field);
if (!pane) {
field.scrollIntoView({ block: 'center', behavior: 'smooth' });
if (!pageRoom) {
releaseRoom();
pageRoom = doc.createElement('div');
pageRoom.setAttribute('aria-hidden', 'true');
pageRoom.style.height = `${keyboardHeight}px`;
doc.body.appendChild(pageRoom);
}
view?.scrollBy({ top: box.bottom - (visibleBottom - AIR), behavior: 'smooth' });
return;
}
if (!padded || padded.pane !== pane) {
@@ -61,6 +75,8 @@ export function revealFocusedField(keyboardHeight: number, doc: Document = docum
/** Takes the room for the keyboard away again. */
export function releaseRoom(): void {
pageRoom?.remove();
pageRoom = null;
if (!padded) return;
padded.pane.style.paddingBottom = padded.before;
padded = null;
+39 -1
View File
@@ -1,14 +1,19 @@
import { Directory, Filesystem } from '@capacitor/filesystem';
import { Share } from '@capacitor/share';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { cacheFileName, fileNameFromUrl, shareFile } from './nativeFiles';
import { hasNativeFeature } from './capabilities';
import { cacheFileName, fileNameFromUrl, openNativeFile, shareFile } from './nativeFiles';
import { TrekShell } from './trekShell';
vi.mock('@capacitor/filesystem', () => ({ Directory: { Cache: 'CACHE' }, Filesystem: { writeFile: vi.fn() } }));
vi.mock('@capacitor/share', () => ({ Share: { share: vi.fn() } }));
vi.mock('./capabilities', () => ({ hasNativeFeature: vi.fn() }));
vi.mock('./trekShell', () => ({ TrekShell: { previewFile: vi.fn() } }));
beforeEach(() => {
vi.mocked(Filesystem.writeFile).mockReset().mockResolvedValue({ uri: 'file:///cache/trip.pdf' });
vi.mocked(Share.share).mockReset().mockResolvedValue({});
vi.mocked(TrekShell.previewFile).mockReset();
});
describe('cacheFileName', () => {
@@ -51,3 +56,36 @@ describe('shareFile', () => {
await expect(shareFile(new Blob(['x']), 'x.txt')).rejects.toThrow('No activity found');
});
});
describe('openNativeFile', () => {
const pdf = () => new Blob(['%PDF'], { type: 'application/pdf' });
it('FE-NATIVE-FILES-OPEN-001: shows the file in the system viewer where the app has one, not in the share sheet', async () => {
vi.mocked(hasNativeFeature).mockReturnValue(true);
vi.mocked(TrekShell.previewFile).mockResolvedValue({ result: 'shown' });
await openNativeFile(pdf(), 'trip.pdf');
expect(TrekShell.previewFile).toHaveBeenCalledWith({
path: 'file:///cache/trip.pdf',
mimeType: 'application/pdf',
title: 'trip.pdf',
});
expect(Share.share).not.toHaveBeenCalled();
});
it('FE-NATIVE-FILES-OPEN-002: falls back to the share sheet for an older app, a type nothing shows, or a viewer that fails', async () => {
vi.mocked(hasNativeFeature).mockReturnValue(false);
await openNativeFile(pdf(), 'trip.pdf');
expect(TrekShell.previewFile).not.toHaveBeenCalled();
vi.mocked(hasNativeFeature).mockReturnValue(true);
vi.mocked(TrekShell.previewFile).mockResolvedValueOnce({ result: 'unsupported' });
await openNativeFile(pdf(), 'trip.pdf');
vi.mocked(TrekShell.previewFile).mockRejectedValueOnce(new Error('no window'));
await openNativeFile(pdf(), 'trip.pdf');
expect(Share.share).toHaveBeenCalledTimes(3);
expect(Share.share).toHaveBeenCalledWith({ files: ['file:///cache/trip.pdf'] });
});
});
+34
View File
@@ -1,6 +1,8 @@
import { Directory, Filesystem } from '@capacitor/filesystem';
import { Share } from '@capacitor/share';
import { toBase64 } from './blobBase64';
import { hasNativeFeature } from './capabilities';
import { TrekShell } from './trekShell';
import { addToWallet, isWalletPassName } from './wallet';
/** Readable, but safe as a single path segment inside the cache directory. */
@@ -29,11 +31,43 @@ export function fileNameFromUrl(url: string): string {
export async function shareFile(blob: Blob, filename: string): Promise<void> {
// A Wallet pass goes to Apple Wallet's own sheet, not to the generic share sheet.
if (isWalletPassName(filename) && (await addToWallet(blob))) return;
await shareCached(await writeToCache(blob, filename));
}
/**
* Opens a file to look at it: a PDF to page through, an image, a document, in
* the system's viewer (Quick Look on iOS, the viewing app on Android). An app
* without the viewer, or a type nothing on the device shows, gets the share
* sheet as before, which still previews and saves it.
*/
export async function openNativeFile(blob: Blob, filename: string): Promise<void> {
if (isWalletPassName(filename) && (await addToWallet(blob))) return;
const uri = await writeToCache(blob, filename);
if (hasNativeFeature('filePreview')) {
try {
const { result } = await TrekShell.previewFile({
path: uri,
mimeType: blob.type || 'application/octet-stream',
title: filename,
});
if (result === 'shown') return;
} catch {
/* the viewer could not open it: the share sheet still can */
}
}
await shareCached(uri);
}
async function writeToCache(blob: Blob, filename: string): Promise<string> {
const { uri } = await Filesystem.writeFile({
path: cacheFileName(filename),
data: await toBase64(blob),
directory: Directory.Cache,
});
return uri;
}
async function shareCached(uri: string): Promise<void> {
try {
await Share.share({ files: [uri] });
} catch (err) {
+14 -6
View File
@@ -3,7 +3,7 @@ import { candidateUrls, probeDemo, probeServer, type ProbeResponse } from './ser
const features = (
url: string,
data: unknown = { bookingImport: false, aiParsing: false, nativeApp: true }
data: unknown = { bookingImport: false, aiParsing: false, nativeApp: true, nativeApi: 2 }
): ProbeResponse => ({ status: 200, data, url });
describe('candidateUrls', () => {
@@ -26,13 +26,21 @@ describe('candidateUrls', () => {
describe('probeServer', () => {
it('accepts a server at the minimum native API version or above', async () => {
const data = { bookingImport: false, nativeApp: true, nativeApi: 1 };
const get = vi.fn().mockResolvedValue(features('https://trek.example.com/api/health/features', data));
expect(await probeServer('https://trek.example.com', get)).toEqual({ ok: true, url: 'https://trek.example.com' });
for (const nativeApi of [2, 3]) {
const data = { bookingImport: false, nativeApp: true, nativeApi };
const get = vi.fn().mockResolvedValue(features('https://trek.example.com/api/health/features', data));
expect(await probeServer('https://trek.example.com', get)).toEqual({ ok: true, url: 'https://trek.example.com' });
}
});
it('calls a server below the minimum native API version too old', async () => {
const data = { bookingImport: false, nativeApp: true, nativeApi: 0 };
const data = { bookingImport: false, nativeApp: true, nativeApi: 1 };
const get = vi.fn().mockResolvedValue(features('https://trek.example.com/api/health/features', data));
expect(await probeServer('https://trek.example.com', get)).toEqual({ ok: false, reason: 'tooOld' });
});
it('calls a server that speaks to the app but reports no native API version too old', async () => {
const data = { bookingImport: false, nativeApp: true };
const get = vi.fn().mockResolvedValue(features('https://trek.example.com/api/health/features', data));
expect(await probeServer('https://trek.example.com', get)).toEqual({ ok: false, reason: 'tooOld' });
});
@@ -173,7 +181,7 @@ describe('probeServer behind mutual TLS', () => {
});
it('connects where the proxy asked and TREK answered, with or without a certificate', async () => {
const trek = { bookingImport: false, nativeApp: true, nativeApi: 1 };
const trek = { bookingImport: false, nativeApp: true, nativeApi: 2 };
for (const sent of [true, false]) {
const get = vi.fn().mockResolvedValue(asked(200, sent, trek));
expect(await probeServer('https://trek.example.com', get)).toEqual({ ok: true, url: 'https://trek.example.com' });
+6 -4
View File
@@ -43,10 +43,12 @@ const FEATURES_PATH = '/api/health/features';
/**
* The oldest server API this app can work with (`nativeApi` in the features
* response). A server from before the field existed answers without it and is
* accepted while the minimum is 1.
* response). 2 is TREK 5.1.3, the first server whose sign-in from the app does
* not end in the browser. A server without the field is older still. Raising it
* goes with SERVER_CHECK in TrekServerStore (iOS and Android), so a server the
* app remembers from before is checked against the new minimum once.
*/
const MIN_NATIVE_API = 1;
const MIN_NATIVE_API = 2;
function normalize(raw: string): string | null {
let parsed: URL;
@@ -80,7 +82,7 @@ function judge(response: ProbeResponse): ProbeResult {
if (response.status !== 200 || !isRecord(response.data)) return { ok: false, reason: 'notTrek' };
if (response.data.nativeApp === true) {
const api = response.data.nativeApi;
if (typeof api === 'number' && api < MIN_NATIVE_API) return { ok: false, reason: 'tooOld' };
if (typeof api !== 'number' || api < MIN_NATIVE_API) return { ok: false, reason: 'tooOld' };
// Follow a redirect (http to https, a canonical host) so the app loads the
// address the server actually answers on.
const base = response.url.endsWith(FEATURES_PATH) ? response.url.slice(0, -FEATURES_PATH.length) : null;
+24 -2
View File
@@ -13,7 +13,12 @@ import type { NativeCapabilities } from './capabilities';
* worker.
*/
export interface TrekShellPlugin {
getServer(): Promise<{ url: string | null }>;
/**
* `previousUrl`: the server this app remembered before an update asked for a
* newer one (SERVER_CHECK in TrekServerStore). Set only while no server is,
* so the address screen can check it again, pre-filled.
*/
getServer(): Promise<{ url: string | null; previousUrl?: string | null }>;
/**
* Remembers the server and loads it. The current page is gone afterwards.
* `signInHosts` are the hosts of a proxy's sign-in page in front of it
@@ -21,8 +26,15 @@ export interface TrekShellPlugin {
* `welcome` is the one-time code of a TREK Cloud purchase: the first load
* opens the server's welcome page with it, which signs the buyer in. A build
* without this option ignores it and opens the server as usual.
* `labels` are the words of the bar the app shows over a proxy's sign-in page,
* in the user's language: the server's UI is not there to say them.
*/
setServer(options: { url: string; signInHosts?: string[]; welcome?: string }): Promise<void>;
setServer(options: {
url: string;
signInHosts?: string[];
welcome?: string;
labels?: { signInHint: string; changeServer: string };
}): Promise<void>;
/** Forgets the server and returns to the address screen. */
resetServer(): Promise<void>;
/** Loads the remembered server again, after it could not be reached. */
@@ -103,6 +115,16 @@ export interface TrekShellPlugin {
storeFinish(options: { transactionId: string }): Promise<void>;
/** iOS, feature wallet: adds a .pkpass (base64) to Apple Wallet, or opens it there when it is already in. */
addToWallet(options: { data: string }): Promise<{ result: 'added' | 'opened' | 'cancelled' }>;
/**
* Shows a file written to the app's cache (a file:// path) in the system's
* viewer: Quick Look on iOS, the app that views the type on Android. Feature
* "filePreview"; "unsupported" where nothing on the device shows the type.
*/
previewFile(options: {
path: string;
mimeType: string;
title?: string;
}): Promise<{ result: 'shown' | 'unsupported' }>;
/** iOS, feature storeKit: the newest active subscription under `prefix` on this Apple ID, if any. */
storeRestore(options: { prefix: string }): Promise<{ jws?: string; transactionId?: string }>;
/** Feature watch: what the Apple Watch app shows (native/watch.ts), as JSON, sent on through WatchConnectivity. */
+90
View File
@@ -211,6 +211,96 @@ describe('the richer snapshot', () => {
expect(withBookings(plain, [], '2026-11-06', (t) => t)).toBe(plain);
});
it('makes a stop of every leg of a flight with a stopover, and keeps a flight in the air ahead until it lands', () => {
const plain = todayOf(
trip(1, '2026-11-09', '2026-11-10'),
[day(5, 1, '2026-11-09', []), day(6, 2, '2026-11-10', [])],
'2026-11-09',
() => 'D',
(t) => t
);
if (!plain) throw new Error('the trip runs today');
const legs = [
{ from: 'SNA', to: 'ATL', dep_day_id: 5, dep_time: '14:00', arr_day_id: 5, arr_time: '21:11' },
{ from: 'ATL', to: 'ORF', dep_day_id: 5, dep_time: '22:40', arr_day_id: 6, arr_time: '00:31' },
];
const today = withBookings(
plain,
[
{ id: 1, trip_id: 1, title: 'Car', type: 'car', status: 'confirmed', reservation_time: '2026-11-09T13:00' },
{
id: 2,
trip_id: 1,
title: 'SNA → ATL → ORF',
type: 'flight',
status: 'confirmed',
reservation_time: '2026-11-09T14:00',
metadata: JSON.stringify({ legs }),
},
] as never,
'2026-11-09',
(t) => t
);
expect(today.stops.map((s) => s.name)).toEqual(['Car', 'SNA → ATL', 'ATL → ORF']);
// In the air on the first leg, between the legs, and on the second leg past midnight.
expect(tripDayState(today, 16 * 60 + 22, 'done')).toMatchObject({ nextName: 'SNA → ATL', done: 1, total: 3 });
expect(tripDayState(today, 21 * 60 + 30, 'done')).toMatchObject({ nextName: 'ATL → ORF', done: 2 });
expect(tripDayState(today, 23 * 60 + 30, 'done')).toMatchObject({ nextName: 'ATL → ORF', done: 2 });
});
it('lands a leg without an arrival day on the day it leaves', () => {
const plain = todayOf(
trip(1, '2026-11-09', '2026-11-09'),
[day(5, 1, '2026-11-09', [])],
'2026-11-09',
() => 'D',
(t) => t
);
if (!plain) throw new Error('the trip runs today');
const legs = [
{ from: 'SNA', to: 'ATL', dep_day_id: 5, dep_time: '08:00', arr_time: '10:00' },
{ from: 'ATL', to: 'ORF', dep_day_id: 5, dep_time: '12:00', arr_time: '14:00' },
];
const today = withBookings(
plain,
[
{ id: 2, trip_id: 1, title: 'Hop', type: 'flight', status: 'confirmed', metadata: JSON.stringify({ legs }) },
] as never,
'2026-11-09',
(t) => t
);
expect(tripDayState(today, 15 * 60, 'done')).toMatchObject({ nextName: 'done', done: 2 });
});
it('counts a booking with an end time as under way until it ends', () => {
const plain = todayOf(
trip(1, '2026-11-09', '2026-11-09'),
[day(5, 1, '2026-11-09', [])],
'2026-11-09',
() => 'D',
(t) => t
);
if (!plain) throw new Error('the trip runs today');
const today = withBookings(
plain,
[
{
id: 1,
trip_id: 1,
title: 'Tour',
type: 'tour',
status: 'confirmed',
reservation_time: '2026-11-09T10:00',
reservation_end_time: '2026-11-09T13:00',
},
] as never,
'2026-11-09',
(t) => t
);
expect(tripDayState(today, 11 * 60, 'done')).toMatchObject({ nextName: 'Tour', done: 0 });
expect(tripDayState(today, 13 * 60 + 1, 'done')).toMatchObject({ nextName: 'done', done: 1 });
});
it('carries the extras and leaves out what is empty', () => {
const snapshot = buildSnapshot(
{ ...trip(1, '2026-11-06', '2026-11-07'), day_count: 2 } as Trip,
+38 -4
View File
@@ -1,4 +1,5 @@
import type { Day, Reservation, Trip } from '../types';
import { getFlightLegs, getTrainLegs, usesStationRoute } from '../utils/flightLegs';
import { splitReservationDateTime } from '../utils/formatters';
import { heroTrip, sortTripsByRelevance } from '../utils/heroTrip';
import { hasNativeFeature } from './capabilities';
@@ -56,6 +57,8 @@ interface WidgetStop {
minutes: number | null;
color?: string | null;
symbol?: string | null;
/** Minutes after midnight it ends, past midnight as 1440: a flight in the air is not done yet. */
endMinutes?: number | null;
}
interface SnapshotStop {
@@ -120,6 +123,8 @@ export interface TripDay {
path: string;
dayLabel: string;
stops: WidgetStop[];
/** The day's id, which the legs of a multi-leg booking name. */
dayId?: number;
/** 1-based position of the day in the trip, and the trip's day count. */
index?: number;
total?: number;
@@ -211,6 +216,7 @@ export function todayOf(
path: `/trips/${trip.id}`,
dayLabel: dayLabel(index + 1, ordered.length),
stops,
dayId: day.id,
index: index + 1,
total: ordered.length,
};
@@ -224,15 +230,38 @@ export function withBookings(
formatTime: (time: string) => string
): TripDay {
const booked = reservations.flatMap((r): WidgetStop[] => {
if (r.status === 'cancelled') return [];
const symbol = RESERVATION_SYMBOLS[r.type] ?? 'bookmark.fill';
// A booking with stopovers is a stop per leg, as the planner shows it: one
// stop at the first departure counted the day done while legs were ahead.
const legs = r.type === 'flight' ? getFlightLegs(r) : usesStationRoute(r.type) ? getTrainLegs(r) : [];
if (legs.length > 1 && day.dayId != null && legs.every((l) => l.dep_day_id != null)) {
return legs.flatMap((leg): WidgetStop[] => {
if (leg.dep_day_id !== day.dayId || !leg.dep_time) return [];
return [
{
name: leg.from && leg.to ? `${leg.from} → ${leg.to}` : r.title,
time: formatTime(leg.dep_time),
minutes: minutesOf(leg.dep_time),
// A leg without an arrival day lands on the day it leaves, as the planner reads it.
endMinutes: (leg.arr_day_id ?? leg.dep_day_id) === day.dayId ? minutesOf(leg.arr_time) : 24 * 60,
color: null,
symbol,
},
];
});
}
const { date, time } = splitReservationDateTime(r.reservation_time);
if (r.status === 'cancelled' || !time || date !== today) return [];
if (!time || date !== today) return [];
const end = splitReservationDateTime(r.reservation_end_time);
return [
{
name: r.title,
time: formatTime(time),
minutes: minutesOf(time),
endMinutes: !end.time ? null : end.date && end.date !== today ? 24 * 60 : minutesOf(end.time),
color: null,
symbol: RESERVATION_SYMBOLS[r.type] ?? 'bookmark.fill',
symbol,
},
];
});
@@ -303,10 +332,15 @@ interface TripDayState {
total: number;
}
/** The Live Activity's state: the first stop whose time is still ahead, else the last. */
/**
* The Live Activity's state: the first stop still ahead or still under way (a
* flight in the air until it lands), else the last.
*/
export function tripDayState(day: TripDay, nowMinutes: number, allDone: string): TripDayState {
const timed = day.stops.filter((s) => s.minutes != null);
const nextIndex = day.stops.findIndex((s) => s.minutes != null && s.minutes >= nowMinutes);
const nextIndex = day.stops.findIndex(
(s) => s.minutes != null && (s.minutes >= nowMinutes || (s.endMinutes != null && s.endMinutes > nowMinutes))
);
const done = nextIndex < 0 ? timed.length : day.stops.slice(0, nextIndex).filter((s) => s.minutes != null).length;
const next = nextIndex >= 0 ? day.stops[nextIndex] : null;
return {
+13 -66
View File
@@ -4,7 +4,6 @@ import {
EyeOff,
Fingerprint,
Globe,
KeyRound,
Loader2,
Lock,
Mail,
@@ -18,6 +17,7 @@ import { SUPPORTED_LANGUAGES, useTranslation } from '../i18n';
import { IDP_LOGIN_URL, useLogin } from './login/useLogin';
import LoginWorld from './login/LoginWorld';
import NativeServerLink from './login/NativeServerLink';
import MfaCodeStep from './login/MfaCodeStep';
import PasswordChecklist from '../components/shared/PasswordChecklist';
/** Fixed so the sky does not reshuffle on every render. */
@@ -739,71 +739,18 @@ export default function LoginPage(): React.ReactElement {
)}
{mode === 'login' && mfaStep && !passwordChangeStep && (
<div>
<label
style={{
display: 'block',
fontSize: 'calc(12.5px * var(--fs-scale-body, 1))',
fontWeight: 600,
color: '#374151',
marginBottom: 6,
}}
>
{t('login.mfaCodeLabel')}
</label>
<div style={{ position: 'relative' }}>
<KeyRound
size={15}
className="text-[#9ca3af]"
style={{
position: 'absolute',
insetInlineStart: 13,
top: '50%',
transform: 'translateY(-50%)',
pointerEvents: 'none',
}}
/>
<input
type="text"
inputMode="text"
autoComplete="one-time-code"
value={mfaCode}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setMfaCode(e.target.value.toUpperCase().slice(0, 24))
}
placeholder="000000 or XXXX-XXXX"
required
autoFocus
style={inputBase}
onFocus={(e: React.FocusEvent<HTMLInputElement>) => (e.target.style.borderColor = '#111827')}
onBlur={(e: React.FocusEvent<HTMLInputElement>) => (e.target.style.borderColor = '#e5e7eb')}
/>
</div>
<p style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: '#9ca3af', marginTop: 8 }}>
{t('login.mfaHint')}
</p>
<button
type="button"
onClick={() => {
setMfaStep(false);
setMfaToken('');
setMfaCode('');
setError('');
}}
style={{
marginTop: 8,
background: 'none',
border: 'none',
color: '#6b7280',
fontSize: 'calc(13px * var(--fs-scale-body, 1))',
cursor: 'pointer',
padding: 0,
fontFamily: 'inherit',
}}
>
{t('login.mfaBack')}
</button>
</div>
<MfaCodeStep
email={email}
code={mfaCode}
onCode={setMfaCode}
inputStyle={inputBase}
onBack={() => {
setMfaStep(false);
setMfaToken('');
setMfaCode('');
setError('');
}}
/>
)}
{/* Username (register only) */}
+99
View File
@@ -0,0 +1,99 @@
import { KeyRound } from 'lucide-react';
import React from 'react';
import { useTranslation } from '../../i18n';
interface MfaCodeStepProps {
/** The account being signed in, kept in the form for password managers. */
email: string;
code: string;
onCode: (code: string) => void;
inputStyle: React.CSSProperties;
onBack: () => void;
}
/** The login's second step: the one-time or backup code, and the way back. */
export default function MfaCodeStep({ email, code, onCode, inputStyle, onBack }: MfaCodeStepProps) {
const { t } = useTranslation();
return (
<div>
<label
style={{
display: 'block',
fontSize: 'calc(12.5px * var(--fs-scale-body, 1))',
fontWeight: 600,
color: '#374151',
marginBottom: 6,
}}
>
{t('login.mfaCodeLabel')}
</label>
{/* The account the code belongs to, for password managers: they match a
one-time code to a saved login through the username in the same form,
and the email field is gone in this step. */}
<input
type="text"
name="username"
autoComplete="username"
value={email}
readOnly
tabIndex={-1}
aria-hidden="true"
className="sr-only"
/>
<div style={{ position: 'relative' }}>
<KeyRound
size={15}
className="text-[#9ca3af]"
style={{
position: 'absolute',
insetInlineStart: 13,
top: '50%',
transform: 'translateY(-50%)',
pointerEvents: 'none',
}}
/>
<input
type="text"
id="totp"
name="totp"
// Text, not numeric: backup codes carry letters. The hints are set here
// rather than left to native/keyboardFields.ts, because autoFocus gives
// the field focus before any touch could prepare it.
inputMode="text"
autoComplete="one-time-code"
autoCapitalize="characters"
autoCorrect="off"
spellCheck={false}
enterKeyHint="go"
value={code}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => onCode(e.target.value.toUpperCase().slice(0, 24))}
placeholder={t('login.mfaPlaceholder')}
required
autoFocus
style={inputStyle}
onFocus={(e: React.FocusEvent<HTMLInputElement>) => (e.target.style.borderColor = '#111827')}
onBlur={(e: React.FocusEvent<HTMLInputElement>) => (e.target.style.borderColor = '#e5e7eb')}
/>
</div>
<p style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: '#9ca3af', marginTop: 8 }}>
{t('login.mfaHint')}
</p>
<button
type="button"
onClick={onBack}
style={{
marginTop: 8,
background: 'none',
border: 'none',
color: '#6b7280',
fontSize: 'calc(13px * var(--fs-scale-body, 1))',
cursor: 'pointer',
padding: 0,
fontFamily: 'inherit',
}}
>
{t('login.mfaBack')}
</button>
</div>
);
}
@@ -102,6 +102,7 @@ describe('useNativeHandoff', () => {
it('FE-NATIVE-HANDOFF-006: only mints the code on the tap, then hands it to the app scheme', async () => {
vi.mocked(requestHandoffCode).mockResolvedValue('c/1');
localStorage.setItem('trek_native_signin', String(Date.now()));
const { result } = renderHandoff();
expect(requestHandoffCode).not.toHaveBeenCalled();
@@ -111,6 +112,8 @@ describe('useNativeHandoff', () => {
expect(requestHandoffCode).toHaveBeenCalledWith(challenge);
expect(window.location.href).toBe('com.liketrek.trek://auth?code=c%2F1');
// Handed back: the browser registers its service worker again from here.
expect(localStorage.getItem('trek_native_signin')).toBeNull();
});
it('FE-NATIVE-HANDOFF-007: reports a failed hand-off', async () => {
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router';
import { useShallow } from 'zustand/react/shallow';
import { HANDOFF_PATH, rememberHandoffReturn, requestHandoffCode, signInWithPasskey } from '../../native/browserSignIn';
import { endBrowserSignIn } from '../../native/browserSignInFlag';
import { APP_URL_SCHEME } from '../../native/trekShell';
import { useAuthStore } from '../../store/authStore';
@@ -50,6 +51,7 @@ export function useNativeHandoff() {
setStatus('sending');
try {
const code = await requestHandoffCode(challenge);
endBrowserSignIn();
window.location.href = `${APP_URL_SCHEME}://auth?code=${encodeURIComponent(code)}`;
} catch {
setStatus('failed');
+4 -4
View File
@@ -2,7 +2,7 @@ import { getCachedBlob } from '../db/offlineDb'
import { isEffectivelyOffline } from '../sync/networkMode'
import { isIosStandalone } from './iosDevice'
import { isNativeApp } from '../native/platform'
import { fileNameFromUrl, shareFile } from '../native/nativeFiles'
import { fileNameFromUrl, openNativeFile, shareFile } from '../native/nativeFiles'
import { reportNativeFailure } from '../native/reportFailure'
// MIME types safe to open inline (will not execute script in any browser).
@@ -118,9 +118,9 @@ export async function downloadFile(url: string, filename?: string): Promise<void
*/
export async function openFile(url: string, filename?: string): Promise<void> {
const blob = await getFileBlob(url)
// The share sheet previews PDFs and images too, and a blob tab has nowhere
// to open inside the app.
if (isNativeApp()) return shareFile(blob, filename ?? fileNameFromUrl(url))
// A blob tab has nowhere to open inside the app: the system's viewer shows it,
// or the share sheet where the app has none.
if (isNativeApp()) return openNativeFile(blob, filename ?? fileNameFromUrl(url))
const blobUrl = URL.createObjectURL(blob)
// Force download for MIME types that can execute script when rendered inline
+24
View File
@@ -447,6 +447,30 @@ describe('reconcileAppVersion: a page loaded past an older worker', () => {
})
})
describe("reconcileAppVersion: during the app's sign-in in the browser", () => {
it('FE-UTIL-VERSION-058: leaves version and page alone while the sign-in runs, and acts again after it', async () => {
const { container } = installWorker(null)
container.getRegistration.mockResolvedValue(undefined)
const { reconcileAppVersion } = await loadModule()
localStorage.setItem('trek_app_version', '4.3.0')
// public/native-handoff.js marks the sign-in; a reload now could land on an
// older worker that a tab still open on the old version keeps alive.
localStorage.setItem('trek_native_signin', String(Date.now()))
await reconcileAppVersion('4.3.1')
expect(marker()).toBe('4.3.0')
expect(reload).not.toHaveBeenCalled()
expect(container.getRegistration).not.toHaveBeenCalled()
// A mark older than any sign-in takes counts for nothing.
localStorage.setItem('trek_native_signin', String(Date.now() - 16 * 60 * 1000))
await reconcileAppVersion('4.3.1')
expect(marker()).toBe('4.3.1')
expect(reload).toHaveBeenCalledTimes(1)
})
})
describe('reconcileAppVersion: the reload guard', () => {
it('FE-UTIL-VERSION-030: a marker that cannot be written does not turn into a reload loop', async () => {
const { reconcileAppVersion } = await loadModule()
+4
View File
@@ -17,6 +17,7 @@
* version and session.
*/
import { browserSignInRunning } from '../native/browserSignInFlag'
import { reloadFresh } from './chunkReload'
import { currentController, servingWorker } from './serviceWorkerShell'
@@ -198,6 +199,9 @@ function waitForTakeover(reg: ServiceWorkerRegistration, version: string): Promi
export async function reconcileAppVersion(reported: unknown): Promise<void> {
const version = asReleaseTag(reported)
if (!version) return
// The app's sign-in runs in this browser: a reload now could land on an older
// version's worker that a tab still open keeps alive (native/browserSignInFlag).
if (browserSignInRunning()) return
let storedVersion: string | null
try {
@@ -1,4 +1,4 @@
// FE-CLIENT-INTERCEPTOR-001 to FE-CLIENT-INTERCEPTOR-012
// FE-CLIENT-INTERCEPTOR-001 to FE-CLIENT-INTERCEPTOR-015
import { describe, it, expect } from 'vitest'
import { isAuthPublicPath } from '../../../src/api/client'
@@ -19,6 +19,10 @@ describe('FE-CLIENT-INTERCEPTOR: 401 AUTH_REQUIRED redirect allowlist', () => {
it('FE-CLIENT-INTERCEPTOR-004: /reset-password', () => {
expect(isAuthPublicPath('/reset-password')).toBe(true)
})
it('FE-CLIENT-INTERCEPTOR-015: /native-handoff, which signs in by itself', () => {
expect(isAuthPublicPath('/native-handoff')).toBe(true)
})
})
describe('prefix-match public paths — no redirect', () => {
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { shareFile } from '../../../src/native/nativeFiles';
import { openNativeFile, shareFile } from '../../../src/native/nativeFiles';
import { downloadBlob, downloadFile, openFile } from '../../../src/utils/fileDownload';
vi.mock('../../../src/db/offlineDb', () => ({ getCachedBlob: vi.fn() }));
@@ -8,13 +8,14 @@ vi.mock('../../../src/native/nativeFiles', async () => {
const actual = await vi.importActual<typeof import('../../../src/native/nativeFiles')>(
'../../../src/native/nativeFiles'
);
return { ...actual, shareFile: vi.fn() };
return { ...actual, shareFile: vi.fn(), openNativeFile: vi.fn() };
});
const pdf = new Blob(['%PDF'], { type: 'application/pdf' });
beforeEach(() => {
vi.mocked(shareFile).mockReset().mockResolvedValue();
vi.mocked(openNativeFile).mockReset().mockResolvedValue();
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, status: 200, blob: () => Promise.resolve(pdf) }));
});
@@ -42,8 +43,9 @@ describe('fileDownload inside the native app', () => {
expect(shareFile).toHaveBeenLastCalledWith(pdf, 'ticket A.pdf');
});
it('opens files through the share sheet too, since there is no tab to open them in', async () => {
it('opens files in the system viewer, since there is no tab to open them in', async () => {
await openFile('/api/trips/1/files/2/download');
expect(shareFile).toHaveBeenLastCalledWith(pdf, 'download');
expect(openNativeFile).toHaveBeenLastCalledWith(pdf, 'download');
expect(shareFile).not.toHaveBeenCalled();
});
});
+826 -1486
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -1,7 +1,7 @@
{
"name": "@trek/root",
"private": true,
"version": "5.1.2",
"version": "5.1.3",
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
},
@@ -33,7 +33,7 @@
},
"devDependencies": {
"concurrently": "^10.0.6",
"knip": "^6.38.0",
"knip": "^6.44.1",
"unrun": "^0.3.1"
},
"comment:scarfSettings": "swagger-ui-dist ships @scarf/scarf install-time analytics; TREK sends no telemetry, so it stays off.",
@@ -42,8 +42,8 @@
},
"comment:overrides": "React and react-dom: one React 19 across the workspace, so the test renderer (@testing-library/react) and the app share one react-dom. Everything else is a security floor. npm reads overrides from the root package.json only, so a floor in a workspace package.json does nothing. A floor never crosses a major its dependents ask for (brace-expansion is pinned per line for that reason), and npm ls <name> shows whether it holds.",
"overrides": {
"react": "19.3.0",
"react-dom": "19.3.0",
"react": "19.3.1",
"react-dom": "19.3.1",
"@hono/node-server": "^1.19.13",
"body-parser@^1.20.0": "^1.20.9",
"body-parser@^2.0.0": "^2.4.0",
+238 -384
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -78,9 +78,9 @@
"@types/node": "^22.20.4",
"@types/semver": "^7.8.0",
"@types/update-notifier": "^6.0.8",
"@vitest/coverage-v8": "^4.1.11",
"@vitest/coverage-v8": "^5.1.0",
"typescript": "^5.9.3",
"vitest": "^4.1.11"
"vitest": "^5.1.0"
},
"keywords": [
"trek",
+3
View File
@@ -530,6 +530,9 @@ export const PLUGIN_ENTITY_FIELDS = {
'accommodation_name',
'accommodation_start_day_id',
'accommodation_end_day_id',
'accommodation_check_in',
'accommodation_check_in_end',
'accommodation_check_out',
'day_positions',
'endpoints',
'travelers',
+23 -23
View File
@@ -1,6 +1,6 @@
{
"name": "@trek/server",
"version": "5.1.2",
"version": "5.1.3",
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
},
@@ -57,22 +57,22 @@
]
},
"dependencies": {
"@aws-lite/client": "^0.23.7",
"@aws-lite/client": "^0.24.1",
"@aws-lite/s3": "^0.2.7",
"@mikro-orm/cli": "^7.2.1",
"@mikro-orm/core": "^7.2.1",
"@mikro-orm/migrations": "^7.2.1",
"@mikro-orm/cli": "^7.2.7",
"@mikro-orm/core": "^7.2.7",
"@mikro-orm/migrations": "^7.2.7",
"@mikro-orm/nestjs": "^7.1.0",
"@mikro-orm/seeder": "^7.2.1",
"@mikro-orm/seeder": "^7.2.7",
"@mikro-orm/sql": "^7.2.1",
"@mikro-orm/sqlite": "^7.2.1",
"@modelcontextprotocol/sdk": "^1.32.1",
"@mikro-orm/sqlite": "^7.2.7",
"@modelcontextprotocol/sdk": "^1.32.2",
"@nestjs/common": "^11.2.5",
"@nestjs/config": "^4.0.4",
"@nestjs/core": "^11.2.5",
"@nestjs/platform-express": "^11.2.5",
"@nestjs/platform-ws": "^11.2.5",
"@nestjs/schedule": "^6.1.3",
"@nestjs/schedule": "^12.0.3",
"@nestjs/swagger": "^11.4.7",
"@nestjs/websockets": "^11.2.5",
"@simplewebauthn/server": "^13.3.3",
@@ -87,7 +87,7 @@
"dotenv": "^16.6.1",
"exifr": "7.1.3",
"express": "^4.22.3",
"fast-xml-parser": "^5.11.1",
"fast-xml-parser": "^5.12.0",
"helmet": "^8.3.0",
"jimp": "^1.6.1",
"json5": "^2.2.3",
@@ -95,30 +95,30 @@
"kysely": "^0.29.6",
"multer": "^2.5.0",
"nestjs-zod": "^5.5.0",
"nodemailer": "^10.1.4",
"nodemailer": "^10.3.1",
"otplib": "^12.0.1",
"pdf-parse": "^2.4.5",
"qrcode": "^1.5.4",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
"semver": "^7.8.5",
"semver": "^7.8.6",
"tsconfig-paths": "^4.2.0",
"tsx": "^4.23.15",
"typescript": "^6.0.3",
"tz-lookup": "^6.1.25",
"undici": "^7.30.1",
"unzipper": "^0.12.5",
"uuid": "^14.0.2",
"ws": "^8.21.3",
"uuid": "^14.0.3",
"ws": "^8.22.1",
"zod": "^4.6.7"
},
"devDependencies": {
"@aws-lite/s3-types": "^0.2.8",
"@eslint/js": "^10.0.1",
"@mikro-orm/entity-generator": "^7.2.1",
"@mikro-orm/postgresql": "^7.2.1",
"@mikro-orm/entity-generator": "^7.2.7",
"@mikro-orm/postgresql": "^7.2.7",
"@nestjs/testing": "^11.2.5",
"@swc/core": "^1.16.2",
"@swc/core": "^1.16.22",
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
"@types/archiver": "^7.0.0",
"@types/bcryptjs": "^2.4.6",
@@ -128,22 +128,22 @@
"@types/cors": "^2.8.19",
"@types/express": "^4.17.25",
"@types/jsonwebtoken": "^9.0.10",
"@types/multer": "^2.2.0",
"@types/multer": "^2.3.0",
"@types/node": "^25.9.8",
"@types/nodemailer": "^8.0.2",
"@types/qrcode": "^1.5.6",
"@types/semver": "^7.8.0",
"@types/supertest": "^6.0.3",
"@types/supertest": "^7.2.1",
"@types/unzipper": "^0.10.11",
"@types/ws": "^8.18.1",
"@types/ws": "^8.18.2",
"@vitest/coverage-istanbul": "^4.1.11",
"eslint": "^9.39.5",
"eslint-config-flat-gitignore": "^2.4.0",
"eslint-config-prettier": "^10.1.8",
"prettier": "^3.9.8",
"prettier": "^3.9.10",
"prettier-plugin-organize-imports": "^4.3.0",
"supertest": "^7.2.2",
"typescript-eslint": "^8.70.0",
"supertest": "^7.3.1",
"typescript-eslint": "^8.72.1",
"unplugin-swc": "^1.6.0",
"vitest": "^4.1.11"
}
+2 -2
View File
@@ -2,7 +2,7 @@
"scripts/db-call-graph.mjs": 1036,
"scripts/generate-entities.ts": 2259,
"src/db/repositories/Places.repository.ts": 1437,
"src/db/repositories/Reservations.repository.ts": 1604,
"src/db/repositories/Reservations.repository.ts": 1559,
"src/db/repositories/Trips.repository.ts": 1121,
"src/db/repositories/Users.repository.ts": 1445,
"src/nest/atlas/atlas.service.ts": 1177,
@@ -15,6 +15,6 @@
"src/nest/places/places.service.ts": 1549,
"src/nest/plugins/plugin-runtime.service.ts": 1460,
"src/nest/plugins/runtime/plugin-sdk.ts": 1093,
"src/nest/reservations/reservations.service.ts": 1237,
"src/nest/reservations/reservations.service.ts": 1228,
"src/nest/vacay/vacay.service.ts": 1409
}
@@ -18,7 +18,10 @@ import type {
PublicApiUnplannedPlaceRow,
PublicApiUnscheduledReservationRow,
} from './reservation-public-api-rows';
import type { ReservationAllColumnsRow, ReservationJoinRow } from './reservation-rows';
// The reservation row shapes live in their own module; re-exported so callers keep one import path.
export type { ReservationAllColumnsRow, ReservationJoinRow } from './reservation-rows';
// The public API row types moved to their own module; re-exported so callers keep one import path.
export type {
PublicApiAccommodationRow,
@@ -27,26 +30,6 @@ export type {
PublicApiUnscheduledReservationRow,
};
/**
* RS18/RR1's shared joined projection — `SELECT r.*, d.day_number, p.name as
* place_name, r.assignment_id, ap.place_id as accommodation_place_id,
* acc_p.name as accommodation_name, ap.start_day_id as
* accommodation_start_day_id, ap.end_day_id as accommodation_end_day_id FROM
* reservations r LEFT JOIN days d ON r.day_id = d.id LEFT JOIN places p ON
* r.place_id = p.id LEFT JOIN day_accommodations ap ON r.accommodation_id =
* ap.id LEFT JOIN places acc_p ON ap.place_id = acc_p.id`. The service
* folds `accommodation_id` (`Math.trunc(Number(x))`), `day_positions`,
* `endpoints` and `travelers` on afterward (unchanged from the legacy split).
*/
export interface ReservationJoinRow extends ReservationAllColumnsRow {
day_number: number | null;
place_name: string | null;
accommodation_place_id: number | null;
accommodation_name: string | null;
accommodation_start_day_id: number | null;
accommodation_end_day_id: number | null;
}
/**
* Kysely typing for the RS18/RR1 join (`joinedQuery` below) — `reservations
* .accommodation_id` is a bare `p.text().nullable()` column with no FK to
@@ -394,6 +377,9 @@ export class ReservationsRepository extends TrekRepository<Reservations> {
'acc_p.name as accommodation_name',
'ap.start_day_id as accommodation_start_day_id',
'ap.end_day_id as accommodation_end_day_id',
'ap.check_in as accommodation_check_in',
'ap.check_in_end as accommodation_check_in_end',
'ap.check_out as accommodation_check_out',
]);
}
@@ -1526,34 +1512,3 @@ export interface RoadtripCarrierRow {
metadata: string | null;
day_plan_position: number | null;
}
/** `SELECT *` — every scalar column of `Reservations`, RS35's shape. */
export interface ReservationAllColumnsRow {
id: number;
trip_id: number;
day_id: number | null;
end_day_id: number | null;
place_id: number | null;
assignment_id: number | null;
title: string;
accommodation_id: string | null;
reservation_time: string | null;
reservation_end_time: string | null;
location: string | null;
confirmation_number: string | null;
notes: string | null;
status: string | null;
type: string | null;
created_at: string | null;
metadata: string | null;
day_plan_position: number | null;
needs_review: number;
external_source: string | null;
external_id: string | null;
external_owner_user_id: number | null;
external_synced_at: string | null;
sync_enabled: number | null;
external_hash: string | null;
url: string | null;
ingest_state: string;
}
@@ -0,0 +1,60 @@
/**
* The row shapes ReservationsRepository reads: every column of a reservation,
* and the joined projection the trip list and the single reads share.
*/
/** `SELECT *` — every scalar column of `Reservations`, RS35's shape. */
export interface ReservationAllColumnsRow {
id: number;
trip_id: number;
day_id: number | null;
end_day_id: number | null;
place_id: number | null;
assignment_id: number | null;
title: string;
accommodation_id: string | null;
reservation_time: string | null;
reservation_end_time: string | null;
location: string | null;
confirmation_number: string | null;
notes: string | null;
status: string | null;
type: string | null;
created_at: string | null;
metadata: string | null;
day_plan_position: number | null;
needs_review: number;
external_source: string | null;
external_id: string | null;
external_owner_user_id: number | null;
external_synced_at: string | null;
sync_enabled: number | null;
external_hash: string | null;
url: string | null;
ingest_state: string;
}
/**
* RS18/RR1's shared joined projection — `SELECT r.*, d.day_number, p.name as
* place_name, r.assignment_id, ap.place_id as accommodation_place_id,
* acc_p.name as accommodation_name, ap.start_day_id as
* accommodation_start_day_id, ap.end_day_id as accommodation_end_day_id,
* ap.check_in/check_in_end/check_out as accommodation_check_in/_check_in_end/_check_out FROM
* reservations r LEFT JOIN days d ON r.day_id = d.id LEFT JOIN places p ON
* r.place_id = p.id LEFT JOIN day_accommodations ap ON r.accommodation_id =
* ap.id LEFT JOIN places acc_p ON ap.place_id = acc_p.id`. The service
* folds `accommodation_id` (`Math.trunc(Number(x))`), `day_positions`,
* `endpoints` and `travelers` on afterward (unchanged from the legacy split).
*/
export interface ReservationJoinRow extends ReservationAllColumnsRow {
day_number: number | null;
place_name: string | null;
accommodation_place_id: number | null;
accommodation_name: string | null;
accommodation_start_day_id: number | null;
accommodation_end_day_id: number | null;
/** The stay's own times, which a hotel booking's card falls back to. */
accommodation_check_in: string | null;
accommodation_check_in_end: string | null;
accommodation_check_out: string | null;
}
+5 -1
View File
@@ -280,7 +280,11 @@ export function applyGlobalMiddleware(
scriptSrc: ["'self'", "'wasm-unsafe-eval'", "'unsafe-eval'"],
// No Google Fonts: every font ships with TREK (client/public/fonts), so no reader's IP goes to Google.
styleSrc: ["'self'", "'unsafe-inline'", 'https://unpkg.com'],
imgSrc: ["'self'", 'data:', 'blob:', 'https:'],
// trek-tile: is the iOS app's map tile store (client/src/native/tileCache.ts).
// The app's web view runs this page, and WebKit holds a scheme the app
// answers itself to img-src as well: without it every raster tile was
// refused and the app showed routes on a blank map.
imgSrc: ["'self'", 'data:', 'blob:', 'https:', 'trek-tile:'],
connectSrc: [
"'self'",
'ws:',
@@ -13,6 +13,8 @@ import { activityOf, foundPlacesOf, googleIdsOf, weatherDayOf, weatherOf } from
import type { TripCard } from './assistant-context';
import { FOLLOW_UPS_ANSWER, followUpsOf } from './assistant-follow-ups';
import { outputText } from './assistant-history';
import { OPEN_TRIP_CHAT_TOOL } from './assistant-home';
import { OPEN_JOURNEY_CHAT_TOOL } from './assistant-home-journeys';
import { toolOutputForProvider } from './assistant-output';
import { MAX_CARDS_PER_STEP, MAX_READS_PER_STEP, MAX_STEPS, MAX_WRITES_PER_STEP } from './assistant-tools.constants';
import type { AssistantToolKind, ToolRun } from './assistant-tools.service';
@@ -557,6 +559,12 @@ async function askForTheAnswer(turn: Turn, rowId: number, step: StepRecord & { a
return null;
}
/**
* The calls that only hand the conversation on, to a trip's or a journey's own
* chat: a card for the user, nothing the model reads further.
*/
const HANDOFF_TOOLS: ReadonlySet<string> = new Set([OPEN_TRIP_CHAT_TOOL, OPEN_JOURNEY_CHAT_TOOL]);
/** One step of the turn: the outcome when the turn ends with it, null when the model is asked again. */
async function runStep(turn: Turn, step: number): Promise<AgentOutcome | null> {
const { deps, input, w, signal, history } = turn;
@@ -638,6 +646,19 @@ async function runStep(turn: Turn, step: number): Promise<AgentOutcome | null> {
return outcomeOf(turn, 'aborted');
}
if (held.length > 0) return holdChanges(turn, rowId, { parts, providerState }, held);
// A step that wrote its answer and only handed the conversation on is the end of
// the turn. Asked once more after the card, the model wrote the same answer again.
if (text.trim() && received.every((call) => HANDOFF_TOOLS.has(call.name))) {
const followUps = asks.length > 0 ? followUpsOf(asks[asks.length - 1].args) : turn.followUps;
if (followUps) {
parts.push({ type: 'data-trek-follow-ups', data: followUps });
w.followUps(followUps);
}
await deps.store.saveStep(rowId, { parts, providerState }, 'complete');
w.finishStep();
finishTurn(turn, 'stop');
return outcomeOf(turn, 'stop');
}
const { images, modelTexts } = collected;
await carryOn(turn, rowId, { parts, providerState }, { text, calls, images, modelTexts });
return null;
@@ -145,6 +145,9 @@ export const PUBLIC_ROUTE_ALLOW_LIST: string[] = [
// The native app redeems a one-time code from the system browser here. The
// code is only good together with the verifier the app kept to itself.
'NativeAuthController.exchange',
// Where the app's sign-in in the system browser starts: a page that forwards
// to the handoff page and carries no credential.
'NativeAuthController.start',
'OauthPublicController.revoke',
'OauthPublicController.token',
'OauthPublicController.userinfo',
@@ -69,7 +69,10 @@ export class FeaturesController {
// The version of what the apps rely on from the server (the hand-off
// routes, the shell bridge in the served UI). Raised only for a change an
// older app cannot follow; the app refuses a server below its minimum.
nativeApi: 1,
// 2 (5.1.3): the browser sign-in starts under /api, past an older TREK's
// service worker, and the hand-off page signs in by itself. Apps from then
// on refuse a server below it, so its users stop landing in the browser.
nativeApi: 2,
// The other way round: the oldest app binary this server's UI works with,
// as the shell API the app reports (TrekShell.getCapabilities). An app
// below it is asked to update instead of running native calls it lacks.
@@ -10,7 +10,7 @@ import { ResponseContract } from '../common/response-contract';
import { sessionClientFrom } from '../sessions/sessions.service';
import { NativeExchangeDto, NativeHandoffDto } from './native-auth.dto';
import { NativeAuthService } from './native-auth.service';
import { Body, Controller, HttpCode, HttpException, Post, Req, Res, UseGuards } from '@nestjs/common';
import { Body, Controller, Get, HttpCode, HttpException, Post, Query, Req, Res, UseGuards } from '@nestjs/common';
import {
nativeHandoffResponseSchema,
successResponseSchema,
@@ -23,6 +23,36 @@ import type { Request, Response } from 'express';
const EXCHANGE_WINDOW_MS = 15 * 60 * 1000;
const EXCHANGE_MAX = 20;
const CHALLENGE = /^[0-9a-f]{64}$/;
const START = new Set(['oidc', 'passkey']);
const INVITE = /^[A-Za-z0-9_-]{1,200}$/;
/** `&`, `<`, `>` and `"` as entities, for a value inside a double-quoted attribute. */
function attr(value: string): string {
return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
/**
* The page /api/auth/native/start answers with: it takes a service worker of an
* older TREK off the browser, then opens the handoff page (public/native-handoff.js).
* A browser that ran TREK before 5.1.1 keeps that version's app in its service
* worker, and the worker answers /native-handoff from its cache with an app that
* has no such page: the sign-in ended on the dashboard in the browser instead of
* in the app. Every TREK service worker lets /api through to the server, so this
* page always comes from the running version.
*/
export function nativeStartPage(target: string): string {
const href = attr(target);
return [
'<!doctype html>',
'<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">',
'<meta name="robots" content="noindex"><title>TREK</title>',
`<noscript><meta http-equiv="refresh" content="0;url=${href}"></noscript>`,
`<script src="/native-handoff.js" data-target="${href}"></script>`,
'</head><body></body></html>',
].join('');
}
/**
* /api/auth/native: the sign-in hand-off between the system browser and the
* native app. See the contract in @trek/shared for the whole round trip.
@@ -47,6 +77,34 @@ export class NativeAuthController {
return { code: await this.nativeAuth.issueCode(user.id, body.challenge) };
}
/**
* Where the app's sign-in in the system browser starts. Answers a small page
* instead of the handoff page itself (see nativeStartPage); the query is the
* one the handoff page takes, checked here so nothing else rides along.
*/
// response-contract-exempt: an HTML page that forwards the browser, no body to check
@Get('start')
@Public('opens the handoff page in the browser; it carries no credential and shows nothing')
start(
@Query('challenge') challenge: unknown,
@Query('start') start: unknown,
@Query('invite') invite: unknown,
@Res() res: Response,
): void {
if (typeof challenge !== 'string' || !CHALLENGE.test(challenge)) {
res.status(400).type('text/plain').send('Invalid sign-in link.');
return;
}
const params = new URLSearchParams({ challenge });
if (typeof start === 'string' && START.has(start)) params.set('start', start);
if (typeof invite === 'string' && INVITE.test(invite)) params.set('invite', invite);
res
.status(200)
.set('Cache-Control', 'no-store')
.type('text/html')
.send(nativeStartPage(`/native-handoff?${params.toString()}`));
}
@Post('exchange')
@Public('the one-time code together with its verifier is the credential')
@HttpCode(200)
@@ -170,6 +170,9 @@ export const PLUGIN_ENTITY_CONTRACT = {
'accommodation_name',
'accommodation_start_day_id',
'accommodation_end_day_id',
'accommodation_check_in',
'accommodation_check_in_end',
'accommodation_check_out',
'day_positions',
'endpoints',
'travelers',
@@ -132,7 +132,7 @@ export interface UpdateReservationData {
confirmation_number?: string;
notes?: string;
url?: string;
day_id?: number;
day_id?: number | null;
end_day_id?: number | null;
place_id?: number;
assignment_id?: number;
@@ -944,32 +944,23 @@ export class ReservationsService {
? null
: (reservation_end_time !== undefined ? (reservation_end_time || null) : current.reservation_end_time);
// day_id / end_day_id: honour an explicit value from the client,
// otherwise derive from the (possibly updated) reservation_time so the
// planner renders the booking on the correct day.
let nextDayId: number | null;
if (day_id != null) {
// Explicit day from the client (e.g. moved on the planner).
nextDayId = day_id;
} else if (resolvedType !== 'hotel' && nextReservationTime) {
// No day set but we have a date — pin it to the matching day so the booking
// still shows in the Plan (covers bookings saved without a selected day, and
// the case where an earlier edit cleared day_id).
// day_id / end_day_id: an explicit value from the client wins (e.g. moved on the
// planner), and an explicit null clears. Otherwise a dated time pins the booking to
// its day, which also covers one saved without a day or an earlier edit that cleared
// it. A time-only value ("12:00") names no day, so an update that leaves the field
// out keeps the stored day rather than reading the miss as "clear it".
let nextDayId: number | null = day_id ?? null;
if (day_id == null && resolvedType !== 'hotel') {
nextDayId = await this.resolveDayIdFromTime(tripId, nextReservationTime);
} else if (day_id === undefined) {
// Field absent and nothing to derive from — keep whatever it had.
nextDayId = current.day_id ?? null;
} else {
nextDayId = null;
}
if (nextDayId == null && day_id === undefined) nextDayId = current.day_id ?? null;
let nextEndDayId: number | null;
if (end_day_id !== undefined) {
nextEndDayId = end_day_id ?? null;
} else if (reservation_end_time !== undefined && resolvedType !== 'hotel') {
nextEndDayId = await this.resolveDayIdFromTime(tripId, nextReservationEndTime);
} else {
nextEndDayId = current.end_day_id ?? null;
let nextEndDayId: number | null = end_day_id === undefined ? (current.end_day_id ?? null) : end_day_id;
if (end_day_id === undefined && reservation_end_time !== undefined && resolvedType !== 'hotel') {
// A cleared end time clears the end day; a new one moves it unless it is time-only.
nextEndDayId = nextReservationEndTime
? ((await this.resolveDayIdFromTime(tripId, nextReservationEndTime)) ?? nextEndDayId)
: null;
}
nextDayId = await this.resolvedOrNull('days', nextDayId);
@@ -1,5 +1,5 @@
/**
* How TREK 5.1.2 registers with the TREK services.
* How TREK 5.1.3 registers with the TREK services.
*
* The issuer writes this file for each release and the release pipeline
* commits it with the version bump. It is not edited by hand, and its layout
@@ -8,103 +8,86 @@
*/
import type { DialectSelfCheck, RegistrationDialect } from './registration-dialect.types';
import { createHash, createHmac, hkdfSync } from 'node:crypto';
import { createHash, pbkdf2Sync } from 'node:crypto';
const DIALECT = 'MFB4wDFbWlov';
const ZINNIA_LANTERN = Buffer.from('0eb4b15eba99bcd9f2624fead1e5e041a4cffd6485ca8740c26b92cd9ab42910', 'hex');
const DIALECT = 'qDlOapZrw47x';
const POND_HILL = Buffer.from('53ee01dfb2711f0b4b7cb1b5d7cd644801289dabda654bf6f53ba0f4727d5d26', 'hex');
function plateau6(input: Buffer, size: number): Buffer {
const out = Buffer.alloc(size);
input.forEach((byte, i) => out.writeUInt8(out.readUInt8(i % size) ^ byte, i % size));
return out;
}
function cedar(alg: string, data: Buffer): Buffer {
function saddle(alg: string, data: Buffer): Buffer {
return createHash(alg).update(data).digest();
}
function barrel(value: number): Buffer {
const out = Buffer.alloc(8);
out.writeBigUInt64LE(BigInt(value));
return out;
}
function vessel(part: Buffer): Buffer {
const head = Buffer.alloc(4);
head.writeUInt32BE(part.length);
return Buffer.concat([head, part]);
}
function crater(value: number): Buffer {
function frost(value: number): Buffer {
const out = Buffer.alloc(8);
out.writeBigUInt64BE(BigInt(value));
return out;
}
function jade(alg: string, key: Buffer, data: Buffer): Buffer {
return createHmac(alg, key).update(data).digest();
function hollow(input: Buffer, size: number): Buffer {
const out = Buffer.alloc(size);
input.forEach((byte, i) => out.writeUInt8(out.readUInt8(i % size) ^ byte, i % size));
return out;
}
function nest(input: Buffer, width: number): Buffer {
function zephyr(value: number): Buffer {
const out: number[] = [];
let rest = value;
do {
const low = rest % 128;
rest = Math.floor(rest / 128);
out.push(low | (Math.sign(rest) << 7));
} while (rest > 0);
return Buffer.from(out);
}
function barrel(input: Buffer, width: number): Buffer {
const out = Buffer.from(input);
for (let i = 0; i + width <= out.length; i += width) out.subarray(i, i + width).reverse();
return out;
}
function ticket(a: Buffer, b: Buffer): Buffer {
const parts: Buffer[] = [];
for (let i = 0; i < Math.max(a.length, b.length); i++) parts.push(a.subarray(i, i + 1), b.subarray(i, i + 1));
return Buffer.concat(parts);
}
function island(alg: string, size: number, data: Buffer): Buffer {
return createHash(alg, { outputLength: size }).update(data).digest();
}
export const registrationDialect: RegistrationDialect = {
id: DIALECT,
releasedFor: '5.1.2',
createdAt: '2026-11-09',
releasedFor: '5.1.3',
createdAt: '2026-11-10',
challengePath: `/v1/register/${DIALECT}/challenge`,
registerPath: `/v1/register/${DIALECT}`,
begin({ challenge, version, random, nowSeconds }) {
const fern = Buffer.from(challenge.c, 'utf8');
const jetty = Buffer.from(version, 'utf8');
const echo = createHash('sha256').update(fern).digest();
const petal = Buffer.concat([ZINNIA_LANTERN.subarray(14, 32), fern, jetty, random.subarray(9, 13)]);
const storm = Buffer.concat([petal.subarray(4 % petal.length), petal.subarray(0, 4 % petal.length)]);
const plateau = Buffer.from(storm).reverse();
const mill = Buffer.from(hkdfSync('sha384', plateau, echo, Buffer.from('3a79e850344e169208', 'hex'), 0x18));
begin({ challenge, version, random }) {
const garnet = Buffer.from(challenge.c, 'utf8');
const glade = Buffer.from(version, 'utf8');
const banner = createHash('sha256').update(garnet).digest();
const kettle = Buffer.from([0x5a]);
const river = Buffer.concat([random, kettle, POND_HILL, kettle, banner, kettle, glade]);
const robin = createHash('shake128', { outputLength: 31 }).update(river).digest();
const larch = barrel(robin, 8);
const ocean = createHash('sha3-512').update(larch).digest();
return {
work(nonce) {
const bamboo = Buffer.concat([
vessel(fern), vessel(jetty), vessel(barrel(nonce)), vessel(ZINNIA_LANTERN.subarray(7, 27)),
vessel(Buffer.from(String(nowSeconds), 'utf8')), vessel(random),
]);
const pasture = nest(bamboo, 4);
const umber = island('shake128', 35, pasture);
const reef = plateau6(umber, 32);
const moss = nest(reef, 4);
return { password: moss, salt: mill };
const wren = Buffer.concat([banner.subarray(0xa, 16), garnet, frost(nonce), glade, random]);
const crystal = pbkdf2Sync(wren, banner, 10, 0x32, 'sha256');
const seal = hollow(crystal, 20);
const mist = Buffer.from([...seal.subarray(0x1b % seal.length), ...seal.subarray(0, 0x1b % seal.length)]);
const coast = barrel(mist, 4);
const dawn = saddle('sha512', coast);
const yarrow = barrel(dawn, 8);
return { password: yarrow, salt: ocean };
},
request(nonce) {
const zinnia5 = Buffer.from([0xa8]);
const olive = Buffer.concat([
fern, zinnia5, Buffer.from(`${nowSeconds}`), zinnia5, crater(nonce), zinnia5, ZINNIA_LANTERN.subarray(9, 16),
zinnia5, random, zinnia5, jetty,
const pilgrim = Buffer.concat([
garnet, Buffer.from([7]), glade, Buffer.from([7]), banner, Buffer.from([7]), zephyr(nonce), Buffer.from([7]),
random,
]);
const bale = ticket(olive, echo);
const olive6 = jade('sha3-256', jetty, bale);
const brook = cedar('sha3-256', olive6);
const pepper = Buffer.from(Array.from(pilgrim, (byte) => (byte * 203 + 60) % 256));
const mesa = createHash('blake2b512').update(pepper).digest();
return {
body: {
'kelp-petal': version,
canoe: challenge.c,
pepper: random.toString('hex'),
'glacier-ferry': nowSeconds.toString(),
'linden-ridge': nonce.toString(),
brookCactus: mesa.toString('base64'),
tundra: random.toString('hex'),
ivoryCape: version,
mill: nonce,
jadeBadge: challenge.c,
},
headers: { 'X-TREK-Mist-Acorn': brook.toString('hex') },
headers: {},
};
},
};
@@ -113,13 +96,13 @@ export const registrationDialect: RegistrationDialect = {
/** Fixed inputs and what this module must make of them; TREK's tests and release job run it. */
export const SELF_CHECK: DialectSelfCheck = {
challenge: { c: 'v1.selfcheck.MFB4wDFbWlov', alg: 'scrypt', N: 16, r: 1, p: 1, bits: 0, expires_at: 1 },
version: '5.1.2',
challenge: { c: 'v1.selfcheck.qDlOapZrw47x', alg: 'scrypt', N: 16, r: 1, p: 1, bits: 0, expires_at: 1 },
version: '5.1.3',
random: '000102030405060708090a0b0c0d0e0f',
nowSeconds: 1800000000,
nonce: 41,
inputSha256: '2d1a46dce1ca69e981e1784ec1533e2b495eccfaeb399f20418c55c509460938',
saltSha256: 'f8f01b58c5140199b3b259f2cba1a6eb19d2783c0b519afc39310d2f11c9787e',
scrypt: '2a0a02e0a17365b24727aea936466a0561eb65bcd4fb21f33aea9b74a6326e68',
requestSha256: 'd7663256c3593f171e27f899081ae52ff7c95619cd8cd22bd0e8113b724d83cf',
inputSha256: 'd0459dd032ca32758d4960be87026844ca0e5f2ccc0cd087838c7cc9f1f054d6',
saltSha256: 'e6beeb65b892a5f7e68977ceb33e0193bcf284904db6b55884823b5b2a948787',
scrypt: 'cb5f7842c312c4442f9d750305c31d2ea2df36556dbf80e17b56df859d2bc1e9',
requestSha256: '53bc1ae4f560b17631848e100dd2469040bbadc8ae61c881263a489bd14842ed',
};
+1 -1
View File
@@ -59,7 +59,7 @@ export const SYSTEM_NOTICES: SystemNotice[] = [
titleKey: 'system_notice.release_notes.headline',
bodyKey: 'system_notice.release_notes.intro',
release: {
version: '5.1.2',
version: '5.1.3',
eyebrowKey: 'system_notice.release_notes.eyebrow',
headlineKey: 'system_notice.release_notes.headline',
introKey: 'system_notice.release_notes.intro',
+10
View File
@@ -132,4 +132,14 @@ describe('Native sign-in hand-off e2e', () => {
const blocked = await exchange({ code: 'nope', verifier });
expect(blocked.status).toBe(429);
});
it('NATIVE-E2E-006: the browser starts at a page under /api that forwards to the handoff page', async () => {
const res = await request(server).get(`/api/auth/native/start?challenge=${challenge}&start=oidc`);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toMatch(/text\/html/);
expect(res.headers['cache-control']).toBe('no-store');
expect(res.text).toContain(`data-target="/native-handoff?challenge=${challenge}&amp;start=oidc"`);
expect((await request(server).get('/api/auth/native/start?challenge=nope')).status).toBe(400);
});
});
+58 -1
View File
@@ -28,6 +28,7 @@ import { NotificationsService } from '../../src/nest/notifications/notifications
import { PermissionsService } from '../../src/nest/permissions/permissions.service';
import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
import { ReservationsModule } from '../../src/nest/reservations/reservations.module';
import { makeReservation } from '../helpers/factories/reservations';
import {
countRows,
deleteRows,
@@ -36,7 +37,7 @@ import {
insertRow,
insertRowIgnoringConflict,
} from '../helpers/factories/rows';
import { makeTrip, makeDay } from '../helpers/factories/trips';
import { makeTrip, makeDay, readTripDays } from '../helpers/factories/trips';
import { makeUser } from '../helpers/factories/users';
import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm';
import { TestUnitOfWorkModule } from '../helpers/test-uow';
@@ -502,4 +503,60 @@ describe('Reservations + accommodations e2e (real auth guard + temp SQLite, real
expect(res.status).toBe(200);
});
});
// Adding notes to two time-only Shinkansen bookings from the browser took them
// off their days: the stored "12:00" resolved to no day, and the day_id the PUT
// left out was written as null.
describe('a partial update keeps the day it does not name', () => {
let days: number[];
let datedTripId: number;
beforeAll(async () => {
datedTripId = (await makeTrip(orm, 1, { title: 'Japan', start_date: '2026-11-10', end_date: '2026-11-12' })).id;
days = (await readTripDays(orm, datedTripId)).map((d) => d.id);
});
async function seedTrain(reservationTime: string, dayId: number | null) {
const train = await makeReservation(orm, datedTripId, {
title: 'Shinkansen',
type: 'train',
reservation_time: reservationTime,
day: dayId,
});
return train.id;
}
function put(reservationId: number, body: Record<string, unknown>) {
return request(server)
.put(`/api/trips/${datedTripId}/reservations/${reservationId}`)
.set('Cookie', sessionCookie(1))
.send(body);
}
it('200 a notes-only update leaves a time-only booking on its day', async () => {
const rid = await seedTrain('12:00', days[1]);
const res = await put(rid, { notes: 'Car 7, seats 12A-B' });
expect(res.status).toBe(200);
expect(res.body.reservation).toMatchObject({
notes: 'Car 7, seats 12A-B',
reservation_time: '12:00',
day_id: days[1],
});
expect(await findRow(orm, Reservations, { id: rid })).toMatchObject({ day_id: days[1] });
});
it('200 a dated time still moves the booking to the day it names', async () => {
const rid = await seedTrain('12:00', days[0]);
const res = await put(rid, { reservation_time: '2026-11-12T09:00' });
expect(res.status).toBe(200);
expect(await findRow(orm, Reservations, { id: rid })).toMatchObject({ day_id: days[2] });
});
it('200 an explicit day_id null still takes the booking off its day', async () => {
const rid = await seedTrain('12:00', days[1]);
const res = await put(rid, { day_id: null, notes: 'Not planned yet' });
expect(res.status).toBe(200);
expect(await findRow(orm, Reservations, { id: rid })).toMatchObject({ day_id: null });
});
});
});
@@ -525,7 +525,8 @@ describe('ReservationsRepository — AC37/AC40 (listIdMetadataByStay/listIdsBySt
describe('ReservationsRepository — Task 2 reads (listForTrip / findWithJoins / findInTrip / listResyncCandidates / listForRestamp)', () => {
const legacyJoinedSql = `
SELECT r.*, d.day_number as day_number, p.name as place_name, ap.place_id as accommodation_place_id,
acc_p.name as accommodation_name, ap.start_day_id as accommodation_start_day_id, ap.end_day_id as accommodation_end_day_id
acc_p.name as accommodation_name, ap.start_day_id as accommodation_start_day_id, ap.end_day_id as accommodation_end_day_id,
ap.check_in as accommodation_check_in, ap.check_in_end as accommodation_check_in_end, ap.check_out as accommodation_check_out
FROM reservations r
LEFT JOIN days d ON d.id = r.day_id
LEFT JOIN places p ON p.id = r.place_id
@@ -22,7 +22,10 @@ import {
createCategory,
addTripMember,
} from '../../helpers/factories';
import { makeReservation } from '../../helpers/factories/reservations';
import { countRows, findRow, findRows, insertRow, updateRows } from '../../helpers/factories/rows';
import { makeTrip, readTripDays } from '../../helpers/factories/trips';
import { makeUser } from '../../helpers/factories/users';
import { FakeRealtimeService } from '../../helpers/fake-realtime';
import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness';
import { resetTestDb } from '../../helpers/test-db';
@@ -236,6 +239,29 @@ describe('Tool: create_reservation', () => {
// ---------------------------------------------------------------------------
describe('Tool: update_reservation', () => {
// The tool goes through the same update as REST, so an edit that names no day
// leaves a time-only booking on the one it has, as a notes-only PUT does.
it('keeps the day of a time-only booking on an edit that names no day', async () => {
const { user } = await makeUser(orm);
const trip = await makeTrip(orm, user.id, { start_date: '2030-05-01', end_date: '2030-05-03' });
const days = await readTripDays(orm, trip.id);
const booking = await makeReservation(orm, trip.id, {
type: 'restaurant',
reservation_time: '19:00',
day: days[1].id,
});
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_reservation',
arguments: { tripId: trip.id, reservationId: booking.id, notes: 'Counter seats' },
});
expect((parseToolResult(result) as { reservation: { day_id: number | null } }).reservation.day_id).toBe(
days[1].id,
);
});
expect((await reservationRow(booking.id)).day_id).toBe(days[1].id);
});
it('updates reservation fields', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
@@ -114,6 +114,14 @@ describe('global CSP: the imagery host (#2307)', () => {
});
});
describe('global CSP: the iOS app tile store', () => {
it('allows trek-tile: images, or the app draws routes on a blank map', async () => {
// The app rewrites raster tiles to trek-tile:// (client/src/native/tileCache.ts)
// and answers them from its store; WebKit checks that scheme against img-src.
expect(await directiveSources('img-src')).toContain('trek-tile:');
});
});
describe('global CSP: the elevation tiles of the 3D tour view', () => {
it('allows the Terrarium tiles, which MapLibre and the flyover preload fetch', async () => {
// The 3D view drapes the route over Mapzen's open elevation model on AWS Open
@@ -65,6 +65,7 @@ const KINDS: Record<string, AssistantToolKind> = {
get_weather: 'read',
get_place_details: 'read',
create_place: 'write',
open_trip_chat: 'read',
};
/** What the fake tools answer, per tool; anything else fails. */
@@ -75,6 +76,7 @@ const ANSWERS: Record<string, unknown> = {
},
get_weather: { weather: { temp: 20, main: 'Clouds', description: 'cloudy', type: 'climate' } },
get_place_details: { details: { name: 'Ginkaku-ji' } },
open_trip_chat: { offered: true },
};
function fakeTools() {
@@ -1022,4 +1024,54 @@ describe('runAgentLoop', () => {
expect(JSON.stringify(t.chunks)).not.toContain('HM4X2');
expect(JSON.stringify(t.memory.saved)).not.toContain('HM4X2');
});
it('ASTAGT-048: an answer that hands the talk on to a trip chat ends the turn; the model is not asked to answer twice', async () => {
const t = setup([
[
{ type: 'text-delta', delta: 'Your next trip is Florida.' },
{
type: 'tool-call',
call: { id: 'toolu_h', name: 'open_trip_chat', args: { tripId: 1, question: 'Book the Uber' } },
},
{ type: 'finish', reason: 'tool-calls' },
],
[{ type: 'text-delta', delta: 'Your next trip is Florida.' }],
]);
const outcome = await runAgentLoop(t.deps, t.input, t.writer, new AbortController().signal);
expect(outcome.end).toBe('stop');
expect(t.requests).toHaveLength(1);
expect(t.tools.call).toHaveBeenCalledTimes(1);
expect(t.memory.saved).toHaveLength(1);
expect(t.memory.saved[0].step.parts.map((part) => part.type)).toEqual(['text', 'tool-call']);
});
it('ASTAGT-049: a hand-on without any answer, or beside a read, still asks the model again', async () => {
const alone = setup([
[
{
type: 'tool-call',
call: { id: 'toolu_h', name: 'open_trip_chat', args: { tripId: 1, question: 'Book it' } },
},
{ type: 'finish', reason: 'tool-calls' },
],
[{ type: 'text-delta', delta: 'Go on in the trip chat.' }],
]);
await runAgentLoop(alone.deps, alone.input, alone.writer, new AbortController().signal);
expect(alone.requests).toHaveLength(2);
const withRead = setup([
[
{ type: 'text-delta', delta: 'Let me look.' },
{ type: 'tool-call', call: { id: 'toolu_r', name: 'list_places', args: { tripId: 1 } } },
{
type: 'tool-call',
call: { id: 'toolu_h', name: 'open_trip_chat', args: { tripId: 1, question: 'Book it' } },
},
{ type: 'finish', reason: 'tool-calls' },
],
[{ type: 'text-delta', delta: 'Kinkaku-ji is on the list.' }],
]);
await runAgentLoop(withRead.deps, withRead.input, withRead.writer, new AbortController().signal);
expect(withRead.requests).toHaveLength(2);
});
});
@@ -37,7 +37,7 @@ describe('FeaturesController (GET /api/health/features)', () => {
aiParsing: true,
aiAssistant: true,
nativeApp: true,
nativeApi: 1,
nativeApi: 2,
minNativeShellApi: 1,
});
});
@@ -49,7 +49,7 @@ describe('FeaturesController (GET /api/health/features)', () => {
aiParsing: false,
aiAssistant: false,
nativeApp: true,
nativeApi: 1,
nativeApi: 2,
minNativeShellApi: 1,
});
});
@@ -60,7 +60,7 @@ describe('FeaturesController (GET /api/health/features)', () => {
aiParsing: false,
aiAssistant: false,
nativeApp: true,
nativeApi: 1,
nativeApi: 2,
minNativeShellApi: 1,
});
expect(await make(false, true).controller.features()).toEqual({
@@ -68,7 +68,7 @@ describe('FeaturesController (GET /api/health/features)', () => {
aiParsing: true,
aiAssistant: true,
nativeApp: true,
nativeApi: 1,
nativeApi: 2,
minNativeShellApi: 1,
});
});
@@ -94,7 +94,7 @@ describe('FeaturesController (GET /api/health/features)', () => {
it('FEAT-014: tells the native apps it speaks their sign-in hand-off, and at which version', async () => {
const features = await make(false, false).controller.features();
expect(features.nativeApp).toBe(true);
expect(features.nativeApi).toBe(1);
expect(features.nativeApi).toBe(2);
// And which app binaries its UI still works with.
expect(features.minNativeShellApi).toBe(1);
});
@@ -8,7 +8,7 @@ import type { AuditService } from '../../../src/nest/audit/audit.service';
import { DomainError } from '../../../src/nest/common/domain-error';
import type { RateLimitService } from '../../../src/nest/common/rate-limit.service';
import { RESPONSE_CONTRACT_KEY } from '../../../src/nest/common/response-contract';
import { NativeAuthController } from '../../../src/nest/native-auth/native-auth.controller';
import { NativeAuthController, nativeStartPage } from '../../../src/nest/native-auth/native-auth.controller';
import type { NativeAuthService } from '../../../src/nest/native-auth/native-auth.service';
import type { User } from '../../../src/types';
import { nativeHandoffResponseSchema, successResponseSchema } from '@trek/shared';
@@ -100,3 +100,50 @@ describe('NativeAuthController.exchange', () => {
expect(Reflect.getMetadata(RESPONSE_CONTRACT_KEY, proto.exchange)).toBe(successResponseSchema);
});
});
describe('NativeAuthController.start', () => {
function htmlRes() {
const res = {
status: vi.fn(),
set: vi.fn(),
type: vi.fn(),
send: vi.fn(),
};
for (const fn of [res.status, res.set, res.type]) fn.mockReturnValue(res);
return res;
}
it('NATIVE-CTL-007: answers the page that opens the handoff page with the same challenge, start and invite', () => {
const { controller } = build();
const res = htmlRes();
controller.start(challenge, 'oidc', 'inv_A-1', res as unknown as Response);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.set).toHaveBeenCalledWith('Cache-Control', 'no-store');
expect(res.type).toHaveBeenCalledWith('text/html');
expect(res.send).toHaveBeenCalledWith(
nativeStartPage(`/native-handoff?challenge=${challenge}&start=oidc&invite=inv_A-1`),
);
});
it('NATIVE-CTL-008: refuses a malformed challenge and drops a start or invite it does not know', () => {
const { controller } = build();
const bad = htmlRes();
controller.start('not-a-hash', 'oidc', undefined, bad as unknown as Response);
expect(bad.status).toHaveBeenCalledWith(400);
const res = htmlRes();
controller.start(challenge, 'evil', '"><script>', res as unknown as Response);
expect(res.send).toHaveBeenCalledWith(nativeStartPage(`/native-handoff?challenge=${challenge}`));
});
it('NATIVE-CTL-009: the page loads the forwarding script from the server and escapes the target', () => {
const page = nativeStartPage('/native-handoff?challenge=a&start=oidc');
expect(page).toContain(
'<script src="/native-handoff.js" data-target="/native-handoff?challenge=a&amp;start=oidc"></script>',
);
expect(page).toContain('<meta http-equiv="refresh" content="0;url=/native-handoff?challenge=a&amp;start=oidc">');
expect(nativeStartPage('/native-handoff?x="<>')).not.toMatch(/"<>/);
});
});
@@ -33,7 +33,7 @@ import { HttpException } from '@nestjs/common';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, createReservation, createBudgetItem, createPlace, createDay, createDayAccommodation, createDayAssignment, addTripMember } from '../../helpers/factories';
import type { PermissionsService } from '../../../src/nest/permissions/permissions.service';
import { ReservationsService } from '../../../src/nest/reservations/reservations.service';
import { ReservationsService, type UpdateReservationData } from '../../../src/nest/reservations/reservations.service';
import { ReservationsReadService } from '../../../src/nest/reservations/reservations-read.service';
import type { BudgetService } from '../../../src/nest/budget/budget.service';
// Was reservations.bridge, deleted along with the other three that had no
@@ -63,6 +63,9 @@ import { createTestBudgetItemsRepo } from '../../helpers/files-repos';
import { sharedTestOrm } from '../../helpers/test-uow';
import type { TestOrm } from '../../helpers/test-orm';
import { countRows, deleteRows, findRow, findRows, insertRow, updateRows } from '../../helpers/factories/rows';
import { makeReservation } from '../../helpers/factories/reservations';
import { makeTrip, readTripDays } from '../../helpers/factories/trips';
import { makeUser } from '../../helpers/factories/users';
import { BudgetItems } from '../../../src/db/entities/BudgetItems.entity';
import { DayAccommodations } from '../../../src/db/entities/DayAccommodations.entity';
import { DayAssignments } from '../../../src/db/entities/DayAssignments.entity';
@@ -1709,3 +1712,54 @@ describe('the day stop a hotel booking implies', () => {
expect(await findRow(orm, DayAssignments, { id: theirStop })).toBeTruthy();
});
});
// A partial update (a notes-only PUT, an MCP edit) leaves a booking on its day unless it
// names one or carries a dated time. A bare "12:00" names no day, and reading that as
// "clear it" used to drop time-only trains off the planner on an edit of their notes.
describe('ReservationsService — the day a partial update leaves a booking on', () => {
async function train(fields: { reservation_time: string; reservation_end_time?: string }, start: number | null, end: number | null = null) {
const { user } = await makeUser(orm);
const trip = await makeTrip(orm, user.id, { start_date: '2030-05-01', end_date: '2030-05-03' });
const days = (await readTripDays(orm, trip.id)).map(d => d.id);
const pick = (i: number | null) => (i === null ? null : days[i]);
const booking = await makeReservation(orm, trip.id, { title: 'Shinkansen', type: 'train', ...fields, day: pick(start), endDay: pick(end) });
const update = async (data: UpdateReservationData) => {
const current = await svc.getReservation(String(booking.id), String(trip.id));
if (!current) throw new Error('the booking under test is gone');
return (await svc.update(String(booking.id), String(trip.id), data, current)).reservation;
};
return { days, update };
}
it('RESV-SVC-044: a notes-only update keeps the day of a time-only booking', async () => {
const { days, update } = await train({ reservation_time: '12:00' }, 1);
expect(await update({ notes: 'Car 7, seats 12A-B' }))
.toMatchObject({ notes: 'Car 7, seats 12A-B', reservation_time: '12:00', day_id: days[1] });
});
it('RESV-SVC-045: a new time-only reservation_time keeps the day as well', async () => {
const { days, update } = await train({ reservation_time: '12:00' }, 1);
expect(await update({ reservation_time: '14:30' })).toMatchObject({ reservation_time: '14:30', day_id: days[1] });
});
it('RESV-SVC-046: a dated time still pins the day, sent with the update or stored', async () => {
const sent = await train({ reservation_time: '12:00' }, 0);
expect(await sent.update({ reservation_time: '2030-05-03T09:00' })).toMatchObject({ day_id: sent.days[2] });
// Saved without a day: any edit puts it on the day its date names.
const stored = await train({ reservation_time: '2030-05-02T10:00' }, null);
expect(await stored.update({ notes: 'Window seat' })).toMatchObject({ day_id: stored.days[1] });
});
it('RESV-SVC-047: an explicit day_id null still clears the day', async () => {
const { update } = await train({ reservation_time: '12:00' }, 1);
expect(await update({ day_id: null, notes: 'Not planned yet' })).toMatchObject({ day_id: null });
});
it('RESV-SVC-048: a time-only end time keeps the end day; a dated one moves it and a cleared one clears it', async () => {
const { days, update } = await train({ reservation_time: '12:00', reservation_end_time: '18:00' }, 0, 1);
expect(await update({ reservation_end_time: '19:30' }))
.toMatchObject({ reservation_end_time: '19:30', day_id: days[0], end_day_id: days[1] });
expect(await update({ reservation_end_time: '2030-05-03T08:00' })).toMatchObject({ end_day_id: days[2] });
expect(await update({ reservation_end_time: '' })).toMatchObject({ reservation_end_time: null, end_day_id: null });
});
});
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@trek/shared",
"version": "5.1.2",
"version": "5.1.3",
"engines": {
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
},
@@ -111,11 +111,11 @@
"eslint": "^10.11.0",
"eslint-config-flat-gitignore": "^2.4.0",
"eslint-config-prettier": "^10.1.8",
"prettier": "3.9.8",
"prettier": "3.9.10",
"prettier-plugin-organize-imports": "^4.3.0",
"tsdown": "^0.23.0",
"tsdown": "^0.23.7",
"typescript": "^6.0.3",
"typescript-eslint": "^8.70.0",
"typescript-eslint": "^8.72.1",
"vitest": "^4.1.11"
}
}
+1
View File
@@ -36,6 +36,7 @@ const login: TranslationStrings = {
'login.mfaTitle': 'المصادقة الثنائية',
'login.mfaSubtitle': 'أدخل الرمز المكون من 6 أرقام من تطبيق المصادقة.',
'login.mfaCodeLabel': 'رمز التحقق',
'login.mfaPlaceholder': '000000 أو XXXX-XXXX',
'login.mfaCodeRequired': 'أدخل الرمز من تطبيق المصادقة.',
'login.mfaHint': 'افتح Google Authenticator أو Authy أو أي تطبيق TOTP آخر.',
'login.mfaBack': '← العودة لتسجيل الدخول',
+6 -1
View File
@@ -11,11 +11,16 @@ const native: TranslationStrings = {
'native.connect.errorInvalid': 'لا يبدو هذا عنوان ويب.',
'native.connect.errorUnreachable': 'لا يستجيب أي خادم على هذا العنوان. تحقق من العنوان ومن اتصالك.',
'native.connect.errorNotTrek': 'الخادم على هذا العنوان ليس TREK.',
'native.connect.errorTooOld': 'خادم TREK هذا قديم جدًا بالنسبة للتطبيق. يحتاج إلى تحديث أولًا.',
'native.connect.errorTooOld':
'يعمل على هذا الخادم إصدار أقدم من TREK. يحتاج التطبيق إلى TREK 5.1.3 أو أحدث، لذا يجب تحديث الخادم أولًا.',
'native.connect.recheckHint':
'يحتاج هذا الإصدار من التطبيق إلى TREK 5.1.3 أو أحدث على خادمك، لذا يتحقق من خادمك مرة أخرى.',
'native.offline.title': 'تعذّر الوصول إلى الخادم',
'native.offline.hint': 'تعذّر على TREK الوصول إلى {server}. تحقق من اتصالك ثم حاول مرة أخرى.',
'native.offline.retry': 'إعادة المحاولة',
'native.changeServer': 'تغيير الخادم',
'native.signInBar.hint':
'لا تعمل مفاتيح المرور ومفاتيح الأمان على هذه الصفحة داخل التطبيق. سجّل الدخول بكلمة مرور، أو اسمح للتطبيق بتجاوز الوكيل.',
'native.login.failed': 'تعذّر تسجيل الدخول. يرجى المحاولة مرة أخرى.',
'native.handoff.title': 'المتابعة في التطبيق',
'native.handoff.hint': 'لقد سجّلت الدخول باسم {name}. افتح تطبيق TREK لإكمال تسجيل الدخول هناك.',
+1
View File
@@ -39,6 +39,7 @@ const login: TranslationStrings = {
'login.mfaTitle': 'İki faktorlu autentifikasiya',
'login.mfaSubtitle': 'Autentifikator tətbiqinizdəki 6 rəqəmli kodu daxil edin.',
'login.mfaCodeLabel': 'Doğrulama kodu',
'login.mfaPlaceholder': '000000 və ya XXXX-XXXX',
'login.mfaCodeRequired': 'Autentifikator tətbiqinizdəki kodu daxil edin.',
'login.mfaHint': 'Google Authenticator, Authy və ya başqa TOTP tətbiqini açın.',
'login.mfaBack': '← Girişə qayıt',
+6 -1
View File
@@ -11,11 +11,16 @@ const native: TranslationStrings = {
'native.connect.errorInvalid': 'Bu, veb ünvana bənzəmir.',
'native.connect.errorUnreachable': 'Bu ünvanda heç bir server cavab vermədi. Ünvanı və bağlantınızı yoxlayın.',
'native.connect.errorNotTrek': 'Bu ünvandakı server TREK deyil.',
'native.connect.errorTooOld': 'Bu TREK serveri tətbiq üçün çox köhnədir. Əvvəlcə onu yeniləmək lazımdır.',
'native.connect.errorTooOld':
'Bu serverdə köhnə TREK işləyir. Tətbiqə TREK 5.1.3 və ya daha yenisi lazımdır, ona görə əvvəlcə serveri yeniləmək lazımdır.',
'native.connect.recheckHint':
'Tətbiqin bu versiyası serverinizdə TREK 5.1.3 və ya daha yenisini tələb edir, ona görə serverinizi bir daha yoxlayır.',
'native.offline.title': 'Serverə çatmaq mümkün deyil',
'native.offline.hint': 'TREK {server} ünvanına çata bilmədi. Bağlantınızı yoxlayın və yenidən cəhd edin.',
'native.offline.retry': 'Yenidən cəhd et',
'native.changeServer': 'Serveri dəyiş',
'native.signInBar.hint':
'Passkey və təhlükəsizlik açarları tətbiqdə bu səhifədə işləmir. Parolla daxil olun və ya tətbiqi proksinizdən keçirin.',
'native.login.failed': 'Daxil olmaq alınmadı. Zəhmət olmasa, yenidən cəhd edin.',
'native.handoff.title': 'Tətbiqdə davam edin',
'native.handoff.hint': 'Siz {name} kimi daxil olmusunuz. Girişi orada tamamlamaq üçün TREK tətbiqini açın.',
+1
View File
@@ -38,6 +38,7 @@ const login: TranslationStrings = {
'login.mfaTitle': 'Autenticação em duas etapas',
'login.mfaSubtitle': 'Digite o código de 6 dígitos do seu app autenticador.',
'login.mfaCodeLabel': 'Código de verificação',
'login.mfaPlaceholder': '000000 ou XXXX-XXXX',
'login.mfaCodeRequired': 'Digite o código do app autenticador.',
'login.mfaHint': 'Abra o Google Authenticator, Authy ou outro app TOTP.',
'login.mfaBack': '← Voltar ao login',
+6 -1
View File
@@ -11,11 +11,16 @@ const native: TranslationStrings = {
'native.connect.errorInvalid': 'Isso não parece um endereço da web.',
'native.connect.errorUnreachable': 'Nenhum servidor respondeu neste endereço. Verifique o endereço e sua conexão.',
'native.connect.errorNotTrek': 'O servidor neste endereço não é o TREK.',
'native.connect.errorTooOld': 'Este servidor TREK é antigo demais para o app. Ele precisa ser atualizado primeiro.',
'native.connect.errorTooOld':
'Este servidor roda uma versão antiga do TREK. O app precisa do TREK 5.1.3 ou mais recente, então o servidor precisa ser atualizado primeiro.',
'native.connect.recheckHint':
'Esta versão do app precisa do TREK 5.1.3 ou mais recente no seu servidor, por isso verifica o servidor mais uma vez.',
'native.offline.title': 'Servidor inacessível',
'native.offline.hint': 'O TREK não conseguiu acessar {server}. Verifique sua conexão e tente novamente.',
'native.offline.retry': 'Tentar novamente',
'native.changeServer': 'Trocar de servidor',
'native.signInBar.hint':
'Passkeys e chaves de segurança não funcionam nesta página no app. Entre com uma senha ou libere o app no seu proxy.',
'native.login.failed': 'Não foi possível entrar. Tente novamente.',
'native.handoff.title': 'Continuar no app',
'native.handoff.hint': 'Você entrou como {name}. Abra o app TREK para concluir o login por lá.',
+1
View File
@@ -26,6 +26,7 @@ const login: TranslationStrings = {
'login.mfaTitle': 'Autenticació de dos factors',
'login.mfaSubtitle': "Introdueix el codi de 6 dígits de la teva app d'autenticació.",
'login.mfaCodeLabel': 'Codi de verificació',
'login.mfaPlaceholder': '000000 o XXXX-XXXX',
'login.mfaCodeRequired': "Introdueix el codi de la teva app d'autenticació.",
'login.mfaHint': 'Obre Google Authenticator, Authy o una altra app TOTP.',
'login.mfaBack': "← Torna a l'inici de sessió",
+6 -1
View File
@@ -11,11 +11,16 @@ const native: TranslationStrings = {
'native.connect.errorInvalid': 'Això no sembla una adreça web.',
'native.connect.errorUnreachable': 'Cap servidor respon en aquesta adreça. Comprova l’adreça i la connexió.',
'native.connect.errorNotTrek': 'El servidor d’aquesta adreça no és TREK.',
'native.connect.errorTooOld': 'Aquest servidor de TREK és massa antic per a l’app. Primer cal actualitzar-lo.',
'native.connect.errorTooOld':
'Aquest servidor executa una versió antiga de TREK. L’app necessita TREK 5.1.3 o posterior, així que primer cal actualitzar el servidor.',
'native.connect.recheckHint':
'Aquesta versió de l’app necessita TREK 5.1.3 o posterior al teu servidor, per això el torna a comprovar.',
'native.offline.title': 'No es pot accedir al servidor',
'native.offline.hint': 'TREK no ha pogut connectar amb {server}. Comprova la connexió i torna-ho a provar.',
'native.offline.retry': 'Torna-ho a provar',
'native.changeServer': 'Canvia de servidor',
'native.signInBar.hint':
'Les claus d’accés i les claus de seguretat no funcionen en aquesta pàgina dins l’app. Inicia la sessió amb una contrasenya o deixa passar l’app pel teu proxy.',
'native.login.failed': 'No s’ha pogut iniciar la sessió. Torna-ho a provar.',
'native.handoff.title': 'Continua a l’app',
'native.handoff.hint': 'Has iniciat la sessió com a {name}. Obre l’app de TREK per acabar d’iniciar-hi la sessió.',
+1
View File
@@ -38,6 +38,7 @@ const login: TranslationStrings = {
'login.mfaTitle': 'Dvoufaktorové ověření',
'login.mfaSubtitle': 'Zadejte 6místný kód z vaší autentizační aplikace.',
'login.mfaCodeLabel': 'Ověřovací kód',
'login.mfaPlaceholder': '000000 nebo XXXX-XXXX',
'login.mfaCodeRequired': 'Zadejte kód z aplikace.',
'login.mfaHint': 'Otevřete Google Authenticator, Authy nebo jinou TOTP aplikaci.',
'login.mfaBack': '← Zpět k přihlášení',
+6 -1
View File
@@ -11,11 +11,16 @@ const native: TranslationStrings = {
'native.connect.errorInvalid': 'To nevypadá jako webová adresa.',
'native.connect.errorUnreachable': 'Na této adrese neodpovídá žádný server. Zkontrolujte adresu a připojení.',
'native.connect.errorNotTrek': 'Server na této adrese není TREK.',
'native.connect.errorTooOld': 'Tento server TREK je pro aplikaci příliš starý. Nejprve potřebuje aktualizaci.',
'native.connect.errorTooOld':
'Na tomto serveru běží starší TREK. Aplikace potřebuje TREK 5.1.3 nebo novější, server proto nejprve potřebuje aktualizaci.',
'native.connect.recheckHint':
'Tato verze aplikace potřebuje na vašem serveru TREK 5.1.3 nebo novější, proto server ještě jednou zkontroluje.',
'native.offline.title': 'Server není dostupný',
'native.offline.hint': 'TREK se nepodařilo spojit s {server}. Zkontrolujte připojení a zkuste to znovu.',
'native.offline.retry': 'Zkusit znovu',
'native.changeServer': 'Změnit server',
'native.signInBar.hint':
'Přístupové klíče a bezpečnostní klíče na této stránce v aplikaci nefungují. Přihlaste se heslem, nebo aplikaci propusťte přes svůj proxy.',
'native.login.failed': 'Přihlášení se nezdařilo. Zkuste to prosím znovu.',
'native.handoff.title': 'Pokračovat v aplikaci',
'native.handoff.hint': 'Jste přihlášeni jako {name}. Otevřete aplikaci TREK a dokončete přihlášení v ní.',
+1
View File
@@ -38,6 +38,7 @@ const login: TranslationStrings = {
'login.mfaTitle': 'Zwei-Faktor-Authentifizierung',
'login.mfaSubtitle': 'Gib den 6-stelligen Code aus deiner Authenticator-App ein.',
'login.mfaCodeLabel': 'Bestätigungscode',
'login.mfaPlaceholder': '000000 oder XXXX-XXXX',
'login.mfaCodeRequired': 'Bitte den Code aus der Authenticator-App eingeben.',
'login.mfaHint': 'Google Authenticator, Authy oder eine andere TOTP-App öffnen.',
'login.mfaBack': '← Zurück zur Anmeldung',
+6 -1
View File
@@ -12,11 +12,16 @@ const native: TranslationStrings = {
'native.connect.errorUnreachable':
'Unter dieser Adresse antwortet kein Server. Prüfe die Adresse und deine Verbindung.',
'native.connect.errorNotTrek': 'Unter dieser Adresse läuft kein TREK.',
'native.connect.errorTooOld': 'Dieser TREK-Server ist zu alt für die App. Er braucht zuerst ein Update.',
'native.connect.errorTooOld':
'Auf diesem Server läuft ein älteres TREK. Die App braucht TREK 5.1.3 oder neuer, der Server braucht also zuerst ein Update.',
'native.connect.recheckHint':
'Diese Version der App braucht TREK 5.1.3 oder neuer auf deinem Server und prüft ihn deshalb einmal neu.',
'native.offline.title': 'Server nicht erreichbar',
'native.offline.hint': 'TREK konnte {server} nicht erreichen. Prüfe deine Verbindung und versuche es erneut.',
'native.offline.retry': 'Erneut versuchen',
'native.changeServer': 'Server wechseln',
'native.signInBar.hint':
'Passkeys und Sicherheitsschlüssel funktionieren auf dieser Seite in der App nicht. Melde dich mit Passwort an oder lass die App an deinem Proxy vorbei.',
'native.login.failed': 'Die Anmeldung hat nicht geklappt. Bitte versuche es erneut.',
'native.handoff.title': 'Weiter in der App',
'native.handoff.hint': 'Du bist als {name} angemeldet. Öffne die TREK-App, um die Anmeldung dort abzuschließen.',
+1
View File
@@ -38,6 +38,7 @@ const login: TranslationStrings = {
'login.mfaTitle': 'Two-factor authentication',
'login.mfaSubtitle': 'Enter the 6-digit code from your authenticator app.',
'login.mfaCodeLabel': 'Verification code',
'login.mfaPlaceholder': '000000 or XXXX-XXXX',
'login.mfaCodeRequired': 'Enter the code from your authenticator app.',
'login.mfaHint': 'Open Google Authenticator, Authy, or another TOTP app.',
'login.mfaBack': '← Back to sign in',
+6 -1
View File
@@ -11,11 +11,16 @@ const native: TranslationStrings = {
'native.connect.errorInvalid': 'That does not look like a web address.',
'native.connect.errorUnreachable': 'No server answered at this address. Check the address and your connection.',
'native.connect.errorNotTrek': 'The server at this address is not TREK.',
'native.connect.errorTooOld': 'This TREK server is too old for the app. It needs an update first.',
'native.connect.errorTooOld':
'This server runs an older TREK. The app needs TREK 5.1.3 or newer, so the server needs an update first.',
'native.connect.recheckHint':
'This version of the app needs TREK 5.1.3 or newer on your server, so it checks your server once more.',
'native.offline.title': 'Server not reachable',
'native.offline.hint': 'TREK could not reach {server}. Check your connection and try again.',
'native.offline.retry': 'Try again',
'native.changeServer': 'Change server',
'native.signInBar.hint':
'Passkeys and security keys do not work on this page in the app. Sign in with a password, or let the app through your proxy.',
'native.login.failed': 'Signing in did not work. Please try again.',
'native.handoff.title': 'Continue in the app',
'native.handoff.hint': 'You are signed in as {name}. Open the TREK app to finish signing in there.',
+1
View File
@@ -26,6 +26,7 @@ const login: TranslationStrings = {
'login.mfaTitle': 'Autenticación de dos factores',
'login.mfaSubtitle': 'Introduce el código de 6 dígitos de tu app de autenticación.',
'login.mfaCodeLabel': 'Código de verificación',
'login.mfaPlaceholder': '000000 o XXXX-XXXX',
'login.mfaCodeRequired': 'Introduce el código de tu app de autenticación.',
'login.mfaHint': 'Abre Google Authenticator, Authy u otra app TOTP.',
'login.mfaBack': '← Volver al inicio de sesión',
+5 -1
View File
@@ -12,11 +12,15 @@ const native: TranslationStrings = {
'native.connect.errorUnreachable': 'Ningún servidor responde en esta dirección. Revisa la dirección y tu conexión.',
'native.connect.errorNotTrek': 'El servidor de esta dirección no es TREK.',
'native.connect.errorTooOld':
'Este servidor de TREK es demasiado antiguo para la app. Primero necesita una actualización.',
'Este servidor ejecuta una versión antigua de TREK. La app necesita TREK 5.1.3 o posterior, así que primero hay que actualizar el servidor.',
'native.connect.recheckHint':
'Esta versión de la app necesita TREK 5.1.3 o posterior en tu servidor, así que lo comprueba una vez más.',
'native.offline.title': 'Servidor no disponible',
'native.offline.hint': 'TREK no pudo conectar con {server}. Revisa tu conexión e inténtalo de nuevo.',
'native.offline.retry': 'Reintentar',
'native.changeServer': 'Cambiar de servidor',
'native.signInBar.hint':
'Las llaves de acceso y las llaves de seguridad no funcionan en esta página dentro de la app. Inicia sesión con una contraseña o deja pasar la app por tu proxy.',
'native.login.failed': 'No se pudo iniciar sesión. Inténtalo de nuevo.',
'native.handoff.title': 'Continuar en la app',
'native.handoff.hint': 'Has iniciado sesión como {name}. Abre la app de TREK para terminar de iniciar sesión allí.',
+1
View File
@@ -38,6 +38,7 @@ const login: TranslationStrings = {
'login.mfaTitle': 'Kaheastmeline autentimine',
'login.mfaSubtitle': 'Sisesta oma autentimisrakenduse 6-kohaline kood.',
'login.mfaCodeLabel': 'Kinnituskood',
'login.mfaPlaceholder': '000000 või XXXX-XXXX',
'login.mfaCodeRequired': 'Sisesta autentimisrakenduse kood.',
'login.mfaHint': 'Ava Google Authenticator, Authy või muu TOTP-rakendus.',
'login.mfaBack': '← Tagasi sisselogimise juurde',

Some files were not shown because too many files have changed in this diff Show More