From d03fa6f4267eebc81f8dd8a135f41e4707bea565 Mon Sep 17 00:00:00 2001 From: Anton Gunnarsson Date: Thu, 19 Oct 2023 21:31:35 +0200 Subject: [PATCH 1/5] base impl --- app/auth.server.ts | 48 ++++- app/components/SocialButton.tsx | 13 ++ app/env.server.ts | 2 + app/models/user.server.ts | 29 ++- app/routes/_layout.join.tsx | 4 + app/routes/_layout.login.tsx | 6 + app/routes/auth.$provider.callback.tsx | 31 +++ app/routes/auth.$provider.tsx | 22 +++ app/routes/logout.tsx | 4 +- package-lock.json | 257 +++++++++++++++++++++++++ package.json | 1 + 11 files changed, 406 insertions(+), 11 deletions(-) create mode 100644 app/components/SocialButton.tsx create mode 100644 app/routes/auth.$provider.callback.tsx create mode 100644 app/routes/auth.$provider.tsx diff --git a/app/auth.server.ts b/app/auth.server.ts index a50cfd3..062bf27 100644 --- a/app/auth.server.ts +++ b/app/auth.server.ts @@ -2,15 +2,61 @@ 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, SocialsProvider } from "remix-auth-socials" +import { getEnv } from "./env.server" export const authenticator = new Authenticator(sessionStorage, { sessionKey: "userId", }) +const getCallback = (provider: SocialsProvider) => { + return `http://localhost:3000/auth/${provider}/callback` +} + +const env = getEnv() +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(SocialsProvider.GOOGLE), + }, + async ({ profile, context, extraParams, request }) => { + console.log(request.url) + console.log(extraParams) + 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") diff --git a/app/components/SocialButton.tsx b/app/components/SocialButton.tsx new file mode 100644 index 0000000..131af3a --- /dev/null +++ b/app/components/SocialButton.tsx @@ -0,0 +1,13 @@ +import { Form, useSearchParams } from "@remix-run/react" +import { Button } from "~/components/Button" +import type { SocialsProvider } from "remix-auth-socials" + +export const SocialButton = ({ provider, label }: { provider: SocialsProvider; label: string }) => { + const [searchParams] = useSearchParams() + + return ( +
+ +
+ ) +} diff --git a/app/env.server.ts b/app/env.server.ts index 3b0c082..0d6ece2 100644 --- a/app/env.server.ts +++ b/app/env.server.ts @@ -8,6 +8,8 @@ function getEnv() { 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, } } diff --git a/app/models/user.server.ts b/app/models/user.server.ts index a7628f9..1384d8a 100644 --- a/app/models/user.server.ts +++ b/app/models/user.server.ts @@ -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({ @@ -255,11 +253,13 @@ export async function createUser( }, name, avatarId, - password: { - create: { - hash: hashedPassword, - }, - }, + password: password + ? { + create: { + hash: await hashPassword(password), + }, + } + : undefined, }, include: { email: { @@ -757,3 +757,16 @@ function generateUserStats(user: NonNullable bcrypt.hash(password, 10) + +export async function forceVerifyUserEmail(email: string) { + return prisma.email.update({ + where: { + email, + }, + data: { + verificationRequestTime: null, + verificationToken: null, + verified: true, + }, + }) +} diff --git a/app/routes/_layout.join.tsx b/app/routes/_layout.join.tsx index c71aee7..fc89731 100644 --- a/app/routes/_layout.join.tsx +++ b/app/routes/_layout.join.tsx @@ -12,6 +12,8 @@ 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" +import { SocialsProvider } from "remix-auth-socials" export const loader = async ({ request }: LoaderFunctionArgs) => { const userId = await getUserId(request) @@ -181,6 +183,8 @@ export default function Join() { +

Use your socials

+ ) } diff --git a/app/routes/_layout.login.tsx b/app/routes/_layout.login.tsx index 75a1e54..c56d285 100644 --- a/app/routes/_layout.login.tsx +++ b/app/routes/_layout.login.tsx @@ -9,6 +9,8 @@ import { Input } from "~/components/Input" import { mergeMeta } from "~/merge-meta" import { useEffect, useRef } from "react" import { authenticator } from "~/auth.server" +import { SocialsProvider } from "remix-auth-socials" +import { SocialButton } from "../components/SocialButton" export const loader = async ({ request }: LoaderFunctionArgs) => { await authenticator.isAuthenticated(request, { @@ -67,6 +69,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(null) const passwordRef = useRef(null) @@ -122,6 +125,9 @@ export default function LoginPage() { Forgot your password? +

Use your socials

+ {error &&

{error}

} + ) } diff --git a/app/routes/auth.$provider.callback.tsx b/app/routes/auth.$provider.callback.tsx new file mode 100644 index 0000000..67e5d6f --- /dev/null +++ b/app/routes/auth.$provider.callback.tsx @@ -0,0 +1,31 @@ +import { redirect, type LoaderFunctionArgs } from "@remix-run/node" +import { AuthorizationError } from "remix-auth" +import invariant from "tiny-invariant" +import { authenticator } from "~/auth.server" + +export let loader = async ({ request, params }: LoaderFunctionArgs) => { + invariant(params.provider, "Provider not found") + const url = new URL(request.url) + + const inviteToken = url.searchParams.get("token") + + console.log("inviteToken", inviteToken) + + try { + await authenticator.authenticate(params.provider, request, { + successRedirect: "/", + throwOnError: true, + }) + } catch (error) { + if (error instanceof Response) { + console.log("SUCCESS?") + return error + } + if (error instanceof AuthorizationError) { + // TODO redirect to correct origin + return redirect(`/login?error=${encodeURIComponent(error.message)}`) + } + + throw error + } +} diff --git a/app/routes/auth.$provider.tsx b/app/routes/auth.$provider.tsx new file mode 100644 index 0000000..afff49b --- /dev/null +++ b/app/routes/auth.$provider.tsx @@ -0,0 +1,22 @@ +import type { ActionFunctionArgs } from "@remix-run/node" +import { redirect } from "@remix-run/node" +import invariant from "tiny-invariant" +import { authenticator } from "~/auth.server" + +export const loader = () => redirect("/login") + +export const action = async ({ request, params }: ActionFunctionArgs) => { + invariant(params.provider, "Provider not found") + + try { + const result = await authenticator.authenticate(params.provider, request, { + throwOnError: true, + successRedirect: "/asdf", + }) + return result + } catch (error) { + if (error instanceof Response) return error + + throw error + } +} diff --git a/app/routes/logout.tsx b/app/routes/logout.tsx index 4b6a983..cd0bbe3 100644 --- a/app/routes/logout.tsx +++ b/app/routes/logout.tsx @@ -1,4 +1,4 @@ -import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node" +import type { ActionFunctionArgs } from "@remix-run/node" import { redirect } from "@remix-run/node" import { logout } from "~/auth.server" @@ -7,6 +7,6 @@ export const action = async ({ request }: ActionFunctionArgs) => { return logout(request) } -export const loader = async ({}: LoaderFunctionArgs) => { +export const loader = async () => { return redirect("/") } diff --git a/package-lock.json b/package-lock.json index 1450b14..505e186 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51,6 +51,7 @@ "react-map-gl": "^7.0.25", "remix-auth": "^3.6.0", "remix-auth-form": "^1.4.0", + "remix-auth-socials": "^2.0.5", "resend": "^1.1.0", "styled-components": "^5.3.11", "tiny-invariant": "^1.3.1", @@ -8569,6 +8570,11 @@ "node": ">= 8" } }, + "node_modules/crypto-js": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.1.1.tgz", + "integrity": "sha512-o2JlM7ydqd3Qk9CA0L4NL6mTzU2sdx96a+oOfPu8Mkl/PK51vSyoi8/rQ8NknZtk44vq15lmhAj9CIAGwgeWKw==" + }, "node_modules/css-color-keywords": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz", @@ -16807,6 +16813,145 @@ "remix-auth": "^3.6.0" } }, + "node_modules/remix-auth-oauth2": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/remix-auth-oauth2/-/remix-auth-oauth2-1.11.0.tgz", + "integrity": "sha512-Yf1LF6NLYPFa7X2Rax/VEhXmYXFjZOi/q+7DmbMeoMHjAfkpqxbvzqqYSKIKDGR51z5TXR5na4to4380mir5bg==", + "dependencies": { + "debug": "^4.3.4" + }, + "peerDependencies": { + "@remix-run/server-runtime": "^1.0.0 || ^2.0.0", + "remix-auth": "^3.6.0" + } + }, + "node_modules/remix-auth-socials": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/remix-auth-socials/-/remix-auth-socials-2.0.5.tgz", + "integrity": "sha512-7HF2zXXj6k0HgfaodysdnlMtjDAjGAd/MSrQgAaO9EOht19zv7feTsjtYJlzdIWi7HYn+Uumq3HcT5Nahp3PbA==", + "dependencies": { + "remix-auth": "^3.4.0", + "remix-auth-discord": "^1.2.1", + "remix-auth-facebook": "^1.0.1", + "remix-auth-github": "^1.3.0", + "remix-auth-google": "^1.2.0", + "remix-auth-linkedin": "^1.0.1", + "remix-auth-microsoft": "^2.0.0", + "remix-auth-twitter": "^0.1.1" + } + }, + "node_modules/remix-auth-socials/node_modules/@remix-run/router": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.7.2.tgz", + "integrity": "sha512-7Lcn7IqGMV+vizMPoEl5F0XDshcdDYtMI6uJLQdQz5CfZAwy3vvGKYSUk789qndt5dEC4HfSjviSYlSoHGL2+A==", + "peer": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/remix-auth-socials/node_modules/@remix-run/server-runtime": { + "version": "1.19.3", + "resolved": "https://registry.npmjs.org/@remix-run/server-runtime/-/server-runtime-1.19.3.tgz", + "integrity": "sha512-KzQ+htUsKqpBgKE2tWo7kIIGy3MyHP58Io/itUPvV+weDjApwr9tQr9PZDPA3yAY6rAzLax7BU0NMSYCXWFY5A==", + "peer": true, + "dependencies": { + "@remix-run/router": "1.7.2", + "@types/cookie": "^0.4.1", + "@web3-storage/multipart-parser": "^1.0.0", + "cookie": "^0.4.1", + "set-cookie-parser": "^2.4.8", + "source-map": "^0.7.3" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/remix-auth-socials/node_modules/remix-auth-discord": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/remix-auth-discord/-/remix-auth-discord-1.2.1.tgz", + "integrity": "sha512-tA3lhN4PEarCfLF5XIWHrVxC3RaDCwt2wp2IcOlriIYSPEl2pTmhgZrsGIS4inEz7n8matnVm+kHr+L/diRJFw==", + "dependencies": { + "remix-auth": "^3.0.0", + "remix-auth-oauth2": "^1.0.0" + }, + "peerDependencies": { + "@remix-run/server-runtime": "^1.0.0" + } + }, + "node_modules/remix-auth-socials/node_modules/remix-auth-facebook": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/remix-auth-facebook/-/remix-auth-facebook-1.0.1.tgz", + "integrity": "sha512-i8uTyNGV97fRKzZ1+HTaEDjvH6rg3oyVPRbvHVU2FxWzjUtTCbpqudsQZbCSL40oO5ct+V7NIt+lT+DRCl3dlA==", + "dependencies": { + "remix-auth-oauth2": "^1.6.0" + }, + "peerDependencies": { + "@remix-run/server-runtime": "^1.5.1", + "remix-auth": "^3.2.2" + } + }, + "node_modules/remix-auth-socials/node_modules/remix-auth-github": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/remix-auth-github/-/remix-auth-github-1.6.0.tgz", + "integrity": "sha512-qdQmWVVEDHxnzMPn7XE0s7QX5GYba0uH3MDW+lVJiCoHDZCGymqmGCajLThL5vFJdwSx0C4GpQJ5EgEsIjQ3pA==", + "dependencies": { + "remix-auth-oauth2": "^1.8.0" + }, + "peerDependencies": { + "@remix-run/server-runtime": "^1.0.0", + "remix-auth": "^3.4.0" + } + }, + "node_modules/remix-auth-socials/node_modules/remix-auth-google": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/remix-auth-google/-/remix-auth-google-1.3.0.tgz", + "integrity": "sha512-HQ9UH7cKR6pDQAuxmdPjDsZe5/PGIQDsheM5q702wJRoXTbKVXPzni/Y1ZdPNaw3sOQkqQ8q5tSbiSwUHeqcCA==", + "dependencies": { + "remix-auth-oauth2": "^1.11.0" + }, + "peerDependencies": { + "@remix-run/server-runtime": "^2.0.1", + "remix-auth": "^3.2.1" + } + }, + "node_modules/remix-auth-socials/node_modules/remix-auth-linkedin": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/remix-auth-linkedin/-/remix-auth-linkedin-1.0.1.tgz", + "integrity": "sha512-ssHXqx3nCmSIfGp8zOXkGY/TZ40bxpi8HsGnFzaWHWJImcyEQQGySB4PHaH0da9Yo7TvcjmQtZaNl66whkcS2g==", + "dependencies": { + "remix-auth": "^3.2.1", + "remix-auth-oauth2": "^1.2.0" + }, + "peerDependencies": { + "@remix-run/server-runtime": "^1.1.3" + } + }, + "node_modules/remix-auth-socials/node_modules/remix-auth-microsoft": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/remix-auth-microsoft/-/remix-auth-microsoft-2.0.0.tgz", + "integrity": "sha512-SfTUvnRe+NIN8frSpAXpwjWBaPV8cM8a8TePbCtgmUy0eexwYM7SZTaEmT75C1XkMa3cVGlEQeKdovPMn0EaQw==", + "dependencies": { + "remix-auth": "^3.0.0", + "remix-auth-oauth2": "^1.0.0" + }, + "peerDependencies": { + "@remix-run/server-runtime": "^1.0.0" + } + }, + "node_modules/remix-auth-socials/node_modules/remix-auth-twitter": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/remix-auth-twitter/-/remix-auth-twitter-0.1.2.tgz", + "integrity": "sha512-rRu/hpxPiRv4N5yHACCdUFbQysNMsmmCT9FpqstuRhAJWQIu1/wk3wEZmxbWF95aalhlHgHBQMXq1s2Jt6LRUQ==", + "dependencies": { + "crypto-js": "^4.1.1", + "debug": "^4.3.3", + "remix-auth": "^3.2.2", + "uuid": "^8.3.2" + }, + "peerDependencies": { + "@remix-run/server-runtime": "^1.6.3" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -25697,6 +25842,11 @@ "which": "^2.0.1" } }, + "crypto-js": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.1.1.tgz", + "integrity": "sha512-o2JlM7ydqd3Qk9CA0L4NL6mTzU2sdx96a+oOfPu8Mkl/PK51vSyoi8/rQ8NknZtk44vq15lmhAj9CIAGwgeWKw==" + }, "css-color-keywords": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz", @@ -31592,6 +31742,113 @@ "integrity": "sha512-PirsVtv2AbJ7Lg+OjE+rjlW9AnkNYmqfmNIqTg0Mh1wur22ls5hxf2icVXVCRRhpcpV+FyoDxh03LtIyRj646A==", "requires": {} }, + "remix-auth-oauth2": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/remix-auth-oauth2/-/remix-auth-oauth2-1.11.0.tgz", + "integrity": "sha512-Yf1LF6NLYPFa7X2Rax/VEhXmYXFjZOi/q+7DmbMeoMHjAfkpqxbvzqqYSKIKDGR51z5TXR5na4to4380mir5bg==", + "requires": { + "debug": "^4.3.4" + } + }, + "remix-auth-socials": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/remix-auth-socials/-/remix-auth-socials-2.0.5.tgz", + "integrity": "sha512-7HF2zXXj6k0HgfaodysdnlMtjDAjGAd/MSrQgAaO9EOht19zv7feTsjtYJlzdIWi7HYn+Uumq3HcT5Nahp3PbA==", + "requires": { + "remix-auth": "^3.4.0", + "remix-auth-discord": "^1.2.1", + "remix-auth-facebook": "^1.0.1", + "remix-auth-github": "^1.3.0", + "remix-auth-google": "^1.2.0", + "remix-auth-linkedin": "^1.0.1", + "remix-auth-microsoft": "^2.0.0", + "remix-auth-twitter": "^0.1.1" + }, + "dependencies": { + "@remix-run/router": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.7.2.tgz", + "integrity": "sha512-7Lcn7IqGMV+vizMPoEl5F0XDshcdDYtMI6uJLQdQz5CfZAwy3vvGKYSUk789qndt5dEC4HfSjviSYlSoHGL2+A==", + "peer": true + }, + "@remix-run/server-runtime": { + "version": "1.19.3", + "resolved": "https://registry.npmjs.org/@remix-run/server-runtime/-/server-runtime-1.19.3.tgz", + "integrity": "sha512-KzQ+htUsKqpBgKE2tWo7kIIGy3MyHP58Io/itUPvV+weDjApwr9tQr9PZDPA3yAY6rAzLax7BU0NMSYCXWFY5A==", + "peer": true, + "requires": { + "@remix-run/router": "1.7.2", + "@types/cookie": "^0.4.1", + "@web3-storage/multipart-parser": "^1.0.0", + "cookie": "^0.4.1", + "set-cookie-parser": "^2.4.8", + "source-map": "^0.7.3" + } + }, + "remix-auth-discord": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/remix-auth-discord/-/remix-auth-discord-1.2.1.tgz", + "integrity": "sha512-tA3lhN4PEarCfLF5XIWHrVxC3RaDCwt2wp2IcOlriIYSPEl2pTmhgZrsGIS4inEz7n8matnVm+kHr+L/diRJFw==", + "requires": { + "remix-auth": "^3.0.0", + "remix-auth-oauth2": "^1.0.0" + } + }, + "remix-auth-facebook": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/remix-auth-facebook/-/remix-auth-facebook-1.0.1.tgz", + "integrity": "sha512-i8uTyNGV97fRKzZ1+HTaEDjvH6rg3oyVPRbvHVU2FxWzjUtTCbpqudsQZbCSL40oO5ct+V7NIt+lT+DRCl3dlA==", + "requires": { + "remix-auth-oauth2": "^1.6.0" + } + }, + "remix-auth-github": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/remix-auth-github/-/remix-auth-github-1.6.0.tgz", + "integrity": "sha512-qdQmWVVEDHxnzMPn7XE0s7QX5GYba0uH3MDW+lVJiCoHDZCGymqmGCajLThL5vFJdwSx0C4GpQJ5EgEsIjQ3pA==", + "requires": { + "remix-auth-oauth2": "^1.8.0" + } + }, + "remix-auth-google": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/remix-auth-google/-/remix-auth-google-1.3.0.tgz", + "integrity": "sha512-HQ9UH7cKR6pDQAuxmdPjDsZe5/PGIQDsheM5q702wJRoXTbKVXPzni/Y1ZdPNaw3sOQkqQ8q5tSbiSwUHeqcCA==", + "requires": { + "remix-auth-oauth2": "^1.11.0" + } + }, + "remix-auth-linkedin": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/remix-auth-linkedin/-/remix-auth-linkedin-1.0.1.tgz", + "integrity": "sha512-ssHXqx3nCmSIfGp8zOXkGY/TZ40bxpi8HsGnFzaWHWJImcyEQQGySB4PHaH0da9Yo7TvcjmQtZaNl66whkcS2g==", + "requires": { + "remix-auth": "^3.2.1", + "remix-auth-oauth2": "^1.2.0" + } + }, + "remix-auth-microsoft": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/remix-auth-microsoft/-/remix-auth-microsoft-2.0.0.tgz", + "integrity": "sha512-SfTUvnRe+NIN8frSpAXpwjWBaPV8cM8a8TePbCtgmUy0eexwYM7SZTaEmT75C1XkMa3cVGlEQeKdovPMn0EaQw==", + "requires": { + "remix-auth": "^3.0.0", + "remix-auth-oauth2": "^1.0.0" + } + }, + "remix-auth-twitter": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/remix-auth-twitter/-/remix-auth-twitter-0.1.2.tgz", + "integrity": "sha512-rRu/hpxPiRv4N5yHACCdUFbQysNMsmmCT9FpqstuRhAJWQIu1/wk3wEZmxbWF95aalhlHgHBQMXq1s2Jt6LRUQ==", + "requires": { + "crypto-js": "^4.1.1", + "debug": "^4.3.3", + "remix-auth": "^3.2.2", + "uuid": "^8.3.2" + } + } + } + }, "require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", diff --git a/package.json b/package.json index 65c3d27..35a37f8 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "react-map-gl": "^7.0.25", "remix-auth": "^3.6.0", "remix-auth-form": "^1.4.0", + "remix-auth-socials": "^2.0.5", "resend": "^1.1.0", "styled-components": "^5.3.11", "tiny-invariant": "^1.3.1", From 340db8d5426fccf5a37d9cc1f5f9abc4330edd45 Mon Sep 17 00:00:00 2001 From: Anton Gunnarsson Date: Sat, 21 Oct 2023 17:40:08 +0200 Subject: [PATCH 2/5] Maybe actually working google login? --- app/auth-providers/GoogleProvider.ts | 158 ++++++++++++ app/auth.server.ts | 20 +- app/components/SocialButton.tsx | 13 +- app/routes/_layout.join.tsx | 5 +- app/routes/_layout.login.tsx | 3 +- app/routes/auth.$provider.callback.tsx | 36 ++- app/routes/auth.$provider.tsx | 19 +- app/session.server.ts | 2 + package-lock.json | 323 ++++++------------------- package.json | 5 +- 10 files changed, 313 insertions(+), 271 deletions(-) create mode 100644 app/auth-providers/GoogleProvider.ts diff --git a/app/auth-providers/GoogleProvider.ts b/app/auth-providers/GoogleProvider.ts new file mode 100644 index 0000000..41f0c49 --- /dev/null +++ b/app/auth-providers/GoogleProvider.ts @@ -0,0 +1,158 @@ +import type { StrategyVerifyCallback } from "remix-auth" +import type { OAuth2Profile, OAuth2StrategyVerifyParams } from "remix-auth-oauth2" +import { OAuth2Strategy } from "remix-auth-oauth2" + +/** + * @see https://developers.google.com/identity/protocols/oauth2/scopes + */ +export type GoogleScope = string + +export type GoogleStrategyOptions = { + clientID: string + clientSecret: string + callbackURL: string + /** + * @default "openid profile email" + */ + scope?: GoogleScope[] | string + accessType?: "online" | "offline" + includeGrantedScopes?: boolean + prompt?: "none" | "consent" | "select_account" + hd?: string + loginHint?: string +} + +export type GoogleProfile = { + id: string + displayName: string + name: { + familyName: string + givenName: string + } + emails: [{ value: string }] + photos: [{ value: string }] + _json: { + sub: string + name: string + given_name: string + family_name: string + picture: string + locale: string + email: string + email_verified: boolean + hd: string + } +} & OAuth2Profile + +export type GoogleExtraParams = { + expires_in: 3920 + token_type: "Bearer" + scope: string + id_token: string +} & Record + +export const GoogleStrategyScopeSeperator = " " +export const GoogleStrategyDefaultScopes = [ + "openid", + "https://www.googleapis.com/auth/userinfo.profile", + "https://www.googleapis.com/auth/userinfo.email", +].join(GoogleStrategyScopeSeperator) +export const GoogleStrategyDefaultName = "google" + +export class GoogleStrategy extends OAuth2Strategy { + public name = GoogleStrategyDefaultName + + private readonly accessType: string + + private readonly prompt?: "none" | "consent" | "select_account" + + private readonly includeGrantedScopes: boolean + + private readonly hd?: string + + private readonly loginHint?: string + + private readonly userInfoURL = "https://www.googleapis.com/oauth2/v3/userinfo" + + constructor( + { + clientID, + clientSecret, + callbackURL, + scope, + accessType, + includeGrantedScopes, + prompt, + hd, + loginHint, + }: GoogleStrategyOptions, + verify: StrategyVerifyCallback>, + ) { + super( + { + clientID, + clientSecret, + callbackURL, + authorizationURL: "https://accounts.google.com/o/oauth2/v2/auth", + tokenURL: "https://oauth2.googleapis.com/token", + }, + verify, + ) + this.scope = this.parseScope(scope) + this.accessType = accessType ?? "online" + this.includeGrantedScopes = includeGrantedScopes ?? false + this.prompt = prompt + this.hd = hd + this.loginHint = loginHint + } + + protected authorizationParams(): URLSearchParams { + const params = new URLSearchParams({ + access_type: this.accessType, + include_granted_scopes: String(this.includeGrantedScopes), + }) + if (this.prompt) { + params.set("prompt", this.prompt) + } + if (this.hd) { + params.set("hd", this.hd) + } + if (this.loginHint) { + params.set("login_hint", this.loginHint) + } + return params + } + + protected async userProfile(accessToken: string): Promise { + const response = await fetch(this.userInfoURL, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) + const raw: GoogleProfile["_json"] = await response.json() + const profile: GoogleProfile = { + provider: "google", + id: raw.sub, + displayName: raw.name, + name: { + familyName: raw.family_name, + givenName: raw.given_name, + }, + emails: [{ value: raw.email }], + photos: [{ value: raw.picture }], + _json: raw, + } + return profile + } + + // Allow users the option to pass a scope string, or typed array + private parseScope(scope: GoogleStrategyOptions["scope"]) { + if (!scope) { + return GoogleStrategyDefaultScopes + } else if (Array.isArray(scope)) { + return scope.join(GoogleStrategyScopeSeperator) + } + + return scope + } +} diff --git a/app/auth.server.ts b/app/auth.server.ts index 062bf27..d5c71b9 100644 --- a/app/auth.server.ts +++ b/app/auth.server.ts @@ -13,15 +13,23 @@ import { import invariant from "tiny-invariant" import type { Theme } from "./styles/theme" import { redirect } from "@remix-run/node" -import { GoogleStrategy, SocialsProvider } from "remix-auth-socials" import { getEnv } from "./env.server" +import { GoogleStrategy } from "./auth-providers/GoogleProvider" +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(sessionStorage, { sessionKey: "userId", }) -const getCallback = (provider: SocialsProvider) => { - return `http://localhost:3000/auth/${provider}/callback` +const getCallback = (provider: "google") => { + return `/auth/${provider}/callback` } const env = getEnv() @@ -33,11 +41,9 @@ authenticator.use( { clientID: env.GOOGLE_CLIENT_ID, clientSecret: env.GOOGLE_CLIENT_SECRET, - callbackURL: getCallback(SocialsProvider.GOOGLE), + callbackURL: getCallback("google"), }, - async ({ profile, context, extraParams, request }) => { - console.log(request.url) - console.log(extraParams) + async ({ profile }) => { if (profile._json.email_verified !== true) { throw new AuthorizationError("You can only use a Google account with a verified email") } diff --git a/app/components/SocialButton.tsx b/app/components/SocialButton.tsx index 131af3a..492f977 100644 --- a/app/components/SocialButton.tsx +++ b/app/components/SocialButton.tsx @@ -1,12 +1,19 @@ import { Form, useSearchParams } from "@remix-run/react" import { Button } from "~/components/Button" -import type { SocialsProvider } from "remix-auth-socials" -export const SocialButton = ({ provider, label }: { provider: SocialsProvider; label: string }) => { +export const SocialButton = ({ + provider, + label, + from, +}: { + provider: "google" + label: string + from: "login" | "join" +}) => { const [searchParams] = useSearchParams() return ( -
+
) diff --git a/app/routes/_layout.join.tsx b/app/routes/_layout.join.tsx index fc89731..042008f 100644 --- a/app/routes/_layout.join.tsx +++ b/app/routes/_layout.join.tsx @@ -13,7 +13,6 @@ import { addUserToGroupWithInviteToken } from "~/models/group.server" import { sendEmailVerificationEmail } from "~/services/email.server" import { mergeMeta } from "~/merge-meta" import { SocialButton } from "~/components/SocialButton" -import { SocialsProvider } from "remix-auth-socials" export const loader = async ({ request }: LoaderFunctionArgs) => { const userId = await getUserId(request) @@ -104,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(null) const passwordRef = React.useRef(null) @@ -184,7 +184,8 @@ export default function Join() {

Use your socials

- + {error &&

{error}

} + ) } diff --git a/app/routes/_layout.login.tsx b/app/routes/_layout.login.tsx index c56d285..1271cb1 100644 --- a/app/routes/_layout.login.tsx +++ b/app/routes/_layout.login.tsx @@ -9,7 +9,6 @@ import { Input } from "~/components/Input" import { mergeMeta } from "~/merge-meta" import { useEffect, useRef } from "react" import { authenticator } from "~/auth.server" -import { SocialsProvider } from "remix-auth-socials" import { SocialButton } from "../components/SocialButton" export const loader = async ({ request }: LoaderFunctionArgs) => { @@ -127,7 +126,7 @@ export default function LoginPage() {

Use your socials

{error &&

{error}

} - + ) } diff --git a/app/routes/auth.$provider.callback.tsx b/app/routes/auth.$provider.callback.tsx index 67e5d6f..0c84cf1 100644 --- a/app/routes/auth.$provider.callback.tsx +++ b/app/routes/auth.$provider.callback.tsx @@ -1,29 +1,49 @@ import { redirect, type LoaderFunctionArgs } from "@remix-run/node" import { AuthorizationError } from "remix-auth" import invariant from "tiny-invariant" -import { authenticator } from "~/auth.server" +import type { AuthRedirectState } from "~/auth.server" +import { authenticator, stateCache } from "~/auth.server" +import { addUserToGroupWithInviteToken } from "~/models/group.server" +import { commitSession, getSession } from "~/session.server" export let loader = async ({ request, params }: LoaderFunctionArgs) => { invariant(params.provider, "Provider not found") const url = new URL(request.url) - const inviteToken = url.searchParams.get("token") + const nonce = url.searchParams.get("state") - console.log("inviteToken", inviteToken) + let successRedirect = "/" + let from = "login" + const result = stateCache.get(nonce || "") + stateCache.del(nonce || "") + if (result?.redirectTo) { + successRedirect = result.redirectTo + } + if (result?.from) { + from = result.from + } try { - await authenticator.authenticate(params.provider, request, { - successRedirect: "/", + const userId = await authenticator.authenticate(params.provider, request, { throwOnError: true, }) + + const session = await getSession(request.headers.get("cookie")) + session.set(authenticator.sessionKey, userId) + const headers = new Headers({ "Set-Cookie": await commitSession(session) }) + + if (result?.groupInviteToken) { + const group = await addUserToGroupWithInviteToken({ inviteToken: result.groupInviteToken, userId }) + return redirect(`/groups/${group.id}`, { headers }) + } + + return redirect(successRedirect, { headers }) } catch (error) { if (error instanceof Response) { - console.log("SUCCESS?") return error } if (error instanceof AuthorizationError) { - // TODO redirect to correct origin - return redirect(`/login?error=${encodeURIComponent(error.message)}`) + return redirect(`/${from}?error=${encodeURIComponent(error.message)}`) } throw error diff --git a/app/routes/auth.$provider.tsx b/app/routes/auth.$provider.tsx index afff49b..1bbf2d3 100644 --- a/app/routes/auth.$provider.tsx +++ b/app/routes/auth.$provider.tsx @@ -1,17 +1,32 @@ import type { ActionFunctionArgs } from "@remix-run/node" import { redirect } from "@remix-run/node" import invariant from "tiny-invariant" -import { authenticator } from "~/auth.server" +import type { AuthRedirectState } from "~/auth.server" +import { authenticator, stateCache } from "~/auth.server" +import { v4 } from "uuid" export const loader = () => redirect("/login") export const action = async ({ request, params }: ActionFunctionArgs) => { invariant(params.provider, "Provider not found") + const url = new URL(request.url) + const redirectTo = url.searchParams.get("redirectTo") + const from = url.searchParams.get("from") + const inviteToken = url.searchParams.get("token") + + const nonce = v4() + stateCache.set(nonce, { + redirectTo: redirectTo ?? undefined, + from: from ?? undefined, + groupInviteToken: inviteToken ?? undefined, + }) + try { const result = await authenticator.authenticate(params.provider, request, { throwOnError: true, - successRedirect: "/asdf", + // @ts-expect-error this works, but the remix-auth types don't know about it yet + state: nonce, }) return result } catch (error) { diff --git a/app/session.server.ts b/app/session.server.ts index e63695a..7b13e65 100644 --- a/app/session.server.ts +++ b/app/session.server.ts @@ -13,3 +13,5 @@ export const sessionStorage = createCookieSessionStorage({ secure: process.env.NODE_ENV === "production", }, }) + +export let { getSession, commitSession } = sessionStorage diff --git a/package-lock.json b/package-lock.json index 505e186..357d3a2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45,16 +45,18 @@ "date-fns": "^2.30.0", "mapbox-gl": "^2.15.0", "nanoid": "^4.0.2", + "node-cache": "^5.1.2", "react": "^18.2.0", "react-dom": "^18.2.0", "react-email": "1.9.5", "react-map-gl": "^7.0.25", "remix-auth": "^3.6.0", "remix-auth-form": "^1.4.0", - "remix-auth-socials": "^2.0.5", + "remix-auth-oauth2": "github:anton-g/remix-auth-oauth2", "resend": "^1.1.0", "styled-components": "^5.3.11", "tiny-invariant": "^1.3.1", + "uuid": "^9.0.1", "zod": "^3.21.4" }, "devDependencies": { @@ -70,6 +72,7 @@ "@types/react": "^18.2.14", "@types/react-dom": "^18.2.6", "@types/styled-components": "^5.1.26", + "@types/uuid": "^9.0.6", "@vitejs/plugin-react": "^4.0.1", "@vitest/coverage-v8": "^0.32.2", "autoprefixer": "^10.4.14", @@ -6743,6 +6746,12 @@ "integrity": "sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ==", "dev": true }, + "node_modules/@types/uuid": { + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.6.tgz", + "integrity": "sha512-BT2Krtx4xaO6iwzwMFUYvWBWkV2pr37zD68Vmp1CDV196MzczBRxuEpD6Pr395HAgebC/co7hOphs53r8V7jew==", + "dev": true + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "5.60.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.60.0.tgz", @@ -8570,11 +8579,6 @@ "node": ">= 8" } }, - "node_modules/crypto-js": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.1.1.tgz", - "integrity": "sha512-o2JlM7ydqd3Qk9CA0L4NL6mTzU2sdx96a+oOfPu8Mkl/PK51vSyoi8/rQ8NknZtk44vq15lmhAj9CIAGwgeWKw==" - }, "node_modules/css-color-keywords": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz", @@ -14392,6 +14396,25 @@ "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", "dev": true }, + "node_modules/node-cache": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/node-cache/-/node-cache-5.1.2.tgz", + "integrity": "sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg==", + "dependencies": { + "clone": "2.x" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/node-cache/node_modules/clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "engines": { + "node": ">=0.8" + } + }, "node_modules/node-fetch": { "version": "2.6.11", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.11.tgz", @@ -16815,8 +16838,8 @@ }, "node_modules/remix-auth-oauth2": { "version": "1.11.0", - "resolved": "https://registry.npmjs.org/remix-auth-oauth2/-/remix-auth-oauth2-1.11.0.tgz", - "integrity": "sha512-Yf1LF6NLYPFa7X2Rax/VEhXmYXFjZOi/q+7DmbMeoMHjAfkpqxbvzqqYSKIKDGR51z5TXR5na4to4380mir5bg==", + "resolved": "git+ssh://git@github.com/anton-g/remix-auth-oauth2.git#fe312d03f72602fab7dd0e13176205068f6899ab", + "license": "MIT", "dependencies": { "debug": "^4.3.4" }, @@ -16825,131 +16848,12 @@ "remix-auth": "^3.6.0" } }, - "node_modules/remix-auth-socials": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/remix-auth-socials/-/remix-auth-socials-2.0.5.tgz", - "integrity": "sha512-7HF2zXXj6k0HgfaodysdnlMtjDAjGAd/MSrQgAaO9EOht19zv7feTsjtYJlzdIWi7HYn+Uumq3HcT5Nahp3PbA==", - "dependencies": { - "remix-auth": "^3.4.0", - "remix-auth-discord": "^1.2.1", - "remix-auth-facebook": "^1.0.1", - "remix-auth-github": "^1.3.0", - "remix-auth-google": "^1.2.0", - "remix-auth-linkedin": "^1.0.1", - "remix-auth-microsoft": "^2.0.0", - "remix-auth-twitter": "^0.1.1" - } - }, - "node_modules/remix-auth-socials/node_modules/@remix-run/router": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.7.2.tgz", - "integrity": "sha512-7Lcn7IqGMV+vizMPoEl5F0XDshcdDYtMI6uJLQdQz5CfZAwy3vvGKYSUk789qndt5dEC4HfSjviSYlSoHGL2+A==", - "peer": true, - "engines": { - "node": ">=14" - } - }, - "node_modules/remix-auth-socials/node_modules/@remix-run/server-runtime": { - "version": "1.19.3", - "resolved": "https://registry.npmjs.org/@remix-run/server-runtime/-/server-runtime-1.19.3.tgz", - "integrity": "sha512-KzQ+htUsKqpBgKE2tWo7kIIGy3MyHP58Io/itUPvV+weDjApwr9tQr9PZDPA3yAY6rAzLax7BU0NMSYCXWFY5A==", - "peer": true, - "dependencies": { - "@remix-run/router": "1.7.2", - "@types/cookie": "^0.4.1", - "@web3-storage/multipart-parser": "^1.0.0", - "cookie": "^0.4.1", - "set-cookie-parser": "^2.4.8", - "source-map": "^0.7.3" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/remix-auth-socials/node_modules/remix-auth-discord": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/remix-auth-discord/-/remix-auth-discord-1.2.1.tgz", - "integrity": "sha512-tA3lhN4PEarCfLF5XIWHrVxC3RaDCwt2wp2IcOlriIYSPEl2pTmhgZrsGIS4inEz7n8matnVm+kHr+L/diRJFw==", - "dependencies": { - "remix-auth": "^3.0.0", - "remix-auth-oauth2": "^1.0.0" - }, - "peerDependencies": { - "@remix-run/server-runtime": "^1.0.0" - } - }, - "node_modules/remix-auth-socials/node_modules/remix-auth-facebook": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/remix-auth-facebook/-/remix-auth-facebook-1.0.1.tgz", - "integrity": "sha512-i8uTyNGV97fRKzZ1+HTaEDjvH6rg3oyVPRbvHVU2FxWzjUtTCbpqudsQZbCSL40oO5ct+V7NIt+lT+DRCl3dlA==", - "dependencies": { - "remix-auth-oauth2": "^1.6.0" - }, - "peerDependencies": { - "@remix-run/server-runtime": "^1.5.1", - "remix-auth": "^3.2.2" - } - }, - "node_modules/remix-auth-socials/node_modules/remix-auth-github": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/remix-auth-github/-/remix-auth-github-1.6.0.tgz", - "integrity": "sha512-qdQmWVVEDHxnzMPn7XE0s7QX5GYba0uH3MDW+lVJiCoHDZCGymqmGCajLThL5vFJdwSx0C4GpQJ5EgEsIjQ3pA==", - "dependencies": { - "remix-auth-oauth2": "^1.8.0" - }, - "peerDependencies": { - "@remix-run/server-runtime": "^1.0.0", - "remix-auth": "^3.4.0" - } - }, - "node_modules/remix-auth-socials/node_modules/remix-auth-google": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/remix-auth-google/-/remix-auth-google-1.3.0.tgz", - "integrity": "sha512-HQ9UH7cKR6pDQAuxmdPjDsZe5/PGIQDsheM5q702wJRoXTbKVXPzni/Y1ZdPNaw3sOQkqQ8q5tSbiSwUHeqcCA==", - "dependencies": { - "remix-auth-oauth2": "^1.11.0" - }, - "peerDependencies": { - "@remix-run/server-runtime": "^2.0.1", - "remix-auth": "^3.2.1" - } - }, - "node_modules/remix-auth-socials/node_modules/remix-auth-linkedin": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/remix-auth-linkedin/-/remix-auth-linkedin-1.0.1.tgz", - "integrity": "sha512-ssHXqx3nCmSIfGp8zOXkGY/TZ40bxpi8HsGnFzaWHWJImcyEQQGySB4PHaH0da9Yo7TvcjmQtZaNl66whkcS2g==", - "dependencies": { - "remix-auth": "^3.2.1", - "remix-auth-oauth2": "^1.2.0" - }, - "peerDependencies": { - "@remix-run/server-runtime": "^1.1.3" - } - }, - "node_modules/remix-auth-socials/node_modules/remix-auth-microsoft": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/remix-auth-microsoft/-/remix-auth-microsoft-2.0.0.tgz", - "integrity": "sha512-SfTUvnRe+NIN8frSpAXpwjWBaPV8cM8a8TePbCtgmUy0eexwYM7SZTaEmT75C1XkMa3cVGlEQeKdovPMn0EaQw==", - "dependencies": { - "remix-auth": "^3.0.0", - "remix-auth-oauth2": "^1.0.0" - }, - "peerDependencies": { - "@remix-run/server-runtime": "^1.0.0" - } - }, - "node_modules/remix-auth-socials/node_modules/remix-auth-twitter": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/remix-auth-twitter/-/remix-auth-twitter-0.1.2.tgz", - "integrity": "sha512-rRu/hpxPiRv4N5yHACCdUFbQysNMsmmCT9FpqstuRhAJWQIu1/wk3wEZmxbWF95aalhlHgHBQMXq1s2Jt6LRUQ==", - "dependencies": { - "crypto-js": "^4.1.1", - "debug": "^4.3.3", - "remix-auth": "^3.2.2", - "uuid": "^8.3.2" - }, - "peerDependencies": { - "@remix-run/server-runtime": "^1.6.3" + "node_modules/remix-auth/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": { + "uuid": "dist/bin/uuid" } }, "node_modules/require-directory": { @@ -18991,9 +18895,13 @@ } }, "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], "bin": { "uuid": "dist/bin/uuid" } @@ -24554,6 +24462,12 @@ "integrity": "sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ==", "dev": true }, + "@types/uuid": { + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.6.tgz", + "integrity": "sha512-BT2Krtx4xaO6iwzwMFUYvWBWkV2pr37zD68Vmp1CDV196MzczBRxuEpD6Pr395HAgebC/co7hOphs53r8V7jew==", + "dev": true + }, "@typescript-eslint/eslint-plugin": { "version": "5.60.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.60.0.tgz", @@ -25842,11 +25756,6 @@ "which": "^2.0.1" } }, - "crypto-js": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.1.1.tgz", - "integrity": "sha512-o2JlM7ydqd3Qk9CA0L4NL6mTzU2sdx96a+oOfPu8Mkl/PK51vSyoi8/rQ8NknZtk44vq15lmhAj9CIAGwgeWKw==" - }, "css-color-keywords": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz", @@ -30112,6 +30021,21 @@ "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", "dev": true }, + "node-cache": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/node-cache/-/node-cache-5.1.2.tgz", + "integrity": "sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg==", + "requires": { + "clone": "2.x" + }, + "dependencies": { + "clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==" + } + } + }, "node-fetch": { "version": "2.6.11", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.11.tgz", @@ -31734,6 +31658,13 @@ "integrity": "sha512-mxlzLYi+/GKQSaXIqIw15dxAT1wm+93REAeDIft2unrKDYnjaGhhpapyPhdbALln86wt9lNAk21znfRss3fG7Q==", "requires": { "uuid": "^8.3.2" + }, + "dependencies": { + "uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" + } } }, "remix-auth-form": { @@ -31743,112 +31674,12 @@ "requires": {} }, "remix-auth-oauth2": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/remix-auth-oauth2/-/remix-auth-oauth2-1.11.0.tgz", - "integrity": "sha512-Yf1LF6NLYPFa7X2Rax/VEhXmYXFjZOi/q+7DmbMeoMHjAfkpqxbvzqqYSKIKDGR51z5TXR5na4to4380mir5bg==", + "version": "git+ssh://git@github.com/anton-g/remix-auth-oauth2.git#fe312d03f72602fab7dd0e13176205068f6899ab", + "from": "remix-auth-oauth2@github:anton-g/remix-auth-oauth2", "requires": { "debug": "^4.3.4" } }, - "remix-auth-socials": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/remix-auth-socials/-/remix-auth-socials-2.0.5.tgz", - "integrity": "sha512-7HF2zXXj6k0HgfaodysdnlMtjDAjGAd/MSrQgAaO9EOht19zv7feTsjtYJlzdIWi7HYn+Uumq3HcT5Nahp3PbA==", - "requires": { - "remix-auth": "^3.4.0", - "remix-auth-discord": "^1.2.1", - "remix-auth-facebook": "^1.0.1", - "remix-auth-github": "^1.3.0", - "remix-auth-google": "^1.2.0", - "remix-auth-linkedin": "^1.0.1", - "remix-auth-microsoft": "^2.0.0", - "remix-auth-twitter": "^0.1.1" - }, - "dependencies": { - "@remix-run/router": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.7.2.tgz", - "integrity": "sha512-7Lcn7IqGMV+vizMPoEl5F0XDshcdDYtMI6uJLQdQz5CfZAwy3vvGKYSUk789qndt5dEC4HfSjviSYlSoHGL2+A==", - "peer": true - }, - "@remix-run/server-runtime": { - "version": "1.19.3", - "resolved": "https://registry.npmjs.org/@remix-run/server-runtime/-/server-runtime-1.19.3.tgz", - "integrity": "sha512-KzQ+htUsKqpBgKE2tWo7kIIGy3MyHP58Io/itUPvV+weDjApwr9tQr9PZDPA3yAY6rAzLax7BU0NMSYCXWFY5A==", - "peer": true, - "requires": { - "@remix-run/router": "1.7.2", - "@types/cookie": "^0.4.1", - "@web3-storage/multipart-parser": "^1.0.0", - "cookie": "^0.4.1", - "set-cookie-parser": "^2.4.8", - "source-map": "^0.7.3" - } - }, - "remix-auth-discord": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/remix-auth-discord/-/remix-auth-discord-1.2.1.tgz", - "integrity": "sha512-tA3lhN4PEarCfLF5XIWHrVxC3RaDCwt2wp2IcOlriIYSPEl2pTmhgZrsGIS4inEz7n8matnVm+kHr+L/diRJFw==", - "requires": { - "remix-auth": "^3.0.0", - "remix-auth-oauth2": "^1.0.0" - } - }, - "remix-auth-facebook": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/remix-auth-facebook/-/remix-auth-facebook-1.0.1.tgz", - "integrity": "sha512-i8uTyNGV97fRKzZ1+HTaEDjvH6rg3oyVPRbvHVU2FxWzjUtTCbpqudsQZbCSL40oO5ct+V7NIt+lT+DRCl3dlA==", - "requires": { - "remix-auth-oauth2": "^1.6.0" - } - }, - "remix-auth-github": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/remix-auth-github/-/remix-auth-github-1.6.0.tgz", - "integrity": "sha512-qdQmWVVEDHxnzMPn7XE0s7QX5GYba0uH3MDW+lVJiCoHDZCGymqmGCajLThL5vFJdwSx0C4GpQJ5EgEsIjQ3pA==", - "requires": { - "remix-auth-oauth2": "^1.8.0" - } - }, - "remix-auth-google": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/remix-auth-google/-/remix-auth-google-1.3.0.tgz", - "integrity": "sha512-HQ9UH7cKR6pDQAuxmdPjDsZe5/PGIQDsheM5q702wJRoXTbKVXPzni/Y1ZdPNaw3sOQkqQ8q5tSbiSwUHeqcCA==", - "requires": { - "remix-auth-oauth2": "^1.11.0" - } - }, - "remix-auth-linkedin": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/remix-auth-linkedin/-/remix-auth-linkedin-1.0.1.tgz", - "integrity": "sha512-ssHXqx3nCmSIfGp8zOXkGY/TZ40bxpi8HsGnFzaWHWJImcyEQQGySB4PHaH0da9Yo7TvcjmQtZaNl66whkcS2g==", - "requires": { - "remix-auth": "^3.2.1", - "remix-auth-oauth2": "^1.2.0" - } - }, - "remix-auth-microsoft": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/remix-auth-microsoft/-/remix-auth-microsoft-2.0.0.tgz", - "integrity": "sha512-SfTUvnRe+NIN8frSpAXpwjWBaPV8cM8a8TePbCtgmUy0eexwYM7SZTaEmT75C1XkMa3cVGlEQeKdovPMn0EaQw==", - "requires": { - "remix-auth": "^3.0.0", - "remix-auth-oauth2": "^1.0.0" - } - }, - "remix-auth-twitter": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/remix-auth-twitter/-/remix-auth-twitter-0.1.2.tgz", - "integrity": "sha512-rRu/hpxPiRv4N5yHACCdUFbQysNMsmmCT9FpqstuRhAJWQIu1/wk3wEZmxbWF95aalhlHgHBQMXq1s2Jt6LRUQ==", - "requires": { - "crypto-js": "^4.1.1", - "debug": "^4.3.3", - "remix-auth": "^3.2.2", - "uuid": "^8.3.2" - } - } - } - }, "require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -33366,9 +33197,9 @@ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==" }, "uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==" }, "uvu": { "version": "0.5.6", diff --git a/package.json b/package.json index 35a37f8..6891992 100644 --- a/package.json +++ b/package.json @@ -66,16 +66,18 @@ "date-fns": "^2.30.0", "mapbox-gl": "^2.15.0", "nanoid": "^4.0.2", + "node-cache": "^5.1.2", "react": "^18.2.0", "react-dom": "^18.2.0", "react-email": "1.9.5", "react-map-gl": "^7.0.25", "remix-auth": "^3.6.0", "remix-auth-form": "^1.4.0", - "remix-auth-socials": "^2.0.5", + "remix-auth-oauth2": "github:anton-g/remix-auth-oauth2", "resend": "^1.1.0", "styled-components": "^5.3.11", "tiny-invariant": "^1.3.1", + "uuid": "^9.0.1", "zod": "^3.21.4" }, "devDependencies": { @@ -91,6 +93,7 @@ "@types/react": "^18.2.14", "@types/react-dom": "^18.2.6", "@types/styled-components": "^5.1.26", + "@types/uuid": "^9.0.6", "@vitejs/plugin-react": "^4.0.1", "@vitest/coverage-v8": "^0.32.2", "autoprefixer": "^10.4.14", From 290ca2dce2201095aeb6bd4eaf49d3191e0c5c0c Mon Sep 17 00:00:00 2001 From: Anton Gunnarsson Date: Sat, 21 Oct 2023 18:59:12 +0200 Subject: [PATCH 3/5] Replace GoogleProvider with package --- app/auth-providers/GoogleProvider.ts | 158 --------------------------- app/auth.server.ts | 2 +- package-lock.json | 21 ++++ package.json | 1 + 4 files changed, 23 insertions(+), 159 deletions(-) delete mode 100644 app/auth-providers/GoogleProvider.ts diff --git a/app/auth-providers/GoogleProvider.ts b/app/auth-providers/GoogleProvider.ts deleted file mode 100644 index 41f0c49..0000000 --- a/app/auth-providers/GoogleProvider.ts +++ /dev/null @@ -1,158 +0,0 @@ -import type { StrategyVerifyCallback } from "remix-auth" -import type { OAuth2Profile, OAuth2StrategyVerifyParams } from "remix-auth-oauth2" -import { OAuth2Strategy } from "remix-auth-oauth2" - -/** - * @see https://developers.google.com/identity/protocols/oauth2/scopes - */ -export type GoogleScope = string - -export type GoogleStrategyOptions = { - clientID: string - clientSecret: string - callbackURL: string - /** - * @default "openid profile email" - */ - scope?: GoogleScope[] | string - accessType?: "online" | "offline" - includeGrantedScopes?: boolean - prompt?: "none" | "consent" | "select_account" - hd?: string - loginHint?: string -} - -export type GoogleProfile = { - id: string - displayName: string - name: { - familyName: string - givenName: string - } - emails: [{ value: string }] - photos: [{ value: string }] - _json: { - sub: string - name: string - given_name: string - family_name: string - picture: string - locale: string - email: string - email_verified: boolean - hd: string - } -} & OAuth2Profile - -export type GoogleExtraParams = { - expires_in: 3920 - token_type: "Bearer" - scope: string - id_token: string -} & Record - -export const GoogleStrategyScopeSeperator = " " -export const GoogleStrategyDefaultScopes = [ - "openid", - "https://www.googleapis.com/auth/userinfo.profile", - "https://www.googleapis.com/auth/userinfo.email", -].join(GoogleStrategyScopeSeperator) -export const GoogleStrategyDefaultName = "google" - -export class GoogleStrategy extends OAuth2Strategy { - public name = GoogleStrategyDefaultName - - private readonly accessType: string - - private readonly prompt?: "none" | "consent" | "select_account" - - private readonly includeGrantedScopes: boolean - - private readonly hd?: string - - private readonly loginHint?: string - - private readonly userInfoURL = "https://www.googleapis.com/oauth2/v3/userinfo" - - constructor( - { - clientID, - clientSecret, - callbackURL, - scope, - accessType, - includeGrantedScopes, - prompt, - hd, - loginHint, - }: GoogleStrategyOptions, - verify: StrategyVerifyCallback>, - ) { - super( - { - clientID, - clientSecret, - callbackURL, - authorizationURL: "https://accounts.google.com/o/oauth2/v2/auth", - tokenURL: "https://oauth2.googleapis.com/token", - }, - verify, - ) - this.scope = this.parseScope(scope) - this.accessType = accessType ?? "online" - this.includeGrantedScopes = includeGrantedScopes ?? false - this.prompt = prompt - this.hd = hd - this.loginHint = loginHint - } - - protected authorizationParams(): URLSearchParams { - const params = new URLSearchParams({ - access_type: this.accessType, - include_granted_scopes: String(this.includeGrantedScopes), - }) - if (this.prompt) { - params.set("prompt", this.prompt) - } - if (this.hd) { - params.set("hd", this.hd) - } - if (this.loginHint) { - params.set("login_hint", this.loginHint) - } - return params - } - - protected async userProfile(accessToken: string): Promise { - const response = await fetch(this.userInfoURL, { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }) - const raw: GoogleProfile["_json"] = await response.json() - const profile: GoogleProfile = { - provider: "google", - id: raw.sub, - displayName: raw.name, - name: { - familyName: raw.family_name, - givenName: raw.given_name, - }, - emails: [{ value: raw.email }], - photos: [{ value: raw.picture }], - _json: raw, - } - return profile - } - - // Allow users the option to pass a scope string, or typed array - private parseScope(scope: GoogleStrategyOptions["scope"]) { - if (!scope) { - return GoogleStrategyDefaultScopes - } else if (Array.isArray(scope)) { - return scope.join(GoogleStrategyScopeSeperator) - } - - return scope - } -} diff --git a/app/auth.server.ts b/app/auth.server.ts index d5c71b9..a6212b4 100644 --- a/app/auth.server.ts +++ b/app/auth.server.ts @@ -14,7 +14,7 @@ import invariant from "tiny-invariant" import type { Theme } from "./styles/theme" import { redirect } from "@remix-run/node" import { getEnv } from "./env.server" -import { GoogleStrategy } from "./auth-providers/GoogleProvider" +import { GoogleStrategy } from "remix-auth-google" import Cache from "node-cache" export type AuthRedirectState = { diff --git a/package-lock.json b/package-lock.json index 357d3a2..7c9093a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -52,6 +52,7 @@ "react-map-gl": "^7.0.25", "remix-auth": "^3.6.0", "remix-auth-form": "^1.4.0", + "remix-auth-google": "^1.3.0", "remix-auth-oauth2": "github:anton-g/remix-auth-oauth2", "resend": "^1.1.0", "styled-components": "^5.3.11", @@ -16836,6 +16837,18 @@ "remix-auth": "^3.6.0" } }, + "node_modules/remix-auth-google": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/remix-auth-google/-/remix-auth-google-1.3.0.tgz", + "integrity": "sha512-HQ9UH7cKR6pDQAuxmdPjDsZe5/PGIQDsheM5q702wJRoXTbKVXPzni/Y1ZdPNaw3sOQkqQ8q5tSbiSwUHeqcCA==", + "dependencies": { + "remix-auth-oauth2": "^1.11.0" + }, + "peerDependencies": { + "@remix-run/server-runtime": "^2.0.1", + "remix-auth": "^3.2.1" + } + }, "node_modules/remix-auth-oauth2": { "version": "1.11.0", "resolved": "git+ssh://git@github.com/anton-g/remix-auth-oauth2.git#fe312d03f72602fab7dd0e13176205068f6899ab", @@ -31673,6 +31686,14 @@ "integrity": "sha512-PirsVtv2AbJ7Lg+OjE+rjlW9AnkNYmqfmNIqTg0Mh1wur22ls5hxf2icVXVCRRhpcpV+FyoDxh03LtIyRj646A==", "requires": {} }, + "remix-auth-google": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/remix-auth-google/-/remix-auth-google-1.3.0.tgz", + "integrity": "sha512-HQ9UH7cKR6pDQAuxmdPjDsZe5/PGIQDsheM5q702wJRoXTbKVXPzni/Y1ZdPNaw3sOQkqQ8q5tSbiSwUHeqcCA==", + "requires": { + "remix-auth-oauth2": "^1.11.0" + } + }, "remix-auth-oauth2": { "version": "git+ssh://git@github.com/anton-g/remix-auth-oauth2.git#fe312d03f72602fab7dd0e13176205068f6899ab", "from": "remix-auth-oauth2@github:anton-g/remix-auth-oauth2", diff --git a/package.json b/package.json index 6891992..f578495 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "react-map-gl": "^7.0.25", "remix-auth": "^3.6.0", "remix-auth-form": "^1.4.0", + "remix-auth-google": "^1.3.0", "remix-auth-oauth2": "github:anton-g/remix-auth-oauth2", "resend": "^1.1.0", "styled-components": "^5.3.11", From 8aa25343af920665a3b3f7f5092fb9bcd5904783 Mon Sep 17 00:00:00 2001 From: Anton Gunnarsson Date: Sat, 21 Oct 2023 19:14:38 +0200 Subject: [PATCH 4/5] Add Set password feature --- app/models/user.server.ts | 38 ++++ app/routes/_layout.users.$userId.settings.tsx | 170 ++++++++++++++---- 2 files changed, 170 insertions(+), 38 deletions(-) diff --git a/app/models/user.server.ts b/app/models/user.server.ts index 1384d8a..f4c1a9e 100644 --- a/app/models/user.server.ts +++ b/app/models/user.server.ts @@ -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, @@ -699,6 +727,16 @@ export async function updateUser(update: Partial) { }) } +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() diff --git a/app/routes/_layout.users.$userId.settings.tsx b/app/routes/_layout.users.$userId.settings.tsx index 7c3081c..3edaa46 100644 --- a/app/routes/_layout.users.$userId.settings.tsx +++ b/app/routes/_layout.users.$userId.settings.tsx @@ -9,7 +9,14 @@ import { Input } from "~/components/Input" import { Spacer } from "~/components/Spacer" import { Stack } from "~/components/Stack" import type { User } from "~/models/user.server" -import { changeUserPassword, checkIsAdmin, getFullUserById, updateUser } from "~/models/user.server" +import { + changeUserPassword, + checkIsAdmin, + getFullUserById, + hasUserPassword, + setUserPassword, + updateUser, +} from "~/models/user.server" import { requireUserId } from "~/auth.server" import { ThemePicker } from "~/components/ThemePicker" import { AvatarPicker } from "~/components/AvatarPicker" @@ -33,8 +40,11 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { throw new Response("Not Found", { status: 404 }) } + const hasPassword = await hasUserPassword(user.id) + return json({ user, + hasPassword, }) } @@ -45,6 +55,7 @@ type ActionData = { newPassword?: string theme?: string avatar?: string + confirmNewPassword?: string } } @@ -68,6 +79,8 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { return await updateDetails(formData, userId) case "changePassword": return await updatePassword(formData, userId) + case "setPassword": + return await setPassword(formData, userId) } } @@ -148,12 +161,48 @@ const updatePassword = async (formData: FormData, userId: User["id"]) => { return redirect(`/users/${userOrError.id}`) } +const setPassword = async (formData: FormData, userId: User["id"]) => { + const newPassword = formData.get("new-password") + const confirmNewPassword = formData.get("confirm-new-password") + + if (typeof newPassword !== "string" || newPassword.length === 0) { + return json({ errors: { newPassword: "New password is required" } }, { status: 400 }) + } + + if (typeof confirmNewPassword !== "string" || confirmNewPassword.length === 0) { + return json( + { errors: { confirmNewPassword: "You must confirm your new password" } }, + { status: 400 }, + ) + } + + if (newPassword.length < 8) { + return json({ errors: { newPassword: "Password is too short" } }, { status: 400 }) + } + + if (newPassword !== confirmNewPassword) { + return json({ errors: { confirmNewPassword: "Passwords do not match" } }, { status: 400 }) + } + + const user = await setUserPassword({ + id: userId, + newPassword, + }) + + if (!user) { + return json({ errors: { password: "Something went wrong" } }, { status: 400 }) + } + + return redirect(`/users/${user.id}`) +} + export default function UserSettingsPage() { - const { user } = useLoaderData() + const { user, hasPassword } = useLoaderData() const actionData = useActionData() as ActionData const nameRef = useRef(null) const currentPasswordRef = useRef(null) const newPasswordRef = useRef(null) + const confirmNewPasswordRef = useRef(null) useEffect(() => { if (actionData?.errors?.name) { @@ -208,46 +257,91 @@ export default function UserSettingsPage() { -
- - Change password - -
- + {hasPassword && ( + + + Change password +
- - {actionData?.errors?.password && ( -
{actionData.errors.password}
- )} + +
+ + {actionData?.errors?.password && ( +
{actionData.errors.password}
+ )} +
-
-
-
- - {actionData?.errors?.newPassword && ( -
{actionData.errors.newPassword}
- )} + +
+ + {actionData?.errors?.newPassword && ( +
{actionData.errors.newPassword}
+ )} +
-
- -
-
+ + + + )} + {!hasPassword && ( +
+ + Set password +

You can set a password to enable logging in with email and password.

+ +
+ +
+ + {actionData?.errors?.newPassword && ( +
{actionData.errors.newPassword}
+ )} +
+
+
+ +
+ + {actionData?.errors?.confirmNewPassword && ( +
{actionData.errors.confirmNewPassword}
+ )} +
+
+ +
+
+ )} ) From 4af90a13ed05d18de0be3997da6fb2383af036b7 Mon Sep 17 00:00:00 2001 From: Anton Gunnarsson Date: Mon, 23 Oct 2023 21:02:14 +0200 Subject: [PATCH 5/5] Add ENABLE_GOOGLE_LOGIN env --- .env.example | 5 ++- app/auth.server.ts | 62 ++++++++++++++++++------------------ app/env.server.ts | 1 + app/root.tsx | 1 + app/routes/_layout.join.tsx | 10 ++++-- app/routes/_layout.login.tsx | 10 ++++-- app/routes/kitchensink.tsx | 6 ++-- 7 files changed, 54 insertions(+), 41 deletions(-) diff --git a/.env.example b/.env.example index bedefae..bd30ed1 100644 --- a/.env.example +++ b/.env.example @@ -5,4 +5,7 @@ ENABLE_PREMIUM="false" FATHOM_SITE_ID="" MAPBOX_ACCESS_TOKEN="" RESEND_API_KEY="" -LOCATIONIQ_API_KEY=" \ No newline at end of file +LOCATIONIQ_API_KEY="" +ENABLE_GOOGLE_LOGIN="false" +GOOGLE_CLIENT_ID="" +GOOGLE_CLIENT_SECRET="" \ No newline at end of file diff --git a/app/auth.server.ts b/app/auth.server.ts index a6212b4..3eacca8 100644 --- a/app/auth.server.ts +++ b/app/auth.server.ts @@ -13,7 +13,6 @@ import { import invariant from "tiny-invariant" import type { Theme } from "./styles/theme" import { redirect } from "@remix-run/node" -import { getEnv } from "./env.server" import { GoogleStrategy } from "remix-auth-google" import Cache from "node-cache" @@ -32,36 +31,37 @@ const getCallback = (provider: "google") => { return `/auth/${provider}/callback` } -const env = getEnv() -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 - }, - ), -) +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 }) => { diff --git a/app/env.server.ts b/app/env.server.ts index 0d6ece2..f5077ef 100644 --- a/app/env.server.ts +++ b/app/env.server.ts @@ -1,6 +1,7 @@ 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, diff --git a/app/root.tsx b/app/root.tsx index 4c1a1f8..3c0a702 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -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, diff --git a/app/routes/_layout.join.tsx b/app/routes/_layout.join.tsx index 042008f..77fc1ab 100644 --- a/app/routes/_layout.join.tsx +++ b/app/routes/_layout.join.tsx @@ -183,9 +183,13 @@ export default function Join() { -

Use your socials

- {error &&

{error}

} - + {ENV.ENABLE_GOOGLE_LOGIN && ( + <> +

Use your socials

+ {error &&

{error}

} + + + )} ) } diff --git a/app/routes/_layout.login.tsx b/app/routes/_layout.login.tsx index 1271cb1..7093eed 100644 --- a/app/routes/_layout.login.tsx +++ b/app/routes/_layout.login.tsx @@ -124,9 +124,13 @@ export default function LoginPage() { Forgot your password? -

Use your socials

- {error &&

{error}

} - + {ENV.ENABLE_GOOGLE_LOGIN && ( + <> +

Use your socials

+ {error &&

{error}

} + + + )} ) } diff --git a/app/routes/kitchensink.tsx b/app/routes/kitchensink.tsx index bdf9fa4..ba81a89 100644 --- a/app/routes/kitchensink.tsx +++ b/app/routes/kitchensink.tsx @@ -36,12 +36,12 @@ export const meta: MetaFunction = mergeMeta(() => [ export const loader = async ({ request }: LoaderFunctionArgs) => { return json({ - ENV: getEnv(), + ENABLE_MAPS: getEnv().ENABLE_MAPS, }) } export default function Kitchensink() { - const { ENV } = useLoaderData() + const { ENABLE_MAPS } = useLoaderData() return (
@@ -83,7 +83,7 @@ export default function Kitchensink() { - {ENV.ENABLE_MAPS ? : Maps disabled} + {ENABLE_MAPS ? : Maps disabled}