Merge pull request #1491 from Budibase/admin/user-management-ui

User Management UI and Settings Fixes
This commit is contained in:
Kevin Åberg Kultalahti 2021-05-18 12:28:47 +02:00 committed by GitHub
commit bc94dfa86b
38 changed files with 1119 additions and 96 deletions

View File

@ -16,7 +16,10 @@
function getInitials(name) { function getInitials(name) {
let parts = name.split(" ") let parts = name.split(" ")
return parts.map(name => name[0]).join("") if (parts.length > 0) {
return parts.map(name => name[0]).join("")
}
return name
} }
</script> </script>

View File

@ -282,6 +282,7 @@
{#if sortedRows?.length && fields.length} {#if sortedRows?.length && fields.length}
{#each sortedRows as row, idx} {#each sortedRows as row, idx}
<tr <tr
on:click={() => dispatch("click", row)}
on:click={() => toggleSelectRow(row)} on:click={() => toggleSelectRow(row)}
class="spectrum-Table-row" class="spectrum-Table-row"
class:hidden={idx < firstVisibleRow || idx > lastVisibleRow} class:hidden={idx < firstVisibleRow || idx > lastVisibleRow}

View File

@ -1,6 +1,7 @@
<script> <script>
export let selected = false export let selected = false
export let open = false export let open = false
export let href = false
export let title export let title
export let icon export let icon
</script> </script>
@ -10,7 +11,7 @@
class:is-open={open} class:is-open={open}
class="spectrum-TreeView-item" class="spectrum-TreeView-item"
> >
<a on:click class="spectrum-TreeView-itemLink" href="#"> <a on:click class="spectrum-TreeView-itemLink" {href}>
{#if $$slots.default} {#if $$slots.default}
<svg <svg
class="spectrum-Icon spectrum-UIIcon-ChevronRight100 spectrum-TreeView-itemIndicator" class="spectrum-Icon spectrum-UIIcon-ChevronRight100 spectrum-TreeView-itemIndicator"

View File

@ -4,16 +4,17 @@
import { routes } from "../.routify/routes" import { routes } from "../.routify/routes"
import { initialise } from "builderStore" import { initialise } from "builderStore"
import { NotificationDisplay } from "@budibase/bbui" import { NotificationDisplay } from "@budibase/bbui"
import { parse, stringify } from "qs"
onMount(async () => { onMount(async () => {
await initialise() await initialise()
}) })
const config = {} const queryHandler = { parse, stringify }
</script> </script>
<NotificationDisplay /> <NotificationDisplay />
<Router {routes} {config} /> <Router {routes} config={{ queryHandler }} />
<div class="modal-container" /> <div class="modal-container" />
<style> <style>

View File

@ -0,0 +1,20 @@
import { writable } from "svelte/store"
import api from "builderStore/api"
export default function (url) {
const store = writable({ status: "LOADING", data: {}, error: {} })
async function get() {
store.update(u => ({ ...u, status: "LOADING" }))
try {
const response = await api.get(url)
store.set({ data: await response.json(), status: "SUCCESS" })
} catch (e) {
store.set({ data: {}, error: e, status: "ERROR" })
}
}
get()
return { subscribe: store.subscribe, refresh: get }
}

View File

@ -0,0 +1,9 @@
export { default as fetchData } from "./fetchData"
export {
buildStyle,
convertCamel,
pipe,
capitalise,
get_name,
get_capitalised_name,
} from "./helpers"

View File

@ -0,0 +1,2 @@
export { emailValidator, requiredValidator } from "./validators"
export { createValidationStore } from "./validation"

View File

@ -0,0 +1,23 @@
import { writable, derived } from "svelte/store"
export function createValidationStore(initialValue, ...validators) {
let touched = false
const value = writable(initialValue || "")
const error = derived(value, $v => validate($v, validators))
const touchedStore = derived(value, () => {
if (!touched) {
touched = true
return false
}
return touched
})
return [value, error, touchedStore]
}
function validate(value, validators) {
const failing = validators.find(v => v(value) !== true)
return failing && failing(value)
}

View File

@ -0,0 +1,16 @@
export function emailValidator(value) {
return (
(value &&
!!value.match(
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
)) ||
"Please enter a valid email"
)
}
export function requiredValidator(value) {
return (
(value !== undefined && value !== null && value !== "") ||
"This field is required"
)
}

View File

@ -1,6 +1,6 @@
<script> <script>
import { onMount } from "svelte" import { onMount } from "svelte"
import { goto } from "@roxi/routify" import { page, goto } from "@roxi/routify"
import { auth } from "stores/backend" import { auth } from "stores/backend"
import { admin } from "stores/portal" import { admin } from "stores/portal"
@ -22,7 +22,12 @@
// Redirect to log in at any time if the user isn't authenticated // Redirect to log in at any time if the user isn't authenticated
$: { $: {
if (loaded && hasAdminUser && !$auth.user) { if (
!$page.path.includes("/builder/invite") &&
loaded &&
hasAdminUser &&
!$auth.user
) {
$goto("./auth/login") $goto("./auth/login")
} }
} }

View File

@ -0,0 +1,97 @@
<script>
import {
Layout,
Heading,
Body,
Input,
Button,
notifications,
} from "@budibase/bbui"
import { goto, params } from "@roxi/routify"
import { createValidationStore, requiredValidator } from "helpers/validation"
import { users } from "stores/portal"
const [password, passwordError, passwordTouched] = createValidationStore(
"",
requiredValidator
)
const [repeat, _, repeatTouched] = createValidationStore(
"",
requiredValidator
)
const inviteCode = $params["?code"]
async function acceptInvite() {
try {
const res = await users.acceptInvite(inviteCode, $password)
if (!res) {
throw new Error(res.message)
}
notifications.success(`User created.`)
$goto("../auth/login")
} catch (err) {
notifications.error(err)
}
}
</script>
<section>
<div class="container">
<Layout gap="XS">
<img src="https://i.imgur.com/ZKyklgF.png" />
</Layout>
<div class="center">
<Layout gap="XS">
<Heading size="M">Accept Invitation</Heading>
<Body size="M">Please enter a password to setup your user.</Body>
</Layout>
</div>
<Layout gap="XS">
<Input
label="Password"
type="password"
error={$passwordTouched && $passwordError}
bind:value={$password}
/>
<Input
label="Repeat Password"
type="password"
error={$repeatTouched &&
$password !== $repeat &&
"Passwords must match"}
bind:value={$repeat}
/>
</Layout>
<Layout gap="S">
<Button
disabled={!$passwordTouched || !$repeatTouched || $password !== $repeat}
cta
on:click={acceptInvite}>Accept invite</Button
>
</Layout>
</div>
</section>
<style>
section {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.container {
margin: 0 auto;
width: 260px;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
}
.center {
text-align: center;
}
img {
width: 40px;
margin: 0 auto;
}
</style>

View File

@ -1,6 +1,5 @@
<script> <script>
import { isActive, goto } from "@roxi/routify" import { isActive, goto } from "@roxi/routify"
import { onMount } from "svelte"
import { import {
Icon, Icon,
Avatar, Avatar,
@ -13,34 +12,21 @@
Modal, Modal,
} from "@budibase/bbui" } from "@budibase/bbui"
import ConfigChecklist from "components/common/ConfigChecklist.svelte" import ConfigChecklist from "components/common/ConfigChecklist.svelte"
import { organisation, apps } from "stores/portal" import { organisation } from "stores/portal"
import { auth } from "stores/backend" import { auth } from "stores/backend"
import BuilderSettingsModal from "components/start/BuilderSettingsModal.svelte" import BuilderSettingsModal from "components/start/BuilderSettingsModal.svelte"
let orgName
let orgLogo
let user
let oldSettingsModal let oldSettingsModal
async function getInfo() { organisation.init()
// fetch orgInfo
orgName = "ACME Inc."
orgLogo = "https://via.placeholder.com/150"
user = { name: "John Doe" }
}
onMount(() => {
organisation.init()
getInfo()
})
let menu = [ let menu = [
{ title: "Apps", href: "/builder/portal/apps" }, { title: "Apps", href: "/builder/portal/apps" },
{ title: "Drafts", href: "/builder/portal/drafts" }, { title: "Drafts", href: "/builder/portal/drafts" },
{ title: "Users", href: "/builder/portal/users", heading: "Manage" }, { title: "Users", href: "/builder/portal/manage/users", heading: "Manage" },
{ title: "Groups", href: "/builder/portal/groups" }, { title: "Groups", href: "/builder/portal/manage/groups" },
{ title: "Auth", href: "/builder/portal/oauth" }, { title: "Auth", href: "/builder/portal/manage/auth" },
{ title: "Email", href: "/builder/portal/email" }, { title: "Email", href: "/builder/portal/manage/email" },
{ {
title: "General", title: "General",
href: "/builder/portal/settings/general", href: "/builder/portal/settings/general",

View File

@ -0,0 +1,7 @@
<script>
import { Page } from "@budibase/bbui"
</script>
<Page>
<slot />
</Page>

View File

@ -0,0 +1,114 @@
<script>
import GoogleLogo from "./_logos/Google.svelte"
import {
Button,
Heading,
Divider,
Page,
Label,
notifications,
Layout,
Input,
Body,
} from "@budibase/bbui"
import { onMount } from "svelte"
import api from "builderStore/api"
const ConfigTypes = {
Google: "google",
// Github: "github",
// AzureAD: "ad",
}
const ConfigFields = {
Google: ["clientID", "clientSecret", "callbackURL"],
}
let google
async function save(doc) {
try {
// Save an oauth config
const response = await api.post(`/api/admin/configs`, doc)
const json = await response.json()
if (response.status !== 200) throw new Error(json.message)
google._rev = json._rev
google._id = json._id
notifications.success(`Settings saved.`)
} catch (err) {
notifications.error(`Failed to update OAuth settings. ${err}`)
}
}
onMount(async () => {
// fetch the configs for oauth
const googleResponse = await api.get(
`/api/admin/configs/${ConfigTypes.Google}`
)
const googleDoc = await googleResponse.json()
if (!googleDoc._id) {
google = {
type: ConfigTypes.Google,
config: {},
}
} else {
google = googleDoc
}
})
</script>
<Page>
<Layout noPadding>
<div>
<Heading size="M">OAuth</Heading>
<Body>
Every budibase app comes with basic authentication (email/password)
included. You can add additional authentication methods from the options
below.
</Body>
</div>
<Divider />
{#if google}
<div>
<Heading size="S">
<span>
<GoogleLogo />
Google
</span>
</Heading>
<Body>
To allow users to authenticate using their Google accounts, fill out
the fields below.
</Body>
</div>
{#each ConfigFields.Google as field}
<div class="form-row">
<Label size="L">{field}</Label>
<Input bind:value={google.config[field]} />
</div>
{/each}
<div>
<Button primary on:click={() => save(google)}>Save</Button>
</div>
<Divider />
{/if}
</Layout>
</Page>
<style>
.form-row {
display: grid;
grid-template-columns: 20% 1fr;
grid-gap: var(--spacing-l);
align-items: center;
}
span {
display: flex;
align-items: center;
gap: var(--spacing-s);
}
</style>

View File

@ -1,32 +1,19 @@
<script> <script>
import { import {
Menu,
MenuItem,
Button, Button,
Detail, Detail,
Heading, Heading,
Divider,
Label,
Modal,
ModalContent,
notifications, notifications,
Layout,
Icon, Icon,
Body,
Page, Page,
Select,
Tabs, Tabs,
Tab, Tab,
MenuSection,
MenuSeparator,
} from "@budibase/bbui" } from "@budibase/bbui"
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import { onMount } from "svelte"
import { fade } from "svelte/transition" import { fade } from "svelte/transition"
import { email } from "stores/portal" import { email } from "stores/portal"
import Editor from "components/integration/QueryEditor.svelte" import Editor from "components/integration/QueryEditor.svelte"
import TemplateBindings from "./TemplateBindings.svelte" import TemplateBindings from "./_components/TemplateBindings.svelte"
import api from "builderStore/api"
const ConfigTypes = { const ConfigTypes = {
SMTP: "smtp", SMTP: "smtp",

View File

@ -23,8 +23,8 @@
import { onMount } from "svelte" import { onMount } from "svelte"
import { email } from "stores/portal" import { email } from "stores/portal"
import Editor from "components/integration/QueryEditor.svelte" import Editor from "components/integration/QueryEditor.svelte"
import TemplateBindings from "./TemplateBindings.svelte" import TemplateBindings from "./_components/TemplateBindings.svelte"
import TemplateLink from "./TemplateLink.svelte" import TemplateLink from "./_components/TemplateLink.svelte"
import api from "builderStore/api" import api from "builderStore/api"
const ConfigTypes = { const ConfigTypes = {

View File

@ -0,0 +1,43 @@
<svg
width="18"
height="18"
viewBox="0 0 268 268"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clip-path="url(#clip0)">
<path
d="M58.8037 109.043C64.0284 93.2355 74.1116 79.4822 87.615 69.7447C101.118
60.0073 117.352 54.783 134 54.8172C152.872 54.8172 169.934 61.5172 183.334
72.4828L222.328 33.5C198.566 12.7858 168.114 0 134 0C81.1817 0 35.711
30.1277 13.8467 74.2583L58.8037 109.043Z"
fill="#EA4335"
/>
<path
d="M179.113 201.145C166.942 208.995 151.487 213.183 134 213.183C117.418
213.217 101.246 208.034 87.7727 198.369C74.2993 188.703 64.2077 175.044
58.9265 159.326L13.8132 193.574C24.8821 215.978 42.012 234.828 63.2572
247.984C84.5024 261.14 109.011 268.075 134 268C166.752 268 198.041 256.353
221.48 234.5L179.125 201.145H179.113Z"
fill="#34A853"
/>
<path
d="M221.48 234.5C245.991 211.631 261.903 177.595 261.903 134C261.903
126.072 260.686 117.552 258.866 109.634H134V161.414H205.869C202.329
178.823 192.804 192.301 179.125 201.145L221.48 234.5Z"
fill="#4A90E2"
/>
<path
d="M58.9265 159.326C56.1947 151.162 54.8068 142.609 54.8172 134C54.8172
125.268 56.213 116.882 58.8037 109.043L13.8467 74.2584C4.64957 92.825
-0.0915078 113.28 1.86708e-05 134C1.86708e-05 155.44 4.96919 175.652
13.8132 193.574L58.9265 159.326Z"
fill="#FBBC05"
/>
</g>
<defs>
<clipPath id="clip0">
<rect width="268" height="268" fill="white" />
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,115 @@
<script>
import GoogleLogo from "./_logos/Google.svelte"
import {
Button,
Heading,
Divider,
Label,
notifications,
Layout,
Input,
Body,
Page,
} from "@budibase/bbui"
import { onMount } from "svelte"
import api from "builderStore/api"
const ConfigTypes = {
Google: "google",
// Github: "github",
// AzureAD: "ad",
}
const ConfigFields = {
Google: ["clientID", "clientSecret", "callbackURL"],
}
let google
async function save(doc) {
try {
// Save an oauth config
const response = await api.post(`/api/admin/configs`, doc)
const json = await response.json()
if (response.status !== 200) throw new Error(json.message)
google._rev = json._rev
google._id = json._id
notifications.success(`Settings saved.`)
} catch (err) {
notifications.error(`Failed to update OAuth settings. ${err}`)
}
}
onMount(async () => {
// fetch the configs for oauth
const googleResponse = await api.get(
`/api/admin/configs/${ConfigTypes.Google}`
)
const googleDoc = await googleResponse.json()
if (!googleDoc._id) {
google = {
type: ConfigTypes.Google,
config: {},
}
} else {
google = googleDoc
}
})
</script>
<Page>
<header>
<Heading size="M">OAuth</Heading>
<Body size="S">
Every budibase app comes with basic authentication (email/password)
included. You can add additional authentication methods from the options
below.
</Body>
</header>
<Divider />
{#if google}
<div class="config-form">
<Layout gap="S">
<Heading size="S">
<span>
<GoogleLogo />
Google
</span>
</Heading>
{#each ConfigFields.Google as field}
<div class="form-row">
<Label>{field}</Label>
<Input bind:value={google.config[field]} />
</div>
{/each}
</Layout>
<Button primary on:click={() => save(google)}>Save</Button>
</div>
<Divider />
{/if}
</Page>
<style>
.config-form {
margin-top: 42px;
margin-bottom: 42px;
}
.form-row {
display: grid;
grid-template-columns: 20% 1fr;
grid-gap: var(--spacing-l);
}
span {
display: flex;
align-items: center;
gap: var(--spacing-s);
}
header {
margin-bottom: 42px;
}
</style>

View File

@ -0,0 +1,168 @@
<script>
import { goto } from "@roxi/routify"
import {
ActionButton,
Button,
Layout,
Heading,
Body,
Divider,
Label,
Input,
Modal,
Table,
ModalContent,
notifications,
} from "@budibase/bbui"
import { fetchData } from "helpers"
import { users } from "stores/portal"
import TagsRenderer from "./_components/TagsTableRenderer.svelte"
import UpdateRolesModal from "./_components/UpdateRolesModal.svelte"
export let userId
let deleteUserModal
let editRolesModal
const roleSchema = {
name: { displayName: "App" },
role: {},
}
// Merge the Apps list and the roles response to get something that makes sense for the table
$: appList = Object.keys($apps?.data).map(id => ({
...$apps?.data?.[id],
_id: id,
role: [$roleFetch?.data?.roles?.[id]],
}))
let selectedApp
const roleFetch = fetchData(`/api/admin/users/${userId}`)
const apps = fetchData(`/api/admin/roles`)
async function deleteUser() {
const res = await users.del(userId)
if (res.message) {
notifications.success(`User ${$roleFetch?.data?.email} deleted.`)
$goto("./")
} else {
notifications.error("Failed to delete user.")
}
}
async function openUpdateRolesModal({ detail }) {
console.log(detail)
selectedApp = detail
editRolesModal.show()
}
</script>
<Layout noPadding gap="XS">
<div class="back">
<ActionButton on:click={() => $goto("./")} quiet size="S" icon="BackAndroid"
>Back to users</ActionButton
>
</div>
<div class="heading">
<Layout noPadding gap="XS">
<Heading>User: {$roleFetch?.data?.email}</Heading>
<Body
>Lorem ipsum dolor sit amet consectetur adipisicing elit. Debitis porro
ut nesciunt ipsam perspiciatis aliquam et hic minus alias beatae. Odit
veritatis quos quas laborum magnam tenetur perspiciatis ex hic.
</Body>
</Layout>
</div>
<Divider size="S" />
<div class="general">
<Heading size="S">General</Heading>
<div class="fields">
<div class="field">
<Label size="L">Email</Label>
<Input disabled thin value={$roleFetch?.data?.email} />
</div>
</div>
<div class="regenerate">
<ActionButton size="S" icon="Refresh" quiet
>Regenerate password</ActionButton
>
</div>
</div>
<Divider size="S" />
<div class="roles">
<Heading size="S">Configure roles</Heading>
<Table
on:click={openUpdateRolesModal}
schema={roleSchema}
data={appList}
allowEditColumns={false}
allowEditRows={false}
allowSelectRows={false}
customRenderers={[{ column: "role", component: TagsRenderer }]}
/>
</div>
<Divider size="S" />
<div class="delete">
<Layout gap="S" noPadding
><Heading size="S">Delete user</Heading>
<Body>Deleting a user completely removes them from your account.</Body>
<div class="delete-button">
<Button warning on:click={deleteUserModal.show}>Delete user</Button>
</div></Layout
>
</div>
</Layout>
<Modal bind:this={deleteUserModal}>
<ModalContent
warning
onConfirm={deleteUser}
title="Delete User"
confirmText="Delete user"
cancelText="Cancel"
showCloseIcon={false}
>
<Body
>Are you sure you want to delete <strong>{$roleFetch?.data?.email}</strong
></Body
>
</ModalContent>
</Modal>
<Modal bind:this={editRolesModal}>
<UpdateRolesModal
app={selectedApp}
user={$roleFetch.data}
on:update={roleFetch.refresh}
/>
</Modal>
<style>
.fields {
display: grid;
grid-gap: var(--spacing-m);
margin-top: var(--spacing-xl);
}
.field {
display: grid;
grid-template-columns: 32% 1fr;
align-items: center;
}
.heading {
margin-bottom: var(--spacing-xl);
}
.general {
position: relative;
margin: var(--spacing-xl) 0;
}
.roles {
margin: var(--spacing-xl) 0;
}
.delete {
margin-top: var(--spacing-xl);
}
.regenerate {
position: absolute;
top: 0;
right: 0;
}
</style>

View File

@ -0,0 +1,59 @@
<script>
import {
Body,
Input,
Select,
ModalContent,
notifications,
} from "@budibase/bbui"
import { createValidationStore, emailValidator } from "helpers/validation"
import { users } from "stores/portal"
export let disabled
const options = ["Email onboarding", "Basic onboarding"]
let selected = options[0]
const [email, error, touched] = createValidationStore("", emailValidator)
async function createUserFlow() {
const res = await users.invite($email)
console.log(res)
if (res.status) {
notifications.error(res.message)
} else {
notifications.success(res.message)
}
}
</script>
<ModalContent
onConfirm={createUserFlow}
size="M"
title="Add new user options"
confirmText="Add user"
confirmDisabled={disabled}
cancelText="Cancel"
disabled={$error}
showCloseIcon={false}
>
<Body noPadding
>If you have SMTP configured and an email for the new user, you can use the
automated email onboarding flow. Otherwise, use our basic onboarding process
with autogenerated passwords.</Body
>
<Select
placeholder={null}
bind:value={selected}
on:change
{options}
label="Add new user via:"
/>
<Input
type="email"
bind:value={$email}
error={$touched && $error}
placeholder="john@doe.com"
label="Email"
/>
</ModalContent>

View File

@ -0,0 +1,40 @@
<script>
import { ModalContent, Body, Input, notifications } from "@budibase/bbui"
import { createValidationStore, emailValidator } from "helpers/validation"
import { users } from "stores/portal"
const [email, error, touched] = createValidationStore("", emailValidator)
const password = Math.random().toString(36).substr(2, 20)
async function createUser() {
const res = await users.create({ email: $email, password })
if (res.status) {
notifications.error(res.message)
} else {
notifications.success("Succesfully created user")
}
}
</script>
<ModalContent
onConfirm={createUser}
size="M"
title="Basic user onboarding"
confirmText="Continue"
cancelText="Cancel"
disabled={$error}
error={$touched && $error}
showCloseIcon={false}
>
<Body noPadding
>Below you will find the users username and password. The password will not
be accessible from this point. Please download the credentials.</Body
>
<Input
type="email"
label="Username"
bind:value={$email}
error={$touched && $error}
/>
<Input disabled label="Password" value={password} />
</ModalContent>

View File

@ -0,0 +1,20 @@
<script>
import { Tag, Tags } from "@budibase/bbui"
export let value
const displayLimit = 5
$: tags = value?.slice(0, displayLimit) ?? []
$: leftover = (value?.length ?? 0) - tags.length
</script>
<Tags>
{#each tags as tag}
<Tag>
{tag}
</Tag>
{/each}
{#if leftover}
<Tag>+{leftover} more</Tag>
{/if}
</Tags>

View File

@ -0,0 +1,51 @@
<script>
import { createEventDispatcher } from "svelte"
import { Body, Select, ModalContent, notifications } from "@budibase/bbui"
import { fetchData } from "helpers"
import { users } from "stores/portal"
export let app
export let user
const dispatch = createEventDispatcher()
const roles = app.roles
let options = roles.map(role => role._id)
let selectedRole
async function updateUserRoles() {
const res = await users.updateRoles({
...user,
roles: {
...user.roles,
[app._id]: selectedRole,
},
})
if (res.status === 400) {
notifications.error("Failed to update role")
} else {
notifications.success("Roles updated")
dispatch("update")
}
}
</script>
<ModalContent
onConfirm={updateUserRoles}
title="Update App Roles"
confirmText="Update roles"
cancelText="Cancel"
size="M"
showCloseIcon={false}
>
<Body noPadding
>Update {user.email}'s roles for <strong>{app.name}</strong>.</Body
>
<Select
placeholder={null}
bind:value={selectedRole}
on:change
{options}
label="Select roles:"
/>
</ModalContent>

View File

@ -0,0 +1,105 @@
<script>
import { goto } from "@roxi/routify"
import {
Heading,
Body,
Divider,
Button,
ButtonGroup,
Search,
Table,
Label,
Layout,
Modal,
} from "@budibase/bbui"
import TagsRenderer from "./_components/TagsTableRenderer.svelte"
import AddUserModal from "./_components/AddUserModal.svelte"
import BasicOnboardingModal from "./_components/BasicOnboardingModal.svelte"
import { users } from "stores/portal"
users.init()
const schema = {
email: {},
status: { displayName: "Development Access", type: "boolean" },
// role: { type: "options" },
group: {},
// access: {},
// group: {}
}
let search
let email
$: filteredUsers = $users
.filter(user => user.email.includes(search || ""))
.map(user => ({ ...user, group: ["All"] }))
let createUserModal
let basicOnboardingModal
function openBasicOnoboardingModal() {
createUserModal.hide()
basicOnboardingModal.show()
}
</script>
<Layout>
<div class="heading">
<Heading>Users</Heading>
<Body
>Users are the common denominator in Budibase. Each user is assigned to a
group that contains apps and permissions. In this section, you can add
users, or edit and delete an existing user.</Body
>
</div>
<Divider size="S" />
<div class="users">
<Heading size="S">Users</Heading>
<div class="field">
<Label size="L">Search / filter</Label>
<Search bind:value={search} placeholder="" />
</div>
<div class="buttons">
<ButtonGroup>
<Button disabled secondary>Import users</Button>
<Button overBackground on:click={createUserModal.show}>Add user</Button>
</ButtonGroup>
</div>
<Table
on:click={({ detail }) => $goto(`./${detail._id}`)}
{schema}
data={filteredUsers || $users}
allowEditColumns={false}
allowEditRows={false}
allowSelectRows={false}
customRenderers={[{ column: "group", component: TagsRenderer }]}
/>
</div>
</Layout>
<Modal bind:this={createUserModal}
><AddUserModal on:change={openBasicOnoboardingModal} /></Modal
>
<Modal bind:this={basicOnboardingModal}><BasicOnboardingModal {email} /></Modal>
<style>
.users {
position: relative;
}
.field {
display: flex;
align-items: center;
flex-direction: row;
grid-gap: var(--spacing-m);
margin: var(--spacing-xl) 0;
}
.field > :global(*) + :global(*) {
margin-left: var(--spacing-m);
}
.buttons {
position: absolute;
top: 0;
right: 0;
}
</style>

View File

@ -0,0 +1,43 @@
<svg
width="18"
height="18"
viewBox="0 0 268 268"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clip-path="url(#clip0)">
<path
d="M58.8037 109.043C64.0284 93.2355 74.1116 79.4822 87.615 69.7447C101.118
60.0073 117.352 54.783 134 54.8172C152.872 54.8172 169.934 61.5172 183.334
72.4828L222.328 33.5C198.566 12.7858 168.114 0 134 0C81.1817 0 35.711
30.1277 13.8467 74.2583L58.8037 109.043Z"
fill="#EA4335"
/>
<path
d="M179.113 201.145C166.942 208.995 151.487 213.183 134 213.183C117.418
213.217 101.246 208.034 87.7727 198.369C74.2993 188.703 64.2077 175.044
58.9265 159.326L13.8132 193.574C24.8821 215.978 42.012 234.828 63.2572
247.984C84.5024 261.14 109.011 268.075 134 268C166.752 268 198.041 256.353
221.48 234.5L179.125 201.145H179.113Z"
fill="#34A853"
/>
<path
d="M221.48 234.5C245.991 211.631 261.903 177.595 261.903 134C261.903
126.072 260.686 117.552 258.866 109.634H134V161.414H205.869C202.329
178.823 192.804 192.301 179.125 201.145L221.48 234.5Z"
fill="#4A90E2"
/>
<path
d="M58.9265 159.326C56.1947 151.162 54.8068 142.609 54.8172 134C54.8172
125.268 56.213 116.882 58.8037 109.043L13.8467 74.2584C4.64957 92.825
-0.0915078 113.28 1.86708e-05 134C1.86708e-05 155.44 4.96919 175.652
13.8132 193.574L58.9265 159.326Z"
fill="#FBBC05"
/>
</g>
<defs>
<clipPath id="clip0">
<rect width="268" height="268" fill="white" />
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -1,7 +1,8 @@
<script> <script>
import GoogleLogo from "./logos/Google.svelte" import GoogleLogo from "./_logos/Google.svelte"
import { import {
Button, Button,
Page,
Heading, Heading,
Divider, Divider,
Label, Label,
@ -9,7 +10,6 @@
Layout, Layout,
Input, Input,
Body, Body,
Page,
} from "@budibase/bbui" } from "@budibase/bbui"
import { onMount } from "svelte" import { onMount } from "svelte"
import api from "builderStore/api" import api from "builderStore/api"

View File

@ -12,6 +12,7 @@
notifications, notifications,
} from "@budibase/bbui" } from "@budibase/bbui"
import { organisation } from "stores/portal" import { organisation } from "stores/portal"
import { post } from "builderStore/api"
import analytics from "analytics" import analytics from "analytics"
let analyticsDisabled = analytics.disabled() let analyticsDisabled = analytics.disabled()
@ -24,18 +25,30 @@
} }
let loading = false let loading = false
let file
$: company = $organisation?.company async function uploadLogo() {
$: logoUrl = $organisation.logoUrl let data = new FormData()
data.append("file", file)
const res = await post("/api/admin/configs/upload/settings/logo", data, {})
return await res.json()
}
async function saveConfig() { async function saveConfig() {
loading = true loading = true
await toggleAnalytics() await toggleAnalytics()
const res = await organisation.save({ ...$organisation, company }) if (file) {
await uploadLogo()
}
const res = await organisation.save({
company: $organisation.company,
platformUrl: $organisation.platformUrl,
})
if (res.status === 200) { if (res.status === 200) {
notifications.success("General settings saved.") notifications.success("Settings saved.")
} else { } else {
notifications.danger("Error when saving settings.") notifications.error(res.message)
} }
loading = false loading = false
} }
@ -46,10 +59,9 @@
<div class="intro"> <div class="intro">
<Heading size="M">General</Heading> <Heading size="M">General</Heading>
<Body> <Body>
Lorem ipsum, dolor sit amet consectetur adipisicing elit. Hic vero, aut General is the place where you edit your organisation name, logo. You
culpa provident sunt ratione! Voluptas doloremque, dicta nisi velit can also configure your platform URL as well as turn on or off
perspiciatis, ratione vel blanditiis totam, nam voluptate repellat analytics.
aperiam fuga!
</Body> </Body>
</div> </div>
<Divider size="S" /> <Divider size="S" />
@ -59,14 +71,30 @@
<div class="fields"> <div class="fields">
<div class="field"> <div class="field">
<Label size="L">Organization name</Label> <Label size="L">Organization name</Label>
<Input thin bind:value={company} /> <Input thin bind:value={$organisation.company} />
</div> </div>
<!-- <div class="field"> <div class="field logo">
<Label>Logo</Label> <Label size="L">Logo</Label>
<div class="file"> <div class="file">
<Dropzone /> <Dropzone
value={[file]}
on:change={e => {
file = e.detail?.[0]
}}
/>
</div> </div>
</div> --> </div>
</div>
</div>
<Divider size="S" />
<div class="analytics">
<Heading size="S">Platform</Heading>
<Body>Here you can set up general platform settings.</Body>
<div class="fields">
<div class="field">
<Label size="L">Platform URL</Label>
<Input thin bind:value={$organisation.platformUrl} />
</div>
</div> </div>
</div> </div>
<Divider size="S" /> <Divider size="S" />
@ -103,6 +131,9 @@
.file { .file {
max-width: 30ch; max-width: 30ch;
} }
.logo {
align-items: start;
}
.intro { .intro {
display: grid; display: grid;
} }

View File

@ -1,4 +1,5 @@
export { organisation } from "./organisation" export { organisation } from "./organisation"
export { users } from "./users"
export { admin } from "./admin" export { admin } from "./admin"
export { apps } from "./apps" export { apps } from "./apps"
export { email } from "./email" export { email } from "./email"

View File

@ -1,35 +1,46 @@
import { writable } from "svelte/store" import { writable, get } from "svelte/store"
import api from "builderStore/api" import api from "builderStore/api"
const FALLBACK_CONFIG = {
platformUrl: "",
logoUrl: "",
docsUrl: "",
company: "http://localhost:10000",
}
export function createOrganisationStore() { export function createOrganisationStore() {
const { subscribe, set } = writable({}) const store = writable({})
const { subscribe, set } = store
async function init() { async function init() {
try { const res = await api.get(`/api/admin/configs/settings`)
const response = await api.get(`/api/admin/configs/settings`) const json = await res.json()
const json = await response.json()
set(json) if (json.status === 400) {
} catch (error) { set(FALLBACK_CONFIG)
set({ } else {
platformUrl: "", set({ ...json.config, _rev: json._rev })
logoUrl: "",
docsUrl: "",
company: "",
})
} }
} }
async function save(config) {
const res = await api.post("/api/admin/configs", {
type: "settings",
config,
_rev: get(store)._rev,
})
const json = await res.json()
if (json.status) {
return json
}
await init()
return { status: 200 }
}
return { return {
subscribe, subscribe,
save: async config => { set,
try { save,
await api.post("/api/admin/configs", { type: "settings", config })
await init()
return { status: 200 }
} catch (error) {
return { error }
}
},
init, init,
} }
} }

View File

@ -0,0 +1,65 @@
import { writable } from "svelte/store"
import api, { post } from "builderStore/api"
import { update } from "lodash"
export function createUsersStore() {
const { subscribe, set } = writable([])
async function init() {
const response = await api.get(`/api/admin/users`)
const json = await response.json()
set(json)
}
async function invite(email) {
const response = await api.post(`/api/admin/users/invite`, { email })
return await response.json()
}
async function acceptInvite(inviteCode, password) {
const response = await api.post("/api/admin/users/invite/accept", {
inviteCode,
password,
})
return await response.json()
}
async function create({ email, password }) {
const response = await api.post("/api/admin/users", {
email,
password,
builder: { global: true },
roles: {},
})
init()
return await response.json()
}
async function del(id) {
const response = await api.delete(`/api/admin/users/${id}`)
update(users => users.filter(user => user._id !== id))
return await response.json()
}
async function updateRoles(data) {
try {
const res = await post(`/api/admin/users`, data)
const json = await res.json()
return json
} catch (error) {
console.log(error)
return error
}
}
return {
subscribe,
init,
invite,
acceptInvite,
create,
updateRoles,
del,
}
}
export const users = createUsersStore()

View File

@ -1,5 +1,4 @@
const { SearchIndexes } = require("../../../db/utils") const { SearchIndexes } = require("../../../db/utils")
const { checkSlashesInUrl } = require("../../../utilities")
const env = require("../../../environment") const env = require("../../../environment")
const fetch = require("node-fetch") const fetch = require("node-fetch")
@ -10,7 +9,7 @@ const fetch = require("node-fetch")
* @returns {string} * @returns {string}
*/ */
const luceneEscape = value => { const luceneEscape = value => {
return `${value}`.replace(/[ #+\-&|!(){}\[\]^"~*?:\\]/g, "\\$&") return `${value}`.replace(/[ #+\-&|!(){}\]^"~*?:\\]/g, "\\$&")
} }
/** /**

View File

@ -118,16 +118,17 @@ exports.upload = async function (ctx) {
// add to configuration structure // add to configuration structure
// TODO: right now this only does a global level // TODO: right now this only does a global level
const db = new CouchDB(GLOBAL_DB) const db = new CouchDB(GLOBAL_DB)
let config = await getScopedFullConfig(db, { type }) let cfgStructure = await getScopedFullConfig(db, { type })
if (!config) { if (!cfgStructure) {
config = { cfgStructure = {
_id: generateConfigID({ type }), _id: generateConfigID({ type }),
config: {},
} }
} }
const url = `/${bucket}/${key}` const url = `/${bucket}/${key}`
config[`${name}Url`] = url cfgStructure.config[`${name}Url`] = url
// write back to db with url updated // write back to db with url updated
await db.put(config) await db.put(cfgStructure)
ctx.body = { ctx.body = {
message: "File has been uploaded and url stored to config.", message: "File has been uploaded and url stored to config.",

View File

@ -25,9 +25,9 @@ function smtpValidation() {
function settingValidation() { function settingValidation() {
// prettier-ignore // prettier-ignore
return Joi.object({ return Joi.object({
platformUrl: Joi.string().valid("", null), platformUrl: Joi.string().optional(),
logoUrl: Joi.string().valid("", null), logoUrl: Joi.string().optional(),
docsUrl: Joi.string().valid("", null), docsUrl: Joi.string().optional(),
company: Joi.string().required(), company: Joi.string().required(),
}).unknown(true) }).unknown(true)
} }
@ -44,9 +44,9 @@ function googleValidation() {
function buildConfigSaveValidation() { function buildConfigSaveValidation() {
// prettier-ignore // prettier-ignore
return joiValidator.body(Joi.object({ return joiValidator.body(Joi.object({
_id: Joi.string(), _id: Joi.string().optional(),
_rev: Joi.string(), _rev: Joi.string().optional(),
group: Joi.string(), group: Joi.string().optional(),
type: Joi.string().valid(...Object.values(Configs)).required(), type: Joi.string().valid(...Object.values(Configs)).required(),
config: Joi.alternatives() config: Joi.alternatives()
.conditional("type", { .conditional("type", {

View File

@ -7,9 +7,8 @@ const {
EmailTemplatePurpose, EmailTemplatePurpose,
} = require("../constants") } = require("../constants")
const { checkSlashesInUrl } = require("./index") const { checkSlashesInUrl } = require("./index")
const env = require("../environment")
const LOCAL_URL = `http://localhost:${env.PORT}` const LOCAL_URL = `http://localhost:10000`
const BASE_COMPANY = "Budibase" const BASE_COMPANY = "Budibase"
exports.getSettingsTemplateContext = async (purpose, code = null) => { exports.getSettingsTemplateContext = async (purpose, code = null) => {
@ -42,7 +41,7 @@ exports.getSettingsTemplateContext = async (purpose, code = null) => {
case EmailTemplatePurpose.INVITATION: case EmailTemplatePurpose.INVITATION:
context[InternalTemplateBindings.INVITE_CODE] = code context[InternalTemplateBindings.INVITE_CODE] = code
context[InternalTemplateBindings.INVITE_URL] = checkSlashesInUrl( context[InternalTemplateBindings.INVITE_URL] = checkSlashesInUrl(
`${URL}/invite?code=${code}` `${URL}/builder/invite?code=${code}`
) )
break break
} }