Browse Source

Merge pull request #6376 from nocodb/nc-feat/profile-page

Profile page
pull/6417/head
Muhammed Mustafa 1 year ago committed by GitHub
parent
commit
1ba72ead3a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 117
      packages/nc-gui/components/account/Profile.vue
  2. 6
      packages/nc-gui/components/dashboard/Sidebar/UserInfo.vue
  3. 29
      packages/nc-gui/components/general/UserIcon.vue
  4. 2
      packages/nc-gui/components/nc/Button.vue
  5. 2
      packages/nc-gui/components/smartsheet/toolbar/GroupByMenu.vue
  6. 1
      packages/nc-gui/composables/useGlobal/actions.ts
  7. 1
      packages/nc-gui/composables/useGlobal/index.ts
  8. 1
      packages/nc-gui/lang/en.json
  9. 1
      packages/nc-gui/lib/types.ts
  10. 214
      packages/nc-gui/pages/account/index.vue
  11. 1
      packages/nc-gui/pages/account/index/[page].vue
  12. 57
      packages/nc-gui/store/users.ts
  13. 2
      packages/nocodb/src/app.module.ts
  14. 2
      packages/nocodb/src/controllers/auth.controller.spec.ts
  15. 46
      packages/nocodb/src/controllers/auth.controller.ts
  16. 260
      packages/nocodb/src/controllers/auth/auth.controller.ts
  17. 0
      packages/nocodb/src/controllers/auth/ui/auth/emailVerify.ts
  18. 0
      packages/nocodb/src/controllers/auth/ui/auth/resetPassword.ts
  19. 0
      packages/nocodb/src/controllers/auth/ui/emailTemplates/forgotPassword.ts
  20. 0
      packages/nocodb/src/controllers/auth/ui/emailTemplates/invite.ts
  21. 0
      packages/nocodb/src/controllers/auth/ui/emailTemplates/verify.ts
  22. 237
      packages/nocodb/src/controllers/users/users.controller.ts
  23. 4
      packages/nocodb/src/meta/migrations/XcMigrationSourcev2.ts
  24. 24
      packages/nocodb/src/meta/migrations/v2/nc_035_add_username_to_users.ts
  25. 5
      packages/nocodb/src/models/User.ts
  26. 21
      packages/nocodb/src/modules/auth/auth.module.ts
  27. 3
      packages/nocodb/src/modules/users/users.module.ts
  28. 24
      packages/nocodb/src/schema/swagger.json
  29. 7
      packages/nocodb/src/services/auth.service.ts
  30. 20
      packages/nocodb/src/services/users/users.service.ts
  31. 2
      tests/playwright/pages/Account/index.ts
  32. 5
      tests/playwright/pages/Base.ts
  33. 2
      tests/playwright/pages/Dashboard/Grid/index.ts
  34. 2
      tests/playwright/pages/Dashboard/ViewSidebar/index.ts
  35. 4
      tests/playwright/pages/Dashboard/common/Footbar/index.ts
  36. 1
      tests/playwright/pages/Dashboard/common/Toolbar/Fields.ts

117
packages/nc-gui/components/account/Profile.vue

@ -0,0 +1,117 @@
<script lang="ts" setup>
const { user } = useGlobal()
const isErrored = ref(false)
const isTitleUpdating = ref(false)
const form = ref({
title: '',
email: '',
})
const { updateUserProfile } = useUsers()
const formValidator = ref()
const formRules = {
title: [
{ required: true, message: 'Name required' },
{ min: 2, message: 'Name must be at least 2 characters long' },
{ max: 60, message: 'Name must be at most 60 characters long' },
],
}
const onSubmit = async () => {
const valid = await formValidator.value.validate()
if (!valid) return
if (isTitleUpdating.value) return
isTitleUpdating.value = true
isErrored.value = false
try {
await updateUserProfile({ attrs: { display_name: form.value.title } })
} catch (e: any) {
console.error(e)
} finally {
isTitleUpdating.value = false
}
}
const email = computed(() => user.value?.email)
watch(
() => user.value?.display_name,
() => {
if (!user.value?.display_name) return
form.value.title = user.value.display_name
form.value.email = user.value.email
},
{
immediate: true,
},
)
const onValidate = async (_: any, valid: boolean) => {
isErrored.value = !valid
}
</script>
<template>
<div class="flex flex-col items-center">
<div class="flex flex-col w-150">
<div class="flex font-medium text-xl">Profile</div>
<div class="mt-5 flex flex-col border-1 rounded-2xl border-gray-200 p-6 gap-y-2">
<div class="flex font-medium text-base">Account details</div>
<div class="flex text-gray-500">Control your appearance.</div>
<div class="flex flex-row mt-4">
<div class="flex h-20 mt-1.5">
<GeneralUserIcon size="xlarge" />
</div>
<div class="flex w-10"></div>
<a-form
ref="formValidator"
layout="vertical"
no-style
:model="form"
class="flex flex-col w-full"
@finish="onSubmit"
@validate="onValidate"
>
<div class="text-gray-800 mb-1.5">Name</div>
<a-form-item name="title" :rules="formRules.title">
<a-input
v-model:value="form.title"
class="w-full !rounded-md !py-1.5"
placeholder="Name"
data-testid="nc-account-settings-rename-input"
/>
</a-form-item>
<div class="text-gray-800 mb-1.5">Account Email ID</div>
<a-input
v-model:value="email"
class="w-full !rounded-md !py-1.5"
placeholder="Email"
disabled
data-testid="nc-account-settings-email-input"
/>
<div class="flex flex-row w-full justify-end mt-8">
<NcButton
type="primary"
html-type="submit"
:disabled="isErrored || (form.title && form.title === user?.display_name)"
:loading="isTitleUpdating"
data-testid="nc-account-settings-save"
@click="onSubmit"
>
<template #loading> Saving </template>
Save
</NcButton>
</div>
</a-form>
</div>
</div>
</div>
</div>
</template>

6
packages/nc-gui/components/dashboard/Sidebar/UserInfo.vue

@ -2,6 +2,8 @@
import GithubButton from 'vue-github-button'
const { user, signOut, token, appInfo } = useGlobal()
// So watcher in users store is triggered
useUsers()
const { clearWorkspaces } = useWorkspace()
@ -9,7 +11,7 @@ const { leftSidebarState } = storeToRefs(useSidebarStore())
const { copy } = useCopy(true)
const name = computed(() => `${user.value?.firstname ?? ''} ${user.value?.lastname ?? ''}`.trim())
const name = computed(() => user.value?.display_name?.trim())
const isMenuOpen = ref(false)
@ -133,7 +135,7 @@ onMounted(() => {
<template v-if="isAuthTokenCopied"> Copied Auth Token </template>
<template v-else> Copy Auth Token </template>
</NcMenuItem>
<nuxt-link v-e="['c:navbar:user:email']" class="!no-underline" to="/account/tokens">
<nuxt-link v-e="['c:navbar:user:email']" class="!no-underline" to="/account/profile">
<NcMenuItem><GeneralIcon icon="settings" class="menu-icon" /> Account Settings</NcMenuItem>
</nuxt-link>
</NcMenu>

29
packages/nc-gui/components/general/UserIcon.vue

@ -1,7 +1,6 @@
<script lang="ts" setup>
const props = defineProps<{
hideLabel?: boolean
size?: 'small' | 'medium'
size?: 'small' | 'medium' | 'large' | 'xlarge'
}>()
const { user } = useGlobal()
@ -10,19 +9,21 @@ const backgroundColor = computed(() => (user.value?.id ? stringToColour(user.val
const size = computed(() => props.size || 'medium')
const firstName = computed(() => user.value?.firstname ?? '')
const lastName = computed(() => user.value?.lastname ?? '')
const displayName = computed(() => user.value?.display_name ?? '')
const email = computed(() => user.value?.email ?? '')
const usernameInitials = computed(() => {
if (firstName.value && lastName.value) {
return firstName.value[0] + lastName.value[0]
} else if (firstName.value) {
return firstName.value[0] + (firstName.value.length > 1 ? firstName.value[1] : '')
} else if (lastName.value) {
return lastName.value[0] + (lastName.value.length > 1 ? lastName.value[1] : '')
const displayNameSplit = displayName.value?.split(' ').filter((name) => name) ?? []
if (displayNameSplit.length > 0) {
if (displayNameSplit.length > 1) {
return displayNameSplit[0][0] + displayNameSplit[1][0]
} else {
return displayName.value.slice(0, 2)
}
} else {
return email.value[0] + email.value[1]
return email.value?.split('@')[0].slice(0, 2)
}
})
</script>
@ -33,12 +34,12 @@ const usernameInitials = computed(() => {
:class="{
'min-w-4 min-h-4': size === 'small',
'min-w-6 min-h-6': size === 'medium',
'min-w-20 min-h-20 !text-3xl': size === 'large',
'min-w-26 min-h-26 !text-4xl': size === 'xlarge',
}"
:style="{ backgroundColor }"
>
<template v-if="!props.hideLabel">
{{ usernameInitials }}
</template>
{{ usernameInitials }}
</div>
</template>

2
packages/nc-gui/components/nc/Button.vue

@ -150,7 +150,7 @@ useEventListener(NcButton, 'mousedown', () => {
}
.nc-button.ant-btn.medium {
@apply py-2 px-3 h-10 min-w-10;
@apply py-2 px-4 h-10 min-w-10;
}
.nc-button.ant-btn.xsmall {

2
packages/nc-gui/components/smartsheet/toolbar/GroupByMenu.vue

@ -170,7 +170,7 @@ watch(open, () => {
<template #overlay>
<div
:class="{ ' min-w-[400px]': _groupBy.length }"
class="flex flex-col bg-white shadow-lg rounded-md overflow-auto border-1 border-gray-50 menu-filter-dropdown max-h-[max(80vh,500px)] py-6 pl-6"
class="flex flex-col bg-white rounded-md overflow-auto menu-filter-dropdown max-h-[max(80vh,500px)] py-6 pl-6"
data-testid="nc-group-by-menu"
>
<div class="group-by-grid pb-1 mb-2 max-h-100 nc-scrollbar-md pr-5" @click.stop>

1
packages/nc-gui/composables/useGlobal/actions.ts

@ -38,6 +38,7 @@ export function useGlobalActions(state: State): Actions {
firstname: state.jwtPayload.value.firstname,
lastname: state.jwtPayload.value.lastname,
roles: state.jwtPayload.value.roles,
display_name: state.jwtPayload.value.display_name,
}
}
}

1
packages/nc-gui/composables/useGlobal/index.ts

@ -71,6 +71,7 @@ export const useGlobal = createGlobalState((): UseGlobalReturn => {
firstname: nextPayload.firstname,
lastname: nextPayload.lastname,
roles: nextPayload.roles,
display_name: nextPayload.display_name,
}
}
},

1
packages/nc-gui/lang/en.json

@ -289,6 +289,7 @@
"createdOn": "Created On",
"notifyVia": "Notify Via",
"projName": "Project name",
"profile": "Profile",
"tableName": "Table name",
"dashboardName": "Dashboard name",
"viewName": "View name",

1
packages/nc-gui/lib/types.ts

@ -24,6 +24,7 @@ interface User {
workspace_roles: Roles | string
invite_token?: string
project_id?: string
display_name?: string | null
}
interface ProjectMetaInfo {

214
packages/nc-gui/pages/account/index.vue

@ -1,10 +1,16 @@
<script lang="ts" setup>
import { iconMap, isEeUI, navigateTo, useUIPermission } from '#imports'
import { iconMap, navigateTo, useUIPermission } from '#imports'
definePageMeta({
hideHeader: true,
})
const { isUIAllowed } = useUIPermission()
const $route = useRoute()
const { appInfo, signedIn, signOut } = useGlobal()
const selectedKeys = computed(() => [
/^\/account\/users\/?$/.test($route.fullPath)
? isUIAllowed('superAdminUserManagement')
@ -12,86 +18,180 @@ const selectedKeys = computed(() => [
: 'settings'
: $route.params.nestedPage ?? $route.params.page,
])
const openKeys = ref([/^\/account\/users/.test($route.fullPath) && 'users'])
const logout = async () => {
await signOut(false)
navigateTo('/signin')
}
</script>
<template>
<div class="mx-auto h-full">
<a-layout class="h-full overflow-y-auto flex">
<!-- Side tabs -->
<a-layout-sider>
<NuxtLayout name="empty">
<div class="mx-auto h-full">
<div class="h-full overflow-y-auto flex">
<!-- Side tabs -->
<div class="h-full bg-white nc-user-sidebar">
<a-menu
<NcMenu
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">{{ $t('title.accountSettings') }}</div>
<div
v-if="!$route.params.projectType"
v-e="['c:navbar:home']"
data-testid="nc-noco-brand-icon"
class="transition-all duration-200 px-2 mx-2 mt-1.5 cursor-pointer transform hover:bg-gray-100 my-1 nc-noco-brand-icon h-8 rounded-md min-w-60"
@click="navigateTo('/')"
>
<div class="flex flex-row gap-x-2 items-center h-8.5">
<GeneralIcon icon="arrowLeft" class="-mt-0.1" />
<div class="flex text-xs text-gray-800">Back to Workspace</div>
</div>
</div>
<a-sub-menu key="users" class="!bg-white">
<div class="text-xs text-gray-600 ml-4 py-1.5 mt-3">{{ $t('labels.account') }}</div>
<NcMenuItem
key="profile"
class="item"
:class="{
active: $route.params.page === 'profile',
}"
@click="navigateTo('/account/profile')"
>
<div class="flex items-center space-x-2">
<GeneralIcon icon="account" />
<div class="select-none">{{ $t('labels.profile') }}</div>
</div>
</NcMenuItem>
<NcMenuItem
key="tokens"
class="item"
:class="{
active: $route.params.page === 'tokens',
}"
@click="navigateTo('/account/tokens')"
>
<div class="flex items-center space-x-2">
<MdiShieldKeyOutline />
<div class="select-none">{{ $t('title.tokens') }}</div>
</div>
</NcMenuItem>
<NcMenuItem
v-if="isUIAllowed('appStore') && !isEeUI"
key="apps"
class="item"
:class="{
active: $route.params.page === 'apps',
}"
@click="navigateTo('/account/apps')"
>
<div class="flex items-center space-x-2">
<component :is="iconMap.appStore" />
<div class="select-none text-sm">{{ $t('title.appStore') }}</div>
</div>
</NcMenuItem>
<a-sub-menu key="users" class="!bg-white !my-0">
<template #icon>
<MdiAccountSupervisorOutline />
</template>
<template #title>Users</template>
<a-menu-item
<NcMenuItem
v-if="isUIAllowed('superAdminUserManagement') && !isEeUI"
key="list"
class="text-xs"
class="text-xs item"
:class="{
active: $route.params.nestedPage === 'list',
}"
@click="navigateTo('/account/users/list')"
>
<span class="ml-4">{{ $t('title.userManagement') }}</span>
</a-menu-item>
<a-menu-item key="password-reset" class="text-xs" @click="navigateTo('/account/users/password-reset')">
</NcMenuItem>
<NcMenuItem
key="password-reset"
class="text-xs item"
:class="{
active: $route.params.nestedPage === 'password-reset',
}"
@click="navigateTo('/account/users/password-reset')"
>
<span class="ml-4">{{ $t('title.resetPasswordMenu') }}</span>
</a-menu-item>
<a-menu-item
</NcMenuItem>
<NcMenuItem
v-if="isUIAllowed('superAdminAppSettings') && !isEeUI"
key="settings"
class="text-xs"
class="text-xs item"
:class="{
active: $route.params.nestedPage === 'settings',
}"
@click="navigateTo('/account/users/settings')"
>
<span class="ml-4">{{ $t('activity.settings') }}</span>
</a-menu-item>
</NcMenuItem>
</a-sub-menu>
</NcMenu>
</div>
<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 />
<!-- Sub Tabs -->
<div class="select-none">{{ $t('title.tokens') }}</div>
</div>
</a-menu-item>
<a-menu-item
v-if="isUIAllowed('appStore') && !isEeUI"
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">
<component :is="iconMap.appStore" />
<div class="flex flex-col w-full">
<div class="flex flex-row p-3 items-center">
<div class="flex-1" />
<LazyGeneralReleaseInfo />
<div class="select-none">{{ $t('title.appStore') }}</div>
<a-tooltip v-if="!appInfo.ee" placement="bottom" :mouse-enter-delay="1">
<template #title> Switch language</template>
<div class="flex pr-4 items-center">
<LazyGeneralLanguage class="cursor-pointer text-2xl hover:text-gray-800" />
</div>
</a-menu-item>
</a-menu>
</div>
</a-layout-sider>
</a-tooltip>
<template v-if="signedIn">
<NcDropdown :trigger="['click']" overlay-class-name="nc-dropdown-user-accounts-menu">
<NcButton type="text" size="small">
<component
:is="iconMap.threeDotVertical"
data-testid="nc-menu-accounts"
class="md:text-lg cursor-pointer hover:text-gray-800 nc-menu-accounts"
@click.prevent
/>
</NcButton>
<template #overlay>
<div class="!py-1 !rounded-md bg-white overflow-hidden">
<div class="!rounded-b group" data-testid="nc-menu-accounts__sign-out">
<div v-e="['a:navbar:user:sign-out']" class="nc-account-dropdown-item group" @click="logout">
<component :is="iconMap.signout" class="group-hover:text-accent" />&nbsp;
<!-- Sub Tabs -->
<a-layout-content class="h-auto px-4 scrollbar-thumb-gray-500">
<div class="container mx-auto">
<NuxtPage />
<span class="prose group-hover:text-primary">
{{ $t('general.signOut') }}
</span>
</div>
</div>
</div>
</template>
</NcDropdown>
</template>
</div>
<div class="flex flex-col container mx-auto mt-2">
<NuxtPage />
</div>
</div>
</a-layout-content>
</a-layout>
</div>
</div>
</div>
</NuxtLayout>
</template>
<style lang="scss" scoped>
@ -111,4 +211,26 @@ const openKeys = ref([/^\/account\/users/.test($route.fullPath) && 'users'])
:deep(.ant-menu-submenu-selected .ant-menu-submenu-arrow) {
@apply !text-inherit;
}
:deep(.item) {
@apply select-none mx-2 !px-3 !text-sm !rounded-md !mb-1 !hover:(bg-brand-50 text-brand-500);
width: calc(100% - 1rem);
}
:deep(.active) {
@apply !bg-brand-50 !text-brand-500;
}
:deep(.ant-menu-submenu-title) {
@apply select-none mx-2 !px-3 !text-sm !rounded-md !mb-1 !hover:(bg-brand-50 text-brand-500);
width: calc(100% - 1rem);
}
:deep(.ant-menu) {
@apply !pt-0 !rounded-none !border-gray-200;
}
.nc-account-dropdown-item {
@apply flex flex-row px-4 items-center py-2 gap-x-2 hover:bg-gray-100 cursor-pointer;
}
</style>

1
packages/nc-gui/pages/account/index/[page].vue

@ -5,6 +5,7 @@ const { appInfo } = useGlobal()
<template>
<AccountUserManagement v-if="$route.params.page === 'users'" />
<AccountToken v-else-if="$route.params.page === 'tokens'" />
<AccountProfile v-else-if="$route.params.page === 'profile'" />
<AccountAppStore v-else-if="$route.params.page === 'apps' && !appInfo.isCloud" />
<span v-else></span>
</template>

57
packages/nc-gui/store/users.ts

@ -0,0 +1,57 @@
import { acceptHMRUpdate, defineStore } from 'pinia'
export const useUsers = defineStore('userStore', () => {
const { api } = useApi()
const { user } = useGlobal()
const updateUserProfile = async ({
attrs,
}: {
attrs: {
display_name?: string
}
}) => {
if (!user.value) throw new Error('User is not defined')
await api.userProfile.update(attrs)
user.value = {
...user.value,
...attrs,
}
}
const loadCurrentUser = async () => {
const res = await api.auth.me()
user.value = {
...user.value,
...res,
roles: res.roles,
project_roles: res.project_roles,
workspace_roles: res.workspace_roles,
}
}
watch(
() => user.value?.id,
(newId, oldId) => {
if (!newId) return
if (newId === oldId) return
loadCurrentUser()
},
{
immediate: true,
},
)
return {
loadCurrentUser,
updateUserProfile,
}
})
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useUsers as any, import.meta.hot))
}

2
packages/nocodb/src/app.module.ts

@ -24,11 +24,13 @@ import { ExtractIdsMiddleware } from '~/middlewares/extract-ids/extract-ids.midd
import { HookHandlerService } from '~/services/hook-handler.service';
import { BasicStrategy } from '~/strategies/basic.strategy/basic.strategy';
import { UsersModule } from '~/modules/users/users.module';
import { AuthModule } from '~/modules/auth/auth.module';
export const ceModuleConfig = {
imports: [
GlobalModule,
UsersModule,
AuthModule,
...(process.env['PLAYWRIGHT_TEST'] === 'true' ? [TestModule] : []),
MetasModule,
DatasModule,

2
packages/nocodb/src/controllers/auth.controller.spec.ts

@ -1,6 +1,6 @@
import { Test } from '@nestjs/testing';
import { AuthService } from '../services/auth.service';
import { AuthController } from './auth.controller';
import { AuthController } from './auth/auth.controller';
import type { TestingModule } from '@nestjs/testing';
describe('AuthController', () => {

46
packages/nocodb/src/controllers/auth.controller.ts

@ -1,46 +0,0 @@
import {
Body,
Controller,
HttpCode,
Post,
Request,
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { ConfigService } from '@nestjs/config';
import type { AppConfig } from '~/interface/config';
import { AuthService } from '~/services/auth.service';
import { NcError } from '~/helpers/catchError';
export class CreateUserDto {
readonly username: string;
readonly email: string;
readonly password: string;
}
@Controller()
export class AuthController {
constructor(
private readonly authService: AuthService,
private readonly config: ConfigService<AppConfig>,
) {}
@UseGuards(AuthGuard('local'))
@Post('/api/v1/auth/user/signin')
@HttpCode(200)
async signin(@Request() req) {
if (this.config.get('auth', { infer: true }).disableEmailAuth) {
NcError.forbidden('Email authentication is disabled');
}
return await this.authService.login(req.user);
}
@Post('/api/v1/auth/user/signup')
@HttpCode(200)
async signup(@Body() createUserDto: CreateUserDto) {
if (this.config.get('auth', { infer: true }).disableEmailAuth) {
NcError.forbidden('Email authentication is disabled');
}
return await this.authService.signup(createUserDto);
}
}

260
packages/nocodb/src/controllers/auth/auth.controller.ts

@ -0,0 +1,260 @@
import {
Body,
Controller,
Get,
HttpCode,
Param,
Post,
Request,
Response,
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { ConfigService } from '@nestjs/config';
import { extractRolesObj } from 'nocodb-sdk';
import * as ejs from 'ejs';
import type { AppConfig } from '~/interface/config';
import { UsersService } from '~/services/users/users.service';
import { AppHooksService } from '~/services/app-hooks/app-hooks.service';
import { randomTokenString, setTokenCookie } from '~/services/users/helpers';
import { GlobalGuard } from '~/guards/global/global.guard';
import { NcError } from '~/helpers/catchError';
import { Acl } from '~/middlewares/extract-ids/extract-ids.middleware';
import { User } from '~/models';
@Controller()
export class AuthController {
constructor(
protected readonly usersService: UsersService,
protected readonly appHooksService: AppHooksService,
protected readonly config: ConfigService<AppConfig>,
) {}
@Post([
'/auth/user/signup',
'/api/v1/db/auth/user/signup',
'/api/v1/auth/user/signup',
])
@HttpCode(200)
async signup(@Request() req: any, @Response() res: any): Promise<any> {
if (this.config.get('auth', { infer: true }).disableEmailAuth) {
NcError.forbidden('Email authentication is disabled');
}
res.json(
await this.usersService.signup({
body: req.body,
req,
res,
}),
);
}
@Post([
'/auth/token/refresh',
'/api/v1/db/auth/token/refresh',
'/api/v1/auth/token/refresh',
])
@HttpCode(200)
async refreshToken(@Request() req: any, @Response() res: any): Promise<any> {
res.json(
await this.usersService.refreshToken({
body: req.body,
req,
res,
}),
);
}
@Post([
'/auth/user/signin',
'/api/v1/db/auth/user/signin',
'/api/v1/auth/user/signin',
])
@UseGuards(AuthGuard('local'))
@HttpCode(200)
async signin(@Request() req, @Response() res) {
if (this.config.get('auth', { infer: true }).disableEmailAuth) {
NcError.forbidden('Email authentication is disabled');
}
await this.setRefreshToken({ req, res });
res.json(await this.usersService.login(req.user));
}
@UseGuards(GlobalGuard)
@Post('/api/v1/auth/user/signout')
@HttpCode(200)
async signOut(@Request() req, @Response() res): Promise<any> {
if (!(req as any).isAuthenticated()) {
NcError.forbidden('Not allowed');
}
res.json(
await this.usersService.signOut({
req,
res,
}),
);
}
@Post(`/auth/google/genTokenByCode`)
@HttpCode(200)
@UseGuards(AuthGuard('google'))
async googleSignin(@Request() req, @Response() res) {
await this.setRefreshToken({ req, res });
res.json(await this.usersService.login(req.user));
}
@Get('/auth/google')
@UseGuards(AuthGuard('google'))
googleAuthenticate() {
// google strategy will take care the request
}
@Get(['/auth/user/me', '/api/v1/db/auth/user/me', '/api/v1/auth/user/me'])
@UseGuards(GlobalGuard)
async me(@Request() req) {
const user = {
...req.user,
roles: extractRolesObj(req.user.roles),
workspace_roles: extractRolesObj(req.user.workspace_roles),
project_roles: extractRolesObj(req.user.project_roles),
};
return user;
}
@Post([
'/user/password/change',
'/api/v1/db/auth/password/change',
'/api/v1/auth/password/change',
])
@UseGuards(GlobalGuard)
@Acl('passwordChange', {
scope: 'org',
})
@HttpCode(200)
async passwordChange(@Request() req: any): Promise<any> {
if (!(req as any).isAuthenticated()) {
NcError.forbidden('Not allowed');
}
await this.usersService.passwordChange({
user: req['user'],
req,
body: req.body,
});
return { msg: 'Password has been updated successfully' };
}
@Post([
'/auth/password/forgot',
'/api/v1/db/auth/password/forgot',
'/api/v1/auth/password/forgot',
])
@HttpCode(200)
async passwordForgot(@Request() req: any): Promise<any> {
await this.usersService.passwordForgot({
siteUrl: (req as any).ncSiteUrl,
body: req.body,
req,
});
return { msg: 'Please check your email to reset the password' };
}
@Post([
'/auth/token/validate/:tokenId',
'/api/v1/db/auth/token/validate/:tokenId',
'/api/v1/auth/token/validate/:tokenId',
])
@HttpCode(200)
async tokenValidate(@Param('tokenId') tokenId: string): Promise<any> {
await this.usersService.tokenValidate({
token: tokenId,
});
return { msg: 'Token has been validated successfully' };
}
@Post([
'/auth/password/reset/:tokenId',
'/api/v1/db/auth/password/reset/:tokenId',
'/api/v1/auth/password/reset/:tokenId',
])
@HttpCode(200)
async passwordReset(
@Request() req: any,
@Param('tokenId') tokenId: string,
@Body() body: any,
): Promise<any> {
await this.usersService.passwordReset({
token: tokenId,
body: body,
req,
});
return { msg: 'Password has been reset successfully' };
}
@Post([
'/api/v1/db/auth/email/validate/:tokenId',
'/api/v1/auth/email/validate/:tokenId',
])
@HttpCode(200)
async emailVerification(
@Request() req: any,
@Param('tokenId') tokenId: string,
): Promise<any> {
await this.usersService.emailVerification({
token: tokenId,
req,
});
return { msg: 'Email has been verified successfully' };
}
@Get([
'/api/v1/db/auth/password/reset/:tokenId',
'/auth/password/reset/:tokenId',
])
async renderPasswordReset(
@Request() req: any,
@Response() res: any,
@Param('tokenId') tokenId: string,
): Promise<any> {
try {
res.send(
ejs.render((await import('./ui/auth/resetPassword')).default, {
ncPublicUrl: process.env.NC_PUBLIC_URL || '',
token: JSON.stringify(tokenId),
baseUrl: `/`,
}),
);
} catch (e) {
return res.status(400).json({ msg: e.message });
}
}
async setRefreshToken({ res, req }) {
const userId = req.user?.id;
if (!userId) return;
const user = await User.get(userId);
if (!user) return;
const refreshToken = randomTokenString();
if (!user['token_version']) {
user['token_version'] = randomTokenString();
}
await User.update(user.id, {
refresh_token: refreshToken,
email: user.email,
token_version: user['token_version'],
});
setTokenCookie(res, refreshToken);
}
}

0
packages/nocodb/src/controllers/users/ui/auth/emailVerify.ts → packages/nocodb/src/controllers/auth/ui/auth/emailVerify.ts

0
packages/nocodb/src/controllers/users/ui/auth/resetPassword.ts → packages/nocodb/src/controllers/auth/ui/auth/resetPassword.ts

0
packages/nocodb/src/controllers/users/ui/emailTemplates/forgotPassword.ts → packages/nocodb/src/controllers/auth/ui/emailTemplates/forgotPassword.ts

0
packages/nocodb/src/controllers/users/ui/emailTemplates/invite.ts → packages/nocodb/src/controllers/auth/ui/emailTemplates/invite.ts

0
packages/nocodb/src/controllers/users/ui/emailTemplates/verify.ts → packages/nocodb/src/controllers/auth/ui/emailTemplates/verify.ts

237
packages/nocodb/src/controllers/users/users.controller.ts

@ -1,25 +1,18 @@
import {
Body,
Controller,
Get,
HttpCode,
Param,
Post,
Patch,
Request,
Response,
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import * as ejs from 'ejs';
import { ConfigService } from '@nestjs/config';
import { extractRolesObj } from 'nocodb-sdk';
import type { AppConfig } from '~/interface/config';
import { GlobalGuard } from '~/guards/global/global.guard';
import { NcError } from '~/helpers/catchError';
import { Acl } from '~/middlewares/extract-ids/extract-ids.middleware';
import { User } from '~/models';
import { AppHooksService } from '~/services/app-hooks/app-hooks.service';
import { randomTokenString, setTokenCookie } from '~/services/users/helpers';
import { UsersService } from '~/services/users/users.service';
@Controller()
@ -30,229 +23,15 @@ export class UsersController {
protected readonly config: ConfigService<AppConfig>,
) {}
@Post([
'/auth/user/signup',
'/api/v1/db/auth/user/signup',
'/api/v1/auth/user/signup',
])
@HttpCode(200)
async signup(@Request() req: any, @Response() res: any): Promise<any> {
if (this.config.get('auth', { infer: true }).disableEmailAuth) {
NcError.forbidden('Email authentication is disabled');
}
res.json(
await this.usersService.signup({
body: req.body,
req,
res,
}),
);
}
@Post([
'/auth/token/refresh',
'/api/v1/db/auth/token/refresh',
'/api/v1/auth/token/refresh',
])
@HttpCode(200)
async refreshToken(@Request() req: any, @Response() res: any): Promise<any> {
res.json(
await this.usersService.refreshToken({
body: req.body,
req,
res,
}),
);
}
@Post([
'/auth/user/signin',
'/api/v1/db/auth/user/signin',
'/api/v1/auth/user/signin',
])
@UseGuards(AuthGuard('local'))
@HttpCode(200)
async signin(@Request() req, @Response() res) {
if (this.config.get('auth', { infer: true }).disableEmailAuth) {
NcError.forbidden('Email authentication is disabled');
}
await this.setRefreshToken({ req, res });
res.json(await this.usersService.login(req.user));
}
@Patch(['/api/v1/user/profile'])
@UseGuards(GlobalGuard)
@Post('/api/v1/auth/user/signout')
@HttpCode(200)
async signOut(@Request() req, @Response() res): Promise<any> {
if (!(req as any).isAuthenticated()) {
NcError.forbidden('Not allowed');
}
async update(@Body() body, @Request() req, @Response() res) {
res.json(
await this.usersService.signOut({
req,
res,
await this.usersService.profileUpdate({
id: req.user.id,
params: body,
}),
);
}
@Post(`/auth/google/genTokenByCode`)
@HttpCode(200)
@UseGuards(AuthGuard('google'))
async googleSignin(@Request() req, @Response() res) {
await this.setRefreshToken({ req, res });
res.json(await this.usersService.login(req.user));
}
@Get('/auth/google')
@UseGuards(AuthGuard('google'))
googleAuthenticate() {
// google strategy will take care the request
}
@Get(['/auth/user/me', '/api/v1/db/auth/user/me', '/api/v1/auth/user/me'])
@UseGuards(GlobalGuard)
async me(@Request() req) {
const user = {
...req.user,
roles: extractRolesObj(req.user.roles),
workspace_roles: extractRolesObj(req.user.workspace_roles),
project_roles: extractRolesObj(req.user.project_roles),
};
return user;
}
@Post([
'/user/password/change',
'/api/v1/db/auth/password/change',
'/api/v1/auth/password/change',
])
@UseGuards(GlobalGuard)
@Acl('passwordChange', {
scope: 'org',
})
@HttpCode(200)
async passwordChange(@Request() req: any): Promise<any> {
if (!(req as any).isAuthenticated()) {
NcError.forbidden('Not allowed');
}
await this.usersService.passwordChange({
user: req['user'],
req,
body: req.body,
});
return { msg: 'Password has been updated successfully' };
}
@Post([
'/auth/password/forgot',
'/api/v1/db/auth/password/forgot',
'/api/v1/auth/password/forgot',
])
@HttpCode(200)
async passwordForgot(@Request() req: any): Promise<any> {
await this.usersService.passwordForgot({
siteUrl: (req as any).ncSiteUrl,
body: req.body,
req,
});
return { msg: 'Please check your email to reset the password' };
}
@Post([
'/auth/token/validate/:tokenId',
'/api/v1/db/auth/token/validate/:tokenId',
'/api/v1/auth/token/validate/:tokenId',
])
@HttpCode(200)
async tokenValidate(@Param('tokenId') tokenId: string): Promise<any> {
await this.usersService.tokenValidate({
token: tokenId,
});
return { msg: 'Token has been validated successfully' };
}
@Post([
'/auth/password/reset/:tokenId',
'/api/v1/db/auth/password/reset/:tokenId',
'/api/v1/auth/password/reset/:tokenId',
])
@HttpCode(200)
async passwordReset(
@Request() req: any,
@Param('tokenId') tokenId: string,
@Body() body: any,
): Promise<any> {
await this.usersService.passwordReset({
token: tokenId,
body: body,
req,
});
return { msg: 'Password has been reset successfully' };
}
@Post([
'/api/v1/db/auth/email/validate/:tokenId',
'/api/v1/auth/email/validate/:tokenId',
])
@HttpCode(200)
async emailVerification(
@Request() req: any,
@Param('tokenId') tokenId: string,
): Promise<any> {
await this.usersService.emailVerification({
token: tokenId,
req,
});
return { msg: 'Email has been verified successfully' };
}
@Get([
'/api/v1/db/auth/password/reset/:tokenId',
'/auth/password/reset/:tokenId',
])
async renderPasswordReset(
@Request() req: any,
@Response() res: any,
@Param('tokenId') tokenId: string,
): Promise<any> {
try {
res.send(
ejs.render((await import('./ui/auth/resetPassword')).default, {
ncPublicUrl: process.env.NC_PUBLIC_URL || '',
token: JSON.stringify(tokenId),
baseUrl: `/`,
}),
);
} catch (e) {
return res.status(400).json({ msg: e.message });
}
}
async setRefreshToken({ res, req }) {
const userId = req.user?.id;
if (!userId) return;
const user = await User.get(userId);
if (!user) return;
const refreshToken = randomTokenString();
if (!user['token_version']) {
user['token_version'] = randomTokenString();
}
await User.update(user.id, {
refresh_token: refreshToken,
email: user.email,
token_version: user['token_version'],
});
setTokenCookie(res, refreshToken);
}
}

4
packages/nocodb/src/meta/migrations/XcMigrationSourcev2.ts

@ -21,6 +21,7 @@ import * as nc_030_add_description_field from './v2/nc_030_add_description_field
import * as nc_031_remove_fk_and_add_idx from './v2/nc_031_remove_fk_and_add_idx';
import * as nc_033_add_group_by from './v2/nc_033_add_group_by';
import * as nc_034_erd_filter_and_notification from './v2/nc_034_erd_filter_and_notification';
import * as nc_035_add_username_to_users from './v2/nc_035_add_username_to_users';
// Create a custom migration source class
export default class XcMigrationSourcev2 {
@ -53,6 +54,7 @@ export default class XcMigrationSourcev2 {
'nc_031_remove_fk_and_add_idx',
'nc_033_add_group_by',
'nc_034_erd_filter_and_notification',
'nc_035_add_username_to_users',
]);
}
@ -108,6 +110,8 @@ export default class XcMigrationSourcev2 {
return nc_033_add_group_by;
case 'nc_034_erd_filter_and_notification':
return nc_034_erd_filter_and_notification;
case 'nc_035_add_username_to_users':
return nc_035_add_username_to_users;
}
}
}

24
packages/nocodb/src/meta/migrations/v2/nc_035_add_username_to_users.ts

@ -0,0 +1,24 @@
import type { Knex } from 'knex';
import { MetaTable } from '~/utils/globals';
const up = async (knex: Knex) => {
await knex.schema.alterTable(MetaTable.USERS, (table) => {
table.string('display_name');
table.string('user_name');
table.dropColumn('firstname');
table.dropColumn('lastname');
table.dropColumn('username');
});
};
const down = async (knex: Knex) => {
await knex.schema.alterTable(MetaTable.USERS, (table) => {
table.dropColumn('display_name');
table.dropColumn('user_name');
table.string('firstname');
table.string('lastname');
table.string('username');
});
};
export { up, down };

5
packages/nocodb/src/models/User.ts

@ -25,6 +25,9 @@ export default class User implements UserType {
roles?: string;
token_version?: string;
display_name?: string;
avatar?: string;
constructor(data: User) {
Object.assign(this, data);
}
@ -80,6 +83,8 @@ export default class User implements UserType {
'email_verified',
'roles',
'token_version',
'display_name',
'avatar',
]);
if (updateObj.email) {

21
packages/nocodb/src/modules/auth/auth.module.ts

@ -0,0 +1,21 @@
import { forwardRef, Module } from '@nestjs/common';
import { PassportModule } from '@nestjs/passport';
import { GoogleStrategyProvider } from '~/strategies/google.strategy/google.strategy';
import { GlobalModule } from '~/modules/global/global.module';
import { UsersService } from '~/services/users/users.service';
import { AuthController } from '~/controllers/auth/auth.controller';
import { MetasModule } from '~/modules/metas/metas.module';
@Module({
imports: [
forwardRef(() => GlobalModule),
PassportModule,
forwardRef(() => MetasModule),
],
controllers: [
...(process.env.NC_WORKER_CONTAINER !== 'true' ? [AuthController] : []),
],
providers: [UsersService, GoogleStrategyProvider],
exports: [UsersService],
})
export class AuthModule {}

3
packages/nocodb/src/modules/users/users.module.ts

@ -1,6 +1,5 @@
import { forwardRef, Module } from '@nestjs/common';
import { PassportModule } from '@nestjs/passport';
import { GoogleStrategyProvider } from '~/strategies/google.strategy/google.strategy';
import { GlobalModule } from '~/modules/global/global.module';
import { UsersService } from '~/services/users/users.service';
import { UsersController } from '~/controllers/users/users.controller';
@ -15,7 +14,7 @@ import { MetasModule } from '~/modules/metas/metas.module';
controllers: [
...(process.env.NC_WORKER_CONTAINER !== 'true' ? [UsersController] : []),
],
providers: [UsersService, GoogleStrategyProvider],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}

24
packages/nocodb/src/schema/swagger.json

@ -64,6 +64,30 @@
}
],
"paths": {
"/api/v1/user/profile": {
"patch": {
"summary": "Update User Profile",
"operationId": "user-profile-update",
"responses": {
"200": {
"$ref": "#/components/schemas/User"
}
},
"tags": [
"User profile"
],
"description": "Update User Profile",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/User"
}
}
}
}
}
},
"/api/v1/auth/user/signup": {
"post": {
"summary": "Signup",

7
packages/nocodb/src/services/auth.service.ts

@ -4,11 +4,16 @@ import { Injectable } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
import { v4 as uuidv4 } from 'uuid';
import type { CreateUserDto } from '~/controllers/auth.controller';
import Noco from '~/Noco';
import { genJwt } from '~/services/users/helpers';
import { UsersService } from '~/services/users/users.service';
export class CreateUserDto {
readonly username: string;
readonly email: string;
readonly password: string;
}
@Injectable()
export class AuthService {
constructor(private usersService: UsersService) {}

20
packages/nocodb/src/services/users/users.service.ts

@ -25,6 +25,7 @@ import { randomTokenString } from '~/helpers/stringHelpers';
import NcPluginMgrv2 from '~/helpers/NcPluginMgrv2';
import { NcError } from '~/helpers/catchError';
import { ProjectsService } from '~/services/projects.service';
import { extractProps } from '~/helpers/extractProps';
@Injectable()
export class UsersService {
@ -70,6 +71,21 @@ export class UsersService {
});
}
async profileUpdate({
id,
params,
}: {
id: number;
params: {
display_name?: string;
avatar?: string;
};
}) {
const updateObj = extractProps(params, ['display_name', 'avatar']);
return await User.update(id, updateObj);
}
async registerNewUserIfAllowed({
email,
salt,
@ -209,7 +225,7 @@ export class UsersService {
});
try {
const template = (
await import('~/controllers/users/ui/emailTemplates/forgotPassword')
await import('~/controllers/auth/ui/emailTemplates/forgotPassword')
).default;
await NcPluginMgrv2.emailAdapter().then((adapter) =>
adapter.mailSend({
@ -451,7 +467,7 @@ export class UsersService {
try {
const template = (
await import('~/controllers/users/ui/emailTemplates/verify')
await import('~/controllers/auth/ui/emailTemplates/verify')
).default;
await (
await NcPluginMgrv2.emailAdapter()

2
tests/playwright/pages/Account/index.ts

@ -32,7 +32,7 @@ export class AccountPage extends BasePage {
async signOut() {
await this.openAppMenu();
await this.rootPage.locator('div.nc-project-menu-item:has-text("Sign Out"):visible').click();
await this.rootPage.locator('div.nc-account-dropdown-item:has-text("Sign Out"):visible').click();
await this.rootPage.locator('[data-testid="nc-form-signin"]:visible').waitFor();
}
}

5
tests/playwright/pages/Base.ts

@ -23,18 +23,21 @@ export default abstract class BasePage {
requestUrlPathToMatch,
// A function that takes the response body and returns true if the response is the one we are looking for
responseJsonMatcher,
timeout,
}: {
uiAction: () => Promise<any>;
requestUrlPathToMatch: string;
httpMethodsToMatch?: string[];
responseJsonMatcher?: ResponseSelector;
timeout?: number;
}) {
const [res] = await Promise.all([
this.rootPage.waitForResponse(
res =>
res.url().includes(requestUrlPathToMatch) &&
res.status() === 200 &&
httpMethodsToMatch.includes(res.request().method())
httpMethodsToMatch.includes(res.request().method()),
timeout ? { timeout } : undefined
),
uiAction(),
]);

2
tests/playwright/pages/Dashboard/Grid/index.ts

@ -64,7 +64,7 @@ export class GridPage extends BasePage {
async verifyCollaborativeMode() {
// add new row button
expect(await this.btn_addNewRow.count()).toBe(1);
await expect(this.btn_addNewRow).toHaveCount(1);
await this.toolbar.verifyCollaborativeMode();
await this.footbar.verifyCollaborativeMode();

2
tests/playwright/pages/Dashboard/ViewSidebar/index.ts

@ -91,6 +91,8 @@ export class ViewSidebarPage extends BasePage {
async createKanbanView({ title }: { title: string }) {
await this.createView({ title, locator: this.createKanbanButton });
await this.rootPage.waitForTimeout(1500);
}
async createMapView({ title }: { title: string }) {

4
tests/playwright/pages/Dashboard/common/Footbar/index.ts

@ -31,9 +31,9 @@ export class FootbarPage extends BasePage {
async verifyRoleAccess(param: { role: string }) {
const role = param.role.toLowerCase();
if (role === 'creator' || role === 'editor' || role === 'owner') {
expect(await this.btn_addNewRow.count()).toBe(1);
await expect(this.btn_addNewRow).toHaveCount(1);
} else {
expect(await this.btn_addNewRow.count()).toBe(0);
await expect(this.btn_addNewRow).toHaveCount(0);
}
}

1
tests/playwright/pages/Dashboard/common/Toolbar/Fields.ts

@ -71,6 +71,7 @@ export class ToolbarFieldsPage extends BasePage {
uiAction: () => this.get().locator(`.nc-fields-show-all-fields`).click(),
requestUrlPathToMatch: isLocallySaved ? '/api/v1/db/public/' : '/api/v1/db/data/noco/',
httpMethodsToMatch: ['GET'],
timeout: 10000,
});
await this.toolbar.clickFields();
}

Loading…
Cancel
Save