mirror of https://github.com/Budibase/budibase.git
committed by
GitHub
104 changed files with 5896 additions and 1738 deletions
@ -0,0 +1,64 @@ |
|||||
|
import { publishEvent } from "../events" |
||||
|
import { |
||||
|
Event, |
||||
|
UserGroup, |
||||
|
GroupCreatedEvent, |
||||
|
GroupDeletedEvent, |
||||
|
GroupUpdatedEvent, |
||||
|
GroupUsersAddedEvent, |
||||
|
GroupUsersDeletedEvent, |
||||
|
GroupAddedOnboardingEvent, |
||||
|
UserGroupRoles, |
||||
|
} from "@budibase/types" |
||||
|
|
||||
|
export async function created(group: UserGroup, timestamp?: number) { |
||||
|
const properties: GroupCreatedEvent = { |
||||
|
groupId: group._id as string, |
||||
|
} |
||||
|
await publishEvent(Event.USER_GROUP_CREATED, properties, timestamp) |
||||
|
} |
||||
|
|
||||
|
export async function updated(group: UserGroup) { |
||||
|
const properties: GroupUpdatedEvent = { |
||||
|
groupId: group._id as string, |
||||
|
} |
||||
|
await publishEvent(Event.USER_GROUP_UPDATED, properties) |
||||
|
} |
||||
|
|
||||
|
export async function deleted(group: UserGroup) { |
||||
|
const properties: GroupDeletedEvent = { |
||||
|
groupId: group._id as string, |
||||
|
} |
||||
|
await publishEvent(Event.USER_GROUP_DELETED, properties) |
||||
|
} |
||||
|
|
||||
|
export async function usersAdded(count: number, group: UserGroup) { |
||||
|
const properties: GroupUsersAddedEvent = { |
||||
|
count, |
||||
|
groupId: group._id as string, |
||||
|
} |
||||
|
await publishEvent(Event.USER_GROUP_USERS_ADDED, properties) |
||||
|
} |
||||
|
|
||||
|
export async function usersDeleted(emails: string[], group: UserGroup) { |
||||
|
const properties: GroupUsersDeletedEvent = { |
||||
|
count: emails.length, |
||||
|
groupId: group._id as string, |
||||
|
} |
||||
|
await publishEvent(Event.USER_GROUP_USERS_REMOVED, properties) |
||||
|
} |
||||
|
|
||||
|
export async function createdOnboarding(groupId: string) { |
||||
|
const properties: GroupAddedOnboardingEvent = { |
||||
|
groupId: groupId, |
||||
|
onboarding: true, |
||||
|
} |
||||
|
await publishEvent(Event.USER_GROUP_ONBOARDING, properties) |
||||
|
} |
||||
|
|
||||
|
export async function permissionsEdited(roles: UserGroupRoles) { |
||||
|
const properties: UserGroupRoles = { |
||||
|
...roles, |
||||
|
} |
||||
|
await publishEvent(Event.USER_GROUP_PERMISSIONS_EDITED, properties) |
||||
|
} |
||||
@ -0,0 +1,9 @@ |
|||||
|
module.exports = async (ctx, next) => { |
||||
|
if ( |
||||
|
!ctx.internal && |
||||
|
(!ctx.user || !ctx.user.admin || !ctx.user.admin.global) |
||||
|
) { |
||||
|
ctx.throw(403, "Admin user only endpoint.") |
||||
|
} |
||||
|
return next() |
||||
|
} |
||||
@ -0,0 +1,28 @@ |
|||||
|
function validate(schema, property) { |
||||
|
// Return a Koa middleware function
|
||||
|
return (ctx, next) => { |
||||
|
if (!schema) { |
||||
|
return next() |
||||
|
} |
||||
|
let params = null |
||||
|
if (ctx[property] != null) { |
||||
|
params = ctx[property] |
||||
|
} else if (ctx.request[property] != null) { |
||||
|
params = ctx.request[property] |
||||
|
} |
||||
|
const { error } = schema.validate(params) |
||||
|
if (error) { |
||||
|
ctx.throw(400, `Invalid ${property} - ${error.message}`) |
||||
|
return |
||||
|
} |
||||
|
return next() |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
module.exports.body = schema => { |
||||
|
return validate(schema, "body") |
||||
|
} |
||||
|
|
||||
|
module.exports.params = schema => { |
||||
|
return validate(schema, "params") |
||||
|
} |
||||
@ -0,0 +1,218 @@ |
|||||
|
<script> |
||||
|
import "@spectrum-css/inputgroup/dist/index-vars.css" |
||||
|
import "@spectrum-css/popover/dist/index-vars.css" |
||||
|
import "@spectrum-css/menu/dist/index-vars.css" |
||||
|
import { fly } from "svelte/transition" |
||||
|
import { createEventDispatcher } from "svelte" |
||||
|
import clickOutside from "../../Actions/click_outside" |
||||
|
|
||||
|
export let inputValue |
||||
|
export let dropdownValue |
||||
|
export let id = null |
||||
|
export let inputType = "text" |
||||
|
export let placeholder = "Choose an option or type" |
||||
|
export let disabled = false |
||||
|
export let readonly = false |
||||
|
export let updateOnChange = true |
||||
|
export let error = null |
||||
|
export let options = [] |
||||
|
export let getOptionLabel = option => extractProperty(option, "label") |
||||
|
export let getOptionValue = option => extractProperty(option, "value") |
||||
|
|
||||
|
export let isOptionSelected = () => false |
||||
|
|
||||
|
const dispatch = createEventDispatcher() |
||||
|
let open = false |
||||
|
let focus = false |
||||
|
|
||||
|
$: fieldText = getFieldText(dropdownValue, options, placeholder) |
||||
|
|
||||
|
const getFieldText = (dropdownValue, options, placeholder) => { |
||||
|
// Always use placeholder if no value |
||||
|
if (dropdownValue == null || dropdownValue === "") { |
||||
|
return placeholder || "Choose an option or type" |
||||
|
} |
||||
|
|
||||
|
// Wait for options to load if there is a value but no options |
||||
|
if (!options?.length) { |
||||
|
return "" |
||||
|
} |
||||
|
|
||||
|
// Render the label if the selected option is found, otherwise raw value |
||||
|
const selected = options.find( |
||||
|
option => getOptionValue(option) === dropdownValue |
||||
|
) |
||||
|
return selected ? getOptionLabel(selected) : dropdownValue |
||||
|
} |
||||
|
|
||||
|
const updateValue = newValue => { |
||||
|
if (readonly) { |
||||
|
return |
||||
|
} |
||||
|
dispatch("change", newValue) |
||||
|
} |
||||
|
|
||||
|
const onFocus = () => { |
||||
|
if (readonly) { |
||||
|
return |
||||
|
} |
||||
|
focus = true |
||||
|
} |
||||
|
|
||||
|
const onBlur = event => { |
||||
|
if (readonly) { |
||||
|
return |
||||
|
} |
||||
|
focus = false |
||||
|
updateValue(event.target.value) |
||||
|
} |
||||
|
|
||||
|
const onInput = event => { |
||||
|
if (readonly || !updateOnChange) { |
||||
|
return |
||||
|
} |
||||
|
updateValue(event.target.value) |
||||
|
} |
||||
|
|
||||
|
const updateValueOnEnter = event => { |
||||
|
if (readonly) { |
||||
|
return |
||||
|
} |
||||
|
if (event.key === "Enter") { |
||||
|
updateValue(event.target.value) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const onClick = () => { |
||||
|
dispatch("click") |
||||
|
if (readonly) { |
||||
|
return |
||||
|
} |
||||
|
open = true |
||||
|
} |
||||
|
|
||||
|
const onPick = newValue => { |
||||
|
dispatch("pick", newValue) |
||||
|
open = false |
||||
|
} |
||||
|
|
||||
|
const extractProperty = (value, property) => { |
||||
|
if (value && typeof value === "object") { |
||||
|
return value[property] |
||||
|
} |
||||
|
return value |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<div |
||||
|
class="spectrum-InputGroup" |
||||
|
class:is-invalid={!!error} |
||||
|
class:is-disabled={disabled} |
||||
|
> |
||||
|
<div |
||||
|
class="spectrum-Textfield spectrum-InputGroup-textfield" |
||||
|
class:is-invalid={!!error} |
||||
|
class:is-disabled={disabled} |
||||
|
class:is-focused={focus} |
||||
|
> |
||||
|
<input |
||||
|
{id} |
||||
|
on:click |
||||
|
on:blur |
||||
|
on:focus |
||||
|
on:input |
||||
|
on:keyup |
||||
|
on:blur={onBlur} |
||||
|
on:focus={onFocus} |
||||
|
on:input={onInput} |
||||
|
on:keyup={updateValueOnEnter} |
||||
|
value={inputValue || ""} |
||||
|
placeholder={placeholder || ""} |
||||
|
{disabled} |
||||
|
{readonly} |
||||
|
{inputType} |
||||
|
class="spectrum-Textfield-input spectrum-InputGroup-input" |
||||
|
/> |
||||
|
</div> |
||||
|
<div style="width: 30%"> |
||||
|
<button |
||||
|
{id} |
||||
|
class="spectrum-Picker spectrum-Picker--sizeM override-borders" |
||||
|
{disabled} |
||||
|
class:is-open={open} |
||||
|
aria-haspopup="listbox" |
||||
|
on:mousedown={onClick} |
||||
|
> |
||||
|
<span class="spectrum-Picker-label"> |
||||
|
<div> |
||||
|
{fieldText} |
||||
|
</div></span |
||||
|
> |
||||
|
<svg |
||||
|
class="spectrum-Icon spectrum-UIIcon-ChevronDown100 spectrum-Picker-menuIcon" |
||||
|
focusable="false" |
||||
|
aria-hidden="true" |
||||
|
> |
||||
|
<use xlink:href="#spectrum-css-icon-Chevron100" /> |
||||
|
</svg> |
||||
|
</button> |
||||
|
{#if open} |
||||
|
<div |
||||
|
use:clickOutside={() => (open = false)} |
||||
|
transition:fly|local={{ y: -20, duration: 200 }} |
||||
|
class="spectrum-Popover spectrum-Popover--bottom spectrum-Picker-popover is-open" |
||||
|
> |
||||
|
<ul class="spectrum-Menu" role="listbox"> |
||||
|
{#each options as option, idx} |
||||
|
<li |
||||
|
class="spectrum-Menu-item" |
||||
|
class:is-selected={isOptionSelected(getOptionValue(option, idx))} |
||||
|
role="option" |
||||
|
aria-selected="true" |
||||
|
tabindex="0" |
||||
|
on:click={() => onPick(getOptionValue(option, idx))} |
||||
|
> |
||||
|
<span class="spectrum-Menu-itemLabel"> |
||||
|
{getOptionLabel(option, idx)} |
||||
|
</span> |
||||
|
<svg |
||||
|
class="spectrum-Icon spectrum-UIIcon-Checkmark100 spectrum-Menu-checkmark spectrum-Menu-itemIcon" |
||||
|
focusable="false" |
||||
|
aria-hidden="true" |
||||
|
> |
||||
|
<use xlink:href="#spectrum-css-icon-Checkmark100" /> |
||||
|
</svg> |
||||
|
</li> |
||||
|
{/each} |
||||
|
</ul> |
||||
|
</div> |
||||
|
{/if} |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<style> |
||||
|
.spectrum-InputGroup { |
||||
|
min-width: 0; |
||||
|
width: 100%; |
||||
|
} |
||||
|
|
||||
|
.spectrum-InputGroup-input { |
||||
|
border-right-width: 1px; |
||||
|
} |
||||
|
.spectrum-Textfield { |
||||
|
width: 100%; |
||||
|
} |
||||
|
.spectrum-Textfield-input { |
||||
|
width: 0; |
||||
|
} |
||||
|
|
||||
|
.override-borders { |
||||
|
border-top-left-radius: 0px; |
||||
|
border-bottom-left-radius: 0px; |
||||
|
} |
||||
|
.spectrum-Popover { |
||||
|
max-height: 240px; |
||||
|
z-index: 999; |
||||
|
top: 100%; |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,430 @@ |
|||||
|
<script> |
||||
|
import "@spectrum-css/inputgroup/dist/index-vars.css" |
||||
|
import "@spectrum-css/popover/dist/index-vars.css" |
||||
|
import "@spectrum-css/menu/dist/index-vars.css" |
||||
|
import { fly } from "svelte/transition" |
||||
|
import { createEventDispatcher } from "svelte" |
||||
|
import clickOutside from "../../Actions/click_outside" |
||||
|
import Icon from "../../Icon/Icon.svelte" |
||||
|
import StatusLight from "../../StatusLight/StatusLight.svelte" |
||||
|
import Detail from "../../Typography/Detail.svelte" |
||||
|
|
||||
|
export let primaryLabel = "" |
||||
|
export let primaryValue = null |
||||
|
export let id = null |
||||
|
export let placeholder = "Choose an option or type" |
||||
|
export let disabled = false |
||||
|
export let readonly = false |
||||
|
export let updateOnChange = true |
||||
|
export let error = null |
||||
|
export let secondaryOptions = [] |
||||
|
export let primaryOptions = [] |
||||
|
export let secondaryFieldText = "" |
||||
|
export let secondaryFieldIcon = "" |
||||
|
export let secondaryFieldColour = "" |
||||
|
export let getPrimaryOptionLabel = option => option |
||||
|
export let getPrimaryOptionValue = option => option |
||||
|
export let getPrimaryOptionColour = () => null |
||||
|
export let getPrimaryOptionIcon = () => null |
||||
|
export let getSecondaryOptionLabel = option => option |
||||
|
export let getSecondaryOptionValue = option => option |
||||
|
export let getSecondaryOptionColour = () => null |
||||
|
export let onSelectOption = () => {} |
||||
|
export let autoWidth = false |
||||
|
export let autocomplete = false |
||||
|
export let isOptionSelected = () => false |
||||
|
export let isPlaceholder = false |
||||
|
export let placeholderOption = null |
||||
|
|
||||
|
const dispatch = createEventDispatcher() |
||||
|
let primaryOpen = false |
||||
|
let secondaryOpen = false |
||||
|
let focus = false |
||||
|
let searchTerm = null |
||||
|
|
||||
|
$: groupTitles = Object.keys(primaryOptions) |
||||
|
$: filteredOptions = getFilteredOptions( |
||||
|
primaryOptions, |
||||
|
searchTerm, |
||||
|
getPrimaryOptionLabel |
||||
|
) |
||||
|
let iconData |
||||
|
/* |
||||
|
$: iconData = primaryOptions?.find(x => { |
||||
|
return x.name === primaryFieldText |
||||
|
}) |
||||
|
*/ |
||||
|
const updateValue = newValue => { |
||||
|
if (readonly) { |
||||
|
return |
||||
|
} |
||||
|
dispatch("change", newValue) |
||||
|
} |
||||
|
|
||||
|
const onClickSecondary = () => { |
||||
|
dispatch("click") |
||||
|
if (readonly) { |
||||
|
return |
||||
|
} |
||||
|
secondaryOpen = true |
||||
|
} |
||||
|
|
||||
|
const onPickPrimary = newValue => { |
||||
|
dispatch("pickprimary", newValue) |
||||
|
primaryOpen = false |
||||
|
} |
||||
|
|
||||
|
const onClearPrimary = () => { |
||||
|
dispatch("pickprimary", null) |
||||
|
primaryOpen = false |
||||
|
} |
||||
|
|
||||
|
const onPickSecondary = newValue => { |
||||
|
dispatch("picksecondary", newValue) |
||||
|
secondaryOpen = false |
||||
|
} |
||||
|
|
||||
|
const onBlur = event => { |
||||
|
if (readonly) { |
||||
|
return |
||||
|
} |
||||
|
focus = false |
||||
|
updateValue(event.target.value) |
||||
|
} |
||||
|
|
||||
|
const onInput = event => { |
||||
|
if (readonly || !updateOnChange) { |
||||
|
return |
||||
|
} |
||||
|
updateValue(event.target.value) |
||||
|
} |
||||
|
|
||||
|
const updateValueOnEnter = event => { |
||||
|
if (readonly) { |
||||
|
return |
||||
|
} |
||||
|
if (event.key === "Enter") { |
||||
|
updateValue(event.target.value) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const getFilteredOptions = (options, term, getLabel) => { |
||||
|
if (autocomplete && term) { |
||||
|
const lowerCaseTerm = term.toLowerCase() |
||||
|
return options.filter(option => { |
||||
|
return `${getLabel(option)}`.toLowerCase().includes(lowerCaseTerm) |
||||
|
}) |
||||
|
} |
||||
|
return options |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<div |
||||
|
class="spectrum-InputGroup" |
||||
|
class:is-invalid={!!error} |
||||
|
class:is-disabled={disabled} |
||||
|
> |
||||
|
<div |
||||
|
class="spectrum-Textfield spectrum-InputGroup-textfield" |
||||
|
class:is-invalid={!!error} |
||||
|
class:is-disabled={disabled} |
||||
|
class:is-focused={focus} |
||||
|
class:is-full-width={!secondaryOptions.length} |
||||
|
> |
||||
|
{#if iconData} |
||||
|
<svg |
||||
|
width="16px" |
||||
|
height="16px" |
||||
|
class="spectrum-Icon iconPadding" |
||||
|
style="color: {iconData?.color}" |
||||
|
focusable="false" |
||||
|
> |
||||
|
<use xlink:href="#spectrum-icon-18-{iconData?.icon}" /> |
||||
|
</svg> |
||||
|
{/if} |
||||
|
<input |
||||
|
{id} |
||||
|
on:click={() => (primaryOpen = true)} |
||||
|
on:blur |
||||
|
on:focus |
||||
|
on:input |
||||
|
on:keyup |
||||
|
on:blur={onBlur} |
||||
|
on:input={onInput} |
||||
|
on:keyup={updateValueOnEnter} |
||||
|
value={primaryLabel || ""} |
||||
|
placeholder={placeholder || ""} |
||||
|
{disabled} |
||||
|
{readonly} |
||||
|
class="spectrum-Textfield-input spectrum-InputGroup-input" |
||||
|
class:labelPadding={iconData} |
||||
|
/> |
||||
|
{#if primaryValue} |
||||
|
<button |
||||
|
on:click={() => onClearPrimary()} |
||||
|
type="reset" |
||||
|
class="spectrum-ClearButton spectrum-Search-clearButton" |
||||
|
> |
||||
|
<svg |
||||
|
class="spectrum-Icon spectrum-UIIcon-Cross75" |
||||
|
focusable="false" |
||||
|
aria-hidden="true" |
||||
|
> |
||||
|
<use xlink:href="#spectrum-css-icon-Cross75" /> |
||||
|
</svg> |
||||
|
</button> |
||||
|
{/if} |
||||
|
</div> |
||||
|
{#if primaryOpen} |
||||
|
<div |
||||
|
use:clickOutside={() => (primaryOpen = false)} |
||||
|
transition:fly|local={{ y: -20, duration: 200 }} |
||||
|
class="spectrum-Popover spectrum-Popover--bottom spectrum-Picker-popover is-open" |
||||
|
class:auto-width={autoWidth} |
||||
|
class:is-full-width={!secondaryOptions.length} |
||||
|
> |
||||
|
<ul class="spectrum-Menu" role="listbox"> |
||||
|
{#if placeholderOption} |
||||
|
<li |
||||
|
class="spectrum-Menu-item placeholder" |
||||
|
class:is-selected={isPlaceholder} |
||||
|
role="option" |
||||
|
aria-selected="true" |
||||
|
tabindex="0" |
||||
|
on:click={() => onSelectOption(null)} |
||||
|
> |
||||
|
<span class="spectrum-Menu-itemLabel">{placeholderOption}</span> |
||||
|
<svg |
||||
|
class="spectrum-Icon spectrum-UIIcon-Checkmark100 spectrum-Menu-checkmark spectrum-Menu-itemIcon" |
||||
|
focusable="false" |
||||
|
aria-hidden="true" |
||||
|
> |
||||
|
<use xlink:href="#spectrum-css-icon-Checkmark100" /> |
||||
|
</svg> |
||||
|
</li> |
||||
|
{/if} |
||||
|
{#each groupTitles as title} |
||||
|
<div class="spectrum-Menu-item"> |
||||
|
<Detail>{title}</Detail> |
||||
|
</div> |
||||
|
{#if primaryOptions} |
||||
|
{#each primaryOptions[title].data as option, idx} |
||||
|
<li |
||||
|
class="spectrum-Menu-item" |
||||
|
class:is-selected={isOptionSelected( |
||||
|
getPrimaryOptionValue(option, idx) |
||||
|
)} |
||||
|
role="option" |
||||
|
aria-selected="true" |
||||
|
tabindex="0" |
||||
|
on:click={() => |
||||
|
onPickPrimary({ |
||||
|
value: primaryOptions[title].getValue(option), |
||||
|
label: primaryOptions[title].getLabel(option), |
||||
|
})} |
||||
|
> |
||||
|
{#if primaryOptions[title].getIcon(option)} |
||||
|
<div |
||||
|
style="background: {primaryOptions[title].getColour( |
||||
|
option |
||||
|
)};" |
||||
|
class="circle" |
||||
|
> |
||||
|
<div> |
||||
|
<Icon |
||||
|
size="S" |
||||
|
name={primaryOptions[title].getIcon(option)} |
||||
|
/> |
||||
|
</div> |
||||
|
</div> |
||||
|
{:else if getPrimaryOptionColour(option, idx)} |
||||
|
<span class="option-left"> |
||||
|
<StatusLight color={getPrimaryOptionColour(option, idx)} /> |
||||
|
</span> |
||||
|
{/if} |
||||
|
<span class="spectrum-Menu-itemLabel"> |
||||
|
<span |
||||
|
class:spacing-group={primaryOptions[title].getIcon(option)} |
||||
|
> |
||||
|
{primaryOptions[title].getLabel(option)} |
||||
|
<span /> |
||||
|
</span> |
||||
|
<svg |
||||
|
class="spectrum-Icon spectrum-UIIcon-Checkmark100 spectrum-Menu-checkmark spectrum-Menu-itemIcon" |
||||
|
focusable="false" |
||||
|
aria-hidden="true" |
||||
|
> |
||||
|
<use xlink:href="#spectrum-css-icon-Checkmark100" /> |
||||
|
</svg> |
||||
|
{#if getPrimaryOptionIcon(option, idx) && getPrimaryOptionColour(option, idx)} |
||||
|
<span class="option-right"> |
||||
|
<StatusLight |
||||
|
color={getPrimaryOptionColour(option, idx)} |
||||
|
/> |
||||
|
</span> |
||||
|
{/if} |
||||
|
</span> |
||||
|
</li> |
||||
|
{/each} |
||||
|
{/if} |
||||
|
{/each} |
||||
|
</ul> |
||||
|
</div> |
||||
|
{/if} |
||||
|
{#if secondaryOptions.length} |
||||
|
<div style="width: 30%"> |
||||
|
<button |
||||
|
{id} |
||||
|
class="spectrum-Picker spectrum-Picker--sizeM override-borders" |
||||
|
{disabled} |
||||
|
class:is-open={secondaryOpen} |
||||
|
aria-haspopup="listbox" |
||||
|
on:mousedown={onClickSecondary} |
||||
|
> |
||||
|
{#if secondaryFieldIcon} |
||||
|
<span class="option-left"> |
||||
|
<Icon name={secondaryFieldIcon} /> |
||||
|
</span> |
||||
|
{:else if secondaryFieldColour} |
||||
|
<span class="option-left"> |
||||
|
<StatusLight color={secondaryFieldColour} /> |
||||
|
</span> |
||||
|
{/if} |
||||
|
|
||||
|
<span class:auto-width={autoWidth} class="spectrum-Picker-label"> |
||||
|
{secondaryFieldText} |
||||
|
</span> |
||||
|
<svg |
||||
|
class="spectrum-Icon spectrum-UIIcon-ChevronDown100 spectrum-Picker-menuIcon" |
||||
|
focusable="false" |
||||
|
aria-hidden="true" |
||||
|
> |
||||
|
<use xlink:href="#spectrum-css-icon-Chevron100" /> |
||||
|
</svg> |
||||
|
</button> |
||||
|
{#if secondaryOpen} |
||||
|
<div |
||||
|
use:clickOutside={() => (secondaryOpen = false)} |
||||
|
transition:fly|local={{ y: -20, duration: 200 }} |
||||
|
class="spectrum-Popover spectrum-Popover--bottom spectrum-Picker-popover is-open" |
||||
|
style="width: 30%" |
||||
|
> |
||||
|
<ul class="spectrum-Menu" role="listbox"> |
||||
|
{#each secondaryOptions as option, idx} |
||||
|
<li |
||||
|
class="spectrum-Menu-item" |
||||
|
class:is-selected={isOptionSelected( |
||||
|
getSecondaryOptionValue(option, idx) |
||||
|
)} |
||||
|
role="option" |
||||
|
aria-selected="true" |
||||
|
tabindex="0" |
||||
|
on:click={() => |
||||
|
onPickSecondary(getSecondaryOptionValue(option, idx))} |
||||
|
> |
||||
|
{#if getSecondaryOptionColour(option, idx)} |
||||
|
<span class="option-left"> |
||||
|
<StatusLight |
||||
|
color={getSecondaryOptionColour(option, idx)} |
||||
|
/> |
||||
|
</span> |
||||
|
{/if} |
||||
|
|
||||
|
<span class="spectrum-Menu-itemLabel"> |
||||
|
{getSecondaryOptionLabel(option, idx)} |
||||
|
</span> |
||||
|
<svg |
||||
|
class="spectrum-Icon spectrum-UIIcon-Checkmark100 spectrum-Menu-checkmark spectrum-Menu-itemIcon" |
||||
|
focusable="false" |
||||
|
aria-hidden="true" |
||||
|
> |
||||
|
<use xlink:href="#spectrum-css-icon-Checkmark100" /> |
||||
|
</svg> |
||||
|
</li> |
||||
|
{/each} |
||||
|
</ul> |
||||
|
</div> |
||||
|
{/if} |
||||
|
</div> |
||||
|
{/if} |
||||
|
</div> |
||||
|
|
||||
|
<style> |
||||
|
.spacing-group { |
||||
|
margin-left: var(--spacing-m); |
||||
|
} |
||||
|
.spectrum-InputGroup { |
||||
|
min-width: 0; |
||||
|
width: 100%; |
||||
|
} |
||||
|
.override-borders { |
||||
|
border-top-left-radius: 0px; |
||||
|
border-bottom-left-radius: 0px; |
||||
|
} |
||||
|
|
||||
|
.spectrum-Popover { |
||||
|
max-height: 240px; |
||||
|
z-index: 999; |
||||
|
top: 100%; |
||||
|
} |
||||
|
|
||||
|
.option-left { |
||||
|
padding-right: 8px; |
||||
|
} |
||||
|
.option-right { |
||||
|
padding-left: 8px; |
||||
|
} |
||||
|
|
||||
|
.circle { |
||||
|
border-radius: 50%; |
||||
|
height: 28px; |
||||
|
color: white; |
||||
|
font-weight: bold; |
||||
|
line-height: 48px; |
||||
|
font-size: 1.2em; |
||||
|
width: 28px; |
||||
|
position: relative; |
||||
|
} |
||||
|
|
||||
|
.circle > div { |
||||
|
position: absolute; |
||||
|
text-decoration: none; |
||||
|
top: 50%; |
||||
|
left: 50%; |
||||
|
transform: translateX(-50%) translateY(-50%); |
||||
|
} |
||||
|
|
||||
|
.iconPadding { |
||||
|
position: absolute; |
||||
|
top: 50%; |
||||
|
left: 10px; |
||||
|
transform: translateY(-50%); |
||||
|
color: silver; |
||||
|
margin-right: 10px; |
||||
|
} |
||||
|
|
||||
|
.labelPadding { |
||||
|
padding-left: calc(1em + 10px + 8px); |
||||
|
} |
||||
|
|
||||
|
.spectrum-Textfield.spectrum-InputGroup-textfield { |
||||
|
width: 70%; |
||||
|
} |
||||
|
.spectrum-Textfield.spectrum-InputGroup-textfield.is-full-width { |
||||
|
width: 100%; |
||||
|
} |
||||
|
.spectrum-Textfield.spectrum-InputGroup-textfield.is-full-width input { |
||||
|
border-right-width: thin; |
||||
|
} |
||||
|
|
||||
|
.spectrum-Popover.spectrum-Popover--bottom.spectrum-Picker-popover.is-open { |
||||
|
width: 70%; |
||||
|
} |
||||
|
.spectrum-Popover.spectrum-Popover--bottom.spectrum-Picker-popover.is-open.is-full-width { |
||||
|
width: 100%; |
||||
|
} |
||||
|
|
||||
|
.spectrum-Search-clearButton { |
||||
|
position: absolute; |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,55 @@ |
|||||
|
<script> |
||||
|
import Field from "./Field.svelte" |
||||
|
import InputDropdown from "./Core/InputDropdown.svelte" |
||||
|
import { createEventDispatcher } from "svelte" |
||||
|
|
||||
|
export let inputValue = null |
||||
|
export let dropdownValue = null |
||||
|
export let inputType = "text" |
||||
|
export let label = null |
||||
|
export let labelPosition = "above" |
||||
|
export let placeholder = null |
||||
|
export let disabled = false |
||||
|
export let readonly = false |
||||
|
export let error = null |
||||
|
export let updateOnChange = true |
||||
|
export let quiet = false |
||||
|
export let dataCy |
||||
|
export let autofocus |
||||
|
export let options = [] |
||||
|
|
||||
|
const dispatch = createEventDispatcher() |
||||
|
|
||||
|
const onPick = e => { |
||||
|
dropdownValue = e.detail |
||||
|
dispatch("pick", e.detail) |
||||
|
} |
||||
|
const onChange = e => { |
||||
|
inputValue = e.detail |
||||
|
dispatch("change", e.detail) |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<Field {label} {labelPosition} {error}> |
||||
|
<InputDropdown |
||||
|
{dataCy} |
||||
|
{updateOnChange} |
||||
|
{error} |
||||
|
{disabled} |
||||
|
{readonly} |
||||
|
{inputValue} |
||||
|
{dropdownValue} |
||||
|
{placeholder} |
||||
|
{inputType} |
||||
|
{quiet} |
||||
|
{autofocus} |
||||
|
{options} |
||||
|
on:change={onChange} |
||||
|
on:pick={onPick} |
||||
|
on:click |
||||
|
on:input |
||||
|
on:blur |
||||
|
on:focus |
||||
|
on:keyup |
||||
|
/> |
||||
|
</Field> |
||||
@ -0,0 +1,125 @@ |
|||||
|
<script> |
||||
|
import Field from "./Field.svelte" |
||||
|
import PickerDropdown from "./Core/PickerDropdown.svelte" |
||||
|
import { createEventDispatcher } from "svelte" |
||||
|
|
||||
|
export let primaryValue = null |
||||
|
export let secondaryValue = null |
||||
|
export let inputType = "text" |
||||
|
export let label = null |
||||
|
export let labelPosition = "above" |
||||
|
export let secondaryPlaceholder = null |
||||
|
export let autocomplete |
||||
|
export let placeholder = null |
||||
|
export let disabled = false |
||||
|
export let readonly = false |
||||
|
export let error = null |
||||
|
export let updateOnChange = true |
||||
|
export let getSecondaryOptionLabel = option => |
||||
|
extractProperty(option, "label") |
||||
|
export let getSecondaryOptionValue = option => |
||||
|
extractProperty(option, "value") |
||||
|
export let getSecondaryOptionColour = () => {} |
||||
|
export let getSecondaryOptionIcon = () => {} |
||||
|
export let quiet = false |
||||
|
export let dataCy |
||||
|
export let autofocus |
||||
|
export let primaryOptions = [] |
||||
|
export let secondaryOptions = [] |
||||
|
|
||||
|
let primaryLabel |
||||
|
let secondaryLabel |
||||
|
const dispatch = createEventDispatcher() |
||||
|
|
||||
|
$: secondaryFieldText = getSecondaryFieldText( |
||||
|
secondaryValue, |
||||
|
secondaryOptions, |
||||
|
secondaryPlaceholder |
||||
|
) |
||||
|
$: secondaryFieldIcon = getSecondaryFieldAttribute( |
||||
|
getSecondaryOptionIcon, |
||||
|
secondaryValue, |
||||
|
secondaryOptions |
||||
|
) |
||||
|
$: secondaryFieldColour = getSecondaryFieldAttribute( |
||||
|
getSecondaryOptionColour, |
||||
|
secondaryValue, |
||||
|
secondaryOptions |
||||
|
) |
||||
|
|
||||
|
const getSecondaryFieldAttribute = (getAttribute, value, options) => { |
||||
|
// Wait for options to load if there is a value but no options |
||||
|
|
||||
|
if (!options?.length) { |
||||
|
return "" |
||||
|
} |
||||
|
|
||||
|
const index = options.findIndex( |
||||
|
(option, idx) => getSecondaryOptionValue(option, idx) === value |
||||
|
) |
||||
|
|
||||
|
return index !== -1 ? getAttribute(options[index], index) : null |
||||
|
} |
||||
|
|
||||
|
const getSecondaryFieldText = (value, options, placeholder) => { |
||||
|
// Always use placeholder if no value |
||||
|
if (value == null || value === "") { |
||||
|
return placeholder || "Choose an option" |
||||
|
} |
||||
|
|
||||
|
return getSecondaryFieldAttribute(getSecondaryOptionLabel, value, options) |
||||
|
} |
||||
|
|
||||
|
const onPickPrimary = e => { |
||||
|
primaryLabel = e?.detail?.label || null |
||||
|
primaryValue = e?.detail?.value || null |
||||
|
dispatch("pickprimary", e?.detail?.value || {}) |
||||
|
} |
||||
|
|
||||
|
const onPickSecondary = e => { |
||||
|
secondaryValue = e.detail |
||||
|
dispatch("picksecondary", e.detail) |
||||
|
} |
||||
|
|
||||
|
const extractProperty = (value, property) => { |
||||
|
if (value && typeof value === "object") { |
||||
|
return value[property] |
||||
|
} |
||||
|
return value |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<Field {label} {labelPosition} {error}> |
||||
|
<PickerDropdown |
||||
|
{autocomplete} |
||||
|
{dataCy} |
||||
|
{updateOnChange} |
||||
|
{error} |
||||
|
{disabled} |
||||
|
{readonly} |
||||
|
{placeholder} |
||||
|
{inputType} |
||||
|
{quiet} |
||||
|
{autofocus} |
||||
|
{primaryOptions} |
||||
|
{secondaryOptions} |
||||
|
{getSecondaryOptionLabel} |
||||
|
{getSecondaryOptionValue} |
||||
|
{getSecondaryOptionIcon} |
||||
|
{getSecondaryOptionColour} |
||||
|
{secondaryFieldText} |
||||
|
{secondaryFieldIcon} |
||||
|
{secondaryFieldColour} |
||||
|
{primaryValue} |
||||
|
{secondaryValue} |
||||
|
{primaryLabel} |
||||
|
{secondaryLabel} |
||||
|
on:pickprimary={onPickPrimary} |
||||
|
on:picksecondary={onPickSecondary} |
||||
|
on:click |
||||
|
on:input |
||||
|
on:blur |
||||
|
on:focus |
||||
|
on:keyup |
||||
|
/> |
||||
|
</Field> |
||||
@ -0,0 +1,177 @@ |
|||||
|
<script> |
||||
|
//import { createEventDispatcher } from "svelte" |
||||
|
import "@spectrum-css/popover/dist/index-vars.css" |
||||
|
import clickOutside from "../Actions/click_outside" |
||||
|
import { fly } from "svelte/transition" |
||||
|
import Icon from "../Icon/Icon.svelte" |
||||
|
import { createEventDispatcher } from "svelte" |
||||
|
|
||||
|
export let value |
||||
|
export let size = "M" |
||||
|
export let alignRight = false |
||||
|
|
||||
|
let open = false |
||||
|
|
||||
|
const dispatch = createEventDispatcher() |
||||
|
|
||||
|
const iconList = [ |
||||
|
{ |
||||
|
label: "Icons", |
||||
|
icons: [ |
||||
|
"Apps", |
||||
|
"Actions", |
||||
|
"ConversionFunnel", |
||||
|
"App", |
||||
|
"Briefcase", |
||||
|
"Money", |
||||
|
"ShoppingCart", |
||||
|
"Form", |
||||
|
"Help", |
||||
|
"Monitoring", |
||||
|
"Sandbox", |
||||
|
"Project", |
||||
|
"Organisations", |
||||
|
"Magnify", |
||||
|
"Launch", |
||||
|
"Car", |
||||
|
"Camera", |
||||
|
"Bug", |
||||
|
"Channel", |
||||
|
"Calculator", |
||||
|
"Calendar", |
||||
|
"GraphDonut", |
||||
|
"GraphBarHorizontal", |
||||
|
"Demographic", |
||||
|
], |
||||
|
}, |
||||
|
] |
||||
|
|
||||
|
const onChange = value => { |
||||
|
dispatch("change", value) |
||||
|
open = false |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<div class="container"> |
||||
|
<div class="preview size--{size || 'M'}" on:click={() => (open = true)}> |
||||
|
<div |
||||
|
class="fill" |
||||
|
style={value ? `background: ${value};` : ""} |
||||
|
class:placeholder={!value} |
||||
|
> |
||||
|
<Icon name={value || "UserGroup"} /> |
||||
|
</div> |
||||
|
</div> |
||||
|
{#if open} |
||||
|
<div |
||||
|
use:clickOutside={() => (open = false)} |
||||
|
transition:fly={{ y: -20, duration: 200 }} |
||||
|
class="spectrum-Popover spectrum-Popover--bottom spectrum-Picker-popover is-open" |
||||
|
class:spectrum-Popover--align-right={alignRight} |
||||
|
> |
||||
|
{#each iconList as icon} |
||||
|
<div class="category"> |
||||
|
<div class="heading">{icon.label}</div> |
||||
|
<div class="icons"> |
||||
|
{#each icon.icons as icon} |
||||
|
<div |
||||
|
on:click={() => { |
||||
|
onChange(icon) |
||||
|
}} |
||||
|
> |
||||
|
<Icon name={icon} /> |
||||
|
</div> |
||||
|
{/each} |
||||
|
</div> |
||||
|
</div> |
||||
|
{/each} |
||||
|
</div> |
||||
|
{/if} |
||||
|
</div> |
||||
|
|
||||
|
<style> |
||||
|
.container { |
||||
|
position: relative; |
||||
|
} |
||||
|
.preview { |
||||
|
width: 32px; |
||||
|
height: 32px; |
||||
|
position: relative; |
||||
|
box-shadow: 0 0 0 1px var(--spectrum-global-color-gray-400); |
||||
|
} |
||||
|
.preview:hover { |
||||
|
cursor: pointer; |
||||
|
} |
||||
|
.fill { |
||||
|
width: 100%; |
||||
|
height: 100%; |
||||
|
position: absolute; |
||||
|
top: 0; |
||||
|
left: 0; |
||||
|
display: grid; |
||||
|
place-items: center; |
||||
|
} |
||||
|
.size--S { |
||||
|
width: 20px; |
||||
|
height: 20px; |
||||
|
} |
||||
|
.size--M { |
||||
|
width: 32px; |
||||
|
height: 32px; |
||||
|
} |
||||
|
.size--L { |
||||
|
width: 48px; |
||||
|
height: 48px; |
||||
|
} |
||||
|
.spectrum-Popover { |
||||
|
width: 210px; |
||||
|
z-index: 999; |
||||
|
top: 100%; |
||||
|
padding: var(--spacing-l) var(--spacing-xl); |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
justify-content: flex-start; |
||||
|
align-items: stretch; |
||||
|
gap: var(--spacing-xl); |
||||
|
} |
||||
|
.spectrum-Popover--align-right { |
||||
|
right: 0; |
||||
|
} |
||||
|
.icons { |
||||
|
display: grid; |
||||
|
grid-template-columns: 1fr 1fr 1fr 1fr 1fr 1fr; |
||||
|
gap: var(--spacing-m); |
||||
|
} |
||||
|
.heading { |
||||
|
font-size: var(--font-size-s); |
||||
|
font-weight: 600; |
||||
|
letter-spacing: 0.14px; |
||||
|
flex: 1 1 auto; |
||||
|
text-transform: uppercase; |
||||
|
grid-column: 1 / 5; |
||||
|
margin-bottom: var(--spacing-s); |
||||
|
} |
||||
|
|
||||
|
.icon { |
||||
|
height: 16px; |
||||
|
width: 16px; |
||||
|
border-radius: 100%; |
||||
|
box-shadow: 0 0 0 1px var(--spectrum-global-color-gray-300); |
||||
|
position: relative; |
||||
|
} |
||||
|
.icon:hover { |
||||
|
cursor: pointer; |
||||
|
box-shadow: 0 0 2px 2px var(--spectrum-global-color-gray-300); |
||||
|
} |
||||
|
.custom { |
||||
|
display: grid; |
||||
|
grid-template-columns: 1fr auto; |
||||
|
align-items: center; |
||||
|
gap: var(--spacing-m); |
||||
|
margin-right: var(--spacing-xs); |
||||
|
} |
||||
|
|
||||
|
.spectrum-wrapper { |
||||
|
background-color: transparent; |
||||
|
} |
||||
|
</style> |
||||
@ -1,53 +0,0 @@ |
|||||
<script> |
|
||||
import { View } from "svench"; |
|
||||
import DetailSummary from "./DetailSummary.svelte"; |
|
||||
</script> |
|
||||
|
|
||||
<svelte:head> |
|
||||
<link href="https://cdn.jsdelivr.net/npm/remixicon@2.5.0/fonts/remixicon.css" rel="stylesheet"> |
|
||||
</svelte:head> |
|
||||
|
|
||||
<style> |
|
||||
div { |
|
||||
display: flex; |
|
||||
flex-direction: column; |
|
||||
justify-content: flex-start; |
|
||||
align-items: stretch; |
|
||||
gap: var(--spacing-m); |
|
||||
width: 120px; |
|
||||
} |
|
||||
</style> |
|
||||
|
|
||||
<View name="default"> |
|
||||
<div> |
|
||||
<DetailSummary name="Category 1"> |
|
||||
<span>1</span> |
|
||||
<span>2</span> |
|
||||
<span>3</span> |
|
||||
<span>4</span> |
|
||||
</DetailSummary> |
|
||||
<DetailSummary name="Category 2"> |
|
||||
<span>1</span> |
|
||||
<span>2</span> |
|
||||
<span>3</span> |
|
||||
<span>4</span> |
|
||||
</DetailSummary> |
|
||||
</div> |
|
||||
</View> |
|
||||
|
|
||||
<View name="thin"> |
|
||||
<div> |
|
||||
<DetailSummary thin name="Category 1"> |
|
||||
<span>1</span> |
|
||||
<span>2</span> |
|
||||
<span>3</span> |
|
||||
<span>4</span> |
|
||||
</DetailSummary> |
|
||||
<DetailSummary thin name="Category 2"> |
|
||||
<span>1</span> |
|
||||
<span>2</span> |
|
||||
<span>3</span> |
|
||||
<span>4</span> |
|
||||
</DetailSummary> |
|
||||
</div> |
|
||||
</View> |
|
||||
@ -0,0 +1,28 @@ |
|||||
|
<script> |
||||
|
import Detail from "../Typography/Detail.svelte" |
||||
|
|
||||
|
export let title = null |
||||
|
</script> |
||||
|
|
||||
|
<div> |
||||
|
{#if title} |
||||
|
<div class="title"> |
||||
|
<Detail>{title}</Detail> |
||||
|
</div> |
||||
|
{/if} |
||||
|
<div class="list-items"> |
||||
|
<slot /> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<style> |
||||
|
.title { |
||||
|
margin-bottom: 6px; |
||||
|
} |
||||
|
.list-items { |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
justify-content: flex-start; |
||||
|
align-items: stretch; |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,92 @@ |
|||||
|
<script> |
||||
|
import Body from "../Typography/Body.svelte" |
||||
|
import Icon from "../Icon/Icon.svelte" |
||||
|
import Label from "../Label/Label.svelte" |
||||
|
import Avatar from "../Avatar/Avatar.svelte" |
||||
|
|
||||
|
export let icon = null |
||||
|
export let iconBackground = null |
||||
|
export let avatar = false |
||||
|
export let title = null |
||||
|
export let subtitle = null |
||||
|
|
||||
|
$: initials = avatar ? title?.[0] : null |
||||
|
</script> |
||||
|
|
||||
|
<div class="list-item"> |
||||
|
<div class="left"> |
||||
|
{#if icon} |
||||
|
<div class="icon" style="background: {iconBackground || `transparent`};"> |
||||
|
<Icon name={icon} size="S" color={iconBackground ? "white" : null} /> |
||||
|
</div> |
||||
|
{/if} |
||||
|
{#if avatar} |
||||
|
<Avatar {initials} /> |
||||
|
{/if} |
||||
|
{#if title} |
||||
|
<Body>{title}</Body> |
||||
|
{/if} |
||||
|
{#if subtitle} |
||||
|
<Label>{subtitle}</Label> |
||||
|
{/if} |
||||
|
</div> |
||||
|
<div class="right"> |
||||
|
<slot /> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<style> |
||||
|
.list-item { |
||||
|
padding: 0 16px; |
||||
|
height: 56px; |
||||
|
background: var(--spectrum-alias-background-color-tertiary); |
||||
|
display: flex; |
||||
|
flex-direction: row; |
||||
|
justify-content: space-between; |
||||
|
border: 1px solid var(--spectrum-global-color-gray-300); |
||||
|
} |
||||
|
.list-item:not(:first-child) { |
||||
|
border-top: none; |
||||
|
} |
||||
|
.list-item:first-child { |
||||
|
border-top-left-radius: 4px; |
||||
|
border-top-right-radius: 4px; |
||||
|
} |
||||
|
.list-item:last-child { |
||||
|
border-bottom-left-radius: 4px; |
||||
|
border-bottom-right-radius: 4px; |
||||
|
} |
||||
|
.left, |
||||
|
.right { |
||||
|
display: flex; |
||||
|
flex-direction: row; |
||||
|
align-items: center; |
||||
|
gap: var(--spacing-xl); |
||||
|
} |
||||
|
.left { |
||||
|
width: 0; |
||||
|
flex: 1 1 auto; |
||||
|
} |
||||
|
.right { |
||||
|
flex: 0 0 auto; |
||||
|
} |
||||
|
.list-item :global(.spectrum-Icon), |
||||
|
.list-item :global(.spectrum-Avatar) { |
||||
|
flex: 0 0 auto; |
||||
|
} |
||||
|
.list-item :global(.spectrum-Body) { |
||||
|
color: var(--spectrum-global-color-gray-900); |
||||
|
} |
||||
|
.list-item :global(.spectrum-Body) { |
||||
|
white-space: nowrap; |
||||
|
overflow: hidden; |
||||
|
text-overflow: ellipsis; |
||||
|
} |
||||
|
.icon { |
||||
|
width: var(--spectrum-alias-avatar-size-400); |
||||
|
height: var(--spectrum-alias-avatar-size-400); |
||||
|
display: grid; |
||||
|
place-items: center; |
||||
|
border-radius: 50%; |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,24 @@ |
|||||
|
<script> |
||||
|
import { Select } from "@budibase/bbui" |
||||
|
import { roles } from "stores/backend" |
||||
|
import { RoleUtils } from "@budibase/frontend-core" |
||||
|
|
||||
|
export let value |
||||
|
export let error |
||||
|
export let placeholder = null |
||||
|
export let autoWidth = false |
||||
|
export let quiet = false |
||||
|
</script> |
||||
|
|
||||
|
<Select |
||||
|
{autoWidth} |
||||
|
{quiet} |
||||
|
bind:value |
||||
|
on:change |
||||
|
options={$roles} |
||||
|
getOptionLabel={role => role.name} |
||||
|
getOptionValue={role => role._id} |
||||
|
getOptionColour={role => RoleUtils.getRoleColour(role._id)} |
||||
|
{placeholder} |
||||
|
{error} |
||||
|
/> |
||||
@ -0,0 +1,75 @@ |
|||||
|
<script> |
||||
|
import { ActionButton, Icon, Search, Divider, Detail } from "@budibase/bbui" |
||||
|
|
||||
|
export let searchTerm = "" |
||||
|
export let selected |
||||
|
export let filtered = [] |
||||
|
export let addAll |
||||
|
export let select |
||||
|
export let title |
||||
|
export let key |
||||
|
</script> |
||||
|
|
||||
|
<div style="padding: var(--spacing-m)"> |
||||
|
<Search placeholder="Search" bind:value={searchTerm} /> |
||||
|
<div class="header sub-header"> |
||||
|
<div> |
||||
|
<Detail |
||||
|
>{filtered.length} {title}{filtered.length === 1 ? "" : "s"}</Detail |
||||
|
> |
||||
|
</div> |
||||
|
<div> |
||||
|
<ActionButton on:click={addAll} emphasized size="S">Add all</ActionButton> |
||||
|
</div> |
||||
|
</div> |
||||
|
<Divider noMargin /> |
||||
|
<div> |
||||
|
{#each filtered as item} |
||||
|
<div |
||||
|
on:click={() => { |
||||
|
select(item._id) |
||||
|
}} |
||||
|
style="padding-bottom: var(--spacing-m)" |
||||
|
class="selection" |
||||
|
> |
||||
|
<div> |
||||
|
{item[key]} |
||||
|
</div> |
||||
|
|
||||
|
{#if selected.includes(item._id)} |
||||
|
<div> |
||||
|
<Icon |
||||
|
color="var(--spectrum-global-color-blue-600);" |
||||
|
name="Checkmark" |
||||
|
/> |
||||
|
</div> |
||||
|
{/if} |
||||
|
</div> |
||||
|
{/each} |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<style> |
||||
|
.header { |
||||
|
align-items: center; |
||||
|
padding: var(--spacing-m) 0 var(--spacing-m) 0; |
||||
|
display: flex; |
||||
|
justify-content: space-between; |
||||
|
} |
||||
|
|
||||
|
.selection { |
||||
|
align-items: end; |
||||
|
display: flex; |
||||
|
justify-content: space-between; |
||||
|
cursor: pointer; |
||||
|
} |
||||
|
|
||||
|
.selection > :first-child { |
||||
|
padding-top: var(--spacing-m); |
||||
|
} |
||||
|
|
||||
|
.sub-header { |
||||
|
display: flex; |
||||
|
justify-content: space-between; |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,43 @@ |
|||||
|
<script> |
||||
|
import { PickerDropdown, notifications } from "@budibase/bbui" |
||||
|
import { groups } from "stores/portal" |
||||
|
import { onMount, createEventDispatcher } from "svelte" |
||||
|
|
||||
|
const dispatch = createEventDispatcher() |
||||
|
|
||||
|
$: optionSections = { |
||||
|
groups: { |
||||
|
data: $groups, |
||||
|
getLabel: group => group.name, |
||||
|
getValue: group => group._id, |
||||
|
getIcon: group => group.icon, |
||||
|
getColour: group => group.color, |
||||
|
}, |
||||
|
} |
||||
|
|
||||
|
$: appData = [{ id: "", role: "" }] |
||||
|
|
||||
|
$: onChange = selected => { |
||||
|
const { detail } = selected |
||||
|
if (!detail) return |
||||
|
|
||||
|
const groupSelected = $groups.find(x => x._id === detail) |
||||
|
const appIds = groupSelected?.apps.map(x => x.appId) || null |
||||
|
dispatch("change", appIds) |
||||
|
} |
||||
|
|
||||
|
onMount(async () => { |
||||
|
try { |
||||
|
await groups.actions.init() |
||||
|
} catch (error) { |
||||
|
notifications.error("Error") |
||||
|
} |
||||
|
}) |
||||
|
</script> |
||||
|
|
||||
|
<PickerDropdown |
||||
|
autocomplete |
||||
|
primaryOptions={optionSections} |
||||
|
placeholder={"Filter by access"} |
||||
|
on:pickprimary={onChange} |
||||
|
/> |
||||
@ -0,0 +1,226 @@ |
|||||
|
<script> |
||||
|
import { goto } from "@roxi/routify" |
||||
|
import { |
||||
|
ActionButton, |
||||
|
Button, |
||||
|
Layout, |
||||
|
Heading, |
||||
|
Body, |
||||
|
Icon, |
||||
|
Popover, |
||||
|
notifications, |
||||
|
List, |
||||
|
ListItem, |
||||
|
StatusLight, |
||||
|
} from "@budibase/bbui" |
||||
|
import UserGroupPicker from "components/settings/UserGroupPicker.svelte" |
||||
|
import { createPaginationStore } from "helpers/pagination" |
||||
|
import { users, apps, groups } from "stores/portal" |
||||
|
import { onMount } from "svelte" |
||||
|
import { RoleUtils } from "@budibase/frontend-core" |
||||
|
|
||||
|
export let groupId |
||||
|
let popoverAnchor |
||||
|
let popover |
||||
|
let searchTerm = "" |
||||
|
let selectedUsers = [] |
||||
|
let prevSearch = undefined, |
||||
|
search = undefined |
||||
|
let pageInfo = createPaginationStore() |
||||
|
|
||||
|
$: page = $pageInfo.page |
||||
|
$: fetchUsers(page, search) |
||||
|
$: group = $groups.find(x => x._id === groupId) |
||||
|
|
||||
|
async function addAll() { |
||||
|
group.users = selectedUsers |
||||
|
await groups.actions.save(group) |
||||
|
} |
||||
|
|
||||
|
async function selectUser(id) { |
||||
|
let selectedUser = selectedUsers.includes(id) |
||||
|
if (selectedUser) { |
||||
|
selectedUsers = selectedUsers.filter(id => id !== selectedUser) |
||||
|
let newUsers = group.users.filter(user => user._id !== id) |
||||
|
group.users = newUsers |
||||
|
} else { |
||||
|
let enrichedUser = $users.data |
||||
|
.filter(user => user._id === id) |
||||
|
.map(u => { |
||||
|
return { |
||||
|
_id: u._id, |
||||
|
email: u.email, |
||||
|
} |
||||
|
})[0] |
||||
|
selectedUsers = [...selectedUsers, id] |
||||
|
group.users.push(enrichedUser) |
||||
|
} |
||||
|
|
||||
|
await groups.actions.save(group) |
||||
|
|
||||
|
let user = await users.get(id) |
||||
|
|
||||
|
let userGroups = user.userGroups || [] |
||||
|
userGroups.push(groupId) |
||||
|
await users.save({ |
||||
|
...user, |
||||
|
userGroups, |
||||
|
}) |
||||
|
} |
||||
|
$: filtered = |
||||
|
$users.data?.filter(x => !group?.users.map(y => y._id).includes(x._id)) || |
||||
|
[] |
||||
|
|
||||
|
$: groupApps = $apps.filter(x => group.apps.includes(x.appId)) |
||||
|
async function removeUser(id) { |
||||
|
let newUsers = group.users.filter(user => user._id !== id) |
||||
|
group.users = newUsers |
||||
|
let user = await users.get(id) |
||||
|
|
||||
|
await users.save({ |
||||
|
...user, |
||||
|
userGroups: [], |
||||
|
}) |
||||
|
|
||||
|
await groups.actions.save(group) |
||||
|
} |
||||
|
|
||||
|
async function fetchUsers(page, search) { |
||||
|
if ($pageInfo.loading) { |
||||
|
return |
||||
|
} |
||||
|
// need to remove the page if they've started searching |
||||
|
if (search && !prevSearch) { |
||||
|
pageInfo.reset() |
||||
|
page = undefined |
||||
|
} |
||||
|
prevSearch = search |
||||
|
try { |
||||
|
pageInfo.loading() |
||||
|
await users.search({ page, search }) |
||||
|
pageInfo.fetched($users.hasNextPage, $users.nextPage) |
||||
|
} catch (error) { |
||||
|
notifications.error("Error getting user list") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
onMount(async () => { |
||||
|
try { |
||||
|
await groups.actions.init() |
||||
|
await apps.load() |
||||
|
} catch (error) { |
||||
|
notifications.error("Error fetching User Group data") |
||||
|
} |
||||
|
}) |
||||
|
</script> |
||||
|
|
||||
|
<Layout noPadding> |
||||
|
<div> |
||||
|
<ActionButton on:click={() => $goto("../groups")} size="S" icon="ArrowLeft"> |
||||
|
Back |
||||
|
</ActionButton> |
||||
|
</div> |
||||
|
<div class="header"> |
||||
|
<div class="title"> |
||||
|
<div style="background: {group?.color};" class="circle"> |
||||
|
<div> |
||||
|
<Icon size="M" name={group?.icon} /> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="text-padding"> |
||||
|
<Heading>{group?.name}</Heading> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div bind:this={popoverAnchor}> |
||||
|
<Button on:click={popover.show()} icon="UserAdd" cta>Add User</Button> |
||||
|
</div> |
||||
|
<Popover align="right" bind:this={popover} anchor={popoverAnchor}> |
||||
|
<UserGroupPicker |
||||
|
key={"email"} |
||||
|
title={"User"} |
||||
|
bind:searchTerm |
||||
|
bind:selected={selectedUsers} |
||||
|
bind:filtered |
||||
|
{addAll} |
||||
|
select={selectUser} |
||||
|
/> |
||||
|
</Popover> |
||||
|
</div> |
||||
|
|
||||
|
<List> |
||||
|
{#if group?.users.length} |
||||
|
{#each group.users as user} |
||||
|
<ListItem title={user?.email} avatar |
||||
|
><Icon |
||||
|
on:click={() => removeUser(user?._id)} |
||||
|
hoverable |
||||
|
size="L" |
||||
|
name="Close" |
||||
|
/></ListItem |
||||
|
> |
||||
|
{/each} |
||||
|
{:else} |
||||
|
<ListItem icon="UserGroup" title="You have no users in this team" /> |
||||
|
{/if} |
||||
|
</List> |
||||
|
<div |
||||
|
style="flex-direction: column; margin-top: var(--spacing-m)" |
||||
|
class="title" |
||||
|
> |
||||
|
<Heading weight="light" size="XS">Apps</Heading> |
||||
|
<div style="margin-top: var(--spacing-xs)"> |
||||
|
<Body size="S">Manage apps that this User group has been assigned to</Body |
||||
|
> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<List> |
||||
|
{#if groupApps.length} |
||||
|
{#each groupApps as app} |
||||
|
<ListItem |
||||
|
title={app.name} |
||||
|
icon={app?.icon?.name || "Apps"} |
||||
|
iconBackground={app?.icon?.color || ""} |
||||
|
> |
||||
|
<div class="title "> |
||||
|
<StatusLight |
||||
|
color={RoleUtils.getRoleColour(group.roles[app.appId])} |
||||
|
/> |
||||
|
<div style="margin-left: var(--spacing-s);"> |
||||
|
<Body size="XS">{group.roles[app.appId]}</Body> |
||||
|
</div> |
||||
|
</div> |
||||
|
</ListItem> |
||||
|
{/each} |
||||
|
{:else} |
||||
|
<ListItem icon="UserGroup" title="No apps" /> |
||||
|
{/if} |
||||
|
</List> |
||||
|
</Layout> |
||||
|
|
||||
|
<style> |
||||
|
.text-padding { |
||||
|
margin-left: var(--spacing-l); |
||||
|
} |
||||
|
|
||||
|
.header { |
||||
|
display: flex; |
||||
|
justify-content: space-between; |
||||
|
} |
||||
|
.title { |
||||
|
display: flex; |
||||
|
} |
||||
|
.circle { |
||||
|
border-radius: 50%; |
||||
|
height: 30px; |
||||
|
color: white; |
||||
|
font-weight: bold; |
||||
|
display: inline-block; |
||||
|
font-size: 1.2em; |
||||
|
width: 30px; |
||||
|
} |
||||
|
|
||||
|
.circle > div { |
||||
|
padding: calc(1.5 * var(--spacing-xs)) var(--spacing-xs); |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,58 @@ |
|||||
|
<script> |
||||
|
import { |
||||
|
ColorPicker, |
||||
|
Body, |
||||
|
ModalContent, |
||||
|
Input, |
||||
|
IconPicker, |
||||
|
} from "@budibase/bbui" |
||||
|
|
||||
|
export let group |
||||
|
export let saveGroup |
||||
|
</script> |
||||
|
|
||||
|
<ModalContent |
||||
|
onConfirm={() => saveGroup(group)} |
||||
|
size="M" |
||||
|
title="Create User Group" |
||||
|
confirmText="Save" |
||||
|
> |
||||
|
<Input bind:value={group.name} label="Team name" /> |
||||
|
<div class="modal-format"> |
||||
|
<div class="modal-inner"> |
||||
|
<Body size="XS">Icon</Body> |
||||
|
<div class="modal-spacing"> |
||||
|
<IconPicker |
||||
|
bind:value={group.icon} |
||||
|
on:change={e => (group.icon = e.detail)} |
||||
|
/> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="modal-inner"> |
||||
|
<Body size="XS">Color</Body> |
||||
|
<div class="modal-spacing"> |
||||
|
<ColorPicker |
||||
|
bind:value={group.color} |
||||
|
on:change={e => (group.color = e.detail)} |
||||
|
/> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
</ModalContent> |
||||
|
|
||||
|
<style> |
||||
|
.modal-format { |
||||
|
display: flex; |
||||
|
justify-content: space-between; |
||||
|
width: 40%; |
||||
|
} |
||||
|
|
||||
|
.modal-inner { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
} |
||||
|
|
||||
|
.modal-spacing { |
||||
|
margin-left: var(--spacing-l); |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,129 @@ |
|||||
|
<script> |
||||
|
import { |
||||
|
Button, |
||||
|
Icon, |
||||
|
Body, |
||||
|
ActionMenu, |
||||
|
MenuItem, |
||||
|
Modal, |
||||
|
} from "@budibase/bbui" |
||||
|
import { goto } from "@roxi/routify" |
||||
|
import CreateEditGroupModal from "./CreateEditGroupModal.svelte" |
||||
|
|
||||
|
export let group |
||||
|
export let deleteGroup |
||||
|
export let saveGroup |
||||
|
let modal |
||||
|
function editGroup() { |
||||
|
modal.show() |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<div class="title"> |
||||
|
<div class="name" style="display: flex; margin-left: var(--spacing-xl)"> |
||||
|
<div style="background: {group.color};" class="circle"> |
||||
|
<div> |
||||
|
<Icon size="M" name={group.icon} /> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="name" data-cy="app-name-link"> |
||||
|
<Body size="S">{group.name}</Body> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="desktop tableElement"> |
||||
|
<Icon name="User" /> |
||||
|
<div style="margin-left: var(--spacing-l"> |
||||
|
{parseInt(group?.users?.length) || 0} user{parseInt( |
||||
|
group?.users?.length |
||||
|
) === 1 |
||||
|
? "" |
||||
|
: "s"} |
||||
|
</div> |
||||
|
</div> |
||||
|
<div class="desktop tableElement"> |
||||
|
<Icon name="WebPage" /> |
||||
|
|
||||
|
<div style="margin-left: var(--spacing-l)"> |
||||
|
{parseInt(group?.apps?.length) || 0} app{parseInt(group?.apps?.length) === 1 |
||||
|
? "" |
||||
|
: "s"} |
||||
|
</div> |
||||
|
</div> |
||||
|
<div> |
||||
|
<div class="group-row-actions"> |
||||
|
<div> |
||||
|
<Button on:click={() => $goto(`./${group._id}`)} size="S" cta |
||||
|
>Manage</Button |
||||
|
> |
||||
|
</div> |
||||
|
<div> |
||||
|
<ActionMenu align="right"> |
||||
|
<span slot="control"> |
||||
|
<Icon hoverable name="More" /> |
||||
|
</span> |
||||
|
<MenuItem on:click={() => deleteGroup(group)} icon="Delete" |
||||
|
>Delete</MenuItem |
||||
|
> |
||||
|
<MenuItem on:click={() => editGroup(group)} icon="Edit">Edit</MenuItem> |
||||
|
</ActionMenu> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<Modal bind:this={modal}> |
||||
|
<CreateEditGroupModal {group} {saveGroup} /> |
||||
|
</Modal> |
||||
|
|
||||
|
<style> |
||||
|
.group-row-actions { |
||||
|
display: flex; |
||||
|
float: right; |
||||
|
margin-right: var(--spacing-xl); |
||||
|
grid-template-columns: 75px 75px; |
||||
|
grid-gap: var(--spacing-xl); |
||||
|
} |
||||
|
.name { |
||||
|
grid-gap: var(--spacing-xl); |
||||
|
grid-template-columns: 75px 75px; |
||||
|
align-items: center; |
||||
|
} |
||||
|
.circle { |
||||
|
border-radius: 50%; |
||||
|
height: 30px; |
||||
|
color: white; |
||||
|
font-weight: bold; |
||||
|
display: inline-block; |
||||
|
font-size: 1.2em; |
||||
|
width: 30px; |
||||
|
} |
||||
|
|
||||
|
.tableElement { |
||||
|
display: flex; |
||||
|
} |
||||
|
|
||||
|
.circle > div { |
||||
|
padding: calc(1.5 * var(--spacing-xs)) var(--spacing-xs); |
||||
|
} |
||||
|
.name { |
||||
|
text-decoration: none; |
||||
|
overflow: hidden; |
||||
|
} |
||||
|
.name :global(.spectrum-Heading) { |
||||
|
overflow: hidden; |
||||
|
white-space: nowrap; |
||||
|
text-overflow: ellipsis; |
||||
|
margin-left: calc(1.5 * var(--spacing-xl)); |
||||
|
} |
||||
|
.title :global(h1:hover) { |
||||
|
color: var(--spectrum-global-color-blue-600); |
||||
|
cursor: pointer; |
||||
|
transition: color 130ms ease; |
||||
|
} |
||||
|
|
||||
|
@media (max-width: 640px) { |
||||
|
.desktop { |
||||
|
display: none !important; |
||||
|
} |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,3 @@ |
|||||
|
<div style="float: right;"> |
||||
|
<slot /> |
||||
|
</div> |
||||
@ -0,0 +1,145 @@ |
|||||
|
<script> |
||||
|
import { |
||||
|
Layout, |
||||
|
Heading, |
||||
|
Body, |
||||
|
Button, |
||||
|
Modal, |
||||
|
Tag, |
||||
|
Tags, |
||||
|
notifications, |
||||
|
} from "@budibase/bbui" |
||||
|
import { groups, auth } from "stores/portal" |
||||
|
import { onMount } from "svelte" |
||||
|
import { Constants } from "@budibase/frontend-core" |
||||
|
import CreateEditGroupModal from "./_components/CreateEditGroupModal.svelte" |
||||
|
import UserGroupsRow from "./_components/UserGroupsRow.svelte" |
||||
|
|
||||
|
$: hasGroupsLicense = $auth.user?.license.features.includes( |
||||
|
Constants.Features.USER_GROUPS |
||||
|
) |
||||
|
|
||||
|
let modal |
||||
|
let group = { |
||||
|
name: "", |
||||
|
icon: "UserGroup", |
||||
|
color: "var(--spectrum-global-color-blue-600)", |
||||
|
users: [], |
||||
|
apps: [], |
||||
|
roles: {}, |
||||
|
} |
||||
|
|
||||
|
async function deleteGroup(group) { |
||||
|
try { |
||||
|
groups.actions.delete(group) |
||||
|
} catch (error) { |
||||
|
notifications.error(`Failed to delete group`) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
async function saveGroup(group) { |
||||
|
try { |
||||
|
await groups.actions.save(group) |
||||
|
} catch (error) { |
||||
|
notifications.error(`Failed to save group`) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
onMount(async () => { |
||||
|
try { |
||||
|
if (hasGroupsLicense) { |
||||
|
await groups.actions.init() |
||||
|
} |
||||
|
} catch (error) { |
||||
|
notifications.error("Error getting User groups") |
||||
|
} |
||||
|
}) |
||||
|
</script> |
||||
|
|
||||
|
<Layout noPadding> |
||||
|
<Layout gap="XS" noPadding> |
||||
|
<div style="display: flex;"> |
||||
|
<Heading size="M">User groups</Heading> |
||||
|
{#if !hasGroupsLicense} |
||||
|
<Tags> |
||||
|
<div class="tags"> |
||||
|
<div class="tag"> |
||||
|
<Tag icon="LockClosed">Pro plan</Tag> |
||||
|
</div> |
||||
|
</div> |
||||
|
</Tags> |
||||
|
{/if} |
||||
|
</div> |
||||
|
<Body>Easily assign and manage your users access with User Groups</Body> |
||||
|
</Layout> |
||||
|
<div class="align-buttons"> |
||||
|
<Button |
||||
|
newStyles |
||||
|
icon={hasGroupsLicense ? "UserGroup" : ""} |
||||
|
cta={hasGroupsLicense} |
||||
|
on:click={hasGroupsLicense |
||||
|
? () => modal.show() |
||||
|
: window.open("https://budibase.com/pricing/", "_blank")} |
||||
|
>{hasGroupsLicense ? "Create user group" : "Upgrade Account"}</Button |
||||
|
> |
||||
|
{#if !hasGroupsLicense} |
||||
|
<Button |
||||
|
newStyles |
||||
|
secondary |
||||
|
on:click={() => { |
||||
|
window.open("https://budibase.com/pricing/", "_blank") |
||||
|
}}>View Plans</Button |
||||
|
> |
||||
|
{/if} |
||||
|
</div> |
||||
|
|
||||
|
{#if hasGroupsLicense && $groups.length} |
||||
|
<div class="groupTable"> |
||||
|
{#each $groups as group} |
||||
|
<div> |
||||
|
<UserGroupsRow {saveGroup} {deleteGroup} {group} /> |
||||
|
</div> |
||||
|
{/each} |
||||
|
</div> |
||||
|
{/if} |
||||
|
</Layout> |
||||
|
|
||||
|
<Modal bind:this={modal}> |
||||
|
<CreateEditGroupModal bind:group {saveGroup} /> |
||||
|
</Modal> |
||||
|
|
||||
|
<style> |
||||
|
.align-buttons { |
||||
|
display: flex; |
||||
|
column-gap: var(--spacing-xl); |
||||
|
} |
||||
|
.tag { |
||||
|
margin-top: var(--spacing-xs); |
||||
|
margin-left: var(--spacing-m); |
||||
|
} |
||||
|
|
||||
|
.groupTable { |
||||
|
display: grid; |
||||
|
grid-template-rows: auto; |
||||
|
align-items: center; |
||||
|
border-bottom: 1px solid var(--spectrum-alias-border-color-mid); |
||||
|
border-left: 1px solid var(--spectrum-alias-border-color-mid); |
||||
|
background: var(--spectrum-global-color-gray-50); |
||||
|
} |
||||
|
|
||||
|
.groupTable :global(> div) { |
||||
|
background: var(--bg-color); |
||||
|
|
||||
|
height: 70px; |
||||
|
display: grid; |
||||
|
align-items: center; |
||||
|
grid-gap: var(--spacing-xl); |
||||
|
grid-template-columns: 2fr 2fr 2fr auto; |
||||
|
white-space: nowrap; |
||||
|
overflow: hidden; |
||||
|
text-overflow: ellipsis; |
||||
|
padding: 0 var(--spacing-s); |
||||
|
border-top: 1px solid var(--spectrum-alias-border-color-mid); |
||||
|
border-right: 1px solid var(--spectrum-alias-border-color-mid); |
||||
|
} |
||||
|
</style> |
||||
@ -1,113 +1,86 @@ |
|||||
<script> |
<script> |
||||
import { |
import { |
||||
Body, |
|
||||
Input, |
|
||||
Label, |
Label, |
||||
|
ActionButton, |
||||
ModalContent, |
ModalContent, |
||||
notifications, |
Multiselect, |
||||
Select, |
InputDropdown, |
||||
Toggle, |
Layout, |
||||
} from "@budibase/bbui" |
} from "@budibase/bbui" |
||||
import { createValidationStore, emailValidator } from "helpers/validation" |
import { groups, auth } from "stores/portal" |
||||
import { users } from "stores/portal" |
import { Constants } from "@budibase/frontend-core" |
||||
import { createEventDispatcher } from "svelte" |
|
||||
|
|
||||
const dispatch = createEventDispatcher() |
export let showOnboardingTypeModal |
||||
const password = Math.random().toString(36).substring(2, 22) |
const password = Math.random().toString(36).substring(2, 22) |
||||
const options = ["Email onboarding", "Basic onboarding"] |
|
||||
const [email, error, touched] = createValidationStore("", emailValidator) |
|
||||
let disabled |
let disabled |
||||
let builder |
let userGroups = [] |
||||
let admin |
|
||||
let selected = "Email onboarding" |
|
||||
|
|
||||
$: basic = selected === "Basic onboarding" |
$: hasGroupsLicense = $auth.user?.license.features.includes( |
||||
|
Constants.Features.USER_GROUPS |
||||
|
) |
||||
|
|
||||
function addUser() { |
$: userData = [ |
||||
if (basic) { |
{ |
||||
createUser() |
email: "", |
||||
} else { |
role: "appUser", |
||||
createUserFlow() |
password, |
||||
} |
forceResetPassword: true, |
||||
} |
}, |
||||
|
] |
||||
async function createUser() { |
function addNewInput() { |
||||
try { |
userData = [ |
||||
await users.create({ |
...userData, |
||||
email: $email, |
{ |
||||
password, |
email: "", |
||||
builder, |
role: "appUser", |
||||
admin, |
password: Math.random().toString(36).substring(2, 22), |
||||
forceResetPassword: true, |
forceResetPassword: true, |
||||
}) |
}, |
||||
notifications.success("Successfully created user") |
] |
||||
dispatch("created") |
|
||||
} catch (error) { |
|
||||
notifications.error("Error creating user") |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
async function createUserFlow() { |
|
||||
try { |
|
||||
const res = await users.invite({ email: $email, builder, admin }) |
|
||||
notifications.success(res.message) |
|
||||
} catch (error) { |
|
||||
notifications.error("Error inviting user") |
|
||||
} |
|
||||
} |
} |
||||
</script> |
</script> |
||||
|
|
||||
<ModalContent |
<ModalContent |
||||
onConfirm={addUser} |
onConfirm={async () => |
||||
|
showOnboardingTypeModal({ users: userData, groups: userGroups })} |
||||
size="M" |
size="M" |
||||
title="Add new user" |
title="Add new user" |
||||
confirmText="Add user" |
confirmText="Add user" |
||||
confirmDisabled={disabled} |
confirmDisabled={disabled} |
||||
cancelText="Cancel" |
cancelText="Cancel" |
||||
disabled={$error} |
|
||||
showCloseIcon={false} |
showCloseIcon={false} |
||||
> |
> |
||||
<Body size="S"> |
<Layout noPadding gap="XS"> |
||||
If you have SMTP configured and an email for the new user, you can use the |
<Label>Email Address</Label> |
||||
automated email onboarding flow. Otherwise, use our basic onboarding process |
|
||||
with autogenerated passwords. |
|
||||
</Body> |
|
||||
<Select |
|
||||
placeholder={null} |
|
||||
bind:value={selected} |
|
||||
{options} |
|
||||
label="Add new user via:" |
|
||||
/> |
|
||||
|
|
||||
<Input |
{#each userData as input, index} |
||||
type="email" |
<InputDropdown |
||||
label="Email" |
inputType="email" |
||||
bind:value={$email} |
bind:inputValue={input.email} |
||||
error={$touched && $error} |
bind:dropdownValue={input.role} |
||||
placeholder="john@doe.com" |
options={Constants.BbRoles} |
||||
/> |
error={input.error} |
||||
|
/> |
||||
|
{/each} |
||||
|
<div> |
||||
|
<ActionButton on:click={addNewInput} icon="Add">Add email</ActionButton> |
||||
|
</div> |
||||
|
</Layout> |
||||
|
|
||||
{#if basic} |
{#if hasGroupsLicense} |
||||
<Input disabled label="Password" value={password} /> |
<Multiselect |
||||
|
bind:value={userGroups} |
||||
|
placeholder="Select User Groups" |
||||
|
label="User Groups" |
||||
|
options={$groups} |
||||
|
getOptionLabel={option => option.name} |
||||
|
getOptionValue={option => option._id} |
||||
|
/> |
||||
{/if} |
{/if} |
||||
|
|
||||
<div> |
|
||||
<div class="toggle"> |
|
||||
<Label size="L">Development access</Label> |
|
||||
<Toggle text="" bind:value={builder} /> |
|
||||
</div> |
|
||||
<div class="toggle"> |
|
||||
<Label size="L">Administration access</Label> |
|
||||
<Toggle text="" bind:value={admin} /> |
|
||||
</div> |
|
||||
</div> |
|
||||
</ModalContent> |
</ModalContent> |
||||
|
|
||||
<style> |
<style> |
||||
.toggle { |
:global(.spectrum-Picker) { |
||||
display: grid; |
border-top-left-radius: 0px; |
||||
grid-template-columns: 78% 1fr; |
|
||||
align-items: center; |
|
||||
width: 50%; |
|
||||
} |
} |
||||
</style> |
</style> |
||||
|
|||||
@ -0,0 +1,22 @@ |
|||||
|
<script> |
||||
|
import { Icon } from "@budibase/bbui" |
||||
|
export let value |
||||
|
</script> |
||||
|
|
||||
|
<div class="align"> |
||||
|
<div class="spacing"> |
||||
|
<Icon name="WebPage" /> |
||||
|
</div> |
||||
|
{parseInt(value?.length) || 0} |
||||
|
</div> |
||||
|
|
||||
|
<style> |
||||
|
.align { |
||||
|
display: flex; |
||||
|
overflow: hidden; |
||||
|
} |
||||
|
|
||||
|
.spacing { |
||||
|
margin-right: var(--spacing-m); |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,31 @@ |
|||||
|
<script> |
||||
|
import { goto } from "@roxi/routify" |
||||
|
import { Body, ModalContent, notifications } from "@budibase/bbui" |
||||
|
|
||||
|
import { users } from "stores/portal" |
||||
|
|
||||
|
export let user |
||||
|
|
||||
|
async function deleteUser() { |
||||
|
try { |
||||
|
await users.delete(user._id) |
||||
|
notifications.success(`User ${user?.email} deleted.`) |
||||
|
$goto("./") |
||||
|
} catch (error) { |
||||
|
notifications.error("Error deleting user") |
||||
|
} |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<ModalContent |
||||
|
warning |
||||
|
onConfirm={deleteUser} |
||||
|
title="Delete User" |
||||
|
confirmText="Delete user" |
||||
|
cancelText="Cancel" |
||||
|
showCloseIcon={false} |
||||
|
> |
||||
|
<Body> |
||||
|
Are you sure you want to delete <strong>{user?.email}</strong> |
||||
|
</Body> |
||||
|
</ModalContent> |
||||
@ -0,0 +1,36 @@ |
|||||
|
<script> |
||||
|
import { Icon, Body } from "@budibase/bbui" |
||||
|
export let value |
||||
|
</script> |
||||
|
|
||||
|
<div class="align"> |
||||
|
<div class="spacing"> |
||||
|
<Icon name="UserGroup" /> |
||||
|
</div> |
||||
|
{#if value?.length === 0} |
||||
|
<div class="opacity">0</div> |
||||
|
{:else if value?.length === 1} |
||||
|
<div class="opacity"> |
||||
|
<Body size="S">{value[0]?.name}</Body> |
||||
|
</div> |
||||
|
{:else} |
||||
|
<div class="opacity"> |
||||
|
{parseInt(value?.length) || 0} groups |
||||
|
</div> |
||||
|
{/if} |
||||
|
</div> |
||||
|
|
||||
|
<style> |
||||
|
.align { |
||||
|
display: flex; |
||||
|
overflow: hidden; |
||||
|
} |
||||
|
|
||||
|
.opacity { |
||||
|
opacity: 0.8; |
||||
|
} |
||||
|
|
||||
|
.spacing { |
||||
|
margin-right: var(--spacing-m); |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,157 @@ |
|||||
|
<script> |
||||
|
import { |
||||
|
Body, |
||||
|
ModalContent, |
||||
|
RadioGroup, |
||||
|
Multiselect, |
||||
|
notifications, |
||||
|
} from "@budibase/bbui" |
||||
|
import { groups, auth, admin } from "stores/portal" |
||||
|
import { emailValidator } from "../../../../../../helpers/validation" |
||||
|
import { Constants } from "@budibase/frontend-core" |
||||
|
|
||||
|
const BYTES_IN_MB = 1000000 |
||||
|
const FILE_SIZE_LIMIT = BYTES_IN_MB * 5 |
||||
|
const MAX_USERS_UPLOAD_LIMIT = 1000 |
||||
|
export let createUsersFromCsv |
||||
|
|
||||
|
let files = [] |
||||
|
let csvString = undefined |
||||
|
let userEmails = [] |
||||
|
let userGroups = [] |
||||
|
let usersRole = null |
||||
|
|
||||
|
$: invalidEmails = [] |
||||
|
$: hasGroupsLicense = $auth.user?.license.features.includes( |
||||
|
Constants.Features.USER_GROUPS |
||||
|
) |
||||
|
|
||||
|
const validEmails = userEmails => { |
||||
|
if ($admin.cloud && userEmails.length > MAX_USERS_UPLOAD_LIMIT) { |
||||
|
notifications.error( |
||||
|
`Max limit for upload is 1000 users. Please reduce file size and try again.` |
||||
|
) |
||||
|
return false |
||||
|
} |
||||
|
for (const email of userEmails) { |
||||
|
if (emailValidator(email) !== true) invalidEmails.push(email) |
||||
|
} |
||||
|
|
||||
|
if (!invalidEmails.length) return true |
||||
|
|
||||
|
notifications.error( |
||||
|
`Error, please check the following email${ |
||||
|
invalidEmails.length > 1 ? "s" : "" |
||||
|
}: ${invalidEmails.join(", ")}` |
||||
|
) |
||||
|
|
||||
|
return false |
||||
|
} |
||||
|
|
||||
|
async function handleFile(evt) { |
||||
|
const fileArray = Array.from(evt.target.files) |
||||
|
if (fileArray.some(file => file.size >= FILE_SIZE_LIMIT)) { |
||||
|
notifications.error( |
||||
|
`Files cannot exceed ${ |
||||
|
FILE_SIZE_LIMIT / BYTES_IN_MB |
||||
|
}MB. Please try again with smaller files.` |
||||
|
) |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
// Read CSV as plain text to upload alongside schema |
||||
|
let reader = new FileReader() |
||||
|
reader.addEventListener("load", function (e) { |
||||
|
csvString = e.target.result |
||||
|
files = fileArray |
||||
|
|
||||
|
userEmails = csvString.split("\n") |
||||
|
}) |
||||
|
reader.readAsText(fileArray[0]) |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<ModalContent |
||||
|
size="M" |
||||
|
title="Import users" |
||||
|
confirmText="Done" |
||||
|
showCancelButton={false} |
||||
|
cancelText="Cancel" |
||||
|
showCloseIcon={false} |
||||
|
onConfirm={() => createUsersFromCsv({ userEmails, usersRole, userGroups })} |
||||
|
disabled={!userEmails.length || !validEmails(userEmails) || !usersRole} |
||||
|
> |
||||
|
<Body size="S">Import your users email addrresses from a CSV</Body> |
||||
|
|
||||
|
<div class="dropzone"> |
||||
|
<input id="file-upload" accept=".csv" type="file" on:change={handleFile} /> |
||||
|
<label for="file-upload" class:uploaded={files[0]}> |
||||
|
{#if files[0]}{files[0].name}{:else}Upload{/if} |
||||
|
</label> |
||||
|
</div> |
||||
|
|
||||
|
<RadioGroup |
||||
|
bind:value={usersRole} |
||||
|
options={Constants.BuilderRoleDescriptions} |
||||
|
/> |
||||
|
|
||||
|
{#if hasGroupsLicense} |
||||
|
<Multiselect |
||||
|
bind:value={userGroups} |
||||
|
placeholder="Select User Groups" |
||||
|
label="User Groups" |
||||
|
options={$groups} |
||||
|
getOptionLabel={option => option.name} |
||||
|
getOptionValue={option => option._id} |
||||
|
/> |
||||
|
{/if} |
||||
|
</ModalContent> |
||||
|
|
||||
|
<style> |
||||
|
:global(.spectrum-Picker) { |
||||
|
border-top-left-radius: 0px; |
||||
|
} |
||||
|
|
||||
|
.dropzone { |
||||
|
text-align: center; |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
flex-direction: column; |
||||
|
border-radius: 10px; |
||||
|
transition: all 0.3s; |
||||
|
} |
||||
|
.uploaded { |
||||
|
color: var(--blue); |
||||
|
} |
||||
|
|
||||
|
label { |
||||
|
font-family: var(--font-sans); |
||||
|
cursor: pointer; |
||||
|
font-weight: 600; |
||||
|
box-sizing: border-box; |
||||
|
overflow: hidden; |
||||
|
border-radius: var(--border-radius-s); |
||||
|
color: var(--ink); |
||||
|
padding: var(--spacing-m) var(--spacing-l); |
||||
|
transition: all 0.2s ease 0s; |
||||
|
display: inline-flex; |
||||
|
text-rendering: optimizeLegibility; |
||||
|
min-width: auto; |
||||
|
outline: none; |
||||
|
font-feature-settings: "case" 1, "rlig" 1, "calt" 0; |
||||
|
-webkit-box-align: center; |
||||
|
user-select: none; |
||||
|
flex-shrink: 0; |
||||
|
align-items: center; |
||||
|
justify-content: center; |
||||
|
width: 100%; |
||||
|
background-color: var(--grey-2); |
||||
|
font-size: var(--font-size-xs); |
||||
|
line-height: normal; |
||||
|
border: var(--border-transparent); |
||||
|
} |
||||
|
|
||||
|
input[type="file"] { |
||||
|
display: none; |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,38 @@ |
|||||
|
<script> |
||||
|
import { Avatar } from "@budibase/bbui" |
||||
|
|
||||
|
export let value |
||||
|
</script> |
||||
|
|
||||
|
<div class="align"> |
||||
|
{#if value} |
||||
|
<div class="spacing"> |
||||
|
<Avatar |
||||
|
size="L" |
||||
|
initials={value |
||||
|
.split(" ") |
||||
|
.map(x => x[0]) |
||||
|
.join("")} |
||||
|
/> |
||||
|
</div> |
||||
|
{value} |
||||
|
{:else} |
||||
|
<div class="text">Not Available</div> |
||||
|
{/if} |
||||
|
</div> |
||||
|
|
||||
|
<style> |
||||
|
.align { |
||||
|
display: flex; |
||||
|
align-items: center; |
||||
|
overflow: hidden; |
||||
|
} |
||||
|
|
||||
|
.spacing { |
||||
|
margin-right: var(--spacing-m); |
||||
|
} |
||||
|
|
||||
|
.text { |
||||
|
opacity: 0.8; |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,108 @@ |
|||||
|
<script> |
||||
|
import { ModalContent, Body, Layout, Icon } from "@budibase/bbui" |
||||
|
|
||||
|
export let chooseCreationType |
||||
|
let emailOnboardingKey = "emailOnboarding" |
||||
|
let basicOnboaridngKey = "basicOnboarding" |
||||
|
|
||||
|
let selectedOnboardingType |
||||
|
</script> |
||||
|
|
||||
|
<ModalContent |
||||
|
size="M" |
||||
|
title="Choose your onboarding" |
||||
|
confirmText="Done" |
||||
|
cancelText="Cancel" |
||||
|
showCloseIcon={false} |
||||
|
onConfirm={() => chooseCreationType(selectedOnboardingType)} |
||||
|
disabled={!selectedOnboardingType} |
||||
|
> |
||||
|
<Layout noPadding gap="S"> |
||||
|
<div |
||||
|
class="onboarding-type item" |
||||
|
class:selected={selectedOnboardingType == emailOnboardingKey} |
||||
|
on:click={() => { |
||||
|
selectedOnboardingType = emailOnboardingKey |
||||
|
}} |
||||
|
> |
||||
|
<div class="content onboarding-type-wrap"> |
||||
|
<Icon name="WebPage" /> |
||||
|
<div class="onboarding-type-text"> |
||||
|
<Body size="S">Send email invites</Body> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div style="color: var(--spectrum-global-color-green-600); float: right"> |
||||
|
{#if selectedOnboardingType == emailOnboardingKey} |
||||
|
<div class="checkmark-spacing"> |
||||
|
<Icon size="S" name="CheckmarkCircle" /> |
||||
|
</div> |
||||
|
{/if} |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div |
||||
|
class="onboarding-type item" |
||||
|
class:selected={selectedOnboardingType == basicOnboaridngKey} |
||||
|
on:click={() => { |
||||
|
selectedOnboardingType = basicOnboaridngKey |
||||
|
}} |
||||
|
> |
||||
|
<div class="content onboarding-type-wrap"> |
||||
|
<Icon name="Key" /> |
||||
|
<div class="onboarding-type-text"> |
||||
|
<Body size="S">Generate passwords for each user</Body> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div style="color: var(--spectrum-global-color-green-600); float: right"> |
||||
|
{#if selectedOnboardingType == basicOnboaridngKey} |
||||
|
<div class="checkmark-spacing"> |
||||
|
<Icon size="S" name="CheckmarkCircle" /> |
||||
|
</div> |
||||
|
{/if} |
||||
|
</div> |
||||
|
</div> |
||||
|
</Layout> |
||||
|
</ModalContent> |
||||
|
|
||||
|
<style> |
||||
|
.onboarding-type.item { |
||||
|
padding: var(--spectrum-alias-item-padding-xl); |
||||
|
} |
||||
|
.onboarding-type-wrap { |
||||
|
display: flex; |
||||
|
flex-direction: row; |
||||
|
align-items: center; |
||||
|
} |
||||
|
.checkmark-spacing { |
||||
|
margin-right: var(--spacing-m); |
||||
|
} |
||||
|
.content { |
||||
|
letter-spacing: 0px; |
||||
|
} |
||||
|
.item { |
||||
|
cursor: pointer; |
||||
|
grid-gap: var(--spectrum-alias-grid-margin-xsmall); |
||||
|
padding: var(--spectrum-alias-item-padding-s); |
||||
|
background: var(--spectrum-alias-background-color-primary); |
||||
|
transition: 0.3s all; |
||||
|
border: 1px solid var(--spectrum-global-color-gray-300); |
||||
|
border-radius: 4px; |
||||
|
border-width: 1px; |
||||
|
display: flex; |
||||
|
justify-content: space-between; |
||||
|
align-items: center; |
||||
|
} |
||||
|
.item:hover, |
||||
|
.selected { |
||||
|
background: var(--spectrum-alias-background-color-tertiary); |
||||
|
} |
||||
|
.onboarding-type-wrap .onboarding-type-text { |
||||
|
padding-left: var(--spectrum-alias-item-padding-xl); |
||||
|
} |
||||
|
.onboarding-type-wrap :global(.spectrum-Icon) { |
||||
|
min-width: var(--spectrum-icon-size-m); |
||||
|
} |
||||
|
.onboarding-type-wrap :global(.spectrum-Heading) { |
||||
|
padding-bottom: var(--spectrum-alias-item-padding-s); |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,15 @@ |
|||||
|
<script> |
||||
|
import { InternalRenderer } from "@budibase/bbui" |
||||
|
|
||||
|
export let value |
||||
|
</script> |
||||
|
|
||||
|
<div style="display: flex; "> |
||||
|
{value} |
||||
|
<div style="margin-left: 1.5rem;"> |
||||
|
<InternalRenderer {value} /> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<style> |
||||
|
</style> |
||||
@ -0,0 +1,94 @@ |
|||||
|
<script> |
||||
|
import { Body, ModalContent, Table, Icon } from "@budibase/bbui" |
||||
|
import PasswordCopyRenderer from "./PasswordCopyRenderer.svelte" |
||||
|
import { parseToCsv } from "helpers/data/utils" |
||||
|
|
||||
|
export let userData |
||||
|
|
||||
|
$: mappedData = userData.map(user => { |
||||
|
return { |
||||
|
email: user.email, |
||||
|
password: user.password, |
||||
|
} |
||||
|
}) |
||||
|
|
||||
|
const schema = { |
||||
|
email: {}, |
||||
|
password: {}, |
||||
|
} |
||||
|
|
||||
|
const downloadCsvFile = () => { |
||||
|
const fileName = "passwords.csv" |
||||
|
const content = parseToCsv(["email", "password"], mappedData) |
||||
|
|
||||
|
download(fileName, content) |
||||
|
} |
||||
|
|
||||
|
const download = (filename, text) => { |
||||
|
const element = document.createElement("a") |
||||
|
element.setAttribute( |
||||
|
"href", |
||||
|
"data:text/csv;charset=utf-8," + encodeURIComponent(text) |
||||
|
) |
||||
|
element.setAttribute("download", filename) |
||||
|
|
||||
|
element.style.display = "none" |
||||
|
document.body.appendChild(element) |
||||
|
|
||||
|
element.click() |
||||
|
|
||||
|
document.body.removeChild(element) |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<ModalContent |
||||
|
size="S" |
||||
|
title="Accounts created!" |
||||
|
confirmText="Done" |
||||
|
showCancelButton={false} |
||||
|
cancelText="Cancel" |
||||
|
showCloseIcon={false} |
||||
|
> |
||||
|
<Body size="XS" |
||||
|
>All your new users can be accessed through the autogenerated passwords. |
||||
|
Make not of these passwords or download the csv</Body |
||||
|
> |
||||
|
|
||||
|
<div class="container" on:click={downloadCsvFile}> |
||||
|
<div class="inner"> |
||||
|
<Icon name="Download" /> |
||||
|
|
||||
|
<div style="margin-left: var(--spacing-m)"> |
||||
|
<Body size="XS">Passwords CSV</Body> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<Table |
||||
|
{schema} |
||||
|
data={mappedData} |
||||
|
allowEditColumns={false} |
||||
|
allowEditRows={false} |
||||
|
allowSelectRows={false} |
||||
|
customRenderers={[{ column: "password", component: PasswordCopyRenderer }]} |
||||
|
/> |
||||
|
</ModalContent> |
||||
|
|
||||
|
<style> |
||||
|
.inner { |
||||
|
display: flex; |
||||
|
} |
||||
|
|
||||
|
:global(.spectrum-Picker) { |
||||
|
border-top-left-radius: 0px; |
||||
|
} |
||||
|
|
||||
|
.container { |
||||
|
width: 100%; |
||||
|
height: var(--spectrum-alias-item-height-l); |
||||
|
background: #009562; |
||||
|
display: flex; |
||||
|
justify-content: center; |
||||
|
align-items: center; |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,16 @@ |
|||||
|
<script> |
||||
|
import { users } from "stores/portal" |
||||
|
import { Constants } from "@budibase/frontend-core" |
||||
|
|
||||
|
export let row |
||||
|
$: value = |
||||
|
Constants.BbRoles.find(x => x.value === users.getUserRole(row))?.label || |
||||
|
"Not Available" |
||||
|
</script> |
||||
|
|
||||
|
<div on:click|stopPropagation> |
||||
|
{value} |
||||
|
</div> |
||||
|
|
||||
|
<style> |
||||
|
</style> |
||||
@ -0,0 +1,267 @@ |
|||||
|
<script> |
||||
|
import { |
||||
|
Layout, |
||||
|
Heading, |
||||
|
Body, |
||||
|
Button, |
||||
|
List, |
||||
|
ListItem, |
||||
|
Modal, |
||||
|
notifications, |
||||
|
Pagination, |
||||
|
Icon, |
||||
|
} from "@budibase/bbui" |
||||
|
import { onMount } from "svelte" |
||||
|
|
||||
|
import RoleSelect from "components/common/RoleSelect.svelte" |
||||
|
import { users, groups, apps, auth } from "stores/portal" |
||||
|
import AssignmentModal from "./AssignmentModal.svelte" |
||||
|
import { createPaginationStore } from "helpers/pagination" |
||||
|
import { Constants } from "@budibase/frontend-core" |
||||
|
import { roles } from "stores/backend" |
||||
|
|
||||
|
export let app |
||||
|
let assignmentModal |
||||
|
let appGroups = [] |
||||
|
let appUsers = [] |
||||
|
let prevSearch = undefined, |
||||
|
search = undefined |
||||
|
let pageInfo = createPaginationStore() |
||||
|
let fixedAppId |
||||
|
$: page = $pageInfo.page |
||||
|
$: fetchUsers(page, search) |
||||
|
|
||||
|
$: hasGroupsLicense = $auth.user?.license.features.includes( |
||||
|
Constants.Features.USER_GROUPS |
||||
|
) |
||||
|
|
||||
|
$: fixedAppId = apps.getProdAppID(app.devId) |
||||
|
|
||||
|
$: appUsers = |
||||
|
$users.data?.filter(x => { |
||||
|
return Object.keys(x.roles).find(y => { |
||||
|
return y === fixedAppId |
||||
|
}) |
||||
|
}) || [] |
||||
|
$: appGroups = $groups.filter(x => { |
||||
|
return x.apps.includes(app.appId) |
||||
|
}) |
||||
|
|
||||
|
async function addData(appData) { |
||||
|
let gr_prefix = "gr" |
||||
|
let us_prefix = "us" |
||||
|
appData.forEach(async data => { |
||||
|
if (data.id.startsWith(gr_prefix)) { |
||||
|
let matchedGroup = $groups.find(group => { |
||||
|
return group._id === data.id |
||||
|
}) |
||||
|
matchedGroup.apps.push(app.appId) |
||||
|
matchedGroup.roles[fixedAppId] = data.role |
||||
|
|
||||
|
groups.actions.save(matchedGroup) |
||||
|
} else if (data.id.startsWith(us_prefix)) { |
||||
|
let matchedUser = $users.data.find(user => { |
||||
|
return user._id === data.id |
||||
|
}) |
||||
|
|
||||
|
let newUser = { |
||||
|
...matchedUser, |
||||
|
roles: { [fixedAppId]: data.role, ...matchedUser.roles }, |
||||
|
} |
||||
|
|
||||
|
await users.save(newUser, { opts: { appId: fixedAppId } }) |
||||
|
await fetchUsers(page, search) |
||||
|
} |
||||
|
}) |
||||
|
await groups.actions.init() |
||||
|
} |
||||
|
|
||||
|
async function removeUser(user) { |
||||
|
// Remove the user role |
||||
|
const filteredRoles = { ...user.roles } |
||||
|
delete filteredRoles[fixedAppId] |
||||
|
await users.save({ |
||||
|
...user, |
||||
|
roles: { |
||||
|
...filteredRoles, |
||||
|
}, |
||||
|
}) |
||||
|
await fetchUsers(page, search) |
||||
|
} |
||||
|
|
||||
|
async function removeGroup(group) { |
||||
|
// Remove the user role |
||||
|
let filteredApps = group.apps.filter( |
||||
|
x => apps.extractAppId(x) !== app.appId |
||||
|
) |
||||
|
const filteredRoles = { ...group.roles } |
||||
|
delete filteredRoles[fixedAppId] |
||||
|
|
||||
|
await groups.actions.save({ |
||||
|
...group, |
||||
|
apps: filteredApps, |
||||
|
roles: { ...filteredRoles }, |
||||
|
}) |
||||
|
|
||||
|
await fetchUsers(page, search) |
||||
|
} |
||||
|
|
||||
|
async function updateUserRole(role, user) { |
||||
|
user.roles[fixedAppId] = role |
||||
|
users.save(user) |
||||
|
} |
||||
|
|
||||
|
async function updateGroupRole(role, group) { |
||||
|
group.roles[fixedAppId] = role |
||||
|
groups.actions.save(group) |
||||
|
} |
||||
|
|
||||
|
async function fetchUsers(page, search) { |
||||
|
if ($pageInfo.loading) { |
||||
|
return |
||||
|
} |
||||
|
// need to remove the page if they've started searching |
||||
|
if (search && !prevSearch) { |
||||
|
pageInfo.reset() |
||||
|
page = undefined |
||||
|
} |
||||
|
prevSearch = search |
||||
|
try { |
||||
|
pageInfo.loading() |
||||
|
await users.search({ page, appId: fixedAppId }) |
||||
|
pageInfo.fetched($users.hasNextPage, $users.nextPage) |
||||
|
} catch (error) { |
||||
|
notifications.error("Error getting user list") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
onMount(async () => { |
||||
|
try { |
||||
|
await groups.actions.init() |
||||
|
await apps.load() |
||||
|
await roles.fetch() |
||||
|
} catch (error) { |
||||
|
notifications.error(error) |
||||
|
} |
||||
|
}) |
||||
|
</script> |
||||
|
|
||||
|
<div class="access-tab"> |
||||
|
<Layout> |
||||
|
{#if appGroups.length || appUsers.length} |
||||
|
<div> |
||||
|
<Heading>Access</Heading> |
||||
|
<div class="subtitle"> |
||||
|
<Body size="S"> |
||||
|
Assign users to your app and define their access here</Body |
||||
|
> |
||||
|
<Button on:click={assignmentModal.show} icon="User" cta |
||||
|
>Assign users</Button |
||||
|
> |
||||
|
</div> |
||||
|
</div> |
||||
|
{#if hasGroupsLicense && appGroups.length} |
||||
|
<List title="User Groups"> |
||||
|
{#each appGroups as group} |
||||
|
<ListItem |
||||
|
title={group.name} |
||||
|
icon={group.icon} |
||||
|
iconBackground={group.color} |
||||
|
> |
||||
|
<RoleSelect |
||||
|
on:change={e => updateGroupRole(e.detail, group)} |
||||
|
autoWidth |
||||
|
quiet |
||||
|
value={group.roles[ |
||||
|
Object.keys(group.roles).find(x => x === fixedAppId) |
||||
|
]} |
||||
|
/> |
||||
|
<Icon |
||||
|
on:click={() => removeGroup(group)} |
||||
|
hoverable |
||||
|
size="S" |
||||
|
name="Close" |
||||
|
/> |
||||
|
</ListItem> |
||||
|
{/each} |
||||
|
</List> |
||||
|
{/if} |
||||
|
{#if appUsers.length} |
||||
|
<List title="Users"> |
||||
|
{#each appUsers as user} |
||||
|
<ListItem title={user.email} avatar> |
||||
|
<RoleSelect |
||||
|
on:change={e => updateUserRole(e.detail, user)} |
||||
|
autoWidth |
||||
|
quiet |
||||
|
value={user.roles[ |
||||
|
Object.keys(user.roles).find(x => x === fixedAppId) |
||||
|
]} |
||||
|
/> |
||||
|
<Icon |
||||
|
on:click={() => removeUser(user)} |
||||
|
hoverable |
||||
|
size="S" |
||||
|
name="Close" |
||||
|
/> |
||||
|
</ListItem> |
||||
|
{/each} |
||||
|
</List> |
||||
|
<div class="pagination"> |
||||
|
<Pagination |
||||
|
page={$pageInfo.pageNumber} |
||||
|
hasPrevPage={$pageInfo.loading ? false : $pageInfo.hasPrevPage} |
||||
|
hasNextPage={$pageInfo.loading ? false : $pageInfo.hasNextPage} |
||||
|
goToPrevPage={pageInfo.prevPage} |
||||
|
goToNextPage={pageInfo.nextPage} |
||||
|
/> |
||||
|
</div> |
||||
|
{/if} |
||||
|
{:else} |
||||
|
<div class="align"> |
||||
|
<Layout gap="S"> |
||||
|
<Heading>No users assigned</Heading> |
||||
|
<div class="opacity"> |
||||
|
<Body size="S" |
||||
|
>Assign users to your app and set their access here</Body |
||||
|
> |
||||
|
</div> |
||||
|
<div class="padding"> |
||||
|
<Button on:click={() => assignmentModal.show()} cta icon="UserArrow" |
||||
|
>Assign Users</Button |
||||
|
> |
||||
|
</div> |
||||
|
</Layout> |
||||
|
</div> |
||||
|
{/if} |
||||
|
</Layout> |
||||
|
</div> |
||||
|
|
||||
|
<Modal bind:this={assignmentModal}> |
||||
|
<AssignmentModal {app} {appUsers} {addData} /> |
||||
|
</Modal> |
||||
|
|
||||
|
<style> |
||||
|
.access-tab { |
||||
|
max-width: 600px; |
||||
|
margin: 0 auto; |
||||
|
padding: 40px; |
||||
|
} |
||||
|
|
||||
|
.padding { |
||||
|
margin-top: var(--spacing-m); |
||||
|
} |
||||
|
.opacity { |
||||
|
opacity: 0.8; |
||||
|
} |
||||
|
|
||||
|
.align { |
||||
|
text-align: center; |
||||
|
} |
||||
|
.subtitle { |
||||
|
display: flex; |
||||
|
flex-direction: row; |
||||
|
justify-content: space-between; |
||||
|
align-items: center; |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,103 @@ |
|||||
|
<script> |
||||
|
import { |
||||
|
ModalContent, |
||||
|
PickerDropdown, |
||||
|
ActionButton, |
||||
|
notifications, |
||||
|
} from "@budibase/bbui" |
||||
|
import { roles } from "stores/backend" |
||||
|
import { groups, users } from "stores/portal" |
||||
|
import { RoleUtils } from "@budibase/frontend-core" |
||||
|
import { createPaginationStore } from "helpers/pagination" |
||||
|
|
||||
|
export let app |
||||
|
export let addData |
||||
|
export let appUsers = [] |
||||
|
|
||||
|
let prevSearch = undefined, |
||||
|
search = undefined |
||||
|
let pageInfo = createPaginationStore() |
||||
|
|
||||
|
$: page = $pageInfo.page |
||||
|
$: fetchUsers(page, search) |
||||
|
async function fetchUsers(page, search) { |
||||
|
if ($pageInfo.loading) { |
||||
|
return |
||||
|
} |
||||
|
// need to remove the page if they've started searching |
||||
|
if (search && !prevSearch) { |
||||
|
pageInfo.reset() |
||||
|
page = undefined |
||||
|
} |
||||
|
prevSearch = search |
||||
|
try { |
||||
|
pageInfo.loading() |
||||
|
await users.search({ page, search }) |
||||
|
pageInfo.fetched($users.hasNextPage, $users.nextPage) |
||||
|
} catch (error) { |
||||
|
notifications.error("Error getting user list") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
$: filteredGroups = $groups.filter(element => { |
||||
|
return !element.apps.find(y => { |
||||
|
return y.appId === app.appId |
||||
|
}) |
||||
|
}) |
||||
|
|
||||
|
$: optionSections = { |
||||
|
...(filteredGroups.length && { |
||||
|
groups: { |
||||
|
data: filteredGroups, |
||||
|
getLabel: group => group.name, |
||||
|
getValue: group => group._id, |
||||
|
getIcon: group => group.icon, |
||||
|
getColour: group => group.color, |
||||
|
}, |
||||
|
}), |
||||
|
users: { |
||||
|
data: $users.data.filter(u => !appUsers.find(x => x._id === u._id)), |
||||
|
getLabel: user => user.email, |
||||
|
getValue: user => user._id, |
||||
|
getIcon: user => user.icon, |
||||
|
getColour: user => user.color, |
||||
|
}, |
||||
|
} |
||||
|
|
||||
|
$: appData = [{ id: "", role: "" }] |
||||
|
|
||||
|
function addNewInput() { |
||||
|
appData = [...appData, { id: "", role: "" }] |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<ModalContent |
||||
|
size="M" |
||||
|
title="Assign users to your app" |
||||
|
confirmText="Done" |
||||
|
cancelText="Cancel" |
||||
|
onConfirm={() => addData(appData)} |
||||
|
showCloseIcon={false} |
||||
|
> |
||||
|
{#each appData as input, index} |
||||
|
<PickerDropdown |
||||
|
autocomplete |
||||
|
primaryOptions={optionSections} |
||||
|
placeholder={"Search Users"} |
||||
|
secondaryOptions={$roles} |
||||
|
bind:primaryValue={input.id} |
||||
|
bind:secondaryValue={input.role} |
||||
|
getPrimaryOptionLabel={group => group.name} |
||||
|
getPrimaryOptionValue={group => group.name} |
||||
|
getPrimaryOptionIcon={group => group.icon} |
||||
|
getPrimaryOptionColour={group => group.colour} |
||||
|
getSecondaryOptionLabel={role => role.name} |
||||
|
getSecondaryOptionValue={role => role._id} |
||||
|
getSecondaryOptionColour={role => RoleUtils.getRoleColour(role._id)} |
||||
|
/> |
||||
|
{/each} |
||||
|
|
||||
|
<div> |
||||
|
<ActionButton on:click={addNewInput} icon="Add">Add email</ActionButton> |
||||
|
</div> |
||||
|
</ModalContent> |
||||
@ -0,0 +1,54 @@ |
|||||
|
import { writable, get } from "svelte/store" |
||||
|
import { API } from "api" |
||||
|
import { auth } from "stores/portal" |
||||
|
import { Constants } from "@budibase/frontend-core" |
||||
|
|
||||
|
export function createGroupsStore() { |
||||
|
const store = writable([]) |
||||
|
|
||||
|
const actions = { |
||||
|
init: async () => { |
||||
|
// only init if these is a groups license, just to be sure but the feature will be blocked
|
||||
|
// on the backend anyway
|
||||
|
if ( |
||||
|
get(auth).user.license.features.includes(Constants.Features.USER_GROUPS) |
||||
|
) { |
||||
|
const users = await API.getGroups() |
||||
|
store.set(users) |
||||
|
} |
||||
|
}, |
||||
|
|
||||
|
save: async group => { |
||||
|
const response = await API.saveGroup(group) |
||||
|
group._id = response._id |
||||
|
group._rev = response._rev |
||||
|
store.update(state => { |
||||
|
const currentIdx = state.findIndex(gr => gr._id === response._id) |
||||
|
if (currentIdx >= 0) { |
||||
|
state.splice(currentIdx, 1, group) |
||||
|
} else { |
||||
|
state.push(group) |
||||
|
} |
||||
|
return state |
||||
|
}) |
||||
|
}, |
||||
|
|
||||
|
delete: async group => { |
||||
|
await API.deleteGroup({ |
||||
|
id: group._id, |
||||
|
rev: group._rev, |
||||
|
}) |
||||
|
store.update(state => { |
||||
|
state = state.filter(state => state._id !== group._id) |
||||
|
return state |
||||
|
}) |
||||
|
}, |
||||
|
} |
||||
|
|
||||
|
return { |
||||
|
subscribe: store.subscribe, |
||||
|
actions, |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export const groups = createGroupsStore() |
||||
@ -0,0 +1,40 @@ |
|||||
|
export const buildGroupsEndpoints = API => ({ |
||||
|
/** |
||||
|
* Creates a user group. |
||||
|
* @param user the new group to create |
||||
|
*/ |
||||
|
saveGroup: async group => { |
||||
|
return await API.post({ |
||||
|
url: "/api/global/groups", |
||||
|
body: group, |
||||
|
}) |
||||
|
}, |
||||
|
/** |
||||
|
* Gets all of the user groups |
||||
|
*/ |
||||
|
getGroups: async () => { |
||||
|
return await API.get({ |
||||
|
url: "/api/global/groups", |
||||
|
}) |
||||
|
}, |
||||
|
|
||||
|
/** |
||||
|
* Gets a group by ID |
||||
|
*/ |
||||
|
getGroup: async id => { |
||||
|
return await API.get({ |
||||
|
url: `/api/global/groups/${id}`, |
||||
|
}) |
||||
|
}, |
||||
|
|
||||
|
/** |
||||
|
* Deletes a user group |
||||
|
* @param id the id of the config to delete |
||||
|
* @param rev the revision of the config to delete |
||||
|
*/ |
||||
|
deleteGroup: async ({ id, rev }) => { |
||||
|
return await API.delete({ |
||||
|
url: `/api/global/groups/${id}/${rev}`, |
||||
|
}) |
||||
|
}, |
||||
|
}) |
||||
File diff suppressed because it is too large
@ -1,2 +1,3 @@ |
|||||
export * from "./config" |
export * from "./config" |
||||
export * from "./user" |
export * from "./user" |
||||
|
export * from "./userGroup" |
||||
|
|||||
@ -0,0 +1,19 @@ |
|||||
|
import { Document } from "../document" |
||||
|
import { User } from "./user" |
||||
|
export interface UserGroup extends Document { |
||||
|
name: string |
||||
|
icon: string |
||||
|
color: string |
||||
|
users: groupUser[] |
||||
|
apps: string[] |
||||
|
roles: UserGroupRoles |
||||
|
createdAt?: number |
||||
|
} |
||||
|
|
||||
|
export interface groupUser { |
||||
|
_id: string |
||||
|
email: string[] |
||||
|
} |
||||
|
export interface UserGroupRoles { |
||||
|
[key: string]: string |
||||
|
} |
||||
@ -0,0 +1,28 @@ |
|||||
|
import { BaseEvent } from "./event" |
||||
|
|
||||
|
export interface GroupCreatedEvent extends BaseEvent { |
||||
|
groupId: string |
||||
|
} |
||||
|
|
||||
|
export interface GroupUpdatedEvent extends BaseEvent { |
||||
|
groupId: string |
||||
|
} |
||||
|
|
||||
|
export interface GroupDeletedEvent extends BaseEvent { |
||||
|
groupId: string |
||||
|
} |
||||
|
|
||||
|
export interface GroupUsersAddedEvent extends BaseEvent { |
||||
|
count: number |
||||
|
groupId: string |
||||
|
} |
||||
|
|
||||
|
export interface GroupUsersDeletedEvent extends BaseEvent { |
||||
|
count: number |
||||
|
groupId: string |
||||
|
} |
||||
|
|
||||
|
export interface GroupAddedOnboardingEvent extends BaseEvent { |
||||
|
groupId: string |
||||
|
onboarding: boolean |
||||
|
} |
||||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue