Team functionality (#459)
* add api enpoints for adding, removing, updating user to workspace and leaving workspace * feat: updates client site workspace settings * refactor and add domain setting ui in modal * move workspace user functionality to its own component * adds tests * fix linting * updates select input to FlatSelectInput * moves workspace user role edit to seperated component * move user adding to its own component * adds check to usure users exist before checking is admin * fix loading users * feat: invite user to team functionality * fix token coulmn * fix self host mode changes * tests for user invite * Refactor back-end * Rename variables * Improve some styling elements + refactor workspace settings * More styling * More UI polishing * More UI fixes * PHP linting * Implemented most of the logic for team-functionnality * Fix user avatar URL * WIP remove users on cancellation * Finished pricing for team functionality * Fix tests * Fix linting * Added pricing_enabled helper * Fix pricing_enabled shortcut * Debug CI * Disable pricing when testing --------- Co-authored-by: LL-Etiane <lukongleinyuyetiane@gmail.com> Co-authored-by: Lukong Etiane <83535251+LL-Etiane@users.noreply.github.com> Co-authored-by: Julien Nahum <julien@nahum.net>
This commit is contained in:
@@ -15,7 +15,7 @@
|
||||
>
|
||||
<div
|
||||
v-if="closeable"
|
||||
class="absolute top-4 right-4"
|
||||
class="absolute top-4 right-4 z-10"
|
||||
>
|
||||
<button
|
||||
class="text-gray-500 hover:text-gray-900 cursor-pointer"
|
||||
|
||||
@@ -23,19 +23,19 @@
|
||||
</div>
|
||||
<div
|
||||
v-if="showAuth"
|
||||
class="hidden md:block ml-auto relative"
|
||||
class="hidden md:flex gap-x-2 ml-auto"
|
||||
>
|
||||
<NuxtLink
|
||||
v-if="$route.name !== 'templates'"
|
||||
:to="{ name: 'templates' }"
|
||||
class="text-sm text-gray-600 dark:text-white hover:text-gray-800 cursor-pointer mt-1 mr-8"
|
||||
:class="navLinkClasses"
|
||||
>
|
||||
Templates
|
||||
</NuxtLink>
|
||||
<template v-if="featureBaseEnabled">
|
||||
<button
|
||||
v-if="user"
|
||||
class="text-sm text-gray-600 dark:text-white hidden sm:inline hover:text-gray-800 cursor-pointer mt-1 mr-8"
|
||||
:class="navLinkClasses"
|
||||
@click.prevent="openChangelog"
|
||||
>
|
||||
What's new? <span
|
||||
@@ -48,7 +48,7 @@
|
||||
v-else
|
||||
:href="opnformConfig.links.changelog_url"
|
||||
target="_blank"
|
||||
class="text-sm text-gray-600 dark:text-white hidden lg:inline hover:text-gray-800 cursor-pointer mt-1 mr-8"
|
||||
:class="navLinkClasses"
|
||||
>
|
||||
What's new?
|
||||
</a>
|
||||
@@ -56,7 +56,8 @@
|
||||
<NuxtLink
|
||||
v-if="$route.name !== 'ai-form-builder' && user === null"
|
||||
:to="{ name: 'ai-form-builder' }"
|
||||
class="text-sm text-gray-600 dark:text-white hidden lg:inline hover:text-gray-800 cursor-pointer mt-1 mr-8"
|
||||
:class="navLinkClasses"
|
||||
class="hidden lg:inline"
|
||||
>
|
||||
AI Form Builder
|
||||
</NuxtLink>
|
||||
@@ -67,15 +68,18 @@
|
||||
$route.name !== 'pricing'
|
||||
"
|
||||
:to="{ name: 'pricing' }"
|
||||
class="text-sm text-gray-600 dark:text-white hover:text-gray-800 cursor-pointer mt-1 mr-8"
|
||||
:class="navLinkClasses"
|
||||
>
|
||||
<span v-if="user">Upgrade</span>
|
||||
<span
|
||||
v-if="user"
|
||||
class="text-primary"
|
||||
>Upgrade</span>
|
||||
<span v-else>Pricing</span>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink
|
||||
:href="helpUrl"
|
||||
class="text-sm text-gray-600 dark:text-white hover:text-gray-800 cursor-pointer mt-1"
|
||||
:class="navLinkClasses"
|
||||
target="_blank"
|
||||
>
|
||||
Help
|
||||
@@ -90,7 +94,7 @@
|
||||
class="block"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<div class="ml-3 mr-4 relative">
|
||||
<div class="ml-4 relative">
|
||||
<div class="relative inline-block text-left">
|
||||
<dropdown
|
||||
v-if="user"
|
||||
@@ -100,7 +104,8 @@
|
||||
<button
|
||||
id="dropdown-menu-button"
|
||||
type="button"
|
||||
class="flex items-center justify-center w-full rounded-md px-4 py-2 text-sm text-gray-700 dark:text-gray-50 hover:bg-gray-50 dark:hover:bg-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-100 focus:ring-gray-500"
|
||||
:class="navLinkClasses"
|
||||
class="flex items-center"
|
||||
dusk="nav-dropdown-button"
|
||||
@click.stop="toggle()"
|
||||
>
|
||||
@@ -117,7 +122,7 @@
|
||||
<NuxtLink
|
||||
v-if="userOnboarded"
|
||||
:to="{ name: 'home' }"
|
||||
class="block block px-4 py-2 text-md text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600 flex items-center"
|
||||
class="block px-4 py-2 text-md text-gray-700 hover:bg-gray-100 hover:no-underline transition-colors hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600 flex items-center"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -139,7 +144,7 @@
|
||||
<NuxtLink
|
||||
v-if="userOnboarded"
|
||||
:to="{ name: 'templates-my-templates' }"
|
||||
class="block block px-4 py-2 text-md text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600 flex items-center"
|
||||
class="block px-4 py-2 text-md text-gray-700 hover:bg-gray-100 hover:no-underline transition-colors hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600 flex items-center"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -160,7 +165,7 @@
|
||||
|
||||
<NuxtLink
|
||||
:to="{ name: 'settings-profile' }"
|
||||
class="block block px-4 py-2 text-md text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600 flex items-center"
|
||||
class="block px-4 py-2 text-md text-gray-700 hover:bg-gray-100 hover:no-underline transition-colors hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600 flex items-center"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 mr-2"
|
||||
@@ -188,7 +193,7 @@
|
||||
<NuxtLink
|
||||
v-if="user.moderator"
|
||||
:to="{ name: 'settings-admin' }"
|
||||
class="block block px-4 py-2 text-md text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600 flex items-center"
|
||||
class="block px-4 py-2 text-md text-gray-700 hover:bg-gray-100 hover:no-underline transition-colors hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600 flex items-center"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -209,7 +214,7 @@
|
||||
|
||||
<a
|
||||
href="#"
|
||||
class="block block px-4 py-2 text-md text-gray-700 dark:text-white hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600 flex items-center"
|
||||
class="block px-4 py-2 text-md text-gray-700 hover:no-underline transition-colors hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600 flex items-center"
|
||||
@click.prevent="logout"
|
||||
>
|
||||
<svg
|
||||
@@ -236,7 +241,7 @@
|
||||
<NuxtLink
|
||||
v-if="$route.name !== 'login'"
|
||||
:to="{ name: 'login' }"
|
||||
class="text-gray-600 dark:text-white hover:text-gray-800 dark:hover:text-white px-0 sm:px-3 py-2 rounded-md text-sm"
|
||||
:class="navLinkClasses"
|
||||
active-class="text-gray-800 dark:text-white"
|
||||
>
|
||||
Login
|
||||
@@ -245,6 +250,7 @@
|
||||
<v-button
|
||||
v-track.nav_create_form_click
|
||||
size="small"
|
||||
class="shrink-0"
|
||||
:to="{ name: 'forms-create-guest' }"
|
||||
color="outline-blue"
|
||||
:arrow="true"
|
||||
@@ -290,6 +296,10 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
data: () => ({
|
||||
navLinkClasses: 'border border-transparent hover:border-gray-200 text-gray-500 hover:text-gray-800 hover:no-underline dark:hover:text-white py-2 px-3 hover:bg-gray-50 rounded-md text-sm font-medium transition-colors w-full md:w-auto text-center md:text-left'
|
||||
}),
|
||||
|
||||
computed: {
|
||||
helpUrl() {
|
||||
return this.opnformConfig.links.help_url
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<dropdown
|
||||
<Dropdown
|
||||
v-if="user && workspaces && workspaces.length > 1"
|
||||
ref="dropdown"
|
||||
dropdown-class="origin-top-left absolute left-0 mt-2 w-56 rounded-md shadow-lg bg-white dark:bg-gray-800 ring-1 ring-black ring-opacity-5 z-50"
|
||||
@@ -10,72 +10,47 @@
|
||||
#trigger="{ toggle }"
|
||||
>
|
||||
<div
|
||||
class="flex items-center cursor group"
|
||||
class="flex items-center cursor border border-transparent hover:border-gray-200 py-2 px-3 hover:bg-gray-50 rounded-md transition-colors"
|
||||
role="button"
|
||||
@click.stop="toggle()"
|
||||
>
|
||||
<div class="rounded-full h-8 8">
|
||||
<img
|
||||
v-if="isUrl(workspace.icon)"
|
||||
:src="workspace.icon"
|
||||
:alt="workspace.name + ' icon'"
|
||||
class="flex-shrink-0 h-8 w-8 rounded-full shadow"
|
||||
>
|
||||
<div
|
||||
v-else
|
||||
class="rounded-full pt-2 text-xs truncate bg-nt-blue-lighter h-8 w-8 text-center shadow"
|
||||
v-text="workspace.icon"
|
||||
/>
|
||||
</div>
|
||||
<WorkspaceIcon :workspace="workspace" />
|
||||
<p
|
||||
class="hidden group-hover:underline lg:block max-w-10 truncate ml-2 text-gray-800 dark:text-gray-200"
|
||||
class="hidden md:block max-w-10 truncate text-sm ml-2 text-gray-800 dark:text-gray-200"
|
||||
>
|
||||
{{ workspace.name }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template
|
||||
v-for="worksp in workspaces"
|
||||
:key="worksp.id"
|
||||
>
|
||||
<div class="px-1">
|
||||
<a
|
||||
v-for="worksp in workspaces"
|
||||
:key="worksp.id"
|
||||
href="#"
|
||||
class="px-4 py-2 text-md text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600 flex items-center"
|
||||
class="px-4 py-2 text-md rounded text-gray-700 hover:no-underline hover:bg-neutral-50 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600 flex items-center"
|
||||
:class="{
|
||||
'bg-blue-100 dark:bg-blue-900': workspace?.id === worksp?.id,
|
||||
'bg-blue-100 dark:bg-blue-900 hover:bg-blue-200':
|
||||
workspace?.id === worksp?.id,
|
||||
}"
|
||||
@click.prevent="switchWorkspace(worksp)"
|
||||
>
|
||||
<div
|
||||
class="rounded-full h-8 w-8 flex-shrink-0"
|
||||
role="button"
|
||||
>
|
||||
<img
|
||||
v-if="isUrl(worksp.icon)"
|
||||
:src="worksp.icon"
|
||||
:alt="worksp.name + ' icon'"
|
||||
class="flex-shrink-0 h-8 w-8 rounded-full shadow"
|
||||
>
|
||||
<div
|
||||
v-else
|
||||
class="rounded-full flex-shrink-0 pt-1 text-xs truncate bg-nt-blue-lighter h-8 w-8 text-center shadow"
|
||||
v-text="worksp.icon"
|
||||
/>
|
||||
</div>
|
||||
<p class="ml-4 truncate">{{ worksp.name }}</p>
|
||||
<WorkspaceIcon :workspace="worksp" />
|
||||
<p class="ml-4 truncate text-sm">{{ worksp.name }}</p>
|
||||
</a>
|
||||
</template>
|
||||
</dropdown>
|
||||
</div>
|
||||
</Dropdown>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { computed } from "vue"
|
||||
import Dropdown from "~/components/global/Dropdown.vue"
|
||||
import WorkspaceIcon from "~/components/workspaces/WorkspaceIcon.vue"
|
||||
|
||||
export default {
|
||||
name: "WorkspaceDropdown",
|
||||
components: {
|
||||
WorkspaceIcon,
|
||||
Dropdown,
|
||||
},
|
||||
|
||||
@@ -114,14 +89,6 @@ export default {
|
||||
}
|
||||
this.formsStore.loadAll(workspace.id)
|
||||
},
|
||||
isUrl(str) {
|
||||
try {
|
||||
new URL(str)
|
||||
} catch (_) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -20,25 +20,13 @@
|
||||
</svg>
|
||||
</template>
|
||||
<template #title>
|
||||
Change form workspace
|
||||
Change form's workspace
|
||||
</template>
|
||||
<div class="p-4">
|
||||
<div class="flex space-x-4 items-center">
|
||||
<p>Current workspace:</p>
|
||||
<div class="flex items-center cursor group p-2 rounded border">
|
||||
<div class="rounded-full h-8 8">
|
||||
<img
|
||||
v-if="isUrl(workspace.icon)"
|
||||
:src="workspace.icon"
|
||||
:alt="workspace.name + ' icon'"
|
||||
class="flex-shrink-0 h-8 w-8 rounded-full shadow"
|
||||
>
|
||||
<div
|
||||
v-else
|
||||
class="rounded-full pt-2 text-xs truncate bg-nt-blue-lighter h-8 w-8 text-center shadow"
|
||||
v-text="workspace.icon"
|
||||
/>
|
||||
</div>
|
||||
<WorkspaceIcon :workspace="workspace" />
|
||||
<p
|
||||
class="lg:block max-w-10 truncate ml-2 text-gray-800 dark:text-gray-200"
|
||||
>
|
||||
@@ -54,7 +42,7 @@
|
||||
class=""
|
||||
:options="workspacesSelectOptions"
|
||||
:required="true"
|
||||
label="Select workspace"
|
||||
label="Select destination workspace"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-end mt-4 pb-5">
|
||||
@@ -78,6 +66,7 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, defineProps, defineEmits, computed } from "vue"
|
||||
import WorkspaceIcon from "~/components/workspaces/WorkspaceIcon.vue"
|
||||
const emit = defineEmits(["close"])
|
||||
const workspacesStore = useWorkspacesStore()
|
||||
const formsStore = useFormsStore()
|
||||
|
||||
87
client/components/pages/admin/AddUserToWorkspace.vue
Normal file
87
client/components/pages/admin/AddUserToWorkspace.vue
Normal file
@@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<form
|
||||
v-if="isWorkspaceAdmin"
|
||||
class="my-2"
|
||||
@submit.prevent="addUser"
|
||||
>
|
||||
<text-input
|
||||
v-model="newUser"
|
||||
name="email"
|
||||
label="Email"
|
||||
:required="true"
|
||||
:disabled="disabled"
|
||||
placeholder="Add a new user by email"
|
||||
/>
|
||||
<select-input
|
||||
v-model="newUserRole"
|
||||
name="newUserRole"
|
||||
:options="roleOptions"
|
||||
:disabled="disabled"
|
||||
placeholder="Select User Role"
|
||||
label="Role"
|
||||
:required="true"
|
||||
/>
|
||||
<div class="flex justify-center mt-2">
|
||||
<UButton
|
||||
type="submit"
|
||||
:disabled="disabled"
|
||||
:loading="addingUsersState"
|
||||
icon="i-heroicons-envelope"
|
||||
>
|
||||
Invite User
|
||||
</UButton>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { watch, ref } from "vue"
|
||||
|
||||
const props = defineProps({
|
||||
isWorkspaceAdmin: {},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
const emit = defineEmits(['fetchUsers'])
|
||||
|
||||
const workspacesStore = useWorkspacesStore()
|
||||
|
||||
const roleOptions = [
|
||||
{ name: "User", value: "user" },
|
||||
{ name: "Admin", value: "admin" }
|
||||
]
|
||||
|
||||
const newUser = ref("")
|
||||
const newUserRole = ref("user")
|
||||
const addingUsersState = ref(false)
|
||||
|
||||
|
||||
const addUser = () => {
|
||||
if (!newUser.value) return
|
||||
addingUsersState.value = true
|
||||
opnFetch(
|
||||
"/open/workspaces/" + workspacesStore.currentId + "/users/add",
|
||||
{
|
||||
method: "POST",
|
||||
body: {
|
||||
email: newUser.value,
|
||||
role: newUserRole.value,
|
||||
},
|
||||
}
|
||||
).then((data) => {
|
||||
newUser.value = ""
|
||||
newUserRole.value = "user"
|
||||
|
||||
useAlert().success(data.message)
|
||||
|
||||
emit("fetchUsers")
|
||||
}).catch((error) => {
|
||||
useAlert().error("There was an error adding user")
|
||||
}).finally(() => {
|
||||
addingUsersState.value = false
|
||||
})
|
||||
}
|
||||
|
||||
</script>
|
||||
82
client/components/pages/admin/EditWorkSpaceUser.vue
Normal file
82
client/components/pages/admin/EditWorkSpaceUser.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<modal
|
||||
:show="showEditUserModal"
|
||||
max-width="lg"
|
||||
@close="$emit('close')"
|
||||
>
|
||||
<template #title>
|
||||
Edit User Role
|
||||
</template>
|
||||
<div class="px-4">
|
||||
<form
|
||||
@submit.prevent="updateUserRole"
|
||||
>
|
||||
<div>
|
||||
<FlatSelectInput
|
||||
v-model="userNewRole"
|
||||
name="newUserRole"
|
||||
:label="'New Role for '+props.user.name"
|
||||
:options="[
|
||||
{ name: 'User', value: 'user' },
|
||||
{ name: 'Admin', value: 'admin' }
|
||||
]"
|
||||
option-key="value"
|
||||
display-key="name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="w-full mt-6">
|
||||
<v-button
|
||||
:loading="updatingUserRoleState"
|
||||
class="w-full my-3"
|
||||
>
|
||||
Update
|
||||
</v-button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {watch, ref} from "vue"
|
||||
|
||||
const props = defineProps(['user', 'showEditUserModal'])
|
||||
const emit = defineEmits(['close', 'fetchUsers'])
|
||||
|
||||
const workspacesStore = useWorkspacesStore()
|
||||
const userNewRole = ref("")
|
||||
|
||||
const updatingUserRoleState = ref(false)
|
||||
|
||||
watch(() => props.user, () => {
|
||||
userNewRole.value = props.user.pivot.role
|
||||
})
|
||||
|
||||
const updateUserRole = () => {
|
||||
updatingUserRoleState.value = true
|
||||
opnFetch(
|
||||
"/open/workspaces/" +
|
||||
workspacesStore.currentId +
|
||||
"/users/" +
|
||||
props.user.id +
|
||||
"/update-role",
|
||||
{
|
||||
method: "PUT",
|
||||
body: {
|
||||
role: userNewRole.value,
|
||||
},
|
||||
},
|
||||
{showSuccess: false},
|
||||
).then(() => {
|
||||
useAlert().success("User role updated.")
|
||||
emit('fetchUsers')
|
||||
emit('close')
|
||||
}).catch((error) => {
|
||||
useAlert().error("There was an error updating user role")
|
||||
}).finally(() => {
|
||||
updatingUserRoleState.value = false
|
||||
})
|
||||
}
|
||||
|
||||
</script>
|
||||
@@ -20,6 +20,7 @@
|
||||
:form="form"
|
||||
label="Email"
|
||||
:required="true"
|
||||
:disabled="disableEmail"
|
||||
placeholder="Your email address"
|
||||
/>
|
||||
|
||||
@@ -135,6 +136,7 @@ export default {
|
||||
agree_terms: false,
|
||||
appsumo_license: null,
|
||||
}),
|
||||
disableEmail:false
|
||||
}),
|
||||
|
||||
computed: {
|
||||
@@ -167,12 +169,26 @@ export default {
|
||||
) {
|
||||
this.form.appsumo_license = this.$route.query.appsumo_license
|
||||
}
|
||||
|
||||
if (this.$route.query?.invite_token) {
|
||||
if (this.$route.query?.email) {
|
||||
this.form.email = this.$route.query?.email
|
||||
this.disableEmail = true
|
||||
}
|
||||
this.form.invite_token = this.$route.query?.invite_token
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
async register() {
|
||||
// Register the user.
|
||||
const data = await this.form.post("/register")
|
||||
let data
|
||||
try {
|
||||
// Register the user.
|
||||
data = await this.form.post("/register")
|
||||
} catch (err) {
|
||||
useAlert().error(err.response?._data?.message)
|
||||
return false
|
||||
}
|
||||
|
||||
// Log in the user.
|
||||
const tokenData = await this.form.post("/login")
|
||||
@@ -216,7 +232,13 @@ export default {
|
||||
if (this.isQuick) {
|
||||
this.$emit("afterQuickLogin")
|
||||
} else {
|
||||
this.$router.push({name: "forms-create"})
|
||||
// If is invite just redirect to home
|
||||
if (this.form.invite_token) {
|
||||
useAlert().success("You have successfully accepted the invite and joined this workspace.")
|
||||
this.$router.push({name: "home"})
|
||||
} else {
|
||||
this.$router.push({name: "forms-create"})
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,31 +1,31 @@
|
||||
<template>
|
||||
<div class="border relative max-w-5xl mx-auto mt-4 lg:mt-10">
|
||||
<div class="w-full">
|
||||
<div
|
||||
class="rounded-lg bg-gray-50 dark:bg-gray-800 px-6 py-8 sm:p-10 lg:flex lg:items-center"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<h3
|
||||
class="inline-flex px-4 py-1 rounded-full text-md font-bold tracking-wide uppercase bg-white text-gray-800"
|
||||
>
|
||||
Custom plan
|
||||
</h3>
|
||||
<div class="mt-4 text-md text-gray-600 dark:text-gray-400">
|
||||
Get a custom file upload limit, enterprise-level support, custom
|
||||
contract, dedicated application instance in a specific region,
|
||||
payment via invoice/PO etc.
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 rounded-md lg:mt-0 lg:ml-10 lg:flex-shrink-0">
|
||||
<v-button
|
||||
color="white"
|
||||
class="w-full mt-4"
|
||||
@click.prevent="customPlanClick"
|
||||
>
|
||||
Contact us
|
||||
</v-button>
|
||||
<div class="border lg:rounded-xl bg-gray-50 dark:bg-gray-800 relative max-w-5xl mx-auto mt-10">
|
||||
<div
|
||||
class=" p-6 lg:flex lg:items-center"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<h3
|
||||
class="inline-flex px-4 py-1 rounded-full text-md font-semibold tracking-wide bg-blue-500 text-white"
|
||||
>
|
||||
Custom Plan
|
||||
</h3>
|
||||
<div class="mt-4 text-gray-600 dark:text-gray-400 max-w-2xl">
|
||||
Get a custom file upload limit, enterprise-level support, custom
|
||||
contract, dedicated application instance in a specific region,
|
||||
payment via invoice/PO etc.
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 lg:mt-0 lg:ml-10 lg:flex-shrink-0">
|
||||
<UButton
|
||||
size="xl"
|
||||
color="white"
|
||||
class="w-auto"
|
||||
icon="i-heroicons-chat-bubble-left"
|
||||
@click.prevent="customPlanClick"
|
||||
>
|
||||
Contact us
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -97,7 +97,10 @@
|
||||
</svg>
|
||||
{{ title }}
|
||||
</li>
|
||||
<slot name="pricing-table" />
|
||||
<slot
|
||||
name="pricing-table"
|
||||
:is-yearly="isYearly"
|
||||
/>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
||||
132
client/components/pages/settings/WorkSpaceCustomDomains.vue
Normal file
132
client/components/pages/settings/WorkSpaceCustomDomains.vue
Normal file
@@ -0,0 +1,132 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="customDomainsEnabled"
|
||||
id="custom-domains"
|
||||
>
|
||||
<UButton
|
||||
color="gray"
|
||||
label="Manage Custom Domains"
|
||||
icon="i-heroicons-globe-alt"
|
||||
@click="showCustomDomainModal = !showCustomDomainModal"
|
||||
/>
|
||||
|
||||
<modal
|
||||
:show="showCustomDomainModal"
|
||||
max-width="lg"
|
||||
@close="showCustomDomainModal = false"
|
||||
>
|
||||
<h4 class="mb-4 font-medium">
|
||||
Manage your custom domains
|
||||
</h4>
|
||||
<UAlert
|
||||
v-if="!workspace.is_pro"
|
||||
icon="i-heroicons-user-group-20-solid"
|
||||
class="mb-4"
|
||||
color="orange"
|
||||
variant="subtle"
|
||||
title="Pro plan required"
|
||||
>
|
||||
<template #description>
|
||||
Please <NuxtLink
|
||||
:to="{name:'pricing'}"
|
||||
class="underline"
|
||||
>
|
||||
upgrade your account
|
||||
</NuxtLink> to setup a custom domain.
|
||||
</template>
|
||||
</UAlert>
|
||||
<p class="text-gray-500 text-sm mb-4">
|
||||
Read
|
||||
<a
|
||||
href="#"
|
||||
class="underline"
|
||||
@click.prevent="
|
||||
crisp.openHelpdeskArticle('how-to-use-my-own-domain-9m77g7')
|
||||
"
|
||||
>our instructions</a>
|
||||
to learn how to setup your own domain.
|
||||
</p>
|
||||
<text-area-input
|
||||
:form="customDomainsForm"
|
||||
name="custom_domains"
|
||||
:required="false"
|
||||
:disabled="!workspace.is_pro"
|
||||
label="Workspace Custom Domains"
|
||||
wrapper-class=""
|
||||
placeholder="yourdomain.com - 1 per line"
|
||||
/>
|
||||
<UButton
|
||||
class="mt-3"
|
||||
:loading="customDomainsLoading"
|
||||
:disabled="!workspace.is_pro"
|
||||
icon="i-heroicons-check"
|
||||
@click="saveChanges"
|
||||
>
|
||||
Save Domain(s)
|
||||
</UButton>
|
||||
</modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {watch} from "vue"
|
||||
|
||||
const crisp = useCrisp()
|
||||
const workspacesStore = useWorkspacesStore()
|
||||
const workspace = computed(() => workspacesStore.getCurrent)
|
||||
const loading = computed(() => workspacesStore.loading)
|
||||
|
||||
const customDomainsForm = useForm({
|
||||
custom_domain: "",
|
||||
})
|
||||
const customDomainsLoading = ref(false)
|
||||
const showCustomDomainModal = ref(false)
|
||||
|
||||
const customDomainsEnabled = computed(
|
||||
() => useRuntimeConfig().public.customDomainsEnabled,
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
initCustomDomains()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => workspace,
|
||||
() => {
|
||||
initCustomDomains()
|
||||
},
|
||||
)
|
||||
|
||||
const saveChanges = () => {
|
||||
if (customDomainsLoading.value) return
|
||||
customDomainsLoading.value = true
|
||||
|
||||
// Update the workspace custom domain
|
||||
customDomainsForm
|
||||
.put("/open/workspaces/" + workspace.value.id + "/custom-domains", {
|
||||
data: {
|
||||
custom_domains: customDomainsForm?.custom_domains
|
||||
?.split("\n")
|
||||
.map((domain) => (domain ? domain.trim() : null))
|
||||
.filter((domain) => domain && domain.length > 0),
|
||||
},
|
||||
})
|
||||
.then((data) => {
|
||||
workspacesStore.save(data)
|
||||
useAlert().success("Custom domains saved.")
|
||||
})
|
||||
.catch((error) => {
|
||||
useAlert().error(
|
||||
"Failed to update custom domains: " + error.response.data.message,
|
||||
)
|
||||
})
|
||||
.finally(() => {
|
||||
customDomainsLoading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
const initCustomDomains = () => {
|
||||
if (!workspace || !workspace.value.custom_domains) return
|
||||
customDomainsForm.custom_domains = workspace.value?.custom_domains.join("\n")
|
||||
}
|
||||
</script>
|
||||
362
client/components/pages/settings/WorkSpaceUser.vue
Normal file
362
client/components/pages/settings/WorkSpaceUser.vue
Normal file
@@ -0,0 +1,362 @@
|
||||
<template>
|
||||
<div class="border rounded-md p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h4 class="font-semibold">
|
||||
Workspace Members
|
||||
</h4>
|
||||
<UButton
|
||||
label="Invite User"
|
||||
icon="i-heroicons-user-plus-20-solid"
|
||||
:loading="loading"
|
||||
@click="userInviteModal = true"
|
||||
/>
|
||||
</div>
|
||||
<!-- User invite modal -->
|
||||
<modal
|
||||
:show="userInviteModal"
|
||||
max-width="lg"
|
||||
@close="userInviteModal = false"
|
||||
>
|
||||
<h4 class="mb-4 font-medium">
|
||||
Invite a new user and collaborate on building forms
|
||||
</h4>
|
||||
|
||||
<template v-if="paidPlansEnabled">
|
||||
<UAlert
|
||||
v-if="workspace.is_pro"
|
||||
icon="i-heroicons-credit-card"
|
||||
color="primary"
|
||||
variant="subtle"
|
||||
title="This is a billable event."
|
||||
>
|
||||
<template #description>
|
||||
You will be charged $6/month for each user you invite to this workspace. More details on the
|
||||
<NuxtLink
|
||||
target="_blank"
|
||||
class="underline"
|
||||
:to="{name:'settings-billing'}"
|
||||
>
|
||||
billing
|
||||
</NuxtLink>
|
||||
and
|
||||
<NuxtLink
|
||||
target="_blank"
|
||||
class="underline"
|
||||
:to="{name:'pricing'}"
|
||||
>
|
||||
pricing
|
||||
</NuxtLink>
|
||||
page.
|
||||
</template>
|
||||
</UAlert>
|
||||
<UAlert
|
||||
v-else
|
||||
icon="i-heroicons-user-group-20-solid"
|
||||
color="orange"
|
||||
variant="subtle"
|
||||
title="Pro plan required"
|
||||
>
|
||||
<template #description>
|
||||
You need a Pro plan to invite new users on OpnForm. Please upgrade on our
|
||||
<NuxtLink
|
||||
target="_blank"
|
||||
class="underline"
|
||||
:to="{name:'pricing'}"
|
||||
>
|
||||
pricing
|
||||
</NuxtLink>
|
||||
page.
|
||||
</template>
|
||||
</UAlert>
|
||||
</template>
|
||||
|
||||
<AddUserToWorkspace
|
||||
:disabled="!canInviteUser"
|
||||
:is-workspace-admin="isWorkspaceAdmin"
|
||||
@fetch-users="getWorkspaceUsers"
|
||||
/>
|
||||
</modal>
|
||||
<UTable
|
||||
class="-mx-4 border-y mt-4"
|
||||
:loading="loadingUsers"
|
||||
:rows="users"
|
||||
:columns="columns"
|
||||
>
|
||||
<template
|
||||
v-if="isWorkspaceAdmin"
|
||||
#actions-data="{ row, index }"
|
||||
>
|
||||
<div class="space-x-2 flex justify-center">
|
||||
<template v-if="row.type == 'user'">
|
||||
<p
|
||||
v-if="row.is_current_user"
|
||||
class="text-gray-500 text-center text-sm"
|
||||
>
|
||||
-
|
||||
</p>
|
||||
<UButtonGroup
|
||||
v-else
|
||||
size="2xs"
|
||||
>
|
||||
<UTooltip
|
||||
text="Edit user"
|
||||
>
|
||||
<UButton
|
||||
icon="i-heroicons-pencil"
|
||||
color="gray"
|
||||
class="hover:text-blue-500"
|
||||
square
|
||||
@click="editUser(index)"
|
||||
/>
|
||||
</UTooltip>
|
||||
<UTooltip
|
||||
text="Remove user"
|
||||
>
|
||||
<UButton
|
||||
v-if="row.type == 'user'"
|
||||
icon="i-heroicons-trash"
|
||||
color="gray"
|
||||
class="hover:text-red-500"
|
||||
square
|
||||
@click="removeUser(index)"
|
||||
/>
|
||||
</UTooltip>
|
||||
</UButtonGroup>
|
||||
</template>
|
||||
<UButtonGroup
|
||||
v-else-if="row.type == 'invitee'"
|
||||
size="2xs"
|
||||
>
|
||||
<UTooltip
|
||||
text="Resend Invite"
|
||||
>
|
||||
<UButton
|
||||
icon="i-heroicons-envelope"
|
||||
color="gray"
|
||||
class="hover:text-blue-500"
|
||||
square
|
||||
@click="resendInvite(index)"
|
||||
/>
|
||||
</UTooltip>
|
||||
<UTooltip
|
||||
text="Cancel Invite"
|
||||
>
|
||||
<UButton
|
||||
icon="i-heroicons-trash"
|
||||
color="gray"
|
||||
class="hover:text-red-500"
|
||||
square
|
||||
@click="cancelInvite(index)"
|
||||
/>
|
||||
</UTooltip>
|
||||
</UButtonGroup>
|
||||
</div>
|
||||
</template>
|
||||
</UTable>
|
||||
|
||||
<EditWorkSpaceUser
|
||||
:user="selectedUser"
|
||||
:show-edit-user-modal="showEditUserModal"
|
||||
@close="showEditUserModal = false"
|
||||
@fetch-users="getWorkspaceUsers"
|
||||
/>
|
||||
|
||||
<div class="flex gap-2 mt-4">
|
||||
<UButton
|
||||
v-if="users.length > 1"
|
||||
color="gray"
|
||||
icon="i-heroicons-arrow-left-start-on-rectangle-20-solid"
|
||||
:loading="leaveWorkspaceLoadingState"
|
||||
@click="leaveWorkSpace(workspace.id)"
|
||||
>
|
||||
Leave Workspace
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
v-if="isWorkspaceAdmin && users.length == 1"
|
||||
icon="i-heroicons-trash"
|
||||
color="gray"
|
||||
:loading="loading"
|
||||
@click="deleteWorkspace(workspace.id)"
|
||||
>
|
||||
Remove workspace
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const workspacesStore = useWorkspacesStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const workspace = computed(() => workspacesStore.getCurrent)
|
||||
const loading = computed(() => workspacesStore.loading)
|
||||
const workspaces = computed(() => workspacesStore.getAll)
|
||||
|
||||
const users = ref([])
|
||||
const loadingUsers = ref(true)
|
||||
const leaveWorkspaceLoadingState = ref(false)
|
||||
|
||||
const userInviteModal = ref(false)
|
||||
const showEditUserModal = ref(false)
|
||||
const selectedUser = ref(null)
|
||||
const userNewRole = ref("")
|
||||
|
||||
const paidPlansEnabled = computed(() => useRuntimeConfig().public.paidPlansEnabled)
|
||||
const canInviteUser = computed(() => {
|
||||
return paidPlansEnabled.value ? workspace.value.is_pro : true
|
||||
})
|
||||
|
||||
|
||||
onMounted(() => {
|
||||
getWorkspaceUsers()
|
||||
})
|
||||
|
||||
const getWorkspaceUsers = async () => {
|
||||
userInviteModal.value = false
|
||||
loadingUsers.value = true
|
||||
let data = await workspacesStore.getWorkspaceUsers()
|
||||
data = data.map(d => {
|
||||
return {
|
||||
...d,
|
||||
id: d.id,
|
||||
is_current_user: d.id === authStore.user.id,
|
||||
name: d.name,
|
||||
email: d.email,
|
||||
status: 'accepted',
|
||||
role: d.pivot.role,
|
||||
type: 'user'
|
||||
}
|
||||
})
|
||||
let invites = await workspacesStore.getWorkspaceInvites()
|
||||
invites = invites.filter(i => i.status !== 'accepted').map(i => {
|
||||
return {
|
||||
...i,
|
||||
name: 'Invitee',
|
||||
email: i.email,
|
||||
status: i.status,
|
||||
type: 'invitee'
|
||||
}
|
||||
})
|
||||
users.value = [...data, ...invites]
|
||||
loadingUsers.value = false
|
||||
}
|
||||
|
||||
const isWorkspaceAdmin = computed(() => {
|
||||
if (!users.value) return false
|
||||
const user = users.value.find((user) => user.id === authStore.user.id)
|
||||
return user && user.pivot.role === "admin"
|
||||
})
|
||||
|
||||
const columns = computed(() => {
|
||||
return [
|
||||
{key: 'name', label: 'Name'},
|
||||
{key: 'email', label: 'Email'},
|
||||
{key: 'role', label: 'Role'},
|
||||
...(isWorkspaceAdmin.value ? [{key: 'actions', label: 'Action', class: 'text-center'}] : [])
|
||||
]
|
||||
})
|
||||
|
||||
|
||||
const editUser = (row) => {
|
||||
selectedUser.value = users.value[row]
|
||||
userNewRole.value = selectedUser.value.pivot.role
|
||||
showEditUserModal.value = true
|
||||
}
|
||||
|
||||
|
||||
const removeUser = (index) => {
|
||||
const user = users.value[index]
|
||||
useAlert().confirm(
|
||||
"Do you really want to remove " + user.name + " from this workspace?",
|
||||
() => {
|
||||
loadingUsers.value = true
|
||||
opnFetch(
|
||||
"/open/workspaces/" + workspacesStore.currentId + "/users/" + user.id + "/remove",
|
||||
{
|
||||
method: "DELETE",
|
||||
},
|
||||
{showSuccess: false},
|
||||
).then(() => {
|
||||
useAlert().success("User successfully removed.")
|
||||
getWorkspaceUsers()
|
||||
}).catch((error) => {
|
||||
useAlert().error("There was an error removing user")
|
||||
}).finally(() => {
|
||||
loadingUsers.value = false
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const deleteWorkspace = (workspaceId) => {
|
||||
if (workspaces.length <= 1) {
|
||||
useAlert().error("You cannot delete your only workspace.")
|
||||
return
|
||||
}
|
||||
useAlert().confirm(
|
||||
"Do you really want to delete this workspace? All forms created in this workspace will be removed.",
|
||||
() => {
|
||||
opnFetch("/open/workspaces/" + workspaceId, {method: "DELETE"}).then(
|
||||
() => {
|
||||
useAlert().success("Workspace successfully removed.")
|
||||
workspacesStore.remove(workspaceId)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const leaveWorkSpace = (workspaceId) => {
|
||||
useAlert().confirm(
|
||||
"Do you really want to leave this workspace? You will lose access to all forms in this workspace.",
|
||||
() => {
|
||||
leaveWorkspaceLoadingState.value = true
|
||||
opnFetch("/open/workspaces/" + workspaceId + "/leave", {
|
||||
method: "POST",
|
||||
}).then(() => {
|
||||
useAlert().success("You have left the workspace.")
|
||||
workspacesStore.remove(workspaceId)
|
||||
getWorkspaceUsers()
|
||||
}).catch((error) => {
|
||||
useAlert().error("There was an error leaving the workspace.")
|
||||
}).finally(() => {
|
||||
leaveWorkspaceLoadingState.value = false
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const resendInvite = (id) => {
|
||||
const inviteId = users.value[id].id
|
||||
useAlert().confirm(
|
||||
"Do you really want to resend invite email to this user?",
|
||||
() => {
|
||||
opnFetch("/open/workspaces/" + workspace.value.id + "/invites/" + inviteId + "/resend", {method: "POST"}).then(
|
||||
() => {
|
||||
useAlert().success("Invitation resent successfully.")
|
||||
getWorkspaceUsers()
|
||||
},
|
||||
).catch(err => {
|
||||
useAlert().error(err.response._data?.message)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const cancelInvite = (id) => {
|
||||
const inviteId = users.value[id].id
|
||||
useAlert().confirm(
|
||||
"Do you really want to cancel this user's invitation to this workspace?",
|
||||
() => {
|
||||
opnFetch("/open/workspaces/" + workspace.value.id + "/invites/" + inviteId + "/cancel", {method: "DELETE"}).then(
|
||||
() => {
|
||||
useAlert().success("Invitation cancelled successfully.")
|
||||
getWorkspaceUsers()
|
||||
},
|
||||
).catch(err => {
|
||||
useAlert().error(err.response._data?.message)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
</script>
|
||||
@@ -34,6 +34,10 @@
|
||||
File Size Uploads:
|
||||
<span class="font-semibold">{{ tierFeatures.file_upload_size }}</span>
|
||||
</li>
|
||||
<li>
|
||||
Users limit:
|
||||
<span class="font-semibold">{{ tierFeatures.users }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="w-max">
|
||||
<v-button
|
||||
@@ -43,7 +47,7 @@
|
||||
href="https://appsumo.com/account/products/"
|
||||
target="_blank"
|
||||
>
|
||||
Mangage in AppSumo
|
||||
Manage in AppSumo
|
||||
</v-button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -80,16 +84,19 @@ export default {
|
||||
form_quantity: "Unlimited",
|
||||
file_upload_size: "25mb",
|
||||
domain_names: "5",
|
||||
users: 1
|
||||
},
|
||||
2: {
|
||||
form_quantity: "Unlimited",
|
||||
file_upload_size: "50mb",
|
||||
domain_names: "25",
|
||||
users: 5
|
||||
},
|
||||
3: {
|
||||
form_quantity: "Unlimited",
|
||||
file_upload_size: "75mb",
|
||||
domain_names: "Unlimited",
|
||||
users: 'Unlimited'
|
||||
},
|
||||
}[this.licenseTier]
|
||||
},
|
||||
|
||||
47
client/components/workspaces/WorkspaceIcon.vue
Normal file
47
client/components/workspaces/WorkspaceIcon.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<img
|
||||
v-if="isUrl(workspace.icon)"
|
||||
:src="workspace.icon"
|
||||
:alt="`${workspace.name} icon`"
|
||||
class="flex-shrink-0 rounded"
|
||||
:class="size"
|
||||
>
|
||||
<div
|
||||
v-else
|
||||
:class="size"
|
||||
class="rounded text-xs truncate bg-neutral-100 text-center flex items-center justify-center"
|
||||
>
|
||||
<p
|
||||
class="font-semibold text-neutral-500"
|
||||
v-text="workspace.icon"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'WorkspaceIcon',
|
||||
components: {},
|
||||
props: {
|
||||
workspace: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'h-6 w-6',
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
isUrl(str) {
|
||||
try {
|
||||
new URL(str)
|
||||
}
|
||||
catch (_) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user