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>
|
||||
@@ -6,7 +6,7 @@ import gtm from "./gtm"
|
||||
|
||||
export default defineNuxtConfig({
|
||||
loglevel: process.env.NUXT_LOG_LEVEL || 'info',
|
||||
devtools: {enabled: false},
|
||||
devtools: {enabled: true},
|
||||
css: ['~/scss/app.scss'],
|
||||
modules: [
|
||||
'@pinia/nuxt',
|
||||
@@ -70,7 +70,7 @@ export default defineNuxtConfig({
|
||||
classPrefix: '',
|
||||
},
|
||||
ui: {
|
||||
icons: ['heroicons','material-symbols'],
|
||||
icons: ['heroicons', 'material-symbols'],
|
||||
},
|
||||
sitemap,
|
||||
runtimeConfig,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<div
|
||||
id="public-form"
|
||||
class="flex flex-col"
|
||||
>
|
||||
<div v-if="form && !isIframe && (form.logo_picture || form.cover_picture)">
|
||||
<div v-if="form.cover_picture">
|
||||
<div
|
||||
@@ -235,3 +238,19 @@ useHead({
|
||||
script: [{ src: '/widgets/iframeResizer.contentWindow.min.js' } ]
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
#public-form {
|
||||
p, div {
|
||||
@apply text-gray-900 dark:text-white;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
@apply text-gray-900 dark:text-white;
|
||||
}
|
||||
|
||||
a {
|
||||
@apply text-blue-600 hover:underline;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
</div>
|
||||
<div class="flex bg-white">
|
||||
<div class="w-full md:w-4/5 lg:w-3/5 md:mx-auto md:max-w-4xl">
|
||||
<div class="mt-8 pb-0">
|
||||
<div class="mt-4 pb-0">
|
||||
<text-input
|
||||
v-if="forms.length > 0"
|
||||
v-model="search"
|
||||
@@ -180,6 +180,40 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="!workspace.is_pro"
|
||||
class="px-4"
|
||||
>
|
||||
<UAlert
|
||||
class="mt-4"
|
||||
icon="i-heroicons-command-line"
|
||||
color="primary"
|
||||
variant="subtle"
|
||||
description="You can add components to your app using the cli."
|
||||
>
|
||||
<template #title>
|
||||
<h2 class="font-medium text-lg -mt-2">
|
||||
Discover our Pro plan
|
||||
</h2>
|
||||
</template>
|
||||
<template #description>
|
||||
<div class="flex flex-wrap sm:flex-nowrap gap-2 items-start">
|
||||
<p class="flex-grow">
|
||||
Remove NoteForms branding, customize forms further, use your custom domain, integrate with your
|
||||
favorite tools, invite users, and more!
|
||||
</p>
|
||||
<UButton
|
||||
v-track.upgrade_banner_home_click
|
||||
:to="{name:'pricing'}"
|
||||
color="white"
|
||||
class="block"
|
||||
>
|
||||
Upgrade Now
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
</UAlert>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="formsLoading"
|
||||
@@ -195,12 +229,12 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useFormsStore } from "../stores/forms"
|
||||
import { useWorkspacesStore } from "../stores/workspaces"
|
||||
import {useFormsStore} from "../stores/forms"
|
||||
import {useWorkspacesStore} from "../stores/workspaces"
|
||||
import Fuse from "fuse.js"
|
||||
import TextInput from "../components/forms/TextInput.vue"
|
||||
import ExtraMenu from "../components/pages/forms/show/ExtraMenu.vue"
|
||||
import { refDebounced } from "@vueuse/core"
|
||||
import {refDebounced} from "@vueuse/core"
|
||||
|
||||
definePageMeta({
|
||||
middleware: "auth",
|
||||
@@ -216,6 +250,8 @@ const formsStore = useFormsStore()
|
||||
const workspacesStore = useWorkspacesStore()
|
||||
formsStore.startLoading()
|
||||
|
||||
const workspace = computed(() => workspacesStore.getCurrent)
|
||||
|
||||
onMounted(() => {
|
||||
if (!formsStore.allLoaded) {
|
||||
formsStore.loadAll(workspacesStore.currentId)
|
||||
|
||||
@@ -20,7 +20,19 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<pricing-table />
|
||||
<pricing-table>
|
||||
<template #pricing-table="{isYearly}">
|
||||
<div class="flex gap-x-2 items-center">
|
||||
<Icon
|
||||
class="inline w-5 h-5 text-blue-500"
|
||||
name="heroicons:user-plus-16-solid"
|
||||
/>
|
||||
<p>
|
||||
Extra users for {{ isYearly?'$5/month':'$6/month' }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</pricing-table>
|
||||
|
||||
<section class="py-12 bg-white sm:py-16 lg:py-24 xl:py-24">
|
||||
<div class="px-4 mx-auto sm:px-6 lg:px-8 max-w-7xl">
|
||||
@@ -387,12 +399,12 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { computed } from "vue"
|
||||
import { useAuthStore } from "../stores/auth"
|
||||
import {computed} from "vue"
|
||||
import {useAuthStore} from "../stores/auth"
|
||||
import PricingTable from "../components/pages/pricing/PricingTable.vue"
|
||||
|
||||
export default {
|
||||
components: { PricingTable },
|
||||
components: {PricingTable},
|
||||
layout: "default",
|
||||
|
||||
setup() {
|
||||
@@ -408,7 +420,7 @@ export default {
|
||||
// Custom inline middleware
|
||||
if (!useRuntimeConfig().public.paidPlansEnabled) {
|
||||
// If no paid plan so no need this page
|
||||
return navigateTo("/", { redirectCode: 301 })
|
||||
return navigateTo("/", {redirectCode: 301})
|
||||
}
|
||||
},
|
||||
],
|
||||
|
||||
@@ -11,7 +11,15 @@
|
||||
Create an account
|
||||
</h2>
|
||||
<small>Sign up in less than 2 minutes.</small>
|
||||
<register-form />
|
||||
<template v-if="!isSelfHosted || isInvited">
|
||||
<register-form />
|
||||
</template>
|
||||
<div
|
||||
v-else
|
||||
class="my-6 p-3 rounded-lg border border-yellow-600 bg-yellow-200 text-yellow-600"
|
||||
>
|
||||
Registration is not allowed in self host mode.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full hidden lg:block lg:w-1/2 md:p-6 mt-8 md:mt-0">
|
||||
@@ -108,7 +116,15 @@ export default {
|
||||
|
||||
data: () => ({}),
|
||||
|
||||
computed: {},
|
||||
computed: {
|
||||
isSelfHosted(){
|
||||
return useRuntimeConfig().public.selfHosted
|
||||
},
|
||||
|
||||
isInvited(){
|
||||
return this.$route.query?.email && this.$route.query?.invite_token
|
||||
}
|
||||
},
|
||||
|
||||
methods: {},
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="bg-white">
|
||||
<div class="flex bg-gray-50">
|
||||
<div class="w-full md:w-4/5 lg:w-3/5 md:mx-auto md:max-w-4xl px-4">
|
||||
<div class="w-full md:w-4/5 md:mx-auto md:max-w-4xl px-4">
|
||||
<div class="pt-4 pb-0">
|
||||
<div class="flex">
|
||||
<h2 class="flex-grow text-gray-900">
|
||||
@@ -12,7 +12,7 @@
|
||||
<li>{{ user.email }}</li>
|
||||
</ul>
|
||||
|
||||
<div class="mt-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="mt-4 border-gray-200 dark:border-gray-700">
|
||||
<ul class="flex flex-wrap -mb-px text-sm font-medium text-center">
|
||||
<li
|
||||
v-for="(tab, i) in tabsList"
|
||||
@@ -33,8 +33,8 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex bg-white">
|
||||
<div class="w-full md:w-4/5 lg:w-3/5 md:mx-auto md:max-w-4xl px-4">
|
||||
<div class="mt-8 pb-0">
|
||||
<div class="w-full md:w-4/5 md:mx-auto md:max-w-4xl px-4">
|
||||
<div class="mt-4 pb-0">
|
||||
<NuxtPage />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,15 +8,21 @@
|
||||
<small class="text-gray-600">Manage your billing. Download invoices, update your plan, or cancel it
|
||||
at any time.</small>
|
||||
|
||||
<div class="mt-4">
|
||||
<v-button
|
||||
<div class="mt-4 flex flex-wrap gap-2 w-full border shadow rounded-lg p-4 items-center">
|
||||
<p
|
||||
v-if="usersCount"
|
||||
class="text-gray-500 flex-grow"
|
||||
>
|
||||
You currently have <span class="font-medium">{{ usersCount }} users</span> in your different workspaces.
|
||||
</p>
|
||||
<UButton
|
||||
color="gray"
|
||||
shade="light"
|
||||
icon="i-heroicons-credit-card"
|
||||
:loading="billingLoading"
|
||||
@click.prevent="openBillingDashboard"
|
||||
@click="openBillingDashboard"
|
||||
>
|
||||
Manage Subscription
|
||||
</v-button>
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -38,10 +44,25 @@ definePageMeta({
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const user = computed(() => authStore.user)
|
||||
let billingLoading = false
|
||||
const billingLoading = ref(false)
|
||||
const usersCount = ref(0)
|
||||
|
||||
onMounted(() => {
|
||||
loadUsersCount()
|
||||
})
|
||||
|
||||
const loadUsersCount = () => {
|
||||
opnFetch("/subscription/users-count")
|
||||
.then((data) => {
|
||||
usersCount.value = data.count
|
||||
})
|
||||
.catch((error) => {
|
||||
useAlert().error(error.data.message)
|
||||
})
|
||||
}
|
||||
|
||||
const openBillingDashboard = () => {
|
||||
billingLoading = true
|
||||
billingLoading.value = true
|
||||
opnFetch("/subscription/billing-portal")
|
||||
.then((data) => {
|
||||
const url = data.portal_url
|
||||
@@ -51,7 +72,7 @@ const openBillingDashboard = () => {
|
||||
useAlert().error(error.data.message)
|
||||
})
|
||||
.finally(() => {
|
||||
billingLoading = false
|
||||
billingLoading.value = false
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -7,27 +7,12 @@
|
||||
</h3>
|
||||
<small class="text-gray-600">Manage your external connections.</small>
|
||||
</div>
|
||||
<v-button
|
||||
color="outline-blue"
|
||||
<UButton
|
||||
label="Connect new account"
|
||||
icon="i-heroicons-plus"
|
||||
:loading="loading"
|
||||
@click="providerModal = true"
|
||||
>
|
||||
<svg
|
||||
class="inline -mt-1 mr-1 h-4 w-4"
|
||||
viewBox="0 0 14 14"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6.99996 1.16699V12.8337M1.16663 7.00033H12.8333"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.67"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
Connect new account
|
||||
</v-button>
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
||||
@@ -1,33 +1,22 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex flex-wrap items-center gap-y-4 flex-wrap-reverse">
|
||||
<div class="flex flex-wrap items-center gap-y-4">
|
||||
<div class="flex-grow">
|
||||
<h3 class="font-semibold text-2xl text-gray-900">
|
||||
Workspace settings
|
||||
</h3>
|
||||
<small class="text-gray-600">Manage your workspaces.</small>
|
||||
<small class="text-gray-500">You're currently editing the settings for the workspace "{{ workspace.name }}".
|
||||
You can switch to another workspace in top left corner of the page.</small>
|
||||
</div>
|
||||
<div class="w-full flex flex-wrap justify-between gap-2">
|
||||
<WorkSpaceCustomDomains v-if="customDomainsEnabled && !loading" />
|
||||
<UButton
|
||||
label="New Workspace"
|
||||
icon="i-heroicons-plus"
|
||||
:loading="loading"
|
||||
@click="workspaceModal = true"
|
||||
/>
|
||||
</div>
|
||||
<v-button
|
||||
color="outline-blue"
|
||||
:loading="loading"
|
||||
@click="workspaceModal = true"
|
||||
>
|
||||
<svg
|
||||
class="inline -mt-1 mr-1 h-4 w-4"
|
||||
viewBox="0 0 14 14"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6.99996 1.16699V12.8337M1.16663 7.00033H12.8333"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.67"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
Create new workspace
|
||||
</v-button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -36,98 +25,11 @@
|
||||
>
|
||||
<Loader class="h-10 w-10 p-5" />
|
||||
</div>
|
||||
<div v-else-if="workspace">
|
||||
<div class="mt-4 flex group bg-white items-center">
|
||||
<div class="flex space-x-4 flex-grow items-center">
|
||||
<img
|
||||
v-if="isUrl(workspace.icon)"
|
||||
:src="workspace.icon"
|
||||
:alt="workspace.name + ' icon'"
|
||||
class="rounded-full h-12 w-12"
|
||||
>
|
||||
<div
|
||||
v-else
|
||||
class="rounded-2xl bg-gray-100 h-12 w-12 text-2xl pt-2 text-center overflow-hidden"
|
||||
v-text="workspace.icon"
|
||||
/>
|
||||
<div class="space-y-4 py-1">
|
||||
<div class="font-bold truncate">
|
||||
{{ workspace.name }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="customDomainsEnabled">
|
||||
<text-area-input
|
||||
:form="customDomainsForm"
|
||||
name="custom_domains"
|
||||
class="mt-4"
|
||||
:required="false"
|
||||
:disabled="!workspace.is_pro"
|
||||
label="Workspace Custom Domains"
|
||||
wrapper-class=""
|
||||
placeholder="yourdomain.com - 1 per line"
|
||||
/>
|
||||
<p class="text-gray-500 text-sm">
|
||||
Read our
|
||||
<a
|
||||
href="#"
|
||||
@click.prevent="
|
||||
crisp.openHelpdeskArticle('how-to-use-my-own-domain-9m77g7')
|
||||
"
|
||||
>custom domain instructions</a>
|
||||
to learn how to use your own domain.
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<div class="flex flex-wrap justify-between gap-2 mt-4">
|
||||
<v-button
|
||||
v-if="customDomainsEnabled"
|
||||
class="w-full sm:w-auto"
|
||||
:loading="customDomainsLoading"
|
||||
@click="saveChanges"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 text-white inline mr-1 -mt-1"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M17 21V13H7V21M7 3V8H15M19 21H5C4.46957 21 3.96086 20.7893 3.58579 20.4142C3.21071 20.0391 3 19.5304 3 19V5C3 4.46957 3.21071 3.96086 3.58579 3.58579C3.96086 3.21071 4.46957 3 5 3H16L21 8V19C21 19.5304 20.7893 20.0391 20.4142 20.4142C20.0391 20.7893 19.5304 21 19 21Z"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
Save Domains
|
||||
</v-button>
|
||||
<v-button
|
||||
v-if="workspaces.length > 1"
|
||||
color="white"
|
||||
class="group w-full sm:w-auto"
|
||||
:loading="loading"
|
||||
@click="deleteWorkspace(workspace.id)"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5 -mt-1 inline group-hover:text-red-700"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
Remove workspace
|
||||
</v-button>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="workspace"
|
||||
class="my-4"
|
||||
>
|
||||
<WorkSpaceUser />
|
||||
</div>
|
||||
|
||||
<!-- Workspace modal -->
|
||||
@@ -192,8 +94,8 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { watch } from "vue"
|
||||
import { fetchAllWorkspaces } from "~/stores/workspaces.js"
|
||||
import {watch, ref} from "vue"
|
||||
import {fetchAllWorkspaces} from "~/stores/workspaces.js"
|
||||
|
||||
const crisp = useCrisp()
|
||||
const workspacesStore = useWorkspacesStore()
|
||||
@@ -212,91 +114,16 @@ const form = useForm({
|
||||
emoji: "",
|
||||
})
|
||||
const workspaceModal = ref(false)
|
||||
const customDomainsForm = useForm({
|
||||
custom_domain: "",
|
||||
})
|
||||
const customDomainsLoading = ref(false)
|
||||
|
||||
const workspace = computed(() => workspacesStore.getCurrent)
|
||||
const customDomainsEnabled = computed(
|
||||
() => useRuntimeConfig().public.customDomainsEnabled,
|
||||
)
|
||||
|
||||
watch(
|
||||
() => workspace,
|
||||
() => {
|
||||
initCustomDomains()
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
fetchAllWorkspaces()
|
||||
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")
|
||||
}
|
||||
|
||||
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 isUrl = (str) => {
|
||||
const pattern = new RegExp(
|
||||
"^(https?:\\/\\/)?" + // protocol
|
||||
"((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|" + // domain name
|
||||
"((\\d{1,3}\\.){3}\\d{1,3}))" + // OR ip (v4) address
|
||||
"(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*" + // port and path
|
||||
"(\\?[;&a-z\\d%_.~+=-]*)?" + // query string
|
||||
"(\\#[-a-z\\d_]*)?$",
|
||||
"i",
|
||||
) // fragment locator
|
||||
return !!pattern.test(str)
|
||||
}
|
||||
const createWorkspace = () => {
|
||||
form.post("/open/workspaces/create").then((data) => {
|
||||
workspacesStore.save(data.workspace)
|
||||
|
||||
@@ -31,8 +31,10 @@ export default {
|
||||
})
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const confetti = useConfetti()
|
||||
return {
|
||||
authStore,
|
||||
confetti,
|
||||
authenticated: computed(() => authStore.check),
|
||||
user: computed(() => authStore.user),
|
||||
crisp: useCrisp(),
|
||||
@@ -53,6 +55,12 @@ export default {
|
||||
beforeUnmount() {
|
||||
clearInterval(this.interval)
|
||||
},
|
||||
unmounted() {
|
||||
// stop confettis after 2 sec
|
||||
setTimeout(() => {
|
||||
this.confetti.stop()
|
||||
}, 2000)
|
||||
},
|
||||
|
||||
methods: {
|
||||
async checkSubscription() {
|
||||
@@ -73,6 +81,7 @@ export default {
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
this.confetti.play()
|
||||
this.$router.push({name: "home"})
|
||||
|
||||
if (this.user.has_enterprise_subscription) {
|
||||
@@ -88,6 +97,6 @@ export default {
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
31
client/runtimeConfig.js
vendored
31
client/runtimeConfig.js
vendored
@@ -1,24 +1,39 @@
|
||||
function parseBoolean(value, defaultValue = false) {
|
||||
if (typeof value === 'string') {
|
||||
value = value.toLowerCase().trim()
|
||||
if (value === 'true' || value === '1') return true
|
||||
if (value === 'false' || value === '0') return false
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
function parseNumber(value, defaultValue = 0) {
|
||||
const parsedValue = parseFloat(value)
|
||||
return isNaN(parsedValue) ? defaultValue : parsedValue
|
||||
}
|
||||
|
||||
export default {
|
||||
// Keys within public, will be also exposed to the client-side
|
||||
public: {
|
||||
apiBase: process.env.NUXT_PUBLIC_API_BASE ||'',
|
||||
apiBase: process.env.NUXT_PUBLIC_API_BASE || '',
|
||||
appUrl: process.env.NUXT_PUBLIC_APP_URL || '',
|
||||
env: process.env.NUXT_PUBLIC_ENV || 'local',
|
||||
hCaptchaSiteKey: process.env.NUXT_PUBLIC_H_CAPTCHA_SITE_KEY || null,
|
||||
gtmCode: process.env.NUXT_PUBLIC_GTM_CODE || null,
|
||||
amplitudeCode: process.env.NUXT_PUBLIC_AMPLITUDE_CODE || null,
|
||||
crispWebsiteId: process.env.NUXT_PUBLIC_CRISP_WEBSITE_ID || null,
|
||||
aiFeaturesEnabled: process.env.NUXT_PUBLIC_AI_FEATURES_ENABLED || false,
|
||||
s3Enabled: process.env.NUXT_PUBLIC_S3_ENABLED || false,
|
||||
paidPlansEnabled: process.env.NUXT_PUBLIC_PAID_PLANS_ENABLED || false,
|
||||
customDomainsEnabled: process.env.NUXT_PUBLIC_CUSTOM_DOMAINS_ENABLED || false,
|
||||
aiFeaturesEnabled: parseBoolean(process.env.NUXT_PUBLIC_AI_FEATURES_ENABLED),
|
||||
s3Enabled: parseBoolean(process.env.NUXT_PUBLIC_S3_ENABLED),
|
||||
paidPlansEnabled: parseBoolean(process.env.NUXT_PUBLIC_PAID_PLANS_ENABLED),
|
||||
customDomainsEnabled: parseBoolean(process.env.NUXT_PUBLIC_CUSTOM_DOMAINS_ENABLED),
|
||||
featureBaseOrganization: process.env.NUXT_PUBLIC_FEATURE_BASE_ORGANISATION || null,
|
||||
selfHosted: parseBoolean(process.env.NUXT_PUBLIC_SELF_HOSTED, true),
|
||||
|
||||
// Config within public will be also exposed to the client
|
||||
SENTRY_DSN_PUBLIC: process.env.SENTRY_DSN_PUBLIC,
|
||||
SENTRY_TRACES_SAMPLE_RATE: parseFloat(process.env.SENTRY_TRACES_SAMPLE_RATE ?? '0'),
|
||||
SENTRY_REPLAY_SAMPLE_RATE: parseFloat(process.env.SENTRY_REPLAY_SAMPLE_RATE ?? '0'),
|
||||
SENTRY_ERROR_REPLAY_SAMPLE_RATE: parseFloat(process.env.SENTRY_ERROR_REPLAY_SAMPLE_RATE ?? '0'),
|
||||
SENTRY_TRACES_SAMPLE_RATE: parseNumber(process.env.SENTRY_TRACES_SAMPLE_RATE),
|
||||
SENTRY_REPLAY_SAMPLE_RATE: parseNumber(process.env.SENTRY_REPLAY_SAMPLE_RATE),
|
||||
SENTRY_ERROR_REPLAY_SAMPLE_RATE: parseNumber(process.env.SENTRY_ERROR_REPLAY_SAMPLE_RATE),
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
12
client/scss/app.scss
vendored
12
client/scss/app.scss
vendored
@@ -22,14 +22,6 @@ body.dark * {
|
||||
--bg-form-color: #2563eb;
|
||||
}
|
||||
|
||||
p, div {
|
||||
@apply text-gray-900 dark:text-white;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
@apply text-gray-900 dark:text-white;
|
||||
}
|
||||
|
||||
h1 {
|
||||
@apply text-3xl sm:text-4xl font-semibold;
|
||||
}
|
||||
@@ -37,10 +29,6 @@ body.dark * {
|
||||
h2 {
|
||||
@apply text-3xl font-semibold;
|
||||
}
|
||||
|
||||
a {
|
||||
@apply text-blue-600 hover:underline;
|
||||
}
|
||||
}
|
||||
|
||||
.bg-white {
|
||||
|
||||
10
client/stores/workspaces.js
vendored
10
client/stores/workspaces.js
vendored
@@ -39,6 +39,14 @@ export const useWorkspacesStore = defineStore("workspaces", () => {
|
||||
}
|
||||
}
|
||||
|
||||
const getWorkspaceUsers = async() => {
|
||||
return await opnFetch(`${workspaceEndpoint}${currentId.value}/users/`)
|
||||
}
|
||||
|
||||
const getWorkspaceInvites = async() => {
|
||||
return await opnFetch(`${workspaceEndpoint}${currentId.value}/invites/`)
|
||||
}
|
||||
|
||||
return {
|
||||
...contentStore,
|
||||
currentId,
|
||||
@@ -47,6 +55,8 @@ export const useWorkspacesStore = defineStore("workspaces", () => {
|
||||
set,
|
||||
save,
|
||||
remove,
|
||||
getWorkspaceUsers,
|
||||
getWorkspaceInvites,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user