Browse Source

Merge pull request #3109 from nocodb/feat/invite-url

feat(gui-v2): allow users to signup with invite url
pull/3082/head
Raju Udava 2 years ago committed by GitHub
parent
commit
4aa628d7e6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 45
      packages/nc-gui-v2/assets/style-v2.scss
  2. 13
      packages/nc-gui-v2/components.d.ts
  3. 110
      packages/nc-gui-v2/components/tabs/auth/UserManagement.vue
  4. 42
      packages/nc-gui-v2/components/tabs/auth/user-management/UsersModal.vue
  5. 15
      packages/nc-gui-v2/composables/useApi/index.ts
  6. 12
      packages/nc-gui-v2/composables/useGlobal/types.ts
  7. 138
      packages/nc-gui-v2/layouts/base.vue
  8. 2
      packages/nc-gui-v2/lib/types.ts
  9. 73
      packages/nc-gui-v2/pages/forgot-password.vue
  10. 96
      packages/nc-gui-v2/pages/signin.vue
  11. 143
      packages/nc-gui-v2/pages/signup.vue
  12. 206
      packages/nc-gui-v2/pages/signup/[[token]].vue

45
packages/nc-gui-v2/assets/style-v2.scss

@ -108,3 +108,48 @@ html {
.ant-modal-wrap {
@apply !scrollbar-thin-dull;
}
.animated-bg-gradient {
background: linear-gradient(122deg, #6f3381, #81c7d4, #fedfe1, #9ee59e);
background-size: 800% 800%;
-webkit-animation: gradient 4s ease infinite;
-moz-animation: gradient 4s ease infinite;
animation: gradient 4s ease infinite;
}
@-webkit-keyframes gradient {
0% {
background-position: 0% 22%
}
50% {
background-position: 100% 79%
}
100% {
background-position: 0% 22%
}
}
@-moz-keyframes gradient {
0% {
background-position: 0% 22%
}
50% {
background-position: 100% 79%
}
100% {
background-position: 0% 22%
}
}
@keyframes gradient {
0% {
background-position: 0% 22%
}
50% {
background-position: 100% 79%
}
100% {
background-position: 0% 22%
}
}

13
packages/nc-gui-v2/components.d.ts vendored

@ -65,16 +65,26 @@ declare module '@vue/runtime-core' {
AUploadDragger: typeof import('ant-design-vue/es')['UploadDragger']
CilFullscreen: typeof import('~icons/cil/fullscreen')['default']
CilFullscreenExit: typeof import('~icons/cil/fullscreen-exit')['default']
ClaritySuccessLine: typeof import('~icons/clarity/success-line')['default']
EvaEmailOutline: typeof import('~icons/eva/email-outline')['default']
IcBaselineMoreVert: typeof import('~icons/ic/baseline-more-vert')['default']
IcOutlineInsertDriveFile: typeof import('~icons/ic/outline-insert-drive-file')['default']
IcRoundEdit: typeof import('~icons/ic/round-edit')['default']
IcRoundKeyboardArrowDown: typeof import('~icons/ic/round-keyboard-arrow-down')['default']
IcRoundSearch: typeof import('~icons/ic/round-search')['default']
MaterialSymbolsAttachFile: typeof import('~icons/material-symbols/attach-file')['default']
MaterialSymbolsChevronLeftRounded: typeof import('~icons/material-symbols/chevron-left-rounded')['default']
MaterialSymbolsChevronRightRounded: typeof import('~icons/material-symbols/chevron-right-rounded')['default']
MaterialSymbolsCloseRounded: typeof import('~icons/material-symbols/close-rounded')['default']
MaterialSymbolsFileCopyOutline: typeof import('~icons/material-symbols/file-copy-outline')['default']
MaterialSymbolsRocketLaunchOutline: typeof import('~icons/material-symbols/rocket-launch-outline')['default']
MaterialSymbolsSendOutline: typeof import('~icons/material-symbols/send-outline')['default']
MaterialSymbolsTranslate: typeof import('~icons/material-symbols/translate')['default']
MaterialSymbolsWarning: typeof import('~icons/material-symbols/warning')['default']
MdiAccountCircle: typeof import('~icons/mdi/account-circle')['default']
MdiAccountGroup: typeof import('~icons/mdi/account-group')['default']
MdiAccountIcon: typeof import('~icons/mdi/account-icon')['default']
MdiAccountOutline: typeof import('~icons/mdi/account-outline')['default']
MdiAlphaA: typeof import('~icons/mdi/alpha-a')['default']
MdiApi: typeof import('~icons/mdi/api')['default']
MdiArrowExpand: typeof import('~icons/mdi/arrow-expand')['default']
@ -98,7 +108,9 @@ declare module '@vue/runtime-core' {
MdiDownload: typeof import('~icons/mdi/download')['default']
MdiDrag: typeof import('~icons/mdi/drag')['default']
MdiDragVertical: typeof import('~icons/mdi/drag-vertical')['default']
MdiDramaMasks: typeof import('~icons/mdi/drama-masks')['default']
MdiEmail: typeof import('~icons/mdi/email')['default']
MdiEmailArrowRightOutline: typeof import('~icons/mdi/email-arrow-right-outline')['default']
MdiExitToApp: typeof import('~icons/mdi/exit-to-app')['default']
MdiEyeOffOutline: typeof import('~icons/mdi/eye-off-outline')['default']
MdiFlag: typeof import('~icons/mdi/flag')['default']
@ -114,6 +126,7 @@ declare module '@vue/runtime-core' {
MdiLense: typeof import('~icons/mdi/lense')['default']
MdiLink: typeof import('~icons/mdi/link')['default']
MdiLinkVariantRemove: typeof import('~icons/mdi/link-variant-remove')['default']
MdiLogin: typeof import('~icons/mdi/login')['default']
MdiLogout: typeof import('~icons/mdi/logout')['default']
MdiMagnify: typeof import('~icons/mdi/magnify')['default']
MdiMenuDown: typeof import('~icons/mdi/menu-down')['default']

110
packages/nc-gui-v2/components/tabs/auth/UserManagement.vue

@ -1,38 +1,50 @@
<script setup lang="ts">
import { useClipboard, watchDebounced } from '@vueuse/core'
import { message } from 'ant-design-vue'
import UsersModal from './user-management/UsersModal.vue'
import FeedbackForm from './user-management/FeedbackForm.vue'
import KebabIcon from '~icons/ic/baseline-more-vert'
import { extractSdkResponseErrorMsg } from '~/utils/errorUtils'
import { projectRoleTagColors } from '~/utils/userUtils'
import MidAccountIcon from '~icons/mdi/account-outline'
import ReloadIcon from '~icons/mdi/reload'
import MdiEditIcon from '~icons/ic/round-edit'
import SearchIcon from '~icons/ic/round-search'
import MdiDeleteOutlineIcon from '~icons/mdi/delete-outline'
import EmailIcon from '~icons/eva/email-outline'
import MdiPlusIcon from '~icons/mdi/plus'
import MdiContentCopyIcon from '~icons/mdi/content-copy'
import MdiEmailSendIcon from '~icons/mdi/email-arrow-right-outline'
import RolesIcon from '~icons/mdi/drama-masks'
import type { User } from '~/lib/types'
const { $api, $e } = useNuxtApp()
import {
extractSdkResponseErrorMsg,
onMounted,
projectRoleTagColors,
ref,
useApi,
useClipboard,
useDashboard,
useNuxtApp,
useProject,
useUIPermission,
watchDebounced,
} from '#imports'
import type { User } from '~/lib'
const { $e } = useNuxtApp()
const { api } = useApi()
const { project } = useProject()
const { copy } = useClipboard()
const { isUIAllowed } = useUIPermission()
const { dashboardUrl } = $(useDashboard())
let users = $ref<null | User[]>(null)
let selectedUser = $ref<null | User>(null)
let showUserModal = $ref(false)
let showUserDeleteModal = $ref(false)
let isLoading = $ref(false)
let totalRows = $ref(0)
const currentPage = $ref(1)
const currentLimit = $ref(10)
const searchText = ref<string>('')
const loadUsers = async (page = currentPage, limit = currentLimit) => {
@ -40,16 +52,17 @@ const loadUsers = async (page = currentPage, limit = currentLimit) => {
if (!project.value?.id) return
// TODO: Types of api is not correct
const response: any = await $api.auth.projectUserList(project.value?.id, {
const response: any = await api.auth.projectUserList(project.value?.id, {
query: {
limit,
offset: searchText.value.length === 0 ? (page - 1) * limit : 0,
query: searchText.value,
},
})
} as any)
if (!response.users) return
totalRows = response.users.pageInfo.totalRows ?? 0
users = response.users.list as User[]
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
@ -60,7 +73,8 @@ const inviteUser = async (user: User) => {
try {
if (!project.value?.id) return
await $api.auth.projectUserAdd(project.value.id, user)
await api.auth.projectUserAdd(project.value.id, user)
message.success('Successfully added user to project')
await loadUsers()
} catch (e: any) {
@ -74,9 +88,12 @@ const deleteUser = async () => {
try {
if (!project.value?.id || !selectedUser?.id) return
await $api.auth.projectUserRemove(project.value.id, selectedUser.id)
await api.auth.projectUserRemove(project.value.id, selectedUser.id)
message.success('Successfully deleted user from project')
await loadUsers()
showUserDeleteModal = false
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
@ -106,7 +123,8 @@ const resendInvite = async (user: User) => {
if (!project.value?.id) return
try {
await $api.auth.projectUserResendInvite(project.value.id, user.id)
await api.auth.projectUserResendInvite(project.value.id, user.id, null)
message.success('Invite email sent successfully')
await loadUsers()
} catch (e: any) {
@ -119,20 +137,16 @@ const resendInvite = async (user: User) => {
const copyInviteUrl = (user: User) => {
if (!user.invite_token) return
const getInviteUrl = (token: string) => `${dashboardUrl}/user/authentication/signup/${token}`
copy(`${dashboardUrl}/signup/${user.invite_token}`)
copy(getInviteUrl(user.invite_token))
message.success('Invite url copied to clipboard')
}
onMounted(async () => {
onMounted(() => {
if (!users) {
isLoading = true
try {
await loadUsers()
} finally {
isLoading = false
}
loadUsers().finally(() => (isLoading = false))
}
})
@ -166,7 +180,7 @@ watchDebounced(searchText, () => loadUsers(), { debounce: 300, maxWait: 600 })
<div class="flex w-1/3">
<a-input v-model:value="searchText" placeholder="Filter by email">
<template #prefix>
<SearchIcon class="text-gray-400" />
<IcRoundSearch class="text-gray-400" />
</template>
</a-input>
</div>
@ -174,13 +188,13 @@ watchDebounced(searchText, () => loadUsers(), { debounce: 300, maxWait: 600 })
<div class="flex flex-row space-x-1">
<a-button size="middle" type="text" @click="loadUsers()">
<div class="flex flex-row justify-center items-center caption capitalize space-x-1">
<ReloadIcon class="text-gray-500" />
<MdiReload class="text-gray-500" />
<div class="text-gray-500">Reload</div>
</div>
</a-button>
<a-button v-if="isUIAllowed('newUser')" size="middle" type="primary" ghost @click="onInvite">
<div class="flex flex-row justify-center items-center caption capitalize space-x-1">
<MidAccountIcon />
<MdiAccountOutline />
<div>{{ $t('activity.inviteTeam') }}</div>
</div>
</a-button>
@ -189,12 +203,12 @@ watchDebounced(searchText, () => loadUsers(), { debounce: 300, maxWait: 600 })
<div class="px-5">
<div class="flex flex-row border-b-1 pb-2 px-2">
<div class="flex flex-row w-4/6 space-x-1 items-center pl-1">
<EmailIcon class="flex text-gray-500 -mt-0.5" />
<EvaEmailOutline class="flex text-gray-500 -mt-0.5" />
<div class="text-gray-600 text-xs space-x-1">{{ $t('labels.email') }}</div>
</div>
<div class="flex flex-row justify-center w-1/6 space-x-1 items-center pl-1">
<RolesIcon class="flex text-gray-500 -mt-0.5" />
<MdiDramaMasks class="flex text-gray-500 -mt-0.5" />
<div class="text-gray-600 text-xs">{{ $t('objects.role') }}</div>
</div>
@ -203,12 +217,13 @@ watchDebounced(searchText, () => loadUsers(), { debounce: 300, maxWait: 600 })
</div>
</div>
<div v-for="(user, index) in users" :key="index" class="flex flex-row items-center border-b-1 py-2 px-2">
<div v-for="(user, index) of users" :key="index" class="flex flex-row items-center border-b-1 py-2 px-2">
<div class="flex w-4/6 flex-wrap">
{{ user.email }}
</div>
<div class="flex w-1/6 justify-center flex-wrap ml-4">
<div class="rounded-full px-2 py-1" :style="{ backgroundColor: projectRoleTagColors[user.roles as String] }">
<div class="rounded-full px-2 py-1" :style="{ backgroundColor: projectRoleTagColors[user.roles] }">
{{ user.roles }}
</div>
</div>
@ -219,7 +234,7 @@ watchDebounced(searchText, () => loadUsers(), { debounce: 300, maxWait: 600 })
</template>
<a-button type="text" class="!rounded-md" @click="onEdit(user)">
<template #icon>
<MdiEditIcon class="flex mx-auto h-[1rem] text-gray-500" />
<IcRoundEdit class="flex mx-auto h-[1rem] text-gray-500" />
</template>
</a-button>
</a-tooltip>
@ -229,7 +244,7 @@ watchDebounced(searchText, () => loadUsers(), { debounce: 300, maxWait: 600 })
</template>
<a-button type="text" class="!rounded-md" @click="inviteUser(user)">
<template #icon>
<MdiPlusIcon class="flex mx-auto h-[1.1rem] text-gray-500" />
<MdiPlus class="flex mx-auto h-[1.1rem] text-gray-500" />
</template>
</a-button>
</a-tooltip>
@ -240,7 +255,7 @@ watchDebounced(searchText, () => loadUsers(), { debounce: 300, maxWait: 600 })
</template>
<a-button type="text" class="!rounded-md" @click="onDelete(user)">
<template #icon>
<MdiDeleteOutlineIcon class="flex mx-auto h-[1.1rem] text-gray-500" />
<MdiDeleteOutline class="flex mx-auto h-[1.1rem] text-gray-500" />
</template>
</a-button>
</a-tooltip>
@ -249,7 +264,7 @@ watchDebounced(searchText, () => loadUsers(), { debounce: 300, maxWait: 600 })
<div class="flex flex-row items-center">
<a-button type="text" class="!px-0">
<div class="flex flex-row items-center h-[1.2rem]">
<KebabIcon />
<IcBaselineMoreVert />
</div>
</a-button>
</div>
@ -257,13 +272,13 @@ watchDebounced(searchText, () => loadUsers(), { debounce: 300, maxWait: 600 })
<a-menu>
<a-menu-item>
<div class="flex flex-row items-center py-1" @click="resendInvite(user)">
<MdiEmailSendIcon class="flex h-[1rem] text-gray-500" />
<MdiEmailArrowRightOutline class="flex h-[1rem] text-gray-500" />
<div class="text-xs pl-2">Resend invite email</div>
</div>
</a-menu-item>
<a-menu-item>
<div class="flex flex-row items-center py-1" @click="copyInviteUrl(user)">
<MdiContentCopyIcon class="flex h-[1rem] text-gray-500" />
<MdiContentCopy class="flex h-[1rem] text-gray-500" />
<div class="text-xs pl-2">{{ $t('activity.copyInviteURL') }}</div>
</div>
</a-menu-item>
@ -285,12 +300,3 @@ watchDebounced(searchText, () => loadUsers(), { debounce: 300, maxWait: 600 })
</div>
</div>
</template>
<style scoped>
.users-table {
/* equally spaced columns in table */
table-layout: fixed;
width: 100%;
}
</style>

42
packages/nc-gui-v2/components/tabs/auth/user-management/UsersModal.vue

@ -1,14 +1,20 @@
<script setup lang="ts">
import { Form, message } from 'ant-design-vue'
import { useClipboard } from '@vueuse/core'
import ShareBase from './ShareBase.vue'
import SendIcon from '~icons/material-symbols/send-outline'
import CloseIcon from '~icons/material-symbols/close-rounded'
import MidAccountIcon from '~icons/mdi/account-outline'
import ContentCopyIcon from '~icons/mdi/content-copy'
import type { User } from '~/lib/types'
import { ProjectRole } from '~/lib/enums'
import { extractSdkResponseErrorMsg, isEmail, projectRoleTagColors, projectRoles } from '~/utils'
import {
computed,
extractSdkResponseErrorMsg,
isEmail,
onMounted,
projectRoles,
ref,
useClipboard,
useDashboard,
useNuxtApp,
useProject,
} from '#imports'
import type { User } from '~/lib'
import { ProjectRole } from '~/lib'
interface Props {
show: boolean
@ -22,6 +28,7 @@ interface Users {
}
const { show, selectedUser } = defineProps<Props>()
const emit = defineEmits(['closed', 'reload'])
const { project } = useProject()
@ -96,14 +103,13 @@ const saveUser = async () => {
}
}
const inviteUrl = $computed(() =>
usersData.invitationToken ? `${dashboardUrl}/user/authentication/signup/${usersData.invitationToken}` : null,
)
const inviteUrl = $computed(() => (usersData.invitationToken ? `${dashboardUrl}/signup/${usersData.invitationToken}` : null))
const copyUrl = async () => {
if (!inviteUrl) return
copy(inviteUrl)
await copy(inviteUrl)
message.success('Copied shareable base url to clipboard!')
$e('c:shared-base:copy-url')
@ -124,7 +130,7 @@ const clickInviteMore = () => {
<a-typography-title class="select-none" :level="4"> {{ $t('activity.share') }}: {{ project.title }} </a-typography-title>
<a-button type="text" class="!rounded-md mr-1 -mt-1.5" @click="emit('closed')">
<template #icon>
<CloseIcon class="flex mx-auto" />
<MaterialSymbolsCloseRounded class="flex mx-auto" />
</template>
</a-button>
</div>
@ -133,7 +139,7 @@ const clickInviteMore = () => {
<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]">
<MidAccountIcon />
<MdiAccountOutline />
<div class="text-xs ml-0.5 mt-0.5">Copy Invite Token</div>
</div>
@ -145,7 +151,7 @@ const clickInviteMore = () => {
</div>
<a-button type="text" class="!rounded-md -mt-0.5" @click="copyUrl">
<template #icon>
<ContentCopyIcon class="flex mx-auto text-green-700 h-[1rem]" />
<MdiContentCopy class="flex mx-auto text-green-700 h-[1rem]" />
</template>
</a-button>
</div>
@ -159,7 +165,7 @@ const clickInviteMore = () => {
<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">
<SendIcon class="flex mx-auto text-gray-600 h-[0.8rem]" />
<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>
@ -168,7 +174,7 @@ const clickInviteMore = () => {
</template>
<div v-else class="flex flex-col pb-4">
<div class="flex flex-row items-center pl-2 pb-1 h-[1rem]">
<MidAccountIcon />
<MdiAccountOutline />
<div class="text-xs ml-0.5 mt-0.5">{{ selectedUser ? 'Edit User' : 'Invite Team' }}</div>
</div>
<div class="border-1 py-3 px-4 rounded-md mt-1">
@ -218,7 +224,7 @@ const clickInviteMore = () => {
<a-button type="primary" html-type="submit">
<div v-if="selectedUser">{{ $t('general.save') }}</div>
<div v-else class="flex flex-row justify-center items-center space-x-1.5">
<SendIcon class="flex h-[0.8rem]" />
<MaterialSymbolsSendOutline class="flex h-[0.8rem]" />
<div>{{ $t('activity.invite') }}</div>
</div>
</a-button>

15
packages/nc-gui-v2/composables/useApi/index.ts

@ -1,9 +1,9 @@
import type { AxiosError, AxiosResponse } from 'axios'
import { Api } from 'nocodb-sdk'
import type { Ref } from 'vue'
import { addAxiosInterceptors } from './interceptors'
import type { CreateApiOptions, UseApiProps, UseApiReturn } from './types'
import { createEventHook, ref, unref, useCounter, useGlobal, useNuxtApp } from '#imports'
import { addAxiosInterceptors } from './interceptors'
import { createEventHook, ref, unref, useCounter, useNuxtApp } from '#imports'
export function createApiInstance<SecurityDataType = any>(options: CreateApiOptions = {}): Api<SecurityDataType> {
return addAxiosInterceptors(
@ -33,8 +33,6 @@ export function useApi<Data = any, RequestConfig = any>({
apiOptions,
axiosConfig,
}: UseApiProps<Data> = {}): UseApiReturn<Data, RequestConfig> {
const state = useGlobal()
/**
* Local state of running requests, do not confuse with global state of running requests
* This state is only counting requests made by this instance of `useApi` and not by other instances.
@ -54,11 +52,10 @@ export function useApi<Data = any, RequestConfig = any>({
const responseHook = createEventHook<AxiosResponse<Data, RequestConfig>>()
/** global api instance */
const $api = useNuxtApp().$api
const nuxtApp = useNuxtApp()
/** api instance - with interceptors for token refresh already bound */
const api = useGlobalInstance && !!$api ? $api : createApiInstance(apiOptions)
const api = useGlobalInstance && !!nuxtApp.$api ? nuxtApp.$api : createApiInstance(apiOptions)
/** set loading to true and increment local and global request counter */
function onRequestStart() {
@ -68,7 +65,7 @@ export function useApi<Data = any, RequestConfig = any>({
inc()
/** global count */
state.runningRequests.inc()
nuxtApp.$state.runningRequests.inc()
}
/** decrement local and global request counter and check if we can stop loading */
@ -76,7 +73,7 @@ export function useApi<Data = any, RequestConfig = any>({
/** local count */
dec()
/** global count */
state.runningRequests.dec()
nuxtApp.$state.runningRequests.dec()
/** try to stop loading */
stopLoading()

12
packages/nc-gui-v2/composables/useGlobal/types.ts

@ -13,6 +13,18 @@ export interface FeedbackForm {
export interface AppInfo {
ncSiteUrl: string
authType: 'jwt' | 'masterKey' | 'none'
connectToExternalDB: boolean
defaultLimit: number
firstUser: boolean
githubAuthEnabled: boolean
googleAuthEnabled: boolean
ncMin: boolean
oneClick: boolean
projectHasAdmin: boolean
teleEnabled: boolean
type: string
version: string
}
export interface StoredState {

138
packages/nc-gui-v2/layouts/base.vue

@ -19,65 +19,77 @@ const logout = () => {
<div id="nc-sidebar-left" />
<a-layout class="!flex-col">
<a-layout-header class="flex !bg-primary items-center text-white pl-4 pr-5 shadow-lg">
<div
v-if="route.name === 'index' || route.name === 'project-index-create' || route.name === 'project-index-create-external'"
class="transition-all duration-200 p-2 cursor-pointer transform hover:scale-105"
@click="navigateTo('/')"
>
<img width="35" alt="NocoDB" src="~/assets/img/icons/512x512-trans.png" />
</div>
<div class="flex justify-center">
<div v-show="isLoading" class="flex items-center gap-2 ml-3">
{{ $t('general.loading') }}
<MdiReload :class="{ 'animate-infinite animate-spin': isLoading }" />
<Transition name="layout">
<a-layout-header v-if="signedIn" class="flex !bg-primary items-center text-white pl-4 pr-5 shadow-lg">
<div
v-if="route.name === 'index' || route.name === 'project-index-create' || route.name === 'project-index-create-external'"
class="transition-all duration-200 p-2 cursor-pointer transform hover:scale-105"
@click="navigateTo('/')"
>
<img width="35" alt="NocoDB" src="~/assets/img/icons/512x512-trans.png" />
</div>
</div>
<div class="flex-1" />
<div class="flex justify-center">
<div v-show="isLoading" class="flex items-center gap-2 ml-3">
{{ $t('general.loading') }}
<a-tooltip placement="left">
<template #title> Switch language </template>
<MdiReload :class="{ 'animate-infinite animate-spin': isLoading }" />
</div>
</div>
<div class="flex pr-4 items-center">
<GeneralLanguage class="cursor-pointer text-2xl" />
<div class="flex-1" />
<a-tooltip placement="left">
<template #title> Switch language </template>
<div class="flex pr-4 items-center">
<GeneralLanguage class="cursor-pointer text-2xl" />
</div>
</a-tooltip>
<template v-if="signedIn">
<a-dropdown :trigger="['click']">
<MdiDotsVertical class="md:text-xl cursor-pointer nc-user-menu" @click.prevent />
<template #overlay>
<a-menu class="!py-0 nc-user-menu dark:(!bg-gray-800) leading-8 !rounded">
<a-menu-item key="0" class="!rounded-t">
<nuxt-link v-t="['c:navbar:user:email']" class="group flex items-center no-underline py-2" to="/user">
<MdiAt class="mt-1 group-hover:text-success" />&nbsp;
<span class="prose group-hover:text-black nc-user-menu-email">{{ email }}</span>
</nuxt-link>
</a-menu-item>
<a-menu-divider class="!m-0" />
<a-menu-item key="1" class="!rounded-b">
<div v-t="['a:navbar:user:sign-out']" class="group flex items-center py-2" @click="logout">
<MdiLogout class="dark:text-white group-hover:(!text-red-500)" />&nbsp;
<span class="prose font-semibold text-gray-500 group-hover:text-black nc-user-menu-signout">
{{ $t('general.signOut') }}
</span>
</div>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</template>
</a-layout-header>
</Transition>
<a-tooltip>
<template #title> Switch language </template>
<Transition name="layout">
<div v-if="!signedIn" class="nc-lang-btn">
<GeneralLanguage />
</div>
</a-tooltip>
<template v-if="signedIn">
<a-dropdown :trigger="['click']">
<MdiDotsVertical class="md:text-xl cursor-pointer nc-user-menu" @click.prevent />
<template #overlay>
<a-menu class="!py-0 nc-user-menu dark:(!bg-gray-800) leading-8 !rounded">
<a-menu-item key="0" class="!rounded-t">
<nuxt-link v-t="['c:navbar:user:email']" class="group flex items-center no-underline py-2" to="/user">
<MdiAt class="mt-1 group-hover:text-success" />&nbsp;
<span class="prose group-hover:text-black nc-user-menu-email">{{ email }}</span>
</nuxt-link>
</a-menu-item>
<a-menu-divider class="!m-0" />
<a-menu-item key="1" class="!rounded-b">
<div v-t="['a:navbar:user:sign-out']" class="group flex items-center py-2" @click="logout">
<MdiLogout class="dark:text-white group-hover:(!text-red-500)" />&nbsp;
<span class="prose font-semibold text-gray-500 group-hover:text-black nc-user-menu-signout">
{{ $t('general.signOut') }}
</span>
</div>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</template>
</a-layout-header>
<div class="w-full overflow-hidden" style="height: calc(100% - var(--header-height))">
</Transition>
</a-tooltip>
<div class="w-full h-full overflow-hidden">
<slot />
</div>
</a-layout>
@ -92,4 +104,22 @@ const logout = () => {
:deep(.ant-dropdown-menu-item-group-list) {
@apply m-0;
}
.nc-lang-btn {
@apply color-transition flex items-center justify-center fixed bottom-10 right-10 z-99 w-12 h-12 rounded-full shadow-md shadow-gray-500 p-2 !bg-primary text-white active:(ring ring-pink-500) hover:(ring ring-pink-500);
&::after {
@apply rounded-full absolute top-0 left-0 right-0 bottom-0 transition-all duration-150 ease-in-out bg-primary;
content: '';
z-index: -1;
}
&:hover::after {
@apply transform scale-110 ring ring-pink-500;
}
&:active::after {
@apply ring ring-pink-500;
}
}
</style>

2
packages/nc-gui-v2/lib/types.ts

@ -6,6 +6,8 @@ export interface User {
firstname: string | null
lastname: string | null
roles: Roles
invite_token?: string
project_id?: string
}
export type Roles = Record<Role, boolean>

73
packages/nc-gui-v2/pages/forgot-password.vue

@ -1,14 +1,7 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { definePageMeta } from '#imports'
import { extractSdkResponseErrorMsg } from '~/utils/errorUtils'
import { useNuxtApp } from '#app'
import { isEmail } from '~/utils/validation'
import MdiLogin from '~icons/mdi/login'
import MaterialSymbolsWarning from '~icons/material-symbols/warning'
import ClaritySuccessLine from '~icons/clarity/success-line'
import { definePageMeta, extractSdkResponseErrorMsg, isEmail, reactive, ref, useApi, useI18n } from '#imports'
const { $api } = $(useNuxtApp())
const { api, isLoading } = useApi()
const { t } = useI18n()
@ -18,6 +11,7 @@ definePageMeta({
})
let error = $ref<string | null>(null)
let success = $ref(false)
const formValidator = ref()
@ -43,13 +37,14 @@ const formRules = {
],
}
const resetPassword = async () => {
const valid = formValidator.value.validate()
if (!valid) return
async function resetPassword() {
if (!formValidator.value.validate()) return
resetError()
error = null
try {
await $api.auth.passwordForgot(form)
await api.auth.passwordForgot(form)
success = true
} catch (e: any) {
// todo: errors should not expose what was wrong (i.e. do not show "Password is wrong" messages)
@ -57,10 +52,8 @@ const resetPassword = async () => {
}
}
const resetError = () => {
if (error) {
error = null
}
function resetError() {
if (error) error = null
}
</script>
@ -70,34 +63,41 @@ const resetError = () => {
ref="formValidator"
layout="vertical"
:model="form"
class="forgot-password h-full min-h-[600px] flex justify-center items-center"
class="bg-primary/5 forgot-password h-full min-h-[600px] flex justify-center items-center"
@finish="resetPassword"
>
<div class="h-full w-full flex flex-col flex-wrap justify-center items-center">
<div
class="color-transition bg-white dark:(!bg-gray-900 !text-white) relative flex flex-col justify-center gap-2 w-full max-w-[500px] mx-auto p-8 md:(rounded-lg border-1 border-gray-200 shadow-xl)"
>
<general-noco-icon />
<general-noco-icon
class="color-transition hover:(ring ring-pink-500)"
:class="[isLoading ? 'animated-bg-gradient' : '']"
/>
<div class="self-center flex flex-col justify-center items-center text-center gap-4">
<div class="self-center flex flex-col justify-center items-center text-center gap-2">
<h1 class="prose-2xl font-bold my-4 w-full">{{ $t('title.resetPassword') }}</h1>
<template v-if="!success">
<p class="prose-sm">{{ $t('msg.info.passwordRecovery.message_1') }}</p>
<p class="prose-sm mb-4">{{ $t('msg.info.passwordRecovery.message_2') }}</p>
<div class="prose-sm">{{ $t('msg.info.passwordRecovery.message_1') }}</div>
<div class="prose-sm mb-4">{{ $t('msg.info.passwordRecovery.message_2') }}</div>
</template>
<template v-else>
<p class="prose-sm text-success flex items-center leading-8 gap-2">
<div class="prose-sm text-success flex items-center leading-8 gap-2">
{{ $t('msg.info.passwordRecovery.success') }} <ClaritySuccessLine />
</p>
</div>
<nuxt-link to="/signin">{{ $t('general.signIn') }}</nuxt-link>
</template>
</div>
<Transition name="layout">
<div v-if="error" class="self-center mb-4 bg-red-500 text-white rounded-lg w-3/4 p-1">
<div class="flex items-center gap-2 justify-center"><MaterialSymbolsWarning /> {{ error }}</div>
<div v-if="error" class="self-center mb-4 bg-red-500 text-white rounded-lg w-3/4 mx-auto p-1">
<div class="flex items-center gap-2 justify-center">
<MaterialSymbolsWarning />
<div style="flex: 0 0 auto" class="break-words">{{ error }}</div>
</div>
</div>
</Transition>
@ -105,10 +105,11 @@ const resetError = () => {
<a-input v-model:value="form.email" size="large" :placeholder="$t('labels.email')" @focus="resetError" />
</a-form-item>
<div class="self-center flex flex-wrap gap-4 items-center mt-4 md:mx-8 md:justify-between justify-center w-full">
<div class="self-center flex flex-col gap-4 items-center justify-center w-full">
<button class="submit" type="submit">
<span class="flex items-center gap-2"><MdiLogin /> {{ $t('activity.sendEmail') }}</span>
</button>
<div class="text-end prose-sm">
{{ $t('msg.info.signUp.alreadyHaveAccount') }}
<nuxt-link to="/signin">{{ $t('general.signIn') }}</nuxt-link>
@ -128,7 +129,21 @@ const resetError = () => {
}
.submit {
@apply ml-1 border border-gray-300 rounded-lg p-4 bg-gray-100/50 text-white bg-primary hover:bg-primary/75 dark:(!bg-secondary/75 hover:!bg-secondary/50);
@apply z-1 relative color-transition border border-gray-300 rounded-md p-3 bg-gray-100/50 text-white bg-primary;
&::after {
@apply rounded-md absolute top-0 left-0 right-0 bottom-0 transition-all duration-150 ease-in-out bg-primary;
content: '';
z-index: -1;
}
&:hover::after {
@apply transform scale-110 ring ring-pink-500;
}
&:active::after {
@apply ring ring-pink-500;
}
}
}
</style>

96
packages/nc-gui-v2/pages/signin.vue

@ -1,14 +1,21 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import type { RuleObject } from 'ant-design-vue/es/form'
import { definePageMeta, useSidebar } from '#imports'
import { extractSdkResponseErrorMsg } from '~/utils/errorUtils'
import { navigateTo, useNuxtApp } from '#app'
import { isEmail } from '~/utils/validation'
import MdiLogin from '~icons/mdi/login'
import MaterialSymbolsWarning from '~icons/material-symbols/warning'
const { $api, $state } = $(useNuxtApp())
import {
definePageMeta,
extractSdkResponseErrorMsg,
isEmail,
navigateTo,
reactive,
ref,
useApi,
useGlobal,
useI18n,
useSidebar,
} from '#imports'
const { signIn: _signIn } = useGlobal()
const { api, isLoading } = useApi()
const { t } = useI18n()
@ -49,25 +56,25 @@ const formRules: Record<string, RuleObject[]> = {
],
}
const signIn = async () => {
const valid = formValidator.value.validate()
if (!valid) return
error = null
try {
const { token } = await $api.auth.signin(form)
$state.signIn(token!)
await navigateTo('/')
} catch (e: any) {
// todo: errors should not expose what was wrong (i.e. do not show "Password is wrong" messages)
error = await extractSdkResponseErrorMsg(e)
}
async function signIn() {
if (!formValidator.value.validate()) return
resetError()
api.auth
.signin(form)
.then(async ({ token }) => {
_signIn(token!)
await navigateTo('/')
})
.catch(async (err) => {
// todo: errors should not expose what was wrong (i.e. do not show "Password is wrong" messages)
error = await extractSdkResponseErrorMsg(err)
})
}
const resetError = () => {
if (error) {
error = null
}
function resetError() {
if (error) error = null
}
</script>
@ -77,20 +84,26 @@ const resetError = () => {
ref="formValidator"
:model="form"
layout="vertical"
class="signin h-[calc(100%_+_90px)] min-h-[600px] flex justify-center items-center nc-form-signin"
class="bg-primary/5 signin h-full min-h-[600px] flex justify-center items-center nc-form-signin"
@finish="signIn"
>
<div class="h-full w-full flex flex-col flex-wrap items-center pt-[100px]">
<div
class="bg-white dark:(!bg-gray-900 !text-white) relative flex flex-col justify-center gap-2 w-full max-w-[500px] mx-auto p-8 md:(rounded-lg border-1 border-gray-200 shadow-xl)"
>
<general-noco-icon />
<general-noco-icon
class="!rounded-full color-transition hover:(ring ring-pink-500)"
:class="[isLoading ? 'animated-bg-gradient' : '']"
/>
<h1 class="prose-2xl font-bold self-center my-4">{{ $t('general.signIn') }}</h1>
<Transition name="layout">
<div v-if="error" class="self-center mb-4 bg-red-500 text-white rounded-lg w-3/4 p-1">
<div class="flex items-center gap-2 justify-center"><MaterialSymbolsWarning /> {{ error }}</div>
<div v-if="error" class="self-center mb-4 bg-red-500 text-white rounded-lg w-3/4 mx-auto p-1">
<div class="flex items-center gap-2 justify-center">
<MaterialSymbolsWarning />
<div style="flex: 0 0 auto" class="break-words">{{ error }}</div>
</div>
</div>
</Transition>
@ -108,18 +121,17 @@ const resetError = () => {
/>
</a-form-item>
<div class="hidden md:block self-end mx-8">
<div class="hidden md:block self-end">
<nuxt-link class="prose-sm" to="/forgot-password">
{{ $t('msg.info.signUp.forgotPassword') }}
</nuxt-link>
</div>
<div
class="self-center flex flex-column flex-wrap gap-4 items-center mt-4 md:mx-8 md:justify-between justify-center w-full"
>
<div class="self-center flex flex-col flex-wrap gap-4 items-center mt-4 justify-center">
<button class="submit" type="submit">
<span class="flex items-center gap-2"><MdiLogin /> {{ $t('general.signIn') }}</span>
</button>
<div class="text-end prose-sm">
{{ $t('msg.info.signUp.dontHaveAccount') }}
<nuxt-link to="/signup">{{ $t('general.signUp') }}</nuxt-link>
@ -155,7 +167,21 @@ const resetError = () => {
}
.submit {
@apply ml-1 border border-gray-300 rounded-lg p-4 bg-gray-100/50 text-white bg-primary hover:bg-primary/75 dark:(!bg-secondary/75 hover:!bg-secondary/50);
@apply z-1 relative color-transition border border-gray-300 rounded-md p-3 bg-gray-100/50 text-white bg-primary;
&::after {
@apply rounded-md absolute top-0 left-0 right-0 bottom-0 transition-all duration-150 ease-in-out bg-primary;
content: '';
z-index: -1;
}
&:hover::after {
@apply transform scale-110 ring ring-pink-500;
}
&:active::after {
@apply ring ring-pink-500;
}
}
}
</style>

143
packages/nc-gui-v2/pages/signup.vue

@ -1,143 +0,0 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { extractSdkResponseErrorMsg } from '~/utils/errorUtils'
import { navigateTo } from '#app'
import { isEmail } from '~/utils/validation'
import MaterialSymbolsWarning from '~icons/material-symbols/warning'
import MaterialSymbolsRocketLaunchOutline from '~icons/material-symbols/rocket-launch-outline'
const { $api, $state } = useNuxtApp()
const { t } = useI18n()
definePageMeta({
requiresAuth: false,
})
const formValidator = ref()
let error = $ref<string | null>(null)
const form = reactive({
email: '',
password: '',
})
const formRules = {
email: [
// E-mail is required
{ required: true, message: t('msg.error.signUpRules.emailReqd') },
// E-mail must be valid format
{
validator: (_: unknown, v: string) => {
return new Promise((resolve, reject) => {
if (isEmail(v)) return resolve(true)
reject(new Error(t('msg.error.signUpRules.emailInvalid')))
})
},
message: t('msg.error.signUpRules.emailInvalid'),
},
],
password: [
// Password is required
{ required: true, message: t('msg.error.signUpRules.passwdRequired') },
{ min: 8, message: t('msg.error.signUpRules.passwdLength') },
],
}
const signUp = async () => {
const valid = formValidator.value.validate()
if (!valid) return
error = null
try {
const { token } = await $api.auth.signup(form)
$state.signIn(token!)
await navigateTo('/')
} catch (e: any) {
error = await extractSdkResponseErrorMsg(e)
}
}
const resetError = () => {
if (error) {
error = null
}
}
</script>
<template>
<NuxtLayout>
<a-form
ref="formValidator"
:model="form"
layout="vertical"
class="signup h-[calc(100%_+_90px)] min-h-[600px] flex justify-center items-center nc-form-signup"
@finish="signUp"
>
<div class="h-full w-full flex flex-col flex-wrap pt-[100px]">
<div
class="bg-white dark:(!bg-gray-900 !text-white) relative flex flex-col justify-center gap-2 w-full max-w-[500px] mx-auto p-8 md:(rounded-lg border-1 border-gray-200 shadow-xl)"
>
<general-noco-icon />
<h1 class="prose-2xl font-bold self-center my-4">{{ $t('general.signUp') }}</h1>
<Transition name="layout">
<div v-if="error" class="self-center mb-4 bg-red-500 text-white rounded-lg w-3/4 p-1">
<div class="flex items-center gap-2 justify-center"><MaterialSymbolsWarning /> {{ error }}</div>
</div>
</Transition>
<a-form-item :label="$t('labels.email')" name="email" :rules="formRules.email">
<a-input v-model:value="form.email" size="large" :placeholder="$t('labels.email')" @focus="resetError" />
</a-form-item>
<a-form-item :label="$t('labels.password')" name="password" :rules="formRules.password">
<a-input-password
v-model:value="form.password"
size="large"
class="password"
:placeholder="$t('labels.password')"
@focus="resetError"
/>
</a-form-item>
<div
class="self-center flex flex-column flex-wrap gap-4 items-center mt-4 md:mx-8 md:justify-between justify-center w-full"
>
<button class="submit" type="submit">
<span class="flex items-center gap-2"><MaterialSymbolsRocketLaunchOutline /> {{ $t('general.signUp') }}</span>
</button>
<div class="text-end prose-sm">
{{ $t('msg.info.signUp.alreadyHaveAccount') }}
<nuxt-link to="/signin">{{ $t('general.signIn') }}</nuxt-link>
</div>
</div>
</div>
</div>
</a-form>
</NuxtLayout>
</template>
<style lang="scss">
.signup {
.ant-input-affix-wrapper,
.ant-input {
@apply dark:(bg-gray-700 !text-white) !appearance-none my-1 border-1 border-solid border-primary/50 rounded;
}
.password {
input {
@apply !border-none;
}
.ant-input-password-icon {
@apply dark:!text-white;
}
}
.submit {
@apply ml-1 border border-gray-300 rounded-lg p-4 bg-gray-100/50 text-white bg-primary hover:bg-primary/75 dark:(!bg-secondary/75 hover:!bg-secondary/50);
}
}
</style>

206
packages/nc-gui-v2/pages/signup/[[token]].vue

@ -0,0 +1,206 @@
<script setup lang="ts">
import {
definePageMeta,
extractSdkResponseErrorMsg,
isEmail,
navigateTo,
reactive,
ref,
useApi,
useGlobal,
useI18n,
useRoute,
} from '#imports'
definePageMeta({
requiresAuth: false,
})
const route = useRoute()
const { appInfo, signIn } = useGlobal()
const { api, isLoading } = useApi()
const { t } = useI18n()
const formValidator = ref()
let error = $ref<string | null>(null)
const subscribe = ref(false)
const form = reactive({
email: '',
password: '',
})
const formRules = {
email: [
// E-mail is required
{ required: true, message: t('msg.error.signUpRules.emailReqd') },
// E-mail must be valid format
{
validator: (_: unknown, v: string) => {
return new Promise((resolve, reject) => {
if (isEmail(v)) return resolve(true)
reject(new Error(t('msg.error.signUpRules.emailInvalid')))
})
},
message: t('msg.error.signUpRules.emailInvalid'),
},
],
password: [
// Password is required
{ required: true, message: t('msg.error.signUpRules.passwdRequired') },
{ min: 8, message: t('msg.error.signUpRules.passwdLength') },
],
}
async function signUp() {
if (!formValidator.value.validate()) return
resetError()
const data: any = {
...form,
token: route.params.token,
}
if (subscribe.value) {
data.ignore_subscribe = !subscribe.value
}
api.auth
.signup(data)
.then(async ({ token }) => {
signIn(token!)
await navigateTo('/')
})
.catch(async (err) => {
error = await extractSdkResponseErrorMsg(err)
})
}
function resetError() {
if (error) error = null
}
</script>
<template>
<NuxtLayout>
<div class="bg-primary/5 signup h-full min-h-[600px] flex flex-col justify-center items-center nc-form-signup">
<div
class="bg-white dark:(!bg-gray-900 !text-white) mt-[60px] relative flex flex-col justify-center gap-2 w-full max-w-[500px] mx-auto p-8 md:(rounded-lg border-1 border-gray-200 shadow-xl)"
>
<general-noco-icon
class="color-transition hover:(ring ring-pink-500)"
:class="[isLoading ? 'animated-bg-gradient' : '']"
/>
<h1 class="prose-2xl font-bold self-center my-4">
{{ $t('general.signUp') }}
{{ $route.query.redirect_to === '/referral' ? '& REFER' : '' }}
{{ $route.query.redirect_to === '/pricing' ? '& BUY' : '' }}
</h1>
<h2 v-if="appInfo.firstUser" class="prose !text-primary font-semibold self-center my-4">
{{ $t('msg.info.signUp.superAdmin') }}
</h2>
<a-form ref="formValidator" :model="form" layout="vertical" no-style @finish="signUp">
<Transition name="layout">
<div v-if="error" class="self-center mb-4 bg-red-500 text-white rounded-lg w-3/4 mx-auto p-1">
<div class="flex items-center gap-2 justify-center">
<MaterialSymbolsWarning />
<div style="flex: 0 0 auto" class="break-words">{{ error }}</div>
</div>
</div>
</Transition>
<a-form-item :label="$t('labels.email')" name="email" :rules="formRules.email">
<a-input v-model:value="form.email" size="large" :placeholder="$t('labels.email')" @focus="resetError" />
</a-form-item>
<a-form-item :label="$t('labels.password')" name="password" :rules="formRules.password">
<a-input-password
v-model:value="form.password"
size="large"
class="password"
:placeholder="$t('labels.password')"
@focus="resetError"
/>
</a-form-item>
<div class="self-center flex flex-col flex-wrap gap-4 items-center mt-4">
<button class="submit" type="submit">
<span class="flex items-center gap-2">
<MaterialSymbolsRocketLaunchOutline />
{{ $t('general.signUp') }}
</span>
</button>
<div class="flex items-center gap-2">
<a-switch
v-model:checked="subscribe"
size="small"
class="my-1 hover:(ring ring-pink-500) focus:(!ring !ring-pink-500)"
/>
<div class="prose-xs text-gray-500">Subscribe to our weekly newsletter</div>
</div>
<div class="text-end prose-sm">
{{ $t('msg.info.signUp.alreadyHaveAccount') }}
<nuxt-link to="/signin">{{ $t('general.signIn') }}</nuxt-link>
</div>
</div>
</a-form>
</div>
<div class="prose-sm mt-4 text-gray-500">
By signing up, you agree to the
<a class="prose-sm text-pink-500 underline" target="_blank" href="https://nocodb.com/policy-nocodb">Terms of Service</a>
</div>
</div>
</NuxtLayout>
</template>
<style lang="scss">
.signup {
.ant-input-affix-wrapper,
.ant-input {
@apply dark:(bg-gray-700 !text-white) !appearance-none my-1 border-1 border-solid border-primary/50 rounded;
}
.password {
input {
@apply !border-none;
}
.ant-input-password-icon {
@apply dark:!text-white;
}
}
.submit {
@apply z-1 relative color-transition border border-gray-300 rounded-md p-3 bg-gray-100/50 text-white bg-primary;
&::after {
@apply rounded-md absolute top-0 left-0 right-0 bottom-0 transition-all duration-150 ease-in-out bg-primary;
content: '';
z-index: -1;
}
&:hover::after {
@apply transform scale-110 ring ring-pink-500;
}
&:active::after {
@apply ring ring-pink-500;
}
}
}
</style>
Loading…
Cancel
Save