refactor(auth): one Google connect route for a sign-in and for YouTube access
/auth/link and /auth/upgrade become /auth/google/connect?access=identity|read|write: one handler, one table of what each access asks Google for, the same callback markers. An unknown access is refused (422) instead of quietly meaning read, and an instance without Google answers ?link=error for a grant too, not a silent return to the app. The frontend builds the URL in lib/googleConnect, and a contract test holds it to the backend route and its access values.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { withActiveAccount } from "../lib/activeAccount";
|
||||
import { connectGoogle } from "../lib/googleConnect";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { formatDate, formatDateTime, formatUnit } from "../lib/format";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -359,7 +359,7 @@ function AccessRow({
|
||||
|
||||
// Sign-in methods: link a Google account to a password account (or vice-versa set a password),
|
||||
// so either method can reach the same account. Google connect is a full-page OAuth round-trip
|
||||
// (auth.py attaches the identity to the current session via /auth/link); the password form posts
|
||||
// (auth.py attaches the identity to the signed-in account via /auth/google/connect); the password form posts
|
||||
// directly with inline errors.
|
||||
export function SignInMethods({ me }: { me: Me }) {
|
||||
const { t } = useTranslation();
|
||||
@@ -451,13 +451,7 @@ export function SignInMethods({ me }: { me: Me }) {
|
||||
{me.has_google ? (
|
||||
<Badge tone="accent">{t("settings.account.googleLink.connected")}</Badge>
|
||||
) : (
|
||||
<Button
|
||||
variant="glass"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
window.location.href = withActiveAccount("/auth/link");
|
||||
}}
|
||||
>
|
||||
<Button variant="glass" size="sm" onClick={() => connectGoogle("identity")}>
|
||||
{t("settings.account.googleLink.connect")}
|
||||
</Button>
|
||||
)}
|
||||
@@ -877,18 +871,14 @@ function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
|
||||
granted={me.can_read}
|
||||
grantedHint={t("settings.account.readGrantedHint")}
|
||||
enableHint={t("settings.account.readEnableHint")}
|
||||
onEnable={() => {
|
||||
window.location.href = withActiveAccount("/auth/upgrade?access=read");
|
||||
}}
|
||||
onEnable={() => connectGoogle("read")}
|
||||
/>
|
||||
<AccessRow
|
||||
title={t("settings.account.writeTitle")}
|
||||
granted={me.can_write}
|
||||
grantedHint={t("settings.account.writeGrantedHint")}
|
||||
enableHint={t("settings.account.writeEnableHint")}
|
||||
onEnable={() => {
|
||||
window.location.href = withActiveAccount("/auth/upgrade?access=write");
|
||||
}}
|
||||
onEnable={() => connectGoogle("write")}
|
||||
/>
|
||||
</div>
|
||||
<Button variant="link" className="mt-2 text-sm" onClick={onOpenWizard}>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { GOOGLE_ACCESS, googleConnectUrl } from "./googleConnect";
|
||||
|
||||
// The frontend builds the Google connect URL by hand, so nothing but this test notices when the
|
||||
// backend route moves or its `access` values change: the click would land on a 404 or a 422 page.
|
||||
const AUTH = readFileSync(new URL("../../../backend/app/routes/auth.py", import.meta.url), "utf-8");
|
||||
|
||||
describe("the Google connect URL", () => {
|
||||
it("names the backend route", () => {
|
||||
const prefix = /APIRouter\(prefix="([^"]+)"/.exec(AUTH)?.[1];
|
||||
const path = /@router\.get\("([^"]+)"\)\nasync def google_connect\(/.exec(AUTH)?.[1];
|
||||
expect(googleConnectUrl("read").split("?")[0]).toBe(`${prefix}${path}`);
|
||||
});
|
||||
|
||||
it("offers exactly the access values the backend accepts", () => {
|
||||
const literal = /access: Literal\[([^\]]+)\]/.exec(AUTH)?.[1] ?? "";
|
||||
const accepted = [...literal.matchAll(/"([^"]+)"/g)].map((m) => m[1]);
|
||||
expect(accepted).toEqual([...GOOGLE_ACCESS]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
// Every in-app Google round-trip starts at one backend route (auth.py `google_connect`): attaching a
|
||||
// Google sign-in to the account (`identity`), or granting YouTube access on top (`read`, `write`).
|
||||
// The page navigates away to Google and comes back through the shared callback, which lands on
|
||||
// `?link=…` (App.tsx) for `identity` and on the app itself for a grant.
|
||||
|
||||
export const GOOGLE_ACCESS = ["identity", "read", "write"] as const;
|
||||
export type GoogleAccess = (typeof GOOGLE_ACCESS)[number];
|
||||
|
||||
export function googleConnectUrl(access: GoogleAccess): string {
|
||||
return `/auth/google/connect?access=${access}`;
|
||||
}
|
||||
|
||||
/** Leave for Google's sign-in or consent screen. */
|
||||
export function connectGoogle(access: GoogleAccess): void {
|
||||
window.location.href = googleConnectUrl(access);
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
// suppresses the auto-popup on future logins (they
|
||||
// can still reopen it from Settings → Account).
|
||||
|
||||
import { withActiveAccount } from "./activeAccount";
|
||||
import { connectGoogle } from "./googleConnect";
|
||||
import { getAccountRaw, LS, setAccountRaw } from "./storage";
|
||||
|
||||
const ONBOARD_ACTIVE = "siftlode.onboarding.active"; // sessionStorage (not in LS, which is localStorage)
|
||||
@@ -34,7 +34,7 @@ export function shouldAutoOpenOnboarding(me: {
|
||||
/** Mark a grant as in progress, then send the user to Google's consent screen. */
|
||||
export function beginGrant(access: "read" | "write"): void {
|
||||
sessionStorage.setItem(ONBOARD_ACTIVE, "1");
|
||||
window.location.href = withActiveAccount(`/auth/upgrade?access=${access}`);
|
||||
connectGoogle(access);
|
||||
}
|
||||
|
||||
/** Leave the wizard. `dismiss` suppresses the future auto-popup (used when read is skipped). */
|
||||
|
||||
Reference in New Issue
Block a user