diff --git a/apps/index.ts b/apps/index.ts index 9da9f27f..20f0d11d 100644 --- a/apps/index.ts +++ b/apps/index.ts @@ -6,10 +6,12 @@ export default function prepareApps() { return mountRouter( [ loadRoutes('./landing', '/login'), + loadRoutes('./user', '/user'), loadRoutes('./main', '/'), ], { noSession: '/login', + noSafeSession: '/user/password', noPermission: '/login', } ) diff --git a/apps/landing/index.module.less b/apps/landing/index.module.less index c0df5705..1ba6a627 100644 --- a/apps/landing/index.module.less +++ b/apps/landing/index.module.less @@ -51,6 +51,7 @@ } .logo { + width: 100%; margin-bottom: 100px; } diff --git a/apps/landing/index.tsx b/apps/landing/index.tsx index 89f5434c..1ade8691 100644 --- a/apps/landing/index.tsx +++ b/apps/landing/index.tsx @@ -29,8 +29,25 @@ export default function Login() { const { handleSubmit, errorMsg, clearErrorMsg, loading } = useLogin( (data) => { - dispatchLogin(data.token!, data.userId!) - push(from) + if (data.passwordExpired) { + dispatchLogin( + { + token: data.token!, + session: data.userId!, + passwordExpired: true, + }, + { + persisted: false, + } + ) + } else { + dispatchLogin({ + token: data.token!, + session: data.userId!, + passwordExpired: false, + }) + push(from) + } }, () => { refForm.setFieldsValue({ password: '' }) @@ -45,13 +62,13 @@ export default function Login() { className={`${styles.container} ${styles.formContainer}`} bordered={false} > +
- } @@ -67,10 +84,9 @@ export default function Login() { validateStatus: 'error', })} > - } placeholder={t('form.password')} - type="password" size="large" disabled={loading} onInput={clearErrorMsg} diff --git a/apps/user/[-1]password/index.module.less b/apps/user/[-1]password/index.module.less new file mode 100644 index 00000000..70f1995f --- /dev/null +++ b/apps/user/[-1]password/index.module.less @@ -0,0 +1,86 @@ +.wrapper { + background: #F7F8F9; +} + +.layout { + min-height: 100vh; + width: fit-content; + margin: auto; + flex-direction: row; +} + +.container { + display: flex; + justify-content: center; + max-width: 100vw; + min-height: 100vh; + + &:global(.ant-card) { + background: #F7F8F9; + } + + :global(.ant-card-body) { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + padding-left: 60px; + padding-right: 60px; + } + + &.form-container { + :global(.ant-card-body) { + width: 480px; + } + } + + .form { + width: 100%; + } + + .toolbar { + align-self: flex-end; + .switch-language { + span { + margin-left: 4px; + vertical-align: middle; + } + } + } + + .password-tip { + font-size: 16px; + margin-bottom: 16px; + + .password-tip-icon { + color: #faad14; + margin-right: 8px; + } + } +} + +.reset-result { + width: 100%; + margin-bottom: 24px; + + .reset-result-title { + text-align: center; + font-size: 16px; + margin-bottom: 16px; + + .reset-result-icon { + color: #52c41a; + margin-right: 8px; + } + } +} + +.logo { + width: 100%; + margin-bottom: 100px; +} + +.bgImg { + width: 540px; +} diff --git a/apps/user/[-1]password/index.tsx b/apps/user/[-1]password/index.tsx new file mode 100644 index 00000000..1ea0b05d --- /dev/null +++ b/apps/user/[-1]password/index.tsx @@ -0,0 +1,172 @@ +import { useCallback, useRef, useState } from 'react' +import { AxiosError } from 'axios' +import { Button, Card, Form, Input, Layout } from 'antd' +import { + CheckCircleFilled, + DownOutlined, + FrownOutlined, + KeyOutlined, +} from '@ant-design/icons' +import { useI18n } from '@i18n-macro' +import { useAuthState } from '@store/auth' +import { UserInfo } from '@/api/model' +import { doUserLogout, resetUserPassword } from '@/api/hooks/platform' +import LanguageDropdown from '@/components/LanguageDropdown' +import { Logo } from '@/components/Logo' +import loginBgImg from '/img/background/login.svg' + +import styles from './index.module.less' + +export default function ResetPassword() { + const { t } = useI18n() + + const clearPersistedAuth = useAuthState((state) => state.clearPersistedAuth) + const dispatchLogout = useAuthState((state) => state.logout) + const userId = useAuthState((state) => state.session) + const passwordExpired = useAuthState((state) => state.passwordExpired) + + const [resetAlread, setResetAlread] = useState(false) + + const [refForm] = Form.useForm() + const refPassword = useRef(null) + + const { handleSubmit, errorMsg, clearErrorMsg, loading } = useResetPassword( + (data) => { + if (data) { + clearPersistedAuth() + setResetAlread(true) + } + }, + () => { + refForm.setFieldsValue({ password: '' }) + refPassword.current?.focus() + } + ) + + const backToLogin = useCallback(() => { + doUserLogout().then(dispatchLogout) + }, [dispatchLogout]) + + return ( +
+ + + + {resetAlread ? ( +
+
+ + {t('message.success')} +
+ +
+ ) : ( + + {passwordExpired && ( +
+ + {t('form.resetTips')} +
+ )} + + + } + placeholder={t('form.password')} + size="large" + minLength={8} + maxLength={20} + disabled={loading} + onInput={clearErrorMsg} + ref={refPassword} + /> + + + + + + )} + +
+ + Landing + +
+
+ ) +} + +function useResetPassword( + onSuccess: (data: UserInfo) => void, + onFailure: () => void +) { + const { t } = useI18n() + + const [loading, setLoading] = useState(false) + + const [errorMsg, setErrorMsg] = useState() + + const clearErrorMsg = () => setErrorMsg(undefined) + + const handleSubmit = async (form: { userId: string; password: string }) => { + clearErrorMsg() + setLoading(true) + try { + const result = await resetUserPassword({ + userId: form.userId, + password: form.password, + }) + setLoading(false) + onSuccess(result.data.data!) + } catch (err) { + setErrorMsg( + t(`error:${(err as AxiosError).response?.data?.code}`, 'unknown') + ) + onFailure() + // when success, this component will be unmounted immediately so no need to setLoading() + setLoading(false) + } + } + + return { handleSubmit, loading, errorMsg, clearErrorMsg } +} diff --git a/apps/user/[-1]password/meta.ts b/apps/user/[-1]password/meta.ts new file mode 100644 index 00000000..caec29f3 --- /dev/null +++ b/apps/user/[-1]password/meta.ts @@ -0,0 +1,3 @@ +import { definePage } from '@/model/page' + +export default definePage({}) diff --git a/apps/user/[-1]password/translations/en.yaml b/apps/user/[-1]password/translations/en.yaml new file mode 100644 index 00000000..1ebd8e88 --- /dev/null +++ b/apps/user/[-1]password/translations/en.yaml @@ -0,0 +1,13 @@ +name: Reset Password +switch_language: Switch Language +form: + resetTips: Your password is expired, please reset it + password: New Password + submit: Reset +rules: + password: 'Password must be 8 to 20 characters that must contain uppercase letters, lowercase letters and numbers, and may contain visible special characters !@#$%^&*' +result: + login: Back to Sign in +message: + error: 'Failed to reset your password: {{ msg }}' + success: Your password has been reset diff --git a/apps/user/[-1]password/translations/zh.yaml b/apps/user/[-1]password/translations/zh.yaml new file mode 100644 index 00000000..5f63e8d7 --- /dev/null +++ b/apps/user/[-1]password/translations/zh.yaml @@ -0,0 +1,13 @@ +name: 重置密码 +switch_language: 更换语言 +form: + resetTips: 密码已过期,请重置密码 + password: 新密码 + submit: 重置密码 +rules: + password: 密码必须是 6 - 20 个字符,必须包含大写字母、小写字母和数字的组合,可包含特殊字符 ~!@#$%^&* +result: + login: 重新登入 +message: + error: '重置密码失败: {{ msg }}' + success: 重置密码成功 diff --git a/apps/user/layout.sync.tsx b/apps/user/layout.sync.tsx new file mode 100644 index 00000000..3501b91f --- /dev/null +++ b/apps/user/layout.sync.tsx @@ -0,0 +1,6 @@ +import { PropsWithChildren } from 'react' +import BlankLayout from './layouts' + +export default function ({ children }: PropsWithChildren<{}>) { + return {children} +} diff --git a/apps/user/layouts/index.module.less b/apps/user/layouts/index.module.less new file mode 100644 index 00000000..964a09df --- /dev/null +++ b/apps/user/layouts/index.module.less @@ -0,0 +1,3 @@ +.blank-layout { + min-height: 100vh; +} diff --git a/apps/user/layouts/index.tsx b/apps/user/layouts/index.tsx new file mode 100644 index 00000000..aee6ce95 --- /dev/null +++ b/apps/user/layouts/index.tsx @@ -0,0 +1,9 @@ +import { FC } from 'react' +import { Layout } from 'antd' +import styles from './index.module.less' + +const BlankLayout: FC = (props) => { + return {props.children} +} + +export default BlankLayout diff --git a/apps/user/meta.ts b/apps/user/meta.ts new file mode 100644 index 00000000..118bffee --- /dev/null +++ b/apps/user/meta.ts @@ -0,0 +1,9 @@ +import { definePage } from '@/model/page' +import { resolveRoute } from '@pages-macro' + +export default definePage({ + role: ['user'], + redirect(_, location) { + if (location.pathname === resolveRoute()) return resolveRoute('../') + }, +}) diff --git a/external/error/input.csv b/external/error/input.csv index ddc589d5..dcddb177 100644 --- a/external/error/input.csv +++ b/external/error/input.csv @@ -175,6 +175,7 @@ TiEM平台管理-7xxxx,TiEM升级-701xx,70100,,,,, ,,70601,User is not found,用户不存在,404,给用户管理模块预留,暂时不会出现, ,,70602,Access token has been expired,用户 Token 已经失效,401,, ,,70603,Incorrect username or password,用户名或密码错误,400,, +,,70615,Password is expired,密码已过期,400,, ,,,,,,, ,,70650," rbac permission check failed",RBAC权限校验失败,403,, diff --git a/src/api/hooks/platform.ts b/src/api/hooks/platform.ts index ed2a9e00..b54b2ed4 100644 --- a/src/api/hooks/platform.ts +++ b/src/api/hooks/platform.ts @@ -24,6 +24,17 @@ export function doUserLogout() { }) } +export function resetUserPassword(payload: { + userId: string + password: string +}) { + return APIS.Platform.usersUserIdPasswordPost( + payload.userId, + { id: payload.userId, password: payload.password }, + { skipNotifications: true } + ) +} + /************** * System Info **************/ diff --git a/src/api/interceptors/index.ts b/src/api/interceptors/index.ts index ee68abf7..b4a52768 100644 --- a/src/api/interceptors/index.ts +++ b/src/api/interceptors/index.ts @@ -8,9 +8,16 @@ export const onErrorResponse = (error: AxiosError) => { if (!error.config.skipNotifications && !error.config.skipErrorNotification) { useErrorNotification(error) } + if (error.response?.status === 401) { getAuthState().logout() + } else if ( + error.response?.status === 400 && + error.response?.data?.code === 70615 + ) { + getAuthState().resetPasswordExpired() } + return Promise.reject(error) } diff --git a/src/router/index.tsx b/src/router/index.tsx index 3f2ea9fa..b5e5fb5a 100644 --- a/src/router/index.tsx +++ b/src/router/index.tsx @@ -14,8 +14,9 @@ import { } from '@/router/helper' function getCurrentRoutePath() { - if (import.meta.env.DEV) return window.location.hash.slice(1) - else return window.location.pathname + // if (import.meta.env.DEV) return window.location.hash.slice(1) + // else return window.location.pathname + return window.location.pathname } export interface RoleGuard { @@ -23,6 +24,9 @@ export interface RoleGuard { noPermission: string // not logged in noSession: string + // logged in and no safe session + // e.g. user password expired + noSafeSession: string } export default function mountRouter( @@ -95,14 +99,21 @@ function genRouteProp( redirect && typeof redirect === 'function' ? guard ? () => { - const [session, location] = useSessionLocation() + const { session, location } = useSessionLocation() + return ( checkRole(role, guard, session) || - checkRedirect(redirect, session, location) || + checkRedirect(redirect, session.sessionId, location) || comp() ) } - : () => checkRedirect(redirect, ...useSessionLocation()) || comp() + : () => { + const { session, location } = useSessionLocation() + + return ( + checkRedirect(redirect, session.sessionId, location) || comp() + ) + } : guard ? () => checkRole(role, guard, useSession()) || comp() : comp @@ -114,32 +125,64 @@ function genRouteProp( } } -function useSessionLocation(): [string, Location] { - return [useSession(), useLocationWithState()] +function useSessionLocation() { + const session = useSession() + const location = useLocationWithState() + + return { + session, + location, + } } -function useSession(): string { - return useAuthState((state) => state.session) +function useSession() { + const sessionId = useAuthState((state) => state.session) + const safe = useAuthState((state) => !state.passwordExpired) + + return { + sessionId, + safe, + } } -function checkRole(role: Role[], guard: RoleGuard, session: string) { - if (role[0] === 'user' && !session) +function checkRole( + role: Role[], + guard: RoleGuard, + session: { sessionId: string; safe: boolean } +) { + const { sessionId, safe } = session + const currentPath = getCurrentRoutePath() + const isNoSafeSessionPath = currentPath === guard.noSafeSession + + if (role[0] === 'user' && !sessionId) { + const validFromPath = isNoSafeSessionPath ? '/' : currentPath + return ( ) + } else if (sessionId && !safe && !isNoSafeSessionPath) { + return ( + + ) + } } function checkRedirect( redirect: Redirector, - session: string, + sessionId: string, location: Location ) { - const res = redirect(session, location) + const res = redirect(sessionId, location) if (res) return ( void + login: ( + payload: { token: string; session: string; passwordExpired?: boolean }, + options?: { persisted: boolean } + ) => void logout: () => void + clearPersistedAuth: () => void + resetPasswordExpired: () => void } const TOKEN_KEY = 'APP_TOKEN' @@ -15,21 +21,58 @@ const SESSION_KEY = 'APP_SESSION' export const useAuthState = create((set) => { const token = sessionStorage.getItem(TOKEN_KEY) || '' const session = sessionStorage.getItem(SESSION_KEY) || '' + if (token) setRequestToken(token) + return { + passwordExpired: false, token: token, session: session, - login: (token: string, session: string) => { - sessionStorage.setItem(TOKEN_KEY, token) - sessionStorage.setItem(SESSION_KEY, session) + login: ( + payload: { + token: string + session: string + passwordExpired?: boolean + }, + options?: { persisted: boolean } + ) => { + const { + token = '', + session = '', + passwordExpired = false, + } = payload || {} + const { persisted = true } = options || {} + + if (persisted) { + sessionStorage.setItem(TOKEN_KEY, token) + sessionStorage.setItem(SESSION_KEY, session) + } + setRequestToken(token) - set({ token, session }) + set({ + token, + session, + passwordExpired, + }) }, logout: () => { sessionStorage.removeItem(TOKEN_KEY) sessionStorage.removeItem(SESSION_KEY) setRequestToken() - set({ token: '', session: '' }) + set({ + token: '', + session: '', + passwordExpired: false, + }) + }, + clearPersistedAuth: () => { + sessionStorage.removeItem(TOKEN_KEY) + sessionStorage.removeItem(SESSION_KEY) + }, + resetPasswordExpired: () => { + set({ + passwordExpired: true, + }) }, } })