Init
26
.eslintrc.js
Normal 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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
|
||||
|
||||

|
||||
|
||||
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)
|
BIN
baseline-core-architecture.png
Normal file
After Width: | Height: | Size: 287 KiB |
287
commands/add-object/add-object.js
Executable 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!');
|
||||
})();
|
14
commands/add-object/package.json
Normal 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"
|
||||
}
|
||||
}
|
102
commands/add-object/template/api/blank-api.ts
Normal 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 }}',
|
||||
});
|
||||
}
|
||||
},
|
||||
]);
|
16
commands/add-object/template/api/blank-dynamodb.yml
Normal 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
|
37
commands/add-object/template/api/blank-functions.yml
Normal 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
|
3
commands/add-object/template/api/blank.seed.json
Normal file
@ -0,0 +1,3 @@
|
||||
[
|
||||
{{ seedData }}
|
||||
]
|
14
commands/add-object/template/api/blank.service.ts
Normal 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 }}',
|
||||
});
|
7
commands/add-object/template/api/blank.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { {{ nameFirst }} } from '@baseline/types/{{ nameKebab }}';
|
||||
|
||||
export const {{ nameCamel }}Mapper = (data: {{ nameFirst }}): {{ nameFirst }} => {
|
||||
const {{ nameCamel }}: {{ nameFirst }} = {{{ mapperFields }}
|
||||
};
|
||||
return {{ nameCamel }};
|
||||
};
|
70
commands/add-object/template/client-api/blank.ts
Normal 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;
|
||||
};
|
3
commands/add-object/template/types/blank.d.ts
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
export interface {{ nameFirst }} {
|
||||
{{ typeFields }}
|
||||
}
|
54
package.json
Normal 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": {}
|
||||
}
|
19
packages/admin/.eslintrc.js
Normal 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',
|
||||
},
|
||||
},
|
||||
};
|
8
packages/admin/.prettierrc.js
Normal file
@ -0,0 +1,8 @@
|
||||
module.exports = {
|
||||
semi: true,
|
||||
singleQuote: true,
|
||||
tabWidth: 2,
|
||||
trailingComma: 'all',
|
||||
arrowParens: 'always',
|
||||
printWidth: 80,
|
||||
};
|
20
packages/admin/.stylelintrc.json
Normal 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
@ -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>
|
72
packages/admin/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
BIN
packages/admin/public/android-chrome-192x192.png
Normal file
After Width: | Height: | Size: 6.4 KiB |
BIN
packages/admin/public/android-chrome-512x512.png
Normal file
After Width: | Height: | Size: 26 KiB |
BIN
packages/admin/public/apple-touch-icon.png
Normal file
After Width: | Height: | Size: 5.7 KiB |
BIN
packages/admin/public/favicon-16x16.png
Normal file
After Width: | Height: | Size: 390 B |
BIN
packages/admin/public/favicon-32x32.png
Normal file
After Width: | Height: | Size: 723 B |
BIN
packages/admin/public/favicon.ico
Normal file
After Width: | Height: | Size: 15 KiB |
7
packages/admin/public/icons/asset.svg
Normal 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 |
3
packages/admin/public/icons/gear.svg
Normal 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 |
3
packages/admin/public/icons/home.svg
Normal 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 |
5
packages/admin/public/icons/post.svg
Normal 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 |
3
packages/admin/public/icons/screen.svg
Normal 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 |
3
packages/admin/public/icons/tag.svg
Normal 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 |
3
packages/admin/public/icons/users.svg
Normal 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 |
BIN
packages/admin/public/logo.png
Normal file
After Width: | Height: | Size: 14 KiB |
25
packages/admin/public/manifest.json
Normal 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"
|
||||
}
|
1
packages/admin/public/placeholder.svg
Normal 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 |
2
packages/admin/public/robots.txt
Normal file
@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Disallow:
|
18
packages/admin/scripts/deploy.sh
Executable 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
|
177
packages/admin/serverless.yml
Normal 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
@ -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 },
|
||||
],
|
||||
},
|
||||
]);
|
@ -0,0 +1,12 @@
|
||||
@use '../../../../styles/global';
|
||||
|
||||
.addUser,
|
||||
.addUserModal {
|
||||
.addUserButton {
|
||||
@include global.smallFont();
|
||||
|
||||
padding: 6px 12px;
|
||||
background: unset;
|
||||
border: 1px solid #bababa;
|
||||
}
|
||||
}
|
@ -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;
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
26
packages/admin/src/baseblocks/admin/pages/Admins.tsx
Normal 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;
|
@ -0,0 +1,7 @@
|
||||
.chartList {
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
@ -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;
|
@ -0,0 +1,7 @@
|
||||
.chartList {
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
@ -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;
|
@ -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;
|
@ -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;
|
16
packages/admin/src/baseblocks/chart/pages/Chart.tsx
Normal 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;
|
13
packages/admin/src/baseblocks/chart/pages/charts.tsx
Normal 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;
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
11
packages/admin/src/baseblocks/dashboard/pages/Dashboard.tsx
Normal 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;
|
22
packages/admin/src/baseblocks/home/pages/Home.module.scss
Normal 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;
|
||||
}
|
||||
}
|
16
packages/admin/src/baseblocks/home/pages/Home.tsx
Normal 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;
|
22
packages/admin/src/baseblocks/login/pages/Login.module.scss
Normal 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;
|
||||
}
|
||||
}
|
14
packages/admin/src/baseblocks/login/pages/Login.tsx
Normal 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;
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
31
packages/admin/src/baseblocks/not-admin/pages/NotAdmin.tsx
Normal 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;
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
34
packages/admin/src/baseblocks/user/pages/User.tsx
Normal 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;
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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 "{itemName}"?
|
||||
</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;
|
25
packages/admin/src/components/layout/Layout.tsx
Normal 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;
|
@ -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;
|
||||
}
|
||||
}
|
20
packages/admin/src/components/page-content/PageContent.tsx
Normal 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;
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
70
packages/admin/src/components/page-content/loader/Loader.tsx
Normal 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;
|
112
packages/admin/src/components/sidebar/Sidebar.module.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
74
packages/admin/src/components/sidebar/Sidebar.tsx
Normal 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;
|
39
packages/admin/src/index.scss
Normal 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;
|
||||
}
|
11
packages/admin/src/index.tsx
Normal 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
@ -0,0 +1 @@
|
||||
/// <reference types="react-scripts" />
|
90
packages/admin/src/styles/_global.scss
Normal 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;
|
||||
}
|
||||
}
|
2
packages/admin/src/styles/fonts.scss
Normal 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');
|
75
packages/admin/src/swr/chart.ts
Normal 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
@ -0,0 +1,5 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
27
packages/admin/tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
41
packages/admin/vite.config.ts
Normal 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'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
3
packages/api/.eslintrc.js
Normal file
@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
extends: ['../../.eslintrc.js'],
|
||||
};
|
55
packages/api/package.json
Normal 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"
|
||||
}
|
||||
}
|
115
packages/api/scripts/add-cognito-user.sh
Executable 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
@ -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
|
38
packages/api/scripts/run-api-local-debug.sh
Executable 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
|
48
packages/api/scripts/run-api-local.sh
Executable 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
@ -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)}
|
181
packages/api/src/baseblocks/admin/admin-api.ts
Normal 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',
|
||||
});
|
||||
}
|
||||
},
|
||||
]);
|
16
packages/api/src/baseblocks/admin/admin-dynamodb.yml
Normal 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
|
37
packages/api/src/baseblocks/admin/admin-functions.yml
Normal 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
|
14
packages/api/src/baseblocks/admin/admin.seed.json
Normal 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"
|
||||
}
|
||||
]
|
27
packages/api/src/baseblocks/admin/admin.service.ts
Normal 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;
|
||||
}
|
||||
};
|