mirror of https://github.com/boxyhq/jackson.git
Admin interface for Jackson (#71)
* NextAuth + users providers
* Add a temporary fix for verification token - don't use it in production
* Admin ui files
* Admin controller
* getAll db apis
* IdP provider page and api route
* Fix padding
* Style fixes
* middleware to check session
* Loading state handling
* fetcher better response handling
* Add new provider form and api route
* Tab panel in client add form
* Tab switching plus new fields
* Flowbite config
* darkMode with flowbite
* Save config
* Update route path to saml
* Reusable component for add/edit
* cleanup
* Set Secret in NextAuth options
* Prettier lint changes
* Support for delete operation
* Link update
* PopUp Modal reusable component
* Popup confirm before delete
* disable SWR revalidation on focus
* Display IdP metadata, clientID,secret
* Header fixed positioning and style fixes
* Filter raw XML in edit mode
* Add name field to config
* - Edit/New form delta
- Split by newline
- Route back after POST
* Remove flowbite
* Remove flowbite [cleanup]
* Add description field
* updateConfig implementation
* Route PATCH to updateConfig
* Naming change
* Naming Client -> Connection
* AddEdit component updates
* Omit provider, returns full config
* Destructure session first
* Change to domain ACL
* Delete unused component
* Support glob and list of emails for ACL
* Delete unused CSS
* Update package lock
* Remove flowbite from content source
* Redirect to admin route
* Check session in Layout and redirect to login
* Logout in dropdown
* vertical alignment
* Show status message on save (edit)
* Consolidate fields to one long vertical column
* GetAll function for SQL and Add CreationDate and Modification Date for Mongo and SQL
* Add name as header
* Styling and opacity transition for status
* Configure button style fix
* overflow for smaller viewports and rounded border
* Fallback to default behavior of useSession
* Store, use and dispose (after signIn)
verification token in db
* Remove unused class
* Rename Connections ➡ Configurations
* Handle getAll and getConfig using slug
* Better naming
* Update fetch paths
* Refactor getAllConfig ➡ getConfig (By Id)
* Better naming
* Rename saml ➡ samlconf
* Use light theme by not defaulting to system theme
* Path update /samlconf ➡ /saml/config
* Fix path
* Revert manual changes
* getall funcationality and migration script
* message
* Updating migration file formating
* message
* Pull and fix package.json and lock file
* correcting the migration script formatting
* remove file
* add new migration files
* e2e with playwright
* Better naming
* Remove comment
* Make headless
* Run npm install from root
* Add e2e steps in workflow
* try with separate npm installs
* Move higher in the pipeline to test
* Fix quote
* Rely on npx
* fixed migration script formatting
* spelling correction
* headless for CI but false for local
* Use secret
* Type fixes for mongo
* [skip ci] Swagger annotation for getConfig
* Adding migration scriptis for all db's
* added migration script to prettierignore
* unformat migration script
* removing postgress migration files
* generate new migration files
* remove wrong migration files
* Add new migration files for mysql and mariadb
* [skip ci] Swagger annotation for updateConfig
* Return empty for update op
* Update swagger spec
* Fix type
* Wait for mongo to start
* Fix db_engine
* Test with pg
* Test with POSTGRES_DB env to auto create db
* Swap install-deps with install
* Use prod build
* enable @ts-ignore
* Test some fixes
* Can be omitted in next-auth v4, uses secret
* Move env to playwright config
* authDbSeed script needs the db and other secrets
* Typo
* Bad typo day 😅
* Again typo
* Set NEXTAUTH_URL
* Use prod build in CI
* Prefix the env for seeding
* Try with inline
* tidying up migration scripts
* fixed migration scripts
* Set env in actions yml
* Remove comma
* Target chromium
* Prefix the env
* Try inline in playwright
* print env
* Move build to action step
* Remove console log
* Let env sit on the job level
* Add ACL
* Fix attribute check
* Add name field
* add name in metadata preload config
* Use postgres
* Remove unneeded secret
* Remove env/options from mongo service
* Fix swagger
* Update swagger spec
* [skip ci] Fix eslint warning
* Add updateConfig test
* Add description to preloaded config
* [skip ci] cleanup
* minor fix
* Update comment
* Expose PATCH in config api
* Added missing validation for clientSecret
* Update swagger spec
* updated example postgres url, updated deps
* Redirect to saml config route
* Remove unused pages/routes
* Update in package lock
* Add primary and secondary colors to tailwind
* Swap icon
* Remove text-color and apply default theme
* Use the primary color from theme
* Reusable custom class for btn-primary
* Add link-primary reusable class
* Use primary secondary colors for main logo
* Show error status & color align with primary color
* Show product if name is absent
* Simplify required attribute setting,
'description' is not required
* Make description optional
* Fix placeholder text
* Swagger updates
* Add validation for description
* Swagger - add missing status codes & descriptions
* Update swagger artifact
* Fix styling for status message
* revalidate config on successful save
* style text highlight globally
* Fix cancel button style
* Set the main height to 100%-headerHeight,
add overflow
* removed default ACL, if someone forgets to change it then we might have Tony Stark logging into everyones instances :)
* print the arch/platform
* Collect platform info
* Disable swc and remove platform query steps
* Try with custom babel config to disable swc
* Add next.js build cache
* Refactor step
* trying swc
* Make name parameter optional
* Update form state from backend after save
* port 5000 -> 5225
* Handle empty value case for ACL
* bumped up version
Co-authored-by: Kiran <kiran@boxyhq.com>
Co-authored-by: Vishal Lodha <vishal@boxyhq.com>
Co-authored-by: Deepak Prabhakara <deepak@boxyhq.com>
Co-authored-by: Utkarsh Mehta <ukrocks.mehta@gmail.com>
This commit is contained in:
parent
8cdc011f9f
commit
bd44c3479c
11
.env.example
11
.env.example
|
@ -15,6 +15,17 @@ DB_CLEANUP_LIMIT=1000
|
|||
# You can use openssl to generate a random 32 character key: openssl rand -base64 24
|
||||
DB_ENCRYPTION_KEY=
|
||||
|
||||
# Admin UI settings
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM=
|
||||
|
||||
NEXTAUTH_URL=
|
||||
NEXTAUTH_SECRET=
|
||||
NEXTAUTH_ACL=
|
||||
|
||||
# OpenTelemetry
|
||||
OTEL_EXPORTER_OTLP_METRICS_ENDPOINT=
|
||||
OTEL_EXPORTER_OTLP_HEADERS=
|
||||
|
|
|
@ -20,7 +20,16 @@ on:
|
|||
jobs:
|
||||
ci:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
env:
|
||||
NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }}
|
||||
NEXTAUTH_URL: http://localhost:5225
|
||||
NEXTAUTH_ACL: '*@boxyhq.com'
|
||||
DB_ENGINE: sql
|
||||
DB_URL: postgres://postgres:postgres@localhost:5432/postgres
|
||||
DB_TYPE: postgres
|
||||
DEBUG: pw:webserver
|
||||
SAML_AUDIENCE: https://saml.boxyhq.com
|
||||
JACKSON_API_KEYS: secret
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [16.x]
|
||||
|
@ -73,8 +82,21 @@ jobs:
|
|||
registry-url: https://registry.npmjs.org
|
||||
scope: '@boxyhq'
|
||||
cache: 'npm'
|
||||
- name: Setup Next.js cache
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: ${{ github.workspace }}/.next/cache
|
||||
# Generate a new cache whenever packages or source files change.
|
||||
key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**.[jt]s', '**.[jt]sx') }}
|
||||
# If source files changed but packages didn't, rebuild from a prior cache.
|
||||
restore-keys: |
|
||||
${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-
|
||||
- run: npm install
|
||||
working-directory: ./npm
|
||||
- run: npm run build
|
||||
- name: Install playwright browser dependencies
|
||||
run: npx playwright install chromium
|
||||
- name: e2e tests
|
||||
run: npx ts-node --log-error e2e/seedAuthDb.ts && npx playwright test
|
||||
- run: npm run test
|
||||
working-directory: ./npm
|
||||
- run: |
|
||||
|
|
|
@ -42,3 +42,4 @@ yarn-error.log*
|
|||
|
||||
npmversion.txt
|
||||
publishTag.txt
|
||||
.env
|
||||
|
|
|
@ -4,6 +4,7 @@ public
|
|||
**/**/node_modules
|
||||
**/**/.next
|
||||
**/**/public
|
||||
npm/migration/**
|
||||
|
||||
*.lock
|
||||
*.log
|
||||
|
|
|
@ -0,0 +1,34 @@
|
|||
import { useRouter } from 'next/router';
|
||||
import Link from 'next/link';
|
||||
import { Children, cloneElement, ReactElement } from 'react';
|
||||
|
||||
const ActiveLink = ({
|
||||
children,
|
||||
activeClassName,
|
||||
href,
|
||||
...props
|
||||
}: {
|
||||
children: ReactElement<HTMLAnchorElement>;
|
||||
activeClassName: string;
|
||||
href: string;
|
||||
} & Record<string, any>) => {
|
||||
const { asPath } = useRouter();
|
||||
const child = Children.only(children);
|
||||
const childClassName = child.props.className || '';
|
||||
|
||||
// pages/index.js will be matched via props.href
|
||||
// pages/about.js will be matched via props.href
|
||||
// pages/[slug].js will be matched via props.as
|
||||
const className =
|
||||
asPath === href || asPath === props.as ? `${childClassName} ${activeClassName}`.trim() : childClassName;
|
||||
|
||||
return (
|
||||
<Link href={href} {...props}>
|
||||
{cloneElement(child as ReactElement, {
|
||||
className: className || null,
|
||||
})}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActiveLink;
|
|
@ -0,0 +1,150 @@
|
|||
import Head from 'next/head';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { ReactNode, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import Logo from '../public/logo.png';
|
||||
import { LogoutIcon, MenuIcon, ShieldCheckIcon } from '@heroicons/react/outline';
|
||||
import useOnClickOutside from 'hooks/useOnClickOutside';
|
||||
import ActiveLink from './ActiveLink';
|
||||
import useKeyPress from 'hooks/useKeyPress';
|
||||
import useMediaQuery from 'hooks/useMediaQuery';
|
||||
import { useSession, signOut } from 'next-auth/react';
|
||||
|
||||
const navigation = [
|
||||
{
|
||||
path: '/admin/saml/config',
|
||||
text: <span className='ml-4'>SAML Configurations</span>,
|
||||
icon: <ShieldCheckIcon className='w-5 h-5' aria-hidden />,
|
||||
},
|
||||
];
|
||||
|
||||
function Layout({ children }: { children: ReactNode }) {
|
||||
const [isSideNavOpen, setIsSideNavOpen] = useState(false);
|
||||
const ref = useRef(null);
|
||||
|
||||
const _closeSideNav = useCallback(() => {
|
||||
if (isSideNavOpen) {
|
||||
setIsSideNavOpen(false);
|
||||
}
|
||||
}, [isSideNavOpen]);
|
||||
|
||||
// close on clicking outside
|
||||
useOnClickOutside(ref, _closeSideNav);
|
||||
// close on "Escape" key press
|
||||
const pressedEsc = useKeyPress('Escape');
|
||||
useEffect(() => {
|
||||
if (pressedEsc) {
|
||||
_closeSideNav();
|
||||
}
|
||||
}, [_closeSideNav, pressedEsc]);
|
||||
// reset state on window resize
|
||||
/*IMPORTANT: matches the md breakpoint default setting at https://tailwindcss.com/docs/screens*/
|
||||
const _mdBreakpointMatch = useMediaQuery('(min-width: 768px)');
|
||||
useEffect(() => {
|
||||
if (_mdBreakpointMatch) {
|
||||
_closeSideNav();
|
||||
}
|
||||
}, [_closeSideNav, _mdBreakpointMatch]);
|
||||
|
||||
// check logged-in status, https://next-auth.js.org/getting-started/client#require-session
|
||||
// The default behavior is to redirect the user to the sign-in page, from where - after a successful login - they will be sent back to the page they started on.
|
||||
const { data: session, status } = useSession({ required: true });
|
||||
|
||||
// user settings dropdown state
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const userDropDownRef = useRef(null);
|
||||
useOnClickOutside(userDropDownRef, () => setIsOpen(false));
|
||||
|
||||
if (status === 'loading') {
|
||||
return <p>Loading...</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Jackson SAML Dashboard</title>
|
||||
<link rel='icon' href='/favicon.ico' />
|
||||
</Head>
|
||||
<header
|
||||
role='banner'
|
||||
className='p-5 md:px-12 fixed left-0 right-0 border-b bg-white dark:bg-gray-900 border-gray-900/10 dark:border-gray-300/10 z-10'>
|
||||
<div className='flex justify-between items-center'>
|
||||
<Link href='/'>
|
||||
<a title='Go to dashboard' className='leading-10 font-bold flex items-center ml-10 md:ml-0'>
|
||||
<Image src={Logo} alt='BoxyHQ' layout='fixed' width={36} height={36} />
|
||||
<h1 className='ml-2 text-secondary hover:text-primary dark:text-white'>Jackson</h1>
|
||||
</a>
|
||||
</Link>
|
||||
<div className='relative'>
|
||||
<button
|
||||
type='button'
|
||||
className='rounded-full h-8 w-8 flex items-center justify-center bg-secondary text-cyan-50 uppercase'
|
||||
aria-label='user settings'
|
||||
aria-expanded={isOpen}
|
||||
onClick={() => setIsOpen(!isOpen)}>
|
||||
{session?.user?.name?.[0]}
|
||||
</button>
|
||||
{isOpen && (
|
||||
<ul
|
||||
className='absolute z-50 top-full right-0 bg-white rounded-lg ring-1 ring-slate-900/10 shadow-lg overflow-hidden w-36 py-1 text-sm text-slate-700 font-semibold dark:bg-slate-800 dark:ring-0 dark:highlight-white/5 dark:text-slate-300'
|
||||
ref={userDropDownRef}>
|
||||
<li>
|
||||
<button
|
||||
type='button'
|
||||
className='py-1 px-2 h-8 w-full flex justify-center items-center cursor-pointer'
|
||||
onClick={() => signOut()}>
|
||||
<LogoutIcon className='w-5 h-5' aria-hidden />
|
||||
Log out
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isSideNavOpen && (
|
||||
<div
|
||||
className='fixed inset-0 bg-black/20 backdrop-blur-sm dark:bg-gray-900/80 md:hidden'
|
||||
id='headlessui-dialog-overlay-14'
|
||||
aria-hidden='true'></div>
|
||||
)}
|
||||
<nav role='navigation'>
|
||||
<button
|
||||
className={`w-10 h-10 inline-flex items-center justify-center absolute top-5 md:hidden`}
|
||||
aria-expanded={isSideNavOpen}
|
||||
aria-controls='menu'
|
||||
onClick={() => setIsSideNavOpen((curState) => !curState)}>
|
||||
<span className='sr-only'>Menu</span>
|
||||
<MenuIcon aria-hidden='true' className='h-6 w-6 text-black dark:text-slate-50'></MenuIcon>
|
||||
</button>
|
||||
<ul
|
||||
className={`fixed top-0 bottom-0 left-0 w-60 p-6 border-r border-gray-900/10 dark:border-gray-300/10 transition-transform ${
|
||||
isSideNavOpen ? 'translate-x-0' : '-translate-x-full'
|
||||
} md:translate-x-0 md:top-20 md:translate-y-[2px] bg-white dark:bg-gray-900`}
|
||||
id='menu'
|
||||
ref={ref}>
|
||||
{navigation.map(({ path, text, icon }, index) => (
|
||||
<li key={index}>
|
||||
<ActiveLink href={path} activeClassName='text-primary/90 dark:text-sky-400 font-semibold'>
|
||||
<a className='link-primary'>
|
||||
{icon}
|
||||
{text}
|
||||
</a>
|
||||
</ActiveLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
<main
|
||||
role='main'
|
||||
className='relative top-[81px] h-[calc(100%_-_81px)] md:left-60 md:w-[calc(100%_-_theme(space.60))] p-6 overflow-auto'>
|
||||
{children}
|
||||
</main>
|
||||
{/* <footer role="contentinfo">
|
||||
<p>© 2022 BoxyHQ, Inc.</p>
|
||||
</footer> */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Layout;
|
|
@ -0,0 +1,355 @@
|
|||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { FormEvent, useEffect, useState } from 'react';
|
||||
import { mutate } from 'swr';
|
||||
import { ArrowLeftIcon, CheckCircleIcon, ExclamationCircleIcon } from '@heroicons/react/outline';
|
||||
import { Modal } from '@supabase/ui';
|
||||
|
||||
/**
|
||||
* Edit view will have extra fields (showOnlyInEditView: true)
|
||||
* to render parsed metadata and other attributes.
|
||||
* All fields are editable unless they have `editable` set to false.
|
||||
* All fields are required unless they have `required` or `requiredInEditView` set to false.
|
||||
*/
|
||||
const fieldCatalog = [
|
||||
{
|
||||
key: 'name',
|
||||
label: 'Name',
|
||||
type: 'text',
|
||||
placeholder: 'MyApp',
|
||||
attributes: { required: false, requiredInEditView: false },
|
||||
},
|
||||
{
|
||||
key: 'description',
|
||||
label: 'Description',
|
||||
type: 'text',
|
||||
placeholder: 'A short description not more than 100 characters',
|
||||
attributes: { maxLength: 100, required: false, requiredInEditView: false }, // not required in create/edit view
|
||||
},
|
||||
{
|
||||
key: 'tenant',
|
||||
label: 'Tenant',
|
||||
type: 'text',
|
||||
placeholder: 'acme.com',
|
||||
attributes: { editable: false },
|
||||
},
|
||||
{
|
||||
key: 'product',
|
||||
label: 'Product',
|
||||
type: 'text',
|
||||
placeholder: 'demo',
|
||||
attributes: { editable: false },
|
||||
},
|
||||
{
|
||||
key: 'redirectUrl',
|
||||
label: 'Allowed redirect URLs (newline separated)',
|
||||
type: 'textarea',
|
||||
placeholder: 'http://localhost:3000',
|
||||
attributes: { isArray: true, rows: 3 },
|
||||
},
|
||||
{
|
||||
key: 'defaultRedirectUrl',
|
||||
label: 'Default redirect URL',
|
||||
type: 'url',
|
||||
placeholder: 'http://localhost:3000/login/saml',
|
||||
attributes: {},
|
||||
},
|
||||
{
|
||||
key: 'rawMetadata',
|
||||
label: 'Raw IdP XML',
|
||||
type: 'textarea',
|
||||
placeholder: 'Paste the raw XML here',
|
||||
attributes: {
|
||||
rows: 5,
|
||||
requiredInEditView: false, //not required in edit view
|
||||
labelInEditView: 'Raw IdP XML (fully replaces the current one)',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'idpMetadata',
|
||||
label: 'IDP Metadata',
|
||||
type: 'pre',
|
||||
attributes: {
|
||||
rows: 10,
|
||||
editable: false,
|
||||
showOnlyInEditView: true,
|
||||
formatForDisplay: (value) => JSON.stringify(value, null, 2),
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'clientID',
|
||||
label: 'Client Id',
|
||||
type: 'text',
|
||||
attributes: { showOnlyInEditView: true },
|
||||
},
|
||||
{
|
||||
key: 'clientSecret',
|
||||
label: 'Client Secret',
|
||||
type: 'password',
|
||||
attributes: { showOnlyInEditView: true },
|
||||
},
|
||||
];
|
||||
|
||||
function getFieldList(isEditView) {
|
||||
return isEditView
|
||||
? fieldCatalog
|
||||
: fieldCatalog.filter(({ attributes: { showOnlyInEditView } }) => !showOnlyInEditView); // filtered list for add view
|
||||
}
|
||||
|
||||
function getInitialState(samlConfig, isEditView) {
|
||||
const _state = {};
|
||||
const _fieldCatalog = getFieldList(isEditView);
|
||||
|
||||
_fieldCatalog.forEach(({ key, attributes }) => {
|
||||
_state[key] = samlConfig?.[key]
|
||||
? attributes.isArray
|
||||
? samlConfig[key].join('\r\n') // render list of items on newline eg:- redirect URLs
|
||||
: samlConfig[key]
|
||||
: '';
|
||||
});
|
||||
return _state;
|
||||
}
|
||||
|
||||
type AddEditProps = {
|
||||
samlConfig?: Record<string, any>;
|
||||
};
|
||||
|
||||
const AddEdit = ({ samlConfig }: AddEditProps) => {
|
||||
const router = useRouter();
|
||||
const isEditView = !!samlConfig;
|
||||
// FORM LOGIC: SUBMIT
|
||||
const [{ status }, setSaveStatus] = useState<{ status: 'UNKNOWN' | 'SUCCESS' | 'ERROR' }>({
|
||||
status: 'UNKNOWN',
|
||||
});
|
||||
const saveSAMLConfiguration = async (event) => {
|
||||
event.preventDefault();
|
||||
const { rawMetadata, redirectUrl, ...rest } = formObj;
|
||||
const encodedRawMetadata = btoa(rawMetadata || '');
|
||||
const redirectUrlList = redirectUrl.split(/\r\n|\r|\n/);
|
||||
|
||||
const res = await fetch('/api/admin/saml/config', {
|
||||
method: isEditView ? 'PATCH' : 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Api-Key secret',
|
||||
},
|
||||
body: JSON.stringify({ ...rest, encodedRawMetadata, redirectUrl: JSON.stringify(redirectUrlList) }),
|
||||
});
|
||||
if (res.ok) {
|
||||
if (!isEditView) {
|
||||
router.replace('/admin/saml/config');
|
||||
} else {
|
||||
setSaveStatus({ status: 'SUCCESS' });
|
||||
// revalidate on save
|
||||
mutate(`/api/admin/saml/config/${router.query.id}`);
|
||||
setTimeout(() => setSaveStatus({ status: 'UNKNOWN' }), 2000);
|
||||
}
|
||||
} else {
|
||||
// save failed
|
||||
setSaveStatus({ status: 'ERROR' });
|
||||
setTimeout(() => setSaveStatus({ status: 'UNKNOWN' }), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
// LOGIC: DELETE
|
||||
const [delModalVisible, setDelModalVisible] = useState(false);
|
||||
const toggleDelConfirm = () => setDelModalVisible(!delModalVisible);
|
||||
const [userNameEntry, setUserNameEntry] = useState('');
|
||||
const deleteConfiguration = async () => {
|
||||
await fetch('/api/admin/saml/config', {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Api-Key secret',
|
||||
},
|
||||
body: JSON.stringify({ clientID: samlConfig?.clientID, clientSecret: samlConfig?.clientSecret }),
|
||||
});
|
||||
toggleDelConfirm();
|
||||
await mutate('/api/admin/saml/config');
|
||||
router.replace('/admin/saml/config');
|
||||
};
|
||||
|
||||
// STATE: FORM
|
||||
const [formObj, setFormObj] = useState<Record<string, string>>(() =>
|
||||
getInitialState(samlConfig, isEditView)
|
||||
);
|
||||
// Resync form state on save
|
||||
useEffect(() => {
|
||||
const _state = getInitialState(samlConfig, isEditView);
|
||||
setFormObj(_state);
|
||||
}, [samlConfig, isEditView]);
|
||||
|
||||
function handleChange(event: FormEvent) {
|
||||
const target = event.target as HTMLInputElement | HTMLTextAreaElement;
|
||||
setFormObj((cur) => ({ ...cur, [target.id]: target.value }));
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Or use router.back() */}
|
||||
<Link href='/admin/saml/config'>
|
||||
<a className='link-primary'>
|
||||
<ArrowLeftIcon aria-hidden className='h-4 w-4' />
|
||||
<span className='ml-2'>Back to Configurations</span>
|
||||
</a>
|
||||
</Link>
|
||||
<div>
|
||||
<h2 className='font-bold text-3xl text-primary mt-2 mb-4 dark:text-white'>
|
||||
{samlConfig?.name || samlConfig?.product || 'New SAML Configuration'}
|
||||
</h2>
|
||||
<form onSubmit={saveSAMLConfiguration}>
|
||||
<div className='bg-white border border-gray-200 dark:bg-gray-800 dark:border-gray-700 p-6 rounded-xl md:w-3/4 min-w-[28rem] md:max-w-lg'>
|
||||
{fieldCatalog
|
||||
.filter(({ attributes: { showOnlyInEditView } }) => (isEditView ? true : !showOnlyInEditView))
|
||||
.map(
|
||||
({
|
||||
key,
|
||||
placeholder,
|
||||
label,
|
||||
type,
|
||||
attributes: {
|
||||
isArray,
|
||||
rows,
|
||||
formatForDisplay,
|
||||
editable,
|
||||
requiredInEditView = true, // by default all fields are required unless explicitly set to false
|
||||
labelInEditView,
|
||||
maxLength,
|
||||
required = true, // by default all fields are required unless explicitly set to false
|
||||
},
|
||||
}) => {
|
||||
const readOnly = isEditView && editable === false;
|
||||
const _required = isEditView ? !!requiredInEditView : !!required;
|
||||
const _label = isEditView && labelInEditView ? labelInEditView : label;
|
||||
const value =
|
||||
readOnly && typeof formatForDisplay === 'function'
|
||||
? formatForDisplay(formObj[key])
|
||||
: formObj[key];
|
||||
return (
|
||||
<div className='mb-6 ' key={key}>
|
||||
<label
|
||||
htmlFor={key}
|
||||
className='block mb-2 text-sm font-medium text-gray-900 dark:text-gray-300'>
|
||||
{_label}
|
||||
</label>
|
||||
{type === 'pre' ? (
|
||||
<pre className='block p-2 w-full text-sm text-gray-900 bg-gray-50 rounded-lg border border-gray-300 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500 overflow-auto'>
|
||||
{value}
|
||||
</pre>
|
||||
) : type === 'textarea' ? (
|
||||
<textarea
|
||||
id={key}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
required={_required}
|
||||
readOnly={readOnly}
|
||||
maxLength={maxLength}
|
||||
onChange={handleChange}
|
||||
className={`block p-2 w-full text-sm text-gray-900 bg-gray-50 rounded-lg border border-gray-300 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500 ${
|
||||
isArray ? 'whitespace-pre' : ''
|
||||
}`}
|
||||
rows={rows}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
id={key}
|
||||
type={type}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
required={_required}
|
||||
readOnly={readOnly}
|
||||
maxLength={maxLength}
|
||||
onChange={handleChange}
|
||||
className='bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
)}
|
||||
<div className='flex'>
|
||||
<button type='submit' className='btn-primary'>
|
||||
Save Changes
|
||||
</button>
|
||||
<p
|
||||
role='status'
|
||||
className={`ml-2 inline-flex items-center ${
|
||||
status === 'SUCCESS' || status === 'ERROR' ? 'opacity-100' : 'opacity-0'
|
||||
} transition-opacity motion-reduce:transition-none`}>
|
||||
{status === 'SUCCESS' && (
|
||||
<span className='text-primary inline-flex items-center'>
|
||||
<CheckCircleIcon aria-hidden className='mr-1 h-5 w-5'></CheckCircleIcon>
|
||||
Saved
|
||||
</span>
|
||||
)}
|
||||
{/* TODO: also display error message once we standardise the response format */}
|
||||
{status === 'ERROR' && (
|
||||
<span className='text-red-900 inline-flex items-center'>
|
||||
<ExclamationCircleIcon aria-hidden className='mr-1 h-5 w-5'></ExclamationCircleIcon>
|
||||
ERROR
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{samlConfig?.clientID && samlConfig.clientSecret && (
|
||||
<section className='flex items-center text-red-900 bg-red-100 p-6 rounded mt-10'>
|
||||
<div className='flex-1'>
|
||||
<h6 className='font-medium mb-1'>Delete this configuration</h6>
|
||||
<p className='font-light'>All your apps using this configuration will stop working.</p>
|
||||
</div>
|
||||
<button
|
||||
type='button'
|
||||
className='bg-red-700 hover:bg-red-800 text-white text-sm font-bold py-2 px-4 rounded leading-6 inline-block'
|
||||
onClick={toggleDelConfirm}
|
||||
data-modal-toggle='popup-modal'>
|
||||
Delete
|
||||
</button>
|
||||
</section>
|
||||
)}
|
||||
</form>
|
||||
<Modal
|
||||
closable
|
||||
title='Are you absolutely sure ?'
|
||||
description='This action cannot be undone. This will permanently delete the SAML config.'
|
||||
visible={delModalVisible}
|
||||
onCancel={toggleDelConfirm}
|
||||
customFooter={
|
||||
<div className='inline-flex ml-auto'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={toggleDelConfirm}
|
||||
className='bg-gray-200 text-secondary/90 hover:bg-gray-300 border-2 text-sm font-bold py-2 px-4 rounded leading-6 inline-block'>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
disabled={userNameEntry !== samlConfig?.product}
|
||||
onClick={deleteConfiguration}
|
||||
className='ml-1.5 bg-red-700 hover:bg-red-800 disabled:bg-slate-400 text-white text-sm font-bold py-2 px-4 rounded leading-6 inline-block'>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
}>
|
||||
<p className='text-slate-600'>
|
||||
Please type in the name of the product '
|
||||
{samlConfig?.product && <strong>{samlConfig.product}</strong>}' to confirm.
|
||||
</p>
|
||||
<label htmlFor='nameOfProd' className='font-medium text-slate-900'>
|
||||
Name *
|
||||
</label>
|
||||
<input
|
||||
id='nameOfProd'
|
||||
required
|
||||
className='bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:white dark:border-gray-600 dark:placeholder-gray-400 d dark:focus:ring-blue-500 dark:focus:border-blue-500'
|
||||
value={userNameEntry}
|
||||
onChange={({ target }) => {
|
||||
setUserNameEntry(target.value);
|
||||
}}></input>
|
||||
</Modal>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddEdit;
|
|
@ -0,0 +1 @@
|
|||
state.json
|
|
@ -0,0 +1,7 @@
|
|||
import { test } from '@playwright/test';
|
||||
|
||||
test('MAGIC_LINK in globalSetup should log me in', async ({ page }) => {
|
||||
await page.goto('/admin/saml/config');
|
||||
// Find an element with the text 'SAML Configurations' and click on it
|
||||
await page.waitForSelector('text=SAML Configurations');
|
||||
});
|
|
@ -0,0 +1,18 @@
|
|||
// global-setup.ts
|
||||
import { chromium, FullConfig } from '@playwright/test';
|
||||
import { IDENTIFIER, TOKEN } from './nextAuth.constants';
|
||||
|
||||
async function globalSetup(config: FullConfig) {
|
||||
const { baseURL, storageState } = config.projects[0].use;
|
||||
// Generate a link with email, unhashed token and callback url
|
||||
const params = new URLSearchParams({ callbackURL: baseURL || '', token: TOKEN, email: IDENTIFIER });
|
||||
const providerId = 'email';
|
||||
const MAGIC_LINK = `${baseURL}/api/auth/callback/${providerId}?${params}`;
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage();
|
||||
await page.goto(MAGIC_LINK);
|
||||
await page.context().storageState({ path: storageState as string });
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
export default globalSetup;
|
|
@ -0,0 +1,6 @@
|
|||
// const ONE_DAY_IN_SECONDS = 86400;
|
||||
// const EXPIRES = new Date(Date.now() + 1000000 * ONE_DAY_IN_SECONDS * 1000);
|
||||
export const EXPIRES = new Date(88043913172731); /* Sat Jan 02 4760 00:02:52 GMT+0530 (India Standard Time) */
|
||||
// const TOKEN = randomBytes(32).toString('hex');
|
||||
export const TOKEN = '94b773327570de9bef4779a40529c467b5b5226f15b1d28b00e7fba629a0921b';
|
||||
export const IDENTIFIER = 'headless@boxyhq.com';
|
|
@ -0,0 +1,20 @@
|
|||
import { createHash } from 'crypto';
|
||||
import { initNextAuthDB } from '@lib/nextAuthAdapter';
|
||||
import { IDENTIFIER as identifier, EXPIRES as expires, TOKEN as token } from './nextAuth.constants';
|
||||
|
||||
export function hashToken(token: string) {
|
||||
return createHash('sha256').update(`${token}${process.env.NEXTAUTH_SECRET}`).digest('hex');
|
||||
}
|
||||
|
||||
(async function setup() {
|
||||
const store = await initNextAuthDB();
|
||||
|
||||
const verificationToken = {
|
||||
identifier,
|
||||
expires,
|
||||
token: hashToken(token),
|
||||
};
|
||||
|
||||
await (await store).put(verificationToken.identifier, verificationToken);
|
||||
process.exit(0);
|
||||
})();
|
|
@ -0,0 +1,29 @@
|
|||
import { useEffect, useState } from "react";
|
||||
|
||||
export default function useKeyPress(targetKey: string): boolean {
|
||||
// State for keeping track of whether key is pressed
|
||||
const [keyPressed, setKeyPressed] = useState<boolean>(false);
|
||||
// If pressed key is our target key then set to true
|
||||
function downHandler({ key }: KeyboardEvent) {
|
||||
if (key === targetKey) {
|
||||
setKeyPressed(true);
|
||||
}
|
||||
}
|
||||
// If released key is our target key then set to false
|
||||
const upHandler = ({ key }: KeyboardEvent) => {
|
||||
if (key === targetKey) {
|
||||
setKeyPressed(false);
|
||||
}
|
||||
};
|
||||
// Add event listeners
|
||||
useEffect(() => {
|
||||
window.addEventListener("keydown", downHandler);
|
||||
window.addEventListener("keyup", upHandler);
|
||||
// Remove event listeners on cleanup
|
||||
return () => {
|
||||
window.removeEventListener("keydown", downHandler);
|
||||
window.removeEventListener("keyup", upHandler);
|
||||
};
|
||||
}, []); // Empty array ensures that effect is only run on mount and unmount
|
||||
return keyPressed;
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
import { useState, useEffect } from "react";
|
||||
|
||||
const useMediaQuery = (query: string) => {
|
||||
const [matches, setMatches] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia(query);
|
||||
if (media.matches !== matches) {
|
||||
setMatches(media.matches);
|
||||
}
|
||||
const listener = () => setMatches(media.matches);
|
||||
window.addEventListener("resize", listener);
|
||||
return () => window.removeEventListener("resize", listener);
|
||||
}, [matches, query]);
|
||||
|
||||
return matches;
|
||||
};
|
||||
|
||||
export default useMediaQuery;
|
|
@ -0,0 +1,35 @@
|
|||
import { RefObject, useEffect } from "react";
|
||||
|
||||
// https://usehooks-ts.com/react-hook/use-on-click-outside
|
||||
|
||||
type Handler = (event: MouseEvent | TouchEvent) => void;
|
||||
|
||||
export default function useOnClickOutside<T extends HTMLElement = HTMLElement>(
|
||||
ref: RefObject<T>,
|
||||
handler: Handler
|
||||
) {
|
||||
useEffect(
|
||||
() => {
|
||||
const listener = (event: MouseEvent | TouchEvent) => {
|
||||
// Do nothing if clicking ref's element or descendent elements
|
||||
if (!ref.current || ref.current.contains(event.target as Node)) {
|
||||
return;
|
||||
}
|
||||
handler(event);
|
||||
};
|
||||
document.addEventListener("mousedown", listener);
|
||||
document.addEventListener("touchstart", listener);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", listener);
|
||||
document.removeEventListener("touchstart", listener);
|
||||
};
|
||||
},
|
||||
// Add ref and handler to effect dependencies
|
||||
// It's worth noting that because passed in handler is a new ...
|
||||
// ... function on every render that will cause this effect ...
|
||||
// ... callback/cleanup to run every render. It's not a big deal ...
|
||||
// ... but to optimize you can wrap handler in useCallback before ...
|
||||
// ... passing it into this hook.
|
||||
[ref, handler]
|
||||
);
|
||||
}
|
|
@ -1,26 +1,33 @@
|
|||
import jackson, { IAPIController, IOAuthController } from '@boxyhq/saml-jackson';
|
||||
import jackson, { IAdminController, IAPIController, IOAuthController, IdPConfig } from '@boxyhq/saml-jackson';
|
||||
import env from '@lib/env';
|
||||
import '@lib/metrics';
|
||||
|
||||
let apiController: IAPIController;
|
||||
let oauthController: IOAuthController;
|
||||
let adminController: IAdminController;
|
||||
|
||||
const g = global as any;
|
||||
|
||||
export default async function init() {
|
||||
if (!g.apiController || !g.oauthController) {
|
||||
if (!g.apiController || !g.oauthController || !g.adminController) {
|
||||
const ret = await jackson(env);
|
||||
apiController = ret.apiController;
|
||||
oauthController = ret.oauthController;
|
||||
adminController = ret.adminController;
|
||||
g.apiController = apiController;
|
||||
g.oauthController = oauthController;
|
||||
g.adminController = adminController;
|
||||
} else {
|
||||
apiController = g.apiController;
|
||||
oauthController = g.oauthController;
|
||||
adminController = g.adminController;
|
||||
}
|
||||
|
||||
return {
|
||||
apiController,
|
||||
oauthController,
|
||||
adminController,
|
||||
};
|
||||
}
|
||||
|
||||
export type { IdPConfig };
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import Cors from 'cors';
|
||||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { getSession } from 'next-auth/react';
|
||||
|
||||
// Initializing the cors middleware
|
||||
const corsFunction = Cors({
|
||||
|
@ -23,3 +24,15 @@ function runMiddleware(req: NextApiRequest, res: NextApiResponse, fn: any) {
|
|||
export async function cors(req: NextApiRequest, res: NextApiResponse) {
|
||||
return await runMiddleware(req, res, corsFunction);
|
||||
}
|
||||
|
||||
export const checkSession = (handler) => async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const session = await getSession({ req });
|
||||
if (session) {
|
||||
// Signed in
|
||||
return handler(req, res);
|
||||
} else {
|
||||
// Not Signed in
|
||||
res.status(401);
|
||||
}
|
||||
res.end();
|
||||
};
|
||||
|
|
|
@ -0,0 +1,94 @@
|
|||
import { Storable } from '@boxyhq/saml-jackson';
|
||||
import DB from 'npm/src/db/db';
|
||||
import opts from './env';
|
||||
import type { AdapterUser, VerificationToken } from 'next-auth/adapters';
|
||||
import { validateEmailWithACL } from './utils';
|
||||
|
||||
const g = global as any;
|
||||
|
||||
export async function initNextAuthDB(): Promise<Storable> {
|
||||
if (!g.adminAuthStore) {
|
||||
const db = await DB.new(opts.db);
|
||||
g.adminAuthStore = db.store('admin:auth');
|
||||
}
|
||||
return g.adminAuthStore as Storable;
|
||||
}
|
||||
|
||||
/** @return { import("next-auth/adapters").Adapter } */
|
||||
export default function Adapter() {
|
||||
const store = (async () => await initNextAuthDB())();
|
||||
return {
|
||||
async createUser(user) {
|
||||
return user;
|
||||
},
|
||||
async getUser(id) {
|
||||
return;
|
||||
},
|
||||
async getUserByEmail(email) {
|
||||
// ?? we already do the validation in signIn callback (see pages/api/auth/[...nextauth].ts)
|
||||
if (validateEmailWithACL(email)) {
|
||||
return {
|
||||
id: email,
|
||||
name: email.split('@')[0],
|
||||
email,
|
||||
role: 'admin',
|
||||
emailVerified: new Date(),
|
||||
} as AdapterUser;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
async getUserByAccount({ providerAccountId, provider }) {
|
||||
return;
|
||||
},
|
||||
async updateUser(user: AdapterUser) {
|
||||
if (!user.id) {
|
||||
return null;
|
||||
}
|
||||
const email = user.id;
|
||||
// ?? we already do the validation in signIn callback (see pages/api/auth/[...nextauth].ts)
|
||||
if (validateEmailWithACL(email)) {
|
||||
return {
|
||||
id: email,
|
||||
name: email.split('@')[0],
|
||||
email,
|
||||
role: 'admin',
|
||||
emailVerified: new Date(),
|
||||
} as AdapterUser;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
// will be required in a future release, but are not yet invoked
|
||||
async deleteUser(userId) {
|
||||
return;
|
||||
},
|
||||
async linkAccount(account) {
|
||||
return;
|
||||
},
|
||||
// will be required in a future release, but are not yet invoked
|
||||
async unlinkAccount({ providerAccountId, provider }) {
|
||||
return;
|
||||
},
|
||||
async createSession({ sessionToken, userId, expires }) {
|
||||
return;
|
||||
},
|
||||
async getSessionAndUser(sessionToken) {
|
||||
return;
|
||||
},
|
||||
async updateSession({ sessionToken }) {
|
||||
return;
|
||||
},
|
||||
async deleteSession(sessionToken) {
|
||||
return;
|
||||
},
|
||||
async createVerificationToken(data: VerificationToken) {
|
||||
await (await store).put(data.identifier, data);
|
||||
},
|
||||
async useVerificationToken({ identifier, token }) {
|
||||
const tokenInStore = await (await store).get(identifier);
|
||||
if (tokenInStore.token === token) {
|
||||
await (await store).delete(identifier);
|
||||
}
|
||||
return tokenInStore ?? null;
|
||||
},
|
||||
};
|
||||
}
|
42
lib/utils.ts
42
lib/utils.ts
|
@ -1,5 +1,7 @@
|
|||
import { NextApiRequest } from 'next';
|
||||
import env from '@lib/env';
|
||||
import micromatch from 'micromatch';
|
||||
import { AdapterUser } from 'next-auth/adapters';
|
||||
|
||||
export const validateApiKey = (token) => {
|
||||
return env.apiKeys.includes(token);
|
||||
|
@ -14,3 +16,43 @@ export const extractAuthToken = (req: NextApiRequest) => {
|
|||
|
||||
return null;
|
||||
};
|
||||
|
||||
export interface APIError extends Error {
|
||||
info?: string;
|
||||
status: number;
|
||||
}
|
||||
|
||||
export const fetcher = async (url: string) => {
|
||||
const res = await fetch(url, {
|
||||
headers: new Headers({
|
||||
Authorization: 'Api-Key secret',
|
||||
}),
|
||||
});
|
||||
let resContent;
|
||||
try {
|
||||
resContent = await res.clone().json();
|
||||
} catch (e) {
|
||||
resContent = await res.clone().text();
|
||||
}
|
||||
if (!res.ok) {
|
||||
const error = new Error('An error occurred while fetching the data.') as APIError;
|
||||
// Attach extra info to the error object.
|
||||
error.info = resContent;
|
||||
error.status = res.status;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return resContent;
|
||||
};
|
||||
|
||||
export const validateEmailWithACL = (email) => {
|
||||
const NEXTAUTH_ACL = process.env.NEXTAUTH_ACL || undefined;
|
||||
const acl = NEXTAUTH_ACL?.split(',');
|
||||
|
||||
if (acl) {
|
||||
if (micromatch.isMatch(email, acl)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
import {MigrationInterface, QueryRunner} from "typeorm";
|
||||
|
||||
export class createdAt1644332636666 implements MigrationInterface {
|
||||
name = 'createdAt1644332636666'
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE \`jackson_store\` ADD \`createdAt\` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP()`);
|
||||
await queryRunner.query(`ALTER TABLE \`jackson_store\` ADD \`modifiedAt\` timestamp NULL`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE \`jackson_store\` DROP COLUMN \`modifiedAt\``);
|
||||
await queryRunner.query(`ALTER TABLE \`jackson_store\` DROP COLUMN \`createdAt\``);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
import {MigrationInterface, QueryRunner} from "typeorm";
|
||||
|
||||
export class createdAt1644332641078 implements MigrationInterface {
|
||||
name = 'createdAt1644332641078'
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE \`jackson_store\` ADD \`createdAt\` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP`);
|
||||
await queryRunner.query(`ALTER TABLE \`jackson_store\` ADD \`modifiedAt\` timestamp NULL`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE \`jackson_store\` DROP COLUMN \`modifiedAt\``);
|
||||
await queryRunner.query(`ALTER TABLE \`jackson_store\` DROP COLUMN \`createdAt\``);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
import {MigrationInterface, QueryRunner} from "typeorm";
|
||||
|
||||
export class createdAt1644332647279 implements MigrationInterface {
|
||||
name = 'createdAt1644332647279'
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "jackson_store" ADD "createdAt" TIMESTAMP NOT NULL DEFAULT now()`);
|
||||
await queryRunner.query(`ALTER TABLE "jackson_store" ADD "modifiedAt" TIMESTAMP`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "jackson_store" DROP COLUMN "modifiedAt"`);
|
||||
await queryRunner.query(`ALTER TABLE "jackson_store" DROP COLUMN "createdAt"`);
|
||||
}
|
||||
|
||||
}
|
|
@ -18,9 +18,9 @@
|
|||
],
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
"db:migration:generate:postgres": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js migration:generate --config ormconfig.js -n Initial",
|
||||
"db:migration:generate:mysql": "cross-env DB_TYPE=mysql DB_URL=mysql://root:mysql@localhost:3307/mysql ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js migration:generate --config ormconfig.js -n Initial",
|
||||
"db:migration:generate:mariadb": "cross-env DB_TYPE=mariadb DB_URL=mariadb://root@localhost:3306/mysql ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js migration:generate --config ormconfig.js -n Initial",
|
||||
"db:migration:generate:postgres": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js migration:generate --config ormconfig.js -n createdAt",
|
||||
"db:migration:generate:mysql": "cross-env DB_TYPE=mysql DB_URL=mysql://root:mysql@localhost:3307/mysql ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js migration:generate --config ormconfig.js -n createdAt",
|
||||
"db:migration:generate:mariadb": "cross-env DB_TYPE=mariadb DB_URL=mariadb://root@localhost:3306/mysql ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js migration:generate --config ormconfig.js -n createdAt",
|
||||
"db:migration:run:postgres": "ts-node --transpile-only ./node_modules/typeorm/cli.js migration:run",
|
||||
"db:migration:run:mysql": "cross-env DB_TYPE=mysql DB_URL=mysql://root:mysql@localhost:3307/mysql ts-node --transpile-only ./node_modules/typeorm/cli.js migration:run",
|
||||
"db:migration:run:mariadb": "cross-env DB_TYPE=mariadb DB_URL=mariadb://root@localhost:3306/mysql ts-node --transpile-only ./node_modules/typeorm/cli.js migration:run",
|
||||
|
|
|
@ -0,0 +1,17 @@
|
|||
import { IAdminController, Storable, OAuth } from '../typings';
|
||||
|
||||
export class AdminController implements IAdminController {
|
||||
configStore: Storable;
|
||||
|
||||
constructor({ configStore }) {
|
||||
this.configStore = configStore;
|
||||
}
|
||||
|
||||
public async getAllConfig(): Promise<Partial<OAuth>[]> {
|
||||
const configList = (await this.configStore.getAll()) as Partial<OAuth>[];
|
||||
if (!configList || !configList.length) {
|
||||
return [];
|
||||
}
|
||||
return configList;
|
||||
}
|
||||
}
|
|
@ -15,7 +15,8 @@ export class APIController implements IAPIController {
|
|||
}
|
||||
|
||||
private _validateIdPConfig(body: IdPConfig): void {
|
||||
const { encodedRawMetadata, rawMetadata, defaultRedirectUrl, redirectUrl, tenant, product } = body;
|
||||
const { encodedRawMetadata, rawMetadata, defaultRedirectUrl, redirectUrl, tenant, product, description } =
|
||||
body;
|
||||
|
||||
if (!rawMetadata && !encodedRawMetadata) {
|
||||
throw new JacksonError('Please provide rawMetadata or encodedRawMetadata', 400);
|
||||
|
@ -36,6 +37,10 @@ export class APIController implements IAPIController {
|
|||
if (!product) {
|
||||
throw new JacksonError('Please provide product', 400);
|
||||
}
|
||||
|
||||
if (description && description.length > 100) {
|
||||
throw new JacksonError('Description should not exceed 100 characters', 400);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -51,10 +56,23 @@ export class APIController implements IAPIController {
|
|||
* consumes:
|
||||
* - application/x-www-form-urlencoded
|
||||
* parameters:
|
||||
* - name: name
|
||||
* description: Name/identifier for the config
|
||||
* type: string
|
||||
* in: formData
|
||||
* example: cal-saml-config
|
||||
* - name: description
|
||||
* description: A short description for the config not more than 100 characters
|
||||
* type: string
|
||||
* in: formData
|
||||
* example: SAML login for cal.com app
|
||||
* - name: encodedRawMetadata
|
||||
* description: Base64 encoding of the XML metadata
|
||||
* in: formData
|
||||
* required: true
|
||||
* type: string
|
||||
* - name: rawMetadata
|
||||
* description: Raw XML metadata
|
||||
* in: formData
|
||||
* type: string
|
||||
* - name: defaultRedirectUrl
|
||||
* description: The redirect URL to use in the IdP login flow
|
||||
|
@ -92,11 +110,22 @@ export class APIController implements IAPIController {
|
|||
* client_id: 8958e13053832b5af58fdf2ee83f35f5d013dc74
|
||||
* client_secret: 13f01f4df5b01770c616e682d14d3ba23f20948cfa89b1d7
|
||||
* type: accounts.google.com
|
||||
* 400:
|
||||
* description: Please provide rawMetadata or encodedRawMetadata | Please provide a defaultRedirectUrl | Please provide redirectUrl | Please provide tenant | Please provide product | Please provide a friendly name | Description should not exceed 100 characters
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
*/
|
||||
public async config(body: IdPConfig): Promise<OAuth> {
|
||||
const { encodedRawMetadata, rawMetadata, defaultRedirectUrl, redirectUrl, tenant, product } = body;
|
||||
const {
|
||||
encodedRawMetadata,
|
||||
rawMetadata,
|
||||
defaultRedirectUrl,
|
||||
redirectUrl,
|
||||
tenant,
|
||||
product,
|
||||
name,
|
||||
description,
|
||||
} = body;
|
||||
|
||||
metrics.increment('createConfig');
|
||||
|
||||
|
@ -143,6 +172,8 @@ export class APIController implements IAPIController {
|
|||
redirectUrl: JSON.parse(redirectUrl), // redirectUrl is a stringified array
|
||||
tenant,
|
||||
product,
|
||||
name,
|
||||
description,
|
||||
clientID,
|
||||
clientSecret,
|
||||
certs,
|
||||
|
@ -165,6 +196,152 @@ export class APIController implements IAPIController {
|
|||
provider: idpMetadata.provider,
|
||||
};
|
||||
}
|
||||
/**
|
||||
* @swagger
|
||||
*
|
||||
* /api/v1/saml/config:
|
||||
* patch:
|
||||
* summary: Update SAML configuration
|
||||
* operationId: update-saml-config
|
||||
* tags: [SAML Config]
|
||||
* consumes:
|
||||
* - application/json
|
||||
* - application/x-www-form-urlencoded
|
||||
* parameters:
|
||||
* - name: clientID
|
||||
* description: Client ID for the config
|
||||
* type: string
|
||||
* in: formData
|
||||
* required: true
|
||||
* - name: clientSecret
|
||||
* description: Client Secret for the config
|
||||
* type: string
|
||||
* in: formData
|
||||
* required: true
|
||||
* - name: name
|
||||
* description: Name/identifier for the config
|
||||
* type: string
|
||||
* in: formData
|
||||
* example: cal-saml-config
|
||||
* - name: description
|
||||
* description: A short description for the config not more than 100 characters
|
||||
* type: string
|
||||
* in: formData
|
||||
* example: SAML login for cal.com app
|
||||
* - name: encodedRawMetadata
|
||||
* description: Base64 encoding of the XML metadata
|
||||
* in: formData
|
||||
* type: string
|
||||
* - name: rawMetadata
|
||||
* description: Raw XML metadata
|
||||
* in: formData
|
||||
* type: string
|
||||
* - name: defaultRedirectUrl
|
||||
* description: The redirect URL to use in the IdP login flow
|
||||
* in: formData
|
||||
* required: true
|
||||
* type: string
|
||||
* example: http://localhost:3000/login/saml
|
||||
* - name: redirectUrl
|
||||
* description: JSON encoded array containing a list of allowed redirect URLs
|
||||
* in: formData
|
||||
* required: true
|
||||
* type: string
|
||||
* example: '["http://localhost:3000/*"]'
|
||||
* - name: tenant
|
||||
* description: Tenant
|
||||
* in: formData
|
||||
* required: true
|
||||
* type: string
|
||||
* example: boxyhq.com
|
||||
* - name: product
|
||||
* description: Product
|
||||
* in: formData
|
||||
* required: true
|
||||
* type: string
|
||||
* example: demo
|
||||
* responses:
|
||||
* 204:
|
||||
* description: Success
|
||||
* 400:
|
||||
* description: Please provide clientID | Please provide clientSecret | clientSecret mismatch | Tenant/Product config mismatch with IdP metadata | Description should not exceed 100 characters
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
*/
|
||||
public async updateConfig(body): Promise<void> {
|
||||
const {
|
||||
encodedRawMetadata, // could be empty
|
||||
rawMetadata, // could be empty
|
||||
defaultRedirectUrl,
|
||||
redirectUrl,
|
||||
name,
|
||||
description,
|
||||
...clientInfo
|
||||
} = body;
|
||||
if (!clientInfo?.clientID) {
|
||||
throw new JacksonError('Please provide clientID', 400);
|
||||
}
|
||||
if (!clientInfo?.clientSecret) {
|
||||
throw new JacksonError('Please provide clientSecret', 400);
|
||||
}
|
||||
if (description && description.length > 100) {
|
||||
throw new JacksonError('Description should not exceed 100 characters', 400);
|
||||
}
|
||||
const _currentConfig = (await this.getConfig(clientInfo))?.config;
|
||||
|
||||
if (_currentConfig.clientSecret !== clientInfo?.clientSecret) {
|
||||
throw new JacksonError('clientSecret mismatch', 400);
|
||||
}
|
||||
let metaData = rawMetadata;
|
||||
if (encodedRawMetadata) {
|
||||
metaData = Buffer.from(encodedRawMetadata, 'base64').toString();
|
||||
}
|
||||
let newMetadata;
|
||||
if (metaData) {
|
||||
newMetadata = await saml.parseMetadataAsync(metaData);
|
||||
|
||||
// extract provider
|
||||
let providerName = extractHostName(newMetadata.entityID);
|
||||
if (!providerName) {
|
||||
providerName = extractHostName(newMetadata.sso.redirectUrl || newMetadata.sso.postUrl);
|
||||
}
|
||||
|
||||
newMetadata.provider = providerName ? providerName : 'Unknown';
|
||||
}
|
||||
|
||||
if (newMetadata) {
|
||||
// check if clientID matches with new metadata payload
|
||||
const clientID = dbutils.keyDigest(
|
||||
dbutils.keyFromParts(clientInfo.tenant, clientInfo.product, newMetadata.entityID)
|
||||
);
|
||||
|
||||
if (clientID !== clientInfo?.clientID) {
|
||||
throw new JacksonError('Tenant/Product config mismatch with IdP metadata', 400);
|
||||
}
|
||||
}
|
||||
|
||||
await this.configStore.put(
|
||||
clientInfo?.clientID,
|
||||
{
|
||||
..._currentConfig,
|
||||
name: name ? name : _currentConfig.name,
|
||||
description: description ? description : _currentConfig.description,
|
||||
idpMetadata: newMetadata ? newMetadata : _currentConfig.idpMetadata,
|
||||
defaultRedirectUrl: defaultRedirectUrl ? defaultRedirectUrl : _currentConfig.defaultRedirectUrl,
|
||||
redirectUrl: redirectUrl ? JSON.parse(redirectUrl) : _currentConfig.redirectUrl,
|
||||
},
|
||||
{
|
||||
// secondary index on entityID
|
||||
name: IndexNames.EntityID,
|
||||
value: _currentConfig.idpMetadata.entityID,
|
||||
},
|
||||
{
|
||||
// secondary index on tenant + product
|
||||
name: IndexNames.TenantProduct,
|
||||
value: dbutils.keyFromParts(_currentConfig.tenant, _currentConfig.product),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
|
@ -193,19 +370,39 @@ export class APIController implements IAPIController {
|
|||
* description: Success
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* provider:
|
||||
* type: string
|
||||
* example:
|
||||
* type: accounts.google.com
|
||||
* {
|
||||
* "config": {
|
||||
* "idpMetadata": {
|
||||
* "sso": {
|
||||
* "postUrl": "https://dev-20901260.okta.com/app/dev-20901260_jacksonnext_1/xxxxxxxxxxxxx/sso/saml",
|
||||
* "redirectUrl": "https://dev-20901260.okta.com/app/dev-20901260_jacksonnext_1/xxxxxxxxxxxxx/sso/saml"
|
||||
* },
|
||||
* "entityID": "http://www.okta.com/xxxxxxxxxxxxx",
|
||||
* "thumbprint": "Eo+eUi3UM3XIMkFFtdVK3yJ5vO9f7YZdasdasdad",
|
||||
* "loginType": "idp",
|
||||
* "provider": "okta.com"
|
||||
* },
|
||||
* "defaultRedirectUrl": "https://hoppscotch.io/",
|
||||
* "redirectUrl": ["https://hoppscotch.io/"],
|
||||
* "tenant": "hoppscotch.io",
|
||||
* "product": "API Engine",
|
||||
* "name": "Hoppscotch-SP",
|
||||
* "description": "SP for hoppscotch.io",
|
||||
* "clientID": "Xq8AJt3yYAxmXizsCWmUBDRiVP1iTC8Y/otnvFIMitk",
|
||||
* "clientSecret": "00e3e11a3426f97d8000000738300009130cd45419c5943",
|
||||
* "certs": {
|
||||
* "publicKey": "-----BEGIN CERTIFICATE-----.......-----END CERTIFICATE-----",
|
||||
* "privateKey": "-----BEGIN PRIVATE KEY-----......-----END PRIVATE KEY-----"
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
* '400':
|
||||
* description: Please provide `clientID` or `tenant` and `product`.
|
||||
* '401':
|
||||
* description: Unauthorized
|
||||
*/
|
||||
public async getConfig(body: {
|
||||
clientID: string;
|
||||
tenant: string;
|
||||
product: string;
|
||||
}): Promise<Partial<OAuth>> {
|
||||
public async getConfig(body: { clientID: string; tenant: string; product: string }): Promise<any> {
|
||||
const { clientID, tenant, product } = body;
|
||||
|
||||
metrics.increment('getConfig');
|
||||
|
@ -213,7 +410,7 @@ export class APIController implements IAPIController {
|
|||
if (clientID) {
|
||||
const samlConfig = await this.configStore.get(clientID);
|
||||
|
||||
return samlConfig ? { provider: samlConfig.idpMetadata.provider } : {};
|
||||
return samlConfig ? { config: samlConfig } : {};
|
||||
}
|
||||
|
||||
if (tenant && product) {
|
||||
|
@ -226,7 +423,7 @@ export class APIController implements IAPIController {
|
|||
return {};
|
||||
}
|
||||
|
||||
return { provider: samlConfigs[0].idpMetadata.provider };
|
||||
return { config: samlConfigs[0] };
|
||||
}
|
||||
|
||||
throw new JacksonError('Please provide `clientID` or `tenant` and `product`.', 400);
|
||||
|
@ -264,6 +461,8 @@ export class APIController implements IAPIController {
|
|||
* responses:
|
||||
* '200':
|
||||
* description: Success
|
||||
* '400':
|
||||
* description: clientSecret mismatch | Please provide `clientID` and `clientSecret` or `tenant` and `product`.'
|
||||
* '401':
|
||||
* description: Unauthorized
|
||||
*/
|
||||
|
@ -287,7 +486,7 @@ export class APIController implements IAPIController {
|
|||
if (samlConfig.clientSecret === clientSecret) {
|
||||
await this.configStore.delete(clientID);
|
||||
} else {
|
||||
throw new JacksonError('clientSecret mismatch.', 400);
|
||||
throw new JacksonError('clientSecret mismatch', 400);
|
||||
}
|
||||
|
||||
return;
|
||||
|
|
|
@ -33,6 +33,14 @@ class DB implements DatabaseDriver {
|
|||
return decrypt(res, this.encryptionKey);
|
||||
}
|
||||
|
||||
async getAll(namespace): Promise<unknown[]> {
|
||||
const res = (await this.db.getAll(namespace)) as Encrypted[];
|
||||
const encryptionKey = this.encryptionKey;
|
||||
return res.map((r) => {
|
||||
return decrypt(r, encryptionKey);
|
||||
});
|
||||
}
|
||||
|
||||
async getByIndex(namespace: string, idx: Index): Promise<unknown[]> {
|
||||
const res = await this.db.getByIndex(namespace, idx);
|
||||
const encryptionKey = this.encryptionKey;
|
||||
|
|
|
@ -51,6 +51,19 @@ class Mem implements DatabaseDriver {
|
|||
return null;
|
||||
}
|
||||
|
||||
async getAll(namespace: string): Promise<unknown[]> {
|
||||
const returnValue: string[] = [];
|
||||
if (namespace) {
|
||||
for (const key in this.store) {
|
||||
if (key.startsWith(namespace)) {
|
||||
returnValue.push(this.store[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (returnValue) return returnValue;
|
||||
return [];
|
||||
}
|
||||
|
||||
async getByIndex(namespace: string, idx: Index): Promise<any> {
|
||||
const dbKeys = await this.indexes[dbutils.keyForIndex(namespace, idx)];
|
||||
|
||||
|
@ -67,6 +80,10 @@ class Mem implements DatabaseDriver {
|
|||
|
||||
this.store[k] = val;
|
||||
|
||||
if (!Date.parse(this.store['createdAt'])) this.store['createdAt'] = new Date().toISOString();
|
||||
this.store['modifiedAt'] = new Date().toISOString();
|
||||
|
||||
// console.log(this.store)
|
||||
if (ttl) {
|
||||
this.ttlStore[k] = {
|
||||
namespace,
|
||||
|
@ -74,7 +91,6 @@ class Mem implements DatabaseDriver {
|
|||
expiresAt: Date.now() + ttl * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
// no ttl support for secondary indexes
|
||||
for (const idx of indexes || []) {
|
||||
const idxKey = dbutils.keyForIndex(namespace, idx);
|
||||
|
@ -85,7 +101,6 @@ class Mem implements DatabaseDriver {
|
|||
}
|
||||
|
||||
set.add(key);
|
||||
|
||||
const cleanupKey = dbutils.keyFromParts(dbutils.indexPrefix, k);
|
||||
let cleanup = this.cleanup[cleanupKey];
|
||||
if (!cleanup) {
|
||||
|
|
|
@ -1,27 +1,34 @@
|
|||
import { MongoClient } from 'mongodb';
|
||||
import { Collection, Db, MongoClient, UpdateOptions } from 'mongodb';
|
||||
import { DatabaseDriver, DatabaseOption, Encrypted, Index } from '../typings';
|
||||
import * as dbutils from './utils';
|
||||
|
||||
type Document = {
|
||||
type _Document = {
|
||||
value: Encrypted;
|
||||
expiresAt: Date;
|
||||
expiresAt?: Date;
|
||||
modifiedAt: string;
|
||||
indexes: string[];
|
||||
};
|
||||
|
||||
class Mongo implements DatabaseDriver {
|
||||
private options: DatabaseOption;
|
||||
private client!: MongoClient;
|
||||
private collection: any;
|
||||
private db: any;
|
||||
private collection!: Collection;
|
||||
private db!: Db;
|
||||
|
||||
constructor(options: DatabaseOption) {
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
async init(): Promise<Mongo> {
|
||||
this.client = new MongoClient(this.options.url!);
|
||||
|
||||
await this.client.connect();
|
||||
try {
|
||||
if (!this.options.url) {
|
||||
throw Error('Please specify a db url');
|
||||
}
|
||||
this.client = new MongoClient(this.options.url);
|
||||
await this.client.connect();
|
||||
} catch (err) {
|
||||
console.error(`error connecting to ${this.options.type} db: ${err}`);
|
||||
}
|
||||
|
||||
this.db = this.client.db();
|
||||
this.collection = this.db.collection('jacksonStore');
|
||||
|
@ -43,6 +50,14 @@ class Mongo implements DatabaseDriver {
|
|||
return null;
|
||||
}
|
||||
|
||||
async getAll(namespace: string): Promise<unknown[]> {
|
||||
const _namespaceMatch = new RegExp(`^${namespace}:.*`);
|
||||
const docs = await this.collection.find({ _id: _namespaceMatch }).toArray();
|
||||
|
||||
if (docs) return docs.map(({ value }) => value);
|
||||
return [];
|
||||
}
|
||||
|
||||
async getByIndex(namespace: string, idx: Index): Promise<any> {
|
||||
const docs = await this.collection
|
||||
.find({
|
||||
|
@ -59,7 +74,7 @@ class Mongo implements DatabaseDriver {
|
|||
}
|
||||
|
||||
async put(namespace: string, key: string, val: Encrypted, ttl = 0, ...indexes: any[]): Promise<void> {
|
||||
const doc = <Document>{
|
||||
const doc = <_Document>{
|
||||
value: val,
|
||||
};
|
||||
|
||||
|
@ -74,16 +89,19 @@ class Mongo implements DatabaseDriver {
|
|||
if (!doc.indexes) {
|
||||
doc.indexes = [];
|
||||
}
|
||||
|
||||
doc.indexes.push(idxKey);
|
||||
}
|
||||
|
||||
doc.modifiedAt = new Date().toISOString();
|
||||
await this.collection.updateOne(
|
||||
{ _id: dbutils.key(namespace, key) },
|
||||
{
|
||||
$set: doc,
|
||||
$setOnInsert: {
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
{ upsert: true }
|
||||
{ upsert: true } as UpdateOptions
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -36,6 +36,23 @@ class Redis implements DatabaseDriver {
|
|||
return null;
|
||||
}
|
||||
|
||||
async getAll(namespace: string): Promise<unknown[]> {
|
||||
const keys = await this.client.sendCommand(['keys', namespace + ':*']);
|
||||
const returnValue: string[] = [];
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
try {
|
||||
if (this.client.get(keys[i])) {
|
||||
const value = await this.client.get(keys[i]);
|
||||
returnValue.push(JSON.parse(value));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
if (returnValue) return returnValue;
|
||||
return [];
|
||||
}
|
||||
|
||||
async getByIndex(namespace: string, idx: Index): Promise<any> {
|
||||
const dbKeys = await this.client.sMembers(dbutils.keyForIndex(namespace, idx));
|
||||
|
||||
|
|
|
@ -27,4 +27,17 @@ export class JacksonStore {
|
|||
nullable: true,
|
||||
})
|
||||
tag?: string;
|
||||
|
||||
@Column({
|
||||
type: 'timestamp',
|
||||
default: () => 'CURRENT_TIMESTAMP',
|
||||
nullable: false,
|
||||
})
|
||||
createdAt?: Date;
|
||||
|
||||
@Column({
|
||||
type: 'timestamp',
|
||||
nullable: true,
|
||||
})
|
||||
modifiedAt?: string;
|
||||
}
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
require('reflect-metadata');
|
||||
|
||||
import { DatabaseDriver, DatabaseOption, Index, Encrypted } from '../../typings';
|
||||
import { Connection, createConnection } from 'typeorm';
|
||||
import { Connection, createConnection, Like } from 'typeorm';
|
||||
import * as dbutils from '../utils';
|
||||
|
||||
import { JacksonStore } from './entity/JacksonStore';
|
||||
|
@ -87,7 +87,7 @@ class Sql implements DatabaseDriver {
|
|||
}
|
||||
|
||||
async get(namespace: string, key: string): Promise<any> {
|
||||
let res = await this.storeRepository.findOne({
|
||||
const res = await this.storeRepository.findOne({
|
||||
key: dbutils.key(namespace, key),
|
||||
});
|
||||
|
||||
|
@ -102,6 +102,21 @@ class Sql implements DatabaseDriver {
|
|||
return null;
|
||||
}
|
||||
|
||||
async getAll(namespace: string): Promise<unknown[]> {
|
||||
const response = await this.storeRepository.find({
|
||||
where: { key: Like(`%${namespace}%`) },
|
||||
select: ['value', 'iv', 'tag'],
|
||||
order: {
|
||||
['createdAt']: 'DESC',
|
||||
// ['createdAt']: 'ASC',
|
||||
},
|
||||
});
|
||||
|
||||
const returnValue = JSON.parse(JSON.stringify(response));
|
||||
if (returnValue) return returnValue;
|
||||
return [];
|
||||
}
|
||||
|
||||
async getByIndex(namespace: string, idx: Index): Promise<any> {
|
||||
const res = await this.indexRepository.find({
|
||||
key: dbutils.keyForIndex(namespace, idx),
|
||||
|
@ -122,13 +137,7 @@ class Sql implements DatabaseDriver {
|
|||
return ret;
|
||||
}
|
||||
|
||||
async put(
|
||||
namespace: string,
|
||||
key: string,
|
||||
val: Encrypted,
|
||||
ttl: number = 0,
|
||||
...indexes: any[]
|
||||
): Promise<void> {
|
||||
async put(namespace: string, key: string, val: Encrypted, ttl = 0, ...indexes: any[]): Promise<void> {
|
||||
await this.connection.transaction(async (transactionalEntityManager) => {
|
||||
const dbKey = dbutils.key(namespace, key);
|
||||
|
||||
|
@ -137,7 +146,7 @@ class Sql implements DatabaseDriver {
|
|||
store.value = val.value;
|
||||
store.iv = val.iv;
|
||||
store.tag = val.tag;
|
||||
|
||||
store.modifiedAt = new Date().toISOString();
|
||||
await transactionalEntityManager.save(store);
|
||||
|
||||
if (ttl) {
|
||||
|
|
|
@ -16,6 +16,10 @@ class Store implements Storable {
|
|||
return await this.db.get(this.namespace, dbutils.keyDigest(key));
|
||||
}
|
||||
|
||||
async getAll(): Promise<unknown[]> {
|
||||
return await this.db.getAll(this.namespace);
|
||||
}
|
||||
|
||||
async getByIndex(idx: Index): Promise<any> {
|
||||
idx.value = dbutils.keyDigest(idx.value);
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { APIController } from './controller/api';
|
||||
import { OAuthController } from './controller/oauth';
|
||||
import { AdminController } from './controller/admin';
|
||||
import DB from './db/db';
|
||||
import readConfig from './read-config';
|
||||
import { JacksonOption } from './typings';
|
||||
|
@ -38,6 +39,7 @@ export const controllers = async (
|
|||
): Promise<{
|
||||
apiController: APIController;
|
||||
oauthController: OAuthController;
|
||||
adminController: AdminController;
|
||||
}> => {
|
||||
opts = defaultOpts(opts);
|
||||
|
||||
|
@ -49,7 +51,7 @@ export const controllers = async (
|
|||
const tokenStore = db.store('oauth:token', opts.db.ttl);
|
||||
|
||||
const apiController = new APIController({ configStore });
|
||||
|
||||
const adminController = new AdminController({ configStore });
|
||||
const oauthController = new OAuthController({
|
||||
configStore,
|
||||
sessionStore,
|
||||
|
@ -76,6 +78,7 @@ export const controllers = async (
|
|||
return {
|
||||
apiController,
|
||||
oauthController,
|
||||
adminController,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -3,6 +3,8 @@ export type IdPConfig = {
|
|||
redirectUrl: string;
|
||||
tenant: string;
|
||||
product: string;
|
||||
name: string;
|
||||
description: string;
|
||||
rawMetadata?: string;
|
||||
encodedRawMetadata?: string;
|
||||
};
|
||||
|
@ -15,7 +17,8 @@ export interface OAuth {
|
|||
|
||||
export interface IAPIController {
|
||||
config(body: IdPConfig): Promise<OAuth>;
|
||||
getConfig(body: { clientID?: string; tenant?: string; product?: string }): Promise<Partial<OAuth>>;
|
||||
updateConfig(body: any): Promise<void>;
|
||||
getConfig(body: { clientID?: string; tenant?: string; product?: string }): Promise<any>;
|
||||
deleteConfig(body: {
|
||||
clientID?: string;
|
||||
clientSecret?: string;
|
||||
|
@ -31,6 +34,10 @@ export interface IOAuthController {
|
|||
userInfo(token: string): Promise<Profile>;
|
||||
}
|
||||
|
||||
export interface IAdminController {
|
||||
getAllConfig();
|
||||
}
|
||||
|
||||
export interface OAuthReqBody {
|
||||
response_type: 'code';
|
||||
client_id: string;
|
||||
|
@ -75,6 +82,7 @@ export interface Index {
|
|||
}
|
||||
|
||||
export interface DatabaseDriver {
|
||||
getAll(namespace: string): Promise<unknown[]>;
|
||||
get(namespace: string, key: string): Promise<any>;
|
||||
put(namespace: string, key: string, val: any, ttl: number, ...indexes: Index[]): Promise<any>;
|
||||
delete(namespace: string, key: string): Promise<any>;
|
||||
|
@ -82,6 +90,7 @@ export interface DatabaseDriver {
|
|||
}
|
||||
|
||||
export interface Storable {
|
||||
getAll(): Promise<unknown[]>;
|
||||
get(key: string): Promise<any>;
|
||||
put(key: string, val: any, ...indexes: Index[]): Promise<any>;
|
||||
delete(key: string): Promise<any>;
|
||||
|
|
|
@ -143,11 +143,11 @@ tap.test('controller/api', async (t) => {
|
|||
t.equal(response.client_id, CLIENT_ID);
|
||||
t.equal(response.provider, PROVIDER);
|
||||
|
||||
const savedConf = await apiController.getConfig({
|
||||
const { config: savedConfig } = await apiController.getConfig({
|
||||
clientID: CLIENT_ID,
|
||||
});
|
||||
|
||||
t.equal(savedConf.provider, PROVIDER);
|
||||
t.equal(savedConfig.name, 'testConfig');
|
||||
|
||||
kdStub.restore();
|
||||
|
||||
|
@ -157,15 +157,67 @@ tap.test('controller/api', async (t) => {
|
|||
t.end();
|
||||
});
|
||||
|
||||
t.test('Update the config', async (t) => {
|
||||
const body: Partial<IdPConfig> = Object.assign({}, config[0]);
|
||||
|
||||
t.test('When clientID is missing', async (t) => {
|
||||
const { client_secret: clientSecret } = await apiController.config(body as IdPConfig);
|
||||
try {
|
||||
await apiController.updateConfig({ description: 'A new description', clientSecret });
|
||||
t.fail('Expecting JacksonError.');
|
||||
} catch (err: any) {
|
||||
t.equal(err.message, 'Please provide clientID');
|
||||
t.equal(err.statusCode, 400);
|
||||
t.end();
|
||||
}
|
||||
});
|
||||
|
||||
t.test('When clientSecret is missing', async (t) => {
|
||||
const { client_id: clientID } = await apiController.config(body as IdPConfig);
|
||||
|
||||
try {
|
||||
await apiController.updateConfig({ description: 'A new description', clientID });
|
||||
t.fail('Expecting JacksonError.');
|
||||
} catch (err: any) {
|
||||
t.equal(err.message, 'Please provide clientSecret');
|
||||
t.equal(err.statusCode, 400);
|
||||
t.end();
|
||||
}
|
||||
});
|
||||
|
||||
t.test('Update the name/description', async (t) => {
|
||||
const { client_id: clientID, client_secret: clientSecret } = await apiController.config(
|
||||
body as IdPConfig
|
||||
);
|
||||
const {
|
||||
config: { name, description },
|
||||
} = await apiController.getConfig({ clientID });
|
||||
t.equal(name, 'testConfig');
|
||||
t.equal(description, 'Just a test configuration');
|
||||
await apiController.updateConfig({
|
||||
clientID,
|
||||
clientSecret,
|
||||
name: 'A new name',
|
||||
description: 'A new description',
|
||||
});
|
||||
const { config: savedConfig } = await apiController.getConfig({ clientID });
|
||||
t.equal(savedConfig.name, 'A new name');
|
||||
t.equal(savedConfig.description, 'A new description');
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test('Get the config', async (t) => {
|
||||
t.test('when valid request', async (t) => {
|
||||
const body: Partial<IdPConfig> = Object.assign({}, config[0]);
|
||||
|
||||
await apiController.config(body as IdPConfig);
|
||||
|
||||
const { provider } = await apiController.getConfig(body);
|
||||
const { config: savedConfig } = await apiController.getConfig(body);
|
||||
|
||||
t.equal(provider, PROVIDER);
|
||||
t.equal(savedConfig.name, 'testConfig');
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
@ -248,7 +300,7 @@ tap.test('controller/api', async (t) => {
|
|||
|
||||
t.fail('Expecting Error.');
|
||||
} catch (err: any) {
|
||||
t.match(err.message, 'clientSecret mismatch.');
|
||||
t.match(err.message, 'clientSecret mismatch');
|
||||
}
|
||||
|
||||
t.end();
|
||||
|
|
|
@ -3,4 +3,6 @@ module.exports = {
|
|||
redirectUrl: '["http://localhost:3366"]',
|
||||
tenant: 'boxyhq.com',
|
||||
product: 'crm',
|
||||
name: 'testConfig',
|
||||
description: 'Just a test configuration',
|
||||
};
|
||||
|
|
|
@ -4,8 +4,8 @@ import DB from '../src/db/db';
|
|||
|
||||
const encryptionKey: EncryptionKey = '3yGrTcnKPBqqHoH3zZMAU6nt4bmIYb2q';
|
||||
|
||||
let configStores: Storable[] = [];
|
||||
let ttlStores: Storable[] = [];
|
||||
const configStores: Storable[] = [];
|
||||
const ttlStores: Storable[] = [];
|
||||
const ttl = 3;
|
||||
|
||||
const record1 = {
|
||||
|
|
|
@ -33,6 +33,7 @@ const options = <JacksonOption>{
|
|||
};
|
||||
|
||||
const samlConfig = {
|
||||
name: 'testConfig',
|
||||
tenant: 'boxyhq.com',
|
||||
product: 'crm',
|
||||
redirectUrl: '["http://localhost:3366/*"]',
|
||||
|
|
File diff suppressed because it is too large
Load Diff
22
package.json
22
package.json
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "jackson",
|
||||
"version": "0.4.0",
|
||||
"version": "0.4.1",
|
||||
"private": true,
|
||||
"description": "SAML 2.0 service",
|
||||
"keywords": [
|
||||
|
@ -26,7 +26,10 @@
|
|||
"mariadb": "cross-env JACKSON_API_KEYS=secret DB_ENGINE=sql DB_TYPE=mariadb DB_URL=mariadb://root@localhost:3306/mysql npm run dev",
|
||||
"start": "next start -p 5225",
|
||||
"swagger-jsdoc": "swagger-jsdoc -d swagger/swaggerDefinition.js npm/src/**/*.ts -o swagger/swagger.json arg",
|
||||
"prepare": "husky install"
|
||||
"redis": "cross-env JACKSON_API_KEYS=secret DB_ENGINE=redis DB_TYPE=redis DB_URL=redis://localhost:6379/redis npm run dev",
|
||||
"prepare": "husky install",
|
||||
"pretest:e2e": "env-cmd -f .env.test.local ts-node --log-error e2e/seedAuthDb.ts",
|
||||
"test:e2e": "env-cmd -f .env.test.local playwright test"
|
||||
},
|
||||
"husky": {
|
||||
"hooks": {
|
||||
|
@ -39,30 +42,45 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@boxyhq/saml-jackson": "file:./npm",
|
||||
"@heroicons/react": "1.0.5",
|
||||
"@opentelemetry/api-metrics": "0.27.0",
|
||||
"@opentelemetry/exporter-metrics-otlp-http": "0.27.0",
|
||||
"@opentelemetry/sdk-metrics-base": "0.27.0",
|
||||
"@supabase/ui": "0.36.3",
|
||||
"cors": "2.8.5",
|
||||
"micromatch": "4.0.4",
|
||||
"next": "12.1.0",
|
||||
"next-auth": "4.1.2",
|
||||
"nodemailer": "6.7.2",
|
||||
"react": "17.0.2",
|
||||
"react-dom": "17.0.2",
|
||||
"swr": "1.2.1",
|
||||
"webpack-filter-warnings-plugin": "1.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@apidevtools/swagger-cli": "4.0.4",
|
||||
"@babel/plugin-proposal-decorators": "7.17.2",
|
||||
"@playwright/test": "1.18.1",
|
||||
"@types/cors": "2.8.12",
|
||||
"@types/micromatch": "4.0.2",
|
||||
"@types/node": "17.0.18",
|
||||
"@types/react": "17.0.39",
|
||||
"@typescript-eslint/eslint-plugin": "5.11.0",
|
||||
"@typescript-eslint/parser": "5.11.0",
|
||||
"autoprefixer": "10.4.2",
|
||||
"cross-env": "7.0.3",
|
||||
"env-cmd": "10.1.0",
|
||||
"eslint": "8.9.0",
|
||||
"eslint-config-next": "12.0.10",
|
||||
"eslint-config-prettier": "8.3.0",
|
||||
"husky": "7.0.4",
|
||||
"lint-staged": "12.3.4",
|
||||
"postcss": "8.4.6",
|
||||
"prettier": "2.5.1",
|
||||
"swagger-jsdoc": "6.1.0",
|
||||
"tailwindcss": "3.0.22",
|
||||
"ts-node": "10.5.0",
|
||||
"tsconfig-paths": "3.12.0",
|
||||
"typescript": "4.5.5"
|
||||
},
|
||||
"engines": {
|
||||
|
|
|
@ -1,8 +1,16 @@
|
|||
import '../styles/globals.css';
|
||||
import Layout from '@components/Layout';
|
||||
import { SessionProvider } from 'next-auth/react';
|
||||
import type { AppProps } from 'next/app';
|
||||
import '../styles/globals.css';
|
||||
|
||||
function MyApp({ Component, pageProps }: AppProps) {
|
||||
return <Component {...pageProps} />;
|
||||
function MyApp({ Component, pageProps: { session, ...pageProps } }: AppProps) {
|
||||
return (
|
||||
<SessionProvider session={session}>
|
||||
<Layout>
|
||||
<Component {...pageProps} />
|
||||
</Layout>
|
||||
</SessionProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default MyApp;
|
||||
|
|
|
@ -0,0 +1,17 @@
|
|||
import Document, { Html, Head, Main, NextScript } from 'next/document';
|
||||
|
||||
class MyDocument extends Document {
|
||||
render() {
|
||||
return (
|
||||
<Html lang='en' className='h-full'>
|
||||
<Head />
|
||||
<body className='antialiased theme-default h-full bg-white dark:bg-gray-900 selection:bg-primary/20 selection:text-secondary'>
|
||||
<Main />
|
||||
<NextScript />
|
||||
</body>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default MyDocument;
|
|
@ -0,0 +1,28 @@
|
|||
import { NextPage } from 'next';
|
||||
import useSWR from 'swr';
|
||||
import { fetcher } from '@lib/utils';
|
||||
import AddEdit from '@components/saml/AddEdit';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
const EditSAMLConfiguration: NextPage = () => {
|
||||
const router = useRouter();
|
||||
const { id } = router.query;
|
||||
const { data: samlConfig, error } = useSWR(`/api/admin/saml/config/${id}`, fetcher, {
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className='bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded'>
|
||||
{error.info ? JSON.stringify(error.info) : error.status}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!samlConfig) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
return <AddEdit samlConfig={samlConfig?.config} />;
|
||||
};
|
||||
|
||||
export default EditSAMLConfiguration;
|
|
@ -0,0 +1,85 @@
|
|||
import { NextPage } from 'next';
|
||||
import useSWR from 'swr';
|
||||
import { fetcher } from '@lib/utils';
|
||||
import Link from 'next/link';
|
||||
import { PencilAltIcon } from '@heroicons/react/outline';
|
||||
|
||||
const SAMLConfigurations: NextPage = () => {
|
||||
const { data, error } = useSWR('/api/admin/saml/config', fetcher, { revalidateOnFocus: false });
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className='bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded'>
|
||||
{error.info ? JSON.stringify(error.info) : error.status}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
if (!Array.isArray(data)) {
|
||||
return (
|
||||
<div>
|
||||
<div className='bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded'>Nothing to show</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className='flex items-center justify-between'>
|
||||
<h2 className='md:text-2xl text-primary dark:text-white font-bold'>SAML Configurations</h2>
|
||||
<Link href={'/admin/saml/config/new'}>
|
||||
<a className='btn-primary'>
|
||||
<span className='inline-block mr-1 md:mr-2' aria-hidden>
|
||||
+
|
||||
</span>
|
||||
Add Configuration
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
<div className='overflow-auto shadow-md rounded-lg mt-6'>
|
||||
<table className='min-w-full'>
|
||||
<thead className='bg-gray-50 dark:bg-gray-700 shadow-md sm:rounded-lg'>
|
||||
<tr>
|
||||
<th
|
||||
scope='col'
|
||||
className='py-3 px-6 text-xs font-medium tracking-wider text-left text-gray-700 uppercase dark:text-gray-400'>
|
||||
Tenant
|
||||
</th>
|
||||
<th
|
||||
scope='col'
|
||||
className='py-3 px-6 text-xs font-medium tracking-wider text-left text-gray-700 uppercase dark:text-gray-400'>
|
||||
Product
|
||||
</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((provider) => (
|
||||
<tr key={provider.clientID} className='bg-white border-b dark:bg-gray-800 dark:border-gray-700'>
|
||||
<td className='py-4 px-6 text-sm font-medium text-gray-900 whitespace-nowrap dark:text-white'>
|
||||
{provider.tenant}
|
||||
</td>
|
||||
<td className='py-4 px-6 text-sm text-gray-500 whitespace-nowrap dark:text-gray-400'>
|
||||
{provider.product}
|
||||
</td>
|
||||
<td>
|
||||
<Link href={`/admin/saml/config/edit/${provider.clientID}`}>
|
||||
<a className='link-primary'>
|
||||
<PencilAltIcon className='h-5 w-5 text-secondary' />
|
||||
</a>
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SAMLConfigurations;
|
|
@ -0,0 +1,8 @@
|
|||
import { NextPage } from 'next';
|
||||
import AddEdit from '@components/saml/AddEdit';
|
||||
|
||||
const NewSAMLConfiguration: NextPage = () => {
|
||||
return <AddEdit />;
|
||||
};
|
||||
|
||||
export default NewSAMLConfiguration;
|
|
@ -0,0 +1,40 @@
|
|||
import { NextApiRequest, NextApiResponse } from 'next';
|
||||
|
||||
import jackson from '@lib/jackson';
|
||||
import { extractAuthToken, validateApiKey } from '@lib/utils';
|
||||
import { checkSession } from '@lib/middleware';
|
||||
|
||||
export const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
try {
|
||||
const apiKey = extractAuthToken(req);
|
||||
if (!validateApiKey(apiKey)) {
|
||||
res.status(401).json({ message: 'Unauthorized' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { adminController, apiController } = await jackson();
|
||||
if (req.method === 'GET') {
|
||||
const { slug } = req.query;
|
||||
if (slug?.[0]) {
|
||||
res.json(await apiController.getConfig({ clientID: slug[0] }));
|
||||
} else {
|
||||
res.json(await adminController.getAllConfig());
|
||||
}
|
||||
} else if (req.method === 'POST') {
|
||||
res.json(await apiController.config(req.body));
|
||||
} else if (req.method === 'PATCH') {
|
||||
res.status(204).send(await apiController.updateConfig(req.body));
|
||||
} else if (req.method === 'DELETE') {
|
||||
res.json(await apiController.deleteConfig(req.body));
|
||||
} else {
|
||||
throw new Error('Method not allowed');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('config api error:', err);
|
||||
const { message, statusCode = 500 } = err;
|
||||
|
||||
res.status(statusCode).send(message);
|
||||
}
|
||||
};
|
||||
|
||||
export default checkSession(handler);
|
|
@ -0,0 +1,44 @@
|
|||
import Adapter from '@lib/nextAuthAdapter';
|
||||
import NextAuth from 'next-auth';
|
||||
import EmailProvider from 'next-auth/providers/email';
|
||||
import { validateEmailWithACL } from '@lib/utils';
|
||||
|
||||
export default NextAuth({
|
||||
debug: true,
|
||||
theme: {
|
||||
colorScheme: 'light',
|
||||
},
|
||||
providers: [
|
||||
EmailProvider({
|
||||
server: {
|
||||
host: process.env.SMTP_HOST,
|
||||
port: Number(process.env.SMTP_PORT),
|
||||
auth: {
|
||||
user: process.env.SMTP_USER,
|
||||
pass: process.env.SMTP_PASSWORD,
|
||||
},
|
||||
},
|
||||
from: process.env.SMTP_FROM,
|
||||
}),
|
||||
],
|
||||
session: {
|
||||
strategy: 'jwt',
|
||||
maxAge: 30 * 24 * 60 * 60,
|
||||
},
|
||||
jwt: {
|
||||
maxAge: 60 * 60 * 24 * 30,
|
||||
},
|
||||
secret: process.env.NEXTAUTH_SECRET,
|
||||
callbacks: {
|
||||
async signIn({ user }): Promise<boolean> {
|
||||
if (!user.email) {
|
||||
return false;
|
||||
}
|
||||
const email = user.email;
|
||||
return validateEmailWithACL(email);
|
||||
},
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
adapter: Adapter(),
|
||||
});
|
|
@ -15,6 +15,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||
res.json(await apiController.config(req.body));
|
||||
} else if (req.method === 'GET') {
|
||||
res.json(await apiController.getConfig(req.query as any));
|
||||
} else if (req.method === 'PATCH') {
|
||||
res.status(204).send(await apiController.updateConfig(req.body));
|
||||
} else if (req.method === 'DELETE') {
|
||||
res.status(204).end(await apiController.deleteConfig(req.body));
|
||||
} else {
|
||||
|
|
|
@ -1,64 +1,15 @@
|
|||
import type { NextPage } from 'next';
|
||||
import Head from 'next/head';
|
||||
import Image from 'next/image';
|
||||
import styles from '../styles/Home.module.css';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
const Home: NextPage = () => {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<Head>
|
||||
<title>Create Next App</title>
|
||||
<meta name='description' content='Generated by create next app' />
|
||||
<link rel='icon' href='/favicon.ico' />
|
||||
</Head>
|
||||
const router = useRouter();
|
||||
|
||||
<main className={styles.main}>
|
||||
<h1 className={styles.title}>
|
||||
Welcome to <a href='https://nextjs.org'>Next.js!</a>
|
||||
</h1>
|
||||
useEffect(() => {
|
||||
router.push('/admin/saml/config');
|
||||
}, [router]);
|
||||
|
||||
<p className={styles.description}>
|
||||
Get started by editing <code className={styles.code}>pages/index.tsx</code>
|
||||
</p>
|
||||
|
||||
<div className={styles.grid}>
|
||||
<a href='https://nextjs.org/docs' className={styles.card}>
|
||||
<h2>Documentation →</h2>
|
||||
<p>Find in-depth information about Next.js features and API.</p>
|
||||
</a>
|
||||
|
||||
<a href='https://nextjs.org/learn' className={styles.card}>
|
||||
<h2>Learn →</h2>
|
||||
<p>Learn about Next.js in an interactive course with quizzes!</p>
|
||||
</a>
|
||||
|
||||
<a href='https://github.com/vercel/next.js/tree/master/examples' className={styles.card}>
|
||||
<h2>Examples →</h2>
|
||||
<p>Discover and deploy boilerplate example Next.js projects.</p>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href='https://vercel.com/new?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app'
|
||||
className={styles.card}>
|
||||
<h2>Deploy →</h2>
|
||||
<p>Instantly deploy your Next.js site to a public URL with Vercel.</p>
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer className={styles.footer}>
|
||||
<a
|
||||
href='https://vercel.com?utm_source=create-next-app&utm_medium=default-template&utm_campaign=create-next-app'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'>
|
||||
Powered by{' '}
|
||||
<span className={styles.logo}>
|
||||
<Image src='/vercel.svg' alt='Vercel Logo' width={72} height={16} />
|
||||
</span>
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
return <p>Redirecting...</p>;
|
||||
};
|
||||
|
||||
export default Home;
|
||||
|
|
|
@ -0,0 +1,72 @@
|
|||
import { PlaywrightTestConfig, devices } from '@playwright/test';
|
||||
import path from 'path';
|
||||
|
||||
// Reference: https://playwright.dev/docs/test-configuration
|
||||
const config: PlaywrightTestConfig = {
|
||||
globalSetup: require.resolve('./e2e/globalSetup'),
|
||||
// Timeout per test
|
||||
timeout: 30 * 1000,
|
||||
// Test directory
|
||||
testDir: path.join(__dirname, 'e2e'),
|
||||
// If a test fails, retry it additional 2 times
|
||||
retries: 2,
|
||||
// Artifacts folder where screenshots, videos, and traces are stored.
|
||||
outputDir: 'test-results/',
|
||||
|
||||
// Run your local dev server before starting the tests:
|
||||
// https://playwright.dev/docs/test-advanced#launching-a-development-web-server-during-the-tests
|
||||
webServer: {
|
||||
command: process.env.CI ? 'npm run start' : 'npm run postgres',
|
||||
port: 5225,
|
||||
timeout: 60 * 1000,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
|
||||
use: {
|
||||
// Retry a test if its failing with enabled tracing. This allows you to analyse the DOM, console logs, network traffic etc.
|
||||
// More information: https://playwright.dev/docs/trace-viewer
|
||||
trace: 'retry-with-trace',
|
||||
storageState: './e2e/state.json',
|
||||
headless: !!process.env.CI,
|
||||
|
||||
// All available context options: https://playwright.dev/docs/api/class-browser#browser-new-context
|
||||
// contextOptions: {
|
||||
// ignoreHTTPSErrors: true,
|
||||
// },
|
||||
},
|
||||
|
||||
projects: [
|
||||
{
|
||||
name: 'Desktop Chrome',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
baseURL: 'http://localhost:5225',
|
||||
storageState: './e2e/state.json',
|
||||
},
|
||||
},
|
||||
// {
|
||||
// name: 'Desktop Firefox',
|
||||
// use: {
|
||||
// ...devices['Desktop Firefox'],
|
||||
// },
|
||||
// },
|
||||
// {
|
||||
// name: 'Desktop Safari',
|
||||
// use: {
|
||||
// ...devices['Desktop Safari'],
|
||||
// },
|
||||
// },
|
||||
// Test against mobile viewports.
|
||||
// {
|
||||
// name: 'Mobile Chrome',
|
||||
// use: {
|
||||
// ...devices['Pixel 5'],
|
||||
// },
|
||||
// },
|
||||
// {
|
||||
// name: 'Mobile Safari',
|
||||
// use: devices['iPhone 12'],
|
||||
// },
|
||||
],
|
||||
};
|
||||
export default config;
|
|
@ -0,0 +1,6 @@
|
|||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
Binary file not shown.
After Width: | Height: | Size: 16 KiB |
|
@ -1,116 +0,0 @@
|
|||
.container {
|
||||
padding: 0 2rem;
|
||||
}
|
||||
|
||||
.main {
|
||||
min-height: 100vh;
|
||||
padding: 4rem 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
padding: 2rem 0;
|
||||
border-top: 1px solid #eaeaea;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.footer a {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.title a {
|
||||
color: #0070f3;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.title a:hover,
|
||||
.title a:focus,
|
||||
.title a:active {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
line-height: 1.15;
|
||||
font-size: 4rem;
|
||||
}
|
||||
|
||||
.title,
|
||||
.description {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 4rem 0;
|
||||
line-height: 1.5;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.code {
|
||||
background: #fafafa;
|
||||
border-radius: 5px;
|
||||
padding: 0.75rem;
|
||||
font-size: 1.1rem;
|
||||
font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono,
|
||||
Bitstream Vera Sans Mono, Courier New, monospace;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.card {
|
||||
margin: 1rem;
|
||||
padding: 1.5rem;
|
||||
text-align: left;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
border: 1px solid #eaeaea;
|
||||
border-radius: 10px;
|
||||
transition: color 0.15s ease, border-color 0.15s ease;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.card:hover,
|
||||
.card:focus,
|
||||
.card:active {
|
||||
color: #0070f3;
|
||||
border-color: #0070f3;
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.card p {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 1em;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.grid {
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
|
@ -1,9 +1,17 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
color-scheme: dark light;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen,
|
||||
Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
|
||||
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, Ubuntu, Cantarell, Fira Sans,
|
||||
Droid Sans, Helvetica Neue, sans-serif;
|
||||
}
|
||||
|
||||
a {
|
||||
|
@ -14,3 +22,20 @@ a {
|
|||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.theme-default {
|
||||
--color-primary: 37 194 160;
|
||||
--color-secondary: 48 56 70;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
#__next {
|
||||
@apply h-full;
|
||||
}
|
||||
.btn-primary {
|
||||
@apply py-2 px-4 bg-primary/70 text-white font-semibold rounded-lg shadow-md hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-opacity-75;
|
||||
}
|
||||
.link-primary {
|
||||
@apply flex items-center px-4 py-2 mt-2 md:text-sm md:leading-6 text-secondary/90 font-medium hover:font-bold dark:text-gray-400 dark:hover:text-gray-300;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -48,11 +48,30 @@
|
|||
"application/x-www-form-urlencoded"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "name",
|
||||
"description": "Name/identifier for the config",
|
||||
"type": "string",
|
||||
"in": "formData",
|
||||
"example": "cal-saml-config"
|
||||
},
|
||||
{
|
||||
"name": "description",
|
||||
"description": "A short description for the config not more than 100 characters",
|
||||
"type": "string",
|
||||
"in": "formData",
|
||||
"example": "SAML login for cal.com app"
|
||||
},
|
||||
{
|
||||
"name": "encodedRawMetadata",
|
||||
"description": "Base64 encoding of the XML metadata",
|
||||
"in": "formData",
|
||||
"required": true,
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "rawMetadata",
|
||||
"description": "Raw XML metadata",
|
||||
"in": "formData",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
|
@ -107,6 +126,105 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Please provide rawMetadata or encodedRawMetadata | Please provide a defaultRedirectUrl | Please provide redirectUrl | Please provide tenant | Please provide product | Please provide a friendly name | Description should not exceed 100 characters"
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized"
|
||||
}
|
||||
}
|
||||
},
|
||||
"patch": {
|
||||
"summary": "Update SAML configuration",
|
||||
"operationId": "update-saml-config",
|
||||
"tags": [
|
||||
"SAML Config"
|
||||
],
|
||||
"consumes": [
|
||||
"application/json",
|
||||
"application/x-www-form-urlencoded"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "clientID",
|
||||
"description": "Client ID for the config",
|
||||
"type": "string",
|
||||
"in": "formData",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "clientSecret",
|
||||
"description": "Client Secret for the config",
|
||||
"type": "string",
|
||||
"in": "formData",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "name",
|
||||
"description": "Name/identifier for the config",
|
||||
"type": "string",
|
||||
"in": "formData",
|
||||
"example": "cal-saml-config"
|
||||
},
|
||||
{
|
||||
"name": "description",
|
||||
"description": "A short description for the config not more than 100 characters",
|
||||
"type": "string",
|
||||
"in": "formData",
|
||||
"example": "SAML login for cal.com app"
|
||||
},
|
||||
{
|
||||
"name": "encodedRawMetadata",
|
||||
"description": "Base64 encoding of the XML metadata",
|
||||
"in": "formData",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "rawMetadata",
|
||||
"description": "Raw XML metadata",
|
||||
"in": "formData",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "defaultRedirectUrl",
|
||||
"description": "The redirect URL to use in the IdP login flow",
|
||||
"in": "formData",
|
||||
"required": true,
|
||||
"type": "string",
|
||||
"example": "http://localhost:3000/login/saml"
|
||||
},
|
||||
{
|
||||
"name": "redirectUrl",
|
||||
"description": "JSON encoded array containing a list of allowed redirect URLs",
|
||||
"in": "formData",
|
||||
"required": true,
|
||||
"type": "string",
|
||||
"example": "[\"http://localhost:3000/*\"]"
|
||||
},
|
||||
{
|
||||
"name": "tenant",
|
||||
"description": "Tenant",
|
||||
"in": "formData",
|
||||
"required": true,
|
||||
"type": "string",
|
||||
"example": "boxyhq.com"
|
||||
},
|
||||
{
|
||||
"name": "product",
|
||||
"description": "Product",
|
||||
"in": "formData",
|
||||
"required": true,
|
||||
"type": "string",
|
||||
"example": "demo"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "Success"
|
||||
},
|
||||
"400": {
|
||||
"description": "Please provide clientID | Please provide clientSecret | clientSecret mismatch | Tenant/Product config mismatch with IdP metadata | Description should not exceed 100 characters"
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized"
|
||||
}
|
||||
|
@ -143,16 +261,39 @@
|
|||
"description": "Success",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"provider": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
"type": "accounts.google.com"
|
||||
"config": {
|
||||
"idpMetadata": {
|
||||
"sso": {
|
||||
"postUrl": "https://dev-20901260.okta.com/app/dev-20901260_jacksonnext_1/xxxxxxxxxxxxx/sso/saml",
|
||||
"redirectUrl": "https://dev-20901260.okta.com/app/dev-20901260_jacksonnext_1/xxxxxxxxxxxxx/sso/saml"
|
||||
},
|
||||
"entityID": "http://www.okta.com/xxxxxxxxxxxxx",
|
||||
"thumbprint": "Eo+eUi3UM3XIMkFFtdVK3yJ5vO9f7YZdasdasdad",
|
||||
"loginType": "idp",
|
||||
"provider": "okta.com"
|
||||
},
|
||||
"defaultRedirectUrl": "https://hoppscotch.io/",
|
||||
"redirectUrl": [
|
||||
"https://hoppscotch.io/"
|
||||
],
|
||||
"tenant": "hoppscotch.io",
|
||||
"product": "API Engine",
|
||||
"name": "Hoppscotch-SP",
|
||||
"description": "SP for hoppscotch.io",
|
||||
"clientID": "Xq8AJt3yYAxmXizsCWmUBDRiVP1iTC8Y/otnvFIMitk",
|
||||
"clientSecret": "00e3e11a3426f97d8000000738300009130cd45419c5943",
|
||||
"certs": {
|
||||
"publicKey": "-----BEGIN CERTIFICATE-----.......-----END CERTIFICATE-----",
|
||||
"privateKey": "-----BEGIN PRIVATE KEY-----......-----END PRIVATE KEY-----"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Please provide `clientID` or `tenant` and `product`."
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized"
|
||||
}
|
||||
|
@ -199,6 +340,9 @@
|
|||
"200": {
|
||||
"description": "Success"
|
||||
},
|
||||
"400": {
|
||||
"description": "clientSecret mismatch | Please provide `clientID` and `clientSecret` or `tenant` and `product`.'"
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized"
|
||||
}
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
const colors = require('tailwindcss/colors');
|
||||
|
||||
function withOpacityValue(variable) {
|
||||
return ({ opacityValue }) => {
|
||||
if (opacityValue === undefined) {
|
||||
return `rgb(var(${variable}))`;
|
||||
}
|
||||
return `rgb(var(${variable}) / ${opacityValue})`;
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
darkMode: 'class',
|
||||
content: ['./pages/**/*.{js,ts,jsx,tsx}', './components/**/*.{js,ts,jsx,tsx}'],
|
||||
theme: {
|
||||
colors: {
|
||||
...colors,
|
||||
primary: withOpacityValue('--color-primary'),
|
||||
secondary: withOpacityValue('--color-secondary'),
|
||||
},
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
|
@ -24,5 +24,11 @@
|
|||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "types/*.d.ts", "**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
"exclude": ["node_modules"],
|
||||
"ts-node": {
|
||||
"compilerOptions": {
|
||||
"module": "CommonJS"
|
||||
},
|
||||
"require": ["tsconfig-paths/register"]
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue