Skip to content

Commit

Permalink
Merge pull request #50 from anton-g/google-login
Browse files Browse the repository at this point in the history
Google login
  • Loading branch information
anton-g authored Oct 23, 2023
2 parents 6357062 + 4af90a1 commit e300ab2
Show file tree
Hide file tree
Showing 16 changed files with 505 additions and 59 deletions.
5 changes: 4 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,7 @@ ENABLE_PREMIUM="false"
FATHOM_SITE_ID=""
MAPBOX_ACCESS_TOKEN=""
RESEND_API_KEY=""
LOCATIONIQ_API_KEY="
LOCATIONIQ_API_KEY=""
ENABLE_GOOGLE_LOGIN="false"
GOOGLE_CLIENT_ID=""
GOOGLE_CLIENT_SECRET=""
54 changes: 53 additions & 1 deletion app/auth.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,67 @@ import { Authenticator, AuthorizationError } from "remix-auth"
import { sessionStorage } from "~/session.server"
import { FormStrategy } from "remix-auth-form"
import type { User } from "./models/user.server"
import { checkIsAdmin, getUserById, verifyLogin } from "./models/user.server"
import {
checkIsAdmin,
createUser,
forceVerifyUserEmail,
getUserByEmail,
getUserById,
verifyLogin,
} from "./models/user.server"
import invariant from "tiny-invariant"
import type { Theme } from "./styles/theme"
import { redirect } from "@remix-run/node"
import { GoogleStrategy } from "remix-auth-google"
import Cache from "node-cache"

export type AuthRedirectState = {
redirectTo?: string
from?: string
groupInviteToken?: string
}
export const stateCache = new Cache({ deleteOnExpire: true, stdTTL: 60 * 10 })

export const authenticator = new Authenticator<string>(sessionStorage, {
sessionKey: "userId",
})

const getCallback = (provider: "google") => {
return `/auth/${provider}/callback`
}

if (ENV.ENABLE_GOOGLE_LOGIN) {
invariant(ENV.GOOGLE_CLIENT_ID, "GOOGLE_CLIENT_ID must be set")
invariant(ENV.GOOGLE_CLIENT_SECRET, "GOOGLE_CLIENT_SECRET must be set")

authenticator.use(
new GoogleStrategy(
{
clientID: ENV.GOOGLE_CLIENT_ID,
clientSecret: ENV.GOOGLE_CLIENT_SECRET,
callbackURL: getCallback("google"),
},
async ({ profile }) => {
if (profile._json.email_verified !== true) {
throw new AuthorizationError("You can only use a Google account with a verified email")
}

const email = profile.emails[0].value
const user = await getUserByEmail(email)

if (user) {
await forceVerifyUserEmail(email)
return user.id
}

const result = await createUser(email, profile.displayName)

return result.user.id
},
),
)
}

authenticator.use(
new FormStrategy(async ({ form }) => {
const email = form.get("email")
Expand Down
20 changes: 20 additions & 0 deletions app/components/SocialButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { Form, useSearchParams } from "@remix-run/react"
import { Button } from "~/components/Button"

export const SocialButton = ({
provider,
label,
from,
}: {
provider: "google"
label: string
from: "login" | "join"
}) => {
const [searchParams] = useSearchParams()

return (
<Form action={`/auth/${provider}?${searchParams.toString()}&from=${from}`} method="post">
<Button size="large">{label}</Button>
</Form>
)
}
3 changes: 3 additions & 0 deletions app/env.server.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
function getEnv() {
return {
NODE_ENV: process.env.NODE_ENV,
ENABLE_GOOGLE_LOGIN: process.env.ENABLE_GOOGLE_LOGIN === "true",
ENABLE_MAPS: process.env.ENABLE_MAPS === "true",
ENABLE_PREMIUM: process.env.ENABLE_PREMIUM === "true",
FATHOM_SITE_ID: process.env.FATHOM_SITE_ID,
MAPBOX_ACCESS_TOKEN: process.env.MAPBOX_ACCESS_TOKEN,
RESEND_API_KEY: process.env.RESEND_API_KEY,
GOOGLE_PLACES_API_KEY: process.env.GOOGLE_PLACES_API_KEY,
LOCATIONIQ_API_KEY: process.env.LOCATIONIQ_API_KEY,
GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET,
}
}

Expand Down
67 changes: 59 additions & 8 deletions app/models/user.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,11 +237,9 @@ export async function getUserByEmail(email: Email["email"]) {
export async function createUser(
email: Email["email"],
name: string,
password: string,
password?: string,
inviteToken?: string | null,
) {
const hashedPassword = await hashPassword(password)

const avatarId = getRandomAvatarId(email)

const user = await prisma.user.create({
Expand All @@ -255,11 +253,13 @@ export async function createUser(
},
name,
avatarId,
password: {
create: {
hash: hashedPassword,
},
},
password: password
? {
create: {
hash: await hashPassword(password),
},
}
: undefined,
},
include: {
email: {
Expand Down Expand Up @@ -583,6 +583,34 @@ export async function changeUserPassword({
})
}

export async function setUserPassword({ id, newPassword }: { id: User["id"]; newPassword: string }) {
const userWithoutPassword = await prisma.user.findUnique({
where: { id },
include: {
password: true,
},
})

if (!userWithoutPassword) {
return null
}

const hashedPassword = await hashPassword(newPassword)

return await prisma.user.update({
where: {
id,
},
data: {
password: {
create: {
hash: hashedPassword,
},
},
},
})
}

export async function changeUserPasswordWithToken({
token,
newPassword,
Expand Down Expand Up @@ -699,6 +727,16 @@ export async function updateUser(update: Partial<User>) {
})
}

export async function hasUserPassword(userId: User["id"]) {
const password = await prisma.password.findFirst({
where: {
userId,
},
})

return Boolean(password)
}

export async function setAllUserAvatars() {
const users = await prisma.user.findMany()

Expand Down Expand Up @@ -757,3 +795,16 @@ function generateUserStats(user: NonNullable<Prisma.PromiseReturnType<typeof fet
}

const hashPassword = (password: string) => bcrypt.hash(password, 10)

export async function forceVerifyUserEmail(email: string) {
return prisma.email.update({
where: {
email,
},
data: {
verificationRequestTime: null,
verificationToken: null,
verified: true,
},
})
}
1 change: 1 addition & 0 deletions app/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
user,
// Do not include tokens with sensitive data
ENV: {
ENABLE_GOOGLE_LOGIN: env.ENABLE_GOOGLE_LOGIN,
ENABLE_MAPS: env.ENABLE_MAPS,
ENABLE_PREMIUM: env.ENABLE_PREMIUM,
FATHOM_SITE_ID: env.FATHOM_SITE_ID,
Expand Down
9 changes: 9 additions & 0 deletions app/routes/_layout.join.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import styled from "styled-components"
import { addUserToGroupWithInviteToken } from "~/models/group.server"
import { sendEmailVerificationEmail } from "~/services/email.server"
import { mergeMeta } from "~/merge-meta"
import { SocialButton } from "~/components/SocialButton"

export const loader = async ({ request }: LoaderFunctionArgs) => {
const userId = await getUserId(request)
Expand Down Expand Up @@ -102,6 +103,7 @@ export const meta: MetaFunction = mergeMeta(() => [
export default function Join() {
const [searchParams] = useSearchParams()
const redirectTo = searchParams.get("redirectTo") ?? undefined
const error = searchParams.get("error")
const actionData = useActionData() as ActionData
const emailRef = React.useRef<HTMLInputElement>(null)
const passwordRef = React.useRef<HTMLInputElement>(null)
Expand Down Expand Up @@ -181,6 +183,13 @@ export default function Join() {
</div>
</Stack>
</Form>
{ENV.ENABLE_GOOGLE_LOGIN && (
<>
<h2 style={{ marginTop: 48 }}>Use your socials</h2>
{error && <p>{error}</p>}
<SocialButton provider={"google"} label="Sign up with Google" from={"join"} />
</>
)}
</Wrapper>
)
}
Expand Down
9 changes: 9 additions & 0 deletions app/routes/_layout.login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { Input } from "~/components/Input"
import { mergeMeta } from "~/merge-meta"
import { useEffect, useRef } from "react"
import { authenticator } from "~/auth.server"
import { SocialButton } from "../components/SocialButton"

export const loader = async ({ request }: LoaderFunctionArgs) => {
await authenticator.isAuthenticated(request, {
Expand Down Expand Up @@ -67,6 +68,7 @@ export const meta: MetaFunction = mergeMeta(() => [
export default function LoginPage() {
const [searchParams] = useSearchParams()
const redirectTo = searchParams.get("redirectTo") || "/"
const error = searchParams.get("error")
const actionData = useActionData() as ActionData
const emailRef = useRef<HTMLInputElement>(null)
const passwordRef = useRef<HTMLInputElement>(null)
Expand Down Expand Up @@ -122,6 +124,13 @@ export default function LoginPage() {
<ForgotPasswordLink to="/forgot-password">Forgot your password?</ForgotPasswordLink>
</Stack>
</Form>
{ENV.ENABLE_GOOGLE_LOGIN && (
<>
<h2>Use your socials</h2>
{error && <p>{error}</p>}
<SocialButton provider={"google"} label="Log in with Google" from="login" />
</>
)}
</Wrapper>
)
}
Expand Down
Loading

0 comments on commit e300ab2

Please sign in to comment.