Skip to content

Commit c95b0f5

Browse files
committed
feat(dashboard): login with google and "last used" indicator
1 parent e7fec40 commit c95b0f5

File tree

15 files changed

+642
-223
lines changed

15 files changed

+642
-223
lines changed

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: 109 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 {
@@ -20,7 +21,14 @@ type FindOrCreateGithub = {
2021
authenticationExtraParams: Record<string, unknown>;
2122
};
2223

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

2533
type LoggedInUser = {
2634
user: User;
@@ -35,6 +43,9 @@ export async function findOrCreateUser(input: FindOrCreateUser): Promise<LoggedI
3543
case "MAGIC_LINK": {
3644
return findOrCreateMagicLinkUser(input);
3745
}
46+
case "GOOGLE": {
47+
return findOrCreateGoogleUser(input);
48+
}
3849
}
3950
}
4051

@@ -162,6 +173,103 @@ export async function findOrCreateGithubUser({
162173
};
163174
}
164175

176+
export async function findOrCreateGoogleUser({
177+
email,
178+
authenticationProfile,
179+
authenticationExtraParams,
180+
}: FindOrCreateGoogle): Promise<LoggedInUser> {
181+
assertEmailAllowed(email);
182+
183+
const name = authenticationProfile._json.name;
184+
let avatarUrl: string | undefined = undefined;
185+
if (authenticationProfile.photos[0]) {
186+
avatarUrl = authenticationProfile.photos[0].value;
187+
}
188+
const displayName = authenticationProfile.displayName;
189+
const authProfile = authenticationProfile
190+
? (authenticationProfile as unknown as Prisma.JsonObject)
191+
: undefined;
192+
const authExtraParams = authenticationExtraParams
193+
? (authenticationExtraParams as unknown as Prisma.JsonObject)
194+
: undefined;
195+
196+
const authIdentifier = `google:${authenticationProfile.id}`;
197+
198+
const existingUser = await prisma.user.findUnique({
199+
where: {
200+
authIdentifier,
201+
},
202+
});
203+
204+
const existingEmailUser = await prisma.user.findUnique({
205+
where: {
206+
email,
207+
},
208+
});
209+
210+
if (existingEmailUser && !existingUser) {
211+
// Link existing email account to Google auth
212+
const user = await prisma.user.update({
213+
where: {
214+
email,
215+
},
216+
data: {
217+
authenticationMethod: "GOOGLE",
218+
authenticationProfile: authProfile,
219+
authenticationExtraParams: authExtraParams,
220+
avatarUrl,
221+
authIdentifier,
222+
},
223+
});
224+
225+
return {
226+
user,
227+
isNewUser: false,
228+
};
229+
}
230+
231+
if (existingEmailUser && existingUser) {
232+
// User already linked to Google, update profile info
233+
const user = await prisma.user.update({
234+
where: {
235+
id: existingUser.id,
236+
},
237+
data: {
238+
avatarUrl,
239+
authenticationProfile: authProfile,
240+
authenticationExtraParams: authExtraParams,
241+
},
242+
});
243+
244+
return {
245+
user,
246+
isNewUser: false,
247+
};
248+
}
249+
250+
const user = await prisma.user.upsert({
251+
where: {
252+
authIdentifier,
253+
},
254+
update: {},
255+
create: {
256+
authenticationProfile: authProfile,
257+
authenticationExtraParams: authExtraParams,
258+
name,
259+
avatarUrl,
260+
displayName,
261+
authIdentifier,
262+
email,
263+
authenticationMethod: "GOOGLE",
264+
},
265+
});
266+
267+
return {
268+
user,
269+
isNewUser: !existingUser,
270+
};
271+
}
272+
165273
export type UserWithDashboardPreferences = User & {
166274
dashboardPreferences: DashboardPreferences;
167275
};

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: 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: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { type ActionFunction, type LoaderFunction, redirect, createCookie } from "@remix-run/node";
2+
import { authenticator } from "~/services/auth.server";
3+
4+
export let loader: LoaderFunction = () => redirect("/login");
5+
6+
export let action: ActionFunction = async ({ request }) => {
7+
const url = new URL(request.url);
8+
const redirectTo = url.searchParams.get("redirectTo");
9+
10+
try {
11+
// call authenticate as usual, in successRedirect use returnTo or a fallback
12+
return await authenticator.authenticate("google", request, {
13+
successRedirect: redirectTo ?? "/",
14+
failureRedirect: "/login",
15+
});
16+
} catch (error) {
17+
// here we catch anything authenticator.authenticate throw, this will
18+
// include redirects
19+
// if the error is a Response and is a redirect
20+
if (error instanceof Response) {
21+
// 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));
24+
}
25+
throw error;
26+
}
27+
};
28+
29+
export const redirectCookie = createCookie("google-redirect-to", {
30+
maxAge: 60 * 60, // 1 hour
31+
httpOnly: true,
32+
});
33+

0 commit comments

Comments
 (0)