From 9cc85372ae33e70e60e211a081a2c7384eda75e4 Mon Sep 17 00:00:00 2001 From: Arnab Chakraborty <11457760+Rocky43007@users.noreply.github.com> Date: Thu, 25 Jul 2024 00:17:33 +0300 Subject: [PATCH] Add Supertoken login page --- apps/desktop/package.json | 3 +- .../settings/client/account/Login.tsx | 133 +++++++--- .../settings/client/account/Register.tsx | 240 ++++++++++++------ .../client/account/handlers/cookieHandler.ts | 106 ++++++++ .../client/account/handlers/windowHandler.ts | 88 +++++++ interface/index.tsx | 22 ++ interface/package.json | 1 + pnpm-lock.yaml | Bin 1036427 -> 1037545 bytes 8 files changed, 474 insertions(+), 119 deletions(-) create mode 100644 interface/app/$libraryId/settings/client/account/handlers/cookieHandler.ts create mode 100644 interface/app/$libraryId/settings/client/account/handlers/windowHandler.ts diff --git a/apps/desktop/package.json b/apps/desktop/package.json index e354d3acd..d818718a6 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -29,7 +29,8 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "=6.20.1", - "sonner": "^1.0.3" + "sonner": "^1.0.3", + "supertokens-web-js": "^0.13.0" }, "devDependencies": { "@sd/config": "workspace:*", diff --git a/interface/app/$libraryId/settings/client/account/Login.tsx b/interface/app/$libraryId/settings/client/account/Login.tsx index 416f0a58b..c6580f307 100644 --- a/interface/app/$libraryId/settings/client/account/Login.tsx +++ b/interface/app/$libraryId/settings/client/account/Login.tsx @@ -1,33 +1,80 @@ -import { useZodForm } from '@sd/client'; -import { Button, Form, Input, z } from '@sd/ui'; - +import { Eye, EyeClosed } from '@phosphor-icons/react'; +import { useState } from 'react'; import { Controller } from 'react-hook-form'; +import { signIn, signUp } from 'supertokens-web-js/recipe/emailpassword'; +import { useZodForm } from '@sd/client'; +import { Button, Form, Input, toast, z } from '@sd/ui'; +async function signInClicked(email: string, password: string) { + try { + const response = await signIn({ + formFields: [ + { + id: 'email', + value: email + }, + { + id: 'password', + value: password + } + ] + }); + console.log('[signInClicked] response', response); + + if (response.status === 'FIELD_ERROR') { + response.formFields.forEach((formField) => { + if (formField.id === 'email') { + // Email validation failed (for example incorrect email syntax). + toast.error(formField.error); + } + }); + } else if (response.status === 'WRONG_CREDENTIALS_ERROR') { + toast.error('Email & password combination is incorrect.'); + } else if (response.status === 'SIGN_IN_NOT_ALLOWED') { + // the reason string is a user friendly message + // about what went wrong. It can also contain a support code which users + // can tell you so you know why their sign in was not allowed. + toast.error(response.reason); + } else { + // sign in successful. The session tokens are automatically handled by + // the frontend SDK. + console.log('Sign in successful'); + } + } catch (err: any) { + if (err.isSuperTokensGeneralError === true) { + // this may be a custom error message sent from the API by you. + toast.error(err.message); + } else { + toast.error('Oops! Something went wrong.'); + } + } +} const LoginSchema = z.object({ email: z.string().email(), - password: z.string().min(6), -}) + password: z.string().min(6) +}); const Login = () => { - const form = useZodForm( - { - schema: LoginSchema, - defaultValues: { - email: '', - password: '', - } - }) - return ( -
{ + const [showPassword, setShowPassword] = useState(false); + const form = useZodForm({ + schema: LoginSchema, + defaultValues: { + email: '', + password: '' + } + }); + return ( + { // handle login submission console.log(data); - })} + await signInClicked(data.email, data.password); + })} form={form} - > -
- +
+ ( @@ -47,33 +94,47 @@ const Login = () => { control={form.control} name="password" render={({ field }) => ( - +
+ { + const pastedText = e.clipboardData.getData('text'); + field.onChange(pastedText); + }} + /> + +
)} /> {form.formState.errors.password && (

{form.formState.errors.password.message}

)} - -
- - ) -} +
+ + ); +}; -export default Login; \ No newline at end of file +export default Login; diff --git a/interface/app/$libraryId/settings/client/account/Register.tsx b/interface/app/$libraryId/settings/client/account/Register.tsx index eba8497f5..0551446d0 100644 --- a/interface/app/$libraryId/settings/client/account/Register.tsx +++ b/interface/app/$libraryId/settings/client/account/Register.tsx @@ -1,105 +1,181 @@ import { zodResolver } from '@hookform/resolvers/zod'; -import { Button, Form, Input, z } from '@sd/ui'; - +import { Eye, EyeClosed } from '@phosphor-icons/react'; +import { useState } from 'react'; import { Controller, useForm } from 'react-hook-form'; +import { signUp } from 'supertokens-web-js/recipe/emailpassword'; +import { Button, Form, Input, toast, z } from '@sd/ui'; +const RegisterSchema = z + .object({ + email: z.string().email(), + password: z.string().min(6), + confirmPassword: z.string().min(6) + }) + .refine((data) => data.password === data.confirmPassword, { + message: 'Passwords do not match', + path: ['confirmPassword'] + }); +type RegisterType = z.infer; -const RegisterSchema = z.object({ - email: z.string().email(), - password: z.string().min(6), - confirmPassword: z.string().min(6), -}).refine(data => data.password === data.confirmPassword, { - message: 'Passwords do not match', - path: ['confirmPassword'] -}) -type RegisterType = z.infer +async function signUpClicked(email: string, password: string) { + try { + const response = await signUp({ + formFields: [ + { + id: 'email', + value: email + }, + { + id: 'password', + value: password + } + ] + }); + + if (response.status === 'FIELD_ERROR') { + // one of the input formFields failed validaiton + response.formFields.forEach((formField) => { + if (formField.id === 'email') { + // Email validation failed (for example incorrect email syntax), + // or the email is not unique. + toast.error(formField.error); + } else if (formField.id === 'password') { + // Password validation failed. + // Maybe it didn't match the password strength + toast.error(formField.error); + } + }); + } else if (response.status === 'SIGN_UP_NOT_ALLOWED') { + // the reason string is a user friendly message + // about what went wrong. It can also contain a support code which users + // can tell you so you know why their sign up was not allowed. + toast.error(response.reason); + } else { + // sign up successful. The session tokens are automatically handled by + // the frontend SDK. + window.location.href = '/homepage'; + } + } catch (err: any) { + if (err.isSuperTokensGeneralError === true) { + // this may be a custom error message sent from the API by you. + toast.error(err.message); + } else { + toast.error('Oops! Something went wrong.'); + } + } +} const Register = () => { - + const [showPassword, setShowPassword] = useState(false); // useZodForm seems to be out-dated or needs //fixing as it does not support the schema using zod.refine - const form = useForm( - { + const form = useForm({ resolver: zodResolver(RegisterSchema), defaultValues: { email: '', password: '', - confirmPassword: '', + confirmPassword: '' } - }) + }); return (
{ - // handle login submission - return console.log(data); + onSubmit={form.handleSubmit(async (data) => { + // handle login submission + console.log(data); + await signUpClicked(data.email, data.password); })} - form={form} + form={form} > -
- ( - - )} - /> - {form.formState.errors.email && ( -

{form.formState.errors.email.message}

- )} - ( - - )} - /> - {form.formState.errors.password && ( -

{form.formState.errors.password.message}

- )} - ( - - )} +
+ ( + + )} /> - {form.formState.errors.confirmPassword && ( -

{form.formState.errors.confirmPassword.message}

- )} + {form.formState.errors.email && ( +

{form.formState.errors.email.message}

+ )} + ( +
+ { + const pastedText = e.clipboardData.getData('text'); + field.onChange(pastedText); + }} + /> + +
+ )} + /> + {form.formState.errors.password && ( +

{form.formState.errors.password.message}

+ )} + ( +
+ + +
+ )} + /> + {form.formState.errors.confirmPassword && ( +

+ {form.formState.errors.confirmPassword.message} +

+ )} + type="submit" + className="mx-auto mt-2 w-full" + variant="accent" + onClick={form.handleSubmit(async (data) => { + console.log(data); + await signUpClicked(data.email, data.password); + })} + disabled={form.formState.isSubmitting} + > + Submit +
- ) -} + ); +}; export default Register; diff --git a/interface/app/$libraryId/settings/client/account/handlers/cookieHandler.ts b/interface/app/$libraryId/settings/client/account/handlers/cookieHandler.ts new file mode 100644 index 000000000..2de7bb498 --- /dev/null +++ b/interface/app/$libraryId/settings/client/account/handlers/cookieHandler.ts @@ -0,0 +1,106 @@ +import { CookieHandlerInterface } from "supertokens-website/utils/cookieHandler/types"; + +const frontendCookiesKey = "frontendCookies"; + +function getCookiesFromStorage(): string { + const cookiesFromStorage = window.localStorage.getItem(frontendCookiesKey); + + if (cookiesFromStorage === null) { + window.localStorage.setItem(frontendCookiesKey, "[]"); + return ""; + } + + /** + * Because we store cookies in local storage, we need to manually check + * for expiry before returning all cookies + */ + const cookieArrayInStorage: string[] = JSON.parse(cookiesFromStorage); + const cookieArrayToReturn: string[] = []; + + for (let cookieIndex = 0; cookieIndex < cookieArrayInStorage.length; cookieIndex++) { + const currentCookieString = cookieArrayInStorage[cookieIndex]; + const parts = currentCookieString?.split(";") ?? []; + let expirationString: string = ""; + + for (let partIndex = 0; partIndex < parts.length; partIndex++) { + const currentPart = parts[partIndex]; + + if (currentPart?.toLocaleLowerCase().includes("expires=")) { + expirationString = currentPart; + break; + } + } + + if (expirationString !== "") { + const expirationValueString = expirationString.split("=")[1]; + const expirationDate = expirationValueString ? new Date(expirationValueString) : null; + const currentTimeInMillis = Date.now(); + + // if the cookie has expired, we skip it + if (expirationDate && expirationDate.getTime() < currentTimeInMillis) { + continue; + } + } + + if (currentCookieString !== undefined) { + cookieArrayToReturn.push(currentCookieString); + } + } + + /** + * After processing and removing expired cookies we need to update the cookies + * in storage so we dont have to process the expired ones again + */ + window.localStorage.setItem(frontendCookiesKey, JSON.stringify(cookieArrayToReturn)); + + return cookieArrayToReturn.join("; "); +} + +function setCookieToStorage(cookieString: string) { + const cookieName = cookieString.split(";")[0]?.split("=")[0]; + const cookiesFromStorage = window.localStorage.getItem(frontendCookiesKey); + let cookiesArray: string[] = []; + + if (cookiesFromStorage !== null) { + const cookiesArrayFromStorage: string[] = JSON.parse(cookiesFromStorage); + cookiesArray = cookiesArrayFromStorage; + } + + let cookieIndex = -1; + + for (let i = 0; i < cookiesArray.length; i++) { + const currentCookie = cookiesArray[i]; + + if (currentCookie?.indexOf(`${cookieName}=`) !== -1) { + cookieIndex = i; + break; + } + } + + /** + * If a cookie with the same name already exists (index != -1) then we + * need to remove the old value and replace it with the new one. + * + * If it does not exist then simply add the new cookie + */ + if (cookieIndex !== -1) { + cookiesArray[cookieIndex] = cookieString; + } else { + cookiesArray.push(cookieString); + } + + window.localStorage.setItem(frontendCookiesKey, JSON.stringify(cookiesArray)); +} + +export default function getCookieHandler(original: CookieHandlerInterface): CookieHandlerInterface { + return { + ...original, + getCookie: async function () { + const cookies = getCookiesFromStorage(); + return cookies; + }, + setCookie: async function (cookieString: string) { + setCookieToStorage(cookieString); + }, + }; +} diff --git a/interface/app/$libraryId/settings/client/account/handlers/windowHandler.ts b/interface/app/$libraryId/settings/client/account/handlers/windowHandler.ts new file mode 100644 index 000000000..c5f095733 --- /dev/null +++ b/interface/app/$libraryId/settings/client/account/handlers/windowHandler.ts @@ -0,0 +1,88 @@ +import { WindowHandlerInterface } from "supertokens-website/utils/windowHandler/types"; + +/** + * This example app uses HashRouter from react-router-dom. The SuperTokens SDK relies on + * some window properties like location hash, query params etc. Because HashRouter places + * everything other than the website base in the location hash, we need to add custom + * handling for some of the properties of the Window API + */ +export default function getWindowHandler(original: WindowHandlerInterface): WindowHandlerInterface { + return { + ...original, + location: { + ...original.location, + getSearch: function () { + const currentURL = window.location.href; + const firstQuestionMarkIndex = currentURL.indexOf("?"); + + if (firstQuestionMarkIndex !== -1) { + // Return the query string from the url + let queryString = currentURL.substring(firstQuestionMarkIndex); + + // Remove any hash + if (queryString.includes("#")) { + queryString = queryString.split("#")[0] ?? ""; + } + + return queryString; + } + + return ""; + }, + getHash: function () { + // Location hash always starts with a #, when returning we prepend it + let locationHash = window.location.hash; + + if (locationHash === "") { + return "#"; + } + + if (locationHash.startsWith("#")) { + // Remove the starting pound symbol + locationHash = locationHash.substring(1); + } + + if (!locationHash.includes("#")) { + // The remaining string did not have any "#" character + return "#"; + } + + const locationSplit = locationHash.split("#"); + + if (locationSplit.length < 2) { + // The string contains a "#" but is followed by nothing + return "#"; + } + + return "#" + locationSplit[1]; + }, + getOrigin: function () { + return "http://localhost:8001"; + }, + getHostName: function () { + return "localhost"; + }, + getPathName: function () { + let locationHash = window.location.hash; + + if (locationHash === "") { + return ""; + } + + if (locationHash.startsWith("#")) { + // Remove the starting pound symbol + locationHash = locationHash.substring(1); + } + + locationHash = locationHash.split("?")[0] ?? ""; + + if (locationHash.includes("#")) { + // Remove location hash + locationHash = locationHash.split("#")[0] ?? ""; + } + + return locationHash; + }, + }, + }; +} diff --git a/interface/index.tsx b/interface/index.tsx index 1d8799098..626b03b0a 100644 --- a/interface/index.tsx +++ b/interface/index.tsx @@ -5,6 +5,10 @@ import relativeTime from 'dayjs/plugin/relativeTime'; import { PropsWithChildren, Suspense } from 'react'; import { I18nextProvider } from 'react-i18next'; import { RouterProvider, RouterProviderProps } from 'react-router-dom'; +import SuperTokens from 'supertokens-web-js'; +import EmailPassword from 'supertokens-web-js/recipe/emailpassword'; +import Session from 'supertokens-web-js/recipe/session'; +import ThirdParty from 'supertokens-web-js/recipe/thirdparty'; import { InteropProviderReact, P2PContextProvider, @@ -15,6 +19,8 @@ import { import { toast, TooltipProvider } from '@sd/ui'; import { createRoutes } from './app'; +import getCookieHandler from './app/$libraryId/settings/client/account/handlers/cookieHandler'; +import getWindowHandler from './app/$libraryId/settings/client/account/handlers/windowHandler'; import { SpacedropProvider } from './app/$libraryId/Spacedrop'; import i18n from './app/I18n'; import { Devtools } from './components/Devtools'; @@ -42,6 +48,22 @@ import('@sentry/browser').then(({ init, Integrations }) => { }); }); +SuperTokens.init({ + // enableDebugLogs: true, + appInfo: { + apiDomain: 'http://localhost:9000', + apiBasePath: '/api/auth', + appName: 'Spacedrive Auth Service' + }, + cookieHandler: getCookieHandler, + windowHandler: getWindowHandler, + recipeList: [ + Session.init({ tokenTransferMethod: 'header' }), + EmailPassword.init(), + ThirdParty.init() + ] +}); + export type Router = RouterProviderProps['router']; export function SpacedriveRouterProvider(props: { diff --git a/interface/package.json b/interface/package.json index 133278847..92aa3ff99 100644 --- a/interface/package.json +++ b/interface/package.json @@ -65,6 +65,7 @@ "rooks": "^7.14.1", "solid-js": "^1.8.8", "solid-refresh": "^0.6.3", + "supertokens-web-js": "^0.13.0", "use-count-up": "^3.0.1", "use-debounce": "^9.0.4", "use-resize-observer": "^9.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4ead2ad2cd9a3bd074152feb81e0647943a29ef7..010d7ed969165d6bc46cade0132e96cd97696e78 100644 GIT binary patch delta 902 zcmaKrO>EL&7{@cmK15_&G)qk8(yJ1nrIZaaGN>C{yM9o{`0^qz6lhC%OM!LF=wdu@ z@UjG#9;e>CId>p2#+xRd3}=r!Eiv)xZFWxGlA(v+>3#CN&-4HPpJy)@k6$mo`8;_8 zK^iSh)SFNdYYn#{0=L|KGIFb%cs^}IJs95o{toFrmLD9FQ$ssb|EwK7t@WT!7j`=f zyF(UNdJDV5{kL{^GY=Z>Dl90b@O8|UDhFz-c?gnH5*69poLs>IS#8Ou6y&Jpic>Wp zrRq3~Z3WiW^2=1JlJsu|;YNa^v>e0da7F+Ep2?QD3L6F9U}c+QsI+c$PDq=@h{dSI zSnq@!|8{r!?_P4l4L3zymx^KpH4lE+?(FC&9V2ChrHZ14GeE)VsEgHVT8nQQ{=|A) zuO^~E-)0M2hLkNpd?l8ydW(5U6>Jye4Sq@Es~ND)SLL`Dwp)5R9DZcC574v` zITI*kJtBxMHq0-F<1;%H3g_dX+VZbpB-GSE0nJiAkQ2Nx42dFSiVss(52rVek+QsVPQ$))*fDpDE1yPTTE_ zuL4Lfo2Es>pPmPGqK>k2L{r|V)ueod+0LZ+TFXV?gp99}e#3ye!O~c6rBwD2JQJ*A zCDmVcQAutsCDS5Zrt8Z~RW%w8caGF?i>nrPi)+0j)$ty4oF^iJnL==4)_gJS7&W~I zPJ3r>-(hjRx3}-S`7t=yJ`ZXwfIR%4&Sw9UVXj kM*h~&>`yvpP58k%7C7G~gkT=)1P1iI{CV$HapG9{3Hq2e=Kufz delta 122 zcmaF4(!P74{f3hQljlx~o&2GgXLG%vV14u53GH_$Faj~t_PZ09Pi>n1W)Y9@^uHn; z^6f<~EI`Z(#B4y!zP-qWV~y5yk4%1^wnC1!Le92AuC_w%ZG}854BLUafS7mtn?-zF SN5N{Q6>@NIm&@cooecmmD>HTg