mirror of https://github.com/boxyhq/jackson.git
Merge branch 'main' into feature/splunk-direct-delivery
This commit is contained in:
commit
4b872939e4
|
@ -36,6 +36,7 @@ NEXTAUTH_ACL=
|
|||
# Change this to your deployment public URL (https://next-auth.js.org/configuration/options#nextauth_url)
|
||||
NEXTAUTH_URL=http://localhost:5225
|
||||
# Change this to a real secret when deploying to production
|
||||
# You can use openssl to generate a secret key: openssl rand -base64 32
|
||||
NEXTAUTH_SECRET=secret
|
||||
# Admin credentials (In the format email:password. Comma separated values if you want multiple logins). Alternative to Magic Links.
|
||||
NEXTAUTH_ADMIN_CREDENTIALS=
|
||||
|
@ -94,10 +95,12 @@ WEBHOOK_SECRET=
|
|||
|
||||
# Directory sync webhook event batch size (Eg: 50)
|
||||
DSYNC_WEBHOOK_BATCH_SIZE=
|
||||
DSYNC_WEBHOOK_BATCH_CRON_INTERVAL=
|
||||
|
||||
# Google workspace directory sync
|
||||
DSYNC_GOOGLE_CLIENT_ID=
|
||||
DSYNC_GOOGLE_CLIENT_SECRET=
|
||||
DSYNC_GOOGLE_CRON_INTERVAL=
|
||||
|
||||
# Only applicable for BoxyHQ SaaS deployments
|
||||
BOXYHQ_HOSTED=0
|
||||
|
|
|
@ -1,6 +1,30 @@
|
|||
# Contributing to Jackson
|
||||
|
||||
We appreciate your interest in contributing to Jackson, and your contributions are integral to enhancing the project. Whether you are addressing a bug, implementing new features, or suggesting improvements, your involvement is highly valued and essential.
|
||||
We appreciate your interest in contributing to Jackson, and your contributions are integral to enhancing the project. Whether addressing a bug, implementing new features, or suggesting improvements, your involvement is highly valued and essential.
|
||||
|
||||
- [Contributing to Jackson](#contributing-to-jackson)
|
||||
- [Code Style](#code-style)
|
||||
- [Getting Started](#getting-started)
|
||||
- [1. Fork the Repository](#1-fork-the-repository)
|
||||
- [2. Clone the Repository](#2-clone-the-repository)
|
||||
- [3. Setup](#3-setup)
|
||||
- [Contribution](#contribution)
|
||||
- [Creating a New Branch](#creating-a-new-branch)
|
||||
- [Staging Your Changes](#staging-your-changes)
|
||||
- [Committing Your Changes](#committing-your-changes)
|
||||
- [Pushing Your Changes](#pushing-your-changes)
|
||||
- [Create a Pull Request](#create-a-pull-request)
|
||||
- [Review and Feedback](#review-and-feedback)
|
||||
- [Merging](#merging)
|
||||
- [Celebrate!](#celebrate)
|
||||
- [Bug Reports](#bug-reports)
|
||||
- [Feature Requests](#feature-requests)
|
||||
- [Testing](#testing)
|
||||
- [Good First Issues](#good-first-issues)
|
||||
- [Development](#development)
|
||||
- [Code Of Conduct](#code-of-conduct)
|
||||
- [License](#license)
|
||||
- [Additional Tips](#additional-tips)
|
||||
|
||||
## Code Style
|
||||
|
||||
|
@ -24,54 +48,26 @@ git clone https://github.com/your-username/jackson.git
|
|||
|
||||
### 3. Setup
|
||||
|
||||
Navigate to the project folder and install the necessary dependencies:
|
||||
|
||||
```shell
|
||||
cd jackson
|
||||
```
|
||||
|
||||
#### Install Dependencies
|
||||
|
||||
```shell
|
||||
npm install
|
||||
```
|
||||
|
||||
#### Configure Environment Variables
|
||||
|
||||
```shell
|
||||
cp .env.example .env
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Please update the .env file with your values. Refer to the complete list of [Environment Variables](https://boxyhq.com/docs/jackson/deploy/env-variables) for guidance.
|
||||
|
||||
### 4. Build and Run
|
||||
|
||||
Ensure that the project is prepared for development:
|
||||
|
||||
```shell
|
||||
npm run build
|
||||
npm run start
|
||||
```
|
||||
|
||||
Visit [http://localhost:5225](http://localhost:5225) in your browser. If you encounter a sign-in page, you've successfully reached the Admin Portal.
|
||||
|
||||
For a comprehensive understanding of the deployment process, consult our documentation [here](https://boxyhq.com/docs/jackson/deploy/).
|
||||
See our [README](README.md) for instructions on setting up the project.
|
||||
|
||||
## Contribution
|
||||
|
||||
### Creating a New Branch
|
||||
|
||||
Begin by creating a new branch where you will work on your changes. You can do this with the following command:
|
||||
Begin by creating a new branch where you will work on your changes. You should always aim to start by creating an issue that describes the problem you are solving or the feature you are implementing. This will help ensure that the maintainers are aware of your work and can provide feedback.
|
||||
|
||||
Let's say that your issue title is "Support Custom Postgres Schema" and is issue number `#1818`. The ideal format for your branch name would be `1818-support-custom-postgres-schema`.
|
||||
|
||||
You can create a new branch with the following command:
|
||||
|
||||
```shell
|
||||
git checkout -b your-branch-name
|
||||
git switch -c 1818-support-custom-postgres-schema
|
||||
```
|
||||
|
||||
Alternatively, you can create a branch using:
|
||||
For older versions of Git, use:
|
||||
|
||||
```shell
|
||||
git branch your-branch-name
|
||||
git checkout -b 1818-support-custom-postgres-schema
|
||||
```
|
||||
|
||||
### Staging Your Changes
|
||||
|
@ -116,7 +112,7 @@ After submitting your pull request, maintainers and other contributors will revi
|
|||
|
||||
## Merging
|
||||
|
||||
Once your pull request is approved, it will be merged into the main repository.
|
||||
Once your pull request is approved, it will be merged into the main branch of the project.
|
||||
|
||||
#### Celebrate!
|
||||
|
||||
|
@ -155,7 +151,8 @@ Jackson is an open-source project released under the [Apache License 2.0](https:
|
|||
|
||||
## Additional Tips
|
||||
|
||||
1. Be responsive to feedback from maintainers.
|
||||
2. Don't hesitate to seek help if needed in the discussion forum or any related platform.
|
||||
1. Be patient. Your contributions are important, and we will do our best to review them in a timely manner.
|
||||
2. Be responsive to feedback from maintainers.
|
||||
3. Don't hesitate to seek help if needed in the discussion forum or any related platform.
|
||||
|
||||
#### Happy contributing!
|
||||
**Happy contributing!**
|
||||
|
|
260
README.md
260
README.md
|
@ -1,76 +1,19 @@
|
|||
<a href="https://boxyhq.com/enterprise-sso">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://github.com/boxyhq/.github/assets/66887028/df1c9904-df2f-4515-b403-58b14a0e9093">
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://github.com/boxyhq/.github/assets/66887028/e093a466-72ea-41c6-a292-4c39a150facd">
|
||||
<img alt="BoxyHQ Banner" src="https://github.com/boxyhq/jackson/assets/66887028/b40520b7-dbce-400b-88d3-400d1c215ea1">
|
||||
</picture>
|
||||
</a>
|
||||
# SAML Jackson: Open Source Enterprise SSO And Directory Sync
|
||||
|
||||
<h3 align="center" >
|
||||
<a href="https://boxyhq.com/docs/jackson/overview" rel="dofollow"><strong>· Explore the docs »</strong></a>
|
||||
<br />
|
||||
<a href="https://app.eu.boxyhq.com/auth/join?utm_source=github&utm_campaign=repo" rel="dofollow"><strong>· SaaS Sign Up »</strong></a>
|
||||
</h3>
|
||||
<a href="https://bestpractices.coreinfrastructure.org/projects/7493"><img src="https://bestpractices.coreinfrastructure.org/projects/7493/badge" alt="OpenSSF Best Practices Badge"></a>
|
||||
<a href="https://www.npmjs.com/package/@boxyhq/saml-jackson"><img src="https://img.shields.io/npm/dt/@boxyhq/saml-jackson" alt="NPM downloads badge" ></a>
|
||||
<a href="https://hub.docker.com/r/boxyhq/jackson"><img src="https://img.shields.io/docker/pulls/boxyhq/jackson" alt="Docker pull statistics badge"></a>
|
||||
<a href="https://github.com/boxyhq/jackson/blob/main/LICENSE"><img src="https://img.shields.io/github/license/boxyhq/jackson" alt="Apache 2.0 license badge"></a>
|
||||
<a href="https://github.com/boxyhq/jackson/issues"><img src="https://img.shields.io/github/issues/boxyhq/jackson" alt="Open Github issues badge"></a>
|
||||
<a href="https://github.com/boxyhq/jackson/stargazers"><img src="https://img.shields.io/github/stars/boxyhq/jackson" alt="Github stargazers"></a>
|
||||
<a href="https://www.npmjs.com/package/@boxyhq/saml-jackson"><img src="https://img.shields.io/node/v/@boxyhq/saml-jackson" alt="Nodejs version support badge"></a>
|
||||
<a href="https://raw.githubusercontent.com/boxyhq/jackson/main/swagger/swagger.json"><img src="https://img.shields.io/swagger/valid/3.0?specUrl=https%3A%2F%2Fraw.githubusercontent.com%2Fboxyhq%2Fjackson%2Fmain%2Fswagger%2Fswagger.json" alt="Swagger Validator badge"></a>
|
||||
|
||||
# ⭐️ SAML Jackson: Enterprise SSO made simple
|
||||
SAML Jackson bridges or proxies a SAML login flow to OAuth 2.0 or OpenID Connect, abstracting away all the complexities of the SAML protocol. It also supports Directory Sync via the SCIM 2.0 protocol for automatic user and group provisioning/de-provisioning.
|
||||
|
||||
<p>
|
||||
<a href="https://bestpractices.coreinfrastructure.org/projects/7493"><img src="https://bestpractices.coreinfrastructure.org/projects/7493/badge"></a>
|
||||
<a href="https://www.npmjs.com/package/@boxyhq/saml-jackson"><img src="https://img.shields.io/npm/dt/@boxyhq/saml-jackson" alt="npm" ></a>
|
||||
<a href="https://hub.docker.com/r/boxyhq/jackson"><img src="https://img.shields.io/docker/pulls/boxyhq/jackson" alt="Docker pull"></a>
|
||||
<a href="https://github.com/boxyhq/jackson/stargazers"><img src="https://img.shields.io/github/stars/boxyhq/jackson" alt="Github stargazers"></a>
|
||||
<a href="https://github.com/boxyhq/jackson/issues"><img src="https://img.shields.io/github/issues/boxyhq/jackson" alt="Github issues"></a>
|
||||
<a href="https://github.com/boxyhq/jackson/blob/main/LICENSE"><img src="https://img.shields.io/github/license/boxyhq/jackson" alt="license"></a>
|
||||
<a href="https://twitter.com/BoxyHQ"><img src="https://img.shields.io/twitter/follow/boxyhq?style=social" alt="Twitter"></a>
|
||||
<a href="https://www.linkedin.com/company/boxyhq"><img src="https://img.shields.io/badge/LinkedIn-blue" alt="LinkedIn"></a>
|
||||
<a href="https://discord.gg/uyb7pYt4Pa"><img src="https://img.shields.io/discord/877585485235630130" alt="Discord"></a>
|
||||
<a href="https://www.npmjs.com/package/@boxyhq/saml-jackson"><img src="https://img.shields.io/node/v/@boxyhq/saml-jackson" alt="node-current"></a>
|
||||
<a href="https://raw.githubusercontent.com/boxyhq/jackson/main/swagger/swagger.json"><img src="https://img.shields.io/swagger/valid/3.0?specUrl=https%3A%2F%2Fraw.githubusercontent.com%2Fboxyhq%2Fjackson%2Fmain%2Fswagger%2Fswagger.json" alt="Swagger Validator"></a>
|
||||
</p>
|
||||
> We now also support OpenID Connect providers.
|
||||
|
||||
[![Deploy with Vercel](https://vercel.com/button)](<https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fboxyhq%2Fjackson&env=DB_ENGINE,DB_TYPE,DB_URL,DB_ENCRYPTION_KEY,DB_TTL,DB_CLEANUP_LIMIT,JACKSON_API_KEYS,EXTERNAL_URL,IDP_ENABLED,SAML_AUDIENCE,CLIENT_SECRET_VERIFIER,SMTP_HOST,SMTP_PORT,SMTP_USER,SMTP_PASSWORD,SMTP_FROM,NEXTAUTH_URL,NEXTAUTH_SECRET,NEXTAUTH_ACL&envDescription=DB%20configuration%20and%20keys%20for%20encryption%20and%20authentication.EXTERNAL_URL%20(Usually%20https%3A%2F%2F%3Cproject-name-from-above%3E.vercel.app)%20can%20be%20set%20after%20deployment%20from%20the%20project%20dashboard.Set%20to%20''%20if%20not%20applicable.&envLink=https://boxyhq.com/docs/jackson/deploy/env-variables>)
|
||||
[![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy)
|
||||
|
||||
## 🚀 Getting Started with SAML Jackson
|
||||
|
||||
Please star ⭐ the repo to support us! 😀
|
||||
|
||||
Streamline your web application's authentication with Jackson, an SSO service supporting SAML and OpenID Connect protocols. Beyond enterprise-grade Single Sign-On, it also supports Directory Sync via the SCIM 2.0 protocol for automatic user and group provisioning/de-provisioning.
|
||||
|
||||
There are two ways to integrate SAML Jackson into an application. Depending on your use case, you can choose either of them. <br>
|
||||
|
||||
1. [separate service](https://boxyhq.com/docs/jackson/deploy/#as-a-separate-service) (Next.js application) Admin Portal out of the box for managing SSO and Directory Sync connections.
|
||||
2. [NPM library](https://boxyhq.com/docs/jackson/deploy/#as-a-separate-service) as an embedded library in your application.
|
||||
|
||||
SAML/OIDC SSO service
|
||||
|
||||
Jackson implements the SAML login flow as an OAuth 2.0 or OpenID Connect flow, abstracting away all the complexities of the SAML protocol. Integrate SAML with just a few lines of code. We also now support OpenID Connect providers.
|
||||
|
||||
Try our hosted demo showcasing the SAML SP login flow [here](https://saml-demo.boxyhq.com), no SAML configuration required thanks to our [Mock SAML](https://mocksaml.com) service.
|
||||
|
||||
## 🎦 Videos
|
||||
|
||||
- SSO/OIDC Tutorial [SAML Jackson Enterprise SSO](https://www.youtube.com/watch?v=nvsD4-GQw4A) (split into chapters to easily find what you are looking for)
|
||||
- SAML single sign-on login [demo](https://www.youtube.com/watch?v=VBUznQwoEWU)
|
||||
|
||||
## ✨ Demo
|
||||
|
||||
- SAML IdP login flow showcasing self hosted [Mock SAML](https://mocksaml.com/saml/login)
|
||||
- SAML [demo flow](https://saml-demo.boxyhq.com/)
|
||||
|
||||
## Here is what deploying SSO looks like with and without BoxyHQ
|
||||
|
||||
<div align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://github.com/boxyhq/jackson/assets/66887028/2abf9852-8d0a-4116-9899-e85703be2fbb">
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://github.com/boxyhq/jackson/assets/66887028/6aa15c53-7719-4eb4-870a-4a3c3d4c1f32">
|
||||
<img alt="BoxyHQ Banner" src="https://github.com/boxyhq/jackson/assets/66887028/1dae6821-d8a5-4302-832f-f1736e284e8c">
|
||||
</picture>
|
||||
</div>
|
||||
|
||||
## Documentation
|
||||
|
||||
For full documentation, visit [boxyhq.com/docs/jackson/overview](https://boxyhq.com/docs/jackson/overview)
|
||||
![A quick demo of the admin portal without sound to show an overview of what to expect. It shows features such as SSO, the ability to set up SSO connections, Setup Links, Directory sync, and more](samljackson480.gif)
|
||||
|
||||
## Directory Sync
|
||||
|
||||
|
@ -80,38 +23,126 @@ Directory sync helps organizations automate the provisioning and de-provisioning
|
|||
|
||||
For complete documentation, visit [boxyhq.com/docs/directory-sync/overview](https://boxyhq.com/docs/directory-sync/overview)
|
||||
|
||||
## Observability
|
||||
## 🌟 Why star this repository?
|
||||
|
||||
We support first-class observability on the back of OpenTelemetry, refer [here](https://boxyhq.com/docs/jackson/observability) for more details.
|
||||
If you find this project helpful, please consider supporting us by starring [the repository](https://github.com/boxyhq/jackson) and sharing it with others. This helps others find the project, grow the community and ensure the long-term health of the project. 🙏
|
||||
|
||||
## SBOM Reports (Software Bill Of Materials)
|
||||
- [SAML Jackson: Open Source Enterprise SSO And Directory Sync](#saml-jackson-open-source-enterprise-sso-and-directory-sync)
|
||||
- [Directory Sync](#directory-sync)
|
||||
- [🌟 Why star this repository?](#-why-star-this-repository)
|
||||
- [🚀 Getting Started with SAML Jackson](#-getting-started-with-saml-jackson)
|
||||
- [Try A Demo](#try-a-demo)
|
||||
- [Deploying SAML Jackson as a separate service locally](#deploying-saml-jackson-as-a-separate-service-locally)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Clone the repository](#clone-the-repository)
|
||||
- [Install dependencies](#install-dependencies)
|
||||
- [Setup environment variables](#setup-environment-variables)
|
||||
- [Database](#database)
|
||||
- [Start the development server](#start-the-development-server)
|
||||
- [Documentation](#documentation)
|
||||
- [Easy Cloud Deployment](#easy-cloud-deployment)
|
||||
- [Videos](#videos)
|
||||
- [End-to-End (E2E) tests](#end-to-end-e2e-tests)
|
||||
- [About BoxyHQ](#about-boxyhq)
|
||||
- [Security And Observability](#security-and-observability)
|
||||
- [Observability](#observability)
|
||||
- [SBOM Reports (Software Bill Of Materials)](#sbom-reports-software-bill-of-materials)
|
||||
- [Container Signing and Verification](#container-signing-and-verification)
|
||||
- [🛡️ Reporting Security Issues](#️-reporting-security-issues)
|
||||
- [Contributing](#contributing)
|
||||
- [💫 Support](#-support)
|
||||
- [📌 License](#-license)
|
||||
|
||||
We support SBOM reports, refer [here](https://boxyhq.com/docs/jackson/sbom) for more details.
|
||||
## 🚀 Getting Started with SAML Jackson
|
||||
|
||||
## Container Signing and Verification
|
||||
There are two ways to integrate SAML Jackson into an application. Depending on your use case, you can choose either of them. <br>
|
||||
|
||||
We support container image verification using cosign, refer [here](https://boxyhq.com/docs/jackson/container-signing) for more details.
|
||||
1. [As a separate service](https://boxyhq.com/docs/jackson/deploy/service) ([Next.js](https://nextjs.org/) application) This includes an admin portal out of the box for managing SSO and Directory Sync connections.
|
||||
2. [NPM library](https://boxyhq.com/docs/jackson/deploy/npm-library) as an embedded library in your application.
|
||||
|
||||
### Development Setup
|
||||
### Try A Demo
|
||||
|
||||
- Try our hosted demo showcasing the SAML service provider (SP) initiated [login flow here](https://saml-demo.boxyhq.com), which uses our [Mock SAML](https://mocksaml.com) IdP service.
|
||||
- Try an Identity Provider (IdP) initiated [login flow here](https://mocksaml.com/saml/login).
|
||||
|
||||
### Deploying SAML Jackson as a separate service locally
|
||||
|
||||
Let's get you to Hello SAML Jackson in no time.
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
- [Node.js](https://nodejs.org/en) at version `18.14.2` or higher
|
||||
|
||||
> It is generally a good idea to install and maintain Node.js versions using a version manager like [nvm](https://github.com/nvm-sh/nvm) or [nvs](https://github.com/jasongin/nvs) on Windows. More [information is available here](https://schalkneethling.com/posts/installing-node-and-managing-versions).
|
||||
|
||||
#### Clone the repository
|
||||
|
||||
```bash
|
||||
git clone https://github.com/boxyhq/jackson.git
|
||||
cd jackson
|
||||
```
|
||||
|
||||
#### Install dependencies
|
||||
|
||||
```bash
|
||||
npm i
|
||||
```
|
||||
|
||||
#### Setup environment variables
|
||||
|
||||
Create a `.env` from the existing `.env.example` file in the root of the project.
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
> **Environment variable documentation:** Have a look at https://boxyhq.com/docs/jackson/deploy/env-variables for all of the available environment variables.
|
||||
|
||||
#### Database
|
||||
|
||||
To get up and running, we have a [docker-compose setup](_dev/docker-compose.yml) that will spawn all the supported databases. Ensure that the docker daemon is running on your machine and then run: `npm run dev-dbs`. In case you need a fresh start, destroy the docker containers using: `npm run dev-dbs-destroy` and run: `npm run dev-dbs`.
|
||||
For the rest of the setup, we will use a PostgreSQL database. The easiest way to get PostgreSQL up and running on macOS is by using Postgres.app. You can download it from [https://postgresapp.com/](https://postgresapp.com/).
|
||||
|
||||
#### Development server
|
||||
> For other operating systems and alternative options for MacOS, please see the [documentation available on the Prisma website](https://www.prisma.io/dataguide/postgresql/setting-up-a-local-postgresql-database).
|
||||
|
||||
Copy the `.env.example` to `.env.local` and populate the values. Have a look at https://boxyhq.com/docs/jackson/deploy/env-variables for the available environment variables.
|
||||
#### Start the development server
|
||||
|
||||
Run the dev server:
|
||||
Now that we have our database running we can start the development server. But before we do, we need a way to log into the admin portal.
|
||||
|
||||
```zsh
|
||||
# Install the packages
|
||||
npm install
|
||||
# Start the server
|
||||
To log in to the admin portal we either need to [configure magic links](https://boxyhq.com/docs/admin-portal/overview#1-magic-links), or [enable username and password](https://boxyhq.com/docs/admin-portal/overview#2-email-and-password) login. The easiest one, and the one we will use, is to enable username and password login.
|
||||
|
||||
In your `.env` find the `NEXTAUTH_ADMIN_CREDENTIALS` environment variable. We need to provide an `email:password` combination that we can then use to log in to the admin portal. For example:
|
||||
|
||||
```bash
|
||||
NEXTAUTH_ADMIN_CREDENTIALS=admin@example.com:password
|
||||
```
|
||||
|
||||
Now we can start the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
#### End-to-End (E2E) tests
|
||||
Open `http://localhost:5225` in your browser and you should be redirected to the login screen.
|
||||
|
||||
At the login screen, you can now use the username and password you set in the `NEXTAUTH_ADMIN_CREDENTIALS` environment variable to log in. Click "Sign In" and you should be logged in and see the SSO Connections page with no configured connections. We have reached Hello SAML Jackson!
|
||||
|
||||
### Documentation
|
||||
|
||||
For the full documentation, visit [boxyhq.com/docs/jackson/overview](https://boxyhq.com/docs/jackson/overview)
|
||||
|
||||
### Easy Cloud Deployment
|
||||
|
||||
Deploy SAML Jackson to the cloud with a single click using the following providers:
|
||||
|
||||
[![Deploy with Vercel](https://vercel.com/button)](<https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fboxyhq%2Fjackson&env=DB_ENGINE,DB_TYPE,DB_URL,DB_ENCRYPTION_KEY,DB_TTL,DB_CLEANUP_LIMIT,JACKSON_API_KEYS,EXTERNAL_URL,IDP_ENABLED,SAML_AUDIENCE,CLIENT_SECRET_VERIFIER,SMTP_HOST,SMTP_PORT,SMTP_USER,SMTP_PASSWORD,SMTP_FROM,NEXTAUTH_URL,NEXTAUTH_SECRET,NEXTAUTH_ACL&envDescription=DB%20configuration%20and%20keys%20for%20encryption%20and%20authentication.EXTERNAL_URL%20(Usually%20https%3A%2F%2F%3Cproject-name-from-above%3E.vercel.app)%20can%20be%20set%20after%20deployment%20from%20the%20project%20dashboard.Set%20to%20''%20if%20not%20applicable.&envLink=https://boxyhq.com/docs/jackson/deploy/env-variables>)
|
||||
[![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy)
|
||||
|
||||
## Videos
|
||||
|
||||
- SSO/OIDC Tutorial [SAML Jackson Enterprise SSO](https://www.youtube.com/watch?v=nvsD4-GQw4A) (split into chapters to easily find what you are looking for)
|
||||
- SAML single sign-on login [demo](https://www.youtube.com/watch?v=VBUznQwoEWU)
|
||||
|
||||
## End-to-End (E2E) tests
|
||||
|
||||
Create a `.env.test.local` file and populate the values. To execute the tests run:
|
||||
|
||||
|
@ -119,33 +150,56 @@ Create a `.env.test.local` file and populate the values. To execute the tests ru
|
|||
npm run test:e2e
|
||||
```
|
||||
|
||||
## 🖳 Contributing
|
||||
## About BoxyHQ
|
||||
|
||||
Thanks for taking the time to contribute! Contributions are what make the open-source community such an amazing place to learn, inspire, and create. Any contributions you make will benefit everybody and are appreciated.
|
||||
<a href="https://boxyhq.com/enterprise-sso">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://github.com/boxyhq/.github/assets/66887028/df1c9904-df2f-4515-b403-58b14a0e9093">
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://github.com/boxyhq/.github/assets/66887028/e093a466-72ea-41c6-a292-4c39a150facd">
|
||||
<img alt="BoxyHQ - Security building blocks for developers" src="https://github.com/boxyhq/jackson/assets/66887028/b40520b7-dbce-400b-88d3-400d1c215ea1" height="auto" width="400" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
Please try to create bug reports that are:
|
||||
BoxyHQ is on a mission to democratize enterprise readiness for developers one building block at a time. We are building a suite of security building blocks that are easy to use and integrate into your applications. Our goal is to make being enterprise-ready accessible to all developers, founders, and those responsible for the security of their internal applications regardless of their security expertise.
|
||||
|
||||
- _Reproducible._ Include steps to reproduce the problem.
|
||||
- _Specific._ Include as much detail as possible: which version, what environment, etc.
|
||||
- _Unique._ Do not duplicate existing opened issues.
|
||||
- _Scoped to a Single Bug._ One bug per report.
|
||||
<a href="https://twitter.com/BoxyHQ"><img src="https://img.shields.io/twitter/follow/boxyhq?style=social" alt="Follow us on Twitter/X"></a>
|
||||
<a href="https://www.linkedin.com/company/boxyhq"><img src="https://img.shields.io/badge/LinkedIn-blue" alt="Connect with us on LinkedIn"></a>
|
||||
|
||||
Community is core to our mission. We are building a community of developers, security enthusiasts, and founders who are passionate about security and building secure applications. We are building in the open and would love for you to join us on this journey.
|
||||
|
||||
Join the community on Discord today.
|
||||
|
||||
<a href="https://discord.gg/uyb7pYt4Pa"><img src="https://img.shields.io/discord/877585485235630130" alt="Join the community on Discord"></a>
|
||||
|
||||
## Security And Observability
|
||||
|
||||
### Observability
|
||||
|
||||
We support first-class observability on the back of OpenTelemetry, refer [here](https://boxyhq.com/docs/jackson/observability) for more details.
|
||||
|
||||
### SBOM Reports (Software Bill Of Materials)
|
||||
|
||||
We support SBOM reports, refer [here](https://boxyhq.com/docs/jackson/sbom) for more details.
|
||||
|
||||
### Container Signing and Verification
|
||||
|
||||
We support container image verification using cosign, refer [here](https://boxyhq.com/docs/jackson/container-signing) for more details.
|
||||
|
||||
### 🛡️ Reporting Security Issues
|
||||
|
||||
[Responsible Disclosure](SECURITY.md)
|
||||
|
||||
## Contributing
|
||||
|
||||
Thank you for your interest in contributing to SAML Jackson! We are excited to welcome contributions from the community. Please refer to our [contributing guidelines](CONTRIBUTING.md) for more information.
|
||||
|
||||
## 💫 Support
|
||||
|
||||
Reach out to the maintainers at one of the following places:
|
||||
|
||||
- [GitHub Discussions](https://github.com/boxyhq/jackson/discussions)
|
||||
- [GitHub Issues](https://github.com/boxyhq/jackson/issues) (Bug reports, Contributions)
|
||||
|
||||
## 🤩 Community
|
||||
|
||||
- [Discord](https://discord.gg/uyb7pYt4Pa) (For live discussion with the Open-Source Community and BoxyHQ team)
|
||||
- [Twitter](https://twitter.com/BoxyHQ) (Follow us)
|
||||
- [Youtube](https://www.youtube.com/@boxyhq) (Watch community events and tutorials)
|
||||
|
||||
## 🛡️ Reporting Security Issues
|
||||
|
||||
[Responsible Disclosure](SECURITY.md)
|
||||
- [GitHub Issues](https://github.com/boxyhq/jackson/issues)
|
||||
- [Discord](https://discord.gg/uyb7pYt4Pa)
|
||||
|
||||
## 📌 License
|
||||
|
||||
|
|
|
@ -1,15 +0,0 @@
|
|||
import { Badge as BaseBadge, BadgeProps } from 'react-daisyui';
|
||||
|
||||
const Badge = (props: BadgeProps) => {
|
||||
const { children, className } = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseBadge {...props} className={`rounded-md py-2 text-white ${className}`}>
|
||||
{children}
|
||||
</BaseBadge>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Badge;
|
|
@ -1,15 +0,0 @@
|
|||
import classNames from 'classnames';
|
||||
import { Button, type ButtonProps } from 'react-daisyui';
|
||||
|
||||
export interface ButtonBaseProps extends ButtonProps {
|
||||
Icon?: any;
|
||||
}
|
||||
|
||||
export const ButtonBase = ({ Icon, children, ...others }: ButtonBaseProps) => {
|
||||
return (
|
||||
<Button {...others}>
|
||||
{Icon && <Icon className={classNames('h-4 w-4', children ? 'mr-1' : '')} aria-hidden />}
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
};
|
|
@ -1,9 +0,0 @@
|
|||
import { ButtonBase, type ButtonBaseProps } from './ButtonBase';
|
||||
|
||||
export const ButtonDanger = ({ children, ...other }: ButtonBaseProps) => {
|
||||
return (
|
||||
<ButtonBase color='error' {...other}>
|
||||
{children}
|
||||
</ButtonBase>
|
||||
);
|
||||
};
|
|
@ -1,9 +0,0 @@
|
|||
import { ButtonBase, type ButtonBaseProps } from './ButtonBase';
|
||||
|
||||
export const ButtonLink = ({ children, ...other }: ButtonBaseProps) => {
|
||||
return (
|
||||
<ButtonBase variant='link' {...other}>
|
||||
{children}
|
||||
</ButtonBase>
|
||||
);
|
||||
};
|
|
@ -1,9 +0,0 @@
|
|||
import { ButtonBase, type ButtonBaseProps } from './ButtonBase';
|
||||
|
||||
export const ButtonOutline = ({ children, ...other }: ButtonBaseProps) => {
|
||||
return (
|
||||
<ButtonBase variant='outline' {...other}>
|
||||
{children}
|
||||
</ButtonBase>
|
||||
);
|
||||
};
|
|
@ -1,9 +0,0 @@
|
|||
import { ButtonBase, type ButtonBaseProps } from './ButtonBase';
|
||||
|
||||
export const ButtonPrimary = ({ children, ...other }: ButtonBaseProps) => {
|
||||
return (
|
||||
<ButtonBase color='primary' {...other}>
|
||||
{children}
|
||||
</ButtonBase>
|
||||
);
|
||||
};
|
|
@ -1,39 +0,0 @@
|
|||
import { useTranslation } from 'next-i18next';
|
||||
import { copyToClipboard } from '@lib/ui/utils';
|
||||
import ClipboardDocumentIcon from '@heroicons/react/24/outline/ClipboardDocumentIcon';
|
||||
import { successToast } from '@components/Toaster';
|
||||
import { IconButton } from './IconButton';
|
||||
|
||||
export const CopyToClipboardButton = ({ text }: { text: string }) => {
|
||||
const { t } = useTranslation('common');
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
tooltip={t('copy')}
|
||||
Icon={ClipboardDocumentIcon}
|
||||
className='hover:text-primary'
|
||||
onClick={() => {
|
||||
copyToClipboard(text);
|
||||
successToast(t('copied'));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const InputWithCopyButton = ({ text, label }: { text: string; label: string }) => {
|
||||
return (
|
||||
<>
|
||||
<div className='flex justify-between'>
|
||||
<label className='mb-2 block text-sm font-medium text-gray-900 dark:text-gray-300'>{label}</label>
|
||||
<CopyToClipboardButton text={text} />
|
||||
</div>
|
||||
<input
|
||||
type='text'
|
||||
defaultValue={text}
|
||||
key={text}
|
||||
readOnly
|
||||
className='input-bordered input w-full text-sm'
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -1,52 +0,0 @@
|
|||
import Modal from './Modal';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { ButtonOutline } from './ButtonOutline';
|
||||
import { ButtonDanger } from './ButtonDanger';
|
||||
import { ButtonBase } from './ButtonBase';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
title: string;
|
||||
description: string;
|
||||
onConfirm: () => void | Promise<void>;
|
||||
onCancel: () => void;
|
||||
actionButtonText?: string;
|
||||
overrideDeleteButton?: boolean;
|
||||
dataTestId?: string;
|
||||
}
|
||||
|
||||
const ConfirmationModal = (props: Props) => {
|
||||
const {
|
||||
visible,
|
||||
title,
|
||||
description,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
actionButtonText,
|
||||
dataTestId = 'confirm-delete',
|
||||
overrideDeleteButton = false,
|
||||
} = props;
|
||||
|
||||
const { t } = useTranslation('common');
|
||||
|
||||
const buttonText = actionButtonText || t('delete');
|
||||
|
||||
return (
|
||||
<Modal visible={visible} title={title} description={description}>
|
||||
<div className='modal-action'>
|
||||
<ButtonOutline onClick={onCancel}>{t('cancel')}</ButtonOutline>
|
||||
{overrideDeleteButton ? (
|
||||
<ButtonBase color='secondary' onClick={onConfirm} data-testid={dataTestId}>
|
||||
{buttonText}
|
||||
</ButtonBase>
|
||||
) : (
|
||||
<ButtonDanger onClick={onConfirm} data-testid={dataTestId}>
|
||||
{buttonText}
|
||||
</ButtonDanger>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfirmationModal;
|
|
@ -1,74 +0,0 @@
|
|||
import { FC, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import ConfirmationModal from '@components/ConfirmationModal';
|
||||
|
||||
interface Props {
|
||||
onChange: (active: boolean) => void;
|
||||
connection: {
|
||||
active: boolean;
|
||||
type: 'sso' | 'dsync';
|
||||
};
|
||||
}
|
||||
|
||||
export const ConnectionToggle: FC<Props> = (props) => {
|
||||
const { onChange, connection } = props;
|
||||
|
||||
const { t } = useTranslation('common');
|
||||
const [isModalVisible, setModalVisible] = useState(false);
|
||||
const [active, setActive] = useState(connection.active);
|
||||
|
||||
useEffect(() => {
|
||||
setActive(connection.active);
|
||||
}, [connection]);
|
||||
|
||||
const askForConfirmation = () => {
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const onConfirm = () => {
|
||||
setModalVisible(false);
|
||||
setActive(!active);
|
||||
onChange(!active);
|
||||
};
|
||||
|
||||
const onCancel = () => {
|
||||
setModalVisible(false);
|
||||
};
|
||||
|
||||
const confirmationModalTitle = active ? t('deactivate_connection') : t('activate_connection');
|
||||
|
||||
const confirmationModalDescription = {
|
||||
sso: {
|
||||
activate: t('activate_sso_connection_description'),
|
||||
deactivate: t('deactivate_sso_connection_description'),
|
||||
},
|
||||
dsync: {
|
||||
activate: t('activate_dsync_connection_description'),
|
||||
deactivate: t('deactivate_dsync_connection_description'),
|
||||
},
|
||||
}[connection.type][active ? 'deactivate' : 'activate'];
|
||||
|
||||
return (
|
||||
<>
|
||||
<label className='label cursor-pointer'>
|
||||
<span className='label-text mr-2'>{active ? t('active') : t('inactive')}</span>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='toggle-success toggle'
|
||||
onChange={askForConfirmation}
|
||||
checked={active}
|
||||
/>
|
||||
</label>
|
||||
<ConfirmationModal
|
||||
title={confirmationModalTitle}
|
||||
description={confirmationModalDescription}
|
||||
actionButtonText={t('yes_proceed')}
|
||||
overrideDeleteButton={true}
|
||||
visible={isModalVisible}
|
||||
onConfirm={onConfirm}
|
||||
onCancel={onCancel}
|
||||
dataTestId='confirm-connection-toggle'
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -1,29 +0,0 @@
|
|||
import InformationCircleIcon from '@heroicons/react/24/outline/InformationCircleIcon';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { LinkPrimary } from '@components/LinkPrimary';
|
||||
|
||||
const EmptyState = ({
|
||||
title,
|
||||
href,
|
||||
className,
|
||||
description,
|
||||
}: {
|
||||
title: string;
|
||||
href?: string;
|
||||
className?: string;
|
||||
description?: string;
|
||||
}) => {
|
||||
const { t } = useTranslation('common');
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`my-3 flex flex-col items-center justify-center space-y-3 rounded border py-32 ${className}`}>
|
||||
<InformationCircleIcon className='h-10 w-10' />
|
||||
<h4 className='text-center'>{title}</h4>
|
||||
{description && <p className='text-center text-gray-500'>{description}</p>}
|
||||
{href && <LinkPrimary href={href}>{t('create_new')}</LinkPrimary>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmptyState;
|
|
@ -1,5 +0,0 @@
|
|||
const ErrorMessage = () => {
|
||||
return <p>{`Unable to load this page. Maybe you don't have enough rights.`}</p>;
|
||||
};
|
||||
|
||||
export default ErrorMessage;
|
|
@ -1,11 +0,0 @@
|
|||
import classNames from 'classnames';
|
||||
|
||||
export const IconButton = ({ Icon, tooltip, onClick, className, ...other }) => {
|
||||
return (
|
||||
<div className='tooltip' data-tip={tooltip}>
|
||||
<button onClick={onClick} type='button' {...other}>
|
||||
<Icon className={classNames('hover:scale-115 h-5 w-5 cursor-pointer text-secondary', className)} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -1,4 +1,4 @@
|
|||
import EmptyState from './EmptyState';
|
||||
import { EmptyState } from '@boxyhq/internal-ui';
|
||||
|
||||
const LicenseRequired = () => {
|
||||
return (
|
||||
|
|
|
@ -1,26 +0,0 @@
|
|||
import ArrowLeftIcon from '@heroicons/react/24/outline/ArrowLeftIcon';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { ButtonOutline } from './ButtonOutline';
|
||||
import { LinkOutline } from './LinkOutline';
|
||||
|
||||
export const LinkBack = ({ href, onClick }: { href?: string; onClick?: () => void }) => {
|
||||
const { t } = useTranslation('common');
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<LinkOutline href={href} Icon={ArrowLeftIcon}>
|
||||
{t('back')}
|
||||
</LinkOutline>
|
||||
);
|
||||
}
|
||||
|
||||
if (onClick) {
|
||||
return (
|
||||
<ButtonOutline onClick={onClick} Icon={ArrowLeftIcon}>
|
||||
{t('back')}
|
||||
</ButtonOutline>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
|
@ -1,17 +0,0 @@
|
|||
import Link from 'next/link';
|
||||
import classNames from 'classnames';
|
||||
import type { LinkProps } from 'react-daisyui';
|
||||
|
||||
export interface LinkBaseProps extends LinkProps {
|
||||
href: string;
|
||||
Icon?: any;
|
||||
}
|
||||
|
||||
export const LinkBase = ({ children, href, className, Icon, ...others }: LinkBaseProps) => {
|
||||
return (
|
||||
<Link href={href} className={classNames('btn', className)} {...others}>
|
||||
{Icon && <Icon className='mr-1 h-4 w-4' aria-hidden />}
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
};
|
|
@ -1,10 +0,0 @@
|
|||
import classNames from 'classnames';
|
||||
import { LinkBase, type LinkBaseProps } from './LinkBase';
|
||||
|
||||
export const LinkOutline = ({ children, className, ...others }: LinkBaseProps) => {
|
||||
return (
|
||||
<LinkBase className={classNames('btn-outline', className)} {...others}>
|
||||
{children}
|
||||
</LinkBase>
|
||||
);
|
||||
};
|
|
@ -1,10 +0,0 @@
|
|||
import classNames from 'classnames';
|
||||
import { LinkBase, type LinkBaseProps } from './LinkBase';
|
||||
|
||||
export const LinkPrimary = ({ children, className, ...others }: LinkBaseProps) => {
|
||||
return (
|
||||
<LinkBase className={classNames('btn-primary', className)} {...others}>
|
||||
{children}
|
||||
</LinkBase>
|
||||
);
|
||||
};
|
|
@ -1,32 +0,0 @@
|
|||
const Loading = () => {
|
||||
return (
|
||||
<div className='flex items-center justify-center p-5'>
|
||||
<div role='status'>
|
||||
<Spinner />
|
||||
<span className='sr-only'>Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Spinner = () => {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden='true'
|
||||
className='h-10 w-10 animate-spin fill-primary text-gray-200'
|
||||
viewBox='0 0 100 101'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'>
|
||||
<path
|
||||
d='M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z'
|
||||
fill='currentColor'
|
||||
/>
|
||||
<path
|
||||
d='M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z'
|
||||
fill='currentFill'
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default Loading;
|
|
@ -1,31 +0,0 @@
|
|||
import React from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
type ModalProps = {
|
||||
visible: boolean;
|
||||
title: string;
|
||||
description?: string;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const Modal = ({ visible, title, description, children }: ModalProps) => {
|
||||
const [open, setOpen] = useState(visible ? visible : false);
|
||||
|
||||
useEffect(() => {
|
||||
setOpen(visible);
|
||||
}, [visible]);
|
||||
|
||||
return (
|
||||
<div className={`modal ${open ? 'modal-open' : ''}`}>
|
||||
<div className='modal-box'>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<h3 className='text-lg font-bold'>{title}</h3>
|
||||
{description && <p className='text-sm'>{description}</p>}
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Modal;
|
|
@ -60,7 +60,7 @@ export const Sidebar = ({ isOpen, setIsOpen }: SidebarProps) => {
|
|||
},
|
||||
{
|
||||
href: '/admin/sso-tracer',
|
||||
text: t('sso_tracer'),
|
||||
text: t('bui-tracer-title'),
|
||||
active: asPath.includes('/admin/sso-tracer'),
|
||||
},
|
||||
],
|
||||
|
|
|
@ -1,10 +1,8 @@
|
|||
import LinkIcon from '@heroicons/react/24/outline/LinkIcon';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useRouter } from 'next/router';
|
||||
import { LinkPrimary } from '@components/LinkPrimary';
|
||||
import { InputWithCopyButton } from '@components/ClipboardButton';
|
||||
import { ConnectionList } from '@boxyhq/react-ui/sso';
|
||||
import { pageLimit } from '@boxyhq/internal-ui';
|
||||
import { InputWithCopyButton, pageLimit, LinkPrimary } from '@boxyhq/internal-ui';
|
||||
|
||||
const SSOConnectionList = ({
|
||||
setupLinkToken,
|
||||
|
@ -45,7 +43,7 @@ const SSOConnectionList = ({
|
|||
Icon={LinkIcon}
|
||||
href='/admin/sso-connection/setup-link/new'
|
||||
data-testid='create-setup-link'>
|
||||
{t('new_setup_link')}
|
||||
{t('bui-sl-new-link')}
|
||||
</LinkPrimary>
|
||||
)}
|
||||
<LinkPrimary href={createConnectionUrl} data-testid='create-connection'>
|
||||
|
|
|
@ -1,187 +1,44 @@
|
|||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
saveConnection,
|
||||
fieldCatalogFilterByConnection,
|
||||
renderFieldList,
|
||||
useFieldCatalog,
|
||||
excludeFallback,
|
||||
type AdminPortalSSODefaults,
|
||||
type FormObj,
|
||||
type FieldCatalogItem,
|
||||
} from './utils';
|
||||
import { mutate } from 'swr';
|
||||
import { ApiResponse } from 'types';
|
||||
import { errorToast } from '@components/Toaster';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { ButtonPrimary } from '@components/ButtonPrimary';
|
||||
import { LinkBack } from '@components/LinkBack';
|
||||
|
||||
function getInitialState(connectionType, fieldCatalog: FieldCatalogItem[]) {
|
||||
const _state = {};
|
||||
|
||||
fieldCatalog.forEach(({ key, type, members, fallback, attributes: { connection } }) => {
|
||||
let value;
|
||||
if (connection && connection !== connectionType) {
|
||||
return;
|
||||
}
|
||||
/** By default those fields which do not have a fallback.activateCondition will be excluded */
|
||||
if (typeof fallback === 'object' && typeof fallback.activateCondition !== 'function') {
|
||||
return;
|
||||
}
|
||||
if (type === 'object') {
|
||||
value = getInitialState(connectionType, members as FieldCatalogItem[]);
|
||||
}
|
||||
_state[key] = value ? value : '';
|
||||
});
|
||||
return _state;
|
||||
}
|
||||
import { LinkBack } from '@boxyhq/internal-ui';
|
||||
import { CreateSSOConnection } from '@boxyhq/react-ui/sso';
|
||||
import { BOXYHQ_UI_CSS } from '@components/styles';
|
||||
import { AdminPortalSSODefaults } from '@lib/utils';
|
||||
|
||||
const CreateConnection = ({
|
||||
setupLinkToken,
|
||||
isSettingsView = false,
|
||||
adminPortalSSODefaults,
|
||||
}: {
|
||||
setupLinkToken?: string;
|
||||
idpEntityID?: string;
|
||||
isSettingsView?: boolean;
|
||||
adminPortalSSODefaults?: AdminPortalSSODefaults;
|
||||
}) => {
|
||||
const fieldCatalog = useFieldCatalog({ isSettingsView });
|
||||
const { t } = useTranslation('common');
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// STATE: New connection type
|
||||
const [newConnectionType, setNewConnectionType] = useState<'saml' | 'oidc'>('saml');
|
||||
const redirectUrl = isSettingsView ? '/admin/settings/sso-connection' : '/admin/sso-connection';
|
||||
|
||||
const handleNewConnectionTypeChange = (event) => {
|
||||
setNewConnectionType(event.target.value);
|
||||
};
|
||||
|
||||
const connectionIsSAML = newConnectionType === 'saml';
|
||||
const connectionIsOIDC = newConnectionType === 'oidc';
|
||||
|
||||
const backUrl = setupLinkToken
|
||||
? null
|
||||
: isSettingsView
|
||||
? '/admin/settings/sso-connection'
|
||||
: '/admin/sso-connection';
|
||||
const redirectUrl = setupLinkToken
|
||||
? `/setup/${setupLinkToken}/sso-connection`
|
||||
: isSettingsView
|
||||
? '/admin/settings/sso-connection'
|
||||
: '/admin/sso-connection';
|
||||
const mutationUrl = setupLinkToken
|
||||
? `/api/setup/${setupLinkToken}/sso-connection`
|
||||
: isSettingsView
|
||||
? '/api/admin/connections?isSystemSSO'
|
||||
: '/api/admin/connections';
|
||||
|
||||
// FORM LOGIC: SUBMIT
|
||||
const save = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
setLoading(true);
|
||||
|
||||
await saveConnection({
|
||||
formObj: formObj,
|
||||
connectionIsSAML: connectionIsSAML,
|
||||
connectionIsOIDC: connectionIsOIDC,
|
||||
setupLinkToken,
|
||||
callback: async (rawResponse) => {
|
||||
setLoading(false);
|
||||
|
||||
const response: ApiResponse = await rawResponse.json();
|
||||
|
||||
if ('error' in response) {
|
||||
errorToast(response.error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (rawResponse.ok) {
|
||||
await mutate(mutationUrl);
|
||||
router.replace(redirectUrl);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// STATE: FORM
|
||||
const [formObj, setFormObj] = useState<FormObj>(() =>
|
||||
isSettingsView
|
||||
? { ...getInitialState(newConnectionType, fieldCatalog), ...adminPortalSSODefaults }
|
||||
: { ...getInitialState(newConnectionType, fieldCatalog) }
|
||||
);
|
||||
// Resync form state on save
|
||||
useEffect(() => {
|
||||
const _state = getInitialState(newConnectionType, fieldCatalog);
|
||||
setFormObj(isSettingsView ? { ..._state, ...adminPortalSSODefaults } : _state);
|
||||
}, [newConnectionType, fieldCatalog, isSettingsView, adminPortalSSODefaults]);
|
||||
|
||||
// HANDLER: Track fallback display
|
||||
const activateFallback = (key, fallbackKey) => {
|
||||
setFormObj((cur) => {
|
||||
const temp = { ...cur };
|
||||
delete temp[key];
|
||||
const fallbackItem = fieldCatalog.find(({ key }) => key === fallbackKey);
|
||||
const fallbackItemVal = fallbackItem?.type === 'object' ? {} : '';
|
||||
return { ...temp, [fallbackKey]: fallbackItemVal };
|
||||
});
|
||||
};
|
||||
const backUrl = redirectUrl;
|
||||
|
||||
return (
|
||||
<>
|
||||
{backUrl && <LinkBack href={backUrl} />}
|
||||
<div>
|
||||
<h2 className='mb-5 mt-5 font-bold text-gray-700 dark:text-white md:text-xl'>
|
||||
{t('create_sso_connection')}
|
||||
</h2>
|
||||
<div className='mb-4 flex items-center'>
|
||||
<div className='mr-2 py-3'>{t('select_sso_type')}:</div>
|
||||
<div className='flex w-52'>
|
||||
<div className='form-control'>
|
||||
<label className='label mr-4 cursor-pointer'>
|
||||
<input
|
||||
type='radio'
|
||||
name='connection'
|
||||
value='saml'
|
||||
className='radio-primary radio'
|
||||
checked={newConnectionType === 'saml'}
|
||||
onChange={handleNewConnectionTypeChange}
|
||||
/>
|
||||
<span className='label-text ml-1'>{t('saml')}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className='form-control'>
|
||||
<label className='label mr-4 cursor-pointer' data-testid='sso-type-oidc'>
|
||||
<input
|
||||
type='radio'
|
||||
name='connection'
|
||||
value='oidc'
|
||||
className='radio-primary radio'
|
||||
checked={newConnectionType === 'oidc'}
|
||||
onChange={handleNewConnectionTypeChange}
|
||||
/>
|
||||
<span className='label-text ml-1'>{t('oidc')}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={save}>
|
||||
<div className='min-w-[28rem] rounded border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800'>
|
||||
{fieldCatalog
|
||||
.filter(fieldCatalogFilterByConnection(newConnectionType))
|
||||
.filter(({ attributes: { hideInSetupView } }) => (setupLinkToken ? !hideInSetupView : true))
|
||||
.filter(excludeFallback(formObj))
|
||||
.map(renderFieldList({ formObj, setFormObj, activateFallback }))}
|
||||
<div className='flex'>
|
||||
<ButtonPrimary loading={loading} data-testid='submit-form-create-sso'>
|
||||
{t('save_changes')}
|
||||
</ButtonPrimary>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<h2 className='mb-8 mt-5 font-bold text-gray-700 dark:text-white md:text-xl'>
|
||||
{t('create_sso_connection')}
|
||||
</h2>
|
||||
<div className='min-w-[28rem] rounded border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800'>
|
||||
<CreateSSOConnection
|
||||
defaults={isSettingsView ? adminPortalSSODefaults : undefined}
|
||||
variant={{ saml: 'advanced', oidc: 'advanced' }}
|
||||
urls={{
|
||||
post: '/api/admin/connections',
|
||||
}}
|
||||
excludeFields={{ saml: ['label'], oidc: ['label'] }}
|
||||
successCallback={() => router.replace(redirectUrl)}
|
||||
errorCallback={(errMessage) => errorToast(errMessage)}
|
||||
classNames={BOXYHQ_UI_CSS}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -1,56 +1,10 @@
|
|||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { mutate } from 'swr';
|
||||
|
||||
import ConfirmationModal from '@components/ConfirmationModal';
|
||||
import {
|
||||
saveConnection,
|
||||
fieldCatalogFilterByConnection,
|
||||
renderFieldList,
|
||||
useFieldCatalog,
|
||||
type FormObj,
|
||||
type FieldCatalogItem,
|
||||
excludeFallback,
|
||||
} from './utils';
|
||||
import { ApiResponse } from 'types';
|
||||
import { errorToast, successToast } from '@components/Toaster';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { LinkBack } from '@components/LinkBack';
|
||||
import { ButtonPrimary } from '@components/ButtonPrimary';
|
||||
import { ButtonDanger } from '@components/ButtonDanger';
|
||||
import { isObjectEmpty } from '@lib/ui/utils';
|
||||
import { ToggleConnectionStatus } from './ToggleConnectionStatus';
|
||||
import { LinkBack } from '@boxyhq/internal-ui';
|
||||
import type { OIDCSSORecord, SAMLSSORecord } from '@boxyhq/saml-jackson';
|
||||
|
||||
function getInitialState(connection, fieldCatalog: FieldCatalogItem[], connectionType) {
|
||||
const _state = {};
|
||||
|
||||
fieldCatalog.forEach(({ key, attributes, type, members }) => {
|
||||
let value;
|
||||
if (attributes.connection && attributes.connection !== connectionType) {
|
||||
return;
|
||||
}
|
||||
if (type === 'object') {
|
||||
value = getInitialState(connection, members as FieldCatalogItem[], connectionType);
|
||||
if (isObjectEmpty(value)) {
|
||||
return;
|
||||
}
|
||||
} else if (typeof attributes.accessor === 'function') {
|
||||
if (attributes.accessor(connection) === undefined) {
|
||||
return;
|
||||
}
|
||||
value = attributes.accessor(connection);
|
||||
} else {
|
||||
value = connection?.[key];
|
||||
}
|
||||
_state[key] = value
|
||||
? attributes.isArray
|
||||
? value.join('\r\n') // render list of items on newline eg:- redirect URLs
|
||||
: value
|
||||
: '';
|
||||
});
|
||||
return _state;
|
||||
}
|
||||
import { EditSAMLConnection, EditOIDCConnection } from '@boxyhq/react-ui/sso';
|
||||
import { BOXYHQ_UI_CSS } from '@components/styles';
|
||||
|
||||
type EditProps = {
|
||||
connection: SAMLSSORecord | OIDCSSORecord;
|
||||
|
@ -59,8 +13,6 @@ type EditProps = {
|
|||
};
|
||||
|
||||
const EditConnection = ({ connection, setupLinkToken, isSettingsView = false }: EditProps) => {
|
||||
const fieldCatalog = useFieldCatalog({ isEditView: true, isSettingsView });
|
||||
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation('common');
|
||||
|
||||
|
@ -69,117 +21,16 @@ const EditConnection = ({ connection, setupLinkToken, isSettingsView = false }:
|
|||
const connectionIsSAML = 'idpMetadata' in connection && typeof connection.idpMetadata === 'object';
|
||||
const connectionIsOIDC = 'oidcProvider' in connection && typeof connection.oidcProvider === 'object';
|
||||
|
||||
// FORM LOGIC: SUBMIT
|
||||
const save = async (event) => {
|
||||
event.preventDefault();
|
||||
saveConnection({
|
||||
formObj: formObj,
|
||||
connectionIsSAML: connectionIsSAML,
|
||||
connectionIsOIDC: connectionIsOIDC,
|
||||
isEditView: true,
|
||||
setupLinkToken,
|
||||
callback: async (res) => {
|
||||
if (res.ok) {
|
||||
successToast(t('saved'));
|
||||
// revalidate on save
|
||||
mutate(
|
||||
setupLinkToken
|
||||
? `/api/setup/${setupLinkToken}/sso-connection`
|
||||
: `/api/admin/connections/${connectionClientId}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const response: ApiResponse = await res.json();
|
||||
|
||||
if ('error' in response) {
|
||||
errorToast(response.error.message);
|
||||
return;
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// LOGIC: DELETE
|
||||
const [delModalVisible, setDelModalVisible] = useState(false);
|
||||
const toggleDelConfirm = () => setDelModalVisible(!delModalVisible);
|
||||
const deleteConnection = async () => {
|
||||
const queryParams = new URLSearchParams({
|
||||
clientID: connection.clientID,
|
||||
clientSecret: connection.clientSecret,
|
||||
});
|
||||
const res = await fetch(
|
||||
setupLinkToken
|
||||
? `/api/setup/${setupLinkToken}/sso-connection?${queryParams}`
|
||||
: `/api/admin/connections?${queryParams}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
}
|
||||
);
|
||||
|
||||
const response: ApiResponse = await res.json();
|
||||
|
||||
toggleDelConfirm();
|
||||
|
||||
if ('error' in response) {
|
||||
errorToast(response.error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.ok) {
|
||||
await mutate(
|
||||
setupLinkToken
|
||||
? `/api/setup/${setupLinkToken}/connections`
|
||||
: isSettingsView
|
||||
? `/api/admin/connections?isSystemSSO`
|
||||
: '/api/admin/connections'
|
||||
);
|
||||
router.replace(
|
||||
setupLinkToken
|
||||
? `/setup/${setupLinkToken}/sso-connection`
|
||||
: isSettingsView
|
||||
? '/admin/settings/sso-connection'
|
||||
: '/admin/sso-connection'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const connectionType = connectionIsSAML ? 'saml' : connectionIsOIDC ? 'oidc' : null;
|
||||
|
||||
// STATE: FORM
|
||||
const [formObj, setFormObj] = useState<FormObj>(() =>
|
||||
getInitialState(connection, fieldCatalog, connectionType)
|
||||
);
|
||||
// Resync form state on save
|
||||
useEffect(() => {
|
||||
const _state = getInitialState(connection, fieldCatalog, connectionType);
|
||||
setFormObj(_state);
|
||||
}, [connection, fieldCatalog, connectionType]);
|
||||
|
||||
const filteredFieldsByConnection = fieldCatalog.filter(fieldCatalogFilterByConnection(connectionType));
|
||||
|
||||
const activateFallback = (activeKey, fallbackKey) => {
|
||||
setFormObj((cur) => {
|
||||
const temp = { ...cur };
|
||||
delete temp[activeKey];
|
||||
const fallbackItem = fieldCatalog.find(({ key }) => key === fallbackKey);
|
||||
const fallbackItemVal = fallbackItem?.type === 'object' ? {} : '';
|
||||
return { ...temp, [fallbackKey]: fallbackItemVal };
|
||||
});
|
||||
};
|
||||
|
||||
const backUrl = setupLinkToken
|
||||
? `/setup/${setupLinkToken}`
|
||||
: isSettingsView
|
||||
? '/admin/settings/sso-connection'
|
||||
: '/admin/sso-connection';
|
||||
|
||||
const fieldsToHideInSetupView = ['forceAuthn', 'clientID', 'clientSecret', 'idpCertExpiry', 'idpMetadata'];
|
||||
const readOnlyFields = filteredFieldsByConnection
|
||||
.filter((field) => field.attributes.editable === false)
|
||||
.filter(({ attributes: { hideInSetupView } }) => (setupLinkToken ? !hideInSetupView : true))
|
||||
.filter(excludeFallback(formObj))
|
||||
.filter((field) => (setupLinkToken ? !fieldsToHideInSetupView.includes(field.key) : true));
|
||||
const apiUrl = setupLinkToken ? `/api/setup/${setupLinkToken}/sso-connection` : `/api/admin/connections`;
|
||||
const connectionFetchUrl = setupLinkToken
|
||||
? `/api/setup/${setupLinkToken}/sso-connection/${connectionClientId}`
|
||||
: `/api/admin/connections/${connectionClientId}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
@ -189,55 +40,99 @@ const EditConnection = ({ connection, setupLinkToken, isSettingsView = false }:
|
|||
<h2 className='mb-5 mt-5 font-bold text-gray-700 dark:text-white md:text-xl'>
|
||||
{t('edit_sso_connection')}
|
||||
</h2>
|
||||
<ToggleConnectionStatus connection={connection} setupLinkToken={setupLinkToken} />
|
||||
</div>
|
||||
<form onSubmit={save}>
|
||||
<div className='min-w-[28rem] rounded border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800 lg:border-none lg:p-0'>
|
||||
<div className='flex flex-col gap-0 lg:flex-row lg:gap-4'>
|
||||
<div
|
||||
className={`w-full rounded border-gray-200 dark:border-gray-700 lg:border lg:p-3 ${readOnlyFields.length > 0 ? 'lg:w-3/5' : ''}`}>
|
||||
{filteredFieldsByConnection
|
||||
.filter((field) => field.attributes.editable !== false)
|
||||
.filter(({ attributes: { hideInSetupView } }) => (setupLinkToken ? !hideInSetupView : true))
|
||||
.filter(excludeFallback(formObj))
|
||||
.filter((field) => (setupLinkToken ? !fieldsToHideInSetupView.includes(field.key) : true))
|
||||
.map(renderFieldList({ isEditView: true, formObj, setFormObj, activateFallback }))}
|
||||
</div>
|
||||
{readOnlyFields.length > 0 && (
|
||||
<div className='w-full rounded border-gray-200 dark:border-gray-700 lg:w-2/5 lg:border lg:p-3'>
|
||||
{readOnlyFields.map(
|
||||
renderFieldList({ isEditView: true, formObj, setFormObj, activateFallback })
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex w-full lg:mt-6'>
|
||||
<ButtonPrimary type='submit'>{t('save_changes')}</ButtonPrimary>
|
||||
</div>
|
||||
</div>
|
||||
{connection?.clientID && connection.clientSecret && (
|
||||
<section className='mt-10 flex items-center rounded bg-red-100 p-6 text-red-900'>
|
||||
<div className='flex-1'>
|
||||
<h6 className='mb-1 font-medium'>{t('delete_this_connection')}</h6>
|
||||
<p className='font-light'>{t('all_your_apps_using_this_connection_will_stop_working')}</p>
|
||||
</div>
|
||||
<ButtonDanger
|
||||
type='button'
|
||||
onClick={toggleDelConfirm}
|
||||
data-modal-toggle='popup-modal'
|
||||
data-testid='delete-connection'>
|
||||
{t('delete')}
|
||||
</ButtonDanger>
|
||||
</section>
|
||||
<div className='min-w-[28rem] rounded border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800'>
|
||||
{connectionIsSAML && (
|
||||
<EditSAMLConnection
|
||||
displayHeader={false}
|
||||
displayIdpMetadata={true}
|
||||
displayInfo={setupLinkToken ? false : true}
|
||||
excludeFields={
|
||||
setupLinkToken
|
||||
? [
|
||||
'name',
|
||||
'tenant',
|
||||
'description',
|
||||
'defaultRedirectUrl',
|
||||
'redirectUrl',
|
||||
'product',
|
||||
'label',
|
||||
'sortOrder',
|
||||
]
|
||||
: ['label']
|
||||
}
|
||||
classNames={BOXYHQ_UI_CSS}
|
||||
variant='advanced'
|
||||
urls={{
|
||||
delete: apiUrl,
|
||||
patch: apiUrl,
|
||||
get: connectionFetchUrl,
|
||||
}}
|
||||
successCallback={({ operation }) => {
|
||||
operation === 'UPDATE'
|
||||
? successToast(t('saved'))
|
||||
: operation === 'DELETE'
|
||||
? successToast(t('sso_connection_deleted_successfully'))
|
||||
: successToast(t('copied'));
|
||||
if (operation !== 'COPY') {
|
||||
router.replace(
|
||||
setupLinkToken
|
||||
? `/setup/${setupLinkToken}/sso-connection`
|
||||
: isSettingsView
|
||||
? '/admin/settings/sso-connection'
|
||||
: '/admin/sso-connection'
|
||||
);
|
||||
}
|
||||
}}
|
||||
errorCallback={(errMessage) => errorToast(errMessage)}
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
<ConfirmationModal
|
||||
title={t('delete_the_connection')}
|
||||
description={t('confirmation_modal_description')}
|
||||
visible={delModalVisible}
|
||||
onConfirm={deleteConnection}
|
||||
onCancel={toggleDelConfirm}
|
||||
/>
|
||||
{connectionIsOIDC && (
|
||||
<EditOIDCConnection
|
||||
displayHeader={false}
|
||||
displayInfo={setupLinkToken ? false : true}
|
||||
variant='advanced'
|
||||
excludeFields={
|
||||
setupLinkToken
|
||||
? [
|
||||
'name',
|
||||
'tenant',
|
||||
'description',
|
||||
'defaultRedirectUrl',
|
||||
'redirectUrl',
|
||||
'product',
|
||||
'oidcClientId',
|
||||
'label',
|
||||
'sortOrder',
|
||||
]
|
||||
: ['label']
|
||||
}
|
||||
classNames={BOXYHQ_UI_CSS}
|
||||
urls={{
|
||||
delete: apiUrl,
|
||||
patch: apiUrl,
|
||||
get: connectionFetchUrl,
|
||||
}}
|
||||
successCallback={({ operation }) => {
|
||||
operation === 'UPDATE'
|
||||
? successToast(t('saved'))
|
||||
: operation === 'DELETE'
|
||||
? successToast(t('sso_connection_deleted_successfully'))
|
||||
: successToast(t('copied'));
|
||||
if (operation !== 'COPY') {
|
||||
router.replace(
|
||||
setupLinkToken
|
||||
? `/setup/${setupLinkToken}/sso-connection`
|
||||
: isSettingsView
|
||||
? '/admin/settings/sso-connection'
|
||||
: '/admin/sso-connection'
|
||||
);
|
||||
}
|
||||
}}
|
||||
errorCallback={(errMessage) => errorToast(errMessage)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -1,73 +0,0 @@
|
|||
import type { OIDCSSORecord, SAMLSSORecord } from '@boxyhq/saml-jackson';
|
||||
import { errorToast, successToast } from '@components/Toaster';
|
||||
import { FC, useEffect, useState } from 'react';
|
||||
import type { ApiResponse } from 'types';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { ConnectionToggle } from '@components/ConnectionToggle';
|
||||
|
||||
interface Props {
|
||||
connection: SAMLSSORecord | OIDCSSORecord;
|
||||
setupLinkToken?: string;
|
||||
}
|
||||
|
||||
export const ToggleConnectionStatus: FC<Props> = (props) => {
|
||||
const { connection, setupLinkToken } = props;
|
||||
|
||||
const { t } = useTranslation('common');
|
||||
const [active, setActive] = useState(!connection.deactivated);
|
||||
|
||||
useEffect(() => {
|
||||
setActive(!connection.deactivated);
|
||||
}, [connection]);
|
||||
|
||||
const updateConnectionStatus = async (active: boolean) => {
|
||||
setActive(active);
|
||||
|
||||
const body = {
|
||||
clientID: connection?.clientID,
|
||||
clientSecret: connection?.clientSecret,
|
||||
tenant: connection?.tenant,
|
||||
product: connection?.product,
|
||||
deactivated: !active,
|
||||
};
|
||||
|
||||
if ('idpMetadata' in connection) {
|
||||
body['isSAML'] = true;
|
||||
} else {
|
||||
body['isOIDC'] = true;
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
setupLinkToken ? `/api/setup/${setupLinkToken}/sso-connection` : '/api/admin/connections',
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const response: ApiResponse = await res.json();
|
||||
|
||||
if ('error' in response) {
|
||||
errorToast(response.error.message);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (body.deactivated) {
|
||||
successToast(t('connection_deactivated'));
|
||||
} else {
|
||||
successToast(t('connection_activated'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ConnectionToggle connection={{ active, type: 'sso' }} onChange={updateConnectionStatus} />
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -1,272 +0,0 @@
|
|||
/**
|
||||
* Edit view will have extra fields to render parsed metadata and other attributes.
|
||||
* All fields are editable unless they have `editable` set to false.
|
||||
* All fields are required unless they have `required` set to false.
|
||||
* `accessor` - only used to set initial state and retrieve saved value. Useful when key is different from retrieved payload.
|
||||
* `fallback` - use this key to activate a fallback catalog item that will take in the values. The fallback will be activated
|
||||
* by means of a switch control in the UI that allows us to deactivate the fallback catalog item and revert to the main field.
|
||||
*/
|
||||
|
||||
import type { FieldCatalogItem } from './utils';
|
||||
|
||||
export const getCommonFields = ({
|
||||
isEditView,
|
||||
isSettingsView,
|
||||
}: {
|
||||
isEditView?: boolean;
|
||||
isSettingsView?: boolean;
|
||||
}): FieldCatalogItem[] => [
|
||||
{
|
||||
key: 'name',
|
||||
label: 'Name',
|
||||
type: 'text',
|
||||
placeholder: 'MyApp',
|
||||
attributes: { required: false, hideInSetupView: true, 'data-testid': 'name' },
|
||||
},
|
||||
{
|
||||
key: 'description',
|
||||
label: 'Description',
|
||||
type: 'text',
|
||||
placeholder: 'A short description not more than 100 characters',
|
||||
attributes: { maxLength: 100, required: false, hideInSetupView: true },
|
||||
},
|
||||
{
|
||||
key: 'tenant',
|
||||
label: 'Tenant',
|
||||
type: 'text',
|
||||
placeholder: 'acme.com',
|
||||
attributes: isEditView
|
||||
? {
|
||||
editable: false,
|
||||
hideInSetupView: true,
|
||||
}
|
||||
: {
|
||||
editable: !isSettingsView,
|
||||
hideInSetupView: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'product',
|
||||
label: 'Product',
|
||||
type: 'text',
|
||||
placeholder: 'demo',
|
||||
attributes: isEditView
|
||||
? {
|
||||
editable: false,
|
||||
hideInSetupView: true,
|
||||
}
|
||||
: {
|
||||
editable: !isSettingsView,
|
||||
hideInSetupView: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'redirectUrl',
|
||||
label: 'Allowed redirect URLs (newline separated)',
|
||||
type: 'textarea',
|
||||
placeholder: 'http://localhost:3366',
|
||||
attributes: { isArray: true, rows: 3, hideInSetupView: true, editable: !isSettingsView },
|
||||
},
|
||||
{
|
||||
key: 'defaultRedirectUrl',
|
||||
label: 'Default redirect URL',
|
||||
type: 'url',
|
||||
placeholder: 'http://localhost:3366/login/saml',
|
||||
attributes: { hideInSetupView: true, editable: !isSettingsView },
|
||||
},
|
||||
{
|
||||
key: 'oidcClientId',
|
||||
label: 'Client ID [OIDC Provider]',
|
||||
type: 'text',
|
||||
placeholder: '',
|
||||
attributes: {
|
||||
'data-testid': 'oidcClientId',
|
||||
connection: 'oidc',
|
||||
accessor: (o) => o?.oidcProvider?.clientId,
|
||||
hideInSetupView: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'oidcClientSecret',
|
||||
label: 'Client Secret [OIDC Provider]',
|
||||
type: 'password',
|
||||
placeholder: '',
|
||||
attributes: {
|
||||
'data-testid': 'oidcClientSecret',
|
||||
connection: 'oidc',
|
||||
accessor: (o) => o?.oidcProvider?.clientSecret,
|
||||
hideInSetupView: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'oidcDiscoveryUrl',
|
||||
label: 'Well-known URL of OpenID Provider',
|
||||
type: 'url',
|
||||
placeholder: 'https://example.com/.well-known/openid-configuration',
|
||||
attributes: {
|
||||
'data-testid': 'oidcDiscoveryUrl',
|
||||
connection: 'oidc',
|
||||
accessor: (o) => o?.oidcProvider?.discoveryUrl,
|
||||
hideInSetupView: false,
|
||||
},
|
||||
fallback: {
|
||||
key: 'oidcMetadata',
|
||||
activateCondition: (fieldValue) => !fieldValue,
|
||||
switch: {
|
||||
label: 'Missing the discovery URL? Click here to set the individual attributes',
|
||||
'data-testid': 'oidcDiscoveryUrl-fallback-switch',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'oidcMetadata',
|
||||
type: 'object',
|
||||
members: [
|
||||
{
|
||||
key: 'issuer',
|
||||
label: 'Issuer',
|
||||
type: 'url',
|
||||
attributes: {
|
||||
accessor: (o) => o?.oidcProvider?.metadata?.issuer,
|
||||
hideInSetupView: false,
|
||||
'data-testid': 'issuer',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'authorization_endpoint',
|
||||
label: 'Authorization Endpoint',
|
||||
type: 'url',
|
||||
attributes: {
|
||||
accessor: (o) => o?.oidcProvider?.metadata?.authorization_endpoint,
|
||||
hideInSetupView: false,
|
||||
'data-testid': 'authorization_endpoint',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'token_endpoint',
|
||||
label: 'Token endpoint',
|
||||
type: 'url',
|
||||
attributes: {
|
||||
accessor: (o) => o?.oidcProvider?.metadata?.token_endpoint,
|
||||
hideInSetupView: false,
|
||||
'data-testid': 'token_endpoint',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'jwks_uri',
|
||||
label: 'JWKS URI',
|
||||
type: 'url',
|
||||
attributes: {
|
||||
accessor: (o) => o?.oidcProvider?.metadata?.jwks_uri,
|
||||
hideInSetupView: false,
|
||||
'data-testid': 'jwks_uri',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'userinfo_endpoint',
|
||||
label: 'UserInfo endpoint',
|
||||
type: 'url',
|
||||
attributes: {
|
||||
accessor: (o) => o?.oidcProvider?.metadata?.userinfo_endpoint,
|
||||
hideInSetupView: false,
|
||||
'data-testid': 'userinfo_endpoint',
|
||||
},
|
||||
},
|
||||
],
|
||||
attributes: { connection: 'oidc', hideInSetupView: false },
|
||||
fallback: {
|
||||
key: 'oidcDiscoveryUrl',
|
||||
switch: {
|
||||
label: 'Have a discovery URL? Click here to set it',
|
||||
'data-testid': 'oidcMetadata-fallback-switch',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'rawMetadata',
|
||||
label: `Raw IdP XML ${isEditView ? '(fully replaces the current one)' : ''}`,
|
||||
type: 'textarea',
|
||||
placeholder: 'Paste the raw XML here',
|
||||
attributes: {
|
||||
rows: 5,
|
||||
required: false,
|
||||
connection: 'saml',
|
||||
hideInSetupView: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'metadataUrl',
|
||||
label: `Metadata URL ${isEditView ? '(fully replaces the current one)' : ''}`,
|
||||
type: 'url',
|
||||
placeholder: 'Paste the Metadata URL here',
|
||||
attributes: {
|
||||
required: false,
|
||||
connection: 'saml',
|
||||
hideInSetupView: false,
|
||||
'data-testid': 'metadataUrl',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'sortOrder',
|
||||
label: 'Sort Order',
|
||||
type: 'number',
|
||||
placeholder: '10',
|
||||
attributes: {
|
||||
required: false,
|
||||
hideInSetupView: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'forceAuthn',
|
||||
label: 'Force Authentication',
|
||||
type: 'checkbox',
|
||||
attributes: { required: false, connection: 'saml', hideInSetupView: false },
|
||||
},
|
||||
];
|
||||
|
||||
export const EditViewOnlyFields: FieldCatalogItem[] = [
|
||||
{
|
||||
key: 'idpMetadata',
|
||||
label: 'IdP Metadata',
|
||||
type: 'pre',
|
||||
attributes: {
|
||||
isArray: false,
|
||||
rows: 10,
|
||||
editable: false,
|
||||
connection: 'saml',
|
||||
hideInSetupView: false,
|
||||
formatForDisplay: (value) => {
|
||||
const obj = JSON.parse(JSON.stringify(value));
|
||||
delete obj.validTo;
|
||||
return JSON.stringify(obj, null, 2);
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'idpCertExpiry',
|
||||
label: 'IdP Certificate Validity',
|
||||
type: 'pre',
|
||||
attributes: {
|
||||
isHidden: (value): boolean => !value || new Date(value).toString() == 'Invalid Date',
|
||||
rows: 10,
|
||||
editable: false,
|
||||
connection: 'saml',
|
||||
hideInSetupView: false,
|
||||
accessor: (o) => o?.idpMetadata?.validTo,
|
||||
showWarning: (value) => new Date(value) < new Date(),
|
||||
formatForDisplay: (value) => new Date(value).toString(),
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'clientID',
|
||||
label: 'Client ID',
|
||||
type: 'text',
|
||||
attributes: { editable: false, hideInSetupView: false },
|
||||
},
|
||||
{
|
||||
key: 'clientSecret',
|
||||
label: 'Client Secret',
|
||||
type: 'password',
|
||||
attributes: { editable: false, hideInSetupView: false },
|
||||
},
|
||||
];
|
|
@ -1,388 +0,0 @@
|
|||
import { ButtonLink } from '@components/ButtonLink';
|
||||
import { Dispatch, FormEvent, SetStateAction, useMemo, useState } from 'react';
|
||||
import { EditViewOnlyFields, getCommonFields } from './fieldCatalog';
|
||||
import { CopyToClipboardButton } from '@components/ClipboardButton';
|
||||
import EyeIcon from '@heroicons/react/24/outline/EyeIcon';
|
||||
import EyeSlashIcon from '@heroicons/react/24/outline/EyeSlashIcon';
|
||||
import { IconButton } from '@components/IconButton';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
export const saveConnection = async ({
|
||||
formObj,
|
||||
isEditView,
|
||||
connectionIsSAML,
|
||||
connectionIsOIDC,
|
||||
setupLinkToken,
|
||||
callback,
|
||||
}: {
|
||||
formObj: FormObj;
|
||||
isEditView?: boolean;
|
||||
connectionIsSAML: boolean;
|
||||
connectionIsOIDC: boolean;
|
||||
setupLinkToken?: string;
|
||||
callback: (res: Response) => Promise<void>;
|
||||
}) => {
|
||||
const {
|
||||
rawMetadata,
|
||||
redirectUrl,
|
||||
oidcDiscoveryUrl,
|
||||
oidcMetadata,
|
||||
oidcClientId,
|
||||
oidcClientSecret,
|
||||
metadataUrl,
|
||||
...rest
|
||||
} = formObj;
|
||||
|
||||
const encodedRawMetadata = btoa((rawMetadata as string) || '');
|
||||
const redirectUrlList = (redirectUrl as string)?.split(/\r\n|\r|\n/);
|
||||
|
||||
const res = await fetch(
|
||||
setupLinkToken ? `/api/setup/${setupLinkToken}/sso-connection` : '/api/admin/connections',
|
||||
{
|
||||
method: isEditView ? 'PATCH' : 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...rest,
|
||||
encodedRawMetadata: connectionIsSAML ? encodedRawMetadata : undefined,
|
||||
oidcDiscoveryUrl: connectionIsOIDC ? oidcDiscoveryUrl : undefined,
|
||||
oidcMetadata: connectionIsOIDC ? oidcMetadata : undefined,
|
||||
oidcClientId: connectionIsOIDC ? oidcClientId : undefined,
|
||||
oidcClientSecret: connectionIsOIDC ? oidcClientSecret : undefined,
|
||||
redirectUrl: redirectUrl && redirectUrlList ? JSON.stringify(redirectUrlList) : undefined,
|
||||
metadataUrl: connectionIsSAML ? metadataUrl : undefined,
|
||||
}),
|
||||
}
|
||||
);
|
||||
callback(res);
|
||||
};
|
||||
|
||||
export function fieldCatalogFilterByConnection(connection) {
|
||||
return ({ attributes }) =>
|
||||
attributes.connection && connection !== null ? attributes.connection === connection : true;
|
||||
}
|
||||
|
||||
/** If a field item has a fallback attribute, only render it if the form state has the field item */
|
||||
export function excludeFallback(formObj: FormObj) {
|
||||
return ({ key, fallback }: FieldCatalogItem) => {
|
||||
if (typeof fallback === 'object') {
|
||||
if (!(key in formObj)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
function getHandleChange(
|
||||
setFormObj: Dispatch<SetStateAction<FormObj>>,
|
||||
opts: { key?: string; formObjParentKey?: string } = {}
|
||||
) {
|
||||
return (event: FormEvent) => {
|
||||
const target = event.target as HTMLInputElement | HTMLTextAreaElement;
|
||||
setFormObj((cur) =>
|
||||
opts.formObjParentKey
|
||||
? {
|
||||
...cur,
|
||||
[opts.formObjParentKey]: {
|
||||
...(cur[opts.formObjParentKey] as FormObj),
|
||||
[target.id]: target[opts.key || 'value'],
|
||||
},
|
||||
}
|
||||
: { ...cur, [target.id]: target[opts.key || 'value'] }
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
type fieldAttributes = {
|
||||
required?: boolean;
|
||||
maxLength?: number;
|
||||
editable?: boolean;
|
||||
isArray?: boolean;
|
||||
rows?: number;
|
||||
accessor?: (any) => unknown;
|
||||
formatForDisplay?: (value) => string;
|
||||
isHidden?: (value) => boolean;
|
||||
showWarning?: (value) => boolean;
|
||||
hideInSetupView: boolean;
|
||||
connection?: string;
|
||||
'data-testid'?: string;
|
||||
};
|
||||
|
||||
export type FieldCatalogItem = {
|
||||
key: string;
|
||||
label?: string;
|
||||
type: 'url' | 'object' | 'pre' | 'text' | 'password' | 'textarea' | 'checkbox' | 'number';
|
||||
placeholder?: string;
|
||||
attributes: fieldAttributes;
|
||||
members?: FieldCatalogItem[];
|
||||
fallback?: {
|
||||
key: string;
|
||||
activateCondition?: (fieldValue) => boolean;
|
||||
switch: { label: string; 'data-testid'?: string };
|
||||
};
|
||||
};
|
||||
|
||||
export type AdminPortalSSODefaults = {
|
||||
tenant: string;
|
||||
product: string;
|
||||
redirectUrl: string;
|
||||
defaultRedirectUrl: string;
|
||||
};
|
||||
|
||||
type FormObjValues = string | boolean | string[];
|
||||
|
||||
export type FormObj = Record<string, FormObjValues | Record<string, FormObjValues>>;
|
||||
|
||||
export const useFieldCatalog = ({
|
||||
isEditView,
|
||||
isSettingsView,
|
||||
}: {
|
||||
isEditView?: boolean;
|
||||
isSettingsView?: boolean;
|
||||
}) => {
|
||||
const fieldCatalog = useMemo(() => {
|
||||
if (isEditView) {
|
||||
return [...getCommonFields({ isEditView: true, isSettingsView }), ...EditViewOnlyFields];
|
||||
}
|
||||
return [...getCommonFields({ isSettingsView })];
|
||||
}, [isEditView, isSettingsView]);
|
||||
return fieldCatalog;
|
||||
};
|
||||
|
||||
function SecretInputFormControl({
|
||||
label,
|
||||
value,
|
||||
isHiddenClassName,
|
||||
id,
|
||||
placeholder,
|
||||
required,
|
||||
maxLength,
|
||||
readOnly,
|
||||
args,
|
||||
dataTestId,
|
||||
}) {
|
||||
const { t } = useTranslation('common');
|
||||
const [isSecretShown, setisSecretShown] = useState(false);
|
||||
return (
|
||||
<div className='mb-6'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<label
|
||||
htmlFor={id}
|
||||
className={'mb-2 block text-sm font-medium text-gray-900 dark:text-gray-300' + isHiddenClassName}>
|
||||
{label}
|
||||
</label>
|
||||
<div className='flex'>
|
||||
<IconButton
|
||||
tooltip={isSecretShown ? t('hide_secret') : t('show_secret')}
|
||||
Icon={isSecretShown ? EyeSlashIcon : EyeIcon}
|
||||
className='hover:text-primary mr-2'
|
||||
onClick={() => {
|
||||
setisSecretShown(!isSecretShown);
|
||||
}}
|
||||
/>
|
||||
<CopyToClipboardButton text={value} />
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
id={id}
|
||||
type={isSecretShown ? 'text' : 'password'}
|
||||
placeholder={placeholder}
|
||||
value={(value as string) || ''}
|
||||
required={required}
|
||||
readOnly={readOnly}
|
||||
maxLength={maxLength}
|
||||
onChange={getHandleChange(args.setFormObj, { formObjParentKey: args.formObjParentKey })}
|
||||
className={'input-bordered input w-full' + isHiddenClassName + (readOnly ? ' bg-gray-50' : '')}
|
||||
data-testid={dataTestId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function renderFieldList(args: {
|
||||
isEditView?: boolean;
|
||||
formObj: FormObj;
|
||||
setFormObj: Dispatch<SetStateAction<FormObj>>;
|
||||
formObjParentKey?: string;
|
||||
activateFallback: (activeKey, fallbackKey) => void;
|
||||
}) {
|
||||
const FieldList = ({
|
||||
key,
|
||||
placeholder,
|
||||
label,
|
||||
type,
|
||||
members,
|
||||
attributes: {
|
||||
isHidden,
|
||||
isArray,
|
||||
rows,
|
||||
formatForDisplay,
|
||||
editable,
|
||||
maxLength,
|
||||
showWarning,
|
||||
required = true,
|
||||
'data-testid': dataTestId,
|
||||
},
|
||||
fallback,
|
||||
}: FieldCatalogItem) => {
|
||||
const readOnly = editable === false;
|
||||
const value =
|
||||
readOnly && typeof formatForDisplay === 'function'
|
||||
? formatForDisplay(
|
||||
args.formObjParentKey ? args.formObj[args.formObjParentKey]?.[key] : args.formObj[key]
|
||||
)
|
||||
: args.formObjParentKey
|
||||
? args.formObj[args.formObjParentKey]?.[key]
|
||||
: args.formObj[key];
|
||||
|
||||
if (type === 'object') {
|
||||
return (
|
||||
<div key={key}>
|
||||
{typeof fallback === 'object' &&
|
||||
(typeof fallback.activateCondition === 'function' ? fallback.activateCondition(value) : true) && (
|
||||
<ButtonLink
|
||||
className='mb-2 px-0'
|
||||
type='button'
|
||||
data-testid={fallback.switch['data-testid']}
|
||||
onClick={() => {
|
||||
/** Switch to fallback.key*/
|
||||
args.activateFallback(key, fallback.key);
|
||||
}}>
|
||||
{fallback.switch.label}
|
||||
</ButtonLink>
|
||||
)}
|
||||
{members?.map(
|
||||
renderFieldList({
|
||||
...args,
|
||||
formObjParentKey: key,
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isHiddenClassName =
|
||||
typeof isHidden === 'function' && isHidden(args.formObj[key]) == true ? ' hidden' : '';
|
||||
|
||||
if (type === 'password') {
|
||||
return (
|
||||
<SecretInputFormControl
|
||||
key={key}
|
||||
label={label}
|
||||
value={value}
|
||||
isHiddenClassName={isHiddenClassName}
|
||||
id={key}
|
||||
placeholder={placeholder}
|
||||
required={required}
|
||||
maxLength={maxLength}
|
||||
readOnly={readOnly}
|
||||
args={args}
|
||||
dataTestId={dataTestId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='mb-6 ' key={key}>
|
||||
{type !== 'checkbox' && (
|
||||
<div className='flex items-center justify-between'>
|
||||
<label
|
||||
htmlFor={key}
|
||||
className={
|
||||
'mb-2 block text-sm font-medium text-gray-900 dark:text-gray-300' + isHiddenClassName
|
||||
}>
|
||||
{label}
|
||||
</label>
|
||||
{typeof fallback === 'object' &&
|
||||
(typeof fallback.activateCondition === 'function'
|
||||
? fallback.activateCondition(value)
|
||||
: true) && (
|
||||
<ButtonLink
|
||||
className='mb-2 px-0'
|
||||
type='button'
|
||||
data-testid={fallback.switch['data-testid']}
|
||||
onClick={() => {
|
||||
/** Switch to fallback.key*/
|
||||
args.activateFallback(key, fallback.key);
|
||||
}}>
|
||||
{fallback.switch.label}
|
||||
</ButtonLink>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{type === 'pre' ? (
|
||||
<pre
|
||||
className={
|
||||
'block w-full overflow-auto rounded-lg border border-gray-300 bg-gray-50 p-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-blue-500 dark:focus:ring-blue-500' +
|
||||
isHiddenClassName +
|
||||
(typeof showWarning === 'function' && showWarning(args.formObj[key])
|
||||
? ' border-2 border-rose-500'
|
||||
: '')
|
||||
}
|
||||
data-testid={dataTestId}>
|
||||
{value}
|
||||
</pre>
|
||||
) : type === 'textarea' ? (
|
||||
<textarea
|
||||
id={key}
|
||||
placeholder={placeholder}
|
||||
value={(value as string) || ''}
|
||||
required={required}
|
||||
readOnly={readOnly}
|
||||
maxLength={maxLength}
|
||||
onChange={getHandleChange(args.setFormObj, { formObjParentKey: args.formObjParentKey })}
|
||||
className={
|
||||
'textarea-bordered textarea h-24 w-full' +
|
||||
(isArray ? ' whitespace-pre' : '') +
|
||||
isHiddenClassName +
|
||||
(readOnly ? ' bg-gray-50' : '')
|
||||
}
|
||||
rows={rows}
|
||||
data-testid={dataTestId}
|
||||
/>
|
||||
) : type === 'checkbox' ? (
|
||||
<>
|
||||
<label
|
||||
htmlFor={key}
|
||||
className={
|
||||
'inline-block align-middle text-sm font-medium text-gray-900 dark:text-gray-300' +
|
||||
isHiddenClassName
|
||||
}>
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
id={key}
|
||||
type={type}
|
||||
checked={!!value}
|
||||
required={required}
|
||||
readOnly={readOnly}
|
||||
maxLength={maxLength}
|
||||
onChange={getHandleChange(args.setFormObj, {
|
||||
key: 'checked',
|
||||
formObjParentKey: args.formObjParentKey,
|
||||
})}
|
||||
className={'checkbox-primary checkbox ml-5 align-middle' + isHiddenClassName}
|
||||
data-testid={dataTestId}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<input
|
||||
id={key}
|
||||
type={type}
|
||||
placeholder={placeholder}
|
||||
value={(value as string) || ''}
|
||||
required={required}
|
||||
readOnly={readOnly}
|
||||
maxLength={maxLength}
|
||||
onChange={getHandleChange(args.setFormObj, { formObjParentKey: args.formObjParentKey })}
|
||||
className={'input-bordered input w-full' + isHiddenClassName + (readOnly ? ' bg-gray-50' : '')}
|
||||
data-testid={dataTestId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
return FieldList;
|
||||
}
|
|
@ -1,200 +1,60 @@
|
|||
import { useTranslation } from 'next-i18next';
|
||||
import { useRouter } from 'next/router';
|
||||
import React, { useState } from 'react';
|
||||
import { ApiResponse } from 'types';
|
||||
import React from 'react';
|
||||
import { errorToast, successToast } from '@components/Toaster';
|
||||
import type { Directory } from '@boxyhq/saml-jackson';
|
||||
import { LinkBack } from '@components/LinkBack';
|
||||
import { ButtonPrimary } from '@components/ButtonPrimary';
|
||||
import useDirectoryProviders from '@lib/ui/hooks/useDirectoryProviders';
|
||||
import { LinkBack } from '@boxyhq/internal-ui';
|
||||
import { CreateDirectory as CreateDSync } from '@boxyhq/react-ui/dsync';
|
||||
import { BOXYHQ_UI_CSS } from '@components/styles';
|
||||
|
||||
interface CreateDirectoryProps {
|
||||
setupLinkToken?: string;
|
||||
defaultWebhookEndpoint: string | undefined;
|
||||
defaultWebhookEndpoint?: string;
|
||||
defaultWebhookSecret?: string;
|
||||
}
|
||||
|
||||
type UnSavedDirectory = Omit<Directory, 'id' | 'log_webhook_events' | 'scim' | 'deactivated' | 'webhook'> & {
|
||||
webhook_url: string;
|
||||
webhook_secret: string;
|
||||
};
|
||||
|
||||
const defaultDirectory: UnSavedDirectory = {
|
||||
name: '',
|
||||
tenant: '',
|
||||
product: '',
|
||||
webhook_url: '',
|
||||
webhook_secret: '',
|
||||
type: 'azure-scim-v2',
|
||||
google_domain: '',
|
||||
};
|
||||
|
||||
const CreateDirectory = ({ setupLinkToken, defaultWebhookEndpoint }: CreateDirectoryProps) => {
|
||||
const CreateDirectory = ({
|
||||
setupLinkToken,
|
||||
defaultWebhookEndpoint,
|
||||
defaultWebhookSecret,
|
||||
}: CreateDirectoryProps) => {
|
||||
const { t } = useTranslation('common');
|
||||
const router = useRouter();
|
||||
const { providers } = useDirectoryProviders(setupLinkToken);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showDomain, setShowDomain] = useState(false);
|
||||
const [directory, setDirectory] = useState<UnSavedDirectory>({
|
||||
...defaultDirectory,
|
||||
webhook_url: defaultWebhookEndpoint || '',
|
||||
});
|
||||
|
||||
const onSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
setLoading(true);
|
||||
|
||||
const rawResponse = await fetch(
|
||||
setupLinkToken ? `/api/setup/${setupLinkToken}/directory-sync` : '/api/admin/directory-sync',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(directory),
|
||||
}
|
||||
);
|
||||
|
||||
setLoading(false);
|
||||
|
||||
const response: ApiResponse<Directory> = await rawResponse.json();
|
||||
|
||||
if ('error' in response) {
|
||||
errorToast(response.error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (rawResponse.ok) {
|
||||
router.replace(
|
||||
setupLinkToken
|
||||
? `/setup/${setupLinkToken}/directory-sync/${response.data.id}`
|
||||
: `/admin/directory-sync/${response.data.id}`
|
||||
);
|
||||
successToast(t('directory_created_successfully'));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const onChange = (event: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
|
||||
setDirectory({
|
||||
...directory,
|
||||
[target.id]: target.value,
|
||||
});
|
||||
|
||||
// Ask for domain if google is selected
|
||||
if (target.id === 'type') {
|
||||
target.value === 'google' ? setShowDomain(true) : setShowDomain(false);
|
||||
}
|
||||
};
|
||||
|
||||
const backUrl = setupLinkToken ? `/setup/${setupLinkToken}/directory-sync` : '/admin/directory-sync';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<LinkBack href={backUrl} />
|
||||
<h2 className='mb-5 mt-5 font-bold text-gray-700 md:text-xl'>{t('new_directory')}</h2>
|
||||
<h2 className='mb-5 mt-5 font-bold text-gray-700 md:text-xl'>{t('create_dsync_connection')}</h2>
|
||||
<div className='min-w-[28rem] rounded border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800'>
|
||||
<form onSubmit={onSubmit}>
|
||||
<div className='flex flex-col space-y-3'>
|
||||
{!setupLinkToken && (
|
||||
<div className='form-control w-full'>
|
||||
<label className='label'>
|
||||
<span className='label-text'>{t('directory_name')}</span>
|
||||
</label>
|
||||
<input type='text' id='name' className='input-bordered input w-full' onChange={onChange} />
|
||||
</div>
|
||||
)}
|
||||
<div className='form-control w-full'>
|
||||
<label className='label'>
|
||||
<span className='label-text'>{t('directory_provider')}</span>
|
||||
</label>
|
||||
<select className='select-bordered select w-full' id='type' onChange={onChange} required>
|
||||
{providers &&
|
||||
Object.keys(providers).map((key) => {
|
||||
return (
|
||||
<option key={key} value={key}>
|
||||
{providers[key]}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
{showDomain && (
|
||||
<div className='form-control w-full'>
|
||||
<label className='label'>
|
||||
<span className='label-text'>{t('directory_domain')}</span>
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
id='google_domain'
|
||||
className='input-bordered input w-full'
|
||||
onChange={onChange}
|
||||
value={directory.google_domain}
|
||||
pattern='^[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)*\.[a-zA-Z]{2,}$'
|
||||
title='Please enter a valid domain (e.g: boxyhq.com)'
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!setupLinkToken && (
|
||||
<>
|
||||
<div className='form-control w-full'>
|
||||
<label className='label'>
|
||||
<span className='label-text'>{t('tenant')}</span>
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
id='tenant'
|
||||
className='input-bordered input w-full'
|
||||
required
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
<div className='form-control w-full'>
|
||||
<label className='label'>
|
||||
<span className='label-text'>{t('product')}</span>
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
id='product'
|
||||
className='input-bordered input w-full'
|
||||
required
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
<div className='form-control w-full'>
|
||||
<label className='label'>
|
||||
<span className='label-text'>{t('webhook_url')}</span>
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
id='webhook_url'
|
||||
className='input-bordered input w-full'
|
||||
onChange={onChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className='form-control w-full'>
|
||||
<label className='label'>
|
||||
<span className='label-text'>{t('webhook_secret')}</span>
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
id='webhook_secret'
|
||||
className='input-bordered input w-full'
|
||||
onChange={onChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className='flex justify-end'>
|
||||
<ButtonPrimary loading={loading}>{t('create_directory')}</ButtonPrimary>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<CreateDSync
|
||||
displayHeader={false}
|
||||
defaultWebhookEndpoint={defaultWebhookEndpoint}
|
||||
defaultWebhookSecret={defaultWebhookSecret}
|
||||
classNames={BOXYHQ_UI_CSS}
|
||||
successCallback={({ connection }) => {
|
||||
successToast(t('directory_created_successfully'));
|
||||
connection?.id &&
|
||||
router.replace(
|
||||
setupLinkToken
|
||||
? `/setup/${setupLinkToken}/directory-sync/${connection.id}`
|
||||
: `/admin/directory-sync/${connection.id}`
|
||||
);
|
||||
}}
|
||||
errorCallback={(errorMessage) => {
|
||||
errorToast(errorMessage);
|
||||
}}
|
||||
excludeFields={
|
||||
setupLinkToken
|
||||
? ['name', 'tenant', 'product', 'webhook_url', 'webhook_secret', 'log_webhook_events']
|
||||
: undefined
|
||||
}
|
||||
urls={{
|
||||
post: setupLinkToken
|
||||
? `/api/setup/${setupLinkToken}/directory-sync`
|
||||
: '/api/admin/directory-sync',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -1,74 +0,0 @@
|
|||
import { useRouter } from 'next/router';
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import type { Directory } from '@boxyhq/saml-jackson';
|
||||
import type { ApiResponse } from 'types';
|
||||
import { errorToast, successToast } from '@components/Toaster';
|
||||
import ConfirmationModal from '@components/ConfirmationModal';
|
||||
import { ButtonDanger } from '@components/ButtonDanger';
|
||||
|
||||
export const DeleteDirectory = ({
|
||||
directoryId,
|
||||
setupLinkToken,
|
||||
}: {
|
||||
directoryId: Directory['id'];
|
||||
setupLinkToken?: string;
|
||||
}) => {
|
||||
const { t } = useTranslation('common');
|
||||
const router = useRouter();
|
||||
const [delModalVisible, setDelModalVisible] = useState(false);
|
||||
|
||||
const deleteDirectory = async () => {
|
||||
const deleteUrl = setupLinkToken
|
||||
? `/api/setup/${setupLinkToken}/directory-sync/${directoryId}`
|
||||
: `/api/admin/directory-sync/${directoryId}`;
|
||||
|
||||
const rawResponse = await fetch(deleteUrl, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
const response: ApiResponse<unknown> = await rawResponse.json();
|
||||
|
||||
if ('error' in response) {
|
||||
errorToast(response.error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if ('data' in response) {
|
||||
const redirectUrl = setupLinkToken
|
||||
? `/setup/${setupLinkToken}/directory-sync`
|
||||
: '/admin/directory-sync';
|
||||
|
||||
successToast(t('directory_connection_deleted_successfully'));
|
||||
router.replace(redirectUrl);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className='mt-5 flex items-center rounded bg-red-100 p-6 text-red-900'>
|
||||
<div className='flex-1'>
|
||||
<h6 className='mb-1 font-medium'>{t('delete_this_directory')}</h6>
|
||||
<p className='font-light'>{t('delete_this_directory_desc')}</p>
|
||||
</div>
|
||||
<ButtonDanger
|
||||
type='button'
|
||||
data-modal-toggle='popup-modal'
|
||||
onClick={() => {
|
||||
setDelModalVisible(true);
|
||||
}}>
|
||||
{t('delete')}
|
||||
</ButtonDanger>
|
||||
</section>
|
||||
<ConfirmationModal
|
||||
title={t('delete_this_directory')}
|
||||
description={t('delete_this_directory_desc')}
|
||||
visible={delModalVisible}
|
||||
onConfirm={deleteDirectory}
|
||||
onCancel={() => {
|
||||
setDelModalVisible(false);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -1,9 +1,8 @@
|
|||
import LinkIcon from '@heroicons/react/24/outline/LinkIcon';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { LinkPrimary } from '@components/LinkPrimary';
|
||||
import { useRouter } from 'next/router';
|
||||
import { DirectoryList } from '@boxyhq/react-ui/dsync';
|
||||
import { pageLimit } from '@boxyhq/internal-ui';
|
||||
import { pageLimit, LinkPrimary } from '@boxyhq/internal-ui';
|
||||
|
||||
const DSyncDirectoryList = ({ setupLinkToken }: { setupLinkToken?: string }) => {
|
||||
const { t } = useTranslation('common');
|
||||
|
@ -24,7 +23,7 @@ const DSyncDirectoryList = ({ setupLinkToken }: { setupLinkToken?: string }) =>
|
|||
<div className='flex gap-2'>
|
||||
{!setupLinkToken && (
|
||||
<LinkPrimary Icon={LinkIcon} href='/admin/directory-sync/setup-link/new'>
|
||||
{t('new_setup_link')}
|
||||
{t('bui-sl-new-link')}
|
||||
</LinkPrimary>
|
||||
)}
|
||||
<LinkPrimary href={createDirectoryUrl}>{t('new_directory')}</LinkPrimary>
|
||||
|
|
|
@ -1,50 +1,18 @@
|
|||
import { useRouter } from 'next/router';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import type { Directory } from '@boxyhq/saml-jackson';
|
||||
import type { ApiResponse } from 'types';
|
||||
import { errorToast, successToast } from '@components/Toaster';
|
||||
import { LinkBack } from '@components/LinkBack';
|
||||
import { ButtonPrimary } from '@components/ButtonPrimary';
|
||||
import Loading from '@components/Loading';
|
||||
import { LinkBack, Loading } from '@boxyhq/internal-ui';
|
||||
import useDirectory from '@lib/ui/hooks/useDirectory';
|
||||
import { ToggleConnectionStatus } from './ToggleConnectionStatus';
|
||||
import { DeleteDirectory } from './DeleteDirectory';
|
||||
|
||||
type FormState = Pick<Directory, 'name' | 'log_webhook_events' | 'webhook' | 'google_domain'>;
|
||||
|
||||
const defaultFormState: FormState = {
|
||||
name: '',
|
||||
log_webhook_events: false,
|
||||
webhook: {
|
||||
endpoint: '',
|
||||
secret: '',
|
||||
},
|
||||
google_domain: '',
|
||||
};
|
||||
import { EditDirectory as EditDSync } from '@boxyhq/react-ui/dsync';
|
||||
import { BOXYHQ_UI_CSS } from '@components/styles';
|
||||
|
||||
const EditDirectory = ({ directoryId, setupLinkToken }: { directoryId: string; setupLinkToken?: string }) => {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation('common');
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [directoryUpdated, setDirectoryUpdated] = useState<FormState>(defaultFormState);
|
||||
const { directory, isLoading, isValidating, error } = useDirectory(directoryId, setupLinkToken);
|
||||
|
||||
useEffect(() => {
|
||||
if (directory) {
|
||||
setDirectoryUpdated({
|
||||
name: directory.name,
|
||||
log_webhook_events: directory.log_webhook_events,
|
||||
webhook: {
|
||||
endpoint: directory.webhook?.endpoint,
|
||||
secret: directory.webhook?.secret,
|
||||
},
|
||||
google_domain: directory.google_domain,
|
||||
});
|
||||
}
|
||||
}, [directory]);
|
||||
|
||||
if (isLoading || !directory || isValidating) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
@ -55,147 +23,54 @@ const EditDirectory = ({ directoryId, setupLinkToken }: { directoryId: string; s
|
|||
}
|
||||
|
||||
const backUrl = setupLinkToken ? `/setup/${setupLinkToken}/directory-sync` : '/admin/directory-sync';
|
||||
const patchUrl = setupLinkToken
|
||||
|
||||
const apiUrl = setupLinkToken
|
||||
? `/api/setup/${setupLinkToken}/directory-sync/${directoryId}`
|
||||
: `/api/admin/directory-sync/${directoryId}`;
|
||||
const redirectUrl = setupLinkToken ? `/setup/${setupLinkToken}/directory-sync` : '/admin/directory-sync';
|
||||
|
||||
const onSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
setLoading(true);
|
||||
|
||||
const rawResponse = await fetch(patchUrl, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(directoryUpdated),
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
|
||||
const response: ApiResponse<Directory> = await rawResponse.json();
|
||||
|
||||
if ('error' in response) {
|
||||
errorToast(response.error.message);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (rawResponse.ok) {
|
||||
successToast(t('directory_updated_successfully'));
|
||||
router.replace(redirectUrl);
|
||||
}
|
||||
};
|
||||
|
||||
const onChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const value = target.type === 'checkbox' ? target.checked : target.value;
|
||||
|
||||
if (target.id === 'webhook.endpoint' || target.id === 'webhook.secret') {
|
||||
setDirectoryUpdated({
|
||||
...directoryUpdated,
|
||||
webhook: {
|
||||
...directoryUpdated.webhook,
|
||||
[target.id.split('.')[1]]: value,
|
||||
},
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setDirectoryUpdated({
|
||||
...directoryUpdated,
|
||||
[target.id]: value,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<LinkBack href={backUrl} />
|
||||
<div className='flex items-center justify-between'>
|
||||
<h2 className='mb-5 mt-5 font-bold text-gray-700 md:text-xl'>{t('edit_directory')}</h2>
|
||||
<ToggleConnectionStatus connection={directory} setupLinkToken={setupLinkToken} />
|
||||
</div>
|
||||
{!setupLinkToken && (
|
||||
<div className='rounded border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800'>
|
||||
<form onSubmit={onSubmit}>
|
||||
<div className='flex flex-col space-y-3'>
|
||||
<div className='form-control w-full'>
|
||||
<label className='label'>
|
||||
<span className='label-text'>{t('directory_name')}</span>
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
id='name'
|
||||
className='input-bordered input w-full'
|
||||
required
|
||||
onChange={onChange}
|
||||
value={directoryUpdated.name}
|
||||
/>
|
||||
</div>
|
||||
{directory.type === 'google' && (
|
||||
<div className='form-control w-full'>
|
||||
<label className='label'>
|
||||
<span className='label-text'>{t('directory_domain')}</span>
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
id='google_domain'
|
||||
className='input-bordered input w-full'
|
||||
onChange={onChange}
|
||||
value={directoryUpdated.google_domain}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className='form-control w-full'>
|
||||
<label className='label'>
|
||||
<span className='label-text'>{t('webhook_url')}</span>
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
id='webhook.endpoint'
|
||||
className='input-bordered input w-full'
|
||||
onChange={onChange}
|
||||
value={directoryUpdated.webhook.endpoint}
|
||||
/>
|
||||
</div>
|
||||
<div className='form-control w-full'>
|
||||
<label className='label'>
|
||||
<span className='label-text'>{t('webhook_secret')}</span>
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
id='webhook.secret'
|
||||
className='input-bordered input w-full'
|
||||
onChange={onChange}
|
||||
value={directoryUpdated.webhook.secret}
|
||||
/>
|
||||
</div>
|
||||
<div className='form-control w-full py-2'>
|
||||
<div className='flex items-center'>
|
||||
<input
|
||||
id='log_webhook_events'
|
||||
type='checkbox'
|
||||
checked={directoryUpdated.log_webhook_events}
|
||||
onChange={onChange}
|
||||
className='h-4 w-4 rounded border-gray-300 bg-gray-100 text-blue-600 focus:ring-2 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:ring-offset-gray-800 dark:focus:ring-blue-600'
|
||||
/>
|
||||
<label className='ml-2 text-sm font-medium text-gray-900 dark:text-gray-300'>
|
||||
{t('enable_webhook_events_logging')}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<ButtonPrimary type='submit' loading={loading}>
|
||||
{t('save_changes')}
|
||||
</ButtonPrimary>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
<DeleteDirectory directoryId={directoryId} setupLinkToken={setupLinkToken} />
|
||||
<div className='rounded border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800'>
|
||||
<EditDSync
|
||||
displayHeader={false}
|
||||
urls={{
|
||||
patch: apiUrl,
|
||||
delete: apiUrl,
|
||||
get: apiUrl,
|
||||
}}
|
||||
excludeFields={
|
||||
setupLinkToken
|
||||
? [
|
||||
'name',
|
||||
'product',
|
||||
'log_webhook_events',
|
||||
'tenant',
|
||||
'google_domain',
|
||||
'type',
|
||||
'webhook_url',
|
||||
'webhook_secret',
|
||||
'scim_endpoint',
|
||||
'scim_token',
|
||||
'google_authorization_url',
|
||||
]
|
||||
: undefined
|
||||
}
|
||||
successCallback={() => {
|
||||
successToast(t('directory_updated_successfully'));
|
||||
router.replace(redirectUrl);
|
||||
}}
|
||||
errorCallback={(errMessage) => {
|
||||
errorToast(errMessage);
|
||||
}}
|
||||
hideSave={setupLinkToken ? true : false}
|
||||
classNames={BOXYHQ_UI_CSS}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,62 +0,0 @@
|
|||
import type { Directory } from '@boxyhq/saml-jackson';
|
||||
import { errorToast, successToast } from '@components/Toaster';
|
||||
import { FC, useState, useEffect } from 'react';
|
||||
import type { ApiResponse } from 'types';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { ConnectionToggle } from '@components/ConnectionToggle';
|
||||
|
||||
interface Props {
|
||||
connection: Directory;
|
||||
setupLinkToken?: string;
|
||||
}
|
||||
|
||||
export const ToggleConnectionStatus: FC<Props> = (props) => {
|
||||
const { connection, setupLinkToken } = props;
|
||||
|
||||
const { t } = useTranslation('common');
|
||||
const [active, setActive] = useState(!connection.deactivated);
|
||||
|
||||
useEffect(() => {
|
||||
setActive(!connection.deactivated);
|
||||
}, [connection]);
|
||||
|
||||
const updateConnectionStatus = async (active: boolean) => {
|
||||
setActive(active);
|
||||
|
||||
const body = {
|
||||
directoryId: connection.id,
|
||||
deactivated: !active,
|
||||
};
|
||||
|
||||
const actionUrl = setupLinkToken
|
||||
? `/api/setup/${setupLinkToken}/directory-sync/${connection.id}`
|
||||
: `/api/admin/directory-sync/${connection.id}`;
|
||||
|
||||
const res = await fetch(actionUrl, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const response: ApiResponse = await res.json();
|
||||
|
||||
if ('error' in response) {
|
||||
errorToast(response.error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (body.deactivated) {
|
||||
successToast(t('connection_deactivated'));
|
||||
} else {
|
||||
successToast(t('connection_activated'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ConnectionToggle connection={{ active, type: 'dsync' }} onChange={updateConnectionStatus} />
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -5,7 +5,7 @@ import Head from 'next/head';
|
|||
import { Sidebar } from '@components/Sidebar';
|
||||
import { Navbar } from '@components/Navbar';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import Loading from '@components/Loading';
|
||||
import { Loading } from '@boxyhq/internal-ui';
|
||||
|
||||
export const AccountLayout = ({ children }: { children: React.ReactNode }) => {
|
||||
const { t } = useTranslation('common');
|
||||
|
|
|
@ -4,7 +4,7 @@ import Head from 'next/head';
|
|||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import InvalidSetupLinkAlert from '@components/setup-link/InvalidSetupLinkAlert';
|
||||
import Loading from '@components/Loading';
|
||||
import { Loading } from '@boxyhq/internal-ui';
|
||||
import useSetupLink from '@lib/ui/hooks/useSetupLink';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { hexToOklch } from '@lib/color';
|
||||
|
|
|
@ -2,7 +2,7 @@ import { useState } from 'react';
|
|||
import classNames from 'classnames';
|
||||
import { useRouter } from 'next/router';
|
||||
import { successToast, errorToast } from '@components/Toaster';
|
||||
import { LinkBack } from '@components/LinkBack';
|
||||
import { LinkBack } from '@boxyhq/internal-ui';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
const AddProject = () => {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { CopyToClipboardButton } from '@components/ClipboardButton';
|
||||
import { CopyToClipboardButton } from '@boxyhq/internal-ui';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter/dist/cjs';
|
||||
import { materialOceanic } from 'react-syntax-highlighter/dist/cjs/styles/prism';
|
||||
|
|
|
@ -4,8 +4,7 @@ import { useTranslation } from 'next-i18next';
|
|||
|
||||
import type { ApiError, ApiSuccess } from 'types';
|
||||
import type { Project } from 'types/retraced';
|
||||
import ErrorMessage from '@components/Error';
|
||||
import Loading from '@components/Loading';
|
||||
import { Loading, Error } from '@boxyhq/internal-ui';
|
||||
import { fetcher } from '@lib/ui/utils';
|
||||
|
||||
const LogsViewer = (props: { project: Project; environmentId: string; groupId: string; host: string }) => {
|
||||
|
@ -28,7 +27,7 @@ const LogsViewer = (props: { project: Project; environmentId: string; groupId: s
|
|||
}
|
||||
|
||||
if (error) {
|
||||
return <ErrorMessage />;
|
||||
return <Error message={t('error_loading_page')} />;
|
||||
}
|
||||
|
||||
const viewerToken = data?.data?.viewerToken;
|
||||
|
|
|
@ -2,7 +2,7 @@ import type { Project } from 'types/retraced';
|
|||
import CodeSnippet from '@components/retraced/CodeSnippet';
|
||||
import { useState } from 'react';
|
||||
import { Select } from 'react-daisyui';
|
||||
import { InputWithCopyButton } from '@components/ClipboardButton';
|
||||
import { InputWithCopyButton } from '@boxyhq/internal-ui';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
const ProjectDetails = (props: { project: Project; host?: string }) => {
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import { useRouter } from 'next/router';
|
||||
|
||||
import { CreateSAMLConnection as CreateSAML, CreateOIDCConnection as CreateOIDC } from '@boxyhq/react-ui/sso';
|
||||
import styles from 'styles/sdk-override.module.css';
|
||||
import { errorToast, successToast } from '@components/Toaster';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { BOXYHQ_UI_CSS } from '@components/styles';
|
||||
|
||||
interface CreateSSOConnectionProps {
|
||||
setupLinkToken: string;
|
||||
|
@ -30,19 +30,13 @@ const CreateSSOConnection = ({ setupLinkToken, idpType }: CreateSSOConnectionPro
|
|||
post: `/api/setup/${setupLinkToken}/sso-connection`,
|
||||
};
|
||||
|
||||
const _CSS = {
|
||||
input: `${styles['sdk-input']} input input-bordered`,
|
||||
button: { ctoa: 'btn btn-primary' },
|
||||
textarea: styles['sdk-input'],
|
||||
};
|
||||
|
||||
return idpType === 'saml' ? (
|
||||
<CreateSAML
|
||||
variant='basic'
|
||||
urls={urls}
|
||||
successCallback={onSuccess}
|
||||
errorCallback={onError}
|
||||
classNames={_CSS}
|
||||
classNames={BOXYHQ_UI_CSS}
|
||||
displayHeader={false}
|
||||
/>
|
||||
) : (
|
||||
|
@ -51,7 +45,7 @@ const CreateSSOConnection = ({ setupLinkToken, idpType }: CreateSSOConnectionPro
|
|||
urls={urls}
|
||||
successCallback={onSuccess}
|
||||
errorCallback={onError}
|
||||
classNames={_CSS}
|
||||
classNames={BOXYHQ_UI_CSS}
|
||||
displayHeader={false}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { useRouter } from 'next/router';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { ButtonPrimary } from '@components/ButtonPrimary';
|
||||
import { ButtonPrimary } from '@boxyhq/internal-ui';
|
||||
|
||||
const NextButton = () => {
|
||||
const router = useRouter();
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { useRouter } from 'next/router';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { ButtonOutline } from '@components/ButtonOutline';
|
||||
import { ButtonOutline } from '@boxyhq/internal-ui';
|
||||
|
||||
const PreviousButton = () => {
|
||||
const router = useRouter();
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
import styles from '@styles/sdk-override.module.css';
|
||||
|
||||
export const BOXYHQ_UI_CSS = {
|
||||
button: {
|
||||
ctoa: 'btn btn-primary',
|
||||
destructive: 'btn btn-md btn-error',
|
||||
},
|
||||
input: `${styles['sdk-input']} input input-bordered`,
|
||||
select: styles['sdk-select'],
|
||||
textarea: styles['sdk-input'],
|
||||
confirmationPrompt: {
|
||||
button: {
|
||||
ctoa: 'btn btn-md',
|
||||
cancel: 'btn btn-md btn-outline',
|
||||
},
|
||||
},
|
||||
secretInput: 'input input-bordered',
|
||||
section: 'mb-8',
|
||||
};
|
|
@ -1,24 +0,0 @@
|
|||
import { TableHeader } from './TableHeader';
|
||||
import { TableBody, TableBodyType } from './TableBody';
|
||||
|
||||
const tableWrapperClass = 'rounder border';
|
||||
const tableClass = 'w-full text-left text-sm text-gray-500 dark:text-gray-400';
|
||||
|
||||
export const Table = ({
|
||||
cols,
|
||||
body,
|
||||
noMoreResults,
|
||||
}: {
|
||||
cols: string[];
|
||||
body: TableBodyType[];
|
||||
noMoreResults?: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<div className={tableWrapperClass}>
|
||||
<table className={tableClass}>
|
||||
<TableHeader cols={cols} />
|
||||
<TableBody cols={cols} body={body} noMoreResults={noMoreResults} />
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -1,110 +0,0 @@
|
|||
import { Button } from 'react-daisyui';
|
||||
import Badge from '@components/Badge';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
|
||||
const trClass = 'border-b bg-white last:border-b-0 hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-800';
|
||||
const tdClassBase = 'px-6 py-3 text-sm text-gray-500 dark:text-gray-400';
|
||||
const tdClass = `whitespace-nowrap ${tdClassBase}`;
|
||||
const tdClassWrap = `break-all ${tdClassBase}`;
|
||||
|
||||
interface TableBodyCell {
|
||||
wrap?: boolean;
|
||||
text?: string;
|
||||
buttons?: {
|
||||
text: string;
|
||||
color?: string;
|
||||
onClick: () => void;
|
||||
}[];
|
||||
badge?: {
|
||||
text: string;
|
||||
color: string;
|
||||
};
|
||||
element?: React.JSX.Element;
|
||||
actions?: {
|
||||
text: string;
|
||||
icon: React.JSX.Element;
|
||||
onClick: () => void;
|
||||
destructive?: boolean;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface TableBodyType {
|
||||
id: string;
|
||||
cells: TableBodyCell[];
|
||||
}
|
||||
|
||||
export const TableBody = ({
|
||||
cols,
|
||||
body,
|
||||
noMoreResults,
|
||||
}: {
|
||||
cols: string[];
|
||||
body: TableBodyType[];
|
||||
noMoreResults?: boolean;
|
||||
}) => {
|
||||
const { t } = useTranslation('common');
|
||||
|
||||
if (noMoreResults) {
|
||||
return (
|
||||
<tbody>
|
||||
<tr>
|
||||
<td colSpan={cols.length} className='px-6 py-3 text-center text-sm text-gray-500'>
|
||||
{t('no_more_results')}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<tbody>
|
||||
{body.map((row) => {
|
||||
return (
|
||||
<tr key={row.id} className={trClass}>
|
||||
{row.cells?.map((cell: any, index: number) => {
|
||||
return (
|
||||
<td key={row.id + '-td-' + index} className={cell.wrap ? tdClassWrap : tdClass}>
|
||||
{!cell.buttons || cell.buttons?.length === 0 ? null : (
|
||||
<div className='flex space-x-2'>
|
||||
{cell.buttons?.map((button: any, index: number) => {
|
||||
return (
|
||||
<Button
|
||||
key={row.id + '-button-' + index}
|
||||
size='xs'
|
||||
color={button.color}
|
||||
variant='outline'
|
||||
onClick={button.onClick}>
|
||||
{button.text}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{!cell.actions || cell.actions?.length === 0 ? null : (
|
||||
<span className='flex gap-3'>
|
||||
{cell.actions?.map((action: any, index: number) => {
|
||||
return (
|
||||
<div key={row.id + '-diva-' + index} className='tooltip' data-tip={action.text}>
|
||||
<button
|
||||
key={row.id + '-action-' + index}
|
||||
className={`py-2 ${action.destructive ? 'text-red-500 hover:text-red-900' : 'hover:text-green-400'}`}
|
||||
onClick={action.onClick}>
|
||||
{action.icon}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
{cell.badge ? <Badge color={cell.badge.color}>{cell.badge.text}</Badge> : null}
|
||||
{cell.text ? cell.text : null}
|
||||
{cell.element ? cell.element : null}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
);
|
||||
};
|
|
@ -1,17 +0,0 @@
|
|||
const theadClass = 'bg-gray-50 text-xs uppercase text-gray-700 dark:bg-gray-700 dark:text-gray-400';
|
||||
const trHeadClass = 'hover:bg-gray-50';
|
||||
const thClass = 'px-6 py-3';
|
||||
|
||||
export const TableHeader = ({ cols }: { cols: string[] }) => {
|
||||
return (
|
||||
<thead className={theadClass}>
|
||||
<tr className={trHeadClass}>
|
||||
{cols.map((col, index) => (
|
||||
<th key={index} scope='col' className={thClass}>
|
||||
{col}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
);
|
||||
};
|
|
@ -9,11 +9,9 @@ import { maskSetup } from '@components/terminus/blocks/customblocks';
|
|||
import locale from 'blockly/msg/en';
|
||||
Blockly.setLocale(locale);
|
||||
|
||||
import { ButtonPrimary } from '@components/ButtonPrimary';
|
||||
import { generateModel } from '@components/terminus/blocks/generator';
|
||||
import { errorToast, successToast } from '@components/Toaster';
|
||||
import ConfirmationModal from '@components/ConfirmationModal';
|
||||
import { ButtonBase } from '@components/ButtonBase';
|
||||
import { ButtonBase, ButtonPrimary, ConfirmationModal } from '@boxyhq/internal-ui';
|
||||
|
||||
function BlocklyComponent(props) {
|
||||
const { t } = useTranslation('common');
|
||||
|
|
|
@ -24,13 +24,13 @@ test.describe('Admin Portal SSO - SAML', () => {
|
|||
// Find the new connection button and click on it
|
||||
await page.getByTestId('create-connection').click();
|
||||
// Fill the name for the connection
|
||||
const nameInput = page.getByTestId('name');
|
||||
const nameInput = page.getByLabel('Connection name (Optional)');
|
||||
await nameInput.fill(TEST_SAML_SSO_CONNECTION_NAME);
|
||||
// Enter the metadata url for mocksaml in the form
|
||||
const metadataUrlInput = page.getByTestId('metadataUrl');
|
||||
const metadataUrlInput = page.getByLabel('Metadata URL');
|
||||
await metadataUrlInput.fill(MOCKSAML_METADATA_URL);
|
||||
// submit the form
|
||||
await page.getByTestId('submit-form-create-sso').click();
|
||||
await page.getByRole('button', { name: /save/i }).click();
|
||||
// check if the added connection appears in the connection list
|
||||
await expect(page.getByText(TEST_SAML_SSO_CONNECTION_NAME)).toBeVisible();
|
||||
});
|
||||
|
@ -78,8 +78,8 @@ test.describe('Admin Portal SSO - SAML', () => {
|
|||
const editButton = page.getByText(TEST_SAML_SSO_CONNECTION_NAME).locator('..').getByLabel('Edit');
|
||||
await editButton.click();
|
||||
// click the delete and confirm deletion
|
||||
await page.getByTestId('delete-connection').click();
|
||||
await page.getByTestId('confirm-delete').click();
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
await page.getByRole('button', { name: 'Confirm' }).click();
|
||||
// check that the SSO connection is deleted from the connection list
|
||||
await expect(page.getByText(TEST_SAML_SSO_CONNECTION_NAME)).not.toBeVisible();
|
||||
});
|
||||
|
@ -94,40 +94,38 @@ test.describe('Admin Portal SSO - OIDC', () => {
|
|||
// Find the new connection button and click on it
|
||||
await page.getByTestId('create-connection').click();
|
||||
// Toggle connection type to OIDC
|
||||
await page.getByTestId('sso-type-oidc').click();
|
||||
await page.getByLabel('OIDC').check();
|
||||
// Fill the name for the connection
|
||||
const nameInput = page.getByTestId('name');
|
||||
const nameInput = page.getByLabel('Connection name (Optional)');
|
||||
await nameInput.fill(TEST_OIDC_SSO_CONNECTION_NAME);
|
||||
if (mode === 'discoveryUrl') {
|
||||
// Enter the OIDC discovery url for mocklab in the form
|
||||
const discoveryUrlInput = page.getByTestId('oidcDiscoveryUrl');
|
||||
const discoveryUrlInput = page.getByLabel('Well-known URL of OpenID Provider');
|
||||
await discoveryUrlInput.fill(MOCKLAB_DISCOVERY_ENDPOINT);
|
||||
} else {
|
||||
// Activate the oidc discovery fallback fields
|
||||
await page.getByTestId('oidcDiscoveryUrl-fallback-switch').click();
|
||||
// Enter the OIDC issuer value for mocklab in the form
|
||||
const issuerInput = page.getByTestId('issuer');
|
||||
const issuerInput = page.getByLabel('issuer');
|
||||
await issuerInput.fill(MOCKLAB_ISSUER);
|
||||
// Enter the OIDC authorization_endpoint value for mocklab in the form
|
||||
const authzEndpointInput = page.getByTestId('authorization_endpoint');
|
||||
const authzEndpointInput = page.getByLabel('Authorization Endpoint');
|
||||
await authzEndpointInput.fill(MOCKLAB_AUTHORIZATION_ENDPOINT);
|
||||
// Enter the OIDC token_endpoint value for mocklab in the form
|
||||
const tokenEndpointInput = page.getByTestId('token_endpoint');
|
||||
const tokenEndpointInput = page.getByLabel('Token endpoint');
|
||||
await tokenEndpointInput.fill(MOCKLAB_TOKEN_ENDPOINT);
|
||||
// Enter the OIDC userinfo_endpoint value for mocklab in the form
|
||||
const userInfoEndpointInput = page.getByTestId('userinfo_endpoint');
|
||||
const userInfoEndpointInput = page.getByLabel('UserInfo endpoint');
|
||||
await userInfoEndpointInput.fill(MOCKLAB_USERINFO_ENDPOINT);
|
||||
// Enter the OIDC jwks_uri value for mocklab in the form
|
||||
const jwksUriInput = page.getByTestId('jwks_uri');
|
||||
const jwksUriInput = page.getByLabel('JWKS URI');
|
||||
await jwksUriInput.fill(MOCKLAB_JWKS_URI);
|
||||
}
|
||||
// Enter the OIDC client credentials for mocklab in the form
|
||||
const clientIdInput = page.getByTestId('oidcClientId');
|
||||
const clientIdInput = page.getByLabel('Client ID');
|
||||
await clientIdInput.fill(MOCKLAB_CLIENT_ID);
|
||||
const clientSecretInput = page.getByTestId('oidcClientSecret');
|
||||
const clientSecretInput = page.getByLabel('Client Secret');
|
||||
await clientSecretInput.fill(MOCKLAB_CLIENT_SECRET);
|
||||
// submit the form
|
||||
await page.getByTestId('submit-form-create-sso').click();
|
||||
await page.getByRole('button', { name: /save/i }).click();
|
||||
// check if the added connection appears in the connection list
|
||||
await expect(page.getByText(TEST_OIDC_SSO_CONNECTION_NAME)).toBeVisible();
|
||||
});
|
||||
|
@ -156,8 +154,8 @@ test.describe('Admin Portal SSO - OIDC', () => {
|
|||
const editButton = page.getByText(TEST_OIDC_SSO_CONNECTION_NAME).locator('..').getByLabel('Edit');
|
||||
await editButton.click();
|
||||
// click the delete and confirm deletion
|
||||
await page.getByTestId('delete-connection').click();
|
||||
await page.getByTestId('confirm-delete').click();
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
await page.getByRole('button', { name: 'Confirm' }).click();
|
||||
// check that the SSO connection is deleted from the connection list
|
||||
await expect(page.getByText(TEST_OIDC_SSO_CONNECTION_NAME)).not.toBeVisible();
|
||||
});
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { ButtonPrimary } from '@components/ButtonPrimary';
|
||||
import { ButtonPrimary } from '@boxyhq/internal-ui';
|
||||
import { errorToast, successToast } from '@components/Toaster';
|
||||
import type { ApiResponse } from 'types';
|
||||
import type { AdminPortalBranding } from '@boxyhq/saml-jackson';
|
||||
|
@ -86,7 +86,7 @@ const Branding = ({ hasValidLicense }: { hasValidLicense: boolean }) => {
|
|||
<div className='flex flex-col space-y-2'>
|
||||
<div className='form-control w-full md:w-1/2'>
|
||||
<label className='label'>
|
||||
<span className='label-text'>{t('branding_logo_url_label')}</span>
|
||||
<span className='label-text'>{t('bui-shared-logo-url')}</span>
|
||||
</label>
|
||||
<input
|
||||
type='url'
|
||||
|
@ -97,12 +97,12 @@ const Branding = ({ hasValidLicense }: { hasValidLicense: boolean }) => {
|
|||
placeholder='https://company.com/logo.png'
|
||||
/>
|
||||
<label className='label'>
|
||||
<span className='label-text-alt'>{t('branding_logo_url_alt')}</span>
|
||||
<span className='label-text-alt'>{t('bui-shared-logo-url-desc')}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className='form-control w-full md:w-1/2'>
|
||||
<label className='label'>
|
||||
<span className='label-text'>{t('branding_favicon_url_label')}</span>
|
||||
<span className='label-text'>{t('bui-shared-favicon-url')}</span>
|
||||
</label>
|
||||
<input
|
||||
type='url'
|
||||
|
@ -113,7 +113,7 @@ const Branding = ({ hasValidLicense }: { hasValidLicense: boolean }) => {
|
|||
placeholder='https://company.com/favicon.ico'
|
||||
/>
|
||||
<label className='label'>
|
||||
<span className='label-text-alt'>{t('branding_favicon_url_alt')}</span>
|
||||
<span className='label-text-alt'>{t('bui-shared-favicon-url-desc')}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className='form-control w-full md:w-1/2'>
|
||||
|
@ -134,15 +134,15 @@ const Branding = ({ hasValidLicense }: { hasValidLicense: boolean }) => {
|
|||
</div>
|
||||
<div className='form-control'>
|
||||
<label className='label'>
|
||||
<span className='label-text'>{t('branding_primary_color_label')}</span>
|
||||
<span className='label-text'>{t('bui-shared-primary-color')}</span>
|
||||
</label>
|
||||
<input type='color' id='primaryColor' onChange={onChange} value={branding.primaryColor || ''} />
|
||||
<label className='label'>
|
||||
<span className='label-text-alt'>{t('branding_primary_color_alt')}</span>
|
||||
<span className='label-text-alt'>{t('bui-shared-primary-color-desc')}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className='mt-5'>
|
||||
<ButtonPrimary loading={loading}>{t('save_changes')}</ButtonPrimary>
|
||||
<ButtonPrimary loading={loading}>{t('bui-shared-save-changes')}</ButtonPrimary>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
@ -2,8 +2,7 @@ import Link from 'next/link';
|
|||
import { useTranslation } from 'next-i18next';
|
||||
import type { SAMLFederationAppWithMetadata } from '@boxyhq/saml-jackson';
|
||||
import { Toaster } from '@components/Toaster';
|
||||
import { InputWithCopyButton, CopyToClipboardButton } from '@components/ClipboardButton';
|
||||
import { LinkOutline } from '@components/LinkOutline';
|
||||
import { InputWithCopyButton, CopyToClipboardButton, LinkOutline } from '@boxyhq/internal-ui';
|
||||
import LicenseRequired from '@components/LicenseRequired';
|
||||
|
||||
type MetadataProps = {
|
||||
|
@ -59,7 +58,7 @@ const Metadata = ({ metadata, hasValidLicense }: MetadataProps) => {
|
|||
<InputWithCopyButton text={metadata.ssoUrl} label={t('sso_url')} />
|
||||
</div>
|
||||
<div className='form-control w-full'>
|
||||
<InputWithCopyButton text={metadata.entityId} label={t('entity_id')} />
|
||||
<InputWithCopyButton text={metadata.entityId} label={t('bui-fs-entity-id')} />
|
||||
</div>
|
||||
<div className='form-control w-full'>
|
||||
<label className='label'>
|
||||
|
@ -70,7 +69,7 @@ const Metadata = ({ metadata, hasValidLicense }: MetadataProps) => {
|
|||
href='/.well-known/saml.cer'
|
||||
target='_blank'
|
||||
className='label-text font-bold text-gray-500 hover:link-primary'>
|
||||
{t('download')}
|
||||
{t('bui-wku-download')}
|
||||
</Link>
|
||||
<CopyToClipboardButton text={metadata.x509cert.trim()} />
|
||||
</span>
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
const allValues = {};
|
||||
|
||||
const localeFile = require('./locales/en/common.json');
|
||||
|
||||
for (const [key, value] of Object.entries(localeFile)) {
|
||||
const arr = allValues[value] || [];
|
||||
allValues[value] = arr.concat(key);
|
||||
}
|
||||
|
||||
const dupValues = {};
|
||||
|
||||
for (const [key, value] of Object.entries(allValues)) {
|
||||
if (value.length > 1) {
|
||||
dupValues[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(dupValues).length) {
|
||||
console.error(`Duplicate values found in locale file: ${Object.keys(dupValues).length}`);
|
||||
for (const [key, value] of Object.entries(dupValues)) {
|
||||
console.error(`${value}: ${key}`);
|
||||
}
|
||||
}
|
File diff suppressed because it is too large
Load Diff
|
@ -26,21 +26,21 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-typescript": "11.1.6",
|
||||
"@types/node": "20.11.28",
|
||||
"@types/react": "18.2.66",
|
||||
"@typescript-eslint/eslint-plugin": "7.2.0",
|
||||
"@typescript-eslint/parser": "7.2.0",
|
||||
"@types/node": "20.11.30",
|
||||
"@types/react": "18.2.73",
|
||||
"@typescript-eslint/eslint-plugin": "7.4.0",
|
||||
"@typescript-eslint/parser": "7.4.0",
|
||||
"@vitejs/plugin-react": "4.2.1",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-plugin-react-hooks": "4.6.0",
|
||||
"eslint-plugin-react-refresh": "0.4.6",
|
||||
"prettier": "3.2.5",
|
||||
"react-daisyui": "5.0.0",
|
||||
"typescript": "5.4.2",
|
||||
"vite": "5.1.6"
|
||||
"typescript": "5.4.3",
|
||||
"vite": "5.2.6"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-linux-x64-gnu": "4.13.0"
|
||||
"@rollup/rollup-linux-x64-gnu": "4.13.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@heroicons/react": ">=2.1.1",
|
||||
|
|
|
@ -54,13 +54,13 @@ export const DirectoryGroups = ({
|
|||
const columns = [
|
||||
{
|
||||
key: 'name',
|
||||
label: t('bui-dsync-name'),
|
||||
label: t('bui-shared-name'),
|
||||
wrap: true,
|
||||
dataIndex: 'name',
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
label: t('bui-dsync-actions'),
|
||||
label: t('bui-shared-actions'),
|
||||
wrap: true,
|
||||
dataIndex: null,
|
||||
},
|
||||
|
@ -78,7 +78,7 @@ export const DirectoryGroups = ({
|
|||
return {
|
||||
actions: [
|
||||
{
|
||||
text: t('bui-dsync-view'),
|
||||
text: t('bui-shared-view'),
|
||||
onClick: () => onView?.(group),
|
||||
icon: <EyeIcon className='w-5' />,
|
||||
},
|
||||
|
|
|
@ -70,13 +70,13 @@ export const DirectoryInfo = ({
|
|||
)}
|
||||
{!excludeFields.includes('tenant') && (
|
||||
<div className='px-4 py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6'>
|
||||
<dt className='text-sm font-medium text-gray-500'>{t('bui-dsync-tenant')}</dt>
|
||||
<dt className='text-sm font-medium text-gray-500'>{t('bui-shared-tenant')}</dt>
|
||||
<dd className='mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0'>{directory.tenant}</dd>
|
||||
</div>
|
||||
)}
|
||||
{!excludeFields.includes('product') && (
|
||||
<div className='px-4 py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6'>
|
||||
<dt className='text-sm font-medium text-gray-500'>{t('bui-dsync-product')}</dt>
|
||||
<dt className='text-sm font-medium text-gray-500'>{t('bui-shared-product')}</dt>
|
||||
<dd className='mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0'>{directory.product}</dd>
|
||||
</div>
|
||||
)}
|
||||
|
@ -89,7 +89,7 @@ export const DirectoryInfo = ({
|
|||
</dd>
|
||||
</div>
|
||||
<div className='px-4 py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6'>
|
||||
<dt className='text-sm font-medium text-gray-500'>{t('bui-dsync-webhook-secret')}</dt>
|
||||
<dt className='text-sm font-medium text-gray-500'>{t('bui-shared-webhook-secret')}</dt>
|
||||
<dd className='mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0'>
|
||||
{directory.webhook.secret || '-'}
|
||||
</dd>
|
||||
|
@ -99,7 +99,7 @@ export const DirectoryInfo = ({
|
|||
{directory.type === 'google' && (
|
||||
<>
|
||||
<div className='px-4 py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6'>
|
||||
<dt className='text-sm font-medium text-gray-500'>{t('bui-dsync-authorized-status')}</dt>
|
||||
<dt className='text-sm font-medium text-gray-500'>{t('bui-shared-status')}</dt>
|
||||
<dd className='mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0'>
|
||||
{authorizedGoogle ? (
|
||||
<Badge color='success'>{t('bui-dsync-authorized')}</Badge>
|
||||
|
|
|
@ -66,19 +66,19 @@ export const DirectoryUsers = ({
|
|||
},
|
||||
{
|
||||
key: 'email',
|
||||
label: t('bui-dsync-email'),
|
||||
label: t('bui-shared-email'),
|
||||
wrap: true,
|
||||
dataIndex: 'email',
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: t('bui-dsync-status'),
|
||||
label: t('bui-shared-status'),
|
||||
wrap: true,
|
||||
dataIndex: 'active',
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
label: t('bui-dsync-actions'),
|
||||
label: t('bui-shared-actions'),
|
||||
wrap: true,
|
||||
dataIndex: null,
|
||||
},
|
||||
|
@ -96,7 +96,7 @@ export const DirectoryUsers = ({
|
|||
return {
|
||||
actions: [
|
||||
{
|
||||
text: t('bui-dsync-view'),
|
||||
text: t('bui-shared-view'),
|
||||
onClick: () => onView?.(user),
|
||||
icon: <EyeIcon className='w-5' />,
|
||||
},
|
||||
|
@ -107,7 +107,7 @@ export const DirectoryUsers = ({
|
|||
if (dataIndex === 'active') {
|
||||
return {
|
||||
badge: {
|
||||
text: user[dataIndex] ? t('bui-dsync-active') : t('bui-dsync-suspended'),
|
||||
text: user[dataIndex] ? t('bui-shared-active') : t('bui-dsync-suspended'),
|
||||
color: user[dataIndex] ? 'success' : 'warning',
|
||||
},
|
||||
};
|
||||
|
|
|
@ -86,7 +86,7 @@ export const DirectoryWebhookLogs = ({
|
|||
},
|
||||
{
|
||||
key: 'actions',
|
||||
label: t('bui-dsync-actions'),
|
||||
label: t('bui-shared-actions'),
|
||||
wrap: true,
|
||||
dataIndex: null,
|
||||
},
|
||||
|
@ -104,7 +104,7 @@ export const DirectoryWebhookLogs = ({
|
|||
return {
|
||||
actions: [
|
||||
{
|
||||
text: t('bui-dsync-view'),
|
||||
text: t('bui-shared-view'),
|
||||
onClick: () => onView?.(event),
|
||||
icon: <EyeIcon className='w-5' />,
|
||||
},
|
||||
|
|
|
@ -1,11 +1,17 @@
|
|||
import { useState } from 'react';
|
||||
import { Button } from 'react-daisyui';
|
||||
import type { SAMLFederationApp } from '../types';
|
||||
import TagsInput from 'react-tagsinput';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { useFormik } from 'formik';
|
||||
import EyeIcon from '@heroicons/react/24/outline/EyeIcon';
|
||||
import EyeSlashIcon from '@heroicons/react/24/outline/EyeSlashIcon';
|
||||
|
||||
import { Card } from '../shared';
|
||||
import { defaultHeaders } from '../utils';
|
||||
import { ItemList } from '../shared/ItemList';
|
||||
import { CopyToClipboardButton } from '../shared/InputWithCopyButton';
|
||||
import { IconButton } from '../shared/IconButton';
|
||||
|
||||
type EditApp = Pick<SAMLFederationApp, 'name' | 'acsUrl' | 'tenants' | 'redirectUrl'>;
|
||||
|
||||
|
@ -23,6 +29,7 @@ export const Edit = ({
|
|||
excludeFields?: 'product'[];
|
||||
}) => {
|
||||
const { t } = useTranslation('common');
|
||||
const [isSecretShown, setisSecretShown] = useState(false);
|
||||
|
||||
const connectionIsOIDC = app.type === 'oidc';
|
||||
const connectionIsSAML = !connectionIsOIDC;
|
||||
|
@ -79,9 +86,9 @@ export const Edit = ({
|
|||
</div>
|
||||
<input
|
||||
type='text'
|
||||
className='input input-bordered w-full text-sm'
|
||||
className='input input-bordered w-full text-sm bg-gray-100'
|
||||
value={app.tenant}
|
||||
disabled
|
||||
readOnly={true}
|
||||
/>
|
||||
</label>
|
||||
{!excludeFields?.includes('product') && (
|
||||
|
@ -91,9 +98,9 @@ export const Edit = ({
|
|||
</div>
|
||||
<input
|
||||
type='text'
|
||||
className='input input-bordered w-full text-sm'
|
||||
className='input input-bordered w-full text-sm bg-gray-100'
|
||||
value={app.product}
|
||||
disabled
|
||||
readOnly={true}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
@ -101,12 +108,15 @@ export const Edit = ({
|
|||
<label className='form-control w-full'>
|
||||
<div className='label'>
|
||||
<span className='label-text'>{t('bui-fs-client-id')}</span>
|
||||
<div className='flex'>
|
||||
<CopyToClipboardButton text={app.clientID!} />
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type='text'
|
||||
className='input-bordered input'
|
||||
className='input-bordered input bg-gray-100'
|
||||
defaultValue={app.clientID}
|
||||
disabled
|
||||
readOnly={true}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
@ -114,12 +124,24 @@ export const Edit = ({
|
|||
<label className='form-control w-full'>
|
||||
<div className='label'>
|
||||
<span className='label-text'>{t('bui-fs-client-secret')}</span>
|
||||
<div className='flex'>
|
||||
<IconButton
|
||||
tooltip={isSecretShown ? t('bui-shared-hide') : t('bui-shared-show')}
|
||||
Icon={isSecretShown ? EyeSlashIcon : EyeIcon}
|
||||
className='hover:text-primary mr-2'
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setisSecretShown(!isSecretShown);
|
||||
}}
|
||||
/>
|
||||
<CopyToClipboardButton text={app.clientSecret!} />
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type='text'
|
||||
className='input-bordered input'
|
||||
type={isSecretShown ? 'text' : 'password'}
|
||||
className='input-bordered input bg-gray-100'
|
||||
defaultValue={app.clientSecret}
|
||||
disabled
|
||||
readOnly={true}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
@ -130,9 +152,9 @@ export const Edit = ({
|
|||
</div>
|
||||
<input
|
||||
type='text'
|
||||
className='input input-bordered w-full text-sm'
|
||||
className='input input-bordered w-full text-sm bg-gray-100'
|
||||
value={app.entityId}
|
||||
disabled
|
||||
readOnly={true}
|
||||
/>
|
||||
<label className='label'>
|
||||
<span className='label-text-alt'>{t('bui-fs-entity-id-edit-desc')}</span>
|
||||
|
@ -142,7 +164,7 @@ export const Edit = ({
|
|||
{connectionIsSAML && (
|
||||
<label className='form-control w-full'>
|
||||
<div className='label'>
|
||||
<span className='label-text'>{t('bui-fs-acs-url')}</span>
|
||||
<span className='label-text'>{t('bui-shared-acs-url')}</span>
|
||||
</div>
|
||||
<input
|
||||
type='url'
|
||||
|
@ -175,7 +197,7 @@ export const Edit = ({
|
|||
onlyUnique={true}
|
||||
inputProps={{
|
||||
placeholder: t('bui-fs-enter-tenant'),
|
||||
autocomplete: 'off',
|
||||
autoComplete: 'off',
|
||||
}}
|
||||
focusedClassName='input-focused'
|
||||
addOnBlur={true}
|
||||
|
|
|
@ -55,7 +55,7 @@ export const EditBranding = ({
|
|||
<div className='flex flex-col'>
|
||||
<label className='form-control w-full'>
|
||||
<div className='label'>
|
||||
<span className='label-text'>{t('bui-fs-logo-url')}</span>
|
||||
<span className='label-text'>{t('bui-shared-logo-url')}</span>
|
||||
</div>
|
||||
<input
|
||||
type='url'
|
||||
|
@ -66,12 +66,12 @@ export const EditBranding = ({
|
|||
onChange={formik.handleChange}
|
||||
/>
|
||||
<label className='label'>
|
||||
<span className='label-text-alt'>{t('bui-fs-logo-url-desc')}</span>
|
||||
<span className='label-text-alt'>{t('bui-shared-logo-url-desc')}</span>
|
||||
</label>
|
||||
</label>
|
||||
<label className='form-control w-full'>
|
||||
<div className='label'>
|
||||
<span className='label-text'>{t('bui-fs-favicon-url')}</span>
|
||||
<span className='label-text'>{t('bui-shared-favicon-url')}</span>
|
||||
</div>
|
||||
<input
|
||||
type='url'
|
||||
|
@ -82,13 +82,13 @@ export const EditBranding = ({
|
|||
onChange={formik.handleChange}
|
||||
/>
|
||||
<label className='label'>
|
||||
<span className='label-text-alt'>{t('bui-fs-favicon-url-desc')}</span>
|
||||
<span className='label-text-alt'>{t('bui-shared-favicon-url-desc')}</span>
|
||||
</label>
|
||||
</label>
|
||||
<label className='form-control'>
|
||||
<div className='flex'>
|
||||
<label className='label pr-3'>
|
||||
<span className='label-text'>{t('bui-fs-primary-color')}</span>
|
||||
<span className='label-text'>{t('bui-shared-primary-color')}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className='flex gap-3 border-[1px] border-gray-200 rounded-md p-2 w-fit'>
|
||||
|
@ -101,7 +101,7 @@ export const EditBranding = ({
|
|||
/>
|
||||
</div>
|
||||
<label className='label'>
|
||||
<span className='label-text-alt'>{t('bui-fs-primary-color-desc')}</span>
|
||||
<span className='label-text-alt'>{t('bui-shared-primary-color-desc')}</span>
|
||||
</label>
|
||||
</label>
|
||||
</div>
|
||||
|
|
|
@ -132,7 +132,7 @@ export const FederatedSAMLApps = ({
|
|||
{t('bui-fs-oidc-config')}
|
||||
</LinkOutline>
|
||||
<LinkOutline href={actions.samlConfiguration} target='_blank' className='btn-md'>
|
||||
{t('bui-fs-saml-config')}
|
||||
{t('bui-shared-saml-configuration')}
|
||||
</LinkOutline>
|
||||
<ButtonPrimary onClick={() => router?.push(actions.newApp)} className='btn-md'>
|
||||
{t('bui-fs-new-app')}
|
||||
|
|
|
@ -109,7 +109,7 @@ export const NewFederatedSAMLApp = ({
|
|||
checked={formik.values.type === 'oidc'}
|
||||
onChange={formik.handleChange}
|
||||
/>
|
||||
<span className='label-text ml-1'>{t('bui-fs-oidc')}</span>
|
||||
<span className='label-text ml-1'>{t('bui-shared-oidc')}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -162,7 +162,7 @@ export const NewFederatedSAMLApp = ({
|
|||
{connectionIsSAML && (
|
||||
<label className='form-control w-full'>
|
||||
<div className='label'>
|
||||
<span className='label-text'>{t('bui-fs-acs-url')}</span>
|
||||
<span className='label-text'>{t('bui-shared-acs-url')}</span>
|
||||
</div>
|
||||
<input
|
||||
type='url'
|
||||
|
@ -226,7 +226,7 @@ export const NewFederatedSAMLApp = ({
|
|||
onlyUnique={true}
|
||||
inputProps={{
|
||||
placeholder: t('bui-fs-enter-tenant'),
|
||||
autocomplete: 'off',
|
||||
autoComplete: 'off',
|
||||
}}
|
||||
focusedClassName='input-focused'
|
||||
addOnBlur={true}
|
||||
|
|
|
@ -74,7 +74,7 @@ export const DSyncForm = ({
|
|||
<Card.Description>{t('bui-sl-dsync-desc')}</Card.Description>
|
||||
<label className='form-control w-full'>
|
||||
<div className='label'>
|
||||
<span className='label-text'>{t('bui-sl-dsync-name')}</span>
|
||||
<span className='label-text'>{t('bui-sl-name')}</span>
|
||||
</div>
|
||||
<input
|
||||
type='text'
|
||||
|
@ -87,7 +87,7 @@ export const DSyncForm = ({
|
|||
</label>
|
||||
<label className='form-control w-full'>
|
||||
<div className='label'>
|
||||
<span className='label-text'>{t('bui-sl-tenant')}</span>
|
||||
<span className='label-text'>{t('bui-shared-tenant')}</span>
|
||||
</div>
|
||||
<input
|
||||
type='text'
|
||||
|
@ -102,7 +102,7 @@ export const DSyncForm = ({
|
|||
{!excludeFields?.includes('product') && (
|
||||
<label className='form-control w-full'>
|
||||
<div className='label'>
|
||||
<span className='label-text'>{t('bui-sl-product')}</span>
|
||||
<span className='label-text'>{t('bui-shared-product')}</span>
|
||||
</div>
|
||||
<input
|
||||
type='text'
|
||||
|
@ -131,7 +131,7 @@ export const DSyncForm = ({
|
|||
</label>
|
||||
<label className='form-control w-full'>
|
||||
<div className='label'>
|
||||
<span className='label-text'>{t('bui-sl-webhook-secret')}</span>
|
||||
<span className='label-text'>{t('bui-shared-webhook-secret')}</span>
|
||||
</div>
|
||||
<input
|
||||
type='password'
|
||||
|
@ -153,6 +153,7 @@ export const DSyncForm = ({
|
|||
className='input input-bordered w-full text-sm'
|
||||
name='expiryDays'
|
||||
required
|
||||
min={1}
|
||||
onChange={formik.handleChange}
|
||||
value={formik.values.expiryDays}
|
||||
/>
|
||||
|
@ -164,7 +165,7 @@ export const DSyncForm = ({
|
|||
className='btn btn-primary btn-md'
|
||||
loading={formik.isSubmitting}
|
||||
disabled={!formik.dirty || !formik.isValid}>
|
||||
{t('bui-sl-create')}
|
||||
{t('bui-sl-create-link')}
|
||||
</Button>
|
||||
</Card.Footer>
|
||||
</Card>
|
||||
|
|
|
@ -78,7 +78,7 @@ export const SSOForm = ({
|
|||
<Card.Description>{t('bui-sl-sso-desc')}</Card.Description>
|
||||
<label className='form-control w-full'>
|
||||
<div className='label'>
|
||||
<span className='label-text'>{t('bui-sl-sso-name')}</span>
|
||||
<span className='label-text'>{t('bui-sl-name')}</span>
|
||||
</div>
|
||||
<input
|
||||
type='text'
|
||||
|
@ -103,7 +103,7 @@ export const SSOForm = ({
|
|||
</label>
|
||||
<label className='form-control w-full'>
|
||||
<div className='label'>
|
||||
<span className='label-text'>{t('bui-sl-tenant')}</span>
|
||||
<span className='label-text'>{t('bui-shared-tenant')}</span>
|
||||
</div>
|
||||
<input
|
||||
type='text'
|
||||
|
@ -118,7 +118,7 @@ export const SSOForm = ({
|
|||
{!excludeFields?.includes('product') && (
|
||||
<label className='form-control w-full'>
|
||||
<div className='label'>
|
||||
<span className='label-text'>{t('bui-sl-product')}</span>
|
||||
<span className='label-text'>{t('bui-shared-product')}</span>
|
||||
</div>
|
||||
<input
|
||||
type='text'
|
||||
|
@ -168,6 +168,7 @@ export const SSOForm = ({
|
|||
className='input input-bordered w-full text-sm'
|
||||
name='expiryDays'
|
||||
required
|
||||
min={1}
|
||||
onChange={formik.handleChange}
|
||||
value={formik.values.expiryDays}
|
||||
/>
|
||||
|
@ -179,7 +180,7 @@ export const SSOForm = ({
|
|||
className='btn btn-primary btn-md'
|
||||
loading={formik.isSubmitting}
|
||||
disabled={!formik.dirty || !formik.isValid}>
|
||||
{t('bui-sl-create')}
|
||||
{t('bui-sl-create-link')}
|
||||
</Button>
|
||||
</Card.Footer>
|
||||
</Card>
|
||||
|
|
|
@ -17,7 +17,7 @@ export const SetupLinkInfo = ({ setupLink, onClose }: { setupLink: SetupLink; on
|
|||
</div>
|
||||
<div>
|
||||
<Button size='sm' color='primary' onClick={onClose}>
|
||||
{t('bui-sl-btn-close')}
|
||||
{t('bui-shared-close')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card.Body>
|
||||
|
|
|
@ -31,7 +31,7 @@ export const SetupLinkInfoModal = ({
|
|||
)}
|
||||
<div className='modal-action'>
|
||||
<Button color='secondary' variant='outline' type='button' size='md' onClick={() => onClose()}>
|
||||
{t('close')}
|
||||
{t('bui-shared-close')}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
|
|
@ -84,11 +84,11 @@ export const SetupLinks = ({
|
|||
const noMoreResults = links.length === 0 && paginate.offset > 0;
|
||||
|
||||
let cols = [
|
||||
t('bui-sl-tenant'),
|
||||
t('bui-sl-product'),
|
||||
t('bui-shared-tenant'),
|
||||
t('bui-shared-product'),
|
||||
t('bui-sl-validity'),
|
||||
t('bui-sl-status'),
|
||||
t('bui-sl-actions'),
|
||||
t('bui-shared-status'),
|
||||
t('bui-shared-actions'),
|
||||
];
|
||||
|
||||
// Exclude fields
|
||||
|
@ -118,7 +118,7 @@ export const SetupLinks = ({
|
|||
wrap: false,
|
||||
element:
|
||||
new Date(setupLink.validTill) > new Date() ? (
|
||||
<Badge color='primary'>{t('bui-sl-active')}</Badge>
|
||||
<Badge color='primary'>{t('bui-shared-active')}</Badge>
|
||||
) : (
|
||||
<Badge color='warning'>{t('bui-sl-expired')}</Badge>
|
||||
),
|
||||
|
@ -126,7 +126,7 @@ export const SetupLinks = ({
|
|||
{
|
||||
actions: [
|
||||
{
|
||||
text: t('bui-sl-copy'),
|
||||
text: t('bui-shared-copy'),
|
||||
onClick: () => {
|
||||
copyToClipboard(setupLink.url);
|
||||
onCopy(setupLink);
|
||||
|
@ -134,7 +134,7 @@ export const SetupLinks = ({
|
|||
icon: <ClipboardDocumentIcon className='h-5 w-5' />,
|
||||
},
|
||||
{
|
||||
text: t('bui-sl-view'),
|
||||
text: t('bui-shared-view'),
|
||||
onClick: () => {
|
||||
setSetupLink(setupLink);
|
||||
setShowSetupLink(true);
|
||||
|
@ -151,7 +151,7 @@ export const SetupLinks = ({
|
|||
},
|
||||
{
|
||||
destructive: true,
|
||||
text: t('bui-sl-delete'),
|
||||
text: t('bui-shared-delete'),
|
||||
onClick: () => {
|
||||
setSetupLink(setupLink);
|
||||
setDelModal(true);
|
||||
|
|
|
@ -25,7 +25,7 @@ export const ConfirmationModal = ({
|
|||
}) => {
|
||||
const { t } = useTranslation('common');
|
||||
|
||||
const buttonText = actionButtonText || t('delete');
|
||||
const buttonText = actionButtonText || t('bui-shared-delete');
|
||||
|
||||
return (
|
||||
<Modal visible={visible} title={title} description={description}>
|
||||
|
|
|
@ -8,7 +8,7 @@ export const CopyToClipboardButton = ({ text }: { text: string }) => {
|
|||
|
||||
return (
|
||||
<IconButton
|
||||
tooltip={t('copy')}
|
||||
tooltip={t('bui-shared-copy')}
|
||||
Icon={ClipboardDocumentIcon}
|
||||
className='hover:text-primary'
|
||||
onClick={() => {
|
||||
|
|
|
@ -6,7 +6,6 @@ export { EmptyState } from './EmptyState';
|
|||
export { Error } from './Error';
|
||||
export { Badge } from './Badge';
|
||||
export { Pagination } from './Pagination';
|
||||
export { ButtonOutline } from './ButtonOutline';
|
||||
export { Modal } from './Modal';
|
||||
export { ConfirmationModal } from './ConfirmationModal';
|
||||
export { PageHeader } from './PageHeader';
|
||||
|
@ -14,6 +13,9 @@ export { LinkOutline } from './LinkOutline';
|
|||
export { LinkPrimary } from './LinkPrimary';
|
||||
export { LinkBack } from './LinkBack';
|
||||
export { pageLimit } from './Pagination';
|
||||
export { ButtonBase } from './ButtonBase';
|
||||
export { ButtonPrimary } from './ButtonPrimary';
|
||||
export { ButtonDanger } from './ButtonDanger';
|
||||
export { ButtonOutline } from './ButtonOutline';
|
||||
export { Alert } from './Alert';
|
||||
export { InputWithCopyButton } from './InputWithCopyButton';
|
||||
export { InputWithCopyButton, CopyToClipboardButton } from './InputWithCopyButton';
|
||||
|
|
|
@ -37,6 +37,23 @@ export const SSOTracerInfo = ({ urls }: { urls: { getTracer: string } }) => {
|
|||
const trace = data.data;
|
||||
const assertionType = trace.context.samlResponse ? 'Response' : trace.context.samlRequest ? 'Request' : '-';
|
||||
|
||||
let badgeText = '';
|
||||
if (trace.context.isOIDCFederated) {
|
||||
if (trace.context.requestedOIDCFlow) {
|
||||
badgeText = t('bui-shared-oidc-federation');
|
||||
} else {
|
||||
badgeText = t('bui-tracer-oauth2-federation');
|
||||
}
|
||||
} else if (trace.context.isSAMLFederated) {
|
||||
badgeText = t('bui-tracer-saml-federation');
|
||||
} else if (trace.context.isIdPFlow) {
|
||||
badgeText = t('bui-tracer-idp-login');
|
||||
} else if (trace.context.requestedOIDCFlow) {
|
||||
badgeText = t('bui-shared-oidc');
|
||||
} else {
|
||||
badgeText = t('bui-tracer-oauth2');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-3'>
|
||||
<PageHeader title={t('bui-tracer-title')} />
|
||||
|
@ -53,13 +70,7 @@ export const SSOTracerInfo = ({ urls }: { urls: { getTracer: string } }) => {
|
|||
size='md'
|
||||
className='font-mono uppercase text-white'
|
||||
aria-label={t('bui-tracer-sp-protocol')!}>
|
||||
{trace.context.requestedOIDCFlow
|
||||
? 'OIDC'
|
||||
: trace.context.isSAMLFederated
|
||||
? t('bui-tracer-saml-federation')
|
||||
: trace.context.isIdPFlow
|
||||
? t('bui-tracer-idp-login')
|
||||
: t('bui-tracer-oauth2')}
|
||||
{badgeText}
|
||||
</Badge>
|
||||
}
|
||||
/>
|
||||
|
@ -70,9 +81,9 @@ export const SSOTracerInfo = ({ urls }: { urls: { getTracer: string } }) => {
|
|||
|
||||
<ListItem term={t('bui-tracer-error')} value={trace.error} />
|
||||
|
||||
{trace.context.tenant && <ListItem term={t('bui-tracer-tenant')} value={trace.context.tenant} />}
|
||||
{trace.context.tenant && <ListItem term={t('bui-shared-tenant')} value={trace.context.tenant} />}
|
||||
|
||||
{trace.context.product && <ListItem term={t('bui-tracer-product')} value={trace.context.product} />}
|
||||
{trace.context.product && <ListItem term={t('bui-shared-product')} value={trace.context.product} />}
|
||||
|
||||
{trace.context.relayState && (
|
||||
<ListItem term={t('bui-tracer-relay-state')} value={trace.context.relayState} />
|
||||
|
@ -81,7 +92,7 @@ export const SSOTracerInfo = ({ urls }: { urls: { getTracer: string } }) => {
|
|||
{trace.context.redirectUri && (
|
||||
<ListItem
|
||||
term={
|
||||
trace.context.isIDPFlow ? t('bui-tracer-default-redirect-url') : t('bui-tracer-redirect-uri')
|
||||
trace.context.isIdPFlow ? t('bui-tracer-default-redirect-url') : t('bui-tracer-redirect-uri')
|
||||
}
|
||||
value={trace.context.redirectUri}
|
||||
/>
|
||||
|
@ -93,7 +104,7 @@ export const SSOTracerInfo = ({ urls }: { urls: { getTracer: string } }) => {
|
|||
|
||||
{trace.context.issuer && <ListItem term={t('bui-tracer-issuer')} value={trace.context.issuer} />}
|
||||
|
||||
{trace.context.acsUrl && <ListItem term={t('bui-tracer-acs-url')} value={trace.context.acsUrl} />}
|
||||
{trace.context.acsUrl && <ListItem term={t('bui-shared-acs-url')} value={trace.context.acsUrl} />}
|
||||
|
||||
{trace.context.entityId && (
|
||||
<ListItem term={t('bui-tracer-entity-id')} value={trace.context.entityId} />
|
||||
|
|
|
@ -119,14 +119,15 @@ export interface Trace {
|
|||
|
||||
export interface SSOTrace extends Omit<Trace, 'traceId' | 'timestamp'> {
|
||||
timestamp?: number /** Can be passed in from outside else will be set to Date.now() */;
|
||||
context: Trace['context'] & {
|
||||
context: {
|
||||
tenant: string;
|
||||
product: string;
|
||||
clientID: string;
|
||||
redirectUri?: string;
|
||||
requestedOIDCFlow?: boolean; // Type of OAuth client request
|
||||
isSAMLFederated?: boolean; // true if hit the SAML Federation flow
|
||||
isIDPFlow?: boolean; // true if IdP Login flow
|
||||
isOIDCFederated?: boolean; // true if hit the OIDC Federation flow
|
||||
isIdPFlow?: boolean; // true if IdP Login flow
|
||||
relayState?: string; // RelayState in SP flow
|
||||
providerName?: string; // SAML Federation SP
|
||||
acsUrl?: string; // ACS Url of SP in SAML Federation flow
|
||||
|
|
|
@ -7,7 +7,7 @@ export const WellKnownURLs = ({ jacksonUrl }: { jacksonUrl?: string }) => {
|
|||
const { t } = useTranslation('common');
|
||||
const [view, setView] = useState<'idp-config' | 'auth' | 'identity-fed'>('idp-config');
|
||||
|
||||
const viewText = t('bui-wku-view');
|
||||
const viewText = t('bui-shared-view');
|
||||
const downloadText = t('bui-wku-download');
|
||||
const baseUrl = jacksonUrl ?? '';
|
||||
|
||||
|
@ -20,7 +20,7 @@ export const WellKnownURLs = ({ jacksonUrl }: { jacksonUrl?: string }) => {
|
|||
type: 'idp-config',
|
||||
},
|
||||
{
|
||||
title: t('bui-wku-saml-configuration'),
|
||||
title: t('bui-shared-saml-configuration'),
|
||||
description: t('bui-wku-sp-config-desc'),
|
||||
href: `${baseUrl}/.well-known/saml-configuration`,
|
||||
buttonText: viewText,
|
||||
|
@ -62,7 +62,7 @@ export const WellKnownURLs = ({ jacksonUrl }: { jacksonUrl?: string }) => {
|
|||
type: 'identity-fed',
|
||||
},
|
||||
{
|
||||
title: t('bui-wku-oidc-federation'),
|
||||
title: t('bui-shared-oidc-federation'),
|
||||
description: t('bui-wku-oidc-federation-desc'),
|
||||
href: `${baseUrl}/.well-known/openid-configuration`,
|
||||
buttonText: viewText,
|
||||
|
|
|
@ -12,4 +12,4 @@ patches:
|
|||
|
||||
images:
|
||||
- name: boxyhq/jackson
|
||||
newTag: 1.21.2
|
||||
newTag: 1.21.4
|
||||
|
|
|
@ -12,4 +12,4 @@ patches:
|
|||
|
||||
images:
|
||||
- name: boxyhq/jackson
|
||||
newTag: 1.21.2
|
||||
newTag: 1.21.4
|
||||
|
|
|
@ -95,6 +95,9 @@ const jacksonOptions: JacksonOption = {
|
|||
webhookBatchSize: process.env.DSYNC_WEBHOOK_BATCH_SIZE
|
||||
? Number(process.env.DSYNC_WEBHOOK_BATCH_SIZE)
|
||||
: undefined,
|
||||
webhookBatchCronInterval: process.env.DSYNC_WEBHOOK_BATCH_CRON_INTERVAL
|
||||
? Number(process.env.DSYNC_WEBHOOK_BATCH_CRON_INTERVAL)
|
||||
: undefined,
|
||||
debugWebhooks: process.env.DSYNC_DEBUG_WEBHOOKS === 'true',
|
||||
providers: {
|
||||
google: {
|
||||
|
@ -102,6 +105,9 @@ const jacksonOptions: JacksonOption = {
|
|||
clientSecret: process.env.DSYNC_GOOGLE_CLIENT_SECRET || process.env.GOOGLE_CLIENT_SECRET || '',
|
||||
authorizePath: googleDSyncAuthorizePath,
|
||||
callbackPath: googleDSyncCallbackPath,
|
||||
cronInterval: process.env.DSYNC_GOOGLE_CRON_INTERVAL
|
||||
? Number(process.env.DSYNC_GOOGLE_CRON_INTERVAL)
|
||||
: undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
@ -1,20 +0,0 @@
|
|||
import useSWR from 'swr';
|
||||
import type { DirectorySyncProviders } from '@boxyhq/saml-jackson';
|
||||
import type { ApiError, ApiSuccess } from 'types';
|
||||
import { fetcher } from '@lib/ui/utils';
|
||||
|
||||
const useDirectoryProviders = (setupLinkToken?: string) => {
|
||||
const url = setupLinkToken
|
||||
? `/api/setup/${setupLinkToken}/directory-sync/providers`
|
||||
: '/api/admin/directory-sync/providers';
|
||||
|
||||
const { data, error, isLoading } = useSWR<ApiSuccess<DirectorySyncProviders>, ApiError>(url, fetcher);
|
||||
|
||||
return {
|
||||
providers: data?.data,
|
||||
isLoading,
|
||||
error,
|
||||
};
|
||||
};
|
||||
|
||||
export default useDirectoryProviders;
|
|
@ -7,10 +7,6 @@ export function getErrorCookie() {
|
|||
return matches ? decodeURIComponent(matches[1]) : undefined;
|
||||
}
|
||||
|
||||
export function copyToClipboard(text: string) {
|
||||
navigator.clipboard.writeText(text);
|
||||
}
|
||||
|
||||
export const fetcher = async (url: string, queryParams = '') => {
|
||||
const res = await fetch(`${url}${queryParams}`);
|
||||
|
||||
|
@ -36,11 +32,3 @@ export const fetcher = async (url: string, queryParams = '') => {
|
|||
|
||||
return resContent;
|
||||
};
|
||||
|
||||
/** Check if object is empty ({}) https://stackoverflow.com/a/32108184 */
|
||||
export const isObjectEmpty = (obj) =>
|
||||
// because Object.keys(new Date()).length === 0;
|
||||
// we have to do some additional check
|
||||
obj && // 👈 null and undefined check
|
||||
Object.keys(obj).length === 0 &&
|
||||
Object.getPrototypeOf(obj) === Object.prototype;
|
||||
|
|
|
@ -100,3 +100,10 @@ export const parsePaginateApiParams = (params: NextApiRequest['query']): Paginat
|
|||
pageToken,
|
||||
};
|
||||
};
|
||||
|
||||
export type AdminPortalSSODefaults = {
|
||||
tenant: string;
|
||||
product: string;
|
||||
redirectUrl: string;
|
||||
defaultRedirectUrl: string;
|
||||
};
|
||||
|
|
|
@ -1,59 +1,35 @@
|
|||
{
|
||||
"error_loading_page": "Unable to load this page. Maybe you don't have enough rights.",
|
||||
"documentation": "Documentation",
|
||||
"actions": "Actions",
|
||||
"active": "Active",
|
||||
"all_your_apps_using_this_connection_will_stop_working": "All your apps using this connection will stop working.",
|
||||
"back": "Back",
|
||||
"cancel": "Cancel",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied",
|
||||
"client_error": "Client error",
|
||||
"close_sidebar": "Close sidebar",
|
||||
"confirmation_modal_description": "This action cannot be undone. This will permanently delete the Connection.",
|
||||
"create_directory": "Create Directory",
|
||||
"create_new": "Create New",
|
||||
"create_sso_connection": "Create SSO Connection",
|
||||
"delete": "Delete",
|
||||
"delete_the_connection": "Delete the Connection?",
|
||||
"delete_this_connection": "Delete this Connection",
|
||||
"directory_name": "Directory name",
|
||||
"directory_provider": "Directory provider",
|
||||
"create_dsync_connection": "Create DSync Connection",
|
||||
"directory_sync": "Directory Sync",
|
||||
"edit_sso_connection": "Edit SSO Connection",
|
||||
"email": "Email",
|
||||
"enable_webhook_events_logging": "Enable Webhook events logging",
|
||||
"boxyhq_tagline": "Security Building Blocks for Developers.",
|
||||
"enterprise_sso": "Enterprise SSO",
|
||||
"idp_entity_id": "IdP Entity ID",
|
||||
"login_with_sso": "Login with SSO",
|
||||
"login_success_toast": "A sign in link has been sent to your email address.",
|
||||
"name": "Name",
|
||||
"new_directory": "New Directory",
|
||||
"new_setup_link": "New Setup Link",
|
||||
"connections": "Connections",
|
||||
"new_connection": "New Connection",
|
||||
"no_projects_found": "No projects found.",
|
||||
"oidc": "OIDC",
|
||||
"open_menu": "Open menu",
|
||||
"open_sidebar": "Open sidebar",
|
||||
"product": "Product",
|
||||
"save_changes": "Save Changes",
|
||||
"saved": "Saved",
|
||||
"server_error": "Server error",
|
||||
"sign_out": "Sign out",
|
||||
"saml": "SAML",
|
||||
"sso_error": "SSO error",
|
||||
"select_sso_type": "Select SSO type",
|
||||
"select_an_app": "Select an App to continue",
|
||||
"send_magic_link": "Send Magic Link",
|
||||
"setup_links": "Setup Links",
|
||||
"tenant": "Tenant",
|
||||
"edit_directory": "Edit Directory",
|
||||
"webhook_secret": "Webhook secret",
|
||||
"webhook_url": "Webhook URL",
|
||||
"download": "Download",
|
||||
"saml_federation_new_success": "Identity Federation app created successfully.",
|
||||
"entity_id": "Entity ID / Audience URI / Audience Restriction",
|
||||
"saml_federation_update_success": "Identity Federation app updated successfully.",
|
||||
"saml_federation_delete_success": "Identity federation app deleted successfully",
|
||||
"saml_federation_app_info": "SAML Federation App Information",
|
||||
|
@ -66,10 +42,8 @@
|
|||
"directory_updated_successfully": "Directory updated successfully",
|
||||
"dashboard": "Dashboard",
|
||||
"saml_federation": "Identity Federation",
|
||||
"sso_tracer": "SSO Tracer",
|
||||
"settings": "Settings",
|
||||
"admin_portal_sso": "SSO for Admin Portal",
|
||||
"close": "Close",
|
||||
"configuration": "Configuration",
|
||||
"view_events": "View Events",
|
||||
"new_project": "New Project",
|
||||
|
@ -78,12 +52,10 @@
|
|||
"publisher_api_base_url": "Publisher API Base URL",
|
||||
"send_event_to_url": "Send your event to the following URL",
|
||||
"curl_request": "cURL Request",
|
||||
"no_more_results": "No more results found",
|
||||
"unable_to_fetch_projects": "Unable to fetch the projects!",
|
||||
"sp_acs_url": "ACS (Assertion Consumer Service) URL / Single Sign-On URL / Destination URL",
|
||||
"sp_oidc_redirect_url": "Authorised redirect URI / Sign-in redirect URI",
|
||||
"sp_entity_id": "SP Entity ID / Identifier / Audience URI / Audience Restriction",
|
||||
"response": "Response",
|
||||
"assertion_signature": "Assertion Signature",
|
||||
"signature_algorithm": "Signature Algorithm",
|
||||
"assertion_encryption": "Assertion Encryption",
|
||||
|
@ -99,16 +71,10 @@
|
|||
"settings_branding_description": "Customize the look and feel of your portal. These values will be used in the Setup Links and IdP selection page.",
|
||||
"settings_updated_successfully": "Settings updated successfully",
|
||||
"settings_branding_title": "Portal Customization",
|
||||
"branding_logo_url_label": "Logo URL",
|
||||
"branding_favicon_url_label": "Favicon URL",
|
||||
"branding_company_name_label": "Company Name",
|
||||
"branding_primary_color_label": "Primary Color",
|
||||
"configure_sso": "Configure Single Sign-On",
|
||||
"configure_dsync": "Configure Directory Sync",
|
||||
"branding_logo_url_alt": "Provide a URL to your logo. Recommend PNG or SVG formats. The image will be capped to a maximum height of 56px.",
|
||||
"branding_favicon_url_alt": "Provide a URL to your favicon. Recommend PNG, SVG, or ICO formats.",
|
||||
"branding_company_name_alt": "Provide your company name or product name.",
|
||||
"branding_primary_color_alt": "Primary color will be applied to buttons, links, and other elements.",
|
||||
"select_an_idp": "Select an Identity Provider to continue",
|
||||
"audit_logs": "Audit Logs",
|
||||
"privacy_vault": "Privacy Vault",
|
||||
|
@ -118,22 +84,6 @@
|
|||
"discard_and_retrieve_model": "Retrieve Model?",
|
||||
"discard_and_retrieve_model_desc": "Discard any unsaved changes and retrieve the Model from the server?",
|
||||
"retrieve": "Retrieve",
|
||||
"connection_activated": "Connection activated successfully",
|
||||
"connection_deactivated": "Connection deactivated successfully",
|
||||
"inactive": "Inactive",
|
||||
"activate_connection": "Activate this connection?",
|
||||
"deactivate_connection": "Deactivate this connection?",
|
||||
"activate_sso_connection_description": "Activating this SSO connection will allow users to sign in with this connection.",
|
||||
"deactivate_sso_connection_description": "Deactivating this SSO connection will prevent users from signing in with this connection.",
|
||||
"activate_dsync_connection_description": "Activating this Directory connection will start sending webhook events to your configured webhook URL.",
|
||||
"deactivate_dsync_connection_description": "Deactivating this Directory connection will stop sending webhook events to your configured webhook URL.",
|
||||
"yes_proceed": "Yes, proceed",
|
||||
"delete_this_directory": "Delete this directory connection?",
|
||||
"delete_this_directory_desc": "This action cannot be undone. This will permanently delete the directory connection, users, and groups.",
|
||||
"directory_connection_deleted_successfully": "Directory connection deleted successfully",
|
||||
"directory_domain": "Directory Domain",
|
||||
"show_secret": "Show secret",
|
||||
"hide_secret": "Hide secret",
|
||||
"retraced_project_created": "Project created successfully",
|
||||
"project_name": "Project name",
|
||||
"create_project": "Create Project",
|
||||
|
@ -177,6 +127,7 @@
|
|||
"new_security_logs_config": "New Configuration",
|
||||
"select_type": "Select a type",
|
||||
"sso_connection_created_successfully": "SSO Connection created successfully",
|
||||
"sso_connection_deleted_successfully": "SSO Connection deleted successfully",
|
||||
"saml_federation_entity_id_generated": "SP Entity ID generated",
|
||||
"setup-link-created": "A new setup link created.",
|
||||
"setup-link-regenerated": "The setup link regenerated.",
|
||||
|
@ -193,6 +144,25 @@
|
|||
"bui-shared-next": "Next",
|
||||
"bui-shared-previous": "Previous",
|
||||
"bui-shared-delete": "Delete",
|
||||
"bui-shared-hide": "Hide",
|
||||
"bui-shared-show": "Show",
|
||||
"bui-shared-status": "Status",
|
||||
"bui-shared-webhook-secret": "Webhook Secret",
|
||||
"bui-shared-acs-url": "ACS URL",
|
||||
"bui-shared-oidc": "OIDC",
|
||||
"bui-shared-view": "View",
|
||||
"bui-shared-oidc-federation": "OIDC Federation",
|
||||
"bui-shared-saml-configuration": "SAML Configuration",
|
||||
"bui-shared-close": "Close",
|
||||
"bui-shared-copy": "Copy",
|
||||
"bui-shared-active": "Active",
|
||||
"bui-shared-email": "Email",
|
||||
"bui-shared-logo-url": "Logo URL",
|
||||
"bui-shared-logo-url-desc": "Provide a URL to your logo. Recommend PNG or SVG formats. The image will be capped to a maximum height of 56px.",
|
||||
"bui-shared-favicon-url": "Favicon URL",
|
||||
"bui-shared-favicon-url-desc": "Provide a URL to your favicon. Recommend PNG, SVG, or ICO formats.",
|
||||
"bui-shared-primary-color": "Primary Color",
|
||||
"bui-shared-primary-color-desc": "Primary color will be applied to buttons, links, and other elements.",
|
||||
"bui-wku-heading": "Here are the set of URIs you would need access to:",
|
||||
"bui-wku-idp-configuration-links": "Identity Provider Configuration links",
|
||||
"bui-wku-desc-idp-configuration": "Links for SAML/OIDC IdP setup",
|
||||
|
@ -202,7 +172,6 @@
|
|||
"bui-wku-desc-identity-federation": "Links for Identity Federation app setup",
|
||||
"bui-wku-sp-metadata": "SP Metadata",
|
||||
"bui-wku-sp-metadata-desc": "The metadata file that your customers who use federated management systems like OpenAthens and Shibboleth will need to configure your service.",
|
||||
"bui-wku-saml-configuration": "SAML Configuration",
|
||||
"bui-wku-sp-config-desc": "The configuration setup guide that your customers will need to refer to when setting up SAML application with their Identity Provider.",
|
||||
"bui-wku-saml-public-cert": "SAML Public Certificate",
|
||||
"bui-wku-saml-public-cert-desc": "The SAML Public Certificate if you want to enable encryption with your Identity Provider.",
|
||||
|
@ -214,14 +183,11 @@
|
|||
"bui-wku-saml-idp-metadata-desc": "The metadata file that your customers who use our SAML federation feature will need to set up SAML SP configuration on their application.",
|
||||
"bui-wku-saml-idp-configuration": "SAML Federation IdP Configuration",
|
||||
"bui-wku-saml-idp-config-desc": "The configuration setup guide that your customers who use our SAML federation feature will need to set up SAML SP configuration on their application.",
|
||||
"bui-wku-oidc-federation": "OIDC Federation",
|
||||
"bui-wku-oidc-federation-desc": "Our OIDC Federation well known URI which you will need if are configuring Identity Federation via OpenID Connect.",
|
||||
"bui-wku-view": "View",
|
||||
"bui-wku-download": "Download",
|
||||
"bui-fs-client-id": "Client ID",
|
||||
"bui-fs-client-secret": "Client Secret",
|
||||
"bui-fs-saml": "SAML",
|
||||
"bui-fs-oidc": "OIDC",
|
||||
"bui-fs-select-app-type": "Select App Type",
|
||||
"bui-fs-generate-sp-entity-id": "Generate Entity ID",
|
||||
"bui-fs-entity-id-instruction": "Use the button to create a distinctive identifier if your service provider does not provide a unique Entity ID and set that in your provider's configuration. If your service provider does provide a unique ID, you can use that instead.",
|
||||
|
@ -241,36 +207,22 @@
|
|||
"bui-fs-add": "Add",
|
||||
"bui-fs-no-apps": "No Identity Federation Apps found.",
|
||||
"bui-fs-no-apps-desc": "Create a new App to configure Identity Federation.",
|
||||
"bui-fs-acs-url": "ACS URL",
|
||||
"bui-fs-entity-id": "Entity ID / Audience URI / Audience Restriction",
|
||||
"bui-fs-branding-title": "Customize Look and Feel",
|
||||
"bui-fs-branding-desc": "You can customize the look and feel of the Identity Provider selection page by setting the following options:",
|
||||
"bui-fs-logo-url": "Logo URL",
|
||||
"bui-fs-logo-url-desc": "Provide a URL to your logo. Recommend PNG or SVG formats. The image will be capped to a maximum height of 56px.",
|
||||
"bui-fs-favicon-url": "Favicon URL",
|
||||
"bui-fs-favicon-url-desc": "Provide a URL to your favicon. Recommend PNG, SVG, or ICO formats.",
|
||||
"bui-fs-primary-color": "Primary Color",
|
||||
"bui-fs-primary-color-desc": "Primary color will be applied to buttons, links, and other elements.",
|
||||
"bui-fs-entity-id-edit-desc": "You can't change this value. Delete and create a new app if you need to change it.",
|
||||
"bui-fs-delete-app-title": "Delete this Identity Federation app",
|
||||
"bui-fs-delete-app-desc": "This action cannot be undone. This will permanently delete the Identity Federation app.",
|
||||
"bui-fs-apps": "Identity Federation Apps",
|
||||
"bui-fs-new-app": "New App",
|
||||
"bui-fs-saml-config": "SAML Configuration",
|
||||
"bui-fs-oidc-config": "OIDC Configuration",
|
||||
"bui-fs-saml-attributes": "SAML Attributes",
|
||||
"bui-fs-oidc-attributes": "OIDC Attributes",
|
||||
"bui-dsync-name": "Name",
|
||||
"bui-dsync-actions": "Actions",
|
||||
"bui-dsync-view": "View",
|
||||
"bui-dsync-no-groups": "No groups found for this directory.",
|
||||
"bui-dsync-no-users": "No users found for this directory.",
|
||||
"bui-dsync-no-events": "No webhook events found for this directory.",
|
||||
"bui-dsync-directory-id": "Directory ID",
|
||||
"bui-dsync-tenant": "Tenant",
|
||||
"bui-dsync-product": "Product",
|
||||
"bui-dsync-webhook-endpoint": "Webhook Endpoint",
|
||||
"bui-dsync-webhook-secret": "Webhook Secret",
|
||||
"bui-dsync-scim-endpoint": "SCIM Endpoint",
|
||||
"bui-dsync-scim-token": "SCIM Token",
|
||||
"bui-dsync-google-auth-url": "The URL that your tenant needs to authorize the application to access their Google Directory.",
|
||||
|
@ -280,16 +232,12 @@
|
|||
"bui-dsync-webhook-events": "Webhook Events",
|
||||
"bui-dsync-first-name": "First Name",
|
||||
"bui-dsync-last-name": "Last Name",
|
||||
"bui-dsync-email": "Email",
|
||||
"bui-dsync-status": "Status",
|
||||
"bui-dsync-active": "Active",
|
||||
"bui-dsync-suspended": "Suspended",
|
||||
"bui-dsync-status-code": "Status Code",
|
||||
"bui-dsync-sent-at": "Sent At",
|
||||
"bui-dsync-remove-events": "Remove Events",
|
||||
"bui-dsync-delete-events-title": "Remove Webhook Events Logs",
|
||||
"bui-dsync-delete-events-desc": "This action will permanently delete all webhook events log. Are you sure you want to proceed?",
|
||||
"bui-dsync-authorized-status": "Status",
|
||||
"bui-dsync-authorized": "Authorized",
|
||||
"bui-dsync-not-authorized": "Not Authorized",
|
||||
"bui-dsync-authorization-google": "Authorize Google Workspace",
|
||||
|
@ -305,17 +253,15 @@
|
|||
"bui-tracer-no-traces": "No SSO Traces recorded yet.",
|
||||
"bui-tracer-sp-protocol": "SP Protocol",
|
||||
"bui-tracer-saml-federation": "SAML Federation",
|
||||
"bui-tracer-oauth2-federation": "OAuth 2.0 Federation",
|
||||
"bui-tracer-idp-login": "IdP Login",
|
||||
"bui-tracer-oauth2": "OAuth 2.0",
|
||||
"bui-tracer-error": "Error",
|
||||
"bui-tracer-tenant": "Tenant",
|
||||
"bui-tracer-product": "Product",
|
||||
"bui-tracer-relay-state": "Relay State",
|
||||
"bui-tracer-default-redirect-url": "Default Redirect URL",
|
||||
"bui-tracer-redirect-uri": "Redirect URI",
|
||||
"bui-tracer-sso-connection-client-id": "SSO Connection Client ID",
|
||||
"bui-tracer-issuer": "Issuer",
|
||||
"bui-tracer-acs-url": "ACS URL",
|
||||
"bui-tracer-entity-id": "Entity ID",
|
||||
"bui-tracer-provider": "Provider",
|
||||
"bui-tracer-saml-response": "SAML Response",
|
||||
|
@ -328,13 +274,9 @@
|
|||
"bui-tracer-stack-trace": "Stack Trace",
|
||||
"bui-tracer-session-state-from-oidc-idp": "Session State (from OIDC Provider)",
|
||||
"bui-tracer-scope-from-op-error": "Scope (from OIDC Provider)",
|
||||
"bui-sl-sso-name": "Name (Optional)",
|
||||
"bui-sl-name": "Name (Optional)",
|
||||
"bui-sl-sso-description": "Description (Optional)",
|
||||
"bui-sl-create-link": "Create Setup Link",
|
||||
"bui-sl-dsync-name": "Name (Optional)",
|
||||
"bui-sl-tenant": "Tenant",
|
||||
"bui-sl-product": "Product",
|
||||
"bui-sl-create": "Create Setup Link",
|
||||
"bui-sl-expiry-days": "Expiry in days",
|
||||
"bui-sl-default-redirect-url": "Default redirect URL",
|
||||
"bui-sl-allowed-redirect-urls": "Allowed redirect URLs (newline separated)",
|
||||
|
@ -344,21 +286,14 @@
|
|||
"bui-sl-dsync-name-placeholder": "Acme Directory",
|
||||
"bui-sl-sso-name-placeholder": "Acme SSO",
|
||||
"bui-sl-share-info": "Share this link with your customers to allow them to set up the integration",
|
||||
"bui-sl-btn-close": "Close",
|
||||
"bui-sl-dsync-title": "Setup Links (Directory Sync)",
|
||||
"bui-sl-sso-title": "Setup Links (Enterprise SSO)",
|
||||
"bui-sl-no-links": "No Setup Links found.",
|
||||
"bui-sl-no-links-desc": "You have not created any Setup Links yet.",
|
||||
"bui-sl-status": "Status",
|
||||
"bui-sl-active": "Active",
|
||||
"bui-sl-expired": "Expired",
|
||||
"bui-sl-actions": "Actions",
|
||||
"bui-sl-validity": "Valid till",
|
||||
"bui-sl-new-link": "New Setup Link",
|
||||
"bui-sl-view": "View",
|
||||
"bui-sl-copy": "Copy",
|
||||
"bui-sl-regenerate": "Regenerate",
|
||||
"bui-sl-delete": "Delete",
|
||||
"bui-sl-delete-link-title": "Delete Setup Link",
|
||||
"bui-sl-delete-link-desc": "This action cannot be undone. This will permanently delete the Setup Link.",
|
||||
"bui-sl-regen-link-title": "Regenerate Setup Link",
|
||||
|
@ -366,6 +301,5 @@
|
|||
"bui-sl-link-expired": "This link has expired",
|
||||
"bui-sl-link-expire-on": "This link will expire on {{expiresAt}}.",
|
||||
"bui-sl-share-link-info": "Share this link with your customer to setup their service",
|
||||
"bui-sl-webhook-url": "Webhook URL",
|
||||
"bui-sl-webhook-secret": "Webhook Secret"
|
||||
"bui-sl-webhook-url": "Webhook URL"
|
||||
}
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -39,9 +39,9 @@
|
|||
"coverage-map": "map.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-dynamodb": "3.535.0",
|
||||
"@aws-sdk/credential-providers": "3.535.0",
|
||||
"@aws-sdk/util-dynamodb": "3.535.0",
|
||||
"@aws-sdk/client-dynamodb": "3.540.0",
|
||||
"@aws-sdk/credential-providers": "3.540.0",
|
||||
"@aws-sdk/util-dynamodb": "3.540.0",
|
||||
"@boxyhq/error-code-mnemonic": "0.1.1",
|
||||
"@boxyhq/metrics": "0.2.6",
|
||||
"@boxyhq/saml20": "1.4.14",
|
||||
|
@ -53,7 +53,7 @@
|
|||
"mixpanel": "0.18.0",
|
||||
"mongodb": "6.5.0",
|
||||
"mssql": "10.0.2",
|
||||
"mysql2": "3.9.2",
|
||||
"mysql2": "3.9.3",
|
||||
"node-forge": "1.3.1",
|
||||
"openid-client": "5.6.5",
|
||||
"pg": "8.11.3",
|
||||
|
@ -65,17 +65,17 @@
|
|||
"devDependencies": {
|
||||
"@faker-js/faker": "8.4.1",
|
||||
"@types/lodash": "4.17.0",
|
||||
"@types/node": "20.11.29",
|
||||
"@types/node": "20.11.30",
|
||||
"@types/sinon": "17.0.3",
|
||||
"@types/tap": "15.0.11",
|
||||
"cross-env": "7.0.3",
|
||||
"migrate-mongo": "11.0.0",
|
||||
"nock": "13.5.4",
|
||||
"sinon": "17.0.1",
|
||||
"tap": "18.7.1",
|
||||
"tap": "18.7.2",
|
||||
"ts-node": "10.9.2",
|
||||
"tsconfig-paths": "4.2.0",
|
||||
"typescript": "5.4.2"
|
||||
"typescript": "5.4.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16",
|
||||
|
|
|
@ -216,7 +216,7 @@ const saml = {
|
|||
name,
|
||||
label,
|
||||
description,
|
||||
forceAuthn = false,
|
||||
forceAuthn,
|
||||
metadataUrl,
|
||||
...clientInfo
|
||||
} = body;
|
||||
|
@ -302,7 +302,7 @@ const saml = {
|
|||
metadataUrl: newMetadata ? newMetadataUrl : _savedConnection.metadataUrl,
|
||||
defaultRedirectUrl: defaultRedirectUrl ? defaultRedirectUrl : _savedConnection.defaultRedirectUrl,
|
||||
redirectUrl: redirectUrlList ? redirectUrlList : _savedConnection.redirectUrl,
|
||||
forceAuthn,
|
||||
forceAuthn: typeof forceAuthn === 'boolean' ? forceAuthn : _savedConnection.forceAuthn,
|
||||
};
|
||||
|
||||
if ('sortOrder' in body) {
|
||||
|
|
|
@ -95,6 +95,7 @@ export class OAuthController implements IOAuthController {
|
|||
let requestedProduct;
|
||||
let requestedScopes: string[] | undefined;
|
||||
let requestedOIDCFlow: boolean | undefined;
|
||||
let isOIDCFederated: boolean | undefined;
|
||||
let connection: SAMLSSORecord | OIDCSSORecord | undefined;
|
||||
let fedApp: SAMLFederationApp | undefined;
|
||||
|
||||
|
@ -177,6 +178,7 @@ export class OAuthController implements IOAuthController {
|
|||
// client_id is not encoded, so we look for the connection using the client_id
|
||||
// First we check if it's a federated connection
|
||||
if (client_id.startsWith(`${clientIDFederatedPrefix}${clientIDOIDCPrefix}`)) {
|
||||
isOIDCFederated = true;
|
||||
fedApp = await this.samlFedApp.get({
|
||||
id: client_id.replace(clientIDFederatedPrefix, ''),
|
||||
});
|
||||
|
@ -227,6 +229,10 @@ export class OAuthController implements IOAuthController {
|
|||
throw new JacksonError('Redirect URL is not allowed.', 403);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isConnectionActive(connection)) {
|
||||
throw new JacksonError('SSO connection is deactivated. Please contact your administrator.', 403);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const error_description = getErrorMessage(err);
|
||||
// Save the error trace
|
||||
|
@ -237,16 +243,13 @@ export class OAuthController implements IOAuthController {
|
|||
product: requestedProduct || '',
|
||||
clientID: connection?.clientID || '',
|
||||
requestedOIDCFlow,
|
||||
isOIDCFederated,
|
||||
redirectUri: redirect_uri,
|
||||
},
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (!isConnectionActive(connection)) {
|
||||
throw new JacksonError('SSO connection is deactivated. Please contact your administrator.', 403);
|
||||
}
|
||||
|
||||
const isMissingJWTKeysForOIDCFlow =
|
||||
requestedOIDCFlow &&
|
||||
(!this.opts.openid?.jwtSigningKeys || !isJWSKeyPairLoaded(this.opts.openid.jwtSigningKeys));
|
||||
|
@ -287,6 +290,7 @@ export class OAuthController implements IOAuthController {
|
|||
product: requestedProduct,
|
||||
clientID: connection.clientID,
|
||||
requestedOIDCFlow,
|
||||
isOIDCFederated,
|
||||
redirectUri: redirect_uri,
|
||||
},
|
||||
});
|
||||
|
@ -331,6 +335,7 @@ export class OAuthController implements IOAuthController {
|
|||
product: requestedProduct as string,
|
||||
clientID: connection.clientID,
|
||||
requestedOIDCFlow,
|
||||
isOIDCFederated,
|
||||
redirectUri: redirect_uri,
|
||||
},
|
||||
});
|
||||
|
@ -367,6 +372,7 @@ export class OAuthController implements IOAuthController {
|
|||
product: requestedProduct,
|
||||
clientID: connection.clientID,
|
||||
requestedOIDCFlow,
|
||||
isOIDCFederated,
|
||||
redirectUri: redirect_uri,
|
||||
},
|
||||
});
|
||||
|
@ -386,18 +392,11 @@ export class OAuthController implements IOAuthController {
|
|||
let oidcCodeVerifier: string | undefined;
|
||||
let oidcNonce: string | undefined;
|
||||
if (connectionIsOIDC) {
|
||||
if (!this.opts.oidcPath) {
|
||||
return {
|
||||
redirect_url: OAuthErrorResponse({
|
||||
error: 'server_error',
|
||||
error_description: 'OpenID response handler path (oidcPath) is not set',
|
||||
redirect_uri,
|
||||
state,
|
||||
}),
|
||||
};
|
||||
}
|
||||
const { discoveryUrl, metadata, clientId, clientSecret } = (connection as OIDCSSORecord).oidcProvider;
|
||||
try {
|
||||
if (!this.opts.oidcPath) {
|
||||
throw new JacksonError('OpenID response handler path (oidcPath) is not set');
|
||||
}
|
||||
const oidcIssuer = await oidcIssuerInstance(discoveryUrl, metadata);
|
||||
const oidcClient = new oidcIssuer.Client({
|
||||
client_id: clientId as string,
|
||||
|
@ -422,11 +421,25 @@ export class OAuthController implements IOAuthController {
|
|||
login_hint,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const error_description = (err as errors.OPError)?.error || getErrorMessage(err);
|
||||
// Save the error trace
|
||||
const traceId = await this.ssoTracer.saveTrace({
|
||||
error: error_description,
|
||||
context: {
|
||||
tenant: requestedTenant as string,
|
||||
product: requestedProduct as string,
|
||||
clientID: connection.clientID,
|
||||
requestedOIDCFlow,
|
||||
isOIDCFederated,
|
||||
redirectUri: redirect_uri,
|
||||
},
|
||||
});
|
||||
|
||||
if (err) {
|
||||
return {
|
||||
redirect_url: OAuthErrorResponse({
|
||||
error: 'server_error',
|
||||
error_description: (err as errors.OPError)?.error || getErrorMessage(err),
|
||||
error_description: traceId ? `${traceId}: ${error_description}` : error_description,
|
||||
redirect_uri,
|
||||
state,
|
||||
}),
|
||||
|
@ -467,7 +480,14 @@ export class OAuthController implements IOAuthController {
|
|||
code_challenge,
|
||||
code_challenge_method,
|
||||
requested,
|
||||
oidcFederated: fedApp ? { redirectUrl: fedApp.redirectUrl, id: fedApp.id } : undefined,
|
||||
oidcFederated: fedApp
|
||||
? {
|
||||
redirectUrl: fedApp.redirectUrl,
|
||||
id: fedApp.id,
|
||||
clientID: fedApp.clientID,
|
||||
clientSecret: fedApp.clientSecret,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
await this.sessionStore.put(
|
||||
sessionId,
|
||||
|
@ -521,6 +541,7 @@ export class OAuthController implements IOAuthController {
|
|||
product: requestedProduct as string,
|
||||
clientID: connection.clientID,
|
||||
requestedOIDCFlow,
|
||||
isOIDCFederated,
|
||||
redirectUri: redirect_uri,
|
||||
samlRequest: samlReq?.request || '',
|
||||
},
|
||||
|
@ -615,6 +636,9 @@ export class OAuthController implements IOAuthController {
|
|||
// Found a connection
|
||||
if ('connection' in response) {
|
||||
connection = response.connection as SAMLSSORecord;
|
||||
if (!isConnectionActive(connection)) {
|
||||
throw new JacksonError('SSO connection is deactivated. Please contact your administrator.', 403);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -678,8 +702,9 @@ export class OAuthController implements IOAuthController {
|
|||
providerName: connection?.idpMetadata?.provider,
|
||||
redirectUri: isIdPFlow ? connection?.defaultRedirectUrl : session?.redirect_uri,
|
||||
issuer,
|
||||
isSAMLFederated: !!isSAMLFederated,
|
||||
isIdPFlow: !!isIdPFlow,
|
||||
isSAMLFederated,
|
||||
isOIDCFederated,
|
||||
isIdPFlow,
|
||||
requestedOIDCFlow: !!session?.requested?.oidc,
|
||||
acsUrl: session?.requested?.acsUrl,
|
||||
entityId: session?.requested?.entityId,
|
||||
|
@ -735,6 +760,7 @@ export class OAuthController implements IOAuthController {
|
|||
providerName: connection?.idpMetadata?.provider,
|
||||
redirectUri: isIdPFlow ? connection?.defaultRedirectUrl : session?.redirect_uri,
|
||||
isSAMLFederated,
|
||||
isOIDCFederated,
|
||||
isIdPFlow,
|
||||
acsUrl: session.requested.acsUrl,
|
||||
entityId: session.requested.entityId,
|
||||
|
@ -821,8 +847,8 @@ export class OAuthController implements IOAuthController {
|
|||
entityId: session?.requested?.entityId,
|
||||
redirectUri: redirect_uri,
|
||||
relayState: RelayState,
|
||||
isSAMLFederated: !!isSAMLFederated,
|
||||
isOIDCFederated: !!isOIDCFederated,
|
||||
isSAMLFederated,
|
||||
isOIDCFederated,
|
||||
requestedOIDCFlow: !!session?.requested?.oidc,
|
||||
},
|
||||
});
|
||||
|
@ -881,8 +907,8 @@ export class OAuthController implements IOAuthController {
|
|||
providerName: oidcConnection.oidcProvider.provider,
|
||||
redirectUri: redirect_uri,
|
||||
relayState: RelayState,
|
||||
isSAMLFederated: !!isSAMLFederated,
|
||||
isOIDCFederated: !!isOIDCFederated,
|
||||
isSAMLFederated,
|
||||
isOIDCFederated,
|
||||
acsUrl: session.requested.acsUrl,
|
||||
entityId: session.requested.entityId,
|
||||
requestedOIDCFlow: !!session.requested.oidc,
|
||||
|
@ -1002,10 +1028,23 @@ export class OAuthController implements IOAuthController {
|
|||
* token_type: bearer
|
||||
* expires_in: 300
|
||||
*/
|
||||
public async token(body: OAuthTokenReq): Promise<OAuthTokenRes> {
|
||||
public async token(body: OAuthTokenReq, authHeader?: string | null): Promise<OAuthTokenRes> {
|
||||
let basic_client_id: string | undefined;
|
||||
let basic_client_secret: string | undefined;
|
||||
try {
|
||||
if (authHeader) {
|
||||
// Authorization: Basic {Base64(<client_id>:<client_secret>)}
|
||||
const base64Credentials = authHeader.split(' ')[1];
|
||||
const credentials = Buffer.from(base64Credentials, 'base64').toString('ascii');
|
||||
[basic_client_id, basic_client_secret] = credentials.split(':');
|
||||
}
|
||||
} catch (err) {
|
||||
// no-op
|
||||
}
|
||||
|
||||
const { code, grant_type = 'authorization_code', redirect_uri } = body;
|
||||
const client_id = 'client_id' in body ? body.client_id : undefined;
|
||||
const client_secret = 'client_secret' in body ? body.client_secret : undefined;
|
||||
const client_id = 'client_id' in body ? body.client_id : basic_client_id;
|
||||
const client_secret = 'client_secret' in body ? body.client_secret : basic_client_secret;
|
||||
const code_verifier = 'code_verifier' in body ? body.code_verifier : undefined;
|
||||
|
||||
metrics.increment('oauthToken');
|
||||
|
@ -1042,6 +1081,16 @@ export class OAuthController implements IOAuthController {
|
|||
if (codeVal.session.code_challenge !== cv) {
|
||||
throw new JacksonError('Invalid code_verifier', 401);
|
||||
}
|
||||
|
||||
// For Federation flow, we need to verify the client_secret
|
||||
if (client_id?.startsWith(`${clientIDFederatedPrefix}${clientIDOIDCPrefix}`)) {
|
||||
if (
|
||||
client_id !== codeVal.session?.oidcFederated?.clientID ||
|
||||
client_secret !== codeVal.session?.oidcFederated?.clientSecret
|
||||
) {
|
||||
throw new JacksonError('Invalid client_id or client_secret', 401);
|
||||
}
|
||||
}
|
||||
} else if (client_id && client_secret) {
|
||||
// check if we have an encoded client_id
|
||||
if (client_id !== 'dummy') {
|
||||
|
|
|
@ -233,7 +233,8 @@ export class SetupLinkController {
|
|||
}
|
||||
|
||||
const token = crypto.randomBytes(24).toString('hex');
|
||||
const expiryInDays = expiryDays || this.opts.setupLinkExpiryDays || 3;
|
||||
const expiryInDays =
|
||||
typeof expiryDays === 'number' && expiryDays > 0 ? expiryDays : this.opts.setupLinkExpiryDays || 3;
|
||||
const setupID = dbutils.keyDigest(dbutils.keyFromParts(tenant, product, service));
|
||||
|
||||
const setupLink: SetupLink = {
|
||||
|
|
|
@ -0,0 +1,108 @@
|
|||
import { randomUUID } from 'crypto';
|
||||
import type { Storable } from '../typings';
|
||||
import { eventLockTTL } from '../directory-sync/utils';
|
||||
|
||||
const lockRenewalInterval = (eventLockTTL / 2) * 1000;
|
||||
const instanceKey = randomUUID();
|
||||
|
||||
interface Lock {
|
||||
key: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface LockParams {
|
||||
lockStore: Storable;
|
||||
key: string;
|
||||
}
|
||||
|
||||
export class CronLock {
|
||||
private lockStore: Storable;
|
||||
private key: string;
|
||||
private intervalId: NodeJS.Timeout | undefined;
|
||||
|
||||
constructor({ key, lockStore }: LockParams) {
|
||||
this.lockStore = lockStore;
|
||||
this.key = key;
|
||||
}
|
||||
|
||||
public async acquire() {
|
||||
try {
|
||||
const lock = await this.get();
|
||||
|
||||
if (lock && !this.isExpired(lock)) {
|
||||
return lock.key === instanceKey;
|
||||
}
|
||||
|
||||
await this.add();
|
||||
|
||||
// Renew the lock periodically
|
||||
if (!this.intervalId) {
|
||||
this.intervalId = setInterval(async () => {
|
||||
this.renew();
|
||||
}, lockRenewalInterval);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
console.error(`Error acquiring lock for ${instanceKey}: ${e}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async renew() {
|
||||
try {
|
||||
const lock = await this.get();
|
||||
|
||||
if (!lock) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (lock.key != instanceKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.add();
|
||||
} catch (e: any) {
|
||||
console.error(`Error renewing lock for ${instanceKey}: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async add() {
|
||||
const record = {
|
||||
key: instanceKey,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await this.lockStore.put(this.key, record);
|
||||
}
|
||||
|
||||
private async get() {
|
||||
return (await this.lockStore.get(this.key)) as Lock;
|
||||
}
|
||||
|
||||
public async release() {
|
||||
if (this.intervalId) {
|
||||
clearInterval(this.intervalId);
|
||||
}
|
||||
|
||||
const lock = await this.get();
|
||||
|
||||
if (!lock) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (lock.key != instanceKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.lockStore.delete(this.key);
|
||||
}
|
||||
|
||||
private isExpired(lock: Lock) {
|
||||
const lockDate = new Date(lock.created_at);
|
||||
const currentDate = new Date();
|
||||
const diffSeconds = (currentDate.getTime() - lockDate.getTime()) / 1000;
|
||||
|
||||
return diffSeconds > eventLockTTL;
|
||||
}
|
||||
}
|
|
@ -20,7 +20,7 @@ export const keyFromParts = (...parts: string[]): string => {
|
|||
export const sleep = (ms: number): Promise<void> => {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
};
|
||||
export function isNumeric(num) {
|
||||
function isNumeric(num) {
|
||||
return !isNaN(num);
|
||||
}
|
||||
export const normalizeOffsetAndLimit = ({
|
||||
|
|
|
@ -1,82 +0,0 @@
|
|||
import type { Storable } from '../../typings';
|
||||
|
||||
interface Lock {
|
||||
key: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface LockParams {
|
||||
lockStore: Storable;
|
||||
}
|
||||
|
||||
export class EventLock {
|
||||
private lockStore: Storable;
|
||||
|
||||
constructor({ lockStore }: LockParams) {
|
||||
this.lockStore = lockStore;
|
||||
}
|
||||
|
||||
public async acquire(key: string) {
|
||||
try {
|
||||
const lock = await this.get();
|
||||
|
||||
if (lock) {
|
||||
return lock.key === key;
|
||||
}
|
||||
|
||||
await this.add(key);
|
||||
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
console.error(`Error acquiring lock for ${key}: ${e}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async renew(key: string) {
|
||||
try {
|
||||
const lock = await this.get();
|
||||
|
||||
if (!lock) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (lock.key != key) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.add(key);
|
||||
} catch (e: any) {
|
||||
console.error(`Error renewing lock for ${key}: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
async add(key: string) {
|
||||
const record = {
|
||||
key,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await this.lockStore.put(key, record);
|
||||
}
|
||||
|
||||
async get() {
|
||||
const { data } = (await this.lockStore.getAll()) as { data: Lock[] };
|
||||
|
||||
return data.length > 0 ? data[0] : null;
|
||||
}
|
||||
|
||||
async release(key: string) {
|
||||
const lock = await this.get();
|
||||
|
||||
if (!lock) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (lock.key != key) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.lockStore.delete(key);
|
||||
}
|
||||
}
|
|
@ -1,4 +1,3 @@
|
|||
import os from 'os';
|
||||
import _ from 'lodash';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
|
@ -8,14 +7,14 @@ import type {
|
|||
IDirectoryConfig,
|
||||
Storable,
|
||||
JacksonOption,
|
||||
EventLock,
|
||||
CronLock,
|
||||
IWebhookEventsLogger,
|
||||
} from '../../typings';
|
||||
import { eventLockTTL } from '../utils';
|
||||
import { sendPayloadToWebhook } from '../../event/webhook';
|
||||
import { isConnectionActive } from '../../controller/utils';
|
||||
import { JacksonError } from '../../controller/error';
|
||||
import * as metrics from '../../opentelemetry/metrics';
|
||||
import { indexNames } from '../scim/utils';
|
||||
|
||||
enum EventStatus {
|
||||
PENDING = 'PENDING',
|
||||
|
@ -34,21 +33,21 @@ interface QueuedEvent {
|
|||
interface DirectoryEventsParams {
|
||||
opts: JacksonOption;
|
||||
eventStore: Storable;
|
||||
eventLock: EventLock;
|
||||
eventLock: CronLock;
|
||||
directories: IDirectoryConfig;
|
||||
webhookLogs: IWebhookEventsLogger;
|
||||
}
|
||||
|
||||
let isJobRunning = false;
|
||||
const lockKey = os.hostname();
|
||||
const lockRenewalInterval = (eventLockTTL / 2) * 1000;
|
||||
let intervalId: NodeJS.Timeout;
|
||||
|
||||
export class EventProcessor {
|
||||
private eventStore: Storable;
|
||||
private eventLock: EventLock;
|
||||
private eventLock: CronLock;
|
||||
private opts: JacksonOption;
|
||||
private directories: IDirectoryConfig;
|
||||
private webhookLogs: IWebhookEventsLogger;
|
||||
private cronInterval: number | undefined;
|
||||
|
||||
constructor({ opts, eventStore, eventLock, directories, webhookLogs }: DirectoryEventsParams) {
|
||||
this.opts = opts;
|
||||
|
@ -56,6 +55,12 @@ export class EventProcessor {
|
|||
this.eventStore = eventStore;
|
||||
this.directories = directories;
|
||||
this.webhookLogs = webhookLogs;
|
||||
this.cronInterval = this.opts.dsync?.webhookBatchCronInterval;
|
||||
|
||||
if (this.cronInterval) {
|
||||
this.scheduleWorker = this.scheduleWorker.bind(this);
|
||||
this.scheduleWorker();
|
||||
}
|
||||
}
|
||||
|
||||
// Push the new event to the database
|
||||
|
@ -72,7 +77,7 @@ export class EventProcessor {
|
|||
|
||||
const index = [
|
||||
{
|
||||
name: 'directoryId',
|
||||
name: indexNames.directoryId,
|
||||
value: event.directory_id,
|
||||
},
|
||||
];
|
||||
|
@ -82,28 +87,8 @@ export class EventProcessor {
|
|||
return record;
|
||||
}
|
||||
|
||||
// Process the events and send them to the webhooks as a batch
|
||||
public async process() {
|
||||
if (isJobRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(await this.eventLock.acquire(lockKey))) {
|
||||
return;
|
||||
}
|
||||
|
||||
isJobRunning = true;
|
||||
|
||||
// Renew the lock periodically
|
||||
const intervalId = setInterval(async () => {
|
||||
this.eventLock.renew(lockKey);
|
||||
}, lockRenewalInterval);
|
||||
|
||||
const batchSize = this.opts.dsync?.webhookBatchSize;
|
||||
|
||||
if (!batchSize) {
|
||||
throw new JacksonError('Batch size not defined');
|
||||
}
|
||||
private async _process() {
|
||||
const batchSize = this.opts.dsync?.webhookBatchSize || 50;
|
||||
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
|
@ -111,8 +96,7 @@ export class EventProcessor {
|
|||
const eventsCount = events.length;
|
||||
|
||||
if (eventsCount === 0) {
|
||||
clearInterval(intervalId);
|
||||
await this.eventLock.release(lockKey);
|
||||
await this.eventLock.release();
|
||||
break;
|
||||
}
|
||||
|
||||
|
@ -183,8 +167,32 @@ export class EventProcessor {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process the events and send them to the webhooks as a batch
|
||||
public async process() {
|
||||
if (isJobRunning) {
|
||||
console.info('A batch process is already running, skipping.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(await this.eventLock.acquire())) {
|
||||
return;
|
||||
}
|
||||
|
||||
isJobRunning = true;
|
||||
|
||||
try {
|
||||
this._process();
|
||||
} catch (e: any) {
|
||||
console.error(' Error processing webhooks batch:', e);
|
||||
}
|
||||
|
||||
isJobRunning = false;
|
||||
|
||||
if (this.cronInterval) {
|
||||
this.scheduleWorker();
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch next batch of events from the database
|
||||
|
@ -260,4 +268,16 @@ export class EventProcessor {
|
|||
metrics.increment('dsyncEventsBatchFailed');
|
||||
console.error('All events in the batch have failed. Please check the system.');
|
||||
}
|
||||
|
||||
public async scheduleWorker() {
|
||||
if (!this.cronInterval) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
|
||||
intervalId = setInterval(() => this.process(), this.cronInterval * 1000);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import type { JacksonOption, IEventController, EventCallback, DB } from '../typings';
|
||||
import type { JacksonOption, IEventController, DB } from '../typings';
|
||||
import { DirectoryConfig } from './scim/DirectoryConfig';
|
||||
import { DirectoryUsers } from './scim/DirectoryUsers';
|
||||
import { DirectoryGroups } from './scim/DirectoryGroups';
|
||||
|
@ -8,11 +8,11 @@ import { getDirectorySyncProviders } from './scim/utils';
|
|||
import { RequestHandler } from './request';
|
||||
import { WebhookEventsLogger } from './scim/WebhookEventsLogger';
|
||||
import { newGoogleProvider } from './non-scim/google';
|
||||
import { startSync } from './non-scim';
|
||||
import { SyncProviders } from './non-scim';
|
||||
import { storeNamespacePrefix } from '../controller/utils';
|
||||
import { eventLockTTL, handleEventCallback } from './utils';
|
||||
import { eventLockKey, eventLockTTL, googleLockKey, handleEventCallback } from './utils';
|
||||
import { EventProcessor } from './batch-events/queue';
|
||||
import { EventLock } from './batch-events/lock';
|
||||
import { CronLock } from '../cron/lock';
|
||||
|
||||
const directorySync = async (params: { db: DB; opts: JacksonOption; eventController: IEventController }) => {
|
||||
const { db, opts, eventController } = params;
|
||||
|
@ -31,7 +31,6 @@ const directorySync = async (params: { db: DB; opts: JacksonOption; eventControl
|
|||
|
||||
const directoryUsers = new DirectoryUsers({ directories, users });
|
||||
const directoryGroups = new DirectoryGroups({ directories, users, groups });
|
||||
const requestHandler = new RequestHandler(directoryUsers, directoryGroups);
|
||||
|
||||
// Fetch the supported providers
|
||||
const getProviders = () => {
|
||||
|
@ -43,7 +42,8 @@ const directorySync = async (params: { db: DB; opts: JacksonOption; eventControl
|
|||
// Batch send events
|
||||
const eventStore = db.store(storeNamespacePrefix.dsync.events);
|
||||
const lockStore = db.store(storeNamespacePrefix.dsync.lock, eventLockTTL);
|
||||
const eventLock = new EventLock({ lockStore });
|
||||
const eventLock = new CronLock({ key: eventLockKey, lockStore });
|
||||
const googleLock = new CronLock({ key: googleLockKey, lockStore });
|
||||
const eventProcessor = new EventProcessor({
|
||||
opts,
|
||||
eventStore,
|
||||
|
@ -52,6 +52,35 @@ const directorySync = async (params: { db: DB; opts: JacksonOption; eventControl
|
|||
webhookLogs,
|
||||
});
|
||||
|
||||
// Internal callback handles sending webhooks
|
||||
const internalCallback = await handleEventCallback({
|
||||
opts,
|
||||
directories,
|
||||
webhookLogs,
|
||||
eventProcessor,
|
||||
});
|
||||
|
||||
// Use the provided callback (Embedded) or fallback to the internal callback (Hosted)
|
||||
const _callback = opts.dsync?.callback || internalCallback;
|
||||
|
||||
// SCIM handler
|
||||
const requestHandler = new RequestHandler({
|
||||
directoryUsers,
|
||||
directoryGroups,
|
||||
eventCallback: _callback,
|
||||
});
|
||||
|
||||
// Google sync handler
|
||||
const syncProviders = new SyncProviders({
|
||||
userController: users,
|
||||
groupController: groups,
|
||||
opts,
|
||||
directories,
|
||||
requestHandler,
|
||||
eventCallback: _callback,
|
||||
eventLock: googleLock,
|
||||
});
|
||||
|
||||
return {
|
||||
users,
|
||||
groups,
|
||||
|
@ -60,27 +89,10 @@ const directorySync = async (params: { db: DB; opts: JacksonOption; eventControl
|
|||
requests: requestHandler,
|
||||
providers: getProviders,
|
||||
events: {
|
||||
callback: await handleEventCallback({
|
||||
opts,
|
||||
directories,
|
||||
webhookLogs,
|
||||
eventProcessor,
|
||||
}),
|
||||
batch: eventProcessor,
|
||||
},
|
||||
google: googleProvider.oauth,
|
||||
sync: async (callback: EventCallback) => {
|
||||
return await startSync(
|
||||
{
|
||||
userController: users,
|
||||
groupController: groups,
|
||||
opts,
|
||||
directories,
|
||||
requestHandler,
|
||||
},
|
||||
callback
|
||||
);
|
||||
},
|
||||
sync: syncProviders.startSync.bind(syncProviders),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -6,6 +6,7 @@ import type {
|
|||
IRequestHandler,
|
||||
JacksonOption,
|
||||
EventCallback,
|
||||
CronLock,
|
||||
} from '../../typings';
|
||||
import { SyncUsers } from './syncUsers';
|
||||
import { SyncGroups } from './syncGroups';
|
||||
|
@ -17,46 +18,109 @@ interface SyncParams {
|
|||
opts: JacksonOption;
|
||||
directories: IDirectoryConfig;
|
||||
requestHandler: IRequestHandler;
|
||||
eventCallback: EventCallback;
|
||||
eventLock: CronLock;
|
||||
}
|
||||
|
||||
// Method to start the directory sync process
|
||||
// This method will be called by the directory sync cron job
|
||||
export const startSync = async (params: SyncParams, callback: EventCallback) => {
|
||||
const { userController, groupController, opts, directories, requestHandler } = params;
|
||||
let isJobRunning = false;
|
||||
let intervalId: NodeJS.Timeout;
|
||||
|
||||
const { directory: provider } = newGoogleProvider({ directories, opts });
|
||||
export class SyncProviders {
|
||||
private userController: IUsers;
|
||||
private groupController: IGroups;
|
||||
private directories: IDirectoryConfig;
|
||||
private requestHandler: IRequestHandler;
|
||||
private opts: JacksonOption;
|
||||
private cronInterval: number | undefined;
|
||||
private eventCallback: EventCallback;
|
||||
private eventLock: CronLock;
|
||||
|
||||
const startTime = Date.now();
|
||||
constructor({
|
||||
userController,
|
||||
groupController,
|
||||
opts,
|
||||
directories,
|
||||
requestHandler,
|
||||
eventCallback,
|
||||
eventLock,
|
||||
}: SyncParams) {
|
||||
this.userController = userController;
|
||||
this.groupController = groupController;
|
||||
this.directories = directories;
|
||||
this.requestHandler = requestHandler;
|
||||
this.eventCallback = eventCallback;
|
||||
this.opts = opts;
|
||||
this.cronInterval = this.opts.dsync?.providers?.google.cronInterval;
|
||||
this.eventLock = eventLock;
|
||||
|
||||
console.info('Starting the sync process');
|
||||
|
||||
const allDirectories = await provider.getDirectories();
|
||||
|
||||
if (allDirectories.length === 0) {
|
||||
console.info('No directories found. Skipping the sync process');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
for (const directory of allDirectories) {
|
||||
const params = {
|
||||
directory,
|
||||
userController,
|
||||
groupController,
|
||||
provider,
|
||||
requestHandler,
|
||||
callback,
|
||||
};
|
||||
|
||||
await new SyncUsers(params).sync();
|
||||
await new SyncGroups(params).sync();
|
||||
await new SyncGroupMembers(params).sync();
|
||||
if (this.cronInterval) {
|
||||
this.scheduleSync = this.scheduleSync.bind(this);
|
||||
this.scheduleSync();
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
const endTime = Date.now();
|
||||
// Start the sync process
|
||||
public async startSync() {
|
||||
if (isJobRunning) {
|
||||
console.info('A sync process is already running, skipping.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.info(`Sync process completed in ${(endTime - startTime) / 1000} seconds`);
|
||||
};
|
||||
if (!(await this.eventLock.acquire())) {
|
||||
return;
|
||||
}
|
||||
|
||||
isJobRunning = true;
|
||||
|
||||
const { directory: provider } = newGoogleProvider({ directories: this.directories, opts: this.opts });
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const allDirectories = await provider.getDirectories();
|
||||
|
||||
console.info(`Starting the sync process for ${allDirectories.length} directories`);
|
||||
|
||||
for (const directory of allDirectories) {
|
||||
const params = {
|
||||
directory,
|
||||
provider,
|
||||
userController: this.userController,
|
||||
groupController: this.groupController,
|
||||
requestHandler: this.requestHandler,
|
||||
callback: this.eventCallback,
|
||||
};
|
||||
|
||||
await new SyncUsers(params).sync();
|
||||
await new SyncGroups(params).sync();
|
||||
await new SyncGroupMembers(params).sync();
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error(' Error processing Google sync:', e);
|
||||
}
|
||||
|
||||
await this.eventLock.release();
|
||||
|
||||
const endTime = Date.now();
|
||||
console.info(`Sync process completed in ${(endTime - startTime) / 1000} seconds`);
|
||||
|
||||
isJobRunning = false;
|
||||
|
||||
if (this.cronInterval) {
|
||||
this.scheduleSync();
|
||||
}
|
||||
}
|
||||
|
||||
// Schedule the next sync process
|
||||
private scheduleSync() {
|
||||
if (!this.cronInterval) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
|
||||
intervalId = setInterval(() => this.startSync(), this.cronInterval * 1000);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,19 +6,30 @@ import type {
|
|||
DirectorySyncRequest,
|
||||
} from '../typings';
|
||||
|
||||
interface RequestHandlerParams {
|
||||
directoryUsers: IDirectoryUsers;
|
||||
directoryGroups: IDirectoryGroups;
|
||||
eventCallback: EventCallback;
|
||||
}
|
||||
|
||||
export class RequestHandler {
|
||||
constructor(
|
||||
private directoryUsers: IDirectoryUsers,
|
||||
private directoryGroups: IDirectoryGroups
|
||||
) {}
|
||||
private directoryUsers: IDirectoryUsers;
|
||||
private directoryGroups: IDirectoryGroups;
|
||||
private eventCallback: EventCallback;
|
||||
|
||||
constructor({ directoryUsers, directoryGroups, eventCallback }: RequestHandlerParams) {
|
||||
this.directoryUsers = directoryUsers;
|
||||
this.directoryGroups = directoryGroups;
|
||||
this.eventCallback = eventCallback;
|
||||
}
|
||||
|
||||
async handle(request: DirectorySyncRequest, callback?: EventCallback): Promise<DirectorySyncResponse> {
|
||||
const resourceType = request.resourceType.toLowerCase();
|
||||
|
||||
if (resourceType === 'users') {
|
||||
return await this.directoryUsers.handleRequest(request, callback);
|
||||
return await this.directoryUsers.handleRequest(request, callback || this.eventCallback);
|
||||
} else if (resourceType === 'groups') {
|
||||
return await this.directoryGroups.handleRequest(request, callback);
|
||||
return await this.directoryGroups.handleRequest(request, callback || this.eventCallback);
|
||||
}
|
||||
|
||||
return { status: 404, data: {} };
|
||||
|
|
|
@ -4,11 +4,7 @@ import type { Group, DatabaseStore, PaginationParams, Response, GroupMembership
|
|||
import * as dbutils from '../../db/utils';
|
||||
import { apiError, JacksonError } from '../../controller/error';
|
||||
import { Base } from './Base';
|
||||
|
||||
const indexNames = {
|
||||
directoryIdDisplayname: 'directoryIdDisplayname',
|
||||
directoryId: 'directoryId',
|
||||
};
|
||||
import { indexNames } from './utils';
|
||||
|
||||
interface CreateGroupParams {
|
||||
directoryId: string;
|
||||
|
@ -171,7 +167,7 @@ export class Groups extends Base {
|
|||
user_id: userId,
|
||||
},
|
||||
{
|
||||
name: 'groupId',
|
||||
name: indexNames.groupId,
|
||||
value: groupId,
|
||||
}
|
||||
);
|
||||
|
@ -310,7 +306,7 @@ export class Groups extends Base {
|
|||
try {
|
||||
const { data } = (await this.store('members').getByIndex(
|
||||
{
|
||||
name: 'groupId',
|
||||
name: indexNames.groupId,
|
||||
value: groupId,
|
||||
},
|
||||
pageOffset,
|
||||
|
@ -331,14 +327,16 @@ export class Groups extends Base {
|
|||
|
||||
// Delete all groups from a directory
|
||||
async deleteAll(directoryId: string) {
|
||||
const index = {
|
||||
name: indexNames.directoryId,
|
||||
value: directoryId,
|
||||
};
|
||||
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
const { data: groups } = await this.store('groups').getByIndex(index, 0, this.bulkDeleteBatchSize);
|
||||
const { data: groups } = await this.store('groups').getByIndex(
|
||||
{
|
||||
name: indexNames.directoryId,
|
||||
value: directoryId,
|
||||
},
|
||||
0,
|
||||
this.bulkDeleteBatchSize
|
||||
);
|
||||
|
||||
if (!groups || groups.length === 0) {
|
||||
break;
|
||||
|
@ -356,14 +354,16 @@ export class Groups extends Base {
|
|||
|
||||
// Remove all users from a group
|
||||
public async removeAllUsers(groupId: string) {
|
||||
const index = {
|
||||
name: 'groupId',
|
||||
value: groupId,
|
||||
};
|
||||
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
const { data: members } = await this.store('members').getByIndex(index, 0, this.bulkDeleteBatchSize);
|
||||
const { data: members } = await this.store('members').getByIndex(
|
||||
{
|
||||
name: indexNames.groupId,
|
||||
value: groupId,
|
||||
},
|
||||
0,
|
||||
this.bulkDeleteBatchSize
|
||||
);
|
||||
|
||||
if (!members || members.length === 0) {
|
||||
break;
|
||||
|
|
|
@ -2,11 +2,7 @@ import type { User, DatabaseStore, PaginationParams, Response } from '../../typi
|
|||
import { apiError, JacksonError } from '../../controller/error';
|
||||
import { Base } from './Base';
|
||||
import { keyFromParts } from '../../db/utils';
|
||||
|
||||
const indexNames = {
|
||||
directoryIdUsername: 'directoryIdUsername',
|
||||
directoryId: 'directoryId',
|
||||
};
|
||||
import { indexNames } from './utils';
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
|
@ -215,14 +211,16 @@ export class Users extends Base {
|
|||
|
||||
// Delete all users from a directory
|
||||
async deleteAll(directoryId: string) {
|
||||
const index = {
|
||||
name: indexNames.directoryId,
|
||||
value: directoryId,
|
||||
};
|
||||
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
const { data: users } = await this.store('users').getByIndex(index, 0, this.bulkDeleteBatchSize);
|
||||
const { data: users } = await this.store('users').getByIndex(
|
||||
{
|
||||
name: indexNames.directoryId,
|
||||
value: directoryId,
|
||||
},
|
||||
0,
|
||||
this.bulkDeleteBatchSize
|
||||
);
|
||||
|
||||
if (!users || users.length === 0) {
|
||||
break;
|
||||
|
|
|
@ -8,7 +8,8 @@ import type {
|
|||
PaginationParams,
|
||||
} from '../../typings';
|
||||
import { Base } from './Base';
|
||||
import { webhookEventTTL } from '../utils';
|
||||
import { webhookLogsTTL } from '../utils';
|
||||
import { indexNames } from './utils';
|
||||
|
||||
type GetAllParams = PaginationParams & {
|
||||
directoryId?: string;
|
||||
|
@ -81,7 +82,7 @@ export class WebhookEventsLogger extends Base {
|
|||
};
|
||||
|
||||
await this.eventStore().put(id, log, {
|
||||
name: 'directoryId',
|
||||
name: indexNames.directoryId,
|
||||
value: directory.id,
|
||||
});
|
||||
|
||||
|
@ -130,7 +131,7 @@ export class WebhookEventsLogger extends Base {
|
|||
|
||||
if (directoryId) {
|
||||
const index = {
|
||||
name: 'directoryId',
|
||||
name: indexNames.directoryId,
|
||||
value: directoryId,
|
||||
};
|
||||
|
||||
|
@ -148,14 +149,16 @@ export class WebhookEventsLogger extends Base {
|
|||
|
||||
// Delete all event logs for a directory
|
||||
async deleteAll(directoryId: string) {
|
||||
const index = {
|
||||
name: 'directoryId',
|
||||
value: directoryId,
|
||||
};
|
||||
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
const { data: events } = await this.eventStore().getByIndex(index, 0, this.bulkDeleteBatchSize);
|
||||
const { data: events } = await this.eventStore().getByIndex(
|
||||
{
|
||||
name: indexNames.directoryId,
|
||||
value: directoryId,
|
||||
},
|
||||
0,
|
||||
this.bulkDeleteBatchSize
|
||||
);
|
||||
|
||||
if (!events || events.length === 0) {
|
||||
break;
|
||||
|
@ -167,6 +170,6 @@ export class WebhookEventsLogger extends Base {
|
|||
|
||||
// Get the store for the events
|
||||
private eventStore() {
|
||||
return this.store('logs', webhookEventTTL);
|
||||
return this.store('logs', webhookLogsTTL);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,6 +3,13 @@ import _ from 'lodash';
|
|||
import { DirectorySyncProviders } from '../../typings';
|
||||
import type { DirectoryType, User, UserPatchOperation, GroupPatchOperation } from '../../typings';
|
||||
|
||||
export const indexNames = {
|
||||
directoryIdUsername: 'directoryIdUsername',
|
||||
directoryIdDisplayname: 'directoryIdDisplayname',
|
||||
directoryId: 'directoryId',
|
||||
groupId: 'groupId',
|
||||
};
|
||||
|
||||
const parseUserRoles = (roles: string | string[]) => {
|
||||
if (typeof roles === 'string') {
|
||||
return roles.split(',');
|
||||
|
|
|
@ -8,7 +8,7 @@ import { WebhookEventsLogger } from './scim/WebhookEventsLogger';
|
|||
import { ApiError } from '../typings';
|
||||
import { RequestHandler } from './request';
|
||||
import { EventProcessor } from './batch-events/queue';
|
||||
import { EventLock as Lock } from './batch-events/lock';
|
||||
import { CronLock as Lock } from '../cron/lock';
|
||||
|
||||
export type IDirectorySyncController = Awaited<ReturnType<typeof directorySync>>;
|
||||
export type IDirectoryConfig = InstanceType<typeof DirectoryConfig>;
|
||||
|
@ -19,7 +19,7 @@ export type IGroups = InstanceType<typeof Groups>;
|
|||
export type IWebhookEventsLogger = InstanceType<typeof WebhookEventsLogger>;
|
||||
export type IRequestHandler = InstanceType<typeof RequestHandler>;
|
||||
export type IEventProcessor = InstanceType<typeof EventProcessor>;
|
||||
export type EventLock = InstanceType<typeof Lock>;
|
||||
export type CronLock = InstanceType<typeof Lock>;
|
||||
|
||||
export type DirectorySyncEventType =
|
||||
| 'user.created'
|
||||
|
|
|
@ -14,8 +14,10 @@ import { sendPayloadToWebhook } from '../event/webhook';
|
|||
import { transformEventPayload } from './scim/transform';
|
||||
import { JacksonError } from '../controller/error';
|
||||
|
||||
export const eventLockTTL = 6;
|
||||
export const webhookEventTTL = 7 * 24 * 60 * 60;
|
||||
export const eventLockTTL = 30;
|
||||
export const webhookLogsTTL = 7 * 24 * 60 * 60;
|
||||
export const eventLockKey = 'dsync-event-lock';
|
||||
export const googleLockKey = 'dsync-google-lock';
|
||||
|
||||
interface Payload {
|
||||
directory: Directory;
|
||||
|
|
|
@ -12,14 +12,15 @@ export interface Trace {
|
|||
|
||||
export interface SSOTrace extends Omit<Trace, 'traceId' | 'timestamp'> {
|
||||
timestamp?: number /** Can be passed in from outside else will be set to Date.now() */;
|
||||
context: Trace['context'] & {
|
||||
context: {
|
||||
tenant: string;
|
||||
product: string;
|
||||
clientID: string;
|
||||
redirectUri?: string;
|
||||
requestedOIDCFlow?: boolean; // Type of OAuth client request
|
||||
isSAMLFederated?: boolean; // true if hit the SAML Federation flow
|
||||
isIDPFlow?: boolean; // true if IdP Login flow
|
||||
isOIDCFederated?: boolean; // true if hit the OIDC Federation flow
|
||||
isIdPFlow?: boolean; // true if IdP Login flow
|
||||
relayState?: string; // RelayState in SP flow
|
||||
providerName?: string; // SAML Federation SP
|
||||
acsUrl?: string; // ACS Url of SP in SAML Federation flow
|
||||
|
|
|
@ -7,6 +7,7 @@ export * from './directory-sync/types';
|
|||
export * from './event/types';
|
||||
|
||||
import db from './db/db';
|
||||
import { EventCallback } from './typings';
|
||||
|
||||
export type DB = Awaited<ReturnType<typeof db.new>>;
|
||||
|
||||
|
@ -456,6 +457,7 @@ export interface JacksonOption {
|
|||
webhook?: Webhook;
|
||||
dsync?: {
|
||||
webhookBatchSize?: number;
|
||||
webhookBatchCronInterval?: number;
|
||||
debugWebhooks?: boolean;
|
||||
providers?: {
|
||||
google: {
|
||||
|
@ -463,8 +465,10 @@ export interface JacksonOption {
|
|||
clientSecret: string;
|
||||
authorizePath: string;
|
||||
callbackPath: string;
|
||||
cronInterval?: number;
|
||||
};
|
||||
};
|
||||
callback?: EventCallback;
|
||||
};
|
||||
|
||||
/** The number of days a setup link is valid for. Defaults to 3 days. */
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue