UserBindingManagementModal.jsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396
  1. /*
  2. Copyright (C) 2025 QuantumNous
  3. This program is free software: you can redistribute it and/or modify
  4. it under the terms of the GNU Affero General Public License as
  5. published by the Free Software Foundation, either version 3 of the
  6. License, or (at your option) any later version.
  7. This program is distributed in the hope that it will be useful,
  8. but WITHOUT ANY WARRANTY; without even the implied warranty of
  9. MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  10. GNU Affero General Public License for more details.
  11. You should have received a copy of the GNU Affero General Public License
  12. along with this program. If not, see <https://www.gnu.org/licenses/>.
  13. For commercial licensing, please contact support@quantumnous.com
  14. */
  15. import React from 'react';
  16. import { useTranslation } from 'react-i18next';
  17. import {
  18. API,
  19. showError,
  20. showSuccess,
  21. getOAuthProviderIcon,
  22. } from '../../../../helpers';
  23. import {
  24. Modal,
  25. Spin,
  26. Typography,
  27. Card,
  28. Checkbox,
  29. Tag,
  30. Button,
  31. } from '@douyinfe/semi-ui';
  32. import {
  33. IconLink,
  34. IconMail,
  35. IconDelete,
  36. IconGithubLogo,
  37. } from '@douyinfe/semi-icons';
  38. import { SiDiscord, SiTelegram, SiWechat, SiLinux } from 'react-icons/si';
  39. const { Text } = Typography;
  40. const UserBindingManagementModal = ({
  41. visible,
  42. onCancel,
  43. userId,
  44. isMobile,
  45. formApiRef,
  46. }) => {
  47. const { t } = useTranslation();
  48. const [bindingLoading, setBindingLoading] = React.useState(false);
  49. const [showUnboundOnly, setShowUnboundOnly] = React.useState(false);
  50. const [statusInfo, setStatusInfo] = React.useState({});
  51. const [customOAuthBindings, setCustomOAuthBindings] = React.useState([]);
  52. const [bindingActionLoading, setBindingActionLoading] = React.useState({});
  53. const loadBindingData = React.useCallback(async () => {
  54. if (!userId) return;
  55. setBindingLoading(true);
  56. try {
  57. const [statusRes, customBindingRes] = await Promise.all([
  58. API.get('/api/status'),
  59. API.get(`/api/user/${userId}/oauth/bindings`),
  60. ]);
  61. if (statusRes.data?.success) {
  62. setStatusInfo(statusRes.data.data || {});
  63. } else {
  64. showError(statusRes.data?.message || t('操作失败'));
  65. }
  66. if (customBindingRes.data?.success) {
  67. setCustomOAuthBindings(customBindingRes.data.data || []);
  68. } else {
  69. showError(customBindingRes.data?.message || t('操作失败'));
  70. }
  71. } catch (error) {
  72. showError(
  73. error.response?.data?.message || error.message || t('操作失败'),
  74. );
  75. } finally {
  76. setBindingLoading(false);
  77. }
  78. }, [t, userId]);
  79. React.useEffect(() => {
  80. if (!visible) return;
  81. setShowUnboundOnly(false);
  82. setBindingActionLoading({});
  83. loadBindingData();
  84. }, [visible, loadBindingData]);
  85. const setBindingLoadingState = (key, value) => {
  86. setBindingActionLoading((prev) => ({ ...prev, [key]: value }));
  87. };
  88. const handleUnbindBuiltInAccount = (bindingItem) => {
  89. if (!userId) return;
  90. Modal.confirm({
  91. title: t('确认解绑'),
  92. content: t('确定要解绑 {{name}} 吗?', { name: bindingItem.name }),
  93. okText: t('确认'),
  94. cancelText: t('取消'),
  95. onOk: async () => {
  96. const loadingKey = `builtin-${bindingItem.key}`;
  97. setBindingLoadingState(loadingKey, true);
  98. try {
  99. const res = await API.delete(
  100. `/api/user/${userId}/bindings/${bindingItem.key}`,
  101. );
  102. if (!res.data?.success) {
  103. showError(res.data?.message || t('操作失败'));
  104. return;
  105. }
  106. formApiRef.current?.setValue(bindingItem.field, '');
  107. showSuccess(t('解绑成功'));
  108. } catch (error) {
  109. showError(
  110. error.response?.data?.message || error.message || t('操作失败'),
  111. );
  112. } finally {
  113. setBindingLoadingState(loadingKey, false);
  114. }
  115. },
  116. });
  117. };
  118. const handleUnbindCustomOAuthAccount = (provider) => {
  119. if (!userId) return;
  120. Modal.confirm({
  121. title: t('确认解绑'),
  122. content: t('确定要解绑 {{name}} 吗?', { name: provider.name }),
  123. okText: t('确认'),
  124. cancelText: t('取消'),
  125. onOk: async () => {
  126. const loadingKey = `custom-${provider.id}`;
  127. setBindingLoadingState(loadingKey, true);
  128. try {
  129. const res = await API.delete(
  130. `/api/user/${userId}/oauth/bindings/${provider.id}`,
  131. );
  132. if (!res.data?.success) {
  133. showError(res.data?.message || t('操作失败'));
  134. return;
  135. }
  136. setCustomOAuthBindings((prev) =>
  137. prev.filter(
  138. (item) => Number(item.provider_id) !== Number(provider.id),
  139. ),
  140. );
  141. showSuccess(t('解绑成功'));
  142. } catch (error) {
  143. showError(
  144. error.response?.data?.message || error.message || t('操作失败'),
  145. );
  146. } finally {
  147. setBindingLoadingState(loadingKey, false);
  148. }
  149. },
  150. });
  151. };
  152. const currentValues = formApiRef.current?.getValues?.() || {};
  153. const builtInBindingItems = [
  154. {
  155. key: 'email',
  156. field: 'email',
  157. name: t('邮箱'),
  158. enabled: true,
  159. value: currentValues.email,
  160. icon: (
  161. <IconMail
  162. size='default'
  163. className='text-slate-600 dark:text-slate-300'
  164. />
  165. ),
  166. },
  167. {
  168. key: 'github',
  169. field: 'github_id',
  170. name: 'GitHub',
  171. enabled: Boolean(statusInfo.github_oauth),
  172. value: currentValues.github_id,
  173. icon: (
  174. <IconGithubLogo
  175. size='default'
  176. className='text-slate-600 dark:text-slate-300'
  177. />
  178. ),
  179. },
  180. {
  181. key: 'discord',
  182. field: 'discord_id',
  183. name: 'Discord',
  184. enabled: Boolean(statusInfo.discord_oauth),
  185. value: currentValues.discord_id,
  186. icon: (
  187. <SiDiscord size={20} className='text-slate-600 dark:text-slate-300' />
  188. ),
  189. },
  190. {
  191. key: 'oidc',
  192. field: 'oidc_id',
  193. name: 'OIDC',
  194. enabled: Boolean(statusInfo.oidc_enabled),
  195. value: currentValues.oidc_id,
  196. icon: (
  197. <IconLink
  198. size='default'
  199. className='text-slate-600 dark:text-slate-300'
  200. />
  201. ),
  202. },
  203. {
  204. key: 'wechat',
  205. field: 'wechat_id',
  206. name: t('微信'),
  207. enabled: Boolean(statusInfo.wechat_login),
  208. value: currentValues.wechat_id,
  209. icon: (
  210. <SiWechat size={20} className='text-slate-600 dark:text-slate-300' />
  211. ),
  212. },
  213. {
  214. key: 'telegram',
  215. field: 'telegram_id',
  216. name: 'Telegram',
  217. enabled: Boolean(statusInfo.telegram_oauth),
  218. value: currentValues.telegram_id,
  219. icon: (
  220. <SiTelegram size={20} className='text-slate-600 dark:text-slate-300' />
  221. ),
  222. },
  223. {
  224. key: 'linuxdo',
  225. field: 'linux_do_id',
  226. name: 'LinuxDO',
  227. enabled: Boolean(statusInfo.linuxdo_oauth),
  228. value: currentValues.linux_do_id,
  229. icon: (
  230. <SiLinux size={20} className='text-slate-600 dark:text-slate-300' />
  231. ),
  232. },
  233. ];
  234. const customBindingMap = new Map(
  235. customOAuthBindings.map((item) => [Number(item.provider_id), item]),
  236. );
  237. const customProviderMap = new Map(
  238. (statusInfo.custom_oauth_providers || []).map((provider) => [
  239. Number(provider.id),
  240. provider,
  241. ]),
  242. );
  243. customOAuthBindings.forEach((binding) => {
  244. if (!customProviderMap.has(Number(binding.provider_id))) {
  245. customProviderMap.set(Number(binding.provider_id), {
  246. id: binding.provider_id,
  247. name: binding.provider_name,
  248. icon: binding.provider_icon,
  249. });
  250. }
  251. });
  252. const customBindingItems = Array.from(customProviderMap.values()).map(
  253. (provider) => {
  254. const binding = customBindingMap.get(Number(provider.id));
  255. return {
  256. key: `custom-${provider.id}`,
  257. providerId: provider.id,
  258. name: provider.name,
  259. enabled: true,
  260. value: binding?.provider_user_id || '',
  261. icon: getOAuthProviderIcon(
  262. provider.icon || binding?.provider_icon || '',
  263. 20,
  264. ),
  265. };
  266. },
  267. );
  268. const allBindingItems = [
  269. ...builtInBindingItems.map((item) => ({ ...item, type: 'builtin' })),
  270. ...customBindingItems.map((item) => ({ ...item, type: 'custom' })),
  271. ];
  272. const visibleBindingItems = showUnboundOnly
  273. ? allBindingItems.filter((item) => !item.value)
  274. : allBindingItems;
  275. return (
  276. <Modal
  277. centered
  278. visible={visible}
  279. onCancel={onCancel}
  280. footer={null}
  281. width={isMobile ? '100%' : 760}
  282. title={
  283. <div className='flex items-center'>
  284. <IconLink className='mr-2' />
  285. {t('绑定信息')}
  286. </div>
  287. }
  288. >
  289. <Spin spinning={bindingLoading}>
  290. <div className='flex items-center justify-between mb-4 gap-3 flex-wrap'>
  291. <Checkbox
  292. checked={showUnboundOnly}
  293. onChange={(e) => setShowUnboundOnly(Boolean(e.target.checked))}
  294. >
  295. {`${t('筛选')} ${t('未绑定')}`}
  296. </Checkbox>
  297. <Text type='tertiary'>
  298. {t('筛选')} · {visibleBindingItems.length}
  299. </Text>
  300. </div>
  301. {visibleBindingItems.length === 0 ? (
  302. <Card className='!rounded-xl border-dashed'>
  303. <Text type='tertiary'>{t('暂无自定义 OAuth 提供商')}</Text>
  304. </Card>
  305. ) : (
  306. <div className='grid grid-cols-1 lg:grid-cols-2 gap-3'>
  307. {visibleBindingItems.map((item) => {
  308. const isBound = Boolean(item.value);
  309. const loadingKey =
  310. item.type === 'builtin'
  311. ? `builtin-${item.key}`
  312. : `custom-${item.providerId}`;
  313. const statusText = isBound
  314. ? item.value
  315. : item.enabled
  316. ? t('未绑定')
  317. : t('未启用');
  318. return (
  319. <Card key={item.key} className='!rounded-xl'>
  320. <div className='flex items-center justify-between gap-3'>
  321. <div className='flex items-center flex-1 min-w-0'>
  322. <div className='w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3 flex-shrink-0'>
  323. {item.icon}
  324. </div>
  325. <div className='min-w-0 flex-1'>
  326. <div className='font-medium text-gray-900 flex items-center gap-2'>
  327. <span>{item.name}</span>
  328. <Tag size='small' color='white'>
  329. {item.type === 'builtin' ? 'Built-in' : 'Custom'}
  330. </Tag>
  331. </div>
  332. <div className='text-sm text-gray-500 truncate'>
  333. {statusText}
  334. </div>
  335. </div>
  336. </div>
  337. <Button
  338. type='danger'
  339. theme='borderless'
  340. icon={<IconDelete />}
  341. size='small'
  342. disabled={!isBound}
  343. loading={Boolean(bindingActionLoading[loadingKey])}
  344. onClick={() => {
  345. if (item.type === 'builtin') {
  346. handleUnbindBuiltInAccount(item);
  347. return;
  348. }
  349. handleUnbindCustomOAuthAccount({
  350. id: item.providerId,
  351. name: item.name,
  352. });
  353. }}
  354. >
  355. {t('解绑')}
  356. </Button>
  357. </div>
  358. </Card>
  359. );
  360. })}
  361. </div>
  362. )}
  363. </Spin>
  364. </Modal>
  365. );
  366. };
  367. export default UserBindingManagementModal;