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",
"repoId": 190729906,
"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.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
* 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.
* @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()
let allDbs = await CouchDB.allDbs()
const appDbNames = allDbs.filter(dbName =>
@ -176,12 +180,19 @@ exports.getAllApps = async (devApps = false) => {
const apps = response
.filter(result => result.status === "fulfilled")
.map(({ value }) => value)
if (!all) {
return apps.filter(app => {
if (devApps) {
return app.appId.startsWith(exports.APP_DEV_PREFIX)
if (dev) {
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>}
*/
exports.getGlobalUserByEmail = async email => {
if (email == null) {
throw "Must supply an email address to view"
}
const db = getDB(StaticDatabases.GLOBAL.name)
try {
let users = (

View File

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

View File

@ -37,9 +37,28 @@
const fieldId = id || generateID()
let selectedImageIdx = 0
let fileDragged = false
let selectedUrl
$: selectedImage = value?.[selectedImageIdx] ?? null
$: 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) {
if (
@ -102,11 +121,13 @@
<div class="gallery">
<div class="title">
<div class="filename">{selectedImage.name}</div>
{#if selectedImage.size}
<div class="filesize">
{#if selectedImage.size <= BYTES_IN_MB}
{`${selectedImage.size / BYTES_IN_KB} KB`}
{:else}{`${selectedImage.size / BYTES_IN_MB} MB`}{/if}
</div>
{/if}
{#if !disabled}
<div class="delete-button" on:click={removeFile}>
<Icon name="Close" />
@ -114,7 +135,7 @@
{/if}
</div>
{#if isImage}
<img alt="preview" src={selectedImage.url} />
<img alt="preview" src={selectedUrl} />
{:else}
<div class="placeholder">
<div class="extension">{selectedImage.extension}</div>
@ -142,11 +163,13 @@
<div class="gallery">
<div class="title">
<div class="filename">{file.name}</div>
{#if file.size}
<div class="filesize">
{#if file.size <= BYTES_IN_MB}
{`${file.size / BYTES_IN_KB} KB`}
{:else}{`${file.size / BYTES_IN_MB} MB`}{/if}
</div>
{/if}
{#if !disabled}
<div class="delete-button" on:click={removeFile}>
<Icon name="Close" />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -10,6 +10,7 @@
import CodeEditorModal from "./CodeEditorModal.svelte"
import QuerySelector from "./QuerySelector.svelte"
import QueryParamSelector from "./QueryParamSelector.svelte"
import CronBuilder from "./CronBuilder.svelte"
import Editor from "components/integration/QueryEditor.svelte"
export let block
@ -76,6 +77,8 @@
/>
{:else if value.customType === "query"}
<QuerySelector bind:value={block.inputs[key]} />
{:else if value.customType === "cron"}
<CronBuilder bind:value={block.inputs[key]} />
{:else if value.customType === "queryParams"}
<QueryParamSelector bind:value={block.inputs[key]} {bindings} />
{: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 />
{#if schema && Object.keys(schema).length > 0}
{#if !isUsersTable}
<CreateRowButton
title={isUsersTable ? "Create user" : "Create row"}
modalContentComponent={isUsersTable ? CreateEditUser : CreateEditRow}
title={"Create row"}
modalContentComponent={CreateEditRow}
/>
{/if}
<CreateViewButton />
<ManageAccessButton resourceId={$tables.selected?._id} />
{#if isUsersTable}

View File

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

View File

@ -32,6 +32,8 @@
delete customSchema["email"]
delete customSchema["roleId"]
delete customSchema["status"]
delete customSchema["firstName"]
delete customSchema["lastName"]
return Object.entries(customSchema)
}
@ -67,7 +69,7 @@
return false
}
notifications.success("User saved successfully.")
notifications.success("User saved successfully")
rows.save(rowResponse)
}
</script>
@ -84,8 +86,14 @@
readonly={!creating}
/>
<RowFieldControl
meta={{ name: "password", type: "password" }}
bind:value={row.password}
meta={{ ...tableSchema.firstName, name: "First Name" }}
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
selection is always undefined -->
@ -97,16 +105,6 @@
getOptionLabel={role => role.name}
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]}
{#if !meta.autocolumn}
<RowFieldControl {meta} bind:value={row[key]} {creating} />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -15,7 +15,14 @@ export const AppStatus = {
}
// 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 = {
MASTER: {

View File

@ -1,8 +1,7 @@
<script>
import { onMount } from "svelte"
import { goto, isActive } from "@roxi/routify"
import { auth } from "stores/backend"
import { admin } from "stores/portal"
import { isActive, redirect } from "@roxi/routify"
import { admin, auth } from "stores/portal"
let loaded = false
$: hasAdminUser = !!$admin?.checklist?.adminUser
@ -16,7 +15,7 @@
// Force creation of an admin user if one doesn't exist
$: {
if (loaded && !hasAdminUser) {
$goto("./admin")
$redirect("./admin")
}
}
@ -29,7 +28,7 @@
!$isActive("./auth") &&
!$isActive("./invite")
) {
$goto("./auth/login")
$redirect("./auth/login")
}
}
</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"
import { goto } from "@roxi/routify"
import api from "builderStore/api"
import { admin } from "stores/portal"
import { admin, organisation } from "stores/portal"
let adminUser = {}
@ -32,22 +32,22 @@
<section>
<div class="container">
<Layout gap="XS">
<img src="https://i.imgur.com/ZKyklgF.png" />
</Layout>
<div class="center">
<Layout gap="XS">
<Layout>
<img src={$organisation.logoUrl || "https://i.imgur.com/ZKyklgF.png"} />
<Layout gap="XS" justifyItems="center" noPadding>
<Heading size="M">Create an admin user</Heading>
<Body size="M"
>The admin user has access to everything in Budibase.</Body
>
<Body size="M" textAlign="center">
The admin user has access to everything in Budibase.
</Body>
</Layout>
</div>
<Layout gap="XS">
<Layout gap="XS" noPadding>
<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 gap="S">
<Button cta on:click={save}>Create super admin user</Button>
</Layout>
</div>
@ -68,9 +68,6 @@
justify-content: flex-start;
align-items: stretch;
}
.center {
text-align: center;
}
img {
width: 40px;
margin: 0 auto;

View File

@ -1,19 +1,7 @@
<script>
import { store, automationStore } from "builderStore"
import { roles } from "stores/backend"
import {
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 { Button, Icon, ActionGroup, Tabs, Tab } from "@budibase/bbui"
import DeployModal from "components/deploy/DeployModal.svelte"
import RevertModal from "components/deploy/RevertModal.svelte"
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>
import { goto } from "@roxi/routify"
$goto("./login")
import { redirect } from "@roxi/routify"
$redirect("./login")
</script>

View File

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

View File

@ -1,8 +1,8 @@
<script>
import { Layout, Heading, Body, Button, notifications } from "@budibase/bbui"
import { goto, params } from "@roxi/routify"
import { users, organisation } from "stores/portal"
import PasswordRepeatInput from "components/common/users/PasswordRepeatInput.svelte"
import { users } from "stores/portal"
const inviteCode = $params["?code"]
let password, error
@ -23,19 +23,18 @@
<section>
<div class="container">
<Layout gap="XS">
<img src="https://i.imgur.com/ZKyklgF.png" />
<Layout>
<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 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 />
</Layout>
<Layout gap="S">
<Button disabled={error} cta on:click={acceptInvite}>Accept invite</Button
>
<Button disabled={error} cta on:click={acceptInvite}>
Accept invite
</Button>
</Layout>
</div>
</section>

View File

@ -1,5 +1,5 @@
<script>
import { isActive, goto } from "@roxi/routify"
import { isActive, redirect, goto } from "@roxi/routify"
import {
Icon,
Avatar,
@ -12,15 +12,14 @@
Modal,
} from "@budibase/bbui"
import ConfigChecklist from "components/common/ConfigChecklist.svelte"
import { organisation } from "stores/portal"
import { auth } from "stores/backend"
import { organisation, auth } from "stores/portal"
import BuilderSettingsModal from "components/start/BuilderSettingsModal.svelte"
import { onMount } from "svelte"
let oldSettingsModal
let loaded = false
organisation.init()
let menu = [
const menu = [
{ title: "Apps", href: "/builder/portal/apps" },
{ title: "Drafts", href: "/builder/portal/drafts" },
{ title: "Users", href: "/builder/portal/manage/users", heading: "Manage" },
@ -35,8 +34,19 @@
{ title: "Theming", href: "/builder/portal/theming" },
{ 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>
{#if loaded}
<div class="container">
<div class="nav">
<Layout paddingX="L" paddingY="L">
@ -72,6 +82,9 @@
<MenuItem icon="Settings" on:click={oldSettingsModal.show}>
Old settings
</MenuItem>
<MenuItem icon="UserDeveloper" on:click={() => $goto("../apps")}>
Close developer mode
</MenuItem>
<MenuItem icon="LogOut" on:click={auth.logout}>Log out</MenuItem>
</ActionMenu>
</div>
@ -83,6 +96,7 @@
<Modal bind:this={oldSettingsModal} width="30%">
<BuilderSettingsModal />
</Modal>
{/if}
<style>
.container {

View File

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

View File

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

View File

@ -59,44 +59,42 @@
})
</script>
<Page>
<Layout noPadding>
<div>
<Layout>
<Layout gap="XS" noPadding>
<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 />
</Layout>
{#if google}
<div>
<Divider />
<Layout gap="XS" noPadding>
<Heading size="S">
<span>
<GoogleLogo />
Google
</span>
</Heading>
<Body>
To allow users to authenticate using their Google accounts, fill out
the fields below.
<Body size="S">
To allow users to authenticate using their Google accounts, fill out the
fields below.
</Body>
</div>
</Layout>
<Layout gap="XS" noPadding>
{#each ConfigFields.Google as field}
<div class="form-row">
<Label size="L">{field}</Label>
<Input bind:value={google.config[field]} />
</div>
{/each}
</Layout>
<div>
<Button primary on:click={() => save(google)}>Save</Button>
<Button cta on:click={() => save(google)}>Save</Button>
</div>
<Divider />
{/if}
</Layout>
</Page>
<style>
.form-row {

View File

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

View File

@ -102,59 +102,57 @@
})
</script>
<Page>
<header>
<Layout>
<Layout noPadding gap="XS">
<Heading size="M">Email</Heading>
<Body size="S">
<Body>
Sending email is not required, but highly recommended for processes such
as password recovery. To setup automated auth emails, simply add the
values below and click activate.
</Body>
</header>
</Layout>
<Divider />
{#if smtpConfig}
<div class="config-form">
<Layout gap="XS" noPadding>
<Heading size="S">SMTP</Heading>
<Body size="S">
To allow your app to benefit from automated auth emails, add your SMTP
details below.
</Body>
<Layout gap="S">
<Heading size="S">
<span />
</Heading>
</Layout>
<Layout gap="XS" noPadding>
<div class="form-row">
<Label>Host</Label>
<Label size="L">Host</Label>
<Input bind:value={smtpConfig.config.host} />
</div>
<div class="form-row">
<Label>Port</Label>
<Label size="L">Port</Label>
<Input type="number" bind:value={smtpConfig.config.port} />
</div>
<div class="form-row">
<Label>User</Label>
<Label size="L">User</Label>
<Input bind:value={smtpConfig.config.auth.user} />
</div>
<div class="form-row">
<Label>Password</Label>
<Label size="L">Password</Label>
<Input type="password" bind:value={smtpConfig.config.auth.pass} />
</div>
<div class="form-row">
<Label>From email address</Label>
<Label size="L">From email address</Label>
<Input type="email" bind:value={smtpConfig.config.from} />
</div>
</Layout>
<div>
<Button cta on:click={saveSmtp}>Save</Button>
</div>
<Divider />
<div class="config-form">
<Layout gap="XS" noPadding>
<Heading size="S">Templates</Heading>
<Body size="S">
Budibase comes out of the box with ready-made email templates to help
with user onboarding. Please refrain from changing the links.
</Body>
</div>
</Layout>
<Table
{customRenderers}
data={$email.templates}
@ -165,27 +163,13 @@
allowEditColumns={false}
/>
{/if}
</Page>
</Layout>
<style>
.config-form {
margin-top: 42px;
margin-bottom: 42px;
}
.form-row {
display: grid;
grid-template-columns: 20% 1fr;
grid-template-columns: 25% 1fr;
grid-gap: var(--spacing-l);
}
span {
display: flex;
align-items: center;
gap: var(--spacing-s);
}
header {
margin-bottom: 42px;
}
</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 }) {
console.log(detail)
selectedApp = detail
editRolesModal.show()
}
</script>
<Layout noPadding gap="XS">
<Layout noPadding>
<Layout gap="XS" noPadding>
<div class="back">
<ActionButton on:click={() => $goto("./")} quiet size="S" icon="BackAndroid"
>Back to users</ActionButton
<ActionButton
on:click={() => $goto("./")}
quiet
size="S"
icon="BackAndroid"
>
Back to users
</ActionButton>
</div>
<div class="heading">
<Layout noPadding gap="XS">
<Heading>User: {$userFetch?.data?.email}</Heading>
<Body
>Change user settings and update their app roles. Also contains the
ability to delete the user as well as force reset their password.
<Body>
Change user settings and update their app roles. Also contains the ability
to delete the user as well as force reset their password..
</Body>
</Layout>
</div>
<Divider size="S" />
<div class="general">
<Layout gap="S" noPadding>
<Heading size="S">General</Heading>
<div class="fields">
<div class="field">
@ -97,6 +99,14 @@
<Label size="L">Group(s)</Label>
<Select disabled options={["All users"]} value="All users" />
</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">
<Label size="L">Development access?</Label>
<Toggle
@ -115,9 +125,9 @@
on:click={resetPasswordModal.show}>Force password reset</ActionButton
>
</div>
</div>
</Layout>
<Divider size="S" />
<div class="roles">
<Layout gap="S" noPadding>
<Heading size="S">Configure roles</Heading>
<Table
on:click={openUpdateRolesModal}
@ -128,16 +138,14 @@
allowSelectRows={false}
customRenderers={[{ column: "role", component: TagsRenderer }]}
/>
</div>
</Layout>
<Divider size="S" />
<div class="delete">
<Layout gap="S" noPadding
><Heading size="S">Delete user</Heading>
<Layout gap="XS" noPadding>
<Heading size="S">Delete user</Heading>
<Body>Deleting a user completely removes them from your account.</Body>
</Layout>
<div class="delete-button">
<Button warning on:click={deleteUserModal.show}>Delete user</Button>
</div></Layout
>
</div>
</Layout>
@ -150,10 +158,9 @@
cancelText="Cancel"
showCloseIcon={false}
>
<Body
>Are you sure you want to delete <strong>{$userFetch?.data?.email}</strong
></Body
>
<Body>
Are you sure you want to delete <strong>{$userFetch?.data?.email}</strong>
</Body>
</ModalContent>
</Modal>
<Modal bind:this={editRolesModal}>
@ -174,26 +181,12 @@
.fields {
display: grid;
grid-gap: var(--spacing-m);
margin-top: var(--spacing-xl);
}
.field {
display: grid;
grid-template-columns: 32% 1fr;
align-items: center;
}
.heading {
margin-bottom: var(--spacing-xl);
}
.general {
position: relative;
margin: var(--spacing-xl) 0;
}
.roles {
margin: var(--spacing-xl) 0;
}
.delete {
margin-top: var(--spacing-xl);
}
.regenerate {
position: absolute;
top: 0;

View File

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

View File

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

View File

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

View File

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

View File

@ -48,28 +48,27 @@
</script>
<Layout>
<div class="heading">
<Layout gap="XS" noPadding>
<Heading>Users</Heading>
<Body
>Users are the common denominator in Budibase. Each user is assigned to a
<Body>
Users are the common denominator in Budibase. Each user is assigned to a
group that contains apps and permissions. In this section, you can add
users, or edit and delete an existing user.</Body
>
</div>
users, or edit and delete an existing user.
</Body>
</Layout>
<Divider size="S" />
<div class="users">
<Layout gap="S" noPadding>
<div class="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">
<Label size="L">Search / filter</Label>
<Search bind:value={search} placeholder="" />
</div>
<div class="buttons">
<ButtonGroup>
<Button disabled secondary>Import users</Button>
<Button overBackground on:click={createUserModal.show}>Add user</Button>
</ButtonGroup>
</div>
<Table
on:click={({ detail }) => $goto(`./${detail._id}`)}
{schema}
@ -79,31 +78,28 @@
allowSelectRows={false}
customRenderers={[{ column: "group", component: TagsRenderer }]}
/>
</div>
</Layout>
</Layout>
<Modal bind:this={createUserModal}
><AddUserModal on:change={openBasicOnoboardingModal} /></Modal
>
<Modal bind:this={createUserModal}>
<AddUserModal on:change={openBasicOnoboardingModal} />
</Modal>
<Modal bind:this={basicOnboardingModal}><BasicOnboardingModal {email} /></Modal>
<style>
.users {
position: relative;
}
.field {
display: flex;
align-items: center;
flex-direction: row;
grid-gap: var(--spacing-m);
margin: var(--spacing-xl) 0;
}
.field > :global(*) + :global(*) {
margin-left: var(--spacing-m);
}
.buttons {
position: absolute;
top: 0;
right: 0;
.users-heading {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
</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 { post } from "builderStore/api"
import analytics from "analytics"
let analyticsDisabled = analytics.disabled()
function toggleAnalytics() {
if (analyticsDisabled) {
analytics.optIn()
} else {
analytics.optOut()
}
}
import { writable } from "svelte/store"
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 file
async function uploadLogo() {
async function uploadLogo(file) {
let data = new FormData()
data.append("file", file)
const res = await post("/api/admin/configs/upload/settings/logo", data, {})
return await res.json()
}
async function saveConfig() {
loading = true
await toggleAnalytics()
if (file) {
await uploadLogo()
// Set analytics preference
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({
company: $organisation.company,
platformUrl: $organisation.platformUrl,
company: $values.company ?? "",
platformUrl: $values.platformUrl ?? "",
})
if (res.status === 200) {
notifications.success("Settings saved.")
notifications.success("Settings saved successfully")
} else {
notifications.error(res.message)
}
loading = false
}
</script>
<div class="container">
<Layout noPadding>
<div class="intro">
<Layout>
<Layout gap="XS" noPadding>
<Heading size="M">General</Heading>
<Body>
General is the place where you edit your organisation name, logo. You
can also configure your platform URL as well as turn on or off
analytics.
General is the place where you edit your organisation name, logo. You can
also configure your platform URL as well as turn on or off analytics.
</Body>
</div>
</Layout>
<Divider size="S" />
<div class="information">
<Layout gap="XS" noPadding>
<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="field">
<Label size="L">Organization name</Label>
<Input thin bind:value={$organisation.company} />
<Input thin bind:value={$values.company} />
</div>
<div class="field logo">
<Label size="L">Logo</Label>
<div class="file">
<Dropzone
value={[file]}
value={[$values.logo]}
on:change={e => {
file = e.detail?.[0]
$values.logo = e.detail?.[0]
}}
/>
</div>
</div>
</div>
</div>
<Divider size="S" />
<div class="analytics">
<Layout gap="XS" noPadding>
<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="field">
<Label size="L">Platform URL</Label>
<Input thin bind:value={$organisation.platformUrl} />
</div>
<Input thin bind:value={$values.platformUrl} />
</div>
</div>
<Divider size="S" />
<div class="analytics">
<Layout gap="S" noPadding>
<Layout gap="XS" noPadding>
<Heading size="S">Analytics</Heading>
<Body>
<Body size="S">
If you would like to send analytics that help us make Budibase better,
please let us know below.
</Body>
</Layout>
<div class="fields">
<div class="field">
<Label size="L">Send Analytics to Budibase</Label>
<Toggle text="" value={!analyticsDisabled} />
<Toggle text="" bind:value={$values.analytics} />
</div>
</div>
</div>
<div class="save">
</Layout>
<div>
<Button disabled={loading} on:click={saveConfig} cta>Save</Button>
</div>
</Layout>
</div>
<style>
.fields {
display: grid;
grid-gap: var(--spacing-m);
margin-top: var(--spacing-xl);
}
.field {
display: grid;
grid-template-columns: 32% 1fr;
grid-template-columns: 33% 1fr;
align-items: center;
}
.file {
@ -134,10 +142,4 @@
.logo {
align-items: start;
}
.intro {
display: grid;
}
.save {
margin-left: auto;
}
</style>

View File

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

View File

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

View File

@ -7,7 +7,7 @@ export function createAuthStore() {
return {
subscribe: store.subscribe,
checkAuth: async () => {
const response = await api.get("/api/self")
const response = await api.get("/api/admin/users/self")
const user = await response.json()
if (response.status === 200) {
store.update(state => ({ ...state, user }))
@ -33,6 +33,14 @@ export function createAuthStore() {
await response.json()
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 => {
const response = await api.post(`/api/admin/auth/reset`, {
email,

View File

@ -3,3 +3,4 @@ export { users } from "./users"
export { admin } from "./admin"
export { apps } from "./apps"
export { email } from "./email"
export { auth } from "./auth"

View File

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

View File

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

View File

@ -6,6 +6,8 @@ const webhooks = require("./webhook")
const { getAutomationParams, generateAutomationID } = require("../../db/utils")
const WH_STEP_ID = triggers.BUILTIN_DEFINITIONS.WEBHOOK.stepId
const CRON_STEP_ID = triggers.BUILTIN_DEFINITIONS.CRON.stepId
/*************************
* *
* BUILDER FUNCTIONS *
@ -32,6 +34,46 @@ function cleanAutomationInputs(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.
* @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,
newAuto: automation,
})
automation = await checkForCronTriggers({
appId: ctx.appId,
newAuto: automation,
})
const response = await db.put(automation)
automation._rev = response.rev
@ -135,6 +181,11 @@ exports.update = async function (ctx) {
oldAuto: oldAutomation,
newAuto: automation,
})
automation = await checkForCronTriggers({
appId: ctx.appId,
oldAuto: oldAutomation,
newAuto: automation,
})
const response = await db.put(automation)
automation._rev = response.rev
@ -171,6 +222,10 @@ exports.destroy = async function (ctx) {
appId: ctx.appId,
oldAuto: oldAutomation,
})
await checkForCronTriggers({
appId: ctx.appId,
oldAuto: oldAutomation,
})
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) {
const appId = ctx.appId
const db = new CouchDB(appId)
let dbRow = await db.get(ctx.params.rowId)
let dbTable = await db.get(dbRow.tableId)
const patchfields = ctx.request.body
const inputs = ctx.request.body
const tableId = inputs.tableId
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
for (let key of Object.keys(patchfields)) {
for (let key of Object.keys(inputs)) {
if (!dbTable.schema[key]) continue
dbRow[key] = patchfields[key]
dbRow[key] = inputs[key]
}
// this returns the table and row incase they have been updated
@ -90,13 +105,9 @@ exports.patch = async function (ctx) {
table,
})
// TODO remove special user case in future
if (row.tableId === InternalTables.USER_METADATA) {
if (isUserTable) {
// the row has been updated, need to put it into the ctx
ctx.request.body = {
...row,
password: ctx.request.body.password,
}
ctx.request.body = row
await userController.updateMetadata(ctx)
return
}
@ -129,13 +140,10 @@ exports.save = async function (ctx) {
// if the row obj had an _id then it will have been retrieved
if (inputs._id && inputs._rev) {
const existingRow = await db.get(inputs._id)
if (existingRow) {
ctx.params.rowId = inputs._id
await exports.patch(ctx)
return
}
}
if (!inputs._rev && !inputs._id) {
inputs._id = generateRowID(inputs.tableId)
@ -167,14 +175,6 @@ exports.save = async function (ctx) {
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"
const response = await db.put(row)
// don't worry about rev, tables handle rev/lastID updates

View File

@ -6,7 +6,7 @@ const {
InternalTables,
} = require("../../../db/utils")
const { isEqual } = require("lodash/fp")
const { AutoFieldSubTypes } = require("../../../constants")
const { AutoFieldSubTypes, FieldTypes } = require("../../../constants")
const { inputProcessing } = require("../../../utilities/rowProcessor")
const { USERS_TABLE_SCHEMA } = require("../../../constants")
@ -72,18 +72,21 @@ exports.handleDataImport = async (appId, user, table, dataImport) => {
row._id = generateRowID(table._id)
row.tableId = table._id
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
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
}

View File

@ -2,17 +2,23 @@ const CouchDB = require("../../db")
const {
generateUserMetadataID,
getUserMetadataParams,
getGlobalIDFromUserMetadataID,
} = 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 {
getGlobalUsers,
saveGlobalUser,
deleteGlobalUser,
addAppRoleToUser,
} = require("../../utilities/workerRequests")
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) {
const database = new CouchDB(ctx.appId)
const global = await getGlobalUsers(ctx, ctx.appId)
@ -38,43 +44,12 @@ exports.fetchMetadata = async function (ctx) {
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) {
// overwrite the ID with current users
ctx.request.body._id = ctx.user._id
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
delete ctx.request.body._rev
@ -84,23 +59,19 @@ exports.updateSelfMetadata = async function (ctx) {
exports.updateMetadata = async function (ctx) {
const appId = ctx.appId
const db = new CouchDB(appId)
const user = ctx.request.body
const globalUser = await saveGlobalUser(ctx, appId, {
...user,
_id: getGlobalIDFromUserMetadataID(user._id),
})
const user = removeGlobalProps(ctx.request.body)
if (user.roleId) {
await addAppRoleToUser(ctx, appId, user.roleId, user._id)
}
const metadata = {
...globalUser,
tableId: InternalTables.USER_METADATA,
_id: user._id || generateUserMetadataID(globalUser._id),
_rev: user._rev,
...user,
}
ctx.body = await db.put(metadata)
}
exports.destroyMetadata = async function (ctx) {
const db = new CouchDB(ctx.appId)
await deleteGlobalUser(ctx, getGlobalIDFromUserMetadataID(ctx.params.id))
try {
const dbUser = await db.get(ctx.params.id)
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
}
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 { 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", () => {
let request = setup.getRequest()
let config = setup.getConfig()
@ -27,7 +13,7 @@ describe("/authenticate", () => {
describe("fetch self", () => {
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 res = await request
.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", () => {
it("should be able to fetch tables contents via 'view'", async () => {
const row = await config.createRow()

View File

@ -8,12 +8,7 @@ jest.mock("../../../utilities/workerRequests", () => ({
getGlobalUsers: jest.fn(() => {
return {}
}),
saveGlobalUser: jest.fn(() => {
const uuid = require("uuid/v4")
return {
_id: `us_${uuid()}`
}
}),
addAppRoleToUser: 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", () => {
beforeEach(() => {
workerRequests.saveGlobalUser.mockImplementationOnce(() => ({
_id: "us_test@test.com"
}))
})
it("should be able to update the user", async () => {
@ -144,16 +88,12 @@ describe("/users", () => {
.expect(200)
.expect("Content-Type", /json/)
expect(res.body.message).toBeDefined()
expect(workerRequests.deleteGlobalUser).toHaveBeenCalled()
})
})
describe("find", () => {
beforeEach(() => {
jest.resetAllMocks()
workerRequests.saveGlobalUser.mockImplementationOnce(() => ({
_id: "us_uuid1",
}))
workerRequests.getGlobalUsers.mockImplementationOnce(() => ({
_id: "us_uuid1",
roleId: BUILTIN_ROLE_IDS.POWER,

View File

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

View File

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

View File

@ -3,7 +3,6 @@ const sendSmtpEmail = require("./steps/sendSmtpEmail")
const createRow = require("./steps/createRow")
const updateRow = require("./steps/updateRow")
const deleteRow = require("./steps/deleteRow")
const createUser = require("./steps/createUser")
const executeScript = require("./steps/executeScript")
const executeQuery = require("./steps/executeQuery")
const outgoingWebhook = require("./steps/outgoingWebhook")
@ -20,7 +19,6 @@ const BUILTIN_ACTIONS = {
CREATE_ROW: createRow.run,
UPDATE_ROW: updateRow.run,
DELETE_ROW: deleteRow.run,
CREATE_USER: createUser.run,
OUTGOING_WEBHOOK: outgoingWebhook.run,
EXECUTE_SCRIPT: executeScript.run,
EXECUTE_QUERY: executeQuery.run,
@ -31,7 +29,6 @@ const BUILTIN_DEFINITIONS = {
CREATE_ROW: createRow.definition,
UPDATE_ROW: updateRow.definition,
DELETE_ROW: deleteRow.definition,
CREATE_USER: createUser.definition,
OUTGOING_WEBHOOK: outgoingWebhook.definition,
EXECUTE_SCRIPT: executeScript.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.
*/
class Orchestrator {
constructor(automation, triggerOutput) {
constructor(automation, triggerOutput = {}) {
this._metadata = triggerOutput.metadata
this._chainCount = this._metadata ? this._metadata.automationChainCount : 0
this._appId = triggerOutput.appId

View File

@ -7,9 +7,10 @@ const Queue = env.isTest()
const { getAutomationParams } = require("../db/utils")
const { coerce } = require("../utilities/rowProcessor")
const { utils } = require("@budibase/auth/redis")
const { JobQueues } = require("../constants")
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_BOOL = false
@ -196,6 +197,29 @@ const BUILTIN_DEFINITIONS = {
},
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) {

View File

@ -5,6 +5,10 @@ const { ObjectStoreBuckets } = require("@budibase/auth").objectStore
exports.LOGO_URL =
"https://d33wubrfki0l68.cloudfront.net/aac32159d7207b5085e74a7ef67afbb7027786c5/2b1fd/img/logo/bb-emblem.svg"
exports.JobQueues = {
AUTOMATIONS: "automationQueue",
}
exports.FieldTypes = {
STRING: "string",
LONGFORM: "longform",
@ -50,6 +54,24 @@ exports.USERS_TABLE_SCHEMA = {
fieldName: "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: {
fieldName: "roleId",
name: "roleId",

View File

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

View File

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

View File

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

View File

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

View File

@ -3,6 +3,7 @@ const env = require("../environment")
const { checkSlashesInUrl } = require("./index")
const { BUILTIN_ROLE_IDS } = require("@budibase/auth/roles")
const { getDeployedAppID } = require("@budibase/auth/db")
const { getGlobalIDFromUserMetadataID } = require("../db/utils")
function getAppRole(appId, user) {
if (!user.roles) {
@ -118,53 +119,50 @@ exports.getGlobalUsers = async (ctx, appId = null, globalId = null) => {
return users
}
exports.saveGlobalUser = async (ctx, appId, body) => {
const globalUser = body._id
? 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,
},
},
}
exports.getGlobalSelf = async ctx => {
const endpoint = `/api/admin/users/self`
const response = await fetch(
checkSlashesInUrl(env.WORKER_URL + endpoint),
request(ctx, reqCfg)
request(ctx, { method: "GET" })
)
const json = await response.json()
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 {
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,
_id: json._id,
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 => {
// 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 = []
for (let app of apps) {
// use dev app IDs

View File

@ -16,10 +16,15 @@ exports.save = async ctx => {
const { email, password, _id } = ctx.request.body
// 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))) {
ctx.throw(400, "Email address already in use.")
}
} else {
dbUser = await db.get(_id)
}
// get the password, make sure one is defined
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
exports.fetch = async ctx => {
const db = new CouchDB(GLOBAL_DB)
@ -145,12 +177,16 @@ exports.invite = async ctx => {
}
exports.inviteAccept = async ctx => {
const { inviteCode } = ctx.request.body
const { inviteCode, password, firstName, lastName } = ctx.request.body
try {
const email = await checkInviteCode(inviteCode)
// redirect the request
delete ctx.request.body.inviteCode
ctx.request.body.email = email
// only pass through certain props for accepting
ctx.request.body = {
firstName,
lastName,
password,
email,
}
// this will flesh out the body response
await exports.save(ctx)
} catch (err) {

View File

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

View File

@ -1,6 +1,7 @@
const Router = require("@koa/router")
const controller = require("../../controllers/admin/groups")
const joiValidator = require("../../../middleware/joi-validator")
const adminOnly = require("../../../middleware/adminOnly")
const Joi = require("joi")
const router = Router()
@ -24,9 +25,14 @@ function buildGroupSaveValidation() {
}
router
.post("/api/admin/groups", buildGroupSaveValidation(), controller.save)
.post(
"/api/admin/groups",
adminOnly,
buildGroupSaveValidation(),
controller.save
)
.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)
module.exports = router

View File

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

View File

@ -3,6 +3,7 @@ const controller = require("../../controllers/admin/templates")
const joiValidator = require("../../../middleware/joi-validator")
const Joi = require("joi")
const { TemplatePurpose, TemplateTypes } = require("../../../constants")
const adminOnly = require("../../../middleware/adminOnly")
const router = Router()
@ -21,11 +22,16 @@ function buildTemplateSaveValidation() {
router
.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/:type", controller.fetchByType)
.get("/api/admin/template/:ownerId", controller.fetchByOwner)
.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

View File

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

View File

@ -30,7 +30,7 @@ describe("/api/admin/auth", () => {
expect(sendMailMock).toHaveBeenCalled()
const emailCall = sendMailMock.mock.calls[0][0]
// 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]
expect(code).toBeDefined()
})

View File

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

View File

@ -30,7 +30,7 @@ describe("/api/admin/users", () => {
expect(sendMailMock).toHaveBeenCalled()
const emailCall = sendMailMock.mock.calls[0][0]
// 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]
expect(code).toBeDefined()
})

View File

@ -38,7 +38,8 @@ class TestConfiguration {
return request.body
}
async init() {
async init(createUser = true) {
if (createUser) {
// create a test user
await this._req(
{
@ -48,11 +49,15 @@ class TestConfiguration {
builder: {
global: true,
},
admin: {
global: true,
},
},
null,
controllers.users.save
)
}
}
async end() {
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 message = {
from: from || config.from,
subject: await processString(subject || config.subject, context),
to: email,
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)
if (TEST_MODE) {
console.log("Test email URL: " + nodemailer.getTestMessageUrl(response))