mirror of https://github.com/nocodb/nocodb
Pranav C
2 years ago
68 changed files with 954 additions and 355 deletions
@ -0,0 +1,58 @@ |
|||||||
|
<script lang="ts" setup> |
||||||
|
import { message } from 'ant-design-vue' |
||||||
|
import type { RequestParams, UserType } from 'nocodb-sdk' |
||||||
|
import { extractSdkResponseErrorMsg, useApi, useCopy } from '#imports' |
||||||
|
|
||||||
|
const { api, isLoading } = useApi() |
||||||
|
|
||||||
|
|
||||||
|
const { copy } = useCopy() |
||||||
|
|
||||||
|
let tokens = $ref<UserType[]>([]) |
||||||
|
|
||||||
|
let currentPage = $ref(1) |
||||||
|
|
||||||
|
const currentLimit = $ref(10) |
||||||
|
|
||||||
|
const showUserModal = ref(false) |
||||||
|
|
||||||
|
const searchText = ref<string>('') |
||||||
|
|
||||||
|
const pagination = reactive({ |
||||||
|
total: 0, |
||||||
|
pageSize: 10, |
||||||
|
}) |
||||||
|
const loadTokens = async (page = currentPage, limit = currentLimit) => { |
||||||
|
currentPage = page |
||||||
|
try { |
||||||
|
const response: any = await api.orgTokens.list({ |
||||||
|
query: { |
||||||
|
limit, |
||||||
|
offset: searchText.value.length === 0 ? (page - 1) * limit : 0, |
||||||
|
}, |
||||||
|
} as RequestParams) |
||||||
|
if (!response) return |
||||||
|
|
||||||
|
pagination.total = response.pageInfo.totalRows ?? 0 |
||||||
|
pagination.pageSize = 10 |
||||||
|
|
||||||
|
tokens = response.list as UserType[] |
||||||
|
} catch (e: any) { |
||||||
|
message.error(await extractSdkResponseErrorMsg(e)) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
loadTokens() |
||||||
|
|
||||||
|
</script> |
||||||
|
|
||||||
|
<template> |
||||||
|
<div class="h-full overflow-y-scroll scrollbar-thin-dull"> |
||||||
|
<div class="text-xl mt-4">License</div> |
||||||
|
<a-divider class="!my-3" /> |
||||||
|
<a-textarea placeholder="License key" class="!mt-4 max-w-[300px]"></a-textarea> |
||||||
|
<a-button class="mt-4 float-right" type="primary">Save license key</a-button> |
||||||
|
</div> |
||||||
|
</template> |
||||||
|
|
||||||
|
<style scoped></style> |
@ -0,0 +1,223 @@ |
|||||||
|
<script lang="ts" setup> |
||||||
|
import { message, Empty, Modal } from 'ant-design-vue' |
||||||
|
|
||||||
|
import type { RequestParams, UserType } from 'nocodb-sdk' |
||||||
|
import { extractSdkResponseErrorMsg, useApi , useCopy} from '#imports' |
||||||
|
|
||||||
|
const { api, isLoading } = useApi() |
||||||
|
|
||||||
|
|
||||||
|
const { copy } = useCopy() |
||||||
|
|
||||||
|
let tokens = $ref<UserType[]>([]) |
||||||
|
|
||||||
|
let currentPage = $ref(1) |
||||||
|
|
||||||
|
const currentLimit = $ref(10) |
||||||
|
|
||||||
|
const showUserModal = ref(false) |
||||||
|
|
||||||
|
const searchText = ref<string>('') |
||||||
|
|
||||||
|
const pagination = reactive({ |
||||||
|
total: 0, |
||||||
|
pageSize: 10, |
||||||
|
}) |
||||||
|
const loadTokens = async (page = currentPage, limit = currentLimit) => { |
||||||
|
currentPage = page |
||||||
|
try { |
||||||
|
const response: any = await api.orgTokens.list({ |
||||||
|
query: { |
||||||
|
limit, |
||||||
|
offset: searchText.value.length === 0 ? (page - 1) * limit : 0, |
||||||
|
}, |
||||||
|
} as RequestParams) |
||||||
|
if (!response) return |
||||||
|
|
||||||
|
pagination.total = response.pageInfo.totalRows ?? 0 |
||||||
|
pagination.pageSize = 10 |
||||||
|
|
||||||
|
tokens = response.list as UserType[] |
||||||
|
} catch (e: any) { |
||||||
|
message.error(await extractSdkResponseErrorMsg(e)) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
loadTokens() |
||||||
|
|
||||||
|
const deleteToken = async (userId: string) => { |
||||||
|
Modal.confirm({ |
||||||
|
title: 'Are you sure you want to delete this token?', |
||||||
|
type: 'warn', |
||||||
|
onOk: async () => { |
||||||
|
try { |
||||||
|
// todo: delete token |
||||||
|
// await api.orgUsers.delete(userId) |
||||||
|
// message.success('User deleted successfully') |
||||||
|
// await loadUsers() |
||||||
|
} catch (e: any) { |
||||||
|
message.error(await extractSdkResponseErrorMsg(e)) |
||||||
|
} |
||||||
|
}, |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
</script> |
||||||
|
|
||||||
|
<template> |
||||||
|
<div class="h-full overflow-y-scroll scrollbar-thin-dull"> |
||||||
|
<div class="text-xl mt-4">Token Management</div> |
||||||
|
<a-divider class="!my-3" /> |
||||||
|
<div class="max-w-[700px] mx-auto p-4"> |
||||||
|
<div class="py-2 flex"> |
||||||
|
<div class="flex-grow"></div> |
||||||
|
<a-button size="small" @click="showUserModal = true"> |
||||||
|
<div class="flex items-center gap-1"> |
||||||
|
<MdiAdd /> |
||||||
|
Add new token |
||||||
|
</div> |
||||||
|
</a-button> |
||||||
|
</div> |
||||||
|
<a-table |
||||||
|
:row-key="(record) => record.id" |
||||||
|
:data-source="tokens" |
||||||
|
:pagination="{ position: ['bottomCenter'] }" |
||||||
|
:loading="isLoading" |
||||||
|
@change="loadTokens($event.current)" |
||||||
|
size="small" |
||||||
|
> |
||||||
|
<template #emptyText> |
||||||
|
<a-empty :image="Empty.PRESENTED_IMAGE_SIMPLE" :description="$t('labels.noData')" /> |
||||||
|
</template> |
||||||
|
|
||||||
|
|
||||||
|
<!-- Token --> |
||||||
|
<a-table-column key="createdBy" :title="$t('labels.createdBy')" data-index="createdBy"> |
||||||
|
<template #default="{ text }"> |
||||||
|
<div> |
||||||
|
{{ text ?? 'N/A' }} |
||||||
|
</div> |
||||||
|
</template> |
||||||
|
</a-table-column> |
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Token --> |
||||||
|
<a-table-column key="token" :title="$t('labels.token')" data-index="token"> |
||||||
|
<template #default="{ text, record }"> |
||||||
|
<div> |
||||||
|
<span v-if="record.show">{{ text }}</span> |
||||||
|
<span v-else>****************************************</span> |
||||||
|
</div> |
||||||
|
</template> |
||||||
|
</a-table-column> |
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Actions --> |
||||||
|
|
||||||
|
<a-table-column key="id" :title="$t('labels.actions')" data-index="id"> |
||||||
|
<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" @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="copy(record.token)"> |
||||||
|
<template #icon> |
||||||
|
<MdiContentCopy 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 /> |
||||||
|
</div> |
||||||
|
</a-button> |
||||||
|
</div> |
||||||
|
|
||||||
|
<template #overlay> |
||||||
|
<a-menu> |
||||||
|
<a-menu-item> |
||||||
|
<div class="flex flex-row items-center py-3 h-[1rem]" @click="deleteToken(record)"> |
||||||
|
<MdiDeleteOutline class="flex" /> |
||||||
|
<div class="text-xs pl-2">{{ $t('general.remove') }}</div> |
||||||
|
</div> |
||||||
|
</a-menu-item> |
||||||
|
</a-menu> |
||||||
|
</template> |
||||||
|
</a-dropdown> |
||||||
|
|
||||||
|
|
||||||
|
</div> |
||||||
|
</template> |
||||||
|
</a-table-column> |
||||||
|
</a-table> |
||||||
|
|
||||||
|
</div> |
||||||
|
|
||||||
|
<a-modal |
||||||
|
v-model:visible="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 justify-center 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 v-model:value="selectedTokenData.description" :placeholder="$t('labels.description')" /> |
||||||
|
|
||||||
|
<!-- Generate --> |
||||||
|
<div class="flex flex-row justify-center"> |
||||||
|
<a-button type="primary" html-type="submit"> |
||||||
|
{{ $t('general.generate') }} |
||||||
|
</a-button> |
||||||
|
</div> |
||||||
|
</a-form> |
||||||
|
</div> |
||||||
|
</a-modal> |
||||||
|
</div> |
||||||
|
</template> |
||||||
|
|
||||||
|
<style scoped></style> |
@ -1,238 +0,0 @@ |
|||||||
<script lang="ts" setup> |
|
||||||
import { Modal, message } from 'ant-design-vue' |
|
||||||
import type { RequestParams, UserType } from 'nocodb-sdk' |
|
||||||
import { Role, extractSdkResponseErrorMsg, useApi, useDashboard, useNuxtApp , useCopy} from '#imports' |
|
||||||
import type { User } from '~/lib' |
|
||||||
|
|
||||||
const { api, isLoading } = useApi() |
|
||||||
|
|
||||||
const { $e } = useNuxtApp() |
|
||||||
|
|
||||||
const { t } = useI18n() |
|
||||||
|
|
||||||
const { dashboardUrl } = $(useDashboard()) |
|
||||||
|
|
||||||
const { copy } = useCopy() |
|
||||||
|
|
||||||
let users = $ref<UserType[]>([]) |
|
||||||
|
|
||||||
let currentPage = $ref(1) |
|
||||||
|
|
||||||
const currentLimit = $ref(10) |
|
||||||
|
|
||||||
const showUserModal = ref(false) |
|
||||||
|
|
||||||
const searchText = ref<string>('') |
|
||||||
|
|
||||||
const pagination = reactive({ |
|
||||||
total: 0, |
|
||||||
pageSize: 10, |
|
||||||
}) |
|
||||||
const loadUsers = async (page = currentPage, limit = currentLimit) => { |
|
||||||
currentPage = page |
|
||||||
try { |
|
||||||
const response: any = await api.orgUsers.list({ |
|
||||||
query: { |
|
||||||
limit, |
|
||||||
offset: searchText.value.length === 0 ? (page - 1) * limit : 0, |
|
||||||
query: searchText.value, |
|
||||||
}, |
|
||||||
} as RequestParams) |
|
||||||
if (!response) return |
|
||||||
|
|
||||||
pagination.total = response.pageInfo.totalRows ?? 0 |
|
||||||
pagination.pageSize = 10 |
|
||||||
|
|
||||||
users = response.list as UserType[] |
|
||||||
} catch (e: any) { |
|
||||||
message.error(await extractSdkResponseErrorMsg(e)) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
loadUsers() |
|
||||||
|
|
||||||
const updateRole = async (userId: string, roles: Role) => { |
|
||||||
try { |
|
||||||
await api.orgUsers.update(userId, { |
|
||||||
roles, |
|
||||||
} as unknown as UserType) |
|
||||||
message.success('Role updated successfully') |
|
||||||
} catch (e: any) { |
|
||||||
message.error(await extractSdkResponseErrorMsg(e)) |
|
||||||
} |
|
||||||
} |
|
||||||
const deleteUser = async (userId: string) => { |
|
||||||
Modal.confirm({ |
|
||||||
title: 'Are you sure you want to delete this user?', |
|
||||||
type: 'warn', |
|
||||||
content: |
|
||||||
'On deleting, user will remove from from organization and any sync source(Airtable) created by user will get removed', |
|
||||||
onOk: async () => { |
|
||||||
try { |
|
||||||
await api.orgUsers.delete(userId) |
|
||||||
message.success('User deleted successfully') |
|
||||||
await loadUsers() |
|
||||||
} catch (e: any) { |
|
||||||
message.error(await extractSdkResponseErrorMsg(e)) |
|
||||||
} |
|
||||||
}, |
|
||||||
}) |
|
||||||
} |
|
||||||
|
|
||||||
const resendInvite = async (user: User) => { |
|
||||||
try { |
|
||||||
await api.orgUsers.resendInvite(user.id) |
|
||||||
|
|
||||||
// Invite email sent successfully |
|
||||||
message.success(t('msg.success.inviteEmailSent')) |
|
||||||
await loadUsers() |
|
||||||
} catch (e: any) { |
|
||||||
message.error(await extractSdkResponseErrorMsg(e)) |
|
||||||
} |
|
||||||
|
|
||||||
$e('a:org-user:resend-invite') |
|
||||||
} |
|
||||||
|
|
||||||
const copyInviteUrl = (user: User) => { |
|
||||||
if (!user.invite_token) return |
|
||||||
|
|
||||||
copy(`${dashboardUrl}#/signup/${user.invite_token}`) |
|
||||||
|
|
||||||
// Invite URL copied to clipboard |
|
||||||
message.success(t('msg.success.inviteURLCopied')) |
|
||||||
$e('c:user:copy-url') |
|
||||||
} |
|
||||||
</script> |
|
||||||
|
|
||||||
<template> |
|
||||||
<div class="h-full overflow-y-scroll scrollbar-thin-dull"> |
|
||||||
<div class="max-w-[700px] mx-auto p-4"> |
|
||||||
<div class="py-2 flex"> |
|
||||||
<a-input-search |
|
||||||
v-model:value="searchText" |
|
||||||
size="small" |
|
||||||
class="max-w-[300px]" |
|
||||||
placeholder="Filter by email" |
|
||||||
@blur="loadUsers" |
|
||||||
@keydown.enter="loadUsers" |
|
||||||
> |
|
||||||
</a-input-search> |
|
||||||
<div class="flex-grow"></div> |
|
||||||
<a-button size="small" @click="showUserModal = true"> |
|
||||||
<div class="flex items-center gap-1"> |
|
||||||
<MdiAdd /> |
|
||||||
Invite new user |
|
||||||
</div> |
|
||||||
</a-button> |
|
||||||
</div> |
|
||||||
<a-table |
|
||||||
:row-key="(record) => record.id" |
|
||||||
:data-source="users" |
|
||||||
:pagination="pagination" |
|
||||||
:loading="isLoading" |
|
||||||
@change="loadUsers($event.current)" |
|
||||||
size="small" |
|
||||||
> |
|
||||||
<template #emptyText> |
|
||||||
<a-empty :image="Empty.PRESENTED_IMAGE_SIMPLE" :description="$t('labels.noData')" /> |
|
||||||
</template> |
|
||||||
|
|
||||||
<!-- Email --> |
|
||||||
<a-table-column key="email" :title="$t('labels.email')" data-index="email"> |
|
||||||
<template #default="{ text }"> |
|
||||||
<div> |
|
||||||
{{ text }} |
|
||||||
</div> |
|
||||||
</template> |
|
||||||
</a-table-column> |
|
||||||
|
|
||||||
<!-- Role --> |
|
||||||
<a-table-column key="roles" :title="$t('objects.role')" data-index="roles"> |
|
||||||
<template #default="{ record }"> |
|
||||||
<div> |
|
||||||
<div v-if="record.roles.includes('super')" class="font-weight-bold">Super Admin</div> |
|
||||||
<a-select |
|
||||||
v-else |
|
||||||
v-model:value="record.roles" |
|
||||||
class="w-[220px]" |
|
||||||
:dropdown-match-select-width="false" |
|
||||||
@change="updateRole(record.id, record.roles)" |
|
||||||
> |
|
||||||
<a-select-option :value="Role.OrgLevelCreator" :label="$t(`objects.roleType.orgLevelCreator`)"> |
|
||||||
<div>{{ $t(`objects.roleType.orgLevelCreator`) }}</div> |
|
||||||
<span class="text-gray-500 text-xs whitespace-normal" |
|
||||||
>Creator can create new projects and access any invited project.</span |
|
||||||
> |
|
||||||
</a-select-option> |
|
||||||
|
|
||||||
<a-select-option :value="Role.OrgLevelViewer" :label="$t(`objects.roleType.orgLevelViewer`)"> |
|
||||||
<div>{{ $t(`objects.roleType.orgLevelViewer`) }}</div> |
|
||||||
<span class="text-gray-500 text-xs whitespace-normal" |
|
||||||
>Viewer is not allowed to create new projects but they can access any invited project.</span |
|
||||||
> |
|
||||||
</a-select-option> |
|
||||||
</a-select> |
|
||||||
</div> |
|
||||||
</template> |
|
||||||
</a-table-column> |
|
||||||
|
|
||||||
<!-- <!– Projects –> |
|
||||||
<a-table-column key="projectsCount" :title="$t('objects.projects')" data-index="projectsCount"> |
|
||||||
<template #default="{ text }"> |
|
||||||
<div> |
|
||||||
{{ text }} |
|
||||||
</div> |
|
||||||
</template> |
|
||||||
</a-table-column>--> |
|
||||||
|
|
||||||
<!-- Actions --> |
|
||||||
|
|
||||||
<a-table-column key="id" :title="$t('labels.actions')" data-index="id"> |
|
||||||
<template #default="{ text, record }"> |
|
||||||
<div class="flex items-center gap-2"> |
|
||||||
<MdiDeleteOutline class="nc-action-btn cursor-pointer" @click="deleteUser(text)" /> |
|
||||||
|
|
||||||
<a-dropdown :trigger="['click']" class="flex" placement="bottomRight" overlay-class-name="nc-dropdown-user-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 /> |
|
||||||
</div> |
|
||||||
</a-button> |
|
||||||
</div> |
|
||||||
|
|
||||||
<template #overlay> |
|
||||||
<a-menu> |
|
||||||
<a-menu-item> |
|
||||||
<!-- Resend invite Email --> |
|
||||||
<div class="flex flex-row items-center py-3" @click="resendInvite(record)"> |
|
||||||
<MdiEmailArrowRightOutline class="flex h-[1rem] text-gray-500" /> |
|
||||||
<div class="text-xs pl-2">{{ $t('activity.resendInvite') }}</div> |
|
||||||
</div> |
|
||||||
</a-menu-item> |
|
||||||
<a-menu-item> |
|
||||||
<div class="flex flex-row items-center py-3" @click="copyInviteUrl(record)"> |
|
||||||
<MdiContentCopy class="flex h-[1rem] text-gray-500" /> |
|
||||||
<div class="text-xs pl-2">{{ $t('activity.copyInviteURL') }}</div> |
|
||||||
</div> |
|
||||||
</a-menu-item> |
|
||||||
<!-- <a-menu-item> |
|
||||||
<div class="flex flex-row items-center py-3" @click="copyInviteUrl(user)"> |
|
||||||
<MdiContentCopy class="flex h-[1rem] text-gray-500" /> |
|
||||||
<div class="text-xs pl-2">{{ $t('activity.copyPasswordResetURL') }}</div> |
|
||||||
</div> |
|
||||||
</a-menu-item> --> |
|
||||||
</a-menu> |
|
||||||
</template> |
|
||||||
</a-dropdown> |
|
||||||
</div> |
|
||||||
</template> |
|
||||||
</a-table-column> |
|
||||||
</a-table> |
|
||||||
|
|
||||||
<LazyOrgUserUsersModal :show="showUserModal" @closed="showUserModal = false" @reload="loadUsers" /> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
</template> |
|
||||||
|
|
||||||
<style scoped></style> |
|
@ -0,0 +1,60 @@ |
|||||||
|
<template> |
||||||
|
<div class="container mx-auto h-full"> |
||||||
|
<a-layout class="mt-3 h-full overflow-y-auto flex"> |
||||||
|
<!-- Side tabs --> |
||||||
|
<a-layout-sider> |
||||||
|
<a-menu :selected-keys="selectedTabKeys" class="tabs-menu h-full" :open-keys="[]"> |
||||||
|
<a-menu-item |
||||||
|
key="users" |
||||||
|
@click="navigateTo('/admin/users')" |
||||||
|
class="group active:(!ring-0) hover:(!bg-primary !bg-opacity-25)" |
||||||
|
> |
||||||
|
<div class="flex items-center space-x-2"> |
||||||
|
<MdiAccountSupervisorOutline /> |
||||||
|
|
||||||
|
<div class="select-none"> |
||||||
|
User Management |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</a-menu-item> |
||||||
|
<a-menu-item |
||||||
|
key="tokens" |
||||||
|
@click="navigateTo('/admin/tokens')" |
||||||
|
class="group active:(!ring-0) hover:(!bg-primary !bg-opacity-25)" |
||||||
|
> |
||||||
|
<div class="flex items-center space-x-2"> |
||||||
|
<MdiShieldKeyOutline /> |
||||||
|
|
||||||
|
<div class="select-none"> |
||||||
|
Tokens |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</a-menu-item> |
||||||
|
<a-menu-item |
||||||
|
key="license" |
||||||
|
@click="navigateTo('/admin/license')" |
||||||
|
class="group active:(!ring-0) hover:(!bg-primary !bg-opacity-25)" |
||||||
|
> |
||||||
|
<div class="flex items-center space-x-2"> |
||||||
|
<MdiKeyChainVariant /> |
||||||
|
<div class="select-none"> |
||||||
|
License |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</a-menu-item> |
||||||
|
</a-menu> |
||||||
|
</a-layout-sider> |
||||||
|
|
||||||
|
<!-- Sub Tabs --> |
||||||
|
<a-layout-content class="h-auto px-4 scrollbar-thumb-gray-500"> |
||||||
|
<NuxtPage /> |
||||||
|
</a-layout-content> |
||||||
|
</a-layout> |
||||||
|
</div> |
||||||
|
</template> |
||||||
|
|
||||||
|
<script lang="ts" setup> |
||||||
|
import { navigateTo } from '#imports' |
||||||
|
const $route = useRoute() |
||||||
|
const selectedTabKeys = computed(() => [$route.params.page]) |
||||||
|
</script> |
@ -0,0 +1,16 @@ |
|||||||
|
<template> |
||||||
|
<AdminUser v-if="$route.params.page === 'users'" /> |
||||||
|
<AdminToken v-else-if="$route.params.page === 'tokens'" /> |
||||||
|
<AdminLicense v-else-if="$route.params.page === 'license'" /> |
||||||
|
<span v-else></span> |
||||||
|
</template> |
||||||
|
|
||||||
|
<script> |
||||||
|
export default { |
||||||
|
name: 'index' |
||||||
|
}; |
||||||
|
</script> |
||||||
|
|
||||||
|
<style scoped> |
||||||
|
|
||||||
|
</style> |
@ -1,49 +0,0 @@ |
|||||||
<template> |
|
||||||
<a-layout class="mt-3 h-[75vh] overflow-y-auto flex"> |
|
||||||
<!-- Side tabs --> |
|
||||||
<a-layout-sider> |
|
||||||
<a-menu :selected-keys="selectedTabKeys" class="tabs-menu h-full" :open-keys="[]"> |
|
||||||
<a-menu-item |
|
||||||
key="users" |
|
||||||
@click="navigateTo('/org/users')" |
|
||||||
class="group active:(!ring-0) hover:(!bg-primary !bg-opacity-25)" |
|
||||||
> |
|
||||||
<div class="flex items-center space-x-2"> |
|
||||||
<MdiAccountSupervisorOutline/> |
|
||||||
|
|
||||||
<div class="select-none"> |
|
||||||
Users |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
</a-menu-item> |
|
||||||
<a-menu-item |
|
||||||
key="tokens" |
|
||||||
@click="navigateTo('/org/tokens')" |
|
||||||
class="group active:(!ring-0) hover:(!bg-primary !bg-opacity-25)" |
|
||||||
> |
|
||||||
<div class="flex items-center space-x-2"> |
|
||||||
<MdiShieldKeyOutline/> |
|
||||||
|
|
||||||
<div class="select-none"> |
|
||||||
Tokens |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
</a-menu-item> |
|
||||||
</a-menu> |
|
||||||
</a-layout-sider> |
|
||||||
|
|
||||||
<!-- Sub Tabs --> |
|
||||||
<a-layout-content class="h-auto px-4 scrollbar-thumb-gray-500"> |
|
||||||
|
|
||||||
<OrgUser v-if="$route.params.page === 'users'" /> |
|
||||||
<OrgToken v-else-if="$route.params.page === 'tokens'" /> |
|
||||||
</a-layout-content> |
|
||||||
</a-layout> |
|
||||||
</template> |
|
||||||
|
|
||||||
<script lang="ts" setup> |
|
||||||
import { navigateTo } from '#imports' |
|
||||||
|
|
||||||
const $route = useRoute() |
|
||||||
const selectedTabKeys = computed(() => [$route.params.page]) |
|
||||||
</script> |
|
@ -0,0 +1,23 @@ |
|||||||
|
import { Router } from 'express'; |
||||||
|
import { OrgUserRoles } from '../../../enums/OrgUserRoles'; |
||||||
|
import ApiToken from '../../models/ApiToken'; |
||||||
|
import { metaApiMetrics } from '../helpers/apiMetrics'; |
||||||
|
import ncMetaAclMw from '../helpers/ncMetaAclMw'; |
||||||
|
import { PagedResponseImpl } from '../helpers/PagedResponse'; |
||||||
|
|
||||||
|
async function tokensList(req, res) { |
||||||
|
res.json( |
||||||
|
new PagedResponseImpl(await ApiToken.listWithCreatedBy(req.query), { |
||||||
|
...req.query, |
||||||
|
count: await ApiToken.count(), |
||||||
|
}) |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
const router = Router({ mergeParams: true }); |
||||||
|
router.get( |
||||||
|
'/api/v1/tokens', |
||||||
|
metaApiMetrics, |
||||||
|
ncMetaAclMw(tokensList, 'tokensList', [OrgUserRoles.SUPER]) |
||||||
|
); |
||||||
|
export default router; |
@ -0,0 +1,48 @@ |
|||||||
|
import Knex from 'knex'; |
||||||
|
import { MetaTable } from '../../utils/globals'; |
||||||
|
|
||||||
|
const up = async (knex: Knex) => { |
||||||
|
await knex.schema.alterTable(MetaTable.API_TOKENS, (table) => { |
||||||
|
table.string('fk_user_id', 20); |
||||||
|
table.foreign('fk_user_id').references(`${MetaTable.USERS}.id`); |
||||||
|
}); |
||||||
|
|
||||||
|
await knex.schema.alterTable(MetaTable.SYNC_SOURCE, (table) => { |
||||||
|
table.dropForeign(['fk_user_id']); |
||||||
|
}); |
||||||
|
}; |
||||||
|
|
||||||
|
const down = async (knex) => { |
||||||
|
await knex.schema.alterTable(MetaTable.API_TOKENS, (table) => { |
||||||
|
table.dropForeign(['fk_user_id']); |
||||||
|
table.dropColumn('fk_user_id'); |
||||||
|
}); |
||||||
|
|
||||||
|
await knex.schema.alterTable(MetaTable.SYNC_SOURCE, (table) => { |
||||||
|
table.foreign('fk_user_id').references(`${MetaTable.USERS}.id`); |
||||||
|
}); |
||||||
|
}; |
||||||
|
|
||||||
|
export { up, down }; |
||||||
|
|
||||||
|
/** |
||||||
|
* @copyright Copyright (c) 2021, Xgene Cloud Ltd |
||||||
|
* |
||||||
|
* @author Wing-Kam Wong <wingkwong.code@gmail.com> |
||||||
|
* |
||||||
|
* @license GNU AGPL version 3 or any later version |
||||||
|
* |
||||||
|
* This program is free software: you can redistribute it and/or modify |
||||||
|
* it under the terms of the GNU Affero General Public License as |
||||||
|
* published by the Free Software Foundation, either version 3 of the |
||||||
|
* License, or (at your option) any later version. |
||||||
|
* |
||||||
|
* This program is distributed in the hope that it will be useful, |
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of |
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||||||
|
* GNU Affero General Public License for more details. |
||||||
|
* |
||||||
|
* You should have received a copy of the GNU Affero General Public License |
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>. |
||||||
|
* |
||||||
|
*/ |
@ -0,0 +1,292 @@ |
|||||||
|
import Emittery from 'emittery'; |
||||||
|
import { machineIdSync } from 'node-machine-id'; |
||||||
|
import axios from 'axios'; |
||||||
|
import os from 'os'; |
||||||
|
import isDocker from 'is-docker'; |
||||||
|
import { packageVersion } from './packageVersion'; |
||||||
|
import Analytics from '@rudderstack/rudder-sdk-node'; |
||||||
|
|
||||||
|
const isDisabled = !!process.env.NC_DISABLE_TELE; |
||||||
|
const cache = !!process.env.NC_REDIS_URL; |
||||||
|
const executable = !!process.env.NC_BINARY_BUILD; |
||||||
|
const litestream = !!( |
||||||
|
process.env.AWS_ACCESS_KEY_ID && |
||||||
|
process.env.AWS_SECRET_ACCESS_KEY && |
||||||
|
process.env.AWS_BUCKET |
||||||
|
); |
||||||
|
|
||||||
|
const sendEvt = () => { |
||||||
|
try { |
||||||
|
const upTime = Math.round(process.uptime() / 3600); |
||||||
|
Tele.emit('evt', { |
||||||
|
evt_type: 'alive', |
||||||
|
count: global.NC_COUNT, |
||||||
|
upTime, |
||||||
|
}); |
||||||
|
|
||||||
|
Tele.event({ |
||||||
|
event: 'alive', |
||||||
|
id: Tele.machineId, |
||||||
|
data: { |
||||||
|
cache, |
||||||
|
upTime, |
||||||
|
litestream, |
||||||
|
executable, |
||||||
|
}, |
||||||
|
}); |
||||||
|
} catch {} |
||||||
|
}; |
||||||
|
setInterval(sendEvt, 4 * 60 * 60 * 1000); |
||||||
|
|
||||||
|
class Tele { |
||||||
|
public static machineId: string; |
||||||
|
public static emitter: Emittery; |
||||||
|
|
||||||
|
private static config: Record<string, any>; |
||||||
|
private static client: any; |
||||||
|
|
||||||
|
static emit(event, data) { |
||||||
|
try { |
||||||
|
this._init(); |
||||||
|
Tele.emitter.emit(event, data); |
||||||
|
} catch (e) {} |
||||||
|
} |
||||||
|
|
||||||
|
static init(config) { |
||||||
|
Tele.config = config; |
||||||
|
Tele._init(); |
||||||
|
} |
||||||
|
|
||||||
|
static page(args) { |
||||||
|
this.emit('page', args); |
||||||
|
} |
||||||
|
|
||||||
|
static event(args) { |
||||||
|
this.emit('ph_event', args); |
||||||
|
} |
||||||
|
|
||||||
|
static _init() { |
||||||
|
try { |
||||||
|
if (!Tele.emitter) { |
||||||
|
Tele.emitter = new Emittery(); |
||||||
|
Tele.machineId = machineIdSync(); |
||||||
|
|
||||||
|
let package_id = ''; |
||||||
|
let xc_version = ''; |
||||||
|
try { |
||||||
|
xc_version = process.env.NC_SERVER_UUID; |
||||||
|
package_id = packageVersion; |
||||||
|
} catch (e) { |
||||||
|
console.log(e); |
||||||
|
} |
||||||
|
|
||||||
|
const teleData: Record<string, any> = { |
||||||
|
package_id, |
||||||
|
os_type: os.type(), |
||||||
|
os_platform: os.platform(), |
||||||
|
os_release: os.release(), |
||||||
|
node_version: process.version, |
||||||
|
docker: isDocker(), |
||||||
|
xc_version: xc_version, |
||||||
|
env: process.env.NODE_ENV || 'production', |
||||||
|
oneClick: !!process.env.NC_ONE_CLICK, |
||||||
|
}; |
||||||
|
teleData.machine_id = `${machineIdSync()},,`; |
||||||
|
Tele.emitter.on('evt_app_started', async (msg: Record<string, any>) => { |
||||||
|
try { |
||||||
|
await waitForMachineId(teleData); |
||||||
|
if (isDisabled) return; |
||||||
|
|
||||||
|
if (msg && msg.count !== undefined) { |
||||||
|
global.NC_COUNT = msg.count; |
||||||
|
} |
||||||
|
|
||||||
|
await axios.post('https://nocodb.com/api/v1/telemetry', { |
||||||
|
...teleData, |
||||||
|
evt_type: 'started', |
||||||
|
payload: { |
||||||
|
count: global.NC_COUNT, |
||||||
|
}, |
||||||
|
}); |
||||||
|
} catch (e) { |
||||||
|
} finally { |
||||||
|
sendEvt(); |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
Tele.emitter.on('evt', async (payload: Record<string, any>) => { |
||||||
|
try { |
||||||
|
await waitForMachineId(teleData); |
||||||
|
if (payload.check) { |
||||||
|
teleData.machine_id = `${machineIdSync()},,`; |
||||||
|
} |
||||||
|
if (isDisabled) return; |
||||||
|
|
||||||
|
if (payload.evt_type === 'project:invite') { |
||||||
|
global.NC_COUNT = payload.count || global.NC_COUNT; |
||||||
|
} |
||||||
|
if (payload.evt_type === 'user:first_signup') { |
||||||
|
global.NC_COUNT = +global.NC_COUNT || 1; |
||||||
|
} |
||||||
|
|
||||||
|
await axios.post('https://nocodb.com/api/v1/telemetry', { |
||||||
|
...teleData, |
||||||
|
evt_type: payload.evt_type, |
||||||
|
payload: payload, |
||||||
|
}); |
||||||
|
} catch (e) { |
||||||
|
// console.log(e)
|
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
Tele.emitter.on( |
||||||
|
'evt_api_created', |
||||||
|
async (data: Record<string, any>) => { |
||||||
|
try { |
||||||
|
await waitForMachineId(teleData); |
||||||
|
const stats = { |
||||||
|
...teleData, |
||||||
|
table_count: data.tablesCount || 0, |
||||||
|
relation_count: data.relationsCount || 0, |
||||||
|
view_count: data.viewsCount || 0, |
||||||
|
api_count: data.apiCount || 0, |
||||||
|
function_count: data.functionsCount || 0, |
||||||
|
procedure_count: data.proceduresCount || 0, |
||||||
|
mysql: data.dbType === 'mysql2' ? 1 : 0, |
||||||
|
pg: data.dbType === 'pg' ? 1 : 0, |
||||||
|
mssql: data.dbType === 'mssql' ? 1 : 0, |
||||||
|
sqlite3: data.dbType === 'sqlite3' ? 1 : 0, |
||||||
|
oracledb: data.dbType === 'oracledb' ? 1 : 0, |
||||||
|
rest: data.type === 'rest' ? 1 : 0, |
||||||
|
graphql: data.type === 'graphql' ? 1 : 0, |
||||||
|
grpc: data.type === 'grpc' ? 1 : 0, |
||||||
|
time_taken: data.timeTaken, |
||||||
|
}; |
||||||
|
if (isDisabled) return; |
||||||
|
await axios.post( |
||||||
|
'https://nocodb.com/api/v1/telemetry/apis_created', |
||||||
|
stats |
||||||
|
); |
||||||
|
} catch (e) {} |
||||||
|
} |
||||||
|
); |
||||||
|
|
||||||
|
Tele.emitter.on('evt_subscribe', async (email) => { |
||||||
|
try { |
||||||
|
if (isDisabled) return; |
||||||
|
await axios.post( |
||||||
|
'https://nocodb.com/api/v1/newsletter/sdhjh34u3yuy34bj343jhj4iwolaAdsdj3434uiut4nn', |
||||||
|
{ |
||||||
|
email, |
||||||
|
} |
||||||
|
); |
||||||
|
} catch (e) {} |
||||||
|
}); |
||||||
|
|
||||||
|
Tele.emitter.on('page', async (args: Record<string, any>) => { |
||||||
|
try { |
||||||
|
if (isDisabled) return; |
||||||
|
const instanceMeta = await this.getInstanceMeta(); |
||||||
|
|
||||||
|
this.client.track({ |
||||||
|
userId: args.id || `${this.machineId}:public`, |
||||||
|
distinctId: args.id || `${this.machineId}:public`, |
||||||
|
event: '$pageview', |
||||||
|
properties: { |
||||||
|
...teleData, |
||||||
|
...instanceMeta, |
||||||
|
$current_url: args.path, |
||||||
|
}, |
||||||
|
}); |
||||||
|
} catch (e) {} |
||||||
|
}); |
||||||
|
Tele.emitter.on('ph_event', async (args: Record<string, any>) => { |
||||||
|
try { |
||||||
|
if (isDisabled) return; |
||||||
|
const instanceMeta = await this.getInstanceMeta(); |
||||||
|
let id = args.id; |
||||||
|
|
||||||
|
if (!id) { |
||||||
|
if (args.event && args.event.startsWith('a:api:')) { |
||||||
|
id = this.machineId; |
||||||
|
} else { |
||||||
|
id = `${this.machineId}:public`; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
this.client.track({ |
||||||
|
userId: id, |
||||||
|
distinctId: id, |
||||||
|
event: args.event, |
||||||
|
properties: { |
||||||
|
...teleData, |
||||||
|
...instanceMeta, |
||||||
|
...(args.data || {}), |
||||||
|
}, |
||||||
|
}); |
||||||
|
} catch (e) {} |
||||||
|
}); |
||||||
|
} |
||||||
|
} catch (e) {} |
||||||
|
|
||||||
|
try { |
||||||
|
if (!this.client) { |
||||||
|
this.client = new Analytics( |
||||||
|
'26w4gRDLSWVX0rtMR0enhTIOu7G', |
||||||
|
'https://nocodbnavehd.dataplane.rudderstack.com/v1/batch', |
||||||
|
{ |
||||||
|
logLevel: '-1', |
||||||
|
} |
||||||
|
); |
||||||
|
} |
||||||
|
} catch (e) {} |
||||||
|
} |
||||||
|
|
||||||
|
static async getInstanceMeta() { |
||||||
|
try { |
||||||
|
return ( |
||||||
|
(Tele.config && |
||||||
|
Tele.config.instance && |
||||||
|
(await Tele.config.instance())) || |
||||||
|
{} |
||||||
|
); |
||||||
|
} catch { |
||||||
|
return {}; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
static get id() { |
||||||
|
return this.machineId || machineIdSync(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
async function waitForMachineId(teleData) { |
||||||
|
let i = 5; |
||||||
|
while (i-- && !teleData.machine_id) { |
||||||
|
await new Promise((resolve) => setTimeout(() => resolve(null), 500)); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// keep polling the site url to avoid going machine idle
|
||||||
|
if (process.env.NC_PUBLIC_URL) { |
||||||
|
setInterval(() => { |
||||||
|
axios({ |
||||||
|
method: 'get', |
||||||
|
url: process.env.NC_PUBLIC_URL, |
||||||
|
}) |
||||||
|
.then(() => {}) |
||||||
|
.catch(() => {}); |
||||||
|
}, 2 * 60 * 60 * 1000); |
||||||
|
} |
||||||
|
|
||||||
|
if (process.env.NC_ONE_CLICK) { |
||||||
|
try { |
||||||
|
Tele.emit('evt', { |
||||||
|
evt_type: 'ONE_CLICK', |
||||||
|
}); |
||||||
|
} catch (e) { |
||||||
|
//
|
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export { Tele }; |
@ -0,0 +1,37 @@ |
|||||||
|
import fs from 'fs'; |
||||||
|
import path from 'path'; |
||||||
|
|
||||||
|
let packageInfo: Record<string, any> = {}; |
||||||
|
|
||||||
|
try { |
||||||
|
packageInfo = JSON.parse( |
||||||
|
fs.readFileSync( |
||||||
|
path.join(process.cwd(), 'node_modules', 'nocodb', 'package.json'), |
||||||
|
'utf8' |
||||||
|
) |
||||||
|
); |
||||||
|
} catch { |
||||||
|
try { |
||||||
|
// check within executable
|
||||||
|
packageInfo = JSON.parse( |
||||||
|
fs.readFileSync( |
||||||
|
path.join( |
||||||
|
path.dirname(process['pkg']?.['defaultEntrypoint']), |
||||||
|
'node_modules', |
||||||
|
'nocodb', |
||||||
|
'package.json' |
||||||
|
), |
||||||
|
'utf8' |
||||||
|
) |
||||||
|
); |
||||||
|
} catch { |
||||||
|
try { |
||||||
|
packageInfo = JSON.parse( |
||||||
|
fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf8') |
||||||
|
); |
||||||
|
} catch {} |
||||||
|
} |
||||||
|
} |
||||||
|
const packageVersion = packageInfo?.version; |
||||||
|
|
||||||
|
export { packageVersion, packageInfo }; |
Loading…
Reference in new issue