From 12a63d437907fb362ab1b6b8eb123ff222aba059 Mon Sep 17 00:00:00 2001 From: RAHEEL Date: Wed, 1 May 2024 18:20:20 +0500 Subject: [PATCH] allow user to create/delete api keys --- client/packages/lowcoder/src/api/userApi.ts | 35 ++++ .../src/constants/reduxActionConstants.ts | 3 + .../lowcoder/src/constants/userConstants.ts | 7 + .../packages/lowcoder/src/i18n/locales/de.ts | 10 +- .../packages/lowcoder/src/i18n/locales/en.ts | 10 +- .../packages/lowcoder/src/i18n/locales/zh.ts | 8 + .../pages/ApplicationV2/UserProfileLayout.tsx | 13 +- .../pages/ApplicationV2/UserProfileView.tsx | 12 +- .../components/CreateApiKeyModal.tsx | 179 ++++++++++++++++++ .../components/UserApiKeysCard.tsx | 143 ++++++++++++++ .../redux/reducers/uiReducers/usersReducer.ts | 11 +- .../src/redux/reduxActions/userActions.ts | 18 +- .../lowcoder/src/redux/sagas/userSagas.ts | 19 +- .../src/redux/selectors/usersSelectors.ts | 6 +- 14 files changed, 461 insertions(+), 13 deletions(-) create mode 100644 client/packages/lowcoder/src/pages/ApplicationV2/components/CreateApiKeyModal.tsx create mode 100644 client/packages/lowcoder/src/pages/ApplicationV2/components/UserApiKeysCard.tsx diff --git a/client/packages/lowcoder/src/api/userApi.ts b/client/packages/lowcoder/src/api/userApi.ts index b599ed061..40324bb86 100644 --- a/client/packages/lowcoder/src/api/userApi.ts +++ b/client/packages/lowcoder/src/api/userApi.ts @@ -38,6 +38,20 @@ export interface GetUserResponse extends ApiResponse { } & BaseUserInfo; } +export interface ApiKeyPayload { + name: string; + description?: string; +} + +export interface FetchApiKeysResponse extends ApiResponse { + data: { + id: string; + name: string; + description: string; + token: string; + } +} + export type GetCurrentUserResponse = GenericApiResponse; class UserApi extends Api { @@ -55,6 +69,9 @@ class UserApi extends Api { static markUserStatusURL = "/users/mark-status"; static userDetailURL = (id: string) => `/users/userDetail/${id}`; static resetPasswordURL = `/users/reset-password`; + static fetchApiKeysURL = `/auth/api-keys`; + static createApiKeyURL = `/auth/api-key`; + static deleteApiKeyURL = (id: string) => `/auth/api-key/${id}`; static thirdPartyLogin( request: ThirdPartyAuthRequest & CommonLoginParam @@ -120,6 +137,24 @@ class UserApi extends Api { static resetPassword(userId: string): AxiosPromise { return Api.post(UserApi.resetPasswordURL, { userId: userId }); } + + static createApiKey({ + name, + description = '' + }: ApiKeyPayload): AxiosPromise { + return Api.post(UserApi.createApiKeyURL, { + name, + description + }); + } + + static fetchApiKeys(): AxiosPromise { + return Api.get(UserApi.fetchApiKeysURL); + } + + static deleteApiKey(apiKeyId: string): AxiosPromise { + return Api.delete(UserApi.deleteApiKeyURL(apiKeyId)); + } } export default UserApi; diff --git a/client/packages/lowcoder/src/constants/reduxActionConstants.ts b/client/packages/lowcoder/src/constants/reduxActionConstants.ts index 90102d79b..f8809c12b 100644 --- a/client/packages/lowcoder/src/constants/reduxActionConstants.ts +++ b/client/packages/lowcoder/src/constants/reduxActionConstants.ts @@ -7,6 +7,9 @@ export const ReduxActionTypes = { FETCH_CURRENT_USER_SUCCESS: "FETCH_CURRENT_USER_SUCCESS", FETCH_RAW_CURRENT_USER: "FETCH_RAW_CURRENT_USER", FETCH_RAW_CURRENT_USER_SUCCESS: "FETCH_RAW_CURRENT_USER_SUCCESS", + FETCH_API_KEYS: "FETCH_API_KEYS", + FETCH_API_KEYS_SUCCESS: "FETCH_API_KEYS_SUCCESS", + /* plugin RELATED */ FETCH_DATA_SOURCE_TYPES: "FETCH_DATA_SOURCE_TYPES", diff --git a/client/packages/lowcoder/src/constants/userConstants.ts b/client/packages/lowcoder/src/constants/userConstants.ts index 413b4942c..7ee4d5094 100644 --- a/client/packages/lowcoder/src/constants/userConstants.ts +++ b/client/packages/lowcoder/src/constants/userConstants.ts @@ -83,4 +83,11 @@ export const defaultCurrentUser: CurrentUser = { extra: {}, }; +export type ApiKey = { + id: string; + name: string; + description: string; + token: string; +} + export type UserStatusType = keyof BaseUserInfo["userStatus"]; diff --git a/client/packages/lowcoder/src/i18n/locales/de.ts b/client/packages/lowcoder/src/i18n/locales/de.ts index b60566870..7982a6edc 100644 --- a/client/packages/lowcoder/src/i18n/locales/de.ts +++ b/client/packages/lowcoder/src/i18n/locales/de.ts @@ -2261,7 +2261,15 @@ export const de: typeof en = { "createdApps": "Erstellte Apps", "createdModules": "Erstellte Module", "onMarketplace": "Auf dem Marktplatz", - "howToPublish": "So veröffentlichen Sie auf dem Marktplatz" + "howToPublish": "So veröffentlichen Sie auf dem Marktplatz", + "apiKeys": "API-Schlüssel", + "createApiKey": "API-Schlüssel erstellen", + "apiKeyName": "Name", + "apiKeyDescription": "Beschreibung", + "apiKey": "API-Schlüssel", + "deleteApiKey": "API-Schlüssel löschen", + "deleteApiKeyContent": "Sind Sie sicher, dass Sie diesen API-Schlüssel löschen möchten?", + "deleteApiKeyError": "Etwas ist schief gelaufen. Bitte versuche es erneut." }, "shortcut": { ...en.shortcut, diff --git a/client/packages/lowcoder/src/i18n/locales/en.ts b/client/packages/lowcoder/src/i18n/locales/en.ts index 3bd6486e7..cf4b58778 100644 --- a/client/packages/lowcoder/src/i18n/locales/en.ts +++ b/client/packages/lowcoder/src/i18n/locales/en.ts @@ -2314,7 +2314,15 @@ export const en = { "createdApps": "Created Apps", "createdModules": "Created Modules", "onMarketplace": "On Marketplace", - "howToPublish": "How to publish on Marketplace" + "howToPublish": "How to publish on Marketplace", + "apiKeys": "API Keys", + "createApiKey": "Create API Key", + "apiKeyName": "Name", + "apiKeyDescription": "Description", + "apiKey": "API Key", + "deleteApiKey": "Delete API Key", + "deleteApiKeyContent": "Are you sure you want to delete this API key?", + "deleteApiKeyError": "Something went wrong. Please try again." }, "shortcut": { "shortcutList": "Keyboard Shortcuts", diff --git a/client/packages/lowcoder/src/i18n/locales/zh.ts b/client/packages/lowcoder/src/i18n/locales/zh.ts index 54ba39b0b..6c7e889aa 100644 --- a/client/packages/lowcoder/src/i18n/locales/zh.ts +++ b/client/packages/lowcoder/src/i18n/locales/zh.ts @@ -2148,6 +2148,14 @@ profile: { createdModules: "创建的模块", onMarketplace: "在市场上", howToPublish: "如何在 Marketplace 上发布", + apiKeys: "API 密钥", + createApiKey: "创建 API 密钥", + apiKeyName: "姓名", + apiKeyDescription: "描述", + apiKey: "API密钥", + deleteApiKey: "删除 API 密钥", + deleteApiKeyContent: "您确定要删除此 API 密钥吗?", + deleteApiKeyError: "出了些问题。请再试一次。" }, shortcut: { shortcutList: "键盘快捷键", diff --git a/client/packages/lowcoder/src/pages/ApplicationV2/UserProfileLayout.tsx b/client/packages/lowcoder/src/pages/ApplicationV2/UserProfileLayout.tsx index 5b1314e3a..b1fee88af 100644 --- a/client/packages/lowcoder/src/pages/ApplicationV2/UserProfileLayout.tsx +++ b/client/packages/lowcoder/src/pages/ApplicationV2/UserProfileLayout.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import CountUp from 'react-countup'; import { useSelector } from "react-redux"; import styled from "styled-components"; @@ -20,7 +19,7 @@ import { ALL_APPLICATIONS_URL } from "constants/routesURL"; import { USER_PROFILE_URL } from "constants/routesURL"; import { default as Divider } from "antd/es/divider"; -import { Avatar, Badge, Button, Card, Col, Row, Space, Typography, Select } from 'antd'; +import { Avatar, Badge, Button, Card, Col, Row, Space, Typography, Select, Table, Flex } from "antd"; import { BlurFinishInput, @@ -40,6 +39,7 @@ import { updateUserAction, updateUserSuccess } from "redux/reduxActions/userActi import { default as Upload, UploadChangeParam } from "antd/es/upload"; import { USER_HEAD_UPLOAD_URL } from "constants/apiConstants"; import { messageInstance } from "lowcoder-design/src/components/GlobalInstances"; +import UserApiKeysCard from './components/UserApiKeysCard'; const { Text, Title, Link } = Typography; const { Option } = Select; @@ -161,7 +161,9 @@ export function UserProfileLayout(props: UserProfileLayoutProps) { const modules = useSelector(modulesSelector); const orgUsers = useSelector(getOrgUsers); const orgGroups = useSelector(getOrgGroups); + const currentOrgId = user.currentOrgId; + const currentOrg = useMemo( () => user.orgs.find((o) => o.id === currentOrgId), [user, currentOrgId] @@ -192,9 +194,6 @@ export function UserProfileLayout(props: UserProfileLayoutProps) { }, 1000); }; - console.log("App Language", language); - console.log("User Language", currentUser.uiLanguage); - if (!user.currentOrgId) { return null; } @@ -395,7 +394,9 @@ export function UserProfileLayout(props: UserProfileLayoutProps) { - + + + diff --git a/client/packages/lowcoder/src/pages/ApplicationV2/UserProfileView.tsx b/client/packages/lowcoder/src/pages/ApplicationV2/UserProfileView.tsx index 0f68f850c..5424158e9 100644 --- a/client/packages/lowcoder/src/pages/ApplicationV2/UserProfileView.tsx +++ b/client/packages/lowcoder/src/pages/ApplicationV2/UserProfileView.tsx @@ -1,17 +1,27 @@ -import { useSelector } from "react-redux"; +import { useDispatch, useSelector } from "react-redux"; import { UserProfileLayout } from "./UserProfileLayout"; import { getUser } from "../../redux/selectors/usersSelectors"; import { trans } from "../../i18n"; import { USER_PROFILE_URL } from "constants/routesURL"; +import { useEffect } from "react"; +import { fetchApiKeysAction } from "redux/reduxActions/userActions"; export function UserProfileView() { const user = useSelector(getUser); + const dispatch = useDispatch(); + + useEffect(() => { + if (!user.currentOrgId) return; + + dispatch(fetchApiKeysAction()); + }, []); if (!user.currentOrgId) { return null; } + return ; }; \ No newline at end of file diff --git a/client/packages/lowcoder/src/pages/ApplicationV2/components/CreateApiKeyModal.tsx b/client/packages/lowcoder/src/pages/ApplicationV2/components/CreateApiKeyModal.tsx new file mode 100644 index 000000000..0a174c0c5 --- /dev/null +++ b/client/packages/lowcoder/src/pages/ApplicationV2/components/CreateApiKeyModal.tsx @@ -0,0 +1,179 @@ +import { useEffect, useMemo, useState } from "react"; +import { + messageInstance, + CustomSelect, + CloseEyeIcon, + CustomModal, + UnderlineCss, +} from "lowcoder-design"; +import { trans } from "i18n"; +import { default as Form } from "antd/es/form"; +import { default as Input } from "antd/es/input"; +import { validateResponse } from "api/apiUtils"; +import _ from "lodash"; +import { styled } from "styled-components"; +import UserApi, { ApiKeyPayload } from "api/userApi"; + +const CustomModalStyled = styled(CustomModal)` + button { + margin-top: 20px; + } +`; + +const FormStyled = styled(Form)` + .ant-form-item-control-input-content > input, + .ant-input-password { + &:hover { + border-color: #8b8fa3; + } + + &:focus, + &.ant-input-affix-wrapper-focused { + border-color: #3377ff; + } + } + + .ant-form-item-label > label { + font-size: 13px; + line-height: 19px; + .has-tip { + ${UnderlineCss}; + } + } + + .ant-input-password-icon.anticon { + color: #8b8fa3; + + &:hover { + color: #222; + } + } + + &.ant-form-vertical .ant-form-item-label { + padding-bottom: 4px; + } + + .ant-form-item-explain-error { + font-size: 12px; + color: #f73131; + line-height: 20px; + } + + .ant-form-item-label + > label.ant-form-item-required:not(.ant-form-item-required-mark-optional)::before { + color: #f73131; + } + + .ant-input-status-error:not(.ant-input-disabled):not(.ant-input-borderless).ant-input, + .ant-input-status-error:not(.ant-input-disabled):not(.ant-input-borderless).ant-input:hover { + border-color: #f73131; + } + + .register { + margin: -4px 0 20px 0; + } + + .ant-input-prefix { + margin-right: 8px; + svg { + path, + rect:nth-of-type(1) { + stroke: #8b8fa3; + } + rect:nth-of-type(2) { + fill: #8b8fa3; + } + } + } +`; + +type CreateApiKeyModalProps = { + modalVisible: boolean; + closeModal: () => void; + onConfigCreate: () => void; +}; + +function CreateApiKeyModal(props: CreateApiKeyModalProps) { + const { + modalVisible, + closeModal, + onConfigCreate + } = props; + const [form] = Form.useForm(); + const [saveLoading, setSaveLoading] = useState(false); + + const handleOk = () => { + form.validateFields().then(values => { + // console.log(values) + createApiKey(values) + }) + } + function createApiKey(values: ApiKeyPayload) { + setSaveLoading(true); + + UserApi.createApiKey(values) + .then((resp) => { + if (validateResponse(resp)) { + messageInstance.success(trans("idSource.saveSuccess")); + } + }) + .catch((e) => messageInstance.error(e.message)) + .finally(() => { + setSaveLoading(false); + onConfigCreate(); + }); + } + + function handleCancel() { + closeModal(); + form.resetFields(); + } + + return ( + form.resetFields()} + > + + + + + + + + + + ); +} + +export default CreateApiKeyModal; diff --git a/client/packages/lowcoder/src/pages/ApplicationV2/components/UserApiKeysCard.tsx b/client/packages/lowcoder/src/pages/ApplicationV2/components/UserApiKeysCard.tsx new file mode 100644 index 000000000..51a8d533f --- /dev/null +++ b/client/packages/lowcoder/src/pages/ApplicationV2/components/UserApiKeysCard.tsx @@ -0,0 +1,143 @@ +import { getApiKeys } from "redux/selectors/usersSelectors"; +import Card from "antd/es/card"; +import Flex from "antd/es/flex"; +import Title from "antd/es/typography/Title"; +import Table from "antd/es/table"; +import { useDispatch, useSelector } from "react-redux"; +import { useState } from "react"; +import { styled } from "styled-components"; +import { AddIcon, CustomModal, EditPopover, TacoButton, messageInstance } from "lowcoder-design"; +import { trans } from "i18n"; +import { PopoverIcon } from "pages/setting/permission/styledComponents"; +import CreateApiKeyModal from "./CreateApiKeyModal"; +import { fetchApiKeysAction } from "redux/reduxActions/userActions"; +import UserApi from "@lowcoder-ee/api/userApi"; +import { validateResponse } from "@lowcoder-ee/api/apiUtils"; + +const TableStyled = styled(Table)` + .ant-table-tbody > tr > td { + padding: 11px 12px; + } +`; + +const OperationWrapper = styled.div` + display: flex; + align-items: center; + justify-content: flex-end; +`; + +const CreateButton = styled(TacoButton)` + svg { + margin-right: 2px; + width: 12px; + height: 12px; + } + + box-shadow: none; +`; + +export default function UserApiKeysCard() { + const dispatch = useDispatch(); + const apiKeys = useSelector(getApiKeys); + const [modalVisible, setModalVisible] = useState(false); + + return ( + <> + + + {trans("profile.apiKeys")} + } + onClick={() => + setModalVisible(true) + } + > + {trans("profile.createApiKey")} + + + ({ + + })} + columns={[ + { + title: trans("profile.apiKeyName"), + dataIndex: "name", + ellipsis: true, + }, + { + title: trans("profile.apiKeyDescription"), + dataIndex: "description", + ellipsis: true, + render: (value: string) => { + return ( + <> + { value || '-'} + + ) + } + }, + { + title: trans("profile.apiKey"), + dataIndex: "token", + ellipsis: true, + render: (value: string) => { + const startToken = value.substring(0, 6); + const endToken = value.substring(value.length - 6); + return ( + <> + { `${startToken}********************${endToken}`} + + ) + } + }, + { title: " ", dataIndex: "operation", width: "208px" }, + ]} + dataSource={apiKeys.map((apiKey, i) => ({ + ...apiKey, + key: i, + operation: ( + + { + CustomModal.confirm({ + title: trans("profile.deleteApiKey"), + content: trans("profile.deleteApiKeyContent"), + onConfirm: () => { + UserApi.deleteApiKey(apiKey.id).then(resp => { + if(validateResponse(resp)) { + dispatch(fetchApiKeysAction()); + } + }) + .catch((e) => { + messageInstance.error(trans("profile.deleteApiKeyError")); + }) + }, + confirmBtnType: "delete", + okText: trans("delete"), + }) + }} + > + + + + ), + }))} + /> + + + setModalVisible(false)} + onConfigCreate={() => { + setModalVisible(false); + dispatch(fetchApiKeysAction()); + }} + /> + + ) +} \ No newline at end of file diff --git a/client/packages/lowcoder/src/redux/reducers/uiReducers/usersReducer.ts b/client/packages/lowcoder/src/redux/reducers/uiReducers/usersReducer.ts index 878499a9d..be4b3a1dd 100644 --- a/client/packages/lowcoder/src/redux/reducers/uiReducers/usersReducer.ts +++ b/client/packages/lowcoder/src/redux/reducers/uiReducers/usersReducer.ts @@ -3,7 +3,7 @@ import { ReduxActionErrorTypes, ReduxActionTypes, } from "constants/reduxActionConstants"; -import { CurrentUser, defaultCurrentUser, defaultUser, User } from "constants/userConstants"; +import { ApiKey, CurrentUser, defaultCurrentUser, defaultUser, User } from "constants/userConstants"; import { UpdateOrgPayload } from "redux/reduxActions/orgActions"; import { MarkUserStatusPayload, UpdateUserPayload } from "redux/reduxActions/userActions"; import { createReducer } from "util/reducerUtils"; @@ -20,6 +20,7 @@ const initialState: UsersReduxState = { currentUser: defaultCurrentUser, rawCurrentUser: defaultCurrentUser, profileSettingModalVisible: false, + apiKeys: [], }; const usersReducer = createReducer(initialState, { @@ -182,6 +183,13 @@ const usersReducer = createReducer(initialState, { }, }; }, + [ReduxActionTypes.FETCH_API_KEYS_SUCCESS]: ( + state: UsersReduxState, + action: ReduxAction> + ): UsersReduxState => ({ + ...state, + apiKeys: action.payload, + }), }); export interface UsersReduxState { @@ -196,6 +204,7 @@ export interface UsersReduxState { }; error: string; profileSettingModalVisible: boolean; + apiKeys: Array; } export default usersReducer; diff --git a/client/packages/lowcoder/src/redux/reduxActions/userActions.ts b/client/packages/lowcoder/src/redux/reduxActions/userActions.ts index 67b5513bc..628d56b63 100644 --- a/client/packages/lowcoder/src/redux/reduxActions/userActions.ts +++ b/client/packages/lowcoder/src/redux/reduxActions/userActions.ts @@ -1,5 +1,5 @@ import { ReduxAction, ReduxActionTypes } from "constants/reduxActionConstants"; -import { UserStatusType } from "constants/userConstants"; +import { ApiKey, UserStatusType } from "constants/userConstants"; export const fetchUserAction = () => ({ type: ReduxActionTypes.FETCH_USER_INIT, @@ -20,6 +20,9 @@ export type UpdateUserPayload = { avatarUrl?: string; uiLanguage?: string; }; + +export type FetchApiKeysPayload = Array; + export const updateUserAction = (payload: UpdateUserPayload) => { return { type: ReduxActionTypes.UPDATE_USER_PROFILE, @@ -58,3 +61,16 @@ export const logoutAction = (payload: LogoutActionPayload) => ({ export const logoutSuccess = () => ({ type: ReduxActionTypes.LOGOUT_USER_SUCCESS, }); + +export const fetchApiKeysAction = () => { + return { + type: ReduxActionTypes.FETCH_API_KEYS, + }; +}; + +export const fetchApiKeysSuccess = (payload: FetchApiKeysPayload) => { + return { + type: ReduxActionTypes.FETCH_API_KEYS_SUCCESS, + payload: payload, + }; +}; diff --git a/client/packages/lowcoder/src/redux/sagas/userSagas.ts b/client/packages/lowcoder/src/redux/sagas/userSagas.ts index e6f3e8610..509bd3088 100644 --- a/client/packages/lowcoder/src/redux/sagas/userSagas.ts +++ b/client/packages/lowcoder/src/redux/sagas/userSagas.ts @@ -1,5 +1,5 @@ import { ApiResponse } from "api/apiResponses"; -import UserApi, { GetCurrentUserResponse, GetUserResponse } from "api/userApi"; +import UserApi, { FetchApiKeysResponse, GetCurrentUserResponse, GetUserResponse } from "api/userApi"; import { AxiosResponse } from "axios"; import { ReduxAction, @@ -177,6 +177,22 @@ function* markUserStatusSaga(action: ReduxAction) { } } +export function* fetchApiKeysSaga() { + try { + const response: AxiosResponse = yield call(UserApi.fetchApiKeys); + const isValidResponse: boolean = validateResponse(response); + if (isValidResponse) { + const apiKeys = response.data.data; + yield put({ + type: ReduxActionTypes.FETCH_API_KEYS_SUCCESS, + payload: apiKeys, + }); + } + } catch(error: any) { + log.error(error); + } +} + export default function* userSagas() { yield all([ takeLatest(ReduxActionTypes.LOGOUT_USER_INIT, logoutSaga), @@ -185,5 +201,6 @@ export default function* userSagas() { takeLatest(ReduxActionTypes.FETCH_RAW_CURRENT_USER, getRawCurrentUserSaga), takeLatest(ReduxActionTypes.UPDATE_USER_PROFILE, updateUserSaga), takeLatest(ReduxActionTypes.MARK_USER_STATUS, markUserStatusSaga), + takeLatest(ReduxActionTypes.FETCH_API_KEYS, fetchApiKeysSaga), ]); } diff --git a/client/packages/lowcoder/src/redux/selectors/usersSelectors.ts b/client/packages/lowcoder/src/redux/selectors/usersSelectors.ts index 391e990e3..8835f4890 100644 --- a/client/packages/lowcoder/src/redux/selectors/usersSelectors.ts +++ b/client/packages/lowcoder/src/redux/selectors/usersSelectors.ts @@ -1,4 +1,4 @@ -import { CurrentUser, User } from "constants/userConstants"; +import { ApiKey, CurrentUser, User } from "constants/userConstants"; import { AppState } from "redux/reducers"; export const getUser = (state: AppState): User => { @@ -33,3 +33,7 @@ export const isProfileUpdating = (state: AppState): boolean => { export const isProfileSettingModalVisible = (state: AppState) => state.ui.users.profileSettingModalVisible; + +export const getApiKeys = (state: AppState): Array => { + return state.ui.users.apiKeys; +};