feat: dingtalk (#1340)

Co-authored-by: sealday <sealday@gmail.com>
Reviewed-on: daoyoucloud/tachybase#1340
This commit is contained in:
sealday 2024-07-18 12:34:49 +08:00
parent 456df870a3
commit aedcf15863
40 changed files with 719 additions and 325 deletions

View File

@ -58,7 +58,7 @@
"react-js-cron": "^3.1.0",
"react-quill": "^2.0.0",
"react-router": "^6.11.2",
"react-router-dom": "^6.11.2",
"react-router-dom": "^6.25.1",
"react-to-print": "^2.14.7",
"sanitize-html": "2.10.0",
"use-deep-compare-effect": "^1.8.1"

View File

@ -12,7 +12,7 @@
"antd-mobile-icons": "^0.3.0",
"lodash": "4.17.21",
"react-i18next": "^14.1.2",
"react-router-dom": "6.x"
"react-router-dom": "~6.25.1"
},
"peerDependencies": {
"@tachybase/client": "workspace:*",

View File

@ -31,7 +31,7 @@
"react-error-boundary": "^4.0.10",
"react-i18next": "^14.1.2",
"react-pdf": "^7.5.1",
"react-router-dom": "^6.11.2",
"react-router-dom": "^6.25.1",
"react-transition-group": "^4.4.5",
"react-zoom-pan-pinch": "^3.4.4",
"redis": "^4.6.11",

View File

@ -22,7 +22,7 @@
"qrcode": "^1.5.1",
"qrcode.react": "^3.1.0",
"react": "~18.3.1",
"react-router-dom": "^6.11.2",
"react-router-dom": "^6.25.1",
"sequelize": "^6.26.0"
},
"peerDependencies": {

View File

@ -7,7 +7,7 @@
"dependencies": {
"@ant-design/icons": "^5.3.7",
"antd-mobile": "^5.36.1",
"react-router-dom": "^6.23.1"
"react-router-dom": "^6.25.1"
},
"peerDependencies": {
"@tachybase/client": "workspace:*",

View File

@ -15,7 +15,7 @@
"antd": "5.19.1",
"lodash": "4.17.21",
"react-i18next": "^14.1.2",
"react-router-dom": "^6.11.2"
"react-router-dom": "^6.25.1"
},
"peerDependencies": {
"@tachybase/client": "workspace:*",

View File

@ -12,7 +12,7 @@
"@tachybase/schema": "workspace:*",
"antd": "5.19.1",
"react-i18next": "^14.1.2",
"react-router-dom": "^6.11.2"
"react-router-dom": "^6.25.1"
},
"peerDependencies": {
"@tachybase/client": "workspace:*",

View File

@ -14,7 +14,7 @@
"cron": "^3.1.7",
"react": "^18.3.1",
"react-i18next": "^14.1.2",
"react-router-dom": "^6.11.2"
"react-router-dom": "^6.25.1"
},
"peerDependencies": {
"@tachybase/actions": "workspace:*",

View File

@ -12,7 +12,7 @@
"@ant-design/icons": "~5.3.7",
"antd": "5.19.1",
"react-i18next": "^14.1.2",
"react-router-dom": "6.x"
"react-router-dom": "~6.25.1"
},
"peerDependencies": {
"@tachybase/actions": "workspace:*",

View File

@ -14,7 +14,7 @@
"antd": "5.19.1",
"lodash": "~4.17.21",
"react-i18next": "^14.1.2",
"react-router-dom": "6.x"
"react-router-dom": "~6.25.1"
},
"peerDependencies": {
"@tachybase/actions": "workspace:*",

View File

@ -16,7 +16,7 @@
"antd": "5.19.1",
"lodash": "4.17.21",
"react-i18next": "^14.1.2",
"react-router-dom": "^6.11.2",
"react-router-dom": "^6.25.1",
"sequelize": "^6.26.0"
},
"peerDependencies": {

View File

@ -0,0 +1,2 @@
/node_modules
/src

View File

@ -0,0 +1 @@
# @tachybase/plugin-dingding

View File

@ -0,0 +1,2 @@
export * from './dist/client';
export { default } from './dist/client';

View File

@ -0,0 +1 @@
module.exports = require('./dist/client/index.js');

View File

@ -0,0 +1,21 @@
{
"name": "@tachybase/plugin-dingtalk",
"version": "0.21.74",
"main": "dist/server/index.js",
"devDependencies": {
"@alicloud/dingtalk": "^2.1.30",
"@alicloud/openapi-client": "^0.4.10",
"@alicloud/tea-util": "^1.4.9",
"@ant-design/icons": "^5.3.7",
"antd": "5.19.1",
"react-router-dom": "^6.25.1"
},
"peerDependencies": {
"@tachybase/actions": "workspace:*",
"@tachybase/auth": "workspace:*",
"@tachybase/client": "workspace:*",
"@tachybase/plugin-auth": "workspace:*",
"@tachybase/server": "workspace:*",
"@tachybase/test": "workspace:*"
}
}

View File

@ -0,0 +1,2 @@
export * from './dist/server';
export { default } from './dist/server';

View File

@ -0,0 +1 @@
module.exports = require('./dist/server/index.js');

View File

@ -0,0 +1,104 @@
import React, { useEffect } from 'react';
import { SchemaComponent, useAPIClient, useApp } from '@tachybase/client';
import { CopyOutlined, LoginOutlined } from '@ant-design/icons';
import { Button, Input, message, Typography } from 'antd';
import { useLocation } from 'react-router-dom';
import { useTranslation } from './locale';
export const AdminSettingsForm = () => {
const { t } = useTranslation();
const app = useApp();
const redirectUrl = React.useMemo(() => app.getApiUrl('dingtalk:redirect'), [app]);
const onCopy = (text) => {
navigator.clipboard.writeText(text), message.success(t('Copied'));
};
return (
<div>
<SchemaComponent
scope={{ t }}
schema={{
type: 'object',
properties: {
public: {
type: 'object',
properties: {
autoSignup: {
'x-decorator': 'FormItem',
type: 'boolean',
title: '{{t("Sign up automatically when the user does not exist")}}',
'x-component': 'Checkbox',
default: true,
},
},
},
dingtalk: {
type: 'object',
properties: {
clientId: {
'x-decorator': 'FormItem',
'x-component': 'Input',
type: 'string',
required: true,
title: '{{t("Client ID")}}',
},
clientSecret: {
'x-decorator': 'FormItem',
'x-component': 'Input',
type: 'string',
required: true,
title: '{{t("Client Secret")}}',
},
},
},
},
}}
/>
<div>
<Typography.Title level={5} style={{ fontSize: '14px' }}>
{t('Redirect URL')}
<span style={{ marginLeft: '2px' }}>:</span>
</Typography.Title>
<Input value={redirectUrl} disabled={true} addonBefore={<CopyOutlined onClick={() => onCopy(redirectUrl)} />} />
</div>
</div>
);
};
export const SignInButton = ({ authenticator }) => {
const [href, setHref] = React.useState();
const { t } = useTranslation();
const api = useAPIClient();
const location = useLocation();
const urlSearchParams = new URLSearchParams(location.search);
const redirect = urlSearchParams.get('redirect');
useEffect(() => {
href && (window.location.href = href);
}, [href]);
useEffect(() => {
const authenticatorName = urlSearchParams.get('authenticator');
const error = urlSearchParams.get('error');
if (authenticatorName === authenticator.name && error) {
message.error(t(error));
return;
}
});
const onClick = async () => {
const result = await api.request({
method: 'post',
url: 'dingtalk:getAuthUrl',
headers: { 'X-Authenticator': authenticator.name },
data: { redirect: redirect },
});
const url = result?.data?.data?.url;
if (url) {
setHref(url);
}
};
return (
<Button block icon={<LoginOutlined />} shape="round" onClick={onClick}>
{authenticator.title ? t(authenticator.title) : t('Sign in via DingTalk')}
</Button>
);
};

View File

@ -0,0 +1,13 @@
import { Plugin } from '@tachybase/client';
import PluginAuthClient from '@tachybase/plugin-auth/client';
import { authType } from '../constants';
import { AdminSettingsForm, SignInButton } from './components';
export class PluginDingding extends Plugin {
async load() {
this.app.pm.get(PluginAuthClient).registerType(authType, { components: { SignInButton, AdminSettingsForm } });
}
}
export default PluginDingding;

View File

@ -0,0 +1,7 @@
import { useTranslation as useT } from '@tachybase/client';
import { namespace } from '../constants';
export function useTranslation() {
return useT([namespace, 'client'], { nsMode: 'fallback' });
}

View File

@ -0,0 +1,2 @@
export const authType = 'dingtalk';
export const namespace = 'dingtalk';

View File

@ -0,0 +1,2 @@
export * from './server';
export { default } from './server';

View File

@ -0,0 +1,15 @@
export default {
"Sign in via DingTalk": "Sign in via DingTalk",
"Client ID": "Client ID",
"Client Secret": "Client Secret",
"DingTalk": "DingTalk",
"dingtalk": "dingtalk",
"Redirect URL": "Redirect URL",
"Please enter the authCode": "Please enter the authCode",
"Authorizing Login": "Authorizing Login",
"Sign up automatically when the user does not exist": "Sign up automatically when the user does not exist",
"User not found": "User not found",
"Failed to get mobile": "Failed to get mobile",
"Failed to get accessToken": "Failed to get accessToken",
"Copied": "Copied"
}

View File

@ -0,0 +1,15 @@
export default {
"Sign in via DingTalk": "钉钉登录",
"Client ID": "Client ID",
"Client Secret": "Client Secret",
"DingTalk": "钉钉",
"dingtalk": "钉钉",
"Redirect URL": "回调地址",
"Please enter the authCode": "请输入authCode",
"Authorizing Login": "授权登录中...",
"Sign up automatically when the user does not exist": "用户不存在时自动注册",
"User not found": "用户不存在",
"Failed to get mobile": "未获取到手机号",
"Failed to get accessToken": "未获取到accessToken",
"Copied": "已复制"
}

View File

@ -0,0 +1,36 @@
import { Context, Next } from '@tachybase/actions';
import { AppSupervisor } from '@tachybase/server';
export const getAuthUrl = async (ctx: Context, next: Next) => {
const { redirect } = ctx.action.params.values;
const url = await ctx.auth.getAuthUrl(redirect);
ctx.body = { url };
await next();
};
export const redirect = async (ctx: Context, next: Next) => {
const { state } = ctx.action.params;
const search = new URLSearchParams(decodeURIComponent(state));
const authenticator = search.get('name');
const appName = search.get('app');
const redirect = search.get('redirect') || '/admin';
let prefix = process.env.APP_PUBLIC_PATH || '';
if (appName && appName !== 'main') {
const appSupervisor = AppSupervisor.getInstance();
if (appSupervisor?.runningMode !== 'single') {
prefix += `apps/${appName}`;
}
}
const auth = await ctx.app.authManager.get(authenticator, ctx);
if (prefix.endsWith('/')) {
prefix = prefix.slice(0, -1);
}
try {
const { token } = await auth.signIn();
ctx.redirect(`${prefix}${redirect}?authenticator=${authenticator}&token=${token}`);
} catch (error) {
ctx.logger.error('Dingtalk auth error', { error });
ctx.redirect(`${prefix}/signin?redirect=${redirect}&authenticator=${authenticator}&error=${error.message}`);
}
await next();
};

View File

@ -0,0 +1,68 @@
import { BaseAuth } from '@tachybase/auth';
import { namespace } from '../constants';
import { DingtalkClient } from './client';
export class Auth extends BaseAuth {
constructor(config) {
const { ctx } = config;
super({
...config,
userCollection: ctx.db.getCollection('users'),
});
}
async validate() {
const ctx = this.ctx;
const { authCode } = ctx.action.params || {};
if (!authCode) {
ctx.throw(401, ctx.t('Please enter the authCode', { ns: namespace }));
}
const dingtalkClient = new DingtalkClient({
clientId: this.options?.dingtalk?.clientId,
clientSecret: this.options?.clientSecret,
ctx: this.ctx,
});
const accessToken = await dingtalkClient.getAccessToken(authCode);
if (!accessToken) {
ctx.throw(401, ctx.t('Failed to get accessToken', { ns: namespace }));
}
const userInfo = await dingtalkClient.getUserInfo(accessToken);
const { mobile, nick, email } = userInfo;
if (!mobile) {
ctx.throw(401, ctx.t('Failed to get mobile', { ns: namespace }));
}
const authenticator = this.authenticator;
let user = await authenticator.findUser(mobile);
if (user) {
return user;
}
user = await this.userRepository.findOne({
filter: { phone: mobile },
});
if (user) {
await authenticator.addUser(user.id, {
through: {
uuid: mobile,
},
});
return user;
}
const { autoSignup } = this.options?.public || {};
if (!autoSignup) {
ctx.throw(401, ctx.t('User not found', { ns: namespace }));
}
return await authenticator.newUser(mobile, {
nickname: nick ?? null,
phone: mobile ?? null,
email: email ?? null,
});
}
async getAuthUrl(redirect) {
const clientId = this.options?.dingtalk?.clientId;
const app = this.ctx.app.name;
const redirectUrl = encodeURIComponent(`${this.ctx.origin}${process.env.API_BASE_PATH}dingtalk:redirect`);
const state = encodeURIComponent(`redirect=${redirect}&app=${app}&name=${this.ctx.headers['x-authenticator']}`);
const url = `https://login.dingtalk.com/oauth2/auth?client_id=${clientId}&response_type=code&scope=openid&state=${state}&redirect_uri=${redirectUrl}&prompt=consent`;
return url;
}
}

View File

@ -0,0 +1,57 @@
import ContactClient, { GetUserHeaders } from '@alicloud/dingtalk/contact_1_0';
import OAuthClient, { GetUserTokenRequest } from '@alicloud/dingtalk/oauth2_1_0';
import { Config } from '@alicloud/openapi-client';
import Util, { RuntimeOptions } from '@alicloud/tea-util';
export class DingtalkClient {
config;
ctx;
constructor(config) {
this.config = config;
this.ctx = config?.ctx;
}
createAuthClient() {
const config = new Config({});
config.protocol = 'https';
config.regionId = 'central';
return new OAuthClient(config);
}
createContactClient() {
const config = new Config({});
config.protocol = 'https';
config.regionId = 'central';
return new ContactClient(config);
}
async getAccessToken(code) {
const client = this.createAuthClient();
const getUserTokenRequest = new GetUserTokenRequest({
clientId: this.config.clientId,
clientSecret: this.config.clientSecret,
code,
grantType: 'authorization_code',
});
try {
const response = await client.getUserToken(getUserTokenRequest);
return response?.body?.accessToken || '';
} catch (err) {
if (!Util.empty(err.code) && !Util.empty(err.message)) {
this.ctx?.log.debug(`DingTalk get accessToken error code:${err.code}, message:${err.message}`);
}
return '';
}
}
async getUserInfo(accessToken) {
const client = this.createContactClient();
const getUserHeaders = new GetUserHeaders({});
getUserHeaders.xAcsDingtalkAccessToken = accessToken;
try {
const response = await client.getUserWithOptions('me', getUserHeaders, new RuntimeOptions({}));
return response?.body || {};
} catch (err) {
if (!Util.empty(err.code) && !Util.empty(err.message)) {
this.ctx?.log.debug(`DingTalk get userInfo error code:${err.code}, message:${err.message}`);
}
return {};
}
}
}

View File

@ -0,0 +1 @@
export { default } from './plugin';

View File

@ -0,0 +1,53 @@
import { tval } from '@tachybase/client';
import { Gateway, Plugin } from '@tachybase/server';
import { authType, namespace } from '../constants';
import { getAuthUrl, redirect } from './actions/dingding';
import { Auth } from './auth';
export class PluginDingdingServer extends Plugin {
async afterAdd() {}
async beforeLoad() {}
async load() {
console.log('hello world dingding');
this.app.authManager.registerTypes(authType, {
auth: Auth,
title: tval('DingTalk', { ns: namespace }),
});
this.app.resourcer.define({
name: 'dingtalk',
actions: {
getAuthUrl: getAuthUrl,
redirect: redirect,
},
});
this.app.acl.allow('dingtalk', '*', 'public');
Gateway.getInstance().addAppSelectorMiddleware(async (ctx, next) => {
const { req } = ctx;
const url = new URL(req.url, `http://${req.headers.host}`);
const params = url.searchParams;
const state = params.get('state');
if (!state) {
return next();
}
const search = new URLSearchParams(state);
const appName = search.get('app');
if (appName) {
ctx.resolvedAppName = appName;
}
await next();
});
}
async install() {}
async afterEnable() {}
async afterDisable() {}
async remove() {}
}
export default PluginDingdingServer;

View File

@ -25,7 +25,7 @@
"lodash": "4.17.21",
"react": "^18.3.1",
"react-i18next": "^14.1.2",
"react-router-dom": "^6.11.2"
"react-router-dom": "^6.25.1"
},
"peerDependencies": {
"@tachybase/client": "workspace:*",

View File

@ -24,7 +24,7 @@
"react": "~18.3.1",
"react-dom": "~18.3.1",
"react-i18next": "^14.1.2",
"react-router-dom": "^6.11.2"
"react-router-dom": "^6.25.1"
},
"peerDependencies": {
"@tachybase/actions": "workspace:*",

View File

@ -19,7 +19,7 @@
"react": "~18.3.1",
"react-dom": "~18.3.1",
"react-i18next": "~14.1.2",
"react-router-dom": "6.x"
"react-router-dom": "~6.25.1"
},
"peerDependencies": {
"@tachybase/client": "workspace:*",

View File

@ -17,7 +17,7 @@
"pg": "^8.7.3",
"react": "~18.3.1",
"react-i18next": "^14.1.2",
"react-router-dom": "^6.11.2"
"react-router-dom": "^6.25.1"
},
"peerDependencies": {
"@tachybase/client": "workspace:*",

View File

@ -18,7 +18,7 @@
"openid-client": "^5.4.2",
"react": "~18.3.1",
"react-i18next": "^14.1.2",
"react-router-dom": "^6.11.2"
"react-router-dom": "^6.25.1"
},
"peerDependencies": {
"@tachybase/actions": "workspace:*",

View File

@ -25,7 +25,7 @@ export class OidcPlugin extends Plugin {
});
// 注册接口
this.app.resource({
this.app.resourcer.define({
name: 'oidc',
actions: {
getAuthUrl,

View File

@ -15,7 +15,7 @@
"antd": "5.19.1",
"react": "~18.3.1",
"react-i18next": "^14.1.2",
"react-router-dom": "^6.11.2"
"react-router-dom": "^6.25.1"
},
"peerDependencies": {
"@tachybase/actions": "workspace:*",

View File

@ -29,7 +29,7 @@
"react": "~18.3.1",
"react-i18next": "^14.1.2",
"react-js-cron": "^3.1.0",
"react-router-dom": "^6.11.2",
"react-router-dom": "^6.25.1",
"sequelize": "^6.26.0"
},
"peerDependencies": {

File diff suppressed because it is too large Load Diff