From 6403bfe7c27f912e1edadda03ee1462b737dbd2a Mon Sep 17 00:00:00 2001 From: Joshua Jones Date: Fri, 3 Dec 2021 16:13:58 -0800 Subject: [PATCH 001/285] Table component revisions. --- .../components/table/table.stories.mdx | 16 ++++++++++---- src/nextapp/components/table/table.tsx | 12 ++++++++--- .../pages/devportal/applications/index.tsx | 2 +- src/stories/Table.stories.mdx | 21 +++++++++++++++---- 4 files changed, 39 insertions(+), 12 deletions(-) diff --git a/src/nextapp/components/table/table.stories.mdx b/src/nextapp/components/table/table.stories.mdx index 4a6524931..9d20c4193 100644 --- a/src/nextapp/components/table/table.stories.mdx +++ b/src/nextapp/components/table/table.stories.mdx @@ -1,6 +1,9 @@ import { Box, Button, + Center, + Heading, + Text, Tbody, Tfoot, Tr, @@ -114,7 +117,12 @@ Use the `emptyView` prop to return a helpful empty data view for users. The comp ), data: [], - emptyView: (
Custom no content message
), + emptyView: ( +
+

Empty Table Content

+

You can customize the view with any composition you'd like.
It will be spaced inside these margins.

+
+ ), sortable: true }} > @@ -124,19 +132,19 @@ Use the `emptyView` prop to return a helpful empty data view for users. The comp ## Disclosure Rows -Use `React.Fragment` to pass in multiple rows so you can use UI methods like disclosure rows. +Use `React.Fragment` to pass in multiple rows so you can use UI methods like disclosure rows. Note that disclosed rows should have a background color of `#f6f6f6`. ( - + {d.name} {d.owner} - {d.id === '234' && Disclosed Content} + {d.id === '234' && Disclosed Content} ), sortable: true diff --git a/src/nextapp/components/table/table.tsx b/src/nextapp/components/table/table.tsx index 4f40b7bb8..182d332fd 100644 --- a/src/nextapp/components/table/table.tsx +++ b/src/nextapp/components/table/table.tsx @@ -20,7 +20,7 @@ interface Column extends TableColumnHeaderProps { } interface ApsTableProps { - children: (d: unknown, index: number) => React.ReactNode; + children: (d: unknown, index: number) => React.ReactElement; columns: Column[]; data: unknown[]; emptyView?: React.ReactNode; @@ -114,10 +114,16 @@ const ApsTable: React.FC = ({ {!data.length && ( - {emptyView} + + {emptyView} + )} - {sorted.map((d, index) => children(d, index))} + {sorted.map((d, index) => + React.cloneElement(children(d, index), { + key: uid(d), + }) + )} ); diff --git a/src/nextapp/pages/devportal/applications/index.tsx b/src/nextapp/pages/devportal/applications/index.tsx index 6c89240d4..67b65914c 100644 --- a/src/nextapp/pages/devportal/applications/index.tsx +++ b/src/nextapp/pages/devportal/applications/index.tsx @@ -140,7 +140,7 @@ const ApplicationsPage: React.FC< emptyView={empty} > {(d: Application) => ( - + {d.name} {d.appId} diff --git a/src/stories/Table.stories.mdx b/src/stories/Table.stories.mdx index c8495aeb4..131e6a97f 100644 --- a/src/stories/Table.stories.mdx +++ b/src/stories/Table.stories.mdx @@ -1,5 +1,6 @@ import { Button, + Icon, Table, Thead, Tbody, @@ -9,7 +10,7 @@ import { Td, TableCaption, } from '@chakra-ui/react'; -import { FaPlus } from 'react-icons/fa' +import { FaPen, FaPlus } from 'react-icons/fa' import { Meta, Story, Canvas } from '@storybook/addon-docs'; @@ -28,17 +29,29 @@ export const Template = (args) => ( Production Public - + + + Conformance Client Credentials - + + + Test Kong-API-Key-ACL - + + + From 2a41ee18f6394fbfab18e1969e6fc30b33ade1af Mon Sep 17 00:00:00 2001 From: jTendeck <34200068+jTendeck@users.noreply.github.com> Date: Mon, 6 Dec 2021 11:00:04 -0800 Subject: [PATCH 002/285] Updates Chromatic workflow file Chromatic builds will now be triggered only when a PR is made into dev from a component/** branch --- .github/workflows/chromatic.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/chromatic.yml b/.github/workflows/chromatic.yml index 6f37f6897..080551265 100644 --- a/.github/workflows/chromatic.yml +++ b/.github/workflows/chromatic.yml @@ -2,9 +2,6 @@ name: "Chromatic Deployment" # the event that will trigger the action on: - push: - branches: - - 'feature/**' pull_request: branches: - dev @@ -14,6 +11,7 @@ jobs: test: # the operating system it will run on runs-on: ubuntu-latest + if: startsWith(github.head_ref, 'component/') == true defaults: run: working-directory: ./src/ From 4d927e8400e530ff083c2eb21de6060c46c3f844 Mon Sep 17 00:00:00 2001 From: ikethecoder Date: Mon, 6 Dec 2021 14:54:51 -0800 Subject: [PATCH 003/285] Better handling of session to keep maintenance page working (#251) * upgrade to v7.2.0 oauth2 proxy in feature deploy * upd sample app for oauth2 proxy * adj the skip-auth-regex for feature branches * force signout if signin token expired * upd doc --- .github/workflows/ci-build-deploy.yaml | 4 +- oauth2-proxy/README.md | 35 ++++++-- oauth2-proxy/sample-upstream/index.js | 118 +++++++++++++++---------- src/auth/auth-oauth2-proxy.js | 95 ++++++++++++-------- 4 files changed, 161 insertions(+), 91 deletions(-) diff --git a/.github/workflows/ci-build-deploy.yaml b/.github/workflows/ci-build-deploy.yaml index a92eec37b..cc6d01244 100644 --- a/.github/workflows/ci-build-deploy.yaml +++ b/.github/workflows/ci-build-deploy.yaml @@ -167,14 +167,14 @@ jobs: oauthProxy: enabled: true image: - tag: v7.1.3 + tag: v7.2.0 config: upstream: http://127.0.0.1:3000 client-id: ${{ secrets.OIDC_CLIENT_ID }} client-secret: ${{ secrets.OIDC_CLIENT_SECRET }} oidc-issuer-url: ${{ secrets.OIDC_ISSUER }} redirect-url: https://api-services-portal-${{ steps.set-deploy-id.outputs.DEPLOY_ID }}.apps.silver.devops.gov.bc.ca/oauth2/callback - skip-auth-regex: '/health|/public|/docs|/redirect|/_next|/images|/devportal|/manager|/feed/|/ds/api|/signout|^[/]$' + skip-auth-regex: '/health|/public|/docs|/redirect|/_next|/images|/devportal|/manager|/about|/maintenance|/admin/session|/ds/api|/feed/|/signout|^[/]$' whitelist-domain: authz-apps-gov-bc-ca.dev.api.gov.bc.ca skip-provider-button: 'true' profile-url: ${{ secrets.OIDC_ISSUER }}/protocol/openid-connect/userinfo diff --git a/oauth2-proxy/README.md b/oauth2-proxy/README.md index d34ee2ed1..5354b8244 100644 --- a/oauth2-proxy/README.md +++ b/oauth2-proxy/README.md @@ -9,9 +9,14 @@ export OIDC_ISSUER="" hostip=$(ifconfig en0 | awk '$1 == "inet" {print $2}') docker run -ti --rm --name proxy -p 4180:4180 \ - quay.io/oauth2-proxy/oauth2-proxy \ + quay.io/oauth2-proxy/oauth2-proxy:v7.2.0 \ --http-address=0.0.0.0:4180 \ --cookie-secret=$COOKIE_SECRET \ + --cookie-secure=False \ + --cookie-refresh=1m \ + --cookie-expire=24h \ + --insecure-oidc-allow-unverified-email=true \ + --insecure-oidc-skip-issuer-verification=true \ --email-domain=* \ --provider=keycloak \ --client-id=${OIDC_CLIENT_ID} \ @@ -22,15 +27,13 @@ docker run -ti --rm --name proxy -p 4180:4180 \ --redeem-url="${OIDC_ISSUER}/protocol/openid-connect/token" \ --validate-url="${OIDC_ISSUER}/protocol/openid-connect/userinfo" \ --redirect-url="http://localhost:4180/oauth2/callback" \ - --cookie-secure=False \ - --cookie-refresh=15m \ --pass-basic-auth=false \ --pass-access-token=true \ --set-xauthrequest=true \ --skip-jwt-bearer-tokens=false \ --set-authorization-header=false \ --pass-authorization-header=false \ - --skip-auth-regex="/home|/public|/docs|/_next|/images|/devportal|/manager|/ds/api|/signout" \ + --skip-auth-regex="/health|/public|/docs|/redirect|/_next|/images|/devportal|/manager|/about|/maintenance|/admin/session|/ds/api|/feed/|/signout|^[/]$" \ --whitelist-domain="${OIDC_ISSUER_HOSTNAME}" \ --upstream="http://${hostip}:3000" ``` @@ -46,7 +49,7 @@ Alternate for unprotected the "/" root: ``` (cd sample-upstream && docker build --tag sample.local .) -docker run -ti --rm -p 9000:9000 \ +docker run -ti --rm -p 3000:9000 \ -e NODE_ENV=test \ -e SESSION_SECRET=s3cr3t \ -e JWKS_URL="${OIDC_ISSUER}/protocol/openid-connect/certs" \ @@ -54,3 +57,25 @@ docker run -ti --rm -p 9000:9000 \ ``` Go to: `http://localhost:4180/public` + +# Session Test Scenarios + +Two session cookies are maintained - one for Oauth2 Proxy and the other for KeystoneJS, so some logic is required to handle different scenarios. + +- `Oauth2 Proxy`: + - The Proxy refreshes the JWT Token based on the `cookie-refresh` time. If this is less than the Access Token Lifespan configured in the IdP then Keystone `auth-oauth2-proxy : [check-jwt-error]` will trigger, which clears the KeystoneJS session and returns a 401. If the cookie refresh is less, then it will continue to pass a valid JWT Token to KeystoneJS. +- `KeystoneJS`: + - `/admin/signin` : If there is an invalid token, then a forced signout occurs. Under normal circumstances a JWT Token should not become expired as the OAuth2 Proxy should be refreshing it + - `/admin/session`: Bypasses the OAuth2 Proxy auth handling and returns successfully as long as the JWT Token exists, the JWT Token has not expired, and the Subject in the JWT Token matches the Subject in the KeystoneJS Session. If there is no JWT Token (Oauth2 Proxy cookie expired) or there is a Subject mismatch, then the KeystoneJS Session is ended and a 401 response is sent (the frontend NextJS app will show a "Unauthorized" page if a 401 response is returned). If the JWT has expired, then a forced Signout (HTTP 302) is returned as this is a special case that can be considered "mis-configuration" rather than expected functionality. + +Scenarios: + +- New browser session, login +- Sitting on an unprotected page when the JWT Token expires and hasn't been refreshed by the OAuth2 Proxy +- Sitting on an protected page when the JWT Token expires and hasn't been refreshed by the OAuth2 Proxy +- Sitting on a unprotected page when switching namespace +- Sitting on a protected page when switching namespace +- Sitting on a protected page when the JWT Refresh limit is reached +- Sitting on a protected page when the 24 hr OAuth2 Proxy session expires +- Sitting on a protected page when the 24 hr KeystoneJS session expires +- Logout diff --git a/oauth2-proxy/sample-upstream/index.js b/oauth2-proxy/sample-upstream/index.js index 063085668..593b0bf85 100644 --- a/oauth2-proxy/sample-upstream/index.js +++ b/oauth2-proxy/sample-upstream/index.js @@ -1,67 +1,93 @@ -const express = require('express') -const session = require('express-session') -const app = express() -const port = 9000 +const express = require('express'); +const session = require('express-session'); +const app = express(); +const port = 9000; const jwt = require('express-jwt'); const jwksRsa = require('jwks-rsa'); -app.set('trust proxy', 1) // trust first proxy +app.set('trust proxy', 1); // trust first proxy -app.use(session({ +app.use( + session({ secret: process.env.SESSION_SECRET, resave: false, saveUninitialized: true, - cookie: { secure: process.env.NODE_ENV == 'production' } - })) + cookie: { secure: process.env.NODE_ENV == 'production' }, + }) +); const jwtCheck = jwksRsa.expressJwtSecret({ - cache: true, - rateLimit: true, - jwksRequestsPerMinute: 5, - jwksUri: process.env.JWKS_URL -}) + cache: true, + rateLimit: true, + jwksRequestsPerMinute: 5, + jwksUri: process.env.JWKS_URL, +}); -app.all('', jwt({ - secret: jwtCheck, - algorithms: ['RS256'], - credentialsRequired: false, - requestProperty: 'user', - getToken: (req) => ('x-forwarded-access-token' in req.headers) ? req.headers['x-forwarded-access-token'] : null -})) +app.all( + '', + jwt({ + secret: jwtCheck, + algorithms: ['RS256'], + credentialsRequired: false, + requestProperty: 'user', + getToken: (req) => + 'x-forwarded-access-token' in req.headers + ? req.headers['x-forwarded-access-token'] + : null, + }) +); app.all('', (req, res, next) => { - if ('user' in req.session && 'user' in req && req.session['user']['sub'] != req['user']['sid']) { - console.log("Detected different user! Invalid session") - req.session.regenerate((err) => { - req.session.user = req.user - next() - }) - return - } - if (!('user' in req.session) && 'user' in req) { - console.log(JSON.stringify(req.user, null, 4)) - console.log("New login, set session") - req.session.user = req.user - } - - next() + if ( + 'user' in req.session && + 'user' in req && + req.session['user']['sub'] != req['user']['sid'] + ) { + console.log('Detected different user! Invalid session'); + req.session.regenerate((err) => { + req.session.user = req.user; + next(); + }); + return; + } + if (!('user' in req.session) && 'user' in req) { + console.log(JSON.stringify(req.user, null, 4)); + console.log('New login, set session'); + req.session.user = req.user; + } + + next(); }); app.get('/public', (req, res) => { - if ('session' in req && 'user' in req.session) { - res.send(`Unprotected content. Hello ${req.session.user.name} (with email ${req.session.user.email}, username ${req.session.user.preferred_username}) in ${req.session.user.namespace}! Go to protected`) - } else { - res.send(`Unprotected content. Anonymous! Signin`) - } -}) + if ('session' in req && 'user' in req.session) { + res.send( + `Unprotected content. Hello ${req.session.user.name} (with email ${req.session.user.email}, username ${req.session.user.preferred_username}) in ${req.session.user.namespace}! Go to protected` + ); + } else { + res.send(`Unprotected content. Anonymous! Signin`); + } +}); app.get('/', (req, res) => { - res.send(`Hello ${req.session.user.name} (with email ${req.session.user.email}, username ${req.session.user.preferred_username}) in ${req.session.user.namespace}! Signout`) -}) + res.send( + `Hello ${req.session.user.name} (with email ${req.session.user.email}, username ${req.session.user.preferred_username}) in ${req.session.user.namespace}! Signout` + ); +}); + +app.get('/public/headers', (req, res) => { + res.setHeader('content-type', 'text/plain'); + res.send(JSON.stringify(req.headers, null, 5)); +}); + +app.get('/headers', (req, res) => { + res.setHeader('content-type', 'text/plain'); + res.send(JSON.stringify(req.headers, null, 5)); +}); app.listen(port, () => { - console.log(`Listening on port ${port}`) -}) + console.log(`Listening on port ${port}`); +}); -process.on('SIGINT', () => process.exit()) \ No newline at end of file +process.on('SIGINT', () => process.exit()); diff --git a/src/auth/auth-oauth2-proxy.js b/src/auth/auth-oauth2-proxy.js index d00eda876..f006fb055 100644 --- a/src/auth/auth-oauth2-proxy.js +++ b/src/auth/auth-oauth2-proxy.js @@ -58,6 +58,7 @@ class Oauth2ProxyAuthStrategy { } prepareMiddleware(app) { + const sessionManager = this._sessionManager; app = express(); app.set('trust proxy', true); @@ -80,55 +81,70 @@ class Oauth2ProxyAuthStrategy { }); // X-Auth-Request-Access-Token - const checkExpired = (err, req, res, next) => { - logger.debug('CHECK EXPIRED!! ' + err); + const checkExpired = async (err, req, res, next) => { + logger.debug('[check-jwt-error] ' + err); if (err) { + logger.error( + '[check-jwt-error] ending session - oauth2 proxy should be refreshing this token!' + ); + await sessionManager.endAuthedSession(req); + if (err.name === 'UnauthorizedError') { - logger.debug('CODE = ' + err.code); - logger.debug('INNER = ' + err.inner); - return res - .status(403) - .json({ error: 'unauthorized_provider_access' }); + logger.debug('[check-jwt-error] CODE = ' + err.code); + logger.debug('[check-jwt-error] INNER = ' + err.inner); } - return res.status(403).json({ error: 'unexpected_error' }); - } else { - next(); + res.status(401).json({ error: 'expired_token' }); + return; } + next(); }; - const detectSessionMismatch = async function (err, req, res, next) { - if (req.oauth_user) { - const jti = req['oauth_user']['jti']; // JWT ID - Unique Identifier for the token - logger.debug('SESSION USER = %j', req.user); - if (req.user) { - if (jti != req.user.jti) { - logger.warn('Looks like a different credential.. %s', jti); + const detectSessionMismatch = async function (req, res, next) { + // If there is a Keystone session, make sure it is not out of sync with the + // OAuth proxy session + if (req.user) { + if (req.oauth_user) { + if (req['oauth_user']['sub'] != req.user.sub) { logger.warn( - 'OK if subjects the same! %s %s', - req['oauth_user']['sub'], - req.user.sub + '[detect-session-mismatch] Different subject (%s) detected!', + req['oauth_user']['sub'] ); - if (req['oauth_user']['sub'] != req.user.sub) { - logger.warn('Subjects different too! Ending session.'); - await this._sessionManager.endAuthedSession(req); - return res.status(403).json({ error: 'invalid_session' }); + logger.warn('[detect-session-mismatch] ending session'); + await sessionManager.endAuthedSession(req); + return res.status(401).json({ error: 'invalid_session' }); + } else { + const jti = req['oauth_user']['jti']; // JWT ID - Unique Identifier for the token + if (jti != req.user.jti) { + logger.debug( + '[detect-session-mismatch] Refreshed credential %s', + jti + ); } } + } else { + logger.warn( + '[detect-session-mismatch] OAuth session ended - ending Keystone session 403' + ); + logger.warn('[detect-session-mismatch] ending session'); + await sessionManager.endAuthedSession(req); + return res.status(401).json({ error: 'proxy_session_expired' }); } } + next(); }; app.get( '/admin/session', - [detectSessionMismatch], + verifyJWT, + detectSessionMismatch, + checkExpired, async (req, res, next) => { const response = req && req.user ? { anonymous: false, user: req.user, maintenance: false } : { anonymous: true, maintenance: false }; if (response.anonymous == false) { - logger.debug('Session %j', response.user); response.user.groups = toJson(response.user.groups); response.user.roles = toJson(response.user.roles); response.user.scopes = toJson(response.user.scopes); @@ -138,22 +154,25 @@ class Oauth2ProxyAuthStrategy { } ); - app.get( - '/admin/signout', - [verifyJWT, checkExpired], - async (req, res, next) => { - if (req.user) { - await this._sessionManager.endAuthedSession(req); - } - res.redirect( - '/oauth2/sign_out?rd=' + querystring.escape(authLogoutUrl) - ); + app.get('/admin/signout', async (req, res, next) => { + if (req.user) { + await this._sessionManager.endAuthedSession(req); } - ); + res.redirect('/oauth2/sign_out?rd=' + querystring.escape(authLogoutUrl)); + }); app.get( '/admin/signin', - [verifyJWT, checkExpired], + [ + verifyJWT, + (err, req, res, next) => { + // If we are signing in and the token is not valid, then force a signout because + // the Oauth2 Proxy is not refreshing the token properly + if (err) { + res.redirect('/admin/signout'); + } + }, + ], async (req, res, next) => { await this.register_user(req, res, next); } From 1679d1508253ffa730f97bd9c63500086482f8b4 Mon Sep 17 00:00:00 2001 From: Joshua Jones Date: Mon, 6 Dec 2021 16:15:43 -0800 Subject: [PATCH 004/285] Use EmptyPane in empty view example --- src/nextapp/components/table/table.stories.mdx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/nextapp/components/table/table.stories.mdx b/src/nextapp/components/table/table.stories.mdx index 9d20c4193..b44d4d937 100644 --- a/src/nextapp/components/table/table.stories.mdx +++ b/src/nextapp/components/table/table.stories.mdx @@ -12,7 +12,8 @@ import { TableCaption } from '@chakra-ui/react'; import { Meta, Story, Canvas } from '@storybook/addon-docs'; -import {uid} from 'react-uid' +import { uid } from 'react-uid' +import EmptyPane from '../empty-pane'; import Table from './table'; @@ -118,10 +119,7 @@ Use the `emptyView` prop to return a helpful empty data view for users. The comp ), data: [], emptyView: ( -
-

Empty Table Content

-

You can customize the view with any composition you'd like.
It will be spaced inside these margins.

-
+ ), sortable: true }} From fa9e8a2f9a1140c697cda38c0ae126236af8a163 Mon Sep 17 00:00:00 2001 From: Joshua Jones Date: Tue, 7 Dec 2021 11:32:36 -0800 Subject: [PATCH 005/285] Fix select and focus states --- src/nextapp/shared/theme.ts | 24 +++++++++++------------- src/stories/Select.stories.tsx | 18 +++++++++++++++--- 2 files changed, 26 insertions(+), 16 deletions(-) diff --git a/src/nextapp/shared/theme.ts b/src/nextapp/shared/theme.ts index a503c9e15..37ef14527 100644 --- a/src/nextapp/shared/theme.ts +++ b/src/nextapp/shared/theme.ts @@ -1,6 +1,5 @@ -import { getServerSideProps } from '@/pages/devportal/access'; import { extendTheme, withDefaultVariant } from '@chakra-ui/react'; -import { mode, transparentize } from '@chakra-ui/theme-tools'; +import { transparentize } from '@chakra-ui/theme-tools'; const colors = { 'bc-blue': '#003366', @@ -63,7 +62,7 @@ const _valid = { borderColor: 'bc-success', }; -const getAlertStatusColor = (color) => { +const getAlertStatusColor = (color: string) => { switch (color) { case 'blue': return 'bc-light-blue'; @@ -79,24 +78,22 @@ const getAlertStatusColor = (color) => { }; const alertVariants = { - outline: (props) => { - const { colorScheme: c, theme: t } = props; - const color = getAlertStatusColor(c); + outline: ({ colorScheme, theme }) => { + const color = getAlertStatusColor(colorScheme); return { container: { paddingStart: 3, borderWidth: '1px', borderColor: color, - bg: transparentize(color, 0.1)(t), + bg: transparentize(color, 0.1)(theme), }, icon: { color: color, }, }; }, - status: (props) => { - const { colorScheme: c } = props; - const color = getAlertStatusColor(c); + status: ({ colorScheme }) => { + const color = getAlertStatusColor(colorScheme); return { container: { paddingStart: 3, @@ -158,8 +155,9 @@ const buttonVariants = { outlineColor: 'transparent', }, _focus: { + borderColor: 'bc-blue-alt', + boxShadow: 'lg', bgColor: '#F2F5F7', - boxShadow: 'none', outlineColor: 'transparent', }, }, @@ -297,10 +295,10 @@ const theme = extendTheme( '& > span': { display: 'none', }, - '& + div': { + '& + div.chakra-form__helper-text': { mb: 2, mt: -2, - color: 'component', + color: 'bc-component', }, }, }, diff --git a/src/stories/Select.stories.tsx b/src/stories/Select.stories.tsx index be7b46616..423b58f09 100644 --- a/src/stories/Select.stories.tsx +++ b/src/stories/Select.stories.tsx @@ -12,12 +12,21 @@ export default { title: 'BCGov/Dropdown', }; +// NOTE: The focus dropdown is for demonstration purpose only. See theme.ts for implementation export const Dropdown = () => ( + @@ -43,6 +52,9 @@ export const DropdownHelpText = () => ( export const DropdownErrorMessage = () => ( Dropdown with Error + + Looks like the helpful text was not read ;-) + + + + + + + ); +}; + +export default TagInput; From cc08c3692be8d91cddeaa6452e330630a6cc20db Mon Sep 17 00:00:00 2001 From: Joshua Jones Date: Thu, 9 Dec 2021 16:30:40 -0800 Subject: [PATCH 007/285] Finish tag input --- .../tag-input/tag-input.stories.mdx | 37 +++++++++++++++++-- .../components/tag-input/tag-input.tsx | 23 ++++++++---- src/nextapp/shared/theme.ts | 14 +++++-- 3 files changed, 60 insertions(+), 14 deletions(-) diff --git a/src/nextapp/components/tag-input/tag-input.stories.mdx b/src/nextapp/components/tag-input/tag-input.stories.mdx index 3414d9b5a..6d8c173de 100644 --- a/src/nextapp/components/tag-input/tag-input.stories.mdx +++ b/src/nextapp/components/tag-input/tag-input.stories.mdx @@ -5,9 +5,17 @@ import { import { Meta, Story, Canvas } from '@storybook/addon-docs'; import TagInput from './tag-input'; - + ## General Usage @@ -37,3 +45,26 @@ Though in most instances the API will be returning the stringified JSON array, a
+ +## Escape key clears the input value and blurs focus + +Hitting enter while focused in this input will prevent event bubbling to the form + + + + + + + +## Event propagation + +Hitting enter while focused in this input will prevent event bubbling to the form + + + +
console.log('submit')}> + + +
+
+ diff --git a/src/nextapp/components/tag-input/tag-input.tsx b/src/nextapp/components/tag-input/tag-input.tsx index 77b931f8f..4f5d232ee 100644 --- a/src/nextapp/components/tag-input/tag-input.tsx +++ b/src/nextapp/components/tag-input/tag-input.tsx @@ -22,7 +22,7 @@ interface TagInputProps extends InputProps { const TagInput: React.FC = ({ id, name, - placeholder = 'Press Enter to Add', + placeholder = 'Press enter to add', value = '', ...rest }) => { @@ -38,14 +38,20 @@ const TagInput: React.FC = ({ }); // Events - const handleKeyPress = React.useCallback( + const handleKeyDown = React.useCallback( (event: React.KeyboardEvent) => { - if (event.key === 'Enter') { - if (inputRef.current) { + if (inputRef.current) { + if (event.key === 'Enter') { + event.stopPropagation(); + event.preventDefault(); + setValues((state) => [...state, inputRef.current.value]); setTimeout(() => { inputRef.current.value = ''; }, 10); + } else if (event.key === 'Escape') { + inputRef.current.value = ''; + inputRef.current.blur(); } } }, @@ -77,9 +83,12 @@ const TagInput: React.FC = ({ <> @@ -94,11 +103,11 @@ const TagInput: React.FC = ({ diff --git a/src/nextapp/shared/theme.ts b/src/nextapp/shared/theme.ts index a503c9e15..819c9d0e0 100644 --- a/src/nextapp/shared/theme.ts +++ b/src/nextapp/shared/theme.ts @@ -48,9 +48,12 @@ const colors = { }, }; const _focus = { - outline: '4px solid', - outlineOffset: 1, - outlineColor: 'bc-border-focus', + // outline: '4px solid', + // outlineOffset: 1, + // outlineColor: 'bc-border-focus', + outline: 'none', + borderColor: 'bc-blue-alt', + boxShadow: 'lg', }; const _disabled = { opacity: 0.3, @@ -390,9 +393,12 @@ const theme = extendTheme( container: { borderRadius: 4, backgroundColor: '#E9F0F8', - borderColor: 'rgba(142, 142, 142, 0.35)', + borderColor: '#8E8E8E35', color: 'text', }, + closeButton: { + color: '#8E8E8E', + }, }, drag: { container: { From e51c1869408ce30b59fcbfaf5d3bf25902fae31a Mon Sep 17 00:00:00 2001 From: Joshua Jones Date: Fri, 10 Dec 2021 10:33:48 -0800 Subject: [PATCH 008/285] UI tweaks --- src/nextapp/components/tag-input/tag-input.stories.mdx | 2 +- src/nextapp/shared/theme.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/nextapp/components/tag-input/tag-input.stories.mdx b/src/nextapp/components/tag-input/tag-input.stories.mdx index 6d8c173de..4de959961 100644 --- a/src/nextapp/components/tag-input/tag-input.stories.mdx +++ b/src/nextapp/components/tag-input/tag-input.stories.mdx @@ -48,7 +48,7 @@ Though in most instances the API will be returning the stringified JSON array, a ## Escape key clears the input value and blurs focus -Hitting enter while focused in this input will prevent event bubbling to the form +Hitting escape when text is entered in the input will clear any non-tag values. diff --git a/src/nextapp/shared/theme.ts b/src/nextapp/shared/theme.ts index 819c9d0e0..468fd2f5e 100644 --- a/src/nextapp/shared/theme.ts +++ b/src/nextapp/shared/theme.ts @@ -393,11 +393,12 @@ const theme = extendTheme( container: { borderRadius: 4, backgroundColor: '#E9F0F8', - borderColor: '#8E8E8E35', + boxShadow: 'inset 0 0 0 1px rgba(142, 142, 142, 0.35)', color: 'text', }, closeButton: { - color: '#8E8E8E', + opacity: 1, + color: '#7C7C7C', }, }, drag: { From 6e18b886b05fc09c6ee690126f85896d8eae10fc Mon Sep 17 00:00:00 2001 From: Joshua Jones Date: Fri, 10 Dec 2021 11:55:27 -0800 Subject: [PATCH 009/285] UI tweaks to card and table --- src/nextapp/components/card/card.stories.mdx | 6 +++--- src/nextapp/components/card/card.tsx | 11 +++++++--- src/nextapp/shared/theme.ts | 8 ++++++++ src/stories/Table.stories.mdx | 21 ++++++++++++++++---- 4 files changed, 36 insertions(+), 10 deletions(-) diff --git a/src/nextapp/components/card/card.stories.mdx b/src/nextapp/components/card/card.stories.mdx index 9af971920..3981714b4 100644 --- a/src/nextapp/components/card/card.stories.mdx +++ b/src/nextapp/components/card/card.stories.mdx @@ -55,7 +55,7 @@ Headings most commonly can be passed in as strings, but for more complex compone - + @@ -113,7 +113,7 @@ Buttons can be passed into the `actions` prop, any group of components will be s } heading="All Activities" > - Content + Content @@ -129,7 +129,7 @@ The card component can accept all `Box` props. See [Box component documentation] bgColor="yellow.500" borderRadius={5} > - Content + Content diff --git a/src/nextapp/components/card/card.tsx b/src/nextapp/components/card/card.tsx index 5a4929231..8ce873949 100644 --- a/src/nextapp/components/card/card.tsx +++ b/src/nextapp/components/card/card.tsx @@ -10,7 +10,7 @@ import { interface CardProps extends BoxProps { actions?: React.ReactNode; - children: React.ReactNode; + children: JSX.Element | JSX.Element[]; heading?: React.ReactNode; } @@ -21,6 +21,11 @@ const Card: React.FC = ({ ...props }) => { const styles = useStyleConfig('Box'); + const hasTable = React.Children.toArray(children).some( + (c: JSX.Element) => c.type?.displayName === 'Table' + ); + const borderBottom = hasTable ? 'none' : '2px solid'; + const paddingBottom = hasTable ? 4 : 9; return ( @@ -28,10 +33,10 @@ const Card: React.FC = ({ {heading} diff --git a/src/nextapp/shared/theme.ts b/src/nextapp/shared/theme.ts index 37ef14527..92ee09270 100644 --- a/src/nextapp/shared/theme.ts +++ b/src/nextapp/shared/theme.ts @@ -302,6 +302,14 @@ const theme = extendTheme( }, }, }, + FormError: { + baseStyle: { + text: { + fontWeight: 'normal', + fontSize: 'md', + }, + }, + }, Menu: { baseStyle: { item: { diff --git a/src/stories/Table.stories.mdx b/src/stories/Table.stories.mdx index c8495aeb4..d83e5c81f 100644 --- a/src/stories/Table.stories.mdx +++ b/src/stories/Table.stories.mdx @@ -1,5 +1,6 @@ import { Button, + Icon, Table, Thead, Tbody, @@ -9,7 +10,7 @@ import { Td, TableCaption, } from '@chakra-ui/react'; -import { FaPlus } from 'react-icons/fa' +import { FaPen, FaPlus } from 'react-icons/fa' import { Meta, Story, Canvas } from '@storybook/addon-docs'; @@ -28,17 +29,29 @@ export const Template = (args) => ( Production Public - + + + Conformance Client Credentials - + + + Test Kong-API-Key-ACL - + + + From 203db2ceb786be80fcfd67ab244bc3cd1cfd9489 Mon Sep 17 00:00:00 2001 From: Joshua Jones Date: Fri, 10 Dec 2021 17:05:10 -0800 Subject: [PATCH 010/285] Add search input story --- .../search-input/search-input.stories.mdx | 32 +++++++++++++++++ .../components/search-input/search-input.tsx | 36 ++++++++++--------- 2 files changed, 51 insertions(+), 17 deletions(-) create mode 100644 src/nextapp/components/search-input/search-input.stories.mdx diff --git a/src/nextapp/components/search-input/search-input.stories.mdx b/src/nextapp/components/search-input/search-input.stories.mdx new file mode 100644 index 000000000..e825c87a6 --- /dev/null +++ b/src/nextapp/components/search-input/search-input.stories.mdx @@ -0,0 +1,32 @@ +import { + Box, +} from '@chakra-ui/react'; +import { Meta, Story, Canvas } from '@storybook/addon-docs'; +import SearchInput from './search-input'; + + + +# Search Input + +A simple wrapper to compose the search styled input. + + + + + + + +## With value + +This is a controlled component, so when there is a value property available, it will render a clear button. + + + + + + + + diff --git a/src/nextapp/components/search-input/search-input.tsx b/src/nextapp/components/search-input/search-input.tsx index 97dacd712..f4ff3f74f 100644 --- a/src/nextapp/components/search-input/search-input.tsx +++ b/src/nextapp/components/search-input/search-input.tsx @@ -3,11 +3,10 @@ import { Input, InputGroup, Icon, - InputLeftElement, InputRightElement, IconButton, } from '@chakra-ui/react'; -import { FaRegTimesCircle, FaSearch } from 'react-icons/fa'; +import { FaTimes, FaSearch } from 'react-icons/fa'; interface SearchInputProps { onChange: (value: string) => void; @@ -39,30 +38,33 @@ const SearchInput: React.FC = ({ return ( - - - - - - + {!value && } + {value && ( + + + + )} ); From b3224a8b99823aa7be0ac0bb3195b931b759485b Mon Sep 17 00:00:00 2001 From: Joshua Jones Date: Mon, 13 Dec 2021 13:02:24 -0800 Subject: [PATCH 011/285] Add better documentation, extend input props --- .../components/search-input/search-input.stories.mdx | 10 +++++++++- src/nextapp/components/search-input/search-input.tsx | 9 ++++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/nextapp/components/search-input/search-input.stories.mdx b/src/nextapp/components/search-input/search-input.stories.mdx index e825c87a6..350e5b288 100644 --- a/src/nextapp/components/search-input/search-input.stories.mdx +++ b/src/nextapp/components/search-input/search-input.stories.mdx @@ -19,13 +19,21 @@ A simple wrapper to compose the search styled input. +|Property|Type|Description| +|--------|----|-----------| +|`onChange`*|`(value: string) => void`|Event handler callback| +|`placeholder`|`string`|Any placeholder string. Defaults to `Search`| +|`value`*|`string`|This is a controlled component, so setting the value is required| + +Search input extends Chakra's `InputProps`, so any text input property can be passed, except `type` of course. + ## With value This is a controlled component, so when there is a value property available, it will render a clear button. - + diff --git a/src/nextapp/components/search-input/search-input.tsx b/src/nextapp/components/search-input/search-input.tsx index f4ff3f74f..a2787279b 100644 --- a/src/nextapp/components/search-input/search-input.tsx +++ b/src/nextapp/components/search-input/search-input.tsx @@ -5,10 +5,11 @@ import { Icon, InputRightElement, IconButton, + InputProps, } from '@chakra-ui/react'; import { FaTimes, FaSearch } from 'react-icons/fa'; -interface SearchInputProps { +interface SearchInputProps extends Omit { onChange: (value: string) => void; placeholder?: string; value: string; @@ -18,6 +19,7 @@ const SearchInput: React.FC = ({ onChange, placeholder = 'Search', value, + ...rest }) => { const ref = React.useRef(null); const handleChange = React.useCallback( @@ -41,12 +43,13 @@ const SearchInput: React.FC = ({ {!value && } From 7432173e06d3a285f180a7f2e6ff32293192c0af Mon Sep 17 00:00:00 2001 From: Joshua Jones Date: Mon, 13 Dec 2021 16:47:00 -0800 Subject: [PATCH 012/285] Add user profile and story --- src/nextapp/components/user-profile/index.ts | 1 + .../user-profile/user-profile.stories.mdx | 24 +++++++ .../components/user-profile/user-profile.tsx | 62 +++++++++++++++++++ 3 files changed, 87 insertions(+) create mode 100644 src/nextapp/components/user-profile/index.ts create mode 100644 src/nextapp/components/user-profile/user-profile.stories.mdx create mode 100644 src/nextapp/components/user-profile/user-profile.tsx diff --git a/src/nextapp/components/user-profile/index.ts b/src/nextapp/components/user-profile/index.ts new file mode 100644 index 000000000..f795d6564 --- /dev/null +++ b/src/nextapp/components/user-profile/index.ts @@ -0,0 +1 @@ +export { default } from './user-profile'; diff --git a/src/nextapp/components/user-profile/user-profile.stories.mdx b/src/nextapp/components/user-profile/user-profile.stories.mdx new file mode 100644 index 000000000..c59614ed2 --- /dev/null +++ b/src/nextapp/components/user-profile/user-profile.stories.mdx @@ -0,0 +1,24 @@ +import { Meta, Story, Canvas } from '@storybook/addon-docs'; +import UserProfile from './user-profile'; + + + +# User Profile + + + + + + + +## Loading state + + + + + + diff --git a/src/nextapp/components/user-profile/user-profile.tsx b/src/nextapp/components/user-profile/user-profile.tsx new file mode 100644 index 000000000..6b34cb1df --- /dev/null +++ b/src/nextapp/components/user-profile/user-profile.tsx @@ -0,0 +1,62 @@ +import * as React from 'react'; +import { + Avatar, + Box, + Flex, + Heading, + Skeleton, + SkeletonCircle, + Text, +} from '@chakra-ui/react'; +import { UserData } from '@/shared/types/app.types'; + +interface UserProfileProps { + data?: UserData; + isLoading?: boolean; +} + +const UserProfile: React.FC = ({ + data = {}, + isLoading = false, +}) => { + return ( + + + Administrator: + + + + + {isLoading && ( + <> + + + + + + + + )} + {!isLoading && ( + <> + + + {data.name} + + + + • + + {data.username} + + + {data.email} + + )} + + + + ); +}; + +export default UserProfile; From 7c0b80f4ef00215f19dde3ff73e9b24775242696 Mon Sep 17 00:00:00 2001 From: Joshua Jones Date: Tue, 14 Dec 2021 15:03:00 -0800 Subject: [PATCH 013/285] Add business profile --- .../business-profile-content.tsx | 93 +++++++++++++++++++ .../business-profile-loading.tsx | 13 --- .../business-profile.stories.mdx | 71 ++++++++++++++ .../business-profile/business-profile.tsx | 51 +--------- .../components/user-profile/user-profile.tsx | 6 +- 5 files changed, 170 insertions(+), 64 deletions(-) create mode 100644 src/nextapp/components/business-profile/business-profile-content.tsx delete mode 100644 src/nextapp/components/business-profile/business-profile-loading.tsx create mode 100644 src/nextapp/components/business-profile/business-profile.stories.mdx diff --git a/src/nextapp/components/business-profile/business-profile-content.tsx b/src/nextapp/components/business-profile/business-profile-content.tsx new file mode 100644 index 000000000..a8a82eb0d --- /dev/null +++ b/src/nextapp/components/business-profile/business-profile-content.tsx @@ -0,0 +1,93 @@ +import * as React from 'react'; +import { + Avatar, + Box, + Flex, + Heading, + Icon, + Skeleton, + Text, +} from '@chakra-ui/react'; +import { BusinessProfile } from '@/shared/types/query.types'; +import compact from 'lodash/compact'; +import { FaBuilding } from 'react-icons/fa'; + +interface BusinessProfileContentProps { + data?: BusinessProfile; + isLoading?: boolean; +} + +const BusinessProfileContent: React.FC = ({ + data = {}, + isLoading, +}) => { + const { addressItems, legalName } = React.useMemo(() => { + let legalName = ''; + let addressItems = ''; + + if (data?.institution) { + legalName = data.institution.legalName; + addressItems = compact([ + data.institution.address.addressLine1, + data.institution.address.addressLine2, + data.institution.address.city, + data.institution.address.postal, + ]).join(' '); + } + + return { + addressItems, + legalName, + }; + }, [data]); + + return ( + + + Business Profile: + + + + } + /> + + {isLoading && ( + <> + + + + + + )} + {!isLoading && ( + <> + + {legalName} + {data.institution?.isSuspended && ( + + (Suspended) + + )} + + {addressItems} + + )} + + + + ); +}; + +export default BusinessProfileContent; diff --git a/src/nextapp/components/business-profile/business-profile-loading.tsx b/src/nextapp/components/business-profile/business-profile-loading.tsx deleted file mode 100644 index 63d13777a..000000000 --- a/src/nextapp/components/business-profile/business-profile-loading.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import * as React from 'react'; -import { Box, SkeletonText } from '@chakra-ui/react'; - -const BusinessProfileLoading: React.FC = () => { - return ( - - - - - ); -}; - -export default BusinessProfileLoading; diff --git a/src/nextapp/components/business-profile/business-profile.stories.mdx b/src/nextapp/components/business-profile/business-profile.stories.mdx new file mode 100644 index 000000000..a02a3b426 --- /dev/null +++ b/src/nextapp/components/business-profile/business-profile.stories.mdx @@ -0,0 +1,71 @@ +import { Meta, Story, Canvas } from '@storybook/addon-docs'; +import BusinessProfileContent from './business-profile-content'; + + + +export const Template = (args) => ( + +); + +# Business Profile + +Displays the address information of a registered vendor. + + + + {Template.bind({})} + + + +## Suspended State + +Business profiles can be suspended + + + + {Template.bind({})} + + + +## Loading state + + + + + + + diff --git a/src/nextapp/components/business-profile/business-profile.tsx b/src/nextapp/components/business-profile/business-profile.tsx index 479abf0cc..7979d0a50 100644 --- a/src/nextapp/components/business-profile/business-profile.tsx +++ b/src/nextapp/components/business-profile/business-profile.tsx @@ -1,9 +1,8 @@ import * as React from 'react'; -import { Heading, Text } from '@chakra-ui/react'; import { gql } from 'graphql-request'; import { useApi } from '@/shared/services/api'; -import Loading from './business-profile-loading'; +import BusinessProfileContent from './business-profile-content'; interface BusinessProfileProps { serviceAccessId: string; @@ -12,7 +11,7 @@ interface BusinessProfileProps { const BusinessProfileComponent: React.FC = ({ serviceAccessId, }) => { - const { data } = useApi( + const { data, isLoading } = useApi( ['BusinessProfile', serviceAccessId], { query, @@ -21,43 +20,8 @@ const BusinessProfileComponent: React.FC = ({ { suspense: false } ); - if (!data) { - return ; - } - if ( - data.BusinessProfile == null || - data.BusinessProfile.institution == null - ) { - return <>; - } - const institution = data.BusinessProfile.institution; - const contact = data.BusinessProfile.user; return ( - <> - - Business Profile - - - {institution.businessTypeOther} - - {institution.legalName} - {institution.address.addressLine1} - {institution.address.addressLine2} - - {institution.address.city} {institution.address.province} - - {institution.address.postal} - {institution.address.country} - - - Contact - - {contact.displayName} - - {contact.surname}, {contact.firstname} - - {contact.email} - + ); }; @@ -66,16 +30,7 @@ export default BusinessProfileComponent; const query = gql` query GetBusinessProfile($serviceAccessId: ID!) { BusinessProfile(serviceAccessId: $serviceAccessId) { - user { - displayName - firstname - surname - email - isSuspended - isManagerDisabled - } institution { - type legalName address { addressLine1 diff --git a/src/nextapp/components/user-profile/user-profile.tsx b/src/nextapp/components/user-profile/user-profile.tsx index 6b34cb1df..b5541065c 100644 --- a/src/nextapp/components/user-profile/user-profile.tsx +++ b/src/nextapp/components/user-profile/user-profile.tsx @@ -30,11 +30,11 @@ const UserProfile: React.FC = ({ {isLoading && ( <> - + - + - + )} {!isLoading && ( From 513caf65b4828f7e55b7678f232de8f31837ad22 Mon Sep 17 00:00:00 2001 From: Joshua Jones Date: Tue, 14 Dec 2021 15:09:02 -0800 Subject: [PATCH 014/285] Fix chrome issue with search x --- src/nextapp/components/search-input/search-input.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/nextapp/components/search-input/search-input.tsx b/src/nextapp/components/search-input/search-input.tsx index a2787279b..648a13703 100644 --- a/src/nextapp/components/search-input/search-input.tsx +++ b/src/nextapp/components/search-input/search-input.tsx @@ -50,6 +50,11 @@ const SearchInput: React.FC = ({ onChange={handleChange} type="search" value={value} + sx={{ + '&::-webkit-search-cancel-button': { + display: 'none', + }, + }} /> {!value && } From 9607921d23594da4040dff871fa826cf67b2d74b Mon Sep 17 00:00:00 2001 From: Joshua Jones Date: Tue, 14 Dec 2021 15:23:28 -0800 Subject: [PATCH 015/285] Minor layout adjustments --- src/nextapp/components/card/card.stories.mdx | 4 ++-- src/nextapp/shared/theme.ts | 6 ++++++ src/stories/Textarea.stories.tsx | 1 + 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/nextapp/components/card/card.stories.mdx b/src/nextapp/components/card/card.stories.mdx index 3981714b4..7f2d676b7 100644 --- a/src/nextapp/components/card/card.stories.mdx +++ b/src/nextapp/components/card/card.stories.mdx @@ -113,7 +113,7 @@ Buttons can be passed into the `actions` prop, any group of components will be s } heading="All Activities" > - Content + Content @@ -129,7 +129,7 @@ The card component can accept all `Box` props. See [Box component documentation] bgColor="yellow.500" borderRadius={5} > - Content + Content diff --git a/src/nextapp/shared/theme.ts b/src/nextapp/shared/theme.ts index 92ee09270..a0da5ee7d 100644 --- a/src/nextapp/shared/theme.ts +++ b/src/nextapp/shared/theme.ts @@ -307,6 +307,12 @@ const theme = extendTheme( text: { fontWeight: 'normal', fontSize: 'md', + clear: 'both', + overflow: 'hidden', + + 'textarea + &': { + mt: 0, + }, }, }, }, diff --git a/src/stories/Textarea.stories.tsx b/src/stories/Textarea.stories.tsx index 001226081..ce5049fe9 100644 --- a/src/stories/Textarea.stories.tsx +++ b/src/stories/Textarea.stories.tsx @@ -38,6 +38,7 @@ export const WithHelperText = () => ( export const WithErrorMessage = () => ( Requirements + Helper text goes here