YANG QIA 24601aa66f
feat(auth): support custom authentication (#2007)
* feat(auth): init auth package & collection

* feat(auth): register

* feat(auth): use authenticator

* feat(auth): mapRoles

* feat(auth): refactor

* feat(auth): base auth class

* feat(auth): add plugin

* chore(auth): test

* chore(auth): add test cases

* feat(auth): authenticators pane

* chore(auth): custom hook useAuthTypes

* feat(auth): authenticator pane

* chore(auth): store options schema using context

* feat(auth): signInPage provider

* feat(auth): signUpPage provider

* chore(auth): solve build errors

* chore(auth): add dependency

* chore(auth): remove dependency cycles

* chore(auth): add plugin-auth to preset

* chore(auth): fix test

* feat(auth): authenticator enable status

* fix(test): fix test using new authentication

* feat(auth): migration, set up basic auth

* chore(auth): can set options ui by component

* fix(test): workflow manunal.test

* fix(test): typo

* feat(auth): support multi-language

* chore(auth): imporve code

* chore(auth): hide button if no configuration

* chore(auth): readme

* chore(auth): remove allowSignup prop

* chore(auth): move configure pane to edit form

* fix(auth): jwt options bug

* feat(auth): init sms-auth

* chore(auth): at least authenticator required

* chore(auth): add test

* feat(auth): support sms auth

* fix(auth): fix test

* chore(auth): move findOrCreateUser to AuthModel

* chore(auth): history compatible processing

* feat(auth): support SAML auth

* chore(auth): saml auth list

* chore(saml-auth): improve ui

* Merge branch 'main' into feat/authentication

* chore(auth): improve code

* fix(saml-auth): fix bug

* fix(saml-auth): fix saml options

* chore(saml-auth): compatible processing && ut

* fix(auth): signin page bug

* chore(auth): saml compatible processing

* feat(auth): oidc-auth

* fix(oidc-auth): bug

* fix(oidc-auth): bug

* fix(auth): fix test

* chore(auth): filter enabled authenticator

* chore(oidc): add field map

* chore(auth): update readme

* docs(auth): create sms-auth readme

* feat(auth): allow signup config

* test(auth): fix test

* feat(auth): allow saml and oidc use http

* chore(oidc-auth): extends timeout

* docs(auth): update readme

* feat(auth): support sort

* docs(saml): update readme

* feat(auth): support sort all authenticator

* Merge branch 'main' into feat/authentication

* Merge branch 'main' into feat/authentication

* feat: improve code

* docs(auth): add doc

* Merge branch 'main' into feat/authentication

* chore: update yarn.lock

* feat: improve code

* chore(acl): write role to acl if it exists in database and not found … (#2001)

* chore(acl): write role to acl if it exists in database and not found in acl

* fix: test

* fix: eager load with nested association (#2002)

* chore: upgrade vitest

* chore: edit

* refactor: auth class

* fix: set options

* chore(acl): write role to acl if it exists in database and not found … (#2001)

* chore(acl): write role to acl if it exists in database and not found in acl

* fix: test

* fix: eager load with nested association (#2002)

* chore: upgrade vitest

* chore: add migrations

* test: fix api-client test

* chore: add sms-auth

* feat: avoid no permission after auth type disabled

* fix: translation

---------

Co-authored-by: chenos <chenlinxh@gmail.com>
2023-06-07 23:46:42 +08:00

398 lines
10 KiB
TypeScript

import React, { useEffect, useMemo, useState, useCallback, MouseEventHandler } from 'react';
import { useAPIClient, useRequest } from '../api-client';
import {
Avatar,
Card,
message,
Modal,
Popconfirm,
Spin,
Switch,
Tabs,
TabsProps,
Tag,
Tooltip,
Typography,
} from 'antd';
import { css } from '@emotion/css';
import cls from 'classnames';
import { useHistory } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { DeleteOutlined, SettingOutlined } from '@ant-design/icons';
import { useParseMarkdown } from '../schema-component/antd/markdown/util';
import type { IPluginData } from '.';
interface PluginDocumentProps {
path: string;
name: string;
}
interface ICommonCard {
onClick: () => void;
name: string;
description: string;
title: string;
displayName: string;
actions?: JSX.Element[];
}
interface IPluginDetail {
plugin: any;
onCancel: () => void;
items: TabsProps['items'];
}
/**
* get color by string
* TODO: real avatar
* @param str
*/
const stringToColor = function (str: string) {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
let color = '#';
for (let i = 0; i < 3; i++) {
const value = (hash >> (i * 8)) & 0xff;
color += ('00' + value.toString(16)).substr(-2);
}
return color;
};
const PluginDocument: React.FC<PluginDocumentProps> = (props) => {
const [docLang, setDocLang] = useState('');
const { name, path } = props;
const { data, loading, error } = useRequest(
{
url: '/plugins:getTabInfo',
params: {
filterByTk: name,
path: path,
locale: docLang,
},
},
{
refreshDeps: [name, path, docLang],
},
);
const { html, loading: parseLoading } = useParseMarkdown(data?.data?.content);
const htmlWithOutRelativeDirect = useMemo(() => {
if (html) {
const pattern = /<a\s+href="\..*?\/([^/]+)"/g;
return html.replace(pattern, (match, $1) => match + `onclick="return false;"`); // prevent the default event of <a/>
}
}, [html]);
const handleSwitchDocLang = useCallback((e: MouseEvent) => {
const lang = (e.target as HTMLDivElement).innerHTML;
if (lang.trim() === '中文') {
setDocLang('zh-CN');
} else if (lang.trim() === 'English') {
setDocLang('en-US');
}
}, []);
useEffect(() => {
const md = document.getElementById('pm-md-preview');
md.addEventListener('click', handleSwitchDocLang);
return () => {
removeEventListener('click', handleSwitchDocLang);
};
}, [handleSwitchDocLang]);
return (
<div
className={css`
background: #ffffff;
padding: var(--nb-spacing); // if the antd can upgrade to v5.0, theme token will be better
height: 60vh;
overflow-y: auto;
`}
id="pm-md-preview"
>
{loading || parseLoading ? (
<Spin />
) : (
<div className="nb-markdown" dangerouslySetInnerHTML={{ __html: error ? '' : htmlWithOutRelativeDirect }}></div>
)}
</div>
);
};
function PluginDetail(props: IPluginDetail) {
const { plugin, onCancel, items } = props;
return (
<Modal
footer={false}
className={css`
.ant-modal-header {
background: #f0f2f5;
padding-bottom: 8px;
}
.ant-modal-body {
padding-top: 0;
}
.ant-modal-body {
background: #f0f2f5;
.plugin-desc {
padding-bottom: 8px;
}
}
`}
width="70%"
title={
<Typography.Title level={2} style={{ margin: 0 }}>
{plugin?.displayName || plugin?.name}
<Tag
className={css`
vertical-align: middle;
margin-top: -3px;
margin-left: 8px;
`}
>
v{plugin?.version}
</Tag>
</Typography.Title>
}
open={!!plugin}
onCancel={onCancel}
destroyOnClose
>
{plugin?.description && <div className={'plugin-desc'}>{plugin?.description}</div>}
<Tabs items={items}></Tabs>
</Modal>
);
}
function CommonCard(props: ICommonCard) {
const { onClick, name, displayName, actions, description, title } = props;
return (
<Card
bordered={false}
style={{ width: 'calc(20% - 24px)', marginRight: 24, marginBottom: 24, transition: 'all 0.35s ease-in-out' }}
onClick={onClick}
className={cls(css`
&:hover {
border: 1px solid var(--antd-wave-shadow-color);
cursor: pointer;
}
border: 1px solid transparent;
`)}
actions={actions}
// actions={[<a>Settings</a>, <a>Remove</a>, <Switch size={'small'} defaultChecked={true}></Switch>]}
>
<Card.Meta
className={css`
.ant-card-meta-avatar {
margin-top: 8px;
.ant-avatar {
border-radius: 2px;
}
}
`}
avatar={<Avatar style={{ background: `${stringToColor(name)}` }}>{name?.[0]}</Avatar>}
description={
<Tooltip title={description} placement="bottom">
<div
style={{
overflow: 'hidden',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
}}
>
{description || '-'}
</div>
</Tooltip>
}
title={
<span>
{displayName || name}
<span
className={css`
display: block;
color: rgba(0, 0, 0, 0.45);
font-weight: normal;
font-size: 13px;
// margin-left: 8px;
`}
>
{title}
</span>
</span>
}
/>
</Card>
);
}
export const PluginCard = (props: { data: IPluginData }) => {
const history = useHistory<any>();
const { data } = props;
const api = useAPIClient();
const { t } = useTranslation();
const { enabled, name, displayName, id, description, version } = data;
const [plugin, setPlugin] = useState<any>(null);
const { data: tabsData, run } = useRequest(
{
url: '/plugins:getTabs',
},
{
manual: true,
},
);
const items = useMemo<TabsProps['items']>(() => {
return tabsData?.data?.tabs.map((item) => {
return {
label: item.title,
key: item.path,
children: React.createElement(PluginDocument, {
name: tabsData?.data.filterByTk,
path: item.path,
}),
};
});
}, [tabsData?.data]);
const actions = useMemo(
() =>
[
enabled ? (
<SettingOutlined
onClick={(e) => {
e.stopPropagation();
history.push(`/admin/settings/${name}`);
}}
/>
) : null,
<Popconfirm
key={id}
title={t('Are you sure to delete this plugin?')}
onConfirm={async (e) => {
e.stopPropagation();
await api.request({
url: `pm:remove/${name}`,
});
message.success(t('插件删除成功'));
window.location.reload();
}}
onCancel={(e) => e.stopPropagation()}
okText={t('Yes')}
cancelText={t('No')}
>
<DeleteOutlined onClick={(e) => e.stopPropagation()} />
</Popconfirm>,
<Switch
key={id}
size={'small'}
onChange={async (checked, e) => {
e.stopPropagation();
Modal.warn({
title: checked ? t('Plugin starting') : t('Plugin stopping'),
content: t('The application is reloading, please do not close the page.'),
okButtonProps: {
style: {
display: 'none',
},
},
});
await api.request({
url: `pm:${checked ? 'enable' : 'disable'}/${name}`,
});
window.location.reload();
// message.success(checked ? t('插件激活成功') : t('插件禁用成功'));
}}
defaultChecked={enabled}
></Switch>,
].filter(Boolean),
[api, enabled, history, id, name, t],
);
return (
<>
<PluginDetail plugin={plugin} onCancel={() => setPlugin(null)} items={items} />
<CommonCard
onClick={() => {
setPlugin(data);
run({
params: {
filterByTk: name,
},
});
}}
name={name}
description={description}
title={version}
actions={actions}
displayName={displayName}
/>
</>
);
};
export const BuiltInPluginCard = (props: { data: IPluginData }) => {
const {
data: { description, name, version, displayName },
data,
} = props;
const history = useHistory();
const [plugin, setPlugin] = useState<any>(null);
const { data: tabsData, run } = useRequest(
{
url: '/plugins:getTabs',
},
{
manual: true,
},
);
const items = useMemo(() => {
return tabsData?.data?.tabs.map((item) => {
return {
label: item.title,
key: item.path,
children: React.createElement(PluginDocument, {
name: tabsData?.data.filterByTk,
path: item.path,
}),
};
});
}, [tabsData?.data]);
return (
<>
<PluginDetail plugin={plugin} onCancel={() => setPlugin(null)} items={items} />
<CommonCard
onClick={() => {
setPlugin(data);
run({
params: {
filterByTk: name,
},
});
}}
name={name}
displayName={displayName}
description={description}
title={version}
actions={[
<div key="placeholder-comp"></div>,
<SettingOutlined
key="settings"
onClick={(e) => {
e.stopPropagation();
history.push(`/admin/settings/${name}`);
}}
/>,
]}
/>
</>
);
};
function useCallabck(arg0: () => void, arg1: undefined[]) {
throw new Error('Function not implemented.');
}