Before Width: | Height: | Size: 67 KiB |
Before Width: | Height: | Size: 6.3 KiB |
Before Width: | Height: | Size: 982 B |
Before Width: | Height: | Size: 1.7 KiB |
Before Width: | Height: | Size: 3.2 KiB |
After Width: | Height: | Size: 3.1 KiB |
After Width: | Height: | Size: 6.6 KiB |
Before Width: | Height: | Size: 67 KiB |
Before Width: | Height: | Size: 13 KiB |
@ -0,0 +1,8 @@ |
|||||||
|
<template> |
||||||
|
<div class="h-full overflow-y-scroll scrollbar-thin-dull pt-2"> |
||||||
|
<div class="text-xl mt-4 mb-8 text-center font-weight-bold">{{ $t('title.appStore') }}</div> |
||||||
|
<div> |
||||||
|
<LazyDashboardSettingsAppStore /> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</template> |
@ -0,0 +1,45 @@ |
|||||||
|
<script lang="ts" setup> |
||||||
|
import { useNuxtApp } from '#app' |
||||||
|
import { message } from 'ant-design-vue' |
||||||
|
import { extractSdkResponseErrorMsg, useApi } from '#imports' |
||||||
|
|
||||||
|
const { api, isLoading } = useApi() |
||||||
|
|
||||||
|
const {$e} = useNuxtApp() |
||||||
|
|
||||||
|
let key = $ref('') |
||||||
|
|
||||||
|
const loadLicense = async () => { |
||||||
|
try { |
||||||
|
const response = await api.orgLicense.get() |
||||||
|
|
||||||
|
key = response.key |
||||||
|
} catch (e) { |
||||||
|
message.error(await extractSdkResponseErrorMsg(e)) |
||||||
|
} |
||||||
|
} |
||||||
|
const setLicense = async () => { |
||||||
|
try { |
||||||
|
await api.orgLicense.set({ key: key }) |
||||||
|
message.success('License key updated') |
||||||
|
} catch (e) { |
||||||
|
message.error(await extractSdkResponseErrorMsg(e)) |
||||||
|
} |
||||||
|
$e('a:account:license') |
||||||
|
} |
||||||
|
|
||||||
|
loadLicense() |
||||||
|
|
||||||
|
</script> |
||||||
|
|
||||||
|
<template> |
||||||
|
<div class="h-full overflow-y-scroll scrollbar-thin-dull"> |
||||||
|
<div class="text-xl mt-4 mb-8 text-center font-weight-bold">License</div> |
||||||
|
<div> |
||||||
|
<a-textarea v-model:value="key" placeholder="License key" class="!mt-2 !max-w-[600px]"></a-textarea> |
||||||
|
</div> |
||||||
|
<a-button class="mt-4" @click="setLicense" type="primary">Save license key</a-button> |
||||||
|
</div> |
||||||
|
</template> |
||||||
|
|
||||||
|
<style scoped></style> |
@ -0,0 +1,146 @@ |
|||||||
|
<script lang="ts" setup> |
||||||
|
import { message, navigateTo, reactive, ref, useApi, useGlobal, useI18n } from '#imports' |
||||||
|
|
||||||
|
const { api, error } = useApi({ useGlobalInstance: true }) |
||||||
|
|
||||||
|
const { t } = useI18n() |
||||||
|
|
||||||
|
const { signOut } = useGlobal() |
||||||
|
|
||||||
|
const formValidator = ref() |
||||||
|
|
||||||
|
const form = reactive({ |
||||||
|
currentPassword: '', |
||||||
|
password: '', |
||||||
|
passwordRepeat: '', |
||||||
|
}) |
||||||
|
|
||||||
|
const formRules = { |
||||||
|
currentPassword: [ |
||||||
|
// Current password is required |
||||||
|
{ required: true, message: t('msg.error.signUpRules.passwdRequired') }, |
||||||
|
], |
||||||
|
password: [ |
||||||
|
// Password is required |
||||||
|
{ required: true, message: t('msg.error.signUpRules.passwdRequired') }, |
||||||
|
{ min: 8, message: t('msg.error.signUpRules.passwdLength') }, |
||||||
|
], |
||||||
|
passwordRepeat: [ |
||||||
|
// PasswordRepeat is required |
||||||
|
{ required: true, message: t('msg.error.signUpRules.passwdRequired') }, |
||||||
|
// Passwords match |
||||||
|
{ |
||||||
|
validator: (_: unknown, _v: string) => { |
||||||
|
return new Promise((resolve, reject) => { |
||||||
|
if (form.password === form.passwordRepeat) return resolve(true) |
||||||
|
reject(new Error(t('msg.error.signUpRules.passwdMismatch'))) |
||||||
|
}) |
||||||
|
}, |
||||||
|
message: t('msg.error.signUpRules.passwdMismatch'), |
||||||
|
}, |
||||||
|
], |
||||||
|
} |
||||||
|
|
||||||
|
const passwordChange = async () => { |
||||||
|
const valid = formValidator.value.validate() |
||||||
|
if (!valid) return |
||||||
|
|
||||||
|
error.value = null |
||||||
|
|
||||||
|
await api.auth.passwordChange({ |
||||||
|
currentPassword: form.currentPassword, |
||||||
|
newPassword: form.password, |
||||||
|
}) |
||||||
|
|
||||||
|
message.success(t('msg.success.passwordChanged')) |
||||||
|
|
||||||
|
signOut() |
||||||
|
|
||||||
|
navigateTo('/signin') |
||||||
|
} |
||||||
|
|
||||||
|
const resetError = () => { |
||||||
|
if (error.value) error.value = null |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<template> |
||||||
|
<div class="mx-auto relative flex flex-col justify-center gap-2 w-full px-8 md:(bg-white) max-w-[900px]"> |
||||||
|
<div class="text-xl mt-4 mb-8 text-center font-weight-bold">{{ $t('activity.changePwd') }}</div> |
||||||
|
<a-form |
||||||
|
ref="formValidator" |
||||||
|
data-testid="nc-user-settings-form" |
||||||
|
layout="vertical" |
||||||
|
class="change-password lg:max-w-3/4 w-full !mx-auto" |
||||||
|
no-style |
||||||
|
:model="form" |
||||||
|
@finish="passwordChange" |
||||||
|
> |
||||||
|
<Transition name="layout"> |
||||||
|
<div v-if="error" class="mx-auto mb-4 bg-red-500 text-white rounded-lg w-3/4 p-1"> |
||||||
|
<div data-testid="nc-user-settings-form__error" class="flex items-center gap-2 justify-center"> |
||||||
|
<MaterialSymbolsWarning /> |
||||||
|
{{ error }} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</Transition> |
||||||
|
|
||||||
|
<a-form-item :label="$t('placeholder.password.current')" name="currentPassword" :rules="formRules.currentPassword"> |
||||||
|
<a-input-password |
||||||
|
v-model:value="form.currentPassword" |
||||||
|
data-testid="nc-user-settings-form__current-password" |
||||||
|
size="large" |
||||||
|
class="password" |
||||||
|
:placeholder="$t('placeholder.password.current')" |
||||||
|
@focus="resetError" |
||||||
|
/> |
||||||
|
</a-form-item> |
||||||
|
|
||||||
|
<a-form-item :label="$t('placeholder.password.new')" name="password" :rules="formRules.password"> |
||||||
|
<a-input-password |
||||||
|
v-model:value="form.password" |
||||||
|
data-testid="nc-user-settings-form__new-password" |
||||||
|
size="large" |
||||||
|
class="password" |
||||||
|
:placeholder="$t('placeholder.password.new')" |
||||||
|
@focus="resetError" |
||||||
|
/> |
||||||
|
</a-form-item> |
||||||
|
|
||||||
|
<a-form-item :label="$t('placeholder.password.confirm')" name="passwordRepeat" :rules="formRules.passwordRepeat"> |
||||||
|
<a-input-password |
||||||
|
v-model:value="form.passwordRepeat" |
||||||
|
data-testid="nc-user-settings-form__new-password-repeat" |
||||||
|
size="large" |
||||||
|
class="password" |
||||||
|
:placeholder="$t('placeholder.password.confirm')" |
||||||
|
@focus="resetError" |
||||||
|
/> |
||||||
|
</a-form-item> |
||||||
|
|
||||||
|
<div class="text-center"> |
||||||
|
<button data-testid="nc-user-settings-form__submit" class="scaling-btn bg-opacity-100" type="submit"> |
||||||
|
<span class="flex items-center gap-2"> |
||||||
|
<MdiKeyChange /> |
||||||
|
{{ $t('activity.changePwd') }} |
||||||
|
</span> |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
</a-form> |
||||||
|
</div> |
||||||
|
</template> |
||||||
|
|
||||||
|
<style lang="scss"> |
||||||
|
.change-password { |
||||||
|
.ant-input-affix-wrapper, |
||||||
|
.ant-input { |
||||||
|
@apply !appearance-none my-1 border-1 border-solid border-primary border-opacity-50 rounded; |
||||||
|
} |
||||||
|
|
||||||
|
.password { |
||||||
|
input { |
||||||
|
@apply !border-none !m-0; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
</style> |
@ -0,0 +1,56 @@ |
|||||||
|
<script lang="ts" setup> |
||||||
|
import { message } from 'ant-design-vue' |
||||||
|
import { extractSdkResponseErrorMsg, useApi } from '#imports' |
||||||
|
|
||||||
|
const { api } = useApi() |
||||||
|
|
||||||
|
const { t } = useI18n() |
||||||
|
|
||||||
|
let settings = $ref<{ invite_only_signup?: boolean }>({ invite_only_signup: false }) |
||||||
|
|
||||||
|
const loadSettings = async () => { |
||||||
|
try { |
||||||
|
const response = await api.orgAppSettings.get() |
||||||
|
settings = response |
||||||
|
} catch (e) { |
||||||
|
message.error(await extractSdkResponseErrorMsg(e)) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const saveSettings = async () => { |
||||||
|
try { |
||||||
|
await api.orgAppSettings.set(settings) |
||||||
|
message.success(t('msg.success.settingsSaved')) |
||||||
|
} catch (e) { |
||||||
|
message.error(await extractSdkResponseErrorMsg(e)) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
loadSettings() |
||||||
|
</script> |
||||||
|
|
||||||
|
<template> |
||||||
|
<div data-testid="nc-app-settings"> |
||||||
|
<div class="text-xl mt-4 mb-8 text-center font-weight-bold">Settings</div> |
||||||
|
<div class="flex justify-center"> |
||||||
|
<a-form-item> |
||||||
|
<a-checkbox |
||||||
|
v-model:checked="settings.invite_only_signup" |
||||||
|
v-e="['c:account:enable-signup']" |
||||||
|
class="nc-checkbox nc-invite-only-signup-checkbox" |
||||||
|
name="virtual" |
||||||
|
@change="saveSettings" |
||||||
|
> |
||||||
|
{{ $t('labels.inviteOnlySignup') }} |
||||||
|
</a-checkbox> |
||||||
|
</a-form-item> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</template> |
||||||
|
|
||||||
|
<style scoped> |
||||||
|
:deep(.ant-checkbox-wrapper) { |
||||||
|
@apply !flex-row-reverse !flex !justify-start gap-4; |
||||||
|
justify-content: start; |
||||||
|
} |
||||||
|
</style> |
@ -0,0 +1,262 @@ |
|||||||
|
<script lang="ts" setup> |
||||||
|
import { Empty, Modal, message } from 'ant-design-vue' |
||||||
|
import type { ApiTokenType, RequestParams, UserType } from 'nocodb-sdk' |
||||||
|
import { extractSdkResponseErrorMsg, useApi, useCopy, useNuxtApp } from '#imports' |
||||||
|
|
||||||
|
const { api, isLoading } = useApi() |
||||||
|
|
||||||
|
const { $e } = useNuxtApp() |
||||||
|
|
||||||
|
const { copy } = useCopy() |
||||||
|
|
||||||
|
const { t } = useI18n() |
||||||
|
|
||||||
|
let tokens = $ref<UserType[]>([]) |
||||||
|
|
||||||
|
let currentPage = $ref(1) |
||||||
|
|
||||||
|
let showNewTokenModal = $ref(false) |
||||||
|
|
||||||
|
const currentLimit = $ref(10) |
||||||
|
|
||||||
|
let selectedTokenData = $ref<ApiTokenType>({}) |
||||||
|
|
||||||
|
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) { |
||||||
|
message.error(await extractSdkResponseErrorMsg(e)) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
loadTokens() |
||||||
|
|
||||||
|
const deleteToken = async (token: string) => { |
||||||
|
Modal.confirm({ |
||||||
|
title: t('msg.info.deleteTokenConfirmation'), |
||||||
|
type: 'warn', |
||||||
|
onOk: async () => { |
||||||
|
try { |
||||||
|
// todo: delete token |
||||||
|
await api.orgTokens.delete(token) |
||||||
|
message.success(t('msg.success.tokenDeleted')) |
||||||
|
await loadTokens() |
||||||
|
} catch (e) { |
||||||
|
message.error(await extractSdkResponseErrorMsg(e)) |
||||||
|
} |
||||||
|
$e('a:account:token:delete') |
||||||
|
}, |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
const generateToken = async () => { |
||||||
|
try { |
||||||
|
await api.orgTokens.create(selectedTokenData) |
||||||
|
showNewTokenModal = false |
||||||
|
// Token generated successfully |
||||||
|
message.success(t('msg.success.tokenGenerated')) |
||||||
|
selectedTokenData = {} |
||||||
|
await loadTokens() |
||||||
|
} catch (e) { |
||||||
|
message.error(await extractSdkResponseErrorMsg(e)) |
||||||
|
} |
||||||
|
$e('a:api-token:generate') |
||||||
|
} |
||||||
|
|
||||||
|
const copyToken = (token: string | undefined) => { |
||||||
|
if (!token) return |
||||||
|
|
||||||
|
copy(token) |
||||||
|
// Copied to clipboard |
||||||
|
message.info(t('msg.info.copiedToClipboard')) |
||||||
|
|
||||||
|
$e('c:api-token:copy') |
||||||
|
} |
||||||
|
|
||||||
|
const descriptionInput = (el) => { |
||||||
|
el?.focus() |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<template> |
||||||
|
<div class="h-full overflow-y-scroll scrollbar-thin-dull pt-2"> |
||||||
|
<div class="text-xl mt-4 mb-8 text-center font-weight-bold">Token Management</div> |
||||||
|
<div class="max-w-[900px] mx-auto p-4" data-testid="nc-token-list"> |
||||||
|
<div class="py-2 flex gap-4 items-center"> |
||||||
|
<div class="flex-grow"></div> |
||||||
|
<MdiReload class="cursor-pointer" @click="loadTokens" /> |
||||||
|
<a-button data-testid="nc-token-create" size="small" type="primary" @click="showNewTokenModal = 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" |
||||||
|
size="small" |
||||||
|
@change="loadTokens($event.current)" |
||||||
|
> |
||||||
|
<template #emptyText> |
||||||
|
<a-empty :image="Empty.PRESENTED_IMAGE_SIMPLE" :description="$t('labels.noData')" /> |
||||||
|
</template> |
||||||
|
|
||||||
|
<!-- Created By --> |
||||||
|
<a-table-column key="created_by" :title="$t('labels.createdBy')" data-index="created_by"> |
||||||
|
<template #default="{ text }"> |
||||||
|
<div v-if="text"> |
||||||
|
{{ text }} |
||||||
|
</div> |
||||||
|
<div v-else class="text-gray-400">N/A</div> |
||||||
|
</template> |
||||||
|
</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> |
||||||
|
</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> |
||||||
|
<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 class="nc-token-menu" /> |
||||||
|
</div> |
||||||
|
</a-button> |
||||||
|
</div> |
||||||
|
|
||||||
|
<template #overlay> |
||||||
|
<a-menu data-testid="nc-token-row-action-icon"> |
||||||
|
<a-menu-item> |
||||||
|
<div class="flex flex-row items-center py-3 h-[1rem] nc-delete-token" @click="deleteToken(record.token)"> |
||||||
|
<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 |
||||||
|
:ref="descriptionInput" |
||||||
|
v-model:value="selectedTokenData.description" |
||||||
|
data-testid="nc-token-modal-description" |
||||||
|
:placeholder="$t('labels.description')" |
||||||
|
/> |
||||||
|
|
||||||
|
<!-- Generate --> |
||||||
|
<div class="flex flex-row justify-center"> |
||||||
|
<a-button type="primary" html-type="submit" data-testid="nc-token-modal-save"> |
||||||
|
{{ $t('general.generate') }} |
||||||
|
</a-button> |
||||||
|
</div> |
||||||
|
</a-form> |
||||||
|
</div> |
||||||
|
</a-modal> |
||||||
|
</div> |
||||||
|
</template> |
||||||
|
|
||||||
|
<style scoped></style> |
@ -0,0 +1,281 @@ |
|||||||
|
<script lang="ts" setup> |
||||||
|
import { Modal, message } from 'ant-design-vue' |
||||||
|
import type { RequestParams, UserType } from 'nocodb-sdk' |
||||||
|
import { Role, extractSdkResponseErrorMsg, useApi, useCopy, useDashboard, useNuxtApp } 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 userMadalKey = ref(0) |
||||||
|
|
||||||
|
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) { |
||||||
|
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(t('msg.success.roleUpdated')) |
||||||
|
|
||||||
|
$e('a:org-user:role-updated', { role: roles }) |
||||||
|
} catch (e) { |
||||||
|
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 organization and any sync source(Airtable) created by user will get removed', |
||||||
|
onOk: async () => { |
||||||
|
try { |
||||||
|
await api.orgUsers.delete(userId) |
||||||
|
message.success(t('msg.success.userDeleted')) |
||||||
|
await loadUsers() |
||||||
|
$e('a:org-user:user-deleted') |
||||||
|
} catch (e) { |
||||||
|
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) { |
||||||
|
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') |
||||||
|
} |
||||||
|
|
||||||
|
const copyPasswordResetUrl = async (user: User) => { |
||||||
|
try { |
||||||
|
const { reset_password_url } = await api.orgUsers.generatePasswordResetToken(user.id) |
||||||
|
|
||||||
|
copy(reset_password_url) |
||||||
|
|
||||||
|
// Invite URL copied to clipboard |
||||||
|
message.success(t('msg.success.passwordResetURLCopied')) |
||||||
|
$e('c:user:copy-url') |
||||||
|
} catch (e) { |
||||||
|
message.error(await extractSdkResponseErrorMsg(e)) |
||||||
|
} |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<template> |
||||||
|
<div data-testid="nc-super-user-list"> |
||||||
|
<div class="text-xl mt-4 mb-8 text-center font-weight-bold">User Management</div> |
||||||
|
<div class="max-w-[900px] mx-auto p-4"> |
||||||
|
<div class="py-2 flex gap-4 items-center"> |
||||||
|
<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> |
||||||
|
<MdiReload class="cursor-pointer" @click="loadUsers" /> |
||||||
|
<a-button |
||||||
|
data-testid="nc-super-user-invite" |
||||||
|
size="small" |
||||||
|
type="primary" |
||||||
|
@click=" |
||||||
|
() => { |
||||||
|
showUserModal = true |
||||||
|
userMadalKey++ |
||||||
|
} |
||||||
|
" |
||||||
|
> |
||||||
|
<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="{ position: ['bottomCenter'] }" |
||||||
|
:loading="isLoading" |
||||||
|
size="small" |
||||||
|
@change="loadUsers($event.current)" |
||||||
|
> |
||||||
|
<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] nc-user-roles" |
||||||
|
:dropdown-match-select-width="false" |
||||||
|
@change="updateRole(record.id, record.roles)" |
||||||
|
> |
||||||
|
<a-select-option |
||||||
|
class="nc-users-list-role-option" |
||||||
|
:value="Role.OrgLevelCreator" |
||||||
|
:label="$t(`objects.roleType.orgLevelCreator`)" |
||||||
|
> |
||||||
|
<div>{{ $t(`objects.roleType.orgLevelCreator`) }}</div> |
||||||
|
<span class="text-gray-500 text-xs whitespace-normal"> |
||||||
|
{{ $t('msg.info.roles.orgCreator') }} |
||||||
|
</span> |
||||||
|
</a-select-option> |
||||||
|
|
||||||
|
<a-select-option |
||||||
|
class="nc-users-list-role-option" |
||||||
|
:value="Role.OrgLevelViewer" |
||||||
|
:label="$t(`objects.roleType.orgLevelViewer`)" |
||||||
|
> |
||||||
|
<div>{{ $t(`objects.roleType.orgLevelViewer`) }}</div> |
||||||
|
<span class="text-gray-500 text-xs whitespace-normal"> |
||||||
|
{{ $t('msg.info.roles.orgViewer') }} |
||||||
|
</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 v-if="!record.roles.includes('super')" class="flex items-center gap-2"> |
||||||
|
<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]"> |
||||||
|
<MdiDotsHorizontal class="nc-user-row-action" /> |
||||||
|
</div> |
||||||
|
</a-button> |
||||||
|
</div> |
||||||
|
|
||||||
|
<template #overlay> |
||||||
|
<a-menu> |
||||||
|
<template v-if="record.invite_token"> |
||||||
|
<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> |
||||||
|
</template> |
||||||
|
<a-menu-item> |
||||||
|
<div class="flex flex-row items-center py-3" @click="copyPasswordResetUrl(record)"> |
||||||
|
<MdiContentCopy class="flex h-[1rem] text-gray-500" /> |
||||||
|
<div class="text-xs pl-2">{{ $t('activity.copyPasswordResetURL') }}</div> |
||||||
|
</div> |
||||||
|
</a-menu-item> |
||||||
|
<a-menu-item> |
||||||
|
<div class="flex flex-row items-center py-3" @click="deleteUser(text)"> |
||||||
|
<MdiDeleteOutline data-testid="nc-super-user-delete" class="flex h-[1rem] text-gray-500" /> |
||||||
|
<div class="text-xs pl-2">{{ $t('general.delete') }}</div> |
||||||
|
</div> |
||||||
|
</a-menu-item> |
||||||
|
</a-menu> |
||||||
|
</template> |
||||||
|
</a-dropdown> |
||||||
|
</div> |
||||||
|
<span v-else></span> |
||||||
|
</template> |
||||||
|
</a-table-column> |
||||||
|
</a-table> |
||||||
|
|
||||||
|
<LazyAccountUsersModal :key="userMadalKey" :show="showUserModal" @closed="showUserModal = false" @reload="loadUsers" /> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</template> |
@ -0,0 +1,256 @@ |
|||||||
|
<script setup lang="ts"> |
||||||
|
import type { UserType } from 'nocodb-sdk' |
||||||
|
import { |
||||||
|
Form, |
||||||
|
computed, |
||||||
|
extractSdkResponseErrorMsg, |
||||||
|
isEmail, |
||||||
|
message, |
||||||
|
ref, |
||||||
|
useCopy, |
||||||
|
useDashboard, |
||||||
|
useI18n, |
||||||
|
useNuxtApp, |
||||||
|
} from '#imports' |
||||||
|
import type { User } from '~/lib' |
||||||
|
import { Role } from '~/lib' |
||||||
|
|
||||||
|
interface Props { |
||||||
|
show: boolean |
||||||
|
selectedUser?: User |
||||||
|
} |
||||||
|
|
||||||
|
interface Users { |
||||||
|
emails: string |
||||||
|
role: Role.OrgLevelCreator | Role.OrgLevelViewer |
||||||
|
invitationToken?: string |
||||||
|
} |
||||||
|
|
||||||
|
const { show } = defineProps<Props>() |
||||||
|
|
||||||
|
const emit = defineEmits(['closed', 'reload']) |
||||||
|
|
||||||
|
const { t } = useI18n() |
||||||
|
|
||||||
|
const { $api, $e } = useNuxtApp() |
||||||
|
|
||||||
|
const { copy } = useCopy() |
||||||
|
|
||||||
|
const { dashboardUrl } = $(useDashboard()) |
||||||
|
|
||||||
|
const usersData = $ref<Users>({ emails: '', role: Role.OrgLevelViewer, invitationToken: undefined }) |
||||||
|
|
||||||
|
const formRef = ref() |
||||||
|
|
||||||
|
const useForm = Form.useForm |
||||||
|
const validators = computed(() => { |
||||||
|
return { |
||||||
|
emails: [ |
||||||
|
{ |
||||||
|
validator: (rule: any, value: string, callback: (errMsg?: string) => void) => { |
||||||
|
if (!value || value.length === 0) { |
||||||
|
callback('Email is required') |
||||||
|
return |
||||||
|
} |
||||||
|
const invalidEmails = (value || '').split(/\s*,\s*/).filter((e: string) => !isEmail(e)) |
||||||
|
if (invalidEmails.length > 0) { |
||||||
|
callback(`${invalidEmails.length > 1 ? ' Invalid emails:' : 'Invalid email:'} ${invalidEmails.join(', ')} `) |
||||||
|
} else { |
||||||
|
callback() |
||||||
|
} |
||||||
|
}, |
||||||
|
}, |
||||||
|
], |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
const { validateInfos } = useForm(usersData, validators) |
||||||
|
|
||||||
|
const saveUser = async () => { |
||||||
|
$e('a:org-user:invite', { role: usersData.role }) |
||||||
|
|
||||||
|
await formRef.value?.validateFields() |
||||||
|
|
||||||
|
try { |
||||||
|
// todo: update sdk(swagger.json) |
||||||
|
const res = await $api.orgUsers.add({ |
||||||
|
roles: usersData.role, |
||||||
|
email: usersData.emails, |
||||||
|
} as unknown as UserType) |
||||||
|
|
||||||
|
usersData.invitationToken = res.invite_token |
||||||
|
emit('reload') |
||||||
|
|
||||||
|
// Successfully updated the user details |
||||||
|
message.success(t('msg.success.userAdded')) |
||||||
|
} catch (e: any) { |
||||||
|
console.error(e) |
||||||
|
message.error(await extractSdkResponseErrorMsg(e)) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const inviteUrl = $computed(() => (usersData.invitationToken ? `${dashboardUrl}#/signup/${usersData.invitationToken}` : null)) |
||||||
|
|
||||||
|
const copyUrl = async () => { |
||||||
|
if (!inviteUrl) return |
||||||
|
|
||||||
|
await copy(inviteUrl) |
||||||
|
|
||||||
|
// Copied shareable base url to clipboard! |
||||||
|
message.success(t('msg.success.shareableURLCopied')) |
||||||
|
|
||||||
|
$e('c:shared-base:copy-url') |
||||||
|
} |
||||||
|
|
||||||
|
const clickInviteMore = () => { |
||||||
|
$e('c:user:invite-more') |
||||||
|
usersData.invitationToken = undefined |
||||||
|
usersData.role = Role.OrgLevelViewer |
||||||
|
usersData.emails = '' |
||||||
|
} |
||||||
|
const emailInput = ref((el) => { |
||||||
|
el?.focus() |
||||||
|
}) |
||||||
|
</script> |
||||||
|
|
||||||
|
<template> |
||||||
|
<a-modal |
||||||
|
:footer="null" |
||||||
|
centered |
||||||
|
:visible="show" |
||||||
|
:closable="false" |
||||||
|
width="max(50vw, 44rem)" |
||||||
|
wrap-class-name="nc-modal-invite-user" |
||||||
|
@cancel="emit('closed')" |
||||||
|
> |
||||||
|
<div class="flex flex-col"> |
||||||
|
<div class="flex flex-row justify-between items-center pb-1.5 mb-2 border-b-1 w-full"> |
||||||
|
<a-typography-title class="select-none" :level="4"> {{ $t('activity.inviteUser') }}</a-typography-title> |
||||||
|
|
||||||
|
<a-button type="text" class="!rounded-md mr-1 -mt-1.5" @click="emit('closed')"> |
||||||
|
<template #icon> |
||||||
|
<MaterialSymbolsCloseRounded data-testid="nc-root-user-invite-modal-close" class="flex mx-auto" /> |
||||||
|
</template> |
||||||
|
</a-button> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="px-2 mt-1.5"> |
||||||
|
<template v-if="usersData.invitationToken"> |
||||||
|
<div class="flex flex-col mt-1 border-b-1 pb-5"> |
||||||
|
<div class="flex flex-row items-center pl-1.5 pb-1 h-[1.1rem]"> |
||||||
|
<MdiAccountOutline /> |
||||||
|
<div class="text-xs ml-0.5 mt-0.5">Copy Invite Token</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<a-alert class="mt-1" type="success" show-icon> |
||||||
|
<template #message> |
||||||
|
<div class="flex flex-row justify-between items-center py-1"> |
||||||
|
<div class="flex pl-2 text-green-700 text-xs"> |
||||||
|
{{ inviteUrl }} |
||||||
|
</div> |
||||||
|
|
||||||
|
<a-button type="text" class="!rounded-md -mt-0.5" @click="copyUrl"> |
||||||
|
<template #icon> |
||||||
|
<MdiContentCopy class="flex mx-auto text-green-700 h-[1rem]" /> |
||||||
|
</template> |
||||||
|
</a-button> |
||||||
|
</div> |
||||||
|
</template> |
||||||
|
</a-alert> |
||||||
|
|
||||||
|
<div class="flex text-xs text-gray-500 mt-2 justify-start ml-2"> |
||||||
|
{{ $t('msg.info.userInviteNoSMTP') }} |
||||||
|
{{ usersData.invitationToken && usersData.emails }} |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="flex flex-row justify-start mt-4 ml-2"> |
||||||
|
<a-button size="small" outlined @click="clickInviteMore"> |
||||||
|
<div class="flex flex-row justify-center items-center space-x-0.5"> |
||||||
|
<MaterialSymbolsSendOutline class="flex mx-auto text-gray-600 h-[0.8rem]" /> |
||||||
|
|
||||||
|
<div class="text-xs text-gray-600">{{ $t('activity.inviteMore') }}</div> |
||||||
|
</div> |
||||||
|
</a-button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</template> |
||||||
|
|
||||||
|
<div v-else class="flex flex-col pb-4"> |
||||||
|
<div class="flex flex-row items-center pl-2 pb-1 h-[1rem]"> |
||||||
|
<MdiAccountOutline /> |
||||||
|
<div class="text-xs ml-0.5 mt-0.5">{{ $t('activity.inviteUser') }}</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="border-1 py-3 px-4 rounded-md mt-1"> |
||||||
|
<a-form |
||||||
|
ref="formRef" |
||||||
|
:validate-on-rule-change="false" |
||||||
|
:model="usersData" |
||||||
|
validate-trigger="onBlur" |
||||||
|
@finish="saveUser" |
||||||
|
> |
||||||
|
<div class="flex flex-row space-x-4"> |
||||||
|
<div class="flex flex-col w-3/4"> |
||||||
|
<a-form-item |
||||||
|
v-bind="validateInfos.emails" |
||||||
|
validate-trigger="onBlur" |
||||||
|
name="emails" |
||||||
|
:rules="[{ required: true, message: 'Please input email' }]" |
||||||
|
> |
||||||
|
<div class="ml-1 mb-1 text-xs text-gray-500">{{ $t('datatype.Email') }}:</div> |
||||||
|
|
||||||
|
<a-input |
||||||
|
:ref="emailInput" |
||||||
|
v-model:value="usersData.emails" |
||||||
|
validate-trigger="onBlur" |
||||||
|
:placeholder="$t('labels.email')" |
||||||
|
/> |
||||||
|
</a-form-item> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="flex flex-col w-2/4"> |
||||||
|
<a-form-item name="role" :rules="[{ required: true, message: 'Role required' }]"> |
||||||
|
<div class="ml-1 mb-1 text-xs text-gray-500">{{ $t('labels.selectUserRole') }}</div> |
||||||
|
|
||||||
|
<a-select v-model:value="usersData.role" class="nc-user-roles" dropdown-class-name="nc-dropdown-user-role"> |
||||||
|
<a-select-option |
||||||
|
class="nc-role-option" |
||||||
|
:value="Role.OrgLevelCreator" |
||||||
|
:label="$t(`objects.roleType.orgLevelCreator`)" |
||||||
|
> |
||||||
|
<div>{{ $t(`objects.roleType.orgLevelCreator`) }}</div> |
||||||
|
<span class="text-gray-500 text-xs whitespace-normal"> |
||||||
|
{{ $t('msg.info.roles.orgCreator') }} |
||||||
|
</span> |
||||||
|
</a-select-option> |
||||||
|
|
||||||
|
<a-select-option |
||||||
|
class="nc-role-option" |
||||||
|
:value="Role.OrgLevelViewer" |
||||||
|
:label="$t(`objects.roleType.orgLevelViewer`)" |
||||||
|
> |
||||||
|
<div>{{ $t(`objects.roleType.orgLevelViewer`) }}</div> |
||||||
|
<span class="text-gray-500 text-xs whitespace-normal"> |
||||||
|
{{ $t('msg.info.roles.orgViewer') }} |
||||||
|
</span> |
||||||
|
</a-select-option> |
||||||
|
</a-select> |
||||||
|
</a-form-item> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="flex flex-row justify-center"> |
||||||
|
<a-button type="primary" html-type="submit"> |
||||||
|
<div class="flex flex-row justify-center items-center space-x-1.5"> |
||||||
|
<MaterialSymbolsSendOutline class="flex h-[0.8rem]" /> |
||||||
|
<div>{{ $t('activity.invite') }}</div> |
||||||
|
</div> |
||||||
|
</a-button> |
||||||
|
</div> |
||||||
|
</a-form> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</a-modal> |
||||||
|
</template> |
@ -0,0 +1,112 @@ |
|||||||
|
<script lang="ts" setup> |
||||||
|
import { navigateTo, useUIPermission } from '#imports' |
||||||
|
|
||||||
|
const { isUIAllowed } = useUIPermission() |
||||||
|
const $route = useRoute() |
||||||
|
|
||||||
|
const selectedKeys = computed(() => [ |
||||||
|
/^\/account\/users\/?$/.test($route.fullPath) |
||||||
|
? isUIAllowed('superAdminUserManagement') |
||||||
|
? 'list' |
||||||
|
: 'settings' |
||||||
|
: $route.params.nestedPage ?? $route.params.page, |
||||||
|
]) |
||||||
|
const openKeys = ref([/^\/account\/users/.test($route.fullPath) && 'users']) |
||||||
|
</script> |
||||||
|
|
||||||
|
<template> |
||||||
|
<div class="mx-auto h-full"> |
||||||
|
<a-layout class="h-full overflow-y-auto flex"> |
||||||
|
<!-- Side tabs --> |
||||||
|
<a-layout-sider> |
||||||
|
<div class="h-full bg-white nc-user-sidebar"> |
||||||
|
<a-menu |
||||||
|
v-model:openKeys="openKeys" |
||||||
|
v-model:selectedKeys="selectedKeys" |
||||||
|
:inline-indent="16" |
||||||
|
class="tabs-menu h-full" |
||||||
|
mode="inline" |
||||||
|
> |
||||||
|
<div class="text-xs text-gray-500 ml-4 pt-4 pb-2 font-weight-bold">Account Settings</div> |
||||||
|
|
||||||
|
<a-sub-menu key="users" class="!bg-white"> |
||||||
|
<template #icon> |
||||||
|
<MdiAccountSupervisorOutline /> |
||||||
|
</template> |
||||||
|
<template #title>Users</template> |
||||||
|
|
||||||
|
<a-menu-item |
||||||
|
v-if="isUIAllowed('superAdminUserManagement')" |
||||||
|
key="list" |
||||||
|
class="text-xs" |
||||||
|
@click="navigateTo('/account/users/list')" |
||||||
|
> |
||||||
|
<span class="ml-4">User Management</span> |
||||||
|
</a-menu-item> |
||||||
|
<a-menu-item key="password-reset" class="text-xs" @click="navigateTo('/account/users/password-reset')"> |
||||||
|
<span class="ml-4">Reset Password</span> |
||||||
|
</a-menu-item> |
||||||
|
<a-menu-item |
||||||
|
v-if="isUIAllowed('superAdminAppSettings')" |
||||||
|
key="settings" |
||||||
|
class="text-xs" |
||||||
|
@click="navigateTo('/account/users/settings')" |
||||||
|
> |
||||||
|
<span class="ml-4">Settings</span> |
||||||
|
</a-menu-item> |
||||||
|
</a-sub-menu> |
||||||
|
|
||||||
|
<a-menu-item |
||||||
|
key="tokens" |
||||||
|
class="group active:(!ring-0) hover:(!bg-primary !bg-opacity-25)" |
||||||
|
@click="navigateTo('/account/tokens')" |
||||||
|
> |
||||||
|
<div class="flex items-center space-x-2"> |
||||||
|
<MdiShieldKeyOutline /> |
||||||
|
|
||||||
|
<div class="select-none">Tokens</div> |
||||||
|
</div> |
||||||
|
</a-menu-item> |
||||||
|
<a-menu-item |
||||||
|
key="apps" |
||||||
|
class="group active:(!ring-0) hover:(!bg-primary !bg-opacity-25)" |
||||||
|
@click="navigateTo('/account/apps')" |
||||||
|
> |
||||||
|
<div class="flex items-center space-x-2"> |
||||||
|
<MdiStorefrontOutline /> |
||||||
|
|
||||||
|
<div class="select-none">App Store</div> |
||||||
|
</div> |
||||||
|
</a-menu-item> |
||||||
|
</a-menu> |
||||||
|
</div> |
||||||
|
</a-layout-sider> |
||||||
|
|
||||||
|
<!-- Sub Tabs --> |
||||||
|
<a-layout-content class="h-auto px-4 scrollbar-thumb-gray-500"> |
||||||
|
<div class="container mx-auto"> |
||||||
|
<NuxtPage /> |
||||||
|
</div> |
||||||
|
</a-layout-content> |
||||||
|
</a-layout> |
||||||
|
</div> |
||||||
|
</template> |
||||||
|
|
||||||
|
<style lang="scss" scoped> |
||||||
|
:deep(.nc-user-sidebar .ant-menu-sub.ant-menu-inline) { |
||||||
|
@apply bg-transparent; |
||||||
|
} |
||||||
|
|
||||||
|
:deep(.nc-user-sidebar .ant-menu-item-only-child), |
||||||
|
:deep(.ant-menu-submenu-title) { |
||||||
|
@apply !h-[30px] !leading-[30px]; |
||||||
|
} |
||||||
|
|
||||||
|
:deep(.ant-menu-submenu-arrow) { |
||||||
|
@apply !text-gray-400; |
||||||
|
} |
||||||
|
|
||||||
|
:deep(.ant-menu-submenu-selected .ant-menu-submenu-arrow) { |
||||||
|
@apply !text-inherit; |
||||||
|
} |
||||||
|
</style> |
@ -0,0 +1,6 @@ |
|||||||
|
<template> |
||||||
|
<AccountUserManagement v-if="$route.params.page === 'users'" /> |
||||||
|
<AccountToken v-else-if="$route.params.page === 'tokens'" /> |
||||||
|
<AccountAppStore v-else-if="$route.params.page === 'apps'" /> |
||||||
|
<span v-else></span> |
||||||
|
</template> |
@ -0,0 +1,5 @@ |
|||||||
|
<template> |
||||||
|
<div class="h-full overflow-y-scroll scrollbar-thin-dull pt-2"> |
||||||
|
<NuxtPage /> |
||||||
|
</div> |
||||||
|
</template> |
@ -0,0 +1,22 @@ |
|||||||
|
<script setup lang="ts"> |
||||||
|
import { useUIPermission } from '#imports' |
||||||
|
|
||||||
|
const { isUIAllowed } = useUIPermission() |
||||||
|
</script> |
||||||
|
|
||||||
|
<template> |
||||||
|
<template |
||||||
|
v-if=" |
||||||
|
$route.params.nestedPage === 'password-reset' || |
||||||
|
(!isUIAllowed('superAdminUserManagement') && !isUIAllowed('superAdminAppSettings')) |
||||||
|
" |
||||||
|
> |
||||||
|
<LazyAccountResetPassword /> |
||||||
|
</template> |
||||||
|
<template v-else-if="$route.params.nestedPage === 'settings'"> |
||||||
|
<LazyAccountSignupSettings /> |
||||||
|
</template> |
||||||
|
<template v-else-if="isUIAllowed('superAdminUserManagement')"> |
||||||
|
<LazyAccountUserList /> |
||||||
|
</template> |
||||||
|
</template> |
@ -0,0 +1,5 @@ |
|||||||
|
export enum OrgUserRoles { |
||||||
|
SUPER_ADMIN = 'super', |
||||||
|
CREATOR = 'org-level-creator', |
||||||
|
VIEWER = 'org-level-viewer', |
||||||
|
} |
@ -0,0 +1,2 @@ |
|||||||
|
export const NC_LICENSE_KEY = 'nc-license-key'; |
||||||
|
export const NC_APP_SETTINGS = 'nc-app-settings'; |
@ -0,0 +1,22 @@ |
|||||||
|
import { OrgUserRoles } from '../../../../enums/OrgUserRoles'; |
||||||
|
import ApiToken from '../../../models/ApiToken'; |
||||||
|
import { PagedResponseImpl } from '../../helpers/PagedResponse'; |
||||||
|
|
||||||
|
export async function apiTokenListEE(req, res) { |
||||||
|
let fk_user_id = req.user.id; |
||||||
|
|
||||||
|
// if super admin get all tokens
|
||||||
|
if (req.user.roles.includes(OrgUserRoles.SUPER_ADMIN)) { |
||||||
|
fk_user_id = undefined; |
||||||
|
} |
||||||
|
|
||||||
|
res.json( |
||||||
|
new PagedResponseImpl( |
||||||
|
await ApiToken.listWithCreatedBy({ ...req.query, fk_user_id }), |
||||||
|
{ |
||||||
|
...req.query, |
||||||
|
count: await ApiToken.count({}), |
||||||
|
} |
||||||
|
) |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,40 @@ |
|||||||
|
import { Router } from 'express'; |
||||||
|
import { OrgUserRoles } from '../../../enums/OrgUserRoles'; |
||||||
|
import { NC_LICENSE_KEY } from '../../constants' |
||||||
|
import Store from '../../models/Store'; |
||||||
|
import { metaApiMetrics } from '../helpers/apiMetrics'; |
||||||
|
import ncMetaAclMw from '../helpers/ncMetaAclMw'; |
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
async function licenseGet(_req, res) { |
||||||
|
const license = await Store.get(NC_LICENSE_KEY); |
||||||
|
|
||||||
|
res.json({ key: license?.value }); |
||||||
|
} |
||||||
|
|
||||||
|
async function licenseSet(req, res) { |
||||||
|
await Store.saveOrUpdate({ value: req.body.key, key: NC_LICENSE_KEY }); |
||||||
|
|
||||||
|
res.json({ msg: 'License key saved' }); |
||||||
|
} |
||||||
|
|
||||||
|
const router = Router({ mergeParams: true }); |
||||||
|
router.get( |
||||||
|
'/api/v1/license', |
||||||
|
metaApiMetrics, |
||||||
|
ncMetaAclMw(licenseGet, 'licenseGet', { |
||||||
|
allowedRoles: [OrgUserRoles.SUPER_ADMIN], |
||||||
|
blockApiTokenAccess: true, |
||||||
|
}) |
||||||
|
); |
||||||
|
router.post( |
||||||
|
'/api/v1/license', |
||||||
|
metaApiMetrics, |
||||||
|
ncMetaAclMw(licenseSet, 'licenseSet', { |
||||||
|
allowedRoles: [OrgUserRoles.SUPER_ADMIN], |
||||||
|
blockApiTokenAccess: true, |
||||||
|
}) |
||||||
|
); |
||||||
|
|
||||||
|
export default router; |
@ -0,0 +1,81 @@ |
|||||||
|
import { Request, Response, Router } from 'express'; |
||||||
|
import { OrgUserRoles } from '../../../enums/OrgUserRoles'; |
||||||
|
import ApiToken from '../../models/ApiToken'; |
||||||
|
import { Tele } from 'nc-help'; |
||||||
|
import { metaApiMetrics } from '../helpers/apiMetrics'; |
||||||
|
import { NcError } from '../helpers/catchError'; |
||||||
|
import getHandler from '../helpers/getHandler'; |
||||||
|
import ncMetaAclMw from '../helpers/ncMetaAclMw'; |
||||||
|
import { PagedResponseImpl } from '../helpers/PagedResponse'; |
||||||
|
import { apiTokenListEE } from './ee/orgTokenApis'; |
||||||
|
|
||||||
|
async function apiTokenList(req, res) { |
||||||
|
const fk_user_id = req.user.id; |
||||||
|
let includeUnmappedToken = false; |
||||||
|
if (req['user'].roles.includes(OrgUserRoles.SUPER_ADMIN)) { |
||||||
|
includeUnmappedToken = true; |
||||||
|
} |
||||||
|
|
||||||
|
res.json( |
||||||
|
new PagedResponseImpl( |
||||||
|
await ApiToken.listWithCreatedBy({ |
||||||
|
...req.query, |
||||||
|
fk_user_id, |
||||||
|
includeUnmappedToken, |
||||||
|
}), |
||||||
|
{ |
||||||
|
...req.query, |
||||||
|
count: await ApiToken.count({ |
||||||
|
includeUnmappedToken, |
||||||
|
fk_user_id, |
||||||
|
}), |
||||||
|
} |
||||||
|
) |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
export async function apiTokenCreate(req: Request, res: Response) { |
||||||
|
Tele.emit('evt', { evt_type: 'org:apiToken:created' }); |
||||||
|
res.json(await ApiToken.insert({ ...req.body, fk_user_id: req['user'].id })); |
||||||
|
} |
||||||
|
|
||||||
|
export async function apiTokenDelete(req: Request, res: Response) { |
||||||
|
const fk_user_id = req['user'].id; |
||||||
|
const apiToken = await ApiToken.getByToken(req.params.token); |
||||||
|
if ( |
||||||
|
!req['user'].roles.includes(OrgUserRoles.SUPER_ADMIN) && |
||||||
|
apiToken.fk_user_id !== fk_user_id |
||||||
|
) { |
||||||
|
NcError.notFound('Token not found'); |
||||||
|
} |
||||||
|
Tele.emit('evt', { evt_type: 'org:apiToken:deleted' }); |
||||||
|
res.json(await ApiToken.delete(req.params.token)); |
||||||
|
} |
||||||
|
|
||||||
|
const router = Router({ mergeParams: true }); |
||||||
|
|
||||||
|
router.get( |
||||||
|
'/api/v1/tokens', |
||||||
|
metaApiMetrics, |
||||||
|
ncMetaAclMw(getHandler(apiTokenList, apiTokenListEE), 'apiTokenList', { |
||||||
|
// allowedRoles: [OrgUserRoles.SUPER],
|
||||||
|
blockApiTokenAccess: true, |
||||||
|
}) |
||||||
|
); |
||||||
|
router.post( |
||||||
|
'/api/v1/tokens', |
||||||
|
metaApiMetrics, |
||||||
|
ncMetaAclMw(apiTokenCreate, 'apiTokenCreate', { |
||||||
|
// allowedRoles: [OrgUserRoles.SUPER],
|
||||||
|
blockApiTokenAccess: true, |
||||||
|
}) |
||||||
|
); |
||||||
|
router.delete( |
||||||
|
'/api/v1/tokens/:token', |
||||||
|
metaApiMetrics, |
||||||
|
ncMetaAclMw(apiTokenDelete, 'apiTokenDelete', { |
||||||
|
// allowedRoles: [OrgUserRoles.SUPER],
|
||||||
|
blockApiTokenAccess: true, |
||||||
|
}) |
||||||
|
); |
||||||
|
export default router; |
@ -0,0 +1,329 @@ |
|||||||
|
import { Router } from 'express'; |
||||||
|
import { |
||||||
|
AuditOperationSubTypes, |
||||||
|
AuditOperationTypes, |
||||||
|
PluginCategory, |
||||||
|
} from 'nocodb-sdk'; |
||||||
|
import { v4 as uuidv4 } from 'uuid'; |
||||||
|
import validator from 'validator'; |
||||||
|
import { OrgUserRoles } from '../../../enums/OrgUserRoles'; |
||||||
|
import { NC_APP_SETTINGS } from '../../constants'; |
||||||
|
import Audit from '../../models/Audit'; |
||||||
|
import ProjectUser from '../../models/ProjectUser'; |
||||||
|
import Store from '../../models/Store'; |
||||||
|
import SyncSource from '../../models/SyncSource'; |
||||||
|
import User from '../../models/User'; |
||||||
|
import Noco from '../../Noco'; |
||||||
|
import { MetaTable } from '../../utils/globals'; |
||||||
|
import { Tele } from 'nc-help'; |
||||||
|
import { metaApiMetrics } from '../helpers/apiMetrics'; |
||||||
|
import { NcError } from '../helpers/catchError'; |
||||||
|
import { extractProps } from '../helpers/extractProps'; |
||||||
|
import ncMetaAclMw from '../helpers/ncMetaAclMw'; |
||||||
|
import { PagedResponseImpl } from '../helpers/PagedResponse'; |
||||||
|
import { randomTokenString } from '../helpers/stringHelpers'; |
||||||
|
import { sendInviteEmail } from './projectUserApis'; |
||||||
|
|
||||||
|
async function userList(req, res) { |
||||||
|
res.json( |
||||||
|
new PagedResponseImpl(await User.list(req.query), { |
||||||
|
...req.query, |
||||||
|
count: await User.count(req.query), |
||||||
|
}) |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
async function userUpdate(req, res) { |
||||||
|
const updateBody = extractProps(req.body, ['roles']); |
||||||
|
|
||||||
|
const user = await User.get(req.params.userId); |
||||||
|
|
||||||
|
if (user.roles.includes(OrgUserRoles.SUPER_ADMIN)) { |
||||||
|
NcError.badRequest('Cannot update super admin roles'); |
||||||
|
} |
||||||
|
|
||||||
|
res.json(await User.update(req.params.userId, updateBody)); |
||||||
|
} |
||||||
|
|
||||||
|
async function userDelete(req, res) { |
||||||
|
const ncMeta = await Noco.ncMeta.startTransaction(); |
||||||
|
try { |
||||||
|
const user = await User.get(req.params.userId, ncMeta); |
||||||
|
|
||||||
|
if (user.roles.includes(OrgUserRoles.SUPER_ADMIN)) { |
||||||
|
NcError.badRequest('Cannot delete super admin'); |
||||||
|
} |
||||||
|
|
||||||
|
// delete project user entry and assign to super admin
|
||||||
|
const projectUsers = await ProjectUser.getProjectsList( |
||||||
|
req.params.userId, |
||||||
|
ncMeta |
||||||
|
); |
||||||
|
|
||||||
|
// todo: clear cache
|
||||||
|
|
||||||
|
// TODO: assign super admin as project owner
|
||||||
|
for (const projectUser of projectUsers) { |
||||||
|
await ProjectUser.delete( |
||||||
|
projectUser.project_id, |
||||||
|
projectUser.fk_user_id, |
||||||
|
ncMeta |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
// delete sync source entry
|
||||||
|
await SyncSource.deleteByUserId(req.params.userId, ncMeta); |
||||||
|
|
||||||
|
// delete user
|
||||||
|
await User.delete(req.params.userId, ncMeta); |
||||||
|
await ncMeta.commit(); |
||||||
|
} catch (e) { |
||||||
|
await ncMeta.rollback(e); |
||||||
|
throw e; |
||||||
|
} |
||||||
|
|
||||||
|
res.json({ msg: 'success' }); |
||||||
|
} |
||||||
|
|
||||||
|
async function userAdd(req, res, next) { |
||||||
|
// allow only viewer or creator role
|
||||||
|
if ( |
||||||
|
req.body.roles && |
||||||
|
![OrgUserRoles.VIEWER, OrgUserRoles.CREATOR].includes(req.body.roles) |
||||||
|
) { |
||||||
|
NcError.badRequest('Invalid role'); |
||||||
|
} |
||||||
|
|
||||||
|
// extract emails from request body
|
||||||
|
const emails = (req.body.email || '') |
||||||
|
.toLowerCase() |
||||||
|
.split(/\s*,\s*/) |
||||||
|
.map((v) => v.trim()); |
||||||
|
|
||||||
|
// check for invalid emails
|
||||||
|
const invalidEmails = emails.filter((v) => !validator.isEmail(v)); |
||||||
|
|
||||||
|
if (!emails.length) { |
||||||
|
return NcError.badRequest('Invalid email address'); |
||||||
|
} |
||||||
|
if (invalidEmails.length) { |
||||||
|
NcError.badRequest('Invalid email address : ' + invalidEmails.join(', ')); |
||||||
|
} |
||||||
|
|
||||||
|
const invite_token = uuidv4(); |
||||||
|
const error = []; |
||||||
|
|
||||||
|
for (const email of emails) { |
||||||
|
// add user to project if user already exist
|
||||||
|
const user = await User.getByEmail(email); |
||||||
|
|
||||||
|
if (user) { |
||||||
|
NcError.badRequest('User already exist'); |
||||||
|
} else { |
||||||
|
try { |
||||||
|
// create new user with invite token
|
||||||
|
await User.insert({ |
||||||
|
invite_token, |
||||||
|
invite_token_expires: new Date(Date.now() + 24 * 60 * 60 * 1000), |
||||||
|
email, |
||||||
|
roles: req.body.roles || OrgUserRoles.VIEWER, |
||||||
|
token_version: randomTokenString(), |
||||||
|
}); |
||||||
|
|
||||||
|
const count = await User.count(); |
||||||
|
Tele.emit('evt', { evt_type: 'org:user:invite', count }); |
||||||
|
|
||||||
|
await Audit.insert({ |
||||||
|
op_type: AuditOperationTypes.ORG_USER, |
||||||
|
op_sub_type: AuditOperationSubTypes.INVITE, |
||||||
|
user: req.user.email, |
||||||
|
description: `invited ${email} to ${req.params.projectId} project `, |
||||||
|
ip: req.clientIp, |
||||||
|
}); |
||||||
|
// in case of single user check for smtp failure
|
||||||
|
// and send back token if failed
|
||||||
|
if ( |
||||||
|
emails.length === 1 && |
||||||
|
!(await sendInviteEmail(email, invite_token, req)) |
||||||
|
) { |
||||||
|
return res.json({ invite_token, email }); |
||||||
|
} else { |
||||||
|
sendInviteEmail(email, invite_token, req); |
||||||
|
} |
||||||
|
} catch (e) { |
||||||
|
console.log(e); |
||||||
|
if (emails.length === 1) { |
||||||
|
return next(e); |
||||||
|
} else { |
||||||
|
error.push({ email, error: e.message }); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (emails.length === 1) { |
||||||
|
res.json({ |
||||||
|
msg: 'success', |
||||||
|
}); |
||||||
|
} else { |
||||||
|
return res.json({ invite_token, emails, error }); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
async function userSettings(_req, _res): Promise<any> { |
||||||
|
NcError.notImplemented(); |
||||||
|
} |
||||||
|
|
||||||
|
async function userInviteResend(req, res): Promise<any> { |
||||||
|
const user = await User.get(req.params.userId); |
||||||
|
|
||||||
|
if (!user) { |
||||||
|
NcError.badRequest(`User with id '${req.params.userId}' not found`); |
||||||
|
} |
||||||
|
|
||||||
|
const invite_token = uuidv4(); |
||||||
|
|
||||||
|
await User.update(user.id, { |
||||||
|
invite_token, |
||||||
|
invite_token_expires: new Date(Date.now() + 24 * 60 * 60 * 1000), |
||||||
|
}); |
||||||
|
|
||||||
|
const pluginData = await Noco.ncMeta.metaGet2(null, null, MetaTable.PLUGIN, { |
||||||
|
category: PluginCategory.EMAIL, |
||||||
|
active: true, |
||||||
|
}); |
||||||
|
|
||||||
|
if (!pluginData) { |
||||||
|
NcError.badRequest( |
||||||
|
`No Email Plugin is found. Please go to App Store to configure first or copy the invitation URL to users instead.` |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
await sendInviteEmail(user.email, invite_token, req); |
||||||
|
|
||||||
|
await Audit.insert({ |
||||||
|
op_type: AuditOperationTypes.ORG_USER, |
||||||
|
op_sub_type: AuditOperationSubTypes.RESEND_INVITE, |
||||||
|
user: user.email, |
||||||
|
description: `resent a invite to ${user.email} `, |
||||||
|
ip: req.clientIp, |
||||||
|
}); |
||||||
|
|
||||||
|
res.json({ msg: 'success' }); |
||||||
|
} |
||||||
|
|
||||||
|
async function generateResetUrl(req, res) { |
||||||
|
const user = await User.get(req.params.userId); |
||||||
|
|
||||||
|
if (!user) { |
||||||
|
NcError.badRequest(`User with id '${req.params.userId}' not found`); |
||||||
|
} |
||||||
|
const token = uuidv4(); |
||||||
|
await User.update(user.id, { |
||||||
|
email: user.email, |
||||||
|
reset_password_token: token, |
||||||
|
reset_password_expires: new Date(Date.now() + 60 * 60 * 1000), |
||||||
|
token_version: null, |
||||||
|
}); |
||||||
|
|
||||||
|
res.json({ |
||||||
|
reset_password_token: token, |
||||||
|
reset_password_url: req.ncSiteUrl + `/auth/password/reset/${token}`, |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
async function appSettingsGet(_req, res) { |
||||||
|
let settings = {}; |
||||||
|
try { |
||||||
|
settings = JSON.parse((await Store.get(NC_APP_SETTINGS))?.value); |
||||||
|
} catch {} |
||||||
|
res.json(settings); |
||||||
|
} |
||||||
|
|
||||||
|
async function appSettingsSet(req, res) { |
||||||
|
await Store.saveOrUpdate({ |
||||||
|
value: JSON.stringify(req.body), |
||||||
|
key: NC_APP_SETTINGS, |
||||||
|
}); |
||||||
|
|
||||||
|
res.json({ msg: 'Settings saved' }); |
||||||
|
} |
||||||
|
|
||||||
|
const router = Router({ mergeParams: true }); |
||||||
|
router.get( |
||||||
|
'/api/v1/users', |
||||||
|
metaApiMetrics, |
||||||
|
ncMetaAclMw(userList, 'userList', { |
||||||
|
allowedRoles: [OrgUserRoles.SUPER_ADMIN], |
||||||
|
blockApiTokenAccess: true, |
||||||
|
}) |
||||||
|
); |
||||||
|
router.patch( |
||||||
|
'/api/v1/users/:userId', |
||||||
|
metaApiMetrics, |
||||||
|
ncMetaAclMw(userUpdate, 'userUpdate', { |
||||||
|
allowedRoles: [OrgUserRoles.SUPER_ADMIN], |
||||||
|
blockApiTokenAccess: true, |
||||||
|
}) |
||||||
|
); |
||||||
|
router.delete( |
||||||
|
'/api/v1/users/:userId', |
||||||
|
metaApiMetrics, |
||||||
|
ncMetaAclMw(userDelete, 'userDelete', { |
||||||
|
allowedRoles: [OrgUserRoles.SUPER_ADMIN], |
||||||
|
blockApiTokenAccess: true, |
||||||
|
}) |
||||||
|
); |
||||||
|
router.post( |
||||||
|
'/api/v1/users', |
||||||
|
metaApiMetrics, |
||||||
|
ncMetaAclMw(userAdd, 'userAdd', { |
||||||
|
allowedRoles: [OrgUserRoles.SUPER_ADMIN], |
||||||
|
blockApiTokenAccess: true, |
||||||
|
}) |
||||||
|
); |
||||||
|
router.post( |
||||||
|
'/api/v1/users/settings', |
||||||
|
metaApiMetrics, |
||||||
|
ncMetaAclMw(userSettings, 'userSettings', { |
||||||
|
allowedRoles: [OrgUserRoles.SUPER_ADMIN], |
||||||
|
blockApiTokenAccess: true, |
||||||
|
}) |
||||||
|
); |
||||||
|
router.post( |
||||||
|
'/api/v1/users/:userId/resend-invite', |
||||||
|
metaApiMetrics, |
||||||
|
ncMetaAclMw(userInviteResend, 'userInviteResend', { |
||||||
|
allowedRoles: [OrgUserRoles.SUPER_ADMIN], |
||||||
|
blockApiTokenAccess: true, |
||||||
|
}) |
||||||
|
); |
||||||
|
|
||||||
|
router.post( |
||||||
|
'/api/v1/users/:userId/generate-reset-url', |
||||||
|
metaApiMetrics, |
||||||
|
ncMetaAclMw(generateResetUrl, 'generateResetUrl', { |
||||||
|
allowedRoles: [OrgUserRoles.SUPER_ADMIN], |
||||||
|
blockApiTokenAccess: true, |
||||||
|
}) |
||||||
|
); |
||||||
|
|
||||||
|
router.get( |
||||||
|
'/api/v1/app-settings', |
||||||
|
metaApiMetrics, |
||||||
|
ncMetaAclMw(appSettingsGet, 'appSettingsGet', { |
||||||
|
allowedRoles: [OrgUserRoles.SUPER_ADMIN], |
||||||
|
blockApiTokenAccess: true, |
||||||
|
}) |
||||||
|
); |
||||||
|
|
||||||
|
router.post( |
||||||
|
'/api/v1/app-settings', |
||||||
|
metaApiMetrics, |
||||||
|
ncMetaAclMw(appSettingsSet, 'appSettingsSet', { |
||||||
|
allowedRoles: [OrgUserRoles.SUPER_ADMIN], |
||||||
|
blockApiTokenAccess: true, |
||||||
|
}) |
||||||
|
); |
||||||
|
|
||||||
|
export default router; |
@ -0,0 +1,16 @@ |
|||||||
|
import express from 'express'; |
||||||
|
import { NC_LICENSE_KEY } from '../../constants'; |
||||||
|
import Store from '../../models/Store'; |
||||||
|
|
||||||
|
export default function getHandler( |
||||||
|
defaultHandler: express.Handler, |
||||||
|
eeHandler: express.Handler |
||||||
|
): express.Handler { |
||||||
|
return async (...args) => { |
||||||
|
const key = await Store.get(NC_LICENSE_KEY); |
||||||
|
if (!key?.value) { |
||||||
|
return defaultHandler(...args); |
||||||
|
} |
||||||
|
return eeHandler(...args); |
||||||
|
}; |
||||||
|
} |
@ -0,0 +1,26 @@ |
|||||||
|
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 }; |
@ -0,0 +1,47 @@ |
|||||||
|
import { NcError } from '../meta/helpers/catchError'; |
||||||
|
import { extractProps } from '../meta/helpers/extractProps'; |
||||||
|
import Noco from '../Noco'; |
||||||
|
import { MetaTable } from '../utils/globals'; |
||||||
|
import { SortType } from 'nocodb-sdk'; |
||||||
|
|
||||||
|
// Store is used for storing key value pairs
|
||||||
|
export default class Store { |
||||||
|
key?: string; |
||||||
|
value?: string; |
||||||
|
type?: string; |
||||||
|
env?: string; |
||||||
|
tag?: string; |
||||||
|
project_id?: string; |
||||||
|
db_alias?: string; |
||||||
|
|
||||||
|
constructor(data: Partial<SortType>) { |
||||||
|
Object.assign(this, data); |
||||||
|
} |
||||||
|
|
||||||
|
public static get(key: string, ncMeta = Noco.ncMeta): Promise<Store> { |
||||||
|
return ncMeta.metaGet(null, null, MetaTable.STORE, { key }); |
||||||
|
} |
||||||
|
|
||||||
|
static async saveOrUpdate(store: Store, ncMeta = Noco.ncMeta) { |
||||||
|
if (!store.key) { |
||||||
|
NcError.badRequest('Key is required'); |
||||||
|
} |
||||||
|
|
||||||
|
const insertObj = extractProps(store, [ |
||||||
|
'key', |
||||||
|
'value', |
||||||
|
'type', |
||||||
|
'env', |
||||||
|
'tag', |
||||||
|
]); |
||||||
|
|
||||||
|
const existing = await Store.get(store.key, ncMeta); |
||||||
|
if (existing) { |
||||||
|
await ncMeta.metaUpdate(null, null, MetaTable.STORE, insertObj, { |
||||||
|
key: store.key, |
||||||
|
}); |
||||||
|
} else { |
||||||
|
await ncMeta.metaInsert(null, null, MetaTable.STORE, insertObj); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
@ -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 }; |
@ -0,0 +1,43 @@ |
|||||||
|
import { OrgUserRoles } from '../../enums/OrgUserRoles'; |
||||||
|
import { NC_APP_SETTINGS } from '../constants'; |
||||||
|
import Store from '../models/Store'; |
||||||
|
import { MetaTable } from '../utils/globals'; |
||||||
|
import { NcUpgraderCtx } from './NcUpgrader'; |
||||||
|
|
||||||
|
/** Upgrader for upgrading roles */ |
||||||
|
export default async function ({ ncMeta }: NcUpgraderCtx) { |
||||||
|
const users = await ncMeta.metaList2(null, null, MetaTable.USERS); |
||||||
|
|
||||||
|
for (const user of users) { |
||||||
|
user.roles = user.roles |
||||||
|
.split(',') |
||||||
|
.map((r) => { |
||||||
|
// update old role names with new roles
|
||||||
|
if (r === 'user') { |
||||||
|
return OrgUserRoles.CREATOR; |
||||||
|
} else if (r === 'user-new') { |
||||||
|
return OrgUserRoles.VIEWER; |
||||||
|
} |
||||||
|
return r; |
||||||
|
}) |
||||||
|
.join(','); |
||||||
|
await ncMeta.metaUpdate( |
||||||
|
null, |
||||||
|
null, |
||||||
|
MetaTable.USERS, |
||||||
|
{ roles: user.roles }, |
||||||
|
user.id |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
// set invite only signup if user have environment variable set
|
||||||
|
if (process.env.NC_INVITE_ONLY_SIGNUP) { |
||||||
|
await Store.saveOrUpdate( |
||||||
|
{ |
||||||
|
value: '{ "invite_only_signup": true }', |
||||||
|
key: NC_APP_SETTINGS, |
||||||
|
}, |
||||||
|
ncMeta |
||||||
|
); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,222 @@ |
|||||||
|
import { expect } from 'chai' |
||||||
|
import 'mocha' |
||||||
|
import request from 'supertest' |
||||||
|
import { OrgUserRoles } from '../../../../src/enums/OrgUserRoles' |
||||||
|
import init from '../../init' |
||||||
|
|
||||||
|
function authTests() { |
||||||
|
let context |
||||||
|
|
||||||
|
beforeEach(async function() { |
||||||
|
context = await init() |
||||||
|
}) |
||||||
|
|
||||||
|
it('Get users list', async () => { |
||||||
|
const response = await request(context.app) |
||||||
|
.get('/api/v1/users') |
||||||
|
.set('xc-auth', context.token) |
||||||
|
.expect(200) |
||||||
|
|
||||||
|
expect(response.body).to.have.keys(['list', 'pageInfo']) |
||||||
|
expect(response.body.list).to.have.length(1) |
||||||
|
|
||||||
|
}) |
||||||
|
|
||||||
|
it('Invite a new user', async () => { |
||||||
|
|
||||||
|
const response = await request(context.app) |
||||||
|
.post('/api/v1/users') |
||||||
|
.set('xc-auth', context.token).send({ email: 'a@nocodb.com' }) |
||||||
|
.expect(200) |
||||||
|
|
||||||
|
console.log(response.body) |
||||||
|
|
||||||
|
expect(response.body).to.have.property('invite_token').to.be.a('string') |
||||||
|
// todo: verify invite token
|
||||||
|
}) |
||||||
|
|
||||||
|
it('Update user role', async () => { |
||||||
|
const email = 'a@nocodb.com' |
||||||
|
// invite a user
|
||||||
|
await request(context.app) |
||||||
|
.post('/api/v1/users') |
||||||
|
.set('xc-auth', context.token).send({ email }) |
||||||
|
.expect(200) |
||||||
|
const response = await request(context.app) |
||||||
|
.get('/api/v1/users') |
||||||
|
.set('xc-auth', context.token) |
||||||
|
.expect(200) |
||||||
|
expect(response.body.list).to.have.length(2) |
||||||
|
|
||||||
|
const user = response.body.list.find(u => u.email === email) |
||||||
|
|
||||||
|
expect(user).to.have.property('roles').to.be.equal(OrgUserRoles.VIEWER) |
||||||
|
|
||||||
|
|
||||||
|
await request(context.app) |
||||||
|
.patch('/api/v1/users/' + user.id) |
||||||
|
.set('xc-auth', context.token) |
||||||
|
.send({ roles: OrgUserRoles.CREATOR }) |
||||||
|
.expect(200) |
||||||
|
|
||||||
|
|
||||||
|
const response2 = await request(context.app) |
||||||
|
.get('/api/v1/users') |
||||||
|
.set('xc-auth', context.token) |
||||||
|
.expect(200) |
||||||
|
expect(response2.body.list).to.have.length(2) |
||||||
|
|
||||||
|
const user2 = response2.body.list.find(u => u.email === email) |
||||||
|
|
||||||
|
expect(user2).to.have.property('roles').to.be.equal(OrgUserRoles.CREATOR) |
||||||
|
}) |
||||||
|
|
||||||
|
it('Remove user', async () => { |
||||||
|
const email = 'a@nocodb.com' |
||||||
|
// invite a user
|
||||||
|
await request(context.app) |
||||||
|
.post('/api/v1/users') |
||||||
|
.set('xc-auth', context.token).send({ email }) |
||||||
|
.expect(200) |
||||||
|
|
||||||
|
const response = await request(context.app) |
||||||
|
.get('/api/v1/users') |
||||||
|
.set('xc-auth', context.token) |
||||||
|
.expect(200) |
||||||
|
expect(response.body.list).to.have.length(2) |
||||||
|
|
||||||
|
const user = response.body.list.find(u => u.email === email) |
||||||
|
|
||||||
|
expect(user).to.have.property('roles').to.be.equal(OrgUserRoles.VIEWER) |
||||||
|
|
||||||
|
|
||||||
|
await request(context.app) |
||||||
|
.delete('/api/v1/users/' + user.id) |
||||||
|
.set('xc-auth', context.token) |
||||||
|
.expect(200) |
||||||
|
|
||||||
|
|
||||||
|
const response2 = await request(context.app) |
||||||
|
.get('/api/v1/users') |
||||||
|
.set('xc-auth', context.token) |
||||||
|
.expect(200) |
||||||
|
expect(response2.body.list).to.have.length(1) |
||||||
|
|
||||||
|
}) |
||||||
|
|
||||||
|
|
||||||
|
it('Get token list', async () => { |
||||||
|
const response = await request(context.app) |
||||||
|
.get('/api/v1/tokens') |
||||||
|
.set('xc-auth', context.token) |
||||||
|
.expect(200) |
||||||
|
|
||||||
|
expect(response.body).to.have.keys(['list', 'pageInfo']) |
||||||
|
expect(response.body.list).to.have.length(0) |
||||||
|
|
||||||
|
}) |
||||||
|
|
||||||
|
it('Generate token', async () => { |
||||||
|
const r = await request(context.app) |
||||||
|
.post('/api/v1/tokens') |
||||||
|
.set('xc-auth', context.token) |
||||||
|
.send({ description: 'test' }) |
||||||
|
.expect(200) |
||||||
|
|
||||||
|
const response = await request(context.app) |
||||||
|
.get('/api/v1/tokens') |
||||||
|
.set('xc-auth', context.token) |
||||||
|
.expect(200) |
||||||
|
|
||||||
|
expect(response.body).to.have.keys(['list', 'pageInfo']) |
||||||
|
expect(response.body.list).to.have.length(1) |
||||||
|
expect(response.body.list[0]).to.have.property('token').to.be.a('string') |
||||||
|
expect(response.body.list[0]).to.have.property('description').to.be.a('string').to.be.eq('test') |
||||||
|
|
||||||
|
}) |
||||||
|
|
||||||
|
it('Delete token', async () => { |
||||||
|
const r = await request(context.app) |
||||||
|
.post('/api/v1/tokens') |
||||||
|
.set('xc-auth', context.token) |
||||||
|
.send({ description: 'test' }) |
||||||
|
.expect(200) |
||||||
|
|
||||||
|
let response = await request(context.app) |
||||||
|
.get('/api/v1/tokens') |
||||||
|
.set('xc-auth', context.token) |
||||||
|
.expect(200) |
||||||
|
|
||||||
|
expect(response.body).to.have.keys(['list', 'pageInfo']) |
||||||
|
expect(response.body.list).to.have.length(1) |
||||||
|
|
||||||
|
await request(context.app) |
||||||
|
.delete('/api/v1/tokens/' + r.body.token) |
||||||
|
.set('xc-auth', context.token) |
||||||
|
.expect(200) |
||||||
|
|
||||||
|
|
||||||
|
response = await request(context.app) |
||||||
|
.get('/api/v1/tokens') |
||||||
|
.set('xc-auth', context.token) |
||||||
|
.expect(200) |
||||||
|
|
||||||
|
expect(response.body).to.have.keys(['list', 'pageInfo']) |
||||||
|
expect(response.body.list).to.have.length(0) |
||||||
|
|
||||||
|
}) |
||||||
|
|
||||||
|
it.only('Disable/Enable signup', async () => { |
||||||
|
const args = { |
||||||
|
email: 'dummyuser@example.com', |
||||||
|
password: 'A1234abh2@dsad', |
||||||
|
}; |
||||||
|
|
||||||
|
|
||||||
|
await request(context.app) |
||||||
|
.post('/api/v1/app-settings') |
||||||
|
.set('xc-auth', context.token) |
||||||
|
.send({ invite_only_signup: true }) |
||||||
|
.expect(200) |
||||||
|
|
||||||
|
|
||||||
|
const failedRes = await request(context.app) |
||||||
|
.post('/api/v1/auth/user/signup') |
||||||
|
.send(args) |
||||||
|
.expect(400) |
||||||
|
|
||||||
|
expect(failedRes.body).to.be.an('object') |
||||||
|
.to.have.property('msg') |
||||||
|
.to.be.equal('Not allowed to signup, contact super admin.') |
||||||
|
|
||||||
|
await request(context.app) |
||||||
|
.post('/api/v1/app-settings') |
||||||
|
.set('xc-auth', context.token) |
||||||
|
.send({ invite_only_signup: false }) |
||||||
|
.expect(200) |
||||||
|
|
||||||
|
|
||||||
|
const successRes = await request(context.app) |
||||||
|
.post('/api/v1/auth/user/signup') |
||||||
|
.send(args) |
||||||
|
.expect(200) |
||||||
|
|
||||||
|
expect(successRes.body).to.be.an('object') |
||||||
|
.to.have.property('token') |
||||||
|
.to.be.a('string') |
||||||
|
|
||||||
|
|
||||||
|
const userMeRes = await request(context.app) |
||||||
|
.get('/api/v1/auth/user/me') |
||||||
|
.set('xc-auth', successRes.body.token) |
||||||
|
.expect(200) |
||||||
|
|
||||||
|
expect(userMeRes.body).to.be.an('object') |
||||||
|
.to.have.property('email') |
||||||
|
.to.be.eq(args.email) |
||||||
|
}) |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
export default function() { |
||||||
|
} |