Merge branch 'next' into fixes/user-management

This commit is contained in:
Keviin Åberg Kultalahti 2021-05-20 11:02:15 +02:00
commit 0346ef9bb1
99 changed files with 2563 additions and 1254 deletions

View File

@ -31,6 +31,14 @@
"created_at": "2021-05-01T05:27:53Z", "created_at": "2021-05-01T05:27:53Z",
"repoId": 190729906, "repoId": 190729906,
"pullRequestNo": 1431 "pullRequestNo": 1431
},
{
"name": "mjashanks",
"id": 3524181,
"comment_id": 844846454,
"created_at": "2021-05-20T08:14:04Z",
"repoId": 190729906,
"pullRequestNo": 1510
} }
] ]
} }

View File

@ -34,6 +34,10 @@ exports.APP_PREFIX = DocumentTypes.APP + SEPARATOR
exports.APP_DEV_PREFIX = DocumentTypes.APP_DEV + SEPARATOR exports.APP_DEV_PREFIX = DocumentTypes.APP_DEV + SEPARATOR
exports.SEPARATOR = SEPARATOR exports.SEPARATOR = SEPARATOR
function isDevApp(app) {
return app.appId.startsWith(exports.APP_DEV_PREFIX)
}
/** /**
* If creating DB allDocs/query params with only a single top level ID this can be used, this * If creating DB allDocs/query params with only a single top level ID this can be used, this
* is usually the case as most of our docs are top level e.g. tables, automations, users and so on. * is usually the case as most of our docs are top level e.g. tables, automations, users and so on.
@ -160,7 +164,7 @@ exports.getDeployedAppID = appId => {
* different users/companies apps as there is no security around it - all apps are returned. * different users/companies apps as there is no security around it - all apps are returned.
* @return {Promise<object[]>} returns the app information document stored in each app database. * @return {Promise<object[]>} returns the app information document stored in each app database.
*/ */
exports.getAllApps = async (devApps = false) => { exports.getAllApps = async ({ dev, all } = {}) => {
const CouchDB = getCouch() const CouchDB = getCouch()
let allDbs = await CouchDB.allDbs() let allDbs = await CouchDB.allDbs()
const appDbNames = allDbs.filter(dbName => const appDbNames = allDbs.filter(dbName =>
@ -176,12 +180,19 @@ exports.getAllApps = async (devApps = false) => {
const apps = response const apps = response
.filter(result => result.status === "fulfilled") .filter(result => result.status === "fulfilled")
.map(({ value }) => value) .map(({ value }) => value)
if (!all) {
return apps.filter(app => { return apps.filter(app => {
if (devApps) { if (dev) {
return app.appId.startsWith(exports.APP_DEV_PREFIX) return isDevApp(app)
} }
return !app.appId.startsWith(exports.APP_DEV_PREFIX) return !isDevApp(app)
}) })
} else {
return apps.map(app => ({
...app,
status: isDevApp(app) ? "development" : "published",
}))
}
} }
} }

View File

@ -112,6 +112,9 @@ exports.isClient = ctx => {
* @return {Promise<object|null>} * @return {Promise<object|null>}
*/ */
exports.getGlobalUserByEmail = async email => { exports.getGlobalUserByEmail = async email => {
if (email == null) {
throw "Must supply an email address to view"
}
const db = getDB(StaticDatabases.GLOBAL.name) const db = getDB(StaticDatabases.GLOBAL.name)
try { try {
let users = ( let users = (

View File

@ -45,3 +45,9 @@
</span> </span>
<span class="spectrum-Checkbox-label">{text || ""}</span> <span class="spectrum-Checkbox-label">{text || ""}</span>
</label> </label>
<style>
.spectrum-Checkbox-input {
opacity: 0;
}
</style>

View File

@ -37,9 +37,28 @@
const fieldId = id || generateID() const fieldId = id || generateID()
let selectedImageIdx = 0 let selectedImageIdx = 0
let fileDragged = false let fileDragged = false
let selectedUrl
$: selectedImage = value?.[selectedImageIdx] ?? null $: selectedImage = value?.[selectedImageIdx] ?? null
$: fileCount = value?.length ?? 0 $: fileCount = value?.length ?? 0
$: isImage = imageExtensions.includes(selectedImage?.extension?.toLowerCase()) $: isImage =
imageExtensions.includes(selectedImage?.extension?.toLowerCase()) ||
selectedImage?.type?.startsWith("image")
$: {
if (selectedImage?.url) {
selectedUrl = selectedImage?.url
} else if (selectedImage) {
try {
let reader = new FileReader()
reader.readAsDataURL(selectedImage)
reader.onload = e => {
selectedUrl = e.target.result
}
} catch (error) {
selectedUrl = null
}
}
}
async function processFileList(fileList) { async function processFileList(fileList) {
if ( if (
@ -102,11 +121,13 @@
<div class="gallery"> <div class="gallery">
<div class="title"> <div class="title">
<div class="filename">{selectedImage.name}</div> <div class="filename">{selectedImage.name}</div>
{#if selectedImage.size}
<div class="filesize"> <div class="filesize">
{#if selectedImage.size <= BYTES_IN_MB} {#if selectedImage.size <= BYTES_IN_MB}
{`${selectedImage.size / BYTES_IN_KB} KB`} {`${selectedImage.size / BYTES_IN_KB} KB`}
{:else}{`${selectedImage.size / BYTES_IN_MB} MB`}{/if} {:else}{`${selectedImage.size / BYTES_IN_MB} MB`}{/if}
</div> </div>
{/if}
{#if !disabled} {#if !disabled}
<div class="delete-button" on:click={removeFile}> <div class="delete-button" on:click={removeFile}>
<Icon name="Close" /> <Icon name="Close" />
@ -114,7 +135,7 @@
{/if} {/if}
</div> </div>
{#if isImage} {#if isImage}
<img alt="preview" src={selectedImage.url} /> <img alt="preview" src={selectedUrl} />
{:else} {:else}
<div class="placeholder"> <div class="placeholder">
<div class="extension">{selectedImage.extension}</div> <div class="extension">{selectedImage.extension}</div>
@ -142,11 +163,13 @@
<div class="gallery"> <div class="gallery">
<div class="title"> <div class="title">
<div class="filename">{file.name}</div> <div class="filename">{file.name}</div>
{#if file.size}
<div class="filesize"> <div class="filesize">
{#if file.size <= BYTES_IN_MB} {#if file.size <= BYTES_IN_MB}
{`${file.size / BYTES_IN_KB} KB`} {`${file.size / BYTES_IN_KB} KB`}
{:else}{`${file.size / BYTES_IN_MB} MB`}{/if} {:else}{`${file.size / BYTES_IN_MB} MB`}{/if}
</div> </div>
{/if}
{#if !disabled} {#if !disabled}
<div class="delete-button" on:click={removeFile}> <div class="delete-button" on:click={removeFile}>
<Icon name="Close" /> <Icon name="Close" />

View File

@ -2,10 +2,11 @@
import "@spectrum-css/search/dist/index-vars.css" import "@spectrum-css/search/dist/index-vars.css"
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
export let value = "" export let value = null
export let placeholder = null export let placeholder = null
export let disabled = false export let disabled = false
export let id = null export let id = null
export let updateOnChange = true
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
let focus = false let focus = false
@ -23,6 +24,13 @@
updateValue(event.target.value) updateValue(event.target.value)
} }
const onInput = event => {
if (!updateOnChange) {
return
}
updateValue(event.target.value)
}
const updateValueOnEnter = event => { const updateValueOnEnter = event => {
if (event.key === "Enter") { if (event.key === "Enter") {
updateValue(event.target.value) updateValue(event.target.value)
@ -44,15 +52,18 @@
<use xlink:href="#spectrum-icon-18-Magnify" /> <use xlink:href="#spectrum-icon-18-Magnify" />
</svg> </svg>
<input <input
on:click
on:keyup={updateValueOnEnter}
{disabled} {disabled}
{id} {id}
value={value || ""} value={value || ""}
placeholder={placeholder || ""} placeholder={placeholder || ""}
on:click
on:blur
on:focus
on:input
on:blur={onBlur} on:blur={onBlur}
on:focus={onFocus} on:focus={onFocus}
on:input on:input={onInput}
on:keyup={updateValueOnEnter}
type="search" type="search"
class="spectrum-Textfield-input spectrum-Search-input" class="spectrum-Textfield-input spectrum-Search-input"
autocomplete="off" autocomplete="off"

View File

@ -26,3 +26,9 @@
<span class="spectrum-Switch-switch" /> <span class="spectrum-Switch-switch" />
<label class="spectrum-Switch-label" for={id}>{text}</label> <label class="spectrum-Switch-label" for={id}>{text}</label>
</div> </div>
<style>
.spectrum-Switch-input {
opacity: 0;
}
</style>

View File

@ -2,13 +2,14 @@
import "@spectrum-css/textfield/dist/index-vars.css" import "@spectrum-css/textfield/dist/index-vars.css"
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
export let value = "" export let value = null
export let placeholder = null export let placeholder = null
export let type = "text" export let type = "text"
export let disabled = false export let disabled = false
export let error = null export let error = null
export let id = null export let id = null
export let readonly = false export let readonly = false
export let updateOnChange = true
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
let focus = false let focus = false
@ -37,7 +38,13 @@
} }
focus = false focus = false
updateValue(event.target.value) updateValue(event.target.value)
dispatch("blur") }
const onInput = event => {
if (readonly || !updateOnChange) {
return
}
updateValue(event.target.value)
} }
const updateValueOnEnter = event => { const updateValueOnEnter = event => {
@ -66,16 +73,19 @@
</svg> </svg>
{/if} {/if}
<input <input
on:click
on:keyup={updateValueOnEnter}
{disabled} {disabled}
{readonly} {readonly}
{id} {id}
value={value || ""} value={value || ""}
placeholder={placeholder || ""} placeholder={placeholder || ""}
on:click
on:blur
on:focus
on:input
on:blur={onBlur} on:blur={onBlur}
on:focus={onFocus} on:focus={onFocus}
on:input on:input={onInput}
on:keyup={updateValueOnEnter}
{type} {type}
class="spectrum-Textfield-input" class="spectrum-Textfield-input"
/> />

View File

@ -11,6 +11,7 @@
export let disabled = false export let disabled = false
export let readonly = false export let readonly = false
export let error = null export let error = null
export let updateOnChange = true
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const onChange = e => { const onChange = e => {
@ -21,6 +22,7 @@
<Field {label} {labelPosition} {error}> <Field {label} {labelPosition} {error}>
<TextField <TextField
{updateOnChange}
{error} {error}
{disabled} {disabled}
{readonly} {readonly}
@ -31,5 +33,6 @@
on:click on:click
on:input on:input
on:blur on:blur
on:focus
/> />
</Field> </Field>

View File

@ -8,6 +8,7 @@
export let labelPosition = "above" export let labelPosition = "above"
export let placeholder = null export let placeholder = null
export let disabled = false export let disabled = false
export let updateOnChange = true
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const onChange = e => { const onChange = e => {
@ -18,11 +19,14 @@
<Field {label} {labelPosition}> <Field {label} {labelPosition}>
<Search <Search
{updateOnChange}
{disabled} {disabled}
{value} {value}
{placeholder} {placeholder}
on:change={onChange} on:change={onChange}
on:click on:click
on:input on:input
on:focus
on:blur
/> />
</Field> </Field>

View File

@ -22,6 +22,7 @@
.container { .container {
display: grid; display: grid;
grid-template-columns: 1fr; grid-template-columns: 1fr;
position: relative;
} }
.paddingX-S { .paddingX-S {
padding-left: var(--spacing-s); padding-left: var(--spacing-s);

View File

@ -15,6 +15,7 @@
max-width: 80ch; max-width: 80ch;
margin: 0 auto; margin: 0 auto;
padding: calc(var(--spacing-xl) * 2); padding: calc(var(--spacing-xl) * 2);
min-height: calc(100% - var(--spacing-xl) * 4);
} }
.wide { .wide {
@ -22,5 +23,6 @@
margin: 0; margin: 0;
padding: var(--spacing-xl) calc(var(--spacing-xl) * 2) padding: var(--spacing-xl) calc(var(--spacing-xl) * 2)
calc(var(--spacing-xl) * 2) calc(var(--spacing-xl) * 2); calc(var(--spacing-xl) * 2) calc(var(--spacing-xl) * 2);
min-height: calc(100% - var(--spacing-xl) * 3);
} }
</style> </style>

View File

@ -3,27 +3,17 @@
export let size = "M" export let size = "M"
export let serif = false export let serif = false
export let noPadding = false export let weight = null
export let textAlign export let textAlign = null
</script> </script>
<p <p
style="{textAlign ? `text-align:${textAlign}` : ``}" style={`
class:noPadding ${weight ? `font-weight:${weight};` : ""}
${textAlign ? `text-align:${textAlign};` : ""}
`}
class="spectrum-Body spectrum-Body--size{size}" class="spectrum-Body spectrum-Body--size{size}"
class:spectrum-Body--serif={serif} class:spectrum-Body--serif={serif}
> >
<slot /> <slot />
</p> </p>
<style>
p {
margin-top: 0.75em;
margin-bottom: 0.75em;
}
.noPadding {
padding: 0;
margin: 0;
}
</style>

View File

@ -8,7 +8,7 @@
</script> </script>
<h1 <h1
style="{textAlign ? `text-align:${textAlign}` : ``}" style={textAlign ? `text-align:${textAlign}` : ``}
class:noPadding class:noPadding
class="spectrum-Heading spectrum-Heading--size{size}" class="spectrum-Heading spectrum-Heading--size{size}"
> >

View File

@ -10,6 +10,7 @@
import CodeEditorModal from "./CodeEditorModal.svelte" import CodeEditorModal from "./CodeEditorModal.svelte"
import QuerySelector from "./QuerySelector.svelte" import QuerySelector from "./QuerySelector.svelte"
import QueryParamSelector from "./QueryParamSelector.svelte" import QueryParamSelector from "./QueryParamSelector.svelte"
import CronBuilder from "./CronBuilder.svelte"
import Editor from "components/integration/QueryEditor.svelte" import Editor from "components/integration/QueryEditor.svelte"
export let block export let block
@ -76,6 +77,8 @@
/> />
{:else if value.customType === "query"} {:else if value.customType === "query"}
<QuerySelector bind:value={block.inputs[key]} /> <QuerySelector bind:value={block.inputs[key]} />
{:else if value.customType === "cron"}
<CronBuilder bind:value={block.inputs[key]} />
{:else if value.customType === "queryParams"} {:else if value.customType === "queryParams"}
<QueryParamSelector bind:value={block.inputs[key]} {bindings} /> <QueryParamSelector bind:value={block.inputs[key]} {bindings} />
{:else if value.customType === "table"} {:else if value.customType === "table"}

View File

@ -0,0 +1,58 @@
<script>
import { Button, Select, Label, Heading, Input } from "@budibase/bbui"
export let value
let presets = false
const CRON_EXPRESSIONS = [
{
label: "Every Minute",
value: "* * * * *",
},
{
label: "Every Hour",
value: "0 * * * *",
},
{
label: "Every Morning at 8AM",
value: "0 8 * * *",
},
{
label: "Every Night at Midnight",
value: "0 0 * * *",
},
{
label: "Every Budibase Reboot",
value: "@reboot",
},
]
</script>
<div class="block-field">
<Input bind:value />
<div class="presets">
<Button on:click={() => (presets = !presets)}
>{presets ? "Hide" : "Show"} Presets</Button
>
{#if presets}
<Select
bind:value
secondary
extraThin
label="Presets"
options={CRON_EXPRESSIONS}
/>
{/if}
</div>
</div>
<style>
.presets {
margin-top: var(--spacing-m);
}
.block-field {
padding-top: var(--spacing-s);
}
</style>

View File

@ -57,10 +57,12 @@
> >
<CreateColumnButton /> <CreateColumnButton />
{#if schema && Object.keys(schema).length > 0} {#if schema && Object.keys(schema).length > 0}
{#if !isUsersTable}
<CreateRowButton <CreateRowButton
title={isUsersTable ? "Create user" : "Create row"} title={"Create row"}
modalContentComponent={isUsersTable ? CreateEditUser : CreateEditRow} modalContentComponent={CreateEditRow}
/> />
{/if}
<CreateViewButton /> <CreateViewButton />
<ManageAccessButton resourceId={$tables.selected?._id} /> <ManageAccessButton resourceId={$tables.selected?._id} />
{#if isUsersTable} {#if isUsersTable}

View File

@ -47,6 +47,8 @@
}) })
schema.email.displayName = "Email" schema.email.displayName = "Email"
schema.roleId.displayName = "Role" schema.roleId.displayName = "Role"
schema.firstName.displayName = "First Name"
schema.lastName.displayName = "Last Name"
if (schema.status) { if (schema.status) {
schema.status.displayName = "Status" schema.status.displayName = "Status"
} }
@ -101,7 +103,7 @@
</div> </div>
<div class="popovers"> <div class="popovers">
<slot /> <slot />
{#if selectedRows.length > 0} {#if !isUsersTable && selectedRows.length > 0}
<DeleteRowsButton {selectedRows} {deleteRows} /> <DeleteRowsButton {selectedRows} {deleteRows} />
{/if} {/if}
</div> </div>
@ -114,7 +116,7 @@
{customRenderers} {customRenderers}
{rowCount} {rowCount}
bind:selectedRows bind:selectedRows
allowSelectRows={allowEditing} allowSelectRows={allowEditing && !isUsersTable}
allowEditRows={allowEditing} allowEditRows={allowEditing}
allowEditColumns={allowEditing} allowEditColumns={allowEditing}
showAutoColumns={!hideAutocolumns} showAutoColumns={!hideAutocolumns}

View File

@ -32,6 +32,8 @@
delete customSchema["email"] delete customSchema["email"]
delete customSchema["roleId"] delete customSchema["roleId"]
delete customSchema["status"] delete customSchema["status"]
delete customSchema["firstName"]
delete customSchema["lastName"]
return Object.entries(customSchema) return Object.entries(customSchema)
} }
@ -67,7 +69,7 @@
return false return false
} }
notifications.success("User saved successfully.") notifications.success("User saved successfully")
rows.save(rowResponse) rows.save(rowResponse)
} }
</script> </script>
@ -84,8 +86,14 @@
readonly={!creating} readonly={!creating}
/> />
<RowFieldControl <RowFieldControl
meta={{ name: "password", type: "password" }} meta={{ ...tableSchema.firstName, name: "First Name" }}
bind:value={row.password} bind:value={row.firstName}
readonly={!creating}
/>
<RowFieldControl
meta={{ ...tableSchema.lastName, name: "Last Name" }}
bind:value={row.lastName}
readonly={!creating}
/> />
<!-- Defer rendering this select until roles load, otherwise the initial <!-- Defer rendering this select until roles load, otherwise the initial
selection is always undefined --> selection is always undefined -->
@ -97,16 +105,6 @@
getOptionLabel={role => role.name} getOptionLabel={role => role.name}
getOptionValue={role => role._id} getOptionValue={role => role._id}
/> />
<Select
label="Status"
bind:value={row.status}
options={[
{ label: "Active", value: "active" },
{ label: "Inactive", value: "inactive" },
]}
getOptionLabel={status => status.label}
getOptionValue={status => status.value}
/>
{#each customSchemaKeys as [key, meta]} {#each customSchemaKeys as [key, meta]}
{#if !meta.autocolumn} {#if !meta.autocolumn}
<RowFieldControl {meta} bind:value={row[key]} {creating} /> <RowFieldControl {meta} bind:value={row[key]} {creating} />

View File

@ -1,5 +1,5 @@
<script> <script>
import { Select, Label } from "@budibase/bbui" import { Select } from "@budibase/bbui"
import { notifications } from "@budibase/bbui" import { notifications } from "@budibase/bbui"
import { FIELDS } from "constants/backend" import { FIELDS } from "constants/backend"
import api from "builderStore/api" import api from "builderStore/api"
@ -99,15 +99,19 @@
const typeOptions = [ const typeOptions = [
{ {
label: "Text", label: "Text",
value: "string", value: FIELDS.STRING.type,
}, },
{ {
label: "Number", label: "Number",
value: "number", value: FIELDS.NUMBER.type,
}, },
{ {
label: "Date", label: "Date",
value: "datetime", value: FIELDS.DATETIME.type,
},
{
label: "Options",
value: FIELDS.OPTIONS.type,
}, },
] ]
</script> </script>

View File

@ -3,7 +3,14 @@
import { store } from "builderStore" import { store } from "builderStore"
import { tables } from "stores/backend" import { tables } from "stores/backend"
import { notifications } from "@budibase/bbui" import { notifications } from "@budibase/bbui"
import { Input, Label, ModalContent, Toggle, Divider } from "@budibase/bbui" import {
Input,
Label,
ModalContent,
Toggle,
Divider,
Layout,
} from "@budibase/bbui"
import TableDataImport from "../TableDataImport.svelte" import TableDataImport from "../TableDataImport.svelte"
import analytics from "analytics" import analytics from "analytics"
import screenTemplates from "builderStore/store/screenTemplates" import screenTemplates from "builderStore/store/screenTemplates"
@ -123,8 +130,10 @@
bind:value={createAutoscreens} bind:value={createAutoscreens}
/> />
<div> <div>
<Layout gap="XS" noPadding>
<Label grey extraSmall>Create Table from CSV (Optional)</Label> <Label grey extraSmall>Create Table from CSV (Optional)</Label>
<TableDataImport bind:dataImport /> <TableDataImport bind:dataImport />
</Layout>
</div> </div>
</ModalContent> </ModalContent>

View File

@ -12,7 +12,7 @@
FAILURE: "FAILURE", FAILURE: "FAILURE",
} }
const POLL_INTERVAL = 1000 const POLL_INTERVAL = 10000
let loading = false let loading = false
let feedbackModal let feedbackModal
@ -61,7 +61,6 @@
// Required to check any updated deployment statuses between polls // Required to check any updated deployment statuses between polls
function checkIncomingDeploymentStatus(current, incoming) { function checkIncomingDeploymentStatus(current, incoming) {
console.log(current, incoming)
for (let incomingDeployment of incoming) { for (let incomingDeployment of incoming) {
if ( if (
incomingDeployment.status === DeploymentStatus.FAILURE || incomingDeployment.status === DeploymentStatus.FAILURE ||

View File

@ -76,6 +76,7 @@
this={control} this={control}
{componentInstance} {componentInstance}
value={safeValue} value={safeValue}
updateOnChange={false}
on:change={handleChange} on:change={handleChange}
onChange={handleChange} onChange={handleChange}
{type} {type}

View File

@ -1,13 +1,13 @@
<script> <script>
import { import {
notifications,
Input, Input,
Button, Button,
Layout, Layout,
Body, Body,
Heading, Heading,
notifications,
} from "@budibase/bbui" } from "@budibase/bbui"
import { auth } from "stores/backend" import { organisation, auth } from "stores/portal"
let email = "" let email = ""
@ -25,17 +25,19 @@
<div class="main"> <div class="main">
<Layout> <Layout>
<Layout noPadding justifyItems="center"> <Layout noPadding justifyItems="center">
<img src="https://i.imgur.com/ZKyklgF.png" /> <img src={$organisation.logoUrl || "https://i.imgur.com/ZKyklgF.png"} />
</Layout> </Layout>
<Layout gap="XS" noPadding> <Layout gap="XS" noPadding>
<Heading textAlign="center">Forgotten your password?</Heading> <Heading textAlign="center">Forgotten your password?</Heading>
<Body size="S" textAlign="center"> <Body size="S" textAlign="center">
No problem! Just enter your account's email address and we'll send No problem! Just enter your account's email address and we'll send you
you a link to reset it. a link to reset it.
</Body> </Body>
<Input label="Email" bind:value={email} /> <Input label="Email" bind:value={email} />
</Layout> </Layout>
<Button cta on:click={forgot} disabled={!email}>Reset your password</Button> <Button cta on:click={forgot} disabled={!email}>
Reset your password
</Button>
</Layout> </Layout>
</div> </div>
</div> </div>

View File

@ -1,16 +1,16 @@
<script> <script>
import { Link } from "@budibase/bbui" import { ActionButton } from "@budibase/bbui"
import GoogleLogo from "/assets/google-logo.png" import GoogleLogo from "/assets/google-logo.png"
</script> </script>
<div class="outer"> <ActionButton>
<Link target="_blank" href="/api/admin/auth/google"> <a target="_blank" href="/api/admin/auth/google">
<div class="inner"> <div class="inner">
<img src={GoogleLogo} alt="google icon" /> <img src={GoogleLogo} alt="google icon" />
<p>Sign in with Google</p> <p>Sign in with Google</p>
</div> </div>
</Link> </a>
</div> </ActionButton>
<style> <style>
.outer { .outer {
@ -34,10 +34,8 @@
.inner p { .inner p {
margin: 0; margin: 0;
} }
.outer :global(a) { a {
text-decoration: none; text-decoration: none;
font-weight: 500; color: inherit;
font-size: var(--font-size-m);
color: #fff;
} }
</style> </style>

View File

@ -11,7 +11,7 @@
Heading, Heading,
} from "@budibase/bbui" } from "@budibase/bbui"
import GoogleButton from "./GoogleButton.svelte" import GoogleButton from "./GoogleButton.svelte"
import { auth } from "stores/backend" import { organisation, auth } from "stores/portal"
let username = "" let username = ""
let password = "" let password = ""
@ -35,12 +35,12 @@
<div class="main"> <div class="main">
<Layout> <Layout>
<Layout noPadding justifyItems="center"> <Layout noPadding justifyItems="center">
<img src="https://i.imgur.com/ZKyklgF.png" /> <img src={$organisation.logoUrl || "https://i.imgur.com/ZKyklgF.png"} />
<Heading>Sign in to Budibase</Heading> <Heading>Sign in to Budibase</Heading>
</Layout> </Layout>
<GoogleButton /> <GoogleButton />
<Layout gap="XS" noPadding>
<Divider noGrid /> <Divider noGrid />
<Layout gap="XS" noPadding>
<Body size="S" textAlign="center">Sign in with email</Body> <Body size="S" textAlign="center">Sign in with email</Body>
<Input label="Email" bind:value={username} /> <Input label="Email" bind:value={username} />
<Input <Input
@ -50,7 +50,7 @@
bind:value={password} bind:value={password}
/> />
</Layout> </Layout>
<Layout gap="S" noPadding> <Layout gap="XS" noPadding>
<Button cta on:click={login}>Sign in to Budibase</Button> <Button cta on:click={login}>Sign in to Budibase</Button>
<ActionButton quiet on:click={() => $goto("./forgot")}> <ActionButton quiet on:click={() => $goto("./forgot")}>
Forgot password? Forgot password?

View File

@ -1,8 +1,8 @@
<script> <script>
import { Button, Layout, Body, Heading, notifications } from "@budibase/bbui" import { notifications, Button, Layout, Body, Heading } from "@budibase/bbui"
import { organisation, auth } from "stores/portal"
import PasswordRepeatInput from "components/common/users/PasswordRepeatInput.svelte" import PasswordRepeatInput from "components/common/users/PasswordRepeatInput.svelte"
import { params, goto } from "@roxi/routify" import { params, goto } from "@roxi/routify"
import { auth } from "stores/backend"
const resetCode = $params["?code"] const resetCode = $params["?code"]
let password, error let password, error
@ -16,7 +16,6 @@
} catch (err) { } catch (err) {
notifications.error("Unable to reset password") notifications.error("Unable to reset password")
} }
} }
</script> </script>
@ -24,7 +23,7 @@
<div class="main"> <div class="main">
<Layout> <Layout>
<Layout noPadding justifyItems="center"> <Layout noPadding justifyItems="center">
<img src="https://i.imgur.com/ZKyklgF.png" /> <img src={$organisation.logoUrl || "https://i.imgur.com/ZKyklgF.png"} />
</Layout> </Layout>
<Layout gap="XS" noPadding> <Layout gap="XS" noPadding>
<Heading textAlign="center">Reset your password</Heading> <Heading textAlign="center">Reset your password</Heading>
@ -33,7 +32,9 @@
</Body> </Body>
<PasswordRepeatInput bind:password bind:error /> <PasswordRepeatInput bind:password bind:error />
</Layout> </Layout>
<Button cta on:click={reset} disabled={error || !resetCode}>Reset your password</Button> <Button cta on:click={reset} disabled={error || !resetCode}>
Reset your password
</Button>
</Layout> </Layout>
</div> </div>
</div> </div>

View File

@ -6,12 +6,9 @@
Layout, Layout,
ActionMenu, ActionMenu,
MenuItem, MenuItem,
Link,
} from "@budibase/bbui" } from "@budibase/bbui"
import { gradient } from "actions" import { gradient } from "actions"
import { AppStatus } from "constants" import { auth } from "stores/portal"
import { url } from "@roxi/routify"
import { auth } from "stores/backend"
export let app export let app
export let exportApp export let exportApp
@ -25,11 +22,11 @@
<Layout noPadding gap="XS" alignContent="start"> <Layout noPadding gap="XS" alignContent="start">
<div class="preview" use:gradient={{ seed: app.name }} /> <div class="preview" use:gradient={{ seed: app.name }} />
<div class="title"> <div class="title">
<Link on:click={() => openApp(app)}> <div class="name" on:click={() => openApp(app)}>
<Heading size="XS"> <Heading size="XS">
{app.name} {app.name}
</Heading> </Heading>
</Link> </div>
<ActionMenu align="right"> <ActionMenu align="right">
<Icon slot="control" name="More" hoverable /> <Icon slot="control" name="More" hoverable />
<MenuItem on:click={() => exportApp(app)} icon="Download"> <MenuItem on:click={() => exportApp(app)} icon="Download">
@ -48,7 +45,7 @@
</ActionMenu> </ActionMenu>
</div> </div>
<div class="status"> <div class="status">
<Body noPadding size="S"> <Body size="S">
Edited {Math.floor(1 + Math.random() * 10)} months ago Edited {Math.floor(1 + Math.random() * 10)} months ago
</Body> </Body>
{#if app.lockedBy} {#if app.lockedBy}
@ -76,7 +73,7 @@
align-items: center; align-items: center;
} }
.title :global(a) { .name {
text-decoration: none; text-decoration: none;
flex: 1 1 auto; flex: 1 1 auto;
width: 0; width: 0;

View File

@ -1,16 +1,7 @@
<script> <script>
import { gradient } from "actions" import { gradient } from "actions"
import { import { Heading, Button, Icon, ActionMenu, MenuItem } from "@budibase/bbui"
Heading, import { auth } from "stores/portal"
Button,
Icon,
ActionMenu,
MenuItem,
Link,
} from "@budibase/bbui"
import { AppStatus } from "constants"
import { url } from "@roxi/routify"
import { auth } from "stores/backend"
export let app export let app
export let openApp export let openApp
@ -23,19 +14,24 @@
<div class="title" class:last> <div class="title" class:last>
<div class="preview" use:gradient={{ seed: app.name }} /> <div class="preview" use:gradient={{ seed: app.name }} />
<Link on:click={() => openApp(app)}> <div class="name" on:click={() => openApp(app)}>
<Heading size="XS"> <Heading size="XS">
{app.name} {app.name}
</Heading> </Heading>
</Link> </div>
</div> </div>
<div class:last> <div class:last>
Edited {Math.round(Math.random() * 10 + 1)} months ago Edited {Math.round(Math.random() * 10 + 1)} months ago
</div> </div>
<div class:last> <div class:last>
{#if app.lockedBy} {#if app.lockedBy}
{#if app.lockedBy.email === $auth.user.email}
<div class="status status--locked-you" /> <div class="status status--locked-you" />
Locked by you
{:else}
<div class="status status--locked-other" />
Locked by {app.lockedBy.email} Locked by {app.lockedBy.email}
{/if}
{:else} {:else}
<div class="status status--open" /> <div class="status status--open" />
Open Open
@ -63,7 +59,7 @@
width: 40px; width: 40px;
border-radius: var(--border-radius-s); border-radius: var(--border-radius-s);
} }
.title :global(a) { .name {
text-decoration: none; text-decoration: none;
} }
.title :global(h1:hover) { .title :global(h1:hover) {

View File

@ -15,7 +15,14 @@ export const AppStatus = {
} }
// fields on the user table that cannot be edited // fields on the user table that cannot be edited
export const UNEDITABLE_USER_FIELDS = ["email", "password", "roleId", "status"] export const UNEDITABLE_USER_FIELDS = [
"email",
"password",
"roleId",
"status",
"firstName",
"lastName",
]
export const LAYOUT_NAMES = { export const LAYOUT_NAMES = {
MASTER: { MASTER: {

View File

@ -1,8 +1,7 @@
<script> <script>
import { onMount } from "svelte" import { onMount } from "svelte"
import { goto, isActive } from "@roxi/routify" import { isActive, redirect } from "@roxi/routify"
import { auth } from "stores/backend" import { admin, auth } from "stores/portal"
import { admin } from "stores/portal"
let loaded = false let loaded = false
$: hasAdminUser = !!$admin?.checklist?.adminUser $: hasAdminUser = !!$admin?.checklist?.adminUser
@ -16,7 +15,7 @@
// Force creation of an admin user if one doesn't exist // Force creation of an admin user if one doesn't exist
$: { $: {
if (loaded && !hasAdminUser) { if (loaded && !hasAdminUser) {
$goto("./admin") $redirect("./admin")
} }
} }
@ -29,7 +28,7 @@
!$isActive("./auth") && !$isActive("./auth") &&
!$isActive("./invite") !$isActive("./invite")
) { ) {
$goto("./auth/login") $redirect("./auth/login")
} }
} }
</script> </script>

View File

@ -0,0 +1,19 @@
<script>
import { admin } from "stores/portal"
import { onMount } from "svelte"
import { redirect } from "@roxi/routify"
let loaded = false
onMount(() => {
if ($admin?.checklist?.adminUser) {
$redirect("../")
} else {
loaded = true
}
})
</script>
{#if loaded}
<slot />
{/if}

View File

@ -9,7 +9,7 @@
} from "@budibase/bbui" } from "@budibase/bbui"
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import api from "builderStore/api" import api from "builderStore/api"
import { admin } from "stores/portal" import { admin, organisation } from "stores/portal"
let adminUser = {} let adminUser = {}
@ -32,22 +32,22 @@
<section> <section>
<div class="container"> <div class="container">
<Layout gap="XS"> <Layout>
<img src="https://i.imgur.com/ZKyklgF.png" /> <img src={$organisation.logoUrl || "https://i.imgur.com/ZKyklgF.png"} />
</Layout> <Layout gap="XS" justifyItems="center" noPadding>
<div class="center">
<Layout gap="XS">
<Heading size="M">Create an admin user</Heading> <Heading size="M">Create an admin user</Heading>
<Body size="M" <Body size="M" textAlign="center">
>The admin user has access to everything in Budibase.</Body The admin user has access to everything in Budibase.
> </Body>
</Layout> </Layout>
</div> <Layout gap="XS" noPadding>
<Layout gap="XS">
<Input label="Email" bind:value={adminUser.email} /> <Input label="Email" bind:value={adminUser.email} />
<Input label="Password" type="password" bind:value={adminUser.password} /> <Input
label="Password"
type="password"
bind:value={adminUser.password}
/>
</Layout> </Layout>
<Layout gap="S">
<Button cta on:click={save}>Create super admin user</Button> <Button cta on:click={save}>Create super admin user</Button>
</Layout> </Layout>
</div> </div>
@ -68,9 +68,6 @@
justify-content: flex-start; justify-content: flex-start;
align-items: stretch; align-items: stretch;
} }
.center {
text-align: center;
}
img { img {
width: 40px; width: 40px;
margin: 0 auto; margin: 0 auto;

View File

@ -1,19 +1,7 @@
<script> <script>
import { store, automationStore } from "builderStore" import { store, automationStore } from "builderStore"
import { roles } from "stores/backend" import { roles } from "stores/backend"
import { import { Button, Icon, ActionGroup, Tabs, Tab } from "@budibase/bbui"
Button,
Icon,
Modal,
ModalContent,
ActionGroup,
ActionButton,
Tabs,
Tab,
} from "@budibase/bbui"
import SettingsLink from "components/settings/Link.svelte"
import ThemeEditorDropdown from "components/settings/ThemeEditorDropdown.svelte"
import FeedbackNavLink from "components/feedback/FeedbackNavLink.svelte"
import DeployModal from "components/deploy/DeployModal.svelte" import DeployModal from "components/deploy/DeployModal.svelte"
import RevertModal from "components/deploy/RevertModal.svelte" import RevertModal from "components/deploy/RevertModal.svelte"
import { get } from "builderStore/api" import { get } from "builderStore/api"

View File

@ -0,0 +1,7 @@
<script>
import { auth } from "stores/portal"
</script>
{#if $auth.user}
<slot />
{/if}

View File

@ -0,0 +1,27 @@
<script>
import { ModalContent, Body, notifications } from "@budibase/bbui"
import PasswordRepeatInput from "components/common/users/PasswordRepeatInput.svelte"
import { auth } from "stores/portal"
let password
let error
const updatePassword = async () => {
try {
await auth.updateSelf({ ...$auth.user, password })
notifications.success("Information updated successfully")
} catch (error) {
notifications.error("Failed to update password")
}
}
</script>
<ModalContent
title="Update password"
confirmText="Update password"
onConfirm={updatePassword}
disabled={error || !password}
>
<Body size="S">Enter your new password below.</Body>
<PasswordRepeatInput bind:password bind:error />
</ModalContent>

View File

@ -0,0 +1,31 @@
<script>
import { ModalContent, Body, Input, notifications } from "@budibase/bbui"
import { writable } from "svelte/store"
import { auth } from "stores/portal"
const values = writable({
firstName: $auth.user.firstName,
lastName: $auth.user.lastName,
})
const updateInfo = async () => {
try {
await auth.updateSelf({ ...$auth.user, ...$values })
notifications.success("Information updated successfully")
} catch (error) {
notifications.error("Failed to update information")
}
}
</script>
<ModalContent
title="Update user information"
confirmText="Update information"
onConfirm={updateInfo}
>
<Body size="S">
Personalise the platform by adding your first name and last name.
</Body>
<Input bind:value={$values.firstName} label="First name" />
<Input bind:value={$values.lastName} label="Last name" />
</ModalContent>

View File

@ -0,0 +1,7 @@
<script>
import { auth } from "stores/portal"
</script>
{#if $auth.user}
<slot />
{/if}

View File

@ -0,0 +1,163 @@
<script>
import {
Heading,
Layout,
Select,
Divider,
ActionMenu,
MenuItem,
Avatar,
Page,
Icon,
Body,
Modal,
} from "@budibase/bbui"
import { onMount } from "svelte"
import { apps, organisation, auth } from "stores/portal"
import { goto } from "@roxi/routify"
import { AppStatus } from "constants"
import { gradient } from "actions"
import UpdateUserInfoModal from "./_components/UpdateUserInfoModal.svelte"
import ChangePasswordModal from "./_components/ChangePasswordModal.svelte"
let loaded = false
let userInfoModal
let changePasswordModal
onMount(async () => {
await organisation.init()
await apps.load(AppStatus.DEV)
loaded = true
})
</script>
{#if loaded}
<div class="container">
<Page>
<div class="content">
<Layout noPadding>
<img src={$organisation.logoUrl} />
<div class="info-title">
<Layout noPadding gap="XS">
<Heading size="L">
Hey {$auth.user.firstName || $auth.user.email}
</Heading>
<Body>
Welcome to the {$organisation.company} portal. Below you'll find
the list of apps that you have access to.
</Body>
</Layout>
<ActionMenu align="right">
<div slot="control" class="avatar">
<Avatar size="M" name="John Doe" />
<Icon size="XL" name="ChevronDown" />
</div>
<MenuItem icon="UserEdit" on:click={() => userInfoModal.show()}>
Update user information
</MenuItem>
<MenuItem
icon="LockClosed"
on:click={() => changePasswordModal.show()}
>
Update password
</MenuItem>
<MenuItem
icon="UserDeveloper"
on:click={() => $goto("../portal")}
>
Open developer mode
</MenuItem>
<MenuItem icon="LogOut" on:click={auth.logout}>Log out</MenuItem>
</ActionMenu>
</div>
<Divider />
{#if $apps.length}
<Heading>Apps</Heading>
<div class="group">
<Layout gap="S" noPadding>
{#each $apps as app, idx (app.appId)}
<a class="app" target="_blank" href={`/${app.appId}`}>
<div class="preview" use:gradient={{ seed: app.name }} />
<div class="app-info">
<Heading size="XS">{app.name}</Heading>
<Body size="S">
Edited {Math.round(Math.random() * 10 + 1)} months ago
</Body>
</div>
<Icon name="ChevronRight" />
</a>
{/each}
</Layout>
</div>
{:else}
<Layout gap="XS" noPadding>
<Heading size="S">You don't have access to any apps yet.</Heading>
<Body size="S">
The apps you have access to will be listed here.
</Body>
</Layout>
{/if}
</Layout>
</div>
</Page>
</div>
<Modal bind:this={userInfoModal}>
<UpdateUserInfoModal />
</Modal>
<Modal bind:this={changePasswordModal}>
<ChangePasswordModal />
</Modal>
{/if}
<style>
.container {
height: 100%;
overflow: auto;
}
.content {
padding: 60px 0;
width: 100%;
}
img {
width: 40px;
margin-bottom: -12px;
}
.info-title {
display: grid;
grid-template-columns: 1fr auto;
grid-gap: var(--spacing-xl);
}
.avatar {
display: grid;
grid-template-columns: auto auto;
place-items: center;
grid-gap: var(--spacing-xs);
}
.avatar:hover {
cursor: pointer;
filter: brightness(110%);
}
.group {
margin-top: var(--spacing-s);
}
.app {
display: grid;
grid-template-columns: auto 1fr auto;
background-color: var(--background);
padding: var(--spacing-xl);
border-radius: var(--border-radius-s);
align-items: center;
grid-gap: var(--spacing-xl);
color: inherit;
}
.app:hover {
cursor: pointer;
background: var(--spectrum-global-color-gray-200);
transition: background-color 130ms ease-in-out;
}
.preview {
height: 40px;
width: 60px;
border-radius: var(--border-radius-s);
}
</style>

View File

@ -1,4 +1,4 @@
<script> <script>
import { goto } from "@roxi/routify" import { redirect } from "@roxi/routify"
$goto("./login") $redirect("./login")
</script> </script>

View File

@ -1,4 +1,14 @@
<script> <script>
import { goto } from "@roxi/routify" import { redirect } from "@roxi/routify"
$goto("./portal") import { auth } from "stores/portal"
$: {
if (!$auth.user) {
$redirect("./auth/login")
} else if ($auth.user.builder?.global) {
$redirect("./portal")
} else {
$redirect("./apps")
}
}
</script> </script>

View File

@ -1,8 +1,8 @@
<script> <script>
import { Layout, Heading, Body, Button, notifications } from "@budibase/bbui" import { Layout, Heading, Body, Button, notifications } from "@budibase/bbui"
import { goto, params } from "@roxi/routify" import { goto, params } from "@roxi/routify"
import { users, organisation } from "stores/portal"
import PasswordRepeatInput from "components/common/users/PasswordRepeatInput.svelte" import PasswordRepeatInput from "components/common/users/PasswordRepeatInput.svelte"
import { users } from "stores/portal"
const inviteCode = $params["?code"] const inviteCode = $params["?code"]
let password, error let password, error
@ -23,19 +23,18 @@
<section> <section>
<div class="container"> <div class="container">
<Layout gap="XS"> <Layout>
<img src="https://i.imgur.com/ZKyklgF.png" /> <img src={$organisation.logoUrl || "https://i.imgur.com/ZKyklgF.png"} />
<Layout gap="XS" justifyItems="center" noPadding>
<Heading size="M">Accept Invitation</Heading>
<Body textAlign="center" size="M">
Please enter a password to set up your user.
</Body>
</Layout> </Layout>
<Layout gap="XS">
<Heading textAlign="center" size="M">Accept Invitation</Heading>
<Body textAlign="center" size="S"
>Please enter a password to setup your user.</Body
>
<PasswordRepeatInput bind:error bind:password /> <PasswordRepeatInput bind:error bind:password />
</Layout> <Button disabled={error} cta on:click={acceptInvite}>
<Layout gap="S"> Accept invite
<Button disabled={error} cta on:click={acceptInvite}>Accept invite</Button </Button>
>
</Layout> </Layout>
</div> </div>
</section> </section>

View File

@ -1,5 +1,5 @@
<script> <script>
import { isActive, goto } from "@roxi/routify" import { isActive, redirect, goto } from "@roxi/routify"
import { import {
Icon, Icon,
Avatar, Avatar,
@ -12,15 +12,14 @@
Modal, Modal,
} from "@budibase/bbui" } from "@budibase/bbui"
import ConfigChecklist from "components/common/ConfigChecklist.svelte" import ConfigChecklist from "components/common/ConfigChecklist.svelte"
import { organisation } from "stores/portal" import { organisation, auth } from "stores/portal"
import { auth } from "stores/backend"
import BuilderSettingsModal from "components/start/BuilderSettingsModal.svelte" import BuilderSettingsModal from "components/start/BuilderSettingsModal.svelte"
import { onMount } from "svelte"
let oldSettingsModal let oldSettingsModal
let loaded = false
organisation.init() const 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/manage/users", heading: "Manage" }, { title: "Users", href: "/builder/portal/manage/users", heading: "Manage" },
@ -35,9 +34,20 @@
{ title: "Theming", href: "/builder/portal/theming" }, { title: "Theming", href: "/builder/portal/theming" },
{ title: "Account", href: "/builder/portal/account" }, { title: "Account", href: "/builder/portal/account" },
] ]
onMount(async () => {
// Prevent non-builders from accessing the portal
if (!$auth.user?.builder?.global) {
$redirect("../")
} else {
await organisation.init()
loaded = true
}
})
</script> </script>
<div class="container"> {#if loaded}
<div class="container">
<div class="nav"> <div class="nav">
<Layout paddingX="L" paddingY="L"> <Layout paddingX="L" paddingY="L">
<div class="branding"> <div class="branding">
@ -72,6 +82,9 @@
<MenuItem icon="Settings" on:click={oldSettingsModal.show}> <MenuItem icon="Settings" on:click={oldSettingsModal.show}>
Old settings Old settings
</MenuItem> </MenuItem>
<MenuItem icon="UserDeveloper" on:click={() => $goto("../apps")}>
Close developer mode
</MenuItem>
<MenuItem icon="LogOut" on:click={auth.logout}>Log out</MenuItem> <MenuItem icon="LogOut" on:click={auth.logout}>Log out</MenuItem>
</ActionMenu> </ActionMenu>
</div> </div>
@ -79,10 +92,11 @@
<slot /> <slot />
</div> </div>
</div> </div>
</div> </div>
<Modal bind:this={oldSettingsModal} width="30%"> <Modal bind:this={oldSettingsModal} width="30%">
<BuilderSettingsModal /> <BuilderSettingsModal />
</Modal> </Modal>
{/if}
<style> <style>
.container { .container {

View File

@ -17,8 +17,7 @@
import api, { del } from "builderStore/api" import api, { del } from "builderStore/api"
import analytics from "analytics" import analytics from "analytics"
import { onMount } from "svelte" import { onMount } from "svelte"
import { apps } from "stores/portal" import { apps, auth } from "stores/portal"
import { auth } from "stores/backend"
import download from "downloadjs" import download from "downloadjs"
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import ConfirmDialog from "components/common/ConfirmDialog.svelte" import ConfirmDialog from "components/common/ConfirmDialog.svelte"
@ -99,8 +98,7 @@
if (!appToDelete) { if (!appToDelete) {
return return
} }
await del(`/api/applications/${appToDelete?.appId}`)
await del(`/api/applications/${appToDelete?._id}`)
await apps.load() await apps.load()
appToDelete = null appToDelete = null
notifications.success("App deleted successfully.") notifications.success("App deleted successfully.")
@ -160,7 +158,7 @@
/> />
</ActionGroup> </ActionGroup>
</div> </div>
{#if $apps.length} {#if loaded && $apps.length}
<div <div
class:appGrid={layout === "grid"} class:appGrid={layout === "grid"}
class:appTable={layout === "table"} class:appTable={layout === "table"}
@ -230,7 +228,7 @@
} }
.select { .select {
width: 120px; width: 150px;
} }
.appGrid { .appGrid {
@ -248,7 +246,7 @@
height: 70px; height: 70px;
display: grid; display: grid;
align-items: center; align-items: center;
gap: var(--spacing-xl); grid-gap: var(--spacing-xl);
grid-template-columns: auto 1fr; grid-template-columns: auto 1fr;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;

View File

@ -1,4 +1,4 @@
<script> <script>
import { goto } from "@roxi/routify" import { redirect } from "@roxi/routify"
$goto("./apps") $redirect("./apps")
</script> </script>

View File

@ -59,44 +59,42 @@
}) })
</script> </script>
<Page> <Layout>
<Layout noPadding> <Layout gap="XS" noPadding>
<div>
<Heading size="M">OAuth</Heading> <Heading size="M">OAuth</Heading>
<Body> <Body>
Every budibase app comes with basic authentication (email/password) Every budibase app comes with basic authentication (email/password)
included. You can add additional authentication methods from the options included. You can add additional authentication methods from the options
below. below.
</Body> </Body>
</div> </Layout>
<Divider />
{#if google} {#if google}
<div> <Divider />
<Layout gap="XS" noPadding>
<Heading size="S"> <Heading size="S">
<span> <span>
<GoogleLogo /> <GoogleLogo />
Google Google
</span> </span>
</Heading> </Heading>
<Body> <Body size="S">
To allow users to authenticate using their Google accounts, fill out To allow users to authenticate using their Google accounts, fill out the
the fields below. fields below.
</Body> </Body>
</div> </Layout>
<Layout gap="XS" noPadding>
{#each ConfigFields.Google as field} {#each ConfigFields.Google as field}
<div class="form-row"> <div class="form-row">
<Label size="L">{field}</Label> <Label size="L">{field}</Label>
<Input bind:value={google.config[field]} /> <Input bind:value={google.config[field]} />
</div> </div>
{/each} {/each}
<div>
<Button primary on:click={() => save(google)}>Save</Button>
</div>
<Divider />
{/if}
</Layout> </Layout>
</Page> <div>
<Button cta on:click={() => save(google)}>Save</Button>
</div>
{/if}
</Layout>
<style> <style>
.form-row { .form-row {

View File

@ -16,7 +16,7 @@
{#each bindings as binding} {#each bindings as binding}
<MenuItem on:click={() => onBindingClick(binding)}> <MenuItem on:click={() => onBindingClick(binding)}>
<Detail size="M">{binding.name}</Detail> <Detail size="M">{binding.name}</Detail>
<Body size="XS" noPadding>{binding.description}</Body> <Body size="XS">{binding.description}</Body>
</MenuItem> </MenuItem>
{/each} {/each}
</Menu> </Menu>

View File

@ -102,59 +102,57 @@
}) })
</script> </script>
<Page> <Layout>
<header> <Layout noPadding gap="XS">
<Heading size="M">Email</Heading> <Heading size="M">Email</Heading>
<Body size="S"> <Body>
Sending email is not required, but highly recommended for processes such Sending email is not required, but highly recommended for processes such
as password recovery. To setup automated auth emails, simply add the as password recovery. To setup automated auth emails, simply add the
values below and click activate. values below and click activate.
</Body> </Body>
</header> </Layout>
<Divider /> <Divider />
{#if smtpConfig} {#if smtpConfig}
<div class="config-form"> <Layout gap="XS" noPadding>
<Heading size="S">SMTP</Heading> <Heading size="S">SMTP</Heading>
<Body size="S"> <Body size="S">
To allow your app to benefit from automated auth emails, add your SMTP To allow your app to benefit from automated auth emails, add your SMTP
details below. details below.
</Body> </Body>
<Layout gap="S"> </Layout>
<Heading size="S"> <Layout gap="XS" noPadding>
<span />
</Heading>
<div class="form-row"> <div class="form-row">
<Label>Host</Label> <Label size="L">Host</Label>
<Input bind:value={smtpConfig.config.host} /> <Input bind:value={smtpConfig.config.host} />
</div> </div>
<div class="form-row"> <div class="form-row">
<Label>Port</Label> <Label size="L">Port</Label>
<Input type="number" bind:value={smtpConfig.config.port} /> <Input type="number" bind:value={smtpConfig.config.port} />
</div> </div>
<div class="form-row"> <div class="form-row">
<Label>User</Label> <Label size="L">User</Label>
<Input bind:value={smtpConfig.config.auth.user} /> <Input bind:value={smtpConfig.config.auth.user} />
</div> </div>
<div class="form-row"> <div class="form-row">
<Label>Password</Label> <Label size="L">Password</Label>
<Input type="password" bind:value={smtpConfig.config.auth.pass} /> <Input type="password" bind:value={smtpConfig.config.auth.pass} />
</div> </div>
<div class="form-row"> <div class="form-row">
<Label>From email address</Label> <Label size="L">From email address</Label>
<Input type="email" bind:value={smtpConfig.config.from} /> <Input type="email" bind:value={smtpConfig.config.from} />
</div> </div>
</Layout> </Layout>
<div>
<Button cta on:click={saveSmtp}>Save</Button> <Button cta on:click={saveSmtp}>Save</Button>
</div> </div>
<Divider /> <Divider />
<Layout gap="XS" noPadding>
<div class="config-form">
<Heading size="S">Templates</Heading> <Heading size="S">Templates</Heading>
<Body size="S"> <Body size="S">
Budibase comes out of the box with ready-made email templates to help Budibase comes out of the box with ready-made email templates to help
with user onboarding. Please refrain from changing the links. with user onboarding. Please refrain from changing the links.
</Body> </Body>
</div> </Layout>
<Table <Table
{customRenderers} {customRenderers}
data={$email.templates} data={$email.templates}
@ -165,27 +163,13 @@
allowEditColumns={false} allowEditColumns={false}
/> />
{/if} {/if}
</Page> </Layout>
<style> <style>
.config-form {
margin-top: 42px;
margin-bottom: 42px;
}
.form-row { .form-row {
display: grid; display: grid;
grid-template-columns: 20% 1fr; grid-template-columns: 25% 1fr;
grid-gap: var(--spacing-l); grid-gap: var(--spacing-l);
}
span {
display: flex;
align-items: center; align-items: center;
gap: var(--spacing-s);
}
header {
margin-bottom: 42px;
} }
</style> </style>

View File

@ -1,43 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -1,115 +0,0 @@
<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

@ -64,29 +64,31 @@
} }
async function openUpdateRolesModal({ detail }) { async function openUpdateRolesModal({ detail }) {
console.log(detail)
selectedApp = detail selectedApp = detail
editRolesModal.show() editRolesModal.show()
} }
</script> </script>
<Layout noPadding gap="XS"> <Layout noPadding>
<Layout gap="XS" noPadding>
<div class="back"> <div class="back">
<ActionButton on:click={() => $goto("./")} quiet size="S" icon="BackAndroid" <ActionButton
>Back to users</ActionButton on:click={() => $goto("./")}
quiet
size="S"
icon="BackAndroid"
> >
Back to users
</ActionButton>
</div> </div>
<div class="heading">
<Layout noPadding gap="XS">
<Heading>User: {$userFetch?.data?.email}</Heading> <Heading>User: {$userFetch?.data?.email}</Heading>
<Body <Body>
>Change user settings and update their app roles. Also contains the Change user settings and update their app roles. Also contains the ability
ability to delete the user as well as force reset their password. to delete the user as well as force reset their password..
</Body> </Body>
</Layout> </Layout>
</div>
<Divider size="S" /> <Divider size="S" />
<div class="general"> <Layout gap="S" noPadding>
<Heading size="S">General</Heading> <Heading size="S">General</Heading>
<div class="fields"> <div class="fields">
<div class="field"> <div class="field">
@ -97,6 +99,14 @@
<Label size="L">Group(s)</Label> <Label size="L">Group(s)</Label>
<Select disabled options={["All users"]} value="All users" /> <Select disabled options={["All users"]} value="All users" />
</div> </div>
<div class="field">
<Label size="L">First name</Label>
<Input disabled thin value={$userFetch?.data?.firstName} />
</div>
<div class="field">
<Label size="L">Last name</Label>
<Input disabled thin value={$userFetch?.data?.lastName} />
</div>
<div class="field"> <div class="field">
<Label size="L">Development access?</Label> <Label size="L">Development access?</Label>
<Toggle <Toggle
@ -115,9 +125,9 @@
on:click={resetPasswordModal.show}>Force password reset</ActionButton on:click={resetPasswordModal.show}>Force password reset</ActionButton
> >
</div> </div>
</div> </Layout>
<Divider size="S" /> <Divider size="S" />
<div class="roles"> <Layout gap="S" noPadding>
<Heading size="S">Configure roles</Heading> <Heading size="S">Configure roles</Heading>
<Table <Table
on:click={openUpdateRolesModal} on:click={openUpdateRolesModal}
@ -128,16 +138,14 @@
allowSelectRows={false} allowSelectRows={false}
customRenderers={[{ column: "role", component: TagsRenderer }]} customRenderers={[{ column: "role", component: TagsRenderer }]}
/> />
</div> </Layout>
<Divider size="S" /> <Divider size="S" />
<div class="delete"> <Layout gap="XS" noPadding>
<Layout gap="S" noPadding <Heading size="S">Delete user</Heading>
><Heading size="S">Delete user</Heading>
<Body>Deleting a user completely removes them from your account.</Body> <Body>Deleting a user completely removes them from your account.</Body>
</Layout>
<div class="delete-button"> <div class="delete-button">
<Button warning on:click={deleteUserModal.show}>Delete user</Button> <Button warning on:click={deleteUserModal.show}>Delete user</Button>
</div></Layout
>
</div> </div>
</Layout> </Layout>
@ -150,10 +158,9 @@
cancelText="Cancel" cancelText="Cancel"
showCloseIcon={false} showCloseIcon={false}
> >
<Body <Body>
>Are you sure you want to delete <strong>{$userFetch?.data?.email}</strong Are you sure you want to delete <strong>{$userFetch?.data?.email}</strong>
></Body </Body>
>
</ModalContent> </ModalContent>
</Modal> </Modal>
<Modal bind:this={editRolesModal}> <Modal bind:this={editRolesModal}>
@ -174,26 +181,12 @@
.fields { .fields {
display: grid; display: grid;
grid-gap: var(--spacing-m); grid-gap: var(--spacing-m);
margin-top: var(--spacing-xl);
} }
.field { .field {
display: grid; display: grid;
grid-template-columns: 32% 1fr; grid-template-columns: 32% 1fr;
align-items: center; 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 { .regenerate {
position: absolute; position: absolute;
top: 0; top: 0;

View File

@ -30,18 +30,18 @@
<ModalContent <ModalContent
onConfirm={createUserFlow} onConfirm={createUserFlow}
size="M" size="M"
title="Add new user options" title="Add new user"
confirmText="Add user" confirmText="Add user"
confirmDisabled={disabled} confirmDisabled={disabled}
cancelText="Cancel" cancelText="Cancel"
disabled={$error} disabled={$error}
showCloseIcon={false} showCloseIcon={false}
> >
<Body noPadding <Body size="S">
>If you have SMTP configured and an email for the new user, you can use the 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 automated email onboarding flow. Otherwise, use our basic onboarding process
with autogenerated passwords.</Body with autogenerated passwords.
> </Body>
<Select <Select
placeholder={null} placeholder={null}
bind:value={selected} bind:value={selected}

View File

@ -26,10 +26,10 @@
error={$touched && $error} error={$touched && $error}
showCloseIcon={false} showCloseIcon={false}
> >
<Body noPadding <Body size="S">
>Below you will find the users username and password. The password will not Below you will find the users username and password. The password will not
be accessible from this point. Please download the credentials.</Body be accessible from this point. Please save the credentials.
> </Body>
<Input <Input
type="email" type="email"
label="Username" label="Username"

View File

@ -4,8 +4,9 @@
const displayLimit = 5 const displayLimit = 5
$: tags = value?.slice(0, displayLimit) ?? [] $: roles = value?.filter(role => role != null) ?? []
$: leftover = (value?.length ?? 0) - tags.length $: tags = roles.slice(0, displayLimit)
$: leftover = roles.length - tags.length
</script> </script>
<Tags> <Tags>

View File

@ -10,7 +10,7 @@
const roles = app.roles const roles = app.roles
let options = roles.map(role => role._id) let options = roles.map(role => role._id)
let selectedRole let selectedRole = user?.roles?.[app?._id]
async function updateUserRoles() { async function updateUserRoles() {
const res = await users.save({ const res = await users.save({
@ -23,7 +23,7 @@
if (res.status === 400) { if (res.status === 400) {
notifications.error("Failed to update role") notifications.error("Failed to update role")
} else { } else {
notifications.success("Roles updated") notifications.success("Role updated")
dispatch("update") dispatch("update")
} }
} }
@ -31,20 +31,20 @@
<ModalContent <ModalContent
onConfirm={updateUserRoles} onConfirm={updateUserRoles}
title="Update App Roles" title="Update App Role"
confirmText="Update roles" confirmText="Update role"
cancelText="Cancel" cancelText="Cancel"
size="M" size="M"
showCloseIcon={false} showCloseIcon={false}
> >
<Body noPadding <Body>
>Update {user.email}'s roles for <strong>{app.name}</strong>.</Body Update {user.email}'s role for <strong>{app.name}</strong>.
> </Body>
<Select <Select
placeholder={null} placeholder={null}
bind:value={selectedRole} bind:value={selectedRole}
on:change on:change
{options} {options}
label="Select roles:" label="Role"
/> />
</ModalContent> </ModalContent>

View File

@ -48,28 +48,27 @@
</script> </script>
<Layout> <Layout>
<div class="heading"> <Layout gap="XS" noPadding>
<Heading>Users</Heading> <Heading>Users</Heading>
<Body <Body>
>Users are the common denominator in Budibase. Each user is assigned to a 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 group that contains apps and permissions. In this section, you can add
users, or edit and delete an existing user.</Body users, or edit and delete an existing user.
> </Body>
</div> </Layout>
<Divider size="S" /> <Divider size="S" />
<Layout gap="S" noPadding>
<div class="users"> <div class="users-heading">
<Heading size="S">Users</Heading> <Heading size="S">Users</Heading>
<ButtonGroup>
<Button disabled secondary>Import users</Button>
<Button primary on:click={createUserModal.show}>Add user</Button>
</ButtonGroup>
</div>
<div class="field"> <div class="field">
<Label size="L">Search / filter</Label> <Label size="L">Search / filter</Label>
<Search bind:value={search} placeholder="" /> <Search bind:value={search} placeholder="" />
</div> </div>
<div class="buttons">
<ButtonGroup>
<Button disabled secondary>Import users</Button>
<Button overBackground on:click={createUserModal.show}>Add user</Button>
</ButtonGroup>
</div>
<Table <Table
on:click={({ detail }) => $goto(`./${detail._id}`)} on:click={({ detail }) => $goto(`./${detail._id}`)}
{schema} {schema}
@ -79,31 +78,28 @@
allowSelectRows={false} allowSelectRows={false}
customRenderers={[{ column: "group", component: TagsRenderer }]} customRenderers={[{ column: "group", component: TagsRenderer }]}
/> />
</div> </Layout>
</Layout> </Layout>
<Modal bind:this={createUserModal} <Modal bind:this={createUserModal}>
><AddUserModal on:change={openBasicOnoboardingModal} /></Modal <AddUserModal on:change={openBasicOnoboardingModal} />
> </Modal>
<Modal bind:this={basicOnboardingModal}><BasicOnboardingModal {email} /></Modal> <Modal bind:this={basicOnboardingModal}><BasicOnboardingModal {email} /></Modal>
<style> <style>
.users {
position: relative;
}
.field { .field {
display: flex; display: flex;
align-items: center; align-items: center;
flex-direction: row; flex-direction: row;
grid-gap: var(--spacing-m); grid-gap: var(--spacing-m);
margin: var(--spacing-xl) 0;
} }
.field > :global(*) + :global(*) { .field > :global(*) + :global(*) {
margin-left: var(--spacing-m); margin-left: var(--spacing-m);
} }
.buttons { .users-heading {
position: absolute; display: flex;
top: 0; flex-direction: row;
right: 0; justify-content: space-between;
align-items: center;
} }
</style> </style>

View File

@ -1,43 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -1,114 +0,0 @@
<script>
import GoogleLogo from "./_logos/Google.svelte"
import {
Button,
Page,
Heading,
Divider,
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 cta 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

@ -14,118 +14,126 @@
import { organisation } from "stores/portal" import { organisation } from "stores/portal"
import { post } from "builderStore/api" import { post } from "builderStore/api"
import analytics from "analytics" import analytics from "analytics"
let analyticsDisabled = analytics.disabled() import { writable } from "svelte/store"
function toggleAnalytics() {
if (analyticsDisabled) {
analytics.optIn()
} else {
analytics.optOut()
}
}
const values = writable({
analytics: !analytics.disabled(),
company: $organisation.company,
platformUrl: $organisation.platformUrl,
logo: $organisation.logoUrl
? { url: $organisation.logoUrl, type: "image", name: "Logo" }
: null,
})
let loading = false let loading = false
let file
async function uploadLogo() { async function uploadLogo(file) {
let data = new FormData() let data = new FormData()
data.append("file", file) data.append("file", file)
const res = await post("/api/admin/configs/upload/settings/logo", data, {}) const res = await post("/api/admin/configs/upload/settings/logo", data, {})
return await res.json() return await res.json()
} }
async function saveConfig() { async function saveConfig() {
loading = true loading = true
await toggleAnalytics()
if (file) { // Set analytics preference
await uploadLogo() if ($values.analytics) {
analytics.optIn()
} else {
analytics.optOut()
} }
// Upload logo if required
if ($values.logo && !$values.logo.url) {
await uploadLogo($values.logo)
await organisation.init()
}
// Update settings
const res = await organisation.save({ const res = await organisation.save({
company: $organisation.company, company: $values.company ?? "",
platformUrl: $organisation.platformUrl, platformUrl: $values.platformUrl ?? "",
}) })
if (res.status === 200) { if (res.status === 200) {
notifications.success("Settings saved.") notifications.success("Settings saved successfully")
} else { } else {
notifications.error(res.message) notifications.error(res.message)
} }
loading = false loading = false
} }
</script> </script>
<div class="container"> <Layout>
<Layout noPadding> <Layout gap="XS" noPadding>
<div class="intro">
<Heading size="M">General</Heading> <Heading size="M">General</Heading>
<Body> <Body>
General is the place where you edit your organisation name, logo. You General is the place where you edit your organisation name, logo. You can
can also configure your platform URL as well as turn on or off also configure your platform URL as well as turn on or off analytics.
analytics.
</Body> </Body>
</div> </Layout>
<Divider size="S" /> <Divider size="S" />
<div class="information"> <Layout gap="XS" noPadding>
<Heading size="S">Information</Heading> <Heading size="S">Information</Heading>
<Body>Here you can update your logo and organization name.</Body> <Body size="S">Here you can update your logo and organization name.</Body>
</Layout>
<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={$organisation.company} /> <Input thin bind:value={$values.company} />
</div> </div>
<div class="field logo"> <div class="field logo">
<Label size="L">Logo</Label> <Label size="L">Logo</Label>
<div class="file"> <div class="file">
<Dropzone <Dropzone
value={[file]} value={[$values.logo]}
on:change={e => { on:change={e => {
file = e.detail?.[0] $values.logo = e.detail?.[0]
}} }}
/> />
</div> </div>
</div> </div>
</div> </div>
</div>
<Divider size="S" /> <Divider size="S" />
<div class="analytics"> <Layout gap="XS" noPadding>
<Heading size="S">Platform</Heading> <Heading size="S">Platform</Heading>
<Body>Here you can set up general platform settings.</Body> <Body size="S">Here you can set up general platform settings.</Body>
</Layout>
<div class="fields"> <div class="fields">
<div class="field"> <div class="field">
<Label size="L">Platform URL</Label> <Label size="L">Platform URL</Label>
<Input thin bind:value={$organisation.platformUrl} /> <Input thin bind:value={$values.platformUrl} />
</div>
</div> </div>
</div> </div>
<Divider size="S" /> <Divider size="S" />
<div class="analytics"> <Layout gap="S" noPadding>
<Layout gap="XS" noPadding>
<Heading size="S">Analytics</Heading> <Heading size="S">Analytics</Heading>
<Body> <Body size="S">
If you would like to send analytics that help us make Budibase better, If you would like to send analytics that help us make Budibase better,
please let us know below. please let us know below.
</Body> </Body>
</Layout>
<div class="fields"> <div class="fields">
<div class="field"> <div class="field">
<Label size="L">Send Analytics to Budibase</Label> <Label size="L">Send Analytics to Budibase</Label>
<Toggle text="" value={!analyticsDisabled} /> <Toggle text="" bind:value={$values.analytics} />
</div> </div>
</div> </div>
</div>
<div class="save">
<Button disabled={loading} on:click={saveConfig} cta>Save</Button>
</div>
</Layout> </Layout>
</div> <div>
<Button disabled={loading} on:click={saveConfig} cta>Save</Button>
</div>
</Layout>
<style> <style>
.fields { .fields {
display: grid; display: grid;
grid-gap: var(--spacing-m); grid-gap: var(--spacing-m);
margin-top: var(--spacing-xl);
} }
.field { .field {
display: grid; display: grid;
grid-template-columns: 32% 1fr; grid-template-columns: 33% 1fr;
align-items: center; align-items: center;
} }
.file { .file {
@ -134,10 +142,4 @@
.logo { .logo {
align-items: start; align-items: start;
} }
.intro {
display: grid;
}
.save {
margin-left: auto;
}
</style> </style>

View File

@ -1,4 +1,4 @@
<script> <script>
import { goto } from "@roxi/routify" import { redirect } from "@roxi/routify"
$goto("./builder") $redirect("./builder")
</script> </script>

View File

@ -7,4 +7,3 @@ export { roles } from "./roles"
export { datasources } from "./datasources" export { datasources } from "./datasources"
export { integrations } from "./integrations" export { integrations } from "./integrations"
export { queries } from "./queries" export { queries } from "./queries"
export { auth } from "./auth"

View File

@ -7,7 +7,7 @@ export function createAuthStore() {
return { return {
subscribe: store.subscribe, subscribe: store.subscribe,
checkAuth: async () => { checkAuth: async () => {
const response = await api.get("/api/self") const response = await api.get("/api/admin/users/self")
const user = await response.json() const user = await response.json()
if (response.status === 200) { if (response.status === 200) {
store.update(state => ({ ...state, user })) store.update(state => ({ ...state, user }))
@ -33,6 +33,14 @@ export function createAuthStore() {
await response.json() await response.json()
store.update(state => ({ ...state, user: null })) store.update(state => ({ ...state, user: null }))
}, },
updateSelf: async user => {
const response = await api.post("/api/admin/users/self", user)
if (response.status === 200) {
store.update(state => ({ ...state, user: { ...state.user, ...user } }))
} else {
throw "Unable to update user details"
}
},
forgotPassword: async email => { forgotPassword: async email => {
const response = await api.post(`/api/admin/auth/reset`, { const response = await api.post(`/api/admin/auth/reset`, {
email, email,

View File

@ -3,3 +3,4 @@ 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"
export { auth } from "./auth"

View File

@ -1,15 +1,15 @@
import { writable, get } from "svelte/store" import { writable, get } from "svelte/store"
import api from "builderStore/api" import api from "builderStore/api"
const FALLBACK_CONFIG = { const DEFAULT_CONFIG = {
platformUrl: "", platformUrl: "http://localhost:1000",
logoUrl: "", logoUrl: "https://i.imgur.com/ZKyklgF.png",
docsUrl: "", docsUrl: undefined,
company: "http://localhost:10000", company: "Budibase",
} }
export function createOrganisationStore() { export function createOrganisationStore() {
const store = writable({}) const store = writable(DEFAULT_CONFIG)
const { subscribe, set } = store const { subscribe, set } = store
async function init() { async function init() {
@ -17,16 +17,16 @@ export function createOrganisationStore() {
const json = await res.json() const json = await res.json()
if (json.status === 400) { if (json.status === 400) {
set(FALLBACK_CONFIG) set(DEFAULT_CONFIG)
} else { } else {
set({ ...json.config, _rev: json._rev }) set({ ...DEFAULT_CONFIG, ...json.config, _rev: json._rev })
} }
} }
async function save(config) { async function save(config) {
const res = await api.post("/api/admin/configs", { const res = await api.post("/api/admin/configs", {
type: "settings", type: "settings",
config, config: { ...get(store), ...config },
_rev: get(store)._rev, _rev: get(store)._rev,
}) })
const json = await res.json() const json = await res.json()

View File

@ -117,13 +117,17 @@ async function createInstance(template) {
} }
exports.fetch = async function (ctx) { exports.fetch = async function (ctx) {
const isDev = ctx.query && ctx.query.status === AppStatus.DEV const dev = ctx.query && ctx.query.status === AppStatus.DEV
const apps = await getAllApps(isDev) const all = ctx.query && ctx.query.status === AppStatus.ALL
const apps = await getAllApps({ dev, all })
// get the locks for all the dev apps // get the locks for all the dev apps
if (isDev) { if (dev || all) {
const locks = await getAllLocks() const locks = await getAllLocks()
for (let app of apps) { for (let app of apps) {
if (app.status !== "development") {
continue
}
const lock = locks.find(lock => lock.appId === app.appId) const lock = locks.find(lock => lock.appId === app.appId)
if (lock) { if (lock) {
app.lockedBy = lock.user app.lockedBy = lock.user
@ -210,7 +214,7 @@ exports.create = async function (ctx) {
exports.update = async function (ctx) { exports.update = async function (ctx) {
const url = await getAppUrlIfNotInUse(ctx) const url = await getAppUrlIfNotInUse(ctx)
const db = new CouchDB(ctx.params.appId) const db = new CouchDB(ctx.params.appId)
const application = await db.get(ctx.params.appId) const application = await db.get(DocumentTypes.APP_METADATA)
const data = ctx.request.body const data = ctx.request.body
const newData = { ...application, ...data, url } const newData = { ...application, ...data, url }
@ -231,7 +235,7 @@ exports.update = async function (ctx) {
exports.delete = async function (ctx) { exports.delete = async function (ctx) {
const db = new CouchDB(ctx.params.appId) const db = new CouchDB(ctx.params.appId)
const app = await db.get(ctx.params.appId) const app = await db.get(DocumentTypes.APP_METADATA)
const result = await db.destroy() const result = await db.destroy()
/* istanbul ignore next */ /* istanbul ignore next */
if (!env.isTest()) { if (!env.isTest()) {

View File

@ -6,6 +6,8 @@ const webhooks = require("./webhook")
const { getAutomationParams, generateAutomationID } = require("../../db/utils") const { getAutomationParams, generateAutomationID } = require("../../db/utils")
const WH_STEP_ID = triggers.BUILTIN_DEFINITIONS.WEBHOOK.stepId const WH_STEP_ID = triggers.BUILTIN_DEFINITIONS.WEBHOOK.stepId
const CRON_STEP_ID = triggers.BUILTIN_DEFINITIONS.CRON.stepId
/************************* /*************************
* * * *
* BUILDER FUNCTIONS * * BUILDER FUNCTIONS *
@ -32,6 +34,46 @@ function cleanAutomationInputs(automation) {
return automation return automation
} }
/**
* This function handles checking of any cron jobs need to be created or deleted for automations.
* @param {string} appId The ID of the app in which we are checking for webhooks
* @param {object|undefined} oldAuto The old automation object if updating/deleting
* @param {object|undefined} newAuto The new automation object if creating/updating
*/
async function checkForCronTriggers({ appId, oldAuto, newAuto }) {
const oldTrigger = oldAuto ? oldAuto.definition.trigger : null
const newTrigger = newAuto ? newAuto.definition.trigger : null
function isCronTrigger(auto) {
return (
auto &&
auto.definition.trigger &&
auto.definition.trigger.stepId === CRON_STEP_ID
)
}
const isLive = auto => auto && auto.live
const cronTriggerRemoved =
isCronTrigger(oldAuto) && !isCronTrigger(newAuto) && oldTrigger.cronJobId
const cronTriggerDeactivated = !isLive(newAuto) && isLive(oldAuto)
const cronTriggerActivated = isLive(newAuto) && !isLive(oldAuto)
if (cronTriggerRemoved || cronTriggerDeactivated) {
await triggers.automationQueue.removeRepeatableByKey(oldTrigger.cronJobId)
}
// need to create cron job
else if (isCronTrigger(newAuto) && cronTriggerActivated) {
const job = await triggers.automationQueue.add(
{ automation: newAuto, event: { appId } },
{ repeat: { cron: newTrigger.inputs.cron } }
)
// Assign cron job ID from bull so we can remove it later if the cron trigger is removed
newTrigger.cronJobId = job.id
}
return newAuto
}
/** /**
* This function handles checking if any webhooks need to be created or deleted for automations. * This function handles checking if any webhooks need to be created or deleted for automations.
* @param {string} appId The ID of the app in which we are checking for webhooks * @param {string} appId The ID of the app in which we are checking for webhooks
@ -111,6 +153,10 @@ exports.create = async function (ctx) {
appId: ctx.appId, appId: ctx.appId,
newAuto: automation, newAuto: automation,
}) })
automation = await checkForCronTriggers({
appId: ctx.appId,
newAuto: automation,
})
const response = await db.put(automation) const response = await db.put(automation)
automation._rev = response.rev automation._rev = response.rev
@ -135,6 +181,11 @@ exports.update = async function (ctx) {
oldAuto: oldAutomation, oldAuto: oldAutomation,
newAuto: automation, newAuto: automation,
}) })
automation = await checkForCronTriggers({
appId: ctx.appId,
oldAuto: oldAutomation,
newAuto: automation,
})
const response = await db.put(automation) const response = await db.put(automation)
automation._rev = response.rev automation._rev = response.rev
@ -171,6 +222,10 @@ exports.destroy = async function (ctx) {
appId: ctx.appId, appId: ctx.appId,
oldAuto: oldAutomation, oldAuto: oldAutomation,
}) })
await checkForCronTriggers({
appId: ctx.appId,
oldAuto: oldAutomation,
})
ctx.body = await db.remove(ctx.params.id, ctx.params.rev) ctx.body = await db.remove(ctx.params.id, ctx.params.rev)
} }

View File

@ -56,13 +56,28 @@ async function findRow(ctx, db, tableId, rowId) {
exports.patch = async function (ctx) { exports.patch = async function (ctx) {
const appId = ctx.appId const appId = ctx.appId
const db = new CouchDB(appId) const db = new CouchDB(appId)
let dbRow = await db.get(ctx.params.rowId) const inputs = ctx.request.body
let dbTable = await db.get(dbRow.tableId) const tableId = inputs.tableId
const patchfields = ctx.request.body const isUserTable = tableId === InternalTables.USER_METADATA
let dbRow
try {
dbRow = await db.get(ctx.params.rowId)
} catch (err) {
if (isUserTable) {
// don't include the rev, it'll be the global rev
// this time
dbRow = {
_id: inputs._id,
}
} else {
ctx.throw(400, "Row does not exist")
}
}
let dbTable = await db.get(tableId)
// need to build up full patch fields before coerce // need to build up full patch fields before coerce
for (let key of Object.keys(patchfields)) { for (let key of Object.keys(inputs)) {
if (!dbTable.schema[key]) continue if (!dbTable.schema[key]) continue
dbRow[key] = patchfields[key] dbRow[key] = inputs[key]
} }
// this returns the table and row incase they have been updated // this returns the table and row incase they have been updated
@ -90,13 +105,9 @@ exports.patch = async function (ctx) {
table, table,
}) })
// TODO remove special user case in future if (isUserTable) {
if (row.tableId === InternalTables.USER_METADATA) {
// the row has been updated, need to put it into the ctx // the row has been updated, need to put it into the ctx
ctx.request.body = { ctx.request.body = row
...row,
password: ctx.request.body.password,
}
await userController.updateMetadata(ctx) await userController.updateMetadata(ctx)
return return
} }
@ -129,13 +140,10 @@ exports.save = async function (ctx) {
// if the row obj had an _id then it will have been retrieved // if the row obj had an _id then it will have been retrieved
if (inputs._id && inputs._rev) { if (inputs._id && inputs._rev) {
const existingRow = await db.get(inputs._id)
if (existingRow) {
ctx.params.rowId = inputs._id ctx.params.rowId = inputs._id
await exports.patch(ctx) await exports.patch(ctx)
return return
} }
}
if (!inputs._rev && !inputs._id) { if (!inputs._rev && !inputs._id) {
inputs._id = generateRowID(inputs.tableId) inputs._id = generateRowID(inputs.tableId)
@ -167,14 +175,6 @@ exports.save = async function (ctx) {
table, table,
}) })
// TODO remove special user case in future
if (row.tableId === InternalTables.USER_METADATA) {
// the row has been updated, need to put it into the ctx
ctx.request.body = row
await userController.createMetadata(ctx)
return
}
row.type = "row" row.type = "row"
const response = await db.put(row) const response = await db.put(row)
// don't worry about rev, tables handle rev/lastID updates // don't worry about rev, tables handle rev/lastID updates

View File

@ -6,7 +6,7 @@ const {
InternalTables, InternalTables,
} = require("../../../db/utils") } = require("../../../db/utils")
const { isEqual } = require("lodash/fp") const { isEqual } = require("lodash/fp")
const { AutoFieldSubTypes } = require("../../../constants") const { AutoFieldSubTypes, FieldTypes } = require("../../../constants")
const { inputProcessing } = require("../../../utilities/rowProcessor") const { inputProcessing } = require("../../../utilities/rowProcessor")
const { USERS_TABLE_SCHEMA } = require("../../../constants") const { USERS_TABLE_SCHEMA } = require("../../../constants")
@ -72,18 +72,21 @@ exports.handleDataImport = async (appId, user, table, dataImport) => {
row._id = generateRowID(table._id) row._id = generateRowID(table._id)
row.tableId = table._id row.tableId = table._id
const processed = inputProcessing(user, table, row) const processed = inputProcessing(user, table, row)
row = processed.row
// these auto-fields will never actually link anywhere (always builder)
for (let [fieldName, schema] of Object.entries(table.schema)) {
if (
schema.autocolumn &&
(schema.subtype === AutoFieldSubTypes.CREATED_BY ||
schema.subtype === AutoFieldSubTypes.UPDATED_BY)
) {
delete row[fieldName]
}
}
table = processed.table table = processed.table
row = processed.row
for (let [fieldName, schema] of Object.entries(table.schema)) {
// check whether the options need to be updated for inclusion as part of the data import
if (
schema.type === FieldTypes.OPTIONS &&
(!schema.constraints.inclusion ||
schema.constraints.inclusion.indexOf(row[fieldName]) === -1)
) {
schema.constraints.inclusion = [
...schema.constraints.inclusion,
row[fieldName],
]
}
}
data[i] = row data[i] = row
} }

View File

@ -2,17 +2,23 @@ const CouchDB = require("../../db")
const { const {
generateUserMetadataID, generateUserMetadataID,
getUserMetadataParams, getUserMetadataParams,
getGlobalIDFromUserMetadataID,
} = require("../../db/utils") } = require("../../db/utils")
const { InternalTables } = require("../../db/utils") const { InternalTables } = require("../../db/utils")
const { getRole, BUILTIN_ROLE_IDS } = require("@budibase/auth/roles") const { BUILTIN_ROLE_IDS } = require("@budibase/auth/roles")
const { const {
getGlobalUsers, getGlobalUsers,
saveGlobalUser, addAppRoleToUser,
deleteGlobalUser,
} = require("../../utilities/workerRequests") } = require("../../utilities/workerRequests")
const { getFullUser } = require("../../utilities/users") const { getFullUser } = require("../../utilities/users")
function removeGlobalProps(user) {
// make sure to always remove some of the global user props
delete user.password
delete user.roles
delete user.builder
return user
}
exports.fetchMetadata = async function (ctx) { exports.fetchMetadata = async function (ctx) {
const database = new CouchDB(ctx.appId) const database = new CouchDB(ctx.appId)
const global = await getGlobalUsers(ctx, ctx.appId) const global = await getGlobalUsers(ctx, ctx.appId)
@ -38,43 +44,12 @@ exports.fetchMetadata = async function (ctx) {
ctx.body = users ctx.body = users
} }
exports.createMetadata = async function (ctx) {
const appId = ctx.appId
const db = new CouchDB(appId)
const { roleId } = ctx.request.body
if (ctx.request.body._id) {
return exports.updateMetadata(ctx)
}
// check role valid
const role = await getRole(appId, roleId)
if (!role) ctx.throw(400, "Invalid Role")
const globalUser = await saveGlobalUser(ctx, appId, ctx.request.body)
const user = {
...globalUser,
_id: generateUserMetadataID(globalUser._id),
type: "user",
tableId: InternalTables.USER_METADATA,
}
const response = await db.post(user)
// for automations to make it obvious was successful
ctx.status = 200
ctx.body = {
_id: response.id,
_rev: response.rev,
email: ctx.request.body.email,
}
}
exports.updateSelfMetadata = async function (ctx) { exports.updateSelfMetadata = async function (ctx) {
// overwrite the ID with current users // overwrite the ID with current users
ctx.request.body._id = ctx.user._id ctx.request.body._id = ctx.user._id
if (ctx.user.builder && ctx.user.builder.global) { if (ctx.user.builder && ctx.user.builder.global) {
ctx.request.body.roleId = BUILTIN_ROLE_IDS.ADMIN // specific case, update self role in global user
await addAppRoleToUser(ctx, ctx.appId, BUILTIN_ROLE_IDS.ADMIN)
} }
// make sure no stale rev // make sure no stale rev
delete ctx.request.body._rev delete ctx.request.body._rev
@ -84,23 +59,19 @@ exports.updateSelfMetadata = async function (ctx) {
exports.updateMetadata = async function (ctx) { exports.updateMetadata = async function (ctx) {
const appId = ctx.appId const appId = ctx.appId
const db = new CouchDB(appId) const db = new CouchDB(appId)
const user = ctx.request.body const user = removeGlobalProps(ctx.request.body)
const globalUser = await saveGlobalUser(ctx, appId, { if (user.roleId) {
...user, await addAppRoleToUser(ctx, appId, user.roleId, user._id)
_id: getGlobalIDFromUserMetadataID(user._id), }
})
const metadata = { const metadata = {
...globalUser,
tableId: InternalTables.USER_METADATA, tableId: InternalTables.USER_METADATA,
_id: user._id || generateUserMetadataID(globalUser._id), ...user,
_rev: user._rev,
} }
ctx.body = await db.put(metadata) ctx.body = await db.put(metadata)
} }
exports.destroyMetadata = async function (ctx) { exports.destroyMetadata = async function (ctx) {
const db = new CouchDB(ctx.appId) const db = new CouchDB(ctx.appId)
await deleteGlobalUser(ctx, getGlobalIDFromUserMetadataID(ctx.params.id))
try { try {
const dbUser = await db.get(ctx.params.id) const dbUser = await db.get(ctx.params.id)
await db.remove(dbUser._id, dbUser._rev) await db.remove(dbUser._id, dbUser._rev)
@ -108,7 +79,7 @@ exports.destroyMetadata = async function (ctx) {
// error just means the global user has no config in this app // error just means the global user has no config in this app
} }
ctx.body = { ctx.body = {
message: `User ${ctx.params.id} deleted.`, message: `User metadata ${ctx.params.id} deleted.`,
} }
} }

View File

@ -1,20 +1,6 @@
const setup = require("./utilities") const setup = require("./utilities")
const { generateUserMetadataID } = require("../../../db/utils") const { generateUserMetadataID } = require("../../../db/utils")
require("../../../utilities/workerRequests")
jest.mock("../../../utilities/workerRequests", () => ({
getGlobalUsers: jest.fn(() => {
return {
_id: "us_uuid1",
}
}),
saveGlobalUser: jest.fn(() => {
return {
_id: "us_uuid1",
}
}),
}))
describe("/authenticate", () => { describe("/authenticate", () => {
let request = setup.getRequest() let request = setup.getRequest()
let config = setup.getConfig() let config = setup.getConfig()
@ -27,7 +13,7 @@ describe("/authenticate", () => {
describe("fetch self", () => { describe("fetch self", () => {
it("should be able to fetch self", async () => { it("should be able to fetch self", async () => {
const user = await config.createUser("test@test.com", "p4ssw0rd") await config.createUser("test@test.com", "p4ssw0rd")
const headers = await config.login("test@test.com", "p4ssw0rd", { userId: "us_uuid1" }) const headers = await config.login("test@test.com", "p4ssw0rd", { userId: "us_uuid1" })
const res = await request const res = await request
.get(`/api/self`) .get(`/api/self`)

View File

@ -304,24 +304,6 @@ describe("/rows", () => {
}) })
}) })
describe("search", () => {
it("should run a search on the table", async () => {
const res = await request
.post(`/api/${table._id}/rows/search`)
.send({
query: {
name: "Test",
},
pagination: { pageSize: 25 }
})
.set(config.defaultHeaders())
.expect('Content-Type', /json/)
.expect(200)
expect(res.body.rows.length).toEqual(1)
expect(res.body.bookmark).toBeDefined()
})
})
describe("fetchView", () => { describe("fetchView", () => {
it("should be able to fetch tables contents via 'view'", async () => { it("should be able to fetch tables contents via 'view'", async () => {
const row = await config.createRow() const row = await config.createRow()

View File

@ -8,12 +8,7 @@ jest.mock("../../../utilities/workerRequests", () => ({
getGlobalUsers: jest.fn(() => { getGlobalUsers: jest.fn(() => {
return {} return {}
}), }),
saveGlobalUser: jest.fn(() => { addAppRoleToUser: jest.fn(),
const uuid = require("uuid/v4")
return {
_id: `us_${uuid()}`
}
}),
deleteGlobalUser: jest.fn(), deleteGlobalUser: jest.fn(),
})) }))
@ -67,59 +62,8 @@ describe("/users", () => {
}) })
}) })
describe("create", () => {
beforeEach(() => {
workerRequests.getGlobalUsers.mockImplementationOnce(() => ([
{
_id: "us_uuid1",
},
{
_id: "us_uuid2",
}
]
))
})
async function create(user, status = 200) {
return request
.post(`/api/users/metadata`)
.set(config.defaultHeaders())
.send(user)
.expect(status)
.expect("Content-Type", /json/)
}
it("returns a success message when a user is successfully created", async () => {
const body = basicUser(BUILTIN_ROLE_IDS.POWER)
const res = await create(body)
expect(res.res.statusMessage).toEqual("OK")
expect(res.body._id).toBeDefined()
})
it("should apply authorization to endpoint", async () => {
const body = basicUser(BUILTIN_ROLE_IDS.POWER)
await checkPermissionsEndpoint({
config,
method: "POST",
body,
url: `/api/users/metadata`,
passRole: BUILTIN_ROLE_IDS.ADMIN,
failRole: BUILTIN_ROLE_IDS.PUBLIC,
})
})
it("should error if no role provided", async () => {
const user = basicUser(null)
await create(user, 400)
})
})
describe("update", () => { describe("update", () => {
beforeEach(() => { beforeEach(() => {
workerRequests.saveGlobalUser.mockImplementationOnce(() => ({
_id: "us_test@test.com"
}))
}) })
it("should be able to update the user", async () => { it("should be able to update the user", async () => {
@ -144,16 +88,12 @@ describe("/users", () => {
.expect(200) .expect(200)
.expect("Content-Type", /json/) .expect("Content-Type", /json/)
expect(res.body.message).toBeDefined() expect(res.body.message).toBeDefined()
expect(workerRequests.deleteGlobalUser).toHaveBeenCalled()
}) })
}) })
describe("find", () => { describe("find", () => {
beforeEach(() => { beforeEach(() => {
jest.resetAllMocks() jest.resetAllMocks()
workerRequests.saveGlobalUser.mockImplementationOnce(() => ({
_id: "us_uuid1",
}))
workerRequests.getGlobalUsers.mockImplementationOnce(() => ({ workerRequests.getGlobalUsers.mockImplementationOnce(() => ({
_id: "us_uuid1", _id: "us_uuid1",
roleId: BUILTIN_ROLE_IDS.POWER, roleId: BUILTIN_ROLE_IDS.POWER,

View File

@ -3,8 +3,7 @@ const structures = require("../../../../tests/utilities/structures")
const env = require("../../../../environment") const env = require("../../../../environment")
jest.mock("../../../../utilities/workerRequests", () => ({ jest.mock("../../../../utilities/workerRequests", () => ({
getGlobalUsers: jest.fn(), getGlobalUsers: jest.fn(() => {
saveGlobalUser: jest.fn(() => {
return { return {
_id: "us_uuid1", _id: "us_uuid1",
} }

View File

@ -25,12 +25,6 @@ router
authorized(PermissionTypes.USER, PermissionLevels.WRITE), authorized(PermissionTypes.USER, PermissionLevels.WRITE),
controller.updateMetadata controller.updateMetadata
) )
.post(
"/api/users/metadata",
authorized(PermissionTypes.USER, PermissionLevels.WRITE),
usage,
controller.createMetadata
)
.post( .post(
"/api/users/metadata/self", "/api/users/metadata/self",
authorized(PermissionTypes.USER, PermissionLevels.WRITE), authorized(PermissionTypes.USER, PermissionLevels.WRITE),

View File

@ -3,7 +3,6 @@ const sendSmtpEmail = require("./steps/sendSmtpEmail")
const createRow = require("./steps/createRow") const createRow = require("./steps/createRow")
const updateRow = require("./steps/updateRow") const updateRow = require("./steps/updateRow")
const deleteRow = require("./steps/deleteRow") const deleteRow = require("./steps/deleteRow")
const createUser = require("./steps/createUser")
const executeScript = require("./steps/executeScript") const executeScript = require("./steps/executeScript")
const executeQuery = require("./steps/executeQuery") const executeQuery = require("./steps/executeQuery")
const outgoingWebhook = require("./steps/outgoingWebhook") const outgoingWebhook = require("./steps/outgoingWebhook")
@ -20,7 +19,6 @@ const BUILTIN_ACTIONS = {
CREATE_ROW: createRow.run, CREATE_ROW: createRow.run,
UPDATE_ROW: updateRow.run, UPDATE_ROW: updateRow.run,
DELETE_ROW: deleteRow.run, DELETE_ROW: deleteRow.run,
CREATE_USER: createUser.run,
OUTGOING_WEBHOOK: outgoingWebhook.run, OUTGOING_WEBHOOK: outgoingWebhook.run,
EXECUTE_SCRIPT: executeScript.run, EXECUTE_SCRIPT: executeScript.run,
EXECUTE_QUERY: executeQuery.run, EXECUTE_QUERY: executeQuery.run,
@ -31,7 +29,6 @@ const BUILTIN_DEFINITIONS = {
CREATE_ROW: createRow.definition, CREATE_ROW: createRow.definition,
UPDATE_ROW: updateRow.definition, UPDATE_ROW: updateRow.definition,
DELETE_ROW: deleteRow.definition, DELETE_ROW: deleteRow.definition,
CREATE_USER: createUser.definition,
OUTGOING_WEBHOOK: outgoingWebhook.definition, OUTGOING_WEBHOOK: outgoingWebhook.definition,
EXECUTE_SCRIPT: executeScript.definition, EXECUTE_SCRIPT: executeScript.definition,
EXECUTE_QUERY: executeQuery.definition, EXECUTE_QUERY: executeQuery.definition,

View File

@ -1,90 +0,0 @@
const roles = require("@budibase/auth/roles")
const userController = require("../../api/controllers/user")
const env = require("../../environment")
const usage = require("../../utilities/usageQuota")
module.exports.definition = {
description: "Create a new user",
tagline: "Create user {{inputs.email}}",
icon: "ri-user-add-line",
name: "Create User",
type: "ACTION",
stepId: "CREATE_USER",
inputs: {
roleId: roles.BUILTIN_ROLE_IDS.POWER,
},
schema: {
inputs: {
properties: {
email: {
type: "string",
customType: "email",
title: "Email",
},
password: {
type: "string",
title: "Password",
},
roleId: {
type: "string",
title: "Role",
enum: roles.BUILTIN_ROLE_ID_ARRAY,
pretty: roles.BUILTIN_ROLE_NAME_ARRAY,
},
},
required: ["email", "password", "roleId"],
},
outputs: {
properties: {
id: {
type: "string",
description: "The identifier of the new user",
},
revision: {
type: "string",
description: "The revision of the new user",
},
response: {
type: "object",
description: "The response from the user table",
},
success: {
type: "boolean",
description: "Whether the action was successful",
},
},
required: ["id", "revision", "success"],
},
},
}
module.exports.run = async function ({ inputs, appId, apiKey, emitter }) {
const { email, password, roleId } = inputs
const ctx = {
appId,
request: {
body: { email, password, roleId },
},
eventEmitter: emitter,
}
try {
if (env.isProd()) {
await usage.update(apiKey, usage.Properties.USER, 1)
}
await userController.createMetadata(ctx)
return {
response: ctx.body,
// internal property not returned through the API
id: ctx.body._id,
revision: ctx.body._rev,
success: ctx.status === 200,
}
} catch (err) {
console.error(err)
return {
success: false,
response: err,
}
}
}

View File

@ -1,42 +0,0 @@
const usageQuota = require("../../utilities/usageQuota")
const setup = require("./utilities")
const { BUILTIN_ROLE_IDS } = require("@budibase/auth/roles")
const { InternalTables } = require("../../db/utils")
jest.mock("../../utilities/usageQuota")
describe("test the create user action", () => {
let config = setup.getConfig()
let user
beforeEach(async () => {
await config.init()
user = {
email: "test@test.com",
password: "password",
roleId: BUILTIN_ROLE_IDS.POWER
}
})
afterAll(setup.afterAll)
it("should be able to run the action", async () => {
const res = await setup.runStep(setup.actions.CREATE_USER.stepId, user)
expect(res.id).toBeDefined()
expect(res.revision).toBeDefined()
const userDoc = await config.getRow(InternalTables.USER_METADATA, res.id)
expect(userDoc).toBeDefined()
})
it("should return an error if no inputs provided", async () => {
const res = await setup.runStep(setup.actions.CREATE_USER.stepId, {})
expect(res.success).toEqual(false)
})
it("check usage quota attempts", async () => {
await setup.runInProd(async () => {
await setup.runStep(setup.actions.CREATE_USER.stepId, user)
expect(usageQuota.update).toHaveBeenCalledWith(setup.apiKey, "users", 1)
})
})
})

View File

@ -12,7 +12,7 @@ const FILTER_STEP_ID = logic.BUILTIN_DEFINITIONS.FILTER.stepId
* inputs and handles any outputs. * inputs and handles any outputs.
*/ */
class Orchestrator { class Orchestrator {
constructor(automation, triggerOutput) { constructor(automation, triggerOutput = {}) {
this._metadata = triggerOutput.metadata this._metadata = triggerOutput.metadata
this._chainCount = this._metadata ? this._metadata.automationChainCount : 0 this._chainCount = this._metadata ? this._metadata.automationChainCount : 0
this._appId = triggerOutput.appId this._appId = triggerOutput.appId

View File

@ -7,9 +7,10 @@ const Queue = env.isTest()
const { getAutomationParams } = require("../db/utils") const { getAutomationParams } = require("../db/utils")
const { coerce } = require("../utilities/rowProcessor") const { coerce } = require("../utilities/rowProcessor")
const { utils } = require("@budibase/auth/redis") const { utils } = require("@budibase/auth/redis")
const { JobQueues } = require("../constants")
const { opts } = utils.getRedisOptions() const { opts } = utils.getRedisOptions()
let automationQueue = new Queue("automationQueue", { redis: opts }) let automationQueue = new Queue(JobQueues.AUTOMATIONS, { redis: opts })
const FAKE_STRING = "TEST" const FAKE_STRING = "TEST"
const FAKE_BOOL = false const FAKE_BOOL = false
@ -196,6 +197,29 @@ const BUILTIN_DEFINITIONS = {
}, },
type: "TRIGGER", type: "TRIGGER",
}, },
CRON: {
name: "Cron Trigger",
event: "cron:trigger",
icon: "ri-timer-line",
tagline: "Cron Trigger (<b>{{inputs.cron}}</b>)",
description: "Triggers automation on a cron schedule.",
stepId: "CRON",
inputs: {},
schema: {
inputs: {
properties: {
cron: {
type: "string",
customType: "cron",
title: "Expression",
},
},
required: ["cron"],
},
outputs: {},
},
type: "TRIGGER",
},
} }
async function queueRelevantRowAutomations(event, eventType) { async function queueRelevantRowAutomations(event, eventType) {

View File

@ -5,6 +5,10 @@ const { ObjectStoreBuckets } = require("@budibase/auth").objectStore
exports.LOGO_URL = exports.LOGO_URL =
"https://d33wubrfki0l68.cloudfront.net/aac32159d7207b5085e74a7ef67afbb7027786c5/2b1fd/img/logo/bb-emblem.svg" "https://d33wubrfki0l68.cloudfront.net/aac32159d7207b5085e74a7ef67afbb7027786c5/2b1fd/img/logo/bb-emblem.svg"
exports.JobQueues = {
AUTOMATIONS: "automationQueue",
}
exports.FieldTypes = { exports.FieldTypes = {
STRING: "string", STRING: "string",
LONGFORM: "longform", LONGFORM: "longform",
@ -50,6 +54,24 @@ exports.USERS_TABLE_SCHEMA = {
fieldName: "email", fieldName: "email",
name: "email", name: "email",
}, },
firstName: {
name: "firstName",
fieldName: "firstName",
type: exports.FieldTypes.STRING,
constraints: {
type: exports.FieldTypes.STRING,
presence: false,
},
},
lastName: {
name: "lastName",
fieldName: "lastName",
type: exports.FieldTypes.STRING,
constraints: {
type: exports.FieldTypes.STRING,
presence: false,
},
},
roleId: { roleId: {
fieldName: "roleId", fieldName: "roleId",
name: "roleId", name: "roleId",

View File

@ -19,6 +19,7 @@ const StaticDatabases = {
const AppStatus = { const AppStatus = {
DEV: "dev", DEV: "dev",
ALL: "all",
DEPLOYED: "PUBLISHED", DEPLOYED: "PUBLISHED",
} }

View File

@ -77,7 +77,11 @@ class TestConfiguration {
if (builder) { if (builder) {
user.builder = { global: true } user.builder = { global: true }
} }
await db.put(user) const resp = await db.put(user)
return {
_rev: resp._rev,
...user,
}
} }
async init(appName = "test_application") { async init(appName = "test_application") {
@ -308,18 +312,12 @@ class TestConfiguration {
roleId = BUILTIN_ROLE_IDS.POWER roleId = BUILTIN_ROLE_IDS.POWER
) { ) {
const globalId = `us_${Math.random()}` const globalId = `us_${Math.random()}`
await this.globalUser(globalId, roleId === BUILTIN_ROLE_IDS.BUILDER) const resp = await this.globalUser(
const user = await this._req( globalId,
{ roleId === BUILTIN_ROLE_IDS.BUILDER
email,
password,
roleId,
},
null,
controllers.user.createMetadata
) )
return { return {
...user, ...resp,
globalId, globalId,
} }
} }

View File

@ -1,14 +1,16 @@
const csv = require("csvtojson") const csv = require("csvtojson")
const { FieldTypes } = require("../constants")
const VALIDATORS = { const VALIDATORS = {
string: () => true, [FieldTypes.STRING]: () => true,
number: attribute => !isNaN(Number(attribute)), [FieldTypes.OPTIONS]: () => true,
datetime: attribute => !isNaN(new Date(attribute).getTime()), [FieldTypes.NUMBER]: attribute => !isNaN(Number(attribute)),
[FieldTypes.DATETIME]: attribute => !isNaN(new Date(attribute).getTime()),
} }
const PARSERS = { const PARSERS = {
number: attribute => Number(attribute), [FieldTypes.NUMBER]: attribute => Number(attribute),
datetime: attribute => new Date(attribute).toISOString(), [FieldTypes.DATETIME]: attribute => new Date(attribute).toISOString(),
} }
function parse(csvString, parsers) { function parse(csvString, parsers) {

View File

@ -1,5 +1,5 @@
const CouchDB = require("../db") const CouchDB = require("../db")
const { getGlobalIDFromUserMetadataID } = require("../db/utils") const { getGlobalIDFromUserMetadataID, InternalTables } = require("../db/utils")
const { getGlobalUsers } = require("../utilities/workerRequests") const { getGlobalUsers } = require("../utilities/workerRequests")
exports.getFullUser = async (ctx, userId) => { exports.getFullUser = async (ctx, userId) => {
@ -21,6 +21,7 @@ exports.getFullUser = async (ctx, userId) => {
return { return {
...global, ...global,
...metadata, ...metadata,
tableId: InternalTables.USER_METADATA,
// make sure the ID is always a local ID, not a global one // make sure the ID is always a local ID, not a global one
_id: userId, _id: userId,
} }

View File

@ -3,6 +3,7 @@ const env = require("../environment")
const { checkSlashesInUrl } = require("./index") const { checkSlashesInUrl } = require("./index")
const { BUILTIN_ROLE_IDS } = require("@budibase/auth/roles") const { BUILTIN_ROLE_IDS } = require("@budibase/auth/roles")
const { getDeployedAppID } = require("@budibase/auth/db") const { getDeployedAppID } = require("@budibase/auth/db")
const { getGlobalIDFromUserMetadataID } = require("../db/utils")
function getAppRole(appId, user) { function getAppRole(appId, user) {
if (!user.roles) { if (!user.roles) {
@ -118,53 +119,50 @@ exports.getGlobalUsers = async (ctx, appId = null, globalId = null) => {
return users return users
} }
exports.saveGlobalUser = async (ctx, appId, body) => { exports.getGlobalSelf = async ctx => {
const globalUser = body._id const endpoint = `/api/admin/users/self`
? await exports.getGlobalUsers(ctx, appId, body._id)
: {}
const preRoles = globalUser.roles || {}
if (body.roleId) {
preRoles[appId] = body.roleId
}
// make sure no dev app IDs in roles
const roles = {}
for (let [appId, roleId] of Object.entries(preRoles)) {
roles[getDeployedAppID(appId)] = roleId
}
const endpoint = `/api/admin/users`
const reqCfg = {
method: "POST",
body: {
...globalUser,
password: body.password || undefined,
status: body.status,
email: body.email,
roles,
builder: {
global: true,
},
},
}
const response = await fetch( const response = await fetch(
checkSlashesInUrl(env.WORKER_URL + endpoint), checkSlashesInUrl(env.WORKER_URL + endpoint),
request(ctx, reqCfg) request(ctx, { method: "GET" })
) )
const json = await response.json() const json = await response.json()
if (json.status !== 200 && response.status !== 200) { if (json.status !== 200 && response.status !== 200) {
ctx.throw(400, "Unable to save global user.") ctx.throw(400, "Unable to get self globally.")
}
delete body.password
delete body.roles
delete body.builder
// TODO: for now these have been left in as they are
// TODO: pretty important to keeping relationships working
// TODO: however if user metadata is changed this should be removed
// delete body.email
// delete body.roleId
// delete body.status
return {
...body,
_id: json._id,
} }
return json
}
exports.addAppRoleToUser = async (ctx, appId, roleId, userId = null) => {
appId = getDeployedAppID(appId)
let user,
endpoint,
body = {}
if (!userId) {
user = await exports.getGlobalSelf(ctx)
endpoint = `/api/admin/users/self`
} else {
userId = getGlobalIDFromUserMetadataID(userId)
user = await exports.getGlobalUsers(ctx, appId, userId)
body._id = userId
endpoint = `/api/admin/users`
}
body = {
...body,
roles: {
...user.roles,
[appId]: roleId,
},
}
const response = await fetch(
checkSlashesInUrl(env.WORKER_URL + endpoint),
request(ctx, {
method: "POST",
body,
})
)
const json = await response.json()
if (json.status !== 200 && response.status !== 200) {
ctx.throw(400, "Unable to save self globally.")
}
return json
} }

File diff suppressed because it is too large Load Diff

View File

@ -8,7 +8,7 @@ const CouchDB = require("../../../db")
exports.fetch = async ctx => { exports.fetch = async ctx => {
// always use the dev apps as they'll be most up to date (true) // always use the dev apps as they'll be most up to date (true)
const apps = await getAllApps(true) const apps = await getAllApps({ dev: true })
const promises = [] const promises = []
for (let app of apps) { for (let app of apps) {
// use dev app IDs // use dev app IDs

View File

@ -16,10 +16,15 @@ exports.save = async ctx => {
const { email, password, _id } = ctx.request.body const { email, password, _id } = ctx.request.body
// make sure another user isn't using the same email // make sure another user isn't using the same email
const dbUser = await getGlobalUserByEmail(email) let dbUser
if (email) {
dbUser = await getGlobalUserByEmail(email)
if (dbUser != null && (dbUser._id !== _id || Array.isArray(dbUser))) { if (dbUser != null && (dbUser._id !== _id || Array.isArray(dbUser))) {
ctx.throw(400, "Email address already in use.") ctx.throw(400, "Email address already in use.")
} }
} else {
dbUser = await db.get(_id)
}
// get the password, make sure one is defined // get the password, make sure one is defined
let hashedPassword let hashedPassword
@ -96,6 +101,33 @@ exports.destroy = async ctx => {
} }
} }
exports.getSelf = async ctx => {
ctx.params = {
id: ctx.user._id,
}
// this will set the body
await exports.find(ctx)
}
exports.updateSelf = async ctx => {
const db = new CouchDB(GLOBAL_DB)
const user = await db.get(ctx.user._id)
if (ctx.request.body.password) {
ctx.request.body.password = await hash(ctx.request.body.password)
}
// don't allow sending up an ID/Rev, always use the existing one
delete ctx.request.body._id
delete ctx.request.body._rev
const response = await db.put({
...user,
...ctx.request.body,
})
ctx.body = {
_id: response.id,
_rev: response.rev,
}
}
// called internally by app server user fetch // called internally by app server user fetch
exports.fetch = async ctx => { exports.fetch = async ctx => {
const db = new CouchDB(GLOBAL_DB) const db = new CouchDB(GLOBAL_DB)
@ -145,12 +177,16 @@ exports.invite = async ctx => {
} }
exports.inviteAccept = async ctx => { exports.inviteAccept = async ctx => {
const { inviteCode } = ctx.request.body const { inviteCode, password, firstName, lastName } = ctx.request.body
try { try {
const email = await checkInviteCode(inviteCode) const email = await checkInviteCode(inviteCode)
// redirect the request // only pass through certain props for accepting
delete ctx.request.body.inviteCode ctx.request.body = {
ctx.request.body.email = email firstName,
lastName,
password,
email,
}
// this will flesh out the body response // this will flesh out the body response
await exports.save(ctx) await exports.save(ctx)
} catch (err) { } catch (err) {

View File

@ -1,6 +1,7 @@
const Router = require("@koa/router") const Router = require("@koa/router")
const controller = require("../../controllers/admin/configs") const controller = require("../../controllers/admin/configs")
const joiValidator = require("../../../middleware/joi-validator") const joiValidator = require("../../../middleware/joi-validator")
const adminOnly = require("../../../middleware/adminOnly")
const Joi = require("joi") const Joi = require("joi")
const { Configs, ConfigUploads } = require("../../../constants") const { Configs, ConfigUploads } = require("../../../constants")
@ -77,8 +78,13 @@ function buildConfigGetValidation() {
} }
router router
.post("/api/admin/configs", buildConfigSaveValidation(), controller.save) .post(
.delete("/api/admin/configs/:id", controller.destroy) "/api/admin/configs",
adminOnly,
buildConfigSaveValidation(),
controller.save
)
.delete("/api/admin/configs/:id", adminOnly, controller.destroy)
.get("/api/admin/configs", controller.fetch) .get("/api/admin/configs", controller.fetch)
.get("/api/admin/configs/checklist", controller.configChecklist) .get("/api/admin/configs/checklist", controller.configChecklist)
.get( .get(
@ -89,6 +95,7 @@ router
.get("/api/admin/configs/:type", buildConfigGetValidation(), controller.find) .get("/api/admin/configs/:type", buildConfigGetValidation(), controller.find)
.post( .post(
"/api/admin/configs/upload/:type/:name", "/api/admin/configs/upload/:type/:name",
adminOnly,
buildUploadValidation(), buildUploadValidation(),
controller.upload controller.upload
) )

View File

@ -1,6 +1,7 @@
const Router = require("@koa/router") const Router = require("@koa/router")
const controller = require("../../controllers/admin/groups") const controller = require("../../controllers/admin/groups")
const joiValidator = require("../../../middleware/joi-validator") const joiValidator = require("../../../middleware/joi-validator")
const adminOnly = require("../../../middleware/adminOnly")
const Joi = require("joi") const Joi = require("joi")
const router = Router() const router = Router()
@ -24,9 +25,14 @@ function buildGroupSaveValidation() {
} }
router router
.post("/api/admin/groups", buildGroupSaveValidation(), controller.save) .post(
"/api/admin/groups",
adminOnly,
buildGroupSaveValidation(),
controller.save
)
.get("/api/admin/groups", controller.fetch) .get("/api/admin/groups", controller.fetch)
.delete("/api/admin/groups/:id", controller.destroy) .delete("/api/admin/groups/:id", adminOnly, controller.destroy)
.get("/api/admin/groups/:id", controller.find) .get("/api/admin/groups/:id", controller.find)
module.exports = router module.exports = router

View File

@ -1,10 +1,11 @@
const Router = require("@koa/router") const Router = require("@koa/router")
const controller = require("../../controllers/admin/roles") const controller = require("../../controllers/admin/roles")
const adminOnly = require("../../../middleware/adminOnly")
const router = Router() const router = Router()
router router
.get("/api/admin/roles", controller.fetch) .get("/api/admin/roles", adminOnly, controller.fetch)
.get("/api/admin/roles/:appId", controller.find) .get("/api/admin/roles/:appId", adminOnly, controller.find)
module.exports = router module.exports = router

View File

@ -3,6 +3,7 @@ const controller = require("../../controllers/admin/templates")
const joiValidator = require("../../../middleware/joi-validator") const joiValidator = require("../../../middleware/joi-validator")
const Joi = require("joi") const Joi = require("joi")
const { TemplatePurpose, TemplateTypes } = require("../../../constants") const { TemplatePurpose, TemplateTypes } = require("../../../constants")
const adminOnly = require("../../../middleware/adminOnly")
const router = Router() const router = Router()
@ -21,11 +22,16 @@ function buildTemplateSaveValidation() {
router router
.get("/api/admin/template/definitions", controller.definitions) .get("/api/admin/template/definitions", controller.definitions)
.post("/api/admin/template", buildTemplateSaveValidation(), controller.save) .post(
"/api/admin/template",
adminOnly,
buildTemplateSaveValidation(),
controller.save
)
.get("/api/admin/template", controller.fetch) .get("/api/admin/template", controller.fetch)
.get("/api/admin/template/:type", controller.fetchByType) .get("/api/admin/template/:type", controller.fetchByType)
.get("/api/admin/template/:ownerId", controller.fetchByOwner) .get("/api/admin/template/:ownerId", controller.fetchByOwner)
.get("/api/admin/template/:id", controller.find) .get("/api/admin/template/:id", controller.find)
.delete("/api/admin/template/:id/:rev", controller.destroy) .delete("/api/admin/template/:id/:rev", adminOnly, controller.destroy)
module.exports = router module.exports = router

View File

@ -1,28 +1,35 @@
const Router = require("@koa/router") const Router = require("@koa/router")
const controller = require("../../controllers/admin/users") const controller = require("../../controllers/admin/users")
const joiValidator = require("../../../middleware/joi-validator") const joiValidator = require("../../../middleware/joi-validator")
const adminOnly = require("../../../middleware/adminOnly")
const Joi = require("joi") const Joi = require("joi")
const router = Router() const router = Router()
function buildUserSaveValidation() { function buildUserSaveValidation(isSelf = false) {
// prettier-ignore let schema = {
return joiValidator.body(Joi.object({ email: Joi.string().allow(null, ""),
_id: Joi.string(),
_rev: Joi.string(),
email: Joi.string(),
password: Joi.string().allow(null, ""), password: Joi.string().allow(null, ""),
forcePasswordChange: Joi.boolean().optional(), forcePasswordChange: Joi.boolean().optional(),
firstName: Joi.string().allow(null, ""),
lastName: Joi.string().allow(null, ""),
builder: Joi.object({ builder: Joi.object({
global: Joi.boolean().optional(), global: Joi.boolean().optional(),
apps: Joi.array().optional(), apps: Joi.array().optional(),
}).unknown(true).optional(), })
// maps appId -> roleId for the user
roles: Joi.object()
.pattern(/.*/, Joi.string())
.required()
.unknown(true) .unknown(true)
}).required().unknown(true)) .optional(),
// maps appId -> roleId for the user
roles: Joi.object().pattern(/.*/, Joi.string()).required().unknown(true),
}
if (!isSelf) {
schema = {
...schema,
_id: Joi.string(),
_rev: Joi.string(),
}
}
return joiValidator.body(Joi.object(schema).required().unknown(true))
} }
function buildInviteValidation() { function buildInviteValidation() {
@ -41,13 +48,29 @@ function buildInviteAcceptValidation() {
} }
router router
.post("/api/admin/users", buildUserSaveValidation(), controller.save) .post(
"/api/admin/users",
adminOnly,
buildUserSaveValidation(),
controller.save
)
.get("/api/admin/users", controller.fetch) .get("/api/admin/users", controller.fetch)
.post("/api/admin/users/init", controller.adminUser) .post("/api/admin/users/init", controller.adminUser)
.delete("/api/admin/users/:id", controller.destroy) .get("/api/admin/users/self", controller.getSelf)
.post(
"/api/admin/users/self",
buildUserSaveValidation(true),
controller.updateSelf
)
.delete("/api/admin/users/:id", adminOnly, controller.destroy)
.get("/api/admin/users/:id", controller.find) .get("/api/admin/users/:id", controller.find)
.get("/api/admin/roles/:appId") .get("/api/admin/roles/:appId")
.post("/api/admin/users/invite", buildInviteValidation(), controller.invite) .post(
"/api/admin/users/invite",
adminOnly,
buildInviteValidation(),
controller.invite
)
.post( .post(
"/api/admin/users/invite/accept", "/api/admin/users/invite/accept",
buildInviteAcceptValidation(), buildInviteAcceptValidation(),

View File

@ -30,7 +30,7 @@ describe("/api/admin/auth", () => {
expect(sendMailMock).toHaveBeenCalled() expect(sendMailMock).toHaveBeenCalled()
const emailCall = sendMailMock.mock.calls[0][0] const emailCall = sendMailMock.mock.calls[0][0]
// after this URL there should be a code // after this URL there should be a code
const parts = emailCall.html.split("http://localhost:10000/reset?code=") const parts = emailCall.html.split("http://localhost:10000/builder/auth/reset?code=")
code = parts[1].split("\"")[0] code = parts[1].split("\"")[0]
expect(code).toBeDefined() expect(code).toBeDefined()
}) })

View File

@ -13,7 +13,7 @@ describe("/api/admin/configs/checklist", () => {
let config = setup.getConfig() let config = setup.getConfig()
beforeAll(async () => { beforeAll(async () => {
await config.init() await config.init(false)
}) })
afterAll(setup.afterAll) afterAll(setup.afterAll)

View File

@ -30,7 +30,7 @@ describe("/api/admin/users", () => {
expect(sendMailMock).toHaveBeenCalled() expect(sendMailMock).toHaveBeenCalled()
const emailCall = sendMailMock.mock.calls[0][0] const emailCall = sendMailMock.mock.calls[0][0]
// after this URL there should be a code // after this URL there should be a code
const parts = emailCall.html.split("http://localhost:10000/invite?code=") const parts = emailCall.html.split("http://localhost:10000/builder/invite?code=")
code = parts[1].split("\"")[0] code = parts[1].split("\"")[0]
expect(code).toBeDefined() expect(code).toBeDefined()
}) })

View File

@ -38,7 +38,8 @@ class TestConfiguration {
return request.body return request.body
} }
async init() { async init(createUser = true) {
if (createUser) {
// create a test user // create a test user
await this._req( await this._req(
{ {
@ -48,11 +49,15 @@ class TestConfiguration {
builder: { builder: {
global: true, global: true,
}, },
admin: {
global: true,
},
}, },
null, null,
controllers.users.save controllers.users.save
) )
} }
}
async end() { async end() {
if (this.server) { if (this.server) {

View File

@ -0,0 +1,6 @@
module.exports = async (ctx, next) => {
if (!ctx.user || !ctx.user.admin || !ctx.user.admin.global) {
ctx.throw(403, "Admin user only endpoint.")
}
return next()
}

View File

@ -155,10 +155,12 @@ exports.sendEmail = async (
const context = await getSettingsTemplateContext(purpose, code) const context = await getSettingsTemplateContext(purpose, code)
const message = { const message = {
from: from || config.from, from: from || config.from,
subject: await processString(subject || config.subject, context),
to: email, to: email,
html: await buildEmail(purpose, email, context, { user, contents }), html: await buildEmail(purpose, email, context, { user, contents }),
} }
if (subject || config.subject) {
message.subject = await processString(subject || config.subject, context)
}
const response = await transport.sendMail(message) const response = await transport.sendMail(message)
if (TEST_MODE) { if (TEST_MODE) {
console.log("Test email URL: " + nodemailer.getTestMessageUrl(response)) console.log("Test email URL: " + nodemailer.getTestMessageUrl(response))