Merge branch 'develop' into feature/nps-feedback-form
This commit is contained in:
commit
2c01db04d8
|
@ -3,4 +3,5 @@ public
|
|||
dist
|
||||
packages/server/builder
|
||||
packages/server/coverage
|
||||
packages/server/client
|
||||
packages/builder/.routify
|
|
@ -5,4 +5,5 @@ dist
|
|||
packages/builder/src/components/design/AppPreview/CurrentItemPreview.svelte
|
||||
packages/server/builder
|
||||
packages/server/coverage
|
||||
packages/server/client
|
||||
packages/builder/.routify
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"version": "0.9.73",
|
||||
"version": "0.9.78",
|
||||
"npmClient": "yarn",
|
||||
"packages": [
|
||||
"packages/*"
|
||||
|
|
|
@ -1,10 +1,14 @@
|
|||
{
|
||||
"name": "@budibase/auth",
|
||||
"version": "0.9.73",
|
||||
"version": "0.9.78",
|
||||
"description": "Authentication middlewares for budibase builder and apps",
|
||||
"main": "src/index.js",
|
||||
"author": "Budibase",
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watchAll"
|
||||
},
|
||||
"dependencies": {
|
||||
"aws-sdk": "^2.901.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
|
@ -13,6 +17,7 @@
|
|||
"koa-passport": "^4.1.4",
|
||||
"lodash": "^4.17.21",
|
||||
"node-fetch": "^2.6.1",
|
||||
"@techpass/passport-openidconnect": "^0.3.0",
|
||||
"passport-google-auth": "^1.0.2",
|
||||
"passport-google-oauth": "^2.0.0",
|
||||
"passport-jwt": "^4.0.0",
|
||||
|
@ -22,8 +27,17 @@
|
|||
"uuid": "^8.3.2",
|
||||
"zlib": "^1.0.5"
|
||||
},
|
||||
"jest": {
|
||||
"setupFiles": [
|
||||
"./scripts/jestSetup.js"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"ioredis-mock": "^5.5.5"
|
||||
"ioredis-mock": "^5.5.5",
|
||||
"jest": "^26.6.3",
|
||||
"pouchdb-adapter-memory": "^7.2.2",
|
||||
"pouchdb": "^7.2.1",
|
||||
"pouchdb-all-dbs": "^1.0.2"
|
||||
},
|
||||
"gitHead": "d1836a898cab3f8ab80ee6d8f42be1a9eed7dcdc"
|
||||
}
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
const env = require("../src/environment")
|
||||
|
||||
env._set("NODE_ENV", "jest")
|
||||
env._set("JWT_SECRET", "test-jwtsecret")
|
||||
env._set("LOG_LEVEL", "silent")
|
|
@ -6,6 +6,7 @@ exports.UserStatus = {
|
|||
exports.Cookies = {
|
||||
CurrentApp: "budibase:currentapp",
|
||||
Auth: "budibase:auth",
|
||||
OIDC_CONFIG: "budibase:oidc:config",
|
||||
}
|
||||
|
||||
exports.GlobalRoles = {
|
||||
|
@ -20,4 +21,6 @@ exports.Configs = {
|
|||
ACCOUNT: "account",
|
||||
SMTP: "smtp",
|
||||
GOOGLE: "google",
|
||||
OIDC: "oidc",
|
||||
OIDC_LOGOS: "logos_oidc",
|
||||
}
|
||||
|
|
|
@ -17,4 +17,8 @@ module.exports = {
|
|||
MINIO_URL: process.env.MINIO_URL,
|
||||
INTERNAL_API_KEY: process.env.INTERNAL_API_KEY,
|
||||
isTest,
|
||||
_set(key, value) {
|
||||
process.env[key] = value
|
||||
module.exports[key] = value
|
||||
},
|
||||
}
|
||||
|
|
|
@ -2,7 +2,14 @@ const passport = require("koa-passport")
|
|||
const LocalStrategy = require("passport-local").Strategy
|
||||
const JwtStrategy = require("passport-jwt").Strategy
|
||||
const { StaticDatabases } = require("./db/utils")
|
||||
const { jwt, local, authenticated, google, auditLog } = require("./middleware")
|
||||
const {
|
||||
jwt,
|
||||
local,
|
||||
authenticated,
|
||||
google,
|
||||
oidc,
|
||||
auditLog,
|
||||
} = require("./middleware")
|
||||
const { setDB, getDB } = require("./db")
|
||||
const userCache = require("./cache/user")
|
||||
|
||||
|
@ -45,6 +52,7 @@ module.exports = {
|
|||
buildAuthMiddleware: authenticated,
|
||||
passport,
|
||||
google,
|
||||
oidc,
|
||||
jwt: require("jsonwebtoken"),
|
||||
auditLog,
|
||||
},
|
||||
|
|
|
@ -1,11 +1,13 @@
|
|||
const jwt = require("./passport/jwt")
|
||||
const local = require("./passport/local")
|
||||
const google = require("./passport/google")
|
||||
const oidc = require("./passport/oidc")
|
||||
const authenticated = require("./authenticated")
|
||||
const auditLog = require("./auditLog")
|
||||
|
||||
module.exports = {
|
||||
google,
|
||||
oidc,
|
||||
jwt,
|
||||
local,
|
||||
authenticated,
|
||||
|
|
|
@ -1,78 +1,25 @@
|
|||
const env = require("../../environment")
|
||||
const jwt = require("jsonwebtoken")
|
||||
const database = require("../../db")
|
||||
const GoogleStrategy = require("passport-google-oauth").OAuth2Strategy
|
||||
const {
|
||||
StaticDatabases,
|
||||
generateGlobalUserID,
|
||||
ViewNames,
|
||||
} = require("../../db/utils")
|
||||
const { newid } = require("../../hashing")
|
||||
const { createASession } = require("../../security/sessions")
|
||||
|
||||
async function authenticate(token, tokenSecret, profile, done) {
|
||||
// Check the user exists in the instance DB by email
|
||||
const db = database.getDB(StaticDatabases.GLOBAL.name)
|
||||
const { authenticateThirdParty } = require("./third-party-common")
|
||||
|
||||
let dbUser
|
||||
|
||||
const userId = generateGlobalUserID(profile.id)
|
||||
|
||||
try {
|
||||
// use the google profile id
|
||||
dbUser = await db.get(userId)
|
||||
} catch (err) {
|
||||
const user = {
|
||||
_id: userId,
|
||||
provider: profile.provider,
|
||||
roles: {},
|
||||
...profile._json,
|
||||
}
|
||||
|
||||
// check if an account with the google email address exists locally
|
||||
const users = await db.query(`database/${ViewNames.USER_BY_EMAIL}`, {
|
||||
key: profile._json.email,
|
||||
include_docs: true,
|
||||
})
|
||||
|
||||
// Google user already exists by email
|
||||
if (users.rows.length > 0) {
|
||||
const existing = users.rows[0].doc
|
||||
|
||||
// remove the local account to avoid conflicts
|
||||
await db.remove(existing._id, existing._rev)
|
||||
|
||||
// merge with existing account
|
||||
user.roles = existing.roles
|
||||
user.builder = existing.builder
|
||||
user.admin = existing.admin
|
||||
|
||||
const response = await db.post(user)
|
||||
dbUser = user
|
||||
dbUser._rev = response.rev
|
||||
} else {
|
||||
return done(
|
||||
new Error(
|
||||
"email does not yet exist. You must set up your local budibase account first."
|
||||
),
|
||||
false
|
||||
)
|
||||
}
|
||||
async function authenticate(accessToken, refreshToken, profile, done) {
|
||||
const thirdPartyUser = {
|
||||
provider: profile.provider, // should always be 'google'
|
||||
providerType: "google",
|
||||
userId: profile.id,
|
||||
profile: profile,
|
||||
email: profile._json.email,
|
||||
oauth2: {
|
||||
accessToken: accessToken,
|
||||
refreshToken: refreshToken,
|
||||
},
|
||||
}
|
||||
|
||||
// authenticate
|
||||
const sessionId = newid()
|
||||
await createASession(dbUser._id, sessionId)
|
||||
|
||||
dbUser.token = jwt.sign(
|
||||
{
|
||||
userId: dbUser._id,
|
||||
sessionId,
|
||||
},
|
||||
env.JWT_SECRET
|
||||
return authenticateThirdParty(
|
||||
thirdPartyUser,
|
||||
true, // require local accounts to exist
|
||||
done
|
||||
)
|
||||
|
||||
return done(null, dbUser)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -103,3 +50,5 @@ exports.strategyFactory = async function (config) {
|
|||
throw new Error("Error constructing google authentication strategy", err)
|
||||
}
|
||||
}
|
||||
// expose for testing
|
||||
exports.authenticate = authenticate
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
const { Cookies } = require("../../constants")
|
||||
const env = require("../../environment")
|
||||
const { authError } = require("./utils")
|
||||
|
||||
exports.options = {
|
||||
secretOrKey: env.JWT_SECRET,
|
||||
|
@ -12,6 +13,6 @@ exports.authenticate = async function (jwt, done) {
|
|||
try {
|
||||
return done(null, jwt)
|
||||
} catch (err) {
|
||||
return done(new Error("JWT invalid."), false)
|
||||
return authError(done, "JWT invalid", err)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@ const { UserStatus } = require("../../constants")
|
|||
const { compare } = require("../../hashing")
|
||||
const env = require("../../environment")
|
||||
const { getGlobalUserByEmail } = require("../../utils")
|
||||
const { authError } = require("./utils")
|
||||
const { newid } = require("../../hashing")
|
||||
const { createASession } = require("../../security/sessions")
|
||||
|
||||
|
@ -18,17 +19,17 @@ exports.options = {}
|
|||
* @returns The authenticated user, or errors if they occur
|
||||
*/
|
||||
exports.authenticate = async function (email, password, done) {
|
||||
if (!email) return done(null, false, "Email Required.")
|
||||
if (!password) return done(null, false, "Password Required.")
|
||||
if (!email) return authError(done, "Email Required")
|
||||
if (!password) return authError(done, "Password Required")
|
||||
|
||||
const dbUser = await getGlobalUserByEmail(email)
|
||||
if (dbUser == null) {
|
||||
return done(null, false, { message: "User not found" })
|
||||
return authError(done, "User not found")
|
||||
}
|
||||
|
||||
// check that the user is currently inactive, if this is the case throw invalid
|
||||
if (dbUser.status === UserStatus.INACTIVE) {
|
||||
return done(null, false, { message: INVALID_ERR })
|
||||
return authError(done, INVALID_ERR)
|
||||
}
|
||||
|
||||
// authenticate
|
||||
|
@ -48,6 +49,6 @@ exports.authenticate = async function (email, password, done) {
|
|||
|
||||
return done(null, dbUser)
|
||||
} else {
|
||||
done(new Error(INVALID_ERR), false)
|
||||
return authError(done, INVALID_ERR)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,128 @@
|
|||
const fetch = require("node-fetch")
|
||||
const OIDCStrategy = require("@techpass/passport-openidconnect").Strategy
|
||||
const { authenticateThirdParty } = require("./third-party-common")
|
||||
|
||||
/**
|
||||
* @param {*} issuer The identity provider base URL
|
||||
* @param {*} sub The user ID
|
||||
* @param {*} profile The user profile information. Created by passport from the /userinfo response
|
||||
* @param {*} jwtClaims The parsed id_token claims
|
||||
* @param {*} accessToken The access_token for contacting the identity provider - may or may not be a JWT
|
||||
* @param {*} refreshToken The refresh_token for obtaining a new access_token - usually not a JWT
|
||||
* @param {*} idToken The id_token - always a JWT
|
||||
* @param {*} params The response body from requesting an access_token
|
||||
* @param {*} done The passport callback: err, user, info
|
||||
*/
|
||||
async function authenticate(
|
||||
issuer,
|
||||
sub,
|
||||
profile,
|
||||
jwtClaims,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
idToken,
|
||||
params,
|
||||
done
|
||||
) {
|
||||
const thirdPartyUser = {
|
||||
// store the issuer info to enable sync in future
|
||||
provider: issuer,
|
||||
providerType: "oidc",
|
||||
userId: profile.id,
|
||||
profile: profile,
|
||||
email: getEmail(profile, jwtClaims),
|
||||
oauth2: {
|
||||
accessToken: accessToken,
|
||||
refreshToken: refreshToken,
|
||||
},
|
||||
}
|
||||
|
||||
return authenticateThirdParty(
|
||||
thirdPartyUser,
|
||||
false, // don't require local accounts to exist
|
||||
done
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {*} profile The structured profile created by passport using the user info endpoint
|
||||
* @param {*} jwtClaims The claims returned in the id token
|
||||
*/
|
||||
function getEmail(profile, jwtClaims) {
|
||||
// profile not guaranteed to contain email e.g. github connected azure ad account
|
||||
if (profile._json.email) {
|
||||
return profile._json.email
|
||||
}
|
||||
|
||||
// fallback to id token email
|
||||
if (jwtClaims.email) {
|
||||
return jwtClaims.email
|
||||
}
|
||||
|
||||
// fallback to id token preferred username
|
||||
const username = jwtClaims.preferred_username
|
||||
if (username && validEmail(username)) {
|
||||
return username
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Could not determine user email from profile ${JSON.stringify(
|
||||
profile
|
||||
)} and claims ${JSON.stringify(jwtClaims)}`
|
||||
)
|
||||
}
|
||||
|
||||
function validEmail(value) {
|
||||
return (
|
||||
value &&
|
||||
!!value.match(
|
||||
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an instance of the oidc passport strategy. This wrapper fetches the configuration
|
||||
* from couchDB rather than environment variables, using this factory is necessary for dynamically configuring passport.
|
||||
* @returns Dynamically configured Passport OIDC Strategy
|
||||
*/
|
||||
exports.strategyFactory = async function (config, callbackUrl) {
|
||||
try {
|
||||
const { clientID, clientSecret, configUrl } = config
|
||||
|
||||
if (!clientID || !clientSecret || !callbackUrl || !configUrl) {
|
||||
throw new Error(
|
||||
"Configuration invalid. Must contain clientID, clientSecret, callbackUrl and configUrl"
|
||||
)
|
||||
}
|
||||
|
||||
const response = await fetch(configUrl)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Unexpected response when fetching openid-configuration: ${response.statusText}`
|
||||
)
|
||||
}
|
||||
|
||||
const body = await response.json()
|
||||
|
||||
return new OIDCStrategy(
|
||||
{
|
||||
issuer: body.issuer,
|
||||
authorizationURL: body.authorization_endpoint,
|
||||
tokenURL: body.token_endpoint,
|
||||
userInfoURL: body.userinfo_endpoint,
|
||||
clientID: clientID,
|
||||
clientSecret: clientSecret,
|
||||
callbackURL: callbackUrl,
|
||||
},
|
||||
authenticate
|
||||
)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
throw new Error("Error constructing OIDC authentication strategy", err)
|
||||
}
|
||||
}
|
||||
|
||||
// expose for testing
|
||||
exports.authenticate = authenticate
|
|
@ -0,0 +1,74 @@
|
|||
// Mock data
|
||||
|
||||
const { data } = require("./utilities/mock-data")
|
||||
|
||||
const googleConfig = {
|
||||
callbackURL: "http://somecallbackurl",
|
||||
clientID: data.clientID,
|
||||
clientSecret: data.clientSecret,
|
||||
}
|
||||
|
||||
const profile = {
|
||||
id: "mockId",
|
||||
_json: {
|
||||
email : data.email
|
||||
},
|
||||
provider: "google"
|
||||
}
|
||||
|
||||
const user = data.buildThirdPartyUser("google", "google", profile)
|
||||
|
||||
describe("google", () => {
|
||||
describe("strategyFactory", () => {
|
||||
// mock passport strategy factory
|
||||
jest.mock("passport-google-oauth")
|
||||
const mockStrategy = require("passport-google-oauth").OAuth2Strategy
|
||||
|
||||
it("should create successfully create a google strategy", async () => {
|
||||
const google = require("../google")
|
||||
|
||||
await google.strategyFactory(googleConfig)
|
||||
|
||||
const expectedOptions = {
|
||||
clientID: googleConfig.clientID,
|
||||
clientSecret: googleConfig.clientSecret,
|
||||
callbackURL: googleConfig.callbackURL,
|
||||
}
|
||||
|
||||
expect(mockStrategy).toHaveBeenCalledWith(
|
||||
expectedOptions,
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("authenticate", () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
// mock third party common authentication
|
||||
jest.mock("../third-party-common")
|
||||
const authenticateThirdParty = require("../third-party-common").authenticateThirdParty
|
||||
|
||||
// mock the passport callback
|
||||
const mockDone = jest.fn()
|
||||
|
||||
it("delegates authentication to third party common", async () => {
|
||||
const google = require("../google")
|
||||
|
||||
await google.authenticate(
|
||||
data.accessToken,
|
||||
data.refreshToken,
|
||||
profile,
|
||||
mockDone
|
||||
)
|
||||
|
||||
expect(authenticateThirdParty).toHaveBeenCalledWith(
|
||||
user,
|
||||
true,
|
||||
mockDone)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
// Mock data
|
||||
|
||||
const { data } = require("./utilities/mock-data")
|
||||
|
||||
const issuer = "mockIssuer"
|
||||
const sub = "mockSub"
|
||||
const profile = {
|
||||
id: "mockId",
|
||||
_json: {
|
||||
email : data.email
|
||||
}
|
||||
}
|
||||
let jwtClaims = {}
|
||||
const idToken = "mockIdToken"
|
||||
const params = {}
|
||||
|
||||
const callbackUrl = "http://somecallbackurl"
|
||||
|
||||
// response from .well-known/openid-configuration
|
||||
const oidcConfigUrlResponse = {
|
||||
issuer: issuer,
|
||||
authorization_endpoint: "mockAuthorizationEndpoint",
|
||||
token_endpoint: "mockTokenEndpoint",
|
||||
userinfo_endpoint: "mockUserInfoEndpoint"
|
||||
}
|
||||
|
||||
const oidcConfig = {
|
||||
configUrl: "http://someconfigurl",
|
||||
clientID: data.clientID,
|
||||
clientSecret: data.clientSecret,
|
||||
}
|
||||
|
||||
const user = data.buildThirdPartyUser(issuer, "oidc", profile)
|
||||
|
||||
describe("oidc", () => {
|
||||
describe("strategyFactory", () => {
|
||||
// mock passport strategy factory
|
||||
jest.mock("@techpass/passport-openidconnect")
|
||||
const mockStrategy = require("@techpass/passport-openidconnect").Strategy
|
||||
|
||||
// mock the request to retrieve the oidc configuration
|
||||
jest.mock("node-fetch")
|
||||
const mockFetch = require("node-fetch")
|
||||
mockFetch.mockReturnValue({
|
||||
ok: true,
|
||||
json: () => oidcConfigUrlResponse
|
||||
})
|
||||
|
||||
it("should create successfully create an oidc strategy", async () => {
|
||||
const oidc = require("../oidc")
|
||||
|
||||
await oidc.strategyFactory(oidcConfig, callbackUrl)
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(oidcConfig.configUrl)
|
||||
|
||||
const expectedOptions = {
|
||||
issuer: oidcConfigUrlResponse.issuer,
|
||||
authorizationURL: oidcConfigUrlResponse.authorization_endpoint,
|
||||
tokenURL: oidcConfigUrlResponse.token_endpoint,
|
||||
userInfoURL: oidcConfigUrlResponse.userinfo_endpoint,
|
||||
clientID: oidcConfig.clientID,
|
||||
clientSecret: oidcConfig.clientSecret,
|
||||
callbackURL: callbackUrl,
|
||||
}
|
||||
expect(mockStrategy).toHaveBeenCalledWith(
|
||||
expectedOptions,
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("authenticate", () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
// mock third party common authentication
|
||||
jest.mock("../third-party-common")
|
||||
const authenticateThirdParty = require("../third-party-common").authenticateThirdParty
|
||||
|
||||
// mock the passport callback
|
||||
const mockDone = jest.fn()
|
||||
|
||||
async function doAuthenticate() {
|
||||
const oidc = require("../oidc")
|
||||
|
||||
await oidc.authenticate(
|
||||
issuer,
|
||||
sub,
|
||||
profile,
|
||||
jwtClaims,
|
||||
data.accessToken,
|
||||
data.refreshToken,
|
||||
idToken,
|
||||
params,
|
||||
mockDone
|
||||
)
|
||||
}
|
||||
|
||||
async function doTest() {
|
||||
await doAuthenticate()
|
||||
|
||||
expect(authenticateThirdParty).toHaveBeenCalledWith(
|
||||
user,
|
||||
false,
|
||||
mockDone)
|
||||
}
|
||||
|
||||
it("delegates authentication to third party common", async () => {
|
||||
doTest()
|
||||
})
|
||||
|
||||
it("uses JWT email to get email", async () => {
|
||||
delete profile._json.email
|
||||
jwtClaims = {
|
||||
email : "mock@budibase.com"
|
||||
}
|
||||
|
||||
doTest()
|
||||
})
|
||||
|
||||
it("uses JWT username to get email", async () => {
|
||||
delete profile._json.email
|
||||
jwtClaims = {
|
||||
preferred_username : "mock@budibase.com"
|
||||
}
|
||||
|
||||
doTest()
|
||||
})
|
||||
|
||||
it("uses JWT invalid username to get email", async () => {
|
||||
delete profile._json.email
|
||||
|
||||
jwtClaims = {
|
||||
preferred_username : "invalidUsername"
|
||||
}
|
||||
|
||||
await expect(doAuthenticate()).rejects.toThrow("Could not determine user email from profile");
|
||||
})
|
||||
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
// Mock data
|
||||
|
||||
require("./utilities/test-config")
|
||||
|
||||
const database = require("../../../db")
|
||||
const { authenticateThirdParty } = require("../third-party-common")
|
||||
const { data } = require("./utilities/mock-data")
|
||||
|
||||
const {
|
||||
StaticDatabases,
|
||||
generateGlobalUserID
|
||||
} = require("../../../db/utils")
|
||||
const { newid } = require("../../../hashing")
|
||||
|
||||
let db
|
||||
|
||||
const done = jest.fn()
|
||||
|
||||
const getErrorMessage = () => {
|
||||
return done.mock.calls[0][2].message
|
||||
}
|
||||
|
||||
describe("third party common", () => {
|
||||
describe("authenticateThirdParty", () => {
|
||||
let thirdPartyUser
|
||||
|
||||
beforeEach(() => {
|
||||
db = database.getDB(StaticDatabases.GLOBAL.name)
|
||||
thirdPartyUser = data.buildThirdPartyUser()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
jest.clearAllMocks()
|
||||
await db.destroy()
|
||||
})
|
||||
|
||||
describe("validation", () => {
|
||||
const testValidation = async (message) => {
|
||||
await authenticateThirdParty(thirdPartyUser, false, done)
|
||||
expect(done.mock.calls.length).toBe(1)
|
||||
expect(getErrorMessage()).toContain(message)
|
||||
}
|
||||
|
||||
it("provider fails", async () => {
|
||||
delete thirdPartyUser.provider
|
||||
testValidation("third party user provider required")
|
||||
})
|
||||
|
||||
it("user id fails", async () => {
|
||||
delete thirdPartyUser.userId
|
||||
testValidation("third party user id required")
|
||||
})
|
||||
|
||||
it("email fails", async () => {
|
||||
delete thirdPartyUser.email
|
||||
testValidation("third party user email required")
|
||||
})
|
||||
})
|
||||
|
||||
const expectUserIsAuthenticated = () => {
|
||||
const user = done.mock.calls[0][1]
|
||||
expect(user).toBeDefined()
|
||||
expect(user._id).toBeDefined()
|
||||
expect(user._rev).toBeDefined()
|
||||
expect(user.token).toBeDefined()
|
||||
return user
|
||||
}
|
||||
|
||||
const expectUserIsSynced = (user, thirdPartyUser) => {
|
||||
expect(user.provider).toBe(thirdPartyUser.provider)
|
||||
expect(user.email).toBe(thirdPartyUser.email)
|
||||
expect(user.firstName).toBe(thirdPartyUser.profile.name.givenName)
|
||||
expect(user.lastName).toBe(thirdPartyUser.profile.name.familyName)
|
||||
expect(user.thirdPartyProfile).toStrictEqual(thirdPartyUser.profile._json)
|
||||
expect(user.oauth2).toStrictEqual(thirdPartyUser.oauth2)
|
||||
}
|
||||
|
||||
describe("when the user doesn't exist", () => {
|
||||
describe("when a local account is required", () => {
|
||||
it("returns an error message", async () => {
|
||||
await authenticateThirdParty(thirdPartyUser, true, done)
|
||||
expect(done.mock.calls.length).toBe(1)
|
||||
expect(getErrorMessage()).toContain("Email does not yet exist. You must set up your local budibase account first.")
|
||||
})
|
||||
})
|
||||
|
||||
describe("when a local account isn't required", () => {
|
||||
it("creates and authenticates the user", async () => {
|
||||
await authenticateThirdParty(thirdPartyUser, false, done)
|
||||
const user = expectUserIsAuthenticated()
|
||||
expectUserIsSynced(user, thirdPartyUser)
|
||||
expect(user.roles).toStrictEqual({})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("when the user exists", () => {
|
||||
let dbUser
|
||||
let id
|
||||
let email
|
||||
|
||||
const createUser = async () => {
|
||||
dbUser = {
|
||||
_id: id,
|
||||
email: email,
|
||||
}
|
||||
const response = await db.post(dbUser)
|
||||
dbUser._rev = response.rev
|
||||
}
|
||||
|
||||
const expectUserIsUpdated = (user) => {
|
||||
// id is unchanged
|
||||
expect(user._id).toBe(id)
|
||||
// user is updated
|
||||
expect(user._rev).not.toBe(dbUser._rev)
|
||||
}
|
||||
|
||||
describe("exists by email", () => {
|
||||
beforeEach(async () => {
|
||||
id = generateGlobalUserID(newid()) // random id
|
||||
email = thirdPartyUser.email // matching email
|
||||
await createUser()
|
||||
})
|
||||
|
||||
it("syncs and authenticates the user", async () => {
|
||||
await authenticateThirdParty(thirdPartyUser, true, done)
|
||||
|
||||
const user = expectUserIsAuthenticated()
|
||||
expectUserIsSynced(user, thirdPartyUser)
|
||||
expectUserIsUpdated(user)
|
||||
})
|
||||
})
|
||||
|
||||
describe("exists by id", () => {
|
||||
beforeEach(async () => {
|
||||
id = generateGlobalUserID(thirdPartyUser.userId) // matching id
|
||||
email = "test@test.com" // random email
|
||||
await createUser()
|
||||
})
|
||||
|
||||
it("syncs and authenticates the user", async () => {
|
||||
await authenticateThirdParty(thirdPartyUser, true, done)
|
||||
|
||||
const user = expectUserIsAuthenticated()
|
||||
expectUserIsSynced(user, thirdPartyUser)
|
||||
expectUserIsUpdated(user)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
const PouchDB = require("pouchdb")
|
||||
const allDbs = require("pouchdb-all-dbs")
|
||||
const env = require("../../../../environment")
|
||||
|
||||
let POUCH_DB_DEFAULTS
|
||||
|
||||
// should always be test but good to do the sanity check
|
||||
if (env.isTest()) {
|
||||
PouchDB.plugin(require("pouchdb-adapter-memory"))
|
||||
POUCH_DB_DEFAULTS = {
|
||||
prefix: undefined,
|
||||
adapter: "memory",
|
||||
}
|
||||
}
|
||||
|
||||
const Pouch = PouchDB.defaults(POUCH_DB_DEFAULTS)
|
||||
|
||||
allDbs(Pouch)
|
||||
|
||||
module.exports = Pouch
|
|
@ -0,0 +1,54 @@
|
|||
// Mock Data
|
||||
|
||||
const mockClientID = "mockClientID"
|
||||
const mockClientSecret = "mockClientSecret"
|
||||
|
||||
const mockEmail = "mock@budibase.com"
|
||||
const mockAccessToken = "mockAccessToken"
|
||||
const mockRefreshToken = "mockRefreshToken"
|
||||
|
||||
const mockProvider = "mockProvider"
|
||||
const mockProviderType = "mockProviderType"
|
||||
|
||||
const mockProfile = {
|
||||
id: "mockId",
|
||||
name: {
|
||||
givenName: "mockGivenName",
|
||||
familyName: "mockFamilyName",
|
||||
},
|
||||
_json: {
|
||||
email: mockEmail,
|
||||
},
|
||||
}
|
||||
|
||||
const buildOauth2 = (
|
||||
accessToken = mockAccessToken,
|
||||
refreshToken = mockRefreshToken
|
||||
) => ({
|
||||
accessToken: accessToken,
|
||||
refreshToken: refreshToken,
|
||||
})
|
||||
|
||||
const buildThirdPartyUser = (
|
||||
provider = mockProvider,
|
||||
providerType = mockProviderType,
|
||||
profile = mockProfile,
|
||||
email = mockEmail,
|
||||
oauth2 = buildOauth2()
|
||||
) => ({
|
||||
provider: provider,
|
||||
providerType: providerType,
|
||||
userId: profile.id,
|
||||
profile: profile,
|
||||
email: email,
|
||||
oauth2: oauth2,
|
||||
})
|
||||
|
||||
exports.data = {
|
||||
clientID: mockClientID,
|
||||
clientSecret: mockClientSecret,
|
||||
email: mockEmail,
|
||||
accessToken: mockAccessToken,
|
||||
refreshToken: mockRefreshToken,
|
||||
buildThirdPartyUser,
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
const packageConfiguration = require("../../../../index")
|
||||
const CouchDB = require("./db")
|
||||
packageConfiguration.init(CouchDB)
|
|
@ -0,0 +1,129 @@
|
|||
const env = require("../../environment")
|
||||
const jwt = require("jsonwebtoken")
|
||||
const database = require("../../db")
|
||||
const { StaticDatabases, generateGlobalUserID } = require("../../db/utils")
|
||||
const { authError } = require("./utils")
|
||||
const { newid } = require("../../hashing")
|
||||
const { createASession } = require("../../security/sessions")
|
||||
const { getGlobalUserByEmail } = require("../../utils")
|
||||
|
||||
/**
|
||||
* Common authentication logic for third parties. e.g. OAuth, OIDC.
|
||||
*/
|
||||
exports.authenticateThirdParty = async function (
|
||||
thirdPartyUser,
|
||||
requireLocalAccount = true,
|
||||
done
|
||||
) {
|
||||
if (!thirdPartyUser.provider)
|
||||
return authError(done, "third party user provider required")
|
||||
if (!thirdPartyUser.userId)
|
||||
return authError(done, "third party user id required")
|
||||
if (!thirdPartyUser.email)
|
||||
return authError(done, "third party user email required")
|
||||
|
||||
const db = database.getDB(StaticDatabases.GLOBAL.name)
|
||||
|
||||
let dbUser
|
||||
|
||||
// use the third party id
|
||||
const userId = generateGlobalUserID(thirdPartyUser.userId)
|
||||
|
||||
// try to load by id
|
||||
try {
|
||||
dbUser = await db.get(userId)
|
||||
} catch (err) {
|
||||
// abort when not 404 error
|
||||
if (!err.status || err.status !== 404) {
|
||||
return authError(
|
||||
done,
|
||||
"Unexpected error when retrieving existing user",
|
||||
err
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// fallback to loading by email
|
||||
if (!dbUser) {
|
||||
dbUser = await getGlobalUserByEmail(thirdPartyUser.email)
|
||||
}
|
||||
|
||||
// exit early if there is still no user and auto creation is disabled
|
||||
if (!dbUser && requireLocalAccount) {
|
||||
return authError(
|
||||
done,
|
||||
"Email does not yet exist. You must set up your local budibase account first."
|
||||
)
|
||||
}
|
||||
|
||||
// first time creation
|
||||
if (!dbUser) {
|
||||
// setup a blank user using the third party id
|
||||
dbUser = {
|
||||
_id: userId,
|
||||
roles: {},
|
||||
}
|
||||
}
|
||||
|
||||
dbUser = syncUser(dbUser, thirdPartyUser)
|
||||
|
||||
// create or sync the user
|
||||
const response = await db.post(dbUser)
|
||||
dbUser._rev = response.rev
|
||||
|
||||
// authenticate
|
||||
const sessionId = newid()
|
||||
await createASession(dbUser._id, sessionId)
|
||||
|
||||
dbUser.token = jwt.sign(
|
||||
{
|
||||
userId: dbUser._id,
|
||||
sessionId,
|
||||
},
|
||||
env.JWT_SECRET
|
||||
)
|
||||
|
||||
return done(null, dbUser)
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns a user that has been sync'd with third party information
|
||||
*/
|
||||
function syncUser(user, thirdPartyUser) {
|
||||
// provider
|
||||
user.provider = thirdPartyUser.provider
|
||||
user.providerType = thirdPartyUser.providerType
|
||||
|
||||
// email
|
||||
user.email = thirdPartyUser.email
|
||||
|
||||
if (thirdPartyUser.profile) {
|
||||
const profile = thirdPartyUser.profile
|
||||
|
||||
if (profile.name) {
|
||||
const name = profile.name
|
||||
// first name
|
||||
if (name.givenName) {
|
||||
user.firstName = name.givenName
|
||||
}
|
||||
// last name
|
||||
if (name.familyName) {
|
||||
user.lastName = name.familyName
|
||||
}
|
||||
}
|
||||
|
||||
// profile
|
||||
user.thirdPartyProfile = {
|
||||
...profile._json,
|
||||
}
|
||||
}
|
||||
|
||||
// oauth tokens for future use
|
||||
if (thirdPartyUser.oauth2) {
|
||||
user.oauth2 = {
|
||||
...thirdPartyUser.oauth2,
|
||||
}
|
||||
}
|
||||
|
||||
return user
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
/**
|
||||
* Utility to handle authentication errors.
|
||||
*
|
||||
* @param {*} done The passport callback.
|
||||
* @param {*} message Message that will be returned in the response body
|
||||
* @param {*} err (Optional) error that will be logged
|
||||
*/
|
||||
exports.authError = function (done, message, err = null) {
|
||||
return done(
|
||||
err,
|
||||
null, // never return a user
|
||||
{ message: message }
|
||||
)
|
||||
}
|
File diff suppressed because it is too large
Load Diff
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@budibase/bbui",
|
||||
"description": "A UI solution used in the different Budibase projects.",
|
||||
"version": "0.9.73",
|
||||
"version": "0.9.78",
|
||||
"license": "AGPL-3.0",
|
||||
"svelte": "src/index.js",
|
||||
"module": "dist/bbui.es.js",
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<script>
|
||||
import { slide } from "svelte/transition"
|
||||
import Portal from "svelte-portal"
|
||||
import ActionButton from "../ActionButton/ActionButton.svelte"
|
||||
import Button from "../Button/Button.svelte"
|
||||
import Body from "../Typography/Body.svelte"
|
||||
import Heading from "../Typography/Heading.svelte"
|
||||
|
||||
|
@ -38,13 +38,13 @@
|
|||
<header>
|
||||
<div class="text">
|
||||
<Heading size="XS">{title}</Heading>
|
||||
<Body size="XXS">
|
||||
<Body size="S">
|
||||
<slot name="description" />
|
||||
</Body>
|
||||
</div>
|
||||
<div class="buttons">
|
||||
<Button secondary quiet on:click={hide}>Cancel</Button>
|
||||
<slot name="buttons" />
|
||||
<ActionButton quiet icon="Close" on:click={hide} />
|
||||
</div>
|
||||
</header>
|
||||
<slot name="body" />
|
||||
|
@ -59,7 +59,7 @@
|
|||
left: 260px;
|
||||
width: calc(100% - 520px);
|
||||
background: var(--background);
|
||||
border: var(--border-light);
|
||||
border-top: var(--border-light);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
|
@ -68,17 +68,15 @@
|
|||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: var(--border-light);
|
||||
padding: var(--spectrum-alias-item-padding-s) 0;
|
||||
}
|
||||
header :global(*) + :global(*) {
|
||||
margin: 0 var(--spectrum-alias-grid-baseline);
|
||||
padding: var(--spacing-l) var(--spacing-xl);
|
||||
gap: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.text {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
margin-left: var(--spectrum-alias-item-padding-s);
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -19,7 +19,7 @@
|
|||
.container {
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 290px 1fr;
|
||||
grid-template-columns: 320px 1fr;
|
||||
}
|
||||
.no-sidebar {
|
||||
grid-template-columns: 1fr;
|
||||
|
@ -27,12 +27,15 @@
|
|||
.sidebar {
|
||||
border-right: var(--border-light);
|
||||
overflow: auto;
|
||||
padding: var(--spacing-xl);
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.sidebar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.main {
|
||||
font-family: var(--font-sans);
|
||||
padding: var(--spacing-xl);
|
||||
}
|
||||
.main :global(textarea) {
|
||||
min-height: 200px;
|
||||
|
|
|
@ -10,6 +10,7 @@
|
|||
export let disabled = false
|
||||
export let error = null
|
||||
export let fieldText = ""
|
||||
export let fieldIcon = ""
|
||||
export let isPlaceholder = false
|
||||
export let placeholderOption = null
|
||||
export let options = []
|
||||
|
@ -17,11 +18,11 @@
|
|||
export let onSelectOption = () => {}
|
||||
export let getOptionLabel = option => option
|
||||
export let getOptionValue = option => option
|
||||
export let getOptionIcon = null
|
||||
export let open = false
|
||||
export let readonly = false
|
||||
export let quiet = false
|
||||
export let autoWidth = false
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
const onClick = () => {
|
||||
dispatch("click")
|
||||
|
@ -42,6 +43,12 @@
|
|||
aria-haspopup="listbox"
|
||||
on:mousedown={onClick}
|
||||
>
|
||||
{#if fieldIcon}
|
||||
<span class="icon-Placeholder-Padding">
|
||||
<img src={fieldIcon} alt="Picker Icon" width="20" height="15" />
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<span
|
||||
class="spectrum-Picker-label"
|
||||
class:is-placeholder={isPlaceholder}
|
||||
|
@ -104,6 +111,16 @@
|
|||
tabindex="0"
|
||||
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"
|
||||
>{getOptionLabel(option, idx)}</span
|
||||
>
|
||||
|
@ -148,4 +165,12 @@
|
|||
.spectrum-Picker-label.auto-width.is-placeholder {
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.icon-Padding {
|
||||
padding-right: 10px;
|
||||
}
|
||||
.icon-Placeholder-Padding {
|
||||
padding-top: 5px;
|
||||
padding-right: 10px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -10,6 +10,7 @@
|
|||
export let options = []
|
||||
export let getOptionLabel = option => option
|
||||
export let getOptionValue = option => option
|
||||
export let getOptionIcon = null
|
||||
export let readonly = false
|
||||
export let quiet = false
|
||||
export let autoWidth = false
|
||||
|
@ -17,6 +18,7 @@
|
|||
const dispatch = createEventDispatcher()
|
||||
let open = false
|
||||
$: fieldText = getFieldText(value, options, placeholder)
|
||||
$: fieldIcon = getFieldIcon(value, options, placeholder)
|
||||
|
||||
const getFieldText = (value, options, placeholder) => {
|
||||
// Always use placeholder if no value
|
||||
|
@ -36,6 +38,17 @@
|
|||
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) : null
|
||||
}
|
||||
|
||||
const selectOption = value => {
|
||||
dispatch("change", value)
|
||||
open = false
|
||||
|
@ -55,6 +68,8 @@
|
|||
{autoWidth}
|
||||
{getOptionLabel}
|
||||
{getOptionValue}
|
||||
{getOptionIcon}
|
||||
{fieldIcon}
|
||||
isPlaceholder={value == null || value === ""}
|
||||
placeholderOption={placeholder}
|
||||
isOptionSelected={option => option === value}
|
||||
|
|
|
@ -13,6 +13,7 @@
|
|||
export let options = []
|
||||
export let getOptionLabel = option => extractProperty(option, "label")
|
||||
export let getOptionValue = option => extractProperty(option, "value")
|
||||
export let getOptionIcon = option => option?.icon
|
||||
export let quiet = false
|
||||
export let autoWidth = false
|
||||
|
||||
|
@ -21,6 +22,7 @@
|
|||
value = e.detail
|
||||
dispatch("change", e.detail)
|
||||
}
|
||||
|
||||
const extractProperty = (value, property) => {
|
||||
if (value && typeof value === "object") {
|
||||
return value[property]
|
||||
|
@ -41,6 +43,7 @@
|
|||
{autoWidth}
|
||||
{getOptionLabel}
|
||||
{getOptionValue}
|
||||
{getOptionIcon}
|
||||
on:change={onChange}
|
||||
on:click
|
||||
/>
|
||||
|
|
Binary file not shown.
After Width: | Height: | Size: 25 KiB |
Binary file not shown.
After Width: | Height: | Size: 4.3 KiB |
Binary file not shown.
After Width: | Height: | Size: 20 KiB |
Binary file not shown.
After Width: | Height: | Size: 24 KiB |
Binary file not shown.
After Width: | Height: | Size: 7.2 KiB |
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@budibase/builder",
|
||||
"version": "0.9.73",
|
||||
"version": "0.9.78",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
@ -65,10 +65,10 @@
|
|||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@budibase/bbui": "^0.9.73",
|
||||
"@budibase/client": "^0.9.73",
|
||||
"@budibase/bbui": "^0.9.78",
|
||||
"@budibase/client": "^0.9.78",
|
||||
"@budibase/colorpicker": "1.1.2",
|
||||
"@budibase/string-templates": "^0.9.73",
|
||||
"@budibase/string-templates": "^0.9.78",
|
||||
"@sentry/browser": "5.19.1",
|
||||
"@spectrum-css/page": "^3.0.1",
|
||||
"@spectrum-css/vars": "^3.0.1",
|
||||
|
|
|
@ -69,8 +69,9 @@
|
|||
<Input type="password" bind:value={block.inputs[key]} />
|
||||
{:else if value.customType === "email"}
|
||||
<DrawerBindableInput
|
||||
title={value.title}
|
||||
panel={AutomationBindingPanel}
|
||||
type={"email"}
|
||||
type="email"
|
||||
value={block.inputs[key]}
|
||||
on:change={e => (block.inputs[key] = e.detail)}
|
||||
{bindings}
|
||||
|
@ -102,6 +103,7 @@
|
|||
</CodeEditorModal>
|
||||
{:else if value.type === "string" || value.type === "number"}
|
||||
<DrawerBindableInput
|
||||
title={value.title}
|
||||
panel={AutomationBindingPanel}
|
||||
type={value.customType}
|
||||
value={block.inputs[key]}
|
||||
|
@ -127,6 +129,7 @@
|
|||
|
||||
.block-field {
|
||||
display: grid;
|
||||
grid-gap: 5px;
|
||||
}
|
||||
|
||||
.block-label {
|
||||
|
|
|
@ -12,7 +12,7 @@
|
|||
apps: "Create your first app",
|
||||
smtp: "Set up email",
|
||||
adminUser: "Create your first user",
|
||||
oauth: "Set up OAuth",
|
||||
sso: "Set up single sign-on",
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
@ -1,20 +1,9 @@
|
|||
<script>
|
||||
import groupBy from "lodash/fp/groupBy"
|
||||
import {
|
||||
Search,
|
||||
TextArea,
|
||||
Heading,
|
||||
Label,
|
||||
DrawerContent,
|
||||
Layout,
|
||||
} from "@budibase/bbui"
|
||||
import { Search, TextArea, DrawerContent } from "@budibase/bbui"
|
||||
import { createEventDispatcher } from "svelte"
|
||||
import { isValid } from "@budibase/string-templates"
|
||||
import {
|
||||
getBindableProperties,
|
||||
readableToRuntimeBinding,
|
||||
} from "builderStore/dataBinding"
|
||||
import { currentAsset, store } from "builderStore"
|
||||
import { readableToRuntimeBinding } from "builderStore/dataBinding"
|
||||
import { handlebarsCompletions } from "constants/completions"
|
||||
import { addToText } from "./utils"
|
||||
|
||||
|
@ -22,44 +11,36 @@
|
|||
|
||||
export let bindableProperties
|
||||
export let value = ""
|
||||
export let bindingDrawer
|
||||
export let valid = true
|
||||
export let valid
|
||||
|
||||
let originalValue = value
|
||||
let helpers = handlebarsCompletions()
|
||||
let getCaretPosition
|
||||
let search = ""
|
||||
|
||||
$: value && checkValid()
|
||||
$: bindableProperties = getBindableProperties(
|
||||
$currentAsset,
|
||||
$store.selectedComponentId
|
||||
)
|
||||
$: dispatch("update", value)
|
||||
$: ({ instance, context } = groupBy("type", bindableProperties))
|
||||
$: valid = isValid(readableToRuntimeBinding(bindableProperties, value))
|
||||
$: dispatch("change", value)
|
||||
$: ({ context } = groupBy("type", bindableProperties))
|
||||
$: searchRgx = new RegExp(search, "ig")
|
||||
|
||||
function checkValid() {
|
||||
// TODO: need to convert the value to the runtime binding
|
||||
const runtimeBinding = readableToRuntimeBinding(bindableProperties, value)
|
||||
valid = isValid(runtimeBinding)
|
||||
}
|
||||
|
||||
export function cancel() {
|
||||
dispatch("update", originalValue)
|
||||
bindingDrawer.close()
|
||||
}
|
||||
$: filteredColumns = context?.filter(context => {
|
||||
return context.readableBinding.match(searchRgx)
|
||||
})
|
||||
$: filteredHelpers = helpers?.filter(helper => {
|
||||
return helper.label.match(searchRgx) || helper.description.match(searchRgx)
|
||||
})
|
||||
</script>
|
||||
|
||||
<DrawerContent>
|
||||
<svelte:fragment slot="sidebar">
|
||||
<Layout>
|
||||
<Search placeholder="Search" bind:value={search} />
|
||||
{#if context}
|
||||
<div class="container">
|
||||
<section>
|
||||
<div class="heading">Search</div>
|
||||
<Search placeholder="Search" bind:value={search} />
|
||||
</section>
|
||||
{#if filteredColumns?.length}
|
||||
<section>
|
||||
<Heading size="XS">Columns</Heading>
|
||||
<div class="heading">Columns</div>
|
||||
<ul>
|
||||
{#each context.filter( context => context.readableBinding.match(searchRgx) ) as { readableBinding }}
|
||||
{#each filteredColumns as { readableBinding }}
|
||||
<li
|
||||
on:click={() => {
|
||||
value = addToText(value, getCaretPosition(), readableBinding)
|
||||
|
@ -71,39 +52,29 @@
|
|||
</ul>
|
||||
</section>
|
||||
{/if}
|
||||
{#if instance}
|
||||
{#if filteredHelpers?.length}
|
||||
<section>
|
||||
<Heading size="XS">Components</Heading>
|
||||
<div class="heading">Helpers</div>
|
||||
<ul>
|
||||
{#each instance.filter( instance => instance.readableBinding.match(searchRgx) ) as { readableBinding }}
|
||||
<li on:click={() => addToText(readableBinding)}>
|
||||
{readableBinding}
|
||||
{#each filteredHelpers as helper}
|
||||
<li
|
||||
on:click={() => {
|
||||
value = addToText(value, getCaretPosition(), helper.text)
|
||||
}}
|
||||
>
|
||||
<div class="helper">
|
||||
<div class="helper__name">{helper.displayText}</div>
|
||||
<div class="helper__description">
|
||||
{@html helper.description}
|
||||
</div>
|
||||
<pre class="helper__example">{helper.example || ''}</pre>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/if}
|
||||
<section>
|
||||
<Heading size="XS">Helpers</Heading>
|
||||
<ul>
|
||||
{#each helpers.filter(helper => helper.label.match(searchRgx) || helper.description.match(searchRgx)) as helper}
|
||||
<li
|
||||
on:click={() => {
|
||||
value = addToText(value, getCaretPosition(), helper.text)
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Label extraSmall>{helper.displayText}</Label>
|
||||
<div class="description">
|
||||
{@html helper.description}
|
||||
</div>
|
||||
<pre>{helper.example || ''}</pre>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
</Layout>
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
<div class="main">
|
||||
<TextArea
|
||||
|
@ -122,50 +93,78 @@
|
|||
</DrawerContent>
|
||||
|
||||
<style>
|
||||
.main {
|
||||
padding: var(--spacing-m);
|
||||
}
|
||||
|
||||
.main :global(textarea) {
|
||||
min-height: 150px !important;
|
||||
}
|
||||
|
||||
.container {
|
||||
margin: calc(-1 * var(--spacing-xl));
|
||||
}
|
||||
.heading {
|
||||
font-size: var(--font-size-s);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: var(--spectrum-global-color-gray-600);
|
||||
padding: var(--spacing-xl) 0 var(--spacing-m) 0;
|
||||
}
|
||||
|
||||
section {
|
||||
display: grid;
|
||||
grid-gap: var(--spacing-s);
|
||||
padding: 0 var(--spacing-xl) var(--spacing-xl) var(--spacing-xl);
|
||||
}
|
||||
section:not(:first-child) {
|
||||
border-top: var(--border-light);
|
||||
}
|
||||
ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--font-size-s);
|
||||
color: var(--grey-7);
|
||||
padding: var(--spacing-m);
|
||||
margin: auto 0px;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
border-top: var(--border-light);
|
||||
border-width: 1px 0 1px 0;
|
||||
border-radius: 4px;
|
||||
border: var(--border-light);
|
||||
transition: background-color 130ms ease-in-out, color 130ms ease-in-out,
|
||||
border-color 130ms ease-in-out;
|
||||
}
|
||||
|
||||
pre,
|
||||
.description {
|
||||
white-space: normal;
|
||||
li:not(:last-of-type) {
|
||||
margin-bottom: var(--spacing-s);
|
||||
}
|
||||
li :global(*) {
|
||||
transition: color 130ms ease-in-out;
|
||||
}
|
||||
|
||||
li:hover {
|
||||
background-color: var(--grey-2);
|
||||
color: var(--spectrum-global-color-gray-900);
|
||||
background-color: var(--spectrum-global-color-gray-50);
|
||||
border-color: var(--spectrum-global-color-gray-500);
|
||||
cursor: pointer;
|
||||
}
|
||||
li:hover :global(*) {
|
||||
color: var(--spectrum-global-color-gray-900) !important;
|
||||
}
|
||||
|
||||
li:active {
|
||||
color: var(--blue);
|
||||
.helper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
.helper__name {
|
||||
font-weight: bold;
|
||||
}
|
||||
.helper__description,
|
||||
.helper__description :global(*) {
|
||||
color: var(--spectrum-global-color-gray-700);
|
||||
}
|
||||
.helper__example {
|
||||
white-space: normal;
|
||||
margin: 0.5rem 0 0 0;
|
||||
font-weight: 700;
|
||||
}
|
||||
.helper__description :global(p) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.syntax-error {
|
||||
|
@ -173,21 +172,8 @@
|
|||
color: var(--red);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.syntax-error a {
|
||||
color: var(--red);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.description :global(p) {
|
||||
color: var(--grey-7);
|
||||
}
|
||||
|
||||
.description :global(p:hover) {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.description :global(p a) {
|
||||
color: var(--grey-7);
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -17,10 +17,11 @@
|
|||
|
||||
const dispatch = createEventDispatcher()
|
||||
let bindingDrawer
|
||||
$: tempValue = Array.isArray(value) ? value : []
|
||||
let valid = true
|
||||
$: readableValue = runtimeToReadableBinding(bindings, value)
|
||||
$: tempValue = readableValue
|
||||
|
||||
const handleClose = () => {
|
||||
const saveBinding = () => {
|
||||
onChange(tempValue)
|
||||
bindingDrawer.hide()
|
||||
}
|
||||
|
@ -48,13 +49,15 @@
|
|||
<svelte:fragment slot="description">
|
||||
Add the objects on the left to enrich your text.
|
||||
</svelte:fragment>
|
||||
<Button cta slot="buttons" on:click={handleClose}>Save</Button>
|
||||
<Button cta slot="buttons" disabled={!valid} on:click={saveBinding}>
|
||||
Save
|
||||
</Button>
|
||||
<svelte:component
|
||||
this={panel}
|
||||
slot="body"
|
||||
bind:valid
|
||||
value={readableValue}
|
||||
close={handleClose}
|
||||
on:update={event => (tempValue = event.detail)}
|
||||
on:change={event => (tempValue = event.detail)}
|
||||
bindableProperties={bindings}
|
||||
/>
|
||||
</Drawer>
|
||||
|
|
|
@ -6,7 +6,6 @@
|
|||
} from "builderStore/dataBinding"
|
||||
import ServerBindingPanel from "components/common/bindings/ServerBindingPanel.svelte"
|
||||
import { createEventDispatcher } from "svelte"
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
export let panel = ServerBindingPanel
|
||||
export let value = ""
|
||||
|
@ -16,12 +15,11 @@
|
|||
export let placeholder
|
||||
export let label
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
let bindingModal
|
||||
let validity = true
|
||||
|
||||
let valid = true
|
||||
$: readableValue = runtimeToReadableBinding(bindings, value)
|
||||
$: tempValue = readableValue
|
||||
$: invalid = !validity
|
||||
|
||||
const saveBinding = () => {
|
||||
onChange(tempValue)
|
||||
|
@ -38,7 +36,7 @@
|
|||
{label}
|
||||
{thin}
|
||||
value={readableValue}
|
||||
on:change={event => onChange(event.target.value)}
|
||||
on:change={event => onChange(event.detail)}
|
||||
{placeholder}
|
||||
/>
|
||||
<div class="icon" on:click={bindingModal.show}>
|
||||
|
@ -46,23 +44,20 @@
|
|||
</div>
|
||||
</div>
|
||||
<Modal bind:this={bindingModal}>
|
||||
<ModalContent
|
||||
{title}
|
||||
onConfirm={saveBinding}
|
||||
bind:disabled={invalid}
|
||||
size="XL"
|
||||
>
|
||||
<ModalContent {title} onConfirm={saveBinding} disabled={!valid} size="XL">
|
||||
<Body extraSmall grey>
|
||||
Add the objects on the left to enrich your text.
|
||||
</Body>
|
||||
<svelte:component
|
||||
this={panel}
|
||||
serverSide
|
||||
value={readableValue}
|
||||
bind:validity
|
||||
on:update={event => (tempValue = event.detail)}
|
||||
bindableProperties={bindings}
|
||||
/>
|
||||
<div class="panel-wrapper">
|
||||
<svelte:component
|
||||
this={panel}
|
||||
serverSide
|
||||
value={readableValue}
|
||||
bind:valid
|
||||
on:change={e => (tempValue = e.detail)}
|
||||
bindableProperties={bindings}
|
||||
/>
|
||||
</div>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
|
@ -100,4 +95,9 @@
|
|||
background-color: var(--spectrum-global-color-gray-50);
|
||||
border-color: var(--spectrum-alias-border-color-hover);
|
||||
}
|
||||
|
||||
.panel-wrapper {
|
||||
border: var(--border-light);
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,12 +1,6 @@
|
|||
<script>
|
||||
import groupBy from "lodash/fp/groupBy"
|
||||
import {
|
||||
Input,
|
||||
TextArea,
|
||||
Heading,
|
||||
Layout,
|
||||
DrawerContent,
|
||||
} from "@budibase/bbui"
|
||||
import { Search, TextArea, DrawerContent } from "@budibase/bbui"
|
||||
import { createEventDispatcher } from "svelte"
|
||||
import { isValid } from "@budibase/string-templates"
|
||||
import { handlebarsCompletions } from "constants/completions"
|
||||
|
@ -16,83 +10,91 @@
|
|||
const dispatch = createEventDispatcher()
|
||||
|
||||
export let bindableProperties = []
|
||||
export let validity = true
|
||||
export let valid = true
|
||||
export let value = ""
|
||||
|
||||
let hasReadable = bindableProperties[0].readableBinding != null
|
||||
let helpers = handlebarsCompletions()
|
||||
let getCaretPosition
|
||||
let search = ""
|
||||
|
||||
$: categories = Object.entries(groupBy("category", bindableProperties))
|
||||
$: value && checkValid()
|
||||
$: dispatch("update", value)
|
||||
$: valid = isValid(readableToRuntimeBinding(bindableProperties, value))
|
||||
$: dispatch("change", value)
|
||||
$: searchRgx = new RegExp(search, "ig")
|
||||
|
||||
function checkValid() {
|
||||
if (hasReadable) {
|
||||
const runtime = readableToRuntimeBinding(bindableProperties, value)
|
||||
validity = isValid(runtime)
|
||||
} else {
|
||||
validity = isValid(value)
|
||||
}
|
||||
}
|
||||
$: filteredCategories = categories.map(([categoryName, bindings]) => {
|
||||
const filteredBindings = bindings.filter(binding => {
|
||||
return binding.label.match(searchRgx)
|
||||
})
|
||||
return [categoryName, filteredBindings]
|
||||
})
|
||||
$: filteredHelpers = helpers?.filter(helper => {
|
||||
return helper.label.match(searchRgx) || helper.description.match(searchRgx)
|
||||
})
|
||||
</script>
|
||||
|
||||
<DrawerContent>
|
||||
<div slot="sidebar" class="list">
|
||||
<Layout>
|
||||
<div class="section">
|
||||
<Heading size="S">Available bindings</Heading>
|
||||
<Input extraThin placeholder="Search" bind:value={search} />
|
||||
</div>
|
||||
<div class="section">
|
||||
{#each categories as [categoryName, bindings]}
|
||||
<Heading size="XS">{categoryName}</Heading>
|
||||
{#each bindings.filter( binding => binding.label.match(searchRgx) ) as binding}
|
||||
<div
|
||||
class="binding"
|
||||
on:click={() => {
|
||||
value = addToText(value, getCaretPosition(), binding)
|
||||
}}
|
||||
>
|
||||
<span class="binding__label">{binding.label}</span>
|
||||
<span class="binding__type">{binding.type}</span>
|
||||
<br />
|
||||
<div class="binding__description">
|
||||
{binding.description || ""}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/each}
|
||||
</div>
|
||||
<div class="section">
|
||||
<Heading size="XS">Helpers</Heading>
|
||||
{#each helpers.filter(helper => helper.label.match(searchRgx) || helper.description.match(searchRgx)) as helper}
|
||||
<div
|
||||
class="binding"
|
||||
on:click={() => {
|
||||
value = addToText(value, getCaretPosition(), helper.text)
|
||||
}}
|
||||
>
|
||||
<span class="binding__label">{helper.label}</span>
|
||||
<br />
|
||||
<div class="binding__description">
|
||||
{@html helper.description || ""}
|
||||
</div>
|
||||
<pre>{helper.example || ""}</pre>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</Layout>
|
||||
</div>
|
||||
<div class="text">
|
||||
<svelte:fragment slot="sidebar">
|
||||
<div class="container">
|
||||
<section>
|
||||
<div class="heading">Search</div>
|
||||
<Search placeholder="Search" bind:value={search} />
|
||||
</section>
|
||||
{#each filteredCategories as [categoryName, bindings]}
|
||||
{#if bindings.length}
|
||||
<section>
|
||||
<div class="heading">{categoryName}</div>
|
||||
<ul>
|
||||
{#each bindings as binding}
|
||||
<li
|
||||
on:click={() => {
|
||||
value = addToText(value, getCaretPosition(), binding)
|
||||
}}
|
||||
>
|
||||
<span class="binding__label">{binding.label}</span>
|
||||
<span class="binding__type">{binding.type}</span>
|
||||
{#if binding.description}
|
||||
<br />
|
||||
<div class="binding__description">
|
||||
{binding.description || ""}
|
||||
</div>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/if}
|
||||
{/each}
|
||||
{#if filteredHelpers?.length}
|
||||
<section>
|
||||
<div class="heading">Helpers</div>
|
||||
<ul>
|
||||
{#each filteredHelpers as helper}
|
||||
<li
|
||||
on:click={() => {
|
||||
value = addToText(value, getCaretPosition(), helper.text)
|
||||
}}
|
||||
>
|
||||
<div class="helper">
|
||||
<div class="helper__name">{helper.displayText}</div>
|
||||
<div class="helper__description">
|
||||
{@html helper.description}
|
||||
</div>
|
||||
<pre class="helper__example">{helper.example || ''}</pre>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
<div class="main">
|
||||
<TextArea
|
||||
bind:getCaretPosition
|
||||
bind:value
|
||||
placeholder="Add text, or click the objects on the left to add them to the textbox."
|
||||
/>
|
||||
{#if !validity}
|
||||
{#if !valid}
|
||||
<p class="syntax-error">
|
||||
Current Handlebars syntax is invalid, please check the guide
|
||||
<a href="https://handlebarsjs.com/guide/">here</a>
|
||||
|
@ -103,70 +105,105 @@
|
|||
</DrawerContent>
|
||||
|
||||
<style>
|
||||
.list {
|
||||
grid-gap: var(--spacing-s);
|
||||
border-right: var(--border-light);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.section {
|
||||
display: grid;
|
||||
grid-gap: var(--spacing-s);
|
||||
}
|
||||
|
||||
.text {
|
||||
padding: var(--spacing-l);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
.text :global(textarea) {
|
||||
.main :global(textarea) {
|
||||
min-height: 150px !important;
|
||||
}
|
||||
.text :global(p) {
|
||||
|
||||
.container {
|
||||
margin: calc(-1 * var(--spacing-xl));
|
||||
}
|
||||
.heading {
|
||||
font-size: var(--font-size-s);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: var(--spectrum-global-color-gray-600);
|
||||
padding: var(--spacing-xl) 0 var(--spacing-m) 0;
|
||||
}
|
||||
|
||||
section {
|
||||
padding: 0 var(--spacing-xl) var(--spacing-xl) var(--spacing-xl);
|
||||
}
|
||||
section:not(:first-child) {
|
||||
border-top: var(--border-light);
|
||||
}
|
||||
ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.binding {
|
||||
font-size: 12px;
|
||||
li {
|
||||
font-size: var(--font-size-s);
|
||||
padding: var(--spacing-m);
|
||||
border-radius: 4px;
|
||||
border: var(--border-light);
|
||||
border-width: 1px 0 0 0;
|
||||
padding: var(--spacing-m) 0;
|
||||
margin: auto 0;
|
||||
align-items: center;
|
||||
transition: background-color 130ms ease-in-out, color 130ms ease-in-out,
|
||||
border-color 130ms ease-in-out;
|
||||
}
|
||||
li:not(:last-of-type) {
|
||||
margin-bottom: var(--spacing-s);
|
||||
}
|
||||
li :global(*) {
|
||||
transition: color 130ms ease-in-out;
|
||||
}
|
||||
li:hover {
|
||||
color: var(--spectrum-global-color-gray-900);
|
||||
background-color: var(--spectrum-global-color-gray-50);
|
||||
border-color: var(--spectrum-global-color-gray-500);
|
||||
cursor: pointer;
|
||||
}
|
||||
.binding:hover {
|
||||
background-color: var(--grey-2);
|
||||
cursor: pointer;
|
||||
li:hover :global(*) {
|
||||
color: var(--spectrum-global-color-gray-900) !important;
|
||||
}
|
||||
|
||||
.helper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
.helper__name {
|
||||
font-weight: bold;
|
||||
}
|
||||
.helper__description,
|
||||
.helper__description :global(*) {
|
||||
color: var(--spectrum-global-color-gray-700);
|
||||
}
|
||||
.helper__example {
|
||||
white-space: normal;
|
||||
margin: 0.5rem 0 0 0;
|
||||
font-weight: 700;
|
||||
}
|
||||
.helper__description :global(p) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.syntax-error {
|
||||
padding-top: var(--spacing-m);
|
||||
color: var(--red);
|
||||
font-size: 12px;
|
||||
}
|
||||
.syntax-error a {
|
||||
color: var(--red);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.binding__label {
|
||||
font-weight: 600;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.binding__description {
|
||||
color: var(--grey-8);
|
||||
margin-top: 2px;
|
||||
color: var(--spectrum-global-color-gray-700);
|
||||
margin: 0.5rem 0 0 0;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
pre {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.binding__type {
|
||||
font-family: monospace;
|
||||
background-color: var(--grey-2);
|
||||
border-radius: var(--border-radius-m);
|
||||
padding: 2px;
|
||||
background-color: var(--spectrum-global-color-gray-200);
|
||||
border-radius: var(--border-radius-s);
|
||||
padding: 2px 4px;
|
||||
margin-left: 2px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.syntax-error {
|
||||
color: var(--red);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.syntax-error a {
|
||||
color: var(--red);
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -40,7 +40,7 @@
|
|||
<Button cta slot="buttons" on:click={save}>Save</Button>
|
||||
<DrawerContent slot="body">
|
||||
<div class="content">
|
||||
<Layout gap="S">
|
||||
<Layout gap="S" noPadding>
|
||||
<Body size="S">Custom CSS overrides all other component styles.</Body>
|
||||
<TextArea bind:value={tempValue} placeholder="Enter some CSS..." />
|
||||
</Layout>
|
||||
|
|
|
@ -132,7 +132,7 @@
|
|||
Save
|
||||
</Button>
|
||||
<DrawerContent slot="body">
|
||||
<Layout>
|
||||
<Layout noPadding>
|
||||
{#if value.parameters.length > 0}
|
||||
<ParameterBuilder
|
||||
bind:customParams={value.queryParams}
|
||||
|
|
|
@ -73,54 +73,49 @@
|
|||
</script>
|
||||
|
||||
<DrawerContent>
|
||||
<div class="actions-list" slot="sidebar">
|
||||
<Layout>
|
||||
<ActionMenu>
|
||||
<Button slot="control" secondary>Add Action</Button>
|
||||
{#each actionTypes as actionType}
|
||||
<MenuItem on:click={addAction(actionType)}>
|
||||
{actionType.name}
|
||||
</MenuItem>
|
||||
{/each}
|
||||
</ActionMenu>
|
||||
|
||||
{#if actions && actions.length > 0}
|
||||
<div
|
||||
class="action-dnd-container"
|
||||
use:dndzone={{
|
||||
items: actions,
|
||||
flipDurationMs,
|
||||
dropTargetStyle: { outline: "none" },
|
||||
}}
|
||||
on:consider={handleDndConsider}
|
||||
on:finalize={handleDndFinalize}
|
||||
>
|
||||
{#each actions as action, index (action.id)}
|
||||
<div
|
||||
class="action-container"
|
||||
animate:flip={{ duration: flipDurationMs }}
|
||||
>
|
||||
<div
|
||||
class="action-header"
|
||||
class:selected={action === selectedAction}
|
||||
on:click={selectAction(action)}
|
||||
>
|
||||
{index + 1}.
|
||||
{action[EVENT_TYPE_KEY]}
|
||||
</div>
|
||||
<div
|
||||
on:click={() => deleteAction(index)}
|
||||
style="margin-left: auto;"
|
||||
>
|
||||
<Icon size="S" hoverable name="Close" />
|
||||
</div>
|
||||
<Layout noPadding gap="S" slot="sidebar">
|
||||
{#if actions && actions.length > 0}
|
||||
<div
|
||||
class="actions"
|
||||
use:dndzone={{
|
||||
items: actions,
|
||||
flipDurationMs,
|
||||
dropTargetStyle: { outline: "none" },
|
||||
}}
|
||||
on:consider={handleDndConsider}
|
||||
on:finalize={handleDndFinalize}
|
||||
>
|
||||
{#each actions as action, index (action.id)}
|
||||
<div
|
||||
class="action-container"
|
||||
animate:flip={{ duration: flipDurationMs }}
|
||||
class:selected={action === selectedAction}
|
||||
on:click={selectAction(action)}
|
||||
>
|
||||
<Icon name="DragHandle" size="XL" />
|
||||
<div class="action-header">
|
||||
{index + 1}. {action[EVENT_TYPE_KEY]}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</Layout>
|
||||
</div>
|
||||
<Layout>
|
||||
<Icon
|
||||
name="Close"
|
||||
hoverable
|
||||
size="S"
|
||||
on:click={() => deleteAction(index)}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<ActionMenu>
|
||||
<Button slot="control" secondary>Add Action</Button>
|
||||
{#each actionTypes as actionType}
|
||||
<MenuItem on:click={addAction(actionType)}>
|
||||
{actionType.name}
|
||||
</MenuItem>
|
||||
{/each}
|
||||
</ActionMenu>
|
||||
</Layout>
|
||||
<Layout noPadding>
|
||||
{#if selectedAction}
|
||||
<div class="selected-action-container">
|
||||
<svelte:component
|
||||
|
@ -133,32 +128,41 @@
|
|||
</DrawerContent>
|
||||
|
||||
<style>
|
||||
.action-header {
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin-top: var(--spacing-s);
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: stretch;
|
||||
gap: var(--spacing-s);
|
||||
}
|
||||
|
||||
.action-header {
|
||||
margin-bottom: var(--spacing-m);
|
||||
font-size: var(--font-size-s);
|
||||
color: var(--grey-7);
|
||||
font-weight: 600;
|
||||
}
|
||||
color: var(--spectrum-global-color-gray-700);
|
||||
|
||||
.action-header:hover,
|
||||
.action-header.selected {
|
||||
cursor: pointer;
|
||||
color: var(--ink);
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.action-container {
|
||||
border-bottom: 1px solid var(--grey-1);
|
||||
background-color: var(--background);
|
||||
padding: var(--spacing-s) var(--spacing-m);
|
||||
border-radius: 4px;
|
||||
border: var(--border-light);
|
||||
transition: background-color 130ms ease-in-out, color 130ms ease-in-out,
|
||||
border-color 130ms ease-in-out;
|
||||
gap: var(--spacing-m);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.action-container:last-child {
|
||||
border-bottom: none;
|
||||
.action-container:hover,
|
||||
.action-container.selected {
|
||||
background-color: var(--spectrum-global-color-gray-50);
|
||||
border-color: var(--spectrum-global-color-gray-500);
|
||||
cursor: pointer;
|
||||
}
|
||||
.action-container:hover .action-header,
|
||||
.action-container.selected .action-header {
|
||||
color: var(--spectrum-global-color-gray-900);
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -25,7 +25,7 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<Layout gap="XS">
|
||||
<Layout gap="XS" noPadding>
|
||||
<Select
|
||||
label="Datasource"
|
||||
bind:value={parameters.datasourceId}
|
||||
|
|
|
@ -42,7 +42,7 @@
|
|||
<Drawer bind:this={drawer} title="Filtering">
|
||||
<Button cta slot="buttons" on:click={saveFilter}>Save</Button>
|
||||
<DrawerContent slot="body">
|
||||
<Layout>
|
||||
<Layout noPadding>
|
||||
<Body size="S">
|
||||
{#if !numFilters}
|
||||
Add your first filter column.
|
||||
|
|
|
@ -41,7 +41,7 @@
|
|||
|
||||
<DrawerContent>
|
||||
<div class="container">
|
||||
<Layout>
|
||||
<Layout noPadding gap="S">
|
||||
{#if links?.length}
|
||||
<div
|
||||
class="links"
|
||||
|
@ -72,7 +72,7 @@
|
|||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="button-container">
|
||||
<div>
|
||||
<Button secondary icon="Add" on:click={addLink}>Add Link</Button>
|
||||
</div>
|
||||
</Layout>
|
||||
|
@ -83,16 +83,16 @@
|
|||
.container {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
margin: var(--spacing-m) auto;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.links {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: stretch;
|
||||
gap: var(--spacing-s);
|
||||
}
|
||||
.link {
|
||||
padding: 4px 8px;
|
||||
gap: var(--spacing-l);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
@ -108,7 +108,4 @@
|
|||
flex: 1 1 auto;
|
||||
width: 0;
|
||||
}
|
||||
.button-container {
|
||||
margin-left: var(--spacing-l);
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -20,7 +20,6 @@
|
|||
export let onChange = () => {}
|
||||
|
||||
let bindingDrawer
|
||||
let temporaryBindableValue = value
|
||||
let anchor
|
||||
let valid
|
||||
|
||||
|
@ -29,10 +28,11 @@
|
|||
$store.selectedComponentId
|
||||
)
|
||||
$: safeValue = getSafeValue(value, props.defaultValue, bindableProperties)
|
||||
$: tempValue = safeValue
|
||||
$: replaceBindings = val => readableToRuntimeBinding(bindableProperties, val)
|
||||
|
||||
const handleClose = () => {
|
||||
handleChange(temporaryBindableValue)
|
||||
handleChange(tempValue)
|
||||
bindingDrawer.hide()
|
||||
}
|
||||
|
||||
|
@ -107,8 +107,7 @@
|
|||
slot="body"
|
||||
bind:valid
|
||||
value={safeValue}
|
||||
close={handleClose}
|
||||
on:update={e => (temporaryBindableValue = e.detail)}
|
||||
on:change={e => (tempValue = e.detail)}
|
||||
{bindableProperties}
|
||||
/>
|
||||
</Drawer>
|
||||
|
|
|
@ -1,11 +1,9 @@
|
|||
<script>
|
||||
import { ActionButton } from "@budibase/bbui"
|
||||
import GoogleLogo from "assets/google-logo.png"
|
||||
import { admin } from "stores/portal"
|
||||
import { organisation } from "stores/portal"
|
||||
|
||||
let show = false
|
||||
|
||||
$: show = $admin.checklist?.oauth
|
||||
$: show = $organisation.google
|
||||
</script>
|
||||
|
||||
{#if show}
|
||||
|
|
|
@ -0,0 +1,59 @@
|
|||
<script>
|
||||
import { ActionButton } from "@budibase/bbui"
|
||||
import OidcLogo from "assets/oidc-logo.png"
|
||||
import Auth0Logo from "assets/auth0-logo.png"
|
||||
import MicrosoftLogo from "assets/microsoft-logo.png"
|
||||
import OktaLogo from "assets/okta-logo.png"
|
||||
import OneLoginLogo from "assets/onelogin-logo.png"
|
||||
|
||||
import { oidc, organisation } from "stores/portal"
|
||||
import { onMount } from "svelte"
|
||||
|
||||
$: show = $organisation.oidc
|
||||
|
||||
let preDefinedIcons = {
|
||||
Oidc: OidcLogo,
|
||||
Auth0: Auth0Logo,
|
||||
Microsoft: MicrosoftLogo,
|
||||
Okta: OktaLogo,
|
||||
OneLogin: OneLoginLogo,
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
await oidc.init()
|
||||
})
|
||||
|
||||
$: src = !$oidc.logo
|
||||
? OidcLogo
|
||||
: preDefinedIcons[$oidc.logo] || `/global/logos_oidc/${$oidc.logo}`
|
||||
</script>
|
||||
|
||||
{#if show}
|
||||
<ActionButton
|
||||
on:click={() =>
|
||||
window.open(`/api/admin/auth/oidc/configs/${$oidc.uuid}`, "_blank")}
|
||||
>
|
||||
<div class="inner">
|
||||
<img {src} alt="oidc icon" />
|
||||
<p>{`Sign in with ${$oidc.name || "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>
|
|
@ -10,8 +10,9 @@
|
|||
notifications,
|
||||
} from "@budibase/bbui"
|
||||
import { goto, params } from "@roxi/routify"
|
||||
import { auth, organisation } from "stores/portal"
|
||||
import { auth, organisation, oidc } from "stores/portal"
|
||||
import GoogleButton from "./_components/GoogleButton.svelte"
|
||||
import OIDCButton from "./_components/OIDCButton.svelte"
|
||||
import Logo from "assets/bb-emblem.svg"
|
||||
import { onMount } from "svelte"
|
||||
|
||||
|
@ -61,6 +62,7 @@
|
|||
<Heading>Sign in to {company}</Heading>
|
||||
</Layout>
|
||||
<GoogleButton />
|
||||
<OIDCButton oidcIcon={$oidc.logo} oidcName={$oidc.name} />
|
||||
<Divider noGrid />
|
||||
<Layout gap="XS" noPadding>
|
||||
<Body size="S" textAlign="center">Sign in with email</Body>
|
||||
|
|
|
@ -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 |
|
@ -1,5 +1,13 @@
|
|||
<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,
|
||||
|
@ -9,20 +17,25 @@
|
|||
Layout,
|
||||
Input,
|
||||
Body,
|
||||
Select,
|
||||
Toggle,
|
||||
} from "@budibase/bbui"
|
||||
import { onMount } from "svelte"
|
||||
import api from "builderStore/api"
|
||||
import { organisation } from "stores/portal"
|
||||
import { uuid } from "builderStore/uuid"
|
||||
|
||||
const ConfigTypes = {
|
||||
Google: "google",
|
||||
OIDC: "oidc",
|
||||
// Github: "github",
|
||||
// AzureAD: "ad",
|
||||
}
|
||||
|
||||
const ConfigFields = {
|
||||
const GoogleConfigFields = {
|
||||
Google: ["clientID", "clientSecret", "callbackURL"],
|
||||
}
|
||||
const ConfigLabels = {
|
||||
const GoogleConfigLabels = {
|
||||
Google: {
|
||||
clientID: "Client ID",
|
||||
clientSecret: "Client secret",
|
||||
|
@ -30,24 +43,167 @@
|
|||
},
|
||||
}
|
||||
|
||||
const OIDCConfigFields = {
|
||||
Oidc: ["configUrl", "clientID", "clientSecret"],
|
||||
}
|
||||
const OIDCConfigLabels = {
|
||||
Oidc: {
|
||||
configUrl: "Config URL",
|
||||
clientID: "Client ID",
|
||||
clientSecret: "Client Secret",
|
||||
},
|
||||
}
|
||||
|
||||
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 }
|
||||
|
||||
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
|
||||
// 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)
|
||||
}
|
||||
|
||||
notifications.success(`Settings saved.`)
|
||||
} catch (err) {
|
||||
notifications.error(`Failed to update OAuth settings. ${err}`)
|
||||
}
|
||||
// Create a flag so that it will only try to save completed forms
|
||||
$: partialGoogle =
|
||||
providers.google?.config?.clientID ||
|
||||
providers.google?.config?.clientSecret ||
|
||||
providers.google?.config?.callbackURL
|
||||
$: 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 &&
|
||||
providers.google?.config?.callbackURL
|
||||
$: oidcComplete =
|
||||
providers.oidc?.config?.configs[0].configUrl &&
|
||||
providers.oidc?.config?.configs[0].clientID &&
|
||||
providers.oidc?.config?.configs[0].clientSecret
|
||||
|
||||
async function uploadLogo(file) {
|
||||
let data = new FormData()
|
||||
data.append("file", file)
|
||||
const res = await api.post(
|
||||
`/api/admin/configs/upload/logos_oidc/${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.configs[0].logo = fileName
|
||||
iconDropdownOptions.unshift({ label: fileName, value: fileName })
|
||||
}
|
||||
|
||||
async function save(docs) {
|
||||
// only if the user has provided an image, upload it.
|
||||
image && uploadLogo(image)
|
||||
let calls = []
|
||||
docs.forEach(element => {
|
||||
if (element.type === ConfigTypes.OIDC) {
|
||||
//Add a UUID here so each config is distinguishable when it arrives at the login page.
|
||||
element.config.configs.forEach(config => {
|
||||
!config.uuid && (config.uuid = uuid())
|
||||
})
|
||||
if (partialOidc) {
|
||||
if (!oidcComplete) {
|
||||
notifications.error(
|
||||
`Please fill in all required ${ConfigTypes.OIDC} fields`
|
||||
)
|
||||
} else {
|
||||
calls.push(api.post(`/api/admin/configs`, 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.post(`/api/admin/configs`, element))
|
||||
googleSaveButtonDisabled = true
|
||||
originalGoogleDoc = cloneDeep(providers.google)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
calls.length &&
|
||||
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 auth settings. ${err}`)
|
||||
throw new Error(err.message)
|
||||
})
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
await organisation.init()
|
||||
// fetch the configs for oauth
|
||||
const googleResponse = await api.get(
|
||||
`/api/admin/configs/${ConfigTypes.Google}`
|
||||
|
@ -55,33 +211,77 @@
|
|||
const googleDoc = await googleResponse.json()
|
||||
|
||||
if (!googleDoc._id) {
|
||||
google = {
|
||||
providers.google = {
|
||||
type: ConfigTypes.Google,
|
||||
config: {},
|
||||
config: { activated: true },
|
||||
}
|
||||
originalGoogleDoc = cloneDeep(googleDoc)
|
||||
} else {
|
||||
originalGoogleDoc = cloneDeep(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 call so they're available when the dropdown renders
|
||||
const res = await api.get(`/api/admin/configs/logos_oidc`)
|
||||
const configSettings = await res.json()
|
||||
|
||||
if (configSettings.config) {
|
||||
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) {
|
||||
console.log("hi")
|
||||
|
||||
providers.oidc = {
|
||||
type: ConfigTypes.OIDC,
|
||||
config: { configs: [{ activated: true }] },
|
||||
}
|
||||
} else {
|
||||
google = googleDoc
|
||||
console.log("hello")
|
||||
originalOidcDoc = cloneDeep(oidcDoc)
|
||||
providers.oidc = oidcDoc
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<Layout>
|
||||
<Layout gap="XS" noPadding>
|
||||
<Heading size="M">OAuth</Heading>
|
||||
<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 google}
|
||||
{#if providers.google}
|
||||
<Divider />
|
||||
<Layout gap="XS" noPadding>
|
||||
<Heading size="S">
|
||||
<span>
|
||||
<div>
|
||||
<GoogleLogo />
|
||||
Google
|
||||
</span>
|
||||
<div class="google-save-button">
|
||||
<div>
|
||||
<Button
|
||||
disabled={googleSaveButtonDisabled}
|
||||
size="s"
|
||||
cta
|
||||
on:click={() => save([providers.google])}>Save</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Heading>
|
||||
<Body size="S">
|
||||
To allow users to authenticate using their Google accounts, fill out the
|
||||
|
@ -89,30 +289,127 @@
|
|||
</Body>
|
||||
</Layout>
|
||||
<Layout gap="XS" noPadding>
|
||||
{#each ConfigFields.Google as field}
|
||||
{#each GoogleConfigFields.Google as field}
|
||||
<div class="form-row">
|
||||
<Label size="L">{ConfigLabels.Google[field]}</Label>
|
||||
<Input bind:value={google.config[field]} />
|
||||
<Label size="L">{GoogleConfigLabels.Google[field]}</Label>
|
||||
<Input bind:value={providers.google.config[field]} />
|
||||
</div>
|
||||
{/each}
|
||||
<div class="form-row">
|
||||
<div class="field">
|
||||
<Label size="L">Activated</Label>
|
||||
<span class="alignedToggle">
|
||||
<Toggle text="" bind:value={providers.google.config.activated} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
<div>
|
||||
<Button cta on:click={() => save(google)}>Save</Button>
|
||||
{/if}
|
||||
{#if providers.oidc}
|
||||
<Divider />
|
||||
<Layout gap="XS" noPadding>
|
||||
<Heading size="S">
|
||||
<div>
|
||||
<OidcLogo />
|
||||
OpenID Connect
|
||||
<div class="oidc-save-button">
|
||||
<div>
|
||||
<Button
|
||||
disabled={oidcSaveButtonDisabled}
|
||||
size="s"
|
||||
cta
|
||||
on:click={() => save([providers.oidc])}>Save</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</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">{OIDCConfigLabels.Oidc[field]}</Label>
|
||||
<Input bind:value={providers.oidc.config.configs[0][field]} />
|
||||
</div>
|
||||
{/each}
|
||||
<div class="form-row">
|
||||
<Label size="L">Callback URL</Label>
|
||||
<Input readonly placeholder="/api/admin/auth/oidc/callback" />
|
||||
</div>
|
||||
<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.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}
|
||||
/>
|
||||
</Layout>
|
||||
<div class="form-row">
|
||||
<div class="field">
|
||||
<Label size="L">Activated</Label>
|
||||
<span class="alignedToggle">
|
||||
<Toggle
|
||||
text=""
|
||||
bind:value={providers.oidc.config.configs[0].activated}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</Layout>
|
||||
|
||||
<style>
|
||||
.field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.alignedToggle {
|
||||
margin-left: 63%;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 20% 1fr;
|
||||
grid-gap: var(--spacing-l);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-s);
|
||||
}
|
||||
|
||||
input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.google-save-button {
|
||||
display: inline-block;
|
||||
margin-left: 400px;
|
||||
}
|
||||
|
||||
.oidc-save-button {
|
||||
display: inline-block;
|
||||
margin-left: 320px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -16,6 +16,7 @@
|
|||
import Editor from "components/integration/QueryEditor.svelte"
|
||||
import TemplateBindings from "./_components/TemplateBindings.svelte"
|
||||
|
||||
// this is the email purpose
|
||||
export let template
|
||||
|
||||
let htmlEditor
|
||||
|
@ -24,9 +25,11 @@
|
|||
$: selectedTemplate = $email.templates?.find(
|
||||
({ purpose }) => purpose === template
|
||||
)
|
||||
$: name = $email.definitions?.info[template]?.name
|
||||
$: description = $email.definitions?.info[template]?.description
|
||||
$: baseTemplate = $email.templates?.find(({ purpose }) => purpose === "base")
|
||||
$: templateBindings =
|
||||
$email.definitions?.bindings?.[selectedTemplate.purpose] || []
|
||||
$email.definitions?.bindings?.[selectedTemplate?.purpose] || []
|
||||
$: previewContent = makePreviewContent(baseTemplate, selectedTemplate)
|
||||
|
||||
async function saveTemplate() {
|
||||
|
@ -81,10 +84,12 @@
|
|||
</div>
|
||||
<header>
|
||||
<Heading>
|
||||
Email Template: {template}
|
||||
Email Template: {name}
|
||||
</Heading>
|
||||
<Button cta on:click={saveTemplate}>Save</Button>
|
||||
</header>
|
||||
<Detail>Description</Detail>
|
||||
<Body>{description}</Body>
|
||||
<Body
|
||||
>Change the email template here. Add dynamic content by using the bindings
|
||||
menu on the right.</Body
|
||||
|
|
|
@ -1,13 +0,0 @@
|
|||
<script>
|
||||
import { goto } from "@roxi/routify"
|
||||
|
||||
export let value
|
||||
</script>
|
||||
|
||||
<span on:click={() => $goto(`./${value}`)}>{value}</span>
|
||||
|
||||
<style>
|
||||
span {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
</style>
|
|
@ -14,7 +14,6 @@
|
|||
Checkbox,
|
||||
} from "@budibase/bbui"
|
||||
import { email } from "stores/portal"
|
||||
import TemplateLink from "./_components/TemplateLink.svelte"
|
||||
import api from "builderStore/api"
|
||||
import { cloneDeep } from "lodash/fp"
|
||||
|
||||
|
@ -23,23 +22,30 @@
|
|||
}
|
||||
|
||||
const templateSchema = {
|
||||
purpose: {
|
||||
displayName: "Email",
|
||||
name: {
|
||||
displayName: "Name",
|
||||
editable: false,
|
||||
},
|
||||
category: {
|
||||
displayName: "Category",
|
||||
editable: false,
|
||||
},
|
||||
}
|
||||
|
||||
const customRenderers = [
|
||||
{
|
||||
column: "purpose",
|
||||
component: TemplateLink,
|
||||
},
|
||||
]
|
||||
$: emailInfo = getEmailInfo($email.definitions)
|
||||
|
||||
let smtpConfig
|
||||
let loading
|
||||
let requireAuth = false
|
||||
|
||||
function getEmailInfo(definitions) {
|
||||
if (!definitions) {
|
||||
return []
|
||||
}
|
||||
const entries = Object.entries(definitions.info)
|
||||
return entries.map(([key, value]) => ({ purpose: key, ...value }))
|
||||
}
|
||||
|
||||
async function saveSmtp() {
|
||||
// clone it so we can remove stuff if required
|
||||
const smtp = cloneDeep(smtpConfig)
|
||||
|
@ -159,8 +165,7 @@
|
|||
</Body>
|
||||
</Layout>
|
||||
<Table
|
||||
{customRenderers}
|
||||
data={$email.templates}
|
||||
data={emailInfo}
|
||||
schema={templateSchema}
|
||||
{loading}
|
||||
on:click={({ detail }) => $goto(`./${detail.purpose}`)}
|
||||
|
|
|
@ -4,3 +4,4 @@ export { admin } from "./admin"
|
|||
export { apps } from "./apps"
|
||||
export { email } from "./email"
|
||||
export { auth } from "./auth"
|
||||
export { oidc } from "./oidc"
|
||||
|
|
|
@ -0,0 +1,33 @@
|
|||
import { writable } from "svelte/store"
|
||||
import api from "builderStore/api"
|
||||
|
||||
const OIDC_CONFIG = {
|
||||
logo: undefined,
|
||||
name: undefined,
|
||||
uuid: undefined,
|
||||
}
|
||||
|
||||
export function createOidcStore() {
|
||||
const store = writable(OIDC_CONFIG)
|
||||
const { set, subscribe } = store
|
||||
|
||||
async function init() {
|
||||
const res = await api.get(`/api/admin/configs/publicOidc`)
|
||||
const json = await res.json()
|
||||
|
||||
if (json.status === 400) {
|
||||
set(OIDC_CONFIG)
|
||||
} else {
|
||||
// Just use the first config for now. We will be support multiple logins buttons later on.
|
||||
set(...json)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
set,
|
||||
init,
|
||||
}
|
||||
}
|
||||
|
||||
export const oidc = createOidcStore()
|
|
@ -6,6 +6,8 @@ const DEFAULT_CONFIG = {
|
|||
logoUrl: undefined,
|
||||
docsUrl: undefined,
|
||||
company: "Budibase",
|
||||
oidc: undefined,
|
||||
google: undefined,
|
||||
}
|
||||
|
||||
export function createOrganisationStore() {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@budibase/cli",
|
||||
"version": "0.9.73",
|
||||
"version": "0.9.78",
|
||||
"description": "Budibase CLI, for developers, self hosting and migrations.",
|
||||
"main": "src/index.js",
|
||||
"bin": {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@budibase/client",
|
||||
"version": "0.9.73",
|
||||
"version": "0.9.78",
|
||||
"license": "MPL-2.0",
|
||||
"module": "dist/budibase-client.js",
|
||||
"main": "dist/budibase-client.js",
|
||||
|
@ -18,9 +18,9 @@
|
|||
"dev:builder": "rollup -cw"
|
||||
},
|
||||
"dependencies": {
|
||||
"@budibase/bbui": "^0.9.73",
|
||||
"@budibase/standard-components": "^0.9.73",
|
||||
"@budibase/string-templates": "^0.9.73",
|
||||
"@budibase/bbui": "^0.9.78",
|
||||
"@budibase/standard-components": "^0.9.78",
|
||||
"@budibase/string-templates": "^0.9.78",
|
||||
"regexparam": "^1.3.0",
|
||||
"shortid": "^2.2.15",
|
||||
"svelte-spa-router": "^3.0.5"
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@budibase/server",
|
||||
"email": "hi@budibase.com",
|
||||
"version": "0.9.73",
|
||||
"version": "0.9.78",
|
||||
"description": "Budibase Web Server",
|
||||
"main": "src/index.js",
|
||||
"repository": {
|
||||
|
@ -60,9 +60,9 @@
|
|||
"author": "Budibase",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@budibase/auth": "^0.9.73",
|
||||
"@budibase/client": "^0.9.73",
|
||||
"@budibase/string-templates": "^0.9.73",
|
||||
"@budibase/auth": "^0.9.78",
|
||||
"@budibase/client": "^0.9.78",
|
||||
"@budibase/string-templates": "^0.9.78",
|
||||
"@elastic/elasticsearch": "7.10.0",
|
||||
"@koa/router": "8.0.0",
|
||||
"@sendgrid/mail": "7.1.1",
|
||||
|
@ -115,7 +115,7 @@
|
|||
"devDependencies": {
|
||||
"@babel/core": "^7.14.3",
|
||||
"@babel/preset-env": "^7.14.4",
|
||||
"@budibase/standard-components": "^0.9.73",
|
||||
"@budibase/standard-components": "^0.9.78",
|
||||
"@jest/test-sequencer": "^24.8.0",
|
||||
"@types/bull": "^3.15.1",
|
||||
"@types/jest": "^26.0.23",
|
||||
|
|
|
@ -2,6 +2,8 @@ CREATE DATABASE IF NOT EXISTS main;
|
|||
USE main;
|
||||
CREATE TABLE Persons (
|
||||
PersonID int NOT NULL AUTO_INCREMENT,
|
||||
CreatedAt datetime,
|
||||
Age float,
|
||||
LastName varchar(255),
|
||||
FirstName varchar(255),
|
||||
Address varchar(255),
|
||||
|
@ -17,6 +19,6 @@ CREATE TABLE Tasks (
|
|||
FOREIGN KEY(PersonID)
|
||||
REFERENCES Persons(PersonID)
|
||||
);
|
||||
INSERT INTO Persons (FirstName, LastName, Address, City) VALUES ('Mike', 'Hughes', '123 Fake Street', 'Belfast');
|
||||
INSERT INTO Persons (FirstName, LastName, Age, Address, City, CreatedAt) VALUES ('Mike', 'Hughes', 28.2, '123 Fake Street', 'Belfast', '2021-01-19 03:14:07');
|
||||
INSERT INTO Tasks (PersonID, TaskName) VALUES (1, 'assembling');
|
||||
INSERT INTO Tasks (PersonID, TaskName) VALUES (1, 'processing');
|
||||
|
|
|
@ -148,12 +148,20 @@ class QueryBuilder {
|
|||
}
|
||||
if (this.query.equal) {
|
||||
build(this.query.equal, (key, value) => {
|
||||
return value ? `${key}:${luceneEscape(value.toLowerCase())}` : null
|
||||
const escapedValue = luceneEscape(value.toLowerCase())
|
||||
// have to do the or to manage straight values, or strings
|
||||
return value
|
||||
? `(${key}:${escapedValue} OR ${key}:"${escapedValue}")`
|
||||
: null
|
||||
})
|
||||
}
|
||||
if (this.query.notEqual) {
|
||||
build(this.query.notEqual, (key, value) => {
|
||||
return value ? `!${key}:${luceneEscape(value.toLowerCase())}` : null
|
||||
const escapedValue = luceneEscape(value.toLowerCase())
|
||||
// have to do the or to manage straight values, or strings
|
||||
return value
|
||||
? `(!${key}:${escapedValue} OR !${key}:"${escapedValue}")`
|
||||
: null
|
||||
})
|
||||
}
|
||||
if (this.query.empty) {
|
||||
|
|
|
@ -76,7 +76,10 @@ module.exports.run = async function ({ inputs, appId, emitter }) {
|
|||
rowId: inputs.rowId,
|
||||
},
|
||||
request: {
|
||||
body: inputs.row,
|
||||
body: {
|
||||
...inputs.row,
|
||||
_id: inputs.rowId,
|
||||
},
|
||||
},
|
||||
appId,
|
||||
eventEmitter: emitter,
|
||||
|
|
|
@ -129,9 +129,11 @@ export interface QueryJson {
|
|||
|
||||
export interface SqlQuery {
|
||||
sql: string
|
||||
bindings?: {
|
||||
[key: string]: any
|
||||
}
|
||||
bindings?:
|
||||
| string[]
|
||||
| {
|
||||
[key: string]: any
|
||||
}
|
||||
}
|
||||
|
||||
export interface QueryOptions {
|
||||
|
|
|
@ -8,9 +8,24 @@ import {
|
|||
Operation,
|
||||
RelationshipsJson,
|
||||
} from "../../definitions/datasource"
|
||||
import { isIsoDateString } from "../utils"
|
||||
|
||||
type KnexQuery = Knex.QueryBuilder | Knex
|
||||
|
||||
function parseBody(body: any) {
|
||||
for (let [key, value] of Object.entries(body)) {
|
||||
if (typeof value !== "string") {
|
||||
continue
|
||||
}
|
||||
if (isIsoDateString(value)) {
|
||||
body[key] = new Date(value)
|
||||
} else if (!isNaN(parseFloat(value))) {
|
||||
body[key] = parseFloat(value)
|
||||
}
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// right now we only do filters on the specific table being queried
|
||||
function addFilters(
|
||||
tableName: string,
|
||||
|
@ -119,11 +134,12 @@ function buildCreate(
|
|||
): KnexQuery {
|
||||
const { endpoint, body } = json
|
||||
let query: KnexQuery = knex(endpoint.entityId)
|
||||
const parsedBody = parseBody(body)
|
||||
// mysql can't use returning
|
||||
if (opts.disableReturning) {
|
||||
return query.insert(body)
|
||||
return query.insert(parsedBody)
|
||||
} else {
|
||||
return query.insert(body).returning("*")
|
||||
return query.insert(parsedBody).returning("*")
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -173,12 +189,13 @@ function buildUpdate(
|
|||
): KnexQuery {
|
||||
const { endpoint, body, filters } = json
|
||||
let query: KnexQuery = knex(endpoint.entityId)
|
||||
const parsedBody = parseBody(body)
|
||||
query = addFilters(endpoint.entityId, query, filters)
|
||||
// mysql can't use returning
|
||||
if (opts.disableReturning) {
|
||||
return query.update(body)
|
||||
return query.update(parsedBody)
|
||||
} else {
|
||||
return query.update(body).returning("*")
|
||||
return query.update(parsedBody).returning("*")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -29,6 +29,7 @@ module MySQLModule {
|
|||
blob: FieldTypes.LONGFORM,
|
||||
enum: FieldTypes.STRING,
|
||||
varchar: FieldTypes.STRING,
|
||||
float: FieldTypes.NUMBER,
|
||||
int: FieldTypes.NUMBER,
|
||||
numeric: FieldTypes.NUMBER,
|
||||
bigint: FieldTypes.NUMBER,
|
||||
|
|
|
@ -92,7 +92,7 @@ module PostgresModule {
|
|||
|
||||
async function internalQuery(client: any, query: SqlQuery) {
|
||||
try {
|
||||
return await client.query(query.sql, query.bindings || {})
|
||||
return await client.query(query.sql, query.bindings || [])
|
||||
} catch (err) {
|
||||
throw new Error(err)
|
||||
}
|
||||
|
|
|
@ -20,7 +20,7 @@ describe("Postgres Integration", () => {
|
|||
await config.integration.create({
|
||||
sql
|
||||
})
|
||||
expect(pg.queryMock).toHaveBeenCalledWith(sql, {})
|
||||
expect(pg.queryMock).toHaveBeenCalledWith(sql, [])
|
||||
})
|
||||
|
||||
it("calls the read method with the correct params", async () => {
|
||||
|
@ -28,7 +28,7 @@ describe("Postgres Integration", () => {
|
|||
await config.integration.read({
|
||||
sql
|
||||
})
|
||||
expect(pg.queryMock).toHaveBeenCalledWith(sql, {})
|
||||
expect(pg.queryMock).toHaveBeenCalledWith(sql, [])
|
||||
})
|
||||
|
||||
it("calls the update method with the correct params", async () => {
|
||||
|
@ -36,7 +36,7 @@ describe("Postgres Integration", () => {
|
|||
const response = await config.integration.update({
|
||||
sql
|
||||
})
|
||||
expect(pg.queryMock).toHaveBeenCalledWith(sql, {})
|
||||
expect(pg.queryMock).toHaveBeenCalledWith(sql, [])
|
||||
})
|
||||
|
||||
it("calls the delete method with the correct params", async () => {
|
||||
|
@ -44,7 +44,7 @@ describe("Postgres Integration", () => {
|
|||
await config.integration.delete({
|
||||
sql
|
||||
})
|
||||
expect(pg.queryMock).toHaveBeenCalledWith(sql, {})
|
||||
expect(pg.queryMock).toHaveBeenCalledWith(sql, [])
|
||||
})
|
||||
|
||||
describe("no rows returned", () => {
|
||||
|
|
|
@ -68,3 +68,11 @@ export function isSQL(datasource: Datasource): boolean {
|
|||
const SQL = [SourceNames.POSTGRES, SourceNames.SQL_SERVER, SourceNames.MYSQL]
|
||||
return SQL.indexOf(datasource.source) !== -1
|
||||
}
|
||||
|
||||
export function isIsoDateString(str: string) {
|
||||
if (!/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/.test(str)) {
|
||||
return false
|
||||
}
|
||||
let d = new Date(str)
|
||||
return d.toISOString() === str
|
||||
}
|
||||
|
|
|
@ -31,7 +31,7 @@
|
|||
"type": "select",
|
||||
"label": "Width",
|
||||
"key": "width",
|
||||
"options": ["Small", "Medium", "Large"],
|
||||
"options": ["Small", "Medium", "Large", "Max"],
|
||||
"defaultValue": "Large"
|
||||
},
|
||||
{
|
||||
|
|
|
@ -29,12 +29,12 @@
|
|||
"keywords": [
|
||||
"svelte"
|
||||
],
|
||||
"version": "0.9.73",
|
||||
"version": "0.9.78",
|
||||
"license": "MIT",
|
||||
"gitHead": "d1836a898cab3f8ab80ee6d8f42be1a9eed7dcdc",
|
||||
"dependencies": {
|
||||
"@budibase/bbui": "^0.9.73",
|
||||
"@spectrum-css/card": "^3.0.3",
|
||||
"@budibase/bbui": "^0.9.78",
|
||||
"@spectrum-css/link": "^3.1.3",
|
||||
"@spectrum-css/page": "^3.0.1",
|
||||
"@spectrum-css/typography": "^3.0.2",
|
||||
|
|
|
@ -20,6 +20,7 @@
|
|||
None: "none",
|
||||
}
|
||||
const widthClasses = {
|
||||
Max: "max",
|
||||
Large: "l",
|
||||
Medium: "m",
|
||||
Small: "s",
|
||||
|
@ -178,6 +179,9 @@
|
|||
position: relative;
|
||||
padding: 32px;
|
||||
}
|
||||
.layout--none .main {
|
||||
padding: 0;
|
||||
}
|
||||
.size--s {
|
||||
width: 800px;
|
||||
}
|
||||
|
@ -187,6 +191,9 @@
|
|||
.size--l {
|
||||
width: 1400px;
|
||||
}
|
||||
.size--max {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Nav components */
|
||||
.burger {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@budibase/string-templates",
|
||||
"version": "0.9.73",
|
||||
"version": "0.9.78",
|
||||
"description": "Handlebars wrapper for Budibase templating.",
|
||||
"main": "src/index.cjs",
|
||||
"module": "dist/bundle.mjs",
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@budibase/worker",
|
||||
"email": "hi@budibase.com",
|
||||
"version": "0.9.73",
|
||||
"version": "0.9.78",
|
||||
"description": "Budibase background service",
|
||||
"main": "src/index.js",
|
||||
"repository": {
|
||||
|
@ -21,8 +21,8 @@
|
|||
"author": "Budibase",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@budibase/auth": "^0.9.73",
|
||||
"@budibase/string-templates": "^0.9.73",
|
||||
"@budibase/auth": "^0.9.78",
|
||||
"@budibase/string-templates": "^0.9.78",
|
||||
"@koa/router": "^8.0.0",
|
||||
"aws-sdk": "^2.811.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
|
@ -39,6 +39,7 @@
|
|||
"koa-static": "^5.0.0",
|
||||
"node-fetch": "^2.6.1",
|
||||
"nodemailer": "^6.5.0",
|
||||
"@techpass/passport-openidconnect": "^0.3.0",
|
||||
"passport-google-oauth": "^2.0.0",
|
||||
"passport-jwt": "^4.0.0",
|
||||
"passport-local": "^1.0.0",
|
||||
|
|
|
@ -1,25 +1,28 @@
|
|||
const authPkg = require("@budibase/auth")
|
||||
const { google } = require("@budibase/auth/src/middleware")
|
||||
const { oidc } = require("@budibase/auth/src/middleware")
|
||||
const { Configs, EmailTemplatePurpose } = require("../../../constants")
|
||||
const CouchDB = require("../../../db")
|
||||
const { sendEmail, isEmailConfigured } = require("../../../utilities/email")
|
||||
const { clearCookie, getGlobalUserByEmail, hash } = authPkg.utils
|
||||
const { setCookie, getCookie, clearCookie, getGlobalUserByEmail, hash } =
|
||||
authPkg.utils
|
||||
const { Cookies } = authPkg.constants
|
||||
const { passport } = authPkg.auth
|
||||
const { checkResetPasswordCode } = require("../../../utilities/redis")
|
||||
|
||||
const GLOBAL_DB = authPkg.StaticDatabases.GLOBAL.name
|
||||
|
||||
async function authInternal(ctx, user, err = null) {
|
||||
async function authInternal(ctx, user, err = null, info = null) {
|
||||
if (err) {
|
||||
return ctx.throw(403, "Unauthorized")
|
||||
console.error("Authentication error", err)
|
||||
return ctx.throw(403, info ? info : "Unauthorized")
|
||||
}
|
||||
|
||||
const expires = new Date()
|
||||
expires.setDate(expires.getDate() + 1)
|
||||
|
||||
if (!user) {
|
||||
return ctx.throw(403, "Unauthorized")
|
||||
return ctx.throw(403, info ? info : "Unauthorized")
|
||||
}
|
||||
|
||||
// just store the user ID
|
||||
|
@ -32,8 +35,8 @@ async function authInternal(ctx, user, err = null) {
|
|||
}
|
||||
|
||||
exports.authenticate = async (ctx, next) => {
|
||||
return passport.authenticate("local", async (err, user) => {
|
||||
await authInternal(ctx, user, err)
|
||||
return passport.authenticate("local", async (err, user, info) => {
|
||||
await authInternal(ctx, user, err, info)
|
||||
|
||||
delete user.token
|
||||
|
||||
|
@ -123,8 +126,54 @@ exports.googleAuth = async (ctx, next) => {
|
|||
return passport.authenticate(
|
||||
strategy,
|
||||
{ successRedirect: "/", failureRedirect: "/error" },
|
||||
async (err, user) => {
|
||||
await authInternal(ctx, user, err)
|
||||
async (err, user, info) => {
|
||||
await authInternal(ctx, user, err, info)
|
||||
|
||||
ctx.redirect("/")
|
||||
}
|
||||
)(ctx, next)
|
||||
}
|
||||
|
||||
async function oidcStrategyFactory(ctx, configId) {
|
||||
const db = new CouchDB(GLOBAL_DB)
|
||||
|
||||
const config = await authPkg.db.getScopedConfig(db, {
|
||||
type: Configs.OIDC,
|
||||
group: ctx.query.group,
|
||||
})
|
||||
|
||||
const chosenConfig = config.configs.filter(c => c.uuid === configId)[0]
|
||||
|
||||
const callbackUrl = `${ctx.protocol}://${ctx.host}/api/admin/auth/oidc/callback`
|
||||
|
||||
return oidc.strategyFactory(chosenConfig, callbackUrl)
|
||||
}
|
||||
|
||||
/**
|
||||
* The initial call that OIDC authentication makes to take you to the configured OIDC login screen.
|
||||
* On a successful login, you will be redirected to the oidcAuth callback route.
|
||||
*/
|
||||
exports.oidcPreAuth = async (ctx, next) => {
|
||||
const { configId } = ctx.params
|
||||
const strategy = await oidcStrategyFactory(ctx, configId)
|
||||
|
||||
setCookie(ctx, configId, Cookies.OIDC_CONFIG)
|
||||
|
||||
return passport.authenticate(strategy, {
|
||||
// required 'openid' scope is added by oidc strategy factory
|
||||
scope: ["profile", "email"],
|
||||
})(ctx, next)
|
||||
}
|
||||
|
||||
exports.oidcAuth = async (ctx, next) => {
|
||||
const configId = getCookie(ctx, Cookies.OIDC_CONFIG)
|
||||
const strategy = await oidcStrategyFactory(ctx, configId)
|
||||
|
||||
return passport.authenticate(
|
||||
strategy,
|
||||
{ successRedirect: "/", failureRedirect: "/error" },
|
||||
async (err, user, info) => {
|
||||
await authInternal(ctx, user, err, info)
|
||||
|
||||
ctx.redirect("/")
|
||||
}
|
||||
|
|
|
@ -98,23 +98,73 @@ exports.find = async function (ctx) {
|
|||
}
|
||||
}
|
||||
|
||||
exports.publicSettings = async function (ctx) {
|
||||
exports.publicOidc = async function (ctx) {
|
||||
const db = new CouchDB(GLOBAL_DB)
|
||||
try {
|
||||
// Find the config with the most granular scope based on context
|
||||
const config = await getScopedFullConfig(db, {
|
||||
type: Configs.SETTINGS,
|
||||
const oidcConfig = await getScopedFullConfig(db, {
|
||||
type: Configs.OIDC,
|
||||
})
|
||||
if (!config) {
|
||||
|
||||
if (!oidcConfig) {
|
||||
ctx.body = {}
|
||||
} else {
|
||||
ctx.body = config
|
||||
const partialOidcCofig = oidcConfig.config.configs.map(config => {
|
||||
return {
|
||||
logo: config.logo,
|
||||
name: config.name,
|
||||
uuid: config.uuid,
|
||||
}
|
||||
})
|
||||
ctx.body = partialOidcCofig
|
||||
}
|
||||
} catch (err) {
|
||||
ctx.throw(err.status, err)
|
||||
}
|
||||
}
|
||||
|
||||
exports.publicSettings = async function (ctx) {
|
||||
const db = new CouchDB(GLOBAL_DB)
|
||||
|
||||
try {
|
||||
// Find the config with the most granular scope based on context
|
||||
const publicConfig = await getScopedFullConfig(db, {
|
||||
type: Configs.SETTINGS,
|
||||
})
|
||||
|
||||
const googleConfig = await getScopedFullConfig(db, {
|
||||
type: Configs.GOOGLE,
|
||||
})
|
||||
|
||||
const oidcConfig = await getScopedFullConfig(db, {
|
||||
type: Configs.OIDC,
|
||||
})
|
||||
|
||||
let config = {}
|
||||
if (!publicConfig) {
|
||||
config = {
|
||||
config: {},
|
||||
}
|
||||
} else {
|
||||
config = publicConfig
|
||||
}
|
||||
|
||||
config.config.google = !googleConfig
|
||||
? !!googleConfig
|
||||
: !googleConfig.config.activated
|
||||
? false
|
||||
: true
|
||||
config.config.oidc = !oidcConfig
|
||||
? !!oidcConfig
|
||||
: !oidcConfig.config.configs[0].activated
|
||||
? false
|
||||
: true
|
||||
ctx.body = config
|
||||
} catch (err) {
|
||||
ctx.throw(err.status, err)
|
||||
}
|
||||
}
|
||||
|
||||
exports.upload = async function (ctx) {
|
||||
if (ctx.request.files == null || ctx.request.files.file.length > 1) {
|
||||
ctx.throw(400, "One file must be uploaded.")
|
||||
|
@ -122,12 +172,8 @@ exports.upload = async function (ctx) {
|
|||
const file = ctx.request.files.file
|
||||
const { type, name } = ctx.params
|
||||
|
||||
const fileExtension = [...file.name.split(".")].pop()
|
||||
// filenames converted to UUIDs so they are unique
|
||||
const processedFileName = `${name}.${fileExtension}`
|
||||
|
||||
const bucket = ObjectStoreBuckets.GLOBAL
|
||||
const key = `${type}/${processedFileName}`
|
||||
const key = `${type}/${name}`
|
||||
await upload({
|
||||
bucket,
|
||||
filename: key,
|
||||
|
@ -146,7 +192,7 @@ exports.upload = async function (ctx) {
|
|||
}
|
||||
}
|
||||
const url = `/${bucket}/${key}`
|
||||
cfgStructure.config[`${name}Url`] = url
|
||||
cfgStructure.config[`${name}`] = url
|
||||
// write back to db with url updated
|
||||
await db.put(cfgStructure)
|
||||
|
||||
|
@ -184,10 +230,14 @@ exports.configChecklist = async function (ctx) {
|
|||
})
|
||||
|
||||
// They have set up Google Auth
|
||||
const oauthConfig = await getScopedFullConfig(db, {
|
||||
const googleConfig = await getScopedFullConfig(db, {
|
||||
type: Configs.GOOGLE,
|
||||
})
|
||||
|
||||
// They have set up OIDC
|
||||
const oidcConfig = await getScopedFullConfig(db, {
|
||||
type: Configs.OIDC,
|
||||
})
|
||||
// They have set up an admin user
|
||||
const users = await db.allDocs(
|
||||
getGlobalUserParams(null, {
|
||||
|
@ -200,7 +250,7 @@ exports.configChecklist = async function (ctx) {
|
|||
apps: appDbNames.length,
|
||||
smtp: !!smtpConfig,
|
||||
adminUser,
|
||||
oauth: !!oauthConfig,
|
||||
sso: !!googleConfig || !!oidcConfig,
|
||||
}
|
||||
} catch (err) {
|
||||
ctx.throw(err.status, err)
|
||||
|
|
|
@ -28,12 +28,18 @@ exports.save = async ctx => {
|
|||
|
||||
exports.definitions = async ctx => {
|
||||
const bindings = {}
|
||||
|
||||
const info = {}
|
||||
for (let template of TemplateMetadata.email) {
|
||||
bindings[template.purpose] = template.bindings
|
||||
info[template.purpose] = {
|
||||
name: template.name,
|
||||
description: template.description,
|
||||
category: template.category,
|
||||
}
|
||||
}
|
||||
|
||||
ctx.body = {
|
||||
info,
|
||||
bindings: {
|
||||
...bindings,
|
||||
common: Object.values(TemplateBindings),
|
||||
|
|
|
@ -25,6 +25,14 @@ const PUBLIC_ENDPOINTS = [
|
|||
route: "/api/admin/auth/google/callback",
|
||||
method: "GET",
|
||||
},
|
||||
{
|
||||
route: "/api/admin/auth/oidc",
|
||||
method: "GET",
|
||||
},
|
||||
{
|
||||
route: "/api/admin/auth/oidc/callback",
|
||||
method: "GET",
|
||||
},
|
||||
{
|
||||
route: "/api/admin/auth/reset",
|
||||
method: "POST",
|
||||
|
@ -41,6 +49,10 @@ const PUBLIC_ENDPOINTS = [
|
|||
route: "/api/admin/configs/public",
|
||||
method: "GET",
|
||||
},
|
||||
{
|
||||
route: "/api/admin/configs/publicOidc",
|
||||
method: "GET",
|
||||
},
|
||||
]
|
||||
|
||||
const router = new Router()
|
||||
|
|
|
@ -39,5 +39,7 @@ router
|
|||
.post("/api/admin/auth/logout", authController.logout)
|
||||
.get("/api/admin/auth/google", authController.googlePreAuth)
|
||||
.get("/api/admin/auth/google/callback", authController.googleAuth)
|
||||
.get("/api/admin/auth/oidc/configs/:configId", authController.oidcPreAuth)
|
||||
.get("/api/admin/auth/oidc/callback", authController.oidcAuth)
|
||||
|
||||
module.exports = router
|
||||
|
|
|
@ -3,7 +3,7 @@ const controller = require("../../controllers/admin/configs")
|
|||
const joiValidator = require("../../../middleware/joi-validator")
|
||||
const adminOnly = require("../../../middleware/adminOnly")
|
||||
const Joi = require("joi")
|
||||
const { Configs, ConfigUploads } = require("../../../constants")
|
||||
const { Configs } = require("../../../constants")
|
||||
|
||||
const router = Router()
|
||||
|
||||
|
@ -38,6 +38,24 @@ function googleValidation() {
|
|||
clientID: Joi.string().required(),
|
||||
clientSecret: Joi.string().required(),
|
||||
callbackURL: Joi.string().required(),
|
||||
activated: Joi.boolean().required(),
|
||||
}).unknown(true)
|
||||
}
|
||||
|
||||
function oidcValidation() {
|
||||
// prettier-ignore
|
||||
return Joi.object({
|
||||
configs: Joi.array().items(
|
||||
Joi.object({
|
||||
clientID: Joi.string().required(),
|
||||
clientSecret: Joi.string().required(),
|
||||
configUrl: Joi.string().required(),
|
||||
logo: Joi.string().allow("", null),
|
||||
name: Joi.string().allow("", null),
|
||||
uuid: Joi.string().required(),
|
||||
activated: Joi.boolean().required(),
|
||||
})
|
||||
).required(true)
|
||||
}).unknown(true)
|
||||
}
|
||||
|
||||
|
@ -54,7 +72,8 @@ function buildConfigSaveValidation() {
|
|||
{ is: Configs.SMTP, then: smtpValidation() },
|
||||
{ is: Configs.SETTINGS, then: settingValidation() },
|
||||
{ is: Configs.ACCOUNT, then: Joi.object().unknown(true) },
|
||||
{ is: Configs.GOOGLE, then: googleValidation() }
|
||||
{ is: Configs.GOOGLE, then: googleValidation() },
|
||||
{ is: Configs.OIDC, then: oidcValidation() }
|
||||
],
|
||||
}),
|
||||
}).required(),
|
||||
|
@ -65,7 +84,7 @@ function buildUploadValidation() {
|
|||
// prettier-ignore
|
||||
return joiValidator.params(Joi.object({
|
||||
type: Joi.string().valid(...Object.values(Configs)).required(),
|
||||
name: Joi.string().valid(...Object.values(ConfigUploads)).required(),
|
||||
name: Joi.string().required(),
|
||||
}).required())
|
||||
}
|
||||
|
||||
|
@ -83,7 +102,7 @@ router
|
|||
buildConfigSaveValidation(),
|
||||
controller.save
|
||||
)
|
||||
.delete("/api/admin/configs/:id", adminOnly, controller.destroy)
|
||||
.delete("/api/admin/configs/:id/:rev", adminOnly, controller.destroy)
|
||||
.get("/api/admin/configs", controller.fetch)
|
||||
.get("/api/admin/configs/checklist", controller.configChecklist)
|
||||
.get(
|
||||
|
@ -92,6 +111,7 @@ router
|
|||
controller.fetch
|
||||
)
|
||||
.get("/api/admin/configs/public", controller.publicSettings)
|
||||
.get("/api/admin/configs/publicOidc", controller.publicOidc)
|
||||
.get("/api/admin/configs/:type", buildConfigGetValidation(), controller.find)
|
||||
.post(
|
||||
"/api/admin/configs/upload/:type/:name",
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
const setup = require("./utilities")
|
||||
const { Cookies } = require("@budibase/auth").constants
|
||||
|
||||
jest.mock("nodemailer")
|
||||
const sendMailMock = setup.emailMock()
|
||||
|
@ -14,6 +15,10 @@ describe("/api/admin/auth", () => {
|
|||
|
||||
afterAll(setup.afterAll)
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it("should be able to generate password reset email", async () => {
|
||||
// initially configure settings
|
||||
await config.saveSmtpConfig()
|
||||
|
@ -46,4 +51,56 @@ describe("/api/admin/auth", () => {
|
|||
.expect(200)
|
||||
expect(res.body).toEqual({ message: "password reset successfully." })
|
||||
})
|
||||
|
||||
describe("oidc", () => {
|
||||
const auth = require("@budibase/auth").auth
|
||||
|
||||
// mock the oidc strategy implementation and return value
|
||||
strategyFactory = jest.fn()
|
||||
mockStrategyReturn = jest.fn()
|
||||
strategyFactory.mockReturnValue(mockStrategyReturn)
|
||||
auth.oidc.strategyFactory = strategyFactory
|
||||
|
||||
const passportSpy = jest.spyOn(auth.passport, "authenticate")
|
||||
let oidcConf
|
||||
let chosenConfig
|
||||
let configId
|
||||
|
||||
beforeEach(async () => {
|
||||
oidcConf = await config.saveOIDCConfig()
|
||||
chosenConfig = oidcConf.config.configs[0]
|
||||
configId = chosenConfig.uuid
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
expect(strategyFactory).toBeCalledWith(
|
||||
chosenConfig,
|
||||
`http://127.0.0.1:4003/api/admin/auth/oidc/callback` // calculated url
|
||||
)
|
||||
})
|
||||
|
||||
describe("/api/admin/auth/oidc/configs", () => {
|
||||
it("should load strategy and delegate to passport", async () => {
|
||||
await request.get(`/api/admin/auth/oidc/configs/${configId}`)
|
||||
|
||||
expect(passportSpy).toBeCalledWith(mockStrategyReturn, {
|
||||
scope: ["profile", "email"],
|
||||
})
|
||||
expect(passportSpy.mock.calls.length).toBe(1);
|
||||
})
|
||||
})
|
||||
|
||||
describe("/api/admin/auth/oidc/callback", () => {
|
||||
it("should load strategy and delegate to passport", async () => {
|
||||
await request.get(`/api/admin/auth/oidc/callback`)
|
||||
.set(config.getOIDConfigCookie(configId))
|
||||
|
||||
expect(passportSpy).toBeCalledWith(mockStrategyReturn, {
|
||||
successRedirect: "/", failureRedirect: "/error"
|
||||
}, expect.anything())
|
||||
expect(passportSpy.mock.calls.length).toBe(1);
|
||||
})
|
||||
})
|
||||
|
||||
})
|
||||
})
|
|
@ -6,6 +6,7 @@ const { Cookies } = require("@budibase/auth").constants
|
|||
const { Configs, LOGO_URL } = require("../../../../constants")
|
||||
const { getGlobalUserByEmail } = require("@budibase/auth").utils
|
||||
const { createASession } = require("@budibase/auth/sessions")
|
||||
const { newid } = require("../../../../../../auth/src/hashing")
|
||||
|
||||
class TestConfiguration {
|
||||
constructor(openServer = true) {
|
||||
|
@ -67,6 +68,12 @@ class TestConfiguration {
|
|||
}
|
||||
}
|
||||
|
||||
cookieHeader(cookies) {
|
||||
return {
|
||||
Cookie: [cookies],
|
||||
}
|
||||
}
|
||||
|
||||
defaultHeaders() {
|
||||
const user = {
|
||||
_id: "us_uuid1",
|
||||
|
@ -76,7 +83,7 @@ class TestConfiguration {
|
|||
const authToken = jwt.sign(user, env.JWT_SECRET)
|
||||
return {
|
||||
Accept: "application/json",
|
||||
Cookie: [`${Cookies.Auth}=${authToken}`],
|
||||
...this.cookieHeader([`${Cookies.Auth}=${authToken}`]),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -155,6 +162,33 @@ class TestConfiguration {
|
|||
)
|
||||
}
|
||||
|
||||
getOIDConfigCookie(configId) {
|
||||
const token = jwt.sign(configId, env.JWT_SECRET)
|
||||
return this.cookieHeader([[`${Cookies.OIDC_CONFIG}=${token}`]])
|
||||
}
|
||||
|
||||
async saveOIDCConfig() {
|
||||
await this.deleteConfig(Configs.OIDC)
|
||||
const config = {
|
||||
type: Configs.OIDC,
|
||||
config: {
|
||||
configs: [
|
||||
{
|
||||
configUrl: "http://someconfigurl",
|
||||
clientID: "clientId",
|
||||
clientSecret: "clientSecret",
|
||||
logo: "Microsoft",
|
||||
name: "Active Directory",
|
||||
uuid: newid(),
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
await this._req(config, null, controllers.config.save)
|
||||
return config
|
||||
}
|
||||
|
||||
async saveSmtpConfig() {
|
||||
await this.deleteConfig(Configs.SMTP)
|
||||
await this._req(
|
||||
|
|
|
@ -16,6 +16,7 @@ exports.Configs = Configs
|
|||
|
||||
exports.ConfigUploads = {
|
||||
LOGO: "logo",
|
||||
OIDC_LOGO: "oidc_logo",
|
||||
}
|
||||
|
||||
const TemplateTypes = {
|
||||
|
@ -96,7 +97,10 @@ const TemplateBindings = {
|
|||
const TemplateMetadata = {
|
||||
[TemplateTypes.EMAIL]: [
|
||||
{
|
||||
name: "Base Format",
|
||||
name: "Base format",
|
||||
description:
|
||||
"This is the base template, all others are based on it. The {{ body }} will be replaced with another email template.",
|
||||
category: "miscellaneous",
|
||||
purpose: EmailTemplatePurpose.BASE,
|
||||
bindings: [
|
||||
{
|
||||
|
@ -110,7 +114,10 @@ const TemplateMetadata = {
|
|||
],
|
||||
},
|
||||
{
|
||||
name: "Password Recovery",
|
||||
name: "Password recovery",
|
||||
description:
|
||||
"When a user requests a password reset they will receive an email built with this template.",
|
||||
category: "user management",
|
||||
purpose: EmailTemplatePurpose.PASSWORD_RECOVERY,
|
||||
bindings: [
|
||||
{
|
||||
|
@ -126,7 +133,18 @@ const TemplateMetadata = {
|
|||
],
|
||||
},
|
||||
{
|
||||
name: "New User Invitation",
|
||||
name: "User welcome",
|
||||
description:
|
||||
"When a new user is added they will be sent a welcome email using this template.",
|
||||
category: "user management",
|
||||
purpose: EmailTemplatePurpose.WELCOME,
|
||||
bindings: [],
|
||||
},
|
||||
{
|
||||
name: "User invitation",
|
||||
description:
|
||||
"When inviting a user via the email on-boarding this template will be used.",
|
||||
category: "user management",
|
||||
purpose: EmailTemplatePurpose.INVITATION,
|
||||
bindings: [
|
||||
{
|
||||
|
@ -143,6 +161,9 @@ const TemplateMetadata = {
|
|||
},
|
||||
{
|
||||
name: "Custom",
|
||||
description:
|
||||
"A custom template, this is currently used for SMTP email actions in automations.",
|
||||
category: "automations",
|
||||
purpose: EmailTemplatePurpose.CUSTOM,
|
||||
bindings: [
|
||||
{
|
||||
|
|
|
@ -5,6 +5,7 @@ require("@budibase/auth").init(CouchDB)
|
|||
const Koa = require("koa")
|
||||
const destroyable = require("server-destroy")
|
||||
const koaBody = require("koa-body")
|
||||
const koaSession = require("koa-session")
|
||||
const { passport } = require("@budibase/auth").auth
|
||||
const logger = require("koa-pino-logger")
|
||||
const http = require("http")
|
||||
|
@ -13,8 +14,11 @@ const redis = require("./utilities/redis")
|
|||
|
||||
const app = new Koa()
|
||||
|
||||
app.keys = ["secret", "key"]
|
||||
|
||||
// set up top level koa middleware
|
||||
app.use(koaBody({ multipart: true }))
|
||||
app.use(koaSession(app))
|
||||
|
||||
app.use(
|
||||
logger({
|
||||
|
|
|
@ -566,6 +566,17 @@
|
|||
dependencies:
|
||||
defer-to-connect "^2.0.0"
|
||||
|
||||
"@techpass/passport-openidconnect@^0.3.0":
|
||||
version "0.3.0"
|
||||
resolved "https://registry.yarnpkg.com/@techpass/passport-openidconnect/-/passport-openidconnect-0.3.0.tgz#a60b2bbf3f262649a5a02d5d186219944acc3010"
|
||||
integrity sha512-bVsPwl66s7J7GHxTPlW/RJYhZol9SshNznQsx83OOh9G+JWFGoeWxh+xbX+FTdJNoUvGIGbJnpWPY2wC6NOHPw==
|
||||
dependencies:
|
||||
base64url "^3.0.1"
|
||||
oauth "^0.9.15"
|
||||
passport-strategy "^1.0.0"
|
||||
request "^2.88.0"
|
||||
webfinger "^0.4.2"
|
||||
|
||||
"@types/babel__core@^7.0.0", "@types/babel__core@^7.1.7":
|
||||
version "7.1.14"
|
||||
resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.1.14.tgz#faaeefc4185ec71c389f4501ee5ec84b170cc402"
|
||||
|
@ -1058,7 +1069,7 @@ base64-js@^1.0.2, base64-js@^1.3.1:
|
|||
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
|
||||
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
|
||||
|
||||
base64url@3.x.x:
|
||||
base64url@3.x.x, base64url@^3.0.1:
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/base64url/-/base64url-3.0.1.tgz#6399d572e2bc3f90a9a8b22d5dbb0a32d33f788d"
|
||||
integrity sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==
|
||||
|
@ -4183,7 +4194,7 @@ oauth-sign@~0.9.0:
|
|||
resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.9.0.tgz#47a7b016baa68b5fa0ecf3dee08a85c679ac6455"
|
||||
integrity sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==
|
||||
|
||||
oauth@0.9.x:
|
||||
oauth@0.9.x, oauth@^0.9.15:
|
||||
version "0.9.15"
|
||||
resolved "https://registry.yarnpkg.com/oauth/-/oauth-0.9.15.tgz#bd1fefaf686c96b75475aed5196412ff60cfb9c1"
|
||||
integrity sha1-vR/vr2hslrdUda7VGWQS/2DPucE=
|
||||
|
@ -4933,7 +4944,7 @@ request-promise-native@^1.0.9:
|
|||
stealthy-require "^1.1.1"
|
||||
tough-cookie "^2.3.3"
|
||||
|
||||
request@^2.88.2:
|
||||
request@^2.88.0, request@^2.88.2:
|
||||
version "2.88.2"
|
||||
resolved "https://registry.yarnpkg.com/request/-/request-2.88.2.tgz#d73c918731cb5a87da047e207234146f664d12b3"
|
||||
integrity sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==
|
||||
|
@ -5080,7 +5091,7 @@ sax@1.2.1:
|
|||
resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.1.tgz#7b8e656190b228e81a66aea748480d828cd2d37a"
|
||||
integrity sha1-e45lYZCyKOgaZq6nSEgNgozS03o=
|
||||
|
||||
sax@>=0.6.0:
|
||||
sax@>=0.1.1, sax@>=0.6.0:
|
||||
version "1.2.4"
|
||||
resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9"
|
||||
integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==
|
||||
|
@ -5390,6 +5401,11 @@ stealthy-require@^1.1.1:
|
|||
resolved "https://registry.yarnpkg.com/stealthy-require/-/stealthy-require-1.1.1.tgz#35b09875b4ff49f26a777e509b3090a3226bf24b"
|
||||
integrity sha1-NbCYdbT/SfJqd35QmzCQoyJr8ks=
|
||||
|
||||
step@0.0.x:
|
||||
version "0.0.6"
|
||||
resolved "https://registry.yarnpkg.com/step/-/step-0.0.6.tgz#143e7849a5d7d3f4a088fe29af94915216eeede2"
|
||||
integrity sha1-FD54SaXX0/SgiP4pr5SRUhbu7eI=
|
||||
|
||||
string-length@^4.0.1:
|
||||
version "4.0.2"
|
||||
resolved "https://registry.yarnpkg.com/string-length/-/string-length-4.0.2.tgz#a8a8dc7bd5c1a82b9b3c8b87e125f66871b6e57a"
|
||||
|
@ -5923,6 +5939,14 @@ walker@^1.0.7, walker@~1.0.5:
|
|||
dependencies:
|
||||
makeerror "1.0.x"
|
||||
|
||||
webfinger@^0.4.2:
|
||||
version "0.4.2"
|
||||
resolved "https://registry.yarnpkg.com/webfinger/-/webfinger-0.4.2.tgz#3477a6d97799461896039fcffc650b73468ee76d"
|
||||
integrity sha1-NHem2XeZRhiWA5/P/GULc0aO520=
|
||||
dependencies:
|
||||
step "0.0.x"
|
||||
xml2js "0.1.x"
|
||||
|
||||
webidl-conversions@^5.0.0:
|
||||
version "5.0.0"
|
||||
resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-5.0.0.tgz#ae59c8a00b121543a2acc65c0434f57b0fc11aff"
|
||||
|
@ -6031,6 +6055,13 @@ xml-name-validator@^3.0.0:
|
|||
resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-3.0.0.tgz#6ae73e06de4d8c6e47f9fb181f78d648ad457c6a"
|
||||
integrity sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==
|
||||
|
||||
xml2js@0.1.x:
|
||||
version "0.1.14"
|
||||
resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.1.14.tgz#5274e67f5a64c5f92974cd85139e0332adc6b90c"
|
||||
integrity sha1-UnTmf1pkxfkpdM2FE54DMq3GuQw=
|
||||
dependencies:
|
||||
sax ">=0.1.1"
|
||||
|
||||
xml2js@0.4.19:
|
||||
version "0.4.19"
|
||||
resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.4.19.tgz#686c20f213209e94abf0d1bcf1efaa291c7827a7"
|
||||
|
|
Loading…
Reference in New Issue