This commit is contained in:
pfych 2024-10-12 14:08:09 +11:00
commit 0029086b3f
148 changed files with 19047 additions and 0 deletions

View file

@ -0,0 +1,19 @@
module.exports = {
extends: [
'../../.eslintrc.js',
'plugin:react/recommended',
'plugin:react-hooks/recommended',
],
rules: {
'@typescript-eslint/no-unused-vars': 'warn',
'react/jsx-curly-brace-presence': [
'error',
{ props: 'never', children: 'never' },
],
},
settings: {
react: {
version: 'detect',
},
},
};

View file

@ -0,0 +1,8 @@
module.exports = {
semi: true,
singleQuote: true,
tabWidth: 2,
trailingComma: 'all',
arrowParens: 'always',
printWidth: 80,
};

View file

@ -0,0 +1,20 @@
{
"extends": [
"stylelint-config-sass-guidelines",
"stylelint-config-css-modules"
],
"plugins": ["stylelint-order"],
"configBaseDir": "./",
"customSyntax": "postcss-scss",
"rules": {
"order/properties-alphabetical-order": null,
"max-nesting-depth": null,
"selector-no-qualifying-type": null,
"selector-class-pattern": "(^[a-z]+([A-Z]|-)+\\w+$)+|(^([a-z]|-)+\\w+$)",
"scss/at-mixin-pattern": "(^[a-z]+([A-Z]|-)+\\w+$)+|(^([a-z]|-)+\\w+$)",
"scss/dollar-variable-pattern": "(^[a-z]+([A-Z]|-)+\\w+$)+|(^([a-z]|-)+\\w+$)",
"selector-max-compound-selectors": 8,
"scss/at-extend-no-missing-placeholder": null,
"at-rule-no-unknown": null
}
}

30
packages/admin/index.html Normal file
View file

@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Baseline Core</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="Baseline Core Admin portal" />
<link rel="icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="manifest.json" />
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.0/dist/css/bootstrap.min.css"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
<script>
var global = global || window;
var Buffer = Buffer || [];
var process = process || { env: { DEBUG: undefined }, version: [] };
</script>
</body>
</html>

View file

@ -0,0 +1,72 @@
{
"name": "@baseline/admin",
"version": "1.0.0",
"type": "commonjs",
"scripts": {
"deploy:staging": "./scripts/deploy.sh staging",
"remove:staging": ". ../../scripts/project-variables.sh && npx serverless remove --stage staging --region $REGION",
"deploy:prod": "./scripts/deploy.sh prod",
"remove:prod": ". ../../scripts/project-variables.sh && npx serverless remove --stage prod --region $REGION",
"aws:profile": "../../scripts/setup-aws-profile.sh",
"start": "npx vite",
"build": "pnpm run generate:env:staging && npx vite build",
"build:deploy": "npx vite build",
"preview": "npx vite preview",
"generate:env:local": "pnpm -w run generate:env:local",
"generate:env:staging": "pnpm -w run generate:env:staging",
"generate:env:prod": "pnpm -w run generate:env:prod",
"lint": "npx stylelint --config '.stylelintrc.json' 'src/**/*.scss' && npx eslint --config '.eslintrc.js' 'src/**/*.{ts,tsx,js}'",
"pretty": "npx prettier --write 'src/**/*.{ts,tsx,js,json,css,scss,md,yml,yaml,html}' && npx prettier --write 'public/**/*.{ts,tsx,js,json,css,scss,md,yml,yaml,html}' && npx prettier --write '*.{ts,tsx,js,json,css,scss,md,yml,yaml,html}'"
},
"dependencies": {
"@aws-amplify/ui-react": "6.1.6",
"@baseline/client-api": "workspace:1.0.0",
"@baseline/types": "workspace:1.0.0",
"@vitejs/plugin-react": "4.2.1",
"aws-amplify": "6.0.20",
"axios": "1.7.7",
"formik": "^2.4.6",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-helmet": "6.1.0",
"react-router-dom": "6.22.3",
"react-select": "5.8.0",
"reactstrap": "9.2.2",
"swr": "^2.2.5",
"typescript": "5.4.2",
"vite": "5.2.6",
"vite-plugin-environment": "1.1.3",
"yup": "^1.4.0"
},
"devDependencies": {
"@types/react": "18.2.67",
"@types/react-dom": "18.2.22",
"eslint-plugin-react": "7.34.0",
"eslint-plugin-react-hooks": "4.6.0",
"postcss": "8.4.35",
"postcss-scss": "4.0.9",
"prettier": "2.4.1",
"sass": "1.43.4",
"serverless": "3.38.0",
"serverless-baseline-invalidate-cloudfront": "0.1.1",
"serverless-s3-sync": "3.1.0",
"stylelint": "16.2.1",
"stylelint-config-css-modules": "4.4.0",
"stylelint-config-sass-guidelines": "11.1.0",
"stylelint-config-standard": "36.0.0",
"stylelint-config-standard-scss": "13.0.0",
"stylelint-order": "6.0.4"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 390 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 723 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -0,0 +1,7 @@
<svg id="Group_60" data-name="Group 60" xmlns="http://www.w3.org/2000/svg" width="24.748" height="21.723" viewBox="0 0 24.748 21.723">
<g id="ic_photo_camera_24px">
<circle cx="3.2" cy="3.2" r="3.2" transform="translate(5.299 5.216)" fill="#707070"/>
<path d="M7.949,2,6.394,3.7H3.7A1.7,1.7,0,0,0,2,5.4V15.6a1.7,1.7,0,0,0,1.7,1.7H17.3A1.7,1.7,0,0,0,19,15.6V5.4a1.7,1.7,0,0,0-1.7-1.7H14.6L13.048,2ZM10.5,14.748A4.249,4.249,0,1,1,14.748,10.5,4.251,4.251,0,0,1,10.5,14.748Z" transform="translate(-2 -2)" fill="#707070"/>
</g>
<path id="ic_audiotrack_24px" d="M11.192,3v8.03a3.8,3.8,0,0,0-1.3-.242,3.894,3.894,0,1,0,3.85,4.326h.043V5.6h3.461V3Z" transform="translate(7 2.649)" fill="#707070" stroke="#fff" stroke-width="1"/>
</svg>

After

Width:  |  Height:  |  Size: 744 B

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="19.4" height="20">
<path d="M19.43,12.98A7.793,7.793,0,0,0,19.5,12a7.793,7.793,0,0,0-.07-.98l2.11-1.65a.5.5,0,0,0,.12-.64l-2-3.46a.5.5,0,0,0-.61-.22l-2.49,1a7.306,7.306,0,0,0-1.69-.98l-.38-2.65A.488.488,0,0,0,14,2H10a.488.488,0,0,0-.49.42L9.13,5.07a7.683,7.683,0,0,0-1.69.98l-2.49-1a.488.488,0,0,0-.61.22l-2,3.46a.493.493,0,0,0,.12.64l2.11,1.65A7.931,7.931,0,0,0,4.5,12a7.931,7.931,0,0,0,.07.98L2.46,14.63a.5.5,0,0,0-.12.64l2,3.46a.5.5,0,0,0,.61.22l2.49-1a7.306,7.306,0,0,0,1.69.98l.38,2.65A.488.488,0,0,0,10,22h4a.488.488,0,0,0,.49-.42l.38-2.65a7.683,7.683,0,0,0,1.69-.98l2.49,1a.488.488,0,0,0,.61-.22l2-3.46a.5.5,0,0,0-.12-.64ZM12,15.5A3.5,3.5,0,1,1,15.5,12,3.5,3.5,0,0,1,12,15.5Z" transform="translate(-2.271 -2)" fill="#707070"/>
</svg>

After

Width:  |  Height:  |  Size: 790 B

View file

@ -0,0 +1,3 @@
<svg width="22" height="19" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<path fill="#707070" stroke="none" d="M 8.8 19 L 8.8 12.294117 L 13.2 12.294117 L 13.2 19 L 18.700001 19 L 18.700001 10.058824 L 22 10.058824 L 11 0 L 0 10.058824 L 3.3 10.058824 L 3.3 19 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 309 B

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated by Pixelmator Pro 2.4.6 -->
<svg width="14" height="14" viewBox="0 0 14 14" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<path id="iccreate24px-1" fill="#707070" stroke="none" d="M -0 11.086084 L -0 14 L 2.916162 14 L 11.520921 5.393497 L 8.604759 2.483365 Z M 13.772342 3.14162 C 13.918085 2.99629 14 2.798914 14 2.593074 C 14 2.387233 13.918085 2.189857 13.772342 2.044527 L 11.95305 0.227704 C 11.80775 0.081932 11.610414 0 11.404614 0 C 11.198815 0 11.00148 0.081932 10.856179 0.227704 L 9.433084 1.651088 L 12.349246 4.567841 L 13.772342 3.144457 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 656 B

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="15">
<path id="ic_video_label_24px" d="M17.316,3H2.632A1.628,1.628,0,0,0,1,4.615V15.922a1.628,1.628,0,0,0,1.632,1.615H17.316a1.628,1.628,0,0,0,1.632-1.615V4.615A1.628,1.628,0,0,0,17.316,3Zm0,10.5H2.632V4.615H17.316Z" transform="translate(-1 -3)" fill="#707070"/>
</svg>

After

Width:  |  Height:  |  Size: 331 B

View file

@ -0,0 +1,3 @@
<svg width="17" height="17" viewBox="0 0 17 17" xmlns="http://www.w3.org/2000/svg">
<path fill="#707070" d="M 16.684999 8.042999 L 8.949 0.308001 C 8.627602 -0.013262 8.191428 -0.193201 7.737 -0.191999 L 1.719 -0.191999 C 0.770762 -0.189255 0.002746 0.578762 0 1.527 L 0 7.545 C -0.000972 8.003402 0.181611 8.443114 0.507 8.766 L 8.244 16.503 C 8.565398 16.824263 9.001572 17.004202 9.456 17.003 C 9.912048 17.005405 10.349501 16.822411 10.668 16.496 L 16.684999 10.479 C 17.01141 10.160501 17.194405 9.723047 17.191999 9.267 C 17.191452 8.80802 17.009161 8.367934 16.684999 8.042999 Z M 3.009 4.108 C 2.297627 4.106895 1.721633 3.529708 1.722002 2.818335 C 1.72237 2.10696 2.298961 1.53037 3.010334 1.530002 C 3.721708 1.529634 4.298895 2.105627 4.3 2.816999 C 4.300799 3.159638 4.16504 3.488474 3.922757 3.730757 C 3.680474 3.97304 3.351639 4.108799 3.009 4.108 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 880 B

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
<path id="ic_account_circle_24px" d="M12,2A10,10,0,1,0,22,12,10,10,0,0,0,12,2Zm0,3A3,3,0,1,1,9,8,3,3,0,0,1,12,5Zm0,14.2a7.2,7.2,0,0,1-6-3.22c.03-1.99,4-3.08,6-3.08s5.97,1.09,6,3.08A7.2,7.2,0,0,1,12,19.2Z" transform="translate(-2 -2)" fill="#707070"/>
</svg>

After

Width:  |  Height:  |  Size: 344 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View file

@ -0,0 +1,25 @@
{
"short_name": "Baseline Core",
"name": "Baseline Core",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" id="svg839" version="1.1" height="1px" width="1px"><g><rect y="0" x="0" height="1px" width="1px" style="fill:#bababa;"/></g></svg>

After

Width:  |  Height:  |  Size: 171 B

View file

@ -0,0 +1,2 @@
User-agent: *
Disallow:

View file

@ -0,0 +1,18 @@
#!/usr/bin/env bash
CURRENT_DIR="$(pwd -P)"
PARENT_PATH="$(
cd "$(dirname "${BASH_SOURCE[0]}")" || exit
pwd -P
)/.."
cd "$PARENT_PATH" || exit
STAGE=$1
pnpm run generate:env:"$STAGE"
. ../../scripts/project-variables.sh
. ../../scripts/get-stack-outputs.sh "$STAGE" >/dev/null
pnpm run build:deploy
npx serverless deploy --verbose --stage "$STAGE" --region "$REGION"
cd "$CURRENT_DIR" || exit

View file

@ -0,0 +1,177 @@
service: ${env:APP_NAME}-admin
frameworkVersion: '>=2.0.0 <4.0.0'
plugins:
- serverless-s3-sync
- serverless-baseline-invalidate-cloudfront
custom:
s3Sync:
- bucketNameKey: S3Bucket
localDir: .dist/
cloudfrontInvalidate:
- distributionIdKey: 'CDNDistributionId'
items:
- '/*'
# domain:
# local: "local-admin.baselinejs.com"
# staging: "staging-admin.baselinejs.com"
# prod: "admin.baselinejs.com"
provider:
name: aws
runtime: nodejs20.x
profile: ${env:AWS_PROFILE}
stage: ${opt:stage}
deploymentMethod: direct
deploymentPrefix: ${self:service}-${sls:stage}
stackTags:
AppName: ${env:APP_NAME}
Stage: ${opt:stage}
Region: ${opt:region}
Product: Baseline
# The "Resources" your "Functions" use. Raw AWS CloudFormation goes in here.
resources:
Description: ${env:APP_NAME} ${opt:stage}
Resources:
## Specifying the S3 Bucket
WebsiteS3Bucket:
Type: AWS::S3::Bucket
Properties:
OwnershipControls:
Rules:
- ObjectOwnership: BucketOwnerPreferred
PublicAccessBlockConfiguration:
BlockPublicAcls: false
BlockPublicPolicy: false
IgnorePublicAcls: false
RestrictPublicBuckets: false
## Specifying the policies to make sure all files inside the Bucket are available to CloudFront
WebsiteS3BucketPolicy:
Type: AWS::S3::BucketPolicy
Properties:
Bucket:
Ref: WebsiteS3Bucket
PolicyDocument:
Statement:
- Sid: PublicReadGetObject
Effect: Allow
Principal:
Service: cloudfront.amazonaws.com
Action:
- s3:GetObject
Resource: !Join
- ''
- - 'arn:aws:s3:::'
- !Ref WebsiteS3Bucket
- /*
Condition:
StringEquals:
AWS:SourceArn:
!Join [
'',
[
'arn:aws:cloudfront::',
!Ref AWS::AccountId,
':distribution/',
!Ref WebsiteCloudFrontDistribution,
],
]
CloudfrontResponsePolicy:
Type: AWS::CloudFront::ResponseHeadersPolicy
Properties:
ResponseHeadersPolicyConfig:
Name: ${self:service}-${sls:stage}-no-cache-headers
CustomHeadersConfig:
Items:
- Header: 'Cache-Control'
Override: true
Value: 'no-cache'
# OAC Role for the Cloudfront distribution to block direct S3 Access
WebsiteCloudFrontDistributionOriginAccessControl:
Type: AWS::CloudFront::OriginAccessControl
Properties:
OriginAccessControlConfig:
Name: ${self:service}-${sls:stage}-cloudfront-oac
OriginAccessControlOriginType: s3
SigningBehavior: always
SigningProtocol: sigv4
## Specifying the CloudFront Distribution to serve your Web Application
WebsiteCloudFrontDistribution:
Type: AWS::CloudFront::Distribution
Properties:
DistributionConfig:
HttpVersion: http2
Origins:
- DomainName: !GetAtt WebsiteS3Bucket.RegionalDomainName
## An identifier for the origin which must be unique within the distribution
Id: !GetAtt WebsiteS3Bucket.RegionalDomainName
OriginAccessControlId: !Ref WebsiteCloudFrontDistributionOriginAccessControl
S3OriginConfig:
OriginAccessIdentity: ''
Enabled: true
## [Custom Domain] Add the domain alias
# Aliases:
# - ${self:custom.domain.${opt:stage}}
DefaultRootObject: 'index.html'
## Since the Single Page App is taking care of the routing we need to make sure ever path is served with index.html
## The only exception are files that actually exist e.h. app.js, reset.css
CustomErrorResponses:
- ErrorCode: 403
ResponseCode: 200
ResponsePagePath: '/index.html'
DefaultCacheBehavior:
AllowedMethods:
- GET
- HEAD
CachedMethods:
- HEAD
- GET
Compress: true
DefaultTTL: 1800
MinTTL: 0
## The origin id defined above
TargetOriginId: !GetAtt WebsiteS3Bucket.RegionalDomainName
## Defining if and how the QueryString and Cookies are forwarded to the origin which in this case is S3
ForwardedValues:
QueryString: false
Cookies:
Forward: none
## The protocol that users can use to access the files in the origin. To allow HTTP use `allow-all`
ViewerProtocolPolicy: redirect-to-https
CachePolicyId: 658327ea-f89d-4fab-a63d-7e88639e58f6
ResponseHeadersPolicyId: !Ref CloudfrontResponsePolicy
## The certificate to use when viewers use HTTPS to request objects.
ViewerCertificate:
CloudFrontDefaultCertificate: true
## [Custom Domain] Stop using the cloudfront default certificate, uncomment below and add ACM Certificate ARN
# MinimumProtocolVersion: TLSv1.2_2021
# SslSupportMethod: sni-only
# AcmCertificateArn: arn:aws:acm:us-east-1:xxxxxxxxxxxx:certificate/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx # ARN of the AWS certificate
## Uncomment the following section in case you want to enable logging for CloudFront requests
# Logging:
# IncludeCookies: 'false'
# Bucket: mylogs.s3.amazonaws.com
# Prefix: myprefix
## In order to print out the hosted domain via `serverless info` we need to define the DomainName output for CloudFormation
Outputs:
AdminCloudFrontUrl:
Description: The Admin URL
Value:
'Fn::GetAtt': [WebsiteCloudFrontDistribution, DomainName]
AdminCloudFrontDistributionId:
Description: CloudFront Distribution Id
Value:
Ref: WebsiteCloudFrontDistribution
CDNDistributionId:
Description: CloudFront Distribution Id for serverless-cloudfront-invalidate
Value:
Ref: WebsiteCloudFrontDistribution
S3Bucket:
Description: S3 Bucket
Value:
Ref: WebsiteS3Bucket

132
packages/admin/src/App.tsx Normal file
View file

@ -0,0 +1,132 @@
import React, { useEffect } from 'react';
import { Amplify } from 'aws-amplify';
import { fetchAuthSession } from 'aws-amplify/auth';
import { Hub } from 'aws-amplify/utils';
import { checkAdmin } from '@baseline/client-api/admin';
import {
Outlet,
RouterProvider,
createBrowserRouter,
redirect,
} from 'react-router-dom';
import '@aws-amplify/ui-react/styles.css';
import Dashboard from './baseblocks/dashboard/pages/Dashboard';
import User, { userLoader } from './baseblocks/user/pages/User';
import Admins, { adminListLoader } from './baseblocks/admin/pages/Admins';
import {
createRequestHandler,
getRequestHandler,
} from '@baseline/client-api/request-handler';
import { AxiosRequestConfig } from 'axios';
import Home from './baseblocks/home/pages/Home';
import Login from './baseblocks/login/pages/Login';
import NotAdmin from './baseblocks/not-admin/pages/NotAdmin';
import Layout from './components/layout/Layout';
import Loader from './components/page-content/loader/Loader';
import Charts from './baseblocks/chart/pages/charts';
import Chart from './baseblocks/chart/pages/Chart';
Amplify.configure({
Auth: {
Cognito: {
signUpVerificationMethod: 'code',
identityPoolId: process.env.REACT_APP_COGNITO_IDENTITY_POOL_ID || '',
userPoolId: process.env.REACT_APP_COGNITO_USER_POOL_ID || '',
userPoolClientId:
process.env.REACT_APP_COGNITO_USER_POOL_WEB_CLIENT_ID || '',
},
},
});
export default function App() {
useEffect(() => {
return Hub.listen('auth', (data) => {
console.debug('auth event', data.payload.event);
switch (data.payload.event) {
case 'signedIn':
router.navigate('/dashboard').catch((e) => console.error(e));
break;
case 'signedOut':
router.navigate('/').catch((e) => console.error(e));
break;
case 'signInWithRedirect_failure':
break;
case 'tokenRefresh':
break;
default:
console.debug(`Unhandled event: ${data.payload.event}`);
}
});
}, []);
return (
<RouterProvider
router={router}
fallbackElement={<Loader hasStartedLoading={true} />}
/>
);
}
async function protectedLoader() {
console.debug('protected loader');
if (!getRequestHandler()) {
console.debug('creating request handler');
createRequestHandler(
async (config: AxiosRequestConfig): Promise<AxiosRequestConfig> => {
const authSession = await fetchAuthSession();
if (!config.headers) config.headers = {};
config.headers.Authorization = `Bearer ${authSession?.tokens?.idToken}`;
return config;
},
);
}
const authSession = await fetchAuthSession();
if (!authSession?.tokens?.idToken) {
return redirect('/login');
}
const isAdmin = await checkAdmin(getRequestHandler());
if (!isAdmin) {
return redirect('/not-admin');
}
return null;
}
async function loginLoader() {
console.debug('login loader');
const authSession = await fetchAuthSession();
if (authSession?.tokens?.idToken) {
console.debug('redirecting to dashboard');
return redirect('/dashboard');
}
return null;
}
const router = createBrowserRouter([
{
id: 'public',
path: '/',
Component: Outlet,
children: [
{ path: '/', Component: Home, index: true },
{ path: '/not-admin', Component: NotAdmin },
{ path: '/login', Component: Login, loader: loginLoader },
],
},
{
id: 'protected',
path: '/',
Component: Layout,
loader: protectedLoader,
children: [
{ path: '/dashboard', Component: Dashboard },
{
path: '/admins',
Component: Admins,
loader: adminListLoader,
},
{ path: '/settings', Component: User, loader: userLoader },
{ path: '/chart', Component: Charts },
{ path: '/chart/:chartId', Component: Chart },
],
},
]);

View file

@ -0,0 +1,12 @@
@use '../../../../styles/global';
.addUser,
.addUserModal {
.addUserButton {
@include global.smallFont();
padding: 6px 12px;
background: unset;
border: 1px solid #bababa;
}
}

View file

@ -0,0 +1,74 @@
import { createAdmin } from '@baseline/client-api/admin';
import React, { useState } from 'react';
import {
FormGroup,
Input,
Label,
Modal,
ModalBody,
ModalFooter,
ModalHeader,
} from 'reactstrap';
import { getRequestHandler } from '@baseline/client-api/request-handler';
import styles from './AddAdmin.module.scss';
import { Admin } from '@baseline/types/admin';
interface Props {
setAllAdmins: React.Dispatch<React.SetStateAction<Admin[]>>;
}
const AddAdmin = (props: Props) => {
const { setAllAdmins } = props;
const [newEmail, setNewEmail] = useState('');
const [isModalOpen, setIsModalOpen] = useState(false);
const toggle = () => {
setNewEmail('');
setIsModalOpen((open) => !open);
};
const addUser = async (): Promise<void> => {
const newAdmin = await createAdmin(getRequestHandler(), {
userEmail: newEmail,
});
setAllAdmins((admins) => [...admins, newAdmin]);
toggle();
};
return (
<div className={styles.addUser}>
<button className={styles.addUserButton} onClick={toggle}>
Invite
</button>
<Modal
className={styles.addUserModal}
isOpen={isModalOpen}
toggle={toggle}
centered
>
<ModalHeader toggle={toggle}>Add Admin</ModalHeader>
<ModalBody>
<FormGroup>
<Label>Email</Label>
<Input
onChange={(e) => setNewEmail(e.target.value)}
value={newEmail}
/>
</FormGroup>
</ModalBody>
<ModalFooter>
<button
disabled={!newEmail}
className={styles.addUserButton}
onClick={() => {
void addUser();
}}
>
Add
</button>
</ModalFooter>
</Modal>
</div>
);
};
export default AddAdmin;

View file

@ -0,0 +1,69 @@
@use '../../../../styles/global';
.userList {
.admin,
.header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
min-width: 0;
padding: 18px 48px;
overflow: hidden;
background: #fff;
border: 1px solid #bababa;
@media screen and (max-width: global.$lg) {
padding: 12px;
}
&:not(:last-of-type) {
border-bottom: unset;
}
.userCount {
@include global.smallFont();
margin-right: 8px;
}
.info {
flex: 1 1 auto;
min-width: 0;
.details {
margin-bottom: 8px;
.name,
.data {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.name {
@include global.largeFont();
margin-bottom: 8px;
font-weight: bold;
}
.data {
@include global.mediumFont();
color: #707070;
}
}
.pill {
@include global.smallFont();
width: min-content;
padding: 6px 18px;
border: 1px solid #bababa;
border-radius: 10px;
}
}
.buttons {
flex: 0 0 auto;
min-width: min-content;
}
}
}

View file

@ -0,0 +1,56 @@
import React, { useState } from 'react';
import { deleteAdmin } from '@baseline/client-api/admin';
import ConfirmDelete from '../../../../components/confirm-delete/ConfirmDelete';
import AddUser from '../add-admin/AddAdmin';
import styles from './AdminList.module.scss';
import { getRequestHandler } from '@baseline/client-api/request-handler';
import { Admin } from '@baseline/types/admin';
interface Props {
admins: Admin[];
}
const AdminList = (props: Props): JSX.Element => {
const [allAdmins, setAllAdmins] = useState<Admin[]>(props?.admins || []);
const handleDelete = async (adminSub: string): Promise<void> => {
await deleteAdmin(getRequestHandler(), { adminId: adminSub });
setAllAdmins((admins) =>
admins.filter((admin) => admin.userSub !== adminSub),
);
};
return (
<div className={styles.userList}>
<div className={styles.list}>
<div className={styles.header}>
<div className={styles.userCount}>
There are {allAdmins.length} people in your team
</div>
<AddUser setAllAdmins={setAllAdmins} />
</div>
{allAdmins.map((admin) => (
<div key={admin.userSub} className={styles.admin}>
<div className={styles.info}>
<div className={styles.details}>
<div className={styles.name}>{admin.userEmail}</div>
<div className={styles.data}>{admin.userSub}</div>
</div>
<div className={styles.pill}>Admin</div>
</div>
<div className={styles.buttons}>
<ConfirmDelete
itemName={admin.userEmail}
deleteFunction={async () => {
await handleDelete(admin.userSub);
}}
/>
</div>
</div>
))}
</div>
</div>
);
};
export default AdminList;

View file

@ -0,0 +1,26 @@
import React from 'react';
import { Admin } from '@baseline/types/admin';
import AdminList from '../components/admin-list/AdminList';
import { useLoaderData } from 'react-router-dom';
import { getAllAdmins } from '@baseline/client-api/admin';
import { getRequestHandler } from '@baseline/client-api/request-handler';
import PageContent from '../../../components/page-content/PageContent';
export async function adminListLoader() {
const admins = await getAllAdmins(getRequestHandler());
return {
admins: admins,
};
}
const Admins = (): JSX.Element => {
const { admins } = useLoaderData() as { admins: Admin[] };
return (
<PageContent>
<AdminList admins={admins} />
</PageContent>
);
};
export default Admins;

View file

@ -0,0 +1,7 @@
.chartList {
.header {
display: flex;
justify-content: space-between;
align-items: center;
}
}

View file

@ -0,0 +1,35 @@
import React from 'react';
import styles from './ChartDetail.module.scss';
import { useChart } from '../../../../swr/chart';
import Loader from '../../../../components/page-content/loader/Loader';
import EditChartModal from '../edit-chart-modal/EditChartModal';
interface Props {
chartId: string;
}
const ChartDetail = (props: Props): JSX.Element => {
const { chartId } = props;
const { chart, isLoading, error } = useChart(chartId);
if (isLoading) {
return <Loader hasStartedLoading={true} isLoading={true} />;
}
if (error) {
return <div>{JSON.stringify(error)}</div>;
}
return (
<div className={styles.chartList}>
<div className={styles.header}>
<h1>Charts</h1>
<EditChartModal chart={chart} />
</div>
<hr />
<pre>{JSON.stringify(chart, null, 2)}</pre>
</div>
);
};
export default ChartDetail;

View file

@ -0,0 +1,7 @@
.chartList {
.header {
display: flex;
justify-content: space-between;
align-items: center;
}
}

View file

@ -0,0 +1,32 @@
import React from 'react';
import styles from './ChartList.module.scss';
import CreateChartModal from '../create-chart-modal/CreateChartModal';
import { useCharts } from '../../../../swr/chart';
import Loader from '../../../../components/page-content/loader/Loader';
const ChartList = (): JSX.Element => {
const { charts, isLoading } = useCharts();
if (isLoading) {
return <Loader hasStartedLoading={true} isLoading={true} />;
}
return (
<div className={styles.chartList}>
<div className={styles.header}>
<h1>Charts</h1>
<CreateChartModal />
</div>
<hr />
<ul>
{charts.map((chart) => (
<li key={chart.chartId}>
<a href={`/chart/${chart.chartId}`}>{chart.name}</a>
</li>
))}
</ul>
</div>
);
};
export default ChartList;

View file

@ -0,0 +1,183 @@
import React, { useMemo, useState } from 'react';
import { Chart, chartSchema } from '@baseline/types/chart';
import { Formik } from 'formik';
import * as Yup from 'yup';
import {
Button,
FormFeedback,
FormGroup,
Input,
Label,
Modal,
ModalBody,
ModalFooter,
ModalHeader,
} from 'reactstrap';
import { createChart } from '@baseline/client-api/chart';
import { onChartCreated } from '../../../../swr/chart';
const CreateChartModal = (): JSX.Element => {
const [isOpen, setIsOpen] = useState(false);
const toggle = () => setIsOpen(!isOpen);
const initialValues: Chart = useMemo(
() => ({
chartId: undefined,
parentChart: 'NO_PARENT',
name: '',
md5: '',
sha256: '',
resourceUri: '',
comment: '',
}),
[],
);
return (
<Formik
initialValues={initialValues}
enableReinitialize
validationSchema={chartSchema}
onSubmit={async (values, { setSubmitting, resetForm }) => {
setSubmitting(true);
const newChart = await createChart(values);
await onChartCreated(newChart);
resetForm();
setIsOpen(false);
}}
>
{({
values,
handleSubmit,
isValid,
isSubmitting,
resetForm,
errors,
setFieldValue,
handleBlur,
}) => (
<>
<Button
onClick={() => {
resetForm();
setIsOpen(true);
}}
>
Create Chart
</Button>
<Modal
centered
isOpen={isOpen}
toggle={() => {
toggle();
resetForm();
}}
>
<ModalHeader toggle={toggle}>Create Chart</ModalHeader>
<ModalBody>
<FormGroup>
<Label htmlFor="name">Name</Label>
<Input
name="name"
id="name"
value={values.name}
onChange={(e) => void setFieldValue('name', e.target.value)}
onBlur={handleBlur}
className={errors.name ? 'is-invalid' : ''}
/>
<FormFeedback>{errors.name}</FormFeedback>
</FormGroup>
<FormGroup>
<Label htmlFor="parentChart">Parent Chart</Label>
<Input
name="parentChart"
id="parentChart"
value={values.parentChart}
onChange={(e) =>
void setFieldValue('parentChart', e.target.value)
}
onBlur={handleBlur}
className={errors.parentChart ? 'is-invalid' : ''}
disabled
placeholder="This will be a dropdown at some point"
/>
<FormFeedback>{errors.parentChart}</FormFeedback>
</FormGroup>
<FormGroup>
<Label htmlFor="md5">MD5</Label>
<Input
name="md5"
id="md5"
value={values.md5}
onChange={(e) => void setFieldValue('md5', e.target.value)}
onBlur={handleBlur}
className={errors.md5 ? 'is-invalid' : ''}
/>
<FormFeedback>{errors.md5}</FormFeedback>
</FormGroup>
<FormGroup>
<Label htmlFor="sha256">SHA256</Label>
<Input
name="sha256"
id="sha256"
value={values.sha256}
onChange={(e) => void setFieldValue('sha256', e.target.value)}
onBlur={handleBlur}
className={errors.sha256 ? 'is-invalid' : ''}
/>
<FormFeedback>{errors.sha256}</FormFeedback>
</FormGroup>
<FormGroup>
<Label htmlFor="resourceUri">Resource URI</Label>
<Input
name="resourceUri"
id="resourceUri"
value={values.resourceUri}
onChange={(e) =>
void setFieldValue('resourceUri', e.target.value)
}
onBlur={handleBlur}
className={errors.resourceUri ? 'is-invalid' : ''}
/>
<FormFeedback>{errors.resourceUri}</FormFeedback>
</FormGroup>
<FormGroup>
<Label htmlFor="comment">Comment</Label>
<textarea
name="comment"
id="comment"
value={values.comment}
onChange={(e) =>
void setFieldValue('comment', e.target.value)
}
onBlur={handleBlur}
className={`${
errors.comment ? 'is-invalid' : ''
} form-control`}
/>
<FormFeedback>{errors.comment}</FormFeedback>
</FormGroup>
</ModalBody>
<ModalFooter>
<Button color="secondary" onClick={() => setIsOpen(false)}>
Cancel
</Button>
<Button
onClick={() => {
void handleSubmit();
}}
disabled={!isValid || isSubmitting}
color="primary"
>
Create
</Button>
</ModalFooter>
</Modal>
</>
)}
</Formik>
);
};
export default CreateChartModal;

View file

@ -0,0 +1,175 @@
import React, { useState } from 'react';
import { Chart, chartSchema } from '@baseline/types/chart';
import { Formik } from 'formik';
import {
Button,
FormFeedback,
FormGroup,
Input,
Label,
Modal,
ModalBody,
ModalFooter,
ModalHeader,
} from 'reactstrap';
import { updateChart } from '@baseline/client-api/chart';
import { onChartUpdated } from '../../../../swr/chart';
interface Props {
chart: Chart;
}
const EditChartModal = (props: Props): JSX.Element => {
const { chart } = props;
const [isOpen, setIsOpen] = useState(false);
const toggle = () => setIsOpen(!isOpen);
return (
<Formik
initialValues={chart}
enableReinitialize
validationSchema={chartSchema}
onSubmit={async (values, { setSubmitting, resetForm }) => {
setSubmitting(true);
const newChart = await updateChart(values);
await onChartUpdated(newChart);
resetForm();
setIsOpen(false);
setSubmitting(false);
}}
>
{({
values,
handleSubmit,
isValid,
isSubmitting,
resetForm,
errors,
setFieldValue,
handleBlur,
}) => (
<>
<Button
onClick={() => {
resetForm();
setIsOpen(true);
}}
>
Edit Chart
</Button>
<Modal
centered
isOpen={isOpen}
toggle={() => {
toggle();
resetForm();
}}
>
<ModalHeader toggle={toggle}>Create Chart</ModalHeader>
<ModalBody>
<FormGroup>
<Label htmlFor="name">Name</Label>
<Input
name="name"
id="name"
value={values.name}
onChange={(e) => void setFieldValue('name', e.target.value)}
onBlur={handleBlur}
className={errors.name ? 'is-invalid' : ''}
/>
<FormFeedback>{errors.name}</FormFeedback>
</FormGroup>
<FormGroup>
<Label htmlFor="parentChart">Parent Chart</Label>
<Input
name="parentChart"
id="parentChart"
value={values.parentChart}
onChange={(e) =>
void setFieldValue('parentChart', e.target.value)
}
onBlur={handleBlur}
className={errors.parentChart ? 'is-invalid' : ''}
disabled
placeholder={'This will be a dropdown at some point'}
/>
<FormFeedback>{errors.parentChart}</FormFeedback>
</FormGroup>
<FormGroup>
<Label htmlFor="md5">MD5</Label>
<Input
name="md5"
id="md5"
value={values.md5}
onChange={(e) => void setFieldValue('md5', e.target.value)}
onBlur={handleBlur}
className={errors.md5 ? 'is-invalid' : ''}
/>
<FormFeedback>{errors.md5}</FormFeedback>
</FormGroup>
<FormGroup>
<Label htmlFor="sha256">SHA256</Label>
<Input
name="sha256"
id="sha256"
value={values.sha256}
onChange={(e) => void setFieldValue('sha256', e.target.value)}
onBlur={handleBlur}
className={errors.sha256 ? 'is-invalid' : ''}
/>
<FormFeedback>{errors.sha256}</FormFeedback>
</FormGroup>
<FormGroup>
<Label htmlFor="resourceUri">Resource URI</Label>
<Input
name="resourceUri"
id="resourceUri"
value={values.resourceUri}
onChange={(e) =>
void setFieldValue('resourceUri', e.target.value)
}
onBlur={handleBlur}
className={errors.resourceUri ? 'is-invalid' : ''}
/>
<FormFeedback>{errors.resourceUri}</FormFeedback>
</FormGroup>
<FormGroup>
<Label htmlFor="comment">Comment</Label>
<textarea
name="comment"
id="comment"
value={values.comment}
onChange={(e) =>
void setFieldValue('comment', e.target.value)
}
onBlur={handleBlur}
className={`${
errors.comment ? 'is-invalid' : ''
} form-control`}
/>
<FormFeedback>{errors.comment}</FormFeedback>
</FormGroup>
</ModalBody>
<ModalFooter>
<Button color="secondary" onClick={() => setIsOpen(false)}>
Cancel
</Button>
<Button
onClick={() => {
void handleSubmit();
}}
disabled={!isValid || isSubmitting}
color="primary"
>
Update
</Button>
</ModalFooter>
</Modal>
</>
)}
</Formik>
);
};
export default EditChartModal;

View file

@ -0,0 +1,16 @@
import React from 'react';
import PageContent from '../../../components/page-content/PageContent';
import { useParams } from 'react-router-dom';
import ChartDetail from '../components/chart-detail/ChartDetail';
const Chart = (): JSX.Element => {
const { chartId } = useParams();
return (
<PageContent>
<ChartDetail chartId={chartId} />
</PageContent>
);
};
export default Chart;

View file

@ -0,0 +1,13 @@
import React from 'react';
import PageContent from '../../../components/page-content/PageContent';
import ChartList from '../components/chart-list/ChartList';
const charts = (): JSX.Element => {
return (
<PageContent>
<ChartList />
</PageContent>
);
};
export default charts;

View file

@ -0,0 +1,55 @@
@use '../../../../styles/global';
.dashboard {
h1 {
margin-bottom: 32px;
}
.grid {
display: grid;
grid-gap: 16px;
grid-template-columns: 1fr 1fr;
@media screen and (max-width: global.$xl) {
grid-template-columns: 1fr;
}
.links {
a {
@include global.mediumFont();
display: flex;
align-items: center;
margin-bottom: 16px;
padding: 32px;
color: #000;
text-decoration: none;
background: #fff;
border: 1px solid #bababa;
&::before {
@include global.mediumFont();
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
margin-right: 16px;
color: #fff;
font-weight: bold;
background: #000;
border-radius: 10px;
content: '+';
}
}
}
.preview {
margin-bottom: 16px;
padding: 32px;
background: #fff;
border: 1px solid #bababa;
}
}
}

View file

@ -0,0 +1,17 @@
import React from 'react';
import styles from './DashboardContent.module.scss';
const DashboardContent = (): JSX.Element => {
return (
<div className={styles.dashboard}>
<h1>Dashboard</h1>
<div className={styles.grid}>
<div className={styles.preview}>
<h2>My Site Preview</h2>
</div>
</div>
</div>
);
};
export default DashboardContent;

View file

@ -0,0 +1,11 @@
import React from 'react';
import PageContent from '../../../components/page-content/PageContent';
import DashboardContent from '../components/dashboard-content/DashboardContent';
const Dashboard = (): JSX.Element => (
<PageContent>
<DashboardContent />
</PageContent>
);
export default Dashboard;

View file

@ -0,0 +1,22 @@
@use '../../../styles/global';
.home {
display: flex;
min-height: 100vh;
overflow: hidden;
flex-direction: row;
flex: 1 1 auto;
> div:last-of-type {
flex: 1 1 auto;
min-width: min(100vw, 390px);
}
.content {
display: flex;
flex-direction: column;
flex: 1 1 auto;
justify-content: center;
align-items: center;
}
}

View file

@ -0,0 +1,16 @@
import React from 'react';
import { Link } from 'react-router-dom';
import styles from './Home.module.scss';
function Home() {
return (
<div className={styles.home}>
<div className={styles.content}>
<h1>Home</h1>
<p>Welcome to the home page</p>
<Link to="/dashboard">Dashboard</Link>
</div>
</div>
);
}
export default Home;

View file

@ -0,0 +1,22 @@
@use '../../../styles/global';
.login {
display: flex;
min-height: 100vh;
overflow: hidden;
flex-direction: row;
flex: 1 1 auto;
> div:last-of-type {
flex: 1 1 auto;
min-width: min(100vw, 390px);
}
.content {
display: flex;
flex-direction: column;
flex: 1 1 auto;
justify-content: center;
align-items: center;
}
}

View file

@ -0,0 +1,14 @@
import React from 'react';
import { Authenticator } from '@aws-amplify/ui-react';
import styles from './Login.module.scss';
function Login() {
return (
<div className={styles.login}>
<div className={styles.content}>
<Authenticator />
</div>
</div>
);
}
export default Login;

View file

@ -0,0 +1,26 @@
@use '../../../styles/global';
.notAdmin {
display: flex;
min-height: 100vh;
overflow: hidden;
flex-direction: row;
flex: 1 1 auto;
> div:last-of-type {
flex: 1 1 auto;
min-width: min(100vw, 390px);
}
.content {
display: flex;
flex-direction: column;
flex: 1 1 auto;
justify-content: center;
align-items: center;
button {
@include global.genericButton();
}
}
}

View file

@ -0,0 +1,31 @@
import React from 'react';
import styles from './NotAdmin.module.scss';
import { signOut } from 'aws-amplify/auth';
import { redirect } from 'react-router-dom';
async function signOutButton() {
await signOut();
redirect('/');
}
function NotAdmin() {
return (
<div className={styles.notAdmin}>
<div className={styles.content}>
<div>
<h1>Please contact your system administrator</h1>
<p>You do not have permission to view this content</p>
<button
onClick={() => {
void signOutButton();
}}
>
Sign out
</button>
</div>
</div>
</div>
);
}
export default NotAdmin;

View file

@ -0,0 +1,52 @@
@use '../../../../styles/global';
.userSettings {
h1 {
@include global.largeFont();
margin-bottom: 32px;
font-weight: 700;
}
.settings {
@include global.smallFont();
align-items: center;
width: 100%;
padding: 18px 48px;
background: #fff;
border: 1px solid #bababa;
@media screen and (max-width: global.$lg) {
padding: 12px;
}
label {
font-weight: 600;
}
input {
padding: 16px;
border: 1px solid #bababa;
border-radius: unset;
}
button {
@include global.smallFont();
padding: 12px 24px;
background: #fff;
border: 1px solid #bababa;
}
.email {
display: flex;
button {
border-left: unset;
}
}
.signOut {
@include global.smallFont();
}
}
}

View file

@ -0,0 +1,128 @@
import {
signOut,
updateUserAttributes,
confirmUserAttribute,
fetchUserAttributes,
} from 'aws-amplify/auth';
import React, { useState } from 'react';
import { FormFeedback, FormGroup, Input, Label } from 'reactstrap';
import styles from './UserSettings.module.scss';
interface Props {
user: { email: string; email_verified: boolean };
}
const UserSettings = (props: Props): JSX.Element => {
const [email, setEmail] = useState<string>(props?.user?.email);
const [isChangingEmail, setIsChangingEmail] = useState(false);
const [isEmailVerified, setIsEmailVerified] = useState(
props?.user?.email_verified,
);
const [isCodeInvalid, setIsCodeInvalid] = useState<undefined | boolean>();
const [changingEmailCode, setChangingEmailCode] = useState('');
const handleEmailChange = async () => {
const attributes = await fetchUserAttributes();
setIsChangingEmail(false);
if (attributes.email !== email) {
setIsEmailVerified(false);
await updateUserAttributes({
userAttributes: {
email: email,
},
});
}
};
const finalizeEmailChange = async () => {
try {
await confirmUserAttribute({
userAttributeKey: 'email',
confirmationCode: changingEmailCode,
});
setIsEmailVerified(true);
setChangingEmailCode('');
} catch {
console.log('Invalid code');
setIsCodeInvalid(true);
}
};
return (
<div className={styles.userSettings}>
<h1>Account settings</h1>
<div className={styles.settings}>
<FormGroup>
<Label for="email">Email</Label>
<div className={styles.email}>
<Input
name="email"
id="email"
type="email"
placeholder="Email"
value={email}
disabled={!isChangingEmail}
onChange={(e) => setEmail(e.target.value)}
/>
<button
disabled={!isEmailVerified}
onClick={() => {
isChangingEmail
? void handleEmailChange()
: setIsChangingEmail(true);
}}
>
{isChangingEmail ? 'Update' : 'Edit'}
</button>
</div>
</FormGroup>
{!isEmailVerified ? (
<FormGroup>
<Label for="code">Check your email for a code</Label>
<div className={styles.email}>
<Input
name="code"
id="code"
type="text"
placeholder="Code"
invalid={isCodeInvalid}
value={changingEmailCode}
onChange={(e) => setChangingEmailCode(e.target.value)}
/>
<button
onClick={() => {
void finalizeEmailChange();
}}
>
Submit
</button>
<button
onClick={() => {
setIsEmailVerified(true);
setIsChangingEmail(true);
}}
>
Cancel
</button>
</div>
<FormFeedback className={isCodeInvalid ? 'd-block' : ''}>
Code is invalid
</FormFeedback>
</FormGroup>
) : (
<></>
)}
<button
className={styles.signOut}
onClick={() => {
void signOut();
}}
>
Sign out
</button>
</div>
</div>
);
};
export default UserSettings;

View file

@ -0,0 +1,34 @@
import React from 'react';
import PageContent from '../../../components/page-content/PageContent';
import UserSettings from '../components/user-settings/UserSettings';
import { fetchUserAttributes } from 'aws-amplify/auth';
import { useLoaderData } from 'react-router-dom';
export async function userLoader() {
const { email, email_verified } = await fetchUserAttributes();
return {
user: {
email,
email_verified,
},
};
}
const User = (): JSX.Element => {
const { user } = useLoaderData() as {
user: { email: string; email_verified: boolean };
};
return (
<PageContent>
<UserSettings
user={{
email: user.email,
email_verified: user.email_verified,
}}
/>
</PageContent>
);
};
export default User;

View file

@ -0,0 +1,19 @@
.confirmDelete {
display: flex;
align-items: center;
}
.deleteModal,
.confirmDelete {
.deleteButton {
display: flex;
align-items: center;
font: normal normal normal 15px/22px 'Montserrat', sans-serif;
background: unset;
border: unset;
&:disabled {
opacity: 0.25;
}
}
}

View file

@ -0,0 +1,88 @@
import React, { useState } from 'react';
import {
FormGroup,
Input,
Label,
Modal,
ModalBody,
ModalFooter,
ModalHeader,
} from 'reactstrap';
import styles from './ConfirmDelete.module.scss';
interface Props {
itemName: string;
deleteFunction(): Promise<void>;
deleteString?: string;
buttonProps?: React.DetailedHTMLProps<
React.ButtonHTMLAttributes<HTMLButtonElement>,
HTMLButtonElement
>;
}
const ConfirmDelete = (props: Props): JSX.Element => {
const {
itemName,
deleteFunction,
deleteString = itemName,
buttonProps,
} = props;
const [isModalOpen, setIsModalOpen] = useState(false);
const toggle = () => setIsModalOpen((open) => !open);
const [deleteType, setDeleteType] = useState('');
const handleDelete = async (): Promise<void> => {
toggle();
setDeleteType('');
await deleteFunction();
};
return (
<div className={styles.confirmDelete}>
<button {...buttonProps} onClick={toggle} className={styles.deleteButton}>
Delete
</button>
<Modal
isOpen={isModalOpen}
toggle={toggle}
centered
className={styles.deleteModal}
>
<ModalHeader toggle={toggle}>
Delete &quot;{itemName}&quot;?
</ModalHeader>
<ModalBody>
<FormGroup>
<Label for="delete">
Please type <b>{deleteString}</b> to confirm deletion
</Label>
<Input
id="delete"
name="delete"
autoComplete="off"
placeholder={deleteString}
value={deleteType}
onChange={(e) => {
setDeleteType(e.target.value);
}}
/>
</FormGroup>
</ModalBody>
<ModalFooter>
<button
disabled={deleteString !== deleteType}
onClick={() => {
void handleDelete();
}}
className={styles.deleteButton}
>
Delete
</button>
</ModalFooter>
</Modal>
</div>
);
};
export default ConfirmDelete;

View file

@ -0,0 +1,25 @@
import React from 'react';
import { Outlet, useNavigation } from 'react-router-dom';
import Sidebar from '../sidebar/Sidebar';
import PageContent from '../page-content/PageContent';
import Loader from '../page-content/loader/Loader';
const Layout = () => {
const navigation = useNavigation();
const isLoading = navigation.state === 'loading';
return (
<>
<Sidebar />
{isLoading ? (
<PageContent>
<Loader hasStartedLoading={true} />
</PageContent>
) : (
<Outlet />
)}
</>
);
};
export default Layout;

View file

@ -0,0 +1,20 @@
@keyframes fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.pageContent {
position: relative;
padding: 98px min(12vw, 144px);
background: #efefef;
width: 100%;
.children {
animation: fade 500ms forwards ease-in-out;
}
}

View file

@ -0,0 +1,20 @@
import React from 'react';
import styles from './PageContent.module.scss';
interface Props {
children: JSX.Element | JSX.Element[];
}
const PageContent = (props: Props) => {
const { children } = props;
return (
<>
<div className={styles.pageContent}>
<div className={styles.children}>{children}</div>
</div>
</>
);
};
export default PageContent;

View file

@ -0,0 +1,51 @@
@keyframes pulse {
0% {
filter: grayscale(0);
}
50% {
filter: grayscale(1);
}
100% {
filter: grayscale(0);
}
}
@keyframes fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.loader {
position: absolute;
top: 0;
left: 0;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
background: #efefef;
opacity: 0;
transition: opacity 100ms ease-in-out;
pointer-events: none;
&.visible,
&.textVisible {
opacity: 1;
}
&.textVisible {
svg {
animation: fade 600ms forwards ease-in-out,
pulse 1.2s infinite ease-in-out;
}
}
}

View file

@ -0,0 +1,70 @@
import React, { useEffect, useState } from 'react';
import styles from './Loader.module.scss';
interface Props {
isLoading?: boolean;
hasStartedLoading?: boolean;
}
const Loader = (props: Props): JSX.Element => {
const [isLoadingTextShowing, setIsLoadingTextShowing] = useState(false);
const [isLoaderShowing, setIsLoaderShowing] = useState(false);
const [loadingTimeout, setLoadingTimeout] = useState<unknown>();
const { isLoading, hasStartedLoading } = props;
useEffect(() => {
if (hasStartedLoading) {
setIsLoaderShowing(true);
setLoadingTimeout(
setTimeout(() => {
setIsLoadingTextShowing(true);
}, 250),
);
}
}, [hasStartedLoading]);
useEffect(() => {
if (hasStartedLoading && !isLoading) {
setIsLoadingTextShowing(false);
setIsLoaderShowing(false);
clearTimeout(loadingTimeout as NodeJS.Timeout);
}
}, [hasStartedLoading, isLoading, loadingTimeout]);
return (
<div
className={`${styles.loader} ${
isLoadingTextShowing ? styles.textVisible : ''
} ${isLoaderShowing ? styles.visible : ''}`}
>
{isLoadingTextShowing ? (
<>
<svg width="77" height="81" xmlns="http://www.w3.org/2000/svg">
<g>
<path
fill="#ffd600"
d="M2.791 25.101c6.527 3.225 12.977 6.45 19.427 9.675l13.9 6.911a3.78 3.78 0 0 0 1.305.461c.23.077.384.154.614.23l.23.077.23-.077c.23-.077.384-.154.614-.23.498-.145.986-.325 1.459-.538 5.068-2.534 10.136-5.068 15.2-7.525 6.066-2.995 12.209-6.066 18.275-9.061a4.186 4.186 0 0 0 2.615-3.689 3.961 3.961 0 0 0-2.611-3.839l-9.522-4.761C56.695 8.822 48.479 4.829 40.416.913a5.533 5.533 0 0 0-4.146 0c-7.909 3.839-15.972 7.832-23.728 11.672l-9.751 4.837a4.119 4.119 0 0 0-2.688 3.763 4.061 4.061 0 0 0 2.688 3.916m59.817-3.225c-1.613.768-3.148 1.613-4.684 2.38l-2.534 1.229a1063.588 1063.588 0 0 1-16.279 7.986 2.12 2.12 0 0 1-1.536 0c-6.757-3.3-13.515-6.68-20.272-9.982l-4.3-2.15 17.047-8.447c2.611-1.305 5.222-2.611 7.832-3.839.268-.108.556-.16.845-.154 8.063 3.993 16.048 7.986 24.111 11.979l.921.461Z"
/>
<path
fill="#ffd600"
d="M2.637 38.154a19132.396 19132.396 0 0 0 33.326 16.509c.495.224 1.01.403 1.537.535l.691.23.23.077.23-.077.691-.23c.55-.17 1.089-.376 1.613-.614 5.759-2.841 11.518-5.682 17.277-8.6l10.981-5.452c1.766-.845 3.532-1.689 5.222-2.611a3.939 3.939 0 0 0 2-4.838 3.882 3.882 0 0 0-4.454-2.611c-.7.162-1.372.42-2 .768l-3.611 1.846c-8.907 4.377-17.815 8.831-26.722 13.284a2.5 2.5 0 0 1-2.534 0A5805.147 5805.147 0 0 0 6.169 31.013c-2.3-1.152-4.607-.538-5.682 1.536a3.751 3.751 0 0 0-.307 2.995 5.054 5.054 0 0 0 2.457 2.611"
/>
<path
fill="#f5931e"
d="M70.67 56.046a5941.672 5941.672 0 0 0-31.406 15.588 1.662 1.662 0 0 1-1.689 0 8859.35 8859.35 0 0 0-31.56-15.588 4.608 4.608 0 0 0-2.457-.537 3.756 3.756 0 0 0-3.379 3.3 3.847 3.847 0 0 0 2.38 4.3 5055.77 5055.77 0 0 0 33.633 16.664c.447.212.909.392 1.382.538.23.077.384.154.614.23l.23.077.23-.077c.23-.077.384-.154.614-.23.461-.154.921-.307 1.305-.461 11.213-5.531 22.424-11.131 33.635-16.663a5.276 5.276 0 0 0 1.612-1.228 4.037 4.037 0 0 0 0-4.991c-1.229-1.613-3.148-1.92-5.145-.921"
/>
<path
fill="#f5931e"
d="m2.791 50.748 10.827 5.375c7.525 3.686 15.05 7.448 22.575 11.211.69.35 1.453.534 2.227.538.775.005 1.54-.18 2.227-.538 1.766-.921 3.532-1.766 5.3-2.688l7.6-3.763 20.5-10.136a4.1 4.1 0 0 0 2.611-3.686 3.884 3.884 0 0 0-1.689-3.379 4.583 4.583 0 0 0-4.454 0l-31.1 15.434c-.61.385-1.39.385-2 0-8.6-4.377-17.123-8.6-25.724-12.823l-5.368-2.686c-2.457-1.229-4.684-.61-5.759 1.459a3.6 3.6 0 0 0-.307 2.918 4.654 4.654 0 0 0 2.534 2.764"
/>
</g>
</svg>
</>
) : (
<></>
)}
</div>
);
};
export default Loader;

View file

@ -0,0 +1,112 @@
$horizontalPadding: 44px;
$width: 250px;
$totalSidebarSize: calc((#{$horizontalPadding} * 2) + #{$width});
$collapseSize: calc(((#{$horizontalPadding} * 2) + #{$width}) * -1);
$animationSpeed: 250ms;
.sidebar {
position: relative;
z-index: 3;
flex: 0 1 auto;
width: $totalSidebarSize;
height: 100vh;
padding: 48px $horizontalPadding 32px;
background: #fff;
transform: translateX(0);
transition: margin-left $animationSpeed linear;
.toggler {
position: absolute;
top: 10px;
right: -42px;
width: 32px;
height: 32px;
border-top: 16px solid #b2b2b2;
border-left: 16px solid #b2b2b2;
transform: rotate(0deg);
cursor: pointer;
opacity: 0.25;
transition: transform calc(#{$animationSpeed} - 100ms) ease-in-out,
opacity calc(#{$animationSpeed} - 100ms) ease-in-out;
&:hover {
transform: rotate(45deg);
opacity: 1;
}
}
&.collapsed {
margin-left: $collapseSize;
.toggler {
transform: rotate(90deg);
&:hover {
transform: rotate(45deg);
}
}
}
.logo {
margin-bottom: 48px;
height: 40px;
max-height: 50px;
max-width: 200px;
object-fit: contain;
}
.links {
margin-top: 48px;
img {
height: 40px;
object-fit: contain;
}
.spacer {
height: 20px;
margin-bottom: 16px;
}
.link,
.spacer {
display: flex;
align-items: center;
text-decoration: none;
img {
flex: 0 0 20px;
width: 20px;
object-fit: contain;
}
span {
margin-left: 15px;
color: #707070;
font: normal normal 300 17px/20px Montserrat;
border-bottom: 1px solid #fff0;
transition: border-bottom 150ms ease-in-out;
}
&.active {
img {
/** Since its not an svg in DOM we cant edit fill */
filter: brightness(0);
}
span {
color: #000;
}
pointer-events: none;
user-select: none;
}
&:hover {
span {
border-bottom: thin solid #707070;
}
}
}
}
}

View file

@ -0,0 +1,74 @@
import React, { useEffect, useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import styles from './Sidebar.module.scss';
const Sidebar = (): JSX.Element => {
const location = useLocation();
const [isToggled, setIsToggled] = useState(false);
useEffect(() => {
if (window.innerWidth < 400) {
setIsToggled(true);
}
}, []);
return (
<div className={`${styles.sidebar} ${isToggled ? styles.collapsed : ''}`}>
<div
className={styles.toggler}
onClick={() => {
setIsToggled((toggled) => !toggled);
}}
></div>
<img className={styles.logo} src="/logo.png" alt="Baseline" />
<div className={styles.links}>
<Link
to="/dashboard"
className={`${styles.link} ${
location.pathname === '/dashboard' ? styles.active : ''
}`}
>
<img src="/icons/home.svg" alt="Home" />
<span>Dashboard</span>
</Link>
<div className={styles.spacer} />
<Link
to="/chart"
className={`${styles.link} ${
location.pathname === '/chart' ? styles.active : ''
}`}
>
<img src="/icons/users.svg" alt="Charts" />
<span>Charts</span>
</Link>
<Link
to="/admins"
className={`${styles.link} ${
location.pathname === '/admins' ? styles.active : ''
}`}
>
<img src="/icons/users.svg" alt="Admins" />
<span>Admins</span>
</Link>
<div className={styles.spacer} />
<Link
to="/settings"
className={`${styles.link} ${
location.pathname === '/settings' ? styles.active : ''
}`}
>
<img src="/icons/gear.svg" alt="Settings" />
<span>Account Settings</span>
</Link>
</div>
</div>
);
};
export default Sidebar;

View file

@ -0,0 +1,39 @@
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #fff;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
.loader-container div {
width: 50px;
height: 50px;
background: #a0a0a0;
border-radius: 30px;
}
.app {
display: flex;
min-height: 100vh;
overflow: hidden;
flex-direction: row;
flex: 1 1 auto;
> div:last-of-type {
flex: 1 1 auto;
min-width: min(100vw, 390px);
}
}
[id=root] {
display: flex;
flex-direction: row;
flex: 1 1 auto;
}

View file

@ -0,0 +1,11 @@
import React from 'react';
import * as ReactDOM from 'react-dom/client';
import './styles/fonts.scss';
import './index.scss';
import App from './App';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

1
packages/admin/src/react-app-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="react-scripts" />

View file

@ -0,0 +1,90 @@
$xl: 1200px;
$lg: 992px;
$md: 768px;
$sm: 576px;
$animationSpeed: 150ms;
$breakSize: $md;
@mixin genericButton {
@include smallFont(normal, 600);
display: inline-block;
padding: 12px 108px;
color: #fff;
text-decoration: none;
background: #3a3838;
border: 2px solid #3a3838;
border-radius: 32px;
transition: background-color $animationSpeed ease-in-out,
color $animationSpeed ease-in-out;
&:hover {
color: #3a3838;
background: #fff;
}
}
@mixin tinyFont(
$style: normal,
$weight: normal,
$fontFace: 'Montserrat',
$fallback: sans-serif
) {
font: $style normal $weight 12px/20px $fontFace, $fallback;
@media screen and (max-width: $breakSize) {
font: $style normal $weight 8px/16px $fontFace, $fallback;
}
}
@mixin smallFont(
$style: normal,
$weight: normal,
$fontFace: 'Montserrat',
$fallback: sans-serif
) {
font: $style normal $weight 16px/24px $fontFace, $fallback;
@media screen and (max-width: $breakSize) {
font: $style normal $weight 12px/20px $fontFace, $fallback;
}
}
@mixin mediumFont(
$style: normal,
$weight: normal,
$fontFace: 'Montserrat',
$fallback: sans-serif
) {
font: $style normal $weight 24px/32px $fontFace, $fallback;
@media screen and (max-width: $breakSize) {
font: $style normal $weight 16px/24px $fontFace, $fallback;
}
}
@mixin largeFont(
$style: normal,
$weight: normal,
$fontFace: 'Montserrat',
$fallback: sans-serif
) {
font: $style normal $weight 40px/49px $fontFace, $fallback;
@media screen and (max-width: $breakSize) {
font: $style normal $weight 24px/32px $fontFace, $fallback;
}
}
@mixin hugeFont(
$style: normal,
$weight: normal,
$fontFace: 'Montserrat',
$fallback: sans-serif
) {
font: $style normal $weight 72px/88px $fontFace, $fallback;
@media screen and (max-width: $breakSize) {
font: $style normal $weight 40px/49px $fontFace, $fallback;
}
}

View file

@ -0,0 +1,2 @@
@import url('https://fonts.googleapis.com/css2?family=Montserrat:wght@300;400;500;600;800;900&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans:wght@400;600&display=swap');

View file

@ -0,0 +1,75 @@
import useSWR, { mutate } from 'swr';
import { getAllCharts, getChart } from '@baseline/client-api/chart';
import { Chart } from '@baseline/types/chart';
export const useCharts = () => {
const { data, error, isLoading, isValidating } = useSWR<Chart[], unknown>(
`chart/list`,
() => getAllCharts(),
);
return {
charts: data,
isLoading,
error,
isValidating,
};
};
export const useChart = (chartId: string) => {
const { data, error, isLoading, isValidating } = useSWR<Chart, unknown>(
chartId ? `chart/${chartId}` : null,
() => getChart(chartId),
);
return {
chart: data,
isLoading,
error,
isValidating,
};
};
export const onChartCreated = async (chart: Chart) => {
await Promise.all([
mutate<Chart[]>(
`chart/list`,
(existingCharts) => [...(existingCharts || []), chart],
{
revalidate: false,
},
),
mutate<Chart>(`chart/${chart.chartId}`, chart),
]);
};
export const onChartUpdated = async (chart: Chart) => {
await Promise.all([
mutate<Chart[]>(
`chart/list`,
(existingCharts) =>
(existingCharts || []).map((chartExisting) =>
chartExisting.chartId === chart.chartId ? chart : chartExisting,
),
{
revalidate: false,
},
),
mutate<Chart>(`chart/${chart.chartId}`, chart),
]);
};
export const onChartDeleted = async (chart: Chart) => {
await Promise.all([
mutate<Chart[]>(
`chart/list`,
(existingCharts) =>
(existingCharts || []).filter(
(chartExisting) => chartExisting.chartId !== chart.chartId,
),
{
revalidate: false,
},
),
]);
};

5
packages/admin/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1,5 @@
/// <reference types="vite/client" />
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View file

@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ESNext",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": false,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"incremental": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react",
"allowSyntheticDefaultImports": true,
"noFallthroughCasesInSwitch": true
},
"include": [
"**/*.ts",
"**/*.tsx",
"../../shared/types",
"../../shared/client-api",
"vite.config.ts"
]
}

View file

@ -0,0 +1,41 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import EnvironmentPlugin from 'vite-plugin-environment';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
react(),
EnvironmentPlugin([
'REACT_APP_APP_NAME',
'REACT_APP_AWS_PROFILE',
'REACT_APP_API_URL',
'REACT_APP_COGNITO_IDENTITY_POOL_ID',
'REACT_APP_COGNITO_USER_POOL_ID',
'REACT_APP_COGNITO_USER_POOL_WEB_CLIENT_ID',
]),
],
envPrefix: 'REACT_APP_',
define: {},
resolve: {
alias: {
'./runtimeConfig': './runtimeConfig.browser',
},
},
esbuild: {
minifyWhitespace: true,
treeShaking: true,
},
build: {
outDir: '.dist',
minify: 'esbuild',
chunkSizeWarningLimit: 1500,
rollupOptions: {
output: {
manualChunks: {
amplify: ['@aws-amplify/ui-react', 'aws-amplify'],
},
},
},
},
});

View file

@ -0,0 +1,3 @@
module.exports = {
extends: ['../../.eslintrc.js'],
};

55
packages/api/package.json Normal file
View file

@ -0,0 +1,55 @@
{
"name": "@baseline/api",
"version": "1.0.0",
"engines": {
"node": "20"
},
"scripts": {
"start:exposed": "pnpm run start --host 0.0.0.0",
"start": "./scripts/run-api-local.sh",
"debug": "export SLS_DEBUG=* && ./scripts/run-api-local-debug.sh",
"deploy:staging": "./scripts/deploy.sh staging",
"remove:staging": ". ../../scripts/project-variables.sh && npx serverless remove --stage staging --region $REGION",
"deploy:prod": "./scripts/deploy.sh prod",
"remove:prod": ". ../../scripts/project-variables.sh && npx serverless remove --stage prod --region $REGION",
"add:env:local": "../../scripts/add-env-var.sh local",
"add:env:staging": "../../scripts/add-env-var.sh staging",
"add:env:prod": "../../scripts/add-env-var.sh prod",
"aws:profile": "../../scripts/setup-aws-profile.sh",
"add:user:staging": "./scripts/add-cognito-user.sh staging",
"add:user:prod": "./scripts/add-cognito-user.sh prod",
"install:dynamodb": ". ../../scripts/project-variables.sh && npx serverless dynamodb install --stage staging --region $REGION",
"lint": "npx eslint --config '.eslintrc.js' 'src/**/*.{ts,tsx,js}'",
"build": ". ../../scripts/project-variables.sh && npx serverless package --stage staging --region $REGION",
"pretty": "npx prettier --write 'src/**/*.{ts,tsx,js,json,css,scss,md,yml,yaml,html}' && npx prettier --write '*.{yml,yaml,js}'"
},
"dependencies": {
"@aws-sdk/client-cognito-identity-provider": "3.530.0",
"@baseline/types": "workspace:1.0.0",
"@baselinejs/dynamodb": "0.2.4",
"@types/lodash-es": "^4.17.12",
"aws-lambda": "1.0.7",
"compression": "1.7.4",
"cors": "2.8.5",
"express": "4.21.0",
"http-status-codes": "2.2.0",
"lodash-es": "^4.17.21",
"serverless-http": "3.0.1"
},
"devDependencies": {
"@types/aws-lambda": "8.10.93",
"@types/compression": "1.7.2",
"@types/cors": "2.8.12",
"@types/express": "4.17.21",
"@types/node": "20.11.26",
"esbuild": "0.20.1",
"prettier": "2.4.1",
"serverless": "3.38.0",
"serverless-dynamodb": "0.2.50",
"serverless-esbuild": "1.52.1",
"serverless-offline": "13.3.3",
"stylelint": "16.2.1",
"stylelint-order": "6.0.4",
"typescript": "5.4.2"
}
}

View file

@ -0,0 +1,115 @@
#!/usr/bin/env bash
shopt -s failglob
CURRENT_DIR="$(pwd -P)"
PARENT_PATH="$(
cd "$(dirname "${BASH_SOURCE[0]}")" || exit
pwd -P
)/.."
cd "$PARENT_PATH" || exit
STAGE=$1
USER_EMAIL=$2
USER_PASSWORD=$3
# Sets REGION, APP_NAME, AWS_REGION, AWS_PROFILE
. ../../scripts/project-variables.sh
TABLE="${APP_NAME}-${STAGE}-admin"
echo "Getting Cognito User Pool Id from [$STAGE]..."
. ../../scripts/get-stack-outputs.sh "$STAGE" >/dev/null
COGNITO_USER_POOL_ID="${UserPoolId:-}"
if [ "$COGNITO_USER_POOL_ID" == "" ]; then
echo "Failed to get Cognito User Pool Id!"
echo 'Check your aws credentials are up to date, maybe run "npm run aws:profile"'
exit 1
else
echo "Cognito Pool Id [$COGNITO_USER_POOL_ID]"
fi
if [ -z "$USER_EMAIL" ]; then
printf "Email: "
read -r USER_EMAIL
fi
if [ "$USER_EMAIL" == "" ]; then
echo "Error: No user email set"
exit 1
fi
if [ -z "$USER_PASSWORD" ]; then
echo
echo "Password Requirements:"
echo "- 8 character minimum length"
echo "- Contains at least 1 number"
echo "- Contains at least 1 lowercase letter"
echo "- Contains at least 1 uppercase letter"
echo "- Contains at least 1 special character"
printf "Password: "
read -sr USER_PASSWORD
echo ""
fi
if [ "$USER_PASSWORD" == "" ]; then
echo "Error: No user password set"
exit 1
fi
EXISTING_USER=$(aws cognito-idp admin-get-user \
--profile "${AWS_PROFILE}" \
--region "${REGION}" \
--user-pool-id "${COGNITO_USER_POOL_ID:-}" \
--username "${USER_EMAIL}")
if [ "$EXISTING_USER" ]; then
echo "User already exists, will not modify password"
echo "Will attempt to add to DynamoDB"
else
echo "Creating User..."
aws cognito-idp admin-create-user \
--profile "${AWS_PROFILE}" \
--region "${REGION}" \
--user-pool-id "${COGNITO_USER_POOL_ID:-}" \
--username "${USER_EMAIL:-}" \
--user-attributes Name=email,Value="${USER_EMAIL:-}" Name=email_verified,Value=true \
--message-action SUPPRESS >/dev/null
echo "Setting Password..."
aws cognito-idp admin-set-user-password \
--profile "${AWS_PROFILE}" \
--region "${REGION}" \
--user-pool-id "${COGNITO_USER_POOL_ID:-}" \
--username "${USER_EMAIL:-}" \
--password "${USER_PASSWORD:-}" \
--permanent >/dev/null
fi
USER_SUB=$(aws cognito-idp admin-get-user \
--profile "${AWS_PROFILE}" \
--region "${REGION}" \
--user-pool-id "${COGNITO_USER_POOL_ID:-}" \
--username "${USER_EMAIL}" |
jq '.["Username"]' |
tr -d '"')
echo "User Sub: [${USER_SUB}]"
if [ "$USER_SUB" ]; then
echo "Found user sub, attempting to create DynamoDB record"
aws dynamodb put-item \
--table-name "${TABLE}" \
--item \
"{\"userSub\": {\"S\": \"${USER_SUB}\"}, \"userEmail\": {\"S\": \"${USER_EMAIL}\"}}" \
--profile "${AWS_PROFILE}" \
--region "${REGION}"
else
echo "User sub not found, cannot create DynamoDB record"
fi
echo "Done!"
cd "$CURRENT_DIR" || exit

25
packages/api/scripts/deploy.sh Executable file
View file

@ -0,0 +1,25 @@
#!/usr/bin/env bash
CURRENT_DIR="$(pwd -P)"
PARENT_PATH="$(
cd "$(dirname "${BASH_SOURCE[0]}")" || exit
pwd -P
)/.."
cd "$PARENT_PATH" || exit
STAGE=$1
. ../../scripts/project-variables.sh
. ../../scripts/get-stack-outputs.sh "$STAGE" >/dev/null
npx serverless deploy --verbose --stage "$STAGE" --region "$REGION"
# check if npx serverless deploy was successful
if [ $? -eq 0 ]; then
echo "Deploy successful"
else
echo "Deploy failed"
cd "$CURRENT_DIR" || exit
exit 1
fi
cd "$CURRENT_DIR" || exit

View file

@ -0,0 +1,38 @@
#!/usr/bin/env bash
CURRENT_DIR="$(pwd -P)"
PARENT_PATH="$(
cd "$(dirname "${BASH_SOURCE[0]}")" || exit
pwd -P
)/.."
cd "$PARENT_PATH" || exit
# Sets REGION, APP_NAME, AWS_REGION, AWS_PROFILE
. ../../scripts/project-variables.sh
echo "Testing AWS Keys..."
IAM_RESULT=$(aws sts get-caller-identity --query "Account" --output text --profile "$AWS_PROFILE")
if [ "$IAM_RESULT" ]; then
echo "AWS Credentials work!"
else
printf "\033[31mAWS Keys did not work!\033[39m\n"
exit
fi
# Set the user that will be used for private authorised endpoints - the user that logs in on the client will be ignored.
# AUTHORIZER is a value detected by serverless offline https://github.com/dherault/serverless-offline#remote-authorizers
# This user is and can be linked in local seed data so that there is user specific relationships.
# Restart the API when this is changed.
export AUTHORIZER='{"claims":{"email":"example@devika.com", "sub":"ed805890-d66b-4126-a5d9-0b22e70fce80"}}'
# Required to install/use local DynamoDB
pnpm run install:dynamodb
# Doesn't seem compatible with debug mode
# Provides stack trace using source map so the correct file and line numbers are shown
# export NODE_OPTIONS=--enable-source-maps
# Start the API with serverless
export SLS_DEBUG="*" && node --inspect ./node_modules/serverless/bin/serverless offline start --stage local --region "$REGION" --httpPort 4000 --verbose "$@"
cd "$CURRENT_DIR" || exit

View file

@ -0,0 +1,48 @@
#!/usr/bin/env bash
CURRENT_DIR="$(pwd -P)"
PARENT_PATH="$(
cd "$(dirname "${BASH_SOURCE[0]}")" || exit
pwd -P
)/.."
cd "$PARENT_PATH" || exit
# Sets REGION, APP_NAME, AWS_REGION, AWS_PROFILE
. ../../scripts/project-variables.sh
echo "Testing AWS Keys..."
IAM_RESULT=$(aws sts get-caller-identity --query "Account" --output text --profile "$AWS_PROFILE")
if [ "$IAM_RESULT" ]; then
echo "AWS Credentials work!"
else
printf "\033[31mAWS Keys did not work!\033[39m\n"
printf "Would you like to continue anyway (y/N)? "
old_stty_cfg=$(stty -g)
stty raw -echo
answer=$(head -c 1)
stty "$old_stty_cfg"
if echo "$answer" | grep -iq "^y"; then
echo Yes
echo "Continuing (be aware things may not work as expected)"
else
echo No
exit
fi
fi
# Set the user that will be used for private authorised endpoints - the user that logs in on the client will be ignored.
# AUTHORIZER is a value detected by serverless offline https://github.com/dherault/serverless-offline#remote-authorizers
# This user is and can be linked in local seed data so that there is user specific relationships.
# Restart the API when this is changed.
export AUTHORIZER='{"claims":{"email":"example@devika.com", "sub":"ed805890-d66b-4126-a5d9-0b22e70fce80"}}'
# Required to install/use local DynamoDB
pnpm run install:dynamodb
# Provides stack trace using source map so the correct file and line numbers are shown
export NODE_OPTIONS=--enable-source-maps
# Start the API with serverless
npx serverless offline start --stage local --region "$REGION" --httpPort 4000 --verbose "$@"
cd "$CURRENT_DIR" || exit

109
packages/api/serverless.yml Normal file
View file

@ -0,0 +1,109 @@
service: ${env:APP_NAME}-api
frameworkVersion: '>=2.0.0 <4.0.0'
plugins:
- serverless-esbuild
- serverless-dynamodb
- serverless-offline
custom:
apiCorsOrigin: '*'
deletionPolicy:
local: Delete
staging: Delete
prod: Retain
updatePolicy:
local: Delete
staging: Delete
prod: Retain
esbuildAWSExclude:
local: ''
staging: '@aws-sdk'
prod: '@aws-sdk'
esbuild:
minify: false
packager: pnpm
keepOutputDirectory: true
sourcemap: linked
exclude:
- ${self:custom.esbuildAWSExclude.${opt:stage}}
watch:
pattern: src/**/*.ts
ignore: >-
scripts/**/* .build/**/* .dynamodb/**/* .serverless/**/* .esbuild/**/*
node_modules/**/*
serverless-dynamodb:
stages:
- local
start:
port: 8000
inMemory: true
migrate: true
seed: true
seed:
local:
sources:
- table: ${env:APP_NAME}-${opt:stage}-admin
sources:
- ./src/baseblocks/admin/admin.seed.json
- table: ${env:APP_NAME}-${opt:stage}-chart
sources:
- ./src/baseblocks/chart/chart.seed.json
package:
individually: true
provider:
name: aws
deploymentMethod: direct
runtime: nodejs20.x
profile: ${env:AWS_PROFILE}
stage: ${opt:stage}
stackTags:
AppName: ${env:APP_NAME}
Stage: ${opt:stage}
Region: ${opt:region}
Product: Baseline
timeout: 30
architecture: arm64
memorySize: 2048
logRetentionInDays: 90
versionFunctions: false
endpointType: REGIONAL
environment:
APP_NAME: ${env:APP_NAME}
NODE_OPTIONS: '--enable-source-maps'
NODE_ENV: ${opt:stage}
API_REGION: ${opt:region}
API_CORS_ORIGIN: ${self:custom.apiCorsOrigin}
COGNITO_USER_POOL_ID:
Ref: CognitoUserPool
apiGateway:
minimumCompressionSize: 1024
iam:
role:
statements:
- Effect: Allow
Action:
- dynamodb:Query
- dynamodb:Scan
- dynamodb:GetItem
- dynamodb:PutItem
- dynamodb:UpdateItem
- dynamodb:DeleteItem
- dynamodb:BatchGetItem
Resource:
- !Sub ${adminTable.Arn}
- !Sub ${adminTable.Arn}/index/*
- !Sub ${chartTable.Arn}
- !Sub ${chartTable.Arn}/index/*
- Effect: Allow
Action:
- cognito-idp:AdminCreateUser
- cognito-idp:AdminGetUser
- cognito-idp:ListUsers
Resource:
- !Sub ${CognitoUserPool.Arn}
resources:
- ${file(./src/baseblocks/cognito/cognito-resources.yml)}
- ${file(./src/baseblocks/admin/admin-dynamodb.yml)}
- ${file(./src/baseblocks/chart/chart-dynamodb.yml)}
functions:
- ${file(./src/baseblocks/admin/admin-functions.yml)}
- ${file(./src/baseblocks/chart/chart-functions.yml)}

View file

@ -0,0 +1,181 @@
import { Response } from 'express';
import { AdminMapper } from './admin';
import { isAdmin } from '../../middleware/is-admin';
import {
createUser,
getUserAttributesByEmail,
} from '../cognito/cognito.service';
import { RequestContext } from '../../util/request-context.type';
import { Admin } from '@baseline/types/admin';
import { getErrorMessage } from '../../util/error-message';
import createApp from '../../util/express-app';
import createAuthenticatedHandler from '../../util/create-authenticated-handler';
import { adminService } from './admin.service';
const app = createApp();
// app.use(isAdmin); // All private endpoints require the user to be an admin
export const handler = createAuthenticatedHandler(app);
app.patch('/admin', [
isAdmin,
async (req: RequestContext, res: Response) => {
try {
const { userEmail, userSub } = req.body as Admin;
const adminData: Partial<Admin> = {
userSub: userSub,
userEmail: userEmail.toLowerCase(),
};
const admin = await adminService.update(adminData);
res.json(AdminMapper(admin));
} catch (error) {
const message = getErrorMessage(error);
console.error(`Failed to update admin: ${message}`);
res.status(400).json({
error: 'Failed to update admin',
});
}
},
]);
app.post('/admin', [
isAdmin,
async (req: RequestContext, res: Response) => {
try {
const { userEmail } = req.body as Admin;
if (!userEmail) {
res.status(400).json({
error: 'No email given',
});
return;
}
// do not attempt to create user if there is a db record for them already
const allAdmins = await adminService.getAll();
const existingAdmin = allAdmins.find(
(admin) => admin.userEmail === userEmail,
);
if (existingAdmin) {
console.log('Admin user already exists');
res.json(AdminMapper(existingAdmin));
return;
}
// determine if email is used in cognito already
let existingUserSub = '';
try {
const userAttributes = await getUserAttributesByEmail(userEmail);
existingUserSub = userAttributes?.sub || '';
} catch (error) {
console.log(error);
}
// when there is an existing cognito user all we need to do is create the db record
if (existingUserSub) {
console.log('Existing cognito user found, adding to db');
const adminData: Partial<Admin> = {
userSub: existingUserSub,
userEmail: userEmail.toLowerCase(),
};
const admin = await adminService.create(adminData);
res.json(AdminMapper(admin));
return;
}
// if there is no existing user create cognito user and db record
if (!existingUserSub) {
console.log('No existing cognito user, creating one');
const userAttributes = await createUser(userEmail);
if (!userAttributes?.sub) {
throw new Error('No user sub after create');
}
const adminData: Partial<Admin> = {
userSub: userAttributes?.sub,
userEmail: userEmail,
};
const admin = await adminService.create(adminData);
res.json(AdminMapper(admin));
return;
}
console.error(`Failed to delete admin`);
res.status(400).json({
error: 'Failed to create admin',
});
} catch (error) {
const message = getErrorMessage(error);
console.error(`Failed to create admin: ${message}`);
res.status(400).json({
error:
'Failed to create admin, if working offline please edit serverless.yml',
});
}
},
]);
app.delete('/admin/:userSub', [
isAdmin,
async (req: RequestContext, res: Response) => {
try {
const userSub = req.params.userSub;
await adminService.delete(userSub);
res.status(200);
res.send();
} catch (error) {
const message = getErrorMessage(error);
console.error(`Failed to delete admin: ${message}`);
res.status(400).json({
error: 'Failed to delete admin',
});
}
},
]);
app.get('/admin/list', [
isAdmin,
async (req: RequestContext, res: Response) => {
try {
const admins = await adminService.getAll();
const formattedAdmins = admins.map((data) => AdminMapper(data));
res.json(formattedAdmins);
} catch (error) {
const message = getErrorMessage(error);
console.error(`Failed to get admins: ${message}`);
res.status(400).json({
error: 'Failed to get admins',
});
}
},
]);
app.get('/admin/:userId', [
isAdmin,
async (req: RequestContext, res: Response) => {
try {
const admin = await adminService.get(req.params.userId);
res.json(AdminMapper(admin));
} catch (error) {
const message = getErrorMessage(error);
console.error(`Failed to get admin: ${message}`);
res.status(400).json({
error: 'Failed to get admin',
});
}
},
]);
app.get('/admin', [
async (req: RequestContext, res: Response) => {
try {
const admin = await adminService.get(req.currentUserSub);
res.json(AdminMapper(admin));
} catch (error) {
const message = getErrorMessage(error);
console.error(`Failed to get admin: ${message}`);
res.status(400).json({
error: 'Failed to get admin',
});
}
},
]);

View file

@ -0,0 +1,16 @@
Resources:
adminTable:
Type: AWS::DynamoDB::Table
DeletionPolicy: ${self:custom.deletionPolicy.${opt:stage}}
UpdateReplacePolicy: ${self:custom.updatePolicy.${opt:stage}}
Properties:
TableName: ${env:APP_NAME}-${opt:stage}-admin
AttributeDefinitions:
- AttributeName: userSub
AttributeType: S
KeySchema:
- AttributeName: userSub
KeyType: HASH
BillingMode: PAY_PER_REQUEST
PointInTimeRecoverySpecification:
PointInTimeRecoveryEnabled: true

View file

@ -0,0 +1,37 @@
ApiAdmin:
handler: src/baseblocks/admin/admin-api.handler
events:
- http:
path: /admin/{any+}
method: ANY
authorizer: # https://www.serverless.com/framework/docs/providers/aws/events/apigateway#http-endpoints-with-aws_iam-authorizers
type: COGNITO_USER_POOLS
authorizerId:
Ref: ApiGatewayAuthorizer
cors:
origin: ${self:custom.apiCorsOrigin}
headers:
- Content-Type
- X-Amz-Date
- Authorization
- X-Api-Key
- X-Amz-Security-Token
- X-Amz-User-Agent
allowCredentials: false
- http:
path: /admin
method: ANY
authorizer: # https://www.serverless.com/framework/docs/providers/aws/events/apigateway#http-endpoints-with-aws_iam-authorizers
type: COGNITO_USER_POOLS
authorizerId:
Ref: ApiGatewayAuthorizer
cors:
origin: ${self:custom.apiCorsOrigin}
headers:
- Content-Type
- X-Amz-Date
- Authorization
- X-Api-Key
- X-Amz-Security-Token
- X-Amz-User-Agent
allowCredentials: false

View file

@ -0,0 +1,14 @@
[
{
"userSub": "ed805890-d66b-4126-a5d9-0b22e70fce80",
"userEmail": "example@devika.com"
},
{
"userSub": "ed805890-d66b-4126-a5d9-0b22e70fce81",
"userEmail": "example+1@devika.com"
},
{
"userSub": "ed805890-d66b-4126-a5d9-0b22e70fce82",
"userEmail": "example+2@devika.com"
}
]

View file

@ -0,0 +1,27 @@
import { Admin } from '@baseline/types/admin';
import { getErrorMessage } from '../../util/error-message';
import { getDynamodbConnection } from '@baselinejs/dynamodb';
import { ServiceObject } from '../../util/service-object';
const dynamoDb = getDynamodbConnection({
region: `${process.env.API_REGION}`,
});
export const adminService = new ServiceObject<Admin>({
dynamoDb: dynamoDb,
objectName: 'Admin',
table: `${process.env.APP_NAME}-${process.env.NODE_ENV}-admin`,
primaryKey: 'userSub',
});
export const isAdminSub = async (userSub: string): Promise<boolean> => {
console.log(`Is ${userSub} Admin`);
try {
const admin = await adminService.get(userSub);
return !!admin?.userSub;
} catch (error) {
const message = getErrorMessage(error);
console.error(`Failed to check if admin: ${message}`);
return false;
}
};

View file

@ -0,0 +1,9 @@
import { Admin } from '@baseline/types/admin';
export const AdminMapper = (data: Admin): Admin => {
const admin: Admin = {
userSub: data?.userSub,
userEmail: data?.userEmail,
};
return admin;
};

View file

@ -0,0 +1,114 @@
import { Response } from 'express';
import { chartMapper } from './chart';
import { isAdmin } from '../../middleware/is-admin';
import { RequestContext } from '../../util/request-context.type';
import { Chart } from '@baseline/types/chart';
import { getErrorMessage } from '../../util/error-message';
import createApp from '../../util/express-app';
import createAuthenticatedHandler from '../../util/create-authenticated-handler';
import { chartService } from './chart.service';
const app = createApp();
export const handler = createAuthenticatedHandler(app);
app.post('/chart/admin', [
isAdmin,
async (req: RequestContext, res: Response) => {
try {
const { md5, sha256, resourceUri, parentChart, name, comment } =
req.body as Chart;
const chartData: Partial<Chart> = {
md5,
sha256,
resourceUri,
parentChart,
name,
comment,
};
const chart = await chartService.create(chartData);
res.json(chartMapper(chart));
} catch (error) {
const message = getErrorMessage(error);
console.error(`Failed to create chart ${message}`);
res.status(400).json({ error: 'Failed to create chart' });
}
},
]);
app.patch('/chart/admin', [
isAdmin,
async (req: RequestContext, res: Response) => {
try {
const { chartId, md5, sha256, resourceUri, parentChart, name, comment } =
req.body as Chart;
const chartData: Partial<Chart> = {
chartId,
md5,
sha256,
resourceUri,
parentChart,
name,
comment,
};
const chart = await chartService.update(chartData);
res.json(chartMapper(chart));
} catch (error) {
const message = getErrorMessage(error);
console.error(`Failed to update chart: ${message}`);
res.status(400).json({
error: 'Failed to update chart',
});
}
},
]);
app.delete('/chart/admin/:chartId', [
isAdmin,
async (req: RequestContext, res: Response) => {
try {
const chartId = req.params.chartId;
await chartService.delete(chartId);
res.status(200);
res.send();
} catch (error) {
const message = getErrorMessage(error);
console.error(`Failed to delete chart: ${message}`);
res.status(400).json({
error: 'Failed to delete chart',
});
}
},
]);
app.get('/chart/admin/list', [
isAdmin,
async (req: RequestContext, res: Response) => {
try {
const charts = await chartService.getAll();
const formattedCharts = charts.map(chartMapper);
res.json(formattedCharts);
} catch (error) {
const message = getErrorMessage(error);
console.error(`Failed to get charts: ${message}`);
res.status(400).json({
error: 'Failed to get charts',
});
}
},
]);
app.get('/chart/admin/:chartId', [
isAdmin,
async (req: RequestContext, res: Response) => {
try {
const chart = await chartService.get(req.params.chartId);
res.json(chartMapper(chart));
} catch (error) {
const message = getErrorMessage(error);
console.error(`Failed to get chart: ${message}`);
res.status(400).json({
error: 'Failed to get chart',
});
}
},
]);

View file

@ -0,0 +1,41 @@
Resources:
chartTable:
Type: AWS::DynamoDB::Table
DeletionPolicy: ${self:custom.deletionPolicy.${opt:stage}}
UpdateReplacePolicy: ${self:custom.updatePolicy.${opt:stage}}
Properties:
TableName: ${env:APP_NAME}-${opt:stage}-chart
AttributeDefinitions:
- AttributeName: chartId
AttributeType: S
- AttributeName: parentChart
AttributeType: S
- AttributeName: md5
AttributeType: S
- AttributeName: sha256
AttributeType: S
KeySchema:
- AttributeName: chartId
KeyType: HASH
BillingMode: PAY_PER_REQUEST
PointInTimeRecoverySpecification:
PointInTimeRecoveryEnabled: true
GlobalSecondaryIndexes:
- IndexName: parentChart-index
KeySchema:
- AttributeName: parentChart
KeyType: HASH
Projection:
ProjectionType: ALL
- IndexName: md5-index
KeySchema:
- AttributeName: md5
KeyType: HASH
Projection:
ProjectionType: ALL
- IndexName: sha256-index
KeySchema:
- AttributeName: sha256
KeyType: HASH
Projection:
ProjectionType: ALL

View file

@ -0,0 +1,67 @@
ApiChartAdmin:
handler: src/baseblocks/chart/chart-admin-api.handler
events:
- http:
path: /chart/admin/{any+}
method: ANY
authorizer: # https://www.serverless.com/framework/docs/providers/aws/events/apigateway#http-endpoints-with-aws_iam-authorizers
type: COGNITO_USER_POOLS
authorizerId:
Ref: ApiGatewayAuthorizer
cors:
origin: ${self:custom.apiCorsOrigin}
headers:
- Content-Type
- X-Amz-Date
- Authorization
- X-Api-Key
- X-Amz-Security-Token
- X-Amz-User-Agent
allowCredentials: false
- http:
path: /chart/admin
method: ANY
authorizer: # https://www.serverless.com/framework/docs/providers/aws/events/apigateway#http-endpoints-with-aws_iam-authorizers
type: COGNITO_USER_POOLS
authorizerId:
Ref: ApiGatewayAuthorizer
cors:
origin: ${self:custom.apiCorsOrigin}
headers:
- Content-Type
- X-Amz-Date
- Authorization
- X-Api-Key
- X-Amz-Security-Token
- X-Amz-User-Agent
allowCredentials: false
ApiChartPublic:
handler: src/baseblocks/chart/chart-public-api.handler
events:
- http:
path: /chart/public/{any+}
method: ANY
cors:
origin: ${self:custom.apiCorsOrigin}
headers:
- Content-Type
- X-Amz-Date
- Authorization
- X-Api-Key
- X-Amz-Security-Token
- X-Amz-User-Agent
allowCredentials: false
- http:
path: /chart/public
method: ANY
cors:
origin: ${self:custom.apiCorsOrigin}
headers:
- Content-Type
- X-Amz-Date
- Authorization
- X-Api-Key
- X-Amz-Security-Token
- X-Amz-User-Agent
allowCredentials: false

View file

@ -0,0 +1,43 @@
import createApp from '../../util/express-app';
import createAuthenticatedHandler from '../../util/create-authenticated-handler';
import { RequestContext } from '../../util/request-context.type';
import { Response } from 'express';
import { getErrorMessage } from '../../util/error-message';
import { getChartsByMd5, getChartsBySha256 } from './chart.service';
import { flatten, keyBy } from 'lodash-es';
const app = createApp();
export const handler = createAuthenticatedHandler(app);
app.post('/chart/public', [
async (req: RequestContext, res: Response) => {
try {
const { md5 = [], sha256 = [] } = req.body as {
md5: string[];
sha256: string[];
};
/** @TODO Ask herman how to batch query */
const md5Charts = flatten(
await Promise.all(md5.map((md5) => getChartsByMd5(md5))),
);
const md5ChartsKeyedByMd5 = keyBy(md5Charts, 'md5');
console.log(md5Charts);
const sha256Charts = flatten(
await Promise.all(sha256.map((sha256) => getChartsBySha256(sha256))),
);
const sha256ChartsKeyedBySha256 = keyBy(sha256Charts, 'sha256');
res.json({
md5: md5ChartsKeyedByMd5,
sha256: sha256ChartsKeyedBySha256,
});
} catch (error) {
const message = getErrorMessage(error);
console.error(`Failed to get charts ${message}`);
res.status(400).json({ error: 'Failed to get charts' });
}
},
]);

View file

@ -0,0 +1,3 @@
[
]

View file

@ -0,0 +1,65 @@
import { Chart } from '@baseline/types/chart';
import { getDynamodbConnection, queryItems } from '@baselinejs/dynamodb';
import { ServiceObject } from '../../util/service-object';
import { getErrorMessage } from '../../util/error-message';
const dynamoDb = getDynamodbConnection({
region: `${process.env.API_REGION}`,
});
export const chartService = new ServiceObject<Chart>({
dynamoDb: dynamoDb,
objectName: 'Chart',
table: `${process.env.APP_NAME}-${process.env.NODE_ENV}-chart`,
primaryKey: 'chartId',
});
export const getChartsByParentChartId = async (
parentChartId: string,
): Promise<Chart[]> => {
try {
return await queryItems<Chart>({
dynamoDb: chartService.dynamoDb,
table: chartService.table,
keyName: 'parentChart',
keyValue: parentChartId,
indexName: 'parentChart-index',
});
} catch (error) {
const message = getErrorMessage(error);
console.error(`Failed to get ${chartService.objectName}: ${message}`);
throw new Error(message);
}
};
export const getChartsByMd5 = async (md5: string): Promise<Chart[]> => {
try {
return await queryItems<Chart>({
dynamoDb: chartService.dynamoDb,
table: chartService.table,
keyName: 'md5',
keyValue: md5,
indexName: 'md5-index',
});
} catch (error) {
const message = getErrorMessage(error);
console.error(`Failed to get ${chartService.objectName}: ${message}`);
throw new Error(message);
}
};
export const getChartsBySha256 = async (sha256: string): Promise<Chart[]> => {
try {
return await queryItems<Chart>({
dynamoDb: chartService.dynamoDb,
table: chartService.table,
keyName: 'sha256',
keyValue: sha256,
indexName: 'sha256-index',
});
} catch (error) {
const message = getErrorMessage(error);
console.error(`Failed to get ${chartService.objectName}: ${message}`);
throw new Error(message);
}
};

View file

@ -0,0 +1,14 @@
import { Chart } from '@baseline/types/chart';
export const chartMapper = (data: Chart): Chart => {
const chart: Chart = {
chartId: data?.chartId,
md5: data?.md5,
sha256: data?.sha256,
resourceUri: data?.resourceUri,
parentChart: data?.parentChart,
name: data?.name,
comment: data?.comment,
};
return chart;
};

View file

@ -0,0 +1,105 @@
Resources:
CognitoUserPool:
Type: AWS::Cognito::UserPool
Properties:
# Generate a name based on the stage
UserPoolName: ${env:APP_NAME}-${opt:stage}-user-pool
# Set email as an alias
UsernameAttributes:
- email
AutoVerifiedAttributes:
- email
UsernameConfiguration:
CaseSensitive: false
CognitoUserPoolClient:
Type: AWS::Cognito::UserPoolClient
Properties:
# Generate an app client name based on the stage
ClientName: ${env:APP_NAME}-${opt:stage}-user-pool-client
UserPoolId:
Ref: CognitoUserPool
ExplicitAuthFlows:
- ADMIN_NO_SRP_AUTH
- USER_PASSWORD_AUTH
GenerateSecret: false
PreventUserExistenceErrors: ENABLED
# The federated identity for our user pool to auth with
CognitoIdentityPool:
Type: AWS::Cognito::IdentityPool
Properties:
# Generate a name based on the stage
IdentityPoolName: ${env:APP_NAME}-${opt:stage}-identity-pool
# Don't allow unathenticated users
AllowUnauthenticatedIdentities: false
# Link to our User Pool
CognitoIdentityProviders:
- ClientId:
Ref: CognitoUserPoolClient
ProviderName:
Fn::GetAtt: ['CognitoUserPool', 'ProviderName']
# IAM roles
CognitoIdentityPoolRoles:
Type: AWS::Cognito::IdentityPoolRoleAttachment
Properties:
IdentityPoolId:
Ref: CognitoIdentityPool
Roles:
authenticated:
Fn::GetAtt: [CognitoAuthRole, Arn]
# IAM role used for authenticated users
CognitoAuthRole:
Type: AWS::IAM::Role
Properties:
Path: /
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: 'Allow'
Principal:
Federated: 'cognito-identity.amazonaws.com'
Action:
- 'sts:AssumeRoleWithWebIdentity'
Condition:
StringEquals:
'cognito-identity.amazonaws.com:aud':
Ref: CognitoIdentityPool
'ForAnyValue:StringLike':
'cognito-identity.amazonaws.com:amr': authenticated
# API Gateway authorizer using Cognito
ApiGatewayAuthorizer:
Type: AWS::ApiGateway::Authorizer
Properties:
Name: ${env:APP_NAME}-${opt:stage}-api-gateway-authorizer
Type: COGNITO_USER_POOLS
IdentitySource: method.request.header.Authorization
RestApiId:
Ref: ApiGatewayRestApi
ProviderARNs:
- Fn::GetAtt:
- CognitoUserPool
- Arn
Outputs:
UserPoolId:
Description: 'Cognito UserPoolId'
Value:
Ref: CognitoUserPool
UserPoolClientId:
Description: 'Cognito UserPoolClientId'
Value:
Ref: CognitoUserPoolClient
IdentityPoolId:
Description: 'Cognito IdentityPoolId'
Value:
Ref: CognitoIdentityPool
CognitoAuthRole:
Description: 'Cognito CognitoAuthRole'
Value:
Ref: CognitoAuthRole

View file

@ -0,0 +1,151 @@
import * as AWS_CognitoIdentityServiceProvider from '@aws-sdk/client-cognito-identity-provider';
const { CognitoIdentityProvider: CognitoIdentityServiceProvider } =
AWS_CognitoIdentityServiceProvider;
const cognito = new CognitoIdentityServiceProvider({
region: process.env.API_REGION || 'ap-southeast-2',
});
export async function getUserAttributesByEmail(userEmail: string) {
try {
const formattedEmail = userEmail?.toLowerCase();
const existingResponse = await cognito.adminGetUser({
UserPoolId: `${process.env.COGNITO_USER_POOL_ID}`,
Username: `${formattedEmail}`,
});
console.log(JSON.stringify(existingResponse, null, 2));
const attributes = existingResponse?.UserAttributes?.reduce(
(prev, attr) => {
if (attr?.Name) {
prev[attr.Name] = `${attr.Value}`;
}
return prev;
},
{} as { [key: string]: string },
);
return attributes;
} catch (error) {
console.log('No user found: ', error);
}
}
export async function createUser(userEmail: string) {
try {
const formattedEmail = userEmail?.toLowerCase();
const userAttributes = [
{
Name: 'email',
Value: formattedEmail,
},
{
Name: 'email_verified',
Value: 'true',
},
];
const cognitoUser = await cognito.adminCreateUser({
UserPoolId: process.env.COGNITO_USER_POOL_ID,
Username: formattedEmail,
UserAttributes: userAttributes,
DesiredDeliveryMediums: ['EMAIL'],
});
console.log(JSON.stringify(cognitoUser, null, 2));
const attributes = cognitoUser.User?.Attributes?.reduce((prev, attr) => {
if (attr.Name) {
prev[attr.Name] = `${attr.Value}`;
}
return prev;
}, {} as { [key: string]: string });
return attributes;
} catch (error) {
console.log('Failed to create cognito user', error);
}
}
export async function getUsers(args: {
subFilter?: string;
usernameFilter?: string;
emailFilter?: string;
phoneNumberFilter?: string;
nameFilter?: string;
givenNameFilter?: string;
familyNameFilter?: string;
pageSize?: number;
paginationToken?: string;
isExactMatch?: boolean;
}): Promise<
AWS_CognitoIdentityServiceProvider.ListUsersCommandOutput | undefined
> {
const {
subFilter,
usernameFilter,
emailFilter,
nameFilter,
givenNameFilter,
familyNameFilter,
phoneNumberFilter,
pageSize,
paginationToken,
isExactMatch,
} = args;
try {
const requestArgs = {
UserPoolId: process.env.COGNITO_USER_POOL_ID,
Limit: pageSize || 50,
PaginationToken: paginationToken,
} as AWS_CognitoIdentityServiceProvider.ListUsersCommandInput;
let field = '';
let value = '';
const comparison = `${isExactMatch ? '' : '^'}=`;
if (subFilter) {
field = 'sub';
value = subFilter;
}
if (usernameFilter) {
field = 'username';
value = usernameFilter;
}
if (emailFilter) {
field = 'email';
value = emailFilter;
}
if (phoneNumberFilter) {
field = 'phone_number';
value = phoneNumberFilter;
}
if (nameFilter) {
field = 'name';
value = nameFilter;
}
if (givenNameFilter) {
field = 'given_name';
value = givenNameFilter;
}
if (familyNameFilter) {
field = 'family_name';
value = familyNameFilter;
}
if (field) {
requestArgs.Filter = `${field} ${comparison} "${value}"`;
}
const response = await cognito.listUsers(requestArgs);
return response;
} catch (error) {
console.log('No users found: ', error);
}
}

View file

@ -0,0 +1,19 @@
import { NextFunction, Response } from 'express';
import { isAdminSub } from '../baseblocks/admin/admin.service';
import { RequestContext } from '../util/request-context.type';
export const isAdmin = async (
req: RequestContext,
res: Response,
next: NextFunction,
) => {
const userSub = req.currentUserSub;
const isAdmin = await isAdminSub(userSub);
if (!isAdmin) {
res.status(403).json({
error: 'User does not have permission',
});
return;
}
next();
};

View file

@ -0,0 +1,11 @@
import { NextFunction, Response } from 'express';
import { RequestContext } from '../util/request-context.type';
export const logRoute = (
req: RequestContext,
res: Response,
next: NextFunction,
) => {
console.log(`Request: ${req.method} ${req.originalUrl}`);
next();
};

View file

@ -0,0 +1,19 @@
import serverless from 'serverless-http';
import { APIGatewayProxyEventBase } from 'aws-lambda';
import { Authorizer, RequestContext } from './request-context.type';
import { Application } from 'express';
const createAuthenticatedHandler = (app: Application) => {
const handler = serverless(app, {
request(
request: RequestContext,
event: APIGatewayProxyEventBase<Authorizer>,
) {
request.context = event.requestContext;
request.currentUserSub = `${request.context?.authorizer?.claims?.sub}`;
},
});
return handler;
};
export default createAuthenticatedHandler;

View file

@ -0,0 +1,9 @@
export function getErrorMessage(error: unknown) {
let message: string;
if (error instanceof Error) {
message = error.message;
} else {
message = String(error);
}
return message;
}

View file

@ -0,0 +1,22 @@
import compression from 'compression';
import cors from 'cors';
import express, { Application } from 'express';
import { logRoute } from '../middleware/log-route';
const createApp = (): Application => {
const corsOptions = {
origin: process.env.API_CORS_ORIGIN,
optionsSuccessStatus: 200, // some legacy browsers (IE11, various SmartTVs) choke on 204
};
const app = express();
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
app.use(compression());
app.use(cors(corsOptions));
app.options('*', cors(corsOptions));
app.use(logRoute);
return app;
};
export default createApp;

View file

@ -0,0 +1,23 @@
import { PagedResponse } from '@baseline/types/paging';
export interface PageDataParams<T> {
items: T[];
limit: string | number;
offset: string | number;
}
export function pageData<T>(params: PageDataParams<T>): PagedResponse<T> {
const limit = parseInt(`${params.limit}`) || 10;
const offset = parseInt(`${params.offset}`) || 0;
const items = params.items;
return {
data: items.slice(offset, offset + limit),
pagination: {
limit: limit,
totalRecords: items.length,
nextFrom: offset + limit < items.length ? offset + limit : undefined,
pages: parseInt(`${Math.ceil(items.length / limit) - 1}`),
currentPage: parseInt(`${offset / limit}`),
},
};
}

View file

@ -0,0 +1,15 @@
import {
APIGatewayEventDefaultAuthorizerContext,
APIGatewayEventRequestContextWithAuthorizer,
} from 'aws-lambda';
import { Request } from 'express';
export type RequestContext = Request & {
context: APIGatewayEventRequestContextWithAuthorizer<Authorizer>;
} & {
currentUserSub: string;
};
export type Authorizer = APIGatewayEventDefaultAuthorizerContext & {
claims: { email?: string; sub?: string };
};

View file

@ -0,0 +1,178 @@
import { getErrorMessage } from './error-message';
import {
batchGetItems,
deleteItem,
getItem,
getAllItems,
putItem,
updateItem,
DynamoDbDocumentClient,
} from '@baselinejs/dynamodb';
import { randomUUID } from 'crypto';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export class ServiceObject<T extends Record<string, any>> {
table: string;
dynamoDb: DynamoDbDocumentClient;
objectName: string;
primaryKey: string;
ownerField: string | undefined;
constructor(params: {
table: string;
objectName: string;
dynamoDb: DynamoDbDocumentClient;
primaryKey: string;
ownerField?: string;
}) {
this.dynamoDb = params.dynamoDb;
this.table = params.table;
this.objectName = params.objectName;
this.primaryKey = params.primaryKey;
this.ownerField = params.ownerField;
}
async getAll(): Promise<T[]> {
console.log(`Get all ${this.objectName} records`);
try {
if (!this.dynamoDb) {
throw new Error('DynamoDB not connected');
}
return getAllItems<T>({
dynamoDb: this.dynamoDb,
table: this.table,
});
} catch (error) {
const message = getErrorMessage(error);
console.error(`Failed to get ${this.objectName}: ${message}`);
throw new Error(message);
}
}
async get(key: string): Promise<T> {
console.log(`Get ${this.objectName} by ${this.primaryKey} [${key}]`);
try {
if (!this.dynamoDb) {
throw new Error('DynamoDB not connected');
}
return await getItem<T>({
dynamoDb: this.dynamoDb,
table: this.table,
key: {
[this.primaryKey]: `${key}`,
},
});
} catch (error) {
const message = getErrorMessage(error);
console.error(`Failed to get ${this.objectName}: ${message}`);
throw new Error(message);
}
}
async create(record: Partial<T>): Promise<T> {
console.log(`Create ${this.objectName}`);
try {
if (!this.dynamoDb) {
throw new Error('DynamoDB not connected');
}
const item: Partial<T> = {
[this.primaryKey]: randomUUID(),
...record,
};
return await putItem<T>({
dynamoDb: this.dynamoDb,
table: this.table,
item: item as T,
});
} catch (error) {
const message = getErrorMessage(error);
console.error(`Failed to create ${this.objectName}: ${message}`);
throw new Error(message);
}
}
async update(record: Partial<T>): Promise<T> {
console.log(
`Update ${this.objectName} ${this.primaryKey} [${
record[this.primaryKey]
}]`,
);
try {
if (!this.dynamoDb) {
throw new Error('DynamoDB not connected');
}
if (!record[this.primaryKey]) {
throw new Error(`Cannot update without ${this.primaryKey}`);
}
const partial = {} as Partial<T>;
Object.keys(record).forEach((key: keyof T) => {
if (key !== this.primaryKey) {
partial[key] = record[key];
}
});
return await updateItem<T>({
dynamoDb: this.dynamoDb,
table: this.table,
key: {
[this.primaryKey]: `${record[this.primaryKey]}`,
},
fields: partial,
});
} catch (error) {
const message = getErrorMessage(error);
console.error(`Failed to update ${this.objectName}: ${message}`);
throw new Error(message);
}
}
async delete(keyValue: string): Promise<boolean> {
console.log(`Delete ${this.objectName} ${keyValue}`);
try {
if (!this.dynamoDb) {
throw new Error('DynamoDB not connected');
}
return await deleteItem({
dynamoDb: this.dynamoDb,
table: this.table,
key: {
[this.primaryKey]: keyValue,
},
});
} catch (error) {
const message = getErrorMessage(error);
console.error(`Failed to delete ${this.objectName}: ${message}`);
throw new Error(message);
}
}
async batchGet(ids: string[]): Promise<T[]> {
console.log(`Getting all ${this.objectName} by ids`);
try {
if (!this.dynamoDb) {
throw new Error('DynamoDB not connected');
}
const keys = ids.map((id) => {
return {
[this.primaryKey]: id,
};
});
return await batchGetItems<T>({
dynamoDb: this.dynamoDb,
keys: keys,
table: this.table,
});
} catch (error) {
const message = getErrorMessage(error);
console.error(`Failed to batch get ${this.objectName}: ${message}`);
throw new Error(message);
}
}
async isOwner(id: string, userSub: string) {
if (this.ownerField) {
const record = await this.get(id);
return record[this.ownerField] === userSub;
}
return false;
}
}

View file

@ -0,0 +1,27 @@
{
"compilerOptions": {
"preserveConstEnums": true,
"strictNullChecks": true,
"sourceMap": true,
"allowJs": true,
"module": "CommonJS",
"target": "es5",
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"lib": ["es2015"],
"rootDirs": ["./"],
"outDir": ".esbuild"
},
"include": ["../../shared/types", "src"],
"watchOptions": {
"excludeDirectories": [
"node_modules",
".esbuild",
".dynamodb",
".serverless",
"scripts"
]
}
}