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:
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>
|
||||
Reference in New Issue
Block a user