1
0
Fork 0
mirror of synced 2024-05-06 13:32:37 +12:00
budibase/packages/builder/src/pages/builder/portal/manage/auth/index.svelte

608 lines
17 KiB
Svelte

<script>
import GoogleLogo from "./_logos/Google.svelte"
import OidcLogo from "./_logos/OIDC.svelte"
import MicrosoftLogo from "assets/microsoft-logo.png"
import Auth0Logo from "assets/auth0-logo.png"
import OktaLogo from "assets/okta-logo.png"
import OneLoginLogo from "assets/onelogin-logo.png"
import OidcLogoPng from "assets/oidc-logo.png"
import { isEqual, cloneDeep } from "lodash/fp"
import {
Button,
Heading,
Divider,
Label,
notifications,
Layout,
Input,
Body,
Select,
Toggle,
Tag,
Tags,
Icon,
Helpers,
} from "@budibase/bbui"
import { onMount } from "svelte"
import { API } from "api"
import { organisation, admin } from "stores/portal"
const ConfigTypes = {
Google: "google",
OIDC: "oidc",
}
const HasSpacesRegex = /[\\"\s]/
// Some older google configs contain a manually specified value - retain the functionality to edit the field
// When there is no value or we are in the cloud - prohibit editing the field, must use platform url to change
$: googleCallbackUrl = undefined
$: googleCallbackReadonly = $admin.cloud || !googleCallbackUrl
// Indicate to user that callback is based on platform url
// If there is an existing value, indicate that it may be removed to return to default behaviour
$: googleCallbackTooltip = $admin.cloud
? null
: googleCallbackReadonly
? "Vist the organisation page to update the platform URL"
: "Leave blank to use the default callback URL"
$: GoogleConfigFields = {
Google: [
{ name: "clientID", label: "Client ID" },
{ name: "clientSecret", label: "Client secret" },
{
name: "callbackURL",
label: "Callback URL",
readonly: googleCallbackReadonly,
tooltip: googleCallbackTooltip,
placeholder: $organisation.googleCallbackUrl,
copyButton: true,
},
],
}
$: OIDCConfigFields = {
Oidc: [
{ name: "configUrl", label: "Config URL" },
{ name: "clientID", label: "Client ID" },
{ name: "clientSecret", label: "Client Secret" },
{
name: "callbackURL",
readonly: true,
tooltip: $admin.cloud
? null
: "Vist the organisation page to update the platform URL",
label: "Callback URL",
placeholder: $organisation.oidcCallbackUrl,
copyButton: true,
},
],
}
let iconDropdownOptions = [
{
label: "Microsoft",
value: "Microsoft",
icon: MicrosoftLogo,
},
{
label: "Okta",
value: "Okta",
icon: OktaLogo,
},
{
label: "OneLogin",
value: "OneLogin",
icon: OneLoginLogo,
},
{
label: "Auth0",
value: "Auth0",
icon: Auth0Logo,
},
{
label: "OIDC",
value: "Oidc",
icon: OidcLogoPng,
},
{
label: "Upload your own",
value: "Upload",
},
]
let fileinput
let image
let google
let oidc
const providers = { google, oidc }
// control the state of the save button depending on whether form has changed
let originalGoogleDoc
let originalOidcDoc
let googleSaveButtonDisabled
let oidcSaveButtonDisabled
$: {
isEqual(providers.google?.config, originalGoogleDoc?.config)
? (googleSaveButtonDisabled = true)
: (googleSaveButtonDisabled = false)
isEqual(providers.oidc?.config, originalOidcDoc?.config)
? (oidcSaveButtonDisabled = true)
: (oidcSaveButtonDisabled = false)
}
// Create a flag so that it will only try to save completed forms
$: partialGoogle =
providers.google?.config?.clientID || providers.google?.config?.clientSecret
$: partialOidc =
providers.oidc?.config?.configs[0].configUrl ||
providers.oidc?.config?.configs[0].clientID ||
providers.oidc?.config?.configs[0].clientSecret
$: googleComplete =
providers.google?.config?.clientID && providers.google?.config?.clientSecret
$: oidcComplete =
providers.oidc?.config?.configs[0].configUrl &&
providers.oidc?.config?.configs[0].clientID &&
providers.oidc?.config?.configs[0].clientSecret
const onFileSelected = e => {
let fileName = e.target.files[0].name
image = e.target.files[0]
providers.oidc.config.configs[0].logo = fileName
iconDropdownOptions.unshift({ label: fileName, value: fileName })
}
async function save(docs) {
let calls = []
// Only if the user has provided an image, upload it
if (image) {
let data = new FormData()
data.append("file", image)
calls.push(
API.uploadOIDCLogo({
name: image.name,
data,
})
)
}
docs.forEach(element => {
// Delete unsupported fields
delete element.createdAt
delete element.updatedAt
if (element.type === ConfigTypes.OIDC) {
// Add a UUID here so each config is distinguishable when it arrives at the login page
for (let config of element.config.configs) {
if (!config.uuid) {
config.uuid = Helpers.uuid()
}
// Callback urls shouldn't be included
delete config.callbackURL
}
if (partialOidc) {
if (!oidcComplete) {
notifications.error(
`Please fill in all required ${ConfigTypes.OIDC} fields`
)
} else {
calls.push(API.saveConfig(element))
// Turn the save button grey when clicked
oidcSaveButtonDisabled = true
originalOidcDoc = cloneDeep(providers.oidc)
}
}
}
if (element.type === ConfigTypes.Google) {
if (partialGoogle) {
if (!googleComplete) {
notifications.error(
`Please fill in all required ${ConfigTypes.Google} fields`
)
} else {
calls.push(API.saveConfig(element))
googleSaveButtonDisabled = true
originalGoogleDoc = cloneDeep(providers.google)
}
}
}
})
if (calls.length) {
Promise.all(calls)
.then(data => {
data.forEach(res => {
providers[res.type]._rev = res._rev
providers[res.type]._id = res._id
})
notifications.success(`Settings saved`)
})
.catch(() => {
notifications.error("Failed to update auth settings")
})
}
}
let defaultScopes = ["profile", "email", "offline_access"]
const refreshScopes = idx => {
providers.oidc.config.configs[idx]["scopes"] =
providers.oidc.config.configs[idx]["scopes"]
}
let scopesFields = [
{
editing: true,
inputText: null,
error: null,
},
]
const copyToClipboard = async value => {
await Helpers.copyToClipboard(value)
notifications.success("Copied")
}
onMount(async () => {
try {
await organisation.init()
} catch (error) {
notifications.error("Error getting org config")
}
// Fetch Google config
let googleDoc
try {
googleDoc = await API.getConfig(ConfigTypes.Google)
} catch (error) {
notifications.error("Error fetching Google OAuth config")
}
if (!googleDoc?._id) {
providers.google = {
type: ConfigTypes.Google,
config: { activated: true },
}
originalGoogleDoc = cloneDeep(googleDoc)
} else {
// Default activated to true for older configs
if (googleDoc.config.activated === undefined) {
googleDoc.config.activated = true
}
originalGoogleDoc = cloneDeep(googleDoc)
providers.google = googleDoc
}
googleCallbackUrl = providers?.google?.config?.callbackURL
// Get the list of user uploaded logos and push it to the dropdown options.
// This needs to be done before the config call so they're available when
// the dropdown renders.
let oidcLogos
try {
oidcLogos = await API.getOIDCLogos()
} catch (error) {
notifications.error("Error fetching OIDC logos")
}
if (oidcLogos?.config) {
const logoKeys = Object.keys(oidcLogos.config)
logoKeys.map(logoKey => {
const logoUrl = oidcLogos.config[logoKey]
iconDropdownOptions.unshift({
label: logoKey,
value: logoKey,
icon: logoUrl,
})
})
}
// Fetch OIDC config
let oidcDoc
try {
oidcDoc = await API.getConfig(ConfigTypes.OIDC)
} catch (error) {
notifications.error("Error fetching OIDC config")
}
if (!oidcDoc?._id) {
providers.oidc = {
type: ConfigTypes.OIDC,
config: { configs: [{ activated: true, scopes: defaultScopes }] },
}
} else {
originalOidcDoc = cloneDeep(oidcDoc)
providers.oidc = oidcDoc
}
})
</script>
<Layout noPadding>
<Layout gap="XS" noPadding>
<Heading size="M">Authentication</Heading>
<Body>
Every budibase app comes with basic authentication (email/password)
included. You can add additional authentication methods from the options
below.
</Body>
</Layout>
{#if providers.google}
<Divider />
<Layout gap="XS" noPadding>
<Heading size="S">
<div class="provider-title">
<GoogleLogo />
<span>Google</span>
<Button
disabled={googleSaveButtonDisabled}
size="s"
cta
on:click={() => save([providers.google])}
>
Save
</Button>
</div>
</Heading>
<Body size="S">
To allow users to authenticate using their Google accounts, fill out the
fields below.
</Body>
</Layout>
<Layout gap="XS" noPadding>
{#each GoogleConfigFields.Google as field}
<div class="form-row">
<Label size="L" tooltip={field.tooltip}>{field.label}</Label>
<div class="inputContainer">
<div class="input">
<Input
bind:value={providers.google.config[field.name]}
readonly={field.readonly}
placeholder={field.placeholder}
/>
</div>
{#if field.copyButton}
<div
class="copy"
on:click={() => copyToClipboard(field.placeholder)}
>
<Icon size="S" name="Copy" />
</div>
{/if}
</div>
</div>
{/each}
<div class="form-row">
<Label size="L">Activated</Label>
<Toggle text="" bind:value={providers.google.config.activated} />
</div>
</Layout>
{/if}
{#if providers.oidc}
<Divider />
<Layout gap="XS" noPadding>
<Heading size="S">
<div class="provider-title">
<OidcLogo />
<span>OpenID Connect</span>
<Button
disabled={oidcSaveButtonDisabled}
size="s"
cta
on:click={() => save([providers.oidc])}
dataCy={"oidc-save"}
>
Save
</Button>
</div>
</Heading>
<Body size="S">
To allow users to authenticate using OIDC, fill out the fields below.
</Body>
</Layout>
<Layout gap="XS" noPadding>
{#each OIDCConfigFields.Oidc as field}
<div class="form-row">
<Label size="L" tooltip={field.tooltip}>{field.label}</Label>
<div class="inputContainer">
<div class="input">
<Input
bind:value={providers.oidc.config.configs[0][field.name]}
readonly={field.readonly}
placeholder={field.placeholder}
/>
</div>
{#if field.copyButton}
<div
class="copy"
on:click={() => copyToClipboard(field.placeholder)}
>
<Icon size="S" name="Copy" />
</div>
{/if}
</div>
</div>
{/each}
</Layout>
<Layout gap="XS" noPadding>
<Body size="S">
To customize your login button, fill out the fields below.
</Body>
<div class="form-row">
<Label size="L">Name</Label>
<Input bind:value={providers.oidc.config.configs[0].name} />
</div>
<div class="form-row">
<Label size="L">Icon</Label>
<Select
label=""
bind:value={providers.oidc.config.configs[0].logo}
options={iconDropdownOptions}
on:change={e => e.detail === "Upload" && fileinput.click()}
/>
</div>
<input
type="file"
accept=".jpg, .jpeg, .png"
on:change={e => onFileSelected(e)}
bind:this={fileinput}
/>
<div class="form-row">
<Label size="L">Activated</Label>
<Toggle
dataCy={"oidc-active"}
text=""
bind:value={providers.oidc.config.configs[0].activated}
/>
</div>
</Layout>
<span class="advanced-config">
<Layout gap="XS" noPadding>
<Heading size="XS">
<div class="auth-scopes">
<div>Advanced</div>
<Button
secondary
newStyles
size="S"
on:click={() => {
providers.oidc.config.configs[0]["scopes"] = [...defaultScopes]
}}
dataCy={"restore-oidc-default-scopes"}
>
Restore Defaults
</Button>
</div>
</Heading>
<Body size="S">
Changes to your authentication scopes will only take effect when you
next log in. Please refer to your vendor documentation before
modification.
</Body>
<div class="auth-form">
<span class="add-new">
<Label size="L">{"Auth Scopes"}</Label>
<Input
dataCy={"new-scope-input"}
error={scopesFields[0].error}
placeholder={"New Scope"}
bind:value={scopesFields[0].inputText}
on:keyup={e => {
if (!scopesFields[0].inputText) {
scopesFields[0].error = null
}
if (
e.key === "Enter" ||
e.keyCode === 13 ||
e.code == "Space" ||
e.keyCode == 32
) {
let scopes = providers.oidc.config.configs[0]["scopes"]
? providers.oidc.config.configs[0]["scopes"]
: [...defaultScopes]
let update = scopesFields[0].inputText.trim()
if (HasSpacesRegex.test(update)) {
scopesFields[0].error =
"Auth scopes cannot contain spaces, double quotes or backslashes"
return
} else if (scopes.indexOf(update) > -1) {
scopesFields[0].error = "Auth scope already exists"
return
} else if (!update.length) {
scopesFields[0].inputText = null
scopesFields[0].error = null
return
} else {
scopesFields[0].error = null
scopes.push(update)
providers.oidc.config.configs[0]["scopes"] = scopes
scopesFields[0].inputText = null
}
}
}}
/>
</span>
<div class="tag-wrap">
<span />
<Tags>
<Tag closable={false}>openid</Tag>
{#each providers.oidc.config.configs[0]["scopes"] || [...defaultScopes] as tag, idx}
<Tag
closable={scopesFields[0].editing}
on:click={() => {
let idxScopes = providers.oidc.config.configs[0]["scopes"]
if (idxScopes.length == 1) {
idxScopes.pop()
} else {
idxScopes.splice(idx, 1)
refreshScopes(0)
}
}}
>
{tag}
</Tag>
{/each}
</Tags>
</div>
</div>
</Layout>
</span>
{/if}
</Layout>
<style>
.auth-scopes {
display: flex;
justify-content: space-between;
align-items: center;
}
.advanced-config :global(.spectrum-Tags-item) {
margin-left: 0px;
margin-top: var(--spacing-m);
margin-right: var(--spacing-m);
}
.auth-form > * {
display: grid;
grid-gap: var(--spacing-l);
grid-template-columns: 100px 1fr;
}
.advanced-config .auth-form .tag-wrap {
padding: 0px 5px 5px 0px;
}
.form-row {
display: grid;
grid-template-columns: 100px 1fr;
grid-gap: var(--spacing-l);
align-items: center;
}
input[type="file"] {
display: none;
}
.provider-title {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: var(--spacing-m);
}
.provider-title span {
flex: 1 1 auto;
}
.inputContainer {
display: flex;
flex-direction: row;
}
.input {
flex: 1;
}
.copy {
display: flex;
align-items: center;
margin-left: 10px;
}
</style>