From 54248539a69da383663635bff9509e8ac962cdee Mon Sep 17 00:00:00 2001 From: John Jeong Date: Mon, 31 Aug 2026 13:29:35 +0900 Subject: [PATCH 1/2] Remember the last web sign-in method Show a browser-scoped Last used badge after successful authentication. --- apps/web/src/functions/auth-last-used.ts | 27 ++++ apps/web/src/functions/auth.ts | 59 +++++-- .../src/lib/auth-last-sign-in-method.test.ts | 35 +++++ apps/web/src/lib/auth-last-sign-in-method.ts | 35 +++++ apps/web/src/routes/_view/callback/auth.tsx | 2 +- apps/web/src/routes/auth.tsx | 148 +++++++++++++----- 6 files changed, 252 insertions(+), 54 deletions(-) create mode 100644 apps/web/src/functions/auth-last-used.ts create mode 100644 apps/web/src/lib/auth-last-sign-in-method.test.ts create mode 100644 apps/web/src/lib/auth-last-sign-in-method.ts diff --git a/apps/web/src/functions/auth-last-used.ts b/apps/web/src/functions/auth-last-used.ts new file mode 100644 index 00000000000..1dc52f40050 --- /dev/null +++ b/apps/web/src/functions/auth-last-used.ts @@ -0,0 +1,27 @@ +import { createServerFn, createServerOnlyFn } from "@tanstack/react-start"; +import { getCookie, setCookie } from "@tanstack/react-start/server"; + +import { getRequestAppOrigin } from "@/functions/app-origin"; +import { + parseAuthSignInMethod, + type AuthSignInMethod, +} from "@/lib/auth-last-sign-in-method"; + +const LAST_SIGN_IN_METHOD_COOKIE = "anarlog-last-sign-in-method"; +const LAST_SIGN_IN_METHOD_MAX_AGE_SECONDS = 365 * 24 * 60 * 60; + +export const fetchLastSignInMethod = createServerFn({ method: "GET" }).handler( + () => parseAuthSignInMethod(getCookie(LAST_SIGN_IN_METHOD_COOKIE)), +); + +export const rememberLastSignInMethod = createServerOnlyFn( + (method: AuthSignInMethod) => { + setCookie(LAST_SIGN_IN_METHOD_COOKIE, method, { + httpOnly: true, + maxAge: LAST_SIGN_IN_METHOD_MAX_AGE_SECONDS, + path: "/", + sameSite: "lax", + secure: getRequestAppOrigin().startsWith("https://"), + }); + }, +); diff --git a/apps/web/src/functions/auth.ts b/apps/web/src/functions/auth.ts index b3a97bfc3d2..c8ab50c7d56 100644 --- a/apps/web/src/functions/auth.ts +++ b/apps/web/src/functions/auth.ts @@ -4,6 +4,7 @@ import { z } from "zod"; import { isAdminEmail } from "@/functions/admin"; import { getRequestAppOrigin } from "@/functions/app-origin"; +import { rememberLastSignInMethod } from "@/functions/auth-last-used"; import { mintDesktopSessionForAuthenticatedUser } from "@/functions/auth-session"; import { desktopSchemeSchema } from "@/functions/desktop-flow"; import { ensureNewAccountTrial } from "@/functions/new-account-trial"; @@ -25,6 +26,10 @@ import { getSupabaseDesktopFlowClient, getSupabaseServerClient, } from "@/functions/supabase"; +import { + resolveSessionSignInMethod, + shouldRememberOtpSignIn, +} from "@/lib/auth-last-sign-in-method"; import { sanitizeInternalReturnPath } from "@/lib/auth-redirect"; import { captureOperationalError } from "@/lib/error-reporting"; import { @@ -44,6 +49,16 @@ type FlowTokenResult = | { ok: true; access_token: string; refresh_token: string } | { ok: false; error: string }; +function rememberSessionSignInMethod(session: Session) { + const method = resolveSessionSignInMethod({ + provider: session.user.app_metadata.provider, + usesSso: sessionUsesSso(session), + }); + if (method) { + rememberLastSignInMethod(method); + } +} + async function rejectIfEmailRequiresSso( supabase: SupabaseClient, email: string, @@ -402,6 +417,16 @@ export const exchangeOAuthCode = createServerFn({ method: "POST" }) z.object({ code: z.string(), flow: z.enum(["desktop", "web"]).default("web"), + type: z + .enum([ + "email", + "recovery", + "magiclink", + "signup", + "invite", + "email_change", + ]) + .optional(), }), ) .handler(async ({ data }) => { @@ -432,9 +457,13 @@ export const exchangeOAuthCode = createServerFn({ method: "POST" }) session: trial.session, }); const response = toSuccessTokenResponse(tokens, authData.session.user.id); - return response.success - ? { ...response, newAccount: trial.needsTrialCheckout } - : response; + if (!response.success) { + return response; + } + if (!data.type || shouldRememberOtpSignIn(data.type)) { + rememberSessionSignInMethod(authData.session); + } + return { ...response, newAccount: trial.needsTrialCheckout }; }); export const doPasswordSignUp = createServerFn({ method: "POST" }) @@ -485,9 +514,11 @@ export const doPasswordSignUp = createServerFn({ method: "POST" }) tokens, authData.session.user.id, ); - return response.success - ? { ...response, newAccount: trial.needsTrialCheckout } - : response; + if (!response.success) { + return response; + } + rememberSessionSignInMethod(authData.session); + return { ...response, newAccount: trial.needsTrialCheckout }; } return { @@ -529,7 +560,11 @@ export const doPasswordSignIn = createServerFn({ method: "POST" }) session: authData.session, email: data.email, }); - return toMutationTokenResponse(tokens, authData.session.user.id); + const response = toMutationTokenResponse(tokens, authData.session.user.id); + if (response.success) { + rememberSessionSignInMethod(authData.session); + } + return response; }); export const exchangeOtpToken = createServerFn({ method: "POST" }) @@ -588,9 +623,13 @@ export const exchangeOtpToken = createServerFn({ method: "POST" }) session: trial.session, }); const response = toSuccessTokenResponse(tokens, authData.session.user.id); - return response.success - ? { ...response, newAccount: trial.needsTrialCheckout } - : response; + if (!response.success) { + return response; + } + if (shouldRememberOtpSignIn(data.type)) { + rememberSessionSignInMethod(authData.session); + } + return { ...response, newAccount: trial.needsTrialCheckout }; }); export const createDesktopSession = createServerFn({ method: "POST" }).handler( diff --git a/apps/web/src/lib/auth-last-sign-in-method.test.ts b/apps/web/src/lib/auth-last-sign-in-method.test.ts new file mode 100644 index 00000000000..1480e972036 --- /dev/null +++ b/apps/web/src/lib/auth-last-sign-in-method.test.ts @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + parseAuthSignInMethod, + resolveSessionSignInMethod, + shouldRememberOtpSignIn, +} from "./auth-last-sign-in-method.ts"; + +test("accepts only supported sign-in methods", () => { + for (const method of ["apple", "google", "azure", "github", "email", "sso"]) { + assert.equal(parseAuthSignInMethod(method), method); + } + + assert.equal(parseAuthSignInMethod("password"), null); + assert.equal(parseAuthSignInMethod(null), null); +}); + +test("prefers an authenticated SSO signal over the session provider", () => { + assert.equal( + resolveSessionSignInMethod({ provider: "email", usesSso: true }), + "sso", + ); + assert.equal( + resolveSessionSignInMethod({ provider: "google", usesSso: false }), + "google", + ); +}); + +test("does not replace the last sign-in method during account maintenance", () => { + assert.equal(shouldRememberOtpSignIn("magiclink"), true); + assert.equal(shouldRememberOtpSignIn("signup"), true); + assert.equal(shouldRememberOtpSignIn("recovery"), false); + assert.equal(shouldRememberOtpSignIn("email_change"), false); +}); diff --git a/apps/web/src/lib/auth-last-sign-in-method.ts b/apps/web/src/lib/auth-last-sign-in-method.ts new file mode 100644 index 00000000000..3dcca87b919 --- /dev/null +++ b/apps/web/src/lib/auth-last-sign-in-method.ts @@ -0,0 +1,35 @@ +export type AuthSignInMethod = + | "apple" + | "google" + | "azure" + | "github" + | "email" + | "sso"; + +export function parseAuthSignInMethod(value: unknown): AuthSignInMethod | null { + switch (value) { + case "apple": + case "google": + case "azure": + case "github": + case "email": + case "sso": + return value; + default: + return null; + } +} + +export function resolveSessionSignInMethod({ + provider, + usesSso, +}: { + provider: unknown; + usesSso: boolean; +}): AuthSignInMethod | null { + return usesSso ? "sso" : parseAuthSignInMethod(provider); +} + +export function shouldRememberOtpSignIn(type: string) { + return type !== "recovery" && type !== "email_change"; +} diff --git a/apps/web/src/routes/_view/callback/auth.tsx b/apps/web/src/routes/_view/callback/auth.tsx index 22bf534ccd8..dd3508bc712 100644 --- a/apps/web/src/routes/_view/callback/auth.tsx +++ b/apps/web/src/routes/_view/callback/auth.tsx @@ -69,7 +69,7 @@ export const Route = createFileRoute("/_view/callback/auth")({ if (search.code) { const result = await exchangeOAuthCode({ - data: { code: search.code, flow: search.flow }, + data: { code: search.code, flow: search.flow, type: search.type }, }); if (!result.success) { diff --git a/apps/web/src/routes/auth.tsx b/apps/web/src/routes/auth.tsx index 44b6809f30d..4b2ef9b5328 100644 --- a/apps/web/src/routes/auth.tsx +++ b/apps/web/src/routes/auth.tsx @@ -22,6 +22,7 @@ import { doSsoAuth, fetchUser, } from "@/functions/auth"; +import { fetchLastSignInMethod } from "@/functions/auth-last-used"; import { DEFAULT_DESKTOP_SCHEME, type DesktopScheme, @@ -29,6 +30,7 @@ import { } from "@/functions/desktop-flow"; import { useMountEffect } from "@/hooks/useMountEffect"; import { toAuthFlowSearch } from "@/lib/auth-flow-context"; +import type { AuthSignInMethod } from "@/lib/auth-last-sign-in-method"; import { buildPostAuthDestination, sanitizeInternalReturnPath, @@ -54,7 +56,10 @@ export const Route = createFileRoute("/auth")({ meta: [{ name: "robots", content: "noindex, nofollow" }], }), beforeLoad: async ({ search }) => { - const user = await fetchUser(); + const [user, lastSignInMethod] = await Promise.all([ + fetchUser(), + fetchLastSignInMethod(), + ]); if (user) { const shouldReauthWithProvider = @@ -83,7 +88,7 @@ export const Route = createFileRoute("/auth")({ } } - return { existingUser: user }; + return { existingUser: user, lastSignInMethod }; }, }); @@ -112,7 +117,7 @@ function Component() { view: initialView, rra, } = Route.useSearch(); - const { existingUser } = Route.useRouteContext(); + const { existingUser, lastSignInMethod } = Route.useRouteContext(); const [view, setView] = useState(initialView ?? "main"); const autoStartOAuth = flow === "desktop" && provider !== undefined; @@ -125,6 +130,7 @@ function Component() { ); @@ -146,6 +152,7 @@ function Component() { provider={provider} rra={rra} autoStart + isLastUsed={lastSignInMethod === provider} /> @@ -170,6 +177,7 @@ function Component() { redirect={redirect} provider="apple" autoStart={autoStartOAuth} + isLastUsed={lastSignInMethod === "apple"} /> )} {showGoogle && ( @@ -179,6 +187,7 @@ function Component() { redirect={redirect} provider="google" autoStart={autoStartOAuth} + isLastUsed={lastSignInMethod === "google"} /> )} {showMicrosoft && ( @@ -188,6 +197,7 @@ function Component() { redirect={redirect} provider="azure" autoStart={autoStartOAuth} + isLastUsed={lastSignInMethod === "azure"} /> )} {showGithub && ( @@ -198,33 +208,40 @@ function Component() { provider="github" rra={rra} autoStart={autoStartOAuth} + isLastUsed={lastSignInMethod === "github"} /> )} {showEmail && ( - + + + )} {showEmail && ( - + + + )} @@ -253,9 +270,11 @@ function Component() { function DesktopReauthView({ email, scheme, + lastSignInMethod, }: { email: string; scheme: DesktopScheme; + lastSignInMethod: AuthSignInMethod | null; }) { const retryMutation = useMutation({ mutationFn: () => { @@ -311,10 +330,30 @@ function DesktopReauthView({

- - - - + + + +
@@ -887,6 +926,25 @@ function AuthProviderContent({ ); } +function AuthMethodButton({ + isLastUsed, + children, +}: { + isLastUsed: boolean; + children: ReactNode; +}) { + return ( +
+ {children} + {isLastUsed && ( + + Last used + + )} +
+ ); +} + function OAuthButton({ flow, scheme, @@ -894,6 +952,7 @@ function OAuthButton({ provider, rra, autoStart = false, + isLastUsed = false, }: { flow: "desktop" | "web"; scheme?: DesktopScheme; @@ -901,6 +960,7 @@ function OAuthButton({ provider: OAuthProvider; rra?: boolean; autoStart?: boolean; + isLastUsed?: boolean; }) { const oauthMutation = useMutation({ mutationFn: (provider: OAuthProvider) => { @@ -951,22 +1011,24 @@ function OAuthButton({ }); return ( - + + } + > + Sign in with {getOAuthProviderName(provider)} + + + ); } From 6b0c4621145a8f9c1a9b98cb8a164be376165eba Mon Sep 17 00:00:00 2001 From: John Jeong Date: Mon, 31 Aug 2026 14:36:34 +0900 Subject: [PATCH 2/2] Record the completed sign-in method Carry the attempted provider through auth callbacks so linked accounts show the correct Last used badge. --- apps/web/src/functions/auth.ts | 45 ++++++++++++------- .../src/lib/auth-last-sign-in-method.test.ts | 27 +++++++++-- apps/web/src/lib/auth-last-sign-in-method.ts | 23 ++++++---- apps/web/src/routes/_view/callback/auth.tsx | 9 +++- 4 files changed, 74 insertions(+), 30 deletions(-) diff --git a/apps/web/src/functions/auth.ts b/apps/web/src/functions/auth.ts index c8ab50c7d56..fcab1557cfa 100644 --- a/apps/web/src/functions/auth.ts +++ b/apps/web/src/functions/auth.ts @@ -27,8 +27,10 @@ import { getSupabaseServerClient, } from "@/functions/supabase"; import { - resolveSessionSignInMethod, + authSignInMethods, + resolveSignInMethod, shouldRememberOtpSignIn, + type AuthSignInMethod, } from "@/lib/auth-last-sign-in-method"; import { sanitizeInternalReturnPath } from "@/lib/auth-redirect"; import { captureOperationalError } from "@/lib/error-reporting"; @@ -49,8 +51,14 @@ type FlowTokenResult = | { ok: true; access_token: string; refresh_token: string } | { ok: false; error: string }; -function rememberSessionSignInMethod(session: Session) { - const method = resolveSessionSignInMethod({ +const authSignInMethodSchema = z.enum(authSignInMethods); + +function rememberSessionSignInMethod( + session: Session, + attemptedMethod?: AuthSignInMethod, +) { + const method = resolveSignInMethod({ + attemptedMethod, provider: session.user.app_metadata.provider, usesSso: sessionUsesSso(session), }); @@ -130,16 +138,20 @@ async function prepareNewAccountTrial( return { needsTrialCheckout: false, session: data.session }; } -function buildAuthCallbackParams(data: { - flow: Flow; - scheme?: string; - redirect?: string; -}) { +function buildAuthCallbackParams( + data: { + flow: Flow; + scheme?: string; + redirect?: string; + }, + method?: AuthSignInMethod, +) { const params = new URLSearchParams({ flow: data.flow }); if (data.scheme) params.set("scheme", data.scheme); if (data.redirect) { params.set("redirect", sanitizeInternalReturnPath(data.redirect)); } + if (method) params.set("method", method); return params; } @@ -291,7 +303,7 @@ export const doAuth = createServerFn({ method: "POST" }) ) .handler(async ({ data }) => { const supabase = getSupabaseServerClient(); - const params = buildAuthCallbackParams(data); + const params = buildAuthCallbackParams(data, data.provider); const { data: authData, error } = await supabase.auth.signInWithOAuth({ provider: data.provider, @@ -326,7 +338,7 @@ export const doSsoAuth = createServerFn({ method: "POST" }) } const supabase = getSupabaseServerClient(); - const params = buildAuthCallbackParams(data); + const params = buildAuthCallbackParams(data, "sso"); const { data: authData, error } = await supabase.auth.signInWithSSO({ domain, @@ -354,7 +366,7 @@ export const doMagicLinkAuth = createServerFn({ method: "POST" }) if (blocked) { return blocked; } - const params = buildAuthCallbackParams(data); + const params = buildAuthCallbackParams(data, "email"); const { error } = await supabase.auth.signInWithOtp({ email: data.email, @@ -427,6 +439,7 @@ export const exchangeOAuthCode = createServerFn({ method: "POST" }) "email_change", ]) .optional(), + method: authSignInMethodSchema.optional(), }), ) .handler(async ({ data }) => { @@ -461,7 +474,7 @@ export const exchangeOAuthCode = createServerFn({ method: "POST" }) return response; } if (!data.type || shouldRememberOtpSignIn(data.type)) { - rememberSessionSignInMethod(authData.session); + rememberSessionSignInMethod(authData.session, data.method); } return { ...response, newAccount: trial.needsTrialCheckout }; }); @@ -480,7 +493,7 @@ export const doPasswordSignUp = createServerFn({ method: "POST" }) if (blocked) { return blocked; } - const params = buildAuthCallbackParams(data); + const params = buildAuthCallbackParams(data, "email"); const { data: authData, error } = await supabase.auth.signUp({ email: data.email, @@ -517,7 +530,7 @@ export const doPasswordSignUp = createServerFn({ method: "POST" }) if (!response.success) { return response; } - rememberSessionSignInMethod(authData.session); + rememberSessionSignInMethod(authData.session, "email"); return { ...response, newAccount: trial.needsTrialCheckout }; } @@ -562,7 +575,7 @@ export const doPasswordSignIn = createServerFn({ method: "POST" }) }); const response = toMutationTokenResponse(tokens, authData.session.user.id); if (response.success) { - rememberSessionSignInMethod(authData.session); + rememberSessionSignInMethod(authData.session, "email"); } return response; }); @@ -627,7 +640,7 @@ export const exchangeOtpToken = createServerFn({ method: "POST" }) return response; } if (shouldRememberOtpSignIn(data.type)) { - rememberSessionSignInMethod(authData.session); + rememberSessionSignInMethod(authData.session, "email"); } return { ...response, newAccount: trial.needsTrialCheckout }; }); diff --git a/apps/web/src/lib/auth-last-sign-in-method.test.ts b/apps/web/src/lib/auth-last-sign-in-method.test.ts index 1480e972036..819be6f647b 100644 --- a/apps/web/src/lib/auth-last-sign-in-method.test.ts +++ b/apps/web/src/lib/auth-last-sign-in-method.test.ts @@ -3,7 +3,7 @@ import test from "node:test"; import { parseAuthSignInMethod, - resolveSessionSignInMethod, + resolveSignInMethod, shouldRememberOtpSignIn, } from "./auth-last-sign-in-method.ts"; @@ -16,13 +16,32 @@ test("accepts only supported sign-in methods", () => { assert.equal(parseAuthSignInMethod(null), null); }); -test("prefers an authenticated SSO signal over the session provider", () => { +test("prefers the completed sign-in method over the account's original provider", () => { assert.equal( - resolveSessionSignInMethod({ provider: "email", usesSso: true }), + resolveSignInMethod({ + attemptedMethod: "email", + provider: "google", + usesSso: false, + }), + "email", + ); + assert.equal( + resolveSignInMethod({ + attemptedMethod: "google", + provider: "email", + usesSso: false, + }), + "google", + ); +}); + +test("falls back to authenticated session metadata for legacy callbacks", () => { + assert.equal( + resolveSignInMethod({ provider: "email", usesSso: true }), "sso", ); assert.equal( - resolveSessionSignInMethod({ provider: "google", usesSso: false }), + resolveSignInMethod({ provider: "google", usesSso: false }), "google", ); }); diff --git a/apps/web/src/lib/auth-last-sign-in-method.ts b/apps/web/src/lib/auth-last-sign-in-method.ts index 3dcca87b919..24ff4e72cda 100644 --- a/apps/web/src/lib/auth-last-sign-in-method.ts +++ b/apps/web/src/lib/auth-last-sign-in-method.ts @@ -1,10 +1,13 @@ -export type AuthSignInMethod = - | "apple" - | "google" - | "azure" - | "github" - | "email" - | "sso"; +export const authSignInMethods = [ + "apple", + "google", + "azure", + "github", + "email", + "sso", +] as const; + +export type AuthSignInMethod = (typeof authSignInMethods)[number]; export function parseAuthSignInMethod(value: unknown): AuthSignInMethod | null { switch (value) { @@ -20,14 +23,16 @@ export function parseAuthSignInMethod(value: unknown): AuthSignInMethod | null { } } -export function resolveSessionSignInMethod({ +export function resolveSignInMethod({ + attemptedMethod, provider, usesSso, }: { + attemptedMethod?: AuthSignInMethod; provider: unknown; usesSso: boolean; }): AuthSignInMethod | null { - return usesSso ? "sso" : parseAuthSignInMethod(provider); + return attemptedMethod ?? (usesSso ? "sso" : parseAuthSignInMethod(provider)); } export function shouldRememberOtpSignIn(type: string) { diff --git a/apps/web/src/routes/_view/callback/auth.tsx b/apps/web/src/routes/_view/callback/auth.tsx index dd3508bc712..677bd0af71c 100644 --- a/apps/web/src/routes/_view/callback/auth.tsx +++ b/apps/web/src/routes/_view/callback/auth.tsx @@ -19,6 +19,7 @@ import { resolveAuthFlowContext, toAuthFlowSearch, } from "@/lib/auth-flow-context"; +import { authSignInMethods } from "@/lib/auth-last-sign-in-method"; import { buildPostAuthDestination, sanitizeInternalReturnPath, @@ -46,6 +47,7 @@ const validateSearch = z.object({ "email_change", ]) .optional(), + method: z.enum(authSignInMethods).optional(), flow: z.enum(["desktop", "web"]).default("web"), scheme: desktopSchemeSchema.catch(DEFAULT_DESKTOP_SCHEME), redirect: z.string().optional(), @@ -69,7 +71,12 @@ export const Route = createFileRoute("/_view/callback/auth")({ if (search.code) { const result = await exchangeOAuthCode({ - data: { code: search.code, flow: search.flow, type: search.type }, + data: { + code: search.code, + flow: search.flow, + type: search.type, + method: search.method, + }, }); if (!result.success) {