This commit is contained in:
Xu Chang 2025-11-04 14:11:18 +08:00
commit 5f33319f75
701 changed files with 22412 additions and 884 deletions

View File

@ -7,66 +7,157 @@ import { MaterialType } from '../types/WeChat';
import { BackendRuntimeContext } from '../context/BackendRuntimeContext';
import { WechatPublicEventData, WechatMpEventData } from 'oak-external-sdk';
export type AspectDict<ED extends EntityDict> = {
/**
* 使 token Web
* @param mpToken token
* @param env Web
* @returns Web token
*/
loginWebByMpToken: (params: {
mpToken: string;
env: WebEnv;
}, context: BackendRuntimeContext<ED>) => Promise<string>;
/**
*
* @param from ID
* @param to ID
*/
mergeUser: (params: {
from: string;
to: string;
}, context: BackendRuntimeContext<ED>) => Promise<void>;
/**
*
*/
refreshWechatPublicUserInfo: (params: {}, context: BackendRuntimeContext<ED>) => Promise<void>;
/**
*
* @param code code
* @param env
* @returns
*/
getWechatMpUserPhoneNumber: (params: {
code: string;
env: WechatMpEnv;
}, context: BackendRuntimeContext<ED>) => Promise<string>;
/**
*
* @param mobile
* @param captcha
* @param env
*/
bindByMobile: (params: {
mobile: string;
captcha: string;
env: WebEnv | WechatMpEnv | NativeEnv;
}, context: BackendRuntimeContext<ED>) => Promise<void>;
/**
*
* @param email
* @param captcha
* @param env
*/
bindByEmail: (params: {
email: string;
captcha: string;
env: WebEnv | WechatMpEnv | NativeEnv;
}, context: BackendRuntimeContext<ED>) => Promise<void>;
/**
*
* @param mobile
* @param captcha
* @param disableRegister true
* @param env
* @returns token
*/
loginByMobile: (params: {
mobile: string;
captcha: string;
disableRegister?: boolean;
env: WebEnv | WechatMpEnv | NativeEnv;
}, context: BackendRuntimeContext<ED>) => Promise<string>;
/**
*
* @param password SHA1
* @param env
*/
verifyPassword: (params: {
password: string;
env: WebEnv | WechatMpEnv | NativeEnv;
}, context: BackendRuntimeContext<ED>) => Promise<void>;
/**
* //
* @param account
* @param password SHA1
* @param env
* @returns token
*/
loginByAccount: (params: {
account: string;
password: string;
env: WebEnv | WechatMpEnv | NativeEnv;
}, context: BackendRuntimeContext<ED>) => Promise<string>;
/**
*
* @param email
* @param captcha
* @param disableRegister true
* @param env
* @returns token
*/
loginByEmail: (params: {
email: string;
captcha: string;
disableRegister?: boolean;
env: WebEnv | WechatMpEnv | NativeEnv;
}, context: BackendRuntimeContext<ED>) => Promise<string>;
/**
*
* @param code code
* @param env Web
* @param wechatLoginId ID
* @returns token
*/
loginWechat: ({ code, env, wechatLoginId, }: {
code: string;
env: WebEnv;
wechatLoginId?: string;
}, context: BackendRuntimeContext<ED>) => Promise<string>;
/**
* 使 token
* @param tokenValue token
*/
logout: (params: {
tokenValue: string;
}, context: BackendRuntimeContext<ED>) => Promise<void>;
/**
*
* @param code code
* @param env
* @returns token
*/
loginWechatMp: ({ code, env, }: {
code: string;
env: WechatMpEnv;
}, context: BackendRuntimeContext<ED>) => Promise<string>;
/**
* APP
* @param code code
* @param env APP
* @returns token
*/
loginWechatNative: ({ code, env, }: {
code: string;
env: NativeEnv;
}, context: BackendRuntimeContext<ED>) => Promise<string>;
/**
*
* @param nickname
* @param avatarUrl URL
* @param encryptedData
* @param iv
* @param signature
*/
syncUserInfoWechatMp: ({ nickname, avatarUrl, encryptedData, iv, signature, }: {
nickname: string;
avatarUrl: string;
@ -74,25 +165,61 @@ export type AspectDict<ED extends EntityDict> = {
iv: string;
signature: string;
}, context: BackendRuntimeContext<ED>) => Promise<void>;
/**
* shadow
* @param id ID
* @param env
* @returns token
*/
wakeupParasite: (params: {
id: string;
env: WebEnv | WechatMpEnv;
}, context: BackendRuntimeContext<ED>) => Promise<string>;
/**
* token
* @param tokenValue token
* @param env
* @param applicationId ID
* @returns token
*/
refreshToken: (params: {
tokenValue: string;
env: WebEnv | WechatMpEnv | NativeEnv;
applicationId: string;
}, context: BackendRuntimeContext<ED>) => Promise<string>;
/**
*
* @param mobile
* @param env
* @param type login-changePassword-confirm-
* @returns ID
*/
sendCaptchaByMobile: (params: {
mobile: string;
env: WechatMpEnv | WebEnv;
type: 'login' | 'changePassword' | 'confirm';
}, context: BackendRuntimeContext<ED>) => Promise<string>;
/**
*
* @param email
* @param env
* @param type login-changePassword-confirm-
* @returns ID
*/
sendCaptchaByEmail: (params: {
email: string;
env: WechatMpEnv | WebEnv;
type: 'login' | 'changePassword' | 'confirm';
}, context: BackendRuntimeContext<ED>) => Promise<string>;
/**
*
* @param version
* @param type web/wechatMp/wechatPublic/native
* @param domain
* @param data
* @param appId ID
* @returns ID
*/
getApplication: (params: {
version: string;
type: AppType;
@ -100,6 +227,12 @@ export type AspectDict<ED extends EntityDict> = {
data: ED['application']['Projection'];
appId?: string;
}, context: BackendRuntimeContext<ED>) => Promise<string>;
/**
* JS-SDK JS
* @param url URL
* @param env Web
* @returns signaturenoncestrtimestampappId
*/
signatureJsSDK: (params: {
url: string;
env: WebEnv;
@ -109,38 +242,88 @@ export type AspectDict<ED extends EntityDict> = {
timestamp: number;
appId: string;
}>;
/**
*
* @param entity platform system
* @param entityId ID
* @param config
*/
updateConfig: (params: {
entity: 'platform' | 'system';
entityId: string;
config: Config;
}, context: BackendRuntimeContext<ED>) => Promise<void>;
/**
*
* @param entity platform/system/application
* @param entityId ID
* @param style
*/
updateStyle: (params: {
entity: 'platform' | 'system' | 'application';
entityId: string;
style: Style;
}, context: BackendRuntimeContext<ED>) => Promise<void>;
/**
*
* @param entity application
* @param entityId ID
* @param config
*/
updateApplicationConfig: (params: {
entity: 'application';
entityId: string;
config: EntityDict['application']['Schema']['config'];
}, context: BackendRuntimeContext<ED>) => Promise<void>;
/**
*
* @param userId ID
*/
switchTo: (params: {
userId: string;
}, context: BackendRuntimeContext<ED>) => Promise<void>;
/**
*
* @param wechatQrCodeId ID
* @returns Base64
*/
getMpUnlimitWxaCode: (wechatQrCodeId: string, context: BackendRuntimeContext<ED>) => Promise<string>;
/**
*
* @param type login-bind-
* @param interval
* @returns ID
*/
createWechatLogin: (params: {
type: EntityDict['wechatLogin']['Schema']['type'];
interval: number;
}, context: BackendRuntimeContext<ED>) => Promise<string>;
/**
*
* @param wechatUserId ID
* @param captcha
* @param mobile
*/
unbindingWechat: (params: {
wechatUserId: string;
captcha?: string;
mobile?: string;
}, context: BackendRuntimeContext<ED>) => Promise<void>;
/**
* ID Web
* @param wechatLoginId ID
* @param env Web
* @returns token
*/
loginByWechat: (params: {
wechatLoginId: string;
env: WebEnv;
}, context: BackendRuntimeContext<ED>) => Promise<string>;
/**
* URL
* @param url URL
* @returns
*/
getInfoByUrl: (params: {
url: string;
}) => Promise<{
@ -148,9 +331,23 @@ export type AspectDict<ED extends EntityDict> = {
publishDate: number | undefined;
imageList: string[];
}>;
/**
*
* @param userId ID
* @returns mobile-password-
*/
getChangePasswordChannels: (params: {
userId: string;
}, context: BackendRuntimeContext<ED>) => Promise<string[]>;
/**
*
* @param userId ID
* @param prevPassword 使
* @param mobile 使
* @param captcha 使
* @param newPassword
* @returns
*/
updateUserPassword: (params: {
userId: string;
prevPassword?: string;
@ -161,136 +358,374 @@ export type AspectDict<ED extends EntityDict> = {
result: string;
times?: number;
}>;
/**
*
* @param data
* @param type
* @param entity
* @param entityId ID
* @returns ID
*/
createSession: (params: {
data?: WechatPublicEventData | WechatMpEventData;
type: AppType;
entity?: string;
entityId?: string;
}, context: BackendRuntimeContext<ED>) => Promise<string>;
/**
*
* @param params ID
* @returns mediaId
*/
uploadWechatMedia: (params: any, context: BackendRuntimeContext<ED>) => Promise<{
mediaId: string;
}>;
/**
* 使
* @param applicationId ID
* @returns
*/
getCurrentMenu: (params: {
applicationId: string;
}, context: BackendRuntimeContext<ED>) => Promise<any>;
/**
*
* @param applicationId ID
* @returns
*/
getMenu: (params: {
applicationId: string;
}, context: BackendRuntimeContext<ED>) => Promise<any>;
/**
*
* @param applicationId ID
* @param menuConfig
* @param id ID
*/
createMenu: (params: {
applicationId: string;
menuConfig: any;
id: string;
}, context: BackendRuntimeContext<ED>) => Promise<any>;
/**
*
* @param applicationId ID
* @param menuConfig
* @param id ID
*/
createConditionalMenu: (params: {
applicationId: string;
menuConfig: any;
id: string;
}, context: BackendRuntimeContext<ED>) => Promise<any>;
/**
*
* @param applicationId ID
* @param menuId ID
*/
deleteConditionalMenu: (params: {
applicationId: string;
menuId: number;
}, context: BackendRuntimeContext<ED>) => Promise<any>;
/**
*
* @param applicationId ID
*/
deleteMenu: (params: {
applicationId: string;
}, context: BackendRuntimeContext<ED>) => Promise<any>;
/**
*
* @param applicationId ID
* @param offset
* @param count
* @param noContent 0-1-
* @returns
*/
batchGetArticle: (params: {
applicationId: string;
offset?: number;
count: number;
noContent?: 0 | 1;
}, context: BackendRuntimeContext<ED>) => Promise<any>;
/**
*
* @param applicationId ID
* @param articleId ID
* @returns
*/
getArticle: (params: {
applicationId: string;
articleId: string;
}, context: BackendRuntimeContext<ED>) => Promise<any>;
/**
*
* @param applicationId ID
* @param type image-voice-video-news-
* @param offset
* @param count
* @returns
*/
batchGetMaterialList: (params: {
applicationId: string;
type: MaterialType;
offset?: number;
count: number;
}, context: BackendRuntimeContext<ED>) => Promise<any>;
/**
*
* @param applicationId ID
* @param mediaId ID
* @param isPermanent
* @returns
*/
getMaterial: (params: {
applicationId: string;
mediaId: string;
isPermanent?: boolean;
}, context: BackendRuntimeContext<ED>) => Promise<any>;
/**
*
* @param applicationId ID
* @param mediaId ID
*/
deleteMaterial: (params: {
applicationId: string;
mediaId: string;
}, context: BackendRuntimeContext<ED>) => Promise<any>;
/**
*
* @param applicationId ID
* @param name
* @returns
*/
createTag: (params: {
applicationId: string;
name: string;
}, context: BackendRuntimeContext<ED>) => Promise<any>;
/**
*
* @param applicationId ID
* @returns
*/
getTags: (params: {
applicationId: string;
}, context: BackendRuntimeContext<ED>) => Promise<any>;
/**
*
* @param applicationId ID
* @param id ID
* @param name
*/
editTag: (params: {
applicationId: string;
id: number;
name: string;
}, context: BackendRuntimeContext<ED>) => Promise<any>;
/**
*
* @param applicationId ID
* @param id ID
* @param wechatId ID
*/
deleteTag: (params: {
applicationId: string;
id: string;
wechatId: number;
}, context: BackendRuntimeContext<ED>) => Promise<any>;
/**
*
* @param applicationId ID
* @returns
*/
syncMessageTemplate: (params: {
applicationId: string;
}, context: BackendRuntimeContext<ED>) => Promise<any>;
/**
*
* @returns
*/
getMessageType: (params: {}, content: BackendRuntimeContext<ED>) => Promise<string[]>;
/**
*
* @param applicationId ID
* @param id ID
*/
syncTag: (params: {
applicationId: string;
id: string;
}, context: BackendRuntimeContext<ED>) => Promise<any>;
/**
*
* @param applicationId ID
*/
oneKeySync: (params: {
applicationId: string;
}, context: BackendRuntimeContext<ED>) => Promise<any>;
/**
*
* @param applicationId ID
* @param tagId ID
* @returns
*/
getTagUsers: (params: {
applicationId: string;
tagId: number;
}, context: BackendRuntimeContext<ED>) => Promise<any>;
/**
*
* @param applicationId ID
* @param openIdList openId
* @param tagId ID
*/
batchtagging: (params: {
applicationId: string;
openIdList: string[];
tagId: number;
}, context: BackendRuntimeContext<ED>) => Promise<any>;
/**
*
* @param applicationId ID
* @param openIdList openId
* @param tagId ID
*/
batchuntagging: (params: {
applicationId: string;
openIdList: string[];
tagId: number;
}, context: BackendRuntimeContext<ED>) => Promise<any>;
/**
*
* @param applicationId ID
* @param openId openId
* @returns ID
*/
getUserTags: (params: {
applicationId: string;
openId: string;
}, context: BackendRuntimeContext<ED>) => Promise<any>;
/**
*
* @param applicationId ID
* @param nextOpenId openId
* @returns
*/
getUsers: (params: {
applicationId: string;
nextOpenId: string;
}, context: BackendRuntimeContext<ED>) => Promise<any>;
/**
*
* @param applicationId ID
* @param openId openId
* @param tagIdList ID
*/
tagging: (params: {
applicationId: string;
openId: string;
tagIdList: number[];
}, context: BackendRuntimeContext<ED>) => Promise<any>;
/**
*
* @param applicationId ID
* @param openId openId
*/
syncToLocale: (params: {
applicationId: string;
openId: string;
}, context: BackendRuntimeContext<ED>) => Promise<any>;
/**
*
* @param applicationId ID
* @param id ID
* @param openId openId
*/
syncToWechat: (params: {
applicationId: string;
id: string;
openId: string;
}, context: BackendRuntimeContext<ED>) => Promise<any>;
/**
*
* @param systemId ID
* @param origin
*/
syncSmsTemplate: (params: {
systemId: string;
origin: EntityDict['smsTemplate']['Schema']['origin'];
}, context: BackendRuntimeContext<ED>) => Promise<void>;
/**
*
* @param applicationId ID
* @returns
*/
getApplicationPassports: (params: {
applicationId: string;
}, context: BackendRuntimeContext<ED>) => Promise<EntityDict['applicationPassport']['Schema'][]>;
/**
* ID
* @param passportIds ID
*/
removeApplicationPassportsByPIds: (params: {
passportIds: string[];
}, content: BackendRuntimeContext<ED>) => Promise<void>;
/**
* OAuth 2.0
* @param code OAuth
* @param state
* @param env
* @returns token
*/
loginByOauth: (params: {
code: string;
state: string;
env: WebEnv | WechatMpEnv | NativeEnv;
}, context: BackendRuntimeContext<ED>) => Promise<string>;
/**
* OAuth /
* @param providerId OAuth ID
* @param userId ID
* @param type bind-login-
* @returns
*/
createOAuthState: (params: {
providerId: string;
userId?: string;
type: "bind" | "login";
}, context: BackendRuntimeContext<ED>) => Promise<string>;
/**
* OAuth 2.0
* @param response_type "code"
* @param client_id ID
* @param redirect_uri
* @param scope
* @param state
* @param action grant-deny-
* @returns URL
*/
authorize: (params: {
response_type: string;
client_id: string;
redirect_uri: string;
scope: string;
state: string;
action: "grant" | "deny";
code_challenge?: string;
code_challenge_method?: 'plain' | 'S256';
}, context: BackendRuntimeContext<ED>) => Promise<{
redirectUri: string;
}>;
/**
* OAuth
* @param client_id ID
* @returns null
*/
getOAuthClientInfo: (params: {
client_id: string;
currentUserId?: string;
}, context: BackendRuntimeContext<ED>) => Promise<{
data: EntityDict['oauthApplication']['Schema'] | null;
alreadyAuth: boolean;
}>;
};
export default AspectDict;

View File

@ -14,6 +14,7 @@ import { createTag, getTags, editTag, deleteTag, syncTag, oneKeySync } from './w
import { getTagUsers, batchtagging, batchuntagging, getUserTags, getUsers, tagging, syncToLocale, syncToWechat } from './userWechatPublicTag';
import { wechatMpJump } from './wechatMpJump';
import { getApplicationPassports, removeApplicationPassportsByPIds } from './applicationPassport';
import { authorize, createOAuthState, getOAuthClientInfo, loginByOauth } from './oauth';
declare const aspectDict: {
bindByEmail: typeof bindByEmail;
bindByMobile: typeof bindByMobile;
@ -80,6 +81,10 @@ declare const aspectDict: {
removeApplicationPassportsByPIds: typeof removeApplicationPassportsByPIds;
verifyPassword: typeof verifyPassword;
loginWebByMpToken: typeof loginWebByMpToken;
loginByOauth: typeof loginByOauth;
getOAuthClientInfo: typeof getOAuthClientInfo;
createOAuthState: typeof createOAuthState;
authorize: typeof authorize;
};
export default aspectDict;
export { AspectDict } from './AspectDict';

View File

@ -14,6 +14,7 @@ import { createTag, getTags, editTag, deleteTag, syncTag, oneKeySync, } from './
import { getTagUsers, batchtagging, batchuntagging, getUserTags, getUsers, tagging, syncToLocale, syncToWechat, } from './userWechatPublicTag';
import { wechatMpJump, } from './wechatMpJump';
import { getApplicationPassports, removeApplicationPassportsByPIds } from './applicationPassport';
import { authorize, createOAuthState, getOAuthClientInfo, loginByOauth } from './oauth';
const aspectDict = {
bindByEmail,
bindByMobile,
@ -80,5 +81,10 @@ const aspectDict = {
removeApplicationPassportsByPIds,
verifyPassword,
loginWebByMpToken,
// oauth
loginByOauth,
getOAuthClientInfo,
createOAuthState,
authorize,
};
export default aspectDict;

32
es/aspects/oauth.d.ts vendored Normal file
View File

@ -0,0 +1,32 @@
import { BRC } from "../types/RuntimeCxt";
import { EntityDict } from "../oak-app-domain";
import { NativeEnv, WebEnv, WechatMpEnv } from "oak-domain/lib/types";
export declare function loginByOauth<ED extends EntityDict>(params: {
code: string;
state: string;
env: WebEnv | WechatMpEnv | NativeEnv;
}, context: BRC<ED>): Promise<string>;
export declare function getOAuthClientInfo<ED extends EntityDict>(params: {
client_id: string;
currentUserId?: string;
}, context: BRC<ED>): Promise<{
data: Partial<ED["oauthApplication"]["Schema"]>;
alreadyAuth: boolean;
}>;
export declare function createOAuthState<ED extends EntityDict>(params: {
providerId: string;
userId?: string;
type: 'login' | 'bind';
}, context: BRC<ED>): Promise<string>;
export declare function authorize<ED extends EntityDict>(params: {
response_type: string;
client_id: string;
redirect_uri: string;
scope?: string;
state?: string;
action: 'grant' | 'deny';
code_challenge?: string;
code_challenge_method?: 'plain' | 'S256';
}, context: BRC<ED>): Promise<{
redirectUri: string;
}>;

423
es/aspects/oauth.js Normal file
View File

@ -0,0 +1,423 @@
import assert from "assert";
import { OakUserException } from "oak-domain/lib/types";
import { generateNewIdAsync } from "oak-domain/lib/utils/uuid";
import { loadTokenInfo, setUpTokenAndUser } from "./token";
import { randomUUID } from "crypto";
import { processUserInfo } from "../utils/oauth";
export async function loginByOauth(params, context) {
const { code, state: stateCode, env } = params;
const closeRootMode = context.openRootMode();
const currentUserId = context.getCurrentUserId(true);
const applicationId = context.getApplicationId();
const islogginedIn = !!currentUserId;
assert(applicationId, '无法获取当前应用ID');
assert(code, 'code 参数缺失');
assert(stateCode, 'state 参数缺失');
// 验证 state 并获取 OAuth 配置
const [state] = await context.select("oauthState", {
data: {
provider: {
type: 1,
clientId: 1,
redirectUri: 1,
clientSecret: 1,
tokenEndpoint: 1,
userInfoEndpoint: 1,
ableState: 1,
autoRegister: 1,
},
usedAt: 1,
},
filter: {
state: stateCode,
},
}, { dontCollect: true });
assert(state, '无效的 state 参数');
assert(state.provider?.ableState && state.provider?.ableState === 'enabled', '该 OAuth 提供商已被禁用');
// 如果已经使用
if (state.usedAt) {
throw new OakUserException('该授权请求已被使用,请重新发起授权请求');
}
// 更新为使用过
await context.operate("oauthState", {
id: await generateNewIdAsync(),
action: 'update',
data: {
usedAt: Date.now(),
},
filter: {
id: state.id,
}
}, { dontCollect: true });
// 使用 code 换取 access_token 并获取用户信息
const { oauthUserInfo, accessToken, refreshToken, accessTokenExp, refreshTokenExp } = await fetchOAuthUserInfo(code, state.provider);
const [existingOAuthUser] = await context.select("oauthUser", {
data: {
id: 1,
userId: 1,
providerUserId: 1,
user: {
id: 1,
userState: 1,
refId: 1,
ref: {
id: 1,
userState: 1,
}
}
},
filter: {
providerUserId: oauthUserInfo.providerUserId,
providerConfigId: state.providerId,
}
}, { dontCollect: true });
// 已登录的情况
if (islogginedIn) {
// 检查当前用户是否已绑定此提供商
const [currentUserBinding] = await context.select("oauthUser", {
data: {
id: 1,
},
filter: {
userId: currentUserId,
providerConfigId: state.providerId,
}
}, {});
if (currentUserBinding) {
throw new OakUserException('当前用户已绑定该 OAuth 平台账号');
}
if (existingOAuthUser) {
throw new OakUserException('该 OAuth 账号已被其他用户绑定');
}
console.log("绑定 OAuth 账号到当前用户:", currentUserId, oauthUserInfo.providerUserId);
// 创建绑定关系
await context.operate("oauthUser", {
id: await generateNewIdAsync(),
action: 'create',
data: {
id: await generateNewIdAsync(),
userId: currentUserId,
providerConfigId: state.providerId,
providerUserId: oauthUserInfo.providerUserId,
rawUserInfo: oauthUserInfo.rawData,
accessToken,
accessExpiresAt: accessTokenExp,
refreshToken,
refreshExpiresAt: refreshTokenExp,
applicationId,
stateId: state.id,
}
}, { dontCollect: true });
// 返回当前 token
const tokenValue = context.getTokenValue();
await loadTokenInfo(tokenValue, context);
closeRootMode();
return tokenValue;
// 未登录OAuth账号已存在直接登录
}
else if (existingOAuthUser) {
console.log("使用已绑定的 OAuth 账号登录:", existingOAuthUser.id);
const { user } = existingOAuthUser;
const targetUser = user?.userState === 'merged' ? user.ref : user;
const tokenValue = await setUpTokenAndUser(env, context, 'oauthUser', existingOAuthUser.id, // 使用已存在的 oauthUser ID
undefined, targetUser // 关联的用户
);
// 更新登录信息
await context.operate("oauthUser", {
id: await generateNewIdAsync(),
action: 'update',
data: {
rawUserInfo: oauthUserInfo.rawData,
accessToken,
accessExpiresAt: accessTokenExp,
refreshToken,
refreshExpiresAt: refreshTokenExp,
applicationId,
stateId: state.id,
},
filter: {
id: existingOAuthUser.id,
}
}, { dontCollect: true });
await loadTokenInfo(tokenValue, context);
closeRootMode();
return tokenValue;
}
// 未登录OAuth账号不存在创建新用户
else {
if (!state.provider.autoRegister) {
throw new OakUserException('您还没有账号,请先注册一个账号');
}
console.log("使用未绑定的 OAuth 账号登录:", oauthUserInfo.providerUserId);
const newUserId = await generateNewIdAsync();
const oauthUserCreateData = {
id: newUserId,
providerConfigId: state.providerId,
providerUserId: oauthUserInfo.providerUserId,
rawUserInfo: oauthUserInfo.rawData,
accessToken,
accessExpiresAt: accessTokenExp,
refreshToken,
refreshExpiresAt: refreshTokenExp,
applicationId,
stateId: state.id,
loadState: 'unload'
};
// 不传 user 参数,会自动创建新用户
const tokenValue = await setUpTokenAndUser(env, context, 'oauthUser', undefined, oauthUserCreateData, // 创建新的 oauthUser
undefined // 不传 user自动创建新用户
);
await context.operate("oauthUser", {
id: await generateNewIdAsync(),
action: 'loadUserInfo',
data: {},
filter: {
id: newUserId,
}
}, { dontCollect: true });
await loadTokenInfo(tokenValue, context);
closeRootMode();
return tokenValue;
}
}
export async function getOAuthClientInfo(params, context) {
const { client_id, currentUserId } = params;
const closeRootMode = context.openRootMode();
const systemId = context.getSystemId();
const applicationId = context.getApplicationId();
const [oauthApp] = await context.select("oauthApplication", {
data: {
id: 1,
name: 1,
redirectUris: 1,
description: 1,
logo: 1,
isConfidential: 1,
},
filter: {
id: client_id,
systemId: systemId,
ableState: "enabled",
}
}, { dontCollect: true });
// 如果还有正在生效的授权,说明已经授权过了
const [hasAuth] = await context.select("oauthUserAuthorization", {
data: {
id: 1,
userId: 1,
applicationId: 1,
usageState: 1,
authorizedAt: 1,
},
filter: {
// 如果 已经授权过token并且 没有被撤销
tokenId: {
$exists: true
},
token: {
revokedAt: {
$exists: false
}
},
usageState: 'granted',
code: {
// 当前应用下的认证客户端
oauthApp: {
id: client_id
},
applicationId: applicationId,
userId: currentUserId,
}
}
}, { dontCollect: true });
if (hasAuth) {
console.log("用户已有授权记录:", currentUserId, hasAuth.id, hasAuth.userId, hasAuth.applicationId, hasAuth.usageState);
}
if (!oauthApp) {
throw new OakUserException('未经授权的客户端应用');
}
closeRootMode();
return {
data: oauthApp,
alreadyAuth: !!hasAuth,
};
}
export async function createOAuthState(params, context) {
const { providerId, userId, type } = params;
const closeRootMode = context.openRootMode();
const generateCode = () => {
return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
};
const state = generateCode();
await context.operate("oauthState", {
id: await generateNewIdAsync(),
action: 'create',
data: {
id: await generateNewIdAsync(),
providerId,
userId,
type,
state
}
}, { dontCollect: true });
closeRootMode();
return state;
}
export async function authorize(params, context) {
const { response_type, client_id, redirect_uri, scope, state, action, code_challenge, code_challenge_method } = params;
if (response_type !== 'code') {
throw new OakUserException('不支持的 response_type 类型');
}
const closeRootMode = context.openRootMode();
const systemId = context.getSystemId();
const [oauthApp] = await context.select("oauthApplication", {
data: {
id: 1,
redirectUris: 1,
isConfidential: 1,
},
filter: {
id: client_id,
systemId: systemId,
}
}, { dontCollect: true });
if (!oauthApp) {
throw new OakUserException('未经授权的客户端应用');
}
// 创建授权记录
const recordId = await generateNewIdAsync();
await context.operate("oauthUserAuthorization", {
id: await generateNewIdAsync(),
action: 'create',
data: {
id: recordId,
userId: context.getCurrentUserId(),
applicationId: oauthApp.id,
usageState: action === 'grant' ? 'unused' : 'denied',
authorizedAt: Date.now(),
}
}, { dontCollect: true });
if (action === 'deny') {
const params = new URLSearchParams();
params.set('error', 'access_denied');
params.set('error_description', '用户拒绝了授权请求');
if (state) {
params.set('state', state);
}
closeRootMode();
return {
redirectUri: `${redirect_uri}?${params.toString()}`,
};
}
if (action === 'grant') {
// 检查redirectUri 是否在注册的列表中
if (!oauthApp.redirectUris?.includes(redirect_uri)) {
console.log('不合法的重定向 URI:', redirect_uri, oauthApp.redirectUris);
throw new OakUserException('重定向 URI 不合法');
}
const code = randomUUID();
const codeId = await generateNewIdAsync();
// 存储授权码
await context.operate("oauthAuthorizationCode", {
id: await generateNewIdAsync(),
action: 'create',
data: {
id: codeId,
code,
redirectUri: redirect_uri,
oauthAppId: oauthApp.id,
applicationId: context.getApplicationId(),
userId: context.getCurrentUserId(),
scope: scope === undefined ? [] : [scope],
expiresAt: Date.now() + 10 * 60 * 1000,
// PKCE 支持
codeChallenge: code_challenge,
codeChallengeMethod: code_challenge_method || 'plain',
}
}, { dontCollect: true });
// 更新记录
await context.operate("oauthUserAuthorization", {
id: await generateNewIdAsync(),
action: 'update',
data: {
codeId: codeId,
},
filter: {
id: recordId,
}
}, {});
const params = new URLSearchParams();
params.set('code', code);
if (state) {
params.set('state', state);
}
closeRootMode();
return {
redirectUri: `${redirect_uri}?${params.toString()}`,
};
}
closeRootMode();
throw new Error('unknown action');
}
const fetchOAuthUserInfo = async (code, providerConfig) => {
// 1. 使用 code 换取 access_token
const tokenResponse = await fetch(providerConfig.tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code: code,
client_id: providerConfig.clientId,
client_secret: providerConfig.clientSecret,
redirect_uri: providerConfig.redirectUri,
}),
});
if (!tokenResponse.ok) {
const errorjson = await tokenResponse.json();
if (errorjson.error == "unauthorized_client") {
throw new OakUserException(`授权校验已过期,请重新发起授权请求`);
}
else if (errorjson.error == "invalid_grant") {
throw new OakUserException(`授权码无效或已过期,请重新发起授权请求`);
}
else if (errorjson.error) {
throw new OakUserException(`获取访问令牌失败: ${errorjson.error_description || errorjson.error}`);
}
throw new OakUserException(`获取访问令牌失败: ${tokenResponse.statusText}`);
}
const tokenData = await tokenResponse.json();
const accessToken = tokenData.access_token;
const refreshToken = tokenData.refresh_token;
const accessTokenExp = tokenData.expires_in ? Date.now() + tokenData.expires_in * 1000 : undefined;
const refreshTokenExp = tokenData.refresh_expires_in ? Date.now() + tokenData.refresh_expires_in * 1000 : undefined;
const tokenType = tokenData.token_type;
assert(tokenType && tokenType.toLowerCase() === 'bearer', '不支持的令牌类型');
// 2. 使用 access_token 获取用户信息
const userInfoResponse = await fetch(providerConfig.userInfoEndpoint, {
method: 'GET',
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
if (!userInfoResponse.ok) {
throw new OakUserException(`获取用户信息失败: ${userInfoResponse.statusText}`);
}
const userInfoData = await userInfoResponse.json();
// TODO: 用户信息中获取唯一标识,通过注入解决: utils/oauth/index.ts
const { id: providerUserId } = await processUserInfo(providerConfig.type, userInfoData);
if (!providerUserId) {
throw new OakUserException('用户信息中缺少唯一标识符');
}
return {
oauthUserInfo: {
providerUserId,
rawData: userInfoData,
},
accessToken,
refreshToken,
accessTokenExp,
refreshTokenExp,
};
};

11
es/aspects/token.d.ts vendored
View File

@ -1,6 +1,17 @@
import { EntityDict } from '../oak-app-domain';
import { NativeEnv, WebEnv, WechatMpEnv } from 'oak-domain/lib/types/Environment';
import { BRC } from '../types/RuntimeCxt';
/**
* user的不同情况
* @param env
* @param context
* @param user
* @return tokenValue
*/
export declare function setUpTokenAndUser<ED extends EntityDict>(env: WebEnv | WechatMpEnv | NativeEnv, context: BRC<ED>, entity: string, // 支持更多的登录渠道使用此函数创建token
entityId?: string, // 如果是现有对象传id如果没有对象传createData
createData?: any, user?: Partial<ED['user']['Schema']>): Promise<string>;
export declare function loadTokenInfo<ED extends EntityDict>(tokenValue: string, context: BRC<ED>): Promise<Partial<ED["token"]["Schema"]>[]>;
export declare function loginByMobile<ED extends EntityDict>(params: {
mobile: string;
captcha?: string;

View File

@ -153,7 +153,7 @@ function autoMergeUser(context) {
* @param user
* @return tokenValue
*/
async function setUpTokenAndUser(env, context, entity, // 支持更多的登录渠道使用此函数创建token
export async function setUpTokenAndUser(env, context, entity, // 支持更多的登录渠道使用此函数创建token
entityId, // 如果是现有对象传id如果没有对象传createData
createData, user) {
const currentToken = context.getToken(true);
@ -431,7 +431,7 @@ async function setupMobile(mobile, env, context) {
});
}
}
async function loadTokenInfo(tokenValue, context) {
export async function loadTokenInfo(tokenValue, context) {
return await context.select('token', {
data: cloneDeep(tokenProjection),
filter: {
@ -566,12 +566,12 @@ export async function verifyPassword(params, context) {
]
};
}
else if (pwdFilter === 'plain') {
else if (pwdMode === 'plain') {
pwdFilter = {
password,
};
}
else if (pwdFilter === 'sha1') {
else if (pwdMode === 'sha1') {
pwdFilter = {
passwordSha1: password,
};
@ -909,11 +909,11 @@ export async function loginByAccount(params, context) {
passwordSha1: encryptPasswordSha1(password),
},
],
},
updateData = {
password,
passwordSha1: encryptPasswordSha1(password),
};
};
updateData = {
password,
passwordSha1: encryptPasswordSha1(password),
};
}
else if (pwdMode === 'plain') {
pwdFilter = {

View File

@ -0,0 +1,5 @@
import { NativeConfig, WebConfig, WechatMpConfig, WechatPublicConfig } from '../../../entities/Application';
export type AppConfig = WebConfig | WechatMpConfig | WechatPublicConfig | NativeConfig;
export type CosConfig = AppConfig['cos'];
declare const _default: any;
export default _default;

View File

@ -0,0 +1,111 @@
import { cloneDeep, set } from 'oak-domain/lib/utils/lodash';
import { generateNewId } from 'oak-domain/lib/utils/uuid';
import { isEmptyJsonObject } from '../../../utils/strings';
export default OakComponent({
isList: false,
properties: {
config: {},
entity: 'application',
entityId: '',
name: '',
},
data: {
initialConfig: {},
dirty: false,
currentConfig: {},
selections: [],
},
lifetimes: {
async ready() {
const { config } = this.props;
this.setState({
initialConfig: config,
dirty: false,
currentConfig: cloneDeep(config),
});
const systemId = this.features.application.getApplication().systemId;
const { data: [system] } = await this.features.cache.refresh("system", {
data: {
config: {
Cos: {
qiniu: 1,
ctyun: 1,
aliyun: 1,
tencent: 1,
local: 1,
s3: 1,
},
},
},
filter: {
id: systemId,
}
});
const cosConfig = system?.config?.Cos;
// 如果key存在并且不为defaultOrigin并且value的keys长度大于0则加入选择项
const selections = [];
if (cosConfig) {
for (const [key, value] of Object.entries(cosConfig)) {
if (key === 'defaultOrigin') {
continue;
}
if (value && !isEmptyJsonObject(value)) {
selections.push({
name: key,
value: key,
});
}
}
}
this.setState({
selections,
});
}
},
methods: {
setValue(path, value) {
const { currentConfig } = this.state;
const newConfig = cloneDeep(currentConfig || {});
set(newConfig, path, value);
this.setState({
currentConfig: newConfig,
dirty: true,
});
},
resetConfig() {
const { initialConfig } = this.state;
this.setState({
dirty: false,
currentConfig: cloneDeep(initialConfig),
});
},
async updateConfig() {
const { currentConfig } = this.state;
const { entity, entityId } = this.props;
if (!entityId) {
this.setMessage({
content: '缺少实体ID无法更新配置',
type: 'error',
});
return;
}
await this.features.cache.operate("application", {
id: generateNewId(),
action: 'update',
data: {
config: currentConfig,
},
filter: {
id: entityId,
}
}, {});
this.setMessage({
content: '操作成功',
type: 'success',
});
this.setState({
dirty: false,
});
},
},
});

View File

@ -0,0 +1,8 @@
{
"qiniu": "七牛云",
"ctyun": "天翼云",
"aliyun": "阿里云",
"tencent": "腾讯云",
"local": "本地存储",
"s3": "S3存储"
}

View File

@ -0,0 +1,5 @@
.contains {
margin-top: 20px;
}

View File

@ -0,0 +1,18 @@
import React from 'react';
import { WebComponentProps } from 'oak-frontend-base';
import { EntityDict } from '../../../oak-app-domain';
declare const Cos: (props: WebComponentProps<EntityDict, keyof EntityDict, false, {
currentConfig: EntityDict['application']['OpSchema']['config'];
dirty: boolean;
entity: string;
name: string;
selections: {
name: string;
value: string;
}[];
}, {
setValue: (path: string, value: any) => void;
resetConfig: () => void;
updateConfig: () => void;
}>) => React.JSX.Element;
export default Cos;

View File

@ -0,0 +1,46 @@
import React from 'react';
import Styles from './styles.module.less';
import { Affix, Alert, Button, Select, Space, Typography } from 'antd';
const Cos = (props) => {
const { currentConfig, dirty, entity, name, selections } = props.data;
const { t, setValue, resetConfig, updateConfig } = props.methods;
return (<>
<Affix offsetTop={64}>
<Alert message={<div>
<text>
您正在更新
<Typography.Text keyboard>
{entity}
</Typography.Text>
对象
<Typography.Text keyboard>
{name}
</Typography.Text>
的COS配置请谨慎操作
</text>
</div>} type="info" showIcon action={<Space>
<Button disabled={!dirty} type="primary" danger onClick={() => resetConfig()} style={{
marginRight: 10,
}}>
{t('common::reset')}
</Button>
<Button disabled={!dirty} type="primary" onClick={() => updateConfig()}>
{t('common::action.confirm')}
</Button>
</Space>}/>
</Affix>
<div className={Styles.contains}>
<Space direction="vertical" style={{ width: '100%' }}>
<Typography.Text strong>默认COS源</Typography.Text>
<Select value={currentConfig?.cos?.defaultOrigin} onChange={(v) => {
setValue('cos.defaultOrigin', v);
}} style={{ width: '100%' }} allowClear placeholder="请选择默认COS源">
{selections.map((item) => (<Select.Option key={item.value} value={item.value}>
{t(item.name)}
</Select.Option>))}
</Select>
</Space>
</div>
</>);
};
export default Cos;

View File

@ -1,3 +1,9 @@
/// <reference types="wechat-miniprogram" />
declare const _default: (props: import("oak-frontend-base").ReactComponentProps<import("../../../oak-app-domain").EntityDict, "application", false, WechatMiniprogram.Component.DataOption>) => React.ReactElement;
import React from 'react';
declare const _default: (props: import("oak-frontend-base").ReactComponentProps<import("../../../oak-app-domain").EntityDict, "application", false, {
tabs: {
label: React.ReactNode;
key: string;
children: React.ReactNode;
}[];
}>) => React.ReactElement;
export default _default;

View File

@ -16,4 +16,7 @@ export default OakComponent({
formData({ data }) {
return data || {};
},
properties: {
tabs: [],
},
});

View File

@ -6,5 +6,6 @@
"menu": "菜单管理",
"autoReply": "被关注回复管理",
"tag": "标签管理",
"user": "用户管理"
"user": "用户管理",
"cos": "COS配置"
}

View File

@ -8,4 +8,9 @@ export default function Render(props: WebComponentProps<EntityDict, 'application
name: string;
style: Style;
type: string;
tabs: {
label: React.ReactNode;
key: string;
children: React.ReactNode;
}[];
}>): React.JSX.Element | undefined;

View File

@ -9,15 +9,16 @@ import WechatMenu from '../../wechatMenu';
import UserWechatPublicTag from '../../userWechatPublicTag';
import WechatPublicTag from '../..//wechatPublicTag/list';
import WechatPublicAutoReply from '../../wechatPublicAutoReply';
import Cos from '../cos';
export default function Render(props) {
const { id, config, oakFullpath, name, style, type } = props.data;
const { id, config, oakFullpath, name, style, type, tabs } = props.data;
const { t, update } = props.methods;
const [tabKey, setTabKey] = useState('detail');
const items = [
{
label: <div className={Styles.tabLabel}>{t('detail')}</div>,
key: 'detail',
children: (<ApplicationDetail oakId={id} oakPath={oakFullpath}/>),
children: <ApplicationDetail oakId={id} oakPath={oakFullpath}/>,
},
{
label: <div className={Styles.tabLabel}>{t('config')}</div>,
@ -27,8 +28,15 @@ export default function Render(props) {
{
label: <div className={Styles.tabLabel}>{t('style')}</div>,
key: 'style',
children: (<StyleUpsert style={style} entity={'platform'} entityId={id} name={name}/>),
children: (<StyleUpsert style={style} entity={'application'} entityId={id} name={name}/>),
},
{
label: <div className={Styles.tabLabel}>{t('cos')}</div>,
key: 'cos',
children: (<Cos oakPath={`#application-panel-cos-${id}`} config={config} entity="application" entityId={id} name={name}>
</Cos>),
},
...(tabs || [])
];
if (type === 'wechatPublic') {
items.push({

View File

@ -5,7 +5,7 @@ declare const _default: (props: import("oak-frontend-base").ReactComponentProps<
tocPosition: "none" | "left" | "right";
highlightBgColor: string;
onArticlePreview: (content?: string, title?: string) => void;
origin: string;
origin: import("../../../types/Config").CosOrigin | null;
scrollId: string;
height: number | "auto";
activeColor: string | undefined;

View File

@ -29,7 +29,7 @@ export default OakComponent({
tocPosition: 'none',
highlightBgColor: 'none',
onArticlePreview: (content, title) => undefined,
origin: 'qiniu',
origin: null,
scrollId: '',
height: 600,
activeColor: undefined,

View File

@ -7,7 +7,7 @@ export default function Render(props: WebComponentProps<EntityDict, 'article', f
name: string;
editor: any;
content?: string;
origin?: string;
origin?: null | EntityDict['extraFile']['Schema']['origin'];
contentTip: boolean;
articleMenuId: string;
oakId: string;

View File

@ -31,7 +31,7 @@ function customCheckImageFn(src, alt, url) {
export default function Render(props) {
const { methods, data } = props;
const { t, setEditor, check, uploadFile, update, setHtml, gotoPreview, clearContentTip, } = methods;
const { oakId, oakFullpath, id, name, content, editor, origin = 'qiniu', tocPosition = 'none', highlightBgColor, scrollId, tocWidth, tocHeight, height = 600, tocClosed = false, execuable, activeColor, } = data;
const { oakId, oakFullpath, id, name, content, editor, origin, tocPosition = 'none', highlightBgColor, scrollId, tocWidth, tocHeight, height = 600, tocClosed = false, execuable, activeColor, } = data;
const [articleId, setArticleId] = useState('');
const [toc, setToc] = useState([]);
const [showToc, setShowToc] = useState(false);

View File

@ -5,7 +5,7 @@ declare const _default: (props: import("oak-frontend-base").ReactComponentProps<
tocPosition: "none" | "left" | "right";
highlightBgColor: string;
onArticlePreview: (content?: string, title?: string) => void;
origin: string;
origin: import("../../../types/Config").CosOrigin | null;
scrollId: string;
height: number | "auto";
activeColor: string | undefined;

View File

@ -28,7 +28,7 @@ export default OakComponent({
tocPosition: 'none',
highlightBgColor: 'none',
onArticlePreview: (content, title) => undefined,
origin: 'qiniu',
origin: null,
scrollId: '',
height: 600,
activeColor: undefined,

View File

@ -7,7 +7,7 @@ export default function Render(props: WebComponentProps<EntityDict, 'article', f
name: string;
editor: any;
content?: string;
origin?: string;
origin?: null | EntityDict['extraFile']['Schema']['origin'];
contentTip: boolean;
articleMenuId: string;
oakId: string;

View File

@ -31,7 +31,7 @@ function customCheckImageFn(src, alt, url) {
export default function Render(props) {
const { methods, data } = props;
const { t, setEditor, check, uploadFile, update, setHtml, gotoPreview, clearContentTip, } = methods;
const { oakId, oakFullpath, id, content, editor, origin = 'qiniu', tocPosition = 'none', highlightBgColor, activeColor, scrollId, tocWidth, tocHeight, height = 600 } = data;
const { oakId, oakFullpath, id, content, editor, origin, tocPosition = 'none', highlightBgColor, activeColor, scrollId, tocWidth, tocHeight, height = 600 } = data;
const [articleId, setArticleId] = useState('');
const [toc, setToc] = useState([]);
const [showToc, setShowToc] = useState(false);

View File

@ -5,7 +5,7 @@ declare const _default: (props: import("oak-frontend-base").ReactComponentProps<
entity: string;
entityId: string;
title: string;
origin: string;
origin: import("../../../types/Config").CosOrigin | null;
menuEmpty: import("react").ReactNode;
articleEmpty: import("react").ReactNode;
generateUrl: GenerateUrlFn;

View File

@ -7,7 +7,7 @@ export default OakComponent({
entity: '',
entityId: '',
title: '',
origin: 'qiniu',
origin: null,
menuEmpty: undefined,
articleEmpty: undefined,
generateUrl: ((mode, type, id) => { }), //构造文章显示路由

View File

@ -10,7 +10,7 @@ export default function Render(props: WebComponentProps<EntityDict, 'articleMenu
id: string;
title: string;
}[];
origin: 'qiniu';
origin: null | EntityDict['extraFile']['Schema']['origin'];
menuEmpty: React.ReactNode | undefined;
articleEmpty: React.ReactNode | undefined;
parentId: string | undefined;

View File

@ -6,7 +6,7 @@ import MenuList from '../list';
import ArticleList from '../../article/list';
export default function Render(props) {
const { data, methods } = props;
const { oakFullpath, oakLoading, oakExecuting, entity, entityId, title, breadcrumbItems, origin = 'qiniu', menuEmpty, articleEmpty, parentId, articleMenuId, showAddArticle, showAddMenu, generateUrl, } = data;
const { oakFullpath, oakLoading, oakExecuting, entity, entityId, title, breadcrumbItems, origin, menuEmpty, articleEmpty, parentId, articleMenuId, showAddArticle, showAddMenu, generateUrl, } = data;
const { t, onBreadcrumItemClick, onMenuClick, onAddMenu, changeAddArticle, onAddArticle, menuCheck } = methods;
const [addMenuOpen, setAddMenuOpen] = useState(false);
const [menuName, setMenuName] = useState('');

View File

@ -1,10 +1,11 @@
/// <reference types="react" />
import { GenerateUrlFn } from "../../../types/Article";
declare const _default: (props: import("oak-frontend-base").ReactComponentProps<import("../../../oak-app-domain").EntityDict, "articleMenu", true, {
import { EntityDict } from "../../../oak-app-domain";
declare const _default: (props: import("oak-frontend-base").ReactComponentProps<EntityDict, "articleMenu", true, {
entity: string;
entityId: string;
parentId: string | undefined;
origin: string;
origin: import("../../../types/Config").CosOrigin | null;
onMenuClick: (menuId: string, menuName: string, isArticle: boolean) => void;
onArticleClick: (atricleId: string) => void;
empty: import("react").ReactNode;

View File

@ -93,7 +93,7 @@ export default OakComponent({
entity: '',
entityId: '',
parentId: '',
origin: 'qiniu',
origin: null,
onMenuClick: (menuId, menuName, isArticle) => undefined,
onArticleClick: (atricleId) => undefined,
empty: undefined,

View File

@ -7,7 +7,7 @@ export default function Render(props: WebComponentProps<EntityDict, 'articleMenu
})[];
entity: string;
entityId: string;
origin: 'qiniu';
origin: null | EntityDict['extraFile']['Schema']['origin'];
onMenuClick: (menuId: string, menuName: string, isArticle: boolean) => void;
empty: React.ReactNode | undefined;
execuable: boolean;

View File

@ -7,7 +7,7 @@ import Pagination from 'oak-frontend-base/es/components/pagination';
const { confirm } = Modal;
export default function Render(props) {
const { data, methods } = props;
const { articleMenus, oakFullpath, oakPagination, oakLoading, oakExecuting, oakEntity, entity, entityId, origin = 'qiniu', onMenuClick, empty, execuable } = data;
const { articleMenus, oakFullpath, oakPagination, oakLoading, oakExecuting, oakEntity, entity, entityId, onMenuClick, empty, execuable } = data;
const { t, goDetail, onCopy } = props.methods;
const [editorId, setEditorId] = useState('');
const IconButton = (props) => {

View File

@ -1,4 +1,5 @@
declare const _default: (props: import("oak-frontend-base").ReactComponentProps<import("../../../oak-app-domain").EntityDict, "articleMenu", false, {
import { EntityDict } from "../../../oak-app-domain";
declare const _default: (props: import("oak-frontend-base").ReactComponentProps<EntityDict, "articleMenu", false, {
onRemove: () => void;
onUpdateName: (name: string) => Promise<void>;
onChildEditArticleChange: (data: string) => void;
@ -24,6 +25,6 @@ declare const _default: (props: import("oak-frontend-base").ReactComponentProps<
setCurrentArticle: (id: string) => void;
onMenuViewById: (articleMenuId: string) => void;
setCopyArticleUrl: (id: string) => string;
origin: string;
origin: import("../../../types/Config").CosOrigin | null;
}>) => React.ReactElement;
export default _default;

View File

@ -62,7 +62,7 @@ export default OakComponent({
setCurrentArticle: (id) => undefined,
onMenuViewById: (articleMenuId) => undefined,
setCopyArticleUrl: (id) => '',
origin: 'qiniu', // cos origin默认七牛云
origin: null, // cos origin默认由系统决定
},
formData({ data: row }) {
const { articleMenu$parent, article$articleMenu } = row || {};

View File

@ -32,7 +32,7 @@ export default function Render(props: WebComponentProps<EntityDict, 'articleMenu
currentArticle: string;
setCurrentArticle: (id: string) => void;
setCopyArticleUrl: (id: string) => string;
origin: 'qiniu';
origin: null | EntityDict['extraFile']['Schema']['origin'];
}, {
createSubArticle: (name: string) => Promise<void>;
createSubArticleMenu: (name: string) => Promise<void>;

View File

@ -7,7 +7,7 @@ import ExtraFileUpload from '../../extraFile/upload';
import ExtraFileCommit from '../../extraFile/commit';
import Styles from './web.pc.module.less';
export default function Render(props) {
const { row, allowCreateSubArticle, allowCreateSubMenu, allowRemove, onRemove, onUpdateName, oakFullpath, logo, onChildEditArticleChange, editArticleId, show, getBreadcrumbItemsByParent, breadItems, drawerOpen, changeDrawerOpen, selectedArticleId, openArray, getTopInfo, articleId, articleMenuId, getSideInfo, currentArticle, setCurrentArticle, setCopyArticleUrl, oakEntity, origin = 'qiniu' } = props.data;
const { row, allowCreateSubArticle, allowCreateSubMenu, allowRemove, onRemove, onUpdateName, oakFullpath, logo, onChildEditArticleChange, editArticleId, show, getBreadcrumbItemsByParent, breadItems, drawerOpen, changeDrawerOpen, selectedArticleId, openArray, getTopInfo, articleId, articleMenuId, getSideInfo, currentArticle, setCurrentArticle, setCopyArticleUrl, oakEntity, origin } = props.data;
const { update, execute, createSubArticle, createSubArticleMenu, setMessage, gotoDoc } = props.methods;
useEffect(() => {
if (editArticleId) {

View File

@ -31,6 +31,6 @@ declare const _default: (props: import("oak-frontend-base").ReactComponentProps<
setCurrentArticle: (id: string) => void;
onMenuViewById: (articleMenuId: string) => void;
setCopyArticleUrl: (id: string) => string;
origin: string;
origin: import("../../../types/Config").CosOrigin | null;
}>) => React.ReactElement;
export default _default;

View File

@ -26,7 +26,7 @@ export default OakComponent({
setCurrentArticle: (id) => undefined,
onMenuViewById: (articleMenuId) => undefined,
setCopyArticleUrl: (id) => '',
origin: 'qiniu', // cos origin默认七牛云
origin: null, // cos origin默认由系统决定
},
projection: {
id: 1,

View File

@ -32,7 +32,7 @@ export default function Render(props: WebComponentProps<EntityDict, 'articleMenu
setCurrentArticle: (id: string) => void;
onMenuViewById: (articleMenuId: string) => void;
setCopyArticleUrl: (id: string) => string;
origin: 'qiniu';
origin: null | EntityDict['extraFile']['Schema']['origin'];
}, {
createOne: (name?: string) => Promise<void>;
getDefaultArticle: (rows: EntityDict['articleMenu']['OpSchema'][]) => void;

View File

@ -3,7 +3,7 @@ import { Divider, Modal, Input, Form, Empty } from 'antd';
import TreeCell from '../treeCell';
import Styles from './web.pc.module.less';
export default function Render(props) {
const { rows, oakFullpath, parentId, onGrandChildEditArticleChange, show, getBreadcrumbItems, breadcrumbItems, drawerOpen, changeDrawerOpen, addOpen, changeAddOpen, selectedArticleId, defaultOpen, changeDefaultOpen, openArray, getTopInfo, articleId, articleMenuId, getSearchOpen, getSideInfo, currentArticle, setCurrentArticle, onMenuViewById, setCopyArticleUrl, origin = 'qiniu' } = props.data;
const { rows, oakFullpath, parentId, onGrandChildEditArticleChange, show, getBreadcrumbItems, breadcrumbItems, drawerOpen, changeDrawerOpen, addOpen, changeAddOpen, selectedArticleId, defaultOpen, changeDefaultOpen, openArray, getTopInfo, articleId, articleMenuId, getSearchOpen, getSideInfo, currentArticle, setCurrentArticle, onMenuViewById, setCopyArticleUrl, origin } = props.data;
const { t, createOne, removeItem, updateItem, execute, setMessage, getDefaultArticle, getSearchArticle } = props.methods;
useEffect(() => {
if (rows && rows.length > 0 && defaultOpen && !articleId) {

View File

@ -1,4 +1,5 @@
declare const _default: (props: import("oak-frontend-base").ReactComponentProps<import("../../../oak-app-domain").EntityDict, "articleMenu", true, {
import { EntityDict } from "../../../oak-app-domain/EntityDict";
declare const _default: (props: import("oak-frontend-base").ReactComponentProps<EntityDict, "articleMenu", true, {
entity: string;
entityId: string;
show: "edit" | "doc" | "preview";
@ -12,7 +13,7 @@ declare const _default: (props: import("oak-frontend-base").ReactComponentProps<
onArticlePreview: (content?: string, title?: string) => void;
onArticleEdit: (articleId: string) => void;
setCopyArticleUrl: (articleId: string) => string;
origin: string;
origin: import("../../../types/Config").CosOrigin | null;
scrollId: string;
activeColor: string | undefined;
}>) => React.ReactElement;

View File

@ -25,7 +25,7 @@ export default OakComponent({
onArticlePreview: (content, title) => undefined,
onArticleEdit: (articleId) => undefined,
setCopyArticleUrl: (articleId) => '',
origin: 'qiniu',
origin: null,
scrollId: '',
activeColor: undefined, //目录高亮颜色
},

View File

@ -9,7 +9,7 @@ export default function Render(props: WebComponentProps<EntityDict, 'articleMenu
articleId: string;
tocPosition: 'none' | 'left' | 'right';
highlightBgColor: string;
origin: 'qiniu';
origin: null | EntityDict['extraFile']['Schema']['origin'];
onMenuViewById: (articleMenuId: string) => void;
onArticlePreview: (content?: string, title?: string) => void;
onArticleEdit: (oakId: string) => void;

View File

@ -1,7 +1,7 @@
import { Style } from '../../../../types/Style';
declare const _default: (props: import("oak-frontend-base").ReactComponentProps<import("../../../../oak-app-domain").EntityDict, keyof import("../../../../oak-app-domain").EntityDict, false, {
style: Style;
entity: "platform" | "system" | "application";
entity: "application" | "platform" | "system";
entityId: string;
name: string;
}>) => React.ReactElement;

View File

@ -1,6 +1,7 @@
import React from 'react';
import { Tabs, Row, Col, Card, Divider, Input, Form, Space, Select, } from 'antd';
import { Tabs, Row, Col, Card, Divider, Input, Form, Space, Select, Typography, Switch, } from 'antd';
import Styles from './web.module.less';
import { isEmptyObject } from '../../../../utils/strings';
// https://developer.qiniu.com/kodo/1671/region-endpoint-fq
const QiniuZoneArray = [
{
@ -947,6 +948,11 @@ function S3Cos(props) {
.value)}/>
</>
</Form.Item>
<Form.Item label="pathStyle" help="使用路径样式访问(MinIO必须开启)">
<>
<Switch checked={ele.pathStyle} onChange={(checked) => setValue(`buckets.${idx}.pathStyle`, checked)}/>
</>
</Form.Item>
<Form.Item label="protocol">
<>
<Select mode="multiple" allowClear style={{
@ -987,6 +993,24 @@ export default function Cos(props) {
const { cos, setValue, removeItem } = props;
const { qiniu, ctyun, aliyun, tencent, local, s3 } = cos;
return (<Space direction="vertical" size="middle" style={{ display: 'flex' }}>
{/* 默认项选择 */}
<Space direction="vertical" style={{ width: '100%' }}>
<Typography.Text strong>默认COS源</Typography.Text>
<Select value={cos?.defaultOrigin} onChange={(v) => {
setValue('defaultOrigin', v);
}} style={{ width: '100%' }} allowClear placeholder="请选择默认COS源">
{Object.entries(cos).map(([key, value]) => {
if (key === 'defaultOrigin') {
return null;
}
if (value && !isEmptyObject(value)) {
return (<Select.Option key={key} value={key}>
{key}
</Select.Option>);
}
}).filter(Boolean)}
</Select>
</Space>
<Row>
<Card className={Styles.tips}>
每种均可配置一个相应的服务所使用的帐号请准确对应

View File

@ -39,7 +39,7 @@ export default function Render(props) {
</Space>}/>
</Affix>
<div className={Style.container}>
<Tabs tabPosition="left" items={[
<Tabs tabPosition="top" items={[
{
key: '云平台帐号',
label: '云平台帐号',
@ -51,13 +51,13 @@ export default function Render(props) {
children: (<Cos cos={cos || {}} setValue={(path, value) => setValue(`Cos.${path}`, value)} removeItem={(path, index) => removeItem(`Cos.${path}`, index)}/>),
},
{
key: '直播api设置',
label: '直播api设置',
key: '直播设置',
label: '直播设置',
children: (<Live live={live || {}} setValue={(path, value) => setValue(`Live.${path}`, value)}/>),
},
{
key: '地图api设置',
label: '地图api设置',
key: '地图设置',
label: '地图设置',
children: (<Map map={map || {}} setValue={(path, value) => setValue(`Map.${path}`, value)} removeItem={(path, index) => removeItem(`Map.${path}`, index)} cleanKey={(path, key) => cleanKey(`Map${path ? `.${path}` : ''}`, key)}/>),
},
{

View File

@ -12,5 +12,6 @@ declare const _default: (props: import("oak-frontend-base").ReactComponentProps<
entityId: string;
tag1: string;
tag2: string;
origin: import("../../types/Config").CosOrigin | null;
}>) => React.ReactElement;
export default _default;

View File

@ -14,6 +14,7 @@ export default OakComponent({
entityId: '',
tag1: '',
tag2: '',
origin: null,
},
methods: {
onEditReady(e) {
@ -69,7 +70,7 @@ export default OakComponent({
const extension = tempFilePath.substring(tempFilePath.lastIndexOf('.') + 1);
const filename = tempFilePath.substring(0, tempFilePath.lastIndexOf('.'));
const extraFile = {
origin: 'qiniu',
origin: this.props.origin,
type: 'image',
tag1: this.props.tag1 || 'editorImg',
tag2: this.props.tag2,

View File

@ -4,5 +4,6 @@ declare const _default: (props: import("oak-frontend-base").ReactComponentProps<
entity: keyof EntityDict;
entityId: string;
autoUpload: boolean;
origin: import("../../../types/Config").CosOrigin | null;
}>) => React.ReactElement;
export default _default;

View File

@ -30,7 +30,6 @@ export default OakComponent({
};
},
data: {
origin: 'qiniu',
type: 'image',
tag1: 'avatar',
},
@ -43,6 +42,7 @@ export default OakComponent({
entity: '',
entityId: '',
autoUpload: false,
origin: null,
},
methods: {
async onPickByMp() {
@ -93,8 +93,8 @@ export default OakComponent({
}));
},
async pushExtraFile(options) {
const { origin, type, tag1, avatar } = this.state;
const { entityId, entity, autoUpload = false } = this.props;
const { type, tag1, avatar } = this.state;
const { entityId, entity, autoUpload = false, origin } = this.props;
const { name, extra1, fileType, size } = options;
const extension = name.substring(name.lastIndexOf('.') + 1);
const filename = name.substring(0, name.lastIndexOf('.'));

View File

@ -15,19 +15,19 @@ declare const _default: <ED2 extends EntityDict & BaseEntityDict, T2 extends key
type?: ButtonProps['type'] | AmButtonProps['type'];
executeText?: string | undefined;
buttonProps?: (ButtonProps & {
color?: "default" | "primary" | "success" | "warning" | "danger" | undefined;
fill?: "none" | "solid" | "outline" | undefined;
size?: "small" | "large" | "middle" | "mini" | undefined;
color?: "default" | "success" | "warning" | "primary" | "danger" | undefined;
fill?: "none" | "outline" | "solid" | undefined;
size?: "small" | "middle" | "large" | "mini" | undefined;
block?: boolean | undefined;
loading?: boolean | "auto" | undefined;
loadingText?: string | undefined;
loadingIcon?: import("react").ReactNode;
disabled?: boolean | undefined;
onClick?: ((event: import("react").MouseEvent<HTMLButtonElement, MouseEvent>) => unknown) | undefined;
type?: "reset" | "submit" | "button" | undefined;
type?: "button" | "submit" | "reset" | undefined;
shape?: "default" | "rounded" | "rectangular" | undefined;
children?: import("react").ReactNode;
} & Pick<import("react").ClassAttributes<HTMLButtonElement> & import("react").ButtonHTMLAttributes<HTMLButtonElement>, "id" | "onMouseUp" | "onMouseDown" | "onTouchStart" | "onTouchEnd"> & {
} & Pick<import("react").ClassAttributes<HTMLButtonElement> & import("react").ButtonHTMLAttributes<HTMLButtonElement>, "id" | "onMouseDown" | "onMouseUp" | "onTouchEnd" | "onTouchStart"> & {
className?: string | undefined;
style?: (import("react").CSSProperties & Partial<Record<"--text-color" | "--background-color" | "--border-radius" | "--border-width" | "--border-style" | "--border-color", string>>) | undefined;
tabIndex?: number | undefined;

View File

@ -61,7 +61,7 @@ export default OakComponent({
disableAdd: false,
disableDownload: false,
type: 'image',
origin: 'qiniu',
origin: null,
tag1: '',
tag2: '',
entity: '',

View File

@ -17,7 +17,7 @@ export default function render(props: WebComponentProps<EntityDict, 'extraFile',
disableDownload: boolean;
disabled: boolean;
type: string;
origin: string;
origin: EntityDict['extraFile']['Schema']['origin'] | null;
tag1: string;
tag2: string;
entity: keyof EntityDict;

View File

@ -67,6 +67,7 @@ export default OakComponent({
entity: '',
entityId: '',
imgUrls: [],
origin: null,
},
lifetimes: {},
listeners: {
@ -154,7 +155,7 @@ export default OakComponent({
},
createExtraFileData(file) {
const { methodsType } = this.state;
const { tag1, tag2, entity, entityId } = this.props;
const { tag1, tag2, entity, entityId, origin } = this.props;
let extension = '';
let filename = '';
const applicationId = this.features.application.getApplicationId();
@ -177,7 +178,7 @@ export default OakComponent({
extension = name.substring(name.lastIndexOf('.') + 1);
filename = name.substring(0, name.lastIndexOf('.'));
Object.assign(createData, {
origin: 'qiniu',
origin,
extension,
filename,
size,
@ -207,7 +208,10 @@ export default OakComponent({
},
async myAddItem(createData) {
// 目前只支持七牛上传
if (createData.origin === 'qiniu') {
if (createData.origin === 'unknown') {
this.addItem(createData);
}
else {
const file = createData.extra1;
const id = this.addItem(Object.assign(createData, {
extra1: null,
@ -215,9 +219,6 @@ export default OakComponent({
}));
this.features.extraFile.addLocalFile(id, file);
}
else {
this.addItem(createData);
}
},
async myUpdateItem(params) {
const { file } = this.state;

View File

@ -33,7 +33,7 @@ declare const _default: <ED2 extends EntityDict & BaseEntityDict, T2 extends key
disableDownload: boolean;
disabled: boolean;
type: string;
origin: string;
origin: ED2["extraFile"]["Schema"]["origin"] | null;
tag1: string;
tag2: string;
entity: keyof ED2;

View File

@ -64,7 +64,7 @@ export default OakComponent({
disableAdd: false,
disableDownload: false,
type: 'image',
origin: 'qiniu',
origin: null,
tag1: '',
tag2: '',
entity: '',
@ -133,7 +133,7 @@ export default OakComponent({
this.features.extraFile.removeLocalFiles([file.id]);
},
async addExtraFileInner(options, file) {
const { type, origin = 'qiniu', // 默认qiniu
const { type, origin, // 默认由系统决定
tag1, tag2, entity, entityId, bucket, autoUpload, } = this.props;
const { name, fileType, size, sort } = options;
const extension = name.substring(name.lastIndexOf('.') + 1);

View File

@ -0,0 +1,3 @@
import { EntityDict } from '../../../../oak-app-domain';
declare const _default: (props: import("oak-frontend-base").ReactComponentProps<EntityDict, keyof EntityDict, false, {}>) => React.ReactElement;
export default _default;

View File

@ -0,0 +1,149 @@
import assert from "assert";
export default OakComponent({
// Virtual Component
isList: false,
filters: [],
properties: {},
data: {
clientInfo: null,
loading: true,
userInfo: 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,
currentUserId: userId,
}).then((clientInfo) => {
if (!clientInfo.result) {
this.setState({ loading: false });
this.setState({
hasError: true,
errorMsg: 'oauth.authorize.error.invalid_client_id',
});
}
else {
this.setState({
clientInfo: clientInfo.result.data,
});
if (clientInfo.result.alreadyAuth) {
// 已经授权过,直接跳转
this.handleGrant();
}
else {
this.setState({ loading: false });
}
}
}).catch((err) => {
this.setState({ loading: false });
console.error('Error loading OAuth client info:', err);
this.setState({
hasError: true,
errorMsg: err.message || 'oauth.authorize.error.unknown',
});
});
},
},
methods: {
handleGrant() {
this.callAspectAuthorize("grant");
},
handleDeny() {
this.callAspectAuthorize("deny");
},
callAspectAuthorize(action) {
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.replace(redirectUri);
}).catch((err) => {
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,23 @@
import React from 'react';
import { WebComponentProps } from 'oak-frontend-base';
import { EntityDict } from '../../../../oak-app-domain';
declare const Authorize: (props: WebComponentProps<EntityDict, keyof EntityDict, false, {
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;
}>) => React.JSX.Element;
export default Authorize;

View File

@ -0,0 +1,73 @@
import React from 'react';
import Styles from './styles.module.less';
import { Avatar } from 'antd';
const Authorize = (props) => {
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;

2
es/components/oauth/index.d.ts vendored Normal file
View File

@ -0,0 +1,2 @@
declare const _default: (props: import("oak-frontend-base").ReactComponentProps<import("../../oak-app-domain").EntityDict, keyof import("../../oak-app-domain").EntityDict, false, {}>) => React.ReactElement;
export default _default;

View File

@ -0,0 +1,64 @@
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, state) {
this.features.token.loginByOAuth(code, state).then(() => {
console.log('OAuth login successful');
}).catch((err) => {
console.error('OAuth login failed:', err);
this.setErrorMsg(err.message || 'OAuth login failed');
}).finally(() => {
this.setState({ loading: false });
});
},
setErrorMsg(message) {
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,5 @@
declare const _default: (props: import("oak-frontend-base").ReactComponentProps<import("../../../oak-app-domain").EntityDict, keyof import("../../../oak-app-domain").EntityDict, false, {
systemId: string;
systemName: string;
}>) => React.ReactElement;
export default _default;

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,4 @@
declare const _default: (props: import("oak-frontend-base").ReactComponentProps<import("../../../../oak-app-domain").EntityDict, "oauthApplication", true, {
systemId: string;
}>) => React.ReactElement;
export default _default;

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,
requirePKCE: 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$$ > 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,2 @@
declare const _default: (props: import("oak-frontend-base").ReactComponentProps<import("../../../../../oak-app-domain").EntityDict, "oauthApplication", false, {}>) => React.ReactElement;
export default _default;

View File

@ -0,0 +1,46 @@
import { generateNewId } from "oak-domain/lib/utils/uuid";
export default OakComponent({
entity: 'oauthApplication',
isList: false,
projection: {
name: 1,
description: 1,
redirectUris: 1,
logo: 1,
isConfidential: 1,
scopes: 1,
ableState: 1,
requirePKCE: 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 || "",
isCreation: this.isCreation(),
};
},
properties: {},
methods: {
reGenerateClientSecret() {
this.features.cache.operate("oauthApplication", {
id: generateNewId(),
action: "resetSecret",
data: {},
filter: {
id: this.props.oakId,
}
});
}
}
});

View File

@ -0,0 +1,25 @@
{
"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",
"requirePKCE": "强制 PKCE",
"requirePKCETooltip": "启用后授权请求必须使用PKCE扩展以增强安全性。",
"clientSecretTooltip": "客户端密钥仅在机密客户端中使用,用于身份验证。创建成功后可以查看"
}

View File

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

View File

@ -0,0 +1,11 @@
import React from 'react';
import { RowWithActions, WebComponentProps } from 'oak-frontend-base';
import { EntityDict } from '../../../../../oak-app-domain';
declare const Upsert: (props: WebComponentProps<EntityDict, 'oauthApplication', false, {
item: RowWithActions<EntityDict, 'oauthApplication'>;
clientSecret: string;
isCreation: boolean;
}, {
reGenerateClientSecret: () => void;
}>) => React.JSX.Element;
export default Upsert;

View File

@ -0,0 +1,74 @@
import React from 'react';
import { Form, Input, Switch, Button, Space, Select } from 'antd';
import Styles from './styles.module.less';
const Upsert = (props) => {
const { item, clientSecret, isCreation } = 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')} tooltip={t('clientSecretTooltip')}>
<Space.Compact style={{ width: '100%' }}>
<Input placeholder={t('clientSecretPlaceholder')} value={clientSecret || "已隐藏"} disabled/>
<Button type="primary" onClick={reGenerateClientSecret} disabled={isCreation} // 只能在更新时重新生成
>
{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>
{/* requirePKCE */}
<Form.Item label={t('requirePKCE')} valuePropName="checked" tooltip={t('requirePKCETooltip')}>
<Switch checked={!!item.requirePKCE} onChange={(checked) => update({ requirePKCE: checked })}/>
</Form.Item>
</Form>
</div>);
};
export default Upsert;

View File

@ -0,0 +1,8 @@
import React from 'react';
import { RowWithActions, WebComponentProps } from 'oak-frontend-base';
import { EntityDict } from '../../../../oak-app-domain';
declare const OauthProvider: (props: WebComponentProps<EntityDict, 'oauthApplication', true, {
list: RowWithActions<EntityDict, 'oauthApplication'>[];
systemId: string;
}>) => React.JSX.Element;
export default OauthProvider;

View File

@ -0,0 +1,59 @@
import React from 'react';
import Styles from './styles.module.less';
import { Button, Modal } from 'antd';
import AppUpsert from "./upsert";
import ListPro from 'oak-frontend-base/es/components/listPro';
const OauthProvider = (props) => {
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", "requirePKCE",
];
const [upsertId, setUpsertId] = React.useState(null);
const handleAction = (row, action) => {
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,4 @@
declare const _default: (props: import("oak-frontend-base").ReactComponentProps<import("../../../../oak-app-domain").EntityDict, "oauthProvider", true, {
systemId: string;
}>) => React.ReactElement;
export default _default;

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,
scopes: 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$$ > 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,2 @@
declare const _default: (props: import("oak-frontend-base").ReactComponentProps<import("../../../../../oak-app-domain").EntityDict, "oauthProvider", false, {}>) => React.ReactElement;
export default _default;

View File

@ -0,0 +1,30 @@
export default OakComponent({
entity: 'oauthProvider',
isList: false,
projection: {
name: 1,
type: 1,
logo: 1,
scopes: 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,7 @@
import React from 'react';
import { RowWithActions, WebComponentProps } from 'oak-frontend-base';
import { EntityDict } from '../../../../../oak-app-domain';
declare const Upsert: (props: WebComponentProps<EntityDict, 'oauthProvider', false, {
item: RowWithActions<EntityDict, 'oauthProvider'>;
}>) => React.JSX.Element;
export default Upsert;

View File

@ -0,0 +1,115 @@
import React from 'react';
import { Form, Input, Switch, Select, Typography } from 'antd';
import Styles from './styles.module.less';
const { Text } = Typography;
const Upsert = (props) => {
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') }]} extra={item.type && item.type !== 'oak' && item.type !== 'gitea' ? (<Text type="warning">
{item.type}不是预设类型请自行注入 handler
</Text>) : undefined}>
<Select mode="tags" placeholder={t('typePlaceholder')} value={item.type ? [item.type] : []} // 保持数组形式
onChange={(v) => {
// 只取最后一个输入或选择的值
const last = v.slice(-1)[0];
update({ type: last });
}} tokenSeparators={[',']} maxTagCount={1} // 只显示一个标签
options={[
{ value: 'oak', label: 'Oak' },
{ value: 'gitea', label: 'Gitea' },
]}/>
</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,8 @@
import React from 'react';
import { RowWithActions, WebComponentProps } from 'oak-frontend-base';
import { EntityDict } from '../../../../oak-app-domain';
declare const OauthProvider: (props: WebComponentProps<EntityDict, 'oauthProvider', true, {
list: RowWithActions<EntityDict, 'oauthProvider'>[];
systemId: string;
}>) => React.JSX.Element;
export default OauthProvider;

View File

@ -0,0 +1,60 @@
import React from 'react';
import Styles from './styles.module.less';
import { Button, Modal } from 'antd';
import ProviderUpsert from "./upsert";
import ListPro from 'oak-frontend-base/es/components/listPro';
const OauthProvider = (props) => {
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(null);
const handleAction = (row, action) => {
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,8 @@
import React from 'react';
import { WebComponentProps } from 'oak-frontend-base';
import { EntityDict } from '../../../oak-app-domain';
declare const Management: (props: WebComponentProps<EntityDict, keyof EntityDict, false, {
systemId: string;
systemName: string;
}>) => React.JSX.Element;
export default Management;

View File

@ -0,0 +1,21 @@
import React from 'react';
import OauthApps from './oauthApps';
import OauthProvider from './oauthProvider';
import { Tabs } from 'antd';
const Management = (props) => {
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,3 @@
import { EntityDict } from "../../../oak-app-domain";
declare const _default: (props: import("oak-frontend-base").ReactComponentProps<EntityDict, "oauthUserAuthorization", true, {}>) => React.ReactElement;
export default _default;

View File

@ -0,0 +1,69 @@
import assert from "assert";
import { generateNewId } from "oak-domain/lib/utils/uuid";
export default OakComponent({
entity: 'oauthUserAuthorization',
isList: true,
projection: {
userId: 1,
applicationId: 1,
authorizedAt: 1,
codeId: 1,
tokenId: 1,
usageState: 1,
$$createAt$$: 1,
application: {
logo: 1,
name: 1,
description: 1,
isConfidential: 1,
},
code: {
scope: 1,
},
token: {
accessExpiresAt: 1,
refreshExpiresAt: 1,
lastUsedAt: 1,
revokedAt: 1,
}
},
pagination: {
pageSize: 5,
currentPage: 1,
},
filters: [{
filter() {
const userId = this.features.token.getUserId();
const systemId = this.features.application.getApplication()?.systemId;
return {
userId,
application: {
systemId: systemId
},
usageState: {
$in: ['granted', 'denied', 'revoked']
}
};
},
}],
formData({ data }) {
return {
list: data,
};
},
properties: {},
methods: {
async revoke(item) {
assert(item.id, 'No id found for this authorization record');
await this.features.cache.operate("oauthUserAuthorization", {
action: "revoke",
id: generateNewId(),
data: {},
filter: {
id: item.id
}
});
console.log('Revoking authorization for:', item.id);
}
}
});

View File

@ -0,0 +1,23 @@
{
"page_title": "My Authorization Records",
"page_description": "Manage access permissions you've granted to third-party applications",
"no_records": "No authorization records",
"unknown_app": "Unknown Application",
"revoke": "Revoke",
"revoke_confirm_title": "Confirm Revoke Authorization",
"revoke_confirm_content": "Are you sure you want to revoke authorization for '%{appName}'? The application will no longer be able to access your data.",
"confirm": "Confirm",
"cancel": "Cancel",
"status_active": "Active",
"status_revoked": "Revoked",
"status_denied": "Denied",
"status_expired": "Expired",
"status_unknown": "Unknown",
"authorized_at": "Authorized At",
"scope": "Scope",
"full_access": "Full Access",
"last_used_at": "Last Used",
"access_expires_at": "Access Expires At",
"revoked_at": "Revoked At",
"pagination_total": "Total %{total} items"
}

Some files were not shown because too many files have changed in this diff Show More