chore: support login with password (#16)

This commit is contained in:
Kilu.He
2025-06-27 15:50:55 +08:00
committed by GitHub
parent 9f2d80a742
commit ac3ddbd74f
15 changed files with 961 additions and 67 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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')}
/>

View 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>
);
}

View 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;

View File

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

View 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>
);
}

View 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>
);
}

View File

@@ -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')}

View 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;

View 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 };

View File

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