mirror of
https://github.com/AppFlowy-IO/AppFlowy-Web.git
synced 2026-03-13 10:00:26 +08:00
chore: support login with password (#16)
This commit is contained in:
@@ -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 <email />",
|
||||
"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 <email/>.",
|
||||
"enterCode": "Enter code",
|
||||
"checkEmailTip": "If this account exists, an email has been sent to <email/> with further instructions.",
|
||||
"enterCodeManually": "Enter code manually",
|
||||
"continue": "Continue to reset password"
|
||||
},
|
||||
"changePassword": {
|
||||
"title": "Reset password",
|
||||
"description": "Enter new password for <email/>",
|
||||
"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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -95,7 +95,10 @@ export interface AppService {
|
||||
getAppTrash: (workspaceId: string) => Promise<View[]>;
|
||||
loginAuth: (url: string) => Promise<void>;
|
||||
signInMagicLink: (params: { email: string; redirectTo: string }) => Promise<void>;
|
||||
signInOTP: (params: { email: string; code: string; redirectTo: string }) => Promise<void>;
|
||||
signInOTP: (params: { email: string; code: string; redirectTo: string; type?: 'magiclink' | 'recovery' }) => Promise<void>;
|
||||
signInWithPassword: (params: { email: string; password: string; redirectTo: string }) => Promise<void>;
|
||||
forgotPassword: (params: { email: string }) => Promise<void>;
|
||||
changePassword: (params: { password: string }) => Promise<void>;
|
||||
signInGoogle: (params: { redirectTo: string }) => Promise<void>;
|
||||
signInGithub: (params: { redirectTo: string }) => Promise<void>;
|
||||
signInDiscord: (params: { redirectTo: string }) => Promise<void>;
|
||||
|
||||
@@ -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;
|
||||
@@ -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() {
|
||||
|
||||
<Import />
|
||||
{openCreateWorkspace && (
|
||||
<CreateWorkspace
|
||||
<EditWorkspace
|
||||
onOk={handleCreateWorkspace}
|
||||
okText={t('workspace.create')}
|
||||
okText={t('button.create')}
|
||||
defaultName={`${currentUser?.name}'s Workspace`}
|
||||
open={openCreateWorkspace}
|
||||
openOnChange={setOpenCreateWorkspace}
|
||||
@@ -278,11 +278,11 @@ export function Workspaces() {
|
||||
)}
|
||||
|
||||
{openRenameWorkspace && (
|
||||
<CreateWorkspace
|
||||
<EditWorkspace
|
||||
open={Boolean(openRenameWorkspace)}
|
||||
openOnChange={() => setOpenRenameWorkspace(null)}
|
||||
onOk={handleUpdateWorkspace}
|
||||
okText={t('workspace.rename')}
|
||||
okText={t('button.rename')}
|
||||
defaultName={openRenameWorkspace.name}
|
||||
title={t('workspace.rename')}
|
||||
/>
|
||||
|
||||
246
src/components/login/ChangePassword.tsx
Normal file
246
src/components/login/ChangePassword.tsx
Normal file
@@ -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<string[]>([]);
|
||||
|
||||
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<HTMLButtonElement> | React.KeyboardEvent<HTMLInputElement>) => {
|
||||
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 (
|
||||
<div className={'flex w-[320px] flex-col items-center justify-center gap-5 text-text-primary'}>
|
||||
<div
|
||||
onClick={() => {
|
||||
window.location.href = '/';
|
||||
}}
|
||||
className={'flex cursor-pointer'}
|
||||
>
|
||||
<Logo className={'h-10 w-10'} />
|
||||
</div>
|
||||
<div className={'text-xl font-semibold text-text-primary'}>{t('changePassword.title')}</div>
|
||||
<div className={'w-full text-center text-sm font-normal'}>
|
||||
<Trans
|
||||
i18nKey='changePassword.description'
|
||||
components={{ email: <span className={'font-semibold'}>{email}</span> }}
|
||||
/>
|
||||
</div>
|
||||
<div className={'flex w-full flex-col gap-3'}>
|
||||
<div className={'flex flex-col gap-1'}>
|
||||
<Label>{t('changePassword.newPassword')}</Label>
|
||||
<PasswordInput
|
||||
autoFocus
|
||||
size={'md'}
|
||||
className={'w-full'}
|
||||
onChange={(e) => {
|
||||
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 && (
|
||||
<div className={'flex flex-col gap-1'}>
|
||||
{passwordErrors.map((error, index) => (
|
||||
<div key={index} className={cn('help-text text-xs text-text-error')}>
|
||||
{error}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={'flex flex-col gap-1'}>
|
||||
<Label>{t('changePassword.confirmPassword')}</Label>
|
||||
<PasswordInput
|
||||
size={'md'}
|
||||
className={'w-full'}
|
||||
onChange={(e) => {
|
||||
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 && <div className={cn('help-text text-xs text-text-error')}>{error}</div>}
|
||||
</div>
|
||||
|
||||
{error && !hasConfirmError && <div className={cn('help-text text-xs text-text-error')}>{error}</div>}
|
||||
</div>
|
||||
<Button loading={loading} size={'lg'} className={'w-full'} onMouseDown={handleSubmit} disabled={!isFormValid}>
|
||||
{loading ? (
|
||||
<>
|
||||
<Progress />
|
||||
{t('verifying')}
|
||||
</>
|
||||
) : (
|
||||
t('changePassword.submit')
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant={'link'}
|
||||
onClick={() => {
|
||||
invalidToken();
|
||||
}}
|
||||
className={'w-full'}
|
||||
>
|
||||
{t('backToLogin')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
125
src/components/login/CheckEmailResetPassword.tsx
Normal file
125
src/components/login/CheckEmailResetPassword.tsx
Normal file
@@ -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<string>('');
|
||||
const [isEnter, setEnter] = useState<boolean>(false);
|
||||
const [code, setCode] = useState<string>('');
|
||||
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 (
|
||||
<div className={'flex w-[320px] flex-col items-center justify-center gap-5 text-text-primary'}>
|
||||
<div
|
||||
onClick={() => {
|
||||
window.location.href = '/';
|
||||
}}
|
||||
className={'flex cursor-pointer'}
|
||||
>
|
||||
<Logo className={'h-10 w-10'} />
|
||||
</div>
|
||||
<div className={'text-center text-xl font-semibold text-text-primary'}>
|
||||
{isEnter ? t('resetPassword.enterCode') : t('resetPassword.checkYourEmail')}
|
||||
</div>
|
||||
<div className={'w-full text-center text-sm font-normal'}>
|
||||
<Trans
|
||||
i18nKey={isEnter ? 'resetPassword.checkCodeTip' : 'resetPassword.checkEmailTip'}
|
||||
components={{ email: <span className={'whitespace-nowrap font-semibold'}>{email}</span> }}
|
||||
/>
|
||||
</div>
|
||||
{isEnter ? (
|
||||
<div className={'flex w-full flex-col gap-3'}>
|
||||
<div className={'flex w-full flex-col gap-1'}>
|
||||
<Input
|
||||
autoFocus
|
||||
size={'md'}
|
||||
className={'w-full'}
|
||||
onChange={(e) => {
|
||||
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 && <div className={cn('help-text text-xs text-text-error')}>{error}</div>}
|
||||
</div>
|
||||
|
||||
<Button loading={loading} onClick={handleSubmit} size={'lg'} className={'w-full'}>
|
||||
{loading ? (
|
||||
<>
|
||||
<Progress />
|
||||
{t('verifying')}
|
||||
</>
|
||||
) : (
|
||||
t('resetPassword.continue')
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button size={'lg'} className={'w-full'} onClick={() => setEnter(true)}>
|
||||
{t('enterCodeManually')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant={'link'}
|
||||
onClick={() => {
|
||||
window.location.href = `/login?redirectTo=${redirectTo}`;
|
||||
}}
|
||||
className={'w-full'}
|
||||
>
|
||||
{t('backToLogin')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CheckEmailResetPassword;
|
||||
@@ -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<string>('');
|
||||
const [loading, setLoading] = React.useState<boolean>(false);
|
||||
const [error, setError] = React.useState<string>('');
|
||||
const [email, setEmail] = useState<string>('');
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string>('');
|
||||
const [, setSearch] = useSearchParams();
|
||||
const service = useContext(AFConfigContext)?.service;
|
||||
const handleSubmit = async () => {
|
||||
const handleSubmitEmail = async (e?: React.MouseEvent<HTMLButtonElement>) => {
|
||||
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<HTMLButtonElement>) => {
|
||||
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 (
|
||||
<div className={'flex w-full flex-col items-center justify-center gap-3'}>
|
||||
<div className={'flex flex-col gap-1'}>
|
||||
<Input
|
||||
autoFocus
|
||||
size={'md'}
|
||||
variant={error ? 'destructive' : 'default'}
|
||||
type={'email'}
|
||||
@@ -69,30 +88,30 @@ function MagicLink ({ redirectTo }: { redirectTo: string }) {
|
||||
}}
|
||||
value={email}
|
||||
placeholder={t('signIn.pleaseInputYourEmail')}
|
||||
onKeyDown={e => {
|
||||
onKeyDown={(e) => {
|
||||
if (createHotkey(HOT_KEY_NAME.ENTER)(e.nativeEvent)) {
|
||||
void handleSubmit();
|
||||
void handleSubmitEmail();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{error && <div className={cn('help-text text-xs text-text-error')}>
|
||||
{error}
|
||||
</div>}
|
||||
{error && <div className={cn('help-text text-xs text-text-error')}>{error}</div>}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
size={'lg'}
|
||||
className={'w-[320px]'}
|
||||
loading={loading}
|
||||
>
|
||||
{loading ? <>
|
||||
<Progress />
|
||||
{t('loading')}
|
||||
</> : t('signIn.signInWithEmail')}
|
||||
<Button onMouseDown={handleSubmitEmail} size={'lg'} className={'w-[320px]'} loading={loading}>
|
||||
{loading ? (
|
||||
<>
|
||||
<Progress />
|
||||
{t('loading')}
|
||||
</>
|
||||
) : (
|
||||
t('signIn.signInWithEmail')
|
||||
)}
|
||||
</Button>
|
||||
<Button variant={'outline'} onMouseDown={handleSubmitPassword} size={'lg'} className={'w-[320px]'}>
|
||||
{t('signIn.signInWithPassword')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MagicLink;
|
||||
export default EmailLogin;
|
||||
104
src/components/login/EnterPassword.tsx
Normal file
104
src/components/login/EnterPassword.tsx
Normal file
@@ -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<HTMLButtonElement> | React.KeyboardEvent<HTMLInputElement>) => {
|
||||
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 (
|
||||
<div className={'flex w-[320px] flex-col items-center justify-center gap-5 text-text-primary'}>
|
||||
<div
|
||||
onClick={() => {
|
||||
window.location.href = '/';
|
||||
}}
|
||||
className={'flex cursor-pointer'}
|
||||
>
|
||||
<Logo className={'h-10 w-10'} />
|
||||
</div>
|
||||
<div className={'text-xl font-semibold text-text-primary'}>{t('signIn.enterPassword')}</div>
|
||||
<div className={'flex w-full items-center justify-center gap-1.5 text-center text-sm'}>
|
||||
<Trans
|
||||
i18nKey='signIn.enterPasswordTip'
|
||||
components={{ email: <span className={'font-semibold'}>{email}</span> }}
|
||||
/>
|
||||
</div>
|
||||
<div className={'flex w-full flex-col gap-2'}>
|
||||
<div className={'flex flex-col gap-1'}>
|
||||
<PasswordInput
|
||||
autoFocus
|
||||
size={'md'}
|
||||
className={'w-full'}
|
||||
onChange={(e) => {
|
||||
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 && <div className={cn('help-text text-xs text-text-error')}>{error}</div>}
|
||||
</div>
|
||||
<Button
|
||||
variant={'link'}
|
||||
onClick={() => {
|
||||
window.location.href = `/login?action=${LOGIN_ACTION.RESET_PASSWORD}&email=${email}&redirectTo=${redirectTo}`;
|
||||
}}
|
||||
className={'w-full justify-start px-0 text-sm'}
|
||||
>
|
||||
{t('signIn.forgotPassword')}
|
||||
</Button>
|
||||
</div>
|
||||
<Button size={'lg'} className={'w-full'} onMouseDown={handleSubmit}>
|
||||
{loading ? (
|
||||
<>
|
||||
<Progress />
|
||||
{t('verifying')}
|
||||
</>
|
||||
) : (
|
||||
t('signIn.continueToSignInWithPassword')
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant={'link'}
|
||||
onClick={() => {
|
||||
window.location.href = `/login?redirectTo=${redirectTo}`;
|
||||
}}
|
||||
className={'w-full'}
|
||||
>
|
||||
{t('backToLogin')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
95
src/components/login/ForgotPassword.tsx
Normal file
95
src/components/login/ForgotPassword.tsx
Normal file
@@ -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<HTMLButtonElement> | React.KeyboardEvent<HTMLInputElement>) => {
|
||||
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 (
|
||||
<div className={'flex w-[320px] flex-col items-center justify-center gap-5 text-text-primary'}>
|
||||
<div
|
||||
onClick={() => {
|
||||
window.location.href = '/';
|
||||
}}
|
||||
className={'flex cursor-pointer'}
|
||||
>
|
||||
<Logo className={'h-10 w-10'} />
|
||||
</div>
|
||||
<div className={'text-xl font-semibold text-text-primary'}>{t('resetPassword.title')}</div>
|
||||
<div className={'flex w-full items-center justify-center gap-1.5 text-center text-sm'}>
|
||||
{t('resetPassword.description')}
|
||||
</div>
|
||||
<div className={'flex w-full flex-col gap-2'}>
|
||||
<div className={'flex flex-col gap-1'}>
|
||||
<Input
|
||||
autoFocus
|
||||
size={'md'}
|
||||
className={'w-full'}
|
||||
onChange={(e) => {
|
||||
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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button size={'lg'} className={'w-full'} onMouseDown={handleSubmit}>
|
||||
{loading && <Progress />}
|
||||
{t('resetPassword.submit')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={'link'}
|
||||
onClick={() => {
|
||||
window.location.href = `/login?redirectTo=${redirectTo}`;
|
||||
}}
|
||||
className={'w-full'}
|
||||
>
|
||||
{t('backToLogin')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 }) {
|
||||
<Logo className={'h-9 w-9'} />
|
||||
<div className={'text-xl font-semibold'}>{t('welcomeTo')} AppFlowy</div>
|
||||
</div>
|
||||
<MagicLink redirectTo={redirectTo} />
|
||||
<EmailLogin redirectTo={redirectTo} />
|
||||
<div className={'flex w-full items-center justify-center gap-2 text-text-secondary'}>
|
||||
<Separator className={'flex-1'} />
|
||||
{t('web.or')}
|
||||
|
||||
7
src/components/login/const.ts
Normal file
7
src/components/login/const.ts
Normal file
@@ -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;
|
||||
121
src/components/ui/password-input.tsx
Normal file
121
src/components/ui/password-input.tsx
Normal file
@@ -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<React.InputHTMLAttributes<HTMLInputElement>, 'size' | 'variant' | 'type'>,
|
||||
VariantProps<typeof inputVariants> {
|
||||
inputProps?: React.InputHTMLAttributes<HTMLInputElement>;
|
||||
inputRef?: React.Ref<HTMLInputElement>;
|
||||
}
|
||||
|
||||
const PasswordInput = forwardRef<HTMLInputElement, PasswordInputProps>(
|
||||
({ className, variant, size, inputProps, inputRef, ...props }, ref) => {
|
||||
const [focused, setFocused] = React.useState(false);
|
||||
const [showPassword, setShowPassword] = React.useState(false);
|
||||
|
||||
const togglePasswordVisibility = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
setShowPassword(!showPassword);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-slot='input'
|
||||
className={cn(inputVariants({ variant, size }), className)}
|
||||
data-focused={focused}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
className={cn(
|
||||
'flex-1',
|
||||
baseInputStyles,
|
||||
// Invalid state styling (applied via aria-invalid attribute)
|
||||
'aria-invalid:ring-border-error-thick aria-invalid:border-border-error-thick',
|
||||
inputProps?.className
|
||||
)}
|
||||
{...props}
|
||||
{...inputProps}
|
||||
onFocus={(e) => {
|
||||
setFocused(true);
|
||||
props?.onFocus?.(e);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
setFocused(false);
|
||||
props?.onBlur?.(e);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size='icon-sm'
|
||||
onMouseDown={togglePasswordVisibility}
|
||||
tabIndex={-1}
|
||||
variant='ghost'
|
||||
aria-label={showPassword ? 'hide password' : 'show password'}
|
||||
>
|
||||
{showPassword ? <HideIcon className='h-5 w-5' /> : <ShowIcon className='h-5 w-5' />}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
PasswordInput.displayName = 'PasswordInput';
|
||||
|
||||
export { PasswordInput, inputVariants, baseInputStyles };
|
||||
@@ -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 <CheckEmail email={email} redirectTo={redirectTo} />;
|
||||
case LOGIN_ACTION.ENTER_PASSWORD:
|
||||
return <EnterPassword email={email} redirectTo={redirectTo} />;
|
||||
case LOGIN_ACTION.RESET_PASSWORD:
|
||||
return <ForgotPassword email={email} redirectTo={redirectTo} />;
|
||||
case LOGIN_ACTION.CHECK_EMAIL_RESET_PASSWORD:
|
||||
return <CheckEmailResetPassword email={email} redirectTo={redirectTo} />;
|
||||
case LOGIN_ACTION.CHANGE_PASSWORD:
|
||||
return <ChangePassword email={email} redirectTo={redirectTo} />;
|
||||
default:
|
||||
return <Login redirectTo={redirectTo} />;
|
||||
}
|
||||
}, [action, email, redirectTo]);
|
||||
|
||||
return (
|
||||
<div className={'flex h-screen w-screen items-center justify-center bg-background-primary'}>
|
||||
{action === 'checkEmail' ? (
|
||||
<CheckEmail email={email} redirectTo={redirectTo} />
|
||||
) : (
|
||||
<Login redirectTo={redirectTo} />
|
||||
)}
|
||||
</div>
|
||||
<div className={'flex h-screen w-screen items-center justify-center bg-background-primary'}>{renderContent}</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user