Merge branch 'feature/oidc-support' of github.com:Budibase/budibase into feature/oidc-support

This commit is contained in:
Rory Powell 2021-07-08 11:12:45 +01:00
commit 238d31e922
14 changed files with 305 additions and 28 deletions

View File

@ -20,4 +20,6 @@ exports.Configs = {
ACCOUNT: "account", ACCOUNT: "account",
SMTP: "smtp", SMTP: "smtp",
GOOGLE: "google", GOOGLE: "google",
OIDC: "oidc",
OIDC_LOGOS: "oidc_logos",
} }

View File

@ -10,6 +10,7 @@
export let disabled = false export let disabled = false
export let error = null export let error = null
export let fieldText = "" export let fieldText = ""
export let fieldIcon = ""
export let isPlaceholder = false export let isPlaceholder = false
export let placeholderOption = null export let placeholderOption = null
export let options = [] export let options = []
@ -17,11 +18,11 @@
export let onSelectOption = () => {} export let onSelectOption = () => {}
export let getOptionLabel = option => option export let getOptionLabel = option => option
export let getOptionValue = option => option export let getOptionValue = option => option
export let getOptionIcon = option => option
export let open = false export let open = false
export let readonly = false export let readonly = false
export let quiet = false export let quiet = false
export let autoWidth = false export let autoWidth = false
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const onClick = () => { const onClick = () => {
dispatch("click") dispatch("click")
@ -30,6 +31,7 @@
} }
open = true open = true
} }
console.log(fieldIcon)
</script> </script>
<button <button
@ -42,6 +44,12 @@
aria-haspopup="listbox" aria-haspopup="listbox"
on:mousedown={onClick} on:mousedown={onClick}
> >
{#if fieldIcon}
<span class="icon-Placeholder-Padding">
<img src={fieldIcon} alt="OpenID Icon" width="20" height="15" />
</span>
{/if}
<span <span
class="spectrum-Picker-label" class="spectrum-Picker-label"
class:is-placeholder={isPlaceholder} class:is-placeholder={isPlaceholder}
@ -104,6 +112,16 @@
tabindex="0" tabindex="0"
on:click={() => onSelectOption(getOptionValue(option, idx))} on:click={() => onSelectOption(getOptionValue(option, idx))}
> >
{#if getOptionIcon(option, idx)}
<span class="icon-Padding">
<img
src={getOptionIcon(option, idx)}
alt="test"
width="20"
height="15"
/>
</span>
{/if}
<span class="spectrum-Menu-itemLabel" <span class="spectrum-Menu-itemLabel"
>{getOptionLabel(option, idx)}</span >{getOptionLabel(option, idx)}</span
> >
@ -148,4 +166,12 @@
.spectrum-Picker-label.auto-width.is-placeholder { .spectrum-Picker-label.auto-width.is-placeholder {
padding-right: 2px; padding-right: 2px;
} }
.icon-Padding {
padding-right: 10px;
}
.icon-Placeholder-Padding {
padding-top: 5px;
padding-right: 10px;
}
</style> </style>

View File

@ -8,8 +8,10 @@
export let disabled = false export let disabled = false
export let error = null export let error = null
export let options = [] export let options = []
export let callbackOptionValue = null
export let getOptionLabel = option => option export let getOptionLabel = option => option
export let getOptionValue = option => option export let getOptionValue = option => option
export let getOptionIcon = option => option
export let readonly = false export let readonly = false
export let quiet = false export let quiet = false
export let autoWidth = false export let autoWidth = false
@ -17,6 +19,7 @@
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
let open = false let open = false
$: fieldText = getFieldText(value, options, placeholder) $: fieldText = getFieldText(value, options, placeholder)
$: fieldIcon = getFieldIcon(value, options, placeholder)
const getFieldText = (value, options, placeholder) => { const getFieldText = (value, options, placeholder) => {
// Always use placeholder if no value // Always use placeholder if no value
@ -36,6 +39,17 @@
return index !== -1 ? getOptionLabel(options[index], index) : value return index !== -1 ? getOptionLabel(options[index], index) : value
} }
const getFieldIcon = (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) => getOptionValue(option, idx) === value
)
return index !== -1 ? getOptionIcon(options[index], index) : value
}
const selectOption = value => { const selectOption = value => {
dispatch("change", value) dispatch("change", value)
open = false open = false
@ -55,6 +69,9 @@
{autoWidth} {autoWidth}
{getOptionLabel} {getOptionLabel}
{getOptionValue} {getOptionValue}
{getOptionIcon}
{fieldIcon}
{callbackOptionValue}
isPlaceholder={value == null || value === ""} isPlaceholder={value == null || value === ""}
placeholderOption={placeholder} placeholderOption={placeholder}
isOptionSelected={option => option === value} isOptionSelected={option => option === value}

View File

@ -13,6 +13,7 @@
export let options = [] export let options = []
export let getOptionLabel = option => extractProperty(option, "label") export let getOptionLabel = option => extractProperty(option, "label")
export let getOptionValue = option => extractProperty(option, "value") export let getOptionValue = option => extractProperty(option, "value")
export let getOptionIcon = option => extractObjectProperty(option, "icon")
export let quiet = false export let quiet = false
export let autoWidth = false export let autoWidth = false
@ -21,6 +22,13 @@
value = e.detail value = e.detail
dispatch("change", e.detail) dispatch("change", e.detail)
} }
const extractObjectProperty = (value, property) => {
if (value && typeof value === "object") {
return value[property]
}
}
const extractProperty = (value, property) => { const extractProperty = (value, property) => {
if (value && typeof value === "object") { if (value && typeof value === "object") {
return value[property] return value[property]
@ -41,6 +49,7 @@
{autoWidth} {autoWidth}
{getOptionLabel} {getOptionLabel}
{getOptionValue} {getOptionValue}
{getOptionIcon}
on:change={onChange} on:change={onChange}
on:click on:click
/> />

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@ -0,0 +1,39 @@
<script>
import { ActionButton } from "@budibase/bbui"
import OidcLogo from "assets/oidc-logo.png"
import { admin } from "stores/portal"
let show = false
$: show = $admin.checklist?.oidc
</script>
{#if show}
<ActionButton
on:click={() => window.open("/api/admin/auth/oidc", "_blank")}
>
<div class="inner">
<img src={OidcLogo} alt="oidc icon" />
<p>Sign in with OIDC</p>
</div>
</ActionButton>
{/if}
<style>
.inner {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
padding-top: var(--spacing-xs);
padding-bottom: var(--spacing-xs);
}
.inner img {
width: 18px;
margin: 3px 10px 3px 3px;
}
.inner p {
margin: 0;
}
</style>

View File

@ -0,0 +1,15 @@
<svg
width="25"
height="25 "
viewBox="0 0 100 100"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clip-path="url(#clip0)">
<path
d="M43,90c-88,-16,-21,-86,41,-51l9,-6v17h-26l8,-5c-55,-25,-86,29,-32,36z"
fill="#ccc"
/>
<path d="M43,90v-75l14,-9v75z" fill="#f60" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 319 B

View File

@ -1,5 +1,11 @@
<script> <script>
import GoogleLogo from "./_logos/Google.svelte" import GoogleLogo from "./_logos/Google.svelte"
import OidcLogo from "./_logos/OIDC.svelte"
import MicrosoftLogo from "assets/microsoft-logo.png"
import OracleLogo from "assets/oracle-logo.png"
import Auth0Logo from "assets/auth0-logo.png"
import OidcLogoPng from "assets/oidc-logo.png"
import { import {
Button, Button,
Heading, Heading,
@ -9,20 +15,25 @@
Layout, Layout,
Input, Input,
Body, Body,
Select,
Dropzone,
} from "@budibase/bbui" } from "@budibase/bbui"
import { onMount } from "svelte" import { onMount } from "svelte"
import api from "builderStore/api" import api from "builderStore/api"
import { writable } from "svelte/store"
import { organisation } from "stores/portal"
const ConfigTypes = { const ConfigTypes = {
Google: "google", Google: "google",
OIDC: "oidc",
// Github: "github", // Github: "github",
// AzureAD: "ad", // AzureAD: "ad",
} }
const ConfigFields = { const GoogleConfigFields = {
Google: ["clientID", "clientSecret", "callbackURL"], Google: ["clientID", "clientSecret", "callbackURL"],
} }
const ConfigLabels = { const GoogleConfigLabels = {
Google: { Google: {
clientID: "Client ID", clientID: "Client ID",
clientSecret: "Client secret", clientSecret: "Client secret",
@ -30,21 +41,81 @@
}, },
} }
let google const OIDCConfigFields = {
Oidc: ["configUrl", "clientId", "clientSecret"],
async function save(doc) {
try {
// Save an oauth config
const response = await api.post(`/api/admin/configs`, doc)
const json = await response.json()
if (response.status !== 200) throw new Error(json.message)
google._rev = json._rev
google._id = json._id
notifications.success(`Settings saved.`)
} catch (err) {
notifications.error(`Failed to update OAuth settings. ${err}`)
} }
const OIDCConfigLabels = {
Oidc: {
configUrl: "Config URL",
clientId: "Client ID",
clientSecret: "Client Secret",
},
}
let iconDropdownOptions = [
{
label: "Azure AD",
value: "Active Directory",
icon: MicrosoftLogo,
},
{ label: "Oracle", value: "Oracle", icon: OracleLogo },
{ 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
async function uploadLogo(file) {
let data = new FormData()
data.append("file", file)
const res = await api.post(
`/api/admin/configs/upload/oidc_logos/${file.name}`,
data,
{}
)
return await res.json()
}
const onFileSelected = e => {
let fileName = e.target.files[0].name
image = e.target.files[0]
providers.oidc.config["iconName"] = fileName
iconDropdownOptions.unshift({label: fileName, value: fileName})
}
const providers = { google, oidc }
async function save(docs) {
// only if the user has provided an image, upload it.
image && uploadLogo(image)
let calls = []
docs.forEach(element => {
calls.push(api.post(`/api/admin/configs`, element))
})
Promise.all(calls)
.then(responses => {
return Promise.all(
responses.map(response => {
return response.json()
})
)
})
.then(data => {
data.forEach(res => {
providers[res.type]._rev = res._rev
providers[res.type]._id = res._id
})
notifications.success(`Settings saved.`)
})
.catch(err => {
notifications.error(`Failed to update OAuth settings. ${err}`)
throw new Error(err.message)
})
} }
onMount(async () => { onMount(async () => {
@ -55,12 +126,39 @@
const googleDoc = await googleResponse.json() const googleDoc = await googleResponse.json()
if (!googleDoc._id) { if (!googleDoc._id) {
google = { providers.google = {
type: ConfigTypes.Google, type: ConfigTypes.Google,
config: {}, config: {},
} }
} else { } else {
google = googleDoc providers.google = googleDoc
}
//Get the list of user uploaded logos and push it to the dropdown options.
//This needs to be done before the config callso they're available when the dropdown renders
const res = await api.get(`/api/admin/configs/oidc_logos`)
const configSettings = await res.json()
const logoKeys = Object.keys(configSettings.config)
logoKeys.map(logoKey => {
const logoUrl = configSettings.config[logoKey]
iconDropdownOptions.unshift({
label: logoKey,
value: logoKey,
icon: logoUrl,
})
})
const oidcResponse = await api.get(`/api/admin/configs/${ConfigTypes.OIDC}`)
const oidcDoc = await oidcResponse.json()
if (!oidcDoc._id) {
providers.oidc = {
type: ConfigTypes.OIDC,
config: {},
}
} else {
providers.oidc = oidcDoc
} }
}) })
</script> </script>
@ -74,7 +172,7 @@
below. below.
</Body> </Body>
</Layout> </Layout>
{#if google} {#if providers.google}
<Divider /> <Divider />
<Layout gap="XS" noPadding> <Layout gap="XS" noPadding>
<Heading size="S"> <Heading size="S">
@ -89,17 +187,65 @@
</Body> </Body>
</Layout> </Layout>
<Layout gap="XS" noPadding> <Layout gap="XS" noPadding>
{#each ConfigFields.Google as field} {#each GoogleConfigFields.Google as field}
<div class="form-row"> <div class="form-row">
<Label size="L">{ConfigLabels.Google[field]}</Label> <Label size="L">{GoogleConfigLabels.Google[field]}</Label>
<Input bind:value={google.config[field]} /> <Input bind:value={providers.google.config[field]} />
</div> </div>
{/each} {/each}
</Layout> </Layout>
<div>
<Button cta on:click={() => save(google)}>Save</Button>
</div>
{/if} {/if}
{#if providers.oidc}
<Divider />
<Layout gap="XS" noPadding>
<Heading size="S">
<span>
<OidcLogo />
OpenID Connect
</span>
</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">{OIDCConfigLabels.Oidc[field]}</Label>
<Input bind:value={providers.oidc.config[field]} />
</div>
{/each}
<br />
<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["name"]} />
</div>
<div class="form-row">
<Label size="L">Icon</Label>
<Select
label=""
bind:value={providers.oidc.config["iconName"]}
options={iconDropdownOptions}
on:change={e => e.detail === "Upload" && fileinput.click()}
/>
</div>
<input
style="display:none"
type="file"
accept=".jpg, .jpeg, .png"
on:change={e => onFileSelected(e)}
bind:this={fileinput}
/>
</Layout>
{/if}
<div>
<Button cta on:click={() => save([providers.google, providers.oidc])}
>Save</Button
>
</div>
</Layout> </Layout>
<style> <style>
@ -109,10 +255,13 @@
grid-gap: var(--spacing-l); grid-gap: var(--spacing-l);
align-items: center; align-items: center;
} }
span { span {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--spacing-s); gap: var(--spacing-s);
} }
input {
display: none;
}
</style> </style>

View File

@ -146,7 +146,7 @@ exports.upload = async function (ctx) {
} }
} }
const url = `/${bucket}/${key}` const url = `/${bucket}/${key}`
cfgStructure.config[`${name}Url`] = url cfgStructure.config[`${name}`] = url
// write back to db with url updated // write back to db with url updated
await db.put(cfgStructure) await db.put(cfgStructure)
@ -188,6 +188,10 @@ exports.configChecklist = async function (ctx) {
type: Configs.GOOGLE, type: Configs.GOOGLE,
}) })
// They have set up OIDC
const oidcConfig = await getScopedFullConfig(db, {
type: Configs.OIDC,
})
// They have set up an admin user // They have set up an admin user
const users = await db.allDocs( const users = await db.allDocs(
getGlobalUserParams(null, { getGlobalUserParams(null, {
@ -201,6 +205,7 @@ exports.configChecklist = async function (ctx) {
smtp: !!smtpConfig, smtp: !!smtpConfig,
adminUser, adminUser,
oauth: !!oauthConfig, oauth: !!oauthConfig,
oidc: !!oidcConfig,
} }
} catch (err) { } catch (err) {
ctx.throw(err.status, err) ctx.throw(err.status, err)

View File

@ -41,6 +41,19 @@ function googleValidation() {
}).unknown(true) }).unknown(true)
} }
function OidcValidation() {
// prettier-ignore
return Joi.object({
clientID: Joi.string().required(),
authUrl: Joi.string().required(),
tokenUrl: Joi.string().required(),
userInfoUrl: Joi.string().required(),
clientId: Joi.string().required(),
clientSecret: Joi.string().required(),
callbackUrl: Joi.string().required(),
}).unknown(true)
}
function buildConfigSaveValidation() { function buildConfigSaveValidation() {
// prettier-ignore // prettier-ignore
return joiValidator.body(Joi.object({ return joiValidator.body(Joi.object({
@ -54,7 +67,8 @@ function buildConfigSaveValidation() {
{ is: Configs.SMTP, then: smtpValidation() }, { is: Configs.SMTP, then: smtpValidation() },
{ is: Configs.SETTINGS, then: settingValidation() }, { is: Configs.SETTINGS, then: settingValidation() },
{ is: Configs.ACCOUNT, then: Joi.object().unknown(true) }, { is: Configs.ACCOUNT, then: Joi.object().unknown(true) },
{ is: Configs.GOOGLE, then: googleValidation() } { is: Configs.GOOGLE, then: googleValidation() },
{ is: Configs.OIDC, then: Joi.object().unknown(true) }
], ],
}), }),
}).required(), }).required(),
@ -65,7 +79,7 @@ function buildUploadValidation() {
// prettier-ignore // prettier-ignore
return joiValidator.params(Joi.object({ return joiValidator.params(Joi.object({
type: Joi.string().valid(...Object.values(Configs)).required(), type: Joi.string().valid(...Object.values(Configs)).required(),
name: Joi.string().valid(...Object.values(ConfigUploads)).required(), name: Joi.string().required(),
}).required()) }).required())
} }

View File

@ -16,6 +16,7 @@ exports.Configs = Configs
exports.ConfigUploads = { exports.ConfigUploads = {
LOGO: "logo", LOGO: "logo",
OIDC_LOGO: "oidc_logo",
} }
const TemplateTypes = { const TemplateTypes = {