diff --git a/.claude/skills/iterating-on-voltius-ui/SKILL.md b/.claude/skills/iterating-on-voltius-ui/SKILL.md index 87571b9c4..6424d4fa1 100644 --- a/.claude/skills/iterating-on-voltius-ui/SKILL.md +++ b/.claude/skills/iterating-on-voltius-ui/SKILL.md @@ -24,7 +24,7 @@ docker compose -f compose.headless.yml logs tauri-headless # wait for the driv Ready signals: log shows `Joined session keyring`, and `claude mcp list` shows `tauri-docker` connected. First build from a cold cache takes minutes; a warm cache -(`target/` is host-mounted) finishes in seconds. If the MCP is registered but failing, +(the `voltius-target` volume at `/target`) finishes in seconds. If the MCP is registered but failing, the cause is almost always that the container isn't up yet — bring it up and retry. Register the MCP if absent: @@ -32,7 +32,7 @@ Register the MCP if absent: ## Loop -1. `launch_app` `appPath=/app/target/debug/voltius` (check `get_app_state` first). +1. `launch_app` `appPath=/target/debug/voltius` (check `get_app_state` first). 2. Interact: `click_element`, `type_text` (`clear:true` to overwrite; `\n` sends Enter), `press_key` (Enter, arrows, chords like `["Control","l"]`), `wait_for_element`. 3. `capture_screenshot` with `returnBase64:false` → saves to `/app/screenshots/.png` diff --git a/.claude/skills/testing-voltius-team-features-e2e/wd.mjs b/.claude/skills/testing-voltius-team-features-e2e/wd.mjs index 716859e82..8525f3afe 100644 --- a/.claude/skills/testing-voltius-team-features-e2e/wd.mjs +++ b/.claude/skills/testing-voltius-team-features-e2e/wd.mjs @@ -26,7 +26,7 @@ try { // (kept for reference); set VOLTIUS_KEYCHAIN_NS at `docker run -e ...` instead. const env = {}; if (a[0]) env.VOLTIUS_KEYCHAIN_NS = a[0]; - const r = await j('POST', '/session', { capabilities:{ alwaysMatch:{ 'tauri:options':{ application:'/app/target/debug/voltius', env } } } }); + const r = await j('POST', '/session', { capabilities:{ alwaysMatch:{ 'tauri:options':{ application: process.env.VOLTIUS_APP_BIN || '/target/debug/voltius', env } } } }); const s = r.d && r.d.value && r.d.value.sessionId; if (!s) { console.log('FAIL '+JSON.stringify(r.d).slice(0,300)); process.exit(1); } writeFileSync(SIDF, s); await j('POST', `/session/${s}/timeouts`, { implicit: 6000 }); diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..36ea625dc --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +# Dockerfile.tauri-headless has no COPY or ADD — the repo arrives as a bind +# mount at run time, not baked into the image. Without this, every build tars +# the whole worktree, which carries a multi-gigabyte cargo target/ and +# node_modules and takes minutes before the first instruction runs. +* diff --git a/Dockerfile.tauri-headless b/Dockerfile.tauri-headless index 4e6d37479..8dd667940 100644 --- a/Dockerfile.tauri-headless +++ b/Dockerfile.tauri-headless @@ -2,6 +2,15 @@ FROM ubuntu:24.04 ENV DEBIAN_FRONTEND=noninteractive +# The container writes into the bind-mounted worktree (pnpm install, and any +# cargo output that still lands there). Running as root left ~30k root-owned +# files the host user could not clean up, so the build user is created with the +# host's uid/gid: +# docker build -f Dockerfile.tauri-headless \ +# --build-arg UID="$(id -u)" --build-arg GID="$(id -g)" -t tauri-mcp . +ARG UID=1000 +ARG GID=1000 + # System dependencies: Xvfb + WebKit + compilers + mold (fast linker) + keyutils # (the app's OS keychain talks to the kernel keyutils keyring at startup) + ffmpeg # (records the Xvfb framebuffer for headless UI screen-capture / video) + xdotool @@ -25,14 +34,28 @@ RUN apt-get update && apt-get install -y \ python3-pil \ && rm -rf /var/lib/apt/lists/* +# ubuntu:24.04 ships a stock `ubuntu` user at 1000:1000, which collides with the +# common host uid. Drop it first so the requested ids are always free. +RUN userdel -r ubuntu 2>/dev/null || true; \ + groupadd -g "${GID}" builder 2>/dev/null || true; \ + useradd -m -u "${UID}" -g "${GID}" -s /bin/bash builder + # Install Node.js and enable pnpm RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \ apt-get install -y nodejs && \ npm install -g pnpm -# Install Rust -RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y -ENV PATH="/root/.cargo/bin:${PATH}" +# Rust lives outside root's home so the build user can reach it, and cargo's +# output lives OUTSIDE the bind mount at /app. A target dir anywhere under +# vite's root makes vite's watcher crawl it — the dev server climbs to ~15GB RSS +# and the webview never loads, which presents as every WebDriver call hanging +# with no error. Mount a host dir at /target to keep the cache across rebuilds. +ENV CARGO_HOME=/opt/cargo \ + RUSTUP_HOME=/opt/rustup \ + CARGO_TARGET_DIR=/target \ + PATH=/opt/cargo/bin:${PATH} + +RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --no-modify-path # Install tauri-driver RUN cargo install tauri-driver @@ -41,12 +64,15 @@ RUN cargo install tauri-driver # RUSTFLAGS env) keeps the build fingerprint constant so the target/ cache is # reused across rebuilds. The host triple is detected at build time so this # works on any arch (arm64 / x86_64) without hardcoding. -RUN mkdir -p /root/.cargo && \ - TRIPLE="$(rustc -vV | sed -n 's/^host: //p')" && \ +RUN TRIPLE="$(rustc -vV | sed -n 's/^host: //p')" && \ printf '[target.%s]\nrustflags = ["-C", "link-arg=-fuse-ld=mold"]\n' "$TRIPLE" \ - > /root/.cargo/config.toml + > "${CARGO_HOME}/config.toml" + +RUN mkdir -p /target /app && \ + chown -R "${UID}:${GID}" /opt/cargo /opt/rustup /target /app WORKDIR /app +USER builder # Dev / hot-reload workflow: # 1. install JS deps @@ -63,16 +89,28 @@ CMD bash -c "CI=true pnpm install && \ (pnpm dev > /tmp/vite.log 2>&1 &) && \ keyctl session - xvfb-run --auto-servernum tauri-driver --port 4444" -# docker build -f Dockerfile.tauri-headless -t tauri-mcp . +# docker build -f Dockerfile.tauri-headless \ +# --build-arg UID="$(id -u)" --build-arg GID="$(id -g)" -t tauri-mcp . # # IMPORTANT: --security-opt seccomp=unconfined is REQUIRED. The app's OS keychain # uses the kernel keyutils keyring; the default Docker seccomp profile blocks # those syscalls, which makes keychain_get fail and the app hang on its splash. -# docker run -d --name tauri-headless --security-opt seccomp=unconfined -v "$(pwd):/app" tauri-mcp +# docker run -d --name tauri-headless --security-opt seccomp=unconfined \ +# -v "$(pwd):/app" -v voltius-node-modules:/app/node_modules \ +# -v voltius-target:/target tauri-mcp +# +# node_modules MUST be a volume, not shared with the host: pnpm records an +# absolute storeDir in node_modules/.modules.yaml, and a host command that then +# sees the container's path decides the tree is foreign and asks to purge it — +# which fails outside a TTY. Recovery on the host: `CI=true pnpm install`. # # docker logs -f tauri-headless # claude mcp add tauri-docker -- docker exec -i tauri-headless npx -y github:VoltiusApp/mcp-tauri-automation # # Then, in Claude Code, drive the live dev build via the MCP: -# launch_app appPath=/app/target/debug/voltius +# launch_app appPath=/target/debug/voltius # capture_screenshot / click_element / type_text / ... +# +# NEVER run cargo against this repo mounted anywhere but /app: the absolute path +# is baked into the target dir's fingerprints, and a later build at /app then +# fails to read plugin permissions from the stale path. diff --git a/compose.headless.yml b/compose.headless.yml index c436e7109..fbc30d228 100644 --- a/compose.headless.yml +++ b/compose.headless.yml @@ -25,6 +25,12 @@ services: build: context: . dockerfile: Dockerfile.tauri-headless + args: + # Matches the container's build user to the host's, so pnpm install and + # cargo stop leaving root-owned files in the mounted worktree. Export + # UID/GID before `up` if your host ids are not 1000. + UID: ${UID:-1000} + GID: ${GID:-1000} image: tauri-mcp container_name: tauri-headless security_opt: @@ -38,6 +44,17 @@ services: - WEBKIT_DISABLE_COMPOSITING_MODE=1 volumes: - .:/app + # The container gets its own node_modules. Sharing the host's poisons it: + # pnpm records an absolute `storeDir` in node_modules/.modules.yaml, the + # container's differs from the host's, and the next host command decides + # the tree is foreign and asks to purge it — which fails outside a TTY, + # so the plugin-bundle tests start failing with a bare "Command failed". + # (Recovery if it happens anyway: `CI=true pnpm install` on the host.) + - voltius-node-modules:/app/node_modules + # CARGO_TARGET_DIR. Deliberately not inside /app: vite's watcher crawls a + # target dir under its root, climbs to ~15GB RSS, and the webview then + # never loads — which presents as every WebDriver call hanging. + - voltius-target:/target networks: - voltius-test @@ -68,3 +85,7 @@ services: networks: voltius-test: driver: bridge + +volumes: + voltius-target: + voltius-node-modules: diff --git a/src/components/settings/sections/AccountSection.handle.test.tsx b/src/components/settings/sections/AccountSection.handle.test.tsx index 7fc2d4d16..1cbae9164 100644 --- a/src/components/settings/sections/AccountSection.handle.test.tsx +++ b/src/components/settings/sections/AccountSection.handle.test.tsx @@ -61,16 +61,33 @@ afterEach(() => { vi.clearAllMocks(); }); -test("a free account sees its generated handle, a copy button and the upsell — no claim form", async () => { - h.getMe.mockResolvedValue({ handle: "swift-otter-4821", handle_is_custom: false, tier: "free", allow_stranger_invites: true }); +test("a free but verified account is offered the claim control — no tier gate", async () => { + h.getMe.mockResolvedValue({ handle: "swift-otter-4821", handle_is_custom: false, tier: "free", email_verified: true, allow_stranger_invites: true }); render(); expect(await screen.findByText("@swift-otter-4821")).toBeTruthy(); - expect(screen.getByText("settings.account.handle.upsell")).toBeTruthy(); - expect(screen.queryByRole("button", { name: "settings.account.handle.save" })).toBeNull(); + const choose = screen.getByRole("button", { name: "settings.account.handle.choose" }); + expect(choose.hasAttribute("disabled")).toBe(false); + expect(screen.getByText("settings.account.handle.chooseSub")).toBeTruthy(); + expect(screen.queryByText("settings.account.handle.unverified")).toBeNull(); }); -test("a pro account can claim and the taken case is explained, not swallowed", async () => { - h.getMe.mockResolvedValue({ handle: "swift-otter-4821", handle_is_custom: false, tier: "pro", allow_stranger_invites: true }); +test("an unverified account sees the control disabled with the reason, not hidden", async () => { + h.getMe.mockResolvedValue({ handle: "swift-otter-4821", handle_is_custom: false, tier: "pro", email_verified: false, allow_stranger_invites: true }); + render(); + const choose = await screen.findByRole("button", { name: "settings.account.handle.choose" }); + expect(choose.hasAttribute("disabled")).toBe(true); + expect(screen.getByText("settings.account.handle.unverified")).toBeTruthy(); +}); + +test("the copy explains what a custom handle does rather than naming a tier", async () => { + h.getMe.mockResolvedValue({ handle: "swift-otter-4821", handle_is_custom: false, tier: "free", email_verified: true, allow_stranger_invites: true }); + render(); + await screen.findByText("settings.account.handle.chooseSub"); + expect(screen.getByText("settings.account.handle.generatedNote")).toBeTruthy(); +}); + +test("a verified account can claim and the taken case is explained, not swallowed", async () => { + h.getMe.mockResolvedValue({ handle: "swift-otter-4821", handle_is_custom: false, tier: "free", email_verified: true, allow_stranger_invites: true }); h.claimHandle.mockRejectedValue(new HandleClaimError(409)); render(); await userEvent.click(await screen.findByRole("button", { name: "settings.account.handle.choose" })); @@ -80,14 +97,14 @@ test("a pro account can claim and the taken case is explained, not swallowed", a }); test("the stranger-invite toggle persists", async () => { - h.getMe.mockResolvedValue({ handle: "h", handle_is_custom: false, tier: "pro", allow_stranger_invites: true }); + h.getMe.mockResolvedValue({ handle: "h", handle_is_custom: false, tier: "pro", email_verified: true, allow_stranger_invites: true }); render(); await userEvent.click(await screen.findByRole("switch", { name: "settings.account.strangerInvites.label" })); expect(h.updateInvitePreferences).toHaveBeenCalledWith(false); }); test("the stranger-invite toggle reverts on failure", async () => { - h.getMe.mockResolvedValue({ handle: "h", handle_is_custom: false, tier: "pro", allow_stranger_invites: true }); + h.getMe.mockResolvedValue({ handle: "h", handle_is_custom: false, tier: "pro", email_verified: true, allow_stranger_invites: true }); h.updateInvitePreferences.mockRejectedValueOnce(new Error("network error")); render(); const toggle = await screen.findByRole("switch", { name: "settings.account.strangerInvites.label" }); @@ -96,10 +113,10 @@ test("the stranger-invite toggle reverts on failure", async () => { expect(toggle.getAttribute("aria-checked")).toBe("true"); }); -test("a pro account sees distinct copy for each claim-failure status", async () => { - h.getMe.mockResolvedValue({ handle: "h", handle_is_custom: false, tier: "pro", allow_stranger_invites: true }); +test("distinct copy for each claim-failure status", async () => { + h.getMe.mockResolvedValue({ handle: "h", handle_is_custom: false, tier: "pro", email_verified: true, allow_stranger_invites: true }); const cases: [number, string][] = [ - [402, "settings.account.handle.errorTierRequired"], + [403, "settings.account.handle.errorEmailNotVerified"], [422, "settings.account.handle.errorInvalid"], [429, "settings.account.handle.errorCooldown"], ]; @@ -114,30 +131,30 @@ test("a pro account sees distinct copy for each claim-failure status", async () } }); -test("a lapsed pro user keeps the custom-handle message, not the upsell", async () => { - h.getMe.mockResolvedValue({ handle: "kevin-p", handle_is_custom: true, tier: "free", allow_stranger_invites: true }); +test("a lapsed account can still rename its custom handle", async () => { + h.getMe.mockResolvedValue({ handle: "kevin-p", handle_is_custom: true, tier: "free", email_verified: true, allow_stranger_invites: true }); render(); await screen.findByText("@kevin-p"); - expect(screen.getByText("settings.account.handle.lapsedKeepsHandle")).toBeTruthy(); - expect(screen.queryByText("settings.account.handle.upsell")).toBeNull(); + const change = screen.getByRole("button", { name: "settings.account.handle.change" }); + expect(change.hasAttribute("disabled")).toBe(false); }); -test("no upsell flash before the tier is known", async () => { +test("no verify-your-email flash before /auth/me answers", async () => { let resolveMe!: (v: MeResponse) => void; h.getMe.mockReturnValue(new Promise((resolve) => { resolveMe = resolve; })); render(); - // Wait for mode ("server") to resolve and the handle block to mount, while - // getMe (and so the tier) is still pending — this is the exact window a - // paying user would otherwise see the free-tier upsell flash in. + // Wait for mode ("server") to resolve and the handle block to mount while + // getMe (and so email_verified) is still pending — the exact window a + // verified user would otherwise see the "verify your email" line flash in. await screen.findByText("settings.account.handle.title"); - expect(screen.queryByText("settings.account.handle.upsell")).toBeNull(); + expect(screen.queryByText("settings.account.handle.unverified")).toBeNull(); expect(screen.queryByRole("button", { name: "settings.account.handle.choose" })).toBeNull(); - resolveMe({ handle: "swift-otter-4821", handle_is_custom: false, tier: "pro", allow_stranger_invites: true }); + resolveMe({ handle: "swift-otter-4821", handle_is_custom: false, tier: "pro", email_verified: true, allow_stranger_invites: true }); expect(await screen.findByRole("button", { name: "settings.account.handle.choose" })).toBeTruthy(); }); test("the stranger-invite toggle disables itself mid-flight so a second click can't race it", async () => { - h.getMe.mockResolvedValue({ handle: "h", handle_is_custom: false, tier: "pro", allow_stranger_invites: true }); + h.getMe.mockResolvedValue({ handle: "h", handle_is_custom: false, tier: "pro", email_verified: true, allow_stranger_invites: true }); let resolveUpdate!: () => void; h.updateInvitePreferences.mockReturnValue(new Promise((resolve) => { resolveUpdate = resolve; })); render(); diff --git a/src/components/settings/sections/AccountSection.tsx b/src/components/settings/sections/AccountSection.tsx index fecb834cc..a2f5b54d0 100644 --- a/src/components/settings/sections/AccountSection.tsx +++ b/src/components/settings/sections/AccountSection.tsx @@ -78,11 +78,12 @@ async function openCheckout(plan: "pro" | "teams") { } /** Maps claimHandle's status-carrying error to the copy the server's per-status - * contract calls for — each status needs a distinct next step, not one generic message. */ + * contract calls for — each status needs a distinct next step, not one generic message. + * 403 is the server's only refusal here that the user can act on themselves. */ function mapHandleClaimError(e: unknown, t: (key: string) => string): Error { if (e instanceof HandleClaimError) { const key = - e.status === 402 ? "settings.account.handle.errorTierRequired" : + e.status === 403 ? "settings.account.handle.errorEmailNotVerified" : e.status === 409 ? "settings.account.handle.errorTaken" : e.status === 422 ? "settings.account.handle.errorInvalid" : e.status === 429 ? "settings.account.handle.errorCooldown" : @@ -105,8 +106,9 @@ export default function AccountSection() { const [showChangePassword, setShowChangePassword] = useState(false); const [handle, setHandle] = useState(null); const [handleIsCustom, setHandleIsCustom] = useState(false); - const [meTier, setMeTier] = useState(undefined); - const [tierKnown, setTierKnown] = useState(false); + // `null` until /auth/me answers — the claim control renders in neither state + // until then, so a verified user never sees the "verify your email" row flash. + const [emailVerified, setEmailVerified] = useState(null); const [allowStrangerInvites, setAllowStrangerInvites] = useState(true); const [strangerInvitesError, setStrangerInvitesError] = useState(""); const [strangerInvitesLoading, setStrangerInvitesLoading] = useState(false); @@ -123,12 +125,6 @@ export default function AccountSection() { }, (value) => { setHandle(value); setHandleIsCustom(true); }, ); - // Lapsing from Pro drops back to "free" but keeps a custom handle and its - // searchability — only the ability to rename is gated on tier. That account - // must never see the "upgrade to get a searchable handle" upsell, since it - // already has exactly that. - const isFreeTier = tierKnown && (!meTier || meTier === "free"); - const isLapsedCustom = isFreeTier && handleIsCustom; const { copied: handleCopied, copy: handleCopyHandle } = useCopyHandle(handle); const toggleStrangerInvites = async (next: boolean) => { @@ -161,8 +157,7 @@ export default function AccountSection() { if (!me) return; if (me.handle) setHandle(me.handle); setHandleIsCustom(!!me.handle_is_custom); - setMeTier(me.tier); - setTierKnown(true); + setEmailVerified(!!me.email_verified); if (typeof me.allow_stranger_invites === "boolean") setAllowStrangerInvites(me.allow_stranger_invites); }).catch(() => {}); setStep("idle"); @@ -256,17 +251,7 @@ export default function AccountSection() { - {!tierKnown ? null : isLapsedCustom ? ( -
-

{t("settings.account.handle.lapsedKeepsHandle")}

-

{t("settings.account.handle.lapsedRenameLocked")}

-
- ) : isFreeTier ? ( -
-

{t("settings.account.handle.upsell")}

-

{t("settings.account.handle.reachableNote")}

-
- ) : handleField.editing ? ( + {emailVerified === null ? null : handleField.editing ? (
{ e.preventDefault(); @@ -290,14 +275,21 @@ export default function AccountSection() {
) : (
+ {/* Disabled with the reason rather than hidden: hiding the + control is what made the old tier gate unreadable. */}

{t("settings.account.handle.chooseSub")}

+

{t("settings.account.handle.generatedNote")}

+ {!emailVerified && ( +

{t("settings.account.handle.unverified")}

+ )}
)} diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index d641ab4f6..441bf8c64 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -82,6 +82,7 @@ "emailAlreadyRegistered": "Email already registered", "registrationFailed": "Registration failed: {{status}}", "accountNotFound": "Account not found", + "tooManyAttempts": "Too many attempts — wait a minute and try again", "noAccountFoundCreateOne": "No account found. Please create one first.", "serverLoginFailed": "Server login failed", "sessionRefreshFailed": "Session refresh failed", diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 9801115b2..b678d5463 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -263,17 +263,15 @@ "copied": "Copied", "choose": "Choose a handle", "change": "Change handle", - "chooseSub": "Lets people invite you by @handle instead of your email.", + "chooseSub": "A custom handle makes you findable by people outside your teams.", + "generatedNote": "Your current handle already works for anyone you give it to.", + "unverified": "Verify your email to choose a custom handle.", "placeholder": "your-handle", "inputLabel": "Handle", "save": "Save", - "upsell": "Custom handles require Pro or higher.", - "reachableNote": "People can still reach you by your @handle — a custom one just makes you searchable.", - "lapsedKeepsHandle": "Your plan lapsed, but you keep your existing handle.", - "lapsedRenameLocked": "Renaming is locked until you resubscribe.", "errorInvalid": "Handles can only contain letters, numbers, and hyphens.", "errorTaken": "That handle is already taken.", - "errorTierRequired": "Custom handles require Pro or higher.", + "errorEmailNotVerified": "Verify your email first, then choose your handle.", "errorCooldown": "You can change your handle again soon.", "errorGeneric": "Could not update your handle." }, diff --git a/src/i18n/locales/en/terminal.json b/src/i18n/locales/en/terminal.json index ef94e63f3..ed3f6ea25 100644 --- a/src/i18n/locales/en/terminal.json +++ b/src/i18n/locales/en/terminal.json @@ -151,7 +151,7 @@ "uninviteFailed": "Could not withdraw the invite", "inviteNoTeammates": "No teammates yet", "inviteLoadFailed": "Could not load teammates", - "peopleSearchPlaceholder": "Search by name, @handle, or email…", + "peopleSearchPlaceholder": "Search by @handle or email…", "peopleNoMatch": "No one in your teams matches \"{{query}}\".", "peopleFindRule": "People outside your teams are found by their @handle or their full email address.", "recentLabel": "Recent", diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index 66cf51a65..5943f51c7 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -82,6 +82,7 @@ "emailAlreadyRegistered": "Cet e-mail est déjà enregistré", "registrationFailed": "Échec de l'inscription : {{status}}", "accountNotFound": "Compte introuvable", + "tooManyAttempts": "Trop de tentatives — attendez une minute puis réessayez", "noAccountFoundCreateOne": "Aucun compte trouvé. Veuillez d'abord en créer un.", "serverLoginFailed": "Échec de la connexion au serveur", "sessionRefreshFailed": "Échec du renouvellement de la session", diff --git a/src/i18n/locales/fr/notifications.json b/src/i18n/locales/fr/notifications.json index da6e22fcf..6380a9f5c 100644 --- a/src/i18n/locales/fr/notifications.json +++ b/src/i18n/locales/fr/notifications.json @@ -43,7 +43,7 @@ "inbox": { "someone": "Quelqu'un", "invite": { - "message": "{{inviter}} vous a invité à rejoindre {{team}}", + "message": "{{inviter}} vous invite à rejoindre {{team}}", "accept": "Accepter", "decline": "Refuser" }, @@ -53,7 +53,7 @@ "joined": "Rejoint" }, "sessionInvite": { - "message": "{{inviter}} vous a invité à {{name}}" + "message": "{{inviter}} vous invite à rejoindre {{name}}" }, "sessionKnock": { "message": "{{inviter}} souhaite partager un terminal", @@ -68,7 +68,7 @@ "granted": "Vous avez le contrôle" }, "awaitingKey": { - "message": "En attente qu'un propriétaire de {{team}} accorde l'accès au coffre" + "message": "En attente qu'un propriétaire de {{team}} vous donne accès au coffre" } } } diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json index f92108793..e2614c006 100644 --- a/src/i18n/locales/fr/settings.json +++ b/src/i18n/locales/fr/settings.json @@ -263,17 +263,15 @@ "copied": "Copié", "choose": "Choisir un pseudo", "change": "Changer de pseudo", - "chooseSub": "Permet aux autres de vous inviter par @pseudo plutôt que par e-mail.", + "chooseSub": "Un pseudo personnalisé vous rend trouvable par des personnes extérieures à vos équipes.", + "generatedNote": "Votre pseudo actuel fonctionne déjà auprès de toute personne à qui vous le donnez.", + "unverified": "Vérifiez votre adresse e-mail pour choisir un pseudo personnalisé.", "placeholder": "votre-pseudo", "inputLabel": "Pseudo", "save": "Enregistrer", - "upsell": "Les pseudos personnalisés nécessitent l'offre Pro ou supérieure.", - "reachableNote": "Vous restez joignable par votre @pseudo — un pseudo personnalisé vous rend simplement trouvable.", - "lapsedKeepsHandle": "Votre offre a expiré, mais vous conservez votre pseudo actuel.", - "lapsedRenameLocked": "Le changement de pseudo est verrouillé jusqu'à votre réabonnement.", "errorInvalid": "Un pseudo ne peut contenir que des lettres, des chiffres et des tirets.", "errorTaken": "Ce pseudo est déjà pris.", - "errorTierRequired": "Les pseudos personnalisés nécessitent l'offre Pro ou supérieure.", + "errorEmailNotVerified": "Vérifiez d'abord votre adresse e-mail, puis choisissez votre pseudo.", "errorCooldown": "Vous pourrez de nouveau changer de pseudo bientôt.", "errorGeneric": "Impossible de mettre à jour votre pseudo." }, diff --git a/src/i18n/locales/fr/terminal.json b/src/i18n/locales/fr/terminal.json index c4d98b5e1..5c4924512 100644 --- a/src/i18n/locales/fr/terminal.json +++ b/src/i18n/locales/fr/terminal.json @@ -144,14 +144,14 @@ "hasControl": "A le contrôle", "stopSharing": "Arrêter le partage", "inviteHasAccess": "A déjà accès", - "inviteSent": "Invité", + "inviteSent": "Invitation envoyée", "inviteCapReached": "Limite atteinte", "inviteFailed": "Impossible d'inviter {{name}}", "withdrawInvite": "Retirer", "uninviteFailed": "Impossible de retirer l'invitation", "inviteNoTeammates": "Aucun coéquipier pour l'instant", "inviteLoadFailed": "Impossible de charger les coéquipiers", - "peopleSearchPlaceholder": "Rechercher par nom, @pseudo ou e-mail…", + "peopleSearchPlaceholder": "Rechercher par @pseudo ou e-mail…", "peopleNoMatch": "Personne dans vos équipes ne correspond à « {{query}} ».", "peopleFindRule": "En dehors de vos équipes, une personne se trouve par son @pseudo ou son adresse e-mail complète.", "recentLabel": "Récent", diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json index 66784a513..d45254e42 100644 --- a/src/i18n/locales/ru/common.json +++ b/src/i18n/locales/ru/common.json @@ -82,6 +82,7 @@ "emailAlreadyRegistered": "Этот email уже зарегистрирован", "registrationFailed": "Не удалось зарегистрироваться: {{status}}", "accountNotFound": "Учётная запись не найдена", + "tooManyAttempts": "Слишком много попыток — подождите минуту и попробуйте снова", "noAccountFoundCreateOne": "Учётная запись не найдена. Сначала создайте её.", "serverLoginFailed": "Не удалось войти на сервер", "sessionRefreshFailed": "Не удалось обновить сессию", diff --git a/src/i18n/locales/ru/notifications.json b/src/i18n/locales/ru/notifications.json index 5577ab1f5..d08ad1e88 100644 --- a/src/i18n/locales/ru/notifications.json +++ b/src/i18n/locales/ru/notifications.json @@ -43,17 +43,17 @@ "inbox": { "someone": "Кто-то", "invite": { - "message": "{{inviter}} пригласил(а) вас в {{team}}", + "message": "Приглашение в {{team}} от {{inviter}}", "accept": "Принять", "decline": "Отклонить" }, "session": { - "message": "Коллега поделился сессией {{name}}", + "message": "Доступ к сессии {{name}} открыт коллегой", "join": "Присоединиться", "joined": "Вы в сессии" }, "sessionInvite": { - "message": "{{inviter}} пригласил(а) вас в {{name}}" + "message": "Приглашение в сессию {{name}} от {{inviter}}" }, "sessionKnock": { "message": "{{inviter}} хочет поделиться терминалом", diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json index aee08c59e..a79419613 100644 --- a/src/i18n/locales/ru/settings.json +++ b/src/i18n/locales/ru/settings.json @@ -263,17 +263,15 @@ "copied": "Скопировано", "choose": "Выбрать псевдоним", "change": "Изменить псевдоним", - "chooseSub": "Позволяет приглашать вас по @псевдониму вместо e-mail.", + "chooseSub": "Собственный псевдоним делает вас находимым для людей вне ваших команд.", + "generatedNote": "Ваш текущий псевдоним уже работает для всех, кому вы его дадите.", + "unverified": "Подтвердите электронную почту, чтобы выбрать собственный псевдоним.", "placeholder": "ваш-псевдоним", "inputLabel": "Псевдоним", "save": "Сохранить", - "upsell": "Пользовательские псевдонимы требуют тариф Pro или выше.", - "reachableNote": "С вами всё равно можно связаться по вашему @псевдониму — свой псевдоним лишь делает вас находимым в поиске.", - "lapsedKeepsHandle": "Ваш тариф истёк, но текущий псевдоним сохраняется за вами.", - "lapsedRenameLocked": "Переименование заблокировано до возобновления подписки.", "errorInvalid": "Псевдоним может содержать только буквы, цифры и дефисы.", "errorTaken": "Этот псевдоним уже занят.", - "errorTierRequired": "Пользовательские псевдонимы требуют тариф Pro или выше.", + "errorEmailNotVerified": "Сначала подтвердите электронную почту, затем выберите псевдоним.", "errorCooldown": "Вы сможете снова сменить псевдоним чуть позже.", "errorGeneric": "Не удалось обновить псевдоним." }, diff --git a/src/i18n/locales/ru/terminal.json b/src/i18n/locales/ru/terminal.json index ff7e53560..98d003f09 100644 --- a/src/i18n/locales/ru/terminal.json +++ b/src/i18n/locales/ru/terminal.json @@ -154,14 +154,14 @@ "hasControl": "Управляет", "stopSharing": "Остановить совместный доступ", "inviteHasAccess": "Уже есть доступ", - "inviteSent": "Приглашён", + "inviteSent": "Приглашение отправлено", "inviteCapReached": "Лимит достигнут", "inviteFailed": "Не удалось пригласить {{name}}", "withdrawInvite": "Отозвать", "uninviteFailed": "Не удалось отозвать приглашение", "inviteNoTeammates": "Пока нет коллег по команде", "inviteLoadFailed": "Не удалось загрузить список коллег", - "peopleSearchPlaceholder": "Поиск по имени, @псевдониму или e-mail…", + "peopleSearchPlaceholder": "Поиск по @псевдониму или e-mail…", "peopleNoMatch": "В ваших командах никто не соответствует «{{query}}».", "peopleFindRule": "Людей вне ваших команд можно найти по их @псевдониму или полному адресу e-mail.", "recentLabel": "Недавние", diff --git a/src/i18n/locales/zh/common.json b/src/i18n/locales/zh/common.json index 2d2302390..363099db5 100644 --- a/src/i18n/locales/zh/common.json +++ b/src/i18n/locales/zh/common.json @@ -82,6 +82,7 @@ "emailAlreadyRegistered": "邮箱已被注册", "registrationFailed": "注册失败:{{status}}", "accountNotFound": "未找到账户", + "tooManyAttempts": "尝试次数过多,请稍等一分钟后重试", "noAccountFoundCreateOne": "未找到账户。请先创建一个。", "serverLoginFailed": "服务器登录失败", "sessionRefreshFailed": "会话刷新失败", @@ -184,13 +185,13 @@ "cascadeCopied_other": "{{names}}将被复制而非移动,因为留在原处的对象仍在使用它们。", "cascadeAlsoCopied_one": "其{{names}}也会一并复制。", "cascadeAlsoCopied_other": "其{{names}}也会一并复制。", - "pasteRootCrossVault": "同时显示多个保管库时,在顶层粘贴无法在保管库之间移动项目——请选择单个保管库,或在目标保管库中打开一个文件夹。", + "pasteRootCrossVault": "同时显示多个保险库时,在顶层粘贴无法在保险库之间移动项目——请选择单个保险库,或在目标保险库中打开一个文件夹。", "pasteFailed": "粘贴失败:{{error}}", "pasteBlocked": "未粘贴任何内容:你在相关保险库中缺少“{{permissions}}”权限。", "permissionsMissing": "你在相关保险库中缺少“{{permissions}}”权限", "pasteSkipped_one": "{{count}} 个项目已不存在,已跳过。", "pasteSkipped_other": "{{count}} 个项目已不存在,已跳过。", - "pasteWouldDangle": "未粘贴任何内容:这些项目引用的{{kinds}}会留在另一个保管库中并因此失效。请先将它们移入目标保管库。", + "pasteWouldDangle": "未粘贴任何内容:这些项目引用的{{kinds}}会留在另一个保险库中并因此失效。请先将它们移入目标保险库。", "kind": { "connection": "主机", "identity": "身份", diff --git a/src/i18n/locales/zh/settings.json b/src/i18n/locales/zh/settings.json index 3b18d1c10..76708cf3e 100644 --- a/src/i18n/locales/zh/settings.json +++ b/src/i18n/locales/zh/settings.json @@ -149,17 +149,15 @@ "copied": "已复制", "choose": "选择 handle", "change": "更改 handle", - "chooseSub": "让其他人可以通过 @handle 而不是邮箱邀请您。", + "chooseSub": "自定义 handle 让您团队以外的人也能搜索到您。", + "generatedNote": "您当前的 handle 已经可以让任何知道它的人找到您。", + "unverified": "请先验证邮箱,才能选择自定义 handle。", "placeholder": "your-handle", "inputLabel": "Handle", "save": "保存", - "upsell": "自定义 handle 需要 Pro 或更高套餐。", - "reachableNote": "他人仍可通过您的 @handle 联系您 — 自定义 handle 只是让您可被搜索到。", - "lapsedKeepsHandle": "您的套餐已过期,但仍保留现有 handle。", - "lapsedRenameLocked": "重新订阅前无法更改 handle。", "errorInvalid": "Handle 只能包含字母、数字和连字符。", "errorTaken": "该 handle 已被占用。", - "errorTierRequired": "自定义 handle 需要 Pro 或更高套餐。", + "errorEmailNotVerified": "请先验证邮箱,然后再选择您的 handle。", "errorCooldown": "您很快就可以再次更改 handle。", "errorGeneric": "无法更新您的 handle。" }, diff --git a/src/i18n/locales/zh/terminal.json b/src/i18n/locales/zh/terminal.json index 405d8cd98..1dde11125 100644 --- a/src/i18n/locales/zh/terminal.json +++ b/src/i18n/locales/zh/terminal.json @@ -151,7 +151,7 @@ "uninviteFailed": "无法撤回邀请", "inviteNoTeammates": "暂无队友", "inviteLoadFailed": "无法加载队友列表", - "peopleSearchPlaceholder": "按姓名、@handle 或邮箱搜索…", + "peopleSearchPlaceholder": "按 @handle 或邮箱搜索…", "peopleNoMatch": "您的团队中没有人与“{{query}}”匹配。", "peopleFindRule": "团队之外的用户可通过其 @handle 或完整邮箱地址找到。", "recentLabel": "最近", diff --git a/src/services/account.serverAuth.test.ts b/src/services/account.serverAuth.test.ts index 740407278..dae5e5a2b 100644 --- a/src/services/account.serverAuth.test.ts +++ b/src/services/account.serverAuth.test.ts @@ -160,6 +160,24 @@ test("signInToCloud maps a failed login to invalidEmailOrPassword", async () => await expect(signInToCloud("a@b.co", "pw", S)).rejects.toThrow("common.error.invalidEmailOrPassword"); }); +test("signInToCloud reports a rate-limited challenge as such, not as a missing account", async () => { + // The auth limiter is a hardcoded 10/min per IP. Rendering its 429 as + // "Account not found" sends people off to create a second account. + h.http["/auth/challenge"] = err(429); + await expect(signInToCloud("a@b.co", "pw", S)).rejects.toThrow("common.error.tooManyAttempts"); +}); + +test("signInToCloud reports a rate-limited login as such, not as a bad password", async () => { + h.http["/auth/challenge"] = ok({ account_id: "acc" }); + h.http["/auth/login"] = err(429); + await expect(signInToCloud("a@b.co", "pw", S)).rejects.toThrow("common.error.tooManyAttempts"); +}); + +test("signInToCloud names the status on a server fault rather than blaming the account", async () => { + h.http["/auth/challenge"] = err(503); + await expect(signInToCloud("a@b.co", "pw", S)).rejects.toThrow("common.error.serverError"); +}); + test("signInToCloud wipes the previous local vault on success", async () => { h.http["/auth/challenge"] = ok({ account_id: "acc" }); h.http["/auth/login"] = ok({ ...TOKENS, wrapped_user_secrets: "W" }); diff --git a/src/services/account.ts b/src/services/account.ts index 3592d70e3..436567dcb 100644 --- a/src/services/account.ts +++ b/src/services/account.ts @@ -201,7 +201,7 @@ export async function login(password: string, email?: string, serverUrl?: string if (!accountId && email && serverUrl) { const res = await fetchWithTimeout(`${serverUrl}/v1/auth/challenge?email=${encodeURIComponent(email)}`); - if (!res.ok) throw new Error(i18n.t("common.error.accountNotFound")); + if (!res.ok) throw authFailure(res.status, "common.error.accountNotFound"); accountId = (await res.json()).account_id; } if (!accountId) throw new Error(i18n.t("common.error.noAccountFoundCreateOne")); @@ -368,6 +368,7 @@ export interface MeResponse { handle_is_custom?: boolean; allow_stranger_invites?: boolean; tier?: string; + email_verified?: boolean; } /** Fetches /v1/auth/me and caches the handle for offline use. Returns the @@ -471,6 +472,19 @@ export async function setMasterPassword(password: string): Promise { } } +/** + * Turns a failed auth response into the message the status actually means. + * The auth limiter is a hardcoded 10/min per IP with no env override, and a + * retry loop can exhaust it before the user types anything — collapsing every + * non-ok status into `expected` rendered that 429 as "Account not found", + * which sends people off to create a second account they don't need. + */ +function authFailure(status: number, expected: string): Error { + if (status === 429) return new Error(i18n.t("common.error.tooManyAttempts")); + if (status === 404 || status === 401 || status === 403) return new Error(i18n.t(expected)); + return new Error(i18n.t("common.error.serverError", { status })); +} + /** Sign in to an existing cloud account (any local mode — replaces local identity). */ export async function signInToCloud( email: string, @@ -479,7 +493,7 @@ export async function signInToCloud( ): Promise { serverUrl = normalizeServerUrl(serverUrl); const res = await fetchWithTimeout(`${serverUrl}/v1/auth/challenge?email=${encodeURIComponent(email)}`); - if (!res.ok) throw new Error(i18n.t("common.error.accountNotFound")); + if (!res.ok) throw authFailure(res.status, "common.error.accountNotFound"); const { account_id: accountId } = await res.json(); const { auth_key, enc_key: kek } = await deriveKeys(password, accountId); @@ -489,7 +503,7 @@ export async function signInToCloud( headers: { "Content-Type": "application/json" }, body: JSON.stringify({ account_id: accountId, auth_key }), }); - if (!loginRes.ok) throw new Error(i18n.t("common.error.invalidEmailOrPassword")); + if (!loginRes.ok) throw authFailure(loginRes.status, "common.error.invalidEmailOrPassword"); const data = await loginRes.json(); let vaultKey = kek; diff --git a/src/services/teamInbox.ts b/src/services/teamInbox.ts index 460ce1bcd..fc0de5cc2 100644 --- a/src/services/teamInbox.ts +++ b/src/services/teamInbox.ts @@ -32,13 +32,19 @@ export function resetTeamInboxState(): void { controlHeldSessions.clear(); } -function toast(message: string, duration: number): void { +/** + * `inboxId` marks the toast as an echo of an inbox entry, which keeps it out of + * the dismissed-notification history — see `ToastEntry.inboxId`. Omit it only + * for toasts that have no inbox entry behind them. + */ +function toast(message: string, duration: number, inboxId?: string): void { useNotificationStore.getState().addToast({ source: APP_SOURCE, type: "toast", message, severity: "info", duration, + inboxId, }); } @@ -212,7 +218,7 @@ export function reconcileSessions( .map((e) => e.id), ); for (const e of entries) { - if ((e.kind === "sessionInvite" || e.kind === "sessionKnock") && !known.has(e.id)) toast(e.message, 8000); + if ((e.kind === "sessionInvite" || e.kind === "sessionKnock") && !known.has(e.id)) toast(e.message, 8000, e.id); } reconcile(["sessionShared", "sessionInvite", "sessionKnock"], entries); @@ -260,7 +266,7 @@ export function reconcileControlRequests(connections: Record e.kind === "controlRequest").map((e) => e.id), ); for (const e of entries) { - if (!known.has(e.id)) toast(e.message, 8000); + if (!known.has(e.id)) toast(e.message, 8000, e.id); } reconcile(["controlRequest"], entries); diff --git a/src/stores/notificationStore.inbox.test.ts b/src/stores/notificationStore.inbox.test.ts index fb1015a82..0e7599074 100644 --- a/src/stores/notificationStore.inbox.test.ts +++ b/src/stores/notificationStore.inbox.test.ts @@ -63,3 +63,37 @@ test("unreadCount counts pending inbox entries and banners, and falls to zero wh get().retractInbox("invite:2"); expect(get().unreadCount()).toBe(0); }); + +test("a toast echoing an inbox entry is dropped, not archived to history", () => { + get().upsertInbox(entry("session:abc", "@ada wants to share a terminal")); + const toastId = get().addToast({ + source: APP, + type: "toast", + message: "@ada wants to share a terminal", + severity: "info", + duration: 8000, + inboxId: "session:abc", + }); + + get().dismissToast(toastId); + + expect(get().toasts).toHaveLength(0); + // A history row would render the same knock a second time with no Join or + // Decline on it — the inbox entry is the only copy that owns the actions. + expect(get().history).toHaveLength(0); + expect(get().inbox).toHaveLength(1); +}); + +test("a toast with no inbox entry behind it is still archived", () => { + const toastId = get().addToast({ + source: APP, + type: "toast", + message: "You have control", + severity: "info", + duration: 4000, + }); + + get().dismissToast(toastId); + + expect(get().history).toHaveLength(1); +}); diff --git a/src/stores/notificationStore.ts b/src/stores/notificationStore.ts index 418280c5c..b1dee7f29 100644 --- a/src/stores/notificationStore.ts +++ b/src/stores/notificationStore.ts @@ -24,6 +24,14 @@ export interface ToastEntry { finished?: boolean; finishedSeverity?: ToastSeverity; timedOutAt?: number; + /** + * Set when this toast is only a transient echo of an inbox entry that owns + * the same event. Such a toast is never archived: history rows carry no + * actions, so a knock whose toast timed out appeared in the panel twice — + * once still actionable in the inbox, once dead below it — and the dead copy + * is the one that reads like Join and Decline were lost. + */ + inboxId?: string; // Meta createdAt: number; } @@ -147,6 +155,7 @@ export const useNotificationStore = create((set, get) => ({ set((s) => { const toast = s.toasts.find((t) => t.id === id); if (!toast) return s; + if (toast.inboxId) return { toasts: removeById(s.toasts, id) }; const historyEntry: HistoryEntry = { id: toast.id, source: toast.source,