Merge branch 'main' into feature/splunk-direct-delivery

This commit is contained in:
ukrocks007 2024-03-28 21:09:19 +05:30
commit 4b872939e4
135 changed files with 5003 additions and 4654 deletions

View File

@ -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

View File

@ -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
View File

@ -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

View File

@ -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;

View File

@ -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>
);
};

View File

@ -1,9 +0,0 @@
import { ButtonBase, type ButtonBaseProps } from './ButtonBase';
export const ButtonDanger = ({ children, ...other }: ButtonBaseProps) => {
return (
<ButtonBase color='error' {...other}>
{children}
</ButtonBase>
);
};

View File

@ -1,9 +0,0 @@
import { ButtonBase, type ButtonBaseProps } from './ButtonBase';
export const ButtonLink = ({ children, ...other }: ButtonBaseProps) => {
return (
<ButtonBase variant='link' {...other}>
{children}
</ButtonBase>
);
};

View File

@ -1,9 +0,0 @@
import { ButtonBase, type ButtonBaseProps } from './ButtonBase';
export const ButtonOutline = ({ children, ...other }: ButtonBaseProps) => {
return (
<ButtonBase variant='outline' {...other}>
{children}
</ButtonBase>
);
};

View File

@ -1,9 +0,0 @@
import { ButtonBase, type ButtonBaseProps } from './ButtonBase';
export const ButtonPrimary = ({ children, ...other }: ButtonBaseProps) => {
return (
<ButtonBase color='primary' {...other}>
{children}
</ButtonBase>
);
};

View File

@ -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'
/>
</>
);
};

View File

@ -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;

View File

@ -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'
/>
</>
);
};

View File

@ -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;

View File

@ -1,5 +0,0 @@
const ErrorMessage = () => {
return <p>{`Unable to load this page. Maybe you don't have enough rights.`}</p>;
};
export default ErrorMessage;

View File

@ -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>
);
};

View File

@ -1,4 +1,4 @@
import EmptyState from './EmptyState';
import { EmptyState } from '@boxyhq/internal-ui';
const LicenseRequired = () => {
return (

View File

@ -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;
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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;

View File

@ -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;

View File

@ -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'),
},
],

View File

@ -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'>

View File

@ -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>
</>
);

View File

@ -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>
</>
);

View File

@ -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} />
</>
);
};

View File

@ -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 },
},
];

View File

@ -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;
}

View File

@ -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>
);

View File

@ -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);
}}
/>
</>
);
};

View File

@ -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>

View File

@ -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>
);
};

View File

@ -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} />
</>
);
};

View File

@ -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');

View File

@ -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';

View File

@ -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 = () => {

View File

@ -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';

View File

@ -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;

View File

@ -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 }) => {

View File

@ -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}
/>
);

View File

@ -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();

View File

@ -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();

19
components/styles.ts Normal file
View File

@ -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',
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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');

View File

@ -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();
});

View File

@ -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>

View File

@ -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>

23
find-dupe-locale.js Normal file
View File

@ -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

View File

@ -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",

View File

@ -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' />,
},

View File

@ -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>

View File

@ -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',
},
};

View File

@ -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' />,
},

View File

@ -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}

View File

@ -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>

View File

@ -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')}

View File

@ -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}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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);

View File

@ -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}>

View File

@ -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={() => {

View File

@ -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';

View File

@ -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} />

View File

@ -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

View File

@ -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,

View File

@ -12,4 +12,4 @@ patches:
images:
- name: boxyhq/jackson
newTag: 1.21.2
newTag: 1.21.4

View File

@ -12,4 +12,4 @@ patches:
images:
- name: boxyhq/jackson
newTag: 1.21.2
newTag: 1.21.4

View File

@ -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,
},
},
},

View File

@ -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;

View File

@ -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;

View File

@ -100,3 +100,10 @@ export const parsePaginateApiParams = (params: NextApiRequest['query']): Paginat
pageToken,
};
};
export type AdminPortalSSODefaults = {
tenant: string;
product: string;
redirectUrl: string;
defaultRedirectUrl: string;
};

View File

@ -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"
}

692
npm/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -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",

View File

@ -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) {

View File

@ -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') {

View File

@ -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 = {

108
npm/src/cron/lock.ts Normal file
View File

@ -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;
}
}

View File

@ -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 = ({

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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),
};
};

View File

@ -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);
}
}

View File

@ -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: {} };

View File

@ -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;

View File

@ -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;

View File

@ -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);
}
}

View File

@ -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(',');

View File

@ -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'

View File

@ -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;

View File

@ -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

View File

@ -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