diff --git a/src/common/Alerts/AlertProfileVerification.tsx b/src/common/Alerts/AlertProfileVerification.tsx index d06e48166f..453ba62fdf 100644 --- a/src/common/Alerts/AlertProfileVerification.tsx +++ b/src/common/Alerts/AlertProfileVerification.tsx @@ -33,7 +33,8 @@ export const AlertProfileVerification = () => { if (!isVerificationSuccessful) { try { - await userStore.sendEmailVerification() + // TODO + // await userStore.sendEmailVerification() setVerificationState('sent') } catch (error) { setVerificationState('error') diff --git a/src/pages/SignIn/SignIn.tsx b/src/pages/SignIn/SignIn.tsx deleted file mode 100644 index 921d4000d0..0000000000 --- a/src/pages/SignIn/SignIn.tsx +++ /dev/null @@ -1,214 +0,0 @@ -import React, { useEffect, useState } from 'react' -import { Field, Form } from 'react-final-form' -import { Link, useNavigate } from '@remix-run/react' -import { observer } from 'mobx-react' -import { Button, FieldInput, HeroBanner, TextNotification } from 'oa-components' -import { getFriendlyMessage } from 'oa-shared' -import { PasswordField } from 'src/common/Form/PasswordField' -import { useCommonStores } from 'src/common/hooks/useCommonStores' -import { required } from 'src/utils/validators' -import { Box, Card, Flex, Heading, Label, Text } from 'theme-ui' - -interface IFormValues { - email: string - password: string -} -interface IState { - formValues: IFormValues - errorMsg?: string - disabled?: boolean - authProvider?: IAuthProvider - notificationProps?: { - isVisible: boolean - variant: 'failure' | 'success' - text: string - } -} -interface IProps { - onChange?: (e: React.FormEvent) => void - preloadValues?: any -} - -interface IAuthProvider { - provider: string - buttonLabel: string - inputLabel: string -} - -const AUTH_PROVIDERS: { [provider: string]: IAuthProvider } = { - firebase: { - provider: 'Firebase', - buttonLabel: 'Email / Password', - inputLabel: 'Email Address', - }, -} - -const SignInPage = observer((props: IProps) => { - const { userStore } = useCommonStores().stores - const navigate = useNavigate() - const [{ authProvider, notificationProps, errorMsg }, setState] = - useState({ - formValues: { - email: props.preloadValues ? props.preloadValues.email : '', - password: props.preloadValues ? props.preloadValues.password : '', - }, - authProvider: AUTH_PROVIDERS.firebase, - }) - - const onLoginSubmit = async (v: IFormValues) => { - setState((state) => ({ ...state, disabled: true })) - try { - await userStore!.login(v.email, v.password) - navigate(-1) - } catch (error) { - const friendlyErrorMessage = getFriendlyMessage(error.code) - setState((state) => ({ - ...state, - errorMsg: friendlyErrorMessage, - disabled: false, - })) - } - } - - const resetPasword = async (inputEmail: string) => { - try { - await userStore!.sendPasswordResetEmail(inputEmail) - setState((state) => ({ - ...state, - notificationProps: { - isVisible: true, - text: 'Reset email sent', - variant: 'success', - }, - })) - } catch (error) { - setState((state) => ({ - ...state, - notificationProps: { - isVisible: true, - text: error.code, - variant: 'failure', - }, - })) - } - } - - useEffect(() => { - if (userStore.authUser) { - // User logged in - navigate('/') - } - }, [userStore.authUser]) - - return ( -
onLoginSubmit(v as IFormValues)} - render={({ submitting, values, invalid, handleSubmit }) => { - return ( - <> - - - - - - - - Log in - - - Don't have an account? Sign-up here - - - - - {notificationProps && notificationProps.text && ( - - - {getFriendlyMessage(notificationProps?.text)} - - - )} - - {errorMsg && {errorMsg}} - - - - - - - - - - - - resetPasword(values.email)} - > - Forgotten password? - - - - - - - - - - - -
- - ) - }} - /> - ) -}) - -export default SignInPage diff --git a/src/pages/SignUp/SignUp.tsx b/src/pages/SignUp/SignUp.tsx deleted file mode 100644 index 2c6a4d7698..0000000000 --- a/src/pages/SignUp/SignUp.tsx +++ /dev/null @@ -1,285 +0,0 @@ -import { useState } from 'react' -import { Field, Form } from 'react-final-form' -import { Link, useNavigate } from '@remix-run/react' -import { observer } from 'mobx-react' -import { Button, ExternalLink, FieldInput, HeroBanner } from 'oa-components' -import { FRIENDLY_MESSAGES } from 'oa-shared' -import { PasswordField } from 'src/common/Form/PasswordField' -import { useCommonStores } from 'src/common/hooks/useCommonStores' -import { logger } from 'src/logger' -import { checkUserNameUnique } from 'src/utils/checkUserNameUnique' -import { formatLowerNoSpecial } from 'src/utils/helpers' -import { - composeValidators, - noSpecialCharacters, - required, -} from 'src/utils/validators' -import { Card, Flex, Heading, Label, Text } from 'theme-ui' -import { bool, object, ref, string } from 'yup' - -interface IFormValues { - email: string - password: string - passwordConfirmation: string - displayName: string - consent: boolean -} -interface IState { - formValues: IFormValues - errorMsg?: string - disabled?: boolean -} - -const rowWidth = ['100%', '100%', `100%`] - -const SignUpPage = observer(() => { - const navigate = useNavigate() - const { userStore } = useCommonStores().stores - const [state, setState] = useState({ - formValues: { - email: '', - password: '', - passwordConfirmation: '', - displayName: '', - consent: false, - }, - }) - - const validationSchema = object({ - displayName: string() - .min(2, 'Username must be at least 2 characters') - .required('Required') - .test( - 'is-unique', - FRIENDLY_MESSAGES['sign-up/username-taken'], - (value) => { - return checkUserNameUnique(userStore, value) - }, - ), - email: string() - .email(FRIENDLY_MESSAGES['auth/invalid-email']) - .required('Required'), - password: string() - .min(6, 'Password must be at least 6 characters') - .required('Password is required'), - 'confirm-password': string() - .oneOf([ref('password'), ''], 'Your new password does not match') - .required('Password confirm is required'), - consent: bool().oneOf([true], 'Consent is required'), - }) - - const onSignupSubmit = async (v: IFormValues) => { - const { email, password, displayName } = v - const userName = formatLowerNoSpecial(displayName as string) - - try { - if (await checkUserNameUnique(userStore, userName)) { - await userStore!.registerNewUser(email, password, displayName) - navigate('/sign-up-message') - } else { - setState((prev) => ({ - ...prev, - errorMsg: FRIENDLY_MESSAGES['sign-up/username-taken'], - disabled: false, - })) - } - } catch (error) { - logger.error(`Error signing up`, { errorCode: error.code, displayName }) - setState((prev) => ({ - ...prev, - errorMsg: FRIENDLY_MESSAGES[error.code] || error.message, - disabled: false, - })) - } - } - - return ( -
onSignupSubmit(v as IFormValues)} - validate={async (values: any) => { - try { - await validationSchema.validate(values, { abortEarly: false }) - } catch (err) { - return err.inner.reduce( - (acc: any, error) => ({ - ...acc, - [error.path]: error.message, - }), - {}, - ) - } - }} - render={({ submitting, invalid, handleSubmit }) => { - const disabled = invalid || submitting - return ( - - - - - - - - Create an account - - - {' '} - Already have an account? Sign-in here - - - - {state.errorMsg && ( - - {state.errorMsg} - - )} - - - - Think carefully. You can't change this. - - - - - - - It can be personal or work email. - - - - - - - - - - - - - - - - - - - - - - -
- ) - }} - /> - ) -}) - -export default SignUpPage diff --git a/src/pages/UserSettings/content/sections/ChangeEmail.form.tsx b/src/pages/UserSettings/content/sections/ChangeEmail.form.tsx index 32f16c1d27..e80e8b715a 100644 --- a/src/pages/UserSettings/content/sections/ChangeEmail.form.tsx +++ b/src/pages/UserSettings/content/sections/ChangeEmail.form.tsx @@ -1,8 +1,8 @@ -import { useEffect, useState } from 'react' +import { useContext, useState } from 'react' import { Field, Form } from 'react-final-form' import { Button, FieldInput, Icon } from 'oa-components' import { PasswordField } from 'src/common/Form/PasswordField' -import { useCommonStores } from 'src/common/hooks/useCommonStores' +import { SessionContext } from 'src/pages/common/SessionContext' import { FormFieldWrapper } from 'src/pages/Library/Content/Common' import { UserContactError } from 'src/pages/User/contact/UserContactError' import { buttons, fields, headings } from 'src/pages/UserSettings/labels' @@ -16,22 +16,18 @@ interface IFormValues { } export const ChangeEmailForm = () => { + const user = useContext(SessionContext) const [isExpanded, setIsExpanded] = useState(false) const [submitResults, setSubmitResults] = useState(null) - const [currentEmail, setCurrentEmail] = useState(null) - const { userStore } = useCommonStores().stores const formId = 'changeEmail' const glyph = isExpanded ? 'arrow-full-up' : 'arrow-full-down' - useEffect(() => { - getUserEmail() - }, []) - const onSubmit = async (values: IFormValues) => { const { password, newEmail } = values try { - await userStore.changeUserEmail(password, newEmail) + // TODO + // await userStore.changeUserEmail(password, newEmail) setSubmitResults({ type: 'success', message: `Email changed to ${newEmail}. You've been sent two emails now(!) One to your old email address to check this was you and the other to your new address to verify it.`, @@ -44,8 +40,8 @@ export const ChangeEmailForm = () => { } const getUserEmail = async () => { - const email = await userStore.getUserEmail() - setCurrentEmail(email) + // TODO + // const email = await userStore.getUserEmail() } return ( @@ -55,14 +51,14 @@ export const ChangeEmailForm = () => { > - {isExpanded && currentEmail && ( + {isExpanded && (
{ const { password, newEmail } = values const disabled = - submitting || !password || !newEmail || newEmail === currentEmail + submitting || !password || !newEmail || newEmail === user?.email return ( { - {fields.email.title}: {currentEmail} + {fields.email.title}: {user?.email} { const [isExpanded, setIsExpanded] = useState(false) const [submitResults, setSubmitResults] = useState(null) - const { userStore } = useCommonStores().stores const formId = 'changePassword' const glyph = isExpanded ? 'arrow-full-up' : 'arrow-full-down' @@ -28,7 +27,8 @@ export const ChangePasswordForm = () => { const { oldPassword, newPassword } = values try { - await userStore.changeUserPassword(oldPassword, newPassword) + // TODO + // await userStore.changeUserPassword(oldPassword, newPassword) setSubmitResults({ type: 'success', message: `Password changed.`, diff --git a/src/pages/common/Header/Menu/Profile/Profile.tsx b/src/pages/common/Header/Menu/Profile/Profile.tsx index 2ce4b2bf22..39dc537bff 100644 --- a/src/pages/common/Header/Menu/Profile/Profile.tsx +++ b/src/pages/common/Header/Menu/Profile/Profile.tsx @@ -1,6 +1,5 @@ import { useState } from 'react' import Foco from 'react-foco' -import { useNavigate } from '@remix-run/react' import { observer } from 'mobx-react' import { MemberBadge } from 'oa-components' import { useCommonStores } from 'src/common/hooks/useCommonStores' @@ -26,7 +25,6 @@ const Profile = observer((props: IProps) => { const { userStore } = useCommonStores().stores const user = userStore.user - const navigate = useNavigate() const [state, setState] = useState({ showProfileModal: false, }) @@ -60,14 +58,7 @@ const Profile = observer((props: IProps) => { content={page.title} /> ))} - { - await userStore.logout() - navigate('/') - }} - /> + ) } diff --git a/src/pages/common/Header/Menu/ProfileModal/ProfileModal.tsx b/src/pages/common/Header/Menu/ProfileModal/ProfileModal.tsx index ac008adad9..a065b770f6 100644 --- a/src/pages/common/Header/Menu/ProfileModal/ProfileModal.tsx +++ b/src/pages/common/Header/Menu/ProfileModal/ProfileModal.tsx @@ -1,10 +1,8 @@ -import { useContext } from 'react' import styled from '@emotion/styled' import { NavLink } from '@remix-run/react' import { observer } from 'mobx-react' import { preciousPlasticTheme } from 'oa-themes' import { useCommonStores } from 'src/common/hooks/useCommonStores' -import { SessionContext } from 'src/pages/common/SessionContext' import { COMMUNITY_PAGES_PROFILE } from 'src/pages/PageList' import { Box, Flex } from 'theme-ui' @@ -50,7 +48,7 @@ const ModalLink = styled(NavLink)` } ` -const LogoutButton = styled.button` +const LogoutButton = styled.a` font-family: inherit; font-size: inherit; color: inherit; @@ -68,18 +66,12 @@ const LogoutButton = styled.button` export const ProfileModal = observer(() => { const { userStore } = useCommonStores().stores - const profile = useContext(SessionContext) - - const logout = () => { - userStore.logout() - } - return ( (isActive ? 'current' : '')} > @@ -100,7 +92,7 @@ export const ProfileModal = observer(() => { ))} - logout()} data-cy="menu-Logout"> + Log out diff --git a/src/pages/common/SessionContext.ts b/src/pages/common/SessionContext.ts index ebab8e4bb5..b77b1e3982 100644 --- a/src/pages/common/SessionContext.ts +++ b/src/pages/common/SessionContext.ts @@ -1,5 +1,5 @@ import { createContext } from 'react' -import type { DBProfile } from 'src/models/profile.model' +import type { User } from '@supabase/supabase-js' -export const SessionContext = createContext(null) +export const SessionContext = createContext(null) diff --git a/src/pages/common/UserStoreWrapper.ts b/src/pages/common/UserStoreWrapper.ts index 2fd26f2af3..a0b5dd8fb0 100644 --- a/src/pages/common/UserStoreWrapper.ts +++ b/src/pages/common/UserStoreWrapper.ts @@ -3,19 +3,30 @@ import { useCommonStores } from 'src/common/hooks/useCommonStores' import { SessionContext } from './SessionContext' -export const UserStoreWrapper = ({ - children, -}: { - children: React.ReactNode -}) => { - const profile = useContext(SessionContext) +export const UserStoreWrapper = (props: { children: React.ReactNode }) => { + const user = useContext(SessionContext) const { userStore } = useCommonStores().stores useEffect(() => { - if (profile) { - userStore.refreshActiveUserDetailsById(profile.username) + const syncProfile = async () => { + try { + const response = await fetch('/api/profile') + if (response.ok) { + const { profile } = await response.json() + + // TODO: actually use the profile from supabase? + userStore.refreshActiveUserDetailsById(profile.username) + return + } + } catch (error) { + console.error(error) + } + + userStore._updateActiveUser(null) } - }, [profile]) - return children + syncProfile() + }, [user?.id]) + + return props.children } diff --git a/src/routes/_.login.tsx b/src/routes/_.login.tsx deleted file mode 100644 index 29ad7636c4..0000000000 --- a/src/routes/_.login.tsx +++ /dev/null @@ -1,45 +0,0 @@ -/* eslint-disable unicorn/filename-case */ -import { Form, redirect, useActionData } from '@remix-run/react' -import { createSupabaseServerClient } from 'src/repository/supabase.server' - -import type { ActionFunctionArgs } from '@remix-run/node' - -export const action = async ({ request }: ActionFunctionArgs) => { - const { client, headers } = createSupabaseServerClient(request) - const formData = await request.formData() - const { error } = await client.auth.signInWithPassword({ - email: formData.get('email') as string, - password: formData.get('password') as string, - }) - - if (error) { - return Response.json({ success: false }, { headers }) - } - - // TODO: returnUrl - return redirect('/') -} - -export default function Login() { - const actionResponse = useActionData() - - return ( - <> - {!actionResponse?.success ? ( - - - -
- - - ) : ( -

Invalid credentials.

- )} - - ) -} diff --git a/src/routes/_.reset-password.tsx b/src/routes/_.reset-password.tsx new file mode 100644 index 0000000000..5a260686e4 --- /dev/null +++ b/src/routes/_.reset-password.tsx @@ -0,0 +1,138 @@ +import { Field, Form } from 'react-final-form' +import { redirect } from '@remix-run/node' +import { Link, useActionData } from '@remix-run/react' +import { Button, FieldInput, HeroBanner } from 'oa-components' +import Main from 'src/pages/common/Layout/Main' +import { createSupabaseServerClient } from 'src/repository/supabase.server' +import { getReturnUrl } from 'src/utils/redirect.server' +import { generateTags, mergeMeta } from 'src/utils/seo.utils' +import { required } from 'src/utils/validators' +import { Card, Flex, Heading, Label, Text } from 'theme-ui' + +import type { ActionFunctionArgs, LoaderFunctionArgs } from '@remix-run/node' + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const { client } = createSupabaseServerClient(request) + const { data } = await client.auth.getUser() + + if (data.user) { + return redirect(getReturnUrl(request)) + } + + return null +} + +export const action = async ({ request }: ActionFunctionArgs) => { + const { client, headers } = createSupabaseServerClient(request) + const formData = await request.formData() + + const url = new URL(request.url) + const emailRedirectUrl = url.protocol + '//' + url.host + '/update-password' + + await client.auth.resetPasswordForEmail(formData.get('email') as string, { + redirectTo: emailRedirectUrl, + }) + + // Always return success and display a generic message, even when the user doesn't exist, for security reasons. + return Response.json({ success: true }, { headers }) +} + +export const meta = mergeMeta(() => { + const title = `Login - ${import.meta.env.VITE_SITE_NAME}` + + return generateTags(title) +}) + +export default function Index() { + const actionResponse = useActionData() + + return ( +
+
{}} + render={({ submitting, invalid }) => { + return ( + + + + + + + + Reset Password + + + Go back to Login + + + + + {actionResponse?.error && ( + {actionResponse?.error} + )} + + {actionResponse?.success ? ( + + Please check your inbox (and your spam folder) for + further instructions. + + ) : ( + <> + + + + + + + + + + )} + + + + +
+ ) + }} + /> +
+ ) +} diff --git a/src/routes/_.sign-in.tsx b/src/routes/_.sign-in.tsx index 59a2edc50f..543ff7be7f 100644 --- a/src/routes/_.sign-in.tsx +++ b/src/routes/_.sign-in.tsx @@ -1,17 +1,150 @@ -/* eslint-disable unicorn/filename-case */ +import { Field, Form } from 'react-final-form' +import { redirect } from '@remix-run/node' +import { Link, useActionData } from '@remix-run/react' +import { Button, FieldInput, HeroBanner } from 'oa-components' +import { PasswordField } from 'src/common/Form/PasswordField' import Main from 'src/pages/common/Layout/Main' -import SignInPage from 'src/pages/SignIn/SignIn' -import { SeoTagsUpdateComponent } from 'src/utils/seo' +import { createSupabaseServerClient } from 'src/repository/supabase.server' +import { getReturnUrl } from 'src/utils/redirect.server' +import { generateTags, mergeMeta } from 'src/utils/seo.utils' +import { required } from 'src/utils/validators' +import { Card, Flex, Heading, Label, Text } from 'theme-ui' + +import type { ActionFunctionArgs, LoaderFunctionArgs } from '@remix-run/node' + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const { client } = createSupabaseServerClient(request) + const { data } = await client.auth.getUser() + + if (data.user) { + return redirect(getReturnUrl(request)) + } -export async function clientLoader() { return null } +export const action = async ({ request }: ActionFunctionArgs) => { + const { client, headers } = createSupabaseServerClient(request) + const formData = await request.formData() + + const { error } = await client.auth.signInWithPassword({ + email: formData.get('email') as string, + password: formData.get('password') as string, + }) + + if (error) { + return Response.json( + { error: 'Invalid username or password.' }, + { headers }, + ) + } + + return redirect(getReturnUrl(request), { headers }) +} + +export const meta = mergeMeta(() => { + const title = `Login - ${import.meta.env.VITE_SITE_NAME}` + + return generateTags(title) +}) + export default function Index() { + const actionResponse = useActionData() + return (
- - +
{}} + render={({ submitting, invalid }) => { + return ( + + + + + + + + Log in + + + Don't have an account? Sign-up here + + + + + {actionResponse?.error && ( + {actionResponse?.error} + )} + + + + + + + + + + + + + Forgotten password? + + + + + + + + + + + +
+ ) + }} + />
) } diff --git a/src/routes/_.sign-up.tsx b/src/routes/_.sign-up.tsx index 60ee3b5f9d..cf11bd41bf 100644 --- a/src/routes/_.sign-up.tsx +++ b/src/routes/_.sign-up.tsx @@ -1,17 +1,287 @@ /* eslint-disable unicorn/filename-case */ +import { Field, Form } from 'react-final-form' +import { Link, redirect, useActionData } from '@remix-run/react' +import { Button, ExternalLink, FieldInput, HeroBanner } from 'oa-components' +import { FRIENDLY_MESSAGES } from 'oa-shared' +import { PasswordField } from 'src/common/Form/PasswordField' import Main from 'src/pages/common/Layout/Main' -import SignUpPage from 'src/pages/SignUp/SignUp' -import { SeoTagsUpdateComponent } from 'src/utils/seo' +import { createSupabaseServerClient } from 'src/repository/supabase.server' +import { authServiceServer } from 'src/services/authService.server' +import { generateTags, mergeMeta } from 'src/utils/seo.utils' +import { + composeValidators, + noSpecialCharacters, + required, +} from 'src/utils/validators' +import { Card, Flex, Heading, Label, Text } from 'theme-ui' +import { bool, object, ref, string } from 'yup' + +import type { ActionFunctionArgs, LoaderFunctionArgs } from '@remix-run/node' + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const { client } = createSupabaseServerClient(request) + const { data } = await client.auth.getUser() + + if (data.user) { + return redirect('/') + } -export async function clientLoader() { return null } +export const meta = mergeMeta(() => { + const title = `Sign Up - ${import.meta.env.VITE_SITE_NAME}` + + return generateTags(title) +}) + +export const action = async ({ request }: ActionFunctionArgs) => { + const { client, headers } = createSupabaseServerClient(request) + const formData = await request.formData() + const url = new URL(request.url) + const emailRedirectUrl = url.protocol + '//' + url.host + '/update-password' + + const username = formData.get('username') as string + + if (!(await authServiceServer.isUsernameAvailable(username, client))) { + return Response.json( + { error: 'That username is already taken!' }, + { headers }, + ) + } + + const { data, error } = await client.auth.signUp({ + email: formData.get('email') as string, + password: 'test', + options: { + emailRedirectTo: emailRedirectUrl, + }, + }) + + if (error) { + if (error.code === 'weak_password') { + return Response.json({ error: error.message }, { headers }) + } + + return Response.json({ error: 'Oops, something went wrong!' }, { headers }) + } + + if (data.user) { + await authServiceServer.createUserProfile( + { user: data.user, username }, + client, + ) + } + + return redirect('/', { headers }) +} + +const rowWidth = ['100%', '100%', `100%`] + export default function Index() { + const actionResponse = useActionData() + + const validationSchema = object({ + username: string() + .min(2, 'Username must be at least 2 characters') + .required('Required'), + email: string() + .email(FRIENDLY_MESSAGES['auth/invalid-email']) + .required('Required'), + password: string() + .min(6, 'Password must be at least 6 characters') + .required('Password is required'), + 'confirm-password': string() + .oneOf([ref('password'), ''], 'Your new password does not match') + .required('Password confirm is required'), + consent: bool().oneOf([true], 'Consent is required'), + }) + return (
- - +
{}} + validate={async (values: any) => { + try { + await validationSchema.validate(values, { abortEarly: false }) + } catch (err) { + return err.inner.reduce( + (acc: any, error) => ({ + ...acc, + [error.path]: error.message, + }), + {}, + ) + } + }} + render={({ submitting, invalid, pristine }) => { + const disabled = invalid || submitting + return ( + + + + + + + + Create an account + + + Already have an account? Sign-in here + + + + + {actionResponse?.error && pristine && ( + {actionResponse?.error} + )} + + + + + + + + + It can be personal or work email. + + + + + + + + + + + + + + + + + + + + + + +
+ ) + }} + />
) } diff --git a/src/routes/_.tsx b/src/routes/_.tsx index 718f5d0c13..91179ae588 100644 --- a/src/routes/_.tsx +++ b/src/routes/_.tsx @@ -15,26 +15,19 @@ import { SessionContext } from 'src/pages/common/SessionContext' import { StickyButton } from 'src/pages/common/StickyButton' import { UserStoreWrapper } from 'src/pages/common/UserStoreWrapper' import { createSupabaseServerClient } from 'src/repository/supabase.server' -import { profileServiceServer } from 'src/services/profileService.server' import { Flex } from 'theme-ui' import type { LoaderFunctionArgs } from '@remix-run/node' -import type { DBProfile } from 'src/models/profile.model' export async function loader({ request }: LoaderFunctionArgs) { const environment = getEnvVariables() const { client } = createSupabaseServerClient(request) - let profile: DBProfile | null = null const { data: { user }, } = await client.auth.getUser() - if (user) { - profile = await profileServiceServer.getByAuthId(user.id, client) - } - - return Response.json({ environment, profile }) + return Response.json({ environment, user }) } export function HydrateFallback() { @@ -45,12 +38,13 @@ export function HydrateFallback() { // This is a Layout file, it will render for all routes that have _. prefix. export default function Index() { - const { environment, profile } = useLoaderData() + const { environment, user } = useLoaderData() return ( - + + {JSON.stringify(user)} { + const { client, headers } = createSupabaseServerClient(request) + + const { + data: { user }, + } = await client.auth.getUser() + + if (!user) { + return Response.json({}, { headers, status: 401 }) + } + + const { data } = await client + .from('profiles') + .select('*') + .eq('auth_id', user.id) + .single() + + return Response.json({ profile: data }, { headers, status: 200 }) +} diff --git a/src/routes/logout.ts b/src/routes/logout.ts new file mode 100644 index 0000000000..7fd4a33fff --- /dev/null +++ b/src/routes/logout.ts @@ -0,0 +1,16 @@ +/* eslint-disable unicorn/filename-case */ +import { redirect } from '@remix-run/react' +import { createSupabaseServerClient } from 'src/repository/supabase.server' + +import type { ActionFunctionArgs } from '@remix-run/node' + +export const loader = async ({ request }: ActionFunctionArgs) => { + const { client, headers } = createSupabaseServerClient(request) + const { error } = await client.auth.signOut() + + if (error) { + return Response.json({ success: false }, { headers }) + } + + return redirect('/') +} diff --git a/src/services/authService.server.ts b/src/services/authService.server.ts new file mode 100644 index 0000000000..6f9517a6e1 --- /dev/null +++ b/src/services/authService.server.ts @@ -0,0 +1,38 @@ +import type { SupabaseClient, User } from '@supabase/supabase-js' + +type CreateProfileArgs = { + user: User + username: string +} + +const createUserProfile = async ( + args: CreateProfileArgs, + client: SupabaseClient, +) => { + return await client.from('profiles').insert({ + auth_id: args.user.id, + username: args.username, + display_name: args.username, + is_verified: false, + firebase_auth_id: '', + tenant_id: process.env.TENANT_ID, + }) +} + +const isUsernameAvailable = async ( + username: string, + client: SupabaseClient, +) => { + const result = await client + .from('profiles') + .select('id') + .eq('username', username) + .limit(1) + + return !result.data?.at(0) +} + +export const authServiceServer = { + createUserProfile, + isUsernameAvailable, +} diff --git a/src/stores/User/user.store.ts b/src/stores/User/user.store.ts index 894fd6f098..5fd05ab291 100644 --- a/src/stores/User/user.store.ts +++ b/src/stores/User/user.store.ts @@ -1,22 +1,10 @@ import pkg from 'countries-list' -import { - createUserWithEmailAndPassword, - onAuthStateChanged, - reauthenticateWithCredential, - sendEmailVerification, - sendPasswordResetEmail, - updateEmail, - updatePassword, - updateProfile, -} from 'firebase/auth' import lodash from 'lodash' import { action, makeObservable, observable, toJS } from 'mobx' import { EmailNotificationFrequency } from 'oa-shared' import { logger } from '../../logger' -import { auth, EmailAuthProvider } from '../../utils/firebase' import { getLocationData } from '../../utils/getLocationData' -import { formatLowerNoSpecial } from '../../utils/helpers' import { ModuleStore } from '../common/module.store' import { Storage } from '../storage' @@ -29,7 +17,6 @@ import type { IUserBadges, IUserDB, } from 'oa-shared' -import type { IFirebaseUser } from 'src/utils/firebase' import type { IRootStore } from '../RootStore' /* The user store listens to login events through the firebase api and exposes logged in user information via an observer. @@ -60,7 +47,6 @@ export class UserStore extends ModuleStore { updateUserImpact: action, _updateActiveUser: action, }) - this._listenToAuthStateChanges() } public async getUsersStartingWith(prefix: string, limit?: number) { @@ -79,43 +65,6 @@ export class UserStore extends ModuleStore { this.updateStatus[update] = true } - // when registering a new user create firebase auth profile as well as database user profile - public async registerNewUser( - email: string, - password: string, - displayName: string, - ) { - // stop auto detect of login as will pick up with incomplete information during registration - this._unsubscribeFromAuthStateChanges() - await createUserWithEmailAndPassword(auth, email, password) - // once registered populate auth profile displayname with the chosen username - if (auth?.currentUser) { - await this.safeUpdateProfile(auth.currentUser, displayName) - // populate db user profile and resume auth listener - await this._createUserProfile('registration') - // when checking auth state change also send confirmation email - this._listenToAuthStateChanges(true) - } - } - - private async safeUpdateProfile(currentUser: User, displayName: string) { - // It should be possible to pass photoURL as null to updateProfile - // but the emulator counts this as an error: - // auth/invalid-json-payload-received.-/photourl-must-be-string - // - // source: https://github.com/firebase/firebase-tools/issues/6424 - if (currentUser.photoURL === null) { - await updateProfile(currentUser, { - displayName, - }) - } else { - await updateProfile(currentUser, { - displayName, - photoURL: currentUser.photoURL, - }) - } - } - public async getUserByUsername(username: string): Promise { const [user] = await this.db .collection(COLLECTION_NAME) @@ -318,144 +267,47 @@ export class UserStore extends ModuleStore { this._updateActiveUser(user) } - public async sendEmailVerification() { - logger.info('sendEmailVerification', { authCurrentUser: auth.currentUser }) - if (auth.currentUser) { - return sendEmailVerification(auth.currentUser) - } - } - - public async getUserEmail() { - const user = this.authUser as firebase.default.User - return user.email as string - } - public async getUserEmailIsVerified() { if (!this.authUser) return return this.authUser.emailVerified } - public async changeUserPassword(oldPassword: string, newPassword: string) { - if (!this.authUser) return - - const user = this.authUser as firebase.default.User - const credentials = EmailAuthProvider.credential( - user.email as string, - oldPassword, - ) - await reauthenticateWithCredential(user, credentials) - return updatePassword(user, newPassword) - } - - public async changeUserEmail(password: string, newEmail: string) { - if (!this.authUser) return - - const user = this.authUser as firebase.default.User - const credentials = EmailAuthProvider.credential( - user.email as string, - password, - ) - await reauthenticateWithCredential(user, credentials) - await updateEmail(user, newEmail) - return this.sendEmailVerification() - } - - public async sendPasswordResetEmail(email: string) { - return sendPasswordResetEmail(auth, email) - } - - public async logout() { - return auth.signOut() - } - - public async deleteUser(reauthPw: string) { - // as delete operation is sensitive requires user to revalidate credentials first - const authUser = auth.currentUser as firebase.default.User - const credential = EmailAuthProvider.credential( - authUser.email as string, - reauthPw, - ) - await authUser.reauthenticateWithCredential(credential) - const user = this.user as IUser - await this.db.collection(COLLECTION_NAME).doc(user.userName).delete() - await authUser.delete() - // TODO - delete user avatar - // TODO - show deleted notification - // TODO show notification if invalid credential - } - - // handle user sign in, when firebase authenticates want to also fetch user document from the database - private async _userSignedIn(user: IFirebaseUser | null) { - if (!user) return null - - // legacy user formats did not save names so get profile via email - this option be removed in later version - // (assumes migration strategy and check) - const userMeta = await this.getUserProfile(user.uid) - if (!userMeta) return - - this._updateActiveUser(userMeta) - logger.debug('user signed in', user) - - await this._updateUserRequest(userMeta._id, {}) - return userMeta - } - - private async _createUserProfile(trigger: string) { - const authUser = auth.currentUser as firebase.default.User - const displayName = authUser.displayName as string - const userName = formatLowerNoSpecial(displayName) - const dbRef = this.db.collection(COLLECTION_NAME).doc(userName) - - if (userName === authUser.uid) { - logger.error( - 'attempted to create duplicate user record with authId', - userName, - ) - throw new Error('attempted to create duplicate user') - } - - logger.debug('creating user profile', userName) - if (!userName) { - throw new Error('No Username Provided') - } - const user: IUser = { - coverImages: [], - links: [], - verified: false, - _authID: authUser.uid, - displayName, - userName, - notifications: [], - profileCreated: new Date().toISOString(), - profileCreationTrigger: trigger, - profileType: 'member', - notification_settings: { - emailFrequency: EmailNotificationFrequency.WEEKLY, - }, - } - // update db - await dbRef.set(user) - } - - // use firebase auth to listen to change to signed in user - // on sign in want to load user profile - // strange implementation return the unsubscribe object on subscription, so stored - // to authUnsubscribe variable for use later - private _listenToAuthStateChanges(checkEmailVerification = false) { - this.authUnsubscribe = onAuthStateChanged(auth, (authUser) => { - this.authUser = authUser - if (authUser) { - this._userSignedIn(authUser as firebase.default.User) - // send verification email if not verified and after first sign-up only - if (!authUser.emailVerified && checkEmailVerification) { - this.sendEmailVerification() - } - } else { - // Explicitly update user to null when logged out - this._updateActiveUser(null) - } - }) - } + // private async _createUserProfile(trigger: string) { + // const authUser = auth.currentUser as firebase.default.User + // const displayName = authUser.displayName as string + // const userName = formatLowerNoSpecial(displayName) + // const dbRef = this.db.collection(COLLECTION_NAME).doc(userName) + + // if (userName === authUser.uid) { + // logger.error( + // 'attempted to create duplicate user record with authId', + // userName, + // ) + // throw new Error('attempted to create duplicate user') + // } + + // logger.debug('creating user profile', userName) + // if (!userName) { + // throw new Error('No Username Provided') + // } + // const user: IUser = { + // coverImages: [], + // links: [], + // verified: false, + // _authID: authUser.uid, + // displayName, + // userName, + // notifications: [], + // profileCreated: new Date().toISOString(), + // profileCreationTrigger: trigger, + // profileType: 'member', + // notification_settings: { + // emailFrequency: EmailNotificationFrequency.WEEKLY, + // }, + // } + // // update db + // await dbRef.set(user) + // } private async _updateUserRequest(userId: string, updateFields: PartialUser) { const _lastActive = new Date().toISOString() diff --git a/src/utils/domain.server.ts b/src/utils/domain.server.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/utils/redirect.server.ts b/src/utils/redirect.server.ts new file mode 100644 index 0000000000..bf588c671c --- /dev/null +++ b/src/utils/redirect.server.ts @@ -0,0 +1,8 @@ +export const getReturnUrl = (request: Request) => { + const url = new URL(request.url) + const params = new URLSearchParams(url.search) + + return params.has('returnUrl') + ? decodeURIComponent(params.get('returnUrl') as string) + : '/' +}