Add Supertoken login page

This commit is contained in:
Arnab Chakraborty
2024-07-25 00:17:33 +03:00
parent d67cbec7df
commit 9cc85372ae
8 changed files with 474 additions and 119 deletions

View File

@@ -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:*",

View File

@@ -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 (
<Form
onSubmit={form.handleSubmit((data) => {
const [showPassword, setShowPassword] = useState(false);
const form = useZodForm({
schema: LoginSchema,
defaultValues: {
email: '',
password: ''
}
});
return (
<Form
onSubmit={form.handleSubmit(async (data) => {
// handle login submission
console.log(data);
})}
await signInClicked(data.email, data.password);
})}
form={form}
>
<div className='flex flex-col gap-1.5'>
<Controller
>
<div className="flex flex-col gap-1.5">
<Controller
control={form.control}
name="email"
render={({ field }) => (
@@ -47,33 +94,47 @@ const Login = () => {
control={form.control}
name="password"
render={({ field }) => (
<Input
{...field}
placeholder="Password"
error={Boolean(form.formState.errors.password?.message)}
type="password"
className='w-full'
disabled={form.formState.isSubmitting}
/>
<div className="relative flex items-center justify-center">
<Input
{...field}
placeholder="Password"
error={Boolean(form.formState.errors.password?.message)}
className="w-full"
disabled={form.formState.isSubmitting}
type={showPassword ? 'text' : 'password'}
onPaste={(e) => {
const pastedText = e.clipboardData.getData('text');
field.onChange(pastedText);
}}
/>
<Button
variant="gray"
className="absolute right-2"
onClick={() => setShowPassword(!showPassword)}
>
{!showPassword ? <EyeClosed /> : <Eye />}
</Button>
</div>
)}
/>
{form.formState.errors.password && (
<p className="text-xs text-red-500">{form.formState.errors.password.message}</p>
)}
<Button
<Button
type="submit"
className='mx-auto mt-2 w-full'
className="mx-auto mt-2 w-full"
variant="accent"
onClick={form.handleSubmit((data) => {
onClick={form.handleSubmit(async (data) => {
console.log(data);
await signInClicked(data.email, data.password);
})}
disabled={form.formState.isSubmitting}
>
Submit
</Button>
</div>
</Form>
)
}
</div>
</Form>
);
};
export default Login;
export default Login;

View File

@@ -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<typeof RegisterSchema>;
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<typeof RegisterSchema>
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<RegisterType>(
{
const form = useForm<RegisterType>({
resolver: zodResolver(RegisterSchema),
defaultValues: {
email: '',
password: '',
confirmPassword: '',
confirmPassword: ''
}
})
});
return (
<Form
onSubmit={form.handleSubmit((data) => {
// 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}
>
<div className='flex flex-col gap-1.5'>
<Controller
control={form.control}
name="email"
render={({ field }) => (
<Input
{...field}
placeholder="Email"
error={Boolean(form.formState.errors.email?.message)}
type="email"
disabled={form.formState.isSubmitting}
/>
)}
/>
{form.formState.errors.email && (
<p className="text-xs text-red-500">{form.formState.errors.email.message}</p>
)}
<Controller
control={form.control}
name="password"
render={({ field }) => (
<Input
{...field}
placeholder="Password"
error={Boolean(form.formState.errors.password?.message)}
type="password"
className='w-full'
disabled={form.formState.isSubmitting}
/>
)}
/>
{form.formState.errors.password && (
<p className="text-xs text-red-500">{form.formState.errors.password.message}</p>
)}
<Controller
control={form.control}
name="confirmPassword"
render={({ field }) => (
<Input
{...field}
placeholder="Confirm Password"
error={Boolean(form.formState.errors.confirmPassword?.message)}
type="password"
className='w-full'
disabled={form.formState.isSubmitting}
/>
)}
<div className="flex flex-col gap-1.5">
<Controller
control={form.control}
name="email"
render={({ field }) => (
<Input
{...field}
placeholder="Email"
error={Boolean(form.formState.errors.email?.message)}
type="email"
disabled={form.formState.isSubmitting}
/>
)}
/>
{form.formState.errors.confirmPassword && (
<p className="text-xs text-red-500">{form.formState.errors.confirmPassword.message}</p>
)}
{form.formState.errors.email && (
<p className="text-xs text-red-500">{form.formState.errors.email.message}</p>
)}
<Controller
control={form.control}
name="password"
render={({ field }) => (
<div className="relative flex items-center justify-center">
<Input
{...field}
placeholder="Password"
error={Boolean(form.formState.errors.password?.message)}
className="w-full"
disabled={form.formState.isSubmitting}
type={showPassword ? 'text' : 'password'}
onPaste={(e) => {
const pastedText = e.clipboardData.getData('text');
field.onChange(pastedText);
}}
/>
<Button
variant="gray"
className="absolute right-2"
onClick={() => setShowPassword(!showPassword)}
>
{!showPassword ? <EyeClosed /> : <Eye />}
</Button>
</div>
)}
/>
{form.formState.errors.password && (
<p className="text-xs text-red-500">{form.formState.errors.password.message}</p>
)}
<Controller
control={form.control}
name="confirmPassword"
render={({ field }) => (
<div className="relative flex items-center justify-center">
<Input
{...field}
placeholder="Confirm Password"
error={Boolean(form.formState.errors.confirmPassword?.message)}
className="w-full"
disabled={form.formState.isSubmitting}
type={showPassword ? 'text' : 'password'}
/>
<Button
variant="gray"
className="absolute right-2"
onClick={() => setShowPassword(!showPassword)}
>
{!showPassword ? <EyeClosed /> : <Eye />}
</Button>
</div>
)}
/>
{form.formState.errors.confirmPassword && (
<p className="text-xs text-red-500">
{form.formState.errors.confirmPassword.message}
</p>
)}
<Button
type="submit"
className='mx-auto mt-2 w-full'
variant="accent"
onClick={form.handleSubmit((data) => {
console.log(data);
})}
disabled={form.formState.isSubmitting}
>
Submit
</Button>
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
</Button>
</div>
</Form>
)
}
);
};
export default Register;

View File

@@ -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);
},
};
}

View File

@@ -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;
},
},
};
}

View File

@@ -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: {

View File

@@ -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",

BIN
pnpm-lock.yaml generated
View File

Binary file not shown.