mirror of
https://github.com/spacedriveapp/spacedrive.git
synced 2026-05-05 13:52:56 -04:00
abstracted out auth page and restyled
This commit is contained in:
@@ -1,140 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Controller } from 'react-hook-form';
|
||||
import { signIn } from 'supertokens-web-js/recipe/emailpassword';
|
||||
import { nonLibraryClient, useZodForm } from '@sd/client';
|
||||
import { Button, Form, Input, toast, z } from '@sd/ui';
|
||||
|
||||
import ShowPassword from './ShowPassword';
|
||||
|
||||
async function signInClicked(email: string, password: string) {
|
||||
try {
|
||||
const response = await signIn({
|
||||
formFields: [
|
||||
{
|
||||
id: 'email',
|
||||
value: email
|
||||
},
|
||||
{
|
||||
id: 'password',
|
||||
value: password
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
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.
|
||||
toast.success('Sign in successful');
|
||||
// Refresh the page to reflect the new session state.
|
||||
// FIXME: This is a temporary workaround. We will provide a better way to handle this.
|
||||
window.location.reload();
|
||||
}
|
||||
} 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 {
|
||||
console.error(err);
|
||||
toast.error('Oops! Something went wrong.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const LoginSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(6)
|
||||
});
|
||||
|
||||
const Login = () => {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const form = useZodForm({
|
||||
schema: LoginSchema,
|
||||
defaultValues: {
|
||||
email: '',
|
||||
password: ''
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Form
|
||||
onSubmit={form.handleSubmit(async (data) => {
|
||||
// handle login submission
|
||||
await signInClicked(data.email, data.password);
|
||||
})}
|
||||
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 }) => (
|
||||
<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);
|
||||
}}
|
||||
/>
|
||||
<ShowPassword
|
||||
showPassword={showPassword}
|
||||
setShowPassword={setShowPassword}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{form.formState.errors.password && (
|
||||
<p className="text-xs text-red-500">{form.formState.errors.password.message}</p>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
className="mx-auto mt-2 w-full"
|
||||
variant="accent"
|
||||
onClick={form.handleSubmit(async (data) => {
|
||||
await signInClicked(data.email, data.password);
|
||||
})}
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
@@ -1,178 +0,0 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
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';
|
||||
|
||||
import ShowPassword from './ShowPassword';
|
||||
|
||||
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.
|
||||
toast.success('Sign up successful');
|
||||
// FIXME: This is a temporary workaround. We will provide a better way to handle this.
|
||||
window.location.reload();
|
||||
}
|
||||
} 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>({
|
||||
resolver: zodResolver(RegisterSchema),
|
||||
defaultValues: {
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: ''
|
||||
}
|
||||
});
|
||||
return (
|
||||
<Form
|
||||
onSubmit={form.handleSubmit(async (data) => {
|
||||
// handle login submission
|
||||
console.log(data);
|
||||
await signUpClicked(data.email, data.password);
|
||||
})}
|
||||
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 }) => (
|
||||
<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);
|
||||
}}
|
||||
/>
|
||||
<ShowPassword
|
||||
showPassword={showPassword}
|
||||
setShowPassword={setShowPassword}
|
||||
/>
|
||||
</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'}
|
||||
/>
|
||||
<ShowPassword
|
||||
showPassword={showPassword}
|
||||
setShowPassword={setShowPassword}
|
||||
/>
|
||||
</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(async (data) => {
|
||||
console.log(data);
|
||||
await signUpClicked(data.email, data.password);
|
||||
})}
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export default Register;
|
||||
@@ -1,169 +0,0 @@
|
||||
import { GoogleLogo, Icon } from '@phosphor-icons/react';
|
||||
import { Apple, Github } from '@sd/assets/svgs/brands';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import clsx from 'clsx';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useState } from 'react';
|
||||
import { getAuthorisationURLWithQueryParamsAndSetState } from 'supertokens-web-js/recipe/thirdparty';
|
||||
import { Button, Card, Divider, toast, Tooltip } from '@sd/ui';
|
||||
|
||||
import Login from './Login';
|
||||
import Register from './Register';
|
||||
|
||||
const AccountTabs = ['Login', 'Register'] as const;
|
||||
|
||||
type SocialLogin = {
|
||||
name: 'Github' | 'Google' | 'Apple';
|
||||
icon: Icon;
|
||||
};
|
||||
|
||||
const SocialLogins: SocialLogin[] = [
|
||||
{ name: 'Github', icon: Github },
|
||||
{ name: 'Google', icon: GoogleLogo },
|
||||
{ name: 'Apple', icon: Apple }
|
||||
];
|
||||
|
||||
const Tabs = () => {
|
||||
const [activeTab, setActiveTab] = useState<'Login' | 'Register'>('Login');
|
||||
|
||||
// Currently opens in App.
|
||||
const socialLoginHandlers = (name: SocialLogin['name']) => {
|
||||
return {
|
||||
Github: async () => {
|
||||
try {
|
||||
const authUrl = await getAuthorisationURLWithQueryParamsAndSetState({
|
||||
thirdPartyId: 'github',
|
||||
|
||||
// This is where Github should redirect the user back after login or error.
|
||||
frontendRedirectURI: 'http://localhost:9420/api/auth/callback/github'
|
||||
});
|
||||
|
||||
// we redirect the user to Github for auth.
|
||||
await open(authUrl);
|
||||
} 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.');
|
||||
}
|
||||
}
|
||||
},
|
||||
Google: async () => {
|
||||
try {
|
||||
const authUrl = await getAuthorisationURLWithQueryParamsAndSetState({
|
||||
thirdPartyId: 'google',
|
||||
|
||||
// This is where Google should redirect the user back after login or error.
|
||||
// This URL goes on the Google's dashboard as well.
|
||||
frontendRedirectURI: 'spacedrive://-/auth'
|
||||
});
|
||||
|
||||
/*
|
||||
Example value of authUrl: https://accounts.google.com/o/oauth2/v2/auth/oauthchooseaccount?scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.email&access_type=offline&include_granted_scopes=true&response_type=code&client_id=1060725074195-kmeum4crr01uirfl2op9kd5acmi9jutn.apps.googleusercontent.com&state=5a489996a28cafc83ddff&redirect_uri=https%3A%2F%2Fsupertokens.io%2Fdev%2Foauth%2Fredirect-to-app&flowName=GeneralOAuthFlow
|
||||
*/
|
||||
|
||||
// we redirect the user to google for auth.
|
||||
await open(authUrl);
|
||||
} 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.');
|
||||
}
|
||||
}
|
||||
},
|
||||
Apple: async () => {
|
||||
try {
|
||||
const authUrl = await getAuthorisationURLWithQueryParamsAndSetState({
|
||||
thirdPartyId: 'apple',
|
||||
|
||||
// This is where Apple should redirect the user back after login or error.
|
||||
frontendRedirectURI: 'http://localhost:9420/api/auth/callback/apple'
|
||||
});
|
||||
|
||||
// we redirect the user to Apple for auth.
|
||||
await open(authUrl);
|
||||
} 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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
}[name]();
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="relative flex w-full max-w-[320px] flex-col items-center justify-center !p-0">
|
||||
<div className="flex w-full">
|
||||
{AccountTabs.map((text) => (
|
||||
<div
|
||||
key={text}
|
||||
onClick={() => {
|
||||
setActiveTab(text);
|
||||
}}
|
||||
className={clsx(
|
||||
'relative flex-1 border-b border-app-line p-2.5 text-center',
|
||||
text === 'Login' ? 'rounded-tl-md' : 'rounded-tr-md'
|
||||
)}
|
||||
>
|
||||
<p
|
||||
className={clsx(
|
||||
'relative z-10 text-sm transition-colors duration-200',
|
||||
text === activeTab ? 'font-medium text-ink' : 'text-ink-faint'
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</p>
|
||||
{text === activeTab && (
|
||||
<motion.div
|
||||
animate={{
|
||||
borderRadius: text === 'Login' ? '0.3rem 0 0 0' : '0 0.3rem 0 0'
|
||||
}}
|
||||
layoutId="tab"
|
||||
className={clsx(
|
||||
'absolute inset-x-0 top-0 z-0 size-full bg-app-line/60'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex w-full flex-col justify-center gap-1.5 p-5">
|
||||
{activeTab === 'Login' ? <Login /> : <Register />}
|
||||
{/* Disabling for now for demo purposes. We need to figure out on the backend how the tokens are recieved so we can a) store them in the frontend and b) use them as auth tokens for our cloud services. - @Rocky43007 */}
|
||||
{/* <div className="my-2 flex w-full items-center gap-3">
|
||||
<Divider />
|
||||
<p className="text-xs text-ink-faint">OR</p>
|
||||
<Divider />
|
||||
</div>
|
||||
<div className="flex justify-center gap-3">
|
||||
{SocialLogins.map((social) => (
|
||||
<Tooltip key={social.name} label={social.name} position="bottom">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={async () => await socialLoginHandlers(social.name)}
|
||||
key={social.name}
|
||||
className="rounded-full border border-app-line bg-app-input p-3"
|
||||
>
|
||||
<social.icon
|
||||
style={{
|
||||
fill: 'white'
|
||||
}}
|
||||
weight="bold"
|
||||
className="size-4"
|
||||
/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div> */}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default Tabs;
|
||||
@@ -2,12 +2,12 @@ import { useEffect, useState } from 'react';
|
||||
import Session, { signOut } from 'supertokens-web-js/recipe/session';
|
||||
import { auth, useBridgeMutation, useBridgeQuery, useFeatureFlag } from '@sd/client';
|
||||
import { Button, Input, toast } from '@sd/ui';
|
||||
import { Authentication } from '~/components';
|
||||
import { useLocale } from '~/hooks';
|
||||
import { AUTH_SERVER_URL } from '~/util';
|
||||
|
||||
import { Heading } from '../../Layout';
|
||||
import Profile from './Profile';
|
||||
import Tabs from './Tabs';
|
||||
|
||||
type User = {
|
||||
email: string;
|
||||
@@ -19,6 +19,8 @@ type User = {
|
||||
export const Component = () => {
|
||||
const { t } = useLocale();
|
||||
const [userInfo, setUserInfo] = useState<User | null>(null);
|
||||
const [reload, setReload] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
async function _() {
|
||||
const user_data = await fetch(`${AUTH_SERVER_URL}/api/user`, {
|
||||
@@ -36,21 +38,22 @@ export const Component = () => {
|
||||
setUserInfo(null);
|
||||
}
|
||||
});
|
||||
setReload(false);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}, [reload]);
|
||||
return (
|
||||
<>
|
||||
<Heading
|
||||
rightArea={
|
||||
<>
|
||||
{userInfo !== null && (
|
||||
<div className="flex-row space-x-2">
|
||||
<div className="flex flex-row space-x-2">
|
||||
<Button
|
||||
variant="accent"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
await signOut();
|
||||
window.location.reload();
|
||||
setReload(true);
|
||||
}}
|
||||
>
|
||||
{t('logout')}
|
||||
@@ -59,11 +62,22 @@ export const Component = () => {
|
||||
)}
|
||||
</>
|
||||
}
|
||||
title={t('spacedrive_cloud')}
|
||||
title={t('spacedrive_account')}
|
||||
description={t('spacedrive_cloud_description')}
|
||||
/>
|
||||
<div className="flex flex-col justify-between gap-5 lg:flex-row">
|
||||
{userInfo === null ? <Tabs /> : <Profile email={userInfo.email} />}
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="w-full max-w-md space-y-8 p-8 text-center lg:p-12">
|
||||
{userInfo === null ? (
|
||||
<>
|
||||
<Authentication reload={setReload} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="text-2xl font-semibold">{t('profile')}</h2>
|
||||
<Profile email={userInfo.email} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* {useFeatureFlag('hostedLocations') && <HostedLocationsPlayground />} */}
|
||||
</>
|
||||
|
||||
154
interface/components/Authentication.tsx
Normal file
154
interface/components/Authentication.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import { GoogleLogo, Icon } from '@phosphor-icons/react';
|
||||
import { Apple, Github } from '@sd/assets/svgs/brands';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
import clsx from 'clsx';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Dispatch, SetStateAction, useState } from 'react';
|
||||
import { getAuthorisationURLWithQueryParamsAndSetState } from 'supertokens-web-js/recipe/thirdparty';
|
||||
import { Button, Card, Divider, toast, Tooltip } from '@sd/ui';
|
||||
import { Icon as Logo } from '~/components';
|
||||
import { useIsDark } from '~/hooks';
|
||||
|
||||
import Login from './Login';
|
||||
import Register from './Register';
|
||||
|
||||
export const AccountTabs = ['Login', 'Register'] as const;
|
||||
|
||||
export type SocialLogin = {
|
||||
name: 'Github' | 'Google' | 'Apple';
|
||||
icon: Icon;
|
||||
};
|
||||
|
||||
export const SocialLogins: SocialLogin[] = [
|
||||
{ name: 'Github', icon: Github },
|
||||
{ name: 'Google', icon: GoogleLogo },
|
||||
{ name: 'Apple', icon: Apple }
|
||||
];
|
||||
|
||||
export const Authentication = ({ reload }: { reload: Dispatch<SetStateAction<boolean>> }) => {
|
||||
const [activeTab, setActiveTab] = useState<'Login' | 'Register'>('Login');
|
||||
const isDark = useIsDark();
|
||||
|
||||
const socialLoginHandlers = (name: SocialLogin['name']) => {
|
||||
return {
|
||||
Github: async () => {
|
||||
try {
|
||||
const authUrl = await getAuthorisationURLWithQueryParamsAndSetState({
|
||||
thirdPartyId: 'github',
|
||||
frontendRedirectURI: 'http://localhost:9420/api/auth/callback/github'
|
||||
});
|
||||
await open(authUrl);
|
||||
} catch (err: any) {
|
||||
if (err.isSuperTokensGeneralError === true) {
|
||||
toast.error(err.message);
|
||||
} else {
|
||||
toast.error('Oops! Something went wrong.');
|
||||
}
|
||||
}
|
||||
},
|
||||
Google: async () => {
|
||||
try {
|
||||
const authUrl = await getAuthorisationURLWithQueryParamsAndSetState({
|
||||
thirdPartyId: 'google',
|
||||
frontendRedirectURI: 'spacedrive://-/auth'
|
||||
});
|
||||
await open(authUrl);
|
||||
} catch (err: any) {
|
||||
if (err.isSuperTokensGeneralError === true) {
|
||||
toast.error(err.message);
|
||||
} else {
|
||||
toast.error('Oops! Something went wrong.');
|
||||
}
|
||||
}
|
||||
},
|
||||
Apple: async () => {
|
||||
try {
|
||||
const authUrl = await getAuthorisationURLWithQueryParamsAndSetState({
|
||||
thirdPartyId: 'apple',
|
||||
frontendRedirectURI: 'http://localhost:9420/api/auth/callback/apple'
|
||||
});
|
||||
await open(authUrl);
|
||||
} catch (err: any) {
|
||||
if (err.isSuperTokensGeneralError === true) {
|
||||
toast.error(err.message);
|
||||
} else {
|
||||
toast.error('Oops! Something went wrong.');
|
||||
}
|
||||
}
|
||||
}
|
||||
}[name]();
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="bg-app-background relative flex w-full max-w-[400px] flex-col items-center justify-center rounded-lg border border-app-line !p-0 shadow-lg">
|
||||
<div className="flex w-full">
|
||||
{AccountTabs.map((text) => (
|
||||
<div
|
||||
key={text}
|
||||
onClick={() => setActiveTab(text)}
|
||||
className={clsx(
|
||||
'relative flex-1 cursor-pointer border-b border-app-line p-3 text-center transition-colors duration-200',
|
||||
text === 'Login' ? 'rounded-tl-lg' : 'rounded-tr-lg',
|
||||
text === activeTab ? 'bg-app-background-alt' : ''
|
||||
)}
|
||||
>
|
||||
<p
|
||||
className={clsx(
|
||||
'relative z-10 text-sm transition-colors duration-200',
|
||||
text === activeTab ? 'font-semibold text-ink' : 'text-ink-faint'
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</p>
|
||||
{text === activeTab && (
|
||||
<motion.div
|
||||
animate={{
|
||||
borderRadius: text === 'Login' ? '0.5rem 0 0 0' : '0 0.5rem 0 0'
|
||||
}}
|
||||
layoutId="tab"
|
||||
className="absolute inset-x-0 top-0 z-0 h-full w-full bg-app-line/60"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex w-full flex-col items-center gap-4 p-6">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Logo size={36} name="Ball" />
|
||||
<h3
|
||||
className={clsx(
|
||||
'text-xl font-extrabold',
|
||||
isDark ? 'text-white' : 'text-black'
|
||||
)}
|
||||
>
|
||||
Spacedrive
|
||||
</h3>
|
||||
</div>
|
||||
{activeTab === 'Login' ? <Login reload={reload} /> : <Register />}
|
||||
{/* Optionally, uncomment the social login block when ready */}
|
||||
{/* <div className="my-4 flex w-full items-center gap-3">
|
||||
<Divider />
|
||||
<p className="text-xs text-ink-faint">OR</p>
|
||||
<Divider />
|
||||
</div>
|
||||
<div className="flex justify-center gap-3">
|
||||
{SocialLogins.map((social) => (
|
||||
<Tooltip key={social.name} label={social.name} position="bottom">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={async () => await socialLoginHandlers(social.name)}
|
||||
className="rounded-full border border-app-line bg-app-input p-3"
|
||||
>
|
||||
<social.icon
|
||||
style={{ fill: 'white' }}
|
||||
weight="bold"
|
||||
className="size-4"
|
||||
/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div> */}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
167
interface/components/Login.tsx
Normal file
167
interface/components/Login.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import clsx from 'clsx';
|
||||
import { Dispatch, SetStateAction, useEffect, useState } from 'react';
|
||||
import { Controller } from 'react-hook-form';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { signIn } from 'supertokens-web-js/recipe/emailpassword';
|
||||
import { nonLibraryClient, useZodForm } from '@sd/client';
|
||||
import { Button, Form, Input, toast, z } from '@sd/ui';
|
||||
import { useIsDark, useLocale } from '~/hooks';
|
||||
|
||||
import ShowPassword from './ShowPassword';
|
||||
|
||||
async function signInClicked(
|
||||
email: string,
|
||||
password: string,
|
||||
reload: Dispatch<SetStateAction<boolean>>
|
||||
) {
|
||||
try {
|
||||
const response = await signIn({
|
||||
formFields: [
|
||||
{
|
||||
id: 'email',
|
||||
value: email
|
||||
},
|
||||
{
|
||||
id: 'password',
|
||||
value: password
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
if (response.status === 'FIELD_ERROR') {
|
||||
response.formFields.forEach((formField) => {
|
||||
if (formField.id === 'email') {
|
||||
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') {
|
||||
toast.error(response.reason);
|
||||
} else {
|
||||
toast.success('Sign in successful');
|
||||
reload(true);
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.isSuperTokensGeneralError === true) {
|
||||
toast.error(err.message);
|
||||
} else {
|
||||
console.error(err);
|
||||
toast.error('Oops! Something went wrong.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const LoginSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(6)
|
||||
});
|
||||
|
||||
const Login = ({ reload }: { reload: Dispatch<SetStateAction<boolean>> }) => {
|
||||
const { t } = useLocale();
|
||||
const isDark = useIsDark();
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const navigate = useNavigate(); // useNavigate hook
|
||||
const form = useZodForm({
|
||||
schema: LoginSchema,
|
||||
defaultValues: {
|
||||
email: '',
|
||||
password: ''
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Form
|
||||
onSubmit={form.handleSubmit(async (data) => {
|
||||
await signInClicked(data.email, data.password, reload);
|
||||
})}
|
||||
form={form}
|
||||
>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col">
|
||||
<label className="mb-2 text-left text-sm text-ink-dull">Email</label>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="Enter your email address"
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<label className="mb-2 text-left text-sm text-ink-dull">Password</label>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<div className="relative flex items-center justify-center">
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="Enter your 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);
|
||||
}}
|
||||
/>
|
||||
<ShowPassword
|
||||
showPassword={showPassword}
|
||||
setShowPassword={setShowPassword}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{form.formState.errors.password && (
|
||||
<p className="text-xs text-red-500">
|
||||
{form.formState.errors.password.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{form.formState.errors.password && (
|
||||
<p className="text-xs text-red-500">{form.formState.errors.password.message}</p>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
className={clsx(
|
||||
'mx-auto mt-3 w-full border-none',
|
||||
isDark
|
||||
? [
|
||||
'mx-auto mt-3 w-full',
|
||||
'border-none bg-[#0E0E12]/30',
|
||||
'shadow-[0px_4px_30px_rgba(0,0,0,0.1)] backdrop-blur-lg backdrop-saturate-150',
|
||||
'rounded-lg px-4 py-2 text-white'
|
||||
]
|
||||
: ['text-black']
|
||||
)}
|
||||
variant={isDark ? 'default' : 'accent'}
|
||||
onClick={form.handleSubmit(async (data) => {
|
||||
await signInClicked(data.email, data.password, reload);
|
||||
})}
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
{t('login')}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
211
interface/components/Register.tsx
Normal file
211
interface/components/Register.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import clsx from 'clsx';
|
||||
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';
|
||||
import { useIsDark, useLocale } from '~/hooks';
|
||||
|
||||
import ShowPassword from './ShowPassword';
|
||||
|
||||
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.
|
||||
toast.success('Sign up successful');
|
||||
// FIXME: This is a temporary workaround. We will provide a better way to handle this.
|
||||
window.location.reload();
|
||||
}
|
||||
} 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 { t } = useLocale();
|
||||
const isDark = useIsDark();
|
||||
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>({
|
||||
resolver: zodResolver(RegisterSchema),
|
||||
defaultValues: {
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: ''
|
||||
}
|
||||
});
|
||||
return (
|
||||
<Form
|
||||
onSubmit={form.handleSubmit(async (data) => {
|
||||
// handle sign-up submission
|
||||
console.log(data);
|
||||
await signUpClicked(data.email, data.password);
|
||||
})}
|
||||
form={form}
|
||||
>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col items-start">
|
||||
<label className="mb-1 text-left text-sm text-ink-dull">Email</label>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="Enter your email address"
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-start">
|
||||
<label className="mb-1 text-left text-sm text-ink-dull">Password</label>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<div className="relative flex w-full items-start">
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="Enter your 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);
|
||||
}}
|
||||
/>
|
||||
<ShowPassword
|
||||
showPassword={showPassword}
|
||||
setShowPassword={setShowPassword}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{form.formState.errors.password && (
|
||||
<p className="text-xs text-red-500">
|
||||
{form.formState.errors.password.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-start">
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="confirmPassword"
|
||||
render={({ field }) => (
|
||||
<div className="relative flex w-full items-start">
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="Confirm your password"
|
||||
error={Boolean(
|
||||
form.formState.errors.confirmPassword?.message
|
||||
)}
|
||||
className="w-full"
|
||||
disabled={form.formState.isSubmitting}
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
/>
|
||||
<ShowPassword
|
||||
showPassword={showPassword}
|
||||
setShowPassword={setShowPassword}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{form.formState.errors.confirmPassword && (
|
||||
<p className="text-xs text-red-500">
|
||||
{form.formState.errors.confirmPassword.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className={clsx(
|
||||
'mx-auto mt-3 w-full border-none',
|
||||
isDark
|
||||
? [
|
||||
'mx-auto mt-3 w-full',
|
||||
'border-none bg-[#0E0E12]/30',
|
||||
'shadow-[0px_4px_30px_rgba(0,0,0,0.1)] backdrop-blur-lg backdrop-saturate-150',
|
||||
'rounded-lg px-4 py-2 text-white'
|
||||
]
|
||||
: ['text-black']
|
||||
)}
|
||||
variant={isDark ? 'default' : 'accent'}
|
||||
onClick={form.handleSubmit(async (data) => {
|
||||
console.log(data);
|
||||
await signUpClicked(data.email, data.password);
|
||||
})}
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
{t('register')}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export default Register;
|
||||
27
interface/components/ShowPassword.tsx
Normal file
27
interface/components/ShowPassword.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Eye, EyeClosed } from '@phosphor-icons/react';
|
||||
import { Button, Tooltip } from '@sd/ui';
|
||||
|
||||
interface Props {
|
||||
showPassword: boolean;
|
||||
setShowPassword: (value: boolean) => void;
|
||||
}
|
||||
|
||||
const ShowPassword = ({ showPassword, setShowPassword }: Props) => {
|
||||
return (
|
||||
<Tooltip
|
||||
className="absolute inset-y-0 right-1 flex items-center"
|
||||
position="top"
|
||||
label="Show password"
|
||||
>
|
||||
<Button
|
||||
variant="gray"
|
||||
className="flex size-6 items-center justify-center !p-0"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{!showPassword ? <EyeClosed size={12} /> : <Eye size={12} />}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShowPassword;
|
||||
@@ -13,3 +13,4 @@ export * from './TextViewer';
|
||||
export * from './TrafficLights';
|
||||
export * from './TruncatedText';
|
||||
export * from './Accordion';
|
||||
export * from './Authentication';
|
||||
|
||||
@@ -467,6 +467,7 @@
|
||||
"log_out": "Log out",
|
||||
"logged_in_as": "Logged in as {{email}}",
|
||||
"logging_in": "Logging in...",
|
||||
"login": "Login",
|
||||
"logout": "Logout",
|
||||
"manage_library": "Manage Library",
|
||||
"managed": "Managed",
|
||||
@@ -610,6 +611,7 @@
|
||||
"regen_labels": "Regen Labels",
|
||||
"regen_thumbnails": "Regen Thumbnails",
|
||||
"regenerate_thumbs": "Regenerate Thumbs",
|
||||
"register": "Register",
|
||||
"reindex": "Re-index",
|
||||
"reject": "Reject",
|
||||
"reject_files": "Reject files",
|
||||
@@ -692,9 +694,9 @@
|
||||
"skip_login": "Skip login",
|
||||
"software": "Software",
|
||||
"sort_by": "Sort by",
|
||||
"spacedrive_account": "Spacedrive Account",
|
||||
"spacedrive_account": "Account",
|
||||
"spacedrive_cloud": "Spacedrive Cloud",
|
||||
"spacedrive_cloud_description": "Spacedrive is always local first, but we will offer our own optional cloud services in the future. For now, authentication is only used for the Feedback feature, otherwise it is not required.",
|
||||
"spacedrive_cloud_description": "Spacedrive is always local first, but to access our Cloud features, users must register for an account. Note that an account is not required to use any of the base features of Spacedrive, and you can still connect devices to your library without an account.",
|
||||
"spacedrop": "Spacedrop visibility",
|
||||
"spacedrop_a_file": "Spacedrop a File",
|
||||
"spacedrop_already_progress": "Spacedrop already in progress",
|
||||
|
||||
@@ -4,5 +4,5 @@
|
||||
"rootDir": "src",
|
||||
"declarationDir": "dist"
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src", "../../interface/components/ShowPassword.tsx"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user