Merge branch 'develop' into prevent_space_in_url

# Conflicts:
#	packages/builder/src/components/design/PropertiesPanel/ScreenSettingsSection.svelte
This commit is contained in:
Maurits Lourens 2021-08-05 20:11:55 +02:00
commit aa1d214c4d
236 changed files with 3922 additions and 1210 deletions

View File

@ -0,0 +1,14 @@
---
name: Feature Request
about: Request a new budibase feature or enhancement
title: ''
labels: enhancement
assignees: ''
---
**Describe the feature request**
A clear and concise description of what the feature request.
**Screenshots**
If applicable, add screenshots to help explain your problem.

View File

@ -57,7 +57,7 @@
- **Open source and extensible.** Budibase is open-source - licensed as GPL v3. This should fill you with confidence that Budibase will always be around. You can also code against Budibase or fork it and make changes as you please, providing a developer-friendly experience. - **Open source and extensible.** Budibase is open-source - licensed as GPL v3. This should fill you with confidence that Budibase will always be around. You can also code against Budibase or fork it and make changes as you please, providing a developer-friendly experience.
- **Load data or start from scratch.** Budibase pulls in data from multiple sources, including MongoDB, CouchDB, PostgreSQL, mySQL, Airtable, S3, DyanmoDB, or a REST API. And unlike other platforms, with Budibase you can start from scratch and create business apps with no data sources. [Request new data sources](https://github.com/Budibase/budibase/discussions?discussions_q=category%3AIdeas). - **Load data or start from scratch.** Budibase pulls in data from multiple sources, including MongoDB, CouchDB, PostgreSQL, MySQL, Airtable, S3, DynamoDB, or a REST API. And unlike other platforms, with Budibase you can start from scratch and create business apps with no data sources. [Request new data sources](https://github.com/Budibase/budibase/discussions?discussions_q=category%3AIdeas).
- **Design and build apps with powerful pre-made components.** Budibase comes out of the box with beautifully designed, powerful components which you can use like building blocks to build your UI. We also expose a lot of your favourite CSS styling options so you can go that extra creative mile. [Request new component](https://github.com/Budibase/budibase/discussions?discussions_q=category%3AIdeas). - **Design and build apps with powerful pre-made components.** Budibase comes out of the box with beautifully designed, powerful components which you can use like building blocks to build your UI. We also expose a lot of your favourite CSS styling options so you can go that extra creative mile. [Request new component](https://github.com/Budibase/budibase/discussions?discussions_q=category%3AIdeas).

View File

@ -26,10 +26,18 @@ static_resources:
cluster: couchdb-service cluster: couchdb-service
prefix_rewrite: "/" prefix_rewrite: "/"
- match: { prefix: "/api/system/" }
route:
cluster: worker-dev
- match: { prefix: "/api/admin/" } - match: { prefix: "/api/admin/" }
route: route:
cluster: worker-dev cluster: worker-dev
- match: { prefix: "/api/global/" }
route:
cluster: worker-dev
- match: { prefix: "/api/" } - match: { prefix: "/api/" }
route: route:
cluster: server-dev cluster: server-dev

View File

@ -37,11 +37,19 @@ static_resources:
route: route:
cluster: app-service cluster: app-service
# special case for worker admin API # special cases for worker admin (deprecated), global and system API
- match: { prefix: "/api/global/" }
route:
cluster: worker-service
- match: { prefix: "/api/admin/" } - match: { prefix: "/api/admin/" }
route: route:
cluster: worker-service cluster: worker-service
- match: { prefix: "/api/system/" }
route:
cluster: worker-service
- match: { path: "/" } - match: { path: "/" }
route: route:
cluster: app-service cluster: app-service

View File

@ -8,10 +8,10 @@
</h1> </h1>
<h3 align="center"> <h3 align="center">
Membangun, mengotomatisasi dan hosting sendiri aplikasi internal dalam hitungan menit Membangun, mengotomatisasi dan <i>hosting</i> sendiri aplikasi internal dalam hitungan menit
</h3> </h3>
<p align="center"> <p align="center">
Budibase adalah sebuah platform low-code dengan sumber terbuka, bertujuan untuk membantu para developer dan professional IT, untuk membangun, mengotomatisasi, dan mengirimkan aplikasi internal di infrastruktur sendiri dalam waktu hitungan menit. Budibase adalah sebuah platform <i>low-code</i> dengan sumber terbuka, bertujuan untuk membantu para <i>developer</i> dan professional IT, untuk membangun, mengotomatisasi, dan mengirimkan aplikasi internal di infrastruktur sendiri dalam waktu hitungan menit.
</p> </p>
<h3 align="center"> <h3 align="center">
@ -39,7 +39,7 @@
</p> </p>
<h3 align="center"> <h3 align="center">
<a href="https://docs.budibase.com/getting-started">Mari mulai</a> <a href="https://docs.budibase.com/getting-started">Memulai</a>
<span> · </span> <span> · </span>
<a href="https://docs.budibase.com">Dokumentasi</a> <a href="https://docs.budibase.com">Dokumentasi</a>
<span> · </span> <span> · </span>
@ -53,17 +53,17 @@
<br /><br /> <br /><br />
## ✨ Fitur-fitur ## ✨ Fitur-fitur
- **Membangun dan mengirimkan software nyata.** Tidak seperti platform lain, dengan Budibase Anda membuat dan mengirimkan aplikasi-aplikasi satu halaman. Aplikasi-aplikasi Budibase memiliki kinerja yang matang dan dapat dirancang secara responsif, memberikan pengalaman yang menyenangkan bagi pengguna. - **Membangun dan mengirimkan perangkat lunak sebenarnya .** Tidak seperti platform lain, dengan Budibase Anda membuat dan mengirimkan aplikasi-aplikasi satu halaman. Aplikasi-aplikasi Budibase memiliki kinerja yang matang dan dapat dirancang secara responsif, memberikan pengalaman yang menyenangkan bagi pengguna.
- **Sumber terbuka dan dapat dikembangkan.** Budibase adalah platform dengan sumber terbuka - dilisensikan sebagai GPL v3. Ini akan membuat Anda yakin bahwa Budibase akan selalu ada. Anda juga dapat berkontribusi terhadap kode Budibase atau melakukan fork dan membuat perubahan sesuka Anda, memberikan pengalaman yang ramah developer. - **Sumber terbuka dan dapat dikembangkan.** Budibase adalah platform dengan sumber terbuka - dilisensikan sebagai GPL v3. Ini akan membuat Anda yakin bahwa Budibase akan selalu ada. Anda juga dapat berkontribusi terhadap kode Budibase atau melakukan <i>fork</i> dan membuat perubahan sesuka Anda, memberikan pengalaman yang ramah developer.
- **Muat data atau mulai dari awal.** Budibase menarik data dari berbagai sumber, termasuk MongoDB, CouchDB, PostgreSQL, mySQL, Airtable, S3, DyanmoDB, atau REST API. Dan tidak seperti platform lain, dengan Budibase Anda dapat memulai dari awal dan membuat aplikasi bisnis tanpa sumber data sekalipun. [Permintaan untuk sumber data baru](https://github.com/Budibase/budibase/discussions?discussions_q=category%3AIdeas). - **Memuat data atau mulai dari awal.** Budibase menarik data dari berbagai sumber, termasuk MongoDB, CouchDB, PostgreSQL, mySQL, Airtable, S3, DyanmoDB, atau REST API. Dan tidak seperti platform lain, dengan Budibase Anda dapat memulai dari awal dan membuat aplikasi bisnis tanpa sumber data sekalipun. [Permintaan untuk sumber data baru](https://github.com/Budibase/budibase/discussions?discussions_q=category%3AIdeas).
- **Rancang dan bangun aplikasi dengan komponen siap pakai yang kuat.** Budibase tersedia dengan siap pakai dan dirancang dengan indah, komponen-komponen kuat yang dapat anda gunakan layaknya blok penyusun untuk merancang UI Anda. Kami juga menyediakan banyak sekali pilihan-pilihan CSS favorit yang dapat digunakan sesuai dengan kreatifitas yang Anda inginkan. [Permintaan komponen baru [Permintaan untuk komponen baru](https://github.com/Budibase/budibase/discussions?discussions_q=category%3AIdeas). - **Rancang dan bangun aplikasi dengan komponen siap pakai yang kuat.** Budibase tersedia dengan siap pakai dan dirancang dengan indah, komponen-komponen kuat yang dapat anda gunakan layaknya blok penyusun untuk merancang antar muka Anda. Kami juga menyediakan banyak sekali pilihan-pilihan CSS favorit yang dapat digunakan sesuai dengan kreatifitas yang Anda inginkan. [Permintaan komponen baru [Permintaan untuk komponen baru](https://github.com/Budibase/budibase/discussions?discussions_q=category%3AIdeas).
- **Otomatiskan proses, integrasikan dengan aplikasi lain, dan sambungkan ke webhook.** Menghemat waktu dengan mengotomatisasikan proses dan alur kerja yang masih dikerjakan secara manual. Dari menghubungkan ke webhooks, hingga mengotomatiasi email-email, cukup beritahu Budibase apa yang harus dilakukan dan biarkan dia berkerja untuk Anda. Anda bisa dengan mudah [membuat otomatisasi baru untuk Budibase disini ](https://github.com/Budibase/automations) atau [minta otomatisasi baru](https://github.com/Budibase/budibase/discussions?discussions_q=category%3AIdeas). - **Otomatiskan proses, integrasikan dengan aplikasi lain, dan sambungkan ke webhook.** Menghemat waktu dengan mengotomatisasikan proses dan alur kerja yang masih dikerjakan secara manual. Dari menghubungkan ke webhooks, hingga mengotomatiasi surel, cukup beritahu Budibase apa yang harus dilakukan dan biarkan dia berkerja untuk Anda. Anda bisa dengan mudah [membuat otomatisasi baru untuk Budibase disini ](https://github.com/Budibase/automations) atau [minta otomatisasi baru](https://github.com/Budibase/budibase/discussions?discussions_q=category%3AIdeas).
- **Surga bagi Admin.** Budibase dibuat untuk berkembang. Dengan Budibase, Anda dapat menghosting sendiri di infrastruktrur Anda punya dan secara global mengelola para pengguna, orientasi, SMTP, aplikasi-aplikasi, grup-grup, tema, dan masih masih banyak lagi. Anda juga dapat menyediakan para pengguna/grup dengan sebuah aplikasi portal dan menyebarkan pengelolaan pengguna kepada manaajer grup. - **Surga bagi Admin.** Budibase dibuat untuk berkembang. Dengan Budibase, Anda dapat meng<i>hosting</i> sendiri di infrastruktrur Anda punya dan secara global mengelola para pengguna, orientasi, SMTP, aplikasi-aplikasi, grup-grup, tema, dan masih masih banyak lagi. Anda juga dapat menyediakan para pengguna/grup dengan sebuah aplikasi portal dan menyebarkan pengelolaan pengguna kepada manaajer grup.
<br /> <br />
@ -77,7 +77,7 @@ Saat ini ada 2 cara untuk memulai Budibase, yaitu: Digital Ocean dan Docker.
### Memulai Dengan Digital Ocean ### Memulai Dengan Digital Ocean
Cara paling mudah dan cepat untuk memulai adalah dengan menggunakan Digital Ocean: Cara paling mudah dan cepat untuk memulai adalah dengan menggunakan Digital Ocean:
<a href="https://marketplace.digitalocean.com/apps/budibase">1-klik Digital Ocean deploy</a> <a href="https://marketplace.digitalocean.com/apps/budibase">1-klik Digital Ocean <i>deploy</i></a>
<a href="https://marketplace.digitalocean.com/apps/budibase"> <a href="https://marketplace.digitalocean.com/apps/budibase">
<img src="https://user-images.githubusercontent.com/552074/87779219-5c3b7600-c824-11ea-9898-981a8ba94f6c.png" alt="digital ocean badge"> <img src="https://user-images.githubusercontent.com/552074/87779219-5c3b7600-c824-11ea-9898-981a8ba94f6c.png" alt="digital ocean badge">
@ -85,17 +85,17 @@ Cara paling mudah dan cepat untuk memulai adalah dengan menggunakan Digital Ocea
<br /><br /> <br /><br />
### Memulai dengan Docker ### Memulai dengan Docker
Untuk memulai, Anda harus memiliki docker dan docker compose terinstal di mesin anda. Untuk memulai, Anda harus memiliki docker dan docker compose ter<i>install</i> di mesin anda.
Setelah Anda docker terinstal, prosesnya akan memakan waktu 5 menit, dengan empat langkah ini: Setelah Anda docker ter<i>install</i>, prosesnya akan memakan waktu 5 menit, dengan empat langkah ini:
1. Instal CLI Budibase. 1. <i>Install</i> CLI Budibase.
``` ```
$ npm i -g @budibase/cli $ npm i -g @budibase/cli
``` ```
2. Mempersiapkan Budibase (pilih tempat menyimpan Budibase dan port untuk menjalankannya) 2. Mempersiapkan Budibase (pilih tempat menyimpan Budibase dan <i>port</i> untuk menjalankannya)
``` ```
budi hosting --init budi hosting --init
@ -144,7 +144,7 @@ Jika Anda memiliki pertanyaan atau ingin berbicara dengan pengguna Budibase lain
## ❗ Kode etik ## ❗ Kode etik
Budibase berdedikasi untuk memberikan pengalaman yang ramah, beragam, dan bebas pelecehan bagi semua orang. Kami mengharapkan semua orang di komunitas Budibase untuk mematuhi [**Kode etik**](https://github.com/Budibase/budibase/blob/HEAD/.github/CODE_OF_CONDUCT.md). Mohon dibac. Budibase berdedikasi untuk memberikan pengalaman yang ramah, beragam, dan bebas pelecehan bagi semua orang. Kami mengharapkan semua orang di komunitas Budibase untuk mematuhi [**Kode etik**](https://github.com/Budibase/budibase/blob/HEAD/.github/CODE_OF_CONDUCT.md). Mohon dibaca.
<br /> <br />
--- ---
@ -153,19 +153,19 @@ Budibase berdedikasi untuk memberikan pengalaman yang ramah, beragam, dan bebas
## 🙌 Berkontribusi untuk Budibase ## 🙌 Berkontribusi untuk Budibase
Dari membuka laporan bug hingga membuat pull request: setiap kontribusi dihargai dan disambut. Jika Anda berencana untuk menerapkan fitur baru atau mengubah API, harap buat issue terlebih dahulu. Dengan cara ini kami dapat memastikan pekerjaan Anda tidak sia-sia. Dari membuka laporan bug hingga membuat <i>pull request</i>: setiap kontribusi dihargai dan disambut. Jika Anda berencana untuk menerapkan fitur baru atau mengubah API, harap buat <i>issue</i> terlebih dahulu. Dengan cara ini kami dapat memastikan pekerjaan Anda tidak sia-sia.
### Tidak yakin memulai dari mana? ### Tidak yakin memulai dari mana?
Sebuat tempat yang baik untuk mulai berkontribusi, adalah [Masalah pertama di proyek](https://github.com/Budibase/budibase/projects/22). Sebuat tempat yang baik untuk mulai berkontribusi, adalah [masalah pertama di proyek](https://github.com/Budibase/budibase/projects/22).
### Bagaimana repositori diatur ### Bagaimana repositori diatur
Budibase adalah monorepo yang dikelola oleh lerna. Lerna mengelola pembangunan dan penerbitan paket budibase. Pada level tinggi, inilah paket-paket yang membentuk Budibase. Budibase adalah <i>monorepo</i> yang dikelola oleh lerna. Lerna mengelola pembangunan dan penerbitan paket Budibase. Pada level tinggi, inilah paket-paket yang membentuk Budibase.
- [packages/builder](https://github.com/Budibase/budibase/tree/HEAD/packages/builder) - berisi kode untuk aplikasi svelte sisi klien pembangun budibase. - [packages/builder](https://github.com/Budibase/budibase/tree/HEAD/packages/builder) - berisi kode untuk aplikasi svelte sisi klien pembangun Budibase.
- [packages/client](https://github.com/Budibase/budibase/tree/HEAD/packages/client) - Modul yang berjalan di browser yang bertanggung jawab untuk membaca definisi JSON dan membuat aplikasi web yang hidup dan bernafas darinya. - [packages/client](https://github.com/Budibase/budibase/tree/HEAD/packages/client) - Modul yang berjalan di <i>browser</i> yang bertanggung jawab untuk membaca definisi JSON dan membuat aplikasi web yang hidup dan bernafas darinya.
- [packages/server](https://github.com/Budibase/budibase/tree/HEAD/packages/server) - Server Budibase. Aplikasi Koa ini bertanggung jawab untuk melayani JS untuk aplikasi builder dan budibase, serta menyediakan API untuk interaksi dengan database dan sistem file. - [packages/server](https://github.com/Budibase/budibase/tree/HEAD/packages/server) - <i>Server</i> Budibase. Aplikasi Koa ini bertanggung jawab untuk melayani JS untuk aplikasi builder dan budibase, serta menyediakan API untuk interaksi dengan database dan sistem file.
Untuk informasi lebih lanjut, lihat [CONTRIBUTING.md](https://github.com/Budibase/budibase/blob/HEAD/.github/CONTRIBUTING.md) Untuk informasi lebih lanjut, lihat [CONTRIBUTING.md](https://github.com/Budibase/budibase/blob/HEAD/.github/CONTRIBUTING.md)
<br /><br /> <br /><br />
@ -188,7 +188,7 @@ Budibase adalah sumber terbuka, dilisensikan sebagai [GPL v3](https://www.gnu.or
[![Stargazers over time](https://starchart.cc/Budibase/budibase.svg)](https://starchart.cc/Budibase/budibase) [![Stargazers over time](https://starchart.cc/Budibase/budibase.svg)](https://starchart.cc/Budibase/budibase)
Jika Anda mengalami masalah di antara pembaruan, silakan gunakan panduan [disini](https://github.com/Budibase/budibase/blob/HEAD/.github/CONTRIBUTING.md#troubleshooting) untuk membersihkan environment Anda. Jika Anda mengalami masalah di antara pembaruan, silakan gunakan panduan [disini](https://github.com/Budibase/budibase/blob/HEAD/.github/CONTRIBUTING.md#troubleshooting) untuk membersihkan <i>environment</i> Anda.
<br /> <br />
@ -227,4 +227,4 @@ Terima kasih untuk orang-orang hebat ini ([emoji key](https://allcontributors.or
<!-- ALL-CONTRIBUTORS-LIST:END --> <!-- ALL-CONTRIBUTORS-LIST:END -->
This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! Proyek ini mengikuti spesifikasi [all-contributors](https://github.com/all-contributors/all-contributors). Kontribusi dalam bentuk apa pun dipersilakan!

View File

@ -1,5 +1,5 @@
{ {
"version": "0.9.82", "version": "0.9.99-alpha.6",
"npmClient": "yarn", "npmClient": "yarn",
"packages": [ "packages": [
"packages/*" "packages/*"

View File

@ -43,6 +43,8 @@
"test:e2e": "lerna run cy:test", "test:e2e": "lerna run cy:test",
"test:e2e:ci": "lerna run cy:ci", "test:e2e:ci": "lerna run cy:ci",
"build:docker": "lerna run build:docker && cd hosting/scripts/linux/ && ./release-to-docker-hub.sh && cd -", "build:docker": "lerna run build:docker && cd hosting/scripts/linux/ && ./release-to-docker-hub.sh && cd -",
"build:docker:develop": "lerna run build:docker && cd hosting/scripts/linux/ && ./release-to-docker-hub.sh develop && cd -" "build:docker:develop": "node scripts/pinVersions && lerna run build:docker && cd hosting/scripts/linux/ && ./release-to-docker-hub.sh develop && cd -",
"multi:enable": "lerna run multi:enable",
"multi:disable": "lerna run multi:disable"
} }
} }

View File

@ -1 +1,4 @@
module.exports = require("./src/db/utils") module.exports = {
...require("./src/db/utils"),
...require("./src/db/constants"),
}

View File

@ -1,6 +1,6 @@
{ {
"name": "@budibase/auth", "name": "@budibase/auth",
"version": "0.9.82", "version": "0.9.99-alpha.6",
"description": "Authentication middlewares for budibase builder and apps", "description": "Authentication middlewares for budibase builder and apps",
"main": "src/index.js", "main": "src/index.js",
"author": "Budibase", "author": "Budibase",
@ -13,6 +13,7 @@
"@techpass/passport-openidconnect": "^0.3.0", "@techpass/passport-openidconnect": "^0.3.0",
"aws-sdk": "^2.901.0", "aws-sdk": "^2.901.0",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"cls-hooked": "^4.2.2",
"ioredis": "^4.27.1", "ioredis": "^4.27.1",
"jsonwebtoken": "^8.5.1", "jsonwebtoken": "^8.5.1",
"koa-passport": "^4.1.4", "koa-passport": "^4.1.4",

View File

@ -1,15 +1,21 @@
const { getDB } = require("../db")
const { StaticDatabases } = require("../db/utils")
const redis = require("../redis/authRedis") const redis = require("../redis/authRedis")
const { getTenantId, lookupTenantId, getGlobalDB } = require("../tenancy")
const EXPIRY_SECONDS = 3600 const EXPIRY_SECONDS = 3600
exports.getUser = async userId => { exports.getUser = async (userId, tenantId = null) => {
if (!tenantId) {
try {
tenantId = getTenantId()
} catch (err) {
tenantId = await lookupTenantId(userId)
}
}
const client = await redis.getUserClient() const client = await redis.getUserClient()
// try cache // try cache
let user = await client.get(userId) let user = await client.get(userId)
if (!user) { if (!user) {
user = await getDB(StaticDatabases.GLOBAL.name).get(userId) user = await getGlobalDB(tenantId).get(userId)
client.store(userId, user, EXPIRY_SECONDS) client.store(userId, user, EXPIRY_SECONDS)
} }
return user return user

View File

@ -14,13 +14,14 @@ exports.Headers = {
API_VER: "x-budibase-api-version", API_VER: "x-budibase-api-version",
APP_ID: "x-budibase-app-id", APP_ID: "x-budibase-app-id",
TYPE: "x-budibase-type", TYPE: "x-budibase-type",
TENANT_ID: "x-budibase-tenant-id",
} }
exports.GlobalRoles = { exports.GlobalRoles = {
OWNER: "owner", OWNER: "owner",
ADMIN: "admin", ADMIN: "admin",
BUILDER: "builder", BUILDER: "builder",
GROUP_MANAGER: "group_manager", WORKSPACE_MANAGER: "workspace_manager",
} }
exports.Configs = { exports.Configs = {
@ -31,3 +32,5 @@ exports.Configs = {
OIDC: "oidc", OIDC: "oidc",
OIDC_LOGOS: "logos_oidc", OIDC_LOGOS: "logos_oidc",
} }
exports.DEFAULT_TENANT_ID = "default"

View File

@ -0,0 +1,17 @@
exports.SEPARATOR = "_"
exports.StaticDatabases = {
GLOBAL: {
name: "global-db",
docs: {
apiKeys: "apikeys",
},
},
// contains information about tenancy and so on
PLATFORM_INFO: {
name: "global-info",
docs: {
tenants: "tenants",
},
},
}

View File

@ -1,36 +1,36 @@
const { newid } = require("../hashing") const { newid } = require("../hashing")
const Replication = require("./Replication") const Replication = require("./Replication")
const { DEFAULT_TENANT_ID } = require("../constants")
const env = require("../environment")
const { StaticDatabases, SEPARATOR } = require("./constants")
const { getTenantId } = require("../tenancy")
const UNICODE_MAX = "\ufff0" const UNICODE_MAX = "\ufff0"
const SEPARATOR = "_"
exports.ViewNames = { exports.ViewNames = {
USER_BY_EMAIL: "by_email", USER_BY_EMAIL: "by_email",
} }
exports.StaticDatabases = { exports.StaticDatabases = StaticDatabases
GLOBAL: {
name: "global-db", const PRE_APP = "app"
}, const PRE_DEV = "dev"
DEPLOYMENTS: {
name: "deployments",
},
}
const DocumentTypes = { const DocumentTypes = {
USER: "us", USER: "us",
GROUP: "group", WORKSPACE: "workspace",
CONFIG: "config", CONFIG: "config",
TEMPLATE: "template", TEMPLATE: "template",
APP: "app", APP: PRE_APP,
APP_DEV: "app_dev", DEV: PRE_DEV,
APP_METADATA: "app_metadata", APP_DEV: `${PRE_APP}${SEPARATOR}${PRE_DEV}`,
APP_METADATA: `${PRE_APP}${SEPARATOR}metadata`,
ROLE: "role", ROLE: "role",
} }
exports.DocumentTypes = DocumentTypes exports.DocumentTypes = DocumentTypes
exports.APP_PREFIX = DocumentTypes.APP + SEPARATOR exports.APP_PREFIX = DocumentTypes.APP + SEPARATOR
exports.APP_DEV_PREFIX = DocumentTypes.APP_DEV + SEPARATOR exports.APP_DEV = exports.APP_DEV_PREFIX = DocumentTypes.APP_DEV + SEPARATOR
exports.SEPARATOR = SEPARATOR exports.SEPARATOR = SEPARATOR
function isDevApp(app) { function isDevApp(app) {
@ -61,21 +61,21 @@ function getDocParams(docType, docId = null, otherProps = {}) {
} }
/** /**
* Generates a new group ID. * Generates a new workspace ID.
* @returns {string} The new group ID which the group doc can be stored under. * @returns {string} The new workspace ID which the workspace doc can be stored under.
*/ */
exports.generateGroupID = () => { exports.generateWorkspaceID = () => {
return `${DocumentTypes.GROUP}${SEPARATOR}${newid()}` return `${DocumentTypes.WORKSPACE}${SEPARATOR}${newid()}`
} }
/** /**
* Gets parameters for retrieving groups. * Gets parameters for retrieving workspaces.
*/ */
exports.getGroupParams = (id = "", otherProps = {}) => { exports.getWorkspaceParams = (id = "", otherProps = {}) => {
return { return {
...otherProps, ...otherProps,
startkey: `${DocumentTypes.GROUP}${SEPARATOR}${id}`, startkey: `${DocumentTypes.WORKSPACE}${SEPARATOR}${id}`,
endkey: `${DocumentTypes.GROUP}${SEPARATOR}${id}${UNICODE_MAX}`, endkey: `${DocumentTypes.WORKSPACE}${SEPARATOR}${id}${UNICODE_MAX}`,
} }
} }
@ -103,14 +103,14 @@ exports.getGlobalUserParams = (globalId, otherProps = {}) => {
/** /**
* Generates a template ID. * Generates a template ID.
* @param ownerId The owner/user of the template, this could be global or a group level. * @param ownerId The owner/user of the template, this could be global or a workspace level.
*/ */
exports.generateTemplateID = ownerId => { exports.generateTemplateID = ownerId => {
return `${DocumentTypes.TEMPLATE}${SEPARATOR}${ownerId}${SEPARATOR}${newid()}` return `${DocumentTypes.TEMPLATE}${SEPARATOR}${ownerId}${SEPARATOR}${newid()}`
} }
/** /**
* Gets parameters for retrieving templates. Owner ID must be specified, either global or a group level. * Gets parameters for retrieving templates. Owner ID must be specified, either global or a workspace level.
*/ */
exports.getTemplateParams = (ownerId, templateId, otherProps = {}) => { exports.getTemplateParams = (ownerId, templateId, otherProps = {}) => {
if (!templateId) { if (!templateId) {
@ -163,11 +163,26 @@ 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 ({ CouchDB, dev, all } = {}) => { exports.getAllApps = async (CouchDB, { dev, all } = {}) => {
let tenantId = getTenantId()
if (!env.MULTI_TENANCY && !tenantId) {
tenantId = DEFAULT_TENANT_ID
}
let allDbs = await CouchDB.allDbs() let allDbs = await CouchDB.allDbs()
const appDbNames = allDbs.filter(dbName => const appDbNames = allDbs.filter(dbName => {
dbName.startsWith(exports.APP_PREFIX) const split = dbName.split(SEPARATOR)
) // it is an app, check the tenantId
if (split[0] === DocumentTypes.APP) {
const noTenantId = split.length === 2 || split[1] === DocumentTypes.DEV
// tenantId is always right before the UUID
const possibleTenantId = split[split.length - 2]
return (
(tenantId === DEFAULT_TENANT_ID && noTenantId) ||
possibleTenantId === tenantId
)
}
return false
})
const appPromises = appDbNames.map(db => const appPromises = appDbNames.map(db =>
// skip setup otherwise databases could be re-created // skip setup otherwise databases could be re-created
new CouchDB(db, { skip_setup: true }).get(DocumentTypes.APP_METADATA) new CouchDB(db, { skip_setup: true }).get(DocumentTypes.APP_METADATA)
@ -214,8 +229,8 @@ exports.dbExists = async (CouchDB, dbName) => {
* Generates a new configuration ID. * Generates a new configuration ID.
* @returns {string} The new configuration ID which the config doc can be stored under. * @returns {string} The new configuration ID which the config doc can be stored under.
*/ */
const generateConfigID = ({ type, group, user }) => { const generateConfigID = ({ type, workspace, user }) => {
const scope = [type, group, user].filter(Boolean).join(SEPARATOR) const scope = [type, workspace, user].filter(Boolean).join(SEPARATOR)
return `${DocumentTypes.CONFIG}${SEPARATOR}${scope}` return `${DocumentTypes.CONFIG}${SEPARATOR}${scope}`
} }
@ -223,8 +238,8 @@ const generateConfigID = ({ type, group, user }) => {
/** /**
* Gets parameters for retrieving configurations. * Gets parameters for retrieving configurations.
*/ */
const getConfigParams = ({ type, group, user }, otherProps = {}) => { const getConfigParams = ({ type, workspace, user }, otherProps = {}) => {
const scope = [type, group, user].filter(Boolean).join(SEPARATOR) const scope = [type, workspace, user].filter(Boolean).join(SEPARATOR)
return { return {
...otherProps, ...otherProps,
@ -234,15 +249,15 @@ const getConfigParams = ({ type, group, user }, otherProps = {}) => {
} }
/** /**
* Returns the most granular configuration document from the DB based on the type, group and userID passed. * Returns the most granular configuration document from the DB based on the type, workspace and userID passed.
* @param {Object} db - db instance to query * @param {Object} db - db instance to query
* @param {Object} scopes - the type, group and userID scopes of the configuration. * @param {Object} scopes - the type, workspace and userID scopes of the configuration.
* @returns The most granular configuration document based on the scope. * @returns The most granular configuration document based on the scope.
*/ */
const getScopedFullConfig = async function (db, { type, user, group }) { const getScopedFullConfig = async function (db, { type, user, workspace }) {
const response = await db.allDocs( const response = await db.allDocs(
getConfigParams( getConfigParams(
{ type, user, group }, { type, user, workspace },
{ {
include_docs: true, include_docs: true,
} }
@ -252,14 +267,14 @@ const getScopedFullConfig = async function (db, { type, user, group }) {
function determineScore(row) { function determineScore(row) {
const config = row.doc const config = row.doc
// Config is specific to a user and a group // Config is specific to a user and a workspace
if (config._id.includes(generateConfigID({ type, user, group }))) { if (config._id.includes(generateConfigID({ type, user, workspace }))) {
return 4 return 4
} else if (config._id.includes(generateConfigID({ type, user }))) { } else if (config._id.includes(generateConfigID({ type, user }))) {
// Config is specific to a user only // Config is specific to a user only
return 3 return 3
} else if (config._id.includes(generateConfigID({ type, group }))) { } else if (config._id.includes(generateConfigID({ type, workspace }))) {
// Config is specific to a group only // Config is specific to a workspace only
return 2 return 2
} else if (config._id.includes(generateConfigID({ type }))) { } else if (config._id.includes(generateConfigID({ type }))) {
// Config is specific to a type only // Config is specific to a type only

View File

@ -1,5 +1,4 @@
const { DocumentTypes, ViewNames, StaticDatabases } = require("./utils") const { DocumentTypes, ViewNames } = require("./utils")
const { getDB } = require("./index")
function DesignDoc() { function DesignDoc() {
return { return {
@ -10,8 +9,7 @@ function DesignDoc() {
} }
} }
exports.createUserEmailView = async () => { exports.createUserEmailView = async db => {
const db = getDB(StaticDatabases.GLOBAL.name)
let designDoc let designDoc
try { try {
designDoc = await db.get("_design/database") designDoc = await db.get("_design/database")

View File

@ -16,6 +16,7 @@ module.exports = {
MINIO_SECRET_KEY: process.env.MINIO_SECRET_KEY, MINIO_SECRET_KEY: process.env.MINIO_SECRET_KEY,
MINIO_URL: process.env.MINIO_URL, MINIO_URL: process.env.MINIO_URL,
INTERNAL_API_KEY: process.env.INTERNAL_API_KEY, INTERNAL_API_KEY: process.env.INTERNAL_API_KEY,
MULTI_TENANCY: process.env.MULTI_TENANCY,
isTest, isTest,
_set(key, value) { _set(key, value) {
process.env[key] = value process.env[key] = value

View File

@ -2,6 +2,7 @@ const passport = require("koa-passport")
const LocalStrategy = require("passport-local").Strategy const LocalStrategy = require("passport-local").Strategy
const JwtStrategy = require("passport-jwt").Strategy const JwtStrategy = require("passport-jwt").Strategy
const { StaticDatabases } = require("./db/utils") const { StaticDatabases } = require("./db/utils")
const { getGlobalDB } = require("./tenancy")
const { const {
jwt, jwt,
local, local,
@ -9,8 +10,9 @@ const {
google, google,
oidc, oidc,
auditLog, auditLog,
tenancy,
} = require("./middleware") } = require("./middleware")
const { setDB, getDB } = require("./db") const { setDB } = require("./db")
const userCache = require("./cache/user") const userCache = require("./cache/user")
// Strategies // Strategies
@ -20,7 +22,7 @@ passport.use(new JwtStrategy(jwt.options, jwt.authenticate))
passport.serializeUser((user, done) => done(null, user)) passport.serializeUser((user, done) => done(null, user))
passport.deserializeUser(async (user, done) => { passport.deserializeUser(async (user, done) => {
const db = getDB(StaticDatabases.GLOBAL.name) const db = getGlobalDB()
try { try {
const user = await db.get(user._id) const user = await db.get(user._id)
@ -54,6 +56,7 @@ module.exports = {
google, google,
oidc, oidc,
jwt: require("jsonwebtoken"), jwt: require("jsonwebtoken"),
buildTenancyMiddleware: tenancy,
auditLog, auditLog,
}, },
cache: { cache: {

View File

@ -2,46 +2,34 @@ const { Cookies, Headers } = require("../constants")
const { getCookie, clearCookie } = require("../utils") const { getCookie, clearCookie } = require("../utils")
const { getUser } = require("../cache/user") const { getUser } = require("../cache/user")
const { getSession, updateSessionTTL } = require("../security/sessions") const { getSession, updateSessionTTL } = require("../security/sessions")
const { buildMatcherRegex, matches } = require("./matchers")
const env = require("../environment") const env = require("../environment")
const PARAM_REGEX = /\/:(.*?)\//g function finalise(
ctx,
function buildNoAuthRegex(patterns) { { authenticated, user, internal, version, publicEndpoint } = {}
return patterns.map(pattern => { ) {
const isObj = typeof pattern === "object" && pattern.route ctx.publicEndpoint = publicEndpoint || false
const method = isObj ? pattern.method : "GET"
let route = isObj ? pattern.route : pattern
const matches = route.match(PARAM_REGEX)
if (matches) {
for (let match of matches) {
route = route.replace(match, "/.*/")
}
}
return { regex: new RegExp(route), method }
})
}
function finalise(ctx, { authenticated, user, internal, version } = {}) {
ctx.isAuthenticated = authenticated || false ctx.isAuthenticated = authenticated || false
ctx.user = user ctx.user = user
ctx.internal = internal || false ctx.internal = internal || false
ctx.version = version ctx.version = version
} }
module.exports = (noAuthPatterns = [], opts) => { /**
const noAuthOptions = noAuthPatterns ? buildNoAuthRegex(noAuthPatterns) : [] * This middleware is tenancy aware, so that it does not depend on other middlewares being used.
* The tenancy modules should not be used here and it should be assumed that the tenancy context
* has not yet been populated.
*/
module.exports = (noAuthPatterns = [], opts = { publicAllowed: false }) => {
const noAuthOptions = noAuthPatterns ? buildMatcherRegex(noAuthPatterns) : []
return async (ctx, next) => { return async (ctx, next) => {
let publicEndpoint = false
const version = ctx.request.headers[Headers.API_VER] const version = ctx.request.headers[Headers.API_VER]
// the path is not authenticated // the path is not authenticated
const found = noAuthOptions.find(({ regex, method }) => { const found = matches(ctx, noAuthOptions)
return ( if (found) {
regex.test(ctx.request.url) && publicEndpoint = true
ctx.request.method.toLowerCase() === method.toLowerCase()
)
})
if (found != null) {
return next()
} }
try { try {
// check the actual user is authenticated first // check the actual user is authenticated first
@ -58,7 +46,7 @@ module.exports = (noAuthPatterns = [], opts) => {
error = "No session found" error = "No session found"
} else { } else {
try { try {
user = await getUser(userId) user = await getUser(userId, session.tenantId)
delete user.password delete user.password
authenticated = true authenticated = true
} catch (err) { } catch (err) {
@ -66,6 +54,7 @@ module.exports = (noAuthPatterns = [], opts) => {
} }
} }
if (error) { if (error) {
console.error("Auth Error", error)
// remove the cookie as the user does not exist anymore // remove the cookie as the user does not exist anymore
clearCookie(ctx, Cookies.Auth) clearCookie(ctx, Cookies.Auth)
} else { } else {
@ -74,22 +63,26 @@ module.exports = (noAuthPatterns = [], opts) => {
} }
} }
const apiKey = ctx.request.headers[Headers.API_KEY] const apiKey = ctx.request.headers[Headers.API_KEY]
const tenantId = ctx.request.headers[Headers.TENANT_ID]
// this is an internal request, no user made it // this is an internal request, no user made it
if (!authenticated && apiKey && apiKey === env.INTERNAL_API_KEY) { if (!authenticated && apiKey && apiKey === env.INTERNAL_API_KEY) {
authenticated = true authenticated = true
internal = true internal = true
} }
if (!user && tenantId) {
user = { tenantId }
}
// be explicit // be explicit
if (authenticated !== true) { if (authenticated !== true) {
authenticated = false authenticated = false
} }
// isAuthenticated is a function, so use a variable to be able to check authed state // isAuthenticated is a function, so use a variable to be able to check authed state
finalise(ctx, { authenticated, user, internal, version }) finalise(ctx, { authenticated, user, internal, version, publicEndpoint })
return next() return next()
} catch (err) { } catch (err) {
// allow configuring for public access // allow configuring for public access
if (opts && opts.publicAllowed) { if ((opts && opts.publicAllowed) || publicEndpoint) {
finalise(ctx, { authenticated: false, version }) finalise(ctx, { authenticated: false, version, publicEndpoint })
} else { } else {
ctx.throw(err.status || 403, err) ctx.throw(err.status || 403, err)
} }

View File

@ -4,6 +4,7 @@ const google = require("./passport/google")
const oidc = require("./passport/oidc") const oidc = require("./passport/oidc")
const authenticated = require("./authenticated") const authenticated = require("./authenticated")
const auditLog = require("./auditLog") const auditLog = require("./auditLog")
const tenancy = require("./tenancy")
module.exports = { module.exports = {
google, google,
@ -12,4 +13,5 @@ module.exports = {
local, local,
authenticated, authenticated,
auditLog, auditLog,
tenancy,
} }

View File

@ -0,0 +1,33 @@
const PARAM_REGEX = /\/:(.*?)(\/.*)?$/g
exports.buildMatcherRegex = patterns => {
if (!patterns) {
return []
}
return patterns.map(pattern => {
const isObj = typeof pattern === "object" && pattern.route
const method = isObj ? pattern.method : "GET"
let route = isObj ? pattern.route : pattern
const matches = route.match(PARAM_REGEX)
if (matches) {
for (let match of matches) {
const pattern = "/.*" + (match.endsWith("/") ? "/" : "")
route = route.replace(match, pattern)
}
}
return { regex: new RegExp(route), method }
})
}
exports.matches = (ctx, options) => {
return options.find(({ regex, method }) => {
const urlMatch = regex.test(ctx.request.url)
const methodMatch =
method === "ALL"
? true
: ctx.request.method.toLowerCase() === method.toLowerCase()
return urlMatch && methodMatch
})
}

View File

@ -27,13 +27,13 @@ async function authenticate(accessToken, refreshToken, profile, done) {
* from couchDB rather than environment variables, using this factory is necessary for dynamically configuring passport. * from couchDB rather than environment variables, using this factory is necessary for dynamically configuring passport.
* @returns Dynamically configured Passport Google Strategy * @returns Dynamically configured Passport Google Strategy
*/ */
exports.strategyFactory = async function (config) { exports.strategyFactory = async function (config, callbackUrl) {
try { try {
const { clientID, clientSecret, callbackURL } = config const { clientID, clientSecret } = config
if (!clientID || !clientSecret || !callbackURL) { if (!clientID || !clientSecret) {
throw new Error( throw new Error(
"Configuration invalid. Must contain google clientID, clientSecret and callbackURL" "Configuration invalid. Must contain google clientID and clientSecret"
) )
} }
@ -41,7 +41,7 @@ exports.strategyFactory = async function (config) {
{ {
clientID: config.clientID, clientID: config.clientID,
clientSecret: config.clientSecret, clientSecret: config.clientSecret,
callbackURL: config.callbackURL, callbackURL: callbackUrl,
}, },
authenticate authenticate
) )

View File

@ -6,19 +6,23 @@ const { getGlobalUserByEmail } = require("../../utils")
const { authError } = require("./utils") const { authError } = require("./utils")
const { newid } = require("../../hashing") const { newid } = require("../../hashing")
const { createASession } = require("../../security/sessions") const { createASession } = require("../../security/sessions")
const { getTenantId } = require("../../tenancy")
const INVALID_ERR = "Invalid Credentials" const INVALID_ERR = "Invalid Credentials"
exports.options = {} exports.options = {
passReqToCallback: true,
}
/** /**
* Passport Local Authentication Middleware. * Passport Local Authentication Middleware.
* @param {*} email - username to login with * @param {*} ctx the request structure
* @param {*} password - plain text password to log in with * @param {*} email username to login with
* @param {*} done - callback from passport to return user information and errors * @param {*} password plain text password to log in with
* @param {*} done callback from passport to return user information and errors
* @returns The authenticated user, or errors if they occur * @returns The authenticated user, or errors if they occur
*/ */
exports.authenticate = async function (email, password, done) { exports.authenticate = async function (ctx, email, password, done) {
if (!email) return authError(done, "Email Required") if (!email) return authError(done, "Email Required")
if (!password) return authError(done, "Password Required") if (!password) return authError(done, "Password Required")
@ -35,12 +39,14 @@ exports.authenticate = async function (email, password, done) {
// authenticate // authenticate
if (await compare(password, dbUser.password)) { if (await compare(password, dbUser.password)) {
const sessionId = newid() const sessionId = newid()
await createASession(dbUser._id, sessionId) const tenantId = getTenantId()
await createASession(dbUser._id, { sessionId, tenantId })
dbUser.token = jwt.sign( dbUser.token = jwt.sign(
{ {
userId: dbUser._id, userId: dbUser._id,
sessionId, sessionId,
tenantId,
}, },
env.JWT_SECRET env.JWT_SECRET
) )

View File

@ -2,8 +2,9 @@
const { data } = require("./utilities/mock-data") const { data } = require("./utilities/mock-data")
const TENANT_ID = "default"
const googleConfig = { const googleConfig = {
callbackURL: "http://somecallbackurl",
clientID: data.clientID, clientID: data.clientID,
clientSecret: data.clientSecret, clientSecret: data.clientSecret,
} }
@ -26,13 +27,14 @@ describe("google", () => {
it("should create successfully create a google strategy", async () => { it("should create successfully create a google strategy", async () => {
const google = require("../google") const google = require("../google")
await google.strategyFactory(googleConfig) const callbackUrl = `/api/global/auth/${TENANT_ID}/google/callback`
await google.strategyFactory(googleConfig, callbackUrl)
const expectedOptions = { const expectedOptions = {
clientID: googleConfig.clientID, clientID: googleConfig.clientID,
clientSecret: googleConfig.clientSecret, clientSecret: googleConfig.clientSecret,
callbackURL: googleConfig.callbackURL, callbackURL: callbackUrl,
} }
expect(mockStrategy).toHaveBeenCalledWith( expect(mockStrategy).toHaveBeenCalledWith(

View File

@ -1,11 +1,12 @@
const env = require("../../environment") const env = require("../../environment")
const jwt = require("jsonwebtoken") const jwt = require("jsonwebtoken")
const database = require("../../db") const { generateGlobalUserID } = require("../../db/utils")
const { StaticDatabases, generateGlobalUserID } = require("../../db/utils")
const { authError } = require("./utils") const { authError } = require("./utils")
const { newid } = require("../../hashing") const { newid } = require("../../hashing")
const { createASession } = require("../../security/sessions") const { createASession } = require("../../security/sessions")
const { getGlobalUserByEmail } = require("../../utils") const { getGlobalUserByEmail } = require("../../utils")
const { getGlobalDB, getTenantId } = require("../../tenancy")
const fetch = require("node-fetch")
/** /**
* Common authentication logic for third parties. e.g. OAuth, OIDC. * Common authentication logic for third parties. e.g. OAuth, OIDC.
@ -15,19 +16,21 @@ exports.authenticateThirdParty = async function (
requireLocalAccount = true, requireLocalAccount = true,
done done
) { ) {
if (!thirdPartyUser.provider) if (!thirdPartyUser.provider) {
return authError(done, "third party user provider required") return authError(done, "third party user provider required")
if (!thirdPartyUser.userId) }
if (!thirdPartyUser.userId) {
return authError(done, "third party user id required") return authError(done, "third party user id required")
if (!thirdPartyUser.email) }
if (!thirdPartyUser.email) {
return authError(done, "third party user email required") return authError(done, "third party user email required")
}
const db = database.getDB(StaticDatabases.GLOBAL.name)
let dbUser
// use the third party id // use the third party id
const userId = generateGlobalUserID(thirdPartyUser.userId) const userId = generateGlobalUserID(thirdPartyUser.userId)
const db = getGlobalDB()
let dbUser
// try to load by id // try to load by id
try { try {
@ -65,7 +68,7 @@ exports.authenticateThirdParty = async function (
} }
} }
dbUser = syncUser(dbUser, thirdPartyUser) dbUser = await syncUser(dbUser, thirdPartyUser)
// create or sync the user // create or sync the user
const response = await db.post(dbUser) const response = await db.post(dbUser)
@ -73,7 +76,8 @@ exports.authenticateThirdParty = async function (
// authenticate // authenticate
const sessionId = newid() const sessionId = newid()
await createASession(dbUser._id, sessionId) const tenantId = getTenantId()
await createASession(dbUser._id, { sessionId, tenantId })
dbUser.token = jwt.sign( dbUser.token = jwt.sign(
{ {
@ -86,10 +90,26 @@ exports.authenticateThirdParty = async function (
return done(null, dbUser) return done(null, dbUser)
} }
async function syncProfilePicture(user, thirdPartyUser) {
const pictureUrl = thirdPartyUser.profile._json.picture
if (pictureUrl) {
const response = await fetch(pictureUrl)
if (response.status === 200) {
const type = response.headers.get("content-type")
if (type.startsWith("image/")) {
user.pictureUrl = pictureUrl
}
}
}
return user
}
/** /**
* @returns a user that has been sync'd with third party information * @returns a user that has been sync'd with third party information
*/ */
function syncUser(user, thirdPartyUser) { async function syncUser(user, thirdPartyUser) {
// provider // provider
user.provider = thirdPartyUser.provider user.provider = thirdPartyUser.provider
user.providerType = thirdPartyUser.providerType user.providerType = thirdPartyUser.providerType
@ -112,6 +132,8 @@ function syncUser(user, thirdPartyUser) {
} }
} }
user = await syncProfilePicture(user, thirdPartyUser)
// profile // profile
user.thirdPartyProfile = { user.thirdPartyProfile = {
...profile._json, ...profile._json,

View File

@ -0,0 +1,14 @@
const { setTenantId } = require("../tenancy")
const ContextFactory = require("../tenancy/FunctionContext")
const { buildMatcherRegex, matches } = require("./matchers")
module.exports = (allowQueryStringPatterns, noTenancyPatterns) => {
const allowQsOptions = buildMatcherRegex(allowQueryStringPatterns)
const noTenancyOptions = buildMatcherRegex(noTenancyPatterns)
return ContextFactory.getMiddleware(ctx => {
const allowNoTenant = !!matches(ctx, noTenancyOptions)
const allowQs = !!matches(ctx, allowQsOptions)
setTenantId(ctx, { allowQs, allowNoTenant })
})
}

View File

@ -12,12 +12,13 @@ function makeSessionID(userId, sessionId) {
return `${userId}/${sessionId}` return `${userId}/${sessionId}`
} }
exports.createASession = async (userId, sessionId) => { exports.createASession = async (userId, session) => {
const client = await redis.getSessionClient() const client = await redis.getSessionClient()
const session = { const sessionId = session.sessionId
session = {
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
lastAccessedAt: new Date().toISOString(), lastAccessedAt: new Date().toISOString(),
sessionId, ...session,
userId, userId,
} }
await client.store(makeSessionID(userId, sessionId), session, EXPIRY_SECONDS) await client.store(makeSessionID(userId, sessionId), session, EXPIRY_SECONDS)

View File

@ -0,0 +1,73 @@
const cls = require("cls-hooked")
const { newid } = require("../hashing")
const REQUEST_ID_KEY = "requestId"
class FunctionContext {
static getMiddleware(updateCtxFn = null) {
const namespace = this.createNamespace()
return async function (ctx, next) {
await new Promise(
namespace.bind(function (resolve, reject) {
// store a contextual request ID that can be used anywhere (audit logs)
namespace.set(REQUEST_ID_KEY, newid())
namespace.bindEmitter(ctx.req)
namespace.bindEmitter(ctx.res)
if (updateCtxFn) {
updateCtxFn(ctx)
}
next().then(resolve).catch(reject)
})
)
}
}
static run(callback) {
const namespace = this.createNamespace()
return namespace.runAndReturn(callback)
}
static setOnContext(key, value) {
const namespace = this.createNamespace()
namespace.set(key, value)
}
static getContextStorage() {
if (this._namespace && this._namespace.active) {
let contextData = this._namespace.active
delete contextData.id
delete contextData._ns_name
return contextData
}
return {}
}
static getFromContext(key) {
const context = this.getContextStorage()
if (context) {
return context[key]
} else {
return null
}
}
static destroyNamespace() {
if (this._namespace) {
cls.destroyNamespace("session")
this._namespace = null
}
}
static createNamespace() {
if (!this._namespace) {
this._namespace = cls.createNamespace("session")
}
return this._namespace
}
}
module.exports = FunctionContext

View File

@ -0,0 +1,81 @@
const env = require("../environment")
const { Headers } = require("../../constants")
const cls = require("./FunctionContext")
exports.DEFAULT_TENANT_ID = "default"
exports.isDefaultTenant = () => {
return exports.getTenantId() === exports.DEFAULT_TENANT_ID
}
exports.isMultiTenant = () => {
return env.MULTI_TENANCY
}
const TENANT_ID = "tenantId"
// used for automations, API endpoints should always be in context already
exports.doInTenant = (tenantId, task) => {
return cls.run(() => {
// set the tenant id
cls.setOnContext(TENANT_ID, tenantId)
// invoke the task
const result = task()
return result
})
}
exports.updateTenantId = tenantId => {
cls.setOnContext(TENANT_ID, tenantId)
}
exports.setTenantId = (
ctx,
opts = { allowQs: false, allowNoTenant: false }
) => {
let tenantId
// exit early if not multi-tenant
if (!exports.isMultiTenant()) {
cls.setOnContext(TENANT_ID, this.DEFAULT_TENANT_ID)
return
}
const allowQs = opts && opts.allowQs
const allowNoTenant = opts && opts.allowNoTenant
const header = ctx.request.headers[Headers.TENANT_ID]
const user = ctx.user || {}
if (allowQs) {
const query = ctx.request.query || {}
tenantId = query.tenantId
}
// override query string (if allowed) by user, or header
// URL params cannot be used in a middleware, as they are
// processed later in the chain
tenantId = user.tenantId || header || tenantId
if (!tenantId && !allowNoTenant) {
ctx.throw(403, "Tenant id not set")
}
// check tenant ID just incase no tenant was allowed
if (tenantId) {
cls.setOnContext(TENANT_ID, tenantId)
}
}
exports.isTenantIdSet = () => {
const tenantId = cls.getFromContext(TENANT_ID)
return !!tenantId
}
exports.getTenantId = () => {
if (!exports.isMultiTenant()) {
return exports.DEFAULT_TENANT_ID
}
const tenantId = cls.getFromContext(TENANT_ID)
if (!tenantId) {
throw Error("Tenant id not found")
}
return tenantId
}

View File

@ -0,0 +1,4 @@
module.exports = {
...require("./context"),
...require("./tenancy"),
}

View File

@ -0,0 +1,105 @@
const { getDB } = require("../db")
const { SEPARATOR, StaticDatabases } = require("../db/constants")
const { getTenantId, DEFAULT_TENANT_ID, isMultiTenant } = require("./context")
const env = require("../environment")
const TENANT_DOC = StaticDatabases.PLATFORM_INFO.docs.tenants
const PLATFORM_INFO_DB = StaticDatabases.PLATFORM_INFO.name
exports.addTenantToUrl = url => {
const tenantId = getTenantId()
if (isMultiTenant()) {
const char = url.indexOf("?") === -1 ? "?" : "&"
url += `${char}tenantId=${tenantId}`
}
return url
}
exports.doesTenantExist = async tenantId => {
const db = getDB(PLATFORM_INFO_DB)
let tenants
try {
tenants = await db.get(TENANT_DOC)
} catch (err) {
// if theres an error the doc doesn't exist, no tenants exist
return false
}
return (
tenants &&
Array.isArray(tenants.tenantIds) &&
tenants.tenantIds.indexOf(tenantId) !== -1
)
}
exports.tryAddTenant = async (tenantId, userId, email) => {
const db = getDB(PLATFORM_INFO_DB)
const getDoc = async id => {
if (!id) {
return null
}
try {
return await db.get(id)
} catch (err) {
return { _id: id }
}
}
let [tenants, userIdDoc, emailDoc] = await Promise.all([
getDoc(TENANT_DOC),
getDoc(userId),
getDoc(email),
])
if (!Array.isArray(tenants.tenantIds)) {
tenants = {
_id: TENANT_DOC,
tenantIds: [],
}
}
let promises = []
if (userIdDoc) {
userIdDoc.tenantId = tenantId
promises.push(db.put(userIdDoc))
}
if (emailDoc) {
emailDoc.tenantId = tenantId
promises.push(db.put(emailDoc))
}
if (tenants.tenantIds.indexOf(tenantId) === -1) {
tenants.tenantIds.push(tenantId)
promises.push(db.put(tenants))
}
await Promise.all(promises)
}
exports.getGlobalDB = (tenantId = null) => {
// tenant ID can be set externally, for example user API where
// new tenants are being created, this may be the case
if (!tenantId) {
tenantId = getTenantId()
}
let dbName
if (tenantId === DEFAULT_TENANT_ID) {
dbName = StaticDatabases.GLOBAL.name
} else {
dbName = `${tenantId}${SEPARATOR}${StaticDatabases.GLOBAL.name}`
}
return getDB(dbName)
}
exports.lookupTenantId = async userId => {
const db = getDB(StaticDatabases.PLATFORM_INFO.name)
let tenantId = env.MULTI_TENANCY ? DEFAULT_TENANT_ID : null
try {
const doc = await db.get(userId)
if (doc && doc.tenantId) {
tenantId = doc.tenantId
}
} catch (err) {
// just return the default
}
return tenantId
}

View File

@ -1,14 +1,9 @@
const { const { DocumentTypes, SEPARATOR, ViewNames } = require("./db/utils")
DocumentTypes,
SEPARATOR,
ViewNames,
StaticDatabases,
} = require("./db/utils")
const jwt = require("jsonwebtoken") const jwt = require("jsonwebtoken")
const { options } = require("./middleware/passport/jwt") const { options } = require("./middleware/passport/jwt")
const { createUserEmailView } = require("./db/views") const { createUserEmailView } = require("./db/views")
const { getDB } = require("./db")
const { Headers } = require("./constants") const { Headers } = require("./constants")
const { getGlobalDB } = require("./tenancy")
const APP_PREFIX = DocumentTypes.APP + SEPARATOR const APP_PREFIX = DocumentTypes.APP + SEPARATOR
@ -111,7 +106,7 @@ exports.getGlobalUserByEmail = async email => {
if (email == null) { if (email == null) {
throw "Must supply an email address to view" throw "Must supply an email address to view"
} }
const db = getDB(StaticDatabases.GLOBAL.name) const db = getGlobalDB()
try { try {
let users = ( let users = (
await db.query(`database/${ViewNames.USER_BY_EMAIL}`, { await db.query(`database/${ViewNames.USER_BY_EMAIL}`, {
@ -123,7 +118,7 @@ exports.getGlobalUserByEmail = async email => {
return users.length <= 1 ? users[0] : users return users.length <= 1 ? users[0] : users
} catch (err) { } catch (err) {
if (err != null && err.name === "not_found") { if (err != null && err.name === "not_found") {
await createUserEmailView() await createUserEmailView(db)
return exports.getGlobalUserByEmail(email) return exports.getGlobalUserByEmail(email)
} else { } else {
throw err throw err

1
packages/auth/tenancy.js Normal file
View File

@ -0,0 +1 @@
module.exports = require("./src/tenancy")

View File

@ -798,6 +798,13 @@ ast-types@0.9.6:
resolved "https://registry.yarnpkg.com/ast-types/-/ast-types-0.9.6.tgz#102c9e9e9005d3e7e3829bf0c4fa24ee862ee9b9" resolved "https://registry.yarnpkg.com/ast-types/-/ast-types-0.9.6.tgz#102c9e9e9005d3e7e3829bf0c4fa24ee862ee9b9"
integrity sha1-ECyenpAF0+fjgpvwxPok7oYu6bk= integrity sha1-ECyenpAF0+fjgpvwxPok7oYu6bk=
async-hook-jl@^1.7.6:
version "1.7.6"
resolved "https://registry.yarnpkg.com/async-hook-jl/-/async-hook-jl-1.7.6.tgz#4fd25c2f864dbaf279c610d73bf97b1b28595e68"
integrity sha512-gFaHkFfSxTjvoxDMYqDuGHlcRyUuamF8s+ZTtJdDzqjws4mCt7v0vuV79/E2Wr2/riMQgtG4/yUtXWs1gZ7JMg==
dependencies:
stack-chain "^1.3.7"
async@~2.1.4: async@~2.1.4:
version "2.1.5" version "2.1.5"
resolved "https://registry.yarnpkg.com/async/-/async-2.1.5.tgz#e587c68580994ac67fc56ff86d3ac56bdbe810bc" resolved "https://registry.yarnpkg.com/async/-/async-2.1.5.tgz#e587c68580994ac67fc56ff86d3ac56bdbe810bc"
@ -1144,6 +1151,15 @@ clone-buffer@1.0.0:
resolved "https://registry.yarnpkg.com/clone-buffer/-/clone-buffer-1.0.0.tgz#e3e25b207ac4e701af721e2cb5a16792cac3dc58" resolved "https://registry.yarnpkg.com/clone-buffer/-/clone-buffer-1.0.0.tgz#e3e25b207ac4e701af721e2cb5a16792cac3dc58"
integrity sha1-4+JbIHrE5wGvch4staFnksrD3Fg= integrity sha1-4+JbIHrE5wGvch4staFnksrD3Fg=
cls-hooked@^4.2.2:
version "4.2.2"
resolved "https://registry.yarnpkg.com/cls-hooked/-/cls-hooked-4.2.2.tgz#ad2e9a4092680cdaffeb2d3551da0e225eae1908"
integrity sha512-J4Xj5f5wq/4jAvcdgoGsL3G103BtWpZrMo8NEinRltN+xpTZdI+M38pyQqhuFU/P792xkMFvnKSf+Lm81U1bxw==
dependencies:
async-hook-jl "^1.7.6"
emitter-listener "^1.0.1"
semver "^5.4.1"
cluster-key-slot@^1.1.0: cluster-key-slot@^1.1.0:
version "1.1.0" version "1.1.0"
resolved "https://registry.yarnpkg.com/cluster-key-slot/-/cluster-key-slot-1.1.0.tgz#30474b2a981fb12172695833052bc0d01336d10d" resolved "https://registry.yarnpkg.com/cluster-key-slot/-/cluster-key-slot-1.1.0.tgz#30474b2a981fb12172695833052bc0d01336d10d"
@ -1444,6 +1460,13 @@ electron-to-chromium@^1.3.723:
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.775.tgz#046517d1f2cea753e06fff549995b9dc45e20082" resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.775.tgz#046517d1f2cea753e06fff549995b9dc45e20082"
integrity sha512-EGuiJW4yBPOTj2NtWGZcX93ZE8IGj33HJAx4d3ouE2zOfW2trbWU+t1e0yzLr1qQIw81++txbM3BH52QwSRE6Q== integrity sha512-EGuiJW4yBPOTj2NtWGZcX93ZE8IGj33HJAx4d3ouE2zOfW2trbWU+t1e0yzLr1qQIw81++txbM3BH52QwSRE6Q==
emitter-listener@^1.0.1:
version "1.1.2"
resolved "https://registry.yarnpkg.com/emitter-listener/-/emitter-listener-1.1.2.tgz#56b140e8f6992375b3d7cb2cab1cc7432d9632e8"
integrity sha512-Bt1sBAGFHY9DKY+4/2cV6izcKJUf5T7/gkdmkxzX/qv9CcGH8xSwVRW5mtX03SWJtRTWSOpzCuWN9rBFYZepZQ==
dependencies:
shimmer "^1.2.0"
emittery@^0.7.1: emittery@^0.7.1:
version "0.7.2" version "0.7.2"
resolved "https://registry.yarnpkg.com/emittery/-/emittery-0.7.2.tgz#25595908e13af0f5674ab419396e2fb394cdfa82" resolved "https://registry.yarnpkg.com/emittery/-/emittery-0.7.2.tgz#25595908e13af0f5674ab419396e2fb394cdfa82"
@ -4035,7 +4058,7 @@ saxes@^5.0.1:
dependencies: dependencies:
xmlchars "^2.2.0" xmlchars "^2.2.0"
"semver@2 || 3 || 4 || 5", semver@^5.5.0, semver@^5.6.0: "semver@2 || 3 || 4 || 5", semver@^5.4.1, semver@^5.5.0, semver@^5.6.0:
version "5.7.1" version "5.7.1"
resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7"
integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==
@ -4096,6 +4119,11 @@ shellwords@^0.1.1:
resolved "https://registry.yarnpkg.com/shellwords/-/shellwords-0.1.1.tgz#d6b9181c1a48d397324c84871efbcfc73fc0654b" resolved "https://registry.yarnpkg.com/shellwords/-/shellwords-0.1.1.tgz#d6b9181c1a48d397324c84871efbcfc73fc0654b"
integrity sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww== integrity sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==
shimmer@^1.2.0:
version "1.2.1"
resolved "https://registry.yarnpkg.com/shimmer/-/shimmer-1.2.1.tgz#610859f7de327b587efebf501fb43117f9aff337"
integrity sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==
signal-exit@^3.0.0, signal-exit@^3.0.2: signal-exit@^3.0.0, signal-exit@^3.0.2:
version "3.0.3" version "3.0.3"
resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.3.tgz#a1410c2edd8f077b08b4e253c8eacfcaf057461c" resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.3.tgz#a1410c2edd8f077b08b4e253c8eacfcaf057461c"
@ -4250,6 +4278,11 @@ sshpk@^1.7.0:
safer-buffer "^2.0.2" safer-buffer "^2.0.2"
tweetnacl "~0.14.0" tweetnacl "~0.14.0"
stack-chain@^1.3.7:
version "1.3.7"
resolved "https://registry.yarnpkg.com/stack-chain/-/stack-chain-1.3.7.tgz#d192c9ff4ea6a22c94c4dd459171e3f00cea1285"
integrity sha1-0ZLJ/06moiyUxN1FkXHj8AzqEoU=
stack-utils@^2.0.2: stack-utils@^2.0.2:
version "2.0.3" version "2.0.3"
resolved "https://registry.yarnpkg.com/stack-utils/-/stack-utils-2.0.3.tgz#cd5f030126ff116b78ccb3c027fe302713b61277" resolved "https://registry.yarnpkg.com/stack-utils/-/stack-utils-2.0.3.tgz#cd5f030126ff116b78ccb3c027fe302713b61277"

View File

@ -1,7 +1,7 @@
{ {
"name": "@budibase/bbui", "name": "@budibase/bbui",
"description": "A UI solution used in the different Budibase projects.", "description": "A UI solution used in the different Budibase projects.",
"version": "0.9.82", "version": "0.9.99-alpha.6",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"svelte": "src/index.js", "svelte": "src/index.js",
"module": "dist/bbui.es.js", "module": "dist/bbui.es.js",

View File

@ -9,6 +9,7 @@
export let text = null export let text = null
export let disabled = false export let disabled = false
export let error = null export let error = null
export let size = "M"
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const onChange = e => { const onChange = e => {
@ -18,5 +19,5 @@
</script> </script>
<Field {label} {labelPosition} {error}> <Field {label} {labelPosition} {error}>
<Checkbox {error} {disabled} {text} {value} on:change={onChange} /> <Checkbox {error} {disabled} {text} {value} {size} on:change={onChange} />
</Field> </Field>

View File

@ -8,6 +8,7 @@
export let id = null export let id = null
export let text = null export let text = null
export let disabled = false export let disabled = false
export let size = null
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const onChange = event => { const onChange = event => {
@ -16,7 +17,7 @@
</script> </script>
<label <label
class="spectrum-Checkbox spectrum-Checkbox--sizeM spectrum-Checkbox--emphasized" class="spectrum-Checkbox spectrum-Checkbox--size{size} spectrum-Checkbox--emphasized"
class:is-invalid={!!error} class:is-invalid={!!error}
> >
<input <input

View File

@ -6,6 +6,7 @@
export let id = null export let id = null
export let text = null export let text = null
export let disabled = false export let disabled = false
export let dataCy = null
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const onChange = event => { const onChange = event => {
@ -15,6 +16,7 @@
<div class="spectrum-Switch spectrum-Switch--emphasized"> <div class="spectrum-Switch spectrum-Switch--emphasized">
<input <input
data-cy={dataCy}
checked={value} checked={value}
{disabled} {disabled}
on:change={onChange} on:change={onChange}

View File

@ -9,6 +9,7 @@
export let text = null export let text = null
export let disabled = false export let disabled = false
export let error = null export let error = null
export let dataCy = null
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const onChange = e => { const onChange = e => {
@ -18,5 +19,5 @@
</script> </script>
<Field {label} {labelPosition} {error}> <Field {label} {labelPosition} {error}>
<Switch {error} {disabled} {text} {value} on:change={onChange} /> <Switch {dataCy} {error} {disabled} {text} {value} on:change={onChange} />
</Field> </Field>

View File

@ -70,6 +70,7 @@
> >
<div class="modal-wrapper" on:mousedown|self={cancel}> <div class="modal-wrapper" on:mousedown|self={cancel}>
<div class="modal-inner-wrapper" on:mousedown|self={cancel}> <div class="modal-inner-wrapper" on:mousedown|self={cancel}>
<slot name="outside" />
<div <div
use:focusFirstInput use:focusFirstInput
class="spectrum-Modal is-open" class="spectrum-Modal is-open"
@ -93,6 +94,7 @@
z-index: 999; z-index: 999;
overflow: auto; overflow: auto;
overflow-x: hidden; overflow-x: hidden;
background: rgba(0, 0, 0, 0.75);
} }
.modal-wrapper { .modal-wrapper {
@ -112,6 +114,7 @@
justify-content: center; justify-content: center;
align-items: flex-start; align-items: flex-start;
width: 0; width: 0;
position: relative;
} }
.spectrum-Modal { .spectrum-Modal {
@ -122,6 +125,7 @@
--spectrum-dialog-confirm-border-radius: var( --spectrum-dialog-confirm-border-radius: var(
--spectrum-global-dimension-size-100 --spectrum-global-dimension-size-100
); );
max-width: 100%;
} }
:global(.spectrum--lightest .spectrum-Modal.inline) { :global(.spectrum--lightest .spectrum-Modal.inline) {
border: var(--border-light); border: var(--border-light);

View File

@ -15,6 +15,7 @@
export let showCloseIcon = true export let showCloseIcon = true
export let onConfirm = undefined export let onConfirm = undefined
export let disabled = false export let disabled = false
export let showDivider = true
const { hide, cancel } = getContext(Context.Modal) const { hide, cancel } = getContext(Context.Modal)
let loading = false let loading = false
@ -41,11 +42,17 @@
aria-modal="true" aria-modal="true"
> >
<div class="spectrum-Dialog-grid"> <div class="spectrum-Dialog-grid">
<h1 class="spectrum-Dialog-heading spectrum-Dialog-heading--noHeader"> {#if title}
{title} <h1
</h1> class="spectrum-Dialog-heading spectrum-Dialog-heading--noHeader"
class:noDivider={!showDivider}
<Divider size="M" /> >
{title}
</h1>
{#if showDivider}
<Divider size="M" />
{/if}
{/if}
<!-- TODO: Remove content-grid class once Layout components are in bbui --> <!-- TODO: Remove content-grid class once Layout components are in bbui -->
<section class="spectrum-Dialog-content content-grid"> <section class="spectrum-Dialog-content content-grid">
<slot /> <slot />
@ -72,8 +79,8 @@
</div> </div>
{/if} {/if}
{#if showCloseIcon} {#if showCloseIcon}
<div class="close-icon" on:click={hide}> <div class="close-icon">
<Icon hoverable name="Close" /> <Icon hoverable name="Close" on:click={cancel} />
</div> </div>
{/if} {/if}
</div> </div>
@ -96,6 +103,9 @@
.spectrum-Dialog-heading { .spectrum-Dialog-heading {
font-family: var(--font-sans); font-family: var(--font-sans);
} }
.spectrum-Dialog-heading.noDivider {
margin-bottom: 12px;
}
.spectrum-Dialog-buttonGroup { .spectrum-Dialog-buttonGroup {
gap: var(--spectrum-global-dimension-static-size-200); gap: var(--spectrum-global-dimension-static-size-200);

View File

@ -0,0 +1,20 @@
<script>
export let type = "info"
export let icon = "Info"
export let message = ""
</script>
<div class="spectrum-Toast spectrum-Toast--{type}">
{#if icon}
<svg
class="spectrum-Icon spectrum-Icon--sizeM spectrum-Toast-typeIcon"
focusable="false"
aria-hidden="true"
>
<use xlink:href="#spectrum-icon-18-{icon}" />
</svg>
{/if}
<div class="spectrum-Toast-body">
<div class="spectrum-Toast-content">{message || ""}</div>
</div>
</div>

View File

@ -2,30 +2,16 @@
import "@spectrum-css/toast/dist/index-vars.css" import "@spectrum-css/toast/dist/index-vars.css"
import Portal from "svelte-portal" import Portal from "svelte-portal"
import { flip } from "svelte/animate" import { flip } from "svelte/animate"
import { fly } from "svelte/transition"
import { notifications } from "../Stores/notifications" import { notifications } from "../Stores/notifications"
import Notification from "./Notification.svelte"
import { fly } from "svelte/transition"
</script> </script>
<Portal target=".modal-container"> <Portal target=".modal-container">
<div class="notifications"> <div class="notifications">
{#each $notifications as { type, icon, message, id } (id)} {#each $notifications as { type, icon, message, id } (id)}
<div <div animate:flip transition:fly={{ y: -30 }}>
animate:flip <Notification {type} {icon} {message} />
transition:fly={{ y: -30 }}
class="spectrum-Toast spectrum-Toast--{type} notification-offset"
>
{#if icon}
<svg
class="spectrum-Icon spectrum-Icon--sizeM spectrum-Toast-typeIcon"
focusable="false"
aria-hidden="true"
>
<use xlink:href="#spectrum-icon-18-{icon}" />
</svg>
{/if}
<div class="spectrum-Toast-body">
<div class="spectrum-Toast-content">{message}</div>
</div>
</div> </div>
{/each} {/each}
</div> </div>
@ -34,7 +20,7 @@
<style> <style>
.notifications { .notifications {
position: fixed; position: fixed;
top: 10px; top: 20px;
left: 0; left: 0;
right: 0; right: 0;
margin: 0 auto; margin: 0 auto;
@ -45,8 +31,6 @@
justify-content: flex-start; justify-content: flex-start;
align-items: center; align-items: center;
pointer-events: none; pointer-events: none;
} gap: 10px;
.notification-offset {
margin-bottom: 10px;
} }
</style> </style>

View File

@ -38,6 +38,7 @@ export { default as MenuItem } from "./Menu/Item.svelte"
export { default as Modal } from "./Modal/Modal.svelte" export { default as Modal } from "./Modal/Modal.svelte"
export { default as ModalContent } from "./Modal/ModalContent.svelte" export { default as ModalContent } from "./Modal/ModalContent.svelte"
export { default as NotificationDisplay } from "./Notification/NotificationDisplay.svelte" export { default as NotificationDisplay } from "./Notification/NotificationDisplay.svelte"
export { default as Notification } from "./Notification/Notification.svelte"
export { default as SideNavigation } from "./SideNavigation/Navigation.svelte" export { default as SideNavigation } from "./SideNavigation/Navigation.svelte"
export { default as SideNavigationItem } from "./SideNavigation/Item.svelte" export { default as SideNavigationItem } from "./SideNavigation/Item.svelte"
export { default as DatePicker } from "./Form/DatePicker.svelte" export { default as DatePicker } from "./Form/DatePicker.svelte"

View File

@ -29,10 +29,10 @@ context("Create a automation", () => {
cy.get(".setup").within(() => { cy.get(".setup").within(() => {
cy.get(".spectrum-Picker-label").click() cy.get(".spectrum-Picker-label").click()
cy.contains("dog").click() cy.contains("dog").click()
cy.get("input") cy.get(".spectrum-Textfield-input")
.first() .first()
.type("goodboy") .type("goodboy")
cy.get("input") cy.get(".spectrum-Textfield-input")
.eq(1) .eq(1)
.type("11") .type("11")
}) })
@ -41,7 +41,7 @@ context("Create a automation", () => {
cy.contains("Save Automation").click() cy.contains("Save Automation").click()
// Activate Automation // Activate Automation
cy.get("[aria-label=PlayCircle]").click() cy.get("[data-cy=activate-automation]").click()
}) })
it("should add row when a new row is added", () => { it("should add row when a new row is added", () => {

View File

@ -1,6 +1,6 @@
{ {
"name": "@budibase/builder", "name": "@budibase/builder",
"version": "0.9.82", "version": "0.9.99-alpha.6",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"private": true, "private": true,
"scripts": { "scripts": {
@ -65,10 +65,10 @@
} }
}, },
"dependencies": { "dependencies": {
"@budibase/bbui": "^0.9.82", "@budibase/bbui": "^0.9.99-alpha.6",
"@budibase/client": "^0.9.82", "@budibase/client": "^0.9.99-alpha.6",
"@budibase/colorpicker": "1.1.2", "@budibase/colorpicker": "^1.1.2",
"@budibase/string-templates": "^0.9.82", "@budibase/string-templates": "^0.9.99-alpha.6",
"@sentry/browser": "5.19.1", "@sentry/browser": "5.19.1",
"@spectrum-css/page": "^3.0.1", "@spectrum-css/page": "^3.0.1",
"@spectrum-css/vars": "^3.0.1", "@spectrum-css/vars": "^3.0.1",

View File

@ -70,7 +70,7 @@ export const getFrontendStore = () => {
url: application.url, url: application.url,
layouts, layouts,
screens, screens,
theme: application.theme, theme: application.theme || "spectrum--light",
hasAppPackage: true, hasAppPackage: true,
appInstance: application.instance, appInstance: application.instance,
clientLibPath, clientLibPath,

View File

@ -1,7 +1,7 @@
<script> <script>
import { automationStore } from "builderStore" import { automationStore } from "builderStore"
import { database } from "stores/backend" import { database } from "stores/backend"
import { notifications, Icon, Button, Modal, Heading } from "@budibase/bbui" import { notifications, Button, Modal, Heading, Toggle } from "@budibase/bbui"
import AutomationBlockSetup from "./AutomationBlockSetup.svelte" import AutomationBlockSetup from "./AutomationBlockSetup.svelte"
import CreateWebookModal from "../Shared/CreateWebhookModal.svelte" import CreateWebookModal from "../Shared/CreateWebhookModal.svelte"
@ -12,7 +12,7 @@
$: automationLive = automation?.live $: automationLive = automation?.live
function setAutomationLive(live) { function setAutomationLive(live) {
if (automation.live === live) { if (automationLive === live) {
return return
} }
automation.live = live automation.live = live
@ -48,20 +48,11 @@
<div class="title"> <div class="title">
<Heading size="S">Setup</Heading> <Heading size="S">Setup</Heading>
<Icon <Toggle
l value={automationLive}
disabled={!automationLive} on:change={() => setAutomationLive(!automationLive)}
hoverable={automationLive} dataCy="activate-automation"
name="PauseCircle" text="Live"
on:click={() => setAutomationLive(false)}
/>
<Icon
l
name="PlayCircle"
disabled={automationLive}
hoverable={!automationLive}
data-cy="activate-automation"
on:click={() => setAutomationLive(true)}
/> />
</div> </div>
{#if $automationStore.selectedBlock} {#if $automationStore.selectedBlock}
@ -69,7 +60,7 @@
bind:block={$automationStore.selectedBlock} bind:block={$automationStore.selectedBlock}
{webhookModal} {webhookModal}
/> />
{:else if $automationStore.selectedAutomation} {:else if automation}
<div class="block-label">{automation.name}</div> <div class="block-label">{automation.name}</div>
<Button secondary on:click={testAutomation}>Test Automation</Button> <Button secondary on:click={testAutomation}>Test Automation</Button>
{/if} {/if}

View File

@ -172,6 +172,11 @@
alt: `Many ${table.name} rows → many ${linkTable.name} rows`, alt: `Many ${table.name} rows → many ${linkTable.name} rows`,
value: RelationshipTypes.MANY_TO_MANY, value: RelationshipTypes.MANY_TO_MANY,
}, },
{
name: `One ${linkName} row → many ${thisName} rows`,
alt: `One ${linkTable.name} rows → many ${table.name} rows`,
value: RelationshipTypes.ONE_TO_MANY,
},
{ {
name: `One ${thisName} row → many ${linkName} rows`, name: `One ${thisName} row → many ${linkName} rows`,
alt: `One ${table.name} rows → many ${linkTable.name} rows`, alt: `One ${table.name} rows → many ${linkTable.name} rows`,

View File

@ -18,8 +18,8 @@
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
let bindingDrawer let bindingDrawer
$: tempValue = Array.isArray(value) ? value : []
$: readableValue = runtimeToReadableBinding(bindings, value) $: readableValue = runtimeToReadableBinding(bindings, value)
$: tempValue = readableValue
const handleClose = () => { const handleClose = () => {
onChange(tempValue) onChange(tempValue)
@ -56,7 +56,7 @@
slot="body" slot="body"
value={readableValue} value={readableValue}
close={handleClose} close={handleClose}
on:update={event => (tempValue = event.detail)} on:change={event => (tempValue = event.detail)}
bindableProperties={bindings} bindableProperties={bindings}
/> />
</Drawer> </Drawer>

View File

@ -24,7 +24,7 @@
<div> <div>
<Select <Select
value={$store.theme || "spectrum--light"} value={$store.theme}
options={themeOptions} options={themeOptions}
placeholder={null} placeholder={null}
on:change={e => store.actions.theme.save(e.detail)} on:change={e => store.actions.theme.save(e.detail)}

View File

@ -49,6 +49,7 @@
"children": [ "children": [
"heading", "heading",
"text", "text",
"divider",
"image", "image",
"backgroundimage", "backgroundimage",
"link", "link",

View File

@ -47,6 +47,18 @@ export default `
return return
} }
// Parse received message
// If parsing fails, just ignore and wait for the next message
let parsed
try {
parsed = JSON.parse(event.data)
} catch (error) {
// Ignore
}
if (!parsed) {
return
}
// Extract data from message // Extract data from message
const { const {
selectedComponentId, selectedComponentId,
@ -55,7 +67,7 @@ export default `
previewType, previewType,
appId, appId,
theme theme
} = JSON.parse(event.data) } = parsed
// Set some flags so the app knows we're in the builder // Set some flags so the app knows we're in the builder
window["##BUDIBASE_IN_BUILDER##"] = true window["##BUDIBASE_IN_BUILDER##"] = true

View File

@ -11,6 +11,7 @@
export let componentDefinition export let componentDefinition
export let componentInstance export let componentInstance
export let assetInstance export let assetInstance
export let bindings
const layoutDefinition = [] const layoutDefinition = []
const screenDefinition = [ const screenDefinition = [
@ -65,6 +66,7 @@
options: setting.options, options: setting.options,
placeholder: setting.placeholder, placeholder: setting.placeholder,
}} }}
{bindings}
/> />
{/if} {/if}
{/each} {/each}

View File

@ -4,6 +4,7 @@
import ConditionalUIDrawer from "./PropertyControls/ConditionalUIDrawer.svelte" import ConditionalUIDrawer from "./PropertyControls/ConditionalUIDrawer.svelte"
export let componentInstance export let componentInstance
export let bindings
let tempValue let tempValue
let drawer let drawer
@ -32,5 +33,5 @@
Show, hide and update components in response to conditions being met. Show, hide and update components in response to conditions being met.
</svelte:fragment> </svelte:fragment>
<Button cta slot="buttons" on:click={() => save()}>Save</Button> <Button cta slot="buttons" on:click={() => save()}>Save</Button>
<ConditionalUIDrawer slot="body" bind:conditions={tempValue} /> <ConditionalUIDrawer slot="body" bind:conditions={tempValue} {bindings} />
</Drawer> </Drawer>

View File

@ -4,6 +4,7 @@
export let componentDefinition export let componentDefinition
export let componentInstance export let componentInstance
export let bindings
const getStyles = def => { const getStyles = def => {
if (!def?.styles?.length) { if (!def?.styles?.length) {
@ -29,6 +30,7 @@
columns={style.columns} columns={style.columns}
properties={style.settings} properties={style.settings}
{componentInstance} {componentInstance}
{bindings}
/> />
{/each} {/each}
{/if} {/if}

View File

@ -1,27 +1,45 @@
<script> <script>
import { store, selectedComponent } from "builderStore" import { store, selectedComponent, currentAsset } from "builderStore"
import { Tabs, Tab } from "@budibase/bbui" import { Tabs, Tab } from "@budibase/bbui"
import ScreenSettingsSection from "./ScreenSettingsSection.svelte" import ScreenSettingsSection from "./ScreenSettingsSection.svelte"
import ComponentSettingsSection from "./ComponentSettingsSection.svelte" import ComponentSettingsSection from "./ComponentSettingsSection.svelte"
import DesignSection from "./DesignSection.svelte" import DesignSection from "./DesignSection.svelte"
import CustomStylesSection from "./CustomStylesSection.svelte" import CustomStylesSection from "./CustomStylesSection.svelte"
import ConditionalUISection from "./ConditionalUISection.svelte" import ConditionalUISection from "./ConditionalUISection.svelte"
import { getBindableProperties } from "builderStore/dataBinding"
$: componentInstance = $selectedComponent $: componentInstance = $selectedComponent
$: componentDefinition = store.actions.components.getDefinition( $: componentDefinition = store.actions.components.getDefinition(
$selectedComponent?._component $selectedComponent?._component
) )
$: bindings = getBindableProperties($currentAsset, $store.selectedComponentId)
</script> </script>
<Tabs selected="Settings" noPadding> <Tabs selected="Settings" noPadding>
<Tab title="Settings"> <Tab title="Settings">
<div class="container"> <div class="container">
{#key componentInstance?._id} {#key componentInstance?._id}
<ScreenSettingsSection {componentInstance} {componentDefinition} /> <ScreenSettingsSection
<ComponentSettingsSection {componentInstance} {componentDefinition} /> {componentInstance}
<DesignSection {componentInstance} {componentDefinition} /> {componentDefinition}
<CustomStylesSection {componentInstance} {componentDefinition} /> {bindings}
<ConditionalUISection {componentInstance} {componentDefinition} /> />
<ComponentSettingsSection
{componentInstance}
{componentDefinition}
{bindings}
/>
<DesignSection {componentInstance} {componentDefinition} {bindings} />
<CustomStylesSection
{componentInstance}
{componentDefinition}
{bindings}
/>
<ConditionalUISection
{componentInstance}
{componentDefinition}
{bindings}
/>
{/key} {/key}
</div> </div>
</Tab> </Tab>

View File

@ -13,12 +13,12 @@
import { generate } from "shortid" import { generate } from "shortid"
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte" import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
import { OperatorOptions, getValidOperatorsForType } from "helpers/lucene" import { OperatorOptions, getValidOperatorsForType } from "helpers/lucene"
import { getBindableProperties } from "builderStore/dataBinding" import { selectedComponent, store } from "builderStore"
import { currentAsset, selectedComponent, store } from "builderStore"
import { getComponentForSettingType } from "./componentSettings" import { getComponentForSettingType } from "./componentSettings"
import PropertyControl from "./PropertyControl.svelte" import PropertyControl from "./PropertyControl.svelte"
export let conditions = [] export let conditions = []
export let bindings = []
const flipDurationMs = 150 const flipDurationMs = 150
const actionOptions = [ const actionOptions = [
@ -64,10 +64,6 @@
value: setting.key, value: setting.key,
} }
}) })
$: bindableProperties = getBindableProperties(
$currentAsset,
$store.selectedComponentId
)
$: conditions.forEach(link => { $: conditions.forEach(link => {
if (!link.id) { if (!link.id) {
link.id = generate() link.id = generate()
@ -101,6 +97,12 @@
conditions = conditions.filter(link => link.id !== id) conditions = conditions.filter(link => link.id !== id)
} }
const duplicateCondition = id => {
const condition = conditions.find(link => link.id === id)
const duplicate = { ...condition, id: generate() }
conditions = [...conditions, duplicate]
}
const handleFinalize = e => { const handleFinalize = e => {
updateConditions(e) updateConditions(e)
dragDisabled = true dragDisabled = true
@ -188,6 +190,7 @@
placeholder: getSettingDefinition(condition.setting) placeholder: getSettingDefinition(condition.setting)
.placeholder, .placeholder,
}} }}
{bindings}
/> />
{:else} {:else}
<Select disabled placeholder=" " /> <Select disabled placeholder=" " />
@ -195,7 +198,7 @@
{/if} {/if}
<div>IF</div> <div>IF</div>
<DrawerBindableInput <DrawerBindableInput
bindings={bindableProperties} {bindings}
placeholder="Value" placeholder="Value"
value={condition.newValue} value={condition.newValue}
on:change={e => (condition.newValue = e.detail)} on:change={e => (condition.newValue = e.detail)}
@ -216,7 +219,7 @@
{#if ["string", "number"].includes(condition.valueType)} {#if ["string", "number"].includes(condition.valueType)}
<DrawerBindableInput <DrawerBindableInput
disabled={condition.noValue} disabled={condition.noValue}
bindings={bindableProperties} {bindings}
placeholder="Value" placeholder="Value"
value={condition.referenceValue} value={condition.referenceValue}
on:change={e => (condition.referenceValue = e.detail)} on:change={e => (condition.referenceValue = e.detail)}
@ -235,6 +238,12 @@
bind:value={condition.referenceValue} bind:value={condition.referenceValue}
/> />
{/if} {/if}
<Icon
name="Duplicate"
hoverable
size="S"
on:click={() => duplicateCondition(condition.id)}
/>
<Icon <Icon
name="Close" name="Close"
hoverable hoverable
@ -273,7 +282,7 @@
gap: var(--spacing-l); gap: var(--spacing-l);
display: grid; display: grid;
align-items: center; align-items: center;
grid-template-columns: auto 1fr auto 1fr 1fr 1fr 1fr auto; grid-template-columns: auto 1fr auto 1fr 1fr 1fr 1fr auto auto;
border-radius: var(--border-radius-s); border-radius: var(--border-radius-s);
transition: background-color ease-in-out 130ms; transition: background-color ease-in-out 130ms;
} }

View File

@ -1,8 +1,5 @@
<script> <script>
import { import { getDataProviderComponents } from "builderStore/dataBinding"
getBindableProperties,
getDataProviderComponents,
} from "builderStore/dataBinding"
import { import {
Button, Button,
Popover, Popover,
@ -31,6 +28,7 @@
export let value = {} export let value = {}
export let otherSources export let otherSources
export let showAllQueries export let showAllQueries
export let bindings = []
$: text = value?.label ?? "Choose an option" $: text = value?.label ?? "Choose an option"
$: tables = $tablesStore.list.map(m => ({ $: tables = $tablesStore.list.map(m => ({
@ -60,10 +58,6 @@
parameters: query.parameters, parameters: query.parameters,
type: "query", type: "query",
})) }))
$: bindableProperties = getBindableProperties(
$currentAsset,
$store.selectedComponentId
)
$: dataProviders = getDataProviderComponents( $: dataProviders = getDataProviderComponents(
$currentAsset, $currentAsset,
$store.selectedComponentId $store.selectedComponentId
@ -75,13 +69,13 @@
type: "provider", type: "provider",
schema: provider.schema, schema: provider.schema,
})) }))
$: queryBindableProperties = bindableProperties.map(property => ({ $: queryBindableProperties = bindings.map(property => ({
...property, ...property,
category: property.type === "instance" ? "Component" : "Table", category: property.type === "instance" ? "Component" : "Table",
label: property.readableBinding, label: property.readableBinding,
path: property.readableBinding, path: property.readableBinding,
})) }))
$: links = bindableProperties $: links = bindings
.filter(x => x.fieldSchema?.type === "link") .filter(x => x.fieldSchema?.type === "link")
.map(property => { .map(property => {
return { return {
@ -138,7 +132,7 @@
bind:customParams={value.queryParams} bind:customParams={value.queryParams}
parameters={queries.find(query => query._id === value._id) parameters={queries.find(query => query._id === value._id)
.parameters} .parameters}
bindings={queryBindableProperties} {bindings}
/> />
{/if} {/if}
<IntegrationQueryEditor <IntegrationQueryEditor

View File

@ -13,10 +13,10 @@
import { generate } from "shortid" import { generate } from "shortid"
const flipDurationMs = 150 const flipDurationMs = 150
const EVENT_TYPE_KEY = "##eventHandlerType" const EVENT_TYPE_KEY = "##eventHandlerType"
export let actions export let actions
export let bindings = []
// dndzone needs an id on the array items, so this adds some temporary ones. // dndzone needs an id on the array items, so this adds some temporary ones.
$: { $: {
@ -121,6 +121,7 @@
<svelte:component <svelte:component
this={selectedActionComponent} this={selectedActionComponent}
parameters={selectedAction.parameters} parameters={selectedAction.parameters}
{bindings}
/> />
</div> </div>
{/if} {/if}

View File

@ -9,6 +9,7 @@
export let value = [] export let value = []
export let name export let name
export let bindings
let drawer let drawer
@ -57,5 +58,5 @@
Define what actions to run. Define what actions to run.
</svelte:fragment> </svelte:fragment>
<Button cta slot="buttons" on:click={saveEventData}>Save</Button> <Button cta slot="buttons" on:click={saveEventData}>Save</Button>
<EventEditor slot="body" bind:actions={value} eventType={name} /> <EventEditor slot="body" bind:actions={value} eventType={name} {bindings} />
</Drawer> </Drawer>

View File

@ -0,0 +1,35 @@
<script>
import { Select, Label } from "@budibase/bbui"
import { currentAsset, store } from "builderStore"
import { getActionProviderComponents } from "builderStore/dataBinding"
export let parameters
$: actionProviders = getActionProviderComponents(
$currentAsset,
$store.selectedComponentId,
"ClearForm"
)
</script>
<div class="root">
<Label small>Form</Label>
<Select
bind:value={parameters.componentId}
options={actionProviders}
getOptionLabel={x => x._instanceName}
getOptionValue={x => x._id}
/>
</div>
<style>
.root {
display: grid;
column-gap: var(--spacing-l);
row-gap: var(--spacing-s);
grid-template-columns: 60px 1fr;
align-items: center;
max-width: 800px;
margin: 0 auto;
}
</style>

View File

@ -0,0 +1,17 @@
<script>
import { Body } from "@budibase/bbui"
</script>
<div class="root">
<Body size="S">This action doesn't require any additional settings.</Body>
<Body size="S">
This action won't do anything if there isn't a screen modal open.
</Body>
</div>
<style>
.root {
max-width: 800px;
margin: 0 auto;
}
</style>

View File

@ -1,14 +1,12 @@
<script> <script>
import { Select, Label, Checkbox, Input } from "@budibase/bbui" import { Select, Label, Checkbox, Input } from "@budibase/bbui"
import { store, currentAsset } from "builderStore"
import { tables } from "stores/backend" import { tables } from "stores/backend"
import { getBindableProperties } from "builderStore/dataBinding"
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte" import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
export let parameters export let parameters
export let bindings = []
$: tableOptions = $tables.list || [] $: tableOptions = $tables.list || []
$: bindings = getBindableProperties($currentAsset, $store.selectedComponentId)
</script> </script>
<div class="root"> <div class="root">

View File

@ -1,21 +1,16 @@
<script> <script>
import { Select, Layout, Input, Checkbox } from "@budibase/bbui" import { Select, Layout, Input, Checkbox } from "@budibase/bbui"
import { store, currentAsset } from "builderStore"
import { datasources, integrations, queries } from "stores/backend" import { datasources, integrations, queries } from "stores/backend"
import { getBindableProperties } from "builderStore/dataBinding"
import ParameterBuilder from "components/integration/QueryParameterBuilder.svelte" import ParameterBuilder from "components/integration/QueryParameterBuilder.svelte"
import IntegrationQueryEditor from "components/integration/index.svelte" import IntegrationQueryEditor from "components/integration/index.svelte"
export let parameters export let parameters
export let bindings = []
$: query = $queries.list.find(q => q._id === parameters.queryId) $: query = $queries.list.find(q => q._id === parameters.queryId)
$: datasource = $datasources.list.find( $: datasource = $datasources.list.find(
ds => ds._id === parameters.datasourceId ds => ds._id === parameters.datasourceId
) )
$: bindableProperties = getBindableProperties(
$currentAsset,
$store.selectedComponentId
)
function fetchQueryDefinition(query) { function fetchQueryDefinition(query) {
const source = $datasources.list.find( const source = $datasources.list.find(
@ -61,7 +56,7 @@
<ParameterBuilder <ParameterBuilder
bind:customParams={parameters.queryParams} bind:customParams={parameters.queryParams}
parameters={query.parameters} parameters={query.parameters}
bindings={bindableProperties} {bindings}
/> />
<IntegrationQueryEditor <IntegrationQueryEditor
height={200} height={200}

View File

@ -1,12 +1,9 @@
<script> <script>
import { Label } from "@budibase/bbui" import { Label, Checkbox } from "@budibase/bbui"
import { getBindableProperties } from "builderStore/dataBinding"
import { currentAsset, store } from "builderStore"
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte" import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
export let parameters export let parameters
export let bindings = []
$: bindings = getBindableProperties($currentAsset, $store.selectedComponentId)
</script> </script>
<div class="root"> <div class="root">
@ -18,19 +15,17 @@
on:change={value => (parameters.url = value.detail)} on:change={value => (parameters.url = value.detail)}
{bindings} {bindings}
/> />
<div />
<Checkbox text="Open screen in modal" bind:value={parameters.peek} />
</div> </div>
<style> <style>
.root { .root {
display: flex; display: grid;
flex-direction: row; align-items: center;
align-items: baseline; gap: var(--spacing-m);
grid-template-columns: auto 1fr;
max-width: 800px; max-width: 800px;
margin: 0 auto; margin: 0 auto;
} }
.root :global(> div) {
flex: 1;
margin-left: var(--spacing-l);
}
</style> </style>

View File

@ -1,7 +1,5 @@
<script> <script>
import { Label, ActionButton, Button, Select, Input } from "@budibase/bbui" import { Label, ActionButton, Button, Select, Input } from "@budibase/bbui"
import { store, currentAsset } from "builderStore"
import { getBindableProperties } from "builderStore/dataBinding"
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte" import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
@ -11,13 +9,10 @@
export let schemaFields export let schemaFields
export let fieldLabel = "Column" export let fieldLabel = "Column"
export let valueLabel = "Value" export let valueLabel = "Value"
export let bindings = []
let fields = Object.entries(parameterFields || {}) let fields = Object.entries(parameterFields || {})
$: onChange(fields) $: onChange(fields)
$: bindableProperties = getBindableProperties(
$currentAsset,
$store.selectedComponentId
)
const addField = () => { const addField = () => {
fields = [...fields.filter(field => field[0]), ["", ""]] fields = [...fields.filter(field => field[0]), ["", ""]]
@ -69,7 +64,7 @@
<DrawerBindableInput <DrawerBindableInput
title={`Value for "${field[0]}"`} title={`Value for "${field[0]}"`}
value={field[1]} value={field[1]}
bindings={bindableProperties} {bindings}
on:change={event => updateFieldValue(idx, event.detail)} on:change={event => updateFieldValue(idx, event.detail)}
/> />
<ActionButton <ActionButton

View File

@ -9,6 +9,7 @@
import SaveFields from "./SaveFields.svelte" import SaveFields from "./SaveFields.svelte"
export let parameters export let parameters
export let bindings = []
$: dataProviderComponents = getDataProviderComponents( $: dataProviderComponents = getDataProviderComponents(
$currentAsset, $currentAsset,
@ -70,6 +71,7 @@
parameterFields={parameters.fields} parameterFields={parameters.fields}
{schemaFields} {schemaFields}
on:change={onFieldsChanged} on:change={onFieldsChanged}
{bindings}
/> />
</div> </div>
{/if} {/if}

View File

@ -3,13 +3,14 @@
import { automationStore } from "builderStore" import { automationStore } from "builderStore"
import SaveFields from "./SaveFields.svelte" import SaveFields from "./SaveFields.svelte"
export let parameters = {}
export let bindings = []
const AUTOMATION_STATUS = { const AUTOMATION_STATUS = {
NEW: "new", NEW: "new",
EXISTING: "existing", EXISTING: "existing",
} }
export let parameters = {}
let automationStatus = parameters.automationId let automationStatus = parameters.automationId
? AUTOMATION_STATUS.EXISTING ? AUTOMATION_STATUS.EXISTING
: AUTOMATION_STATUS.NEW : AUTOMATION_STATUS.NEW
@ -109,6 +110,7 @@
parameterFields={parameters.fields} parameterFields={parameters.fields}
fieldLabel="Field" fieldLabel="Field"
on:change={onFieldsChanged} on:change={onFieldsChanged}
{bindings}
/> />
{/key} {/key}
</div> </div>

View File

@ -5,6 +5,8 @@ import ExecuteQuery from "./ExecuteQuery.svelte"
import TriggerAutomation from "./TriggerAutomation.svelte" import TriggerAutomation from "./TriggerAutomation.svelte"
import ValidateForm from "./ValidateForm.svelte" import ValidateForm from "./ValidateForm.svelte"
import LogOut from "./LogOut.svelte" import LogOut from "./LogOut.svelte"
import ClearForm from "./ClearForm.svelte"
import CloseScreenModal from "./CloseScreenModal.svelte"
// Defines which actions are available to configure in the front end. // Defines which actions are available to configure in the front end.
// Unfortunately the "name" property is used as the identifier so please don't // Unfortunately the "name" property is used as the identifier so please don't
@ -42,4 +44,12 @@ export default [
name: "Log Out", name: "Log Out",
component: LogOut, component: LogOut,
}, },
{
name: "Clear Form",
component: ClearForm,
},
{
name: "Close Screen Modal",
component: CloseScreenModal,
},
] ]

View File

@ -20,6 +20,8 @@
export let value = [] export let value = []
export let componentInstance export let componentInstance
export let bindings = []
let drawer let drawer
let tempValue = value || [] let tempValue = value || []
@ -51,7 +53,7 @@
constraints. constraints.
{/if} {/if}
</Body> </Body>
<LuceneFilterBuilder bind:value={tempValue} {schemaFields} /> <LuceneFilterBuilder bind:value={tempValue} {schemaFields} {bindings} />
</Layout> </Layout>
</DrawerContent> </DrawerContent>
</Drawer> </Drawer>

View File

@ -7,20 +7,15 @@
Combobox, Combobox,
Input, Input,
} from "@budibase/bbui" } from "@budibase/bbui"
import { store, currentAsset } from "builderStore"
import { getBindableProperties } from "builderStore/dataBinding"
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte" import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
import { generate } from "shortid" import { generate } from "shortid"
import { OperatorOptions, getValidOperatorsForType } from "helpers/lucene" import { OperatorOptions, getValidOperatorsForType } from "helpers/lucene"
export let schemaFields export let schemaFields
export let value export let value
export let bindings = []
const BannedTypes = ["link", "attachment"] const BannedTypes = ["link", "attachment"]
$: bindableProperties = getBindableProperties(
$currentAsset,
$store.selectedComponentId
)
$: fieldOptions = (schemaFields ?? []) $: fieldOptions = (schemaFields ?? [])
.filter(field => !BannedTypes.includes(field.type)) .filter(field => !BannedTypes.includes(field.type))
.map(field => field.name) .map(field => field.name)
@ -101,7 +96,7 @@
title={`Value for "${expression.field}"`} title={`Value for "${expression.field}"`}
value={expression.value} value={expression.value}
placeholder="Value" placeholder="Value"
bindings={bindableProperties} {bindings}
on:change={event => (expression.value = event.detail)} on:change={event => (expression.value = event.detail)}
/> />
{:else if ["string", "longform", "number"].includes(expression.type)} {:else if ["string", "longform", "number"].includes(expression.type)}

View File

@ -1,8 +1,6 @@
<script> <script>
import { Button, Icon, Drawer, Label } from "@budibase/bbui" import { Button, Icon, Drawer, Label } from "@budibase/bbui"
import { store, currentAsset } from "builderStore"
import { import {
getBindableProperties,
readableToRuntimeBinding, readableToRuntimeBinding,
runtimeToReadableBinding, runtimeToReadableBinding,
} from "builderStore/dataBinding" } from "builderStore/dataBinding"
@ -18,18 +16,15 @@
export let value = null export let value = null
export let props = {} export let props = {}
export let onChange = () => {} export let onChange = () => {}
export let bindings = []
let bindingDrawer let bindingDrawer
let anchor let anchor
let valid let valid
$: bindableProperties = getBindableProperties( $: safeValue = getSafeValue(value, props.defaultValue, bindings)
$currentAsset,
$store.selectedComponentId
)
$: safeValue = getSafeValue(value, props.defaultValue, bindableProperties)
$: tempValue = safeValue $: tempValue = safeValue
$: replaceBindings = val => readableToRuntimeBinding(bindableProperties, val) $: replaceBindings = val => readableToRuntimeBinding(bindings, val)
const handleClose = () => { const handleClose = () => {
handleChange(tempValue) handleChange(tempValue)
@ -61,8 +56,8 @@
// The "safe" value is the value with any bindings made readable // The "safe" value is the value with any bindings made readable
// If there is no value set, any default value is used // If there is no value set, any default value is used
const getSafeValue = (value, defaultValue, bindableProperties) => { const getSafeValue = (value, defaultValue, bindings) => {
const enriched = runtimeToReadableBinding(bindableProperties, value) const enriched = runtimeToReadableBinding(bindings, value)
return enriched == null && defaultValue !== undefined return enriched == null && defaultValue !== undefined
? defaultValue ? defaultValue
: enriched : enriched
@ -83,6 +78,7 @@
updateOnChange={false} updateOnChange={false}
on:change={handleChange} on:change={handleChange}
onChange={handleChange} onChange={handleChange}
{bindings}
name={key} name={key}
text={label} text={label}
{type} {type}
@ -108,7 +104,7 @@
bind:valid bind:valid
value={safeValue} value={safeValue}
on:change={e => (tempValue = e.detail)} on:change={e => (tempValue = e.detail)}
{bindableProperties} bindableProperties={bindings}
/> />
</Drawer> </Drawer>
{/if} {/if}

View File

@ -3,10 +3,11 @@
import DrawerBindableCombobox from "components/common/bindings/DrawerBindableCombobox.svelte" import DrawerBindableCombobox from "components/common/bindings/DrawerBindableCombobox.svelte"
export let value export let value
export let bindings
$: urlOptions = $store.screens $: urlOptions = $store.screens
.map(screen => screen.routing?.route) .map(screen => screen.routing?.route)
.filter(x => x != null) .filter(x => x != null)
</script> </script>
<DrawerBindableCombobox {value} on:change options={urlOptions} /> <DrawerBindableCombobox {value} {bindings} on:change options={urlOptions} />

View File

@ -9,6 +9,7 @@
import { FrontendTypes } from "constants" import { FrontendTypes } from "constants"
export let componentInstance export let componentInstance
export let bindings
function setAssetProps(name, value, parser) { function setAssetProps(name, value, parser) {
if (parser && typeof parser === "function") { if (parser && typeof parser === "function") {
@ -53,6 +54,7 @@
key={def.key} key={def.key}
value={deepGet($currentAsset, def.key)} value={deepGet($currentAsset, def.key)}
on:change={event => setAssetProps(def.key, event.detail, def.parser)} on:change={event => setAssetProps(def.key, event.detail, def.parser)}
{bindings}
/> />
{/each} {/each}
</DetailSummary> </DetailSummary>

View File

@ -7,6 +7,7 @@
export let columns export let columns
export let properties export let properties
export let componentInstance export let componentInstance
export let bindings = []
$: style = componentInstance._styles.normal || {} $: style = componentInstance._styles.normal || {}
$: changed = properties?.some(prop => hasPropChanged(style, prop)) ?? false $: changed = properties?.some(prop => hasPropChanged(style, prop)) ?? false
@ -36,6 +37,7 @@
value={style[prop.key]} value={style[prop.key]}
onChange={val => store.actions.components.updateStyle(prop.key, val)} onChange={val => store.actions.components.updateStyle(prop.key, val)}
props={getControlProps(prop)} props={getControlProps(prop)}
{bindings}
/> />
</div> </div>
{/each} {/each}

View File

@ -0,0 +1,48 @@
<script>
import { Select, Label, Input } from "@budibase/bbui"
/**
* This component takes the query object and populates the 'extra' property
* when a datasource has specified a configuration for these fields in SCHEMA.extra
*/
export let populateExtraQuery
export let config
export let query
$: extraFields = Object.keys(config).map(key => ({
...config[key],
key,
}))
$: extraQueryFields = query.fields.extra || {}
</script>
{#each extraFields as { key, displayName, type }}
<div class="config-field">
<Label>{displayName}</Label>
{#if type === "string"}
<Input
on:change={() => populateExtraQuery(extraQueryFields)}
bind:value={extraQueryFields[key]}
/>
{/if}
{#if type === "list"}
<Select
on:change={() => populateExtraQuery(extraQueryFields)}
bind:value={extraQueryFields[key]}
options={config[key].data[query.queryVerb]}
getOptionLabel={current => current}
/>
{/if}
</div>
{/each}
<style>
.config-field {
display: grid;
grid-template-columns: 20% 1fr;
grid-gap: var(--spacing-l);
align-items: center;
}
</style>

View File

@ -15,6 +15,7 @@
} from "@budibase/bbui" } from "@budibase/bbui"
import { notifications, Divider } from "@budibase/bbui" import { notifications, Divider } from "@budibase/bbui"
import api from "builderStore/api" import api from "builderStore/api"
import ExtraQueryConfig from "./ExtraQueryConfig.svelte"
import IntegrationQueryEditor from "components/integration/index.svelte" import IntegrationQueryEditor from "components/integration/index.svelte"
import ExternalDataSourceTable from "components/backend/DataTable/ExternalDataSourceTable.svelte" import ExternalDataSourceTable from "components/backend/DataTable/ExternalDataSourceTable.svelte"
import ParameterBuilder from "components/integration/QueryParameterBuilder.svelte" import ParameterBuilder from "components/integration/QueryParameterBuilder.svelte"
@ -60,6 +61,14 @@
fields = fields fields = fields
} }
function resetDependentFields() {
if (query.fields.extra) query.fields.extra = {}
}
function populateExtraQuery(extraQueryFields) {
query.fields.extra = extraQueryFields
}
async function previewQuery() { async function previewQuery() {
try { try {
const response = await api.post(`/api/queries/preview`, { const response = await api.post(`/api/queries/preview`, {
@ -127,11 +136,19 @@
<Label>Function</Label> <Label>Function</Label>
<Select <Select
bind:value={query.queryVerb} bind:value={query.queryVerb}
on:change={resetDependentFields}
options={Object.keys(queryConfig)} options={Object.keys(queryConfig)}
getOptionLabel={verb => getOptionLabel={verb =>
queryConfig[verb]?.displayName || capitalise(verb)} queryConfig[verb]?.displayName || capitalise(verb)}
/> />
</div> </div>
{#if integrationInfo?.extra && query.queryVerb}
<ExtraQueryConfig
{query}
{populateExtraQuery}
config={integrationInfo.extra}
/>
{/if}
<ParameterBuilder bind:parameters={query.parameters} bindable={false} /> <ParameterBuilder bind:parameters={query.parameters} bindable={false} />
{/if} {/if}
</div> </div>

View File

@ -15,6 +15,7 @@
export let exportApp export let exportApp
export let viewApp export let viewApp
export let editApp export let editApp
export let updateApp
export let deleteApp export let deleteApp
export let unpublishApp export let unpublishApp
export let releaseLock export let releaseLock
@ -53,6 +54,9 @@
</MenuItem> </MenuItem>
{/if} {/if}
{#if !app.deployed} {#if !app.deployed}
<MenuItem on:click={() => updateApp(app)} icon="Edit">
Update
</MenuItem>
<MenuItem on:click={() => deleteApp(app)} icon="Delete"> <MenuItem on:click={() => deleteApp(app)} icon="Delete">
Delete Delete
</MenuItem> </MenuItem>

View File

@ -14,6 +14,7 @@
export let exportApp export let exportApp
export let viewApp export let viewApp
export let editApp export let editApp
export let updateApp
export let deleteApp export let deleteApp
export let unpublishApp export let unpublishApp
export let releaseLock export let releaseLock
@ -82,6 +83,7 @@
</MenuItem> </MenuItem>
{/if} {/if}
{#if !app.deployed} {#if !app.deployed}
<MenuItem on:click={() => updateApp(app)} icon="Edit">Update</MenuItem>
<MenuItem on:click={() => deleteApp(app)} icon="Delete">Delete</MenuItem> <MenuItem on:click={() => deleteApp(app)} icon="Delete">Delete</MenuItem>
{/if} {/if}
</ActionMenu> </ActionMenu>

View File

@ -0,0 +1,111 @@
<script>
import { writable, get as svelteGet } from "svelte/store"
import {
notifications,
Input,
Modal,
ModalContent,
Body,
} from "@budibase/bbui"
import { hostingStore } from "builderStore"
import { apps } from "stores/portal"
import { string, object } from "yup"
import { onMount } from "svelte"
import { capitalise } from "helpers"
const values = writable({ name: null })
const errors = writable({})
const touched = writable({})
const validator = {
name: string().required("Your application must have a name"),
}
export let app
let modal
let valid = false
let dirty = false
$: checkValidity($values, validator)
$: {
// prevent validation by setting name to undefined without an app
if (app) {
$values.name = app?.name
}
}
onMount(async () => {
await hostingStore.actions.fetchDeployedApps()
const existingAppNames = svelteGet(hostingStore).deployedAppNames
validator.name = string()
.required("Your application must have a name")
.test(
"non-existing-app-name",
"Another app with the same name already exists",
value => {
return !existingAppNames.some(
appName => dirty && appName.toLowerCase() === value.toLowerCase()
)
}
)
})
const checkValidity = async (values, validator) => {
const obj = object().shape(validator)
Object.keys(validator).forEach(key => ($errors[key] = null))
try {
await obj.validate(values, { abortEarly: false })
} catch (validationErrors) {
validationErrors.inner.forEach(error => {
$errors[error.path] = capitalise(error.message)
})
}
valid = await obj.isValid(values)
}
async function updateApp() {
try {
// Update App
await apps.update(app.instance._id, $values.name)
hide()
} catch (error) {
console.error(error)
notifications.error(error)
}
}
export const show = () => {
modal.show()
}
export const hide = () => {
modal.hide()
}
const onCancel = () => {
hide()
}
const onShow = () => {
dirty = false
}
</script>
<Modal bind:this={modal} on:hide={onCancel} on:show={onShow}>
<ModalContent
title={"Update app"}
confirmText={"Update app"}
onConfirm={updateApp}
disabled={!(valid && dirty)}
>
<Body size="S">
Give your new app a name, and choose which groups have access (paid plans
only).
</Body>
<Input
bind:value={$values.name}
error={$touched.name && $errors.name}
on:blur={() => ($touched.name = true)}
on:change={() => (dirty = true)}
label="Name"
/>
</ModalContent>
</Modal>

View File

@ -4,7 +4,10 @@
import { onMount } from "svelte" import { onMount } from "svelte"
let loaded = false let loaded = false
$: multiTenancyEnabled = $admin.multiTenancy
$: hasAdminUser = !!$admin?.checklist?.adminUser $: hasAdminUser = !!$admin?.checklist?.adminUser
$: tenantSet = $auth.tenantSet
onMount(async () => { onMount(async () => {
await admin.init() await admin.init()
@ -12,9 +15,14 @@
loaded = true loaded = true
}) })
// Force creation of an admin user if one doesn't exist
$: { $: {
if (loaded && !hasAdminUser) { const apiReady = $admin.loaded && $auth.loaded
// if tenant is not set go to it
if (loaded && apiReady && multiTenancyEnabled && !tenantSet) {
$redirect("./auth/org")
}
// Force creation of an admin user if one doesn't exist
else if (loaded && apiReady && !hasAdminUser) {
$redirect("./admin") $redirect("./admin")
} }
} }
@ -29,7 +37,7 @@
!$isActive("./invite") !$isActive("./invite")
) { ) {
const returnUrl = encodeURIComponent(window.location.pathname) const returnUrl = encodeURIComponent(window.location.pathname)
$redirect("./auth/login?", { returnUrl }) $redirect("./auth?", { returnUrl })
} else if ($auth?.user?.forceResetPassword) { } else if ($auth?.user?.forceResetPassword) {
$redirect("./auth/reset") $redirect("./auth/reset")
} }

View File

@ -6,20 +6,25 @@
Layout, Layout,
Input, Input,
Body, Body,
ActionButton,
} 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, auth } from "stores/portal"
import PasswordRepeatInput from "components/common/users/PasswordRepeatInput.svelte" import PasswordRepeatInput from "components/common/users/PasswordRepeatInput.svelte"
import Logo from "assets/bb-emblem.svg" import Logo from "assets/bb-emblem.svg"
let adminUser = {} let adminUser = {}
let error let error
$: tenantId = $auth.tenantId
$: multiTenancyEnabled = $admin.multiTenancy
async function save() { async function save() {
try { try {
adminUser.tenantId = tenantId
// Save the admin user // Save the admin user
const response = await api.post(`/api/admin/users/init`, adminUser) const response = await api.post(`/api/global/users/init`, adminUser)
const json = await response.json() const json = await response.json()
if (response.status !== 200) { if (response.status !== 200) {
throw new Error(json.message) throw new Error(json.message)
@ -47,9 +52,22 @@
<Input label="Email" bind:value={adminUser.email} /> <Input label="Email" bind:value={adminUser.email} />
<PasswordRepeatInput bind:password={adminUser.password} bind:error /> <PasswordRepeatInput bind:password={adminUser.password} bind:error />
</Layout> </Layout>
<Button cta disabled={error} on:click={save}> <Layout gap="XS" noPadding>
Create super admin user <Button cta disabled={error} on:click={save}>
</Button> Create super admin user
</Button>
{#if multiTenancyEnabled}
<ActionButton
quiet
on:click={() => {
admin.unload()
$goto("../auth/org")
}}
>
Change organisation
</ActionButton>
{/if}
</Layout>
</Layout> </Layout>
</div> </div>
</section> </section>

View File

@ -158,10 +158,16 @@
fieldName: fromTable.primary[0], fieldName: fromTable.primary[0],
} }
} else { } else {
// the relateFrom.fieldName should remain the same, as it is the foreignKey in the other
// table, this is due to the way that budibase represents relationships, the fieldName in a
// link column schema is the column linked to (FK in this case). The foreignKey column is
// essentially what is linked to in the from table, this is unique to SQL as this isn't a feature
// of Budibase internal tables.
// Essentially this means the fieldName is what we are linking to in the other table, and the
// foreignKey is what is linking out of the current table.
relateFrom = { relateFrom = {
...relateFrom, ...relateFrom,
foreignKey: relateFrom.fieldName, foreignKey: fromTable.primary[0],
fieldName: fromTable.primary[0],
} }
relateTo = { relateTo = {
...relateTo, ...relateTo,

View File

@ -54,7 +54,11 @@
</Layout> </Layout>
<ActionMenu align="right"> <ActionMenu align="right">
<div slot="control" class="avatar"> <div slot="control" class="avatar">
<Avatar size="M" initials={$auth.initials} /> <Avatar
size="M"
initials={$auth.initials}
url={$auth.user.pictureUrl}
/>
<Icon size="XL" name="ChevronDown" /> <Icon size="XL" name="ChevronDown" />
</div> </div>
<MenuItem icon="UserEdit" on:click={() => userInfoModal.show()}> <MenuItem icon="UserEdit" on:click={() => userInfoModal.show()}>

View File

@ -1,14 +1,18 @@
<script> <script>
import { ActionButton } from "@budibase/bbui" import { ActionButton } from "@budibase/bbui"
import GoogleLogo from "assets/google-logo.png" import GoogleLogo from "assets/google-logo.png"
import { organisation } from "stores/portal" import { auth, organisation } from "stores/portal"
let show
$: tenantId = $auth.tenantId
$: show = $organisation.google $: show = $organisation.google
</script> </script>
{#if show} {#if show}
<ActionButton <ActionButton
on:click={() => window.open("/api/admin/auth/google", "_blank")} on:click={() =>
window.open(`/api/global/auth/${tenantId}/google`, "_blank")}
> >
<div class="inner"> <div class="inner">
<img src={GoogleLogo} alt="google icon" /> <img src={GoogleLogo} alt="google icon" />

View File

@ -6,7 +6,7 @@
import OktaLogo from "assets/okta-logo.png" import OktaLogo from "assets/okta-logo.png"
import OneLoginLogo from "assets/onelogin-logo.png" import OneLoginLogo from "assets/onelogin-logo.png"
import { oidc, organisation } from "stores/portal" import { oidc, organisation, auth } from "stores/portal"
import { onMount } from "svelte" import { onMount } from "svelte"
$: show = $organisation.oidc $: show = $organisation.oidc
@ -31,7 +31,10 @@
{#if show} {#if show}
<ActionButton <ActionButton
on:click={() => on:click={() =>
window.open(`/api/admin/auth/oidc/configs/${$oidc.uuid}`, "_blank")} window.open(
`/api/global/auth/${$auth.tenantId}/oidc/configs/${$oidc.uuid}`,
"_blank"
)}
> >
<div class="inner"> <div class="inner">
<img {src} alt="oidc icon" /> <img {src} alt="oidc icon" />

View File

@ -6,10 +6,12 @@
Layout, Layout,
Body, Body,
Heading, Heading,
ActionButton,
} from "@budibase/bbui" } from "@budibase/bbui"
import { organisation, auth } from "stores/portal" import { organisation, auth } from "stores/portal"
import Logo from "assets/bb-emblem.svg" import Logo from "assets/bb-emblem.svg"
import { onMount } from "svelte" import { onMount } from "svelte"
import { goto } from "@roxi/routify"
let email = "" let email = ""
@ -41,9 +43,12 @@
</Body> </Body>
<Input label="Email" bind:value={email} /> <Input label="Email" bind:value={email} />
</Layout> </Layout>
<Button cta on:click={forgot} disabled={!email}> <Layout gap="XS" nopadding>
Reset your password <Button cta on:click={forgot} disabled={!email}>
</Button> Reset your password
</Button>
<ActionButton quiet on:click={() => $goto("../")}>Back</ActionButton>
</Layout>
</Layout> </Layout>
</div> </div>
</div> </div>

View File

@ -1,4 +1,24 @@
<script> <script>
import { redirect } from "@roxi/routify" import { redirect } from "@roxi/routify"
$redirect("./login") import { auth, admin } from "stores/portal"
import { onMount } from "svelte"
$: tenantSet = $auth.tenantSet
$: multiTenancyEnabled = $admin.multiTenancy
let loaded = false
$: {
if (loaded && multiTenancyEnabled && !tenantSet) {
$redirect("./org")
} else if (loaded) {
$redirect("./login")
}
}
onMount(async () => {
await admin.init()
await auth.checkQueryString()
loaded = true
})
</script> </script>

View File

@ -10,7 +10,7 @@
notifications, notifications,
} from "@budibase/bbui" } from "@budibase/bbui"
import { goto, params } from "@roxi/routify" import { goto, params } from "@roxi/routify"
import { auth, organisation, oidc } from "stores/portal" import { auth, organisation, oidc, admin } from "stores/portal"
import GoogleButton from "./_components/GoogleButton.svelte" import GoogleButton from "./_components/GoogleButton.svelte"
import OIDCButton from "./_components/OIDCButton.svelte" import OIDCButton from "./_components/OIDCButton.svelte"
import Logo from "assets/bb-emblem.svg" import Logo from "assets/bb-emblem.svg"
@ -18,8 +18,10 @@
let username = "" let username = ""
let password = "" let password = ""
let loaded = false
$: company = $organisation.company || "Budibase" $: company = $organisation.company || "Budibase"
$: multiTenancyEnabled = $admin.multiTenancy
async function login() { async function login() {
try { try {
@ -27,7 +29,6 @@
username, username,
password, password,
}) })
notifications.success("Logged in successfully")
if ($auth?.user?.forceResetPassword) { if ($auth?.user?.forceResetPassword) {
$goto("./reset") $goto("./reset")
} else { } else {
@ -50,6 +51,7 @@
onMount(async () => { onMount(async () => {
await organisation.init() await organisation.init()
loaded = true
}) })
</script> </script>
@ -61,8 +63,10 @@
<img alt="logo" src={$organisation.logoUrl || Logo} /> <img alt="logo" src={$organisation.logoUrl || Logo} />
<Heading>Sign in to {company}</Heading> <Heading>Sign in to {company}</Heading>
</Layout> </Layout>
<GoogleButton /> {#if loaded}
<OIDCButton oidcIcon={$oidc.logo} oidcName={$oidc.name} /> <GoogleButton />
<OIDCButton oidcIcon={$oidc.logo} oidcName={$oidc.name} />
{/if}
<Divider noGrid /> <Divider noGrid />
<Layout gap="XS" noPadding> <Layout gap="XS" noPadding>
<Body size="S" textAlign="center">Sign in with email</Body> <Body size="S" textAlign="center">Sign in with email</Body>
@ -79,6 +83,17 @@
<ActionButton quiet on:click={() => $goto("./forgot")}> <ActionButton quiet on:click={() => $goto("./forgot")}>
Forgot password? Forgot password?
</ActionButton> </ActionButton>
{#if multiTenancyEnabled}
<ActionButton
quiet
on:click={() => {
admin.unload()
$goto("./org")
}}
>
Change organisation
</ActionButton>
{/if}
</Layout> </Layout>
</Layout> </Layout>
</div> </div>

View File

@ -0,0 +1,73 @@
<script>
import { Body, Button, Divider, Heading, Input, Layout } from "@budibase/bbui"
import { goto } from "@roxi/routify"
import { auth, admin } from "stores/portal"
import Logo from "assets/bb-emblem.svg"
import { get } from "svelte/store"
import { onMount } from "svelte"
let tenantId = get(auth).tenantSet ? get(auth).tenantId : ""
$: multiTenancyEnabled = $admin.multiTenancy
async function setOrg() {
if (tenantId == null || tenantId === "") {
tenantId = "default"
}
await auth.setOrg(tenantId)
// re-init now org selected
await admin.init()
$goto("../")
}
function handleKeydown(evt) {
if (evt.key === "Enter") setOrg()
}
onMount(async () => {
await auth.checkQueryString()
if (!multiTenancyEnabled) {
$goto("../")
} else {
admin.unload()
}
})
</script>
<svelte:window on:keydown={handleKeydown} />
<div class="login">
<div class="main">
<Layout>
<Layout noPadding justifyItems="center">
<img alt="logo" src={Logo} />
<Heading>Set Budibase organisation</Heading>
</Layout>
<Divider noGrid />
<Layout gap="XS" noPadding>
<Body size="S" textAlign="center">Set organisation</Body>
<Input label="Organisation" bind:value={tenantId} />
</Layout>
<Layout gap="XS" noPadding>
<Button cta on:click={setOrg}>Set organisation</Button>
</Layout>
</Layout>
</div>
</div>
<style>
.login {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.main {
width: 300px;
}
img {
width: 48px;
}
</style>

View File

@ -2,13 +2,15 @@
import { redirect } from "@roxi/routify" import { redirect } from "@roxi/routify"
import { auth } from "stores/portal" import { auth } from "stores/portal"
auth.checkQueryString()
$: { $: {
if (!$auth.user) { if (!$auth.user) {
$redirect("./auth/login") $redirect(`./auth`)
} else if ($auth.user.builder?.global) { } else if ($auth.user.builder?.global) {
$redirect("./portal") $redirect(`./portal`)
} else { } else {
$redirect("./apps") $redirect(`./apps`)
} }
} }
</script> </script>

View File

@ -100,7 +100,11 @@
<div /> <div />
<ActionMenu align="right"> <ActionMenu align="right">
<div slot="control" class="avatar"> <div slot="control" class="avatar">
<Avatar size="M" initials={$auth.initials} /> <Avatar
size="M"
initials={$auth.initials}
url={$auth.user.pictureUrl}
/>
<Icon size="XL" name="ChevronDown" /> <Icon size="XL" name="ChevronDown" />
</div> </div>
<MenuItem icon="UserEdit" on:click={() => userInfoModal.show()}> <MenuItem icon="UserEdit" on:click={() => userInfoModal.show()}>

View File

@ -14,6 +14,7 @@
Body, Body,
} from "@budibase/bbui" } from "@budibase/bbui"
import CreateAppModal from "components/start/CreateAppModal.svelte" import CreateAppModal from "components/start/CreateAppModal.svelte"
import UpdateAppModal from "components/start/UpdateAppModal.svelte"
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"
@ -30,6 +31,7 @@
let template let template
let selectedApp let selectedApp
let creationModal let creationModal
let updatingModal
let deletionModal let deletionModal
let unpublishModal let unpublishModal
let creatingApp = false let creatingApp = false
@ -164,6 +166,11 @@
selectedApp = null selectedApp = null
} }
const updateApp = async app => {
selectedApp = app
updatingModal.show()
}
const releaseLock = async app => { const releaseLock = async app => {
try { try {
const response = await del(`/api/dev/${app.devId}/lock`) const response = await del(`/api/dev/${app.devId}/lock`)
@ -236,6 +243,7 @@
{editApp} {editApp}
{exportApp} {exportApp}
{deleteApp} {deleteApp}
{updateApp}
/> />
{/each} {/each}
</div> </div>
@ -289,6 +297,8 @@
Are you sure you want to unpublish the app <b>{selectedApp?.name}</b>? Are you sure you want to unpublish the app <b>{selectedApp?.name}</b>?
</ConfirmDialog> </ConfirmDialog>
<UpdateAppModal app={selectedApp} bind:this={updatingModal} />
<style> <style>
.title, .title,
.filter { .filter {

View File

@ -7,7 +7,6 @@
import OneLoginLogo from "assets/onelogin-logo.png" import OneLoginLogo from "assets/onelogin-logo.png"
import OidcLogoPng from "assets/oidc-logo.png" import OidcLogoPng from "assets/oidc-logo.png"
import { isEqual, cloneDeep } from "lodash/fp" import { isEqual, cloneDeep } from "lodash/fp"
import { import {
Button, Button,
Heading, Heading,
@ -22,36 +21,51 @@
} from "@budibase/bbui" } from "@budibase/bbui"
import { onMount } from "svelte" import { onMount } from "svelte"
import api from "builderStore/api" import api from "builderStore/api"
import { organisation } from "stores/portal" import { organisation, auth, admin } from "stores/portal"
import { uuid } from "builderStore/uuid" import { uuid } from "builderStore/uuid"
$: tenantId = $auth.tenantId
$: multiTenancyEnabled = $admin.multiTenancy
const ConfigTypes = { const ConfigTypes = {
Google: "google", Google: "google",
OIDC: "oidc", OIDC: "oidc",
// Github: "github",
// AzureAD: "ad",
} }
const GoogleConfigFields = { function callbackUrl(tenantId, end) {
Google: ["clientID", "clientSecret", "callbackURL"], let url = `/api/global/auth`
} if (multiTenancyEnabled && tenantId) {
const GoogleConfigLabels = { url += `/${tenantId}`
Google: { }
clientID: "Client ID", url += end
clientSecret: "Client secret", return url
callbackURL: "Callback URL",
},
} }
const OIDCConfigFields = { $: GoogleConfigFields = {
Oidc: ["configUrl", "clientID", "clientSecret"], Google: [
{ name: "clientID", label: "Client ID" },
{ name: "clientSecret", label: "Client secret" },
{
name: "callbackURL",
label: "Callback URL",
readonly: true,
placeholder: callbackUrl(tenantId, "/google/callback"),
},
],
} }
const OIDCConfigLabels = {
Oidc: { $: OIDCConfigFields = {
configUrl: "Config URL", Oidc: [
clientID: "Client ID", { name: "configUrl", label: "Config URL" },
clientSecret: "Client Secret", { name: "clientID", label: "Client ID" },
}, { name: "clientSecret", label: "Client Secret" },
{
name: "callbackURL",
label: "Callback URL",
readonly: true,
placeholder: callbackUrl(tenantId, "/oidc/callback"),
},
],
} }
let iconDropdownOptions = [ let iconDropdownOptions = [
@ -109,17 +123,13 @@
// Create a flag so that it will only try to save completed forms // Create a flag so that it will only try to save completed forms
$: partialGoogle = $: partialGoogle =
providers.google?.config?.clientID || providers.google?.config?.clientID || providers.google?.config?.clientSecret
providers.google?.config?.clientSecret ||
providers.google?.config?.callbackURL
$: partialOidc = $: partialOidc =
providers.oidc?.config?.configs[0].configUrl || providers.oidc?.config?.configs[0].configUrl ||
providers.oidc?.config?.configs[0].clientID || providers.oidc?.config?.configs[0].clientID ||
providers.oidc?.config?.configs[0].clientSecret providers.oidc?.config?.configs[0].clientSecret
$: googleComplete = $: googleComplete =
providers.google?.config?.clientID && providers.google?.config?.clientID && providers.google?.config?.clientSecret
providers.google?.config?.clientSecret &&
providers.google?.config?.callbackURL
$: oidcComplete = $: oidcComplete =
providers.oidc?.config?.configs[0].configUrl && providers.oidc?.config?.configs[0].configUrl &&
providers.oidc?.config?.configs[0].clientID && providers.oidc?.config?.configs[0].clientID &&
@ -129,7 +139,7 @@
let data = new FormData() let data = new FormData()
data.append("file", file) data.append("file", file)
const res = await api.post( const res = await api.post(
`/api/admin/configs/upload/logos_oidc/${file.name}`, `/api/global/configs/upload/logos_oidc/${file.name}`,
data, data,
{} {}
) )
@ -149,17 +159,21 @@
let calls = [] let calls = []
docs.forEach(element => { docs.forEach(element => {
if (element.type === ConfigTypes.OIDC) { if (element.type === ConfigTypes.OIDC) {
//Add a UUID here so each config is distinguishable when it arrives at the login page. //Add a UUID here so each config is distinguishable when it arrives at the login page
element.config.configs.forEach(config => { for (let config of element.config.configs) {
!config.uuid && (config.uuid = uuid()) if (!config.uuid) {
}) config.uuid = uuid()
}
// callback urls shouldn't be included
delete config.callbackURL
}
if (partialOidc) { if (partialOidc) {
if (!oidcComplete) { if (!oidcComplete) {
notifications.error( notifications.error(
`Please fill in all required ${ConfigTypes.OIDC} fields` `Please fill in all required ${ConfigTypes.OIDC} fields`
) )
} else { } else {
calls.push(api.post(`/api/admin/configs`, element)) calls.push(api.post(`/api/global/configs`, element))
// turn the save button grey when clicked // turn the save button grey when clicked
oidcSaveButtonDisabled = true oidcSaveButtonDisabled = true
originalOidcDoc = cloneDeep(providers.oidc) originalOidcDoc = cloneDeep(providers.oidc)
@ -173,7 +187,7 @@
`Please fill in all required ${ConfigTypes.Google} fields` `Please fill in all required ${ConfigTypes.Google} fields`
) )
} else { } else {
calls.push(api.post(`/api/admin/configs`, element)) calls.push(api.post(`/api/global/configs`, element))
googleSaveButtonDisabled = true googleSaveButtonDisabled = true
originalGoogleDoc = cloneDeep(providers.google) originalGoogleDoc = cloneDeep(providers.google)
} }
@ -206,7 +220,7 @@
await organisation.init() await organisation.init()
// fetch the configs for oauth // fetch the configs for oauth
const googleResponse = await api.get( const googleResponse = await api.get(
`/api/admin/configs/${ConfigTypes.Google}` `/api/global/configs/${ConfigTypes.Google}`
) )
const googleDoc = await googleResponse.json() const googleDoc = await googleResponse.json()
@ -227,7 +241,7 @@
//Get the list of user uploaded logos and push it to the dropdown options. //Get the list of user uploaded logos and push it to the dropdown options.
//This needs to be done before the config call so they're available when the dropdown renders //This needs to be done before the config call so they're available when the dropdown renders
const res = await api.get(`/api/admin/configs/logos_oidc`) const res = await api.get(`/api/global/configs/logos_oidc`)
const configSettings = await res.json() const configSettings = await res.json()
if (configSettings.config) { if (configSettings.config) {
@ -242,17 +256,16 @@
}) })
}) })
} }
const oidcResponse = await api.get(`/api/admin/configs/${ConfigTypes.OIDC}`) const oidcResponse = await api.get(
`/api/global/configs/${ConfigTypes.OIDC}`
)
const oidcDoc = await oidcResponse.json() const oidcDoc = await oidcResponse.json()
if (!oidcDoc._id) { if (!oidcDoc._id) {
console.log("hi")
providers.oidc = { providers.oidc = {
type: ConfigTypes.OIDC, type: ConfigTypes.OIDC,
config: { configs: [{ activated: true }] }, config: { configs: [{ activated: true }] },
} }
} else { } else {
console.log("hello")
originalOidcDoc = cloneDeep(oidcDoc) originalOidcDoc = cloneDeep(oidcDoc)
providers.oidc = oidcDoc providers.oidc = oidcDoc
} }
@ -295,8 +308,12 @@
<Layout gap="XS" noPadding> <Layout gap="XS" noPadding>
{#each GoogleConfigFields.Google as field} {#each GoogleConfigFields.Google as field}
<div class="form-row"> <div class="form-row">
<Label size="L">{GoogleConfigLabels.Google[field]}</Label> <Label size="L">{field.label}</Label>
<Input bind:value={providers.google.config[field]} /> <Input
bind:value={providers.google.config[field.name]}
readonly={field.readonly}
placeholder={field.placeholder}
/>
</div> </div>
{/each} {/each}
<div class="form-row"> <div class="form-row">
@ -335,14 +352,14 @@
<Layout gap="XS" noPadding> <Layout gap="XS" noPadding>
{#each OIDCConfigFields.Oidc as field} {#each OIDCConfigFields.Oidc as field}
<div class="form-row"> <div class="form-row">
<Label size="L">{OIDCConfigLabels.Oidc[field]}</Label> <Label size="L">{field.label}</Label>
<Input bind:value={providers.oidc.config.configs[0][field]} /> <Input
bind:value={providers.oidc.config.configs[0][field.name]}
readonly={field.readonly}
placeholder={field.placeholder}
/>
</div> </div>
{/each} {/each}
<div class="form-row">
<Label size="L">Callback URL</Label>
<Input readonly placeholder="/api/admin/auth/oidc/callback" />
</div>
<br /> <br />
<Body size="S"> <Body size="S">
To customize your login button, fill out the fields below. To customize your login button, fill out the fields below.

View File

@ -53,7 +53,7 @@
delete smtp.config.auth delete smtp.config.auth
} }
// Save your SMTP config // Save your SMTP config
const response = await api.post(`/api/admin/configs`, smtp) const response = await api.post(`/api/global/configs`, smtp)
if (response.status !== 200) { if (response.status !== 200) {
const error = await response.text() const error = await response.text()
@ -75,7 +75,9 @@
async function fetchSmtp() { async function fetchSmtp() {
loading = true loading = true
// fetch the configs for smtp // fetch the configs for smtp
const smtpResponse = await api.get(`/api/admin/configs/${ConfigTypes.SMTP}`) const smtpResponse = await api.get(
`/api/global/configs/${ConfigTypes.SMTP}`
)
const smtpDoc = await smtpResponse.json() const smtpDoc = await smtpResponse.json()
if (!smtpDoc._id) { if (!smtpDoc._id) {
@ -92,8 +94,13 @@
requireAuth = smtpConfig.config.auth != null requireAuth = smtpConfig.config.auth != null
// always attach the auth for the forms purpose - // always attach the auth for the forms purpose -
// this will be removed later if required // this will be removed later if required
smtpConfig.config.auth = { if (!smtpDoc.config) {
type: "login", smtpDoc.config = {}
}
if (!smtpDoc.config.auth) {
smtpConfig.config.auth = {
type: "login",
}
} }
} }

View File

@ -47,8 +47,8 @@
}) })
let selectedApp let selectedApp
const userFetch = fetchData(`/api/admin/users/${userId}`) const userFetch = fetchData(`/api/global/users/${userId}`)
const apps = fetchData(`/api/admin/roles`) const apps = fetchData(`/api/global/roles`)
async function deleteUser() { async function deleteUser() {
const res = await users.delete(userId) const res = await users.delete(userId)

View File

@ -48,7 +48,7 @@
on:change on:change
{options} {options}
label="Role" label="Role"
getOptionLabel={role => role.name} getOptionLabel={role => role.label}
getOptionValue={role => role._id} getOptionValue={role => role.value}
/> />
</ModalContent> </ModalContent>

View File

@ -37,7 +37,7 @@
async function uploadLogo(file) { 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/global/configs/upload/settings/logo", data, {})
return await res.json() return await res.json()
} }

View File

@ -1,4 +1,11 @@
<script> <script>
import { redirect } from "@roxi/routify" import { redirect } from "@roxi/routify"
$redirect("./builder") import { auth } from "../stores/portal"
import { onMount } from "svelte"
auth.checkQueryString()
onMount(() => {
$redirect(`./builder`)
})
</script> </script>

View File

@ -1,12 +1,18 @@
import { writable } from "svelte/store" import { writable, get } from "svelte/store"
import api from "builderStore/api" import api from "builderStore/api"
import { auth } from "stores/portal"
export function createAdminStore() { export function createAdminStore() {
const { subscribe, set } = writable({}) const admin = writable({
loaded: false,
})
async function init() { async function init() {
try { try {
const response = await api.get("/api/admin/configs/checklist") const tenantId = get(auth).tenantId
const response = await api.get(
`/api/global/configs/checklist?tenantId=${tenantId}`
)
const json = await response.json() const json = await response.json()
const onboardingSteps = Object.keys(json) const onboardingSteps = Object.keys(json)
@ -16,20 +22,49 @@ export function createAdminStore() {
0 0
) )
set({ await multiTenancyEnabled()
checklist: json, admin.update(store => {
onboardingProgress: (stepsComplete / onboardingSteps.length) * 100, store.loaded = true
store.checklist = json
store.onboardingProgress =
(stepsComplete / onboardingSteps.length) * 100
return store
}) })
} catch (err) { } catch (err) {
set({ admin.update(store => {
checklist: null, store.checklist = null
return store
}) })
} }
} }
async function multiTenancyEnabled() {
let enabled = false
try {
const response = await api.get(`/api/system/flags`)
const json = await response.json()
enabled = json.multiTenancy
} catch (err) {
// just let it stay disabled
}
admin.update(store => {
store.multiTenancy = enabled
return store
})
return enabled
}
function unload() {
admin.update(store => {
store.loaded = false
return store
})
}
return { return {
subscribe, subscribe: admin.subscribe,
init, init,
unload,
} }
} }

View File

@ -1,6 +1,7 @@
import { writable } from "svelte/store" import { writable } from "svelte/store"
import { get } from "builderStore/api" import { get } from "builderStore/api"
import { AppStatus } from "../../constants" import { AppStatus } from "../../constants"
import api from "../../builderStore/api"
export function createAppStore() { export function createAppStore() {
const store = writable([]) const store = writable([])
@ -53,9 +54,29 @@ export function createAppStore() {
} }
} }
async function update(appId, name) {
const response = await api.put(`/api/applications/${appId}`, { name })
if (response.status === 200) {
store.update(state => {
const updatedAppIndex = state.findIndex(
app => app.instance._id === appId
)
if (updatedAppIndex !== -1) {
const updatedApp = state[updatedAppIndex]
updatedApp.name = name
state.apps = state.splice(updatedAppIndex, 1, updatedApp)
}
return state
})
} else {
throw new Error("Error updating name")
}
}
return { return {
subscribe: store.subscribe, subscribe: store.subscribe,
load, load,
update,
} }
} }

Some files were not shown because too many files have changed in this diff Show More