Browse Source

feat(gui-v2): allow users to signup with invite url

pull/3109/head
braks 2 years ago
parent
commit
bdada366f5
  1. 95
      packages/nc-gui-v2/components/tabs/auth/UserManagement.vue
  2. 23
      packages/nc-gui-v2/layouts/base.vue
  3. 1
      packages/nc-gui-v2/lib/types.ts
  4. 71
      packages/nc-gui-v2/pages/forgot-password.vue
  5. 41
      packages/nc-gui-v2/pages/signin.vue
  6. 62
      packages/nc-gui-v2/pages/signup/[[token]].vue

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

@ -1,38 +1,49 @@
<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,
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,13 +51,13 @@ 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
@ -60,8 +71,9 @@ const inviteUser = async (user: User) => {
try {
if (!project.value?.id) return
await $api.auth.projectUserAdd(project.value.id, user)
message.success('Successfully added user to project')
await api.auth.projectUserAdd(project.value.id, user)
message.success('Successfully added user to project')
await loadUsers()
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
@ -74,9 +86,11 @@ const deleteUser = async () => {
try {
if (!project.value?.id || !selectedUser?.id) return
await $api.auth.projectUserRemove(project.value.id, selectedUser.id)
message.success('Successfully deleted user from project')
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 +120,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,10 +134,11 @@ const resendInvite = async (user: User) => {
const copyInviteUrl = (user: User) => {
if (!user.invite_token) return
const getInviteUrl = (token: string) => `${dashboardUrl}/user/authentication/signup/${token}`
const getInviteUrl = (token: string) => `${dashboardUrl}/signup/${token}`
copy(getInviteUrl(user.invite_token))
message.success('Invite url copied to clipboard')
message.success('Invite url copied to clipboard')
}
onMounted(async () => {
@ -166,7 +182,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 +190,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 +205,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 +219,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 +236,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 +246,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 +257,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 +266,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 +274,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>

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

@ -80,10 +80,7 @@ const logout = () => {
<a-tooltip>
<template #title> Switch language </template>
<div
v-if="!signedIn"
class="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)"
>
<div v-if="!signedIn" class="nc-lang-btn">
<GeneralLanguage />
</div>
</a-tooltip>
@ -103,4 +100,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>

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

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

71
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>
@ -77,27 +70,34 @@ const resetError = () => {
<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>

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

@ -56,26 +56,24 @@ 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)
_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 = () => {
function resetError() {
if (error) error = null
}
</script>
@ -94,7 +92,7 @@ const resetError = () => {
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
class="color-transition hover:(ring ring-pink-500)"
class="!rounded-full color-transition hover:(ring ring-pink-500)"
:class="[isLoading ? 'animated-bg-gradient' : '']"
/>
@ -102,7 +100,10 @@ const resetError = () => {
<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 /> {{ error }}</div>
<div class="flex items-center gap-2 justify-center">
<MaterialSymbolsWarning />
<div style="flex: 0 0 auto" class="break-words">{{ error }}</div>
</div>
</div>
</Transition>

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

@ -9,12 +9,15 @@ import {
useApi,
useGlobal,
useI18n,
useRoute,
} from '#imports'
definePageMeta({
requiresAuth: false,
})
const route = useRoute()
const { appInfo, signIn } = useGlobal()
const { api, isLoading } = useApi()
@ -22,8 +25,11 @@ 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: '',
@ -51,34 +57,42 @@ const formRules = {
],
}
const signUp = async () => {
const valid = formValidator.value.validate()
async function signUp() {
if (!formValidator.value.validate()) return
if (!valid) return
resetError()
error = null
const data: any = {
...form,
token: route.params.token,
}
try {
const { token } = await api.auth.signup(form)
if (subscribe.value) {
data.ignore_subscribe = !subscribe.value
}
signIn(token!)
api.auth
.signup(data)
.then(async ({ token }) => {
signIn(token!)
await navigateTo('/')
} catch (e: any) {
error = await extractSdkResponseErrorMsg(e)
}
await navigateTo('/')
})
.catch(async (err) => {
error = await extractSdkResponseErrorMsg(err)
})
}
const resetError = () => {
function resetError() {
if (error) error = null
}
</script>
<template>
<NuxtLayout>
<div class="signup h-full min-h-[600px] flex justify-center items-center nc-form-signup">
<div class="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) 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)"
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)"
@ -99,10 +113,8 @@ const resetError = () => {
<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">
<div class="w-[25px]">
<MaterialSymbolsWarning />
</div>
<div class="flex-auto break-words">{{ error }}</div>
<MaterialSymbolsWarning />
<div style="flex: 0 0 auto" class="break-words">{{ error }}</div>
</div>
</div>
</Transition>
@ -130,6 +142,15 @@ const resetError = () => {
</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') }}
@ -138,6 +159,11 @@ const resetError = () => {
</div>
</a-form>
</div>
<div class="prose-sm mt-4 text-gray-500">
By signing up, you agree to
<a class="prose-sm text-pink-500 underline" target="_blank" href="https://nocodb.com/policy-nocodb">Terms of Service</a>
</div>
</div>
</NuxtLayout>
</template>

Loading…
Cancel
Save