diff --git a/apps/vben5/apps/app-antd/package.json b/apps/vben5/apps/app-antd/package.json index a77e1336d..675f638eb 100644 --- a/apps/vben5/apps/app-antd/package.json +++ b/apps/vben5/apps/app-antd/package.json @@ -30,6 +30,7 @@ "@abp/auditing": "workspace:*", "@abp/core": "workspace:*", "@abp/identity": "workspace:*", + "@abp/notifications": "workspace:*", "@abp/openiddict": "workspace:*", "@abp/permissions": "workspace:*", "@abp/request": "workspace:*", diff --git a/apps/vben5/apps/app-antd/src/hooks/useSessions.ts b/apps/vben5/apps/app-antd/src/hooks/useSessions.ts new file mode 100644 index 000000000..ae8cc1e28 --- /dev/null +++ b/apps/vben5/apps/app-antd/src/hooks/useSessions.ts @@ -0,0 +1,48 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import type { Notification as NotificationInfo } from '@abp/notifications'; + +import { onMounted, onUnmounted } from 'vue'; + +import { useAbpStore, useEventBus } from '@abp/core'; +import { NotificationNames, useNotifications } from '@abp/notifications'; +import { Modal } from 'ant-design-vue'; + +import { useAuthStore } from '#/store'; + +export function useSessions() { + const authStore = useAuthStore(); + const abpStore = useAbpStore(); + const { subscribe, unSubscribe } = useEventBus(); + const { register, release } = useNotifications(); + + function _register() { + subscribe(NotificationNames.SessionExpiration, _onSessionExpired); + register(); + } + + function _release() { + unSubscribe(NotificationNames.SessionExpiration, _onSessionExpired); + release(); + } + + /** 处理会话过期事件 */ + function _onSessionExpired(event?: NotificationInfo) { + const { data, title, message } = event!; + const sessionId = data.SessionId; + if (sessionId === abpStore.application?.currentUser?.sessionId) { + _release(); + Modal.confirm({ + iconType: 'warning', + title, + content: message, + centered: true, + afterClose: () => { + authStore.logout(); + }, + }); + } + } + + onMounted(_register); + onUnmounted(_release); +} diff --git a/apps/vben5/apps/app-antd/src/layouts/basic.vue b/apps/vben5/apps/app-antd/src/layouts/basic.vue index bc20b84e5..5367f444e 100644 --- a/apps/vben5/apps/app-antd/src/layouts/basic.vue +++ b/apps/vben5/apps/app-antd/src/layouts/basic.vue @@ -23,6 +23,7 @@ import { preferences } from '@vben/preferences'; import { useAccessStore, useUserStore } from '@vben/stores'; import { openWindow } from '@vben/utils'; +import { useSessions } from '#/hooks/useSessions'; import { $t } from '#/locales'; import { useAuthStore } from '#/store'; import LoginForm from '#/views/_core/authentication/login.vue'; @@ -60,6 +61,8 @@ const notifications = ref([ }, ]); +useSessions(); + const { replace } = useRouter(); const userStore = useUserStore(); const authStore = useAuthStore(); diff --git a/apps/vben5/apps/app-antd/src/locales/langs/en-US/abp.json b/apps/vben5/apps/app-antd/src/locales/langs/en-US/abp.json index eafe759ce..083b5adf2 100644 --- a/apps/vben5/apps/app-antd/src/locales/langs/en-US/abp.json +++ b/apps/vben5/apps/app-antd/src/locales/langs/en-US/abp.json @@ -19,7 +19,8 @@ "claimTypes": "Claim Types", "securityLogs": "Security Logs", "organizationUnits": "Organization Units", - "auditLogs": "Audit Logs" + "auditLogs": "Audit Logs", + "sessions": "Sessions" }, "permissions": { "title": "Permissions", @@ -59,7 +60,8 @@ "bindSettings": "Bind Settings", "noticeSettings": "Notice Settings", "authenticatorSettings": "Authenticator Settings", - "changeAvatar": "Change Avatar" + "changeAvatar": "Change Avatar", + "sessionSettings": "Session Settings" }, "profile": "My Profile" } diff --git a/apps/vben5/apps/app-antd/src/locales/langs/zh-CN/abp.json b/apps/vben5/apps/app-antd/src/locales/langs/zh-CN/abp.json index b05654d95..3de89786d 100644 --- a/apps/vben5/apps/app-antd/src/locales/langs/zh-CN/abp.json +++ b/apps/vben5/apps/app-antd/src/locales/langs/zh-CN/abp.json @@ -19,7 +19,8 @@ "claimTypes": "身份标识", "securityLogs": "安全日志", "organizationUnits": "组织机构", - "auditLogs": "审计日志" + "auditLogs": "审计日志", + "sessions": "会话管理" }, "permissions": { "title": "权限管理", @@ -59,7 +60,8 @@ "bindSettings": "账号绑定", "noticeSettings": "新消息通知", "authenticatorSettings": "身份验证程序", - "changeAvatar": "更改头像" + "changeAvatar": "更改头像", + "sessionSettings": "会话管理" }, "profile": "个人中心" } diff --git a/apps/vben5/apps/app-antd/src/router/routes/modules/abp.ts b/apps/vben5/apps/app-antd/src/router/routes/modules/abp.ts index 376a6f185..d0fa18b44 100644 --- a/apps/vben5/apps/app-antd/src/router/routes/modules/abp.ts +++ b/apps/vben5/apps/app-antd/src/router/routes/modules/abp.ts @@ -79,6 +79,15 @@ const routes: RouteRecordRaw[] = [ icon: 'clarity:organization-line', }, }, + { + component: () => import('#/views/identity/sessions/index.vue'), + name: 'IdentitySessions', + path: '/manage/identity/sessions', + meta: { + title: $t('abp.manage.identity.sessions'), + icon: 'carbon:prompt-session', + }, + }, ], }, { diff --git a/apps/vben5/apps/app-antd/src/store/auth.ts b/apps/vben5/apps/app-antd/src/store/auth.ts index 407426e9a..deb7fe4b8 100644 --- a/apps/vben5/apps/app-antd/src/store/auth.ts +++ b/apps/vben5/apps/app-antd/src/store/auth.ts @@ -7,7 +7,7 @@ import { DEFAULT_HOME_PATH, LOGIN_PATH } from '@vben/constants'; import { resetAllStores, useAccessStore, useUserStore } from '@vben/stores'; import { useTokenApi, useUserInfoApi } from '@abp/account'; -import { useAbpStore } from '@abp/core'; +import { Events, useAbpStore, useEventBus } from '@abp/core'; import { notification } from 'ant-design-vue'; import { defineStore } from 'pinia'; @@ -15,6 +15,7 @@ import { useAbpConfigApi } from '#/api/core/useAbpConfigApi'; import { $t } from '#/locales'; export const useAuthStore = defineStore('auth', () => { + const { publish } = useEventBus(); const { loginApi } = useTokenApi(); const { getUserInfoApi } = useUserInfoApi(); const { getConfigApi } = useAbpConfigApi(); @@ -49,6 +50,8 @@ export const useAuthStore = defineStore('auth', () => { userStore.setUserInfo(userInfo); + publish(Events.UserLogin, userInfo); + if (accessStore.loginExpired) { accessStore.setLoginExpired(false); } else { @@ -83,6 +86,8 @@ export const useAuthStore = defineStore('auth', () => { resetAllStores(); accessStore.setLoginExpired(false); + publish(Events.UserLogout); + // 回登录页带上当前路由地址 await router.replace({ path: LOGIN_PATH, diff --git a/apps/vben5/apps/app-antd/src/views/identity/sessions/index.vue b/apps/vben5/apps/app-antd/src/views/identity/sessions/index.vue new file mode 100644 index 000000000..ba2913376 --- /dev/null +++ b/apps/vben5/apps/app-antd/src/views/identity/sessions/index.vue @@ -0,0 +1,15 @@ + + + diff --git a/apps/vben5/apps/app-antd/vite.config.mts b/apps/vben5/apps/app-antd/vite.config.mts index 9ac834557..ae6f52e14 100644 --- a/apps/vben5/apps/app-antd/vite.config.mts +++ b/apps/vben5/apps/app-antd/vite.config.mts @@ -8,22 +8,18 @@ export default defineConfig(async () => { proxy: { '/.well-known': { changeOrigin: true, - // rewrite: (path) => path.replace(/^\/api/, ''), - // mock代理目标地址 target: 'http://127.0.0.1:30001/', - ws: true, }, '/api': { changeOrigin: true, - // rewrite: (path) => path.replace(/^\/api/, ''), - // mock代理目标地址 target: 'http://127.0.0.1:30001/', - ws: true, }, '/connect': { changeOrigin: true, - // rewrite: (path) => path.replace(/^\/api/, ''), - // mock代理目标地址 + target: 'http://127.0.0.1:30001/', + }, + '/signalr-hubs': { + changeOrigin: true, target: 'http://127.0.0.1:30001/', ws: true, }, diff --git a/apps/vben5/packages/@abp/account/package.json b/apps/vben5/packages/@abp/account/package.json index a663164aa..d677830fb 100644 --- a/apps/vben5/packages/@abp/account/package.json +++ b/apps/vben5/packages/@abp/account/package.json @@ -21,6 +21,7 @@ }, "dependencies": { "@abp/core": "workspace:*", + "@abp/identity": "workspace:*", "@abp/request": "workspace:*", "@abp/ui": "workspace:*", "@ant-design/icons-vue": "catalog:", diff --git a/apps/vben5/packages/@abp/account/src/api/index.ts b/apps/vben5/packages/@abp/account/src/api/index.ts index 90f884fc6..2aa1d0ab1 100644 --- a/apps/vben5/packages/@abp/account/src/api/index.ts +++ b/apps/vben5/packages/@abp/account/src/api/index.ts @@ -1,4 +1,5 @@ export { useAccountApi } from './useAccountApi'; +export { useMySessionApi } from './useMySessionApi'; export { useProfileApi } from './useProfileApi'; export { useTokenApi } from './useTokenApi'; export { useUserInfoApi } from './useUserInfoApi'; diff --git a/apps/vben5/packages/@abp/account/src/api/useMySessionApi.ts b/apps/vben5/packages/@abp/account/src/api/useMySessionApi.ts new file mode 100644 index 000000000..8a752a12b --- /dev/null +++ b/apps/vben5/packages/@abp/account/src/api/useMySessionApi.ts @@ -0,0 +1,41 @@ +import type { PagedResultDto } from '@abp/core'; +import type { GetMySessionsInput, IdentitySessionDto } from '@abp/identity'; + +import { useRequest } from '@abp/request'; + +export function useMySessionApi() { + const { cancel, request } = useRequest(); + + /** + * 查询会话列表 + * @param { GetMySessionsInput } input 查询参数 + * @returns { Promise> } 用户会话列表 + */ + function getSessionsApi( + input?: GetMySessionsInput, + ): Promise> { + return request>( + '/api/account/my-profile/sessions', + { + method: 'GET', + params: input, + }, + ); + } + /** + * 撤销会话 + * @param { string } sessionId 会话id + * @returns { Promise } + */ + function revokeSessionApi(sessionId: string): Promise { + return request(`/api/account/my-profile/sessions/${sessionId}/revoke`, { + method: 'DELETE', + }); + } + + return { + cancel, + getSessionsApi, + revokeSessionApi, + }; +} diff --git a/apps/vben5/packages/@abp/account/src/components/MySetting.vue b/apps/vben5/packages/@abp/account/src/components/MySetting.vue index eda031603..f8fb3cd0d 100644 --- a/apps/vben5/packages/@abp/account/src/components/MySetting.vue +++ b/apps/vben5/packages/@abp/account/src/components/MySetting.vue @@ -12,12 +12,25 @@ import { useUserStore } from '@vben/stores'; import { Card, Menu, message, Modal } from 'ant-design-vue'; import { useProfileApi } from '../api/useProfileApi'; -import AuthenticatorSettings from './components/AuthenticatorSettings.vue'; -import BasicSettings from './components/BasicSettings.vue'; -import BindSettings from './components/BindSettings.vue'; -import NoticeSettings from './components/NoticeSettings.vue'; -import SecuritySettings from './components/SecuritySettings.vue'; +const AuthenticatorSettings = defineAsyncComponent( + () => import('./components/AuthenticatorSettings.vue'), +); +const BasicSettings = defineAsyncComponent( + () => import('./components/BasicSettings.vue'), +); +const BindSettings = defineAsyncComponent( + () => import('./components/BindSettings.vue'), +); +const NoticeSettings = defineAsyncComponent( + () => import('./components/NoticeSettings.vue'), +); +const SecuritySettings = defineAsyncComponent( + () => import('./components/SecuritySettings.vue'), +); +const SessionSettings = defineAsyncComponent( + () => import('./components/SessionSettings.vue'), +); const { getApi, updateApi } = useProfileApi(); const userStore = useUserStore(); const { query } = useRoute(); @@ -37,6 +50,10 @@ const menuItems = reactive([ key: 'bind', label: $t('abp.account.settings.bindSettings'), }, + { + key: 'session', + label: $t('abp.account.settings.sessionSettings'), + }, { key: 'notice', label: $t('abp.account.settings.noticeSettings'), @@ -137,6 +154,7 @@ onMounted(async () => { + diff --git a/apps/vben5/packages/@abp/account/src/components/components/SessionSettings.vue b/apps/vben5/packages/@abp/account/src/components/components/SessionSettings.vue new file mode 100644 index 000000000..8e193c9bb --- /dev/null +++ b/apps/vben5/packages/@abp/account/src/components/components/SessionSettings.vue @@ -0,0 +1,48 @@ + + + + + diff --git a/apps/vben5/packages/@abp/core/src/constants/events.ts b/apps/vben5/packages/@abp/core/src/constants/events.ts new file mode 100644 index 000000000..eb2360775 --- /dev/null +++ b/apps/vben5/packages/@abp/core/src/constants/events.ts @@ -0,0 +1,10 @@ +export const Events = { + /** 收到服务器消息 */ + GetNotification: 'get-notification', + /** 新通知消息 */ + NotificationRecevied: 'sys_notifications_recevied', + /** 用户登录事件 */ + UserLogin: 'sys_user_login', + /** 用户登出事件 */ + UserLogout: 'sys_user_logout', +}; diff --git a/apps/vben5/packages/@abp/core/src/constants/index.ts b/apps/vben5/packages/@abp/core/src/constants/index.ts index 4d5ffa36a..b624aad55 100644 --- a/apps/vben5/packages/@abp/core/src/constants/index.ts +++ b/apps/vben5/packages/@abp/core/src/constants/index.ts @@ -1 +1,2 @@ +export * from './events'; export * from './validation'; diff --git a/apps/vben5/packages/@abp/core/src/hooks/index.ts b/apps/vben5/packages/@abp/core/src/hooks/index.ts index 84b8a1dc5..cf94b1a90 100644 --- a/apps/vben5/packages/@abp/core/src/hooks/index.ts +++ b/apps/vben5/packages/@abp/core/src/hooks/index.ts @@ -1,4 +1,5 @@ export * from './useAuthorization'; +export * from './useEventBus'; export * from './useFeatures'; export * from './useGlobalFeatures'; export * from './useLocalization'; diff --git a/apps/vben5/packages/@abp/core/src/hooks/useEventBus.ts b/apps/vben5/packages/@abp/core/src/hooks/useEventBus.ts new file mode 100644 index 000000000..a3339d12a --- /dev/null +++ b/apps/vben5/packages/@abp/core/src/hooks/useEventBus.ts @@ -0,0 +1,30 @@ +import type { EventType, Handler, WildcardHandler } from '../utils/mitt'; + +import mitt from '../utils/mitt'; + +const emitter = mitt(); + +interface EventBus { + /** 发布事件 */ + publish(type: '*', event?: any): void; + /** 发布事件 */ + publish(type: EventType, event?: T): void; + + /** 订阅事件 */ + subscribe(type: '*', handler: WildcardHandler): void; + /** 订阅事件 */ + subscribe(type: EventType, handler: Handler): void; + + /** 退订事件 */ + unSubscribe(type: '*', handler: WildcardHandler): void; + /** 退订事件 */ + unSubscribe(type: EventType, handler: Handler): void; +} + +export function useEventBus(): EventBus { + return { + publish: emitter.emit, + subscribe: emitter.on, + unSubscribe: emitter.off, + }; +} diff --git a/apps/vben5/packages/@abp/core/src/utils/index.ts b/apps/vben5/packages/@abp/core/src/utils/index.ts index 202185507..2109114a1 100644 --- a/apps/vben5/packages/@abp/core/src/utils/index.ts +++ b/apps/vben5/packages/@abp/core/src/utils/index.ts @@ -1,4 +1,5 @@ export * from './date'; +export * from './mitt'; export * from './regex'; export * from './string'; export * from './tree'; diff --git a/apps/vben5/packages/@abp/core/src/utils/mitt.ts b/apps/vben5/packages/@abp/core/src/utils/mitt.ts new file mode 100644 index 000000000..28668e4d2 --- /dev/null +++ b/apps/vben5/packages/@abp/core/src/utils/mitt.ts @@ -0,0 +1,107 @@ +/* eslint-disable array-callback-return */ +/** + * copy to https://github.com/developit/mitt + * Expand clear method + */ + +export type EventType = string | symbol; + +// An event handler can take an optional event argument +// and should not return a value +export type Handler = (event?: T) => void; +export type WildcardHandler = (type: EventType, event?: any) => void; + +// An array of all currently registered event handlers for a type +export type EventHandlerList = Array; +export type WildCardEventHandlerList = Array; + +// A map of event types and their corresponding event handlers. +export type EventHandlerMap = Map< + EventType, + EventHandlerList | WildCardEventHandlerList +>; + +export interface Emitter { + all: EventHandlerMap; + + clear(): void; + emit(type: '*', event?: any): void; + + emit(type: EventType, event?: T): void; + off(type: '*', handler: WildcardHandler): void; + + off(type: EventType, handler: Handler): void; + on(type: '*', handler: WildcardHandler): void; + on(type: EventType, handler: Handler): void; +} + +/** + * Mitt: Tiny (~200b) functional event emitter / pubsub. + * @name mitt + * @returns {Mitt} Emitter + */ +export default function mitt(all?: EventHandlerMap): Emitter { + all = all || new Map(); + + return { + /** + * A Map of event names to registered handler functions. + */ + all, + + /** + * Clear all + */ + clear() { + this.all.clear(); + }, + + /** + * Invoke all handlers for the given type. + * If present, `"*"` handlers are invoked after type-matched handlers. + * + * Note: Manually firing "*" handlers is not supported. + * + * @param {string|symbol} type The event type to invoke + * @param {Any} [evt] Any value (object is recommended and powerful), passed to each handler + * @memberOf mitt + */ + emit(type: EventType, evt: T) { + [...((all?.get(type) || []) as EventHandlerList)].map((handler) => { + handler(evt); + }); + [...((all?.get('*') || []) as WildCardEventHandlerList)].map( + (handler) => { + handler(type, evt); + }, + ); + }, + + /** + * Remove an event handler for the given type. + * @param {string|symbol} type Type of event to unregister `handler` from, or `"*"` + * @param {Function} handler Handler function to remove + * @memberOf mitt + */ + off(type: EventType, handler: Handler) { + const handlers = all?.get(type); + if (handlers) { + handlers.splice(handlers.indexOf(handler) >>> 0, 1); + } + }, + + /** + * Register an event handler for the given type. + * @param {string|symbol} type Type of event to listen for, or `"*"` for all events + * @param {Function} handler Function to call in response to given event + * @memberOf mitt + */ + on(type: EventType, handler: Handler) { + const handlers = all?.get(type); + const added = handlers && handlers.push(handler); + if (!added) { + all?.set(type, [handler]); + } + }, + }; +} diff --git a/apps/vben5/packages/@abp/identity/package.json b/apps/vben5/packages/@abp/identity/package.json index e0f14b212..47dfea8dc 100644 --- a/apps/vben5/packages/@abp/identity/package.json +++ b/apps/vben5/packages/@abp/identity/package.json @@ -33,6 +33,10 @@ "@vben/layouts": "workspace:*", "@vben/locales": "workspace:*", "ant-design-vue": "catalog:", + "lodash.debounce": "catalog:", "vue": "catalog:*" + }, + "devDependencies": { + "@types/lodash.debounce": "catalog:" } } diff --git a/apps/vben5/packages/@abp/identity/src/api/index.ts b/apps/vben5/packages/@abp/identity/src/api/index.ts index 78cb73ef3..bb910e41d 100644 --- a/apps/vben5/packages/@abp/identity/src/api/index.ts +++ b/apps/vben5/packages/@abp/identity/src/api/index.ts @@ -4,3 +4,4 @@ export { useRolesApi } from './useRolesApi'; export { useSecurityLogsApi } from './useSecurityLogsApi'; export { useUserLookupApi } from './useUserLookupApi'; export { useUsersApi } from './useUsersApi'; +export { useUserSessionsApi } from './useUserSessionsApi'; diff --git a/apps/vben5/packages/@abp/identity/src/api/useUserSessionsApi.ts b/apps/vben5/packages/@abp/identity/src/api/useUserSessionsApi.ts new file mode 100644 index 000000000..2125f70bd --- /dev/null +++ b/apps/vben5/packages/@abp/identity/src/api/useUserSessionsApi.ts @@ -0,0 +1,42 @@ +import type { PagedResultDto } from '@abp/core'; + +import type { GetUserSessionsInput, IdentitySessionDto } from '../types'; + +import { useRequest } from '@abp/request'; + +export function useUserSessionsApi() { + const { cancel, request } = useRequest(); + + /** + * 查询会话列表 + * @param { GetUserSessionsInput } input 查询参数 + * @returns { Promise> } 用户会话列表 + */ + function getSessionsApi( + input?: GetUserSessionsInput, + ): Promise> { + return request>( + '/api/identity/sessions', + { + method: 'GET', + params: input, + }, + ); + } + /** + * 撤销会话 + * @param { string } sessionId 会话id + * @returns { Promise } + */ + function revokeSessionApi(sessionId: string): Promise { + return request(`/api/identity/sessions/${sessionId}/revoke`, { + method: 'DELETE', + }); + } + + return { + cancel, + getSessionsApi, + revokeSessionApi, + }; +} diff --git a/apps/vben5/packages/@abp/identity/src/components/index.ts b/apps/vben5/packages/@abp/identity/src/components/index.ts index 35188be4d..15b90b088 100644 --- a/apps/vben5/packages/@abp/identity/src/components/index.ts +++ b/apps/vben5/packages/@abp/identity/src/components/index.ts @@ -2,4 +2,6 @@ export { default as ClaimTypeTable } from './claim-types/ClaimTypeTable.vue'; export { default as OrganizationUnitPage } from './organization-units/OrganizationUnitPage.vue'; export { default as RoleTable } from './roles/RoleTable.vue'; export { default as SecurityLogTable } from './security-logs/SecurityLogTable.vue'; +export { default as SessionTable } from './sessions/SessionTable.vue'; +export { default as UserSessionTable } from './sessions/UserSessionTable.vue'; export { default as UserTable } from './users/UserTable.vue'; diff --git a/apps/vben5/packages/@abp/identity/src/components/sessions/SessionTable.vue b/apps/vben5/packages/@abp/identity/src/components/sessions/SessionTable.vue new file mode 100644 index 000000000..a111f43f7 --- /dev/null +++ b/apps/vben5/packages/@abp/identity/src/components/sessions/SessionTable.vue @@ -0,0 +1,243 @@ + + + + + diff --git a/apps/vben5/packages/@abp/identity/src/components/sessions/UserSessionTable.vue b/apps/vben5/packages/@abp/identity/src/components/sessions/UserSessionTable.vue new file mode 100644 index 000000000..dd131764f --- /dev/null +++ b/apps/vben5/packages/@abp/identity/src/components/sessions/UserSessionTable.vue @@ -0,0 +1,158 @@ + + + + + diff --git a/apps/vben5/packages/@abp/identity/src/components/users/UserSessionDrawer.vue b/apps/vben5/packages/@abp/identity/src/components/users/UserSessionDrawer.vue new file mode 100644 index 000000000..c75952b81 --- /dev/null +++ b/apps/vben5/packages/@abp/identity/src/components/users/UserSessionDrawer.vue @@ -0,0 +1,67 @@ + + + + + diff --git a/apps/vben5/packages/@abp/identity/src/components/users/UserTable.vue b/apps/vben5/packages/@abp/identity/src/components/users/UserTable.vue index a72b74654..f786ee42f 100644 --- a/apps/vben5/packages/@abp/identity/src/components/users/UserTable.vue +++ b/apps/vben5/packages/@abp/identity/src/components/users/UserTable.vue @@ -26,7 +26,10 @@ import { import { Button, Dropdown, Menu, message, Modal } from 'ant-design-vue'; import { useUsersApi } from '../../api/useUsersApi'; -import { IdentityUserPermissions } from '../../constants/permissions'; +import { + IdentitySessionPermissions, + IdentityUserPermissions, +} from '../../constants/permissions'; defineOptions({ name: 'UserTable', @@ -46,6 +49,7 @@ const PasswordIcon = createIconifyIcon('carbon:password'); const MenuOutlined = createIconifyIcon('heroicons-outline:menu-alt-3'); const ClaimOutlined = createIconifyIcon('la:id-card-solid'); const PermissionsOutlined = createIconifyIcon('icon-park-outline:permissions'); +const SessionIcon = createIconifyIcon('carbon:prompt-session'); const AuditLogIcon = createIconifyIcon('fluent-mdl2:compliance-audit'); const getLockEnd = computed(() => { @@ -166,6 +170,11 @@ const [UserPermissionModal, permissionModalApi] = useVbenModal({ const [UserChangeDrawer, userChangeDrawerApi] = useVbenDrawer({ connectedComponent: EntityChangeDrawer, }); +const [UserSessionDrawer, userSessionDrawerApi] = useVbenDrawer({ + connectedComponent: defineAsyncComponent( + () => import('./UserSessionDrawer.vue'), + ), +}); const [Grid, { query }] = useVbenVxeGrid({ formOptions, gridEvents, @@ -240,6 +249,11 @@ const handleMenuClick = async (row: IdentityUserDto, info: MenuInfo) => { permissionModalApi.open(); break; } + case 'session': { + userSessionDrawerApi.setData(row); + userSessionDrawerApi.open(); + break; + } case 'unlock': { handleUnlock(row); break; @@ -330,6 +344,13 @@ const handleMenuClick = async (row: IdentityUserDto, info: MenuInfo) => { > {{ $t('AbpPermissionManagement.Permissions') }} + + {{ $t('AbpIdentity.IdentitySessions') }} + query()" /> + diff --git a/apps/vben5/packages/@abp/identity/src/constants/permissions.ts b/apps/vben5/packages/@abp/identity/src/constants/permissions.ts index 36a0b622a..54cc5565a 100644 --- a/apps/vben5/packages/@abp/identity/src/constants/permissions.ts +++ b/apps/vben5/packages/@abp/identity/src/constants/permissions.ts @@ -46,6 +46,12 @@ export const SecurityLogPermissions = { /** 删除 */ Delete: 'AbpAuditing.SecurityLog.Delete', }; +/** 用户会话权限 */ +export const IdentitySessionPermissions = { + Default: 'AbpIdentity.IdentitySessions', + /** 移除 */ + Revoke: 'AbpIdentity.IdentitySessions.Revoke', +}; /** * 搜索用户权限 * @deprecated 后台服务删除权限后将无法使用. diff --git a/apps/vben5/packages/@abp/identity/src/types/index.ts b/apps/vben5/packages/@abp/identity/src/types/index.ts index 6fbf916c4..86f1233c6 100644 --- a/apps/vben5/packages/@abp/identity/src/types/index.ts +++ b/apps/vben5/packages/@abp/identity/src/types/index.ts @@ -2,4 +2,5 @@ export * from './claim-types'; export * from './organization-units'; export * from './roles'; export * from './security-logs'; +export * from './sessions'; export * from './users'; diff --git a/apps/vben5/packages/@abp/identity/src/types/sessions.ts b/apps/vben5/packages/@abp/identity/src/types/sessions.ts new file mode 100644 index 000000000..64f9f07fe --- /dev/null +++ b/apps/vben5/packages/@abp/identity/src/types/sessions.ts @@ -0,0 +1,25 @@ +import type { EntityDto, PagedAndSortedResultRequestDto } from '@abp/core'; + +interface IdentitySessionDto extends EntityDto { + clientId?: string; + device: string; + deviceInfo: string; + ipAddresses?: string; + lastAccessed?: Date; + sessionId: string; + signedIn: Date; + userId: string; +} + +interface GetUserSessionsInput extends PagedAndSortedResultRequestDto { + clientId?: string; + device?: string; + userId?: string; +} + +interface GetMySessionsInput extends PagedAndSortedResultRequestDto { + clientId?: string; + device?: string; +} + +export type { GetMySessionsInput, GetUserSessionsInput, IdentitySessionDto }; diff --git a/apps/vben5/packages/@abp/notifications/package.json b/apps/vben5/packages/@abp/notifications/package.json index 4d2262b37..9b8708530 100644 --- a/apps/vben5/packages/@abp/notifications/package.json +++ b/apps/vben5/packages/@abp/notifications/package.json @@ -22,6 +22,7 @@ "dependencies": { "@abp/core": "workspace:*", "@abp/request": "workspace:*", + "@abp/signalr": "workspace:*", "@abp/ui": "workspace:*", "@ant-design/icons-vue": "catalog:", "@vben/access": "workspace:*", diff --git a/apps/vben5/packages/@abp/notifications/src/constants/index.ts b/apps/vben5/packages/@abp/notifications/src/constants/index.ts index e69de29bb..9ea5ce778 100644 --- a/apps/vben5/packages/@abp/notifications/src/constants/index.ts +++ b/apps/vben5/packages/@abp/notifications/src/constants/index.ts @@ -0,0 +1 @@ +export * from './notifications'; diff --git a/apps/vben5/packages/@abp/notifications/src/constants/notifications.ts b/apps/vben5/packages/@abp/notifications/src/constants/notifications.ts new file mode 100644 index 000000000..7b932c481 --- /dev/null +++ b/apps/vben5/packages/@abp/notifications/src/constants/notifications.ts @@ -0,0 +1,4 @@ +export const NotificationNames = { + /** 会话过期通知 */ + SessionExpiration: 'AbpIdentity.Session.Expiration', +}; diff --git a/apps/vben5/packages/@abp/notifications/src/hooks/index.ts b/apps/vben5/packages/@abp/notifications/src/hooks/index.ts new file mode 100644 index 000000000..59c19dc79 --- /dev/null +++ b/apps/vben5/packages/@abp/notifications/src/hooks/index.ts @@ -0,0 +1,2 @@ +export * from './useNotifications'; +export * from './useNotificationSerializer'; diff --git a/apps/vben5/packages/@abp/notifications/src/hooks/useNotificationSerializer.ts b/apps/vben5/packages/@abp/notifications/src/hooks/useNotificationSerializer.ts new file mode 100644 index 000000000..c9bfff54d --- /dev/null +++ b/apps/vben5/packages/@abp/notifications/src/hooks/useNotificationSerializer.ts @@ -0,0 +1,56 @@ +import type { Notification, NotificationInfo } from '../types'; + +import { useLocalization } from '@abp/core'; + +export function useNotificationSerializer() { + function deserialize(notificationInfo: NotificationInfo): Notification { + const { data } = notificationInfo; + let title = data.extraProperties.title; + let message = data.extraProperties.message; + let description = data.extraProperties.description; + if (data.extraProperties.L === true || data.extraProperties.L === 'true') { + const { L } = useLocalization([ + data.extraProperties.title.resourceName ?? + data.extraProperties.title.ResourceName, + data.extraProperties.message.resourceName ?? + data.extraProperties.message.ResourceName, + data.extraProperties.description?.resourceName ?? + data.extraProperties.description?.ResourceName ?? + 'AbpUi', + ]); + title = L( + data.extraProperties.title.name ?? data.extraProperties.title.Name, + data.extraProperties.title.values ?? data.extraProperties.title.Values, + ); + message = L( + data.extraProperties.message.name ?? data.extraProperties.message.Name, + data.extraProperties.message.values ?? + data.extraProperties.message.Values, + ); + if (description) { + description = L( + data.extraProperties.description.name ?? + data.extraProperties.description.Name, + data.extraProperties.description.values ?? + data.extraProperties.description.Values, + ); + } + } + return { + contentType: notificationInfo.contentType, + creationTime: notificationInfo.creationTime, + data: data.extraProperties, + description, + lifetime: notificationInfo.lifetime, + message, + name: notificationInfo.name, + severity: notificationInfo.severity, + title, + type: notificationInfo.type, + }; + } + + return { + deserialize, + }; +} diff --git a/apps/vben5/packages/@abp/notifications/src/hooks/useNotifications.ts b/apps/vben5/packages/@abp/notifications/src/hooks/useNotifications.ts new file mode 100644 index 000000000..39b2370a6 --- /dev/null +++ b/apps/vben5/packages/@abp/notifications/src/hooks/useNotifications.ts @@ -0,0 +1,113 @@ +import type { Notification, NotificationInfo } from '../types/notifications'; + +import { Events, useEventBus } from '@abp/core'; +import { useSignalR } from '@abp/signalr'; +import { notification } from 'ant-design-vue'; + +import { + NotificationContentType, + NotificationSeverity, + NotificationType, +} from '../types/notifications'; +import { useNotificationSerializer } from './useNotificationSerializer'; + +export function useNotifications() { + const { deserialize } = useNotificationSerializer(); + const { publish, subscribe, unSubscribe } = useEventBus(); + const { init, off, on, onStart, stop } = useSignalR(); + + /** 注册通知 */ + function register() { + _registerEvents(); + _init(); + } + + /** 释放通知 */ + function release() { + _releaseEvents(); + stop(); + } + + function _init() { + onStart(() => on(Events.GetNotification, _onNotifyReceived)); + init({ + autoStart: true, + serverUrl: '/signalr-hubs/notifications', + useAccessToken: true, + }); + } + + /** 注册通知事件 */ + function _registerEvents() { + subscribe(Events.UserLogout, stop); + } + + /** 释放通知事件 */ + function _releaseEvents() { + unSubscribe(Events.UserLogout, stop); + off(Events.GetNotification, _onNotifyReceived); + } + + /** 接收通知回调 */ + function _onNotifyReceived(notificationInfo: NotificationInfo) { + const notification = deserialize(notificationInfo); + if (notification.type === NotificationType.ServiceCallback) { + publish(notification.name, notification); + return; + } + publish(Events.NotificationRecevied, notification); + _notification(notification); + } + + /** 通知推送 */ + function _notification(notifier: Notification) { + let message = notifier.description; + switch (notifier.contentType) { + case NotificationContentType.Html: + case NotificationContentType.Json: + case NotificationContentType.Markdown: { + message = notifier.title; + break; + } + case NotificationContentType.Text: { + message = notifier.description; + } + } + switch (notifier.severity) { + case NotificationSeverity.Error: + case NotificationSeverity.Fatal: { + notification.error({ + description: message, + message: notifier.title, + }); + break; + } + case NotificationSeverity.Info: { + notification.info({ + description: message, + message: notifier.title, + }); + break; + } + case NotificationSeverity.Success: { + notification.success({ + description: message, + message: notifier.title, + }); + break; + } + case NotificationSeverity.Warn: { + notification.warning({ + description: message, + message: notifier.title, + }); + break; + } + } + } + + return { + register, + release, + }; +} diff --git a/apps/vben5/packages/@abp/notifications/src/index.ts b/apps/vben5/packages/@abp/notifications/src/index.ts index f43dbaee0..48e3b6454 100644 --- a/apps/vben5/packages/@abp/notifications/src/index.ts +++ b/apps/vben5/packages/@abp/notifications/src/index.ts @@ -1,4 +1,5 @@ export * from './api'; export * from './components'; export * from './constants'; +export * from './hooks'; export * from './types'; diff --git a/apps/vben5/packages/@abp/notifications/src/types/notifications.ts b/apps/vben5/packages/@abp/notifications/src/types/notifications.ts index bf780d040..a1eeb8b10 100644 --- a/apps/vben5/packages/@abp/notifications/src/types/notifications.ts +++ b/apps/vben5/packages/@abp/notifications/src/types/notifications.ts @@ -75,7 +75,21 @@ interface NotificationGroupDto { notifications: NotificationDto[]; } +interface Notification { + contentType: NotificationContentType; + creationTime: Date; + data: Record; + description?: string; + lifetime: NotificationLifetime; + message: string; + name: string; + severity: NotificationSeverity; + title: string; + type: NotificationType; +} + export type { + Notification, NotificationData, NotificationDto, NotificationGroupDto, diff --git a/apps/vben5/packages/@abp/signalr/package.json b/apps/vben5/packages/@abp/signalr/package.json new file mode 100644 index 000000000..62c8d715c --- /dev/null +++ b/apps/vben5/packages/@abp/signalr/package.json @@ -0,0 +1,27 @@ +{ + "name": "@abp/signalr", + "version": "8.3.2", + "homepage": "https://github.com/colinin/abp-next-admin", + "bugs": "https://github.com/colinin/abp-next-admin/issues", + "repository": { + "type": "git", + "url": "git+https://github.com/colinin/abp-next-admin.git", + "directory": "packages/@abp/signalr" + }, + "license": "MIT", + "type": "module", + "sideEffects": [ + "**/*.css" + ], + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + } + }, + "dependencies": { + "@abp/core": "workspace:*", + "@microsoft/signalr": "catalog:", + "@vben/stores": "workspace:*" + } +} diff --git a/apps/vben5/packages/@abp/signalr/src/hooks/index.ts b/apps/vben5/packages/@abp/signalr/src/hooks/index.ts new file mode 100644 index 000000000..d5bdc52ff --- /dev/null +++ b/apps/vben5/packages/@abp/signalr/src/hooks/index.ts @@ -0,0 +1 @@ +export * from './useSignalR'; diff --git a/apps/vben5/packages/@abp/signalr/src/hooks/useSignalR.ts b/apps/vben5/packages/@abp/signalr/src/hooks/useSignalR.ts new file mode 100644 index 000000000..99cc2c057 --- /dev/null +++ b/apps/vben5/packages/@abp/signalr/src/hooks/useSignalR.ts @@ -0,0 +1,155 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import type { HubConnection, IHttpConnectionOptions } from '@microsoft/signalr'; + +import { useAccessStore } from '@vben/stores'; + +import { useEventBus } from '@abp/core'; +import { HubConnectionBuilder, LogLevel } from '@microsoft/signalr'; + +interface SignalROptions { + /** 断线自动重连 */ + automaticReconnect?: boolean; + /** 初始化自动建立连接 */ + autoStart?: boolean; + /** 下次重试间隔(ms) */ + nextRetryDelayInMilliseconds?: number; + /** 服务端url */ + serverUrl: string; + /** 是否携带访问令牌 */ + useAccessToken?: boolean; +} + +export function useSignalR() { + const { publish, subscribe } = useEventBus(); + + let connection: HubConnection | null = null; + + /** 初始化SignalR */ + async function init({ + automaticReconnect = true, + autoStart = false, + nextRetryDelayInMilliseconds = 60_000, + serverUrl, + useAccessToken = true, + }: SignalROptions) { + const httpOptions: IHttpConnectionOptions = {}; + if (useAccessToken) { + const accessStore = useAccessStore(); + const token = accessStore.accessToken; + if (token) { + httpOptions.accessTokenFactory = () => + token.startsWith('Bearer ') ? token.slice(7) : token; + } + } + const connectionBuilder = new HubConnectionBuilder() + .withUrl(serverUrl, httpOptions) + .configureLogging(LogLevel.Warning); + if (automaticReconnect && nextRetryDelayInMilliseconds) { + connectionBuilder.withAutomaticReconnect({ + nextRetryDelayInMilliseconds: () => nextRetryDelayInMilliseconds, + }); + } + connection = connectionBuilder.build(); + if (autoStart) { + await start(); + } + } + + /** 启动连接 */ + async function start(): Promise { + _throwIfNotInit(); + publish('signalR:beforeStart'); + try { + await connection!.start(); + publish('signalR:onStart'); + } catch (error) { + publish('signalR:onError', error); + } + } + + /** 关闭连接 */ + async function stop(): Promise { + _throwIfNotInit(); + publish('signalR:beforeStop'); + try { + await connection!.stop(); + publish('signalR:onStop'); + } catch (error) { + publish('signalR:onError', error); + } + } + + /** 连接前事件 */ + function beforeStart(callback: (event?: T) => void) { + subscribe('signalR:beforeStart', callback); + } + + /** 连接后事件 */ + function onStart(callback: (event?: T) => void) { + subscribe('signalR:onStart', callback); + } + + /** 关闭连接前事件 */ + function beforeStop(callback: (event?: T) => void) { + subscribe('signalR:beforeStop', callback); + } + + /** 关闭连接后事件 */ + function onStop(callback: (event?: T) => void) { + subscribe('signalR:onStop', callback); + } + + /** 连接错误事件 */ + function onError(callback: (error?: Error) => void) { + subscribe('signalR:onError', callback); + } + + /** 订阅服务端消息 */ + function on(methodName: string, newMethod: (...args: any[]) => void): void { + connection?.on(methodName, newMethod); + } + + /** 注销服务端消息 */ + function off(methodName: string, method: (...args: any[]) => void): void { + connection?.off(methodName, method); + } + + /** 连接关闭事件 */ + function onClose(callback: (error?: Error) => void): void { + connection?.onclose(callback); + } + + /** 发送消息 */ + function send(methodName: string, ...args: any[]): Promise { + _throwIfNotInit(); + return connection!.send(methodName, ...args); + } + + /** 调用函数 */ + function invoke(methodName: string, ...args: any[]): Promise { + _throwIfNotInit(); + return connection!.invoke(methodName, ...args); + } + + function _throwIfNotInit() { + if (connection === null) { + throw new Error('unable to send message, connection not initialized!'); + } + } + + return { + beforeStart, + beforeStop, + init, + invoke, + off, + on, + onClose, + onError, + onStart, + onStop, + send, + start, + stop, + }; +} diff --git a/apps/vben5/packages/@abp/signalr/src/index.ts b/apps/vben5/packages/@abp/signalr/src/index.ts new file mode 100644 index 000000000..4cc90d02b --- /dev/null +++ b/apps/vben5/packages/@abp/signalr/src/index.ts @@ -0,0 +1 @@ +export * from './hooks'; diff --git a/apps/vben5/packages/@abp/signalr/tsconfig.json b/apps/vben5/packages/@abp/signalr/tsconfig.json new file mode 100644 index 000000000..ce1a891fb --- /dev/null +++ b/apps/vben5/packages/@abp/signalr/tsconfig.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@vben/tsconfig/web.json", + "include": ["src"], + "exclude": ["node_modules"] +} diff --git a/apps/vben5/pnpm-workspace.yaml b/apps/vben5/pnpm-workspace.yaml index 1337072fc..1f751e415 100644 --- a/apps/vben5/pnpm-workspace.yaml +++ b/apps/vben5/pnpm-workspace.yaml @@ -32,6 +32,7 @@ catalog: '@intlify/unplugin-vue-i18n': ^6.0.1 '@jspm/generator': ^2.4.1 '@manypkg/get-packages': ^2.2.2 + '@microsoft/signalr': ^8.0.7 '@nolebase/vitepress-plugin-git-changelog': ^2.11.1 '@playwright/test': ^1.49.1 '@pnpm/workspace.read-manifest': ^1000.0.1