fix(plugin-auth): restore authenticator edit values (#10091)

This commit is contained in:
Jiann
2026-07-14 15:22:11 +08:00
committed by GitHub
parent 322c9482f6
commit 3ce6232dab
4 changed files with 225 additions and 40 deletions
@@ -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}} 密码"
}
}