diff --git a/es/components/oauth/records/index.d.ts b/es/components/oauth/records/index.d.ts new file mode 100644 index 000000000..001999ada --- /dev/null +++ b/es/components/oauth/records/index.d.ts @@ -0,0 +1,3 @@ +import { EntityDict } from "../../../oak-app-domain"; +declare const _default: (props: import("oak-frontend-base").ReactComponentProps) => React.ReactElement; +export default _default; diff --git a/es/components/oauth/records/index.js b/es/components/oauth/records/index.js new file mode 100644 index 000000000..bbf966b2f --- /dev/null +++ b/es/components/oauth/records/index.js @@ -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); + } + } +}); diff --git a/es/components/oauth/records/locales/en_US.json b/es/components/oauth/records/locales/en_US.json new file mode 100644 index 000000000..7f9ea2b2c --- /dev/null +++ b/es/components/oauth/records/locales/en_US.json @@ -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" +} diff --git a/es/components/oauth/records/locales/zh_CN.json b/es/components/oauth/records/locales/zh_CN.json new file mode 100644 index 000000000..0e6cdb8ca --- /dev/null +++ b/es/components/oauth/records/locales/zh_CN.json @@ -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} 条" +} diff --git a/es/components/oauth/records/styles.module.less b/es/components/oauth/records/styles.module.less new file mode 100644 index 000000000..b7a581516 --- /dev/null +++ b/es/components/oauth/records/styles.module.less @@ -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; +} \ No newline at end of file diff --git a/es/components/oauth/records/web.pc.d.ts b/es/components/oauth/records/web.pc.d.ts new file mode 100644 index 000000000..2e1766f5c --- /dev/null +++ b/es/components/oauth/records/web.pc.d.ts @@ -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[]; +}, { + revoke: (item: RowWithActions) => void; +}>) => React.JSX.Element; +export default Records; diff --git a/es/components/oauth/records/web.pc.js b/es/components/oauth/records/web.pc.js new file mode 100644 index 000000000..08005af5f --- /dev/null +++ b/es/components/oauth/records/web.pc.js @@ -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 {t('status_revoked')}; + } + if (item.usageState === 'denied') { + return {t('status_denied')}; + } + if (item.token?.refreshExpiresAt && new Date(item.token.refreshExpiresAt) < new Date()) { + return {t('status_expired')}; + } + if (item.usageState === 'granted') { + return {t('status_active')}; + } + if (item.usageState === 'revoked') { + return {t('status_revoked')}; + } + return {t('status_unknown')}; + }; + const isRevokable = (item) => { + return item.usageState === 'granted' && !item.token?.revokedAt; + }; + return (
+
+

{t('page_title')}

+

{t('page_description')}

+
+ + {!list || list.length === 0 ? (
+
🔐
+

{t('no_records')}

+
) : (<> +
+ {list.map((item) => { + return (
+
+
+ } size={48} shape="square" className={Styles.appLogo}/> +
+

+ {item.application?.name || t('unknown_app')} +

+ {getStatusTag(item)} +
+
+ {isRevokable(item) && ()} +
+ + {item.application?.description && (

+ {item.application?.description} +

)} + +
+
+ {t('authorized_at')}: + {formatDate(item.authorizedAt)} +
+ +
+ {t('scope')}: + + {formateScope(item.code?.scope)} + +
+ + {item.token?.lastUsedAt && (
+ {t('last_used_at')}: + {formatDate(item.token.lastUsedAt)} +
)} + + {/* {item.token?.accessExpiresAt && ( +
+ {t('access_expires_at')}: + {formatDate(item.token.accessExpiresAt)} +
+ )} */} + + {item.token?.revokedAt && (
+ {t('revoked_at')}: + {formatDate(item.token.revokedAt)} +
)} +
+
); + })} +
+ + {total && total > 0 && (
+ 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}/> +
)} + )} +
); +}; +export default Records; diff --git a/es/data/i18n.js b/es/data/i18n.js index 187b56a38..8905f1679 100644 --- a/es/data/i18n.js +++ b/es/data/i18n.js @@ -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", diff --git a/es/triggers/oauthUserAuth.js b/es/triggers/oauthUserAuth.js index e8802c153..7f29f94e5 100644 --- a/es/triggers/oauthUserAuth.js +++ b/es/triggers/oauthUserAuth.js @@ -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; } } diff --git a/lib/data/i18n.js b/lib/data/i18n.js index adc021733..6ebf866c3 100644 --- a/lib/data/i18n.js +++ b/lib/data/i18n.js @@ -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", diff --git a/lib/triggers/oauthUserAuth.js b/lib/triggers/oauthUserAuth.js index fd51e554c..13ea0695c 100644 --- a/lib/triggers/oauthUserAuth.js +++ b/lib/triggers/oauthUserAuth.js @@ -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; } } diff --git a/src/components/oauth/records/index.ts b/src/components/oauth/records/index.ts new file mode 100644 index 000000000..d77f5f053 --- /dev/null +++ b/src/components/oauth/records/index.ts @@ -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) { + 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); + } + } +}); diff --git a/src/components/oauth/records/locales/en_US.json b/src/components/oauth/records/locales/en_US.json new file mode 100644 index 000000000..7f9ea2b2c --- /dev/null +++ b/src/components/oauth/records/locales/en_US.json @@ -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" +} diff --git a/src/components/oauth/records/locales/zh_CN.json b/src/components/oauth/records/locales/zh_CN.json new file mode 100644 index 000000000..3ee66486d --- /dev/null +++ b/src/components/oauth/records/locales/zh_CN.json @@ -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} 条" +} \ No newline at end of file diff --git a/src/components/oauth/records/styles.module.less b/src/components/oauth/records/styles.module.less new file mode 100644 index 000000000..b7a581516 --- /dev/null +++ b/src/components/oauth/records/styles.module.less @@ -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; +} \ No newline at end of file diff --git a/src/components/oauth/records/web.pc.tsx b/src/components/oauth/records/web.pc.tsx new file mode 100644 index 000000000..a910ca6c3 --- /dev/null +++ b/src/components/oauth/records/web.pc.tsx @@ -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[]; + }, + { + revoke: (item: RowWithActions) => void; + } + > +) => { + const { list, oakPagination } = props.data; + const { pageSize, currentPage, total } = oakPagination || {}; + const { t, revoke, setCurrentPage, setPageSize } = props.methods; + + const handleRevoke = (item: RowWithActions) => { + 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) => { + if (item.token?.revokedAt) { + return {t('status_revoked')}; + } + if (item.usageState === 'denied') { + return {t('status_denied')}; + } + if (item.token?.refreshExpiresAt && new Date(item.token.refreshExpiresAt) < new Date()) { + return {t('status_expired')}; + } + if (item.usageState === 'granted') { + return {t('status_active')}; + } + if (item.usageState === 'revoked') { + return {t('status_revoked')}; + } + return {t('status_unknown')}; + }; + + const isRevokable = (item: RowWithActions) => { + return item.usageState === 'granted' && !item.token?.revokedAt; + }; + + return ( +
+
+

{t('page_title')}

+

{t('page_description')}

+
+ + {!list || list.length === 0 ? ( +
+
🔐
+

{t('no_records')}

+
+ ) : ( + <> +
+ {list.map((item) => { + return ( +
+
+
+ } + size={48} + shape="square" + className={Styles.appLogo} + /> +
+

+ {item.application?.name || t('unknown_app')} +

+ {getStatusTag(item)} +
+
+ {isRevokable(item) && ( + + )} +
+ + {item.application?.description && ( +

+ {item.application?.description} +

+ )} + +
+
+ {t('authorized_at')}: + {formatDate(item.authorizedAt)} +
+ +
+ {t('scope')}: + + {formateScope(item.code?.scope)} + +
+ + {item.token?.lastUsedAt && ( +
+ {t('last_used_at')}: + {formatDate(item.token.lastUsedAt)} +
+ )} + + {/* {item.token?.accessExpiresAt && ( +
+ {t('access_expires_at')}: + {formatDate(item.token.accessExpiresAt)} +
+ )} */} + + {item.token?.revokedAt && ( +
+ {t('revoked_at')}: + {formatDate(item.token.revokedAt)} +
+ )} +
+
+ ); + })} +
+ + {total && total > 0 && ( +
+ 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} + /> +
+ )} + + )} +
+ ); +}; + +export default Records; \ No newline at end of file diff --git a/src/data/i18n.ts b/src/data/i18n.ts index 220757196..fc3bd4fd7 100644 --- a/src/data/i18n.ts +++ b/src/data/i18n.ts @@ -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", diff --git a/src/triggers/oauthUserAuth.ts b/src/triggers/oauthUserAuth.ts index 4f2a8776d..66981ae1b 100644 --- a/src/triggers/oauthUserAuth.ts +++ b/src/triggers/oauthUserAuth.ts @@ -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; }