Sign in on a phone with no console work, in the browser's own session

Mobile OAuth reused the desktop client all along; what stopped it was the
browser. Sending the user out to Safari or Chrome backgrounds the app, iOS
suspends it, and the redirect carrying the code arrives at a socket nobody
is accepting on. The consent page now opens in front of the app instead, in
SFSafariViewController or a Chrome Custom Tab, so the loopback listener
stays live and the existing `installed` client is enough. Verified against
Google's real consent screen on a simulator and an emulator.

A per-platform client is still supported and is now an upgrade rather than a
prerequisite. On iOS it buys ASWebAuthenticationSession, which shares
Safari's session so nobody is asked to sign in to Google twice. Android
needs nothing: Custom Tabs share Chrome's cookies, measured rather than
assumed. iOS session sharing could not be confirmed on the simulator and
wants a real device.

Never an app-owned WebView: Google blocks it, and rightly, since a webview
the app controls can read the password typed into it.

Cancelling is no longer reported as a failure. AuthEvent carries a
`cancelled` flag, set by comparing against the constant every back-out path
returns, and Google's `access_denied` on desktop counts too.

Five frontend bugs found by driving the real UI, not by reading it: the
details card slid under the tab bar leaving its buttons unhittable; the
ghost click after a touch pressed a button in the card that tap had just
opened, opening the editor by itself; the swipe that pages the day was dead
over every read-only block; 84px of macOS traffic-light lane was reserved on
platforms with no traffic lights; and the desktop header ignored the top
safe area on an iPad. A first launch now says what to do next rather than
showing an empty grid, and accounts are named as Google accounts throughout.
This commit is contained in:
pj committed 2026-08-12 18:32:45 +05:30
1 parent 661100dfdc
commit d4c3a304b5
31 files changed
+1319 -114

No files matched your search

+19 -7
View File
@@ -10,6 +10,12 @@ type Phase = "idle" | "connecting" | "working" | "error";
interface AccountsState {
accounts: Account[];
calendars: Calendar[];
/**
* True once a list has actually come back. An empty `accounts` before that is a page that has not
* asked yet, and telling the two apart is the difference between "connect one" and a first frame
* of it on every launch.
*/
loaded: boolean;
phase: Phase;
error: string | null;
authUrl: string | null;
@@ -17,7 +23,7 @@ interface AccountsState {
refresh: () => Promise<void>;
connect: () => Promise<boolean>;
cancelConnect: () => void;
handleAuthEvent: (ok: boolean, error: string | null) => Promise<void>;
handleAuthEvent: (ok: boolean, error: string | null, cancelled?: boolean) => Promise<void>;
openAuthUrl: () => void;
copyAuthUrl: () => Promise<void>;
disconnect: (accountId: string) => Promise<void>;
@@ -27,6 +33,7 @@ interface AccountsState {
export const useAccounts = create<AccountsState>((set, get) => ({
accounts: [],
calendars: [],
loaded: false,
phase: "idle",
error: null,
authUrl: null,
@@ -35,7 +42,7 @@ export const useAccounts = create<AccountsState>((set, get) => ({
if (!live()) return;
try {
const [accounts, calendars] = await Promise.all([accountsList(), calendarsList()]);
set({ accounts, calendars });
set({ accounts, calendars, loaded: true });
} catch (e) {
set({ error: String(e) });
}
@@ -50,7 +57,7 @@ export const useAccounts = create<AccountsState>((set, get) => ({
.then((url) => set({ authUrl: url }))
.catch((e) => {
set({ phase: "error", error: String(e), resolveConnect: null });
notify(`Could not connect: ${e}`);
notify(`Could not connect your Google account: ${e}`);
resolve(false);
});
}),
@@ -59,17 +66,22 @@ export const useAccounts = create<AccountsState>((set, get) => ({
set({ phase: "idle", authUrl: null, resolveConnect: null });
resolve?.(false);
},
handleAuthEvent: async (ok, error) => {
handleAuthEvent: async (ok, error, cancelled = false) => {
if (get().phase !== "connecting") return;
const resolve = get().resolveConnect;
if (ok) {
await get().refresh();
set({ phase: "idle", authUrl: null, error: null, resolveConnect: null });
notify("Connected to Google Calendar");
} else if (cancelled) {
// Closing the consent browser is an answer, not a fault. Back to idle with nothing said:
// the user already knows what they did, and a red panel telling them about it reads as
// though shutting the sheet broke something.
set({ phase: "idle", authUrl: null, error: null, resolveConnect: null });
} else {
const message = error ?? "authorization failed";
set({ phase: "error", authUrl: null, error: message, resolveConnect: null });
notify(`Could not connect: ${message}`);
notify(`Could not connect your Google account: ${message}`);
}
resolve?.(ok);
},
@@ -93,10 +105,10 @@ export const useAccounts = create<AccountsState>((set, get) => ({
await accountDisconnect(accountId);
await get().refresh();
set({ phase: "idle" });
notify("Disconnected");
notify("Google account disconnected");
} catch (e) {
set({ phase: "error", error: String(e) });
notify(`Could not disconnect: ${e}`);
notify(`Could not disconnect that Google account: ${e}`);
}
},
setSelected: async (calendarId, selected) => {