diff --git a/src/@types/translations/en.json b/src/@types/translations/en.json index 16771647..95e6ea50 100644 --- a/src/@types/translations/en.json +++ b/src/@types/translations/en.json @@ -39,7 +39,7 @@ "anonymous": "Anonymous", "buttonText": "Sign In", "signingInText": "Signing in...", - "forgotPassword": "Forgot Password?", + "forgotPassword": "Forgot password?", "emailHint": "Email", "passwordHint": "Password", "dontHaveAnAccount": "Don't have an account?", @@ -57,7 +57,8 @@ "signUpWithGithub": "Sign up with Github", "signUpWithDiscord": "Sign up with Discord", "signInWith": "Continue with:", - "signInWithEmail": "Continue with Email", + "signInWithEmail": "Continue with email", + "signInWithPassword": "Continue with password", "signInWithMagicLink": "Continue", "signUpWithMagicLink": "Sign up with Magic Link", "pleaseInputYourEmail": "Please enter your email address", @@ -66,10 +67,40 @@ "invalidEmail": "Please enter a valid email address", "alreadyHaveAnAccount": "Already have an account?", "logIn": "Log in", + "enterPassword": "Enter password", + "enterPasswordTip": "Login as ", + "continueToSignInWithPassword": "Continue", "generalError": "Something went wrong. Please try again later", "limitRateError": "For security reasons, you can only request a magic link every 60 seconds", "magicLinkSentDescription": "A Magic Link was sent to your email. Click the link to complete your login. The link will expire after 5 minutes." }, + "resetPassword": { + "title": "Reset password", + "description": "Enter your email address to reset password", + "placeholder": "Enter your email", + "submit": "Submit", + "checkYourEmail": "Check your email to reset password", + "checkCodeTip": "A temporary verification code has been sent. Please check your inbox at .", + "enterCode": "Enter code", + "checkEmailTip": "If this account exists, an email has been sent to with further instructions.", + "enterCodeManually": "Enter code manually", + "continue": "Continue to reset password" + }, + "changePassword": { + "title": "Reset password", + "description": "Enter new password for ", + "placeholder": "Enter new password", + "newPassword": "New password", + "confirmPassword": "Confirm password", + "repeatPassword": "Repeat password", + "passwordError": "Password must be at least 6 characters long", + "passwordErrorUppercase": "Password must include at least one uppercase letter", + "passwordErrorLowercase": "Password must include at least one lowercase letter", + "passwordErrorSpecialChar": "Password must include at least one special character", + "passwordErrorMatch": "Passwords do not match", + "submit": "Reset password", + "success": "Password reset. Please login again" + }, "workspace": { "chooseWorkspace": "Choose your workspace", "defaultName": "My Workspace", diff --git a/src/application/services/js-services/http/gotrue.ts b/src/application/services/js-services/http/gotrue.ts index 00700a63..aa5614f1 100644 --- a/src/application/services/js-services/http/gotrue.ts +++ b/src/application/services/js-services/http/gotrue.ts @@ -1,11 +1,12 @@ +import axios, { AxiosInstance } from 'axios'; + import { emit, EventType } from '@/application/session'; import { afterAuth } from '@/application/session/sign_in'; -import { refreshToken as refreshSessionToken } from '@/application/session/token'; -import axios, { AxiosInstance } from 'axios'; +import { getTokenParsed, refreshToken as refreshSessionToken } from '@/application/session/token'; let axiosInstance: AxiosInstance | null = null; -export function initGrantService (baseURL: string) { +export function initGrantService(baseURL: string) { if (axiosInstance) { return; } @@ -23,7 +24,7 @@ export function initGrantService (baseURL: string) { }); } -export async function refreshToken (refresh_token: string) { +export async function refreshToken(refresh_token: string) { const response = await axiosInstance?.post<{ access_token: string; expires_at: number; @@ -43,13 +44,117 @@ export async function refreshToken (refresh_token: string) { return newToken; } -export async function signInOTP ({ +export async function signInWithPassword(params: { email: string; password: string; redirectTo: string }) { + try { + const response = await axiosInstance?.post<{ + access_token: string; + expires_at: number; + refresh_token: string; + }>('/token?grant_type=password', { + email: params.email, + password: params.password, + }); + + const data = response?.data; + + if (data) { + refreshSessionToken(JSON.stringify(data)); + emit(EventType.SESSION_VALID); + afterAuth(); + } else { + emit(EventType.SESSION_INVALID); + return Promise.reject({ + code: -1, + message: 'Failed to sign in with password', + }); + } + // eslint-disable-next-line + } catch (e: any) { + emit(EventType.SESSION_INVALID); + return Promise.reject({ + code: -1, + message: 'Incorrect password. Please try again.', + }); + } +} + +export async function forgotPassword(params: { email: string }) { + try { + const response = await axiosInstance?.post<{ + access_token: string; + expires_at: number; + refresh_token: string; + }>('/recover', { + email: params.email, + }); + + if (response?.data) { + return; + } else { + emit(EventType.SESSION_INVALID); + return Promise.reject({ + code: -1, + message: 'Failed to send recovery email', + }); + } + // eslint-disable-next-line + } catch (e: any) { + emit(EventType.SESSION_INVALID); + return Promise.reject({ + code: -1, + message: e.message, + }); + } +} + +export async function changePassword(params: { password: string }) { + try { + const token = getTokenParsed(); + const access_token = token?.access_token; + + if (!access_token) { + return Promise.reject({ + code: -1, + message: 'You have not logged in yet. Can not change password.', + }); + } + + await axiosInstance?.post<{ + code: number; + msg: string; + }>( + '/user/change-password', + { + password: params.password, + current_password: params.password, + }, + { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${access_token}`, + }, + } + ); + + return; + // eslint-disable-next-line + } catch (e: any) { + emit(EventType.SESSION_INVALID); + return Promise.reject({ + code: -1, + message: e.response?.data?.msg || e.message, + }); + } +} + +export async function signInOTP({ email, code, + type = 'magiclink', }: { email: string; code: string; - redirectTo: string; + type?: 'magiclink' | 'recovery'; }) { try { const response = await axiosInstance?.post<{ @@ -61,7 +166,7 @@ export async function signInOTP ({ }>('/verify', { email, token: code, - type: 'magiclink', + type, }); const data = response?.data; @@ -69,7 +174,10 @@ export async function signInOTP ({ if (data) { if (!data.code) { refreshSessionToken(JSON.stringify(data)); - emit(EventType.SESSION_VALID); + if (type === 'magiclink') { + emit(EventType.SESSION_VALID); + } + afterAuth(); } else { emit(EventType.SESSION_INVALID); @@ -87,7 +195,6 @@ export async function signInOTP ({ } // eslint-disable-next-line } catch (e: any) { - emit(EventType.SESSION_INVALID); return Promise.reject({ code: e.response?.data?.code || e.response?.status, @@ -98,7 +205,7 @@ export async function signInOTP ({ return; } -export async function signInWithMagicLink (email: string, authUrl: string) { +export async function signInWithMagicLink(email: string, authUrl: string) { const res = await axiosInstance?.post( '/magiclink', { @@ -111,19 +218,19 @@ export async function signInWithMagicLink (email: string, authUrl: string) { headers: { Redirect_to: authUrl, }, - }, + } ); return res?.data; } -export async function settings () { +export async function settings() { const res = await axiosInstance?.get('/settings'); return res?.data; } -export function signInGoogle (authUrl: string) { +export function signInGoogle(authUrl: string) { const provider = 'google'; const redirectTo = encodeURIComponent(authUrl); const accessType = 'offline'; @@ -134,7 +241,7 @@ export function signInGoogle (authUrl: string) { window.open(url, '_current'); } -export function signInApple (authUrl: string) { +export function signInApple(authUrl: string) { const provider = 'apple'; const redirectTo = encodeURIComponent(authUrl); const baseURL = axiosInstance?.defaults.baseURL; @@ -143,7 +250,7 @@ export function signInApple (authUrl: string) { window.open(url, '_current'); } -export function signInGithub (authUrl: string) { +export function signInGithub(authUrl: string) { const provider = 'github'; const redirectTo = encodeURIComponent(authUrl); const baseURL = axiosInstance?.defaults.baseURL; @@ -152,7 +259,7 @@ export function signInGithub (authUrl: string) { window.open(url, '_current'); } -export function signInDiscord (authUrl: string) { +export function signInDiscord(authUrl: string) { const provider = 'discord'; const redirectTo = encodeURIComponent(authUrl); const baseURL = axiosInstance?.defaults.baseURL; diff --git a/src/application/services/js-services/index.ts b/src/application/services/js-services/index.ts index fb93a741..6fd09ffa 100644 --- a/src/application/services/js-services/index.ts +++ b/src/application/services/js-services/index.ts @@ -297,13 +297,26 @@ export class AFClientService implements AFService { } } + @withSignIn() + async signInWithPassword (params: { email: string; password: string; redirectTo: string }) { + return APIService.signInWithPassword(params); + } + + async forgotPassword (params: { email: string }) { + return APIService.forgotPassword(params); + } + + async changePassword (params: { password: string }) { + return APIService.changePassword(params); + } + @withSignIn() async signInMagicLink ({ email }: { email: string; redirectTo: string }) { return await APIService.signInWithMagicLink(email, AUTH_CALLBACK_URL); } @withSignIn() - async signInOTP (params: { email: string; code: string; redirectTo: string }) { + async signInOTP (params: { email: string; code: string; type?: 'magiclink' | 'recovery' }) { return APIService.signInOTP(params); } diff --git a/src/application/services/services.type.ts b/src/application/services/services.type.ts index 07fd6c10..e1d3c661 100644 --- a/src/application/services/services.type.ts +++ b/src/application/services/services.type.ts @@ -95,7 +95,10 @@ export interface AppService { getAppTrash: (workspaceId: string) => Promise; loginAuth: (url: string) => Promise; signInMagicLink: (params: { email: string; redirectTo: string }) => Promise; - signInOTP: (params: { email: string; code: string; redirectTo: string }) => Promise; + signInOTP: (params: { email: string; code: string; redirectTo: string; type?: 'magiclink' | 'recovery' }) => Promise; + signInWithPassword: (params: { email: string; password: string; redirectTo: string }) => Promise; + forgotPassword: (params: { email: string }) => Promise; + changePassword: (params: { password: string }) => Promise; signInGoogle: (params: { redirectTo: string }) => Promise; signInGithub: (params: { redirectTo: string }) => Promise; signInDiscord: (params: { redirectTo: string }) => Promise; diff --git a/src/components/app/workspaces/CreateWorkspace.tsx b/src/components/app/workspaces/EditWorkspace.tsx similarity index 97% rename from src/components/app/workspaces/CreateWorkspace.tsx rename to src/components/app/workspaces/EditWorkspace.tsx index 8e799dc7..a75f4590 100644 --- a/src/components/app/workspaces/CreateWorkspace.tsx +++ b/src/components/app/workspaces/EditWorkspace.tsx @@ -9,7 +9,7 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Progress } from '@/components/ui/progress'; -function CreateWorkspace({ +function EditWorkspace({ open, openOnChange, defaultName, @@ -65,6 +65,7 @@ function CreateWorkspace({ name='name' autoFocus value={name} + autoComplete='off' ref={(input: HTMLInputElement) => { if (!input) return; if (!inputRef.current) { @@ -108,4 +109,4 @@ function CreateWorkspace({ ); } -export default CreateWorkspace; +export default EditWorkspace; diff --git a/src/components/app/workspaces/Workspaces.tsx b/src/components/app/workspaces/Workspaces.tsx index bb112098..1f01b1dd 100644 --- a/src/components/app/workspaces/Workspaces.tsx +++ b/src/components/app/workspaces/Workspaces.tsx @@ -13,9 +13,9 @@ import { ReactComponent as AddIcon } from '@/assets/icons/plus.svg'; import { ReactComponent as ImportIcon } from '@/assets/icons/save_as.svg'; import { ReactComponent as UpgradeIcon } from '@/assets/icons/upgrade.svg'; import { useAppHandlers, useCurrentWorkspaceId, useUserWorkspaceInfo } from '@/components/app/app.hooks'; -import CreateWorkspace from '@/components/app/workspaces/CreateWorkspace'; import CurrentWorkspace from '@/components/app/workspaces/CurrentWorkspace'; import DeleteWorkspace from '@/components/app/workspaces/DeleteWorkspace'; +import EditWorkspace from '@/components/app/workspaces/EditWorkspace'; import InviteMember from '@/components/app/workspaces/InviteMember'; import LeaveWorkspace from '@/components/app/workspaces/LeaveWorkspace'; import WorkspaceList from '@/components/app/workspaces/WorkspaceList'; @@ -264,9 +264,9 @@ export function Workspaces() { {openCreateWorkspace && ( - setOpenRenameWorkspace(null)} onOk={handleUpdateWorkspace} - okText={t('workspace.rename')} + okText={t('button.rename')} defaultName={openRenameWorkspace.name} title={t('workspace.rename')} /> diff --git a/src/components/login/ChangePassword.tsx b/src/components/login/ChangePassword.tsx new file mode 100644 index 00000000..a28ba956 --- /dev/null +++ b/src/components/login/ChangePassword.tsx @@ -0,0 +1,246 @@ +import { useCallback, useContext, useEffect, useState } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; +import { useSearchParams } from 'react-router-dom'; +import { toast } from 'sonner'; + +import { invalidToken } from '@/application/session/token'; +import { ReactComponent as Logo } from '@/assets/icons/logo.svg'; +import { AFConfigContext, useService } from '@/components/main/app.hooks'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { PasswordInput } from '@/components/ui/password-input'; +import { Progress } from '@/components/ui/progress'; +import { cn } from '@/lib/utils'; +import { createHotkey, HOT_KEY_NAME } from '@/utils/hotkeys'; + +interface PasswordValidation { + minLength: boolean; + hasUppercase: boolean; + hasLowercase: boolean; + hasSpecialChar: boolean; +} + +export function ChangePassword({ email, redirectTo }: { email: string; redirectTo: string }) { + const { t } = useTranslation(); + const [loading, setLoading] = useState(false); + const service = useService(); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [error, setError] = useState(''); + const [passwordErrors, setPasswordErrors] = useState([]); + + const isAuthenticated = useContext(AFConfigContext)?.isAuthenticated || false; + const [, setSearchParams] = useSearchParams(); + + useEffect(() => { + if (!isAuthenticated) { + setSearchParams((prev) => { + prev.delete('email'); + prev.delete('action'); + prev.set('redirectTo', encodeURIComponent(redirectTo)); + return prev; + }); + } + }, [isAuthenticated, redirectTo, setSearchParams]); + + const validatePassword = (password: string): PasswordValidation => { + return { + minLength: password.length >= 6, + hasUppercase: /[A-Z]/.test(password), + hasLowercase: /[a-z]/.test(password), + hasSpecialChar: /[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/.test(password), + }; + }; + + const getPasswordErrors = useCallback( + (password: string): string[] => { + const validation = validatePassword(password); + const errors: string[] = []; + + if (!validation.minLength) { + errors.push(t('changePassword.passwordError')); + } + + if (!validation.hasUppercase) { + errors.push(t('changePassword.passwordErrorUppercase')); + } + + if (!validation.hasLowercase) { + errors.push(t('changePassword.passwordErrorLowercase')); + } + + if (!validation.hasSpecialChar) { + errors.push(t('changePassword.passwordErrorSpecialChar')); + } + + return errors; + }, + [t] + ); + + const validateConfirmPassword = useCallback( + (password: string) => { + if (password !== newPassword) { + setError(t('changePassword.passwordErrorMatch')); + return false; + } + + setError(''); + return true; + }, + [newPassword, t] + ); + + const validateNewPassword = useCallback( + (password: string) => { + const errors = getPasswordErrors(password); + + setPasswordErrors(errors); + }, + [getPasswordErrors] + ); + + const handlePasswordChange = useCallback((password: string) => { + setNewPassword(password); + setError(''); + setPasswordErrors([]); + }, []); + + const handleSubmit = async (e: React.MouseEvent | React.KeyboardEvent) => { + e.preventDefault(); + if (!service) return; + + const errors = getPasswordErrors(newPassword); + + if (errors.length > 0) { + setPasswordErrors(errors); + return; + } + + if (newPassword !== confirmPassword) { + setError(t('changePassword.passwordErrorMatch')); + return; + } + + setLoading(true); + setError(''); + setPasswordErrors([]); + + try { + await service.changePassword({ password: newPassword }); + toast.success(t('changePassword.success')); + + invalidToken(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (error: any) { + setError(error.message); + } finally { + setLoading(false); + } + }; + + const hasPasswordErrors = passwordErrors.length > 0; + const hasConfirmError = error?.includes('match'); + const isFormValid = newPassword && confirmPassword && !hasPasswordErrors && newPassword === confirmPassword; + + return ( +
+
{ + window.location.href = '/'; + }} + className={'flex cursor-pointer'} + > + +
+
{t('changePassword.title')}
+
+ {email} }} + /> +
+
+
+ + { + handlePasswordChange(e.target.value); + }} + onBlur={() => { + validateNewPassword(newPassword); + }} + value={newPassword} + placeholder={t('changePassword.placeholder')} + variant={hasPasswordErrors ? 'destructive' : 'default'} + onKeyDown={(e) => { + if (createHotkey(HOT_KEY_NAME.ENTER)(e.nativeEvent)) { + void handleSubmit(e); + } + }} + /> + {passwordErrors.length > 0 && ( +
+ {passwordErrors.map((error, index) => ( +
+ {error} +
+ ))} +
+ )} +
+ +
+ + { + setConfirmPassword(e.target.value); + setError(''); + }} + onBlur={() => { + if (confirmPassword) { + validateConfirmPassword(confirmPassword); + } + }} + value={confirmPassword} + placeholder={t('changePassword.confirmPassword')} + variant={hasConfirmError ? 'destructive' : 'default'} + onKeyDown={(e) => { + if (createHotkey(HOT_KEY_NAME.ENTER)(e.nativeEvent)) { + void handleSubmit(e); + } + }} + /> + {hasConfirmError &&
{error}
} +
+ + {error && !hasConfirmError &&
{error}
} +
+ + +
+ ); +} diff --git a/src/components/login/CheckEmailResetPassword.tsx b/src/components/login/CheckEmailResetPassword.tsx new file mode 100644 index 00000000..87181ccb --- /dev/null +++ b/src/components/login/CheckEmailResetPassword.tsx @@ -0,0 +1,125 @@ +import { useContext, useState } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; + +import { ReactComponent as Logo } from '@/assets/icons/logo.svg'; +import { LOGIN_ACTION } from '@/components/login/const'; +import { AFConfigContext } from '@/components/main/app.hooks'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Progress } from '@/components/ui/progress'; +import { cn } from '@/lib/utils'; +import { createHotkey, HOT_KEY_NAME } from '@/utils/hotkeys'; + +function CheckEmailResetPassword({ email, redirectTo }: { email: string; redirectTo: string }) { + const { t } = useTranslation(); + const [loading, setLoading] = useState(false); + + const [error, setError] = useState(''); + const [isEnter, setEnter] = useState(false); + const [code, setCode] = useState(''); + const service = useContext(AFConfigContext)?.service; + + const handleSubmit = async () => { + if (loading) return; + if (!code) { + setError(t('requireCode')); + return; + } + + setLoading(true); + + try { + await service?.signInOTP({ + email, + code, + type: 'recovery', + redirectTo: encodeURIComponent( + `${window.location.origin}/login?action=${LOGIN_ACTION.CHANGE_PASSWORD}&email=${email}&redirectTo=${redirectTo}` + ), + }); + + // eslint-disable-next-line + } catch (e: any) { + if (e.code === 403) { + setError(t('invalidOTPCode')); + } else { + setError(e.message); + } + } finally { + setLoading(false); + } + }; + + return ( +
+
{ + window.location.href = '/'; + }} + className={'flex cursor-pointer'} + > + +
+
+ {isEnter ? t('resetPassword.enterCode') : t('resetPassword.checkYourEmail')} +
+
+ {email} }} + /> +
+ {isEnter ? ( +
+
+ { + setError(''); + setCode(e.target.value); + }} + value={code} + placeholder={t('resetPassword.enterCode')} + variant={error ? 'destructive' : 'default'} + onKeyDown={(e) => { + if (createHotkey(HOT_KEY_NAME.ENTER)(e.nativeEvent)) { + void handleSubmit(); + } + }} + /> + {error &&
{error}
} +
+ + +
+ ) : ( + + )} + + +
+ ); +} + +export default CheckEmailResetPassword; diff --git a/src/components/login/MagicLink.tsx b/src/components/login/EmailLogin.tsx similarity index 56% rename from src/components/login/MagicLink.tsx rename to src/components/login/EmailLogin.tsx index f2dd5261..d954fda7 100644 --- a/src/components/login/MagicLink.tsx +++ b/src/components/login/EmailLogin.tsx @@ -1,28 +1,31 @@ +import { useContext, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSearchParams } from 'react-router-dom'; +import { toast } from 'sonner'; +import isEmail from 'validator/lib/isEmail'; + import { AFConfigContext } from '@/components/main/app.hooks'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Progress } from '@/components/ui/progress'; import { cn } from '@/lib/utils'; import { createHotkey, HOT_KEY_NAME } from '@/utils/hotkeys'; -import React, { useContext } from 'react'; -import { useTranslation } from 'react-i18next'; -import { useSearchParams } from 'react-router-dom'; -import { toast } from 'sonner'; -import isEmail from 'validator/lib/isEmail'; +import { LOGIN_ACTION } from '@/components/login/const'; -function MagicLink ({ redirectTo }: { redirectTo: string }) { +function EmailLogin({ redirectTo }: { redirectTo: string }) { const { t } = useTranslation(); - const [email, setEmail] = React.useState(''); - const [loading, setLoading] = React.useState(false); - const [error, setError] = React.useState(''); + const [email, setEmail] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); const [, setSearch] = useSearchParams(); const service = useContext(AFConfigContext)?.service; - const handleSubmit = async () => { + const handleSubmitEmail = async (e?: React.MouseEvent) => { if (loading) return; const isValidEmail = isEmail(email); if (!isValidEmail) { - toast.error(t('signIn.invalidEmail')); + e?.preventDefault(); + setError(t('signIn.invalidEmail')); return; } @@ -47,18 +50,34 @@ function MagicLink ({ redirectTo }: { redirectTo: string }) { } })(); - setSearch(prev => { + setSearch((prev) => { prev.set('email', email); - prev.set('action', 'checkEmail'); + prev.set('action', LOGIN_ACTION.CHECK_EMAIL); return prev; }); + }; + const handleSubmitPassword = async (e: React.MouseEvent) => { + e.preventDefault(); + const isValidEmail = isEmail(email); + + if (!isValidEmail) { + setError(t('signIn.invalidEmail')); + return; + } + + setSearch((prev) => { + prev.set('email', email); + prev.set('action', LOGIN_ACTION.ENTER_PASSWORD); + return prev; + }); }; return (
{ + onKeyDown={(e) => { if (createHotkey(HOT_KEY_NAME.ENTER)(e.nativeEvent)) { - void handleSubmit(); + void handleSubmitEmail(); } }} /> - {error &&
- {error} -
} + {error &&
{error}
}
- +
); } -export default MagicLink; +export default EmailLogin; diff --git a/src/components/login/EnterPassword.tsx b/src/components/login/EnterPassword.tsx new file mode 100644 index 00000000..49005708 --- /dev/null +++ b/src/components/login/EnterPassword.tsx @@ -0,0 +1,104 @@ +import { useState } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; + +import { ReactComponent as Logo } from '@/assets/icons/logo.svg'; +import { LOGIN_ACTION } from '@/components/login/const'; +import { useService } from '@/components/main/app.hooks'; +import { Button } from '@/components/ui/button'; +import { PasswordInput } from '@/components/ui/password-input'; +import { Progress } from '@/components/ui/progress'; +import { cn } from '@/lib/utils'; +import { createHotkey, HOT_KEY_NAME } from '@/utils/hotkeys'; + +export function EnterPassword({ email, redirectTo }: { email: string; redirectTo: string }) { + const { t } = useTranslation(); + const [loading, setLoading] = useState(false); + const service = useService(); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + + const handleSubmit = async (e: React.MouseEvent | React.KeyboardEvent) => { + e.preventDefault(); + if (!service) return; + setLoading(true); + setError(''); + try { + await service.signInWithPassword({ email, password, redirectTo }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (error: any) { + setError(error.message); + } finally { + setLoading(false); + } + }; + + return ( +
+
{ + window.location.href = '/'; + }} + className={'flex cursor-pointer'} + > + +
+
{t('signIn.enterPassword')}
+
+ {email} }} + /> +
+
+
+ { + setError(''); + setPassword(e.target.value); + }} + value={password} + placeholder={t('signIn.enterPassword')} + variant={error ? 'destructive' : 'default'} + onKeyDown={(e) => { + if (createHotkey(HOT_KEY_NAME.ENTER)(e.nativeEvent)) { + void handleSubmit(e); + } + }} + /> + {error &&
{error}
} +
+ +
+ + +
+ ); +} diff --git a/src/components/login/ForgotPassword.tsx b/src/components/login/ForgotPassword.tsx new file mode 100644 index 00000000..f7bbd629 --- /dev/null +++ b/src/components/login/ForgotPassword.tsx @@ -0,0 +1,95 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSearchParams } from 'react-router-dom'; +import { toast } from 'sonner'; + +import { ReactComponent as Logo } from '@/assets/icons/logo.svg'; +import { LOGIN_ACTION } from '@/components/login/const'; +import { useService } from '@/components/main/app.hooks'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Progress } from '@/components/ui/progress'; +import { createHotkey, HOT_KEY_NAME } from '@/utils/hotkeys'; + +export function ForgotPassword({ redirectTo, email: initialEmail }: { redirectTo: string; email: string }) { + const { t } = useTranslation(); + const [loading, setLoading] = useState(false); + const service = useService(); + const [email, setEmail] = useState(initialEmail); + const [, setSearch] = useSearchParams(); + const handleSubmit = async (e: React.MouseEvent | React.KeyboardEvent) => { + e.preventDefault(); + if (!service) return; + setLoading(true); + void (async () => { + try { + await service.forgotPassword({ email }); + // eslint-disable-next-line + } catch (e: any) { + if (e.code === 429 || e.response?.status === 429) { + toast.error(t('tooManyRequests')); + } else { + toast.error(e.message); + } + } finally { + setLoading(false); + } + })(); + + setSearch((prev) => { + prev.set('email', email); + prev.set('action', LOGIN_ACTION.CHECK_EMAIL_RESET_PASSWORD); + return prev; + }); + }; + + return ( +
+
{ + window.location.href = '/'; + }} + className={'flex cursor-pointer'} + > + +
+
{t('resetPassword.title')}
+
+ {t('resetPassword.description')} +
+
+
+ { + setEmail(e.target.value); + }} + value={email} + placeholder={t('resetPassword.placeholder')} + type='email' + onKeyDown={(e) => { + if (createHotkey(HOT_KEY_NAME.ENTER)(e.nativeEvent)) { + void handleSubmit(e); + } + }} + /> +
+
+ + +
+ ); +} diff --git a/src/components/login/Login.tsx b/src/components/login/Login.tsx index 976fb671..6cc6ae67 100644 --- a/src/components/login/Login.tsx +++ b/src/components/login/Login.tsx @@ -1,9 +1,9 @@ import LoginProvider from '@/components/login/LoginProvider'; -import MagicLink from '@/components/login/MagicLink'; import { Separator } from '@/components/ui/separator'; import { ReactComponent as Logo } from '@/assets/icons/logo.svg'; import { useTranslation } from 'react-i18next'; import { ReactComponent as ArrowRight } from '@/assets/icons/arrow_right.svg'; +import EmailLogin from '@/components/login/EmailLogin'; export function Login ({ redirectTo }: { redirectTo: string }) { const { t } = useTranslation(); @@ -20,7 +20,7 @@ export function Login ({ redirectTo }: { redirectTo: string }) {
{t('welcomeTo')} AppFlowy
- +
{t('web.or')} diff --git a/src/components/login/const.ts b/src/components/login/const.ts new file mode 100644 index 00000000..1b5bba2d --- /dev/null +++ b/src/components/login/const.ts @@ -0,0 +1,7 @@ +export const LOGIN_ACTION = { + CHECK_EMAIL: 'checkEmail', + ENTER_PASSWORD: 'enterPassword', + RESET_PASSWORD: 'resetPassword', + CHECK_EMAIL_RESET_PASSWORD: 'checkEmailResetPassword', + CHANGE_PASSWORD: 'changePassword', +} as const; \ No newline at end of file diff --git a/src/components/ui/password-input.tsx b/src/components/ui/password-input.tsx new file mode 100644 index 00000000..f761681f --- /dev/null +++ b/src/components/ui/password-input.tsx @@ -0,0 +1,121 @@ +import { cva, type VariantProps } from 'class-variance-authority'; +import * as React from 'react'; +import { forwardRef } from 'react'; + +import { ReactComponent as HideIcon } from '@/assets/icons/hide.svg'; +import { ReactComponent as ShowIcon } from '@/assets/icons/show.svg'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; + +// Base input styles that apply to all variants and sizes +const baseInputStyles = cn( + // Text and placeholder styling + 'text-text-primary placeholder:text-text-tertiary', + + // Selection styling + 'selection:bg-fill-theme-thick selection:text-text-on-fill focus:caret-fill-theme-thick', + + 'bg-fill-content', + + // Layout + 'flex min-w-0', + + // Typography + 'text-sm', + + // Effects + 'outline-none', + + // File input styling + 'file:inline-flex file:border-0 file:bg-fill-content file:text-sm file:font-medium', + + // Disabled state + 'disabled:pointer-events-none disabled:cursor-not-allowed' +); + +const inputVariants = cva('flex items-center gap-1', { + variants: { + variant: { + // Default variant with focus styles + default: + 'border-border-primary border data-[focused=true]:border-border-theme-thick data-[focused=true]:ring-border-theme-thick data-[focused=true]:ring-[0.5px] disabled:border-border-primary disabled:bg-fill-primary-hover disabled:text-text-tertiary hover:border-border-primary-hover', + // Destructive variant for error states + destructive: + 'border border-border-error-thick focus-visible:border-border-error-thick focus-visible:ring-border-error-thick focus-visible:ring-[0.5px] focus:caret-text-primary disabled:border-border-primary disabled:bg-fill-primary-hover disabled:text-text-tertiary', + }, + size: { + // Small size input + sm: 'h-8 px-2 rounded-300', + + // Medium size input (default) + md: 'h-10 px-2 rounded-400', + }, + }, + defaultVariants: { + variant: 'default', + size: 'sm', + }, +}); + +export interface PasswordInputProps + extends Omit, 'size' | 'variant' | 'type'>, + VariantProps { + inputProps?: React.InputHTMLAttributes; + inputRef?: React.Ref; +} + +const PasswordInput = forwardRef( + ({ className, variant, size, inputProps, inputRef, ...props }, ref) => { + const [focused, setFocused] = React.useState(false); + const [showPassword, setShowPassword] = React.useState(false); + + const togglePasswordVisibility = (e: React.MouseEvent) => { + e.preventDefault(); + setShowPassword(!showPassword); + }; + + return ( +
+ { + setFocused(true); + props?.onFocus?.(e); + }} + onBlur={(e) => { + setFocused(false); + props?.onBlur?.(e); + }} + /> + +
+ ); + } +); + +PasswordInput.displayName = 'PasswordInput'; + +export { PasswordInput, inputVariants, baseInputStyles }; diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx index 592cbeb5..152c912a 100644 --- a/src/pages/LoginPage.tsx +++ b/src/pages/LoginPage.tsx @@ -1,8 +1,13 @@ -import { useContext, useEffect } from 'react'; +import { useContext, useEffect, useMemo } from 'react'; import { useSearchParams } from 'react-router-dom'; import { Login } from '@/components/login'; +import { ChangePassword } from '@/components/login/ChangePassword'; import CheckEmail from '@/components/login/CheckEmail'; +import CheckEmailResetPassword from '@/components/login/CheckEmailResetPassword'; +import { LOGIN_ACTION } from '@/components/login/const'; +import { EnterPassword } from '@/components/login/EnterPassword'; +import { ForgotPassword } from '@/components/login/ForgotPassword'; import { AFConfigContext } from '@/components/main/app.hooks'; function LoginPage() { @@ -10,21 +15,38 @@ function LoginPage() { const redirectTo = search.get('redirectTo') || ''; const action = search.get('action') || ''; const email = search.get('email') || ''; + const force = search.get('force') === 'true'; const isAuthenticated = useContext(AFConfigContext)?.isAuthenticated || false; useEffect(() => { + if (action === LOGIN_ACTION.CHANGE_PASSWORD || force) { + return; + } + if (isAuthenticated && redirectTo && decodeURIComponent(redirectTo) !== window.location.href) { window.location.href = decodeURIComponent(redirectTo); } - }, [isAuthenticated, redirectTo]); + }, [action, force, isAuthenticated, redirectTo]); + + const renderContent = useMemo(() => { + switch (action) { + case LOGIN_ACTION.CHECK_EMAIL: + return ; + case LOGIN_ACTION.ENTER_PASSWORD: + return ; + case LOGIN_ACTION.RESET_PASSWORD: + return ; + case LOGIN_ACTION.CHECK_EMAIL_RESET_PASSWORD: + return ; + case LOGIN_ACTION.CHANGE_PASSWORD: + return ; + default: + return ; + } + }, [action, email, redirectTo]); + return ( -
- {action === 'checkEmail' ? ( - - ) : ( - - )} -
+
{renderContent}
); }