Skip to content

Commit f62cdfe

Browse files
ericallamsamejr
andauthored
feat(dashboard): login with google and "last used" indicator (#2746)
<img width="568" height="513" alt="CleanShot 2025-12-05 at 14 27 16" src="https://github.com/user-attachments/assets/1f44d8b9-8791-4b44-96d5-4a0960a1ab36" /> <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Adds Google OAuth login and a cookie-based “last used” indicator on the login page, with supporting backend, routes, and schema updates. > > - **Auth/Backend**: > - **Google OAuth**: Integrates `remix-auth-google` via new `addGoogleStrategy` and enables when `AUTH_GOOGLE_CLIENT_ID/SECRET` are set (`services/googleAuth.server.ts`, `services/auth.server.ts`). > - **User handling**: Implements `findOrCreateGoogleUser` with linking/upsert logic and conflict logging (`models/user.server.ts`). > - **MFA + session**: Google/GitHub/Magic callbacks now set session, handle MFA, and set a "last-auth-method" cookie (`routes/auth.google*.tsx`, `routes/auth.github.callback.tsx`, `routes/magic.tsx`, `services/lastAuthMethod.server.ts`). > - **GitHub strategy**: Safer email check (`services/gitHubAuth.server.ts`). > - **Routes/UI**: > - **Login page**: Adds "Continue with Google" button and animated "Last used" badge based on cookie; keeps GitHub/Email options (`routes/login._index/route.tsx`). > - **Redirect safety**: Sanitize redirect paths and persist redirect via cookies in auth actions (`routes/auth.github.ts`, `routes/auth.google.ts`). > - **Assets**: Adds `GoogleLogo` SVG. > - **Avatar**: Set `referrerPolicy="no-referrer"` on profile image. > - **Config/Schema**: > - **Env**: Adds `AUTH_GOOGLE_CLIENT_ID`/`AUTH_GOOGLE_CLIENT_SECRET` (`env.server.ts`). > - **DB**: Extends `AuthenticationMethod` enum with `GOOGLE` (Prisma schema + migration). > - **Dependencies**: > - Adds `remix-auth-google` in `package.json`. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 9f84f97. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: James Ritchie <[email protected]>
1 parent 66c6da7 commit f62cdfe

File tree

18 files changed

+702
-227
lines changed

18 files changed

+702
-227
lines changed
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
export function GoogleLogo({ className }: { className?: string }) {
2+
return (
3+
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
4+
<path
5+
d="M19.9075 21.0983C22.7427 18.4521 24.0028 14.0417 23.2468 9.82031H11.9688V14.4827H18.3953C18.1433 15.9949 17.2612 17.255 16.0011 18.0741L19.9075 21.0983Z"
6+
fill="#4285F4"
7+
/>
8+
<path
9+
d="M1.25781 17.3802C2.08665 19.013 3.27532 20.4362 4.73421 21.5428C6.1931 22.6493 7.88415 23.4102 9.67988 23.7681C11.4756 24.1261 13.3292 24.0717 15.1008 23.6091C16.8725 23.1465 18.516 22.2877 19.9075 21.0976L16.0011 18.0733C12.6618 20.2785 7.11734 19.4594 5.22717 14.293L1.25781 17.3802Z"
10+
fill="#34A853"
11+
/>
12+
<path
13+
d="M5.22701 14.2922C4.72297 12.717 4.72297 11.2679 5.22701 9.69275L1.25765 6.60547C-0.191479 9.50373 -0.632519 13.5991 1.25765 17.3794L5.22701 14.2922Z"
14+
fill="#FBBC02"
15+
/>
16+
<path
17+
d="M5.22717 9.69209C6.6133 5.34469 12.5358 2.82446 16.5052 6.5418L19.9705 3.13949C15.0561 -1.58594 5.47919 -1.39692 1.25781 6.60481L5.22717 9.69209Z"
18+
fill="#EA4335"
19+
/>
20+
</svg>
21+
);
22+
}

apps/webapp/app/components/UserProfilePhoto.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export function UserAvatar({
2222
className={cn("aspect-square rounded-full p-[7%]")}
2323
src={avatarUrl}
2424
alt={name ?? "User"}
25+
referrerPolicy="no-referrer"
2526
/>
2627
</div>
2728
) : (

apps/webapp/app/env.server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,8 @@ const EnvironmentSchema = z
9494
TRIGGER_TELEMETRY_DISABLED: z.string().optional(),
9595
AUTH_GITHUB_CLIENT_ID: z.string().optional(),
9696
AUTH_GITHUB_CLIENT_SECRET: z.string().optional(),
97+
AUTH_GOOGLE_CLIENT_ID: z.string().optional(),
98+
AUTH_GOOGLE_CLIENT_SECRET: z.string().optional(),
9799
EMAIL_TRANSPORT: z.enum(["resend", "smtp", "aws-ses"]).optional(),
98100
FROM_EMAIL: z.string().optional(),
99101
REPLY_TO_EMAIL: z.string().optional(),

apps/webapp/app/models/user.server.ts

Lines changed: 142 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Prisma, User } from "@trigger.dev/database";
22
import type { GitHubProfile } from "remix-auth-github";
3+
import type { GoogleProfile } from "remix-auth-google";
34
import { prisma } from "~/db.server";
45
import { env } from "~/env.server";
56
import {
@@ -8,6 +9,8 @@ import {
89
} from "~/services/dashboardPreferences.server";
910
export type { User } from "@trigger.dev/database";
1011
import { assertEmailAllowed } from "~/utils/email";
12+
import { logger } from "~/services/logger.server";
13+
1114
type FindOrCreateMagicLink = {
1215
authenticationMethod: "MAGIC_LINK";
1316
email: string;
@@ -20,7 +23,14 @@ type FindOrCreateGithub = {
2023
authenticationExtraParams: Record<string, unknown>;
2124
};
2225

23-
type FindOrCreateUser = FindOrCreateMagicLink | FindOrCreateGithub;
26+
type FindOrCreateGoogle = {
27+
authenticationMethod: "GOOGLE";
28+
email: User["email"];
29+
authenticationProfile: GoogleProfile;
30+
authenticationExtraParams: Record<string, unknown>;
31+
};
32+
33+
type FindOrCreateUser = FindOrCreateMagicLink | FindOrCreateGithub | FindOrCreateGoogle;
2434

2535
type LoggedInUser = {
2636
user: User;
@@ -35,6 +45,9 @@ export async function findOrCreateUser(input: FindOrCreateUser): Promise<LoggedI
3545
case "MAGIC_LINK": {
3646
return findOrCreateMagicLinkUser(input);
3747
}
48+
case "GOOGLE": {
49+
return findOrCreateGoogleUser(input);
50+
}
3851
}
3952
}
4053

@@ -162,6 +175,134 @@ export async function findOrCreateGithubUser({
162175
};
163176
}
164177

178+
export async function findOrCreateGoogleUser({
179+
email,
180+
authenticationProfile,
181+
authenticationExtraParams,
182+
}: FindOrCreateGoogle): Promise<LoggedInUser> {
183+
assertEmailAllowed(email);
184+
185+
const name = authenticationProfile._json.name;
186+
let avatarUrl: string | undefined = undefined;
187+
if (authenticationProfile.photos[0]) {
188+
avatarUrl = authenticationProfile.photos[0].value;
189+
}
190+
const displayName = authenticationProfile.displayName;
191+
const authProfile = authenticationProfile
192+
? (authenticationProfile as unknown as Prisma.JsonObject)
193+
: undefined;
194+
const authExtraParams = authenticationExtraParams
195+
? (authenticationExtraParams as unknown as Prisma.JsonObject)
196+
: undefined;
197+
198+
const authIdentifier = `google:${authenticationProfile.id}`;
199+
200+
const existingUser = await prisma.user.findUnique({
201+
where: {
202+
authIdentifier,
203+
},
204+
});
205+
206+
const existingEmailUser = await prisma.user.findUnique({
207+
where: {
208+
email,
209+
},
210+
});
211+
212+
if (existingEmailUser && !existingUser) {
213+
// Link existing email account to Google auth, preserving original authenticationMethod
214+
const user = await prisma.user.update({
215+
where: {
216+
email,
217+
},
218+
data: {
219+
authenticationProfile: authProfile,
220+
authenticationExtraParams: authExtraParams,
221+
avatarUrl,
222+
authIdentifier,
223+
},
224+
});
225+
226+
return {
227+
user,
228+
isNewUser: false,
229+
};
230+
}
231+
232+
if (existingEmailUser && existingUser) {
233+
// Check if email user and auth user are the same
234+
if (existingEmailUser.id !== existingUser.id) {
235+
// Different users: email is taken by one user, Google auth belongs to another
236+
logger.error(
237+
`Google auth conflict: Google ID ${authenticationProfile.id} belongs to user ${existingUser.id} but email ${email} is taken by user ${existingEmailUser.id}`,
238+
{
239+
email,
240+
existingEmailUserId: existingEmailUser.id,
241+
existingAuthUserId: existingUser.id,
242+
authIdentifier,
243+
}
244+
);
245+
246+
return {
247+
user: existingUser,
248+
isNewUser: false,
249+
};
250+
}
251+
252+
// Same user: update all profile fields
253+
const user = await prisma.user.update({
254+
where: {
255+
id: existingUser.id,
256+
},
257+
data: {
258+
email,
259+
displayName,
260+
name,
261+
avatarUrl,
262+
authenticationProfile: authProfile,
263+
authenticationExtraParams: authExtraParams,
264+
},
265+
});
266+
267+
return {
268+
user,
269+
isNewUser: false,
270+
};
271+
}
272+
273+
// When the IDP user (Google) already exists, the "update" path will be taken and the email will be updated
274+
// It's not possible that the email is already taken by a different user because that would have been handled
275+
// by one of the if statements above.
276+
const user = await prisma.user.upsert({
277+
where: {
278+
authIdentifier,
279+
},
280+
update: {
281+
email,
282+
displayName,
283+
name,
284+
avatarUrl,
285+
authenticationProfile: authProfile,
286+
authenticationExtraParams: authExtraParams,
287+
},
288+
create: {
289+
authenticationProfile: authProfile,
290+
authenticationExtraParams: authExtraParams,
291+
name,
292+
avatarUrl,
293+
displayName,
294+
authIdentifier,
295+
email,
296+
authenticationMethod: "GOOGLE",
297+
},
298+
});
299+
300+
return {
301+
user,
302+
isNewUser: !existingUser,
303+
};
304+
}
305+
165306
export type UserWithDashboardPreferences = User & {
166307
dashboardPreferences: DashboardPreferences;
167308
};

apps/webapp/app/routes/auth.github.callback.tsx

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { redirect } from "@remix-run/node";
33
import { prisma } from "~/db.server";
44
import { getSession, redirectWithErrorMessage } from "~/models/message.server";
55
import { authenticator } from "~/services/auth.server";
6+
import { setLastAuthMethodHeader } from "~/services/lastAuthMethod.server";
67
import { commitSession } from "~/services/sessionStorage.server";
78
import { redirectCookie } from "./auth.github";
89
import { sanitizeRedirectPath } from "~/utils";
@@ -41,19 +42,19 @@ export let loader: LoaderFunction = async ({ request }) => {
4142
session.set("pending-mfa-user-id", userRecord.id);
4243
session.set("pending-mfa-redirect-to", redirectTo);
4344

44-
return redirect("/login/mfa", {
45-
headers: {
46-
"Set-Cookie": await commitSession(session),
47-
},
48-
});
45+
const headers = new Headers();
46+
headers.append("Set-Cookie", await commitSession(session));
47+
headers.append("Set-Cookie", await setLastAuthMethodHeader("github"));
48+
49+
return redirect("/login/mfa", { headers });
4950
}
5051

5152
// and store the user data
5253
session.set(authenticator.sessionKey, auth);
5354

54-
return redirect(redirectTo, {
55-
headers: {
56-
"Set-Cookie": await commitSession(session),
57-
},
58-
});
55+
const headers = new Headers();
56+
headers.append("Set-Cookie", await commitSession(session));
57+
headers.append("Set-Cookie", await setLastAuthMethodHeader("github"));
58+
59+
return redirect(redirectTo, { headers });
5960
};
Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
11
import { type ActionFunction, type LoaderFunction, redirect, createCookie } from "@remix-run/node";
22
import { authenticator } from "~/services/auth.server";
3+
import { env } from "~/env.server";
4+
import { sanitizeRedirectPath } from "~/utils";
35

46
export let loader: LoaderFunction = () => redirect("/login");
57

68
export let action: ActionFunction = async ({ request }) => {
79
const url = new URL(request.url);
810
const redirectTo = url.searchParams.get("redirectTo");
11+
const safeRedirect = sanitizeRedirectPath(redirectTo, "/");
912

1013
try {
1114
// call authenticate as usual, in successRedirect use returnTo or a fallback
1215
return await authenticator.authenticate("github", request, {
13-
successRedirect: redirectTo ?? "/",
16+
successRedirect: safeRedirect,
1417
failureRedirect: "/login",
1518
});
1619
} catch (error) {
@@ -19,8 +22,8 @@ export let action: ActionFunction = async ({ request }) => {
1922
// if the error is a Response and is a redirect
2023
if (error instanceof Response) {
2124
// we need to append a Set-Cookie header with a cookie storing the
22-
// returnTo value
23-
error.headers.append("Set-Cookie", await redirectCookie.serialize(redirectTo));
25+
// returnTo value (store the sanitized path)
26+
error.headers.append("Set-Cookie", await redirectCookie.serialize(safeRedirect));
2427
}
2528
throw error;
2629
}
@@ -29,4 +32,6 @@ export let action: ActionFunction = async ({ request }) => {
2932
export const redirectCookie = createCookie("redirect-to", {
3033
maxAge: 60 * 60, // 1 hour
3134
httpOnly: true,
35+
sameSite: "lax",
36+
secure: env.NODE_ENV === "production",
3237
});
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import type { LoaderFunction } from "@remix-run/node";
2+
import { redirect } from "@remix-run/node";
3+
import { prisma } from "~/db.server";
4+
import { getSession, redirectWithErrorMessage } from "~/models/message.server";
5+
import { authenticator } from "~/services/auth.server";
6+
import { setLastAuthMethodHeader } from "~/services/lastAuthMethod.server";
7+
import { commitSession } from "~/services/sessionStorage.server";
8+
import { redirectCookie } from "./auth.google";
9+
import { sanitizeRedirectPath } from "~/utils";
10+
11+
export let loader: LoaderFunction = async ({ request }) => {
12+
const cookie = request.headers.get("Cookie");
13+
const redirectValue = await redirectCookie.parse(cookie);
14+
const redirectTo = sanitizeRedirectPath(redirectValue);
15+
16+
const auth = await authenticator.authenticate("google", request, {
17+
failureRedirect: "/login", // If auth fails, the failureRedirect will be thrown as a Response
18+
});
19+
20+
// manually get the session
21+
const session = await getSession(request.headers.get("cookie"));
22+
23+
const userRecord = await prisma.user.findFirst({
24+
where: {
25+
id: auth.userId,
26+
},
27+
select: {
28+
id: true,
29+
mfaEnabledAt: true,
30+
},
31+
});
32+
33+
if (!userRecord) {
34+
return redirectWithErrorMessage(
35+
"/login",
36+
request,
37+
"Could not find your account. Please contact support."
38+
);
39+
}
40+
41+
if (userRecord.mfaEnabledAt) {
42+
session.set("pending-mfa-user-id", userRecord.id);
43+
session.set("pending-mfa-redirect-to", redirectTo);
44+
45+
const headers = new Headers();
46+
headers.append("Set-Cookie", await commitSession(session));
47+
headers.append("Set-Cookie", await setLastAuthMethodHeader("google"));
48+
49+
return redirect("/login/mfa", { headers });
50+
}
51+
52+
// and store the user data
53+
session.set(authenticator.sessionKey, auth);
54+
55+
const headers = new Headers();
56+
headers.append("Set-Cookie", await commitSession(session));
57+
headers.append("Set-Cookie", await setLastAuthMethodHeader("google"));
58+
59+
return redirect(redirectTo, { headers });
60+
};
61+
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { type ActionFunction, type LoaderFunction, redirect, createCookie } from "@remix-run/node";
2+
import { authenticator } from "~/services/auth.server";
3+
import { env } from "~/env.server";
4+
import { sanitizeRedirectPath } from "~/utils";
5+
6+
export let loader: LoaderFunction = () => redirect("/login");
7+
8+
export let action: ActionFunction = async ({ request }) => {
9+
const url = new URL(request.url);
10+
const redirectTo = url.searchParams.get("redirectTo");
11+
const safeRedirect = sanitizeRedirectPath(redirectTo, "/");
12+
13+
try {
14+
// call authenticate as usual, in successRedirect use returnTo or a fallback
15+
return await authenticator.authenticate("google", request, {
16+
successRedirect: safeRedirect,
17+
failureRedirect: "/login",
18+
});
19+
} catch (error) {
20+
// here we catch anything authenticator.authenticate throw, this will
21+
// include redirects
22+
// if the error is a Response and is a redirect
23+
if (error instanceof Response) {
24+
// we need to append a Set-Cookie header with a cookie storing the
25+
// returnTo value (store the sanitized path)
26+
error.headers.append("Set-Cookie", await redirectCookie.serialize(safeRedirect));
27+
}
28+
throw error;
29+
}
30+
};
31+
32+
export const redirectCookie = createCookie("google-redirect-to", {
33+
maxAge: 60 * 60, // 1 hour
34+
httpOnly: true,
35+
sameSite: "lax",
36+
secure: env.NODE_ENV === "production",
37+
});
38+

0 commit comments

Comments
 (0)