Add `@boxyhq/internal-ui` (#2305)

* add WellKnownURLs

* Fix translation keys

* Update dependencies and add IdP Configuration

* Update common.json with new translations

* wip

* Update @boxyhq/internal-ui version to 0.0.5

* add internal ui folder

* Fix imports and build

* Refactor internal-ui package structure

* wip shared UI

* Fix the build

* Add new components and hooks for directory sync

* lint fix

* updated swr

* users

* Refactor shared components and fix API endpoints***

***Update directory user page and add new federated SAML app

* Fix lint

* wip

* Add new files and update existing files

* Refactor DirectoryGroups and DirectoryInfo components

* Update localization strings for directory UI

* Update Google Auth URL description in common.json

* Refactor directory tab and add delete functionality to webhook logs

* Delete unused files and update dependencies

* Fix column declaration

* Add internal-ui/dist to .gitignore

* Update page limit and add new dependencies

* wip

* Refactor directory search in user API endpoint

* wip

* Refactor directory retrieval logic in user and group API handlers

* Add API endpoints for retrieving webhook events

* Add query parameters to API URLs in DirectoryGroups

* Add Google authorization status badge and handle pagination in FederatedSAMLApps

* Add router prop to AppsList component and update page header titles

* UI changes

* Add new files and export functions

* Remove unused router prop

* Add PencilIcon to FederatedSAMLApps

* Refactor FederatedSAMLApps and NewFederatedSAMLApp components

* lint fix

* add jose npm to dev dep

* added missing strings

* locale strings fix

* locale strings cleanup

* update package-lock

* Add prepublish step

* Build and publish npm and internal ui

* Refactor install step

* Run npm install (for local) inside internal ui automatically using prepare

* Remove eslint setup for internal-ui

* Add `--legacy-peer-deps` to prevent installing peer dependencies

* Fix the types import path

* wip

* wip

* Fix the types

* Format

* Update package-lock

* Cleanup

* Try adding jose library version 5.2.2

* COPY internal-ui before npm install

* COPY internal-ui in builder stage

* fixed sort order for jose

---------

Co-authored-by: Deepak Prabhakara <deepak@boxyhq.com>
Co-authored-by: Aswin V <vaswin91@gmail.com>
This commit is contained in:
Kiran K 2024-02-28 03:42:39 +05:30 committed by GitHub
parent 69454c2a23
commit 734de64c4d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
85 changed files with 16545 additions and 11286 deletions

View File

@ -1,4 +1,5 @@
node_modules
dist
npm/dist
npm/migration
npm/migration
internal-ui/dist

View File

@ -407,6 +407,7 @@ jobs:
strategy:
matrix:
node-version: [20]
package: [npm, internal-ui]
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
steps:
@ -424,9 +425,8 @@ jobs:
cache: 'npm'
check-latest: true
- run: npm install --legacy-peer-deps
working-directory: ./npm
- name: Publish NPM
working-directory: ./${{ matrix.package }}
- name: Publish ${{ matrix.package }}
if: github.ref == 'refs/heads/release' || contains(github.ref, 'refs/tags/beta-v')
run: |
npm install -g json
@ -436,6 +436,6 @@ jobs:
json -I -f package.json -e "this.version=\"${JACKSON_VERSION}\""
npm publish --tag ${{ needs.ci.outputs.PUBLISH_TAG }} --access public
working-directory: ./npm
working-directory: ./${{ matrix.package }}
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

2
.gitignore vendored
View File

@ -46,3 +46,5 @@ publishTag.txt
_dev/docker/dynamodb/shared-local-instance.db
public/terminus/sprites.png
**/.tap/**
internal-ui/dist

View File

@ -22,4 +22,6 @@ npm/package-lock.json
**/LICENSE
swagger/swagger.json
e2e/state.json
e2e/state.json
internal-ui/dist/**

View File

@ -10,6 +10,7 @@ WORKDIR /app
# Install dependencies based on the preferred package manager
COPY package.json package-lock.json ./
COPY npm npm
COPY internal-ui internal-ui
COPY migrate.sh prebuild.ts ./
RUN npm install
@ -20,6 +21,7 @@ FROM base AS builder
WORKDIR /app
COPY --from=deps /app/npm ./npm
COPY --from=deps /app/internal-ui ./internal-ui
COPY --from=deps /app/node_modules ./node_modules
COPY . .

View File

@ -9,8 +9,8 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
switch (method) {
case 'GET':
return await handleGET(req, res);
case 'PUT':
return await handlePUT(req, res);
case 'PATCH':
return await handlePATCH(req, res);
case 'DELETE':
return await handleDELETE(req, res);
default:
@ -44,7 +44,7 @@ const handleGET = async (req: NextApiRequest, res: NextApiResponse) => {
};
// Update SAML Federation app
const handlePUT = async (req: NextApiRequest, res: NextApiResponse) => {
const handlePATCH = async (req: NextApiRequest, res: NextApiResponse) => {
const { samlFederatedController } = await jackson();
const updatedApp = await samlFederatedController.app.update(req.body);

View File

@ -1,291 +1,42 @@
import type { AdminPortalBranding, SAMLFederationApp } from '@boxyhq/saml-jackson';
import { useEffect, useState } from 'react';
import useSWR from 'swr';
import { useRouter } from 'next/router';
import { useTranslation } from 'next-i18next';
import TagsInput from 'react-tagsinput';
import { fetcher } from '@lib/ui/utils';
import Loading from '@components/Loading';
import { errorToast, successToast } from '@components/Toaster';
import ConfirmationModal from '@components/ConfirmationModal';
import type { ApiError, ApiResponse, ApiSuccess } from 'types';
import { LinkBack } from '@components/LinkBack';
import { ButtonPrimary } from '@components/ButtonPrimary';
import { ButtonDanger } from '@components/ButtonDanger';
import { LinkOutline } from '@components/LinkOutline';
import LicenseRequired from '@components/LicenseRequired';
import { EditFederatedSAMLApp, LinkBack } from '@boxyhq/internal-ui';
import 'react-tagsinput/react-tagsinput.css';
const UpdateApp = ({ hasValidLicense }: { hasValidLicense: boolean }) => {
const { t } = useTranslation('common');
const router = useRouter();
const [loading, setLoading] = useState(false);
const [app, setApp] = useState<SAMLFederationApp & Omit<AdminPortalBranding, 'companyName'>>({
id: '',
name: '',
tenant: '',
product: '',
acsUrl: '',
entityId: '',
logoUrl: '',
faviconUrl: '',
primaryColor: '',
tenants: [],
mappings: [],
});
const { t } = useTranslation('common');
const { id } = router.query as { id: string };
const { data, error, isLoading, mutate } = useSWR<ApiSuccess<SAMLFederationApp>, ApiError>(
`/api/admin/federated-saml/${id}`,
fetcher,
{
revalidateOnFocus: false,
}
);
useEffect(() => {
if (data) {
setApp(data.data);
}
}, [data]);
if (!hasValidLicense) {
return <LicenseRequired />;
}
if (error) {
errorToast(error.message);
return null;
}
if (isLoading) {
return <Loading />;
}
const onSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setLoading(true);
const rawResponse = await fetch(`/api/admin/federated-saml/${app.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(app),
});
setLoading(false);
const response: ApiResponse<SAMLFederationApp> = await rawResponse.json();
if ('error' in response) {
errorToast(response.error.message);
return;
}
if ('data' in response) {
mutate();
successToast(t('saml_federation_update_success'));
}
};
const onChange = (event: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const target = event.target as HTMLInputElement;
setApp({
...app,
[target.id]: target.value,
});
};
return (
<>
<div className='space-y-4'>
<LinkBack href='/admin/federated-saml' />
<div className='mb-5 flex items-center justify-between'>
<h2 className='mt-5 font-bold text-gray-700 md:text-xl'>{t('saml_federation_update_app')}</h2>
<div>
<LinkOutline href={'/.well-known/idp-configuration'} target='_blank' className='m-2'>
{t('idp_configuration')}
</LinkOutline>
</div>
</div>
<div className='rounded border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800'>
<form onSubmit={onSubmit}>
<div className='space-y-3'>
<div className='form-control w-full'>
<label className='label'>
<span className='label-text'>{t('tenant')}</span>
</label>
<input type='text' className='input-bordered input' defaultValue={app.tenant} disabled />
</div>
<div className='form-control w-full'>
<label className='label'>
<span className='label-text'>{t('product')}</span>
</label>
<input type='text' className='input-bordered input' defaultValue={app.product} disabled />
</div>
<div className='form-control w-full'>
<label className='label'>
<span className='label-text'>{t('entity_id')}</span>
</label>
<input type='url' className='input-bordered input' defaultValue={app.entityId} disabled />
<label className='label'>
<span className='label-text-alt'>{t('desc-entity-id')}</span>
</label>
</div>
<div className='form-control w-full'>
<label className='label'>
<span className='label-text'>{t('name')}</span>
</label>
<input
type='text'
id='name'
className='input-bordered input'
required
onChange={onChange}
value={app.name}
/>
</div>
<div className='form-control w-full'>
<label className='label'>
<span className='label-text'>{t('acs_url')}</span>
</label>
<input
type='url'
id='acsUrl'
className='input-bordered input'
required
onChange={onChange}
value={app.acsUrl}
/>
</div>
<div className='form-control w-full'>
<label className='label'>
<span className='label-text'>{t('tenants')}</span>
</label>
<TagsInput
value={app.tenants || []}
onChange={(tags) => setApp({ ...app, tenants: tags })}
onlyUnique={true}
inputProps={{
placeholder: t('enter_tenant'),
}}
focusedClassName='input-focused'
addOnBlur={true}
/>
<label className='label'>
<span className='label-text-alt'>{t('tenants_mapping_description')}</span>
</label>
</div>
<div className='pt-4'>
<p className='text-base leading-6 text-gray-500'>{t('customize_branding')}:</p>
</div>
<div className='form-control w-full'>
<label className='label'>
<span className='label-text'>{t('branding_logo_url_label')}</span>
</label>
<input
type='url'
id='logoUrl'
className='input-bordered input'
onChange={onChange}
placeholder='https://company.com/logo.png'
value={app.logoUrl || ''}
/>
<label className='label'>
<span className='label-text-alt'>{t('branding_logo_url_alt')}</span>
</label>
</div>
<div className='form-control w-full'>
<label className='label'>
<span className='label-text'>{t('branding_favicon_url_label')}</span>
</label>
<input
type='url'
id='faviconUrl'
className='input-bordered input'
onChange={onChange}
placeholder='https://company.com/favicon.ico'
value={app.faviconUrl || ''}
/>
<label className='label'>
<span className='label-text-alt'>{t('branding_favicon_url_alt')}</span>
</label>
</div>
<div className='form-control'>
<label className='label'>
<span className='label-text'>{t('branding_primary_color_label')}</span>
</label>
<input type='color' id='primaryColor' onChange={onChange} value={app.primaryColor || ''} />
<label className='label'>
<span className='label-text-alt'>{t('branding_primary_color_alt')}</span>
</label>
</div>
<div>
<ButtonPrimary type='submit' loading={loading}>
{t('save_changes')}
</ButtonPrimary>
</div>
</div>
</form>
</div>
<DeleteApp app={app} />
</>
);
};
export const DeleteApp = ({ app }: { app: SAMLFederationApp }) => {
const { t } = useTranslation('common');
const [delModalVisible, setDelModalVisible] = useState(false);
const deleteApp = async () => {
const rawResponse = await fetch(`/api/admin/federated-saml/${app.id}`, {
method: 'DELETE',
});
const response: ApiResponse<unknown> = await rawResponse.json();
if ('error' in response) {
errorToast(response.error.message);
return;
}
if ('data' in response) {
successToast(t('saml_federation_delete_success'));
window.location.href = '/admin/federated-saml';
}
};
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_saml_federation_app')}</h6>
<p className='font-light'>{t('all_your_apps_using_this_connection_will_stop_working')}</p>
</div>
<ButtonDanger
type='button'
data-modal-toggle='popup-modal'
onClick={() => {
setDelModalVisible(true);
}}>
{t('delete')}
</ButtonDanger>
</section>
<ConfirmationModal
title={t('delete_the_saml_federation_app')}
description={t('confirmation_modal_description')}
visible={delModalVisible}
onConfirm={deleteApp}
onCancel={() => {
setDelModalVisible(false);
<EditFederatedSAMLApp
urls={{
getApp: `/api/admin/federated-saml/${id}`,
updateApp: `/api/admin/federated-saml/${id}`,
deleteApp: `/api/admin/federated-saml/${id}`,
}}
onUpdate={() => {
successToast(t('saml_federation_update_success'));
}}
onDelete={() => {
successToast(t('saml_federation_delete_success'));
router.push('/admin/federated-saml');
}}
onError={(error) => {
errorToast(error.message);
}}
/>
</>
</div>
);
};

View File

@ -1,130 +1,18 @@
import { useEffect } from 'react';
import type { SAMLFederationApp } from '@boxyhq/saml-jackson';
import useSWR from 'swr';
import { useTranslation } from 'next-i18next';
import type { ApiError, ApiSuccess } from 'types';
import { fetcher } from '@lib/ui/utils';
import Loading from '@components/Loading';
import EmptyState from '@components/EmptyState';
import { LinkPrimary } from '@components/LinkPrimary';
import { pageLimit, Pagination } from '@components/Pagination';
import usePaginate from '@lib/ui/hooks/usePaginate';
import { LinkOutline } from '@components/LinkOutline';
import PencilIcon from '@heroicons/react/24/outline/PencilIcon';
import router from 'next/router';
import LicenseRequired from '@components/LicenseRequired';
import { errorToast } from '@components/Toaster';
import { Table } from '@components/table/Table';
import { FederatedSAMLApps } from '@boxyhq/internal-ui';
const AppsList = ({ hasValidLicense }: { hasValidLicense: boolean }) => {
const { t } = useTranslation('common');
const { paginate, setPaginate, pageTokenMap, setPageTokenMap } = usePaginate();
let getAppsUrl = `/api/admin/federated-saml?offset=${paginate.offset}&limit=${pageLimit}`;
// Use the (next)pageToken mapped to the previous page offset to get the current page
if (paginate.offset > 0 && pageTokenMap[paginate.offset - pageLimit]) {
getAppsUrl += `&pageToken=${pageTokenMap[paginate.offset - pageLimit]}`;
}
const { data, error, isLoading } = useSWR<ApiSuccess<SAMLFederationApp[]>, ApiError>(getAppsUrl, fetcher);
const nextPageToken = data?.pageToken;
// store the nextPageToken against the pageOffset
useEffect(() => {
if (nextPageToken) {
setPageTokenMap((tokenMap) => ({ ...tokenMap, [paginate.offset]: nextPageToken }));
}
}, [nextPageToken, paginate.offset]);
if (!hasValidLicense) {
return <LicenseRequired />;
}
if (isLoading) {
return <Loading />;
}
if (error) {
errorToast(error.message);
return;
}
const apps = data?.data || [];
const noApps = apps.length === 0 && paginate.offset === 0;
const noMoreResults = apps.length === 0 && paginate.offset > 0;
return (
<>
<div className='mb-5 flex items-center justify-between'>
<h2 className='font-bold text-gray-700 dark:text-white md:text-xl'>{t('saml_federation_apps')}</h2>
<div className='flex'>
<LinkOutline href={'/.well-known/idp-configuration'} target='_blank' className='m-2'>
{t('idp_configuration')}
</LinkOutline>
<LinkPrimary className='m-2' href='/admin/federated-saml/new'>
{t('new_saml_federation_app')}
</LinkPrimary>
</div>
</div>
{noApps ? (
<>
<EmptyState title={t('no_saml_federation_apps')} href='/admin/federated-saml/new' />
</>
) : (
<>
<Table
noMoreResults={noMoreResults}
cols={[t('name'), t('tenant'), t('product'), t('actions')]}
body={apps.map((app) => {
return {
id: app.id,
cells: [
{
wrap: true,
text: app.name,
},
{
wrap: true,
text: app.tenant,
},
{
wrap: true,
text: app.product,
},
{
actions: [
{
text: t('edit'),
onClick: () => {
router.push(`/admin/federated-saml/${app.id}/edit`);
},
icon: <PencilIcon className='h-5 w-5' />,
},
],
},
],
};
})}></Table>
<Pagination
itemsCount={apps.length}
offset={paginate.offset}
onPrevClick={() => {
setPaginate({
offset: paginate.offset - pageLimit,
});
}}
onNextClick={() => {
setPaginate({
offset: paginate.offset + pageLimit,
});
}}
/>
</>
)}
</>
<FederatedSAMLApps
urls={{ getApps: '/api/admin/federated-saml' }}
onEdit={(app) => router.push(`/admin/federated-saml/${app.id}/edit`)}
actions={{ newApp: '/admin/federated-saml/new', idpConfiguration: '/.well-known/idp-configuration' }}
/>
);
};

View File

@ -1,198 +1,37 @@
import { useState } from 'react';
import { useRouter } from 'next/router';
import { useTranslation } from 'next-i18next';
import type { SAMLFederationApp } from '@boxyhq/saml-jackson';
import TagsInput from 'react-tagsinput';
import { QuestionMarkCircleIcon } from '@heroicons/react/24/outline';
import type { ApiResponse } from 'types';
import { LinkBack } from '@components/LinkBack';
import { ButtonPrimary } from '@components/ButtonPrimary';
import { errorToast, successToast } from '@components/Toaster';
import LicenseRequired from '@components/LicenseRequired';
import { copyToClipboard } from '@lib/ui/utils';
import { NewFederatedSAMLApp, LinkBack } from '@boxyhq/internal-ui';
import 'react-tagsinput/react-tagsinput.css';
const NewApp = ({ hasValidLicense, samlAudience }: { hasValidLicense: boolean; samlAudience: string }) => {
const { t } = useTranslation('common');
const router = useRouter();
const [loading, setLoading] = useState(false);
const [newApp, setApp] = useState({
name: '',
tenant: '',
product: '',
acsUrl: '',
entityId: '',
tenants: [],
mappings: [],
});
const { t } = useTranslation('common');
if (!hasValidLicense) {
return <LicenseRequired />;
}
const onSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setLoading(true);
const rawResponse = await fetch('/api/admin/federated-saml', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(newApp),
});
setLoading(false);
const response: ApiResponse<SAMLFederationApp> = await rawResponse.json();
if ('error' in response) {
errorToast(response.error.message);
return;
}
if ('data' in response) {
successToast(t('saml_federation_new_success'));
router.replace(`/admin/federated-saml/${response.data.id}/edit`);
}
};
const onChange = (event: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const target = event.target as HTMLInputElement;
setApp({
...newApp,
[target.id]: target.value,
});
};
const generateEntityId = () => {
const id = crypto.randomUUID().replace(/-/g, '');
const entityId = `${samlAudience}/${id}`;
setApp({ ...newApp, entityId });
copyToClipboard(entityId);
successToast(t('entity_id_generated_copied'));
};
return (
<>
<div className='space-y-4'>
<LinkBack href='/admin/federated-saml' />
<h2 className='mb-5 mt-5 font-bold text-gray-700 md:text-xl'>{t('saml_federation_add_new_app')}</h2>
<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'>
<p className='text-sm leading-6 text-gray-800'>{t('saml_federation_add_new_app_description')}</p>
<div className='form-control w-full'>
<label className='label'>
<span className='label-text'>{t('name')}</span>
</label>
<input
type='text'
id='name'
className='input-bordered input'
required
onChange={onChange}
placeholder='Your app'
/>
</div>
<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'
required
onChange={onChange}
placeholder='boxyhq'
/>
</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'
required
onChange={onChange}
placeholder='saml-jackson'
/>
</div>
<div className='form-control w-full'>
<label className='label'>
<span className='label-text'>{t('acs_url')}</span>
</label>
<input
type='url'
id='acsUrl'
className='input-bordered input'
required
onChange={onChange}
placeholder='https://your-sp.com/saml/acs'
/>
</div>
<label className='form-control w-full'>
<div className='label'>
<span className='label-text'>{t('entity_id')}</span>
<span className='label-text-alt'>
<div className='flex items-center gap-1'>
<span
className='cursor-pointer border-stone-600 border p-1 rounded'
onClick={generateEntityId}>
{t('generate_sp_entity_id')}
</span>
<div
className='tooltip tooltip-left'
data-tip={t('saml_federation_entity_id_instruction')}>
<QuestionMarkCircleIcon className='h-5 w-5' />
</div>
</div>
</span>
</div>
<input
type='text'
className='input input-bordered w-full'
id='entityId'
placeholder='https://your-sp.com/saml/entityId'
required
value={newApp.entityId}
onChange={onChange}
/>
<label className='label'>
<span className='label-text-alt'>{t('entity-id-change-restriction')}</span>
</label>
</label>
<div className='form-control w-full'>
<label className='label'>
<span className='label-text'>{t('tenants')}</span>
</label>
<TagsInput
value={newApp.tenants}
onChange={(tags) => setApp({ ...newApp, tenants: tags })}
onlyUnique={true}
inputProps={{
placeholder: t('enter_tenant'),
}}
focusedClassName='input-focused'
addOnBlur={true}
/>
<label className='label'>
<span className='label-text-alt'>{t('tenants_mapping_description')}</span>
</label>
</div>
<div>
<ButtonPrimary loading={loading}>{t('create_app')}</ButtonPrimary>
</div>
</div>
</form>
</div>
</>
<NewFederatedSAMLApp
urls={{ createApp: '/api/admin/federated-saml' }}
onSuccess={(data) => {
successToast(t('saml_federation_new_success'));
router.replace(`/admin/federated-saml/${data.id}/edit`);
}}
onError={(error) => {
errorToast(error.message);
}}
onEntityIdGenerated={() => {
successToast(t('saml_federation_entity_id_generated'));
}}
samlAudience={samlAudience}
/>
</div>
);
};

View File

@ -1,5 +1,24 @@
.vscode
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
/node_modules
**/node_modules/**
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

1
internal-ui/README.md Normal file
View File

@ -0,0 +1 @@
# Shared React UI components for BoxyHQ SaaS and Admin Portal

27
internal-ui/dev/verdaccio.sh Executable file
View File

@ -0,0 +1,27 @@
#!/bin/zsh -ex
# TODO: Make this generic so everyone can run it
VERSION=0.0.0
# Unpublish the current version
npm unpublish --registry http://localhost:4873/ @boxyhq/internal-ui@$VERSION
# Build the package
rm -rf dist
npm run build
# Publish
npm publish --registry http://localhost:4873/
# Install the published version in `boxyhq/jackson`
# cd ../../jackson
# npm uninstall @boxyhq/internal-ui
# npm i --save-exact --registry http://localhost:4873/ @boxyhq/internal-ui@$VERSION
# rm -rf .next
# Install the published version in `boxyhq/saas-app`
cd ../../saas-app
npm uninstall @boxyhq/internal-ui
npm i --save-exact --registry http://localhost:4873/ @boxyhq/internal-ui@$VERSION --force
rm -rf .next

1810
internal-ui/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

47
internal-ui/package.json Normal file
View File

@ -0,0 +1,47 @@
{
"name": "@boxyhq/internal-ui",
"version": "0.0.0",
"description": "Shared React UI components for BoxyHQ SaaS and Admin Portal",
"repository": {
"type": "git",
"url": "https://github.com/boxyhq/jackson.git"
},
"files": [
"dist"
],
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.d.ts",
"scripts": {
"dev": "vite",
"build": "vite build",
"build-watch": "vite build --watch",
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview",
"publish-local": "./dev/verdaccio.sh",
"prepublishOnly": "npm run build"
},
"dependencies": {
"vite-tsconfig-paths": "4.3.1"
},
"devDependencies": {
"@rollup/plugin-typescript": "11.1.6",
"@types/react": "18.2.56",
"@vitejs/plugin-react": "4.2.1",
"autoprefixer": "10.4.17",
"typescript": "5.3.3",
"vite": "5.1.0"
},
"peerDependencies": {
"@heroicons/react": "2.1.1",
"classnames": "2.5.1",
"formik": "2.4.5",
"next": "14.1.0",
"next-i18next": "13.3.0",
"react": "18.2.0",
"react-daisyui": "5.0.0",
"react-syntax-highlighter": "15.5.0",
"react-tagsinput": "3.20.3",
"swr": "2.2.5"
}
}

View File

@ -0,0 +1,43 @@
import useSWR from 'swr';
import type { Group } from '../types';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter/dist/cjs';
import { materialOceanic } from 'react-syntax-highlighter/dist/cjs/styles/prism';
import { fetcher } from '../utils';
import { useDirectory } from '../hooks';
import { DirectoryTab } from '../dsync';
import { Loading, Error, PageHeader } from '../shared';
export const DirectoryGroupInfo = ({
urls,
}: {
urls: { getGroup: string; getDirectory: string; tabBase: string };
}) => {
const { directory, isLoadingDirectory, directoryError } = useDirectory(urls.getDirectory);
const { data, isLoading, error } = useSWR<{ data: Group }>(urls.getGroup, fetcher);
if (isLoading || isLoadingDirectory) {
return <Loading />;
}
if (error || directoryError) {
return <Error message={error.message || directoryError.message} />;
}
if (!data || !directory) {
return null;
}
const group = data.data;
return (
<>
<PageHeader title={directory.name} />
<DirectoryTab activeTab='groups' baseUrl={urls.tabBase} />
<div className='text-sm'>
<SyntaxHighlighter language='json' style={materialOceanic}>
{JSON.stringify(group, null, 2)}
</SyntaxHighlighter>
</div>
</>
);
};

View File

@ -0,0 +1,124 @@
import useSWR from 'swr';
import { useTranslation } from 'next-i18next';
import { EyeIcon } from '@heroicons/react/24/outline';
import type { Group } from '../types';
import { fetcher, addQueryParamsToPath } from '../utils';
import { DirectoryTab } from '../dsync';
import { usePaginate, useDirectory } from '../hooks';
import { TableBodyType } from '../shared/Table';
import { Loading, Table, EmptyState, Error, Pagination, PageHeader, pageLimit } from '../shared';
import { useRouter } from '../hooks';
export const DirectoryGroups = ({
urls,
onView,
}: {
urls: { getGroups: string; getDirectory: string; tabBase: string };
onView?: (group: Group) => void;
}) => {
const { router } = useRouter();
const { t } = useTranslation('common');
const { paginate, setPaginate, pageTokenMap } = usePaginate(router!);
const params = {
offset: paginate.offset,
limit: pageLimit,
};
// For DynamoDB
if (paginate.offset > 0 && pageTokenMap[paginate.offset - pageLimit]) {
params['pageToken'] = pageTokenMap[paginate.offset - pageLimit];
}
const getUrl = addQueryParamsToPath(urls.getGroups, params);
const { directory, isLoadingDirectory, directoryError } = useDirectory(urls.getDirectory);
const { data, isLoading, error } = useSWR<{ data: Group[] }>(getUrl, fetcher);
if (isLoading || isLoadingDirectory) {
return <Loading />;
}
if (error || directoryError) {
return <Error message={error.message || directoryError.message} />;
}
if (!data || !directory) {
return null;
}
const groups = data?.data || [];
const noGroups = groups.length === 0 && paginate.offset === 0;
const noMoreResults = groups.length === 0 && paginate.offset > 0;
const columns = [
{
key: 'name',
label: t('bui-dsync-name'),
wrap: true,
dataIndex: 'name',
},
{
key: 'actions',
label: t('bui-dsync-actions'),
wrap: true,
dataIndex: null,
},
];
const cols = columns.map(({ label }) => label);
const body: TableBodyType[] = groups.map((group) => {
return {
id: group.id,
cells: columns.map((column) => {
const dataIndex = column.dataIndex as string;
if (dataIndex === null) {
return {
actions: [
{
text: t('bui-dsync-view'),
onClick: () => onView?.(group),
icon: <EyeIcon className='w-5' />,
},
],
};
}
return {
wrap: column.wrap,
text: group[dataIndex],
};
}),
};
});
return (
<>
<PageHeader title={directory.name} />
<DirectoryTab activeTab='groups' baseUrl={urls.tabBase} />
{noGroups ? (
<EmptyState title={t('bui-dsync-no-groups')} />
) : (
<>
<Table noMoreResults={noMoreResults} cols={cols} body={body} />
<Pagination
itemsCount={groups.length}
offset={paginate.offset}
onPrevClick={() => {
setPaginate({
offset: paginate.offset - pageLimit,
});
}}
onNextClick={() => {
setPaginate({
offset: paginate.offset + pageLimit,
});
}}
/>
</>
)}
</>
);
};

View File

@ -0,0 +1,110 @@
import { useDirectory } from '../hooks';
import { DirectoryTab } from '../dsync';
import { Loading, Error, PageHeader, Badge } from '../shared';
import { useTranslation } from 'next-i18next';
import { InputWithCopyButton } from '../shared/InputWithCopyButton';
import type { Directory } from '../types';
type ExcludeFields = keyof Pick<Directory, 'tenant' | 'product' | 'webhook'>;
// TODO:
// Add the toast after copying the google auth url
export const DirectoryInfo = ({
urls,
excludeFields = [],
}: {
urls: { getDirectory: string; tabBase: string; googleAuth: string };
excludeFields?: ExcludeFields[];
}) => {
const { t } = useTranslation('common');
const { directory, isLoadingDirectory, directoryError } = useDirectory(urls.getDirectory);
if (isLoadingDirectory) {
return <Loading />;
}
if (directoryError) {
return <Error message={directoryError.message} />;
}
if (!directory) {
return null;
}
return (
<>
<PageHeader title={directory.name} />
<DirectoryTab activeTab='directory' baseUrl={urls.tabBase} />
<div className='rounded border'>
<dl className='divide-y'>
<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-directory-id')}</dt>
<dd className='mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0'>{directory.id}</dd>
</div>
{!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>
<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>
<dd className='mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0'>{directory.product}</dd>
</div>
)}
{!excludeFields.includes('webhook') && (
<>
<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-endpoint')}</dt>
<dd className='mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0'>
{directory.webhook.endpoint || '-'}
</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>
<dd className='mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0'>
{directory.webhook.secret || '-'}
</dd>
</div>
</>
)}
{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>
<dd className='mt-1 text-sm text-gray-900 sm:col-span-2 sm:mt-0'>
{directory.google_access_token && directory.google_refresh_token ? (
<Badge color='success'>{t('bui-dsync-authorized')}</Badge>
) : (
<Badge color='warning'>{t('bui-dsync-not-authorized')}</Badge>
)}
</dd>
</div>
)}
</dl>
</div>
{directory.scim.endpoint && directory.scim.secret && (
<div className='mt-4 space-y-4 rounded border p-6'>
<div className='form-control'>
<InputWithCopyButton
text={directory.scim.endpoint as string}
label={t('bui-dsync-scim-endpoint')}
/>
</div>
<div className='form-control'>
<InputWithCopyButton text={directory.scim.secret} label={t('bui-dsync-scim-token')} />
</div>
</div>
)}
{directory.type === 'google' && (
<div className='form-control mt-6'>
<InputWithCopyButton
text={`${urls.googleAuth}?directoryId=${directory.id}`}
label={t('bui-dsync-google-auth-url')}
/>
</div>
)}
</>
);
};

View File

@ -0,0 +1,56 @@
import classNames from 'classnames';
import { useTranslation } from 'next-i18next';
import { useContext } from 'react';
import { BUIContext } from '../provider';
type Tabs = 'directory' | 'users' | 'groups' | 'events';
export const DirectoryTab = ({ activeTab, baseUrl }: { activeTab: Tabs; baseUrl: string }) => {
const { t } = useTranslation('common');
const { router } = useContext(BUIContext);
const menus = [
{
name: t('bui-dsync-directory'),
href: baseUrl,
active: activeTab === 'directory',
},
{
name: t('bui-dsync-users'),
href: `${baseUrl}/users`,
active: activeTab === 'users',
},
{
name: t('bui-dsync-groups'),
href: `${baseUrl}/groups`,
active: activeTab === 'groups',
},
{
name: t('bui-dsync-webhook-events'),
href: `${baseUrl}/events`,
active: activeTab === 'events',
},
];
return (
<div className='pb-3'>
<ul className='-mb-px flex space-x-5 border-b' aria-label='Tabs'>
{menus.map((menu) => {
return (
<li
onClick={() => router?.push(menu.href)}
key={menu.href}
className={classNames(
'cursor-pointer inline-flex items-center border-b-2 py-4 text-sm font-medium',
menu.active
? 'border-gray-700 text-gray-700'
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700'
)}>
{menu.name}
</li>
);
})}
</ul>
</div>
);
};

View File

@ -0,0 +1,43 @@
import useSWR from 'swr';
import type { User } from '../types';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter/dist/cjs';
import { materialOceanic } from 'react-syntax-highlighter/dist/cjs/styles/prism';
import { fetcher } from '../utils';
import { useDirectory } from '../hooks';
import { DirectoryTab } from '../dsync';
import { Loading, Error, PageHeader } from '../shared';
export const DirectoryUserInfo = ({
urls,
}: {
urls: { getUser: string; getDirectory: string; tabBase: string };
}) => {
const { directory, isLoadingDirectory, directoryError } = useDirectory(urls.getDirectory);
const { data, isLoading, error } = useSWR<{ data: User }>(urls.getUser, fetcher);
if (isLoading || isLoadingDirectory) {
return <Loading />;
}
if (error || directoryError) {
return <Error message={error.message || directoryError.message} />;
}
if (!data || !directory) {
return null;
}
const user = data.data;
return (
<>
<PageHeader title={directory.name} />
<DirectoryTab activeTab='users' baseUrl={urls.tabBase} />
<div className='text-sm'>
<SyntaxHighlighter language='json' style={materialOceanic}>
{JSON.stringify(user, null, 2)}
</SyntaxHighlighter>
</div>
</>
);
};

View File

@ -0,0 +1,151 @@
import useSWR from 'swr';
import { useTranslation } from 'next-i18next';
import { EyeIcon } from '@heroicons/react/24/outline';
import type { User } from '../types';
import { addQueryParamsToPath, fetcher } from '../utils';
import { DirectoryTab } from '../dsync';
import { usePaginate, useDirectory } from '../hooks';
import { TableBodyType } from '../shared/Table';
import { Loading, Table, EmptyState, Error, Pagination, PageHeader, pageLimit } from '../shared';
import { useRouter } from '../hooks';
export const DirectoryUsers = ({
urls,
onView,
}: {
urls: { getUsers: string; getDirectory: string; tabBase: string };
onView?: (user: User) => void;
}) => {
const { router } = useRouter();
const { t } = useTranslation('common');
const { paginate, setPaginate, pageTokenMap } = usePaginate(router!);
const params = {
offset: paginate.offset,
limit: pageLimit,
};
// For DynamoDB
if (paginate.offset > 0 && pageTokenMap[paginate.offset - pageLimit]) {
params['pageToken'] = pageTokenMap[paginate.offset - pageLimit];
}
const getUrl = addQueryParamsToPath(urls.getUsers, params);
const { directory, isLoadingDirectory, directoryError } = useDirectory(urls.getDirectory);
const { data, isLoading, error } = useSWR<{ data: User[] }>(getUrl, fetcher);
if (isLoading || isLoadingDirectory) {
return <Loading />;
}
if (error || directoryError) {
return <Error message={error.message || directoryError.message} />;
}
if (!data || !directory) {
return null;
}
const users = data?.data || [];
const noUsers = users.length === 0 && paginate.offset === 0;
const noMoreResults = users.length === 0 && paginate.offset > 0;
const columns = [
{
key: 'first_name',
label: t('bui-dsync-first-name'),
wrap: true,
dataIndex: 'first_name',
},
{
key: 'last_name',
label: t('bui-dsync-last-name'),
wrap: true,
dataIndex: 'last_name',
},
{
key: 'email',
label: t('bui-dsync-email'),
wrap: true,
dataIndex: 'email',
},
{
key: 'status',
label: t('bui-dsync-status'),
wrap: true,
dataIndex: 'active',
},
{
key: 'actions',
label: t('bui-dsync-actions'),
wrap: true,
dataIndex: null,
},
];
const cols = columns.map(({ label }) => label);
const body: TableBodyType[] = users.map((user) => {
return {
id: user.id,
cells: columns.map((column) => {
const dataIndex = column.dataIndex as string;
if (dataIndex === null) {
return {
actions: [
{
text: t('bui-dsync-view'),
onClick: () => onView?.(user),
icon: <EyeIcon className='w-5' />,
},
],
};
}
if (dataIndex === 'active') {
return {
badge: {
text: user[dataIndex] ? t('bui-dsync-active') : t('bui-dsync-suspended'),
color: user[dataIndex] ? 'success' : 'warning',
},
};
}
return {
wrap: column.wrap,
text: user[dataIndex],
};
}),
};
});
return (
<>
<PageHeader title={directory.name} />
<DirectoryTab activeTab='users' baseUrl={urls.tabBase} />
{noUsers ? (
<EmptyState title={t('bui-dsync-no-users')} />
) : (
<>
<Table noMoreResults={noMoreResults} cols={cols} body={body} />
<Pagination
itemsCount={users.length}
offset={paginate.offset}
onPrevClick={() => {
setPaginate({
offset: paginate.offset - pageLimit,
});
}}
onNextClick={() => {
setPaginate({
offset: paginate.offset + pageLimit,
});
}}
/>
</>
)}
</>
);
};

View File

@ -0,0 +1,44 @@
import useSWR from 'swr';
import type { WebhookEventLog } from '../types';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter/dist/cjs';
import { materialOceanic } from 'react-syntax-highlighter/dist/cjs/styles/prism';
import { fetcher } from '../utils';
import { useDirectory } from '../hooks';
import { DirectoryTab } from '../dsync';
import { Loading, Error, PageHeader } from '../shared';
export const DirectoryWebhookLogInfo = ({
urls,
}: {
urls: { getEvent: string; getDirectory: string; tabBase: string };
}) => {
const { directory, isLoadingDirectory, directoryError } = useDirectory(urls.getDirectory);
const { data, isLoading, error } = useSWR<{ data: WebhookEventLog }>(urls.getEvent, fetcher);
if (isLoading || isLoadingDirectory) {
return <Loading />;
}
if (error || directoryError) {
return <Error message={error.message || directoryError.message} />;
}
if (!data || !directory) {
return null;
}
const event = data.data;
return (
<>
<PageHeader title={directory.name} />
<DirectoryTab activeTab='events' baseUrl={urls.tabBase} />
<div className='text-sm'>
<SyntaxHighlighter language='json' style={materialOceanic}>
{JSON.stringify(event, null, 2)}
</SyntaxHighlighter>
</div>
</>
);
};

View File

@ -0,0 +1,187 @@
import useSWR from 'swr';
import { useState } from 'react';
import { useTranslation } from 'next-i18next';
import { EyeIcon } from '@heroicons/react/24/outline';
import type { WebhookEventLog } from '../types';
import { fetcher, addQueryParamsToPath } from '../utils';
import { DirectoryTab } from '../dsync';
import { usePaginate, useDirectory } from '../hooks';
import { TableBodyType } from '../shared/Table';
import {
Loading,
Table,
EmptyState,
Error,
Pagination,
PageHeader,
pageLimit,
DeleteConfirmationModal,
} from '../shared';
import { ButtonDanger } from '../shared/ButtonDanger';
import { useRouter } from '../hooks';
export const DirectoryWebhookLogs = ({
urls,
onView,
onDelete,
}: {
urls: { getEvents: string; getDirectory: string; tabBase: string; deleteEvents?: string };
onView?: (event: WebhookEventLog) => void;
onDelete?: () => void;
}) => {
const { router } = useRouter();
const { t } = useTranslation('common');
const [delModalVisible, setDelModalVisible] = useState(false);
const { paginate, setPaginate, pageTokenMap } = usePaginate(router!);
const params = {
offset: paginate.offset,
limit: pageLimit,
};
// For DynamoDB
if (paginate.offset > 0 && pageTokenMap[paginate.offset - pageLimit]) {
params['pageToken'] = pageTokenMap[paginate.offset - pageLimit];
}
const getUrl = addQueryParamsToPath(urls.getEvents, params);
const { directory, isLoadingDirectory, directoryError } = useDirectory(urls.getDirectory);
const { data, isLoading, error } = useSWR<{ data: WebhookEventLog[] }>(getUrl, fetcher);
if (isLoading || isLoadingDirectory) {
return <Loading />;
}
if (error || directoryError) {
return <Error message={error.message || directoryError.message} />;
}
if (!data || !directory) {
return null;
}
const events = data?.data || [];
const noEvents = events.length === 0 && paginate.offset === 0;
const noMoreResults = events.length === 0 && paginate.offset > 0;
const columns = [
{
key: 'webhook_endpoint',
label: t('bui-dsync-webhook-endpoint'),
wrap: true,
dataIndex: 'webhook_endpoint',
},
{
key: 'status_code',
label: t('bui-dsync-status-code'),
wrap: true,
dataIndex: 'status_code',
},
{
key: 'created_at',
label: t('bui-dsync-sent-at'),
wrap: true,
dataIndex: 'created_at',
},
{
key: 'actions',
label: t('bui-dsync-actions'),
wrap: true,
dataIndex: null,
},
];
const cols = columns.map(({ label }) => label);
const body: TableBodyType[] = events.map((event) => {
return {
id: event.id,
cells: columns.map((column) => {
const dataIndex = column.dataIndex as keyof typeof event;
if (dataIndex === null) {
return {
actions: [
{
text: t('bui-dsync-view'),
onClick: () => onView?.(event),
icon: <EyeIcon className='w-5' />,
},
],
};
}
if (dataIndex === 'status_code') {
return {
badge: {
text: event[dataIndex] as any,
color: event[dataIndex] === 200 ? 'success' : 'error',
},
};
}
return {
wrap: column.wrap,
text: event[dataIndex] as string,
};
}),
};
});
const removeEvents = async () => {
if (!urls.deleteEvents) {
return;
}
await fetch(urls.deleteEvents, {
method: 'DELETE',
});
onDelete?.();
};
return (
<>
<PageHeader title={directory.name} />
<DirectoryTab activeTab='events' baseUrl={urls.tabBase} />
{noEvents ? (
<EmptyState title={t('bui-dsync-no-events')} />
) : (
<>
{urls.deleteEvents && (
<>
<div className='py-2 flex justify-end'>
<ButtonDanger onClick={() => setDelModalVisible(true)}>
{t('bui-dsync-remove-events')}
</ButtonDanger>
</div>
<DeleteConfirmationModal
title={t('bui-dsync-delete-events-title')}
description={t('bui-dsync-delete-events-desc')}
visible={delModalVisible}
onConfirm={() => removeEvents()}
onCancel={() => setDelModalVisible(false)}
/>
</>
)}
<Table noMoreResults={noMoreResults} cols={cols} body={body} />
<Pagination
itemsCount={events.length}
offset={paginate.offset}
onPrevClick={() => {
setPaginate({
offset: paginate.offset - pageLimit,
});
}}
onNextClick={() => {
setPaginate({
offset: paginate.offset + pageLimit,
});
}}
/>
</>
)}
</>
);
};

View File

@ -0,0 +1,8 @@
export { DirectoryUsers } from './DirectoryUsers';
export { DirectoryUserInfo } from './DirectoryUserInfo';
export { DirectoryGroups } from './DirectoryGroups';
export { DirectoryGroupInfo } from './DirectoryGroupInfo';
export { DirectoryWebhookLogs } from './DirectoryWebhookLogs';
export { DirectoryWebhookLogInfo } from './DirectoryWebhookLogInfo';
export { DirectoryTab } from './DirectoryTab';
export { DirectoryInfo } from './DirectoryInfo';

View File

@ -0,0 +1,141 @@
import { useTranslation } from 'next-i18next';
import XMarkIcon from '@heroicons/react/24/outline/XMarkIcon';
import type { AttributeMapping } from '../types';
const standardAttributes = {
saml: [
'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier',
'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress',
'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname',
'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname',
'http://schemas.microsoft.com/ws/2008/06/identity/claims/role',
'http://schemas.xmlsoap.org/claims/Group',
],
oidc: ['sub', 'email', 'given_name', 'family_name', 'roles', 'groups'],
};
export const AttributesMapping = ({
mappings,
onAttributeMappingsChange,
}: {
mappings: AttributeMapping[];
onAttributeMappingsChange: (attributeMappings: AttributeMapping[]) => void;
}) => {
const { t } = useTranslation('common');
const addAnother = () => {
onAttributeMappingsChange([...mappings, { key: '', value: '' }]);
};
return (
<div>
{mappings.length > 0 && (
<div className='flex space-x-20 items-center pb-2'>
<label className='label font-semibold'>
<span className='label-text'>{t('bui-fs-sp-attribute')}</span>
</label>
<label className='label font-semibold'>
<span className='label-text'>{t('bui-fs-idp-attribute')}</span>
</label>
</div>
)}
<div className='flex flex-col gap-4'>
{mappings.map((attributeMapping, index) => (
<div key={index}>
<AttributeRow
attributeMapping={attributeMapping}
onMappingChange={(newAttributeMapping) => {
const newMappings = [...mappings];
newMappings[index] = newAttributeMapping;
onAttributeMappingsChange(newMappings);
}}
onMappingDelete={() => {
onAttributeMappingsChange(mappings.filter((_, i) => i !== index));
}}
/>
</div>
))}
<div>
<button className='btn btn-primary btn-sm btn-outline' type='button' onClick={addAnother}>
{mappings.length === 0 ? t('bui-fs-add-mapping') : t('bui-fs-add-another')}
</button>
</div>
</div>
</div>
);
};
const AttributeRow = ({
attributeMapping,
onMappingChange,
onMappingDelete,
}: {
attributeMapping: AttributeMapping;
onMappingChange: (newAttributeMapping: AttributeMapping) => void;
onMappingDelete: () => void;
}) => {
const { t } = useTranslation('common');
return (
<div className='flex space-x-3 items-center'>
<input
type='text'
className='input input-bordered input-sm'
name='attribute'
value={attributeMapping.key}
onChange={(e) => {
onMappingChange({
key: e.target.value,
value: attributeMapping.value,
});
}}
required
/>
<div className='join flex'>
<div>
<div>
<input
type='text'
className='input input-bordered input-sm w-full join-item'
name='mapping'
value={attributeMapping.value}
onChange={(e) => {
onMappingChange({
key: attributeMapping.key,
value: e.target.value,
});
}}
required
/>
</div>
</div>
<select
className='select select-bordered join-item select-sm rounded w-40'
onChange={(e) => {
onMappingChange({
key: attributeMapping.key,
value: e.target.value,
});
}}
value={attributeMapping.value}>
<option value=''></option>
<option value='' disabled>
{t('bui-fs-saml-attributes')}
</option>
{standardAttributes.saml.map((attribute) => (
<option key={attribute}>{attribute}</option>
))}
<option value='' disabled>
{t('bui-fs-oidc-attributes')}
</option>
{standardAttributes.oidc.map((attribute) => (
<option key={attribute}>{attribute}</option>
))}
</select>
</div>
<button type='button' onClick={onMappingDelete}>
<XMarkIcon className='h-5 w-5 text-red-500' />
</button>
</div>
);
};

View File

@ -0,0 +1,157 @@
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 { Card } from '../shared';
import { defaultHeaders } from '../utils';
type EditApp = Pick<SAMLFederationApp, 'name' | 'acsUrl' | 'tenants'>;
export const Edit = ({
app,
urls,
onError,
onUpdate,
excludeFields,
}: {
app: SAMLFederationApp;
urls: { patch: string };
onUpdate?: (data: SAMLFederationApp) => void;
onError?: (error: Error) => void;
excludeFields?: 'product'[];
}) => {
const { t } = useTranslation('common');
const formik = useFormik<EditApp>({
enableReinitialize: true,
initialValues: {
name: app.name || '',
acsUrl: app.acsUrl || '',
tenants: app.tenants || [],
},
onSubmit: async (values) => {
const rawResponse = await fetch(urls.patch, {
method: 'PATCH',
body: JSON.stringify({ ...values, id: app.id }),
headers: defaultHeaders,
});
const response = await rawResponse.json();
if (rawResponse.ok) {
onUpdate?.(response.data);
} else {
onError?.(response.error);
}
},
});
return (
<>
<div className='flex flex-col gap-6'>
<form onSubmit={formik.handleSubmit} method='POST'>
<Card>
<Card.Body>
<div className='flex flex-col space-y-3'>
<label className='form-control w-full'>
<div className='label'>
<span className='label-text'>{t('bui-shared-name')}</span>
</div>
<input
type='text'
placeholder='Your app'
className='input input-bordered w-full text-sm'
name='name'
value={formik.values.name}
onChange={formik.handleChange}
required
/>
</label>
<label className='form-control w-full'>
<div className='label'>
<span className='label-text'>{t('bui-shared-tenant')}</span>
</div>
<input
type='text'
className='input input-bordered w-full text-sm'
value={app.tenant}
disabled
/>
</label>
{!excludeFields?.includes('product') && (
<label className='form-control w-full'>
<div className='label'>
<span className='label-text'>{t('bui-shared-product')}</span>
</div>
<input
type='text'
className='input input-bordered w-full text-sm'
value={app.product}
disabled
/>
</label>
)}
<label className='form-control w-full'>
<div className='label'>
<span className='label-text'>{t('bui-fs-acs-url')}</span>
</div>
<input
type='url'
placeholder='https://your-sp.com/saml/acs'
className='input input-bordered w-full text-sm'
name='acsUrl'
value={formik.values.acsUrl}
onChange={formik.handleChange}
required
/>
</label>
<label className='form-control w-full'>
<div className='label'>
<span className='label-text'>{t('bui-fs-entity-id')}</span>
</div>
<input
type='text'
className='input input-bordered w-full text-sm'
value={app.entityId}
disabled
/>
<label className='label'>
<span className='label-text-alt'>{t('bui-fs-entity-id-edit-desc')}</span>
</label>
</label>
<label className='form-control w-full'>
<label className='label'>
<span className='label-text'>{t('bui-fs-tenants')}</span>
</label>
<TagsInput
value={formik.values.tenants}
onChange={(tags: string[]) => formik.setFieldValue('tenants', tags)}
onlyUnique={true}
inputProps={{
placeholder: t('bui-fs-enter-tenant'),
}}
focusedClassName='input-focused'
addOnBlur={true}
/>
<label className='label'>
<span className='label-text-alt'>{t('bui-fs-tenants-mapping-desc')}</span>
</label>
</label>
</div>
</Card.Body>
<Card.Footer>
<Button
type='submit'
className='btn btn-primary btn-md'
loading={formik.isSubmitting}
disabled={!formik.dirty || !formik.isValid}>
{t('bui-shared-save-changes')}
</Button>
</Card.Footer>
</Card>
</form>
</div>
</>
);
};

View File

@ -0,0 +1,73 @@
import { Button } from 'react-daisyui';
import { useTranslation } from 'next-i18next';
import type { SAMLFederationApp } from '../types';
import { useFormik } from 'formik';
import { defaultHeaders } from '../utils';
import { Card } from '../shared';
import { AttributesMapping } from './AttributesMapping';
type Mappings = Pick<SAMLFederationApp, 'mappings'>;
export const EditAttributesMapping = ({
app,
urls,
onUpdate,
onError,
}: {
app: SAMLFederationApp;
urls: { patch: string };
onUpdate?: (data: SAMLFederationApp) => void;
onError?: (error: Error) => void;
}) => {
const { t } = useTranslation('common');
const formik = useFormik<Mappings>({
initialValues: {
mappings: app.mappings || [],
},
enableReinitialize: true,
onSubmit: async (values) => {
const rawResponse = await fetch(urls.patch, {
method: 'PATCH',
body: JSON.stringify({ ...values, id: app.id }),
headers: defaultHeaders,
});
const response = await rawResponse.json();
if (rawResponse.ok) {
onUpdate?.(response.data);
} else {
onError?.(response.error);
}
},
});
return (
<form onSubmit={formik.handleSubmit} method='POST'>
<Card>
<Card.Body>
<Card.Header>
<Card.Title>{t('bui-fs-attribute-mappings')}</Card.Title>
<Card.Description>{t('bui-fs-attribute-mappings-desc')}</Card.Description>
</Card.Header>
<AttributesMapping
mappings={formik.values.mappings || []}
onAttributeMappingsChange={(newAttributeMappings) => {
formik.setFieldValue('mappings', newAttributeMappings);
}}
/>
</Card.Body>
<Card.Footer>
<Button
type='submit'
className='btn btn-primary btn-md'
loading={formik.isSubmitting}
disabled={!formik.dirty || !formik.isValid}>
{t('bui-shared-save-changes')}
</Button>
</Card.Footer>
</Card>
</form>
);
};

View File

@ -0,0 +1,121 @@
import { Button } from 'react-daisyui';
import { useTranslation } from 'next-i18next';
import type { SAMLFederationApp } from '../types';
import { useFormik } from 'formik';
import { defaultHeaders } from '../utils';
import { Card } from '../shared';
type Branding = Pick<SAMLFederationApp, 'logoUrl' | 'faviconUrl' | 'primaryColor'>;
export const EditBranding = ({
app,
urls,
onUpdate,
onError,
}: {
app: SAMLFederationApp;
urls: { patch: string };
onUpdate?: (data: SAMLFederationApp) => void;
onError?: (error: Error) => void;
}) => {
const { t } = useTranslation('common');
const formik = useFormik<Branding>({
enableReinitialize: true,
initialValues: {
logoUrl: app.logoUrl,
faviconUrl: app.faviconUrl,
primaryColor: app.primaryColor || '#25c2a0',
},
onSubmit: async (values) => {
const rawResponse = await fetch(urls.patch, {
method: 'PATCH',
body: JSON.stringify({ ...values, id: app.id }),
headers: defaultHeaders,
});
const response = await rawResponse.json();
if (rawResponse.ok) {
onUpdate?.(response.data);
} else {
onError?.(response.error);
}
},
});
return (
<form onSubmit={formik.handleSubmit} method='POST'>
<Card>
<Card.Body>
<Card.Header>
<Card.Title>{t('bui-fs-branding-title')}</Card.Title>
<Card.Description>{t('bui-fs-branding-desc')}</Card.Description>
</Card.Header>
<div className='flex flex-col'>
<label className='form-control w-full'>
<div className='label'>
<span className='label-text'>{t('bui-fs-logo-url')}</span>
</div>
<input
type='url'
placeholder='https://your-app.com/logo.png'
className='input input-bordered w-full text-sm'
name='logoUrl'
value={formik.values.logoUrl || ''}
onChange={formik.handleChange}
/>
<label className='label'>
<span className='label-text-alt'>{t('bui-fs-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>
</div>
<input
type='url'
placeholder='https://your-app.com/favicon.ico'
className='input input-bordered w-full text-sm'
name='faviconUrl'
value={formik.values.faviconUrl || ''}
onChange={formik.handleChange}
/>
<label className='label'>
<span className='label-text-alt'>{t('bui-fs-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>
</label>
</div>
<div className='flex gap-3 border-[1px] border-gray-200 rounded-md p-2 w-fit'>
<h3 className='border-r-[1px] border-gray-200 pr-2'>{formik.values.primaryColor}</h3>
<input
type='color'
name='primaryColor'
onChange={formik.handleChange}
value={formik.values.primaryColor || '#25c2a0'}
/>
</div>
<label className='label'>
<span className='label-text-alt'>{t('bui-fs-primary-color-desc')}</span>
</label>
</label>
</div>
</Card.Body>
<Card.Footer>
<Button
type='submit'
className='btn btn-primary btn-md'
loading={formik.isSubmitting}
disabled={!formik.dirty || !formik.isValid}>
{t('bui-shared-save-changes')}
</Button>
</Card.Footer>
</Card>
</form>
);
};

View File

@ -0,0 +1,103 @@
import useSWR from 'swr';
import type { SAMLFederationApp } from '../types';
import { EditBranding } from './EditBranding';
import { Edit } from './Edit';
import { EditAttributesMapping } from './EditAttributesMapping';
import { DeleteCard, Loading, DeleteConfirmationModal } from '../shared';
import { useTranslation } from 'next-i18next';
import { useState } from 'react';
import { defaultHeaders, fetcher } from '../utils';
import { PageHeader } from '../shared';
export const EditFederatedSAMLApp = ({
urls,
onError,
onUpdate,
onDelete,
excludeFields,
}: {
urls: { getApp: string; updateApp: string; deleteApp: string };
onUpdate?: (data: SAMLFederationApp) => void;
onError?: (error: Error) => void;
onDelete?: () => void;
excludeFields?: 'product'[];
}) => {
const { t } = useTranslation('common');
const [delModalVisible, setDelModalVisible] = useState(false);
const { data, isLoading, error, mutate } = useSWR<{ data: SAMLFederationApp }>(urls.getApp, fetcher);
if (isLoading) {
return <Loading />;
}
if (error) {
onError?.(error);
return;
}
if (!data) {
return null;
}
const app = data?.data;
const deleteApp = async () => {
try {
await fetch(urls.deleteApp, { method: 'DELETE', headers: defaultHeaders });
setDelModalVisible(false);
onDelete?.();
} catch (error: any) {
onError?.(error);
}
};
return (
<>
<PageHeader title={t('bui-fs-edit-app')} />
<div className='flex flex-col gap-6'>
<Edit
app={app}
urls={{ patch: urls.updateApp }}
onError={onError}
onUpdate={(data) => {
mutate({ data });
onUpdate?.(data);
}}
excludeFields={excludeFields}
/>
<EditAttributesMapping
app={app}
urls={{ patch: urls.updateApp }}
onError={onError}
onUpdate={(data) => {
mutate({ data });
onUpdate?.(data);
}}
/>
<EditBranding
app={app}
urls={{ patch: urls.updateApp }}
onError={onError}
onUpdate={(data) => {
mutate({ data });
onUpdate?.(data);
}}
/>
<DeleteCard
title={t('bui-fs-delete-app-title')}
description={t('bui-fs-delete-app-desc')}
buttonLabel={t('bui-shared-delete')}
onClick={() => setDelModalVisible(true)}
/>
<DeleteConfirmationModal
title={t('bui-fs-delete-app-title')}
description={t('bui-fs-delete-app-desc')}
visible={delModalVisible}
onConfirm={() => deleteApp()}
onCancel={() => setDelModalVisible(false)}
/>
</div>
</>
);
};

View File

@ -0,0 +1,163 @@
import useSWR from 'swr';
import { fetcher } from '../utils';
import {
Loading,
Table,
EmptyState,
Error,
Pagination,
PageHeader,
LinkOutline,
ButtonPrimary,
} from '../shared';
import { useTranslation } from 'next-i18next';
import type { SAMLFederationApp } from '../types';
import { PencilIcon } from '@heroicons/react/24/outline';
import { TableBodyType } from '../shared/Table';
import { pageLimit } from '../shared/Pagination';
import { usePaginate } from '../hooks';
import { useRouter } from '../hooks';
type ExcludeFields = keyof Pick<SAMLFederationApp, 'product'>;
export const FederatedSAMLApps = ({
urls,
excludeFields,
onEdit,
actions,
actionCols = [],
}: {
urls: { getApps: string };
excludeFields?: ExcludeFields[];
onEdit?: (app: SAMLFederationApp) => void;
actions: { newApp: string; idpConfiguration: string };
actionCols?: { text: string; onClick: (app: SAMLFederationApp) => void; icon: JSX.Element }[];
}) => {
const { router } = useRouter();
const { t } = useTranslation('common');
const { paginate, setPaginate, pageTokenMap } = usePaginate(router!);
let getAppsUrl = `${urls.getApps}?offset=${paginate.offset}&limit=${pageLimit}`;
// For DynamoDB
if (paginate.offset > 0 && pageTokenMap[paginate.offset - pageLimit]) {
getAppsUrl += `&pageToken=${pageTokenMap[paginate.offset - pageLimit]}`;
}
const { data, isLoading, error } = useSWR<{ data: SAMLFederationApp[] }>(getAppsUrl, fetcher);
if (isLoading) {
return <Loading />;
}
if (error) {
return <Error message={error.message} />;
}
if (!data) {
return null;
}
const apps = data?.data || [];
const noApps = apps.length === 0 && paginate.offset === 0;
const noMoreResults = apps.length === 0 && paginate.offset > 0;
let columns = [
{
key: 'name',
label: t('bui-shared-name'),
wrap: true,
dataIndex: 'name',
},
{
key: 'tenant',
label: t('bui-shared-tenant'),
wrap: true,
dataIndex: 'tenant',
},
{
key: 'product',
label: t('bui-shared-product'),
wrap: true,
dataIndex: 'product',
},
];
if (excludeFields) {
columns = columns.filter((column) => !excludeFields.includes(column.key as ExcludeFields));
}
const cols = columns.map(({ label }) => label);
const body: TableBodyType[] = apps.map((app) => {
return {
id: app.id,
cells: columns.map((column) => {
const dataIndex = column.dataIndex as keyof typeof app;
return {
wrap: column.wrap,
text: app[dataIndex] as string,
};
}),
};
});
// Action column & buttons
cols.push(t('bui-shared-actions'));
body.forEach((row) => {
row.cells.push({
actions: [
{
text: t('bui-shared-edit'),
onClick: () => onEdit?.(apps.find((app) => app.id === row.id)!),
icon: <PencilIcon className='w-5' />,
},
...actionCols.map((actionCol) => ({
text: actionCol.text,
onClick: () => actionCol.onClick(apps.find((app) => app.id === row.id)!),
icon: actionCol.icon,
})),
],
});
});
return (
<div className='space-y-3'>
<PageHeader
title={t('bui-fs-apps')}
actions={
<>
<LinkOutline href={actions.idpConfiguration} target='_blank' className='btn-md'>
{t('bui-fs-idp-config')}
</LinkOutline>
<ButtonPrimary onClick={() => router?.push(actions.newApp)} className='btn-md'>
{t('bui-fs-new-app')}
</ButtonPrimary>
</>
}
/>
{noApps ? (
<EmptyState title={t('bui-fs-no-apps')} description={t('bui-fs-no-apps-desc')} />
) : (
<>
<Table noMoreResults={noMoreResults} cols={cols} body={body} />
<Pagination
itemsCount={apps.length}
offset={paginate.offset}
onPrevClick={() => {
setPaginate({
offset: paginate.offset - pageLimit,
});
}}
onNextClick={() => {
setPaginate({
offset: paginate.offset + pageLimit,
});
}}
/>
</>
)}
</div>
);
};

View File

@ -0,0 +1,210 @@
import { useFormik } from 'formik';
import TagsInput from 'react-tagsinput';
import { Card, Button } from 'react-daisyui';
import { useTranslation } from 'next-i18next';
import type { SAMLFederationApp } from '../types';
import { QuestionMarkCircleIcon } from '@heroicons/react/24/outline';
import { defaultHeaders } from '../utils';
import { AttributesMapping } from './AttributesMapping';
import { PageHeader } from '../shared';
type NewSAMLFederationApp = Pick<
SAMLFederationApp,
'name' | 'tenant' | 'product' | 'acsUrl' | 'entityId' | 'tenants' | 'mappings'
>;
export const NewFederatedSAMLApp = ({
samlAudience = 'https://saml.boxyhq.com',
urls,
onSuccess,
onError,
onEntityIdGenerated,
excludeFields,
}: {
samlAudience?: string;
urls: { createApp: string };
onSuccess?: (data: SAMLFederationApp) => void;
onError?: (error: Error) => void;
onEntityIdGenerated?: (entityId: string) => void;
excludeFields?: 'product'[];
}) => {
const { t } = useTranslation('common');
const initialValues: NewSAMLFederationApp = {
name: '',
tenant: '',
product: '',
acsUrl: '',
entityId: '',
tenants: [],
mappings: [],
};
if (excludeFields) {
excludeFields.forEach((key) => {
delete initialValues[key as keyof NewSAMLFederationApp];
});
}
const formik = useFormik<NewSAMLFederationApp>({
initialValues: initialValues,
onSubmit: async (values) => {
const rawResponse = await fetch(urls.createApp, {
method: 'POST',
body: JSON.stringify(values),
headers: defaultHeaders,
});
const response = await rawResponse.json();
if (rawResponse.ok) {
onSuccess?.(response.data);
} else {
onError?.(response.error);
}
},
});
const generateEntityId = () => {
const id = crypto.randomUUID().replace(/-/g, '');
const entityId = `${samlAudience}/${id}`;
formik.setFieldValue('entityId', entityId);
navigator.clipboard.writeText(entityId);
onEntityIdGenerated?.(entityId);
};
return (
<>
<PageHeader title={t('bui-fs-create-app')} />
<form onSubmit={formik.handleSubmit} method='POST'>
<Card className='p-6 rounded space-y-3'>
<label className='form-control w-full'>
<div className='label'>
<span className='label-text'>{t('bui-shared-name')}</span>
</div>
<input
type='text'
placeholder='Your app'
className='input input-bordered w-full text-sm'
name='name'
value={formik.values.name}
onChange={formik.handleChange}
required
/>
</label>
<label className='form-control w-full'>
<div className='label'>
<span className='label-text'>{t('bui-shared-tenant')}</span>
</div>
<input
type='text'
placeholder='acme'
className='input input-bordered w-full text-sm'
name='tenant'
value={formik.values.tenant}
onChange={formik.handleChange}
required
/>
</label>
{!excludeFields?.includes('product') && (
<label className='form-control w-full'>
<div className='label'>
<span className='label-text'>{t('bui-shared-product')}</span>
</div>
<input
type='text'
placeholder='MyApp'
className='input input-bordered w-full text-sm'
name='product'
value={formik.values.product}
onChange={formik.handleChange}
required
/>
</label>
)}
<label className='form-control w-full'>
<div className='label'>
<span className='label-text'>{t('bui-fs-acs-url')}</span>
</div>
<input
type='url'
placeholder='https://your-sp.com/saml/acs'
className='input input-bordered w-full text-sm'
name='acsUrl'
value={formik.values.acsUrl}
onChange={formik.handleChange}
required
/>
</label>
<label className='form-control w-full'>
<div className='label'>
<span className='label-text'>{t('bui-fs-entity-id')}</span>
<span className='label-text-alt'>
<div className='flex items-center gap-1'>
<span
className='cursor-pointer border-stone-600 border p-1 rounded'
onClick={generateEntityId}>
{t('bui-fs-generate-sp-entity-id')}
</span>
<div className='tooltip tooltip-left' data-tip={t('bui-fs-entity-id-instruction')}>
<QuestionMarkCircleIcon className='h-5 w-5' />
</div>
</div>
</span>
</div>
<input
type='text'
placeholder='https://your-sp.com/saml/entityId'
className='input input-bordered w-full text-sm'
name='entityId'
value={formik.values.entityId}
onChange={formik.handleChange}
required
/>
<label className='label'>
<span className='label-text-alt'>{t('bui-fs-entity-id-change-restriction')}</span>
</label>
</label>
<label className='form-control w-full'>
<label className='label'>
<span className='label-text'>{t('bui-fs-tenants')}</span>
</label>
<TagsInput
value={formik.values.tenants}
onChange={(tags: string[]) => formik.setFieldValue('tenants', tags)}
onlyUnique={true}
inputProps={{
placeholder: t('bui-fs-enter-tenant'),
}}
focusedClassName='input-focused'
addOnBlur={true}
/>
<label className='label'>
<span className='label-text-alt'>{t('bui-fs-tenants-mapping-desc')}</span>
</label>
</label>
<label className='form-control w-full'>
<div className='label'>
<span className='label-text'>{t('bui-fs-attribute-mappings')}</span>
</div>
<div className='label'>
<span className='label-text-alt'>{t('bui-fs-attribute-mappings-desc')}</span>
</div>
</label>
<AttributesMapping
mappings={formik.values.mappings || []}
onAttributeMappingsChange={(mappings) => formik.setFieldValue('mappings', mappings)}
/>
<div className='flex gap-2 justify-end pt-6'>
<Button
className='btn btn-primary btn-md'
loading={formik.isSubmitting}
disabled={!formik.dirty || !formik.isValid}>
{t('bui-fs-create-app-btn')}
</Button>
</div>
</Card>
</form>
</>
);
};

View File

@ -0,0 +1,3 @@
export { NewFederatedSAMLApp } from './NewFederatedSAMLApp';
export { EditFederatedSAMLApp } from './EditFederatedSAMLApp';
export { FederatedSAMLApps } from './FederatedSAMLApps';

View File

@ -0,0 +1,3 @@
export { usePaginate } from './usePaginate';
export { useDirectory } from './useDirectory';
export { useRouter } from './useRouter';

View File

@ -0,0 +1,15 @@
import useSWR from 'swr';
import { fetcher } from '../utils';
// TODO:
// Add types to response
export const useDirectory = (getDirectoryUrl: string) => {
const { data, error, isLoading } = useSWR(getDirectoryUrl, fetcher);
return {
directory: data?.data,
isLoadingDirectory: isLoading,
directoryError: error,
};
};

View File

@ -0,0 +1,36 @@
import type { NextRouter } from 'next/router';
import { useState, useEffect } from 'react';
// TODO:
// https://nextjs.org/docs/messages/next-router-not-mounted
// Accepting router is a temp workaround to handle Router not mounted error
export const usePaginate = (router: NextRouter) => {
const offset = router.query.offset ? Number(router.query.offset) : 0;
const [paginate, setPaginate] = useState({ offset });
const [pageTokenMap, setPageTokenMap] = useState({});
useEffect(() => {
// Prevent pushing the same URL to the history
if (offset === paginate.offset) {
return;
}
const path = router.asPath.split('?')[0];
router.push(`${path}?offset=${paginate.offset}`, undefined, { shallow: true });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [paginate]);
useEffect(() => {
setPaginate({ offset });
}, [offset]);
return {
paginate,
setPaginate,
pageTokenMap,
setPageTokenMap,
};
};

View File

@ -0,0 +1,7 @@
import { useContext } from 'react';
import { BUIContext } from '../provider';
export const useRouter = () => {
const { router } = useContext(BUIContext);
return { router };
};

5
internal-ui/src/index.ts Normal file
View File

@ -0,0 +1,5 @@
export * from './well-known';
export * from './federated-saml';
export * from './shared';
export * from './dsync';
export * from './provider';

View File

@ -0,0 +1,8 @@
import { createContext } from 'react';
import type { NextRouter } from 'next/router';
export const BUIContext = createContext<{ router: NextRouter | null }>({ router: null });
export const BUIProvider = BUIContext.Provider;
export const BUIConsumer = BUIContext.Consumer;

View File

@ -0,0 +1,12 @@
import classNames from 'classnames';
import { BadgeProps, Badge as BaseBadge } from 'react-daisyui';
export const Badge = ({ children, className, ...props }: BadgeProps) => {
return (
<>
<BaseBadge {...props} className={classNames('rounded text-xs py-2 text-white', className)}>
{children}
</BaseBadge>
</>
);
};

View File

@ -0,0 +1,15 @@
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

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,39 @@
import React from 'react';
const Card = ({ children }: { children: React.ReactNode }) => {
return (
<div className='card w-full border border-rounded dark:bg-black dark:border-gray-600'>{children}</div>
);
};
const Title = ({ children }: { children: React.ReactNode }) => {
return <h2 className='card-title text-xl font-medium leading-none tracking-tight'>{children}</h2>;
};
const Description = ({ children }: { children: React.ReactNode }) => {
return <div className='text-gray-600 dark:text-gray-400 text-sm'>{children}</div>;
};
const Header = ({ children }: { children: React.ReactNode }) => {
return <div className='flex gap-2 flex-col'>{children}</div>;
};
const Body = ({ children }: { children: React.ReactNode }) => {
return <div className='card-body dark:bg-black gap-4 p-6'>{children}</div>;
};
const Footer = ({ children }: { children: React.ReactNode }) => {
return (
<div className='card-actions justify-end dark:border-gray-600 p-2 border-t bg-gray-50 dark:bg-black'>
{children}
</div>
);
};
Card.Body = Body;
Card.Title = Title;
Card.Description = Description;
Card.Header = Header;
Card.Footer = Footer;
export { Card };

View File

@ -0,0 +1,31 @@
import { Card, Button } from 'react-daisyui';
export const DeleteCard = ({
title,
description,
buttonLabel,
onClick,
}: {
title: string;
description: string;
buttonLabel: string;
onClick: () => void;
}) => {
return (
<Card className='border-red-400'>
<Card.Body className='p-6'>
<div className='flex justify-between items-center gap-2'>
<div className='flex flex-col gap-1'>
<h6 className='text-lg font-medium'>{title}</h6>
<p className='text-sm text-gray-500'>{description}</p>
</div>
<div>
<Button color='warning' className='btn btn-md btn-error text-white' onClick={onClick}>
{buttonLabel}
</Button>
</div>
</div>
</Card.Body>
</Card>
);
};

View File

@ -0,0 +1,46 @@
import { useTranslation } from 'next-i18next';
import { Modal } from './Modal';
import { ButtonOutline } from './ButtonOutline';
import { ButtonDanger } from './ButtonDanger';
import { ButtonBase } from './ButtonBase';
export const DeleteConfirmationModal = ({
visible,
title,
description,
onConfirm,
onCancel,
actionButtonText,
dataTestId = 'confirm-delete',
overrideDeleteButton = false,
}: {
visible: boolean;
title: string;
description: string;
onConfirm: () => void | Promise<void>;
onCancel: () => void;
actionButtonText?: string;
overrideDeleteButton?: boolean;
dataTestId?: string;
}) => {
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>
);
};

View File

@ -0,0 +1,11 @@
import InformationCircleIcon from '@heroicons/react/24/outline/InformationCircleIcon';
export const EmptyState = ({ title, description }: { title: string; description?: string | null }) => {
return (
<div className='my-2 flex w-full flex-col items-center justify-center rounded lg:p-20 border gap-2 bg-white dark:bg-black h-80 border-slate-300 dark:border-white'>
<InformationCircleIcon className='w-10 h-10' />
<h3 className='text-semibold text-emphasis text-center text-lg'>{title}</h3>
{description && <p className='text-sm text-center font-light leading-6'>{description}</p>}
</div>
);
};

View File

@ -0,0 +1,11 @@
import { Card } from 'react-daisyui';
export const Error = ({ message }: { message: string }) => {
return (
<Card className='border-red-400'>
<Card.Body>
<p className='leading-none'>{message}</p>
</Card.Body>
</Card>
);
};

View File

@ -0,0 +1,11 @@
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

@ -0,0 +1,38 @@
import { useTranslation } from 'next-i18next';
import { copyToClipboard } from '../utils';
import { IconButton } from './IconButton';
import ClipboardDocumentIcon from '@heroicons/react/24/outline/ClipboardDocumentIcon';
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

@ -0,0 +1,34 @@
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,
className,
}: {
href?: string;
onClick?: () => void;
className?: string;
}) => {
const { t } = useTranslation('common');
if (href) {
return (
<LinkOutline href={href} Icon={ArrowLeftIcon} className={className}>
{t('back')}
</LinkOutline>
);
}
if (onClick) {
return (
<ButtonOutline onClick={onClick} Icon={ArrowLeftIcon} className={className}>
{t('back')}
</ButtonOutline>
);
}
return null;
};

View File

@ -0,0 +1,17 @@
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} passHref>
{Icon && <Icon className='mr-1 h-4 w-4' aria-hidden />}
{children}
</Link>
);
};

View File

@ -0,0 +1,10 @@
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

@ -0,0 +1,10 @@
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

@ -0,0 +1,30 @@
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 const Loading = () => {
return (
<div className='flex items-center justify-center'>
<div role='status'>
<Spinner />
<span className='sr-only'>Loading...</span>
</div>
</div>
);
};

View File

@ -0,0 +1,32 @@
import React from 'react';
import { useState, useEffect } from 'react';
export const Modal = ({
visible,
title,
description,
children,
}: {
visible: boolean;
title: string;
description?: string;
children?: React.ReactNode;
}) => {
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-1'>
<h3 className='text-lg font-bold'>{title}</h3>
{description && <p className='text-sm'>{description}</p>}
<div>{children}</div>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,13 @@
import { useTranslation } from 'next-i18next';
export const NoMoreResults = ({ colSpan }: { colSpan: number }) => {
const { t } = useTranslation('common');
return (
<tr>
<td colSpan={colSpan} className='px-6 py-3 text-center text-sm text-gray-500'>
{t('bui-shared-no-more-results')}
</td>
</tr>
);
};

View File

@ -0,0 +1,21 @@
export const PageHeader = ({
title,
description,
actions,
className,
}: {
title: string;
description?: string;
actions?: any;
className?: string;
}) => {
return (
<div className={`flex flex-col ${className}`}>
<div className='flex justify-between items-center'>
<h2 className='text-emphasis text-xl font-semibold tracking-wide'>{title}</h2>
{actions && <div className='flex gap-4'>{actions}</div>}
</div>
{description && <div className='text-gray-700 dark:text-white'>{description}</div>}
</div>
);
};

View File

@ -0,0 +1,49 @@
import { useTranslation } from 'next-i18next';
import ArrowLeftIcon from '@heroicons/react/24/outline/ArrowLeftIcon';
import ArrowRightIcon from '@heroicons/react/24/outline/ArrowRightIcon';
import { ButtonOutline } from './ButtonOutline';
export const pageLimit = 15;
export const Pagination = ({
itemsCount,
offset,
onPrevClick,
onNextClick,
}: {
itemsCount: number;
offset: number;
onPrevClick: () => void;
onNextClick: () => void;
}) => {
const { t } = useTranslation('common');
// Hide pagination if there are no items to paginate.
if ((itemsCount === 0 && offset === 0) || (itemsCount < pageLimit && offset === 0)) {
return null;
}
const prevDisabled = offset === 0;
const nextDisabled = itemsCount < pageLimit || itemsCount === 0;
return (
<div className='flex justify-center space-x-4 py-4'>
<ButtonOutline
className='btn-md'
Icon={ArrowLeftIcon}
aria-label={t('bui-shared-previous') as string}
onClick={onPrevClick}
disabled={prevDisabled}>
{t('bui-shared-previous')}
</ButtonOutline>
<ButtonOutline
className='btn-md'
Icon={ArrowRightIcon}
aria-label={t('bui-shared-next') as string}
onClick={onNextClick}
disabled={nextDisabled}>
{t('bui-shared-next')}
</ButtonOutline>
</div>
);
};

View File

@ -0,0 +1,152 @@
import { Button } from 'react-daisyui';
import { useTranslation } from 'next-i18next';
import { Badge } from './Badge';
const tableWrapperClass = 'rounder border';
const tableClass = 'w-full text-left text-sm text-gray-500 dark:text-gray-400';
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-2 text-sm text-gray-500 dark:text-gray-400';
const tdClass = `whitespace-nowrap ${tdClassBase}`;
const tdClassWrap = `break-all ${tdClassBase}`;
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 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-2 text-center text-sm text-gray-500'>
{t('bui-shared-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>
);
};
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>
);
};
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

@ -0,0 +1,17 @@
export { Loading } from './Loading';
export { DeleteCard } from './DeleteCard';
export { Card } from './Card';
export { Table } from './Table';
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 { DeleteConfirmationModal } from './DeleteConfirmationModal';
export { PageHeader } from './PageHeader';
export { LinkOutline } from './LinkOutline';
export { LinkPrimary } from './LinkPrimary';
export { LinkBack } from './LinkBack';
export { pageLimit } from './Pagination';
export { ButtonPrimary } from './ButtonPrimary';

97
internal-ui/src/types.ts Normal file
View File

@ -0,0 +1,97 @@
enum DirectorySyncProviders {
'azure-scim-v2' = 'Azure SCIM v2.0',
'onelogin-scim-v2' = 'OneLogin SCIM v2.0',
'okta-scim-v2' = 'Okta SCIM v2.0',
'jumpcloud-scim-v2' = 'JumpCloud v2.0',
'generic-scim-v2' = 'Generic SCIM v2.0',
'google' = 'Google',
}
type DirectorySyncEventType =
| 'user.created'
| 'user.updated'
| 'user.deleted'
| 'group.created'
| 'group.updated'
| 'group.deleted'
| 'group.user_added'
| 'group.user_removed';
type DirectoryType = keyof typeof DirectorySyncProviders;
type UserWithGroup = User & { group: Group };
type DirectorySyncEventData = User | Group | UserWithGroup;
interface DirectorySyncEvent {
directory_id: Directory['id'];
event: DirectorySyncEventType;
data: DirectorySyncEventData;
tenant: string;
product: string;
}
export type Directory = {
id: string;
name: string;
tenant: string;
product: string;
type: DirectoryType;
log_webhook_events: boolean;
scim: {
path: string;
endpoint?: string;
secret: string;
};
webhook: {
endpoint: string;
secret: string;
};
deactivated?: boolean;
google_domain?: string;
google_access_token?: string;
google_refresh_token?: string;
};
export type User = {
id: string;
email: string;
first_name: string;
last_name: string;
active: boolean;
raw?: any;
};
export type Group = {
id: string;
name: string;
raw?: any;
};
export interface WebhookEventLog {
id: string;
webhook_endpoint: string;
created_at: Date;
status_code?: number;
delivered?: boolean;
payload: DirectorySyncEvent | DirectorySyncEvent[];
}
export type AttributeMapping = {
key: string;
value: string;
};
export type SAMLFederationApp = {
id: string;
name: string;
tenant: string;
product: string;
acsUrl: string;
entityId: string;
logoUrl: string | null;
faviconUrl: string | null;
primaryColor: string | null;
tenants?: string[]; // To support multiple tenants for a single app
mappings: AttributeMapping[] | null;
};

45
internal-ui/src/utils.ts Normal file
View File

@ -0,0 +1,45 @@
export function copyToClipboard(text: string) {
navigator.clipboard.writeText(text);
}
export const defaultHeaders = {
'Content-Type': 'application/json',
};
export const fetcher = async (url: string, queryParams = '') => {
const res = await fetch(`${url}${queryParams}`);
let resContent, pageToken;
try {
resContent = await res.clone().json();
pageToken = res.headers.get('jackson-pagetoken');
if (pageToken !== null) {
return { ...resContent, pageToken };
}
} catch (e) {
resContent = await res.clone().text();
}
if (!res.ok) {
const error = new Error(
(resContent.error.message as string) || 'An error occurred while fetching the data.'
);
throw error;
}
return resContent;
};
export const addQueryParamsToPath = (path: string, queryParams: Record<string, any>) => {
const hasQuery = path.includes('?');
const queryString = Object.keys(queryParams)
.map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(queryParams[key])}`)
.join('&');
const newPath = hasQuery ? `${path}&${queryString}` : `${path}?${queryString}`;
return newPath;
};

View File

@ -1,63 +1,63 @@
import ArrowTopRightOnSquareIcon from '@heroicons/react/20/solid/ArrowTopRightOnSquareIcon';
import { useTranslation } from 'next-i18next';
import { LinkOutline } from '@components/LinkOutline';
import Link from 'next/link';
import { useState } from 'react';
import { useTranslation } from 'next-i18next';
import ArrowTopRightOnSquareIcon from '@heroicons/react/20/solid/ArrowTopRightOnSquareIcon';
const WellKnownURLs = () => {
export const WellKnownURLs = ({ jacksonUrl }: { jacksonUrl?: string }) => {
const { t } = useTranslation('common');
const viewText = t('view');
const downloadText = t('download');
const [view, setView] = useState<'idp-config' | 'auth' | 'saml-fed'>('idp-config');
const viewText = t('bui-wku-view');
const downloadText = t('bui-wku-download');
const baseUrl = jacksonUrl ?? '';
const links = [
{
title: t('sp_metadata'),
description: t('sp_metadata_description'),
href: '/.well-known/sp-metadata',
title: t('bui-wku-sp-metadata'),
description: t('bui-wku-sp-metadata-desc'),
href: `${baseUrl}/.well-known/sp-metadata`,
buttonText: viewText,
type: 'idp-config',
},
{
title: t('saml_configuration'),
description: t('sp_config_description'),
href: '/.well-known/saml-configuration',
title: t('bui-wku-saml-configuration'),
description: t('bui-wku-sp-config-desc'),
href: `${baseUrl}/.well-known/saml-configuration`,
buttonText: viewText,
type: 'idp-config',
},
{
title: t('saml_public_cert'),
description: t('saml_public_cert_description'),
href: '/.well-known/saml.cer',
title: t('bui-wku-saml-public-cert'),
description: t('bui-wku-saml-public-cert-desc'),
href: `${baseUrl}/.well-known/saml.cer`,
buttonText: downloadText,
type: 'idp-config',
},
{
title: t('oidc_configuration'),
description: t('oidc_config_description'),
href: '/.well-known/oidc-configuration',
title: t('bui-wku-oidc-configuration'),
description: t('bui-wku-oidc-config-desc'),
href: `${baseUrl}/.well-known/oidc-configuration`,
buttonText: viewText,
type: 'idp-config',
},
{
title: t('oidc_discovery'),
description: t('oidc_discovery_description'),
href: '/.well-known/openid-configuration',
title: t('bui-wku-oidc-discovery'),
description: t('bui-wku-oidc-discovery-desc'),
href: `${baseUrl}/.well-known/openid-configuration`,
buttonText: viewText,
type: 'auth',
},
{
title: t('idp_metadata'),
description: t('idp_metadata_description'),
href: '/.well-known/idp-metadata',
title: t('bui-wku-idp-metadata'),
description: t('bui-wku-idp-metadata-desc'),
href: `${baseUrl}/.well-known/idp-metadata`,
buttonText: viewText,
type: 'saml-fed',
},
{
title: t('idp_configuration'),
description: t('idp_config_description'),
href: '/.well-known/idp-configuration',
title: t('bui-wku-idp-configuration'),
description: t('bui-wku-idp-config-desc'),
href: `${baseUrl}/.well-known/idp-configuration`,
buttonText: viewText,
type: 'saml-fed',
},
@ -65,32 +65,30 @@ const WellKnownURLs = () => {
return (
<>
<div className='mb-5 flex items-center justify-between'>
<h2 className='font-bold text-gray-700 dark:text-white md:text-xl'>
{t('here_are_the_set_of_uris_you_would_need_access_to')}:
</h2>
</div>
<h2 className='text-emphasis text-xl font-semibold leading-5 tracking-wide dark:text-white pb-4'>
{t('bui-wku-heading')}
</h2>
<div className='grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6'>
<Tab
isActive={view === 'idp-config'}
setIsActive={() => setView('idp-config')}
title={t('idp_configuration_title')}
description={t('idp_configuration_description')}
label={t('idp_configuration_label')}
title={t('bui-wku-idp-configuration-links')}
description={t('bui-wku-desc-idp-configuration')}
label={t('bui-wku-idp-configuration-links')}
/>
<Tab
isActive={view === 'auth'}
setIsActive={() => setView('auth')}
title={t('auth_integration_title')}
description={t('auth_integration_description')}
label={t('auth_integration_label')}
title={t('bui-wku-auth-integration-links')}
description={t('bui-wku-desc-auth-integration')}
label={t('bui-wku-auth-integration-links')}
/>
<Tab
isActive={view === 'saml-fed'}
setIsActive={() => setView('saml-fed')}
title={t('saml_fed_configuration_title')}
description={t('saml_fed_configuration_description')}
label={t('saml_fed_configuration_label')}
title={t('bui-wku-saml-federation-links')}
description={t('bui-wku-desc-saml-federation')}
label={t('bui-wku-saml-federation-links')}
/>
</div>
<div className='space-y-3 mt-8'>
@ -114,14 +112,14 @@ const Tab = ({ isActive, setIsActive, title, description, label }) => {
return (
<button
type='button'
className={`w-full text-left rounded-lg focus:outline-none focus:ring focus:ring-teal-200 border hover:border-teal-800 p-6${
className={`w-full text-left rounded border hover:border-teal-800 p-4${
isActive ? ' bg-teal-50 opacity-100' : ' opacity-50'
}`}
onClick={setIsActive}
aria-label={label}>
<span className='flex flex-col items-end'>
<span className='font-semibold'>{title}</span>
<span>{description}</span>
<span className='text-sm'>{description}</span>
</span>
</button>
);
@ -139,25 +137,23 @@ const LinkCard = ({
buttonText: string;
}) => {
return (
<div className='space-y-2 rounded-md border p-4 hover:border-gray-400'>
<div className='rounded border p-4 hover:border-gray-400'>
<div className='flex items-center justify-between'>
<div className='space-y-2'>
<h3 className='font-bold'>{title}</h3>
<p className='text-[15px]'>{description}</p>
</div>
<div className='mx-4'>
<LinkOutline
className='btn btn-secondary btn-sm w-32'
<Link
className='btn btn-secondary btn-outline btn-sm w-32'
href={href}
target='_blank'
rel='noreferrer'
Icon={ArrowTopRightOnSquareIcon}>
rel='noreferrer'>
<ArrowTopRightOnSquareIcon className='w-4 h-4 mr-2' />
{buttonText}
</LinkOutline>
</Link>
</div>
</div>
</div>
);
};
export default WellKnownURLs;

View File

@ -0,0 +1 @@
export { WellKnownURLs } from './WellKnownURLs';

22
internal-ui/tsconfig.json Normal file
View File

@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "es6",
"baseUrl": ".",
"jsx": "react-jsx",
"moduleResolution": "Node",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
"esModuleInterop": true,
"strict": true,
"noImplicitAny": false,
"declaration": true,
"noUnusedParameters": true,
"strictNullChecks": true,
"experimentalDecorators": true,
"paths": {
"react": ["./node_modules/@types/react"],
},
},
"include": ["./src/**/*"],
"exclude": ["dist", "build", "node_modules"],
}

1
internal-ui/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@ -0,0 +1,29 @@
import { resolve } from 'path';
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import viteTsconfigPaths from 'vite-tsconfig-paths';
import typescript from '@rollup/plugin-typescript';
export default defineConfig({
build: {
lib: {
entry: resolve(__dirname, 'src/index.ts'),
fileName: 'index',
formats: ['es'],
},
rollupOptions: {
external: ['react', 'react/jsx-runtime', 'react-dom', 'next', 'next-i18next'],
},
},
plugins: [
react(),
viteTsconfigPaths(),
typescript({
declaration: true,
emitDeclarationOnly: true,
noForceEmit: true,
declarationDir: resolve(__dirname, 'dist/'),
rootDir: resolve(__dirname, 'src'),
}),
],
});

View File

@ -1,8 +1,6 @@
{
"validity": "Validity",
"documentation": "Documentation",
"entity-id-change-restriction": "You can't change this value once the app is created.",
"desc-entity-id": "You can't change this value. Delete and create a new app if you need to change it.",
"actions": "Actions",
"active": "Active",
"all_your_apps_using_this_connection_will_stop_working": "All your apps using this connection will stop working.",
@ -10,7 +8,6 @@
"cancel": "Cancel",
"copy": "Copy",
"copied": "Copied",
"clear_events": "Clear Events",
"client_error": "Client error",
"close_sidebar": "Close sidebar",
"confirmation_modal_description": "This action cannot be undone. This will permanently delete the Connection.",
@ -31,11 +28,8 @@
"boxyhq_tagline": "Security Building Blocks for Developers.",
"enterprise_sso": "Enterprise SSO",
"error": "Error",
"first_name": "First Name",
"here_are_the_set_of_uris_you_would_need_access_to": "Here are the set of URIs you would need access to",
"idp_entity_id": "IdP Entity ID",
"idp_login": "IdP Login",
"last_name": "Last Name",
"login_with_sso": "Login with SSO",
"login_success_toast": "A sign in link has been sent to your email address.",
"link_generated": "Link Generated",
@ -67,30 +61,18 @@
"select_sso_type": "Select SSO type",
"select_an_app": "Select an App to continue",
"send_magic_link": "Send Magic Link",
"sent_at": "Sent At",
"setup_links": "Setup Links",
"status_code": "Status Code",
"status": "Status",
"suspended": "Suspended",
"tenant": "Tenant",
"update_directory": "Update Directory",
"webhook_endpoint": "Webhook Endpoint",
"webhook_secret": "Webhook secret",
"webhook_url": "Webhook URL",
"saml_federation_apps": "SAML Federation Apps",
"no_saml_federation_apps": "No SAML Federation Apps found.",
"download": "Download",
"saml_federation_new_success": "SAML Federation app created successfully.",
"saml_federation_add_new_app": "Add SAML Federation App",
"saml_federation_add_new_app_description": "To configure SAML Federation app, add service provider details such as ACS URL and Entity ID.",
"acs_url": "ACS URL",
"entity_id": "Entity ID / Audience URI / Audience Restriction",
"create_app": "Create App",
"saml_federation_update_success": "SAML Federation app updated successfully.",
"saml_federation_update_app": "Update SAML Federation App",
"saml_federation_delete_success": "SAML federation app deleted successfully",
"delete_this_saml_federation_app": "Delete this SAML Federation app",
"delete_the_saml_federation_app": "Delete the SAML Federation app?",
"saml_federation_app_info": "SAML Federation App Information",
"saml_federation_app_info_details": "Choose from the following options to configure your SAML Federation on the service provider side",
"download_metadata": "Download Metadata",
@ -121,22 +103,10 @@
"trace_entity_id": "Entity ID",
"sso_connection_client_id": "SSO Connection Client ID",
"error_description_from_oidc_idp": "Error Description (from OIDC Provider)",
"new_saml_federation_app": "New App",
"view": "View",
"edit": "Edit",
"settings": "Settings",
"admin_portal_sso": "SSO for Admin Portal",
"sp_metadata_description": "The metadata file that your customers who use federated management systems like OpenAthens and Shibboleth will need to configure your service.",
"sp_config_description": "The configuration setup guide that your customers will need to refer to when setting up SAML application with their Identity Provider.",
"saml_public_cert_description": "The SAML Public Certificate if you want to enable encryption with your Identity Provider.",
"oidc_config_description": "URIs that your customers will need to set up the OIDC app on the Identity Provider.",
"oidc_discovery_description": "Our OpenID well known URI which your customers will need if they are authenticating via OAuth 2.0 or Open ID Connect.",
"idp_metadata_description": "The metadata file that your customers who use our SAML federation feature will need to set up SAML SP configuration on their application.",
"idp_config_description": "The configuration setup guide that your customers who use our SAML federation feature will need to set up SAML SP configuration on their application.",
"no_users_found": "No users found",
"no_groups_found": "No groups found",
"setup_link_url": "Setup Link URL",
"no_webhook_events_found": "No webhook events found",
"regenerate_setup_link": "Regenerate this setup link?",
"regenerate_setup_link_description": "This action cannot be undone. This will permanently delete the old setup link.",
"delete_setup_link": "Delete this setup link?",
@ -215,22 +185,6 @@
"users": "Users",
"groups": "Groups",
"webhook_events": "Webhook Events",
"sp_metadata": "SP Metadata",
"saml_configuration": "SAML Configuration",
"saml_public_cert": "SAML Public Certificate",
"oidc_configuration": "OpenID Configuration",
"oidc_discovery": "OpenID Connect Discovery",
"idp_metadata": "IdP Metadata",
"idp_configuration": "IdP Configuration",
"idp_configuration_title": "Identity Provider Configuration",
"idp_configuration_description": "Links for SAML/OIDC IdP setup",
"idp_configuration_label": "Identity Provider Configuration links",
"auth_integration_title": "Auth integration",
"auth_integration_description": "Links for OAuth 2.0/OpenID Connect auth",
"auth_integration_label": "Auth integration links",
"saml_fed_configuration_title": "SAML Federation",
"saml_fed_configuration_description": "Links for SAML Federation app setup",
"saml_fed_configuration_label": "SAML Federation links",
"retraced_project_created": "Project created successfully",
"project_name": "Project name",
"create_project": "Create Project",
@ -257,19 +211,113 @@
"choose_an_identity_provider_to_continue": "Choose an Identity Provider to continue. If you don't see your Identity Provider, please contact your administrator.",
"choose_an_app_to_continue": "Choose an app to continue. If you don't see your app, please contact your administrator.",
"no_saml_response_try_again": "No SAMLResponse found. Please try again.",
"customize_branding": "You can customize the look and feel of the Identity Provider selection page by setting the following options",
"expiry_in_days": "Expiry in days",
"enter_tenant": "Enter tenant",
"tenants": "Tenants",
"tenants_mapping_description": "Provide a custom mapping for SAML federation apps that lets you associate multiple tenants to it. Enter the tenant name and press ENTER or TAB.",
"generate_sp_entity_id": "Generate Entity ID",
"saml_federation_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.",
"entity_id_generated_copied": "Generated Entity ID copied to clipboard.",
"stack_trace": "Stack Trace",
"error_uri": "error_uri in error response (from OIDC Provider)",
"id_token_from_oidc_idp": "ID Token (from OIDC Provider)",
"access_token_from_oidc_idp": "Access Token (from OIDC Provider)",
"session_state_from_oidc_idp": "Session State (from OIDC Provider)",
"scope_from_op_error": "Scope (from OIDC Provider)",
"sso_connection_created_successfully": "SSO Connection created successfully"
"sso_connection_created_successfully": "SSO Connection created successfully",
"saml_federation_entity_id_generated": "SP Entity ID generated",
"bui-shared-name": "Name",
"bui-shared-tenant": "Tenant",
"bui-shared-product": "Product",
"bui-shared-actions": "Actions",
"bui-shared-edit": "Edit",
"bui-shared-save-changes": "Save Changes",
"bui-shared-no-more-results": "No more results found",
"bui-shared-next": "Next",
"bui-shared-previous": "Previous",
"bui-shared-delete": "Delete",
"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",
"bui-wku-auth-integration-links": "Auth Integration links",
"bui-wku-desc-auth-integration": "Links for OAuth 2.0/OpenID Connect auth",
"bui-wku-saml-federation-links": "SAML Federation links",
"bui-wku-desc-saml-federation": "Links for SAML 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.",
"bui-wku-oidc-configuration": "OpenID Configuration",
"bui-wku-oidc-config-desc": "URIs that your customers will need to set up the OIDC app on the Identity Provider.",
"bui-wku-oidc-discovery": "OpenID Connect Discovery",
"bui-wku-oidc-discovery-desc": "Our OpenID well known URI which your customers will need if they are authenticating via OAuth 2.0 or Open ID Connect.",
"bui-wku-idp-metadata": "IdP Metadata",
"bui-wku-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-idp-configuration": "IdP Configuration",
"bui-wku-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-view": "View",
"bui-wku-download": "Download",
"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.",
"bui-fs-entity-id-change-restriction": "You can't change this value once the app is created.",
"bui-fs-tenants": "Tenants",
"bui-fs-enter-tenant": "Enter tenant",
"bui-fs-tenants-mapping-desc": "Provide a custom mapping for SAML federation apps that lets you associate multiple tenants to it. Enter the tenant name and press ENTER or TAB.",
"bui-fs-attribute-mappings": "Attribute Mappings",
"bui-fs-attribute-mappings-desc": "Map the attributes from your IdP to your SP (if needed)",
"bui-fs-create-app": "Create SAML Federation App",
"bui-fs-create-app-btn": "Create App",
"bui-fs-edit-app": "Edit SAML Federation App",
"bui-fs-sp-attribute": "SP Attribute",
"bui-fs-idp-attribute": "IdP Attribute",
"bui-fs-add-mapping": "Add Mapping",
"bui-fs-add-another": "Add another",
"bui-fs-no-apps": "No SAML Federation Apps found.",
"bui-fs-no-apps-desc": "Create a new SAML Federation App to configure SAML 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 SAML Federation app",
"bui-fs-delete-app-desc": "This action cannot be undone. This will permanently delete the SAML Federation app.",
"bui-fs-apps": "SAML Federation Apps",
"bui-fs-new-app": "New App",
"bui-fs-idp-config": "IdP 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 you will need to authorize the application to access your Google Directory.",
"bui-dsync-directory": "Directory",
"bui-dsync-users": "Users",
"bui-dsync-groups": "Groups",
"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"
}

58
npm/package-lock.json generated
View File

@ -1192,7 +1192,7 @@
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
"integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
"devOptional": true,
"dev": true,
"dependencies": {
"@jridgewell/trace-mapping": "0.3.9"
},
@ -1371,7 +1371,7 @@
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"devOptional": true,
"dev": true,
"engines": {
"node": ">=6.0.0"
}
@ -1380,13 +1380,13 @@
"version": "1.4.15",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz",
"integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==",
"devOptional": true
"dev": true
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
"integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
"devOptional": true,
"dev": true,
"dependencies": {
"@jridgewell/resolve-uri": "^3.0.3",
"@jridgewell/sourcemap-codec": "^1.4.10"
@ -3097,22 +3097,22 @@
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz",
"integrity": "sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==",
"devOptional": true
"dev": true
},
"node_modules/@tsconfig/node12": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
"integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==",
"devOptional": true
"dev": true
},
"node_modules/@tsconfig/node14": {
"version": "1.0.3",
"devOptional": true,
"dev": true,
"license": "MIT"
},
"node_modules/@tsconfig/node16": {
"version": "1.0.4",
"devOptional": true,
"dev": true,
"license": "MIT"
},
"node_modules/@tsconfig/node18": {
@ -3259,7 +3259,7 @@
"version": "8.11.3",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz",
"integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==",
"devOptional": true,
"dev": true,
"bin": {
"acorn": "bin/acorn"
},
@ -3271,7 +3271,7 @@
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz",
"integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==",
"devOptional": true,
"dev": true,
"engines": {
"node": ">=0.4.0"
}
@ -3383,7 +3383,7 @@
},
"node_modules/arg": {
"version": "4.1.3",
"devOptional": true,
"dev": true,
"license": "MIT"
},
"node_modules/array-buffer-byte-length": {
@ -4193,7 +4193,7 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
"devOptional": true
"dev": true
},
"node_modules/cross-env": {
"version": "7.0.3",
@ -4321,7 +4321,7 @@
},
"node_modules/diff": {
"version": "4.0.2",
"devOptional": true,
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.3.1"
@ -5335,7 +5335,7 @@
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz",
"integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==",
"devOptional": true,
"dev": true,
"dependencies": {
"jsbn": "1.1.0",
"sprintf-js": "^1.1.3"
@ -5762,7 +5762,7 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz",
"integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==",
"devOptional": true
"dev": true
},
"node_modules/json-bigint": {
"version": "1.0.0",
@ -6000,7 +6000,7 @@
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
"integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
"devOptional": true
"dev": true
},
"node_modules/make-fetch-happen": {
"version": "13.0.0",
@ -7336,20 +7336,6 @@
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "18.2.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
"integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==",
"dev": true,
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.0"
},
"peerDependencies": {
"react": "^18.2.0"
}
},
"node_modules/react-element-to-jsx-string": {
"version": "15.0.0",
"resolved": "https://registry.npmjs.org/react-element-to-jsx-string/-/react-element-to-jsx-string-15.0.0.tgz",
@ -7833,7 +7819,7 @@
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
"devOptional": true,
"dev": true,
"engines": {
"node": ">= 6.0.0",
"npm": ">= 3.0.0"
@ -7843,7 +7829,7 @@
"version": "2.7.3",
"resolved": "https://registry.npmjs.org/socks/-/socks-2.7.3.tgz",
"integrity": "sha512-vfuYK48HXCTFD03G/1/zkIls3Ebr2YNa4qU9gHDZdblHLiqhJrJGkY3+0Nx0JpN9qBhJbVObc1CNciT1bIZJxw==",
"devOptional": true,
"dev": true,
"dependencies": {
"ip-address": "^9.0.5",
"smart-buffer": "^4.2.0"
@ -8437,7 +8423,7 @@
},
"node_modules/ts-node": {
"version": "10.9.2",
"devOptional": true,
"dev": true,
"license": "MIT",
"dependencies": {
"@cspotcode/source-map-support": "^0.8.0",
@ -8777,7 +8763,7 @@
},
"node_modules/typescript": {
"version": "5.3.3",
"devOptional": true,
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@ -8861,7 +8847,7 @@
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
"devOptional": true
"dev": true
},
"node_modules/v8-to-istanbul": {
"version": "9.2.0",
@ -9308,7 +9294,7 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
"integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
"devOptional": true,
"dev": true,
"engines": {
"node": ">=6"
}

21650
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -38,8 +38,9 @@
"start": "cross-env PORT=5225 NODE_OPTIONS=--dns-result-order=ipv4first node .next/standalone/server.js",
"swagger-jsdoc": "swagger-jsdoc -d swagger/swaggerDefinition.js npm/src/**/*.ts npm/src/**/**/*.ts -o swagger/swagger.json",
"redis": "cross-env JACKSON_API_KEYS=secret DB_ENGINE=redis DB_TYPE=redis DB_URL=redis://localhost:6379/redis npm run dev",
"prepare": "npm run prepare:npm",
"prepare": "npm run prepare:npm && npm run prepare:internal-ui",
"prepare:npm": "cd npm && npm install --legacy-peer-deps",
"prepare:internal-ui": "cd internal-ui && npm install --legacy-peer-deps",
"pretest:e2e": "env-cmd -f .env.test.local ts-node --logError e2e/support/pretest.ts",
"test:e2e": "env-cmd -f .env.test.local playwright test",
"test": "cd npm && npm run test",
@ -55,6 +56,7 @@
"locale-check": "node locale-check.js"
},
"dependencies": {
"@boxyhq/internal-ui": "file:internal-ui",
"@boxyhq/metrics": "0.2.6",
"@boxyhq/react-ui": "3.3.37",
"@boxyhq/saml-jackson": "file:npm",
@ -103,6 +105,7 @@
"eslint-config-next": "14.1.0",
"eslint-config-prettier": "9.1.0",
"eslint-plugin-i18next": "6.0.3",
"jose": "5.2.2",
"postcss": "8.4.35",
"prettier": "3.2.5",
"prettier-plugin-tailwindcss": "0.5.11",

View File

@ -12,6 +12,7 @@ import nextI18NextConfig from '../next-i18next.config.js';
import { AccountLayout, SetupLinkLayout } from '@components/layouts';
import '@boxyhq/react-ui/dist/style.css';
import '../styles/globals.css';
import { BUIProvider } from '@boxyhq/internal-ui';
const unauthenticatedRoutes = [
'/',
@ -31,7 +32,8 @@ const isUnauthenticatedRoute = (pathname: string) => {
};
function MyApp({ Component, pageProps }: AppPropsWithLayout) {
const { pathname } = useRouter();
const router = useRouter();
const { pathname } = router;
const { session, ...props } = pageProps;
@ -61,12 +63,14 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) {
}
return (
<SessionProvider session={session}>
<AccountLayout>
<Component {...props} />
<Toaster />
</AccountLayout>
</SessionProvider>
<BUIProvider value={{ router }}>
<SessionProvider session={session}>
<AccountLayout>
<Component {...props} />
<Toaster />
</AccountLayout>
</SessionProvider>
</BUIProvider>
);
}

View File

@ -215,7 +215,7 @@ const Login = ({
</div>
</div>
<Link href='/.well-known' className='my-3 text-sm underline underline-offset-4' target='_blank'>
{t('here_are_the_set_of_uris_you_would_need_access_to')}
{t('bui-wku-heading')}
</Link>
</div>
</>

View File

@ -1,6 +1,5 @@
import { WellKnownURLs } from '@boxyhq/internal-ui';
import type { GetServerSidePropsContext, NextPage } from 'next';
import WellKnownURLs from '@components/connection/WellKnownURLs';
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
const Dashboard: NextPage = () => {

View File

@ -1,65 +1,27 @@
import type { NextPage, GetServerSidePropsContext } from 'next';
import React from 'react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter/dist/cjs';
import { materialOceanic } from 'react-syntax-highlighter/dist/cjs/styles/prism';
import useSWR from 'swr';
import type { WebhookEventLog } from '@boxyhq/saml-jackson';
import { useRouter } from 'next/router';
import type { NextPage, GetServerSidePropsContext } from 'next';
import { DirectoryWebhookLogInfo, LinkBack } from '@boxyhq/internal-ui';
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
import DirectoryTab from '@components/dsync/DirectoryTab';
import type { ApiError, ApiSuccess } from 'types';
import { fetcher } from '@lib/ui/utils';
import { errorToast } from '@components/Toaster';
import Loading from '@components/Loading';
import useDirectory from '@lib/ui/hooks/useDirectory';
import { LinkBack } from '@components/LinkBack';
const EventInfo: NextPage = () => {
const router = useRouter();
const { directoryId, eventId } = router.query as { directoryId: string; eventId: string };
const { directory, isLoading: isDirectoryLoading, error: directoryError } = useDirectory(directoryId);
const {
data: eventsData,
error: eventsError,
isLoading,
} = useSWR<ApiSuccess<WebhookEventLog>, ApiError>(
`/api/admin/directory-sync/${directoryId}/events/${eventId}`,
fetcher
);
if (isDirectoryLoading || isLoading) {
return <Loading />;
}
const error = directoryError || eventsError;
if (error) {
errorToast(error.message);
return null;
}
if (!directory) {
return null;
}
const event = eventsData?.data;
const { directoryId, eventId } = router.query as {
directoryId: string;
eventId: string;
};
return (
<>
<LinkBack href={`/admin/directory-sync/${directoryId}/events`} />
<h2 className='mt-5 font-bold text-gray-700 md:text-xl'>{directory.name}</h2>
<div className='w-full md:w-3/4'>
<DirectoryTab directory={directory} activeTab='events' />
<div className='my-3 rounded border text-sm'>
<SyntaxHighlighter language='json' style={materialOceanic}>
{JSON.stringify(event, null, 3)}
</SyntaxHighlighter>
</div>
</div>
<LinkBack href={`/admin/directory-sync/${directoryId}/events`} className='mb-3' />
<DirectoryWebhookLogInfo
urls={{
getEvent: `/api/admin/directory-sync/${directoryId}/events/${eventId}`,
getDirectory: `/api/admin/directory-sync/${directoryId}`,
tabBase: `/admin/directory-sync/${directoryId}`,
}}
/>
</>
);
};

View File

@ -1,151 +1,28 @@
import type { NextPage, GetServerSidePropsContext } from 'next';
import React from 'react';
import EyeIcon from '@heroicons/react/24/outline/EyeIcon';
import Link from 'next/link';
import { useRouter } from 'next/router';
import classNames from 'classnames';
import { useTranslation } from 'next-i18next';
import type { NextPage, GetServerSidePropsContext } from 'next';
import { DirectoryWebhookLogs, LinkBack } from '@boxyhq/internal-ui';
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
import useSWR from 'swr';
import type { WebhookEventLog } from '@boxyhq/saml-jackson';
import EmptyState from '@components/EmptyState';
import DirectoryTab from '@components/dsync/DirectoryTab';
import Badge from '@components/Badge';
import type { ApiError, ApiSuccess } from 'types';
import { fetcher } from '@lib/ui/utils';
import { errorToast } from '@components/Toaster';
import Loading from '@components/Loading';
import useDirectory from '@lib/ui/hooks/useDirectory';
import { LinkBack } from '@components/LinkBack';
import { Pagination, pageLimit, NoMoreResults } from '@components/Pagination';
import usePaginate from '@lib/ui/hooks/usePaginate';
const Events: NextPage = () => {
const { t } = useTranslation('common');
const router = useRouter();
const { paginate, setPaginate } = usePaginate();
const [loading, setLoading] = React.useState(false);
const { directoryId } = router.query as { directoryId: string };
const { directory, isLoading: isDirectoryLoading, error: directoryError } = useDirectory(directoryId);
const {
data: eventsData,
error: eventsError,
isLoading,
} = useSWR<ApiSuccess<WebhookEventLog[]>, ApiError>(
`/api/admin/directory-sync/${directoryId}/events?offset=${paginate.offset}&limit=${pageLimit}`,
fetcher
);
if (isDirectoryLoading || isLoading) {
return <Loading />;
}
const error = directoryError || eventsError;
if (error) {
errorToast(error.message);
return null;
}
if (!directory) {
return null;
}
const clearEvents = async () => {
setLoading(true);
await fetch(`/api/admin/directory-sync/${directoryId}/events`, {
method: 'DELETE',
});
setLoading(false);
router.reload();
const { directoryId } = router.query as {
directoryId: string;
};
const events = eventsData?.data || [];
const noEvents = events.length === 0 && paginate.offset === 0;
const noMoreResults = paginate.offset > 0 && events.length === 0;
return (
<>
<LinkBack href='/admin/directory-sync' />
<h2 className='mt-5 font-bold text-gray-700 md:text-xl'>{directory.name}</h2>
<div className='w-full'>
<DirectoryTab directory={directory} activeTab='events' />
{noEvents ? (
<EmptyState title={t('no_webhook_events_found')} />
) : (
<>
<div className='my-3 flex justify-end'>
<button
onClick={clearEvents}
className={classNames('btn-error btn-sm btn', loading ? 'loading' : '')}>
{t('clear_events')}
</button>
</div>
<div className='rounded border'>
<table className='w-full text-left text-sm text-gray-500 dark:text-gray-400'>
<thead className='bg-gray-50 text-xs uppercase text-gray-700 dark:bg-gray-700 dark:text-gray-400'>
<tr className='hover:bg-gray-50'>
<th scope='col' className='px-6 py-3'>
{t('sent_at')}
</th>
<th scope='col' className='px-6 py-3'>
{t('status_code')}
</th>
<th scope='col' className='px-6 py-3'></th>
</tr>
</thead>
<tbody>
{events.map((event) => {
return (
<tr
key={event.id}
className='border-b bg-white last:border-b-0 hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-800 dark:hover:bg-gray-600'>
<td className='px-6 py-3'>{event.created_at.toString()}</td>
<td className='px-6 py-3'>
{event.status_code === 200 ? (
<Badge color='success' size='md'>
200
</Badge>
) : (
<Badge color='error' size='md'>{`${event.status_code}`}</Badge>
)}
</td>
<td className='px-6 py-3'>
<Link href={`/admin/directory-sync/${directoryId}/events/${event.id}`}>
<EyeIcon className='h-5 w-5' />
</Link>
</td>
</tr>
);
})}
{noMoreResults && <NoMoreResults colSpan={4} />}
</tbody>
</table>
</div>
<Pagination
itemsCount={events.length}
offset={paginate.offset}
onPrevClick={() => {
setPaginate({
offset: paginate.offset - pageLimit,
});
}}
onNextClick={() => {
setPaginate({
offset: paginate.offset + pageLimit,
});
}}
/>
</>
)}
</div>
<LinkBack href='/admin/directory-sync' className='mb-3' />
<DirectoryWebhookLogs
urls={{
getEvents: `/api/admin/directory-sync/${directoryId}/events`,
getDirectory: `/api/admin/directory-sync/${directoryId}`,
tabBase: `/admin/directory-sync/${directoryId}`,
deleteEvents: `/api/admin/directory-sync/${directoryId}/events`,
}}
onView={(event) => router.push(`/admin/directory-sync/${directoryId}/events/${event.id}`)}
onDelete={() => router.reload()}
/>
</>
);
};

View File

@ -1,65 +1,26 @@
import type { NextPage, GetServerSidePropsContext } from 'next';
import React from 'react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter/dist/cjs';
import { materialOceanic } from 'react-syntax-highlighter/dist/cjs/styles/prism';
import { useRouter } from 'next/router';
import useSWR from 'swr';
import type { Group } from '@boxyhq/saml-jackson';
import type { NextPage, GetServerSidePropsContext } from 'next';
import { DirectoryGroupInfo, LinkBack } from '@boxyhq/internal-ui';
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
import DirectoryTab from '@components/dsync/DirectoryTab';
import type { ApiError, ApiSuccess } from 'types';
import { fetcher } from '@lib/ui/utils';
import { errorToast } from '@components/Toaster';
import Loading from '@components/Loading';
import useDirectory from '@lib/ui/hooks/useDirectory';
import { LinkBack } from '@components/LinkBack';
const GroupInfo: NextPage = () => {
const router = useRouter();
const { directoryId, groupId } = router.query as { directoryId: string; groupId: string };
const { directory, isLoading: isDirectoryLoading, error: directoryError } = useDirectory(directoryId);
const {
data: groupData,
error: groupError,
isLoading,
} = useSWR<ApiSuccess<Group>, ApiError>(
`/api/admin/directory-sync/${directoryId}/groups/${groupId}`,
fetcher
);
if (isDirectoryLoading || isLoading) {
return <Loading />;
}
const error = directoryError || groupError;
if (error) {
errorToast(error.message);
return null;
}
if (!directory) {
return null;
}
const group = groupData?.data;
const { directoryId, groupId } = router.query as {
directoryId: string;
groupId: string;
};
return (
<>
<LinkBack href={`/admin/directory-sync/${directory.id}/groups`} />
<h2 className='mt-5 font-bold text-gray-700 md:text-xl'>{directory.name}</h2>
<div className='w-full md:w-3/4'>
<DirectoryTab directory={directory} activeTab='groups' />
<div className='my-3 rounded border text-sm'>
<SyntaxHighlighter language='json' style={materialOceanic}>
{JSON.stringify(group, null, 3)}
</SyntaxHighlighter>
</div>
</div>
<LinkBack href={`/admin/directory-sync/${directoryId}/groups`} className='mb-3' />
<DirectoryGroupInfo
urls={{
getGroup: `/api/admin/directory-sync/${directoryId}/groups/${groupId}`,
getDirectory: `/api/admin/directory-sync/${directoryId}`,
tabBase: `/admin/directory-sync/${directoryId}`,
}}
/>
</>
);
};

View File

@ -1,119 +1,26 @@
import type { NextPage, GetServerSidePropsContext } from 'next';
import React from 'react';
import Link from 'next/link';
import EyeIcon from '@heroicons/react/24/outline/EyeIcon';
import { useTranslation } from 'next-i18next';
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
import { useRouter } from 'next/router';
import useSWR from 'swr';
import type { Group } from '@boxyhq/saml-jackson';
import EmptyState from '@components/EmptyState';
import DirectoryTab from '@components/dsync/DirectoryTab';
import type { ApiError, ApiSuccess } from 'types';
import { fetcher } from '@lib/ui/utils';
import { errorToast } from '@components/Toaster';
import Loading from '@components/Loading';
import useDirectory from '@lib/ui/hooks/useDirectory';
import { Pagination, pageLimit, NoMoreResults } from '@components/Pagination';
import usePaginate from '@lib/ui/hooks/usePaginate';
import { LinkBack } from '@components/LinkBack';
import { DirectoryGroups, LinkBack } from '@boxyhq/internal-ui';
import type { NextPage, GetServerSidePropsContext } from 'next';
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
const GroupsList: NextPage = () => {
const { t } = useTranslation('common');
const router = useRouter();
const { paginate, setPaginate } = usePaginate();
const { directoryId } = router.query as { directoryId: string };
const { directory, isLoading: isDirectoryLoading, error: directoryError } = useDirectory(directoryId);
const {
data: groupsData,
error: groupsError,
isLoading,
} = useSWR<ApiSuccess<Group[]>, ApiError>(
`/api/admin/directory-sync/${directoryId}/groups?offset=${paginate.offset}&limit=${pageLimit}`,
fetcher
);
if (isDirectoryLoading || isLoading) {
return <Loading />;
}
const error = directoryError || groupsError;
if (error) {
errorToast(error.message);
return null;
}
if (!directory) {
return null;
}
const groups = groupsData?.data || [];
const noGroups = groups.length === 0 && paginate.offset === 0;
const noMoreResults = groups.length === 0 && paginate.offset > 0;
const { directoryId } = router.query as {
directoryId: string;
};
return (
<>
<LinkBack href='/admin/directory-sync' />
<h2 className='mt-5 font-bold text-gray-700 md:text-xl'>{directory.name}</h2>
<div className='w-full'>
<DirectoryTab directory={directory} activeTab='groups' />
{noGroups ? (
<EmptyState title={t('no_groups_found')} />
) : (
<>
<div className='my-3 rounded border'>
<table className='w-full table-fixed text-left text-sm text-gray-500 dark:text-gray-400'>
<thead className='bg-gray-50 text-xs uppercase text-gray-700 dark:bg-gray-700 dark:text-gray-400'>
<tr className='hover:bg-gray-50'>
<th scope='col' className='w-5/6 px-6 py-3'>
{t('name')}
</th>
<th scope='col' className='w-1/6 px-6 py-3'>
{t('actions')}
</th>
</tr>
</thead>
<tbody>
{groups.map((group) => {
return (
<tr
key={group.id}
className='border-b bg-white last:border-b-0 hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-800 dark:hover:bg-gray-600'>
<td className='px-6 py-3'>{group.name}</td>
<td className='px-6 py-3'>
<Link href={`/admin/directory-sync/${directory.id}/groups/${group.id}`}>
<EyeIcon className='h-5 w-5' />
</Link>
</td>
</tr>
);
})}
{noMoreResults && <NoMoreResults colSpan={2} />}
</tbody>
</table>
</div>
<Pagination
itemsCount={groups.length}
offset={paginate.offset}
onPrevClick={() => {
setPaginate({
offset: paginate.offset - pageLimit,
});
}}
onNextClick={() => {
setPaginate({
offset: paginate.offset + pageLimit,
});
}}
/>
</>
)}
</div>
<LinkBack href='/admin/directory-sync' className='mb-3' />
<DirectoryGroups
urls={{
getGroups: `/api/admin/directory-sync/${directoryId}/groups`,
getDirectory: `/api/admin/directory-sync/${directoryId}`,
tabBase: `/admin/directory-sync/${directoryId}`,
}}
onView={(group) => router.push(`/admin/directory-sync/${directoryId}/groups/${group.id}`)}
/>
</>
);
};

View File

@ -1,14 +1,26 @@
import type { NextPage, GetServerSidePropsContext } from 'next';
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
import { useRouter } from 'next/router';
import DirectoryInfo from '@components/dsync/DirectoryInfo';
import type { NextPage, GetServerSidePropsContext } from 'next';
import { DirectoryInfo, LinkBack } from '@boxyhq/internal-ui';
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
import { dsyncGoogleAuthURL } from '@lib/env';
const DirectoryInfoPage: NextPage = () => {
const router = useRouter();
const { directoryId } = router.query as { directoryId: string };
return <DirectoryInfo directoryId={directoryId} />;
return (
<>
<LinkBack href='/admin/directory-sync' className='mb-3' />
<DirectoryInfo
urls={{
getDirectory: `/api/admin/directory-sync/${directoryId}`,
tabBase: `/admin/directory-sync/${directoryId}`,
googleAuth: dsyncGoogleAuthURL,
}}
/>
</>
);
};
export const getServerSideProps = async (context: GetServerSidePropsContext) => {

View File

@ -1,62 +1,26 @@
import type { NextPage, GetServerSidePropsContext } from 'next';
import React from 'react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter/dist/cjs';
import { materialOceanic } from 'react-syntax-highlighter/dist/cjs/styles/prism';
import { useRouter } from 'next/router';
import useSWR from 'swr';
import type { User } from '@boxyhq/saml-jackson';
import type { NextPage, GetServerSidePropsContext } from 'next';
import { DirectoryUserInfo, LinkBack } from '@boxyhq/internal-ui';
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
import DirectoryTab from '@components/dsync/DirectoryTab';
import type { ApiError, ApiSuccess } from 'types';
import { fetcher } from '@lib/ui/utils';
import { errorToast } from '@components/Toaster';
import Loading from '@components/Loading';
import useDirectory from '@lib/ui/hooks/useDirectory';
import { LinkBack } from '@components/LinkBack';
const UserInfo: NextPage = () => {
const router = useRouter();
const { directoryId, userId } = router.query as { directoryId: string; userId: string };
const { directory, isLoading: isDirectoryLoading, error: directoryError } = useDirectory(directoryId);
const {
data: userData,
error: userError,
isLoading,
} = useSWR<ApiSuccess<User>, ApiError>(`/api/admin/directory-sync/${directoryId}/users/${userId}`, fetcher);
if (isDirectoryLoading || isLoading) {
return <Loading />;
}
const error = directoryError || userError;
if (error) {
errorToast(error.message);
return null;
}
if (!directory) {
return null;
}
const user = userData?.data;
const { directoryId, userId } = router.query as {
directoryId: string;
userId: string;
};
return (
<>
<LinkBack href={`/admin/directory-sync/${directory.id}/users`} />
<h2 className='mt-5 font-bold text-gray-700 md:text-xl'>{directory.name}</h2>
<div className='w-full md:w-3/4'>
<DirectoryTab directory={directory} activeTab='users' />
<div className='my-3 rounded border text-sm'>
<SyntaxHighlighter language='json' style={materialOceanic}>
{JSON.stringify(user, null, 3)}
</SyntaxHighlighter>
</div>
</div>
<LinkBack href={`/admin/directory-sync/${directoryId}/users`} className='mb-3' />
<DirectoryUserInfo
urls={{
getUser: `/api/admin/directory-sync/${directoryId}/users/${userId}`,
getDirectory: `/api/admin/directory-sync/${directoryId}`,
tabBase: `/admin/directory-sync/${directoryId}`,
}}
/>
</>
);
};

View File

@ -1,142 +1,26 @@
import type { NextPage, GetServerSidePropsContext } from 'next';
import React from 'react';
import EyeIcon from '@heroicons/react/24/outline/EyeIcon';
import Link from 'next/link';
import { useTranslation } from 'next-i18next';
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
import { useRouter } from 'next/router';
import useSWR from 'swr';
import type { User } from '@boxyhq/saml-jackson';
import EmptyState from '@components/EmptyState';
import DirectoryTab from '@components/dsync/DirectoryTab';
import Badge from '@components/Badge';
import type { ApiError, ApiSuccess } from 'types';
import { fetcher } from '@lib/ui/utils';
import { errorToast } from '@components/Toaster';
import Loading from '@components/Loading';
import useDirectory from '@lib/ui/hooks/useDirectory';
import { Pagination, pageLimit, NoMoreResults } from '@components/Pagination';
import usePaginate from '@lib/ui/hooks/usePaginate';
import { LinkBack } from '@components/LinkBack';
import type { NextPage, GetServerSidePropsContext } from 'next';
import { DirectoryUsers, LinkBack } from '@boxyhq/internal-ui';
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
const UsersList: NextPage = () => {
const { t } = useTranslation('common');
const router = useRouter();
const { paginate, setPaginate } = usePaginate();
const { directoryId } = router.query as { directoryId: string };
const { directory, isLoading: isDirectoryLoading, error: directoryError } = useDirectory(directoryId);
const {
data: usersData,
error: usersError,
isLoading,
} = useSWR<ApiSuccess<User[]>, ApiError>(
`/api/admin/directory-sync/${directoryId}/users?offset=${paginate.offset}&limit=${pageLimit}`,
fetcher
);
if (isDirectoryLoading || isLoading) {
return <Loading />;
}
const error = directoryError || usersError;
if (error) {
errorToast(error.message);
return null;
}
if (!directory) {
return null;
}
const users = usersData?.data || [];
const noUsers = users.length === 0 && paginate.offset === 0;
const noMoreResults = users.length === 0 && paginate.offset > 0;
const { directoryId } = router.query as {
directoryId: string;
};
return (
<>
<LinkBack href='/admin/directory-sync' />
<h2 className='mt-5 font-bold text-gray-700 md:text-xl'>{directory.name}</h2>
<div className='w-full'>
<DirectoryTab directory={directory} activeTab='users' />
{noUsers ? (
<EmptyState title={t('no_users_found')} />
) : (
<>
<div className='my-3 rounded border'>
<table className='w-full text-left text-sm text-gray-500 dark:text-gray-400'>
<thead className='bg-gray-50 text-xs uppercase text-gray-700 dark:bg-gray-700 dark:text-gray-400'>
<tr className='hover:bg-gray-50'>
<th scope='col' className='px-6 py-3'>
{t('first_name')}
</th>
<th scope='col' className='px-6 py-3'>
{t('last_name')}
</th>
<th scope='col' className='px-6 py-3'>
{t('email')}
</th>
<th scope='col' className='px-6 py-3'>
{t('status')}
</th>
<th scope='col' className='px-6 py-3'>
{t('actions')}
</th>
</tr>
</thead>
<tbody>
{users.map((user) => {
return (
<tr
key={user.id}
className='border-b bg-white last:border-b-0 hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-800 dark:hover:bg-gray-600'>
<td className='px-6 py-3'>{user.first_name}</td>
<td className='px-6 py-3'>{user.last_name}</td>
<td className='px-6 py-3'>{user.email}</td>
<td className='px-6 py-3'>
{user.active ? (
<Badge color='success' size='md'>
{t('active')}
</Badge>
) : (
<Badge color='warning' size='md'>
{t('suspended')}
</Badge>
)}
</td>
<td className='px-6 py-3'>
<Link href={`/admin/directory-sync/${directory.id}/users/${user.id}`}>
<EyeIcon className='h-5 w-5' />
</Link>
</td>
</tr>
);
})}
{noMoreResults && <NoMoreResults colSpan={5} />}
</tbody>
</table>
</div>
<Pagination
itemsCount={users.length}
offset={paginate.offset}
onPrevClick={() => {
setPaginate({
offset: paginate.offset - pageLimit,
});
}}
onNextClick={() => {
setPaginate({
offset: paginate.offset + pageLimit,
});
}}
/>
</>
)}
</div>
<LinkBack href='/admin/directory-sync' className='mb-3' />
<DirectoryUsers
urls={{
getUsers: `/api/admin/directory-sync/${directoryId}/users`,
getDirectory: `/api/admin/directory-sync/${directoryId}`,
tabBase: `/admin/directory-sync/${directoryId}`,
}}
onView={(user) => router.push(`/admin/directory-sync/${directoryId}/users/${user.id}`)}
/>
</>
);
};

View File

@ -1,14 +1,11 @@
import { WellKnownURLs } from '@boxyhq/internal-ui';
import type { NextPage, GetServerSidePropsContext } from 'next';
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
import WellKnownURLs from '@components/connection/WellKnownURLs';
const WellKnownURLsIndex: NextPage = () => {
return (
<div className='my-10 mx-5 flex justify-center'>
<div className='flex flex-col'>
<WellKnownURLs />
</div>
<div className='mx-auto max-w-5xl px-4 py-10'>
<WellKnownURLs />
</div>
);
};

View File

@ -59,6 +59,10 @@ a {
.modal-box {
@apply rounded;
}
.card {
@apply rounded;
}
}
.react-datepicker {

View File

@ -7,6 +7,7 @@ module.exports = {
'./ee/**/*.{js,ts,jsx,tsx}',
'node_modules/daisyui/dist/**/*.js',
'node_modules/react-daisyui/dist/**/*.js',
'./internal-ui/src/**/*.{js,ts,jsx,tsx}',
],
daisyui: {
themes: [