backmerge from master

This commit is contained in:
Martin McKeaveney 2023-09-27 17:06:57 +01:00
commit 65af2ed7c2
26 changed files with 193 additions and 49 deletions

View File

@ -1,5 +1,5 @@
{ {
"version": "2.10.12-alpha.26", "version": "2.10.15",
"npmClient": "yarn", "npmClient": "yarn",
"packages": [ "packages": [
"packages/*" "packages/*"

View File

@ -18,7 +18,7 @@ export enum ViewName {
ROUTING = "screen_routes", ROUTING = "screen_routes",
AUTOMATION_LOGS = "automation_logs", AUTOMATION_LOGS = "automation_logs",
ACCOUNT_BY_EMAIL = "account_by_email", ACCOUNT_BY_EMAIL = "account_by_email",
PLATFORM_USERS_LOWERCASE = "platform_users_lowercase", PLATFORM_USERS_LOWERCASE = "platform_users_lowercase_2",
USER_BY_GROUP = "user_by_group", USER_BY_GROUP = "user_by_group",
APP_BACKUP_BY_TRIGGER = "by_trigger", APP_BACKUP_BY_TRIGGER = "by_trigger",
} }

View File

@ -190,6 +190,10 @@ export const createPlatformUserView = async () => {
if (doc.tenantId) { if (doc.tenantId) {
emit(doc._id.toLowerCase(), doc._id) emit(doc._id.toLowerCase(), doc._id)
} }
if (doc.ssoId) {
emit(doc.ssoId, doc._id)
}
}` }`
await createPlatformView(viewJs, ViewName.PLATFORM_USERS_LOWERCASE) await createPlatformView(viewJs, ViewName.PLATFORM_USERS_LOWERCASE)
} }

View File

@ -5,6 +5,7 @@ import {
PlatformUser, PlatformUser,
PlatformUserByEmail, PlatformUserByEmail,
PlatformUserById, PlatformUserById,
PlatformUserBySsoId,
User, User,
} from "@budibase/types" } from "@budibase/types"
@ -45,6 +46,20 @@ function newUserEmailDoc(
} }
} }
function newUserSsoIdDoc(
ssoId: string,
email: string,
userId: string,
tenantId: string
): PlatformUserBySsoId {
return {
_id: ssoId,
userId,
email,
tenantId,
}
}
/** /**
* Add a new user id or email doc if it doesn't exist. * Add a new user id or email doc if it doesn't exist.
*/ */
@ -64,11 +79,24 @@ async function addUserDoc(emailOrId: string, newDocFn: () => PlatformUser) {
} }
} }
export async function addUser(tenantId: string, userId: string, email: string) { export async function addUser(
await Promise.all([ tenantId: string,
userId: string,
email: string,
ssoId?: string
) {
const promises = [
addUserDoc(userId, () => newUserIdDoc(userId, tenantId)), addUserDoc(userId, () => newUserIdDoc(userId, tenantId)),
addUserDoc(email, () => newUserEmailDoc(userId, email, tenantId)), addUserDoc(email, () => newUserEmailDoc(userId, email, tenantId)),
]) ]
if (ssoId) {
promises.push(
addUserDoc(ssoId, () => newUserSsoIdDoc(ssoId, email, userId, tenantId))
)
}
await Promise.all(promises)
} }
// DELETE // DELETE

View File

@ -278,7 +278,12 @@ export class UserDB {
builtUser._rev = response.rev builtUser._rev = response.rev
await eventHelpers.handleSaveEvents(builtUser, dbUser) await eventHelpers.handleSaveEvents(builtUser, dbUser)
await platform.users.addUser(tenantId, builtUser._id!, builtUser.email) await platform.users.addUser(
tenantId,
builtUser._id!,
builtUser.email,
builtUser.ssoId
)
await cache.user.invalidateUser(response.id) await cache.user.invalidateUser(response.id)
await Promise.all(groupPromises) await Promise.all(groupPromises)

View File

@ -1,4 +1,4 @@
import { generator, uuid, quotas } from "." import { generator, quotas, uuid } from "."
import { generateGlobalUserID } from "../../../../src/docIds" import { generateGlobalUserID } from "../../../../src/docIds"
import { import {
Account, Account,
@ -6,10 +6,11 @@ import {
AccountSSOProviderType, AccountSSOProviderType,
AuthType, AuthType,
CloudAccount, CloudAccount,
Hosting,
SSOAccount,
CreateAccount, CreateAccount,
CreatePassswordAccount, CreatePassswordAccount,
CreateVerifiableSSOAccount,
Hosting,
SSOAccount,
} from "@budibase/types" } from "@budibase/types"
import sample from "lodash/sample" import sample from "lodash/sample"
@ -68,6 +69,23 @@ export function ssoAccount(account: Account = cloudAccount()): SSOAccount {
} }
} }
export function verifiableSsoAccount(
account: Account = cloudAccount()
): SSOAccount {
return {
...account,
authType: AuthType.SSO,
oauth2: {
accessToken: generator.string(),
refreshToken: generator.string(),
},
pictureUrl: generator.url(),
provider: AccountSSOProvider.MICROSOFT,
providerType: AccountSSOProviderType.MICROSOFT,
thirdPartyProfile: { id: "abc123" },
}
}
export const cloudCreateAccount: CreatePassswordAccount = { export const cloudCreateAccount: CreatePassswordAccount = {
email: "cloud@budibase.com", email: "cloud@budibase.com",
tenantId: "cloud", tenantId: "cloud",
@ -91,6 +109,19 @@ export const cloudSSOCreateAccount: CreateAccount = {
profession: "Software Engineer", profession: "Software Engineer",
} }
export const cloudVerifiableSSOCreateAccount: CreateVerifiableSSOAccount = {
email: "cloud-sso@budibase.com",
tenantId: "cloud-sso",
hosting: Hosting.CLOUD,
authType: AuthType.SSO,
tenantName: "cloudsso",
name: "Budi Armstrong",
size: "10+",
profession: "Software Engineer",
provider: AccountSSOProvider.MICROSOFT,
thirdPartyProfile: { id: "abc123" },
}
export const selfCreateAccount: CreatePassswordAccount = { export const selfCreateAccount: CreatePassswordAccount = {
email: "self@budibase.com", email: "self@budibase.com",
tenantId: "self", tenantId: "self",

View File

@ -33,6 +33,8 @@ const generateTableBlock = datasource => {
showTitleButton: true, showTitleButton: true,
titleButtonText: "Create row", titleButtonText: "Create row",
titleButtonClickBehaviour: "new", titleButtonClickBehaviour: "new",
sidePanelSaveLabel: "Save",
sidePanelDeleteLabel: "Delete",
}) })
.instanceName(`${datasource.label} - Table block`) .instanceName(`${datasource.label} - Table block`)
return tableBlock return tableBlock

View File

@ -47,28 +47,29 @@
<style> <style>
div { div {
display: grid; display: grid;
grid-gap: 16px; --gap: 16px;
grid-gap: var(--gap);
} }
.mainSidebar { .mainSidebar {
grid-template-columns: 3fr 1fr; grid-template-columns:
calc((100% - var(--gap)) / 4 * 3) /* 75% */
calc((100% - var(--gap)) / 4); /* 25% */
} }
.sidebarMain { .sidebarMain {
grid-template-columns: 1fr 3fr; grid-template-columns:
} calc((100% - var(--gap)) / 4) /* 25% */
.oneColumn { calc((100% - var(--gap)) / 4 * 3); /* 75% */
grid-template-columns: 1fr;
}
.twoColumns {
grid-template-columns: 1fr 1fr;
}
.threeColumns {
grid-template-columns: 1fr 1fr 1fr;
} }
.oneColumn,
.columns-1 { .columns-1 {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.twoColumns,
.columns-2 { .columns-2 {
grid-template-columns: 1fr 1fr; grid-template-columns: repeat(2, calc((100% - var(--gap)) / 2));
}
.threeColumns {
grid-template-columns: repeat(3, calc((100% - var(--gap)) / 3));
} }
.placeholder { .placeholder {
border: 2px dashed var(--spectrum-global-color-gray-600); border: 2px dashed var(--spectrum-global-color-gray-600);

View File

@ -45,8 +45,21 @@
let enrichedSearchColumns let enrichedSearchColumns
let schemaLoaded = false let schemaLoaded = false
// Accommodate old config to ensure delete button does not reappear $: deleteLabel = setDeleteLabel(sidePanelDeleteLabel, sidePanelShowDelete)
$: deleteLabel = sidePanelShowDelete === false ? "" : sidePanelDeleteLabel
const setDeleteLabel = sidePanelDeleteLabel => {
// Accommodate old config to ensure delete button does not reappear
let labelText = sidePanelShowDelete === false ? "" : sidePanelDeleteLabel
// Empty text is considered hidden.
if (labelText?.trim() === "") {
return ""
}
// Default to "Delete" if the value is unset
return labelText || "Delete"
}
$: isDSPlus = dataSource?.type === "table" || dataSource?.type === "viewV2" $: isDSPlus = dataSource?.type === "table" || dataSource?.type === "viewV2"
$: fetchSchema(dataSource) $: fetchSchema(dataSource)
$: enrichSearchColumns(searchColumns, schema).then( $: enrichSearchColumns(searchColumns, schema).then(
@ -249,7 +262,7 @@
props={{ props={{
dataSource, dataSource,
saveButtonLabel: sidePanelSaveLabel || "Save", //always show saveButtonLabel: sidePanelSaveLabel || "Save", //always show
deleteButtonLabel: deleteLabel, //respect config deleteButtonLabel: deleteLabel,
actionType: "Update", actionType: "Update",
rowId: `{{ ${safe("state")}.${safe(stateKey)} }}`, rowId: `{{ ${safe("state")}.${safe(stateKey)} }}`,
fields: sidePanelFields || normalFields, fields: sidePanelFields || normalFields,

View File

@ -1567,8 +1567,7 @@
} }
}, },
"required": [ "required": [
"email", "email"
"roles"
] ]
}, },
"userOutput": { "userOutput": {
@ -1639,7 +1638,6 @@
}, },
"required": [ "required": [
"email", "email",
"roles",
"_id" "_id"
] ]
} }
@ -1718,7 +1716,6 @@
}, },
"required": [ "required": [
"email", "email",
"roles",
"_id" "_id"
] ]
} }

View File

@ -1337,7 +1337,6 @@ components:
role ID, e.g. ADMIN. role ID, e.g. ADMIN.
required: required:
- email - email
- roles
userOutput: userOutput:
type: object type: object
properties: properties:
@ -1398,7 +1397,6 @@ components:
type: string type: string
required: required:
- email - email
- roles
- _id - _id
required: required:
- data - data
@ -1464,7 +1462,6 @@ components:
type: string type: string
required: required:
- email - email
- roles
- _id - _id
required: required:
- data - data

View File

@ -92,7 +92,7 @@ const userSchema = object(
}, },
}, },
}, },
{ required: ["email", "roles"] } { required: ["email"] }
) )
const userOutputSchema = { const userOutputSchema = {

View File

@ -15,10 +15,15 @@ function user(body: any): User {
} }
} }
function mapUser(ctx: any): { data: User } { function mapUser(ctx: any) {
return { const body: { data: User; message?: string } = {
data: user(ctx.body), data: user(ctx.body),
} }
if (ctx.extra?.message) {
body.message = ctx.extra.message
delete ctx.extra
}
return body
} }
function mapUsers(ctx: any): { data: User[] } { function mapUsers(ctx: any): { data: User[] } {

View File

@ -10,6 +10,32 @@ import { search as stringSearch } from "./utils"
import { UserCtx, User } from "@budibase/types" import { UserCtx, User } from "@budibase/types"
import { Next } from "koa" import { Next } from "koa"
import { sdk } from "@budibase/pro" import { sdk } from "@budibase/pro"
import { isEqual, cloneDeep } from "lodash"
function rolesRemoved(base: User, ctx: UserCtx) {
return (
!isEqual(base.builder, ctx.request.body.builder) ||
!isEqual(base.admin, ctx.request.body.admin) ||
!isEqual(base.roles, ctx.request.body.roles)
)
}
const NO_ROLES_MSG =
"Roles/admin/builder can only be set on business/enterprise licenses - input ignored."
async function createUpdateResponse(ctx: UserCtx, user?: User) {
const base = cloneDeep(ctx.request.body)
ctx = await sdk.publicApi.users.roleCheck(ctx, user)
// check the ctx before any updates to it
const removed = rolesRemoved(base, ctx)
ctx = publicApiUserFix(ctx)
const response = await saveGlobalUser(ctx)
ctx.body = await getUser(ctx, response._id)
if (removed) {
ctx.extra = { message: NO_ROLES_MSG }
}
return ctx
}
function isLoggedInUser(ctx: UserCtx, user: User) { function isLoggedInUser(ctx: UserCtx, user: User) {
const loggedInId = ctx.user?._id const loggedInId = ctx.user?._id
@ -35,9 +61,7 @@ export async function search(ctx: UserCtx, next: Next) {
} }
export async function create(ctx: UserCtx, next: Next) { export async function create(ctx: UserCtx, next: Next) {
ctx = publicApiUserFix(await sdk.publicApi.users.roleCheck(ctx)) await createUpdateResponse(ctx)
const response = await saveGlobalUser(ctx)
ctx.body = await getUser(ctx, response._id)
await next() await next()
} }
@ -52,9 +76,7 @@ export async function update(ctx: UserCtx, next: Next) {
...ctx.request.body, ...ctx.request.body,
_rev: user._rev, _rev: user._rev,
} }
ctx = publicApiUserFix(await sdk.publicApi.users.roleCheck(ctx, user)) await createUpdateResponse(ctx, user)
const response = await saveGlobalUser(ctx)
ctx.body = await getUser(ctx, response._id)
await next() await next()
} }

View File

@ -68,6 +68,7 @@ describe("no user role update in free", () => {
}) })
expect(res.status).toBe(200) expect(res.status).toBe(200)
expect(res.body.data.roles["app_a"]).toBeUndefined() expect(res.body.data.roles["app_a"]).toBeUndefined()
expect(res.body.message).toBeDefined()
}) })
it("should not allow 'admin' to be updated", async () => { it("should not allow 'admin' to be updated", async () => {
@ -77,6 +78,7 @@ describe("no user role update in free", () => {
}) })
expect(res.status).toBe(200) expect(res.status).toBe(200)
expect(res.body.data.admin).toBeUndefined() expect(res.body.data.admin).toBeUndefined()
expect(res.body.message).toBeDefined()
}) })
it("should not allow 'builder' to be updated", async () => { it("should not allow 'builder' to be updated", async () => {
@ -86,6 +88,7 @@ describe("no user role update in free", () => {
}) })
expect(res.status).toBe(200) expect(res.status).toBe(200)
expect(res.body.data.builder).toBeUndefined() expect(res.body.data.builder).toBeUndefined()
expect(res.body.message).toBeDefined()
}) })
}) })
@ -102,6 +105,7 @@ describe("no user role update in business", () => {
}) })
expect(res.status).toBe(200) expect(res.status).toBe(200)
expect(res.body.data.roles["app_a"]).toBe("BASIC") expect(res.body.data.roles["app_a"]).toBe("BASIC")
expect(res.body.message).toBeUndefined()
}) })
it("should allow 'admin' to be updated", async () => { it("should allow 'admin' to be updated", async () => {
@ -112,6 +116,7 @@ describe("no user role update in business", () => {
}) })
expect(res.status).toBe(200) expect(res.status).toBe(200)
expect(res.body.data.admin.global).toBe(true) expect(res.body.data.admin.global).toBe(true)
expect(res.body.message).toBeUndefined()
}) })
it("should allow 'builder' to be updated", async () => { it("should allow 'builder' to be updated", async () => {
@ -122,5 +127,6 @@ describe("no user role update in business", () => {
}) })
expect(res.status).toBe(200) expect(res.status).toBe(200)
expect(res.body.data.builder.global).toBe(true) expect(res.body.data.builder.global).toBe(true)
expect(res.body.message).toBeUndefined()
}) })
}) })

View File

@ -613,7 +613,7 @@ export interface components {
global?: boolean; global?: boolean;
}; };
/** @description Contains the roles of the user per app (assuming they are not a builder user). This field can only be set on a business or enterprise license. */ /** @description Contains the roles of the user per app (assuming they are not a builder user). This field can only be set on a business or enterprise license. */
roles: { [key: string]: string }; roles?: { [key: string]: string };
}; };
userOutput: { userOutput: {
data: { data: {
@ -643,7 +643,7 @@ export interface components {
global?: boolean; global?: boolean;
}; };
/** @description Contains the roles of the user per app (assuming they are not a builder user). This field can only be set on a business or enterprise license. */ /** @description Contains the roles of the user per app (assuming they are not a builder user). This field can only be set on a business or enterprise license. */
roles: { [key: string]: string }; roles?: { [key: string]: string };
/** @description The ID of the user. */ /** @description The ID of the user. */
_id: string; _id: string;
}; };
@ -676,7 +676,7 @@ export interface components {
global?: boolean; global?: boolean;
}; };
/** @description Contains the roles of the user per app (assuming they are not a builder user). This field can only be set on a business or enterprise license. */ /** @description Contains the roles of the user per app (assuming they are not a builder user). This field can only be set on a business or enterprise license. */
roles: { [key: string]: string }; roles?: { [key: string]: string };
/** @description The ID of the user. */ /** @description The ID of the user. */
_id: string; _id: string;
}[]; }[];

View File

@ -36,5 +36,8 @@ export function publicApiUserFix(ctx: UserCtx) {
if (!ctx.request.body._id && ctx.params.userId) { if (!ctx.request.body._id && ctx.params.userId) {
ctx.request.body._id = ctx.params.userId ctx.request.body._id = ctx.params.userId
} }
if (!ctx.request.body.roles) {
ctx.request.body.roles = {}
}
return ctx return ctx
} }

View File

@ -1,4 +1,4 @@
import { Account } from "../../documents" import { Account, AccountSSOProvider } from "../../documents"
import { Hosting } from "../../sdk" import { Hosting } from "../../sdk"
export interface CreateAccountRequest { export interface CreateAccountRequest {
@ -11,6 +11,8 @@ export interface CreateAccountRequest {
tenantName?: string tenantName?: string
name?: string name?: string
password: string password: string
provider?: AccountSSOProvider
thirdPartyProfile: object
} }
export interface SearchAccountsRequest { export interface SearchAccountsRequest {

View File

@ -61,6 +61,7 @@ export interface CreateAdminUserRequest {
email: string email: string
password: string password: string
tenantId: string tenantId: string
ssoId?: string
} }
export interface CreateAdminUserResponse { export interface CreateAdminUserResponse {

View File

@ -20,6 +20,11 @@ export interface CreatePassswordAccount extends CreateAccount {
password: string password: string
} }
export interface CreateVerifiableSSOAccount extends CreateAccount {
provider?: AccountSSOProvider
thirdPartyProfile?: any
}
export const isCreatePasswordAccount = ( export const isCreatePasswordAccount = (
account: CreateAccount account: CreateAccount
): account is CreatePassswordAccount => account.authType === AuthType.PASSWORD ): account is CreatePassswordAccount => account.authType === AuthType.PASSWORD
@ -50,6 +55,8 @@ export interface Account extends CreateAccount {
licenseKeyActivatedAt?: number licenseKeyActivatedAt?: number
licenseRequestedAt?: number licenseRequestedAt?: number
licenseOverrides?: LicenseOverrides licenseOverrides?: LicenseOverrides
provider?: AccountSSOProvider
providerType?: AccountSSOProviderType
quotaUsage?: QuotaUsage quotaUsage?: QuotaUsage
offlineLicenseToken?: string offlineLicenseToken?: string
} }
@ -87,6 +94,13 @@ export enum AccountSSOProvider {
MICROSOFT = "microsoft", MICROSOFT = "microsoft",
} }
const verifiableSSOProviders: AccountSSOProvider[] = [
AccountSSOProvider.MICROSOFT,
]
export function isVerifiableSSOProvider(provider: AccountSSOProvider): boolean {
return verifiableSSOProviders.includes(provider)
}
export interface AccountSSO { export interface AccountSSO {
provider: AccountSSOProvider provider: AccountSSOProvider
providerType: AccountSSOProviderType providerType: AccountSSOProviderType

View File

@ -55,6 +55,7 @@ export interface User extends Document {
userGroups?: string[] userGroups?: string[]
onboardedAt?: string onboardedAt?: string
scimInfo?: { isSync: true } & Record<string, any> scimInfo?: { isSync: true } & Record<string, any>
ssoId?: string
} }
export enum UserStatus { export enum UserStatus {

View File

@ -15,4 +15,16 @@ export interface PlatformUserById extends Document {
tenantId: string tenantId: string
} }
export type PlatformUser = PlatformUserByEmail | PlatformUserById /**
* doc id is a unique SSO provider ID for the user
*/
export interface PlatformUserBySsoId extends Document {
tenantId: string
userId: string
email: string
}
export type PlatformUser =
| PlatformUserByEmail
| PlatformUserById
| PlatformUserBySsoId

View File

@ -95,7 +95,7 @@ const parseBooleanParam = (param: any) => {
export const adminUser = async ( export const adminUser = async (
ctx: Ctx<CreateAdminUserRequest, CreateAdminUserResponse> ctx: Ctx<CreateAdminUserRequest, CreateAdminUserResponse>
) => { ) => {
const { email, password, tenantId } = ctx.request.body const { email, password, tenantId, ssoId } = ctx.request.body
if (await platform.tenants.exists(tenantId)) { if (await platform.tenants.exists(tenantId)) {
ctx.throw(403, "Organisation already exists.") ctx.throw(403, "Organisation already exists.")
@ -136,6 +136,7 @@ export const adminUser = async (
global: true, global: true,
}, },
tenantId, tenantId,
ssoId,
} }
try { try {
// always bust checklist beforehand, if an error occurs but can proceed, don't get // always bust checklist beforehand, if an error occurs but can proceed, don't get

View File

@ -14,6 +14,7 @@ function buildAdminInitValidation() {
email: Joi.string().required(), email: Joi.string().required(),
password: Joi.string(), password: Joi.string(),
tenantId: Joi.string().required(), tenantId: Joi.string().required(),
ssoId: Joi.string(),
}) })
.required() .required()
.unknown(false) .unknown(false)

View File

@ -1 +0,0 @@
../packages/server/specs/openapi.json

View File

@ -1 +0,0 @@
../packages/server/specs/openapi.yaml