feat: oauth配置相关增删改查组件实现

This commit is contained in:
Pan Qiancheng 2025-10-23 15:30:54 +08:00
parent 18c8d3b670
commit a3a4837974
30 changed files with 1824 additions and 1 deletions

View File

@ -0,0 +1,162 @@
import { EntityDict } from '../../../../oak-app-domain';
import assert from "assert";
export default OakComponent({
// Virtual Component
isList: false,
filters: [],
properties: {
},
data: {
clientInfo: null as EntityDict['oauthApplication']['Schema'] | null,
loading: true,
userInfo: null as EntityDict['token']['Schema']['user'] | null,
hasError: false,
errorMsg: '',
name: '',
nickname: '',
mobile: '',
avatarUrl: '',
response_type: '',
client_id: '',
redirect_uri: '',
scope: '',
state: '',
},
lifetimes: {
ready() {
const searchParams = new URLSearchParams(window.location.search);
const clientId = searchParams.get('client_id') || '';
const responseType = searchParams.get('response_type') || '';
const redirectUri = searchParams.get('redirect_uri') || '';
const scope = searchParams.get('scope') || '';
const state = searchParams.get('state') || '';
this.setState({
client_id: clientId,
response_type: responseType,
redirect_uri: redirectUri,
scope: scope,
state: state,
});
// load userinfo
const userId = this.features.token.getUserId(true);
if (!userId) {
const params = new URLSearchParams();
params.set('response_type', responseType || "");
params.set('client_id', clientId || "");
params.set('redirect_uri', redirectUri || "");
params.set('scope', scope || "");
params.set('state', state || "");
const redirectUrl = `/login/oauth/authorize?${params.toString()}`;
console.log('Not logged in, redirecting to login page:', redirectUrl);
const encoded = btoa(encodeURIComponent(redirectUrl));
this.features.navigator.navigateTo({
url: `/login?redirect=${encoded}`,
}, undefined, true);
return;
}
const userInfo = this.features.token.getUserInfo();
const { mobile } =
(userInfo?.mobile$user && userInfo?.mobile$user[0]) ||
(userInfo?.user$ref &&
userInfo?.user$ref[0] &&
userInfo?.user$ref[0].mobile$user &&
userInfo?.user$ref[0].mobile$user[0]) ||
{};
const extraFile = userInfo?.extraFile$entity?.find(
(ele) => ele.tag1 === 'avatar'
);
const avatarUrl = this.features.extraFile.getUrl(extraFile);
this.setState({
userInfo: userId ? this.features.token.getUserInfo() : null,
name: userInfo?.name || '',
nickname: userInfo?.nickname || '',
mobile: mobile || '',
avatarUrl,
})
// end load userinfo
if (!clientId) {
this.setState({
hasError: true,
errorMsg: 'oauth.authorize.error.missing_client_id',
});
this.setState({ loading: false });
return;
}
if (!responseType) {
this.setState({
hasError: true,
errorMsg: 'oauth.authorize.error.missing_response_type',
});
this.setState({ loading: false });
return;
}
this.features.cache.exec("getOAuthClientInfo", {
client_id: clientId,
}).then((clientInfo) => {
if (!clientInfo.result) {
this.setState({
hasError: true,
errorMsg: 'oauth.authorize.error.invalid_client_id',
});
} else {
this.setState({
clientInfo: clientInfo.result as any,
});
}
}).catch((err: Error) => {
console.error('Error loading OAuth client info:', err);
this.setState({
hasError: true,
errorMsg: err.message || 'oauth.authorize.error.unknown',
});
}).finally(() => {
this.setState({ loading: false });
});
},
},
methods: {
handleGrant() {
this.callAspectAuthorize("grant");
},
handleDeny() {
this.callAspectAuthorize("deny");
},
callAspectAuthorize(action: "grant" | "deny") {
this.features.cache.exec("authorize", {
response_type: this.state.response_type || "",
client_id: this.state.client_id || "",
redirect_uri: this.state.redirect_uri || "",
scope: this.state.scope || "",
state: this.state.state || "",
action: action,
}).then((result) => {
const { redirectUri } = result.result;
assert(redirectUri, 'redirectUri should be present in authorize result');
window.location.href = redirectUri;
}).catch((err: Error) => {
console.error('Error during OAuth authorization:', err);
this.setState({
hasError: true,
errorMsg: err.message || 'oauth.authorize.error.unknown',
});
});
}
},
});

View File

@ -0,0 +1,21 @@
{
"oauth": {
"authorize": {
"title": "授权确认",
"loading": "正在加载...",
"description": "第三方应用请求访问您的账户",
"clientName": "应用名称",
"clientDescription": "应用介绍",
"scope": "授权范围",
"allPermissions": "该应用将获得您账户的完整访问权限",
"confirm": "同意授权",
"deny": "拒绝",
"error": {
"title": "授权失败",
"missing_response_type": "缺少 response_type 参数",
"missing_client_id": "缺少 client_id 参数",
"unknown": "未知错误,请稍后重试"
}
}
}
}

View File

@ -0,0 +1,213 @@
.container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100%;
background-color: #f0f2f5;
padding: 10px;
}
.loadingBox,
.errorBox,
.authorizeBox {
background: white;
border-radius: 2px;
padding: 20px 24px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
max-width: 500px;
width: 100%;
border: 1px solid #e8e8e8;
}
.loadingBox {
text-align: center;
}
.spinner {
width: 48px;
height: 48px;
margin: 0 auto 20px;
border: 3px solid #f0f0f0;
border-top: 3px solid #1890ff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.loadingText {
font-size: 14px;
color: #595959;
}
.errorBox {
border-top: 3px solid #f5222d;
}
.errorTitle {
font-size: 18px;
font-weight: 600;
color: #262626;
margin-bottom: 16px;
}
.errorMessage {
font-size: 14px;
color: #595959;
line-height: 1.8;
padding: 16px;
background: #fff1f0;
border: 1px solid #ffa39e;
border-radius: 2px;
}
.authorizeBox {
border-top: 3px solid #faad14;
}
.title {
font-size: 24px;
font-weight: 600;
color: #262626;
margin-bottom: 4px;
padding-bottom: 8px;
border-bottom: 1px solid #e8e8e8;
}
.description {
font-size: 14px;
color: #595959;
margin-top: 10px;
margin-bottom: 24px;
line-height: 1.8;
}
.appInfo {
background: #fafafa;
padding: 8px 12px;
border-radius: 2px;
margin-bottom: 12px;
border: 1px solid #e8e8e8;
}
.userInfo {
background: #fafafa;
padding: 8px 12px;
border-radius: 2px;
margin-bottom: 12px;
border: 1px solid #e8e8e8;
display: flex;
align-items: center;
gap: 12px;
}
.label {
font-size: 14px;
color: #262626;
font-weight: 500;
}
.infoLabel {
font-size: 12px;
color: #8c8c8c;
margin-bottom: 4px;
margin-top: 4px;
font-weight: 500;
}
.infoValue {
font-size: 15px;
color: #262626;
font-weight: 600;
}
.descValue {
font-size: 14px;
color: #595959;
line-height: 1.6;
}
.scopeSection {
margin-top: 12px;
margin-bottom: 12px;
background: #fffbe6;
padding: 8px 12px;
border-radius: 2px;
border-left: 4px solid #faad14;
}
.scopeTitle {
font-size: 14px;
font-weight: 600;
color: #262626;
margin-bottom: 6px;
}
.scopeItem {
display: flex;
align-items: flex-start;
padding: 8px 0;
font-size: 14px;
color: #262626;
line-height: 1.8;
}
.scopeIcon {
color: #faad14;
margin-right: 8px;
flex-shrink: 0;
margin-top: 2px;
}
.actions {
display: flex;
gap: 12px;
margin-top: 8px;
padding-top: 12px;
border-top: 1px solid #e8e8e8;
}
.denyButton,
.grantButton {
flex: 1;
height: 40px;
border-radius: 2px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s;
}
.denyButton {
background: #ff5e5e;
color: #ffffff;
border: 1px solid #d9d9d9;
}
.denyButton:hover {
color: #262626;
border-color: #40a9ff;
}
.denyButton:active {
background: #f5f5f5;
}
.grantButton {
background: #1890ff;
color: white;
border: 1px solid #1890ff;
}
.grantButton:hover {
background: #40a9ff;
border-color: #40a9ff;
}
.grantButton:active {
background: #096dd9;
border-color: #096dd9;
}

View File

@ -0,0 +1,129 @@
import React from 'react';
import { RowWithActions, WebComponentProps } from 'oak-frontend-base';
import Styles from './styles.module.less';
import { Avatar } from 'antd';
import { EntityDict } from '../../../../oak-app-domain';
const Authorize = (
props: WebComponentProps<
EntityDict,
keyof EntityDict,
false,
{
// virtual
loading: boolean;
hasError: boolean;
errorMsg: string;
userInfo: EntityDict['token']['Schema']['user'] | null;
response_type: string;
client_id: string;
redirect_uri: string;
scope: string;
state: string;
clientInfo: EntityDict['oauthApplication']['Schema'] | null;
name: string;
nickname: string;
mobile: string;
avatarUrl: string;
},
{
handleGrant: () => void;
handleDeny: () => void;
}
>
) => {
const {
oakFullpath, loading, hasError, errorMsg, userInfo,
response_type, client_id, redirect_uri, scope, state, clientInfo,
name, nickname, mobile, avatarUrl
} = props.data;
const { t, handleGrant, handleDeny } = props.methods;
// Loading state
if (loading) {
return (
<div className={Styles.container}>
<div className={Styles.loadingBox}>
<div className={Styles.spinner}></div>
<div className={Styles.loadingText}>{t('oauth.authorize.loading')}</div>
</div>
</div>
);
}
// Error state
if (hasError) {
return (
<div className={Styles.container}>
<div className={Styles.errorBox}>
<div className={Styles.errorTitle}>{t('oauth.authorize.error.title')}</div>
<div className={Styles.errorMessage}>{t(errorMsg)}</div>
</div>
</div>
);
}
// Logged in - show authorization confirmation
return (
<div className={Styles.container}>
<div className={Styles.authorizeBox}>
<div className={Styles.title}>{t('oauth.authorize.title')}</div>
<div className={Styles.description}>
{t('oauth.authorize.description')}
</div>
<div className={Styles.appInfo}>
<div className={Styles.infoLabel}>{t('oauth.authorize.clientName')}:</div>
<div className={Styles.infoValue}>{clientInfo?.name || client_id}</div>
{clientInfo?.description && (
<>
<div className={Styles.infoLabel}>{t('oauth.authorize.clientDescription')}:</div>
<div className={Styles.descValue}>{clientInfo.description}</div>
</>
)}
</div>
<div className={Styles.scopeSection}>
<div className={Styles.scopeTitle}>{t('oauth.authorize.scope')}</div>
<div className={Styles.scopeItem}>
<span className={Styles.scopeIcon}></span>
<span>{t('oauth.authorize.allPermissions')}</span>
</div>
</div>
<div className={Styles.userInfo}>
{avatarUrl ? (
<Avatar className={Styles.avatar} src={avatarUrl}></Avatar>
) : (
<Avatar className={Styles.avatar}>
<span className={Styles.text}>
{nickname?.[0]}
</span>
</Avatar>
)}
<div className={Styles.userDetails}>
<div className={Styles.userName}>{name || nickname}</div>
{mobile && <div className={Styles.userMobile}>{mobile}</div>}
</div>
</div>
<div className={Styles.actions}>
<button
className={Styles.denyButton}
onClick={handleDeny}
>
{t('oauth.authorize.deny')}
</button>
<button
className={Styles.grantButton}
onClick={handleGrant}
>
{t('oauth.authorize.confirm')}
</button>
</div>
</div>
</div>
);
};
export default Authorize;

View File

@ -0,0 +1,71 @@
import assert from "assert";
export default OakComponent({
// Virtual Component
isList: false,
filters: [],
properties: {},
data: {
hasError: false,
errorMessage: '',
loading: true,
},
lifetimes: {
async ready() {
const queryString = window.location.search;
const urlParams = new URLSearchParams(queryString);
const code = urlParams.get('code');
const state = urlParams.get('state');
const error = urlParams.get('error');
const errorDescription = urlParams.get('error_description');
if (error) {
this.setErrorMsg(errorDescription || 'OAuth authorization error: ' + error);
this.setState({ loading: false });
return;
}
assert(state, 'State parameter is missing');
assert(code, 'Code parameter is missing');
this.setState({ hasError: false, errorMessage: '' });
if (!state) {
this.setErrorMsg('Invalid state parameter');
return;
}
this.performLogin(code, state!);
},
},
methods: {
performLogin(code: string, state: string) {
this.features.token.loginByOAuth(code, state).then(() => {
console.log('OAuth login successful');
}).catch((err: Error) => {
console.error('OAuth login failed:', err);
this.setErrorMsg(err.message || 'OAuth login failed');
}).finally(() => {
this.setState({ loading: false });
});
},
setErrorMsg(message: string) {
if (!message) {
this.setState({ hasError: false, errorMessage: '' });
return;
}
this.setState({ hasError: true, errorMessage: message });
},
retry() {
this.features.navigator.redirectTo({
url: '/login',
});
},
returnToIndex() {
this.features.navigator.redirectTo({
url: '/',
});
}
}
});

View File

@ -0,0 +1,20 @@
{
"Invalid state parameter": "无效的状态参数",
"oauth": {
"loading": {
"title": "授权中..."
},
"loadingMessage": "正在处理授权请求,请稍候",
"error": {
"title": "授权失败"
},
"success": {
"title": "授权成功"
},
"successMessage": "授权已成功完成",
"return": "返回首页",
"confirm": "确认登录",
"cancel": "取消",
"close": "关闭窗口"
}
}

View File

@ -0,0 +1,9 @@
export default OakComponent({
// Virtual Component
isList: false,
filters: [],
properties: {
systemId: '',
systemName: '',
}
});

View File

@ -0,0 +1,5 @@
{
"systemInfo": "系统信息",
"applications": "OAuth应用",
"providers": "OAuth供应商"
}

View File

@ -0,0 +1,34 @@
import assert from "assert";
export default OakComponent({
entity: 'oauthApplication',
isList: true,
projection: {
id: 1,
name: 1,
description: 1,
redirectUris: 1,
logo: 1,
isConfidential: 1,
scopes: 1,
ableState: 1,
},
filters: [{
filter() {
const systemId = this.props.systemId;
assert(systemId, 'systemId is required');
return {
systemId: systemId,
}
},
}],
formData({ data }) {
return {
list: data?.filter(item => (item.$$createAt$$ as number) > 1) || [],
};
},
properties: {
systemId: '',
},
actions: ["remove", "update"]
});

View File

@ -0,0 +1,7 @@
{
"oauthAppConfig": "OAuth应用程序配置",
"confirm": {
"deleteTitle": "确认删除",
"deleteContent": "您确定要删除此OAuth应用程序配置吗"
}
}

View File

@ -0,0 +1,7 @@
.id {
font-size: 18px;
}
.item {
font-size: 18px;
}

View File

@ -0,0 +1,45 @@
import { generateNewId } from "oak-domain/lib/utils/uuid";
export default OakComponent({
entity: 'oauthApplication',
isList: false,
projection: {
name: 1,
description: 1,
redirectUris: 1,
logo: 1, // string
isConfidential: 1,
scopes: 1, // string[]
ableState: 1,
},
formData({ data, features }) {
if (!data) {
return { item: {}, clientSecret: ""};
}
const [client] = features.cache.get("oauthApplication", {
data: {
clientSecret: 1,
},
filter: {
id: data.id,
}
})
return {
item: data,
clientSecret: client?.clientSecret || "",
};
},
properties: {},
methods: {
reGenerateClientSecret() {
this.features.cache.operate("oauthApplication", {
id: generateNewId(),
action: "resetSecret",
data: {},
filter: {
id: this.props.oakId,
}
})
}
}
});

View File

@ -0,0 +1,22 @@
{
"name": "名称",
"nameRequired": "请输入名称",
"namePlaceholder": "请输入应用名称",
"description": "描述",
"descriptionPlaceholder": "请输入应用描述",
"logo": "Logo",
"logoPlaceholder": "请输入Logo URL",
"redirectUris": "重定向URI列表",
"redirectUrisRequired": "请输入重定向URI列表",
"redirectUrisPlaceholder": "请输入重定向URI每行一个",
"scopes": "权限范围",
"scopesPlaceholder": "请选择或输入权限范围",
"clientSecret": "客户端密钥",
"clientSecretPlaceholder": "自动生成的客户端密钥",
"regenerate": "重新生成",
"isConfidential": "机密客户端",
"ableState": "启用状态",
"noData": "无数据",
"clientId": "客户端ID",
"clientIdPlaceholder": "自动生成的客户端ID"
}

View File

@ -0,0 +1,7 @@
.id {
font-size: 18px;
}
.item {
font-size: 18px;
}

View File

@ -0,0 +1,157 @@
import React, { useEffect } from 'react';
import { Form, Input, Switch, Button, Space, Upload, Select } from 'antd';
import { UploadOutlined } from '@ant-design/icons';
import { RowWithActions, WebComponentProps } from 'oak-frontend-base';
import Styles from './styles.module.less';
import { EntityDict } from '../../../../../oak-app-domain';
const Upsert = (
props: WebComponentProps<
EntityDict,
'oauthApplication',
false,
{
item: RowWithActions<EntityDict, 'oauthApplication'>;
clientSecret: string;
},
{
reGenerateClientSecret: () => void;
}
>
) => {
const { item, clientSecret } = props.data;
const { t, update, reGenerateClientSecret } = props.methods;
if (item === undefined) {
return <div>{t('noData')}</div>;
}
return (
<div className={Styles.id}>
<Form
layout="vertical"
autoComplete="off"
>
<Form.Item
label={t('name')}
rules={[{ required: true, message: t('nameRequired') }]}
>
<Input
placeholder={t('namePlaceholder')}
value={item.name || ""}
onChange={(v) => {
update({ name: v.target.value });
}}
/>
</Form.Item>
<Form.Item
label={t('description')}
>
<Input.TextArea
placeholder={t('descriptionPlaceholder')}
value={item.description || ""}
rows={4}
onChange={(v) => {
update({ description: v.target.value });
}}
/>
</Form.Item>
<Form.Item
label={t('logo')}
>
<Input
placeholder={t('logoPlaceholder')}
value={item.logo || ""}
onChange={(v) => {
update({ logo: v.target.value });
}}
/>
</Form.Item>
<Form.Item
label={t('redirectUris')}
rules={[{ required: true, message: t('redirectUrisRequired') }]}
>
<Input.TextArea
placeholder={t('redirectUrisPlaceholder')}
value={Array.isArray(item.redirectUris) ? item.redirectUris.join('\n') : ""}
rows={3}
onChange={(v) => {
const uris = v.target.value.split('\n').filter(uri => uri.trim() !== '');
update({ redirectUris: uris });
}}
/>
</Form.Item>
<Form.Item
label={t('scopes')}
>
<Select
mode="tags"
placeholder={t('scopesPlaceholder')}
value={item.scopes || []}
onChange={(v) => {
update({ scopes: v });
}}
tokenSeparators={[',']}
open={false}
/>
</Form.Item>
<Form.Item
label={t('clientId')}
>
<Space.Compact style={{ width: '100%' }}>
<Input
placeholder={t('clientIdPlaceholder')}
value={item.id || "已隐藏"}
disabled
/>
</Space.Compact>
</Form.Item>
<Form.Item
label={t('clientSecret')}
>
<Space.Compact style={{ width: '100%' }}>
<Input
placeholder={t('clientSecretPlaceholder')}
value={clientSecret || "已隐藏"}
disabled
/>
<Button
type="primary"
onClick={reGenerateClientSecret}
>
{t('regenerate')}
</Button>
</Space.Compact>
</Form.Item>
<Form.Item
label={t('isConfidential')}
valuePropName="checked"
>
<Switch
checked={!!item.isConfidential}
onChange={(checked) => update({ isConfidential: checked })}
/>
</Form.Item>
<Form.Item
label={t('ableState')}
valuePropName="checked"
>
<Switch
checked={!!item.ableState}
onChange={(checked) => update({ ableState: checked ? "enabled" : "disabled" })}
/>
</Form.Item>
</Form>
</div>
);
};
export default Upsert;

View File

@ -0,0 +1,96 @@
import React from 'react';
import { onActionFnDef, RowWithActions, WebComponentProps } from 'oak-frontend-base';
import Styles from './styles.module.less';
import PageHeader from 'oak-frontend-base/es/components/pageHeader';
import { Button, Modal } from 'antd';
import AppUpsert from "./upsert"
import { EntityDict } from '../../../../oak-app-domain';
import ListPro from 'oak-frontend-base/es/components/listPro';
const OauthProvider = (
props: WebComponentProps<
EntityDict,
'oauthApplication',
true,
{
list: RowWithActions<EntityDict, 'oauthApplication'>[];
systemId: string;
}
>
) => {
const { oakFullpath, systemId } = props.data;
const { list, oakLoading } = props.data;
const { t, addItem, removeItem, execute, clean } = props.methods;
const attrs = [
"id", "name", "description", "redirectUris",
"logo", "isConfidential", "scopes",
"ableState"
]
const [upsertId, setUpsertId] = React.useState<string | null>(null);
const handleAction: onActionFnDef = (row, action: string) => {
switch (action) {
case "update": {
setUpsertId(row.id);
break;
}
case "remove": {
Modal.confirm({
title: t('confirm.deleteTitle'),
content: t('confirm.deleteContent'),
onOk: () => {
removeItem(row.id);
execute();
}
});
break;
}
}
}
return (
<>
{list && (
<ListPro
entity='oauthApplication'
attributes={attrs}
data={list}
loading={oakLoading}
oakPath={`${oakFullpath}`}
onAction={handleAction}
extraContent={
<div className={Styles.actions}>
<Button
type="primary"
onClick={() => {
setUpsertId(addItem({
systemId: systemId,
isConfidential: true,
}));
}}
>
{t('common::action.create')}
</Button>
</div>
}
>
</ListPro>
)}
{/* antd model */}
<Modal open={!!upsertId} destroyOnClose={true} width={600} onCancel={() => {
clean()
setUpsertId(null);
}} onOk={() => {
execute()
setUpsertId(null);
}}>
{upsertId && <AppUpsert oakPath={`${oakFullpath}.${upsertId}`} oakId={upsertId} />}
</Modal>
</>
);
};
export default OauthProvider;

View File

@ -0,0 +1,39 @@
import assert from "assert";
export default OakComponent({
entity: 'oauthProvider',
isList: true,
projection: {
name: 1,
type: 1,
logo: 1,
authorizationEndpoint: 1,
tokenEndpoint: 1,
userInfoEndpoint: 1,
revokeEndpoint: 1,
refreshEndpoint: 1,
clientId: 1,
clientSecret: 1,
redirectUri: 1,
autoRegister: 1,
ableState: 1,
},
filters: [{
filter() {
const systemId = this.props.systemId;
assert(systemId, 'systemId is required');
return {
systemId: systemId,
}
},
}],
formData({ data }) {
return {
list: data?.filter(item => (item.$$createAt$$ as number) > 1) || [],
};
},
properties: {
systemId: '',
},
actions: ["remove", "update"]
});

View File

@ -0,0 +1,7 @@
{
"oauthProviderConfig": "OAuth提供商配置",
"confirm": {
"deleteTitle": "确认删除",
"deleteContent": "您确定要删除此OAuth提供商配置吗"
}
}

View File

@ -0,0 +1,12 @@
.id {
font-size: 18px;
}
.item {
font-size: 18px;
}
.actions {
display: flex;
gap: 8px;
}

View File

@ -0,0 +1,29 @@
export default OakComponent({
entity: 'oauthProvider',
isList: false,
projection: {
name: 1,
type: 1,
logo: 1,
authorizationEndpoint: 1,
tokenEndpoint: 1,
userInfoEndpoint: 1,
revokeEndpoint: 1,
refreshEndpoint: 1,
clientId: 1,
clientSecret: 1,
redirectUri: 1,
autoRegister: 1,
ableState: 1,
},
formData({ data }) {
return {
item: data,
};
},
properties: {},
lifetimes: {
ready() {
},
}
});

View File

@ -0,0 +1,38 @@
{
"name": "名称",
"nameRequired": "请输入名称",
"namePlaceholder": "请输入OAuth提供商名称",
"type": "类型",
"typeRequired": "请输入类型",
"typePlaceholder": "请输入OAuth类型",
"logo": "Logo",
"logoPlaceholder": "请输入Logo URL",
"authorizationEndpoint": "授权端点",
"authorizationEndpointRequired": "请输入授权端点",
"authorizationEndpointPlaceholder": "请输入授权端点URL",
"tokenEndpoint": "令牌端点",
"tokenEndpointRequired": "请输入令牌端点",
"tokenEndpointPlaceholder": "请输入令牌端点URL",
"userInfoEndpoint": "用户信息端点",
"userInfoEndpointPlaceholder": "请输入用户信息端点URL",
"revokeEndpoint": "撤销端点",
"revokeEndpointPlaceholder": "请输入撤销端点URL",
"clientId": "客户端ID",
"clientIdRequired": "请输入客户端ID",
"clientIdPlaceholder": "请输入客户端ID",
"clientSecret": "客户端密钥",
"clientSecretRequired": "请输入客户端密钥",
"clientSecretPlaceholder": "请输入客户端密钥",
"redirectUri": "重定向URI",
"redirectUriRequired": "请输入重定向URI",
"redirectUriPlaceholder": "请输入重定向URI",
"autoRegister": "自动注册",
"ableState": "启用状态",
"confirm": "确认",
"cancel": "取消",
"noData": "无数据",
"scopes": "权限范围",
"scopesPlaceholder": "请选择或输入权限范围",
"refreshEndpoint": "刷新端点",
"refreshEndpointPlaceholder": "请输入刷新端点URL"
}

View File

@ -0,0 +1,7 @@
.id {
font-size: 18px;
}
.item {
font-size: 18px;
}

View File

@ -0,0 +1,208 @@
import React, { useEffect } from 'react';
import { Form, Input, Switch, Button, Space, Upload, Select } from 'antd';
import { UploadOutlined } from '@ant-design/icons';
import { RowWithActions, WebComponentProps } from 'oak-frontend-base';
import Styles from './styles.module.less';
import { EntityDict } from '../../../../../oak-app-domain';
const Upsert = (
props: WebComponentProps<
EntityDict,
'oauthProvider',
false,
{
item: RowWithActions<EntityDict, 'oauthProvider'>;
}
>
) => {
const { item } = props.data;
const { t, update } = props.methods;
if (item === undefined) {
return <div>{t('noData')}</div>;
}
return (
<div className={Styles.id}>
<Form
layout="vertical"
autoComplete="off"
>
<Form.Item
label={t('name')}
rules={[{ required: true, message: t('nameRequired') }]}
>
<Input placeholder={t('namePlaceholder')} value={item.name || ""}
onChange={(v) => {
update({ name: v.target.value });
}}
/>
</Form.Item>
<Form.Item
label={t('type')}
rules={[{ required: true, message: t('typeRequired') }]}
>
<Select placeholder={t('typePlaceholder')} value={item.type || ""}
onChange={(v) => {
update({ type: v as "oak" | "gitea" | "github" | "google" | "facebook" | "twitter" | "linkedin" | "custom" | "gitlab" | "microsoft" | "apple" | "tencent" | "weixin" | "weibo" | "dingtalk" | null | undefined });
}}
>
<Select.Option value="oak">Oak</Select.Option>
<Select.Option value="gitea">Gitea</Select.Option>
<Select.Option value="github">GitHub</Select.Option>
<Select.Option value="google">Google</Select.Option>
<Select.Option value="facebook">Facebook</Select.Option>
<Select.Option value="twitter">Twitter</Select.Option>
<Select.Option value="linkedin">LinkedIn</Select.Option>
<Select.Option value="custom">Custom</Select.Option>
<Select.Option value="gitlab">GitLab</Select.Option>
<Select.Option value="microsoft">Microsoft</Select.Option>
<Select.Option value="apple">Apple</Select.Option>
<Select.Option value="tencent">Tencent</Select.Option>
<Select.Option value="weixin">Weixin</Select.Option>
<Select.Option value="weibo">Weibo</Select.Option>
<Select.Option value="dingtalk">DingTalk</Select.Option>
</Select>
</Form.Item>
<Form.Item
label={t('logo')}
>
<Input placeholder={t('logoPlaceholder')} value={item.logo || ""}
onChange={(v) => {
update({ logo: v.target.value });
}}
/>
</Form.Item>
<Form.Item
label={t('authorizationEndpoint')}
rules={[{ required: true, message: t('authorizationEndpointRequired') }]}
>
<Input placeholder={t('authorizationEndpointPlaceholder')} value={item.authorizationEndpoint || ""}
onChange={(v) => {
update({ authorizationEndpoint: v.target.value });
}}
/>
</Form.Item>
<Form.Item
label={t('tokenEndpoint')}
rules={[{ required: true, message: t('tokenEndpointRequired') }]}
>
<Input placeholder={t('tokenEndpointPlaceholder')} value={item.tokenEndpoint || ""}
onChange={(v) => {
update({ tokenEndpoint: v.target.value });
}}
/>
</Form.Item>
<Form.Item
label={t('refreshEndpoint')}
>
<Input placeholder={t('refreshEndpointPlaceholder')} value={item.refreshEndpoint || ""}
onChange={(v) => {
update({ refreshEndpoint: v.target.value });
}}
/>
</Form.Item>
<Form.Item
label={t('userInfoEndpoint')}
>
<Input placeholder={t('userInfoEndpointPlaceholder')} value={item.userInfoEndpoint || ""}
onChange={(v) => {
update({ userInfoEndpoint: v.target.value });
}}
/>
</Form.Item>
<Form.Item
label={t('revokeEndpoint')}
>
<Input placeholder={t('revokeEndpointPlaceholder')} value={item.revokeEndpoint || ""}
onChange={(v) => {
update({ revokeEndpoint: v.target.value });
}}
/>
</Form.Item>
<Form.Item
label={t('clientId')}
rules={[{ required: true, message: t('clientIdRequired') }]}
>
<Input placeholder={t('clientIdPlaceholder')} value={item.clientId || ""}
onChange={(v) => {
update({ clientId: v.target.value });
}}
/>
</Form.Item>
<Form.Item
label={t('clientSecret')}
rules={[{ required: true, message: t('clientSecretRequired') }]}
>
<Input.Password placeholder={t('clientSecretPlaceholder')} value={item.clientSecret || ""}
onChange={(v) => {
update({ clientSecret: v.target.value });
}}
/>
</Form.Item>
<Form.Item
label={t('scopes')}
>
<Select
mode="tags"
placeholder={t('scopesPlaceholder')}
value={item.scopes || []}
onChange={(v) => {
update({ scopes: v });
}}
tokenSeparators={[',']}
open={false}
/>
</Form.Item>
<Form.Item
label={t('redirectUri')}
rules={[{ required: true, message: t('redirectUriRequired') }]}
>
<Input placeholder={t('redirectUriPlaceholder')} value={item.redirectUri || ""}
onChange={(v) => {
update({ redirectUri: v.target.value });
}}
/>
</Form.Item>
<Form.Item
label={t('autoRegister')}
valuePropName="checked"
>
<Switch checked={!!item.autoRegister} onChange={(checked) => update({ autoRegister: checked })} />
</Form.Item>
<Form.Item
label={t('ableState')}
valuePropName="checked"
>
<Switch checked={!!item.ableState} onChange={(checked) => update({ ableState: checked ? "enabled" : "disabled" })} />
</Form.Item>
{/* <Form.Item>
<Space>
<Button type="primary" htmlType="submit">
{t('confirm')}
</Button>
<Button onClick={handleCancel}>
{t('cancel')}
</Button>
</Space>
</Form.Item> */}
</Form>
</div>
);
};
export default Upsert;

View File

@ -0,0 +1,97 @@
import React from 'react';
import { onActionFnDef, RowWithActions, WebComponentProps } from 'oak-frontend-base';
import Styles from './styles.module.less';
import PageHeader from 'oak-frontend-base/es/components/pageHeader';
import { Button, Modal } from 'antd';
import ProviderUpsert from "./upsert"
import { EntityDict } from '../../../../oak-app-domain';
import ListPro from 'oak-frontend-base/es/components/listPro';
const OauthProvider = (
props: WebComponentProps<
EntityDict,
'oauthProvider',
true,
{
list: RowWithActions<EntityDict, 'oauthProvider'>[];
systemId: string;
}
>
) => {
const { oakFullpath, systemId } = props.data;
const { list, oakLoading } = props.data;
const { t, addItem, removeItem, execute, clean } = props.methods;
const attrs = [
"id", "name", "logo", "authorizationEndpoint",
"tokenEndpoint", "userInfoEndpoint", "clientId",
"clientSecret", "redirectUri",
"autoRegister", "ableState", "$$createAt$$"
]
const [upsertId, setUpsertId] = React.useState<string | null>(null);
const handleAction: onActionFnDef = (row, action: string) => {
switch (action) {
case "update": {
setUpsertId(row.id);
break;
}
case "remove": {
Modal.confirm({
title: t('confirm.deleteTitle'),
content: t('confirm.deleteContent'),
onOk: () => {
removeItem(row.id);
execute();
}
});
break;
}
}
}
return (
<>
{list && (
<ListPro
entity='oauthProvider'
attributes={attrs}
data={list}
loading={oakLoading}
oakPath={`${oakFullpath}`}
onAction={handleAction}
extraContent={
<div className={Styles.actions}>
<Button
type="primary"
onClick={() => {
setUpsertId(addItem({
systemId: systemId,
autoRegister: true,
}));
}}
>
{t('common::action.create')}
</Button>
</div>
}
>
</ListPro>
)}
{/* antd model */}
<Modal open={!!upsertId} destroyOnClose={true} width={600} onCancel={() => {
clean()
setUpsertId(null);
}} onOk={() => {
execute()
setUpsertId(null);
}}>
{upsertId && <ProviderUpsert oakPath={`${oakFullpath}.${upsertId}`} oakId={upsertId} />}
</Modal>
</>
);
};
export default OauthProvider;

View File

@ -0,0 +1,7 @@
.id {
font-size: 18px;
}
.item {
font-size: 18px;
}

View File

@ -0,0 +1,50 @@
import React from 'react';
import { RowWithActions, WebComponentProps } from 'oak-frontend-base';
import Styles from './styles.module.less';
import { EntityDict } from '../../../oak-app-domain';
import OauthApps from './oauthApps';
import OauthProvider from './oauthProvider';
import { Tabs } from 'antd';
const Management = (
props: WebComponentProps<
EntityDict,
keyof EntityDict,
false,
{
// virtual
systemId: string;
systemName: string;
}
>
) => {
const { oakFullpath, oakDirty } = props.data;
const { t, execute } = props.methods;
return <Tabs
items={[
{
key: '1',
label: t('providers'),
children: (
<OauthProvider
systemId={props.data.systemId}
oakPath={`${oakFullpath}.oauthProviders:list`}
></OauthProvider>
)
},
{
key: '2',
label: t('applications'),
children: (
<OauthApps
systemId={props.data.systemId}
oakPath={`${oakFullpath}.oauthApplications:list`}
></OauthApps>
)
}
]}
/>
};
export default Management;

View File

@ -0,0 +1,223 @@
// OAuth 回调页面样式
.oauthContainer {
display: flex;
align-items: center;
justify-content: center;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
.oauthCard {
background: white;
border-radius: 16px;
padding: 48px 40px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
text-align: center;
max-width: 480px;
width: 100%;
animation: slideUp 0.5s ease-out;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.iconWrapper {
margin-bottom: 24px;
display: flex;
justify-content: center;
}
.successIcon {
width: 80px;
height: 80px;
color: #52c41a;
animation: scaleIn 0.6s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
.errorIcon {
width: 80px;
height: 80px;
color: #ff4d4f;
animation: scaleIn 0.6s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
.successButton {
background-color: #52c41a;
color: white;
&:hover {
background-color: #73d13d;
}
}
.loadingIcon {
width: 80px;
height: 80px;
color: #1890ff;
animation: spin 1s linear infinite;
}
@keyframes scaleIn {
from {
transform: scale(0);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.title {
font-size: 28px;
font-weight: 600;
color: #1f2937;
margin: 0 0 16px 0;
animation: fadeIn 0.8s ease-out 0.2s both;
}
.successMessage {
font-size: 16px;
color: #52c41a;
margin: 0 0 12px 0;
line-height: 1.6;
font-weight: 500;
animation: fadeIn 0.8s ease-out 0.3s both;
}
.errorMessage {
font-size: 16px;
color: #ff4d4f;
margin: 0 0 24px 0;
line-height: 1.6;
font-weight: 500;
animation: fadeIn 0.8s ease-out 0.3s both;
}
.description {
font-size: 14px;
color: #6b7280;
margin: 0;
animation: fadeIn 0.8s ease-out 0.4s both;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.button {
margin-top: 24px;
padding: 12px 32px;
font-size: 16px;
font-weight: 500;
border: none;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s ease;
animation: fadeIn 0.8s ease-out 0.5s both;
&:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15);
}
&:active {
transform: translateY(0);
}
}
.errorButton {
background-color: #ff4d4f;
color: white;
&:hover {
background-color: #ff7875;
}
}
.buttonGroup {
display: flex;
gap: 12px;
margin-top: 24px;
justify-content: center;
animation: fadeIn 0.8s ease-out 0.5s both;
}
.confirmButton {
background-color: #1890ff;
color: white;
flex: 1;
max-width: 140px;
&:hover {
background-color: #40a9ff;
}
}
.cancelButton {
background-color: #fff;
color: #595959;
border: 1px solid #d9d9d9;
flex: 1;
max-width: 140px;
&:hover {
color: #1890ff;
border-color: #1890ff;
}
}
// 响应式设计
@media (max-width: 640px) {
.oauthCard {
padding: 36px 24px;
}
.title {
font-size: 24px;
}
.successIcon,
.errorIcon {
width: 64px;
height: 64px;
}
.successMessage,
.errorMessage {
font-size: 14px;
}
}
// 保留原有的样式以防其他地方使用
.id {
font-size: 18px;
}
.item {
font-size: 18px;
}

View File

@ -0,0 +1,83 @@
import React from 'react';
import { RowWithActions, WebComponentProps } from 'oak-frontend-base';
import Styles from './styles.module.less';
import { EntityDict } from '../../oak-app-domain';
const Oauth = (
props: WebComponentProps<
EntityDict,
keyof EntityDict,
false,
{
// virtual
hasError: boolean;
errorMessage: string;
loading: boolean;
},
{
retry: () => void;
returnToIndex: () => void;
}
>
) => {
const { loading, hasError, errorMessage } = props.data;
const { t, retry, returnToIndex } = props.methods;
const tErrMsg = t(errorMessage);
return (
<div className={Styles.oauthContainer}>
<div className={Styles.oauthCard}>
{loading ? (
<>
<div className={Styles.iconWrapper}>
<svg className={Styles.loadingIcon} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" strokeOpacity="0.2" />
<path d="M12 2C6.477 2 2 6.477 2 12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
</div>
<h2 className={Styles.title}>{t('oauth.loading.title')}</h2>
<p className={Styles.description}>
{t('oauth.loadingMessage')}
</p>
</>
) : hasError ? (
<>
<div className={Styles.iconWrapper}>
<svg className={Styles.errorIcon} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" />
<path d="M15 9L9 15M9 9L15 15" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
</div>
<h2 className={Styles.title}>{t('oauth.error.title')}</h2>
<p className={Styles.errorMessage}>{tErrMsg}</p>
<button
className={`${Styles.button} ${Styles.errorButton}`}
onClick={() => retry()}
>
{t('oauth.close')}
</button>
</>
) : (
<>
<div className={Styles.iconWrapper}>
<svg className={Styles.successIcon} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" />
<path d="M8 12L11 15L16 9" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
<h2 className={Styles.title}>{t('oauth.success.title')}</h2>
<p className={Styles.successMessage}>{t('oauth.successMessage')}</p>
<button
className={`${Styles.button} ${Styles.successButton}`}
onClick={() => returnToIndex()}
>
{t('oauth.return')}
</button>
</>
)}
</div>
</div>
);
};
export default Oauth;

View File

@ -5,5 +5,6 @@
"style": "样式管理",
"application-list": "应用管理",
"smsTemplate-list": "短信模板管理",
"login": "登录管理"
"login": "登录管理",
"oauth": "OAuth管理"
}

View File

@ -12,6 +12,7 @@ import { EntityDict } from '../../../oak-app-domain';
import { Config } from '../../../types/Config';
import { Style } from '../../../types/Style';
import Styles from './web.pc.module.less';
import OAuthManagement from '../../oauth/management';
export default function Render(props: WebComponentProps<EntityDict, 'system', false, {
id: string;
@ -135,6 +136,22 @@ export default function Render(props: WebComponentProps<EntityDict, 'system', fa
/>
),
},
{
label: (
<div className={Styles.tabLabel}>
{t('oauth')}
</div>
),
key: 'oauth-manage',
destroyInactiveTabPane: true,
children: (
<OAuthManagement
oakPath={`$system-oauth-${id}`}
systemId={id}
systemName={name}
/>
),
},
]}
/>
</div>