Browse Source

Merge pull request #6420 from nocodb/feat/token

feat: new token table design
pull/6478/head
Raju Udava 1 year ago committed by GitHub
parent
commit
7991d13973
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 348
      packages/nc-gui/components/account/Token.vue
  2. 22
      tests/playwright/pages/Account/Token.ts

348
packages/nc-gui/components/account/Token.vue

@ -1,8 +1,8 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { VNodeRef } from '@vue/runtime-core' import type { VNodeRef } from '@vue/runtime-core'
import { Empty, message } from 'ant-design-vue' import { message } from 'ant-design-vue'
import type { ApiTokenType, RequestParams, UserType } from 'nocodb-sdk' import type { ApiTokenType, RequestParams } from 'nocodb-sdk'
import { extractSdkResponseErrorMsg, iconMap, ref, useApi, useCopy, useNuxtApp } from '#imports' import { extractSdkResponseErrorMsg, ref, useApi, useCopy, useNuxtApp } from '#imports'
const { api, isLoading } = useApi() const { api, isLoading } = useApi()
@ -12,7 +12,16 @@ const { copy } = useCopy()
const { t } = useI18n() const { t } = useI18n()
const tokens = ref<UserType[]>([]) interface IApiTokenInfo extends ApiTokenType {
created_by: string
}
const tokens = ref<IApiTokenInfo[]>([])
const selectedToken = reactive({
isShow: false,
id: '',
})
const currentPage = ref(1) const currentPage = ref(1)
@ -20,7 +29,11 @@ const showNewTokenModal = ref(false)
const currentLimit = ref(10) const currentLimit = ref(10)
const selectedTokenData = ref<ApiTokenType>({}) const defaultTokenName = 'Untitled token'
const selectedTokenData = ref<ApiTokenType>({
description: defaultTokenName,
})
const searchText = ref<string>('') const searchText = ref<string>('')
@ -28,6 +41,17 @@ const pagination = reactive({
total: 0, total: 0,
pageSize: 10, pageSize: 10,
}) })
const hideOrShowToken = (tokenId: string) => {
if (selectedToken.isShow && selectedToken.id === tokenId) {
selectedToken.isShow = false
selectedToken.id = ''
} else {
selectedToken.isShow = true
selectedToken.id = tokenId
}
}
const loadTokens = async (page = currentPage.value, limit = currentLimit.value) => { const loadTokens = async (page = currentPage.value, limit = currentLimit.value) => {
currentPage.value = page currentPage.value = page
try { try {
@ -42,7 +66,7 @@ const loadTokens = async (page = currentPage.value, limit = currentLimit.value)
pagination.total = response.pageInfo.totalRows ?? 0 pagination.total = response.pageInfo.totalRows ?? 0
pagination.pageSize = 10 pagination.pageSize = 10
tokens.value = response.list as UserType[] tokens.value = response.list as IApiTokenInfo[]
} catch (e: any) { } catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e)) message.error(await extractSdkResponseErrorMsg(e))
} }
@ -53,18 +77,17 @@ loadTokens()
const isModalOpen = ref(false) const isModalOpen = ref(false)
const tokenDesc = ref('') const tokenDesc = ref('')
const tokenToCopy = ref('') const tokenToCopy = ref('')
const isValidTokenName = ref(false)
const openModal = (tk: string, desc: string) => {
isModalOpen.value = true
tokenToCopy.value = tk
tokenDesc.value = desc
}
const deleteToken = async (token: string): Promise<void> => { const deleteToken = async (token: string): Promise<void> => {
try { try {
await api.orgTokens.delete(token) await api.orgTokens.delete(token)
// message.success(t('msg.success.tokenDeleted')) // message.success(t('msg.success.tokenDeleted'))
await loadTokens() await loadTokens()
if (!tokens.value.length && currentPage.value !== 1) {
currentPage.value--
loadTokens(currentPage.value)
}
} catch (e: any) { } catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e)) message.error(await extractSdkResponseErrorMsg(e))
} }
@ -74,7 +97,15 @@ const deleteToken = async (token: string): Promise<void> => {
tokenDesc.value = '' tokenDesc.value = ''
} }
const validateTokenName = (tokenName: string | undefined) => {
if (!tokenName) return false
return tokenName.length < 255
}
const generateToken = async () => { const generateToken = async () => {
isValidTokenName.value = validateTokenName(selectedTokenData.value.description)
if (!isValidTokenName.value) return
try { try {
await api.orgTokens.create(selectedTokenData.value) await api.orgTokens.create(selectedTokenData.value)
showNewTokenModal.value = false showNewTokenModal.value = false
@ -84,8 +115,10 @@ const generateToken = async () => {
await loadTokens() await loadTokens()
} catch (e: any) { } catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e)) message.error(await extractSdkResponseErrorMsg(e))
} finally {
selectedTokenData.value.description = defaultTokenName
$e('a:api-token:generate')
} }
$e('a:api-token:generate')
} }
const copyToken = async (token: string | undefined) => { const copyToken = async (token: string | undefined) => {
@ -102,129 +135,152 @@ const copyToken = async (token: string | undefined) => {
} }
} }
const descriptionInput: VNodeRef = (el) => (el as HTMLInputElement)?.focus() const triggerDeleteModal = (tokenToDelete: string, tokenDescription: string) => {
tokenToCopy.value = tokenToDelete
tokenDesc.value = tokenDescription
isModalOpen.value = true
}
const selectInputOnMount: VNodeRef = (el) =>
selectedTokenData.value.description === defaultTokenName && (el as HTMLInputElement)?.select()
const errorMessage = computed(() => {
const tokenLength = selectedTokenData.value.description?.length
if (!tokenLength) {
return 'Token name should not be empty'
} else if (tokenLength > 255) {
return 'Token name should not be more than 255 characters'
}
})
const handleCancel = () => {
showNewTokenModal.value = false
isValidTokenName.value = false
}
</script> </script>
<template> <template>
<div class="h-full overflow-y-scroll scrollbar-thin-dull pt-2"> <div class="h-full overflow-y-scroll scrollbar-thin-dull pt-2">
<div class="max-w-[900px] mx-auto p-4" data-testid="nc-token-list"> <div class="max-w-[810px] mx-auto p-4" data-testid="nc-token-list">
<div class="text-xl my-4 text-left font-weight-bold">{{ $t('title.tokenManagement') }}</div> <div class="py-2 flex gap-4 items-center justify-between">
<div class="py-2 flex gap-4 items-center"> <h6 class="text-2xl my-4 text-left font-bold">API Tokens</h6>
<div class="flex-grow"></div> <NcButton
<component :is="iconMap.reload" class="cursor-pointer" @click="() => loadTokens()" /> :disabled="showNewTokenModal"
<a-button
class="!rounded-md" class="!rounded-md"
data-testid="nc-token-create" data-testid="nc-token-create"
size="middle" size="middle"
type="primary" type="primary"
@click="showNewTokenModal = true" @click="showNewTokenModal = true"
> >
<div class="flex items-center gap-1"> <span class="hidden md:block">
<component :is="iconMap.plus" />
{{ $t('title.addNewToken') }} {{ $t('title.addNewToken') }}
</div> </span>
</a-button> <span class="flex items-center justify-center md:hidden">
<component :is="iconMap.plus" />
</span>
</NcButton>
</div> </div>
<a-table <span>Create personal API tokens to use in automation or external apps.</span>
:row-key="(record) => record.id" <div class="w-[780px] mt-5 border-1 rounded-md h-[530px] overflow-y-scroll">
:data-source="tokens" <div>
:pagination="{ position: ['bottomCenter'] }" <div class="flex w-full pl-5 bg-gray-50 border-b-1">
:loading="isLoading" <span class="py-3.5 text-gray-500 font-medium text-3.5 w-2/9">Token name</span>
size="small" <span class="py-3.5 text-gray-500 font-medium text-3.5 w-2/9 text-start">Creator</span>
@change="loadTokens($event.current)" <span class="py-3.5 text-gray-500 font-medium text-3.5 w-3/9 text-start">Token</span>
> <span class="py-3.5 pl-19 text-gray-500 font-medium text-3.5 w-2/9 text-start">Actions</span>
<template #emptyText> </div>
<a-empty :image="Empty.PRESENTED_IMAGE_SIMPLE" :description="$t('labels.noData')" /> <main>
</template> <div v-if="showNewTokenModal">
<div class="flex gap-5 px-3 py-3.5 text-gray-500 font-medium text-3.5 w-full nc-token-generate">
<!-- Created By --> <div class="flex flex-col w-full">
<a-table-column key="created_by" :title="$t('labels.createdBy')" data-index="created_by"> <a-input
<template #default="{ text }"> :ref="selectInputOnMount"
<div v-if="text"> v-model:value="selectedTokenData.description"
{{ text }} :default-value="defaultTokenName"
type="text"
class="!rounded-lg !py-1"
placeholder="Token Name"
data-testid="nc-token-input"
@press-enter="generateToken"
/>
<span v-if="!isValidTokenName" class="text-red-500 text-xs font-light mt-1.5 ml-1">{{ errorMessage }} </span>
</div>
<div class="flex gap-2 justify-start">
<NcButton v-if="!isLoading" type="secondary" size="small" @click="handleCancel">
{{ $t('general.cancel') }}
</NcButton>
<NcButton
type="primary"
size="sm"
:is-loading="isLoading"
data-testid="nc-token-save-btn"
@click="generateToken"
>
{{ $t('general.save') }}
</NcButton>
</div>
</div>
<NcDivider />
</div> </div>
<div v-else class="text-gray-400">N/A</div> <div v-if="!tokens.length" class="h-118 justify-center flex items-center">
</template> <a-empty :image="Empty.PRESENTED_IMAGE_SIMPLE" :description="`${$t('general.no')} ${$t('labels.token')}`" />
</a-table-column>
<!-- Description -->
<a-table-column key="description" :title="$t('labels.description')" data-index="description">
<template #default="{ text }">
{{ text }}
</template>
</a-table-column>
<!-- Token -->
<a-table-column key="token" :title="$t('labels.token')" data-index="token">
<template #default="{ text, record }">
<div class="w-[320px]">
<span v-if="record.show">{{ text }}</span>
<span v-else>*******************************************</span>
</div> </div>
</template>
</a-table-column>
<!-- Actions -->
<a-table-column key="actions" :title="$t('labels.actions')" data-index="token">
<template #default="{ record }">
<div class="flex items-center gap-2">
<a-tooltip placement="bottom">
<template #title>
<span v-if="record.show"> {{ $t('general.hide') }} </span>
<span v-else> {{ $t('general.show') }} </span>
</template>
<a-button type="text" class="!rounded-md nc-toggle-token-visibility" @click="record.show = !record.show">
<template #icon>
<MaterialSymbolsVisibilityOff v-if="record.show" class="flex mx-auto h-[1.1rem]" />
<MaterialSymbolsVisibility v-else class="flex mx-auto h-[1rem]" />
</template>
</a-button>
</a-tooltip>
<a-tooltip placement="bottom">
<template #title> {{ $t('general.copy') }}</template>
<a-button type="text" class="!rounded-md" @click="copyToken(record.token)">
<template #icon>
<component :is="iconMap.copy" class="flex mx-auto h-[1rem]" />
</template>
</a-button>
</a-tooltip>
<a-dropdown
:trigger="['click']"
class="flex"
placement="bottomRight"
overlay-class-name="nc-dropdown-api-token-mgmt"
>
<div class="flex flex-row items-center">
<a-button type="text" class="!px-0">
<div class="flex flex-row items-center h-[1.2rem]">
<IcBaselineMoreVert class="nc-token-menu" />
</div>
</a-button>
</div>
<template #overlay> <div v-for="el of tokens" :key="el.id" data-testid="nc-token-list" class="flex border-b-1 pl-5 py-3 justify-between">
<a-menu data-testid="nc-token-row-action-icon"> <span class="text-black font-bold text-3.5 text-start w-2/9">
<a-menu-item> <GeneralTruncateText placement="top" length="20">
<div {{ el.description }}
class="flex flex-row items-center py-3 h-[2rem] nc-delete-token" </GeneralTruncateText>
@click="openModal(record.token, record.description)" </span>
> <span class="text-gray-500 font-medium text-3.5 text-start w-2/9">
<component :is="iconMap.delete" class="flex" /> <GeneralTruncateText placement="top" length="20">
<div class="text-sm pl-2">{{ $t('general.remove') }}</div> {{ el.created_by }}
</div> </GeneralTruncateText>
</a-menu-item> </span>
</a-menu> <span class="text-gray-500 font-medium text-3.5 text-start w-3/9">
</template> <GeneralTruncateText v-if="el.token === selectedToken.id && selectedToken.isShow" placement="top" length="29">
</a-dropdown> {{ el.token }}
</GeneralTruncateText>
<span v-else>**************************************</span>
</span>
<!-- ACTIONS -->
<span class="text-gray-500 font-medium text-3.5 w-2/9">
<div class="flex justify-end items-center gap-3 pr-5">
<NcTooltip placement="top">
<template #title>show or hide</template>
<component
:is="iconMap.eye"
class="nc-toggle-token-visibility hover::cursor-pointer"
@click="hideOrShowToken(el.token as string)"
/>
</NcTooltip>
<NcTooltip placement="top" class="h-4">
<template #title>copy</template>
<component :is="iconMap.copy" class="hover::cursor-pointer" @click="copyToken(el.token)" />
</NcTooltip>
<NcTooltip placement="top" class="mb-0.5">
<template #title>delete</template>
<component
:is="iconMap.delete"
data-testid="nc-token-row-action-icon"
class="nc-delete-icon hover::cursor-pointer"
@click="triggerDeleteModal(el.token as string, el.description as string)"
/>
</NcTooltip>
</div>
</span>
</div> </div>
</template> </main>
</a-table-column> </div>
</a-table> </div>
<div v-if="pagination.total > 10" class="flex items-center justify-center mt-15">
<a-pagination
v-model:current="currentPage"
:total="pagination.total"
show-less-items
@change="loadTokens(currentPage, currentLimit)"
/>
</div>
</div> </div>
<GeneralDeleteModal v-model:visible="isModalOpen" entity-name="Token" :on-delete="() => deleteToken(tokenToCopy)"> <GeneralDeleteModal v-model:visible="isModalOpen" entity-name="Token" :on-delete="() => deleteToken(tokenToCopy)">
@ -242,57 +298,5 @@ const descriptionInput: VNodeRef = (el) => (el as HTMLInputElement)?.focus()
</span> </span>
</template> </template>
</GeneralDeleteModal> </GeneralDeleteModal>
<a-modal
v-model:visible="showNewTokenModal"
:class="{ active: showNewTokenModal }"
:closable="false"
width="28rem"
centered
:footer="null"
wrap-class-name="nc-modal-generate-token"
>
<div class="relative flex flex-col h-full">
<a-button type="text" class="!absolute top-0 right-0 rounded-md -mt-2 -mr-3" @click="showNewTokenModal = false">
<template #icon>
<MaterialSymbolsCloseRounded class="flex mx-auto" />
</template>
</a-button>
<!-- Generate Token -->
<div class="flex flex-row w-full -mt-1 mb-3">
<a-typography-title :level="5">{{ $t('title.generateToken') }}</a-typography-title>
</div>
<!-- Description -->
<a-form
ref="form"
:model="selectedTokenData"
name="basic"
layout="vertical"
class="flex flex-col justify-center space-y-6"
no-style
autocomplete="off"
@finish="generateToken"
>
<a-input
:ref="descriptionInput"
v-model:value="selectedTokenData.description"
data-testid="nc-token-modal-description"
:placeholder="$t('labels.description')"
class="h-9 rounded-md"
/>
<!-- Generate -->
<div class="flex flex-row justify-end">
<a-button size="middle" class="!rounded-md" type="primary" html-type="submit" data-testid="nc-token-modal-save">
{{ $t('general.generate') }}
</a-button>
</div>
</a-form>
</div>
</a-modal>
</div> </div>
</template> </template>
<style scoped></style>

22
tests/playwright/pages/Account/Token.ts

@ -4,14 +4,14 @@ import { AccountPage } from './index';
export class AccountTokenPage extends BasePage { export class AccountTokenPage extends BasePage {
readonly createBtn: Locator; readonly createBtn: Locator;
readonly createModal: Locator; readonly createInputDiv: Locator;
private accountPage: AccountPage; private accountPage: AccountPage;
constructor(accountPage: AccountPage) { constructor(accountPage: AccountPage) {
super(accountPage.rootPage); super(accountPage.rootPage);
this.accountPage = accountPage; this.accountPage = accountPage;
this.createBtn = this.get().locator(`[data-testid="nc-token-create"]`); this.createBtn = this.get().locator(`[data-testid="nc-token-create"]`);
this.createModal = accountPage.rootPage.locator(`.nc-modal-generate-token`); this.createInputDiv = accountPage.rootPage.locator(`.nc-token-generate`);
} }
async goto() { async goto() {
@ -28,13 +28,12 @@ export class AccountTokenPage extends BasePage {
async createToken({ description }: { description: string }) { async createToken({ description }: { description: string }) {
await this.createBtn.click(); await this.createBtn.click();
await this.createModal.locator(`input[placeholder="Description"]`).fill(description); await this.createInputDiv.locator(`[data-testid="nc-token-input"]`).fill(description);
await this.createModal.locator(`[data-testid="nc-token-modal-save"]`).click(); await this.createInputDiv.locator(`[data-testid="nc-token-save-btn"]`).click();
await this.verifyToast({ message: 'Token generated successfully' });
} }
getTokenRow({ idx = 0 }) { getTokenRow({ idx = 0 }) {
return this.get().locator(`tr:nth-child(${idx})`); return this.get().locator(`span:nth-child(${idx})`);
} }
async toggleVisibility({ idx = 0 }) { async toggleVisibility({ idx = 0 }) {
@ -42,17 +41,10 @@ export class AccountTokenPage extends BasePage {
await row.locator('.nc-toggle-token-visibility').click(); await row.locator('.nc-toggle-token-visibility').click();
} }
async openRowActionMenu({ description }: { description: string }) {
const userRow = this.get().locator(`tr:has-text("${description}")`);
return userRow.locator(`.nc-token-menu`).click();
}
async deleteToken({ description }: { description: string }) { async deleteToken({ description }: { description: string }) {
await this.openRowActionMenu({ description }); await this.rootPage.locator('[data-testid="nc-token-row-action-icon"]').click();
await this.rootPage.locator('[data-testid="nc-token-row-action-icon"] .nc-delete-token').click();
await this.rootPage.locator('.ant-modal.active button:has-text("Delete Token")').click(); await this.rootPage.locator('.ant-modal.active button:has-text("Delete Token")').click();
await this.verifyToast({ message: 'Token deleted successfully' });
expect(await this.get().locator(`tr:has-text("${description}:visible")`).count()).toBe(0); expect(await this.get().locator(`span:has-text("${description}:visible")`).count()).toBe(0);
} }
} }

Loading…
Cancel
Save