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:
Favour Olayinka
2024-07-04 16:21:36 +01:00
committed by GitHub
parent 383fff7b2c
commit 90ff91b1e9
64 changed files with 2503 additions and 596 deletions

View File

@@ -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"

View File

@@ -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

View File

@@ -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>

View File

@@ -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()

View 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>

View 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>

View File

@@ -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"})
}
}
},
},

View File

@@ -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>

View File

@@ -97,7 +97,10 @@
</svg>
{{ title }}
</li>
<slot name="pricing-table" />
<slot
name="pricing-table"
:is-yearly="isYearly"
/>
</ul>
</div>

View 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>

View 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>

View File

@@ -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]
},

View 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>