feat: 新增我的授权历史组件

This commit is contained in:
Pan Qiancheng 2025-10-24 11:44:21 +08:00
parent c28bca6385
commit 6b29b03750
18 changed files with 1328 additions and 125 deletions

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"
}

View File

@ -0,0 +1,23 @@
{
"page_title": "我的授权记录",
"page_description": "管理您授权给第三方应用的访问权限",
"no_records": "暂无授权记录",
"unknown_app": "未知应用",
"revoke": "撤销",
"revoke_confirm_title": "确认撤销授权",
"revoke_confirm_content": "确定要撤销对「%{appName}」的授权吗?撤销后该应用将无法访问您的数据。",
"confirm": "确定",
"cancel": "取消",
"status_active": "已授权",
"status_revoked": "已撤销",
"status_denied": "已拒绝",
"status_expired": "已过期",
"status_unknown": "未知状态",
"authorized_at": "授权时间",
"scope": "授权范围",
"full_access": "完全访问",
"last_used_at": "最后使用",
"access_expires_at": "访问过期时间",
"revoked_at": "撤销时间",
"pagination_total": "共 %{total} 条"
}

View File

@ -0,0 +1,185 @@
.container {
max-width: 900px;
margin: 0 auto;
padding: 24px;
}
.header {
margin-bottom: 32px;
h2 {
font-size: 24px;
font-weight: 600;
color: #1f2937;
margin: 0 0 8px 0;
}
}
.description {
font-size: 14px;
color: #6b7280;
margin: 0;
}
.listContainer {
display: flex;
flex-direction: column;
gap: 16px;
}
.item {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 20px;
transition: all 0.2s ease;
&:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
border-color: #d1d5db;
}
}
.itemHeader {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
}
.appInfo {
flex: 1;
display: flex;
align-items: center;
gap: 16px;
}
.appLogo {
flex-shrink: 0;
background: #ffffff;
border: 1px solid #d1d5db;
color: #6b7280;
}
.appNameWrapper {
flex: 1;
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.appName {
font-size: 16px;
font-weight: 600;
color: #1f2937;
margin: 0;
}
.appDescription {
font-size: 14px;
color: #6b7280;
margin: 0 0 16px 60px;
line-height: 1.5;
}
.statusActive,
.statusRevoked,
.statusDenied,
.statusExpired,
.statusUnknown {
font-size: 12px;
padding: 2px 10px;
border-radius: 12px;
font-weight: 500;
white-space: nowrap;
}
.statusActive {
background: #d1fae5;
color: #065f46;
}
.statusRevoked {
background: #fee2e2;
color: #991b1b;
}
.statusDenied {
background: #fef3c7;
color: #92400e;
}
.statusExpired {
background: #e5e7eb;
color: #4b5563;
}
.statusUnknown {
background: #f3f4f6;
color: #6b7280;
}
.revokeButton {
padding: 6px 16px;
font-size: 14px;
color: #dc2626;
background: #fff;
border: 1px solid #dc2626;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
font-weight: 500;
&:hover {
background: #dc2626;
color: #fff;
}
&:active {
transform: scale(0.98);
}
}
.itemDetails {
display: flex;
flex-direction: column;
gap: 8px;
padding-top: 12px;
margin-left: 64px;
border-top: 1px solid #f3f4f6;
}
.detailRow {
display: flex;
font-size: 14px;
line-height: 1.5;
}
.label {
color: #6b7280;
min-width: 120px;
font-weight: 500;
}
.value {
color: #1f2937;
flex: 1;
}
.empty {
text-align: center;
padding: 80px 20px;
color: #9ca3af;
}
.emptyIcon {
font-size: 64px;
margin-bottom: 16px;
opacity: 0.5;
}
.empty p {
font-size: 16px;
margin: 0;
}

View File

@ -0,0 +1,9 @@
import React from 'react';
import { RowWithActions, WebComponentProps } from 'oak-frontend-base';
import { EntityDict } from '../../../oak-app-domain';
declare const Records: (props: WebComponentProps<EntityDict, "oauthUserAuthorization", true, {
list: RowWithActions<EntityDict, "oauthUserAuthorization">[];
}, {
revoke: (item: RowWithActions<EntityDict, "oauthUserAuthorization">) => void;
}>) => React.JSX.Element;
export default Records;

View File

@ -0,0 +1,134 @@
import React from 'react';
import { Modal, Avatar, Pagination } from 'antd';
import { AppstoreOutlined } from '@ant-design/icons';
import Styles from './styles.module.less';
const Records = (props) => {
const { list, oakPagination } = props.data;
const { pageSize, currentPage, total } = oakPagination || {};
const { t, revoke, setCurrentPage, setPageSize } = props.methods;
const handleRevoke = (item) => {
Modal.confirm({
title: t('revoke_confirm_title'),
content: t('revoke_confirm_content', { appName: item.application?.name || t('unknown_app') }),
okText: t('confirm'),
cancelText: t('cancel'),
okButtonProps: { danger: true },
onOk: () => {
revoke(item);
},
});
};
const formatDate = (date) => {
if (!date)
return '-';
return new Date(date).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
};
function formateScope(scope) {
if (!scope || scope.length === 0 || scope.includes('')) {
return t('full_access');
}
return scope.join(', ');
}
const getStatusTag = (item) => {
if (item.token?.revokedAt) {
return <span className={Styles.statusRevoked}>{t('status_revoked')}</span>;
}
if (item.usageState === 'denied') {
return <span className={Styles.statusDenied}>{t('status_denied')}</span>;
}
if (item.token?.refreshExpiresAt && new Date(item.token.refreshExpiresAt) < new Date()) {
return <span className={Styles.statusExpired}>{t('status_expired')}</span>;
}
if (item.usageState === 'granted') {
return <span className={Styles.statusActive}>{t('status_active')}</span>;
}
if (item.usageState === 'revoked') {
return <span className={Styles.statusRevoked}>{t('status_revoked')}</span>;
}
return <span className={Styles.statusUnknown}>{t('status_unknown')}</span>;
};
const isRevokable = (item) => {
return item.usageState === 'granted' && !item.token?.revokedAt;
};
return (<div className={Styles.container}>
<div className={Styles.header}>
<h2>{t('page_title')}</h2>
<p className={Styles.description}>{t('page_description')}</p>
</div>
{!list || list.length === 0 ? (<div className={Styles.empty}>
<div className={Styles.emptyIcon}>🔐</div>
<p>{t('no_records')}</p>
</div>) : (<>
<div className={Styles.listContainer}>
{list.map((item) => {
return (<div key={item.id} className={Styles.item}>
<div className={Styles.itemHeader}>
<div className={Styles.appInfo}>
<Avatar src={item.application?.logo} icon={!item.application?.logo && <AppstoreOutlined />} size={48} shape="square" className={Styles.appLogo}/>
<div className={Styles.appNameWrapper}>
<h3 className={Styles.appName}>
{item.application?.name || t('unknown_app')}
</h3>
{getStatusTag(item)}
</div>
</div>
{isRevokable(item) && (<button className={Styles.revokeButton} onClick={() => handleRevoke(item)}>
{t('revoke')}
</button>)}
</div>
{item.application?.description && (<p className={Styles.appDescription}>
{item.application?.description}
</p>)}
<div className={Styles.itemDetails}>
<div className={Styles.detailRow}>
<span className={Styles.label}>{t('authorized_at')}:</span>
<span className={Styles.value}>{formatDate(item.authorizedAt)}</span>
</div>
<div className={Styles.detailRow}>
<span className={Styles.label}>{t('scope')}:</span>
<span className={Styles.value}>
{formateScope(item.code?.scope)}
</span>
</div>
{item.token?.lastUsedAt && (<div className={Styles.detailRow}>
<span className={Styles.label}>{t('last_used_at')}:</span>
<span className={Styles.value}>{formatDate(item.token.lastUsedAt)}</span>
</div>)}
{/* {item.token?.accessExpiresAt && (
<div className={Styles.detailRow}>
<span className={Styles.label}>{t('access_expires_at')}:</span>
<span className={Styles.value}>{formatDate(item.token.accessExpiresAt)}</span>
</div>
)} */}
{item.token?.revokedAt && (<div className={Styles.detailRow}>
<span className={Styles.label}>{t('revoked_at')}:</span>
<span className={Styles.value}>{formatDate(item.token.revokedAt)}</span>
</div>)}
</div>
</div>);
})}
</div>
{total && total > 0 && (<div className={Styles.paginationWrapper}>
<Pagination current={currentPage} pageSize={pageSize} total={total} onChange={(page) => setCurrentPage(page)} onShowSizeChange={(current, size) => {
setPageSize(size);
setCurrentPage(1);
}} showSizeChanger showQuickJumper showTotal={(total) => t('pagination_total', { total })} pageSizeOptions={['5', '10', '20', '50', '100']} className={Styles.pagination}/>
</div>)}
</>)}
</div>);
};
export default Records;

View File

@ -444,6 +444,36 @@ const i18ns = [
"refreshEndpointPlaceholder": "请输入刷新端点URL"
}
},
{
id: "0b72f69f7fe7160fb6871917d674c12b",
namespace: "oak-general-business-c-oauth-records",
language: "zh-CN",
module: "oak-general-business",
position: "src/components/oauth/records",
data: {
"page_title": "我的授权记录",
"page_description": "管理您授权给第三方应用的访问权限",
"no_records": "暂无授权记录",
"unknown_app": "未知应用",
"revoke": "撤销",
"revoke_confirm_title": "确认撤销授权",
"revoke_confirm_content": "确定要撤销对「%{appName}」的授权吗?撤销后该应用将无法访问您的数据。",
"confirm": "确定",
"cancel": "取消",
"status_active": "已授权",
"status_revoked": "已撤销",
"status_denied": "已拒绝",
"status_expired": "已过期",
"status_unknown": "未知状态",
"authorized_at": "授权时间",
"scope": "授权范围",
"full_access": "完全访问",
"last_used_at": "最后使用",
"access_expires_at": "访问过期时间",
"revoked_at": "撤销时间",
"pagination_total": "共 %{total} 条"
}
},
{
id: "9bdbb9993789ecd48e7b0e10b7117620",
namespace: "oak-general-business-c-passport",

View File

@ -9,52 +9,107 @@ const triggers = [
fn: async ({ operation }, context) => {
const { filter } = operation;
assert(filter, 'No filter found in revoke operation');
const datas = await context.select("oauthUserAuthorization", {
data: {
userId: 1,
applicationId: 1,
tokenId: 1,
codeId: 1,
usageState: 1,
},
filter: { ...filter },
}, {});
let res = 0;
// 如果是unused并且code的usedAt是空的则把code的usedAt全部设置为当前时间
const opRes0 = await context.operate("oauthAuthorizationCode", {
id: await generateNewIdAsync(),
action: "update",
data: {
usedAt: new Date()
},
filter: {
usedAt: {
$exists: false
for (const data of datas) {
// 如果是unused并且code的usedAt是空的则把code的usedAt全部设置为当前时间
const opRes0 = await context.operate("oauthAuthorizationCode", {
id: await generateNewIdAsync(),
action: "update",
data: {
usedAt: new Date()
},
oauthUserAuthorization$code: {
...filter,
// 未被使用肯定就没有tokenId
usageState: 'unused',
filter: {
usedAt: {
$exists: false
},
// 某一个用户对某一个应用的授权记录
oauthAppId: data.applicationId,
userId: data.userId,
}
}, {});
res += opRes0.oauthAuthorizationCode?.update || 0;
// // 如果没有token可以直接删除oauthUserAuthorization (可能是复用的之前的token 也可能是未被使用的授权记录)
if (data.usageState === 'unused' && !data.tokenId) {
const opRes = await context.operate("oauthUserAuthorization", {
id: await generateNewIdAsync(),
action: "remove",
data: {},
filter: {
id: data.id,
}
}, {});
res += opRes.oauthApplication?.remove || 0;
}
}, {});
res += opRes0.oauthAuthorizationCode?.update || 0;
// 如果没有token可以直接删除oauthUserAuthorization (可能是复用的之前的token 也可能是未被使用的授权记录)
const opRes = await context.operate("oauthUserAuthorization", {
id: await generateNewIdAsync(),
action: "remove",
data: {},
filter: {
...filter,
// 未被使用肯定就没有tokenId
usageState: 'unused',
// 如果有token则将token的revokedAt设置为当前时间
if (data.tokenId) {
const opRes2 = await context.operate("oauthToken", {
id: await generateNewIdAsync(),
action: "update",
data: {
revokedAt: new Date()
},
filter: {
id: data.tokenId
}
}, {});
res += opRes2.oauthToken?.update || 0;
}
}, {});
res += opRes.oauthApplication?.remove || 0;
// 如果有token则将token的revokedAt设置为当前时间
const opRes2 = await context.operate("oauthToken", {
id: await generateNewIdAsync(),
action: "update",
data: {
revokedAt: new Date()
},
filter: {
oauthUserAuthorization$token: {
...filter
}
}
}, {});
res += opRes2.oauthToken?.update || 0;
}
// // 如果是unused并且code的usedAt是空的则把code的usedAt全部设置为当前时间
// const opRes0 = await context.operate("oauthAuthorizationCode", {
// id: await generateNewIdAsync(),
// action: "update",
// data: {
// usedAt: new Date()
// },
// filter: {
// usedAt: {
// $exists: false
// },
// oauthUserAuthorization$code: {
// ...filter,
// // 未被使用肯定就没有tokenId
// usageState: 'unused',
// }
// }
// }, {});
// res += opRes0.oauthAuthorizationCode?.update || 0;
// // 如果没有token可以直接删除oauthUserAuthorization (可能是复用的之前的token 也可能是未被使用的授权记录)
// const opRes = await context.operate("oauthUserAuthorization", {
// id: await generateNewIdAsync(),
// action: "remove",
// data: {},
// filter: {
// ...filter,
// // 未被使用肯定就没有tokenId
// usageState: 'unused',
// }
// }, {});
// res += opRes.oauthApplication?.remove || 0;
// // 如果有token则将token的revokedAt设置为当前时间
// const opRes2 = await context.operate("oauthToken", {
// id: await generateNewIdAsync(),
// action: "update",
// data: {
// revokedAt: new Date()
// },
// filter: {
// oauthUserAuthorization$token: {
// ...filter
// }
// }
// }, {});
// res += opRes2.oauthToken?.update || 0;
return res;
}
}

View File

@ -446,6 +446,36 @@ const i18ns = [
"refreshEndpointPlaceholder": "请输入刷新端点URL"
}
},
{
id: "0b72f69f7fe7160fb6871917d674c12b",
namespace: "oak-general-business-c-oauth-records",
language: "zh-CN",
module: "oak-general-business",
position: "src/components/oauth/records",
data: {
"page_title": "我的授权记录",
"page_description": "管理您授权给第三方应用的访问权限",
"no_records": "暂无授权记录",
"unknown_app": "未知应用",
"revoke": "撤销",
"revoke_confirm_title": "确认撤销授权",
"revoke_confirm_content": "确定要撤销对「%{appName}」的授权吗?撤销后该应用将无法访问您的数据。",
"confirm": "确定",
"cancel": "取消",
"status_active": "已授权",
"status_revoked": "已撤销",
"status_denied": "已拒绝",
"status_expired": "已过期",
"status_unknown": "未知状态",
"authorized_at": "授权时间",
"scope": "授权范围",
"full_access": "完全访问",
"last_used_at": "最后使用",
"access_expires_at": "访问过期时间",
"revoked_at": "撤销时间",
"pagination_total": "共 %{total} 条"
}
},
{
id: "9bdbb9993789ecd48e7b0e10b7117620",
namespace: "oak-general-business-c-passport",

View File

@ -12,52 +12,107 @@ const triggers = [
fn: async ({ operation }, context) => {
const { filter } = operation;
(0, assert_1.default)(filter, 'No filter found in revoke operation');
const datas = await context.select("oauthUserAuthorization", {
data: {
userId: 1,
applicationId: 1,
tokenId: 1,
codeId: 1,
usageState: 1,
},
filter: { ...filter },
}, {});
let res = 0;
// 如果是unused并且code的usedAt是空的则把code的usedAt全部设置为当前时间
const opRes0 = await context.operate("oauthAuthorizationCode", {
id: await (0, uuid_1.generateNewIdAsync)(),
action: "update",
data: {
usedAt: new Date()
},
filter: {
usedAt: {
$exists: false
for (const data of datas) {
// 如果是unused并且code的usedAt是空的则把code的usedAt全部设置为当前时间
const opRes0 = await context.operate("oauthAuthorizationCode", {
id: await (0, uuid_1.generateNewIdAsync)(),
action: "update",
data: {
usedAt: new Date()
},
oauthUserAuthorization$code: {
...filter,
// 未被使用肯定就没有tokenId
usageState: 'unused',
filter: {
usedAt: {
$exists: false
},
// 某一个用户对某一个应用的授权记录
oauthAppId: data.applicationId,
userId: data.userId,
}
}, {});
res += opRes0.oauthAuthorizationCode?.update || 0;
// // 如果没有token可以直接删除oauthUserAuthorization (可能是复用的之前的token 也可能是未被使用的授权记录)
if (data.usageState === 'unused' && !data.tokenId) {
const opRes = await context.operate("oauthUserAuthorization", {
id: await (0, uuid_1.generateNewIdAsync)(),
action: "remove",
data: {},
filter: {
id: data.id,
}
}, {});
res += opRes.oauthApplication?.remove || 0;
}
}, {});
res += opRes0.oauthAuthorizationCode?.update || 0;
// 如果没有token可以直接删除oauthUserAuthorization (可能是复用的之前的token 也可能是未被使用的授权记录)
const opRes = await context.operate("oauthUserAuthorization", {
id: await (0, uuid_1.generateNewIdAsync)(),
action: "remove",
data: {},
filter: {
...filter,
// 未被使用肯定就没有tokenId
usageState: 'unused',
// 如果有token则将token的revokedAt设置为当前时间
if (data.tokenId) {
const opRes2 = await context.operate("oauthToken", {
id: await (0, uuid_1.generateNewIdAsync)(),
action: "update",
data: {
revokedAt: new Date()
},
filter: {
id: data.tokenId
}
}, {});
res += opRes2.oauthToken?.update || 0;
}
}, {});
res += opRes.oauthApplication?.remove || 0;
// 如果有token则将token的revokedAt设置为当前时间
const opRes2 = await context.operate("oauthToken", {
id: await (0, uuid_1.generateNewIdAsync)(),
action: "update",
data: {
revokedAt: new Date()
},
filter: {
oauthUserAuthorization$token: {
...filter
}
}
}, {});
res += opRes2.oauthToken?.update || 0;
}
// // 如果是unused并且code的usedAt是空的则把code的usedAt全部设置为当前时间
// const opRes0 = await context.operate("oauthAuthorizationCode", {
// id: await generateNewIdAsync(),
// action: "update",
// data: {
// usedAt: new Date()
// },
// filter: {
// usedAt: {
// $exists: false
// },
// oauthUserAuthorization$code: {
// ...filter,
// // 未被使用肯定就没有tokenId
// usageState: 'unused',
// }
// }
// }, {});
// res += opRes0.oauthAuthorizationCode?.update || 0;
// // 如果没有token可以直接删除oauthUserAuthorization (可能是复用的之前的token 也可能是未被使用的授权记录)
// const opRes = await context.operate("oauthUserAuthorization", {
// id: await generateNewIdAsync(),
// action: "remove",
// data: {},
// filter: {
// ...filter,
// // 未被使用肯定就没有tokenId
// usageState: 'unused',
// }
// }, {});
// res += opRes.oauthApplication?.remove || 0;
// // 如果有token则将token的revokedAt设置为当前时间
// const opRes2 = await context.operate("oauthToken", {
// id: await generateNewIdAsync(),
// action: "update",
// data: {
// revokedAt: new Date()
// },
// filter: {
// oauthUserAuthorization$token: {
// ...filter
// }
// }
// }, {});
// res += opRes2.oauthToken?.update || 0;
return res;
}
}

View File

@ -0,0 +1,74 @@
import assert from "assert";
import { generateNewId } from "oak-domain/lib/utils/uuid";
import { RowWithActions } from "oak-frontend-base";
import { EntityDict } from "../../../oak-app-domain";
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: RowWithActions<EntityDict, 'oauthUserAuthorization'>) {
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"
}

View File

@ -0,0 +1,23 @@
{
"page_title": "我的授权记录",
"page_description": "管理您授权给第三方应用的访问权限",
"no_records": "暂无授权记录",
"unknown_app": "未知应用",
"revoke": "撤销",
"revoke_confirm_title": "确认撤销授权",
"revoke_confirm_content": "确定要撤销对「%{appName}」的授权吗?撤销后该应用将无法访问您的数据。",
"confirm": "确定",
"cancel": "取消",
"status_active": "已授权",
"status_revoked": "已撤销",
"status_denied": "已拒绝",
"status_expired": "已过期",
"status_unknown": "未知状态",
"authorized_at": "授权时间",
"scope": "授权范围",
"full_access": "完全访问",
"last_used_at": "最后使用",
"access_expires_at": "访问过期时间",
"revoked_at": "撤销时间",
"pagination_total": "共 %{total} 条"
}

View File

@ -0,0 +1,185 @@
.container {
max-width: 900px;
margin: 0 auto;
padding: 24px;
}
.header {
margin-bottom: 32px;
h2 {
font-size: 24px;
font-weight: 600;
color: #1f2937;
margin: 0 0 8px 0;
}
}
.description {
font-size: 14px;
color: #6b7280;
margin: 0;
}
.listContainer {
display: flex;
flex-direction: column;
gap: 16px;
}
.item {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 20px;
transition: all 0.2s ease;
&:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
border-color: #d1d5db;
}
}
.itemHeader {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
}
.appInfo {
flex: 1;
display: flex;
align-items: center;
gap: 16px;
}
.appLogo {
flex-shrink: 0;
background: #ffffff;
border: 1px solid #d1d5db;
color: #6b7280;
}
.appNameWrapper {
flex: 1;
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.appName {
font-size: 16px;
font-weight: 600;
color: #1f2937;
margin: 0;
}
.appDescription {
font-size: 14px;
color: #6b7280;
margin: 0 0 16px 60px;
line-height: 1.5;
}
.statusActive,
.statusRevoked,
.statusDenied,
.statusExpired,
.statusUnknown {
font-size: 12px;
padding: 2px 10px;
border-radius: 12px;
font-weight: 500;
white-space: nowrap;
}
.statusActive {
background: #d1fae5;
color: #065f46;
}
.statusRevoked {
background: #fee2e2;
color: #991b1b;
}
.statusDenied {
background: #fef3c7;
color: #92400e;
}
.statusExpired {
background: #e5e7eb;
color: #4b5563;
}
.statusUnknown {
background: #f3f4f6;
color: #6b7280;
}
.revokeButton {
padding: 6px 16px;
font-size: 14px;
color: #dc2626;
background: #fff;
border: 1px solid #dc2626;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
font-weight: 500;
&:hover {
background: #dc2626;
color: #fff;
}
&:active {
transform: scale(0.98);
}
}
.itemDetails {
display: flex;
flex-direction: column;
gap: 8px;
padding-top: 12px;
margin-left: 64px;
border-top: 1px solid #f3f4f6;
}
.detailRow {
display: flex;
font-size: 14px;
line-height: 1.5;
}
.label {
color: #6b7280;
min-width: 120px;
font-weight: 500;
}
.value {
color: #1f2937;
flex: 1;
}
.empty {
text-align: center;
padding: 80px 20px;
color: #9ca3af;
}
.emptyIcon {
font-size: 64px;
margin-bottom: 16px;
opacity: 0.5;
}
.empty p {
font-size: 16px;
margin: 0;
}

View File

@ -0,0 +1,194 @@
import React from 'react';
import { RowWithActions, WebComponentProps } from 'oak-frontend-base';
import { Modal, Avatar, Pagination } from 'antd';
import { AppstoreOutlined } from '@ant-design/icons';
import Styles from './styles.module.less';
import { EntityDict } from '../../../oak-app-domain';
import { StringListJson } from '../../../types/datatype';
const Records = (
props: WebComponentProps<
EntityDict,
'oauthUserAuthorization',
true,
{
list: RowWithActions<EntityDict, 'oauthUserAuthorization'>[];
},
{
revoke: (item: RowWithActions<EntityDict, 'oauthUserAuthorization'>) => void;
}
>
) => {
const { list, oakPagination } = props.data;
const { pageSize, currentPage, total } = oakPagination || {};
const { t, revoke, setCurrentPage, setPageSize } = props.methods;
const handleRevoke = (item: RowWithActions<EntityDict, 'oauthUserAuthorization'>) => {
Modal.confirm({
title: t('revoke_confirm_title'),
content: t('revoke_confirm_content', { appName: item.application?.name || t('unknown_app') }),
okText: t('confirm'),
cancelText: t('cancel'),
okButtonProps: { danger: true },
onOk: () => {
revoke(item);
},
});
};
const formatDate = (date: string | Date | number | undefined) => {
if (!date) return '-';
return new Date(date).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
};
function formateScope(scope: StringListJson | undefined): React.ReactNode {
if (!scope || scope.length === 0 || scope.includes('')) {
return t('full_access');
}
return scope.join(', ');
}
const getStatusTag = (item: RowWithActions<EntityDict, 'oauthUserAuthorization'>) => {
if (item.token?.revokedAt) {
return <span className={Styles.statusRevoked}>{t('status_revoked')}</span>;
}
if (item.usageState === 'denied') {
return <span className={Styles.statusDenied}>{t('status_denied')}</span>;
}
if (item.token?.refreshExpiresAt && new Date(item.token.refreshExpiresAt) < new Date()) {
return <span className={Styles.statusExpired}>{t('status_expired')}</span>;
}
if (item.usageState === 'granted') {
return <span className={Styles.statusActive}>{t('status_active')}</span>;
}
if (item.usageState === 'revoked') {
return <span className={Styles.statusRevoked}>{t('status_revoked')}</span>;
}
return <span className={Styles.statusUnknown}>{t('status_unknown')}</span>;
};
const isRevokable = (item: RowWithActions<EntityDict, 'oauthUserAuthorization'>) => {
return item.usageState === 'granted' && !item.token?.revokedAt;
};
return (
<div className={Styles.container}>
<div className={Styles.header}>
<h2>{t('page_title')}</h2>
<p className={Styles.description}>{t('page_description')}</p>
</div>
{!list || list.length === 0 ? (
<div className={Styles.empty}>
<div className={Styles.emptyIcon}>🔐</div>
<p>{t('no_records')}</p>
</div>
) : (
<>
<div className={Styles.listContainer}>
{list.map((item) => {
return (
<div key={item.id} className={Styles.item}>
<div className={Styles.itemHeader}>
<div className={Styles.appInfo}>
<Avatar
src={item.application?.logo}
icon={!item.application?.logo && <AppstoreOutlined />}
size={48}
shape="square"
className={Styles.appLogo}
/>
<div className={Styles.appNameWrapper}>
<h3 className={Styles.appName}>
{item.application?.name || t('unknown_app')}
</h3>
{getStatusTag(item)}
</div>
</div>
{isRevokable(item) && (
<button
className={Styles.revokeButton}
onClick={() => handleRevoke(item)}
>
{t('revoke')}
</button>
)}
</div>
{item.application?.description && (
<p className={Styles.appDescription}>
{item.application?.description}
</p>
)}
<div className={Styles.itemDetails}>
<div className={Styles.detailRow}>
<span className={Styles.label}>{t('authorized_at')}:</span>
<span className={Styles.value}>{formatDate(item.authorizedAt)}</span>
</div>
<div className={Styles.detailRow}>
<span className={Styles.label}>{t('scope')}:</span>
<span className={Styles.value}>
{formateScope(item.code?.scope)}
</span>
</div>
{item.token?.lastUsedAt && (
<div className={Styles.detailRow}>
<span className={Styles.label}>{t('last_used_at')}:</span>
<span className={Styles.value}>{formatDate(item.token.lastUsedAt)}</span>
</div>
)}
{/* {item.token?.accessExpiresAt && (
<div className={Styles.detailRow}>
<span className={Styles.label}>{t('access_expires_at')}:</span>
<span className={Styles.value}>{formatDate(item.token.accessExpiresAt)}</span>
</div>
)} */}
{item.token?.revokedAt && (
<div className={Styles.detailRow}>
<span className={Styles.label}>{t('revoked_at')}:</span>
<span className={Styles.value}>{formatDate(item.token.revokedAt)}</span>
</div>
)}
</div>
</div>
);
})}
</div>
{total && total > 0 && (
<div className={Styles.paginationWrapper}>
<Pagination
current={currentPage}
pageSize={pageSize}
total={total}
onChange={(page) => setCurrentPage(page)}
onShowSizeChange={(current, size) => {
setPageSize(size);
setCurrentPage(1);
}}
showSizeChanger
showQuickJumper
showTotal={(total) => t('pagination_total', { total })}
pageSizeOptions={['5' ,'10', '20', '50', '100']}
className={Styles.pagination}
/>
</div>
)}
</>
)}
</div>
);
};
export default Records;

View File

@ -446,6 +446,36 @@ const i18ns: I18n[] = [
"refreshEndpointPlaceholder": "请输入刷新端点URL"
}
},
{
id: "0b72f69f7fe7160fb6871917d674c12b",
namespace: "oak-general-business-c-oauth-records",
language: "zh-CN",
module: "oak-general-business",
position: "src/components/oauth/records",
data: {
"page_title": "我的授权记录",
"page_description": "管理您授权给第三方应用的访问权限",
"no_records": "暂无授权记录",
"unknown_app": "未知应用",
"revoke": "撤销",
"revoke_confirm_title": "确认撤销授权",
"revoke_confirm_content": "确定要撤销对「%{appName}」的授权吗?撤销后该应用将无法访问您的数据。",
"confirm": "确定",
"cancel": "取消",
"status_active": "已授权",
"status_revoked": "已撤销",
"status_denied": "已拒绝",
"status_expired": "已过期",
"status_unknown": "未知状态",
"authorized_at": "授权时间",
"scope": "授权范围",
"full_access": "完全访问",
"last_used_at": "最后使用",
"access_expires_at": "访问过期时间",
"revoked_at": "撤销时间",
"pagination_total": "共 %{total} 条"
}
},
{
id: "9bdbb9993789ecd48e7b0e10b7117620",
namespace: "oak-general-business-c-passport",

View File

@ -14,58 +14,116 @@ const triggers = [
const { filter } = operation;
assert(filter, 'No filter found in revoke operation');
const datas = await context.select("oauthUserAuthorization", {
data: {
userId: 1,
applicationId: 1,
tokenId: 1,
codeId: 1,
usageState: 1,
},
filter: { ...filter },
}, {})
let res = 0;
// 如果是unused并且code的usedAt是空的则把code的usedAt全部设置为当前时间
const opRes0 = await context.operate("oauthAuthorizationCode", {
id: await generateNewIdAsync(),
action: "update",
data: {
usedAt: new Date()
},
filter: {
usedAt: {
$exists: false
for (const data of datas) {
// 如果是unused并且code的usedAt是空的则把code的usedAt全部设置为当前时间
const opRes0 = await context.operate("oauthAuthorizationCode", {
id: await generateNewIdAsync(),
action: "update",
data: {
usedAt: new Date()
},
oauthUserAuthorization$code: {
...filter,
// 未被使用肯定就没有tokenId
usageState: 'unused',
filter: {
usedAt: {
$exists: false
},
// 某一个用户对某一个应用的授权记录
oauthAppId: data.applicationId,
userId: data.userId,
}
}, {});
res += opRes0.oauthAuthorizationCode?.update || 0;
// // 如果没有token可以直接删除oauthUserAuthorization (可能是复用的之前的token 也可能是未被使用的授权记录)
if (data.usageState === 'unused' && !data.tokenId) {
const opRes = await context.operate("oauthUserAuthorization", {
id: await generateNewIdAsync(),
action: "remove",
data: {},
filter: {
id: data.id,
}
}, {});
res += opRes.oauthApplication?.remove || 0;
}
}, {});
res += opRes0.oauthAuthorizationCode?.update || 0;
// 如果没有token可以直接删除oauthUserAuthorization (可能是复用的之前的token 也可能是未被使用的授权记录)
const opRes = await context.operate("oauthUserAuthorization", {
id: await generateNewIdAsync(),
action: "remove",
data: {},
filter: {
...filter,
// 未被使用肯定就没有tokenId
usageState: 'unused',
// 如果有token则将token的revokedAt设置为当前时间
if (data.tokenId) {
const opRes2 = await context.operate("oauthToken", {
id: await generateNewIdAsync(),
action: "update",
data: {
revokedAt: new Date()
},
filter: {
id: data.tokenId
}
}, {});
res += opRes2.oauthToken?.update || 0;
}
}, {});
}
res += opRes.oauthApplication?.remove || 0;
// // 如果是unused并且code的usedAt是空的则把code的usedAt全部设置为当前时间
// const opRes0 = await context.operate("oauthAuthorizationCode", {
// id: await generateNewIdAsync(),
// action: "update",
// data: {
// usedAt: new Date()
// },
// filter: {
// usedAt: {
// $exists: false
// },
// oauthUserAuthorization$code: {
// ...filter,
// // 未被使用肯定就没有tokenId
// usageState: 'unused',
// }
// }
// }, {});
// 如果有token则将token的revokedAt设置为当前时间
const opRes2 = await context.operate("oauthToken", {
id: await generateNewIdAsync(),
action: "update",
data: {
revokedAt: new Date()
},
filter: {
oauthUserAuthorization$token: {
...filter
}
}
}, {});
// res += opRes0.oauthAuthorizationCode?.update || 0;
res += opRes2.oauthToken?.update || 0;
// // 如果没有token可以直接删除oauthUserAuthorization (可能是复用的之前的token 也可能是未被使用的授权记录)
// const opRes = await context.operate("oauthUserAuthorization", {
// id: await generateNewIdAsync(),
// action: "remove",
// data: {},
// filter: {
// ...filter,
// // 未被使用肯定就没有tokenId
// usageState: 'unused',
// }
// }, {});
// res += opRes.oauthApplication?.remove || 0;
// // 如果有token则将token的revokedAt设置为当前时间
// const opRes2 = await context.operate("oauthToken", {
// id: await generateNewIdAsync(),
// action: "update",
// data: {
// revokedAt: new Date()
// },
// filter: {
// oauthUserAuthorization$token: {
// ...filter
// }
// }
// }, {});
// res += opRes2.oauthToken?.update || 0;
return res;
}