forked from tsai/budibase
committed by
GitHub
227 changed files with 10494 additions and 3381 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 @@ |
|||
module.exports = require("./src/constants") |
|||
@ -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> |
|||
@ -1,42 +1,8 @@ |
|||
<script> |
|||
import { createEventDispatcher } from "svelte" |
|||
import Colorpicker from "@budibase/colorpicker" |
|||
|
|||
const dispatch = createEventDispatcher() |
|||
import { ColorPicker } from "@budibase/bbui" |
|||
import { store } from "builderStore" |
|||
|
|||
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> |
|||
|
|||
<Colorpicker value={value || "#C4C4C4"} on:change={onChange} /> |
|||
<ColorPicker {value} on:change spectrumTheme={$store.theme} /> |
|||
|
|||
@ -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} /> |
|||
@ -1,59 +0,0 @@ |
|||
<script> |
|||
import analytics from "analytics" |
|||
import { createEventDispatcher } from "svelte" |
|||
import { store } from "builderStore" |
|||
|
|||
const dispatch = createEventDispatcher() |
|||
const feedbackUrl = "https://feedback.budibase.com" |
|||
|
|||
let iframe |
|||
|
|||
// the app @ feedback.budibase.com expects to be loaded |
|||
// in an iframe, and posts messages back. |
|||
// this means that we can submit using the Builder's posthog setup |
|||
window.addEventListener( |
|||
"message", |
|||
function (ev) { |
|||
if (ev.origin !== feedbackUrl) return |
|||
|
|||
if (ev.data.type === "loaded") { |
|||
iframe.setAttribute( |
|||
"style", |
|||
`height:${ev.data.height}px; width:${ev.data.width}px` |
|||
) |
|||
} else if (ev.data.type === "submitted") { |
|||
analytics.submitFeedback(ev.data.data) |
|||
$store.highlightFeedbackIcon = false |
|||
dispatch("finished") |
|||
} |
|||
}, |
|||
false |
|||
) |
|||
</script> |
|||
|
|||
<iframe src={feedbackUrl} title="feedback" bind:this={iframe}> |
|||
<html lang="html"> |
|||
<style> |
|||
body { |
|||
display: flex; |
|||
height: 100%; |
|||
width: 100%; |
|||
justify-content: center; |
|||
align-items: center; |
|||
} |
|||
</style> |
|||
<body> |
|||
<div>Loading...</div> |
|||
</body> |
|||
</html> |
|||
</iframe> |
|||
|
|||
<style> |
|||
iframe { |
|||
border-style: none; |
|||
height: auto; |
|||
overflow-y: hidden; |
|||
overflow-x: hidden; |
|||
min-width: 500px; |
|||
} |
|||
</style> |
|||
@ -1,52 +0,0 @@ |
|||
<script> |
|||
import { Icon, Popover } from "@budibase/bbui" |
|||
import { store } from "builderStore" |
|||
import { onMount } from "svelte" |
|||
|
|||
import FeedbackIframe from "./FeedbackIframe.svelte" |
|||
import analytics from "analytics" |
|||
|
|||
const FIVE_MINUTES = 300000 |
|||
|
|||
let iconContainer |
|||
let popover |
|||
|
|||
onMount(() => { |
|||
const interval = setInterval(() => { |
|||
store.update(state => { |
|||
state.highlightFeedbackIcon = analytics.highlightFeedbackIcon() |
|||
return state |
|||
}) |
|||
}, FIVE_MINUTES) |
|||
return () => clearInterval(interval) |
|||
}) |
|||
</script> |
|||
|
|||
<div class="container" bind:this={iconContainer} on:click={popover.show}> |
|||
<Icon hoverable name="Feedback" /> |
|||
</div> |
|||
<div class="iframe"> |
|||
<Popover bind:this={popover} anchor={iconContainer} align="right"> |
|||
<FeedbackIframe on:finished={popover.hide} /> |
|||
</Popover> |
|||
</div> |
|||
|
|||
<style> |
|||
.container { |
|||
cursor: pointer; |
|||
color: var(--grey-7); |
|||
margin: 0 12px 0 0; |
|||
font-weight: 600; |
|||
font-size: 1rem; |
|||
display: flex; |
|||
flex-direction: row; |
|||
justify-content: center; |
|||
align-items: center; |
|||
height: 24px; |
|||
width: 24px; |
|||
} |
|||
|
|||
.iframe :global(.menu-container) { |
|||
background-color: white; |
|||
} |
|||
</style> |
|||
@ -0,0 +1,168 @@ |
|||
<script> |
|||
import analytics from "analytics" |
|||
import { createEventDispatcher } from "svelte" |
|||
import { fade, fly } from "svelte/transition" |
|||
import { |
|||
ActionButton, |
|||
ClearButton, |
|||
RadioGroup, |
|||
TextArea, |
|||
ButtonGroup, |
|||
Button, |
|||
Heading, |
|||
Detail, |
|||
Divider, |
|||
Layout, |
|||
} from "@budibase/bbui" |
|||
import { auth } from "stores/portal" |
|||
|
|||
let step = 0 |
|||
let ratings = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] |
|||
let options = [ |
|||
"Importing / managing data", |
|||
"Designing", |
|||
"Automations", |
|||
"Managing users / groups", |
|||
"Deployment / hosting", |
|||
"Documentation", |
|||
] |
|||
|
|||
const dispatch = createEventDispatcher() |
|||
|
|||
// Data to send off |
|||
let rating |
|||
let improvements |
|||
let comment |
|||
|
|||
function selectNumber(n) { |
|||
rating = n |
|||
step = 1 |
|||
} |
|||
|
|||
function submitFeedback() { |
|||
analytics.submitFeedback({ |
|||
rating, |
|||
improvements, |
|||
comment, |
|||
}) |
|||
auth.updateSelf({ |
|||
flags: { |
|||
feedbackSubmitted: true, |
|||
}, |
|||
}) |
|||
dispatch("complete") |
|||
} |
|||
|
|||
function cancelFeedback() { |
|||
auth.updateSelf({ |
|||
flags: { |
|||
feedbackSubmitted: true, |
|||
}, |
|||
}) |
|||
dispatch("complete") |
|||
} |
|||
</script> |
|||
|
|||
<div |
|||
class="position" |
|||
in:fade={{ duration: 200 }} |
|||
out:fade|local={{ duration: 200 }} |
|||
> |
|||
<div |
|||
class="feedback-frame" |
|||
in:fly={{ y: 30, duration: 200 }} |
|||
out:fly|local={{ y: 30, duration: 200 }} |
|||
> |
|||
<div class="close"> |
|||
<ClearButton on:click={cancelFeedback} /> |
|||
</div> |
|||
<Layout gap="XS"> |
|||
{#if step === 0} |
|||
<Heading size="XS" |
|||
>How likely are you to recommend Budibase to a colleague?</Heading |
|||
> |
|||
<Divider /> |
|||
<div class="ratings"> |
|||
{#each ratings as number} |
|||
<ActionButton |
|||
size="L" |
|||
emphasized |
|||
selected={number === rating} |
|||
on:click={() => selectNumber(number)} |
|||
> |
|||
{number} |
|||
</ActionButton> |
|||
{/each} |
|||
</div> |
|||
<div class="footer"> |
|||
<Detail size="S">NOT LIKELY</Detail> |
|||
<Detail size="S">EXTREMELY LIKELY</Detail> |
|||
</div> |
|||
{:else if step === 1} |
|||
<Heading size="XS">What could be improved most in Budibase?</Heading> |
|||
<Divider /> |
|||
<RadioGroup bind:value={improvements} {options} /> |
|||
<div class="footer"> |
|||
<Detail size="S">STEP 2 OF 3</Detail> |
|||
<ButtonGroup> |
|||
<Button secondary on:click={() => (step -= 1)}>Previous</Button> |
|||
<Button |
|||
disabled={!improvements} |
|||
primary |
|||
on:click={() => (step += 1)}>Next</Button |
|||
> |
|||
</ButtonGroup> |
|||
</div> |
|||
{:else} |
|||
<Heading size="XS">How can we improve your experience?</Heading> |
|||
<Divider /> |
|||
<TextArea bind:value={comment} placeholder="Add comments" /> |
|||
<div class="footer"> |
|||
<Detail size="S">STEP 3 OF 3</Detail> |
|||
<ButtonGroup> |
|||
<Button secondary on:click={() => (step -= 1)}>Previous</Button> |
|||
<Button disabled={!comment} cta on:click={submitFeedback} |
|||
>Complete</Button |
|||
> |
|||
</ButtonGroup> |
|||
</div> |
|||
{/if} |
|||
</Layout> |
|||
</div> |
|||
</div> |
|||
|
|||
<style> |
|||
.feedback-frame :global(textarea) { |
|||
min-height: 180px !important; |
|||
} |
|||
|
|||
.position { |
|||
position: absolute; |
|||
right: var(--spacing-l); |
|||
bottom: calc(5 * var(--spacing-xl)); |
|||
} |
|||
.feedback-frame { |
|||
position: absolute; |
|||
bottom: 0; |
|||
right: 0; |
|||
min-width: 510px; |
|||
background: var(--background); |
|||
border-radius: var(--spectrum-global-dimension-size-50); |
|||
border: 2px solid var(--spectrum-global-color-blue-400); |
|||
padding: var(--spacing-xl); |
|||
} |
|||
.ratings { |
|||
display: flex; |
|||
justify-content: space-between; |
|||
} |
|||
.close { |
|||
position: absolute; |
|||
top: 0; |
|||
right: 0; |
|||
} |
|||
.footer { |
|||
display: flex; |
|||
justify-content: space-between; |
|||
align-items: center; |
|||
} |
|||
</style> |
|||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue