mirror of
https://github.com/nocobase/nocobase.git
synced 2026-08-28 17:43:07 +08:00
fix(plugin-auth): restore authenticator edit values (#10091)
This commit is contained in:
+129
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* This file is part of the NocoBase (R) project.
|
||||
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
||||
* Authors: NocoBase Team.
|
||||
*
|
||||
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
||||
* For more information, please refer to: https://www.nocobase.com/agreement.
|
||||
*/
|
||||
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { Form, Input } from 'antd';
|
||||
import React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const holder = vi.hoisted(() => ({
|
||||
resource: {
|
||||
create: vi.fn(),
|
||||
get: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
ctx: {
|
||||
api: {
|
||||
resource: () => holder.resource,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@nocobase/flow-engine', () => {
|
||||
return {
|
||||
randomId: () => 's_test',
|
||||
useFlowContext: () => holder.ctx,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@nocobase/client-v2', () => {
|
||||
return {
|
||||
DEFAULT_PAGE_SIZE: 20,
|
||||
DrawerFormLayout: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
|
||||
Table: () => null,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../locale', () => ({
|
||||
useT: () => (key: string) => key,
|
||||
useAuthTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
import type PluginAuthClientV2 from '../plugin';
|
||||
import { AuthenticatorFormView } from '../pages/AuthenticatorsPage';
|
||||
|
||||
function AdminSettingsBody() {
|
||||
return (
|
||||
<Form.Item name={['options', 'clientId']} label="Client ID">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
|
||||
describe('AuthenticatorFormView', () => {
|
||||
it('loads the complete record and shows nested option values after the type-specific form loads', async () => {
|
||||
holder.resource.get.mockResolvedValue({
|
||||
data: {
|
||||
data: {
|
||||
id: 1,
|
||||
name: 'oidc',
|
||||
authType: 'oidc',
|
||||
options: { clientId: 'saved-client-id' },
|
||||
},
|
||||
},
|
||||
});
|
||||
const plugin = {
|
||||
authTypes: {
|
||||
get: () => ({
|
||||
adminSettingsFormLoader: () => Promise.resolve({ default: AdminSettingsBody }),
|
||||
}),
|
||||
},
|
||||
} as unknown as PluginAuthClientV2;
|
||||
|
||||
render(
|
||||
<AuthenticatorFormView
|
||||
mode="edit"
|
||||
authType="oidc"
|
||||
authTypeOptions={[{ name: 'oidc', title: 'OIDC' }]}
|
||||
plugin={plugin}
|
||||
record={{
|
||||
id: 1,
|
||||
name: 'oidc',
|
||||
authType: 'oidc',
|
||||
}}
|
||||
onSubmitted={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(holder.resource.get).toHaveBeenCalledWith({ filterByTk: 1 });
|
||||
expect(screen.getByLabelText('Client ID')).toHaveValue('saved-client-id');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the request error and keeps the list values as a fallback', async () => {
|
||||
holder.resource.get.mockRejectedValue(new Error('Failed to load authenticator'));
|
||||
const plugin = {
|
||||
authTypes: {
|
||||
get: () => ({
|
||||
adminSettingsFormLoader: () => Promise.resolve({ default: AdminSettingsBody }),
|
||||
}),
|
||||
},
|
||||
} as unknown as PluginAuthClientV2;
|
||||
|
||||
render(
|
||||
<AuthenticatorFormView
|
||||
mode="edit"
|
||||
authType="oidc"
|
||||
authTypeOptions={[{ name: 'oidc', title: 'OIDC' }]}
|
||||
plugin={plugin}
|
||||
record={{
|
||||
id: 1,
|
||||
name: 'oidc',
|
||||
authType: 'oidc',
|
||||
options: { clientId: 'fallback-client-id' },
|
||||
}}
|
||||
onSubmitted={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Failed to load authenticator');
|
||||
expect(screen.getByLabelText('Client ID')).toHaveValue('fallback-client-id');
|
||||
});
|
||||
});
|
||||
@@ -11,12 +11,13 @@ import { CheckOutlined, DeleteOutlined, DownOutlined, PlusOutlined } from '@ant-
|
||||
import { DEFAULT_PAGE_SIZE, DrawerFormLayout, Table } from '@nocobase/client-v2';
|
||||
import { randomId, useFlowContext } from '@nocobase/flow-engine';
|
||||
import { useMemoizedFn, useRequest } from 'ahooks';
|
||||
import { App, Button, Card, Checkbox, Dropdown, Form, Input, Select, Space, Spin, Tag, theme } from 'antd';
|
||||
import { Alert, App, Button, Card, Checkbox, Dropdown, Form, Input, Select, Space, Spin, Tag, theme } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import React, { lazy, Suspense, useEffect, useMemo, useState } from 'react';
|
||||
import { useAuthTranslation, useT } from '../locale';
|
||||
import PluginAuthClientV2, { type AuthOptions } from '../plugin';
|
||||
import type PluginAuthClientV2 from '../plugin';
|
||||
import type { AuthOptions } from '../plugin';
|
||||
|
||||
type AuthenticatorRecord = {
|
||||
id: number | string;
|
||||
@@ -31,6 +32,12 @@ type AuthenticatorRecord = {
|
||||
|
||||
type AuthTypeOption = { name: string; title?: string };
|
||||
|
||||
const AUTHENTICATOR_LIST_FIELDS = ['id', 'name', 'authType', 'title', 'description', 'enabled'];
|
||||
|
||||
type AuthenticatorDetailResource = {
|
||||
get: (options: { filterByTk: AuthenticatorRecord['id'] }) => Promise<{ data?: unknown }>;
|
||||
};
|
||||
|
||||
function recursiveTrim(value: any): any {
|
||||
if (typeof value === 'string') return value.trim();
|
||||
if (Array.isArray(value)) return value.map(recursiveTrim);
|
||||
@@ -59,7 +66,25 @@ function useAuthTypesFromServer() {
|
||||
);
|
||||
}
|
||||
|
||||
function AuthenticatorFormView(props: {
|
||||
export async function loadAuthenticatorForEdit(
|
||||
resource: AuthenticatorDetailResource,
|
||||
record: AuthenticatorRecord,
|
||||
): Promise<AuthenticatorRecord> {
|
||||
const response = await resource.get({ filterByTk: record.id });
|
||||
const body = response?.data;
|
||||
const detail = body && typeof body === 'object' && 'data' in body ? body.data : body;
|
||||
if (!detail || typeof detail !== 'object' || Array.isArray(detail)) {
|
||||
return cloneDeep(record);
|
||||
}
|
||||
const normalizedDetail = cloneDeep(detail as AuthenticatorRecord);
|
||||
return {
|
||||
...cloneDeep(record),
|
||||
...normalizedDetail,
|
||||
options: normalizedDetail.options ?? cloneDeep(record.options),
|
||||
};
|
||||
}
|
||||
|
||||
export function AuthenticatorFormView(props: {
|
||||
mode: 'create' | 'edit';
|
||||
authType: string;
|
||||
authTypeOptions: AuthTypeOption[];
|
||||
@@ -80,16 +105,33 @@ function AuthenticatorFormView(props: {
|
||||
const resource = useAuthenticatorsResource();
|
||||
const [form] = Form.useForm();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [loadError, setLoadError] = useState<Error | null>(null);
|
||||
const shouldLoadDetail = props.mode === 'edit' && props.record?.id != null;
|
||||
|
||||
const { data: detailRecord, loading: loadingDetail } = useRequest(
|
||||
async () => {
|
||||
if (!props.record) return undefined;
|
||||
return loadAuthenticatorForEdit(resource, props.record);
|
||||
},
|
||||
{
|
||||
ready: shouldLoadDetail,
|
||||
refreshDeps: [props.record?.id],
|
||||
onBefore: () => setLoadError(null),
|
||||
onError: (error) => setLoadError(error instanceof Error ? error : new Error(String(error))),
|
||||
},
|
||||
);
|
||||
|
||||
const editRecord = detailRecord ?? props.record;
|
||||
|
||||
const initialValues = useMemo(() => {
|
||||
if (props.mode === 'edit') return cloneDeep(props.record || {});
|
||||
if (props.mode === 'edit') return cloneDeep(editRecord || {});
|
||||
return {
|
||||
name: randomId('s_'),
|
||||
authType: props.authType,
|
||||
enabled: false,
|
||||
options: {},
|
||||
};
|
||||
}, [props.authType, props.mode, props.record]);
|
||||
}, [editRecord, props.authType, props.mode]);
|
||||
|
||||
useEffect(() => {
|
||||
form.setFieldsValue(initialValues);
|
||||
@@ -152,42 +194,53 @@ function AuthenticatorFormView(props: {
|
||||
<DrawerFormLayout
|
||||
title={props.mode === 'create' ? t('Add new') : t('Configure')}
|
||||
onSubmit={handleSubmit}
|
||||
submitting={submitting}
|
||||
submitting={submitting || loadingDetail}
|
||||
submitText={t('Submit')}
|
||||
cancelText={t('Cancel')}
|
||||
>
|
||||
<Form form={form} layout="vertical" initialValues={initialValues}>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label={t('Auth UID')}
|
||||
rules={[
|
||||
{ required: true, message: t('Please enter an Auth UID') },
|
||||
{
|
||||
pattern: /^[a-zA-Z0-9_-]+$/,
|
||||
message: t('a-z, A-Z, 0-9, _, -'),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input disabled={props.mode === 'edit'} />
|
||||
</Form.Item>
|
||||
<Form.Item name="authType" label={t('Auth Type')} rules={[{ required: true }]}>
|
||||
<Select options={compiledTypeOptions} onChange={handleAuthTypeChange} />
|
||||
</Form.Item>
|
||||
<Form.Item name="title" label={t('Title')}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label={t('Description')}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="enabled" label={t('Enabled')} valuePropName="checked">
|
||||
<Checkbox />
|
||||
</Form.Item>
|
||||
{AdminSettingsBody ? (
|
||||
<Suspense fallback={<Spin />}>
|
||||
<AdminSettingsBody />
|
||||
</Suspense>
|
||||
) : null}
|
||||
</Form>
|
||||
{loadError ? (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message={t('Failed to load authenticator')}
|
||||
description={loadError.message}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
) : null}
|
||||
<Spin spinning={loadingDetail}>
|
||||
<Form form={form} layout="vertical" initialValues={initialValues}>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label={t('Auth UID')}
|
||||
rules={[
|
||||
{ required: true, message: t('Please enter an Auth UID') },
|
||||
{
|
||||
pattern: /^[a-zA-Z0-9_-]+$/,
|
||||
message: t('a-z, A-Z, 0-9, _, -'),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input disabled={props.mode === 'edit'} />
|
||||
</Form.Item>
|
||||
<Form.Item name="authType" label={t('Auth Type')} rules={[{ required: true }]}>
|
||||
<Select options={compiledTypeOptions} onChange={handleAuthTypeChange} />
|
||||
</Form.Item>
|
||||
<Form.Item name="title" label={t('Title')}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label={t('Description')}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="enabled" label={t('Enabled')} valuePropName="checked">
|
||||
<Checkbox />
|
||||
</Form.Item>
|
||||
{AdminSettingsBody ? (
|
||||
<Suspense fallback={<Spin />}>
|
||||
<AdminSettingsBody />
|
||||
</Suspense>
|
||||
) : null}
|
||||
</Form>
|
||||
</Spin>
|
||||
</DrawerFormLayout>
|
||||
);
|
||||
}
|
||||
@@ -225,6 +278,7 @@ export default function AuthenticatorsPage() {
|
||||
page,
|
||||
pageSize,
|
||||
sort: ['sort'],
|
||||
fields: AUTHENTICATOR_LIST_FIELDS,
|
||||
appends: [],
|
||||
});
|
||||
return normalizeListResponse(response);
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"Days": "Days",
|
||||
"Email channel not found": "Email channel not found",
|
||||
"Enable forget password": "Enable forget password",
|
||||
"Failed to load authenticator": "Failed to load authenticator",
|
||||
"Expired token refresh limit": "Expired token refresh limit",
|
||||
"Forgot password": "Forgot password",
|
||||
"Go to login": "Go to login",
|
||||
@@ -81,4 +82,4 @@
|
||||
"defaultResetPasswordEmailContentHTML": "<p>Hello {{$user.username}},</p>\n\n<p>We received a request to reset the password for your {{$systemSettings.title}} account.</p>\n\n<p>Please click the link below to set your new password:</p>\n\n<p>\n <a href=\"{{$resetLink}}\">Reset Your Password</a>\n</p>\n\n<p>\n If you did not request a password reset, please ignore this email. Your password will remain unchanged.\n</p>\n\n<p>\n Please note: For your security, this password reset link will expire in <strong>{{$resetLinkExpiration}} minutes</strong>.\n</p>\n\n<p>If you encounter any issues resetting your password, please contact our support team.</p>\n\n<p>\n Thanks,<br>\n The {{$systemSettings.title}} Team\n</p>",
|
||||
"defaultResetPasswordEmailContentText": "Hello {{$user.username}},\n\nWe received a request to reset the password for your {{$systemSettings.title}} account.\n\nPlease click the link below to set your new password:\n\n{{$resetLink}}\n\nIf you did not request a password reset, please ignore this email. Your password will remain unchanged.\n\nPlease note: For your security, this password reset link will expire in {{$resetLinkExpiration}} minutes.\n\nIf you encounter any issues resetting your password, please contact our support team.\n\nThanks, The {{$systemSettings.title}} Team",
|
||||
"defaultResetPasswordEmailSubject": "Reset your password for {{$systemSettings.title}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"Days": "天",
|
||||
"Email channel not found": "未找到邮件通道",
|
||||
"Enable forget password": "启用忘记密码功能",
|
||||
"Failed to load authenticator": "加载认证器失败",
|
||||
"Expired token refresh limit": "过期 Token 刷新时限",
|
||||
"Forgot password": "忘记密码",
|
||||
"Go to login": "前往登录",
|
||||
@@ -81,4 +82,4 @@
|
||||
"defaultResetPasswordEmailContentHTML": "<p>您好 {{$user.username}},</p>\n\n<p>我们收到了重置您 {{$systemSettings.title}} 账户密码的请求。</p>\n\n<p>请点击下面的链接设置您的新密码:</p>\n\n<p>\n <a href=\"{{$resetLink}}\">重置您的密码</a>\n</p>\n\n<p>\n 如果您没有请求重置密码,请忽略此邮件。您的密码将保持不变。\n</p>\n\n<p>\n 请注意:为了您的安全,此密码重置链接将在 <strong>{{$resetLinkExpiration}} 分钟</strong>后过期。\n</p>\n\n<p>如果您在重置密码时遇到任何问题,请联系我们的支持团队。</p>\n\n<p>\n 谢谢,<br>\n {{$systemSettings.title}} 团队\n</p>",
|
||||
"defaultResetPasswordEmailContentText": "您好 {{$user.username}},\n\n我们收到了重置您 {{$systemSettings.title}} 账户密码的请求。\n\n请点击下面的链接设置您的新密码:\n\n{{$resetLink}}\n\n如果您没有请求重置密码,请忽略此邮件。您的密码将保持不变。\n\n请注意:为了您的安全,此密码重置链接将在 {{$resetLinkExpiration}} 分钟后过期。\n\n如果您在重置密码时遇到任何问题,请联系我们的支持团队。\n\n谢谢,{{$systemSettings.title}} 团队",
|
||||
"defaultResetPasswordEmailSubject": "重置您的 {{$systemSettings.title}} 密码"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user