Compare commits

...

2 Commits

Author SHA1 Message Date
55571b1e59 Threema UX: static file middleware fix, *AIAGENT display, info banner
All checks were successful
Build and Push / build (push) Successful in 1m26s
2026-05-17 17:29:25 +02:00
c0ff22394c Threema QR: on-demand modal + auto-open on first add
All checks were successful
Build and Push / build (push) Successful in 1m29s
2026-05-17 17:13:44 +02:00
10 changed files with 259 additions and 98 deletions

147
README.md
View File

@@ -1,116 +1,77 @@
# Threema UX rework + QR code # Threema UX v3 — three real fixes
## What this fixes vs v2
| # | Issue | Fix |
|---|-------|-----|
| 1 | QR image 404'd as `GET /en/threema/qr_code_AIAGENT.png` | Middleware matcher now excludes paths with file extensions, so static files in `public/` are not locale-prefixed |
| 2 | Displayed gateway name as `AIAGENT` (without asterisk) | `displayName` is now `*AIAGENT` (with asterisk) — what users actually see in their Threema contacts |
| 3 | "Show QR" hyperlink — too small, unclear what it does | Replaced with a proper accent-bordered info banner: icon + title + body explaining what to do + prominent "Show QR code" button |
## Files ## Files
``` ```
src/lib/threema-gateway-config.ts # NEW — single source of truth for gateway ID + QR path src/middleware.ts # MODIFIED — matcher excludes dot-paths (static files)
src/components/channel-users/threema-setup.tsx # NEW — QR + 3-step instructions component src/lib/threema-gateway-config.ts # MODIFIED — displayName: "*AIAGENT"
src/components/channel-users/channel-users.tsx # MODIFIED — renders <ThreemaSetup /> for the threema channel src/components/channel-users/channel-users.tsx # MODIFIED — banner replaces inline hyperlink
deploy/patch-i18n-threema.mjs # REPLACES earlier version — customer-friendly texts in 4 langs src/components/channel-users/threema-qr-modal.tsx # UNCHANGED from v2 (label now reads "*AIAGENT" automatically via config)
public/threema/qr_code_AIAGENT.png # NEW — the QR you uploaded deploy/patch-i18n-threema.mjs # MODIFIED — new bannerTitle/bannerBody/bannerButton keys, showQr dropped
public/threema/qr_code_AIAGENT.png # UNCHANGED
``` ```
## What changed (UX, customer-facing)
1. **Package description / instructions / disclaimer.** No mention of
"Gateway account", "Gateway credentials", or anything about asterisks.
The disclaimer now explicitly says messages are end-to-end encrypted
only up to PieCed's messaging service (where they get decrypted to
route to the assistant) — accurate, not overclaiming. Crucially it
also says **Threema charges per message** so customers know that
sending/receiving on this channel has a cost separate from their
PieCed subscription.
2. **Threema ID help text.** Now tells the customer to add **their
own** ID, with explicit instructions for finding it in the Threema
app (Settings → My Threema ID). Drops the asterisk / Gateway-prefix
explanation entirely.
3. **QR code component (NEW).** Shown above the help text in the
channel-users panel when the threema channel is enabled. Three-step
flow: open Threema, scan, add your own ID below.
4. **All four languages** (en/de/fr/it) updated consistently.
## What changed (technical)
- Gateway constants centralised in `src/lib/threema-gateway-config.ts`.
Today hardcoded to `*AIAGENT` + `/threema/qr_code_AIAGENT.png`. When
you need multiple gateway accounts, edit that one file (and the
inline TODO block tells the next person how).
- The component uses Next.js `<Image>` — automatic optimisation,
lazy-loaded by default (no `priority`).
- The PNG goes in `public/threema/`, served as a static asset under
`/threema/qr_code_AIAGENT.png` — no API route, no auth wrapper, the
QR encodes a Threema invitation anyone can scan anyway.
## Apply ## Apply
```bash ```bash
cd /path/to/pieced-portal cd /path/to/pieced-portal
# Drop in new files (overwrites prior channel-users.tsx and i18n script) unzip -o /path/to/threema-ux-v3.zip
cp -r <unzipped>/* .
# Quick TS check (the new component uses next/image — should be fine)
npx tsc --noEmit
# Patch the message files
node deploy/patch-i18n-threema.mjs node deploy/patch-i18n-threema.mjs
# Should print 4 lines, one per language npx tsc --noEmit
# Commit + push
git add -A git add -A
git status # eyeball the changes git status # eyeball
git commit -m "Threema: customer-friendly texts + QR setup component" git commit -m "Threema UX: middleware static fix, *AIAGENT display, info banner"
git push git push
``` ```
## Verify after redeploy ## Layout after redeploy
1. Open the portal as a customer admin, pick the test tenant. ```
2. Disable + re-enable Threema in the package list (or just look at ┌─────────────────────────────────────────────────────────┐
the channel-users panel — the QR shows there regardless). threema 2 users │
3. Authorized Users → threema section should show: ├─────────────────────────────────────────────────────────┤
- QR code on the left │ ╔═══════════════════════════════════════════════════════╗│
- "AIAGENT" label under the QR (no asterisk) │ ║ [icon] Set up Threema [ Show QR code ] ║│ ← prominent banner, accent border
- 3-step instruction list │ ║ Open Threema on your phone and scan our ║│
- Help text below: "Enter your own Threema ID..." │ ║ QR code to add the assistant as a contact. ║│
- Existing user pills + Add input │ ║ Then add your own Threema ID below. ║│
│ ╚═══════════════════════════════════════════════════════╝│
Scan the QR with your phone — it should prompt to add `*AIAGENT` as a ├─────────────────────────────────────────────────────────┤
contact in Threema with trust level "verified by the operator" (the │ <help: "Enter your own Threema ID..."> │
QR encodes the contact's public key). │ ┌───────┐ ┌───────┐ │
│ │USER01 ✕│ │USER02 ✕│ │
## Billing log query (re-run after sending a few messages) │ └───────┘ └───────┘ │
│ [ A8K2P3X7 ] [ Add ] │
```bash └─────────────────────────────────────────────────────────┘
POD=$(kubectl -n threema-gateway get pods -l 'cnpg.io/cluster=pieced-threema-gateway-db,role=primary' -o jsonpath='{.items[0].metadata.name}')
DBPASS=$(kubectl -n threema-gateway get secret pieced-threema-gateway-db-app -o jsonpath='{.data.password}' | base64 -d)
# Per-tenant in/out counts
kubectl -n threema-gateway exec $POD -- env PGPASSWORD="$DBPASS" \
psql -U relay -d relay -c \
"SELECT tenant_name, direction, count(*), max(created_at) FROM messages GROUP BY tenant_name, direction ORDER BY tenant_name, direction;"
``` ```
That's your billing source — count(*) × Threema's per-message rate × The banner is always visible whenever the threema channel is enabled.
direction-specific multiplier = customer charge. Clicking "Show QR code" opens the modal with the QR and 3-step
instructions. ESC, overlay click, or × button closes.
## Future: dynamic gateway accounts Auto-open on first focus of the add-ID input is preserved from v2 —
the modal pops once when a customer clicks into the input to add their
first ID, so a brand-new customer who skipped the banner still gets
the QR right when they need it.
Today's hardcoded `*AIAGENT` works for one shared gateway. When you ## Verification
move to per-tenant gateway accounts (Threema's bigger plans, or to
isolate billing per tenant):
1. Update `src/lib/threema-gateway-config.ts`: After redeploy:
- Make the constants a lookup keyed by tenant
- Or fetch from `/api/tenants/<name>/threema` (new admin route)
2. Update `threema-setup.tsx` to accept gateway info as props
3. Update the parent `channel-users.tsx` to pass tenant-specific values
4. Replace the static PNG with a server route that generates per-tenant
QR codes from each gateway's `id` + `publicKey`
The single config file means this refactor is contained — every 1. Open `https://app.pieced.ch/en/tenants/acme-gmbh-2acf4612` in the browser.
consumer reads from one place, so once that one place returns the 2. Scroll to the **Authorized Users → threema** card.
right values, everything else falls in line. 3. Visible banner with icon, title "Set up Threema", body text, and a
clearly clickable "Show QR code" button on the right.
4. Click the button → modal with the QR shows.
5. The label under the QR reads `*AIAGENT` (with asterisk).
6. Browser DevTools → Network → `GET /threema/qr_code_AIAGENT.png` is
`200`, not `404`, and not redirected to `/en/threema/...`.

View File

@@ -37,6 +37,9 @@ const i18n = {
step2: "Tap the scan icon and scan this QR code to add the assistant as a contact.", step2: "Tap the scan icon and scan this QR code to add the assistant as a contact.",
step3: "Then add your own Threema ID below.", step3: "Then add your own Threema ID below.",
qrAlt: "QR code to add {gateway} as a Threema contact", qrAlt: "QR code to add {gateway} as a Threema contact",
bannerTitle: "Set up Threema",
bannerBody: "Open Threema on your phone and scan our QR code to add the assistant as a contact. Then add your own Threema ID below.",
bannerButton: "Show QR code",
}, },
}, },
de: { de: {
@@ -56,6 +59,9 @@ const i18n = {
step2: "Tippen Sie auf das Scan-Symbol und scannen Sie diesen QR-Code, um den Assistenten als Kontakt hinzuzufügen.", step2: "Tippen Sie auf das Scan-Symbol und scannen Sie diesen QR-Code, um den Assistenten als Kontakt hinzuzufügen.",
step3: "Fügen Sie anschliessend unten Ihre eigene Threema-ID hinzu.", step3: "Fügen Sie anschliessend unten Ihre eigene Threema-ID hinzu.",
qrAlt: "QR-Code, um {gateway} als Threema-Kontakt hinzuzufügen", qrAlt: "QR-Code, um {gateway} als Threema-Kontakt hinzuzufügen",
bannerTitle: "Threema einrichten",
bannerBody: "Öffnen Sie Threema auf Ihrem Telefon und scannen Sie unseren QR-Code, um den Assistenten als Kontakt hinzuzufügen. Geben Sie anschliessend unten Ihre eigene Threema-ID ein.",
bannerButton: "QR-Code anzeigen",
}, },
}, },
fr: { fr: {
@@ -75,6 +81,9 @@ const i18n = {
step2: "Appuyez sur l'icône de scan et scannez ce QR code pour ajouter l'assistant comme contact.", step2: "Appuyez sur l'icône de scan et scannez ce QR code pour ajouter l'assistant comme contact.",
step3: "Puis ajoutez votre propre identifiant Threema ci-dessous.", step3: "Puis ajoutez votre propre identifiant Threema ci-dessous.",
qrAlt: "QR code pour ajouter {gateway} comme contact Threema", qrAlt: "QR code pour ajouter {gateway} comme contact Threema",
bannerTitle: "Configurer Threema",
bannerBody: "Ouvrez Threema sur votre téléphone et scannez notre QR code pour ajouter l'assistant comme contact. Saisissez ensuite votre propre identifiant Threema ci-dessous.",
bannerButton: "Afficher le QR code",
}, },
}, },
it: { it: {
@@ -94,6 +103,9 @@ const i18n = {
step2: "Tocca l'icona di scansione e scansiona questo QR code per aggiungere l'assistente ai contatti.", step2: "Tocca l'icona di scansione e scansiona questo QR code per aggiungere l'assistente ai contatti.",
step3: "Quindi aggiungi il tuo ID Threema qui sotto.", step3: "Quindi aggiungi il tuo ID Threema qui sotto.",
qrAlt: "QR code per aggiungere {gateway} come contatto Threema", qrAlt: "QR code per aggiungere {gateway} come contatto Threema",
bannerTitle: "Configura Threema",
bannerBody: "Apri Threema sul tuo telefono e scansiona il nostro QR code per aggiungere l'assistente ai contatti. Inserisci poi il tuo ID Threema qui sotto.",
bannerButton: "Mostra QR code",
}, },
}, },
}; };

View File

@@ -3,6 +3,7 @@
import { useState, useCallback } from "react"; import { useState, useCallback } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { ThreemaQrModal } from "./threema-qr-modal";
/** Maps channel IDs to the instructions for finding the user ID. */ /** Maps channel IDs to the instructions for finding the user ID. */
const CHANNEL_ID_HELP: Record<string, string> = { const CHANNEL_ID_HELP: Record<string, string> = {
@@ -51,6 +52,14 @@ export function ChannelUsers({
const [inputValues, setInputValues] = useState<Record<string, string>>({}); const [inputValues, setInputValues] = useState<Record<string, string>>({});
const [channelUsers, setChannelUsers] = const [channelUsers, setChannelUsers] =
useState<Record<string, string[]>>(initialChannelUsers); useState<Record<string, string[]>>(initialChannelUsers);
/** Which channel's QR helper modal is open, if any. */
const [showQrFor, setShowQrFor] = useState<string | null>(null);
/**
* Tracks channels for which we've already auto-opened the helper
* modal on this page load. Prevents the modal from re-popping every
* time the user refocuses the input after dismissing it.
*/
const [autoOpened, setAutoOpened] = useState<Set<string>>(() => new Set());
const updateChannelUsers = useCallback( const updateChannelUsers = useCallback(
async (updated: Record<string, string[]>) => { async (updated: Record<string, string[]>) => {
@@ -224,6 +233,39 @@ export function ChannelUsers({
</span> </span>
</div> </div>
{channel === "threema" && (
<div className="mb-3 flex flex-col sm:flex-row gap-3 items-start sm:items-center justify-between bg-accent/5 border border-accent/30 rounded-lg p-3">
<div className="flex items-start gap-2 flex-1">
<svg
className="w-4 h-4 mt-0.5 text-accent flex-shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth="2"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 4a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H4a1 1 0 01-1-1V4zM15 4a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V4zM3 16a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H4a1 1 0 01-1-1v-4zM13 13h3v3h-3zM18 13h3v3h-3zM13 18h3v3h-3zM18 18h3v3h-3z"
/>
</svg>
<div className="text-xs text-text-secondary leading-relaxed">
<p className="font-medium text-text-primary mb-0.5">
{t("threemaSetup.bannerTitle")}
</p>
<p>{t("threemaSetup.bannerBody")}</p>
</div>
</div>
<button
onClick={() => setShowQrFor("threema")}
className="self-stretch sm:self-auto px-3 py-2 text-xs font-medium bg-accent text-surface-0 rounded-lg hover:bg-accent-dim transition-colors whitespace-nowrap cursor-pointer shadow-lg shadow-accent/20"
>
{t("threemaSetup.bannerButton")}
</button>
</div>
)}
{helpKey && ( {helpKey && (
<p className="text-xs text-text-secondary bg-surface-1 border border-border rounded-lg p-3 mb-3 whitespace-pre-line"> <p className="text-xs text-text-secondary bg-surface-1 border border-border rounded-lg p-3 mb-3 whitespace-pre-line">
{t(helpKey)} {t(helpKey)}
@@ -266,6 +308,17 @@ export function ChannelUsers({
[channel]: e.target.value, [channel]: e.target.value,
})) }))
} }
onFocus={() => {
// For threema specifically, open the QR helper the
// first time the user clicks into the input on this
// page load. We don't repeat after dismiss — the
// "Show QR" button next to the channel name covers
// re-opens on demand.
if (channel === "threema" && !autoOpened.has("threema")) {
setShowQrFor("threema");
setAutoOpened((prev) => new Set(prev).add("threema"));
}
}}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter") handleAdd(channel); if (e.key === "Enter") handleAdd(channel);
}} }}
@@ -284,6 +337,11 @@ export function ChannelUsers({
</div> </div>
); );
})} })}
<ThreemaQrModal
open={showQrFor === "threema"}
onClose={() => setShowQrFor(null)}
/>
</div> </div>
); );
} }

View File

@@ -0,0 +1,82 @@
"use client";
import { useTranslations } from "next-intl";
import { useEffect } from "react";
import { THREEMA_GATEWAY } from "@/lib/threema-gateway-config";
interface ThreemaQrModalProps {
open: boolean;
onClose: () => void;
}
/**
* On-demand modal showing the QR for adding the assistant on Threema.
* Triggered by the "Show QR" button in the threema channel card and
* closes on overlay click, ESC, or the close button.
*
* Uses a plain <img> not next/image — image optimization adds nothing
* for a 57KB static PNG and removes a potential source of rendering
* bugs in the Next.js standalone build.
*/
export function ThreemaQrModal({ open, onClose }: ThreemaQrModalProps) {
const t = useTranslations("channelUsers.threemaSetup");
useEffect(() => {
if (!open) return;
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]);
if (!open) return null;
return (
<div
onClick={onClose}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
>
<div
onClick={(e) => e.stopPropagation()}
className="w-full max-w-md bg-surface-1 border border-border rounded-2xl p-6 shadow-2xl shadow-black/40 space-y-4"
>
<div className="flex items-start justify-between">
<h3 className="text-base font-semibold text-text-primary">
{t("title")}
</h3>
<button
onClick={onClose}
className="text-text-muted hover:text-text-primary text-xl leading-none"
aria-label="Close"
>
×
</button>
</div>
<div className="flex justify-center">
<div className="bg-white p-3 rounded-md">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={THREEMA_GATEWAY.qrCodePath}
alt={t("qrAlt", { gateway: THREEMA_GATEWAY.displayName })}
width={220}
height={220}
style={{ display: "block" }}
/>
</div>
</div>
<p className="text-center text-xs text-text-muted font-mono">
{THREEMA_GATEWAY.displayName}
</p>
<ol className="list-decimal list-inside text-xs text-text-secondary space-y-1.5">
<li>{t("step1")}</li>
<li>{t("step2")}</li>
<li>{t("step3")}</li>
</ol>
</div>
</div>
);
}

View File

@@ -0,0 +1,31 @@
/**
* Threema central gateway info shown to customers.
*
* Today PieCed runs exactly one Threema Gateway account (*AIAGENT) and
* every tenant talks to that. The constants below are hardcoded for
* that account. The values are intentionally kept here (and not split
* across i18n / env / runtime config) so when we move to multiple
* gateway accounts there's a single file to refactor.
*
* To go dynamic (future):
* 1. Replace `THREEMA_GATEWAY` constant with a runtime lookup —
* either per-tenant from the relay's admin API, or from an
* env var that lists the active account.
* 2. Move the QR PNG into a server-rendered route that takes a
* gateway ID query param.
* 3. Update consumers to accept the gateway info as a prop and pass
* it from a server component.
*/
export const THREEMA_GATEWAY = {
/** Technical Threema Gateway ID, with leading asterisk. */
id: "*AIAGENT",
/**
* Display name shown to customers. INCLUDES the leading asterisk —
* customers need to recognise this exact string in their Threema
* contacts after scanning the QR, so we don't strip it.
*/
displayName: "*AIAGENT",
/** Public path to the QR code PNG served from `public/`. */
qrCodePath: "/threema/qr_code_AIAGENT.png",
} as const;

View File

@@ -402,7 +402,10 @@
"step1": "Öffnen Sie Threema auf Ihrem Telefon.", "step1": "Öffnen Sie Threema auf Ihrem Telefon.",
"step2": "Tippen Sie auf das Scan-Symbol und scannen Sie diesen QR-Code, um den Assistenten als Kontakt hinzuzufügen.", "step2": "Tippen Sie auf das Scan-Symbol und scannen Sie diesen QR-Code, um den Assistenten als Kontakt hinzuzufügen.",
"step3": "Fügen Sie anschliessend unten Ihre eigene Threema-ID hinzu.", "step3": "Fügen Sie anschliessend unten Ihre eigene Threema-ID hinzu.",
"qrAlt": "QR-Code, um {gateway} als Threema-Kontakt hinzuzufügen" "qrAlt": "QR-Code, um {gateway} als Threema-Kontakt hinzuzufügen",
"bannerTitle": "Threema einrichten",
"bannerBody": "Öffnen Sie Threema auf Ihrem Telefon und scannen Sie unseren QR-Code, um den Assistenten als Kontakt hinzuzufügen. Geben Sie anschliessend unten Ihre eigene Threema-ID ein.",
"bannerButton": "QR-Code anzeigen"
} }
}, },
"team": { "team": {

View File

@@ -402,7 +402,10 @@
"step1": "Open Threema on your phone.", "step1": "Open Threema on your phone.",
"step2": "Tap the scan icon and scan this QR code to add the assistant as a contact.", "step2": "Tap the scan icon and scan this QR code to add the assistant as a contact.",
"step3": "Then add your own Threema ID below.", "step3": "Then add your own Threema ID below.",
"qrAlt": "QR code to add {gateway} as a Threema contact" "qrAlt": "QR code to add {gateway} as a Threema contact",
"bannerTitle": "Set up Threema",
"bannerBody": "Open Threema on your phone and scan our QR code to add the assistant as a contact. Then add your own Threema ID below.",
"bannerButton": "Show QR code"
} }
}, },
"team": { "team": {

View File

@@ -402,7 +402,10 @@
"step1": "Ouvrez Threema sur votre téléphone.", "step1": "Ouvrez Threema sur votre téléphone.",
"step2": "Appuyez sur l'icône de scan et scannez ce QR code pour ajouter l'assistant comme contact.", "step2": "Appuyez sur l'icône de scan et scannez ce QR code pour ajouter l'assistant comme contact.",
"step3": "Puis ajoutez votre propre identifiant Threema ci-dessous.", "step3": "Puis ajoutez votre propre identifiant Threema ci-dessous.",
"qrAlt": "QR code pour ajouter {gateway} comme contact Threema" "qrAlt": "QR code pour ajouter {gateway} comme contact Threema",
"bannerTitle": "Configurer Threema",
"bannerBody": "Ouvrez Threema sur votre téléphone et scannez notre QR code pour ajouter l'assistant comme contact. Saisissez ensuite votre propre identifiant Threema ci-dessous.",
"bannerButton": "Afficher le QR code"
} }
}, },
"team": { "team": {

View File

@@ -402,7 +402,10 @@
"step1": "Apri Threema sul tuo telefono.", "step1": "Apri Threema sul tuo telefono.",
"step2": "Tocca l'icona di scansione e scansiona questo QR code per aggiungere l'assistente ai contatti.", "step2": "Tocca l'icona di scansione e scansiona questo QR code per aggiungere l'assistente ai contatti.",
"step3": "Quindi aggiungi il tuo ID Threema qui sotto.", "step3": "Quindi aggiungi il tuo ID Threema qui sotto.",
"qrAlt": "QR code per aggiungere {gateway} come contatto Threema" "qrAlt": "QR code per aggiungere {gateway} come contatto Threema",
"bannerTitle": "Configura Threema",
"bannerBody": "Apri Threema sul tuo telefono e scansiona il nostro QR code per aggiungere l'assistente ai contatti. Inserisci poi il tuo ID Threema qui sotto.",
"bannerButton": "Mostra QR code"
} }
}, },
"team": { "team": {

View File

@@ -40,5 +40,10 @@ export default async function middleware(request: NextRequest) {
} }
export const config = { export const config = {
matcher: ["/((?!_next|favicon.ico|api).*)"], // Excludes _next/* internal routes, the favicon, api routes, AND any
// path containing a dot (covers all static files served from public/,
// e.g. /threema/qr_code_AIAGENT.png). Without the dot exclusion, the
// i18n middleware prepends the locale ("/en/threema/qr_code_AIAGENT.png")
// and the file is not found.
matcher: ["/((?!_next|favicon.ico|api|.*\\..*).*)"],
}; };