diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index 6ced1c77..7182a074 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -33,7 +33,32 @@ services: - '6379:6379' expose: - 6379 + keycloak: + image: quay.io/keycloak/keycloak:21.1 + restart: always + container_name: keycloak + environment: + - KEYCLOAK_ADMIN=admin + - KEYCLOAK_ADMIN_PASSWORD=admin + - KC_DB=postgres + - KC_DB_URL_HOST=postgres + - KC_DB_URL_DATABASE=keycloak + - KC_DB_USERNAME=automatisch_user + - KC_DB_PASSWORD=automatisch_password + - KC_HEALTH_ENABLED=true + ports: + - "8080:8080" + command: start-dev + depends_on: + - postgres + healthcheck: + test: "curl -f http://localhost:8080/health/ready || exit 1" + volumes: + - keycloak:/opt/keycloak/data/ + expose: + - 8080 volumes: postgres_data: redis_data: + keycloak: diff --git a/packages/backend/package.json b/packages/backend/package.json index ce4d2846..d257feed 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -27,10 +27,12 @@ "@casl/ability": "^6.5.0", "@graphql-tools/graphql-file-loader": "^7.3.4", "@graphql-tools/load": "^7.5.2", + "@node-saml/passport-saml": "^4.0.4", "@rudderstack/rudder-sdk-node": "^1.1.2", "@sentry/node": "^7.42.0", "@sentry/tracing": "^7.42.0", "@types/luxon": "^2.3.1", + "@types/passport": "^1.0.12", "@types/xmlrpc": "^1.3.7", "ajv-formats": "^2.1.1", "axios": "0.24.0", @@ -63,6 +65,7 @@ "nodemailer": "6.7.0", "oauth-1.0a": "^2.2.6", "objection": "^3.0.0", + "passport": "^0.6.0", "pg": "^8.7.1", "php-serialize": "^4.0.2", "stripe": "^11.13.0", diff --git a/packages/backend/src/app.ts b/packages/backend/src/app.ts index bade3e86..99c19bf2 100644 --- a/packages/backend/src/app.ts +++ b/packages/backend/src/app.ts @@ -17,6 +17,7 @@ import { } from './helpers/create-bull-board-handler'; import injectBullBoardHandler from './helpers/inject-bull-board-handler'; import router from './routes'; +import configurePassport from './helpers/passport'; createBullBoardHandler(serverAdapter); @@ -50,6 +51,9 @@ app.use( }) ); app.use(cors(corsOptions)); + +configurePassport(app); + app.use('/', router); webUIHandler(app); diff --git a/packages/backend/src/db/migrations/20230702210636_create_saml_auth_providers.ts b/packages/backend/src/db/migrations/20230702210636_create_saml_auth_providers.ts new file mode 100644 index 00000000..ab57067c --- /dev/null +++ b/packages/backend/src/db/migrations/20230702210636_create_saml_auth_providers.ts @@ -0,0 +1,23 @@ +import { Knex } from 'knex'; + +export async function up(knex: Knex): Promise { + return knex.schema.createTable('saml_auth_providers', (table) => { + table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()')); + table.string('name').notNullable(); + table.text('certificate').notNullable(); + table.string('signature_algorithm').notNullable(); + table.string('issuer').notNullable(); + table.text('entry_point').notNullable(); + table.text('firstname_attribute_name').notNullable(); + table.text('surname_attribute_name').notNullable(); + table.text('email_attribute_name').notNullable(); + table.text('role_attribute_name').notNullable(); + table.uuid('default_role_id').references('id').inTable('roles'); + + table.timestamps(true, true); + }); +} + +export async function down(knex: Knex): Promise { + return knex.schema.dropTable('saml_auth_providers'); +} diff --git a/packages/backend/src/db/migrations/20230707094923_create_identities.ts b/packages/backend/src/db/migrations/20230707094923_create_identities.ts new file mode 100644 index 00000000..2e63f296 --- /dev/null +++ b/packages/backend/src/db/migrations/20230707094923_create_identities.ts @@ -0,0 +1,17 @@ +import { Knex } from 'knex'; + +export async function up(knex: Knex): Promise { + return knex.schema.createTable('identities', (table) => { + table.uuid('id').primary().defaultTo(knex.raw('gen_random_uuid()')); + table.uuid('user_id').references('id').inTable('users'); + table.string('remote_id').notNullable(); + table.string('provider_id').notNullable(); + table.string('provider_type').notNullable(); + + table.timestamps(true, true); + }); +} + +export async function down(knex: Knex): Promise { + return knex.schema.dropTable('identities'); +} diff --git a/packages/backend/src/db/migrations/20230715214424_make_user_password_nullable.ts b/packages/backend/src/db/migrations/20230715214424_make_user_password_nullable.ts new file mode 100644 index 00000000..095a176d --- /dev/null +++ b/packages/backend/src/db/migrations/20230715214424_make_user_password_nullable.ts @@ -0,0 +1,14 @@ +import { Knex } from 'knex'; + +export async function up(knex: Knex): Promise { + return await knex.schema.alterTable('users', (table) => { + table.string('password').nullable().alter(); + }); +} + +export async function down(knex: Knex): Promise { + return await knex.schema.alterTable('users', table => { + // what do we do? passwords cannot be left empty + // table.string('password').notNullable().alter(); + }); +} diff --git a/packages/backend/src/graphql/queries/get-saml-auth-providers.ee.ts b/packages/backend/src/graphql/queries/get-saml-auth-providers.ee.ts new file mode 100644 index 00000000..e4179d00 --- /dev/null +++ b/packages/backend/src/graphql/queries/get-saml-auth-providers.ee.ts @@ -0,0 +1,9 @@ +import SamlAuthProvider from '../../models/saml-auth-provider.ee'; + +const getSamlAuthProviders = async () => { + const providers = await SamlAuthProvider.query(); + + return providers; +}; + +export default getSamlAuthProviders; diff --git a/packages/backend/src/graphql/query-resolvers.ts b/packages/backend/src/graphql/query-resolvers.ts index 4dde16fe..1af07484 100644 --- a/packages/backend/src/graphql/query-resolvers.ts +++ b/packages/backend/src/graphql/query-resolvers.ts @@ -18,6 +18,7 @@ import getInvoices from './queries/get-invoices.ee'; import getAutomatischInfo from './queries/get-automatisch-info'; import getTrialStatus from './queries/get-trial-status.ee'; import getSubscriptionStatus from './queries/get-subscription-status.ee'; +import getSamlAuthProviders from './queries/get-saml-auth-providers.ee'; import healthcheck from './queries/healthcheck'; const queryResolvers = { @@ -41,6 +42,7 @@ const queryResolvers = { getAutomatischInfo, getTrialStatus, getSubscriptionStatus, + getSamlAuthProviders, healthcheck, }; diff --git a/packages/backend/src/graphql/schema.graphql b/packages/backend/src/graphql/schema.graphql index b9203bff..add17ccb 100644 --- a/packages/backend/src/graphql/schema.graphql +++ b/packages/backend/src/graphql/schema.graphql @@ -41,6 +41,7 @@ type Query { getAutomatischInfo: GetAutomatischInfo getTrialStatus: GetTrialStatus getSubscriptionStatus: GetSubscriptionStatus + getSamlAuthProviders: [GetSamlAuthProviders] healthcheck: AppHealth } @@ -554,6 +555,12 @@ type PaymentPlan { productId: String } +type GetSamlAuthProviders { + id: String + name: String + issuer: String +} + schema { query: Query mutation: Mutation diff --git a/packages/backend/src/helpers/authentication.ts b/packages/backend/src/helpers/authentication.ts index 49da3eae..5a39394e 100644 --- a/packages/backend/src/helpers/authentication.ts +++ b/packages/backend/src/helpers/authentication.ts @@ -33,6 +33,7 @@ const authentication = shield( Query: { '*': isAuthenticated, getAutomatischInfo: allow, + getSamlAuthProviders: allow, healthcheck: allow, }, Mutation: { diff --git a/packages/backend/src/helpers/create-auth-token-by-user-id.ts b/packages/backend/src/helpers/create-auth-token-by-user-id.ts new file mode 100644 index 00000000..afc3fe19 --- /dev/null +++ b/packages/backend/src/helpers/create-auth-token-by-user-id.ts @@ -0,0 +1,14 @@ +import jwt from 'jsonwebtoken'; +import appConfig from '../config/app'; + +const TOKEN_EXPIRES_IN = '14d'; + +const createAuthTokenByUserId = (userId: string) => { + const token = jwt.sign({ userId }, appConfig.appSecretKey, { + expiresIn: TOKEN_EXPIRES_IN, + }); + + return token; +}; + +export default createAuthTokenByUserId; diff --git a/packages/backend/src/helpers/find-or-create-user-by-saml-identity.ee.ts b/packages/backend/src/helpers/find-or-create-user-by-saml-identity.ee.ts new file mode 100644 index 00000000..c930a021 --- /dev/null +++ b/packages/backend/src/helpers/find-or-create-user-by-saml-identity.ee.ts @@ -0,0 +1,48 @@ +import SamlAuthProvider from '../models/saml-auth-provider.ee'; +import User from '../models/user'; +import Identity from '../models/identity.ee'; + +const getUser = (user: Record, providerConfig: SamlAuthProvider) => ({ + name: user[providerConfig.firstnameAttributeName], + surname: user[providerConfig.surnameAttributeName], + id: user.nameID, + email: user[providerConfig.emailAttributeName], + role: user[providerConfig.roleAttributeName], +}) + +const findOrCreateUserBySamlIdentity = async (userIdentity: Record, samlAuthProvider: SamlAuthProvider) => { + const mappedUser = getUser(userIdentity, samlAuthProvider); + const identity = await Identity.query().findOne({ + remote_id: mappedUser.id, + }); + + if (identity) { + const user = await identity.$relatedQuery('user'); + + return user; + } + + const createdUser = await User.query().insertGraphAndFetch({ + fullName: [ + mappedUser.name, + mappedUser.surname + ] + .filter(Boolean) + .join(' '), + email: mappedUser.email as string, + roleId: samlAuthProvider.defaultRoleId, + identities: [ + { + remoteId: mappedUser.id as string, + providerId: samlAuthProvider.id, + providerType: 'saml' + } + ] + }, { + relate: ['identities'] + }); + + return createdUser; +}; + +export default findOrCreateUserBySamlIdentity; diff --git a/packages/backend/src/helpers/passport.ts b/packages/backend/src/helpers/passport.ts new file mode 100644 index 00000000..19e24d0f --- /dev/null +++ b/packages/backend/src/helpers/passport.ts @@ -0,0 +1,84 @@ +import { URL } from 'node:url'; +import { IRequest } from '@automatisch/types'; +import { MultiSamlStrategy } from '@node-saml/passport-saml'; +import { Express } from 'express'; +import passport from 'passport'; + +import appConfig from '../config/app'; +import createAuthTokenByUserId from '../helpers/create-auth-token-by-user-id'; +import SamlAuthProvider from '../models/saml-auth-provider.ee'; +import findOrCreateUserBySamlIdentity from './find-or-create-user-by-saml-identity.ee' + +export default function configurePassport(app: Express) { + app.use(passport.initialize({ + userProperty: 'currentUser', + })); + + passport.use(new MultiSamlStrategy( + { + passReqToCallback: true, + getSamlOptions: async function (request, done) { + const { issuer } = request.params; + const notFoundIssuer = new Error('Issuer cannot be found!'); + + if (!issuer) return done(notFoundIssuer); + + const authProvider = await SamlAuthProvider.query().findOne({ + issuer: request.params.issuer as string, + }); + + if (!authProvider) { + return done(notFoundIssuer); + } + + return done(null, authProvider.config); + }, + }, + async function (request, user: Record, done) { + const { issuer } = request.params; + const notFoundIssuer = new Error('Issuer cannot be found!'); + + if (!issuer) return done(notFoundIssuer); + + const authProvider = await SamlAuthProvider.query().findOne({ + issuer: request.params.issuer as string, + }); + + if (!authProvider) { + return done(notFoundIssuer); + } + + const foundUserWithIdentity = await findOrCreateUserBySamlIdentity(user, authProvider); + return done(null, foundUserWithIdentity as unknown as Record); + }, + function (request, user: Record, done: (error: any, user: Record) => void) { + return done(null, null); + } + )); + + app.get('/login/saml/:issuer', + passport.authenticate('saml', + { + session: false, + successRedirect: '/', + }) + ); + + app.post( + '/login/saml/:issuer/callback', + passport.authenticate('saml', { + session: false, + failureRedirect: '/', + failureFlash: true, + }), + (req: IRequest, res) => { + const token = createAuthTokenByUserId(req.currentUser.id); + + const redirectUrl = new URL( + `/login/callback?token=${token}`, + appConfig.webAppUrl, + ).toString(); + res.redirect(redirectUrl); + } + ); +}; diff --git a/packages/backend/src/models/identity.ee.ts b/packages/backend/src/models/identity.ee.ts new file mode 100644 index 00000000..68f8ff14 --- /dev/null +++ b/packages/backend/src/models/identity.ee.ts @@ -0,0 +1,53 @@ +import Base from './base'; +import SamlAuthProvider from './saml-auth-provider.ee'; +import User from './user'; + +class Identity extends Base { + id!: string; + remoteId!: string; + userId!: string; + providerId!: string; + providerType!: 'saml'; + + static tableName = 'identities'; + + static jsonSchema = { + type: 'object', + required: [ + 'providerId', + 'remoteId', + 'userId', + 'providerType', + ], + + properties: { + id: { type: 'string', format: 'uuid' }, + userId: { type: 'string', format: 'uuid' }, + remoteId: { type: 'string', minLength: 1 }, + providerId: { type: 'string', format: 'uuid' }, + providerType: { type: 'string', enum: ['saml'] }, + }, + }; + + static relationMappings = () => ({ + user: { + relation: Base.BelongsToOneRelation, + modelClass: User, + join: { + from: 'users.id', + to: 'identities.user_id', + }, + }, + samlAuthProvider: { + relation: Base.BelongsToOneRelation, + modelClass: SamlAuthProvider, + join: { + from: 'saml_auth_providers.id', + to: 'identities.provider_id' + }, + }, + }); + +} + +export default Identity; diff --git a/packages/backend/src/models/saml-auth-provider.ee.ts b/packages/backend/src/models/saml-auth-provider.ee.ts new file mode 100644 index 00000000..480d976f --- /dev/null +++ b/packages/backend/src/models/saml-auth-provider.ee.ts @@ -0,0 +1,79 @@ +import { URL } from 'node:url'; +import type { SamlConfig } from '@node-saml/passport-saml'; +import appConfig from '../config/app'; +import Base from './base'; +import Identity from './identity.ee'; + +class SamlAuthProvider extends Base { + id!: string; + name: string; + certificate: string; + signatureAlgorithm: SamlConfig["signatureAlgorithm"]; + issuer: string; + entryPoint: string; + firstnameAttributeName: string; + surnameAttributeName: string; + emailAttributeName: string; + roleAttributeName: string; + defaultRoleId: string; + + static tableName = 'saml_auth_providers'; + + static jsonSchema = { + type: 'object', + required: [ + 'name', + 'certificate', + 'signatureAlgorithm', + 'entryPoint', + 'issuer', + 'firstnameAttributeName', + 'surnameAttributeName', + 'emailAttributeName', + 'roleAttributeName', + 'defaultRoleId', + ], + + properties: { + id: { type: 'string', format: 'uuid' }, + name: { type: 'string', minLength: 1 }, + certificate: { type: 'string', minLength: 1 }, + signatureAlgorithm: { type: 'string', enum: ['sha1', 'sha256', 'sha512'] }, + issuer: { type: 'string', minLength: 1 }, + entryPoint: { type: 'string', minLength: 1 }, + firstnameAttributeName: { type: 'string', minLength: 1 }, + surnameAttributeName: { type: 'string', minLength: 1 }, + emailAttributeName: { type: 'string', minLength: 1 }, + roleAttributeName: { type: 'string', minLength: 1 }, + defaultRoleId: { type: 'string', format: 'uuid' } + }, + }; + + static relationMappings = () => ({ + identities: { + relation: Base.HasOneRelation, + modelClass: Identity, + join: { + from: 'identities.provider_id', + to: 'saml_auth_providers.id', + }, + }, + }); + + get config(): SamlConfig { + const callbackUrl = new URL( + `/login/saml/${this.issuer}/callback`, + appConfig.baseUrl + ).toString(); + + return { + callbackUrl, + cert: this.certificate, + entryPoint: this.entryPoint, + issuer: this.issuer, + signatureAlgorithm: this.signatureAlgorithm, + } + } +} + +export default SamlAuthProvider; diff --git a/packages/backend/src/models/user.ts b/packages/backend/src/models/user.ts index 3258d0fe..a2341199 100644 --- a/packages/backend/src/models/user.ts +++ b/packages/backend/src/models/user.ts @@ -14,6 +14,7 @@ import Step from './step'; import Role from './role'; import Permission from './permission'; import Execution from './execution'; +import Identity from './identity.ee'; import UsageData from './usage-data.ee'; import Subscription from './subscription.ee'; @@ -36,18 +37,19 @@ class User extends Base { currentSubscription?: Subscription; role: Role; permissions: Permission[]; + identities: Identity[]; static tableName = 'users'; static jsonSchema = { type: 'object', - required: ['fullName', 'email', 'password'], + required: ['fullName', 'email'], properties: { id: { type: 'string', format: 'uuid' }, fullName: { type: 'string', minLength: 1 }, email: { type: 'string', format: 'email', minLength: 1, maxLength: 255 }, - password: { type: 'string', minLength: 1, maxLength: 255 }, + password: { type: 'string' }, resetPasswordToken: { type: 'string' }, resetPasswordTokenSentAt: { type: 'string' }, trialExpiryDate: { type: 'string' }, @@ -157,6 +159,14 @@ class User extends Base { to: 'permissions.id', }, }, + identities: { + relation: Base.HasManyRelation, + modelClass: Identity, + join: { + from: 'identities.user_id', + to: 'users.id', + } + } }); login(password: string) { @@ -191,7 +201,9 @@ class User extends Base { } async generateHash() { - this.password = await bcrypt.hash(this.password, 10); + if (this.password) { + this.password = await bcrypt.hash(this.password, 10); + } } async startTrialPeriod() { @@ -265,9 +277,7 @@ class User extends Base { async $beforeUpdate(opt: ModelOptions, queryContext: QueryContext) { await super.$beforeUpdate(opt, queryContext); - if (this.password) { - await this.generateHash(); - } + await this.generateHash(); } async $afterInsert(queryContext: QueryContext) { diff --git a/packages/types/index.d.ts b/packages/types/index.d.ts index 8532fea0..069bf542 100644 --- a/packages/types/index.d.ts +++ b/packages/types/index.d.ts @@ -386,6 +386,20 @@ type TInvoice = { receipt_url: string; }; +type TSamlAuthProvider = { + id: string; + name: string; + certificate: string; + signatureAlgorithm: "sha1" | "sha256" | "sha512"; + issuer: string; + entryPoint: string; + firstnameAttributeName: string; + surnameAttributeName: string; + emailAttributeName: string; + roleAttributeName: string; + defaultRoleId: string; +} + declare module 'axios' { interface AxiosResponse { httpError?: IJSONObject; diff --git a/packages/web/.env-example b/packages/web/.env-example index 003236c0..e1d96693 100644 --- a/packages/web/.env-example +++ b/packages/web/.env-example @@ -1,4 +1,5 @@ PORT=3001 +REACT_APP_API_URL=http://localhost:3000 REACT_APP_GRAPHQL_URL=http://localhost:3000/graphql # HTTPS=true REACT_APP_BASE_URL=http://localhost:3001 diff --git a/packages/web/src/components/SsoProviders/index.ee.tsx b/packages/web/src/components/SsoProviders/index.ee.tsx new file mode 100644 index 00000000..da47b588 --- /dev/null +++ b/packages/web/src/components/SsoProviders/index.ee.tsx @@ -0,0 +1,39 @@ +import * as React from 'react'; +import Paper from '@mui/material/Paper'; +import Button from '@mui/material/Button'; +import Stack from '@mui/material/Stack'; +import Divider from '@mui/material/Divider'; + +import appConfig from 'config/app'; +import useSamlAuthProviders from 'hooks/useSamlAuthProviders.ee'; +import useFormatMessage from 'hooks/useFormatMessage'; + +function SsoProviders() { + const formatMessage = useFormatMessage(); + const { providers, loading } = useSamlAuthProviders(); + + if (!loading && providers.length === 0) return null; + + return ( + <> + {formatMessage('loginPage.divider')} + + + + {providers.map((provider) => ( + + ))} + + + + ); +} + +export default SsoProviders; diff --git a/packages/web/src/config/app.ts b/packages/web/src/config/app.ts index ea01596c..10b072b1 100644 --- a/packages/web/src/config/app.ts +++ b/packages/web/src/config/app.ts @@ -1,13 +1,24 @@ type Config = { [key: string]: string; + baseUrl: string; + apiUrl: string; + graphqlUrl: string; + notificationsUrl: string; + chatwootBaseUrl: string; + supportEmailAddress: string; }; const config: Config = { baseUrl: process.env.REACT_APP_BASE_URL as string, + apiUrl: process.env.REACT_APP_API_URL as string, graphqlUrl: process.env.REACT_APP_GRAPHQL_URL as string, notificationsUrl: process.env.REACT_APP_NOTIFICATIONS_URL as string, chatwootBaseUrl: 'https://app.chatwoot.com', supportEmailAddress: 'support@automatisch.io' }; +if (!config.apiUrl) { + config.apiUrl = (new URL(config.graphqlUrl)).origin; +} + export default config; diff --git a/packages/web/src/config/urls.ts b/packages/web/src/config/urls.ts index 174bbc9a..c2212e42 100644 --- a/packages/web/src/config/urls.ts +++ b/packages/web/src/config/urls.ts @@ -5,6 +5,7 @@ export const EXECUTION = (executionId: string): string => `/executions/${executionId}`; export const LOGIN = '/login'; +export const LOGIN_CALLBACK = `${LOGIN}/callback`; export const SIGNUP = '/sign-up'; export const FORGOT_PASSWORD = '/forgot-password'; export const RESET_PASSWORD = '/reset-password'; diff --git a/packages/web/src/graphql/queries/get-saml-auth-providers.ee.ts b/packages/web/src/graphql/queries/get-saml-auth-providers.ee.ts new file mode 100644 index 00000000..e50d83e1 --- /dev/null +++ b/packages/web/src/graphql/queries/get-saml-auth-providers.ee.ts @@ -0,0 +1,11 @@ +import { gql } from '@apollo/client'; + +export const GET_SAML_AUTH_PROVIDERS = gql` + query GetSamlAuthProviders { + getSamlAuthProviders { + id + name + issuer + } + } +`; diff --git a/packages/web/src/hooks/useSamlAuthProviders.ee.ts b/packages/web/src/hooks/useSamlAuthProviders.ee.ts new file mode 100644 index 00000000..39d64179 --- /dev/null +++ b/packages/web/src/hooks/useSamlAuthProviders.ee.ts @@ -0,0 +1,18 @@ +import { useQuery } from '@apollo/client'; + +import { TSamlAuthProvider } from '@automatisch/types'; +import { GET_SAML_AUTH_PROVIDERS } from 'graphql/queries/get-saml-auth-providers.ee'; + +type UseSamlAuthProvidersReturn = { + providers: TSamlAuthProvider[]; + loading: boolean; +}; + +export default function useSamlAuthProviders(): UseSamlAuthProvidersReturn { + const { data, loading } = useQuery(GET_SAML_AUTH_PROVIDERS); + + return { + providers: data?.getSamlAuthProviders || [], + loading + }; +} diff --git a/packages/web/src/locales/en.json b/packages/web/src/locales/en.json index 616b18eb..05ec3d74 100644 --- a/packages/web/src/locales/en.json +++ b/packages/web/src/locales/en.json @@ -129,6 +129,7 @@ "loginForm.submit": "Login", "loginForm.noAccount": "Don't have an Automatisch account yet?", "loginForm.signUp": "Sign up", + "loginPage.divider": "OR", "forgotPasswordForm.title": "Forgot password", "forgotPasswordForm.submit": "Send reset instructions", "forgotPasswordForm.instructionsSent": "The instructions have been sent!", diff --git a/packages/web/src/pages/Login/index.tsx b/packages/web/src/pages/Login/index.tsx index 81ea9f62..3cdee67e 100644 --- a/packages/web/src/pages/Login/index.tsx +++ b/packages/web/src/pages/Login/index.tsx @@ -1,13 +1,20 @@ import * as React from 'react'; import Box from '@mui/material/Box'; +import Stack from '@mui/material/Stack'; import Container from 'components/Container'; import LoginForm from 'components/LoginForm'; +import SsoProviders from 'components/SsoProviders/index.ee'; + export default function Login(): React.ReactElement { return ( - + + + + + ); diff --git a/packages/web/src/pages/LoginCallback/index.tsx b/packages/web/src/pages/LoginCallback/index.tsx new file mode 100644 index 00000000..bcc5f29e --- /dev/null +++ b/packages/web/src/pages/LoginCallback/index.tsx @@ -0,0 +1,29 @@ +import * as React from 'react'; +import { useNavigate, useSearchParams } from 'react-router-dom'; + +import useAuthentication from 'hooks/useAuthentication'; +import * as URLS from 'config/urls'; + +export default function LoginCallback(): React.ReactElement { + const navigate = useNavigate(); + const authentication = useAuthentication(); + const [searchParams] = useSearchParams(); + + React.useEffect(() => { + if (authentication.isAuthenticated) { + navigate(URLS.DASHBOARD); + } + }, [authentication.isAuthenticated]); + + React.useEffect(() => { + const token = searchParams.get('token'); + + if (token) { + authentication.updateToken(token); + } + + // TODO: handle non-existing token scenario + }, []); + + return (<>); +} diff --git a/packages/web/src/routes.tsx b/packages/web/src/routes.tsx index c06b8467..79860faf 100644 --- a/packages/web/src/routes.tsx +++ b/packages/web/src/routes.tsx @@ -8,6 +8,7 @@ import Execution from 'pages/Execution'; import Flows from 'pages/Flows'; import Flow from 'pages/Flow'; import Login from 'pages/Login'; +import LoginCallback from 'pages/LoginCallback'; import SignUp from 'pages/SignUp/index.ee'; import ForgotPassword from 'pages/ForgotPassword/index.ee'; import ResetPassword from 'pages/ResetPassword/index.ee'; @@ -83,6 +84,11 @@ export default ( } /> + } + /> + =0.6.0" xmlbuilder "~9.0.1" +xml2js@^0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.5.0.tgz#d9440631fbb2ed800203fad106f2724f62c493b7" + integrity sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA== + dependencies: + sax ">=0.6.0" + xmlbuilder "~11.0.0" + xmlbuilder@8.2.x: version "8.2.2" resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-8.2.2.tgz#69248673410b4ba42e1a6136551d2922335aa773" integrity sha512-eKRAFz04jghooy8muekqzo8uCSVNeyRedbuJrp0fovbLIi7wlsYtdUn3vBAAPq2Y3/0xMz2WMEUQ8yhVVO9Stw== +xmlbuilder@^15.1.1: + version "15.1.1" + resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-15.1.1.tgz#9dcdce49eea66d8d10b42cae94a79c3c8d0c2ec5" + integrity sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg== + +xmlbuilder@~11.0.0: + version "11.0.1" + resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-11.0.1.tgz#be9bae1c8a046e76b31127726347d0ad7002beb3" + integrity sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA== + xmlbuilder@~9.0.1: version "9.0.7" resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-9.0.7.tgz#132ee63d2ec5565c557e20f4c22df9aca686b10d" @@ -18070,6 +18240,16 @@ xmlrpc@^1.3.2: sax "1.2.x" xmlbuilder "8.2.x" +xpath@0.0.27: + version "0.0.27" + resolved "https://registry.yarnpkg.com/xpath/-/xpath-0.0.27.tgz#dd3421fbdcc5646ac32c48531b4d7e9d0c2cfa92" + integrity sha512-fg03WRxtkCV6ohClePNAECYsmpKKTv5L8y/X3Dn1hQrec3POx2jHZ/0P2qQ6HvsrU1BmeqXcof3NGGueG6LxwQ== + +xpath@0.0.32: + version "0.0.32" + resolved "https://registry.yarnpkg.com/xpath/-/xpath-0.0.32.tgz#1b73d3351af736e17ec078d6da4b8175405c48af" + integrity sha512-rxMJhSIoiO8vXcWvSifKqhvV96GjiD5wYb8/QHdoRyQvraTpp4IEv944nhGausZZ3u7dhQXteZuZbaqfpB7uYw== + xtend@^4.0.0, xtend@^4.0.2, xtend@~4.0.1: version "4.0.2" resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"