commit 0029086b3f6a8876c18e5f4ae15177eed97b947d Author: pfych Date: Sat Oct 12 14:08:09 2024 +1100 Init diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..d02124e --- /dev/null +++ b/.eslintrc.js @@ -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', + }, +}; diff --git a/.github/workflows/build-lint.yml b/.github/workflows/build-lint.yml new file mode 100644 index 0000000..669e504 --- /dev/null +++ b/.github/workflows/build-lint.yml @@ -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 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..8f13369 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -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 + \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5964802 --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/.prettierrc.js b/.prettierrc.js new file mode 100644 index 0000000..122b902 --- /dev/null +++ b/.prettierrc.js @@ -0,0 +1,8 @@ +module.exports = { + semi: true, + singleQuote: true, + tabWidth: 2, + trailingComma: 'all', + arrowParens: 'always', + printWidth: 80, +}; diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..58e5ea5 --- /dev/null +++ b/.vscode/launch.json @@ -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/**"] + } + ] +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4b78fa9 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..bceb0a3 --- /dev/null +++ b/README.md @@ -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 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 +- IAM credentials be sure to follow [Security best practices in IAM](https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html) + +## Run Locally + +If the project is already setup and you are trying run the project locally make sure you run `pnpm run aws:profile` first so that you have the correct credentials for AWS configured locally. + +### Commands + +Start the api, admin and web in their own terminal windows/tabs with the following commands. + +1. `pnpm run generate:env:local` to generate the env files for the frontend clients +2. `pnpm run start:api` +3. `pnpm run start:admin` + +### Running locally Limitations + +- API, Web & Admin: No S3, you will need to rely on AWS staging S3 +- API: No local Cognito Authorizer, the deployed staging cognito can be used (see `packages/api/serverless.yml`) or the payload set by `AUTHORIZER` in `packages/api/scripts/run-api-local.sh`. +- Admin: Cognito UI relies on an active AWS Cognito user pool, use deployed staging + +## Deploy a Change + +Swap `staging` to `prod` to deploy to production. All environment variables will be automatically set in the process. + +1. `pnpm run deploy:staging` to deploy api/web/admin + +## Remove Stack + +To destroy the deployed cloudformation stacks so it is no longer on AWS run `pnpm run remove:staging`. This will likely destroy all data associated with the application. + +# Built using BaselineJS + +![1688515420018](https://github.com/Baseline-JS/core/assets/151841910/620fa869-4bca-418d-af2c-3a3f8b2d2719) + +BaselineJS is an open-source, fullstack TypeScript, serverless first framework designed to make building cloud native applications easier. Our framework utilizes a combination of modern technologies, architectures and operational processes to help teams to efficiently build and deploy robust applications + +If you like BaselineJS give us a ⭐️ + +[Website](https://baselinejs.com/) | +[Documentation](https://docs.baselinejs.com/) | +[Discord](https://discord.gg/beCj9VDeMm) | +[LinkedIn](https://www.linkedin.com/company/baselinejs) | +[YouTube](https://www.youtube.com/@Baseline-JS) + +Startups, want $10k USD of AWS Credits to Get Started? [Apply Here](https://share.hsforms.com/1P6p_G9Q_Q-SJAESAYtLftgqm3as) diff --git a/baseline-core-architecture.png b/baseline-core-architecture.png new file mode 100644 index 0000000..32fa015 Binary files /dev/null and b/baseline-core-architecture.png differ diff --git a/commands/add-object/add-object.js b/commands/add-object/add-object.js new file mode 100755 index 0000000..c925e57 --- /dev/null +++ b/commands/add-object/add-object.js @@ -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!'); +})(); diff --git a/commands/add-object/package.json b/commands/add-object/package.json new file mode 100644 index 0000000..1ca084d --- /dev/null +++ b/commands/add-object/package.json @@ -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" + } +} diff --git a/commands/add-object/template/api/blank-api.ts b/commands/add-object/template/api/blank-api.ts new file mode 100644 index 0000000..3d5d2ad --- /dev/null +++ b/commands/add-object/template/api/blank-api.ts @@ -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 }}', + }); + } + }, +]); diff --git a/commands/add-object/template/api/blank-dynamodb.yml b/commands/add-object/template/api/blank-dynamodb.yml new file mode 100644 index 0000000..e3fa372 --- /dev/null +++ b/commands/add-object/template/api/blank-dynamodb.yml @@ -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 diff --git a/commands/add-object/template/api/blank-functions.yml b/commands/add-object/template/api/blank-functions.yml new file mode 100644 index 0000000..07787d8 --- /dev/null +++ b/commands/add-object/template/api/blank-functions.yml @@ -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 \ No newline at end of file diff --git a/commands/add-object/template/api/blank.seed.json b/commands/add-object/template/api/blank.seed.json new file mode 100644 index 0000000..80d0616 --- /dev/null +++ b/commands/add-object/template/api/blank.seed.json @@ -0,0 +1,3 @@ +[ + {{ seedData }} +] diff --git a/commands/add-object/template/api/blank.service.ts b/commands/add-object/template/api/blank.service.ts new file mode 100644 index 0000000..c9b9e26 --- /dev/null +++ b/commands/add-object/template/api/blank.service.ts @@ -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 }}', +}); diff --git a/commands/add-object/template/api/blank.ts b/commands/add-object/template/api/blank.ts new file mode 100644 index 0000000..c01813f --- /dev/null +++ b/commands/add-object/template/api/blank.ts @@ -0,0 +1,7 @@ +import { {{ nameFirst }} } from '@baseline/types/{{ nameKebab }}'; + +export const {{ nameCamel }}Mapper = (data: {{ nameFirst }}): {{ nameFirst }} => { + const {{ nameCamel }}: {{ nameFirst }} = {{{ mapperFields }} + }; + return {{ nameCamel }}; +}; diff --git a/commands/add-object/template/client-api/blank.ts b/commands/add-object/template/client-api/blank.ts new file mode 100644 index 0000000..401d902 --- /dev/null +++ b/commands/add-object/template/client-api/blank.ts @@ -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 => { + const response = await requestHandler.request({ + 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; +}; diff --git a/commands/add-object/template/types/blank.d.ts b/commands/add-object/template/types/blank.d.ts new file mode 100644 index 0000000..efc4e23 --- /dev/null +++ b/commands/add-object/template/types/blank.d.ts @@ -0,0 +1,3 @@ +export interface {{ nameFirst }} { +{{ typeFields }} +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..59cdb62 --- /dev/null +++ b/package.json @@ -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": {} +} diff --git a/packages/admin/.eslintrc.js b/packages/admin/.eslintrc.js new file mode 100644 index 0000000..dbc3d32 --- /dev/null +++ b/packages/admin/.eslintrc.js @@ -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', + }, + }, +}; diff --git a/packages/admin/.prettierrc.js b/packages/admin/.prettierrc.js new file mode 100644 index 0000000..122b902 --- /dev/null +++ b/packages/admin/.prettierrc.js @@ -0,0 +1,8 @@ +module.exports = { + semi: true, + singleQuote: true, + tabWidth: 2, + trailingComma: 'all', + arrowParens: 'always', + printWidth: 80, +}; diff --git a/packages/admin/.stylelintrc.json b/packages/admin/.stylelintrc.json new file mode 100644 index 0000000..5d12e70 --- /dev/null +++ b/packages/admin/.stylelintrc.json @@ -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 + } +} diff --git a/packages/admin/index.html b/packages/admin/index.html new file mode 100644 index 0000000..a57bdb1 --- /dev/null +++ b/packages/admin/index.html @@ -0,0 +1,30 @@ + + + + Baseline Core + + + + + + + + + + + +
+ + + + diff --git a/packages/admin/package.json b/packages/admin/package.json new file mode 100644 index 0000000..bae82bf --- /dev/null +++ b/packages/admin/package.json @@ -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" + ] + } +} diff --git a/packages/admin/public/android-chrome-192x192.png b/packages/admin/public/android-chrome-192x192.png new file mode 100644 index 0000000..a31fbae Binary files /dev/null and b/packages/admin/public/android-chrome-192x192.png differ diff --git a/packages/admin/public/android-chrome-512x512.png b/packages/admin/public/android-chrome-512x512.png new file mode 100644 index 0000000..595d1d7 Binary files /dev/null and b/packages/admin/public/android-chrome-512x512.png differ diff --git a/packages/admin/public/apple-touch-icon.png b/packages/admin/public/apple-touch-icon.png new file mode 100644 index 0000000..d412551 Binary files /dev/null and b/packages/admin/public/apple-touch-icon.png differ diff --git a/packages/admin/public/favicon-16x16.png b/packages/admin/public/favicon-16x16.png new file mode 100644 index 0000000..5769832 Binary files /dev/null and b/packages/admin/public/favicon-16x16.png differ diff --git a/packages/admin/public/favicon-32x32.png b/packages/admin/public/favicon-32x32.png new file mode 100644 index 0000000..2db46b4 Binary files /dev/null and b/packages/admin/public/favicon-32x32.png differ diff --git a/packages/admin/public/favicon.ico b/packages/admin/public/favicon.ico new file mode 100644 index 0000000..650c2e9 Binary files /dev/null and b/packages/admin/public/favicon.ico differ diff --git a/packages/admin/public/icons/asset.svg b/packages/admin/public/icons/asset.svg new file mode 100644 index 0000000..60d0f50 --- /dev/null +++ b/packages/admin/public/icons/asset.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/admin/public/icons/gear.svg b/packages/admin/public/icons/gear.svg new file mode 100644 index 0000000..268662a --- /dev/null +++ b/packages/admin/public/icons/gear.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/admin/public/icons/home.svg b/packages/admin/public/icons/home.svg new file mode 100644 index 0000000..07e4e1e --- /dev/null +++ b/packages/admin/public/icons/home.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/admin/public/icons/post.svg b/packages/admin/public/icons/post.svg new file mode 100644 index 0000000..c95a672 --- /dev/null +++ b/packages/admin/public/icons/post.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/admin/public/icons/screen.svg b/packages/admin/public/icons/screen.svg new file mode 100644 index 0000000..403ee63 --- /dev/null +++ b/packages/admin/public/icons/screen.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/admin/public/icons/tag.svg b/packages/admin/public/icons/tag.svg new file mode 100644 index 0000000..2d793bc --- /dev/null +++ b/packages/admin/public/icons/tag.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/admin/public/icons/users.svg b/packages/admin/public/icons/users.svg new file mode 100644 index 0000000..cd3911c --- /dev/null +++ b/packages/admin/public/icons/users.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/admin/public/logo.png b/packages/admin/public/logo.png new file mode 100644 index 0000000..bffc179 Binary files /dev/null and b/packages/admin/public/logo.png differ diff --git a/packages/admin/public/manifest.json b/packages/admin/public/manifest.json new file mode 100644 index 0000000..86f925c --- /dev/null +++ b/packages/admin/public/manifest.json @@ -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" +} diff --git a/packages/admin/public/placeholder.svg b/packages/admin/public/placeholder.svg new file mode 100644 index 0000000..be21cbb --- /dev/null +++ b/packages/admin/public/placeholder.svg @@ -0,0 +1 @@ + diff --git a/packages/admin/public/robots.txt b/packages/admin/public/robots.txt new file mode 100644 index 0000000..eb05362 --- /dev/null +++ b/packages/admin/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: diff --git a/packages/admin/scripts/deploy.sh b/packages/admin/scripts/deploy.sh new file mode 100755 index 0000000..dbf6a07 --- /dev/null +++ b/packages/admin/scripts/deploy.sh @@ -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 diff --git a/packages/admin/serverless.yml b/packages/admin/serverless.yml new file mode 100644 index 0000000..1174d56 --- /dev/null +++ b/packages/admin/serverless.yml @@ -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 diff --git a/packages/admin/src/App.tsx b/packages/admin/src/App.tsx new file mode 100644 index 0000000..99694d5 --- /dev/null +++ b/packages/admin/src/App.tsx @@ -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 ( + } + /> + ); +} + +async function protectedLoader() { + console.debug('protected loader'); + if (!getRequestHandler()) { + console.debug('creating request handler'); + createRequestHandler( + async (config: AxiosRequestConfig): Promise => { + 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 }, + ], + }, +]); diff --git a/packages/admin/src/baseblocks/admin/components/add-admin/AddAdmin.module.scss b/packages/admin/src/baseblocks/admin/components/add-admin/AddAdmin.module.scss new file mode 100644 index 0000000..624f3ab --- /dev/null +++ b/packages/admin/src/baseblocks/admin/components/add-admin/AddAdmin.module.scss @@ -0,0 +1,12 @@ +@use '../../../../styles/global'; + +.addUser, +.addUserModal { + .addUserButton { + @include global.smallFont(); + + padding: 6px 12px; + background: unset; + border: 1px solid #bababa; + } +} diff --git a/packages/admin/src/baseblocks/admin/components/add-admin/AddAdmin.tsx b/packages/admin/src/baseblocks/admin/components/add-admin/AddAdmin.tsx new file mode 100644 index 0000000..93e9431 --- /dev/null +++ b/packages/admin/src/baseblocks/admin/components/add-admin/AddAdmin.tsx @@ -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>; +} + +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 => { + const newAdmin = await createAdmin(getRequestHandler(), { + userEmail: newEmail, + }); + setAllAdmins((admins) => [...admins, newAdmin]); + toggle(); + }; + + return ( +
+ + + Add Admin + + + + setNewEmail(e.target.value)} + value={newEmail} + /> + + + + + + +
+ ); +}; + +export default AddAdmin; diff --git a/packages/admin/src/baseblocks/admin/components/admin-list/AdminList.module.scss b/packages/admin/src/baseblocks/admin/components/admin-list/AdminList.module.scss new file mode 100644 index 0000000..0be2472 --- /dev/null +++ b/packages/admin/src/baseblocks/admin/components/admin-list/AdminList.module.scss @@ -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; + } + } +} diff --git a/packages/admin/src/baseblocks/admin/components/admin-list/AdminList.tsx b/packages/admin/src/baseblocks/admin/components/admin-list/AdminList.tsx new file mode 100644 index 0000000..3ab6d2a --- /dev/null +++ b/packages/admin/src/baseblocks/admin/components/admin-list/AdminList.tsx @@ -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(props?.admins || []); + + const handleDelete = async (adminSub: string): Promise => { + await deleteAdmin(getRequestHandler(), { adminId: adminSub }); + setAllAdmins((admins) => + admins.filter((admin) => admin.userSub !== adminSub), + ); + }; + + return ( +
+
+
+
+ There are {allAdmins.length} people in your team +
+ +
+ {allAdmins.map((admin) => ( +
+
+
+
{admin.userEmail}
+
{admin.userSub}
+
+
Admin
+
+
+ { + await handleDelete(admin.userSub); + }} + /> +
+
+ ))} +
+
+ ); +}; + +export default AdminList; diff --git a/packages/admin/src/baseblocks/admin/pages/Admins.tsx b/packages/admin/src/baseblocks/admin/pages/Admins.tsx new file mode 100644 index 0000000..db7fd2d --- /dev/null +++ b/packages/admin/src/baseblocks/admin/pages/Admins.tsx @@ -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 ( + + + + ); +}; + +export default Admins; diff --git a/packages/admin/src/baseblocks/chart/components/chart-detail/ChartDetail.module.scss b/packages/admin/src/baseblocks/chart/components/chart-detail/ChartDetail.module.scss new file mode 100644 index 0000000..ece1f11 --- /dev/null +++ b/packages/admin/src/baseblocks/chart/components/chart-detail/ChartDetail.module.scss @@ -0,0 +1,7 @@ +.chartList { + .header { + display: flex; + justify-content: space-between; + align-items: center; + } +} diff --git a/packages/admin/src/baseblocks/chart/components/chart-detail/ChartDetail.tsx b/packages/admin/src/baseblocks/chart/components/chart-detail/ChartDetail.tsx new file mode 100644 index 0000000..d99066f --- /dev/null +++ b/packages/admin/src/baseblocks/chart/components/chart-detail/ChartDetail.tsx @@ -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 ; + } + + if (error) { + return
{JSON.stringify(error)}
; + } + + return ( +
+
+

Charts

+ +
+
+
{JSON.stringify(chart, null, 2)}
+
+ ); +}; + +export default ChartDetail; diff --git a/packages/admin/src/baseblocks/chart/components/chart-list/ChartList.module.scss b/packages/admin/src/baseblocks/chart/components/chart-list/ChartList.module.scss new file mode 100644 index 0000000..ece1f11 --- /dev/null +++ b/packages/admin/src/baseblocks/chart/components/chart-list/ChartList.module.scss @@ -0,0 +1,7 @@ +.chartList { + .header { + display: flex; + justify-content: space-between; + align-items: center; + } +} diff --git a/packages/admin/src/baseblocks/chart/components/chart-list/ChartList.tsx b/packages/admin/src/baseblocks/chart/components/chart-list/ChartList.tsx new file mode 100644 index 0000000..eee5043 --- /dev/null +++ b/packages/admin/src/baseblocks/chart/components/chart-list/ChartList.tsx @@ -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 ; + } + + return ( +
+
+

Charts

+ +
+
+ +
+ ); +}; + +export default ChartList; diff --git a/packages/admin/src/baseblocks/chart/components/create-chart-modal/CreateChartModal.tsx b/packages/admin/src/baseblocks/chart/components/create-chart-modal/CreateChartModal.tsx new file mode 100644 index 0000000..bbad78a --- /dev/null +++ b/packages/admin/src/baseblocks/chart/components/create-chart-modal/CreateChartModal.tsx @@ -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 ( + { + setSubmitting(true); + const newChart = await createChart(values); + await onChartCreated(newChart); + + resetForm(); + setIsOpen(false); + }} + > + {({ + values, + handleSubmit, + isValid, + isSubmitting, + resetForm, + errors, + setFieldValue, + handleBlur, + }) => ( + <> + + { + toggle(); + resetForm(); + }} + > + Create Chart + + + + void setFieldValue('name', e.target.value)} + onBlur={handleBlur} + className={errors.name ? 'is-invalid' : ''} + /> + {errors.name} + + + + + void setFieldValue('parentChart', e.target.value) + } + onBlur={handleBlur} + className={errors.parentChart ? 'is-invalid' : ''} + disabled + placeholder="This will be a dropdown at some point" + /> + {errors.parentChart} + + + + void setFieldValue('md5', e.target.value)} + onBlur={handleBlur} + className={errors.md5 ? 'is-invalid' : ''} + /> + {errors.md5} + + + + void setFieldValue('sha256', e.target.value)} + onBlur={handleBlur} + className={errors.sha256 ? 'is-invalid' : ''} + /> + {errors.sha256} + + + + + void setFieldValue('resourceUri', e.target.value) + } + onBlur={handleBlur} + className={errors.resourceUri ? 'is-invalid' : ''} + /> + {errors.resourceUri} + + + +