refactor: login form based on sso auth
This commit is contained in:
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1755020187776" class="icon" viewBox="0 0 1105 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="7392" xmlns:xlink="http://www.w3.org/1999/xlink" width="138.125" height="128"><path d="M507.2896 133.44768l130.98496-71.4496v820.9408l-130.97984 69.71392V133.44768z" fill="#F38019" p-id="7393"></path><path d="M479.39072 323.968a724.30592 724.30592 0 0 0-77.056 16.6144C118.35904 417.01376 37.77536 603.8016 67.95776 701.696c30.11584 97.8944 161.03424 263.2192 439.2704 250.19904 0.13824-27.41248 0.13824-57.73824 0-90.89536-200.49408-26.37824-302.67904-88.82688-306.55488-187.40736-5.74464-147.74272 134.1696-216.90368 217.87136-229.0176 16.82432-2.49344 37.66272-5.67808 61.19936-8.6528l-0.27648-111.9488h-0.07168z m185.12384 107.65312c52.1984 5.6832 103.57248 18.69312 147.0464 44.44672-10.65984 5.19168-34.26816 19.73248-70.82496 43.47904l290.49344 66.39104-16.54272-206.58176-78.37184 44.30336c-116.10112-57.8048-205.19936-91.0336-267.29984-99.6864l-4.50048 107.648z" fill="#ADAFB3" p-id="7394"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="none">
|
||||
␍<g fill="#C22E33">
|
||||
␍<path d="M7.754 2l.463.41c.343.304.687.607 1.026.915C11.44 5.32 13.3 7.565 14.7 10.149c.072.132.137.268.202.403l.098.203-.108.057-.081-.115-.21-.299-.147-.214c-1.019-1.479-2.04-2.96-3.442-4.145a6.563 6.563 0 00-1.393-.904c-1.014-.485-1.916-.291-2.69.505-.736.757-1.118 1.697-1.463 2.653-.045.123-.092.245-.139.367l-.082.215-.172-.055c.1-.348.192-.698.284-1.049.21-.795.42-1.59.712-2.356.31-.816.702-1.603 1.093-2.39.169-.341.338-.682.5-1.025h.092z"/>
|
||||
␍<path d="M8.448 11.822c-1.626.77-5.56 1.564-7.426 1.36C.717 11.576 3.71 4.05 5.18 2.91l-.095.218a4.638 4.638 0 01-.138.303l-.066.129c-.76 1.462-1.519 2.926-1.908 4.53a7.482 7.482 0 00-.228 1.689c-.01 1.34.824 2.252 2.217 2.309.67.027 1.347-.043 2.023-.114.294-.03.587-.061.88-.084.108-.008.214-.021.352-.039l.231-.028z"/>
|
||||
␍<path d="M3.825 14.781c-.445.034-.89.068-1.333.108 4.097.39 8.03-.277 11.91-1.644-1.265-2.23-2.97-3.991-4.952-5.522.026.098.084.169.141.239l.048.06c.17.226.348.448.527.67.409.509.818 1.018 1.126 1.578.778 1.42.356 2.648-1.168 3.296-1.002.427-2.097.718-3.18.892-1.03.164-2.075.243-3.119.323z"/>
|
||||
␍</g>
|
||||
␍</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -1,72 +0,0 @@
|
||||
.alert-info-block {
|
||||
padding-block: 6px;
|
||||
padding-inline: 10px 16px;
|
||||
position: relative;
|
||||
padding-left: 32px;
|
||||
text-align: left;
|
||||
border-radius: var(--border-radius-base);
|
||||
margin: 0;
|
||||
border: 1px solid transparent;
|
||||
|
||||
.ant-typography {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
&.danger {
|
||||
border-color: var(--ant-color-error-border);
|
||||
background-color: var(--ant-color-error-bg);
|
||||
}
|
||||
|
||||
&.warning {
|
||||
border-color: var(--ant-color-warning-border);
|
||||
background-color: var(--ant-color-warning-bg);
|
||||
}
|
||||
|
||||
&.transition {
|
||||
color: var(--ant-geekblue-7);
|
||||
background: var(--ant-geekblue-1);
|
||||
border-color: var(--ant-geekblue-3);
|
||||
}
|
||||
|
||||
&.success {
|
||||
border: 1px solid var(--ant-color-success);
|
||||
color: var(--ant-color-success);
|
||||
background: var(--ant-color-success-bg);
|
||||
|
||||
.content.success {
|
||||
font-weight: var(--font-weight-normal);
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
display: flex;
|
||||
height: 32px;
|
||||
padding: 5px 10px;
|
||||
border-radius: var(--border-radius-base) var(--border-radius-base) 0 0;
|
||||
|
||||
.info-icon {
|
||||
&.danger {
|
||||
color: var(--ant-color-error);
|
||||
}
|
||||
|
||||
&.warning {
|
||||
color: var(--ant-color-warning);
|
||||
}
|
||||
|
||||
&.transition {
|
||||
color: var(--ant-geekblue-7);
|
||||
}
|
||||
|
||||
&.success {
|
||||
color: var(--ant-color-success);
|
||||
}
|
||||
}
|
||||
|
||||
.text {
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { WarningFilled } from '@ant-design/icons';
|
||||
import { Typography } from 'antd';
|
||||
import { createStyles } from 'antd-style';
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
import styled from 'styled-components';
|
||||
import OverlayScroller, { OverlayScrollerOptions } from '../overlay-scroller';
|
||||
import './block.less';
|
||||
interface AlertInfoProps {
|
||||
type: Global.MessageType;
|
||||
message: React.ReactNode;
|
||||
@@ -18,6 +18,81 @@ interface AlertInfoProps {
|
||||
overlayScrollerProps?: OverlayScrollerOptions;
|
||||
}
|
||||
|
||||
const useStyles = createStyles(({ token, css }) => {
|
||||
return {
|
||||
alertBlockInfo: css`
|
||||
padding-block: 6px;
|
||||
padding-inline: 10px 16px;
|
||||
position: relative;
|
||||
padding-left: 32px;
|
||||
text-align: left;
|
||||
border-radius: ${token.borderRadius}px;
|
||||
margin: 0;
|
||||
border: 1px solid transparent;
|
||||
.ant-typography {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
&.danger {
|
||||
border-color: ${token.colorErrorBorder};
|
||||
background-color: ${token.colorErrorBg};
|
||||
}
|
||||
|
||||
&.warning {
|
||||
border-color: ${token.colorWarningBorder};
|
||||
background-color: ${token.colorWarningBg};
|
||||
}
|
||||
|
||||
&.transition {
|
||||
color: ${token.geekblue7};
|
||||
background: ${token.geekblue1};
|
||||
border-color: ${token.geekblue3};
|
||||
}
|
||||
|
||||
&.success {
|
||||
border: 1px solid ${token.colorSuccess};
|
||||
color: ${token.colorSuccessText};
|
||||
background: ${token.colorSuccessBg};
|
||||
|
||||
.content.success {
|
||||
font-weight: var(--font-weight-normal);
|
||||
}
|
||||
}
|
||||
.title {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
display: flex;
|
||||
height: 32px;
|
||||
padding: 5px 10px;
|
||||
border-radius: ${token.borderRadius}px ${token.borderRadius}px 0 0;
|
||||
|
||||
.info-icon {
|
||||
&.danger {
|
||||
color: ${token.colorErrorText};
|
||||
}
|
||||
|
||||
&.warning {
|
||||
color: ${token.colorWarningText};
|
||||
}
|
||||
|
||||
&.transition {
|
||||
color: ${token.geekblue7};
|
||||
}
|
||||
|
||||
&.success {
|
||||
color: ${token.colorSuccessText};
|
||||
}
|
||||
}
|
||||
|
||||
.text {
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
}
|
||||
`
|
||||
};
|
||||
});
|
||||
|
||||
const TitleWrapper = styled.div`
|
||||
font-weight: 700;
|
||||
color: var(--ant-color-text);
|
||||
@@ -46,12 +121,12 @@ const AlertInfo: React.FC<AlertInfoProps> = (props) => {
|
||||
maxHeight = 86,
|
||||
overlayScrollerProps = {}
|
||||
} = props;
|
||||
|
||||
const { styles } = useStyles();
|
||||
return (
|
||||
<>
|
||||
{message ? (
|
||||
<div
|
||||
className={classNames('alert-info-block', type)}
|
||||
className={classNames(styles.alertBlockInfo, type)}
|
||||
style={{ ...style }}
|
||||
>
|
||||
<Typography.Paragraph
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { CloseOutlined } from '@ant-design/icons';
|
||||
import { createStyles } from 'antd-style/lib/functions';
|
||||
|
||||
const useStyles = createStyles(({ token, css }) => ({
|
||||
errorIcon: css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
background-color: ${token.colorErrorBgActive};
|
||||
color: ${token.colorError};
|
||||
`
|
||||
}));
|
||||
|
||||
const ErrorIcon: React.FC<{ size?: number }> = ({ size }) => {
|
||||
const { styles } = useStyles();
|
||||
return (
|
||||
<div className={styles.errorIcon}>
|
||||
<CloseOutlined style={{ fontSize: size || 30 }} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ErrorIcon;
|
||||
@@ -5,9 +5,10 @@ export default {
|
||||
'common.button.editmode': 'Edit mode',
|
||||
'common.button.shortcut': 'Keyboard Shortcut',
|
||||
'common.button.add': 'Add',
|
||||
'common.button.login': 'Use a local user',
|
||||
'common.button.oidclogin': 'Log In With OIDC',
|
||||
'common.button.samllogin': 'Log In With SAML',
|
||||
'common.button.login': 'Log in',
|
||||
'common.button.login.local': 'Log in with local user',
|
||||
'common.button.oidclogin': 'Log in with OIDC',
|
||||
'common.button.samllogin': 'Log in with SAML',
|
||||
'common.button.select': 'Select',
|
||||
'common.button.selected': 'Selected',
|
||||
'common.button.continue': 'Continue',
|
||||
@@ -253,5 +254,8 @@ export default {
|
||||
'common.tips.escape.disable':
|
||||
'Click Cancel or the X at the top right to close.',
|
||||
'common.button.clearSelection': 'Clear Selection',
|
||||
'common.select.count': '{count} selected'
|
||||
'common.select.count': '{count} selected',
|
||||
'common.login.auth': 'Authenticating...',
|
||||
'common.login.auth.failed': 'Authentication failed',
|
||||
'common.login.thirdparty': 'or login with'
|
||||
};
|
||||
|
||||
@@ -6,8 +6,9 @@ export default {
|
||||
'common.button.shortcut': 'キーボードショートカット',
|
||||
'common.button.add': '追加',
|
||||
'common.button.login': 'ログイン',
|
||||
'common.button.oidclogin': 'OIDCでログイン',
|
||||
'common.button.samllogin': 'SAMLでログイン',
|
||||
'common.button.login.local': 'ローカルユーザーでログイン',
|
||||
'common.button.oidclogin': 'OIDC でログイン',
|
||||
'common.button.samllogin': 'SAML でログイン',
|
||||
'common.button.select': '選択',
|
||||
'common.button.selected': '選択済み',
|
||||
'common.button.continue': '続行',
|
||||
@@ -253,7 +254,10 @@ export default {
|
||||
'common.tips.escape.disable':
|
||||
'Click Cancel or the X at the top right to close.',
|
||||
'common.button.clearSelection': 'Clear Selection',
|
||||
'common.select.count': '{count} selected'
|
||||
'common.select.count': '{count} selected',
|
||||
'common.login.auth': 'Authenticating...',
|
||||
'common.login.auth.failed': 'Authentication failed',
|
||||
'common.login.thirdparty': 'or login with'
|
||||
};
|
||||
|
||||
// ========== To-Do: Translate Keys (Remove After Translation) ==========
|
||||
@@ -271,5 +275,8 @@ export default {
|
||||
// 12. 'common.page.refresh.tips': 'Oops! Something went wrong. Try refreshing the page.'
|
||||
// 13. 'common.tips.escape.disable': 'Click Cancel or the X at the top right to close.'
|
||||
// 14. 'common.button.clearSelection': 'Clear Selection',
|
||||
// 15. 'common.select.count': '{count} selected'
|
||||
// 15. 'common.select.count': '{count} selected',
|
||||
// 16. 'common.login.auth': 'Authenticating...',
|
||||
// 17. 'common.login.auth.failed': 'Authentication failed'
|
||||
// 18. 'common.login.thirdparty': 'or login with'
|
||||
// ========== End of To-Do List ==========
|
||||
|
||||
@@ -6,6 +6,7 @@ export default {
|
||||
'common.button.shortcut': 'Сочетания клавиш',
|
||||
'common.button.add': 'Добавить',
|
||||
'common.button.login': 'Войти',
|
||||
'common.button.login.local': 'Войти с локальным пользователем',
|
||||
'common.button.oidclogin': 'Войти с OIDC',
|
||||
'common.button.samllogin': 'Войти с SAML',
|
||||
'common.button.select': 'Выбрать',
|
||||
@@ -252,9 +253,12 @@ export default {
|
||||
'common.tips.escape.disable':
|
||||
'Чтобы закрыть, нажмите "Отмена" или крестик (X) в правом верхнем углу.',
|
||||
'common.button.clearSelection': 'Сбросить выбор',
|
||||
'common.select.count': '{count} Выбрано'
|
||||
'common.select.count': '{count} Выбрано',
|
||||
'common.login.auth': 'Аутентификация...',
|
||||
'common.login.auth.failed': 'Ошибка аутентификации',
|
||||
'common.login.thirdparty': 'or login with'
|
||||
};
|
||||
|
||||
// ========== To-Do: Translate Keys (Remove After Translation) ==========
|
||||
|
||||
// 1. 'common.login.thirdparty': 'or login with'
|
||||
// ========== End of To-Do List ==========
|
||||
|
||||
@@ -5,9 +5,10 @@ export default {
|
||||
'common.button.editmode': '编辑模式',
|
||||
'common.button.shortcut': '快捷键',
|
||||
'common.button.add': '添加',
|
||||
'common.button.login': 'Use a local user',
|
||||
'common.button.oidclogin': 'Log In With OIDC',
|
||||
'common.button.samllogin': 'Log In With SAML',
|
||||
'common.button.login': '登录',
|
||||
'common.button.login.local': '本地用户登录',
|
||||
'common.button.oidclogin': '使用 OIDC 登录',
|
||||
'common.button.samllogin': '使用 SAML 登录',
|
||||
'common.button.select': '选择',
|
||||
'common.button.selected': '已选择',
|
||||
'common.button.continue': '继续',
|
||||
@@ -246,5 +247,8 @@ export default {
|
||||
'common.page.refresh.tips': '出了点问题,试试刷新页面吧!',
|
||||
'common.tips.escape.disable': '请点击「取消」按钮或右上角 X 关闭窗口',
|
||||
'common.button.clearSelection': '清除选择',
|
||||
'common.select.count': '已选 {count} 项'
|
||||
'common.select.count': '已选 {count} 项',
|
||||
'common.login.auth': '认证中...',
|
||||
'common.login.auth.failed': '认证失败',
|
||||
'common.login.thirdparty': '或使用以下方式登录'
|
||||
};
|
||||
|
||||
@@ -1,27 +1,47 @@
|
||||
import LogoIcon from '@/assets/images/gpustack-logo.png';
|
||||
import { initialPasswordAtom, userAtom } from '@/atoms/user';
|
||||
import OIDCIcon from '@/assets/images/oidc.svg';
|
||||
import SAMLIcon from '@/assets/images/saml.svg';
|
||||
import { userAtom } from '@/atoms/user';
|
||||
import LangSelect from '@/components/lang-select';
|
||||
import SealInput from '@/components/seal-form/seal-input';
|
||||
import ThemeDropActions from '@/components/theme-toggle/theme-drop-actions';
|
||||
import externalLinks from '@/constants/external-links';
|
||||
import {
|
||||
CRYPT_TEXT,
|
||||
REMEMBER_ME_KEY,
|
||||
getRememberMe,
|
||||
rememberMe,
|
||||
removeRememberMe
|
||||
} from '@/utils/localstore/index';
|
||||
import { LockOutlined, UserOutlined } from '@ant-design/icons';
|
||||
import { history, useIntl, useModel } from '@umijs/max';
|
||||
import { Button, Checkbox, Form } from 'antd';
|
||||
import { useIntl, useModel } from '@umijs/max';
|
||||
import { Button, Checkbox, Divider, Form, Spin, Tooltip, message } from 'antd';
|
||||
import { createStyles } from 'antd-style';
|
||||
import CryptoJS from 'crypto-js';
|
||||
import { useAtom } from 'jotai';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { login } from '../apis';
|
||||
import styled from 'styled-components';
|
||||
import { useLocalAuth } from '../hooks/use-local-auth';
|
||||
import { useSSOAuth } from '../hooks/use-sso-auth';
|
||||
import { checkDefaultPage } from '../utils';
|
||||
|
||||
const Buttons = styled.div`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 32px;
|
||||
`;
|
||||
|
||||
const ButtonWrapper = styled(Button).attrs({
|
||||
shape: 'circle',
|
||||
size: 'large',
|
||||
color: 'default',
|
||||
variant: 'filled'
|
||||
})`
|
||||
height: 42px;
|
||||
width: 42px;
|
||||
`;
|
||||
|
||||
const DividerWrapper = styled(Divider)`
|
||||
margin-block: 24px !important;
|
||||
.ant-divider-inner-text {
|
||||
color: var(--ant-color-text-secondary);
|
||||
}
|
||||
`;
|
||||
|
||||
const useStyles = createStyles(({ token, css }) => ({
|
||||
header: css`
|
||||
display: flex;
|
||||
@@ -38,40 +58,28 @@ const useStyles = createStyles(({ token, css }) => ({
|
||||
.anticon:hover {
|
||||
color: ${token.colorTextTertiary};
|
||||
}
|
||||
`,
|
||||
errorMessage: css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
color: ${token.colorText};
|
||||
.title {
|
||||
font-weight: bold;
|
||||
}
|
||||
`
|
||||
}));
|
||||
// function authentication configuration method
|
||||
async function fetchAuthConfig(url: string) {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
console.error('OIDC config error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const LoginForm = () => {
|
||||
type LoginOption = {
|
||||
saml?: boolean;
|
||||
oidc?: boolean;
|
||||
};
|
||||
|
||||
const [err, setErr] = useState<Error | null>(null);
|
||||
const [loginOption, setLoginOption] = useState<LoginOption>({
|
||||
saml: false,
|
||||
oidc: false
|
||||
});
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
const { styles } = useStyles();
|
||||
const [userInfo, setUserInfo] = useAtom(userAtom);
|
||||
const [initialPassword, setInitialPassword] = useAtom(initialPasswordAtom);
|
||||
const { initialState, setInitialState } = useModel('@@initialState') || {};
|
||||
const [authError, setAuthError] = useState<Error | null>(null);
|
||||
const intl = useIntl();
|
||||
const [form] = Form.useForm();
|
||||
const { location } = history;
|
||||
const params = new URLSearchParams(location.search);
|
||||
const sso = params.get('sso'); // OIDC callback information
|
||||
|
||||
const renderWelCome = useMemo(() => {
|
||||
return (
|
||||
<div
|
||||
@@ -94,6 +102,7 @@ const LoginForm = () => {
|
||||
</div>
|
||||
);
|
||||
}, [intl]);
|
||||
|
||||
const gotoDefaultPage = async (userInfo: any) => {
|
||||
checkDefaultPage(userInfo, true);
|
||||
};
|
||||
@@ -112,121 +121,97 @@ const LoginForm = () => {
|
||||
return userInfo;
|
||||
};
|
||||
|
||||
const encryptPassword = (password: string) => {
|
||||
const psw = CryptoJS.AES?.encrypt?.(password, CRYPT_TEXT).toString();
|
||||
return psw;
|
||||
};
|
||||
const decryptPassword = (password: string) => {
|
||||
const bytes = CryptoJS.AES?.decrypt?.(password, CRYPT_TEXT);
|
||||
const res = bytes.toString(CryptoJS.enc.Utf8);
|
||||
return res;
|
||||
// error handling for authentication
|
||||
const handleOnError = (error: Error) => {
|
||||
setAuthError(error);
|
||||
messageApi.error({
|
||||
duration: 5,
|
||||
content: (
|
||||
<div className={styles.errorMessage}>
|
||||
<div className="title">
|
||||
{intl.formatMessage({ id: 'common.login.auth.failed' })}
|
||||
</div>
|
||||
<div className="message">{error?.message || 'Unknown error'}</div>
|
||||
</div>
|
||||
)
|
||||
});
|
||||
};
|
||||
|
||||
const callRememberMe = async (values: any) => {
|
||||
const { autoLogin } = values;
|
||||
if (autoLogin) {
|
||||
await rememberMe(REMEMBER_ME_KEY, {
|
||||
um: encryptPassword(values.username),
|
||||
pw: encryptPassword(values.password),
|
||||
f: true
|
||||
});
|
||||
} else {
|
||||
await removeRememberMe(REMEMBER_ME_KEY);
|
||||
}
|
||||
};
|
||||
|
||||
const callGetRememberMe = async () => {
|
||||
const rememberMe = await getRememberMe(REMEMBER_ME_KEY);
|
||||
|
||||
if (rememberMe?.f) {
|
||||
const username = decryptPassword(rememberMe?.um);
|
||||
const password = decryptPassword(rememberMe?.pw);
|
||||
form.setFieldsValue({ username, password, autoLogin: true });
|
||||
}
|
||||
};
|
||||
|
||||
// OIDC certification
|
||||
const handleOidcLogin = async () => {
|
||||
window.location.href = '/auth/oidc/login';
|
||||
};
|
||||
// SAML certification
|
||||
const handleSamlLogin = async () => {
|
||||
window.location.href = '/auth/saml/login';
|
||||
};
|
||||
|
||||
// Handling SSO callbacks
|
||||
useEffect(() => {
|
||||
fetchAuthConfig('/auth_config')
|
||||
.then((authConfig) => {
|
||||
if (authConfig.is_oidc) {
|
||||
setLoginOption({
|
||||
oidc: true
|
||||
});
|
||||
} else if (authConfig.is_saml) {
|
||||
if (authConfig.is_saml) {
|
||||
setLoginOption({
|
||||
saml: true
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
setLoginOption({ oidc: false, saml: false });
|
||||
});
|
||||
if (sso) {
|
||||
fetchUserInfo()
|
||||
.then((userInfo) => {
|
||||
setUserInfo(userInfo);
|
||||
gotoDefaultPage({});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
setErr(error);
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
const handleLogin = async (values: any) => {
|
||||
try {
|
||||
await login({
|
||||
username: values.username,
|
||||
password: values.password
|
||||
});
|
||||
|
||||
const userInfo = await fetchUserInfo();
|
||||
// local user authentication
|
||||
const { handleLogin } = useLocalAuth({
|
||||
fetchUserInfo,
|
||||
form,
|
||||
onSuccess: async (userInfo) => {
|
||||
setUserInfo(userInfo);
|
||||
if (values.autoLogin) {
|
||||
await callRememberMe(values);
|
||||
} else {
|
||||
await removeRememberMe(REMEMBER_ME_KEY);
|
||||
}
|
||||
if (!userInfo?.require_password_change) {
|
||||
gotoDefaultPage(userInfo);
|
||||
} else {
|
||||
setInitialPassword(encryptPassword(values.password));
|
||||
}
|
||||
} catch (error) {
|
||||
// to do something
|
||||
},
|
||||
onError: (error) => {
|
||||
// gpustack handle in the interceptor
|
||||
}
|
||||
});
|
||||
|
||||
// SSO hook
|
||||
const SSOAuth = useSSOAuth({
|
||||
fetchUserInfo,
|
||||
onSuccess: (userInfo) => {
|
||||
setUserInfo(userInfo);
|
||||
gotoDefaultPage({});
|
||||
},
|
||||
onError: handleOnError
|
||||
});
|
||||
|
||||
const hasThirdPartyLogin = useMemo(() => {
|
||||
return SSOAuth.options.oidc || SSOAuth.options.saml;
|
||||
}, [SSOAuth.options]);
|
||||
|
||||
const renderThirdPartyLoginButtons = () => {
|
||||
if (!hasThirdPartyLogin) return null;
|
||||
return (
|
||||
<>
|
||||
<DividerWrapper plain>
|
||||
{intl.formatMessage({ id: 'common.login.thirdparty' })}
|
||||
</DividerWrapper>
|
||||
<Buttons>
|
||||
{SSOAuth.options.oidc && (
|
||||
<Tooltip title="OIDC">
|
||||
<ButtonWrapper onClick={SSOAuth.loginWithOIDC}>
|
||||
<img src={OIDCIcon} alt="" height={32} width={32} />
|
||||
</ButtonWrapper>
|
||||
</Tooltip>
|
||||
)}
|
||||
{SSOAuth.options.saml && (
|
||||
<Tooltip title="SAML">
|
||||
<ButtonWrapper onClick={SSOAuth.loginWithSAML}>
|
||||
<img src={SAMLIcon} alt="" height={32} width={32} />
|
||||
</ButtonWrapper>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Buttons>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
callGetRememberMe();
|
||||
}, []);
|
||||
const isThirdPartyAuthHandling = useMemo(() => {
|
||||
return SSOAuth.isSSOLogin && !authError;
|
||||
}, [SSOAuth.isSSOLogin, authError]);
|
||||
|
||||
if (sso && !err) {
|
||||
return <div>Handle SSO callback...</div>;
|
||||
} else if (err) {
|
||||
return (
|
||||
<div style={{ color: 'red' }}>Error to log in by SSO: {err.message}</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
return (
|
||||
<div>
|
||||
{contextHolder}
|
||||
<div className={styles.header}>
|
||||
<ThemeDropActions></ThemeDropActions>
|
||||
<LangSelect />
|
||||
</div>
|
||||
<div>
|
||||
<div className={styles.header}>
|
||||
<ThemeDropActions></ThemeDropActions>
|
||||
<LangSelect />
|
||||
</div>
|
||||
<div>
|
||||
{isThirdPartyAuthHandling ? (
|
||||
<Spin>
|
||||
<span style={{ color: 'var(--ant-color-text)' }}>
|
||||
{intl.formatMessage({ id: 'common.login.auth' })}
|
||||
</span>
|
||||
</Spin>
|
||||
) : (
|
||||
<Form
|
||||
form={form}
|
||||
style={{ width: '360px', margin: '0 auto' }}
|
||||
@@ -240,7 +225,9 @@ const LoginForm = () => {
|
||||
required: true,
|
||||
message: intl.formatMessage(
|
||||
{ id: 'common.form.rule.input' },
|
||||
{ name: intl.formatMessage({ id: 'common.form.username' }) }
|
||||
{
|
||||
name: intl.formatMessage({ id: 'common.form.username' })
|
||||
}
|
||||
)
|
||||
}
|
||||
]}
|
||||
@@ -258,7 +245,9 @@ const LoginForm = () => {
|
||||
required: true,
|
||||
message: intl.formatMessage(
|
||||
{ id: 'common.form.rule.input' },
|
||||
{ name: intl.formatMessage({ id: 'common.form.password' }) }
|
||||
{
|
||||
name: intl.formatMessage({ id: 'common.form.password' })
|
||||
}
|
||||
)
|
||||
}
|
||||
]}
|
||||
@@ -291,47 +280,20 @@ const LoginForm = () => {
|
||||
{intl.formatMessage({ id: 'common.button.forgotpassword' })}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleOidcLogin}
|
||||
type="primary"
|
||||
block
|
||||
style={{
|
||||
height: '48px',
|
||||
fontSize: '14px',
|
||||
display: loginOption.oidc ? 'block' : 'none'
|
||||
}}
|
||||
>
|
||||
{intl.formatMessage({ id: 'common.button.oidclogin' })}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSamlLogin}
|
||||
type="primary"
|
||||
block
|
||||
style={{
|
||||
height: '48px',
|
||||
fontSize: '14px',
|
||||
display: loginOption.saml ? 'block' : 'none'
|
||||
}}
|
||||
>
|
||||
{intl.formatMessage({ id: 'common.button.samllogin' })}
|
||||
</Button>
|
||||
<Button
|
||||
htmlType="submit"
|
||||
type={
|
||||
loginOption.oidc === false && loginOption.saml === false
|
||||
? 'primary'
|
||||
: 'link'
|
||||
}
|
||||
type="primary"
|
||||
block
|
||||
style={{ height: '48px', fontSize: '14px' }}
|
||||
>
|
||||
{intl.formatMessage({ id: 'common.button.login' })}
|
||||
</Button>
|
||||
{renderThirdPartyLoginButtons()}
|
||||
</Form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginForm;
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { initialPasswordAtom } from '@/atoms/user';
|
||||
import {
|
||||
CRYPT_TEXT,
|
||||
REMEMBER_ME_KEY,
|
||||
getRememberMe,
|
||||
rememberMe,
|
||||
removeRememberMe
|
||||
} from '@/utils/localstore/index';
|
||||
import { FormInstance } from 'antd';
|
||||
import CryptoJS from 'crypto-js';
|
||||
import { useAtom } from 'jotai';
|
||||
import { useEffect } from 'react';
|
||||
import { login } from '../apis';
|
||||
|
||||
interface UseLocalAuthOptions {
|
||||
fetchUserInfo: () => Promise<any>;
|
||||
onSuccess?: (userInfo: any) => void;
|
||||
onError?: (error: Error) => void;
|
||||
form: FormInstance;
|
||||
}
|
||||
|
||||
export const useLocalAuth = ({
|
||||
fetchUserInfo,
|
||||
onSuccess,
|
||||
onError,
|
||||
form
|
||||
}: UseLocalAuthOptions) => {
|
||||
const [initialPassword, setInitialPassword] = useAtom(initialPasswordAtom);
|
||||
|
||||
// Encrypt password before storing
|
||||
const encryptPassword = (password: string) => {
|
||||
const psw = CryptoJS.AES?.encrypt?.(password, CRYPT_TEXT).toString();
|
||||
return psw;
|
||||
};
|
||||
|
||||
const decryptPassword = (password: string) => {
|
||||
const bytes = CryptoJS.AES?.decrypt?.(password, CRYPT_TEXT);
|
||||
const res = bytes.toString(CryptoJS.enc.Utf8);
|
||||
return res;
|
||||
};
|
||||
|
||||
const callGetRememberMe = async () => {
|
||||
const rememberMe = await getRememberMe(REMEMBER_ME_KEY);
|
||||
|
||||
if (rememberMe?.f) {
|
||||
const username = decryptPassword(rememberMe?.um);
|
||||
const password = decryptPassword(rememberMe?.pw);
|
||||
form.setFieldsValue({ username, password, autoLogin: true });
|
||||
}
|
||||
};
|
||||
|
||||
// remember me
|
||||
const callRememberMe = async (values: any) => {
|
||||
const { autoLogin } = values;
|
||||
if (autoLogin) {
|
||||
await rememberMe(REMEMBER_ME_KEY, {
|
||||
um: encryptPassword(values.username),
|
||||
pw: encryptPassword(values.password),
|
||||
f: true
|
||||
});
|
||||
} else {
|
||||
await removeRememberMe(REMEMBER_ME_KEY);
|
||||
}
|
||||
};
|
||||
|
||||
// click login button
|
||||
const handleLogin = async (values: any) => {
|
||||
try {
|
||||
await login({
|
||||
username: values.username,
|
||||
password: values.password
|
||||
});
|
||||
|
||||
const userInfo = await fetchUserInfo();
|
||||
if (values.autoLogin) {
|
||||
await callRememberMe(values);
|
||||
} else {
|
||||
await removeRememberMe(REMEMBER_ME_KEY);
|
||||
}
|
||||
|
||||
if (userInfo?.require_password_change) {
|
||||
setInitialPassword(encryptPassword(values.password));
|
||||
}
|
||||
|
||||
onSuccess?.(userInfo);
|
||||
} catch (error) {
|
||||
onError?.(error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
callGetRememberMe();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
handleLogin
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
// hooks/useSSOAuth.ts
|
||||
import { history } from '@umijs/max';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
type LoginOption = {
|
||||
saml: boolean;
|
||||
oidc: boolean;
|
||||
};
|
||||
|
||||
// sso configuration
|
||||
async function fetchAuthConfig(url: string) {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
console.error('OIDC config error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function useSSOAuth({
|
||||
fetchUserInfo,
|
||||
onSuccess,
|
||||
onError
|
||||
}: {
|
||||
fetchUserInfo: () => Promise<any>;
|
||||
onSuccess?: (userInfo: any) => void;
|
||||
onError?: (err: Error) => void;
|
||||
}) {
|
||||
const [loginOption, setLoginOption] = useState<LoginOption>({
|
||||
saml: false,
|
||||
oidc: false
|
||||
});
|
||||
|
||||
const { location } = history;
|
||||
const params = new URLSearchParams(location.search);
|
||||
const sso = params.get('sso');
|
||||
|
||||
const oidcLogin = () => {
|
||||
window.location.href = '/auth/oidc/login';
|
||||
};
|
||||
|
||||
const samlLogin = () => {
|
||||
window.location.href = '/auth/saml/login';
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchAuthConfig('/auth_config')
|
||||
.then((authConfig) => {
|
||||
setLoginOption({
|
||||
oidc: !!authConfig.is_oidc,
|
||||
saml: !!authConfig.is_saml
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setLoginOption({ oidc: false, saml: false });
|
||||
});
|
||||
|
||||
if (sso) {
|
||||
fetchUserInfo()
|
||||
.then((userInfo) => {
|
||||
onSuccess?.(userInfo);
|
||||
})
|
||||
.catch((error) => {
|
||||
onError?.(error);
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isSSOLogin: !!sso,
|
||||
options: loginOption,
|
||||
loginWithOIDC: oidcLogin,
|
||||
loginWithSAML: samlLogin
|
||||
};
|
||||
}
|
||||
@@ -30,7 +30,7 @@ const Wrapper = styled.div<{ $isDarkTheme: boolean }>`
|
||||
`;
|
||||
|
||||
const Box = styled.div`
|
||||
padding-top: 10%;
|
||||
padding-top: 8%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
@@ -43,6 +43,8 @@ const FormWrapper = styled.div`
|
||||
border-radius: var(--border-radius-modal);
|
||||
width: max-content;
|
||||
height: max-content;
|
||||
max-width: 800px;
|
||||
max-height: 600px;
|
||||
padding: 40px;
|
||||
background-color: var(--color-modal-content-bg);
|
||||
box-shadow: var(--color-modal-box-shadow);
|
||||
|
||||
Reference in New Issue
Block a user