Init
Some checks failed
Build & Lint / build-lint (push) Failing after 2m10s
Deploy / setup (push) Failing after 1m3s

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

26
.eslintrc.js Normal file
View File

@ -0,0 +1,26 @@
module.exports = {
root: true,
parser: '@typescript-eslint/parser',
parserOptions: {
tsconfigRootDir: __dirname,
project: [
'./tsconfig.json',
'./packages/web/tsconfig.json',
'./packages/api/tsconfig.json',
'./packages/admin/tsconfig.json',
],
},
plugins: ['@typescript-eslint'],
extends: [
'prettier',
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:@typescript-eslint/recommended-requiring-type-checking',
],
ignorePatterns: ['.eslintrc.js'],
rules: {
'@typescript-eslint/unbound-method': 'off',
'@typescript-eslint/restrict-template-expressions': 'off',
'@typescript-eslint/no-unsafe-assignment': 'warn',
},
};

59
.github/workflows/build-lint.yml vendored Normal file
View File

@ -0,0 +1,59 @@
name: Build & Lint
on:
push:
branches: [ '**' ]
pull_request:
branches: [ '**' ]
workflow_dispatch:
permissions:
id-token: write
contents: read
jobs:
build-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Project Variables
run: ./scripts/project-variables-github.sh
- uses: actions/setup-node@v4
with:
node-version: 20
- uses: pnpm/action-setup@v3
with:
version: 9
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- uses: actions/cache@v4
name: Setup pnpm cache
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install
- name: AWS Assume Role
id: aws-assume
uses: aws-actions/configure-aws-credentials@v4
with:
aws-region: ${{ env.AWS_REGION }}
role-to-assume: arn:aws:iam::123456789123:role/GitHub-Deploy
output-credentials: true
- name: AWS Configure
id: aws-configure
shell: bash
run: |
echo "Configuring AWS Profile ${{ env.AWS_PROFILE_GITHUB }}"
aws configure set region ${{ env.AWS_REGION }} --profile ${{ env.AWS_PROFILE_GITHUB }}
aws configure set aws_access_key_id ${{ env.AWS_ACCESS_KEY_ID }} --profile ${{ env.AWS_PROFILE_GITHUB }}
aws configure set aws_secret_access_key ${{ env.AWS_SECRET_ACCESS_KEY }} --profile ${{ env.AWS_PROFILE_GITHUB }}
aws configure set aws_session_token ${{ env.AWS_SESSION_TOKEN }} --profile ${{ env.AWS_PROFILE_GITHUB }}
aws sts get-caller-identity --query "Account" --output text --profile "${{ env.AWS_PROFILE_GITHUB }}"
- name: Build
run: pnpm build
- name: Lint
run: pnpm lint

70
.github/workflows/deploy.yml vendored Normal file
View File

@ -0,0 +1,70 @@
name: Deploy
on:
push:
branches: [ main, prod ]
pull_request:
branches: [ main, prod ]
workflow_dispatch:
permissions:
id-token: write
contents: read
jobs:
setup:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Project Variables
run: ./scripts/project-variables-github.sh
- uses: actions/setup-node@v4
with:
node-version: 20
- uses: pnpm/action-setup@v3
with:
version: 9
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- uses: actions/cache@v4
name: Setup pnpm cache
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install
- name: Set Role ARN
run: |
if [[ "${{ github.ref }}" == "refs/heads/main" ]]; then
echo "ROLE_ARN=arn:aws:iam::123456789123:role/GitHub-Deploy" >> $GITHUB_ENV
elif [[ "${{ github.ref }}" == "refs/heads/prod" ]]; then
echo "ROLE_ARN=arn:aws:iam::123456789123:role/GitHub-Deploy" >> $GITHUB_ENV
fi
- name: AWS Assume Role
id: aws-assume
uses: aws-actions/configure-aws-credentials@v4
with:
aws-region: ${{ env.AWS_REGION }}
role-to-assume: ${{ env.ROLE_ARN }}
output-credentials: true
- name: AWS Configure
id: aws-configure
shell: bash
run: |
echo "Configuring AWS Profile ${{ env.AWS_PROFILE_GITHUB }}"
aws configure set region ${{ env.AWS_REGION }} --profile ${{ env.AWS_PROFILE_GITHUB }}
aws configure set aws_access_key_id ${{ env.AWS_ACCESS_KEY_ID }} --profile ${{ env.AWS_PROFILE_GITHUB }}
aws configure set aws_secret_access_key ${{ env.AWS_SECRET_ACCESS_KEY }} --profile ${{ env.AWS_PROFILE_GITHUB }}
aws configure set aws_session_token ${{ env.AWS_SESSION_TOKEN }} --profile ${{ env.AWS_PROFILE_GITHUB }}
aws sts get-caller-identity --query "Account" --output text --profile "${{ env.AWS_PROFILE_GITHUB }}"
- name: Deploy
run: |
if [[ ${{ github.ref }} == 'refs/heads/main' ]]; then
pnpm run deploy:staging
elif [[ ${{ github.ref }} == 'refs/heads/prod' ]]; then
pnpm run deploy:prod
fi

66
.gitignore vendored Normal file
View File

@ -0,0 +1,66 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
*/node_modules
**/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
.build
build
dist
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
# local env files
.env.local
.env.development
.env.development.local
.env.test.local
.env.production
.env.production.local
# vercel
.vercel
# serverless
.serverless
.serverless_nextjs
# IDE Specific
.idea
.vscode/*
!.vscode/launch.json
# Setup specific
.gitconfig
# dynamodb
.dynamodb
#esbuild
.esbuild
tsconfig.tsbuildinfo
# eslint
.eslintcache
# vite
.dist
# logs
*.log

8
.prettierrc.js Normal file
View File

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

20
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,20 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "node",
"runtimeVersion": "20",
"request": "launch",
"name": "Debug Local API",
"cwd": "${workspaceFolder}/packages/api",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "debug"],
"sourceMaps": true,
"outFiles": ["${workspaceFolder}/packages/api/.esbuild/src/**/*.js"],
"resolveSourceMapLocations": ["${workspaceFolder}/packages/api/**"]
}
]
}

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Baseline
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

80
README.md Normal file
View File

@ -0,0 +1,80 @@
# BMS Repository
An extremely simple serverless application that manages BMS Zip files. Clients can request download URLs by providing either `md5` or `sha256` of the requested chart.
This service also offers a basic admin dashboard for managing charts in the system.
The system is **not designed** to provide any UI/UX to end users for listing and downloading charts.
This README will be updated as the application is developed.
---
## Local Requirements
These must be installed before following the setup instructions.
- Macos/Linux OS/Windows Subsystem for Linux
- Node.js v20 (v20 or higher) & npm@10.5.0 or higher [(we suggest installing and using nvm)](https://github.com/nvm-sh/nvm#install--update-script) follow instructions in the link to install, you will generally need to create a new terminal session after installing. If you already have nvm update it before proceeding.
- If nvm is installed you can switch versions with `nvm install 20 && nvm use 20 && nvm alias default 20 && nvm install-latest-npm`
- If not using nvm you can manually install node and npm, download from <https://nodejs.org/en/download/> alternatively use the operating system package manager or any other appropriate tool to install node
- Current versions can be checked with `node -v`, `npm -v`
- pnpm version 9, version can be checked with `pnpm -v`, install with `npm install -g pnpm@9` [or other methods](https://pnpm.io/installation)
- [AWS CLI v2](https://aws.amazon.com/cli)
- Homebrew, for Mac users only, follow install instructions [here](https://brew.sh/)
- [jq](https://stedolan.github.io/jq/download/) for extracting Cloudformation outputs
- Install on mac `brew install jq`
- Install on Linux `sudo yum install jq` or `sudo apt-get install jq`, or other appropriate methods
- Java Runtime Environment (JRE) version 8.x or newer, it is only required for [DynamoDB local](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/DynamoDBLocal.html)
- Install on mac `brew install java`
- Install on linux `sudo yum install java` or `sudo apt-get install openjdk-8-jdk`, or other appropriate methods
- DynamoDB local will automatically install in the project when the api is started, java needs to installed before this occurs
- curl which is used in some of the bash scripts, [curl install instructions](https://everything.curl.dev/get)
- An IDE such as [Visual Studio Code](https://code.visualstudio.com/)
- Internet connectivity
- AWS Account <https://aws.amazon.com/getting-started/>
- IAM credentials <https://docs.aws.amazon.com/cli/latest/userguide/getting-started-prereqs.html#getting-started-prereqs-iam> be sure to follow [Security best practices in IAM](https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html)
## Run Locally
If the project is already setup and you are trying run the project locally make sure you run `pnpm run aws:profile` first so that you have the correct credentials for AWS configured locally.
### Commands
Start the api, admin and web in their own terminal windows/tabs with the following commands.
1. `pnpm run generate:env:local` to generate the env files for the frontend clients
2. `pnpm run start:api`
3. `pnpm run start:admin`
### Running locally Limitations
- API, Web & Admin: No S3, you will need to rely on AWS staging S3
- API: No local Cognito Authorizer, the deployed staging cognito can be used (see `packages/api/serverless.yml`) or the payload set by `AUTHORIZER` in `packages/api/scripts/run-api-local.sh`.
- Admin: Cognito UI relies on an active AWS Cognito user pool, use deployed staging
## Deploy a Change
Swap `staging` to `prod` to deploy to production. All environment variables will be automatically set in the process.
1. `pnpm run deploy:staging` to deploy api/web/admin
## Remove Stack
To destroy the deployed cloudformation stacks so it is no longer on AWS run `pnpm run remove:staging`. This will likely destroy all data associated with the application.
# Built using BaselineJS
![1688515420018](https://github.com/Baseline-JS/core/assets/151841910/620fa869-4bca-418d-af2c-3a3f8b2d2719)
BaselineJS is an open-source, fullstack TypeScript, serverless first framework designed to make building cloud native applications easier. Our framework utilizes a combination of modern technologies, architectures and operational processes to help teams to efficiently build and deploy robust applications
If you like BaselineJS give us a ⭐️
[Website](https://baselinejs.com/) |
[Documentation](https://docs.baselinejs.com/) |
[Discord](https://discord.gg/beCj9VDeMm) |
[LinkedIn](https://www.linkedin.com/company/baselinejs) |
[YouTube](https://www.youtube.com/@Baseline-JS)
Startups, want $10k USD of AWS Credits to Get Started? [Apply Here](https://share.hsforms.com/1P6p_G9Q_Q-SJAESAYtLftgqm3as)

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 KiB

287
commands/add-object/add-object.js Executable file
View File

@ -0,0 +1,287 @@
#!/usr/bin/env node
// Template Fields
// - nameFirst
// - nameCamel
// - nameSnakeUpper
// - nameUpper
// - nameLower
// - nameKebab
// - apiCreateFields "field1, field2, field3"
// - apiUpdateFields "blankId, field1, field2, field3"
// - primaryKey "blankId"
// - seedData {"blankId": "", "field1": ""},{"blankId": "", "field1": ""}
// - mapperFields blankId: data?.blankId, field1: data?.field1
// - typeFields blankId: string; field1: string;
const fs = require('fs');
const readlineSync = require('readline-sync');
const YAML = require('js-yaml');
const functionNames = [
'And',
'Base64',
'Cidr',
'Condition',
'Equals',
'FindInMap',
'GetAtt',
'GetAZs',
'If',
'ImportValue',
'Join',
'Not',
'Or',
'Ref',
'Select',
'Split',
'Sub',
];
class CustomTag {
constructor(type, data) {
this.type = type;
this.data = data;
}
}
function yamlType(name, kind) {
const functionName = ['Ref', 'Condition'].includes(name) ? name : `!${name}`;
return new YAML.Type(`${functionName}`, {
kind,
multi: true,
representName: function (object) {
return object.type;
},
represent: function (object) {
return object.data;
},
instanceOf: CustomTag,
construct: function (data, type) {
return new CustomTag(type, data);
},
});
}
function generateTypes() {
const types = functionNames
.map((functionName) =>
['mapping', 'scalar', 'sequence'].map((kind) =>
yamlType(functionName, kind),
),
)
.flat();
return types;
}
const writeServerlessApiYaml = () => {
const yamlTypes = generateTypes();
const schema = YAML.DEFAULT_SCHEMA.extend(yamlTypes);
const serverlessFile = fs.readFileSync(
`${projectRoot}/packages/api/serverless.yml`,
'utf8',
);
const yamlJson = YAML.load(serverlessFile, { schema: schema });
const filenameName = `${toKebabCase(name.toLowerCase())}`;
const newFunction = `\${file(./src/baseblocks/${filenameName}/${filenameName}-functions.yml)}`;
const newResource = `\${file(./src/baseblocks/${filenameName}/${filenameName}-dynamodb.yml)}`;
if (
yamlJson.functions.find((i) => i === newFunction) ||
yamlJson.resources.find((i) => i === newResource)
) {
console.log('Conflicting resource/function in serverless.yml, not saving.');
return;
}
yamlJson.functions.push(newFunction);
yamlJson.resources.push(newResource);
yamlJson.provider.iam.role.statements[0].Resource.push(
new CustomTag('!Sub', `\${${toCamelCase(name)}Table.Arn}`),
new CustomTag('!Sub', `\${${toCamelCase(name)}Table.Arn}/index/*`),
);
yamlJson.custom['serverless-dynamodb'].seed.local.sources.push({
table: `\${env:APP_NAME}-\${opt:stage}-${filenameName}`,
sources: [`./src/baseblocks/${filenameName}/${filenameName}.seed.json`],
});
const yamlResult = YAML.dump(yamlJson, {
schema,
});
fs.writeFileSync(`${projectRoot}/packages/api/serverless.yml`, yamlResult);
};
const toCamelCase = (str) => {
return str
.replace(/(?:^\w|[A-Z]|\b\w)/g, function (word, index) {
return index === 0 ? word.toLowerCase() : word.toUpperCase();
})
.replace(/\s+/g, '');
};
const toKebabCase = (str) =>
str &&
str
.match(/[A-Z]{2,}(?=[A-Z][a-z]+[0-9]*|\b)|[A-Z]?[a-z]+[0-9]*|[A-Z]|[0-9]+/g)
.map((x) => x.toLowerCase())
.join('-');
const cwd = process.cwd();
// console.log(`Current Working Dir: ${cwd}`);
const projectRoot = cwd.split('/commands')[0];
// console.log(`Project Root: ${projectRoot}`);
const templatePath = `${cwd}/template`;
// console.log(`Template Path: ${templatePath}`);
let name = readlineSync.question('What is the name of the new object? ');
console.log(`Creating new object [${name}]`);
// Support multi words in kebab case, pascal case or snake case
if (name.includes('-')) {
name = name.split('-').join(' ');
} else if (name.includes('_')) {
name = name.split('_').join(' ');
} else {
name = name
.replace(/([A-Z][a-z])/g, ' $1')
.replace(/(\d)/g, ' $1')
.trim();
}
const primaryKey = `${toCamelCase(name)}Id`;
const inputFields = [];
var fieldName = '';
do {
console.log('Current Fields:', [
primaryKey,
...inputFields.map((i) => i.name),
]);
fieldName = readlineSync.question('New field name (or enter to finish): ');
if (fieldName) {
const tsTypes = ['string', 'number', 'boolean', 'any', 'string[]'];
const index = readlineSync.keyInSelect(tsTypes, 'Type?');
const tsType = tsTypes[index];
const isRequired = readlineSync.keyInYN('is field required?');
console.log(
`Added field [${fieldName}${isRequired ? '' : '?'}: ${tsType}]\n`,
);
inputFields.push({
name: fieldName,
tsType: tsType,
isRequired: isRequired,
});
}
} while (fieldName);
const fields = inputFields.map((field) => field.name);
const allFields = [primaryKey, ...fields];
let dataTypeFields = ` ${primaryKey}: string;`;
inputFields.forEach((field) => {
dataTypeFields = `${dataTypeFields}\n ${field.name}${
field.isRequired ? '' : '?'
}: ${field.tsType};`;
});
let dataMapperFields = '';
allFields.forEach((field) => {
dataMapperFields = `${dataMapperFields}\n ${field}: data?.${field},`;
});
const data = {
name,
nameFirst: `${name[0].toUpperCase()}${toCamelCase(name.slice(1))}`,
nameCamel: `${toCamelCase(name)}`,
nameSnakeUpper: `${name.replace(/\s/g, '_').toUpperCase()}`,
nameUpper: `${name.toUpperCase()}`,
nameLower: `${name.toLowerCase()}`,
nameKebab: `${toKebabCase(name.toLowerCase())}`,
apiCreateFields: fields.join(', '),
apiUpdateFields: allFields.join(', '),
primaryKey: `${toCamelCase(name)}Id`,
seedData: ``,
mapperFields: dataMapperFields,
typeFields: dataTypeFields,
};
const renderTemplate = (template) => {
let generatedContent = template;
Object.keys(data).forEach((key) => {
generatedContent = generatedContent.replace(
new RegExp(`{{ ${key} }}`, 'g'),
data[key],
);
});
return generatedContent;
};
const apiOutputPath = `${projectRoot}/packages/api/src/baseblocks/${toKebabCase(
name.toLowerCase(),
)}`;
const filenameName = `${toKebabCase(name.toLowerCase())}`;
const files = [
{
templateFile: `${templatePath}/api/blank.ts`,
outputPath: apiOutputPath,
outputFilename: `${apiOutputPath}/${filenameName}.ts`,
},
{
templateFile: `${templatePath}/api/blank.service.ts`,
outputPath: apiOutputPath,
outputFilename: `${apiOutputPath}/${filenameName}.service.ts`,
},
{
templateFile: `${templatePath}/api/blank.seed.json`,
outputPath: apiOutputPath,
outputFilename: `${apiOutputPath}/${filenameName}.seed.json`,
},
{
templateFile: `${templatePath}/api/blank-functions.yml`,
outputPath: apiOutputPath,
outputFilename: `${apiOutputPath}/${filenameName}-functions.yml`,
},
{
templateFile: `${templatePath}/api/blank-dynamodb.yml`,
outputPath: apiOutputPath,
outputFilename: `${apiOutputPath}/${filenameName}-dynamodb.yml`,
},
{
templateFile: `${templatePath}/api/blank-api.ts`,
outputPath: apiOutputPath,
outputFilename: `${apiOutputPath}/${filenameName}-api.ts`,
},
{
templateFile: `${templatePath}/types/blank.d.ts`,
outputPath: `${projectRoot}/shared/types`,
outputFilename: `${projectRoot}/shared/types/${filenameName}.d.ts`,
},
{
templateFile: `${templatePath}/client-api/blank.ts`,
outputPath: `${projectRoot}/shared/client-api`,
outputFilename: `${projectRoot}/shared/client-api/${filenameName}.ts`,
},
];
const fileOperations = async (file) => {
const templateFileData = fs.readFileSync(file.templateFile).toString();
const result = renderTemplate(templateFileData);
await fs.promises.mkdir(file.outputPath, {
recursive: true,
});
console.log(`Creating ${file.outputFilename}`);
fs.writeFileSync(file.outputFilename, result);
};
(async () => {
console.log('Creating files...');
for (let filePos = 0; filePos < files.length; filePos++) {
const file = files[filePos];
await fileOperations(file);
}
console.log('Updating api serverless.yml');
writeServerlessApiYaml();
console.log('Done!');
})();

View File

@ -0,0 +1,14 @@
{
"name": "@baseline/add-object",
"version": "1.0.0",
"main": "add-object.js",
"scripts": {
"start": "./add-object.js",
"lint": "echo 'No linting'",
"pretty": "npx prettier --write '*.{ts,tsx,js,json,css,scss,md,yml,yaml,html}'"
},
"devDependencies": {
"readline-sync": "1.4.10",
"js-yaml": "4.1.0"
}
}

View File

@ -0,0 +1,102 @@
import { Response } from 'express';
import { {{ nameCamel }}Mapper } from './{{ nameKebab }}';
import { isAdmin } from '../../middleware/is-admin';
import { RequestContext } from '../../util/request-context.type';
import { {{ nameFirst }} } from '@baseline/types/{{ nameKebab }}';
import { getErrorMessage } from '../../util/error-message';
import createApp from '../../util/express-app';
import createAuthenticatedHandler from '../../util/create-authenticated-handler';
import { {{ nameCamel }}Service } from './{{ nameKebab }}.service';
const app = createApp();
// app.use(isAdmin); // All private endpoints require the user to be an admin
export const handler = createAuthenticatedHandler(app);
app.post('/{{ nameKebab }}', [
isAdmin,
async (req: RequestContext, res: Response) => {
try {
const { {{ apiCreateFields }} } = req.body as {{ nameFirst }};
const {{ nameCamel }}Data: Partial<{{ nameFirst }}> = {
{{ apiCreateFields }},
};
const {{ nameCamel }} = await {{ nameCamel }}Service.create({{ nameCamel }}Data);
res.json({{ nameCamel }}Mapper({{ nameCamel }}));
} catch (error) {
const message = getErrorMessage(error);
console.error(`Failed to create {{ nameLower }} ${message}`);
res.status(400).json({ error: 'Failed to create {{ nameLower }}' });
}
},
]);
app.patch('/{{ nameKebab }}', [
isAdmin,
async (req: RequestContext, res: Response) => {
try {
const { {{ apiUpdateFields }} } = req.body as {{ nameFirst }};
const {{ nameCamel }}Data: Partial<{{ nameFirst }}> = {
{{ apiUpdateFields }}
};
const {{ nameCamel }} = await {{ nameCamel }}Service.update({{ nameCamel }}Data);
res.json({{ nameCamel }}Mapper({{ nameCamel }}));
} catch (error) {
const message = getErrorMessage(error);
console.error(`Failed to update {{ nameLower }}: ${message}`);
res.status(400).json({
error: 'Failed to update {{ nameLower }}',
});
}
},
]);
app.delete('/{{ nameKebab }}/:{{ primaryKey }}', [
isAdmin,
async (req: RequestContext, res: Response) => {
try {
const {{ primaryKey }} = req.params.{{ primaryKey }};
await {{ nameCamel }}Service.delete({{ primaryKey }});
res.status(200);
res.send();
} catch (error) {
const message = getErrorMessage(error);
console.error(`Failed to delete {{ nameLower }}: ${message}`);
res.status(400).json({
error: 'Failed to delete {{ nameLower }}',
});
}
},
]);
app.get('/{{ nameKebab }}/list', [
isAdmin,
async (req: RequestContext, res: Response) => {
try {
const {{ nameCamel }}s = await {{ nameCamel }}Service.getAll();
const formatted{{ nameFirst }}s = {{ nameCamel }}s.map({{ nameCamel }}Mapper);
res.json(formatted{{ nameFirst }}s);
} catch (error) {
const message = getErrorMessage(error);
console.error(`Failed to get {{ nameLower }}s: ${message}`);
res.status(400).json({
error: 'Failed to get {{ nameLower }}s',
});
}
},
]);
app.get('/{{ nameKebab }}/:{{ primaryKey }}', [
isAdmin,
async (req: RequestContext, res: Response) => {
try {
const {{ nameCamel }} = await {{ nameCamel }}Service.get(req.params.{{ primaryKey }});
res.json({{ nameCamel }}Mapper({{ nameCamel }}));
} catch (error) {
const message = getErrorMessage(error);
console.error(`Failed to get {{ nameLower }}: ${message}`);
res.status(400).json({
error: 'Failed to get {{ nameLower }}',
});
}
},
]);

View File

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

View File

@ -0,0 +1,37 @@
Api{{ nameFirst }}:
handler: src/baseblocks/{{ nameKebab }}/{{ nameKebab }}-api.handler
events:
- http:
path: /{{ nameKebab }}/{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: /{{ nameKebab }}
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,3 @@
[
{{ seedData }}
]

View File

@ -0,0 +1,14 @@
import { {{ nameFirst }} } from '@baseline/types/{{ nameKebab }}';
import { getDynamodbConnection } from '@baselinejs/dynamodb';
import { ServiceObject } from '../../util/service-object';
const dynamoDb = getDynamodbConnection({
region: `${process.env.API_REGION}`,
});
export const {{ nameCamel }}Service = new ServiceObject<{{ nameFirst }}>({
dynamoDb: dynamoDb,
objectName: '{{ nameFirst }}',
table: `${process.env.APP_NAME}-${process.env.NODE_ENV}-{{ nameKebab }}`,
primaryKey: '{{ primaryKey }}',
});

View File

@ -0,0 +1,7 @@
import { {{ nameFirst }} } from '@baseline/types/{{ nameKebab }}';
export const {{ nameCamel }}Mapper = (data: {{ nameFirst }}): {{ nameFirst }} => {
const {{ nameCamel }}: {{ nameFirst }} = {{{ mapperFields }}
};
return {{ nameCamel }};
};

View File

@ -0,0 +1,70 @@
import { {{ nameFirst }} } from '@baseline/types/{{ nameKebab }}';
import { RequestHandler } from './request-handler';
export const get{{ nameFirst }} = async (requestHandler: RequestHandler, {{ nameCamel }}Id: string): Promise<{{ nameFirst }}> => {
const response = await requestHandler.request<{{ nameFirst }}>({
method: 'GET',
url: `{{ nameKebab }}/${{{ nameCamel }}Id}`,
hasAuthentication: true,
});
if ('data' in response) {
return response.data;
}
throw response;
};
export const getAll{{ nameFirst }}s = async (requestHandler: RequestHandler): Promise<{{ nameFirst }}[]> => {
const response = await requestHandler.request<{{ nameFirst }}[]>({
method: 'GET',
url: `{{ nameKebab }}/list`,
hasAuthentication: true,
});
if ('data' in response) {
return response.data;
}
throw response;
};
export const delete{{ nameFirst }} = async (requestHandler: RequestHandler, {{ nameCamel }}Id: string): Promise<boolean> => {
const response = await requestHandler.request<boolean>({
method: 'DELETE',
url: `{{ nameKebab }}/${{{ nameCamel }}Id}`,
hasAuthentication: true,
});
if ('data' in response) {
return response.data;
}
throw response;
};
export const create{{ nameFirst }} = async (
requestHandler: RequestHandler,
{{ nameCamel }}: Partial<{{ nameFirst }}>,
): Promise<{{ nameFirst }}> => {
const response = await requestHandler.request<{{ nameFirst }}>({
method: 'POST',
url: `{{ nameKebab }}`,
hasAuthentication: true,
data: {{ nameCamel }},
});
if ('data' in response) {
return response.data;
}
throw response;
};
export const update{{ nameFirst }} = async (
requestHandler: RequestHandler,
{{ nameCamel }}: Partial<{{ nameFirst }}>,
): Promise<{{ nameFirst }}> => {
const response = await requestHandler.request<{{ nameFirst }}>({
method: 'PATCH',
url: `{{ nameKebab }}`,
hasAuthentication: true,
data: {{ nameCamel }},
});
if ('data' in response) {
return response.data;
}
throw response;
};

View File

@ -0,0 +1,3 @@
export interface {{ nameFirst }} {
{{ typeFields }}
}

54
package.json Normal file
View File

@ -0,0 +1,54 @@
{
"name": "@baselinejs/core",
"version": "0.0.13",
"engines": {
"node": ">=20",
"pnpm": ">=9"
},
"homepage": "https://baselinejs.com",
"repository": {
"type": "git",
"url": "https://github.com/Baseline-JS/core.git"
},
"author": "Baseline JS",
"description": "Baseline JS is a serverless first full-stack JavaScript framework for building modern web applications.",
"license": "MIT",
"workspaces": [
"packages/*",
"shared/*",
"commands/*"
],
"scripts": {
"region": "./scripts/set-region.sh",
"aws:profile": "./scripts/setup-aws-profile.sh",
"setup": "./scripts/setup.sh",
"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",
"generate:env:local": "./scripts/generate-env-vars.sh local",
"generate:env:staging": "./scripts/generate-env-vars.sh staging",
"generate:env:prod": "./scripts/generate-env-vars.sh prod",
"urls:staging": "./scripts/project-urls.sh staging",
"urls:prod": "./scripts/project-urls.sh prod",
"lint": "pnpm --if-present --recursive --parallel run lint",
"build": "pnpm --if-present --recursive --parallel run build",
"pretty": "pnpm --if-present --recursive --parallel run pretty",
"add:object": "pnpm --filter @baseline/add-object start",
"add:user:staging": "pnpm --filter @baseline/api run add:user:staging",
"add:user:prod": "pnpm --filter @baseline/api run add:user:prod",
"deploy:staging": "pnpm --filter @baseline/api run deploy:staging && pnpm --filter @baseline/admin --if-present --recursive --parallel run deploy:staging",
"remove:staging": "pnpm --filter @baseline/api --filter @baseline/admin --if-present --recursive --parallel run remove:staging",
"deploy:prod": "pnpm --filter @baseline/api run deploy:prod && pnpm --filter @baseline/admin --if-present --recursive --parallel run deploy:prod",
"remove:prod": "pnpm --filter @baseline/api --filter @baseline/admin --if-present --recursive --parallel run remove:prod",
"start:api": "pnpm --filter @baseline/api run start",
"start:admin": "pnpm --filter @baseline/admin run start",
"install:requirements": "./scripts/experimental-install-requirements.sh"
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "7.2.0",
"@typescript-eslint/parser": "7.2.0",
"eslint": "8.57.0",
"eslint-config-prettier": "9.1.0"
},
"resolutions": {}
}

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

Some files were not shown because too many files have changed in this diff Show More