diff --git a/src/App.test.tsx b/src/App.test.tsx index 32587cea..f16d5827 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -621,7 +621,7 @@ describe("Switchify PC shell", () => { const entry = await (await sidebar()).findByRole("button", { name: /^Account, signed in/ }); fireEvent.click(entry); expect(await screen.findByRole("heading", { name: "Account", level: 1 })).toBeInTheDocument(); - expect(await screen.findByText(/Signed in as/)).toBeInTheDocument(); + expect(await screen.findByRole("heading", { name: "me@example.com" })).toBeInTheDocument(); expect(entry).toHaveAttribute("aria-current", "page"); expect(screen.queryByRole("tablist", { name: "Settings sections" })).not.toBeInTheDocument(); }); diff --git a/src/App.tsx b/src/App.tsx index f2856859..138b163b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { Button, Input, Select } from "./ui/controls"; +import { Button, Input, Select, StatusIcon } from "./ui/controls"; import { Demonstration, DemonstrationProvider } from "./help/Demonstration"; import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react"; import { @@ -67,11 +67,6 @@ function NavButton({ active, icon, children, onClick }: { active: boolean; icon: return ; } -// `ok` omitted marks a row that has no state to report, only a subject. -function StatusIcon({ ok, children }: { ok?: boolean; children: ReactNode }) { - return ; -} - function AccessibilityCopy({ state, detailed = false }: { state: AppState; detailed?: boolean }) { if (state.accessibility === "granted") return

Ready

; if (state.accessibility === "unavailable") return

Unavailable on this system

; diff --git a/src/settings/AccountSection.test.tsx b/src/settings/AccountSection.test.tsx index 3326417a..621fb3e3 100644 --- a/src/settings/AccountSection.test.tsx +++ b/src/settings/AccountSection.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { api } from "../api"; import type { AccountView, SyncView } from "../types"; @@ -59,6 +59,19 @@ describe("AccountSection", () => { expect(await screen.findByText(/saved by a newer Switchify PC/)).toBeInTheDocument(); }); + it("announces the sync status only once it is known", async () => { + vi.spyOn(api, "account").mockResolvedValue(signedIn); + vi.spyOn(api, "onAccount").mockResolvedValue(() => undefined); + let resolve: (view: SyncView) => void = () => undefined; + vi.spyOn(api, "settingsSync").mockImplementation(() => new Promise((done) => { resolve = done; })); + vi.spyOn(api, "onSettingsSync").mockResolvedValue(() => undefined); + render(); + const placeholder = await screen.findByText("Checking settings sync…"); + expect(placeholder).not.toHaveAttribute("role", "status"); + act(() => resolve({ status: "upToDate", lastSyncedAt: null, message: null })); + expect(await screen.findByText("Settings are up to date.")).toHaveAttribute("role", "status"); + }); + it("follows sync changes from the backend", async () => { let receive: (view: SyncView) => void = () => undefined; vi.spyOn(api, "account").mockResolvedValue(signedIn); @@ -71,17 +84,20 @@ describe("AccountSection", () => { expect(await screen.findByText("Could not reach Switchify to sync settings.")).toBeInTheDocument(); }); - it("is hidden while confirming account deletion", async () => { + it("stays in its own row while deletion is confirmed in another", async () => { start(signedIn); await screen.findByRole("button", { name: "Sync now" }); fireEvent.click(screen.getByRole("button", { name: "Delete account…" })); - expect(screen.queryByRole("button", { name: "Sync now" })).not.toBeInTheDocument(); + const confirm = screen.getByRole("group", { name: "Confirm account deletion" }); + expect(within(confirm).getByRole("button", { name: "Delete permanently" })).toBeInTheDocument(); + expect(within(confirm).queryByRole("button", { name: "Sync now" })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Sync now" })).toBeInTheDocument(); }); }); it("says when accounts are unavailable in this build", async () => { start({ ...signedOut, available: false }); - expect(await screen.findByText("Accounts are unavailable in this build.")).toBeInTheDocument(); + expect(await screen.findByRole("heading", { name: "Accounts are unavailable in this build" })).toBeInTheDocument(); expect(screen.queryByRole("textbox")).not.toBeInTheDocument(); }); @@ -103,7 +119,8 @@ describe("AccountSection", () => { fireEvent.click(screen.getByRole("button", { name: "Sign in" })); expect(verify).toHaveBeenCalledWith("123456"); await waitFor(() => expect(screen.getByRole("button", { name: "Sign out" })).toHaveFocus()); - expect(screen.getByText(/Signed in as/)).toHaveTextContent("Signed in as me@example.com."); + expect(screen.getByRole("heading", { name: "me@example.com" })).toBeInTheDocument(); + expect(screen.getByText(/^Signed in./)).toHaveTextContent("Signed in. It's the same account as Switchify on Android."); }); it("shows a wrong-code error and keeps the code form", async () => { @@ -136,7 +153,7 @@ describe("AccountSection", () => { const remove = vi.spyOn(api, "deleteAccount").mockResolvedValue(signedOut); fireEvent.click(await screen.findByRole("button", { name: "Delete account…" })); expect(remove).not.toHaveBeenCalled(); - expect(screen.getByText(/removes it and its saved settings from every device/)).toBeInTheDocument(); + expect(screen.getByText(/removes your account and its saved settings from every device/)).toBeInTheDocument(); await waitFor(() => expect(screen.getByRole("button", { name: "Keep account" })).toHaveFocus()); fireEvent.click(screen.getByRole("button", { name: "Keep account" })); await waitFor(() => expect(screen.getByRole("button", { name: "Delete account…" })).toHaveFocus()); @@ -237,10 +254,29 @@ describe("AccountSection", () => { it("explains a locked keychain instead of offering sign-in", async () => { start({ ...signedOut, keychainUnavailable: true }); + expect(await screen.findByRole("heading", { name: "Can't read your saved sign-in" })).toBeInTheDocument(); expect(await screen.findByText(/keychain is locked or unavailable/)).toBeInTheDocument(); expect(screen.queryByLabelText("Email")).not.toBeInTheDocument(); }); + it("shows a sign-out failure beside Sign out", async () => { + start(signedIn); + vi.spyOn(api, "signOut").mockRejectedValue("Could not reach Switchify."); + fireEvent.click(await screen.findByRole("button", { name: "Sign out" })); + const alert = await screen.findByRole("alert"); + expect(alert).toHaveTextContent("Could not reach Switchify."); + expect(alert.closest("section")).toContainElement(screen.getByRole("button", { name: "Sign out" })); + }); + + it("shows a delete failure inside the confirmation", async () => { + start(signedIn); + vi.spyOn(api, "deleteAccount").mockRejectedValue("Could not delete the account. Try again."); + fireEvent.click(await screen.findByRole("button", { name: "Delete account…" })); + fireEvent.click(screen.getByRole("button", { name: "Delete permanently" })); + const confirm = screen.getByRole("group", { name: "Confirm account deletion" }); + expect(await within(confirm).findByRole("alert")).toHaveTextContent("Could not delete the account."); + }); + it("follows account changes from the backend", async () => { let receive: (view: AccountView) => void = () => undefined; vi.spyOn(api, "account").mockResolvedValue(signedOut); diff --git a/src/settings/AccountSection.tsx b/src/settings/AccountSection.tsx index 648e058b..eb411262 100644 --- a/src/settings/AccountSection.tsx +++ b/src/settings/AccountSection.tsx @@ -1,10 +1,11 @@ -import { useEffect, useRef, useState, type FormEvent } from "react"; +import { useEffect, useRef, useState, type FormEvent, type ReactNode } from "react"; +import { CircleUserRound, ListChecks, LockKeyhole, MailCheck, RefreshCw, Trash2 } from "lucide-react"; import { api } from "../api"; -import { Button, Input } from "../ui/controls"; +import { Button, Input, StatusIcon } from "../ui/controls"; import type { AccountView, SyncView } from "../types"; -import { SettingGroup } from "./controls"; -const description = "The same account as Switchify on Android. We email you a code to sign in; there is no password."; +const sameAccount = "It's the same account as Switchify on Android."; +const whatSyncs = "Pointer, repeat, dwell and cursor settings; your switches and their keys; scanning and keyboard layout; your switch profiles; and remote switches. Pairings, diagnostics sharing, setup progress and starting with your computer stay on each computer."; type FocusTarget = "email" | "code" | "signOut" | "deleteAccount" | "keepAccount"; type Screen = "loading" | "unavailable" | "keychain" | "signedIn" | "pending" | "signedOut"; @@ -17,11 +18,30 @@ function screenOf(account: AccountView | null): Screen { return account.pendingEmail ? "pending" : "signedOut"; } +// The status band and grouped rows used on Home, so the page reads like the +// rest of the app. +function Band({ tone, ok, icon, title, children, action }: { tone: "ready" | "attention" | "neutral"; ok?: boolean; icon: ReactNode; title: ReactNode; children: ReactNode; action?: ReactNode }) { + return
+ {icon} +

{title}

{children}
+ {action} +
; +} + +function WhatSyncs() { + return
+

What syncs

{whatSyncs}

+
; +} + export function AccountSection() { const [account, setAccount] = useState(null); const [email, setEmail] = useState(""); const [code, setCode] = useState(""); - const [error, setError] = useState<{ text: string; id: number } | null>(null); + // Where an error is shown: beside the control that failed, so it is seen + // by whoever pressed it, not off screen at the bottom of the page. + type ErrorPlace = "form" | "signOut" | "delete"; + const [error, setError] = useState<{ text: string; id: number; place: ErrorPlace } | null>(null); const [busy, setBusy] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false); // Set only by the user's own actions, so opening the page never moves focus. @@ -79,7 +99,7 @@ export function AccountSection() { // Controls stay enabled while busy so focus is never dropped to the page; // repeated presses are ignored instead. - const run = async (action: () => Promise, next: (view: AccountView) => FocusTarget | null) => { + const run = async (action: () => Promise, next: (view: AccountView) => FocusTarget | null, place: ErrorPlace = "form") => { if (busy) return false; setBusy(true); setError(null); @@ -89,7 +109,7 @@ export function AccountSection() { setFocusNext(next(view)); return true; } catch (failure) { - setError({ text: failure instanceof Error ? failure.message : String(failure), id: Date.now() }); + setError({ text: failure instanceof Error ? failure.message : String(failure), id: Date.now(), place }); return false; } finally { setBusy(false); @@ -108,69 +128,92 @@ export function AccountSection() { void run(() => api.verifySignInCode(code), () => "signOut").then((ok) => { if (ok) setCode(""); }); }; - if (!account) return

Loading…

; + if (!account) return

Loading…

; // Keyed by occurrence so the same message is announced again. - const errorText = error && {error.text}; + // Off the signed-in screen (e.g. a session rejected mid-delete) the + // failed control is gone, so the error joins the form that replaced it. + const errorAt = (place: ErrorPlace) => error && (account.signedIn ? error.place : "form") === place && {error.text}; + const errorText = errorAt("form"); const errorProps = { "aria-invalid": Boolean(error), "aria-describedby": error ? "account-error" : undefined }; if (!account.available) { - return

Accounts are unavailable in this build.

; + return } title="Accounts are unavailable in this build">

This build was made without account support.

; } if (account.keychainUnavailable) { - return

Your saved sign-in can't be read because this computer's keychain is locked or unavailable. Unlock it, then open this page again.

; + return } title="Can't read your saved sign-in"> +

Your saved sign-in can't be read because this computer's keychain is locked or unavailable. Unlock it, then open this page again.

+
; } if (account.signedIn) { - return -

Signed in as {account.email}.

- {!confirmDelete && } - {confirmDelete - ?
-

Deleting your account removes it and its saved settings from every device, including Switchify on Android. This cannot be undone.

+ return
+ } title={{account.email}} + action={}> +

Signed in. {sameAccount}

+ {errorAt("signOut")} +
+
+ +
+ {confirmDelete + ?
+ +

Delete your account?

This removes your account and its saved settings from every device, including Switchify on Android. It cannot be undone.

{errorAt("delete")}
+
+ + +
+
+ :
+ +

Delete account

Remove your account and its saved settings from every device.

+ +
} +
+
; + } + + if (account.pendingEmail) { + return
+ } title="Check your email"> +

We sent a code to {account.pendingEmail}. It can take a minute to arrive.

+
+
+
+ + {errorText}
- - + +
-
- :
- - -
} - {errorText} - ; + +
+
; } - if (account.pendingEmail) { - return -
-

We sent a code to {account.pendingEmail}. It can take a minute to arrive.

-