forked from tsai/budibase
committed by
GitHub
236 changed files with 10830 additions and 2573 deletions
@ -0,0 +1,52 @@ |
|||||
|
name: Budibase Release Staging |
||||
|
|
||||
|
on: |
||||
|
push: |
||||
|
branches: |
||||
|
- develop |
||||
|
|
||||
|
env: |
||||
|
POSTHOG_TOKEN: ${{ secrets.POSTHOG_TOKEN }} |
||||
|
POSTHOG_URL: ${{ secrets.POSTHOG_URL }} |
||||
|
SENTRY_DSN: ${{ secrets.SENTRY_DSN }} |
||||
|
|
||||
|
jobs: |
||||
|
release: |
||||
|
runs-on: ubuntu-latest |
||||
|
|
||||
|
steps: |
||||
|
- uses: actions/checkout@v2 |
||||
|
- uses: actions/setup-node@v1 |
||||
|
with: |
||||
|
node-version: 12.x |
||||
|
- run: yarn |
||||
|
- run: yarn bootstrap |
||||
|
- run: yarn lint |
||||
|
- run: yarn build |
||||
|
- run: yarn test |
||||
|
|
||||
|
- name: Configure AWS Credentials |
||||
|
uses: aws-actions/configure-aws-credentials@v1 |
||||
|
with: |
||||
|
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} |
||||
|
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} |
||||
|
aws-region: eu-west-1 |
||||
|
|
||||
|
- name: Publish budibase packages to NPM |
||||
|
env: |
||||
|
NPM_TOKEN: ${{ secrets.NPM_TOKEN }} |
||||
|
run: | |
||||
|
# setup the username and email. I tend to use 'GitHub Actions Bot' with no email by default |
||||
|
git config user.name "Budibase Staging Release Bot" |
||||
|
git config user.email "<>" |
||||
|
echo //registry.npmjs.org/:_authToken=${NPM_TOKEN} >> .npmrc |
||||
|
yarn release:develop |
||||
|
|
||||
|
- name: Build/release Docker images |
||||
|
run: | |
||||
|
docker login -u $DOCKER_USER -p $DOCKER_PASSWORD |
||||
|
yarn build |
||||
|
yarn build:docker:develop |
||||
|
env: |
||||
|
DOCKER_USER: ${{ secrets.DOCKER_USERNAME }} |
||||
|
DOCKER_PASSWORD: ${{ secrets.DOCKER_API_KEY }} |
||||
@ -0,0 +1,3 @@ |
|||||
|
module.exports = { |
||||
|
user: require("./src/cache/user"), |
||||
|
} |
||||
@ -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") |
||||
@ -0,0 +1 @@ |
|||||
|
module.exports = require("./src/security/sessions") |
||||
@ -0,0 +1,21 @@ |
|||||
|
const { getDB } = require("../db") |
||||
|
const { StaticDatabases } = require("../db/utils") |
||||
|
const redis = require("../redis/authRedis") |
||||
|
|
||||
|
const EXPIRY_SECONDS = 3600 |
||||
|
|
||||
|
exports.getUser = async userId => { |
||||
|
const client = await redis.getUserClient() |
||||
|
// try cache
|
||||
|
let user = await client.get(userId) |
||||
|
if (!user) { |
||||
|
user = await getDB(StaticDatabases.GLOBAL.name).get(userId) |
||||
|
client.store(userId, user, EXPIRY_SECONDS) |
||||
|
} |
||||
|
return user |
||||
|
} |
||||
|
|
||||
|
exports.invalidateUser = async userId => { |
||||
|
const client = await redis.getUserClient() |
||||
|
await client.delete(userId) |
||||
|
} |
||||
@ -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 } |
||||
|
) |
||||
|
} |
||||
@ -0,0 +1,29 @@ |
|||||
|
const Client = require("./index") |
||||
|
const utils = require("./utils") |
||||
|
|
||||
|
let userClient, sessionClient |
||||
|
|
||||
|
async function init() { |
||||
|
userClient = await new Client(utils.Databases.USER_CACHE).init() |
||||
|
sessionClient = await new Client(utils.Databases.SESSIONS).init() |
||||
|
} |
||||
|
|
||||
|
process.on("exit", async () => { |
||||
|
if (userClient) await userClient.finish() |
||||
|
if (sessionClient) await sessionClient.finish() |
||||
|
}) |
||||
|
|
||||
|
module.exports = { |
||||
|
getUserClient: async () => { |
||||
|
if (!userClient) { |
||||
|
await init() |
||||
|
} |
||||
|
return userClient |
||||
|
}, |
||||
|
getSessionClient: async () => { |
||||
|
if (!sessionClient) { |
||||
|
await init() |
||||
|
} |
||||
|
return sessionClient |
||||
|
}, |
||||
|
} |
||||
@ -0,0 +1,69 @@ |
|||||
|
const redis = require("../redis/authRedis") |
||||
|
|
||||
|
const EXPIRY_SECONDS = 86400 |
||||
|
|
||||
|
async function getSessionsForUser(userId) { |
||||
|
const client = await redis.getSessionClient() |
||||
|
const sessions = await client.scan(userId) |
||||
|
return sessions.map(session => session.value) |
||||
|
} |
||||
|
|
||||
|
function makeSessionID(userId, sessionId) { |
||||
|
return `${userId}/${sessionId}` |
||||
|
} |
||||
|
|
||||
|
exports.createASession = async (userId, sessionId) => { |
||||
|
const client = await redis.getSessionClient() |
||||
|
const session = { |
||||
|
createdAt: new Date().toISOString(), |
||||
|
lastAccessedAt: new Date().toISOString(), |
||||
|
sessionId, |
||||
|
userId, |
||||
|
} |
||||
|
await client.store(makeSessionID(userId, sessionId), session, EXPIRY_SECONDS) |
||||
|
} |
||||
|
|
||||
|
exports.invalidateSessions = async (userId, sessionId = null) => { |
||||
|
let sessions = [] |
||||
|
if (sessionId) { |
||||
|
sessions.push({ key: makeSessionID(userId, sessionId) }) |
||||
|
} else { |
||||
|
sessions = await getSessionsForUser(userId) |
||||
|
} |
||||
|
const client = await redis.getSessionClient() |
||||
|
const promises = [] |
||||
|
for (let session of sessions) { |
||||
|
promises.push(client.delete(session.key)) |
||||
|
} |
||||
|
await Promise.all(promises) |
||||
|
} |
||||
|
|
||||
|
exports.updateSessionTTL = async session => { |
||||
|
const client = await redis.getSessionClient() |
||||
|
const key = makeSessionID(session.userId, session.sessionId) |
||||
|
session.lastAccessedAt = new Date().toISOString() |
||||
|
await client.store(key, session, EXPIRY_SECONDS) |
||||
|
} |
||||
|
|
||||
|
exports.endSession = async (userId, sessionId) => { |
||||
|
const client = await redis.getSessionClient() |
||||
|
await client.delete(makeSessionID(userId, sessionId)) |
||||
|
} |
||||
|
|
||||
|
exports.getUserSessions = getSessionsForUser |
||||
|
|
||||
|
exports.getSession = async (userId, sessionId) => { |
||||
|
try { |
||||
|
const client = await redis.getSessionClient() |
||||
|
return client.get(makeSessionID(userId, sessionId)) |
||||
|
} catch (err) { |
||||
|
// if can't get session don't error, just don't return anything
|
||||
|
return null |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
exports.getAllSessions = async () => { |
||||
|
const client = await redis.getSessionClient() |
||||
|
const sessions = await client.scan() |
||||
|
return sessions.map(session => session.value) |
||||
|
} |
||||
File diff suppressed because it is too large
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
@ -0,0 +1,97 @@ |
|||||
|
<script> |
||||
|
import { Icon, Combobox, Drawer, Button } from "@budibase/bbui" |
||||
|
import { |
||||
|
readableToRuntimeBinding, |
||||
|
runtimeToReadableBinding, |
||||
|
} from "builderStore/dataBinding" |
||||
|
import BindingPanel from "components/common/bindings/BindingPanel.svelte" |
||||
|
import { createEventDispatcher } from "svelte" |
||||
|
|
||||
|
export let panel = BindingPanel |
||||
|
export let value = "" |
||||
|
export let bindings = [] |
||||
|
export let title = "Bindings" |
||||
|
export let placeholder |
||||
|
export let label |
||||
|
export let disabled = false |
||||
|
export let options |
||||
|
|
||||
|
const dispatch = createEventDispatcher() |
||||
|
let bindingDrawer |
||||
|
$: tempValue = Array.isArray(value) ? value : [] |
||||
|
$: readableValue = runtimeToReadableBinding(bindings, value) |
||||
|
|
||||
|
const handleClose = () => { |
||||
|
onChange(tempValue) |
||||
|
bindingDrawer.hide() |
||||
|
} |
||||
|
|
||||
|
const onChange = value => { |
||||
|
dispatch("change", readableToRuntimeBinding(bindings, value)) |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<div class="control"> |
||||
|
<Combobox |
||||
|
{label} |
||||
|
{disabled} |
||||
|
value={readableValue} |
||||
|
on:change={event => onChange(event.detail)} |
||||
|
{placeholder} |
||||
|
{options} |
||||
|
/> |
||||
|
{#if !disabled} |
||||
|
<div class="icon" on:click={bindingDrawer.show}> |
||||
|
<Icon size="S" name="FlashOn" /> |
||||
|
</div> |
||||
|
{/if} |
||||
|
</div> |
||||
|
<Drawer bind:this={bindingDrawer} {title}> |
||||
|
<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> |
||||
|
<svelte:component |
||||
|
this={panel} |
||||
|
slot="body" |
||||
|
value={readableValue} |
||||
|
close={handleClose} |
||||
|
on:update={event => (tempValue = event.detail)} |
||||
|
bindableProperties={bindings} |
||||
|
/> |
||||
|
</Drawer> |
||||
|
|
||||
|
<style> |
||||
|
.control { |
||||
|
flex: 1; |
||||
|
position: relative; |
||||
|
} |
||||
|
|
||||
|
.icon { |
||||
|
right: 31px; |
||||
|
bottom: 1px; |
||||
|
position: absolute; |
||||
|
justify-content: center; |
||||
|
align-items: center; |
||||
|
display: flex; |
||||
|
flex-direction: row; |
||||
|
box-sizing: border-box; |
||||
|
border-left: 1px solid var(--spectrum-alias-border-color); |
||||
|
border-right: 1px solid var(--spectrum-alias-border-color); |
||||
|
width: 31px; |
||||
|
color: var(--spectrum-alias-text-color); |
||||
|
background-color: var(--spectrum-global-color-gray-75); |
||||
|
transition: background-color |
||||
|
var(--spectrum-global-animation-duration-100, 130ms), |
||||
|
box-shadow var(--spectrum-global-animation-duration-100, 130ms), |
||||
|
border-color var(--spectrum-global-animation-duration-100, 130ms); |
||||
|
height: calc(var(--spectrum-alias-item-height-m) - 2px); |
||||
|
} |
||||
|
|
||||
|
.icon:hover { |
||||
|
cursor: pointer; |
||||
|
color: var(--spectrum-alias-text-color-hover); |
||||
|
background-color: var(--spectrum-global-color-gray-50); |
||||
|
border-color: var(--spectrum-alias-border-color-hover); |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,120 @@ |
|||||
|
<script> |
||||
|
import { |
||||
|
Icon, |
||||
|
Modal, |
||||
|
notifications, |
||||
|
ModalContent, |
||||
|
Body, |
||||
|
Button, |
||||
|
} from "@budibase/bbui" |
||||
|
import { store } from "builderStore" |
||||
|
import api from "builderStore/api" |
||||
|
import clientPackage from "@budibase/client/package.json" |
||||
|
|
||||
|
let updateModal |
||||
|
|
||||
|
$: appId = $store.appId |
||||
|
$: updateAvailable = clientPackage.version !== $store.version |
||||
|
$: revertAvailable = $store.revertableVersion != null |
||||
|
|
||||
|
const refreshAppPackage = async () => { |
||||
|
const applicationPkg = await api.get( |
||||
|
`/api/applications/${appId}/appPackage` |
||||
|
) |
||||
|
const pkg = await applicationPkg.json() |
||||
|
if (applicationPkg.ok) { |
||||
|
await store.actions.initialise(pkg) |
||||
|
} else { |
||||
|
throw new Error(pkg) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const update = async () => { |
||||
|
try { |
||||
|
const response = await api.post( |
||||
|
`/api/applications/${appId}/client/update` |
||||
|
) |
||||
|
const json = await response.json() |
||||
|
if (response.status !== 200) { |
||||
|
throw json.message |
||||
|
} |
||||
|
|
||||
|
// Don't wait for the async refresh, since this causes modal flashing |
||||
|
refreshAppPackage() |
||||
|
notifications.success( |
||||
|
`App updated successfully to version ${clientPackage.version}` |
||||
|
) |
||||
|
} catch (err) { |
||||
|
notifications.error(`Error updating app: ${err}`) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const revert = async () => { |
||||
|
try { |
||||
|
const revertableVersion = $store.revertableVersion |
||||
|
const response = await api.post( |
||||
|
`/api/applications/${appId}/client/revert` |
||||
|
) |
||||
|
const json = await response.json() |
||||
|
if (response.status !== 200) { |
||||
|
throw json.message |
||||
|
} |
||||
|
|
||||
|
// Don't wait for the async refresh, since this causes modal flashing |
||||
|
refreshAppPackage() |
||||
|
notifications.success( |
||||
|
`App reverted successfully to version ${revertableVersion}` |
||||
|
) |
||||
|
} catch (err) { |
||||
|
notifications.error(`Error reverting app: ${err}`) |
||||
|
} |
||||
|
updateModal.hide() |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<div class="icon-wrapper" class:highlight={updateAvailable}> |
||||
|
<Icon name="Refresh" hoverable on:click={updateModal.show} /> |
||||
|
</div> |
||||
|
<Modal bind:this={updateModal}> |
||||
|
<ModalContent |
||||
|
title="App version" |
||||
|
confirmText="Update" |
||||
|
cancelText={updateAvailable ? "Cancel" : "Close"} |
||||
|
onConfirm={update} |
||||
|
showConfirmButton={updateAvailable} |
||||
|
> |
||||
|
<div slot="footer"> |
||||
|
{#if revertAvailable} |
||||
|
<Button quiet secondary on:click={revert}>Revert</Button> |
||||
|
{/if} |
||||
|
</div> |
||||
|
{#if updateAvailable} |
||||
|
<Body size="S"> |
||||
|
This app is currently using version <b>{$store.version}</b>, but version |
||||
|
<b>{clientPackage.version}</b> is available. Updates can contain new features, |
||||
|
performance improvements and bug fixes. |
||||
|
</Body> |
||||
|
{:else} |
||||
|
<Body size="S"> |
||||
|
This app is currently using version <b>{$store.version}</b> which is the |
||||
|
latest version available. |
||||
|
</Body> |
||||
|
{/if} |
||||
|
{#if revertAvailable} |
||||
|
<Body size="S"> |
||||
|
You can revert this app to version |
||||
|
<b>{$store.revertableVersion}</b> |
||||
|
if you're experiencing issues with the current version. |
||||
|
</Body> |
||||
|
{/if} |
||||
|
</ModalContent> |
||||
|
</Modal> |
||||
|
|
||||
|
<style> |
||||
|
.icon-wrapper { |
||||
|
display: contents; |
||||
|
} |
||||
|
.icon-wrapper.highlight :global(svg) { |
||||
|
color: var(--spectrum-global-color-blue-600); |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,38 @@ |
|||||
|
<script> |
||||
|
import { Select } from "@budibase/bbui" |
||||
|
import { store } from "builderStore" |
||||
|
|
||||
|
const themeOptions = [ |
||||
|
{ |
||||
|
label: "Lightest", |
||||
|
value: "spectrum--lightest", |
||||
|
}, |
||||
|
{ |
||||
|
label: "Light", |
||||
|
value: "spectrum--light", |
||||
|
}, |
||||
|
{ |
||||
|
label: "Dark", |
||||
|
value: "spectrum--dark", |
||||
|
}, |
||||
|
{ |
||||
|
label: "Darkest", |
||||
|
value: "spectrum--darkest", |
||||
|
}, |
||||
|
] |
||||
|
</script> |
||||
|
|
||||
|
<div> |
||||
|
<Select |
||||
|
value={$store.theme || "spectrum--light"} |
||||
|
options={themeOptions} |
||||
|
placeholder={null} |
||||
|
on:change={e => store.actions.theme.save(e.detail)} |
||||
|
/> |
||||
|
</div> |
||||
|
|
||||
|
<style> |
||||
|
div { |
||||
|
padding-right: 8px; |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,36 @@ |
|||||
|
<script> |
||||
|
import { DetailSummary, ActionButton, Drawer, Button } from "@budibase/bbui" |
||||
|
import { store } from "builderStore" |
||||
|
import ConditionalUIDrawer from "./PropertyControls/ConditionalUIDrawer.svelte" |
||||
|
|
||||
|
export let componentInstance |
||||
|
|
||||
|
let tempValue |
||||
|
let drawer |
||||
|
|
||||
|
const openDrawer = () => { |
||||
|
tempValue = JSON.parse(JSON.stringify(componentInstance?._conditions ?? [])) |
||||
|
drawer.show() |
||||
|
} |
||||
|
|
||||
|
const save = () => { |
||||
|
store.actions.components.updateConditions(tempValue) |
||||
|
drawer.hide() |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<DetailSummary |
||||
|
name={`Conditions${componentInstance?._conditions ? " *" : ""}`} |
||||
|
collapsible={false} |
||||
|
> |
||||
|
<div> |
||||
|
<ActionButton on:click={openDrawer}>Configure conditions</ActionButton> |
||||
|
</div> |
||||
|
</DetailSummary> |
||||
|
<Drawer bind:this={drawer} title="Conditions"> |
||||
|
<svelte:fragment slot="description"> |
||||
|
Show, hide and update components in response to conditions being met. |
||||
|
</svelte:fragment> |
||||
|
<Button cta slot="buttons" on:click={() => save()}>Save</Button> |
||||
|
<ConditionalUIDrawer slot="body" bind:conditions={tempValue} /> |
||||
|
</Drawer> |
||||
@ -1,42 +1,8 @@ |
|||||
<script> |
<script> |
||||
import { createEventDispatcher } from "svelte" |
import { ColorPicker } from "@budibase/bbui" |
||||
import Colorpicker from "@budibase/colorpicker" |
import { store } from "builderStore" |
||||
|
|
||||
const dispatch = createEventDispatcher() |
|
||||
|
|
||||
export let value |
export let value |
||||
|
|
||||
const WAIT = 150 |
|
||||
|
|
||||
function throttle(callback, wait, immediate = false) { |
|
||||
let timeout = null |
|
||||
let initialCall = true |
|
||||
|
|
||||
return function () { |
|
||||
const callNow = immediate && initialCall |
|
||||
const next = () => { |
|
||||
callback.apply(this, arguments) |
|
||||
timeout = null |
|
||||
} |
|
||||
|
|
||||
if (callNow) { |
|
||||
initialCall = false |
|
||||
next() |
|
||||
} |
|
||||
|
|
||||
if (!timeout) { |
|
||||
timeout = setTimeout(next, wait) |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
const onChange = throttle( |
|
||||
e => { |
|
||||
dispatch("change", e.detail) |
|
||||
}, |
|
||||
WAIT, |
|
||||
true |
|
||||
) |
|
||||
</script> |
</script> |
||||
|
|
||||
<Colorpicker value={value || "#C4C4C4"} on:change={onChange} /> |
<ColorPicker {value} on:change spectrumTheme={$store.theme} /> |
||||
|
|||||
@ -0,0 +1,290 @@ |
|||||
|
<script> |
||||
|
import { |
||||
|
Button, |
||||
|
Body, |
||||
|
Icon, |
||||
|
DrawerContent, |
||||
|
Layout, |
||||
|
Select, |
||||
|
DatePicker, |
||||
|
} from "@budibase/bbui" |
||||
|
import { flip } from "svelte/animate" |
||||
|
import { dndzone } from "svelte-dnd-action" |
||||
|
import { generate } from "shortid" |
||||
|
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte" |
||||
|
import { OperatorOptions, getValidOperatorsForType } from "helpers/lucene" |
||||
|
import { getBindableProperties } from "builderStore/dataBinding" |
||||
|
import { currentAsset, selectedComponent, store } from "builderStore" |
||||
|
import { getComponentForSettingType } from "./componentSettings" |
||||
|
import PropertyControl from "./PropertyControl.svelte" |
||||
|
|
||||
|
export let conditions = [] |
||||
|
|
||||
|
const flipDurationMs = 150 |
||||
|
const actionOptions = [ |
||||
|
{ |
||||
|
label: "Hide component", |
||||
|
value: "hide", |
||||
|
}, |
||||
|
{ |
||||
|
label: "Show component", |
||||
|
value: "show", |
||||
|
}, |
||||
|
{ |
||||
|
label: "Update setting", |
||||
|
value: "update", |
||||
|
}, |
||||
|
] |
||||
|
const valueTypeOptions = [ |
||||
|
{ |
||||
|
value: "string", |
||||
|
label: "Binding", |
||||
|
}, |
||||
|
{ |
||||
|
value: "number", |
||||
|
label: "Number", |
||||
|
}, |
||||
|
{ |
||||
|
value: "datetime", |
||||
|
label: "Date", |
||||
|
}, |
||||
|
{ |
||||
|
value: "boolean", |
||||
|
label: "Boolean", |
||||
|
}, |
||||
|
] |
||||
|
|
||||
|
let dragDisabled = true |
||||
|
$: definition = store.actions.components.getDefinition( |
||||
|
$selectedComponent?._component |
||||
|
) |
||||
|
$: settings = (definition?.settings ?? []).map(setting => { |
||||
|
return { |
||||
|
label: setting.label, |
||||
|
value: setting.key, |
||||
|
} |
||||
|
}) |
||||
|
$: bindableProperties = getBindableProperties( |
||||
|
$currentAsset, |
||||
|
$store.selectedComponentId |
||||
|
) |
||||
|
$: conditions.forEach(link => { |
||||
|
if (!link.id) { |
||||
|
link.id = generate() |
||||
|
} |
||||
|
}) |
||||
|
|
||||
|
const getSettingDefinition = key => { |
||||
|
return definition?.settings?.find(setting => { |
||||
|
return setting.key === key |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
const getComponentForSetting = key => { |
||||
|
const settingDefinition = getSettingDefinition(key) |
||||
|
return getComponentForSettingType(settingDefinition?.type || "text") |
||||
|
} |
||||
|
|
||||
|
const addCondition = () => { |
||||
|
conditions = [ |
||||
|
...conditions, |
||||
|
{ |
||||
|
valueType: "string", |
||||
|
id: generate(), |
||||
|
action: "hide", |
||||
|
operator: OperatorOptions.Equals.value, |
||||
|
}, |
||||
|
] |
||||
|
} |
||||
|
|
||||
|
const removeCondition = id => { |
||||
|
conditions = conditions.filter(link => link.id !== id) |
||||
|
} |
||||
|
|
||||
|
const handleFinalize = e => { |
||||
|
updateConditions(e) |
||||
|
dragDisabled = true |
||||
|
} |
||||
|
|
||||
|
const updateConditions = e => { |
||||
|
conditions = e.detail.items |
||||
|
} |
||||
|
|
||||
|
const getOperatorOptions = condition => { |
||||
|
return getValidOperatorsForType(condition.valueType) |
||||
|
} |
||||
|
|
||||
|
const onOperatorChange = (condition, newOperator) => { |
||||
|
const noValueOptions = [ |
||||
|
OperatorOptions.Empty.value, |
||||
|
OperatorOptions.NotEmpty.value, |
||||
|
] |
||||
|
condition.noValue = noValueOptions.includes(newOperator) |
||||
|
if (condition.noValue) { |
||||
|
condition.referenceValue = null |
||||
|
condition.valueType = "string" |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const onValueTypeChange = (condition, newType) => { |
||||
|
condition.referenceValue = null |
||||
|
|
||||
|
// Ensure a valid operator is set |
||||
|
const validOperators = getValidOperatorsForType(newType).map(x => x.value) |
||||
|
if (!validOperators.includes(condition.operator)) { |
||||
|
condition.operator = validOperators[0] ?? OperatorOptions.Equals.value |
||||
|
onOperatorChange(condition, condition.operator) |
||||
|
} |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<DrawerContent> |
||||
|
<div class="container"> |
||||
|
<Layout noPadding> |
||||
|
{#if conditions?.length} |
||||
|
<div |
||||
|
class="conditions" |
||||
|
use:dndzone={{ |
||||
|
items: conditions, |
||||
|
flipDurationMs, |
||||
|
dropTargetStyle: { outline: "none" }, |
||||
|
dragDisabled, |
||||
|
}} |
||||
|
on:finalize={handleFinalize} |
||||
|
on:consider={updateConditions} |
||||
|
> |
||||
|
{#each conditions as condition (condition.id)} |
||||
|
<div |
||||
|
class="condition" |
||||
|
class:update={condition.action === "update"} |
||||
|
animate:flip={{ duration: flipDurationMs }} |
||||
|
> |
||||
|
<div |
||||
|
class="handle" |
||||
|
aria-label="drag-handle" |
||||
|
style={dragDisabled ? "cursor: grab" : "cursor: grabbing"} |
||||
|
on:mousedown={() => (dragDisabled = false)} |
||||
|
> |
||||
|
<Icon name="DragHandle" size="XL" /> |
||||
|
</div> |
||||
|
<Select |
||||
|
placeholder={null} |
||||
|
options={actionOptions} |
||||
|
bind:value={condition.action} |
||||
|
/> |
||||
|
{#if condition.action === "update"} |
||||
|
<Select options={settings} bind:value={condition.setting} /> |
||||
|
<div>TO</div> |
||||
|
{#if getSettingDefinition(condition.setting)} |
||||
|
<PropertyControl |
||||
|
type={getSettingDefinition(condition.setting).type} |
||||
|
control={getComponentForSetting(condition.setting)} |
||||
|
key={getSettingDefinition(condition.setting).key} |
||||
|
value={condition.settingValue} |
||||
|
componentInstance={$selectedComponent} |
||||
|
onChange={val => (condition.settingValue = val)} |
||||
|
props={{ |
||||
|
options: getSettingDefinition(condition.setting).options, |
||||
|
placeholder: getSettingDefinition(condition.setting) |
||||
|
.placeholder, |
||||
|
}} |
||||
|
/> |
||||
|
{:else} |
||||
|
<Select disabled placeholder=" " /> |
||||
|
{/if} |
||||
|
{/if} |
||||
|
<div>IF</div> |
||||
|
<DrawerBindableInput |
||||
|
bindings={bindableProperties} |
||||
|
placeholder="Value" |
||||
|
value={condition.newValue} |
||||
|
on:change={e => (condition.newValue = e.detail)} |
||||
|
/> |
||||
|
<Select |
||||
|
placeholder={null} |
||||
|
options={getOperatorOptions(condition)} |
||||
|
bind:value={condition.operator} |
||||
|
on:change={e => onOperatorChange(condition, e.detail)} |
||||
|
/> |
||||
|
<Select |
||||
|
disabled={condition.noValue} |
||||
|
options={valueTypeOptions} |
||||
|
bind:value={condition.valueType} |
||||
|
placeholder={null} |
||||
|
on:change={e => onValueTypeChange(condition, e.detail)} |
||||
|
/> |
||||
|
{#if ["string", "number"].includes(condition.valueType)} |
||||
|
<DrawerBindableInput |
||||
|
disabled={condition.noValue} |
||||
|
bindings={bindableProperties} |
||||
|
placeholder="Value" |
||||
|
value={condition.referenceValue} |
||||
|
on:change={e => (condition.referenceValue = e.detail)} |
||||
|
/> |
||||
|
{:else if condition.valueType === "datetime"} |
||||
|
<DatePicker |
||||
|
placeholder="Value" |
||||
|
disabled={condition.noValue} |
||||
|
bind:value={condition.referenceValue} |
||||
|
/> |
||||
|
{:else if condition.valueType === "boolean"} |
||||
|
<Select |
||||
|
placeholder="Value" |
||||
|
disabled={condition.noValue} |
||||
|
options={["True", "False"]} |
||||
|
bind:value={condition.referenceValue} |
||||
|
/> |
||||
|
{/if} |
||||
|
<Icon |
||||
|
name="Close" |
||||
|
hoverable |
||||
|
size="S" |
||||
|
on:click={() => removeCondition(condition.id)} |
||||
|
/> |
||||
|
</div> |
||||
|
{/each} |
||||
|
</div> |
||||
|
{:else} |
||||
|
<Body size="S">Add your first condition to get started.</Body> |
||||
|
{/if} |
||||
|
<div> |
||||
|
<Button secondary icon="Add" on:click={addCondition}> |
||||
|
Add condition |
||||
|
</Button> |
||||
|
</div> |
||||
|
</Layout> |
||||
|
</div> |
||||
|
</DrawerContent> |
||||
|
|
||||
|
<style> |
||||
|
.container { |
||||
|
width: 100%; |
||||
|
max-width: 1400px; |
||||
|
margin: 0 auto; |
||||
|
} |
||||
|
.conditions { |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
justify-content: flex-start; |
||||
|
align-items: stretch; |
||||
|
gap: var(--spacing-m); |
||||
|
} |
||||
|
.condition { |
||||
|
gap: var(--spacing-l); |
||||
|
display: grid; |
||||
|
align-items: center; |
||||
|
grid-template-columns: auto 1fr auto 1fr 1fr 1fr 1fr auto; |
||||
|
border-radius: var(--border-radius-s); |
||||
|
transition: background-color ease-in-out 130ms; |
||||
|
} |
||||
|
.condition.update { |
||||
|
grid-template-columns: auto 1fr 1fr auto 1fr auto 1fr 1fr 1fr 1fr auto; |
||||
|
} |
||||
|
.condition:hover { |
||||
|
background-color: var(--spectrum-global-color-gray-100); |
||||
|
} |
||||
|
.handle { |
||||
|
display: grid; |
||||
|
place-items: center; |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1,12 @@ |
|||||
|
<script> |
||||
|
import { store } from "builderStore" |
||||
|
import DrawerBindableCombobox from "components/common/bindings/DrawerBindableCombobox.svelte" |
||||
|
|
||||
|
export let value |
||||
|
|
||||
|
$: urlOptions = $store.screens |
||||
|
.map(screen => screen.routing?.route) |
||||
|
.filter(x => x != null) |
||||
|
</script> |
||||
|
|
||||
|
<DrawerBindableCombobox {value} on:change options={urlOptions} /> |
||||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue