The UI should indicate who the account holder is (#14470)
* Get tenantInfo in user fetch * Add account holder label in users table * Don't allow account holder to be selected in users table * Sort account holder to top of list * Only use account holder role in users table * lint * Remove joi validation from tenant-info endpoint * Remove dayPasses * Catch CouchDB 404 and return undefined * Don't allow account holder role to be changed UI * Don't offer delete option for tenant owner * Backend validation to ensure account holder role cannot be updated * Don't allow account holder role to be changed UI * Get tenantOwner in separate call * Pass data into SelectEditRenderer * Rename var to __selectable * setEnrichedUsers * Update pro reference * Only load tenantOwner once
This commit is contained in:
parent
ab0d658930
commit
3f357561d0
|
@ -15,7 +15,15 @@ export async function saveTenantInfo(tenantInfo: TenantInfo) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getTenantInfo(tenantId: string): Promise<TenantInfo> {
|
export async function getTenantInfo(
|
||||||
const db = getTenantDB(tenantId)
|
tenantId: string
|
||||||
return db.get("tenant_info")
|
): Promise<TenantInfo | undefined> {
|
||||||
|
try {
|
||||||
|
const db = getTenantDB(tenantId)
|
||||||
|
const tenantInfo = (await db.get("tenant_info")) as TenantInfo
|
||||||
|
delete tenantInfo.owner.password
|
||||||
|
return tenantInfo
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,10 +6,11 @@
|
||||||
export let onEdit
|
export let onEdit
|
||||||
export let allowSelectRows = false
|
export let allowSelectRows = false
|
||||||
export let allowEditRows = false
|
export let allowEditRows = false
|
||||||
|
export let data
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
{#if allowSelectRows}
|
{#if allowSelectRows && data.__selectable !== false}
|
||||||
<Checkbox value={selected} />
|
<Checkbox value={selected} />
|
||||||
{/if}
|
{/if}
|
||||||
{#if allowEditRows}
|
{#if allowEditRows}
|
||||||
|
|
|
@ -43,6 +43,8 @@
|
||||||
export let showHeaderBorder = true
|
export let showHeaderBorder = true
|
||||||
export let placeholderText = "No rows found"
|
export let placeholderText = "No rows found"
|
||||||
export let snippets = []
|
export let snippets = []
|
||||||
|
export let defaultSortColumn
|
||||||
|
export let defaultSortOrder = "Ascending"
|
||||||
|
|
||||||
const dispatch = createEventDispatcher()
|
const dispatch = createEventDispatcher()
|
||||||
|
|
||||||
|
@ -162,6 +164,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
const sortRows = (rows, sortColumn, sortOrder) => {
|
const sortRows = (rows, sortColumn, sortOrder) => {
|
||||||
|
sortColumn = sortColumn ?? defaultSortColumn
|
||||||
|
sortOrder = sortOrder ?? defaultSortOrder
|
||||||
if (!sortColumn || !sortOrder || disableSorting) {
|
if (!sortColumn || !sortOrder || disableSorting) {
|
||||||
return rows
|
return rows
|
||||||
}
|
}
|
||||||
|
@ -259,7 +263,10 @@
|
||||||
if (select) {
|
if (select) {
|
||||||
// Add any rows which are not already in selected rows
|
// Add any rows which are not already in selected rows
|
||||||
rows.forEach(row => {
|
rows.forEach(row => {
|
||||||
if (selectedRows.findIndex(x => x._id === row._id) === -1) {
|
if (
|
||||||
|
row.__selectable !== false &&
|
||||||
|
selectedRows.findIndex(x => x._id === row._id) === -1
|
||||||
|
) {
|
||||||
selectedRows.push(row)
|
selectedRows.push(row)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
@ -396,6 +403,9 @@
|
||||||
class:noBorderCheckbox={!showHeaderBorder}
|
class:noBorderCheckbox={!showHeaderBorder}
|
||||||
class="spectrum-Table-cell spectrum-Table-cell--divider spectrum-Table-cell--edit"
|
class="spectrum-Table-cell spectrum-Table-cell--divider spectrum-Table-cell--edit"
|
||||||
on:click={e => {
|
on:click={e => {
|
||||||
|
if (row.__selectable === false) {
|
||||||
|
return
|
||||||
|
}
|
||||||
toggleSelectRow(row)
|
toggleSelectRow(row)
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
}}
|
}}
|
||||||
|
|
|
@ -85,7 +85,7 @@
|
||||||
let popoverAnchor
|
let popoverAnchor
|
||||||
let searchTerm = ""
|
let searchTerm = ""
|
||||||
let popover
|
let popover
|
||||||
let user
|
let user, tenantOwner
|
||||||
let loaded = false
|
let loaded = false
|
||||||
|
|
||||||
$: internalGroups = $groups?.filter(g => !g?.scimInfo?.isSync)
|
$: internalGroups = $groups?.filter(g => !g?.scimInfo?.isSync)
|
||||||
|
@ -104,6 +104,7 @@
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
$: globalRole = users.getUserRole(user)
|
$: globalRole = users.getUserRole(user)
|
||||||
|
$: isTenantOwner = tenantOwner?.email && tenantOwner.email === user?.email
|
||||||
|
|
||||||
const getAvailableApps = (appList, privileged, roles) => {
|
const getAvailableApps = (appList, privileged, roles) => {
|
||||||
let availableApps = appList.slice()
|
let availableApps = appList.slice()
|
||||||
|
@ -205,6 +206,7 @@
|
||||||
if (!user?._id) {
|
if (!user?._id) {
|
||||||
$goto("./")
|
$goto("./")
|
||||||
}
|
}
|
||||||
|
tenantOwner = await users.tenantOwner($auth.tenantId)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleFlags(detail) {
|
async function toggleFlags(detail) {
|
||||||
|
@ -268,9 +270,11 @@
|
||||||
Force password reset
|
Force password reset
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
{/if}
|
{/if}
|
||||||
<MenuItem on:click={deleteModal.show} icon="Delete">
|
{#if !isTenantOwner}
|
||||||
Delete
|
<MenuItem on:click={deleteModal.show} icon="Delete">
|
||||||
</MenuItem>
|
Delete
|
||||||
|
</MenuItem>
|
||||||
|
{/if}
|
||||||
</ActionMenu>
|
</ActionMenu>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
@ -310,9 +314,11 @@
|
||||||
<Label size="L">Role</Label>
|
<Label size="L">Role</Label>
|
||||||
<Select
|
<Select
|
||||||
placeholder={null}
|
placeholder={null}
|
||||||
disabled={!sdk.users.isAdmin($auth.user)}
|
disabled={!sdk.users.isAdmin($auth.user) || isTenantOwner}
|
||||||
value={globalRole}
|
value={isTenantOwner ? "owner" : globalRole}
|
||||||
options={Constants.BudibaseRoleOptions}
|
options={isTenantOwner
|
||||||
|
? Constants.ExtendedBudibaseRoleOptions
|
||||||
|
: Constants.BudibaseRoleOptions}
|
||||||
on:change={updateUserRole}
|
on:change={updateUserRole}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
export let row
|
export let row
|
||||||
|
|
||||||
$: role = Constants.BudibaseRoleOptions.find(
|
$: role = Constants.ExtendedBudibaseRoleOptions.find(
|
||||||
x => x.value === users.getUserRole(row)
|
x => x.value === users.getUserRole(row)
|
||||||
)
|
)
|
||||||
$: value = role?.label || "Not available"
|
$: value = role?.label || "Not available"
|
||||||
|
|
|
@ -52,6 +52,7 @@
|
||||||
|
|
||||||
let groupsLoaded = !$licensing.groupsEnabled || $groups?.length
|
let groupsLoaded = !$licensing.groupsEnabled || $groups?.length
|
||||||
let enrichedUsers = []
|
let enrichedUsers = []
|
||||||
|
let tenantOwner
|
||||||
let createUserModal,
|
let createUserModal,
|
||||||
inviteConfirmationModal,
|
inviteConfirmationModal,
|
||||||
onboardingTypeModal,
|
onboardingTypeModal,
|
||||||
|
@ -70,6 +71,7 @@
|
||||||
]
|
]
|
||||||
let userData = []
|
let userData = []
|
||||||
let invitesLoaded = false
|
let invitesLoaded = false
|
||||||
|
let tenantOwnerLoaded = false
|
||||||
let pendingInvites = []
|
let pendingInvites = []
|
||||||
let parsedInvites = []
|
let parsedInvites = []
|
||||||
|
|
||||||
|
@ -98,8 +100,14 @@
|
||||||
$: pendingSchema = getPendingSchema(schema)
|
$: pendingSchema = getPendingSchema(schema)
|
||||||
$: userData = []
|
$: userData = []
|
||||||
$: inviteUsersResponse = { successful: [], unsuccessful: [] }
|
$: inviteUsersResponse = { successful: [], unsuccessful: [] }
|
||||||
$: {
|
$: setEnrichedUsers($fetch.rows)
|
||||||
enrichedUsers = $fetch.rows?.map(user => {
|
|
||||||
|
const setEnrichedUsers = async rows => {
|
||||||
|
if (!tenantOwnerLoaded) {
|
||||||
|
enrichedUsers = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
enrichedUsers = rows?.map(user => {
|
||||||
let userGroups = []
|
let userGroups = []
|
||||||
$groups.forEach(group => {
|
$groups.forEach(group => {
|
||||||
if (group.users) {
|
if (group.users) {
|
||||||
|
@ -110,15 +118,21 @@
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
user.tenantOwnerEmail = tenantOwner?.email
|
||||||
|
const role = Constants.ExtendedBudibaseRoleOptions.find(
|
||||||
|
x => x.value === users.getUserRole(user)
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
...user,
|
...user,
|
||||||
name: user.firstName ? user.firstName + " " + user.lastName : "",
|
name: user.firstName ? user.firstName + " " + user.lastName : "",
|
||||||
userGroups,
|
userGroups,
|
||||||
|
__selectable:
|
||||||
|
role.value === Constants.BudibaseRoles.Owner ? false : undefined,
|
||||||
apps: [...new Set(Object.keys(user.roles))],
|
apps: [...new Set(Object.keys(user.roles))],
|
||||||
|
access: role.sortOrder,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const getPendingSchema = tblSchema => {
|
const getPendingSchema = tblSchema => {
|
||||||
if (!tblSchema) {
|
if (!tblSchema) {
|
||||||
return {}
|
return {}
|
||||||
|
@ -302,6 +316,8 @@
|
||||||
groupsLoaded = true
|
groupsLoaded = true
|
||||||
pendingInvites = await users.getInvites()
|
pendingInvites = await users.getInvites()
|
||||||
invitesLoaded = true
|
invitesLoaded = true
|
||||||
|
tenantOwner = await users.tenantOwner($auth.tenantId)
|
||||||
|
tenantOwnerLoaded = true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
notifications.error("Error fetching user group data")
|
notifications.error("Error fetching user group data")
|
||||||
}
|
}
|
||||||
|
@ -376,6 +392,7 @@
|
||||||
allowSelectRows={!readonly}
|
allowSelectRows={!readonly}
|
||||||
{customRenderers}
|
{customRenderers}
|
||||||
loading={!$fetch.loaded || !groupsLoaded}
|
loading={!$fetch.loaded || !groupsLoaded}
|
||||||
|
defaultSortColumn={"access"}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="pagination">
|
<div class="pagination">
|
||||||
|
|
|
@ -198,7 +198,7 @@ export const createLicensingStore = () => {
|
||||||
}, {})
|
}, {})
|
||||||
}
|
}
|
||||||
const monthlyMetrics = getMetrics(
|
const monthlyMetrics = getMetrics(
|
||||||
["dayPasses", "queries", "automations"],
|
["queries", "automations"],
|
||||||
license.quotas.usage.monthly,
|
license.quotas.usage.monthly,
|
||||||
usage.monthly.current
|
usage.monthly.current
|
||||||
)
|
)
|
||||||
|
|
|
@ -128,8 +128,15 @@ export function createUsersStore() {
|
||||||
return await API.removeAppBuilder({ userId, appId })
|
return await API.removeAppBuilder({ userId, appId })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getTenantOwner(tenantId) {
|
||||||
|
const tenantInfo = await API.getTenantInfo({ tenantId })
|
||||||
|
return tenantInfo?.owner
|
||||||
|
}
|
||||||
|
|
||||||
const getUserRole = user => {
|
const getUserRole = user => {
|
||||||
if (sdk.users.isAdmin(user)) {
|
if (user && user.email === user.tenantOwnerEmail) {
|
||||||
|
return Constants.BudibaseRoles.Owner
|
||||||
|
} else if (sdk.users.isAdmin(user)) {
|
||||||
return Constants.BudibaseRoles.Admin
|
return Constants.BudibaseRoles.Admin
|
||||||
} else if (sdk.users.isBuilder(user)) {
|
} else if (sdk.users.isBuilder(user)) {
|
||||||
return Constants.BudibaseRoles.Developer
|
return Constants.BudibaseRoles.Developer
|
||||||
|
@ -169,6 +176,7 @@ export function createUsersStore() {
|
||||||
save: refreshUsage(save),
|
save: refreshUsage(save),
|
||||||
bulkDelete: refreshUsage(bulkDelete),
|
bulkDelete: refreshUsage(bulkDelete),
|
||||||
delete: refreshUsage(del),
|
delete: refreshUsage(del),
|
||||||
|
tenantOwner: getTenantOwner,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -295,4 +295,10 @@ export const buildUserEndpoints = API => ({
|
||||||
url: `/api/global/users/${userId}/app/${appId}/builder`,
|
url: `/api/global/users/${userId}/app/${appId}/builder`,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getTenantInfo: async ({ tenantId }) => {
|
||||||
|
return await API.get({
|
||||||
|
url: `/api/global/tenant/${tenantId}`,
|
||||||
|
})
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
|
@ -41,6 +41,7 @@ export const BudibaseRoles = {
|
||||||
Developer: "developer",
|
Developer: "developer",
|
||||||
Creator: "creator",
|
Creator: "creator",
|
||||||
Admin: "admin",
|
Admin: "admin",
|
||||||
|
Owner: "owner",
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BudibaseRoleOptionsOld = [
|
export const BudibaseRoleOptionsOld = [
|
||||||
|
@ -54,18 +55,28 @@ export const BudibaseRoleOptions = [
|
||||||
label: "Account admin",
|
label: "Account admin",
|
||||||
value: BudibaseRoles.Admin,
|
value: BudibaseRoles.Admin,
|
||||||
subtitle: "Has full access to all apps and settings in your account",
|
subtitle: "Has full access to all apps and settings in your account",
|
||||||
|
sortOrder: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Creator",
|
label: "Creator",
|
||||||
value: BudibaseRoles.Creator,
|
value: BudibaseRoles.Creator,
|
||||||
subtitle: "Can create and edit apps they have access to",
|
subtitle: "Can create and edit apps they have access to",
|
||||||
|
sortOrder: 2,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "App user",
|
label: "App user",
|
||||||
value: BudibaseRoles.AppUser,
|
value: BudibaseRoles.AppUser,
|
||||||
subtitle: "Can only use published apps they have access to",
|
subtitle: "Can only use published apps they have access to",
|
||||||
|
sortOrder: 3,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
export const ExtendedBudibaseRoleOptions = [
|
||||||
|
{
|
||||||
|
label: "Account holder",
|
||||||
|
value: BudibaseRoles.Owner,
|
||||||
|
sortOrder: 0,
|
||||||
|
},
|
||||||
|
].concat(BudibaseRoleOptions)
|
||||||
|
|
||||||
export const PlanType = {
|
export const PlanType = {
|
||||||
FREE: "free",
|
FREE: "free",
|
||||||
|
|
|
@ -54,6 +54,17 @@ export const save = async (ctx: UserCtx<User, SaveUserResponse>) => {
|
||||||
const currentUserId = ctx.user?._id
|
const currentUserId = ctx.user?._id
|
||||||
const requestUser = ctx.request.body
|
const requestUser = ctx.request.body
|
||||||
|
|
||||||
|
// Do not allow the account holder role to be changed
|
||||||
|
const tenantInfo = await tenancy.getTenantInfo(requestUser.tenantId)
|
||||||
|
if (tenantInfo?.owner.email === requestUser.email) {
|
||||||
|
if (
|
||||||
|
requestUser.admin?.global !== true ||
|
||||||
|
requestUser.builder?.global !== true
|
||||||
|
) {
|
||||||
|
throw Error("Cannot set role of account holder")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const user = await userSdk.db.save(requestUser, { currentUserId })
|
const user = await userSdk.db.save(requestUser, { currentUserId })
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
|
|
|
@ -1,36 +1,11 @@
|
||||||
import Router from "@koa/router"
|
import Router from "@koa/router"
|
||||||
import Joi from "joi"
|
|
||||||
import { auth } from "@budibase/backend-core"
|
|
||||||
import * as controller from "../../controllers/global/tenant"
|
import * as controller from "../../controllers/global/tenant"
|
||||||
import cloudRestricted from "../../../middleware/cloudRestricted"
|
import cloudRestricted from "../../../middleware/cloudRestricted"
|
||||||
|
|
||||||
const router: Router = new Router()
|
const router: Router = new Router()
|
||||||
const OPTIONAL_STRING = Joi.string().optional().allow(null).allow("")
|
|
||||||
|
|
||||||
function buildTenantInfoValidation() {
|
|
||||||
return auth.joiValidator.body(
|
|
||||||
Joi.object({
|
|
||||||
owner: Joi.object({
|
|
||||||
email: Joi.string().required(),
|
|
||||||
password: OPTIONAL_STRING,
|
|
||||||
ssoId: OPTIONAL_STRING,
|
|
||||||
givenName: OPTIONAL_STRING,
|
|
||||||
familyName: OPTIONAL_STRING,
|
|
||||||
budibaseUserId: OPTIONAL_STRING,
|
|
||||||
}).required(),
|
|
||||||
hosting: Joi.string().required(),
|
|
||||||
tenantId: Joi.string().required(),
|
|
||||||
}).required()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
router
|
router
|
||||||
.post(
|
.post("/api/global/tenant", cloudRestricted, controller.save)
|
||||||
"/api/global/tenant",
|
|
||||||
cloudRestricted,
|
|
||||||
buildTenantInfoValidation(),
|
|
||||||
controller.save
|
|
||||||
)
|
|
||||||
.get("/api/global/tenant/:id", controller.get)
|
.get("/api/global/tenant/:id", controller.get)
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|
|
@ -412,6 +412,28 @@ describe("/api/global/users", () => {
|
||||||
expect(events.user.permissionBuilderRemoved).toHaveBeenCalledTimes(1)
|
expect(events.user.permissionBuilderRemoved).toHaveBeenCalledTimes(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("should not be able to update an account holder user to a basic user", async () => {
|
||||||
|
const accountHolderUser = await config.createUser(
|
||||||
|
structures.users.adminUser()
|
||||||
|
)
|
||||||
|
jest.clearAllMocks()
|
||||||
|
tenancy.getTenantInfo = jest.fn().mockImplementation(() => ({
|
||||||
|
owner: {
|
||||||
|
email: accountHolderUser.email,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
accountHolderUser.admin!.global = false
|
||||||
|
accountHolderUser.builder!.global = false
|
||||||
|
|
||||||
|
await config.api.users.saveUser(accountHolderUser, 400)
|
||||||
|
|
||||||
|
expect(events.user.created).not.toHaveBeenCalled()
|
||||||
|
expect(events.user.updated).not.toHaveBeenCalled()
|
||||||
|
expect(events.user.permissionAdminRemoved).not.toHaveBeenCalled()
|
||||||
|
expect(events.user.permissionBuilderRemoved).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
it("should be able to update an builder user to a basic user", async () => {
|
it("should be able to update an builder user to a basic user", async () => {
|
||||||
const user = await config.createUser(structures.users.builderUser())
|
const user = await config.createUser(structures.users.builderUser())
|
||||||
jest.clearAllMocks()
|
jest.clearAllMocks()
|
||||||
|
|
Loading…
Reference in New Issue