diff --git a/.cursor/rules/check-docs.mdc b/.cursor/rules/check-docs.mdc new file mode 100644 index 0000000..5162712 --- /dev/null +++ b/.cursor/rules/check-docs.mdc @@ -0,0 +1,11 @@ +--- +description: Check current progress +globs: +alwaysApply: false +--- +Check our progress and update the documentation + +[next-agent-01.md](mdc:next-agent-01.md) +[file-migration-list.md](mdc:standards/blueprints/file-migration-list.md) +[react-component-conventions.md](mdc:standards/documentation/react-component-conventions.md) + diff --git a/.cursor/rules/nextjs-filetype-scrutiny.mdc b/.cursor/rules/nextjs-filetype-scrutiny.mdc new file mode 100644 index 0000000..1d26036 --- /dev/null +++ b/.cursor/rules/nextjs-filetype-scrutiny.mdc @@ -0,0 +1,12 @@ +--- +description: Identify and execute best practice for nextjs file types +globs: app/**/*.tsx, page.tsx, layout.tsx, error.tsx, not-found.tsx, layout.tsx +alwaysApply: false +--- +# Follow Next.js 15 App Router current spec + - Identify the context and file type + - Note the file's role within this specific app strucure + - consider: async, dynamic routes, metadata, error handling, loading states + - Be aware of special files and their purposes +Next.js docs for detailed specifications and best practices. + - Document components using tsdoc diff --git a/.cursor/rules/ui-components-in-workspace.mdc b/.cursor/rules/ui-components-in-workspace.mdc new file mode 100644 index 0000000..933d979 --- /dev/null +++ b/.cursor/rules/ui-components-in-workspace.mdc @@ -0,0 +1,10 @@ +--- +description: When creating or updating UI, first use existing UI from @workspace/ui +globs: src/**/*.tsx +alwaysApply: false +--- + +# Always use existing UI before creating new components + +Find this in +`services/ui` available with import alias `@workspace/ui/*` diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..6c2dc6d --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,30 @@ +# Setting Up Contribution Guidelines with CODEOWNERS + +You can create a CODEOWNERS file to define which files require specific approval before changes can be merged. This helps protect critical files in your project. + +## Steps to implement CODEOWNERS: + +1. Create a CODEOWNERS file in either the root directory, .github directory, or docs directory +2. Define file patterns and the users/teams who own those files + +Here's how to implement it: + +# CODEOWNERS file defines who needs to approve changes to specific files +# Format: file-pattern @user-or-team + +# Dev container configuration +.devcontainer/* @your-username + +# Core configuration files +.vscode/* @your-username +*.json @your-username + +# Critical application files that need review +/src/core/* @your-username @another-team-member +/services/api/* @api-team-name + +# Documentation changes can be reviewed by docs team +/docs/* @docs-team-or-username + +# Default owners for everything else +* @default-team-or-username diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..43d6094 --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,46 @@ +# Contributing to Laconic Core + +Thank you for considering contributing to our project! Here are some guidelines to help you get started. + +## Development Environment + +This project uses dev containers to ensure consistent development environments. To get started: + +1. Install [Docker](https://www.docker.com/products/docker-desktop) and [VS Code](https://code.visualstudio.com/) +2. Install the [Remote - Containers](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) extension +3. Clone the repository and open it in VS Code +4. Click the green button in the bottom-left corner and select "Reopen in Container" + +## Protected Files + +Some files in this repository are protected and require specific approval before changes can be merged: + +- `.devcontainer/*` - Dev container configuration +- `.vscode/*` - VS Code workspace settings +- `core configuration files` - Project configuration files +- `critical application files` - Core functionality + +Please discuss any changes to these files with the maintainers before submitting pull requests. + +## Code Style + +This project uses Biome for formatting and linting. The dev container will automatically configure your editor to use the correct settings. + +## Submitting Changes + +1. Fork the repository +2. Create a new branch for your changes +3. Make your changes following the code style guidelines +4. Write tests for your changes +5. Submit a pull request + +## Pull Request Process + +1. Update the README.md with details of changes if applicable +2. The version numbers will be updated by maintainers following semantic versioning +3. Pull requests require approval from at least one maintainer +4. Once approved, maintainers will merge the PR + +## Questions? + +If you have questions, please open an issue or contact the maintainers. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..485770d --- /dev/null +++ b/.gitignore @@ -0,0 +1,75 @@ +# Dependencies +node_modules/ +.pnp +.pnp.js + +# Testing +coverage + +# Next.js +.next/ +out/ +build +dist + +# Misc +.DS_Store +*.pem + +# Debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# Local env files +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Turbo +.turbo + +# Vercel +.vercel + +# Build outputs +dist/ +build/ + +# TypeScript +*.tsbuildinfo +next-env.d.ts + +# Cache +.cache/ + +# IDE specific files +.idea/ +.vscode/* +!.vscode/extensions.json +!.vscode/launch.json +!.vscode/settings.json +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Logs +logs +*.log + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +.pnpm-store +.cursorignore \ No newline at end of file diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..3ad8959 --- /dev/null +++ b/.npmrc @@ -0,0 +1,3 @@ +@cerc-io:registry=https://git.vdb.to/api/packages/cerc-io/npm/ +legacy-peer-deps=true +strict-peer-dependencies=false \ No newline at end of file diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..c957e10 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["biomejs.biome", "ms-typescript.vscode-typescript-next"] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..8e460ce --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,38 @@ +{ + "typescript.tsdk": "node_modules/typescript/lib", + "typescript.enablePromptUseWorkspaceTsdk": true, + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.fixAll": "explicit", + "source.addMissingImports.ts": "explicit", + "source.organizeImports.biome": "explicit", + "source.removeUnused.ts": "explicit" + }, + "editor.defaultFormatter": "biomejs.biome", + "[typescript]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[javascript]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[javascriptreact]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[json]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "typescript.validate.enable": true, + "javascript.validate.enable": true, + "typescript.reportStyleChecksAsWarnings": true, + "typescript.surveys.enabled": false, + "prettier.enable": false, + "typescript.experimental.expandableHover": true, + "github.copilot.enable": { + "typescript": true, + "reacttypescript": true + }, + "github.copilot.chat.codeGeneration.useInstructionFiles": false +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c1a5a99 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 QWRK-ORG + +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/apps/.gitignore b/apps/.gitignore new file mode 100644 index 0000000..39b2403 --- /dev/null +++ b/apps/.gitignore @@ -0,0 +1,41 @@ +# Dependencies +node_modules +.pnp +.pnp.js + +# Build outputs +dist +build +.next +out + +# Testing +coverage + +# Debug logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# Environment +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# IDE +.idea +.vscode/* +!.vscode/extensions.json +!.vscode/settings.json +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# OS +.DS_Store +Thumbs.db \ No newline at end of file diff --git a/apps/backend/README.md b/apps/backend/README.md new file mode 100644 index 0000000..7fb3c8e --- /dev/null +++ b/apps/backend/README.md @@ -0,0 +1,76 @@ +# backend + +This backend is a [node.js](https://nodejs.org/) [express.js](https://expressjs.com/) [apollo server](https://www.apollographql.com/docs/apollo-server/) project in a [yarn workspace](https://yarnpkg.com/features/workspaces). + +## Getting Started + +### Install dependencies + +In the root of the project, run: + +```zsh +yarn +``` + +### Build backend + +```zsh +yarn build --ignore frontend +``` + +### Environment variables + +#### Local + +Copy the `environments/local.toml.example` file to `environments/local.toml`: + +```zsh +cp environments/local.toml.example environments/local.toml +``` + +#### Staging environment variables + +In the deployment repository, update staging [staging/configmaps/config/prod.toml](https://git.vdb.to/cerc-io/snowballtools-base-api-deployments/src/commit/318c2bc09f334dca79c3501838512749f9431bf1/deployments/staging/configmaps/config/prod.toml) + +#### Production environment variables + +In the deployment repository, update production [production/configmaps/config/prod.toml](https://git.vdb.to/cerc-io/snowballtools-base-api-deployments/src/commit/318c2bc09f334dca79c3501838512749f9431bf1/deployments/production/configmaps/config/prod.toml) + +### Run development server + +```zsh +yarn start +``` + +## Deployment + +Clone the [deployer repository](https://git.vdb.to/cerc-io/snowballtools-base-api-deployments): + +```zsh +git clone git@git.vdb.to:cerc-io/snowballtools-base-api-deployments.git +``` + +### Staging + +```zsh +echo trigger >> .gitea/workflows/triggers/staging-deploy +git commit -a -m "Deploy v0.0.8" # replace with version number +git push +``` + +### Production + +```zsh +echo trigger >> .gitea/workflows/triggers/production-deploy +git commit -a -m "Deploy v0.0.8" # replace with version number +git push +``` + +### Deployment status + +Dumb for now + +- [Staging](https://snowballtools-base-api.staging.apps.snowballtools.com/staging/version) +- [Production](https://snowballtools-base-api.apps.snowballtools.com/staging/version) + +Update version number manually in [routes/staging.ts](/packages/backend/src/routes/staging.ts) diff --git a/apps/backend/biome.json b/apps/backend/biome.json new file mode 100644 index 0000000..dd2cde3 --- /dev/null +++ b/apps/backend/biome.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 80 + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "suspicious": { + "noExplicitAny": "off" + }, + "style": { + "noNonNullAssertion": "off" + } + } + }, + "javascript": { + "formatter": { + "enabled": true, + "quoteStyle": "single", + "trailingCommas": "none", + "semicolons": "asNeeded" + } + }, + "files": { + "ignore": ["dist/**/*", "node_modules/**/*", ".turbo/**/*"] + } +} diff --git a/apps/backend/environments/local.toml b/apps/backend/environments/local.toml new file mode 100644 index 0000000..233d495 --- /dev/null +++ b/apps/backend/environments/local.toml @@ -0,0 +1,43 @@ +[server] + host = "127.0.0.1" + port = 8000 + gqlPath = "/graphql" + [server.session] + secret = "" + # Frontend webapp URL origin + appOriginUrl = "http://localhost:3000" + # Set to true if server running behind proxy + trustProxy = false + # Backend URL hostname + domain = "localhost" + +[database] + dbPath = "db/snowball" + +[gitHub] + webhookUrl = "" + [gitHub.oAuth] + clientId = "" + clientSecret = "" + +[registryConfig] + fetchDeploymentRecordDelay = 5000 + checkAuctionStatusDelay = 5000 + restEndpoint = "http://localhost:1317" + gqlEndpoint = "http://localhost:9473/api" + chainId = "laconic_9000-1" + privateKey = "" + bondId = "" + authority = "" + [registryConfig.fee] + gas = "" + fees = "" + gasPrice = "1alnt" + +# Durations are set to 2 mins as deployers may take time with ongoing deployments and auctions +[auction] + commitFee = "100000" + commitsDuration = "120s" + revealFee = "100000" + revealsDuration = "120s" + denom = "alnt" diff --git a/apps/backend/environments/local.toml.example b/apps/backend/environments/local.toml.example new file mode 100644 index 0000000..233d495 --- /dev/null +++ b/apps/backend/environments/local.toml.example @@ -0,0 +1,43 @@ +[server] + host = "127.0.0.1" + port = 8000 + gqlPath = "/graphql" + [server.session] + secret = "" + # Frontend webapp URL origin + appOriginUrl = "http://localhost:3000" + # Set to true if server running behind proxy + trustProxy = false + # Backend URL hostname + domain = "localhost" + +[database] + dbPath = "db/snowball" + +[gitHub] + webhookUrl = "" + [gitHub.oAuth] + clientId = "" + clientSecret = "" + +[registryConfig] + fetchDeploymentRecordDelay = 5000 + checkAuctionStatusDelay = 5000 + restEndpoint = "http://localhost:1317" + gqlEndpoint = "http://localhost:9473/api" + chainId = "laconic_9000-1" + privateKey = "" + bondId = "" + authority = "" + [registryConfig.fee] + gas = "" + fees = "" + gasPrice = "1alnt" + +# Durations are set to 2 mins as deployers may take time with ongoing deployments and auctions +[auction] + commitFee = "100000" + commitsDuration = "120s" + revealFee = "100000" + revealsDuration = "120s" + denom = "alnt" diff --git a/apps/backend/package.json b/apps/backend/package.json new file mode 100644 index 0000000..fbf128c --- /dev/null +++ b/apps/backend/package.json @@ -0,0 +1,68 @@ +{ + "name": "@qwrk/backend", + "license": "UNLICENSED", + "version": "1.0.0", + "private": true, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "dependencies": { + "@cerc-io/registry-sdk": "^0.2.11", + "@cosmjs/stargate": "^0.33.0", + "@graphql-tools/schema": "^10.0.2", + "@graphql-tools/utils": "^10.0.12", + "@octokit/oauth-app": "^6.1.0", + "@turnkey/sdk-server": "^0.1.0", + "@types/debug": "^4.1.5", + "@types/node": "^20.11.0", + "@types/semver": "^7.5.8", + "apollo-server-core": "^3.13.0", + "apollo-server-express": "^3.13.0", + "cookie-session": "^2.1.0", + "cors": "^2.8.5", + "debug": "^4.3.1", + "express": "^4.18.2", + "express-async-errors": "^3.1.1", + "express-session": "^1.18.0", + "fs-extra": "^11.2.0", + "graphql": "^16.8.1", + "luxon": "^3.5.0", + "nanoid": "3", + "nanoid-dictionary": "^5.0.0-beta.1", + "octokit": "^3.1.2", + "openpgp": "^6.0.1", + "reflect-metadata": "^0.2.1", + "semver": "^7.6.0", + "siwe": "^3.0.0", + "toml": "^3.0.0", + "ts-node": "^10.9.2", + "typeorm": "^0.3.19", + "typescript": "^5.3.3" + }, + "scripts": { + "start": "DEBUG=snowball:* node --enable-source-maps ./dist/index.js", + "start:dev": "DEBUG=snowball:* ts-node ./src/index.ts", + "copy-assets": "copyfiles -u 1 src/**/*.gql dist/", + "clean": "rm -rf ./dist", + "build": "pnpm clean && tsc && pnpm copy-assets", + "format": "biome format .", + "format:check": "biome format --check .", + "lint": "biome check .", + "test:registry:init": "DEBUG=snowball:* ts-node ./test/initialize-registry.ts", + "test:registry:publish-deploy-records": "DEBUG=snowball:* ts-node ./test/publish-deploy-records.ts", + "test:registry:publish-deployment-removal-records": "DEBUG=snowball:* ts-node ./test/publish-deployment-removal-records.ts", + "test:db:load:fixtures": "DEBUG=snowball:* ts-node ./test/initialize-db.ts", + "test:db:delete": "DEBUG=snowball:* ts-node ./test/delete-db.ts" + }, + "devDependencies": { + "@biomejs/biome": "1.9.4", + "@types/cookie-session": "^2.0.49", + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/express-session": "^1.17.10", + "@types/fs-extra": "^11.0.4", + "better-sqlite3": "^9.2.2", + "copyfiles": "^2.4.1", + "prettier": "^3.1.1", + "workspace": "^0.0.1-preview.1" + } +} diff --git a/apps/backend/src/config.ts b/apps/backend/src/config.ts new file mode 100644 index 0000000..53a071a --- /dev/null +++ b/apps/backend/src/config.ts @@ -0,0 +1,66 @@ +export interface SessionConfig { + secret: string + appOriginUrl: string + trustProxy: boolean + domain: string +} + +export interface ServerConfig { + host: string + port: number + gqlPath?: string + sessionSecret: string + appOriginUrl: string + isProduction: boolean + session: SessionConfig +} + +export interface DatabaseConfig { + dbPath: string +} + +export interface GitHubConfig { + webhookUrl: string + oAuth: { + clientId: string + clientSecret: string + } +} + +export interface RegistryConfig { + restEndpoint: string + gqlEndpoint: string + chainId: string + privateKey: string + bondId: string + fetchDeploymentRecordDelay: number + checkAuctionStatusDelay: number + authority: string + fee: { + gas: string + fees: string + gasPrice: string + } +} + +export interface AuctionConfig { + commitFee: string + commitsDuration: string + revealFee: string + revealsDuration: string + denom: string +} + +export interface Config { + server: ServerConfig + database: DatabaseConfig + gitHub: GitHubConfig + registryConfig: RegistryConfig + auction: AuctionConfig + turnkey: { + apiBaseUrl: string + apiPublicKey: string + apiPrivateKey: string + defaultOrganizationId: string + } +} diff --git a/apps/backend/src/constants.ts b/apps/backend/src/constants.ts new file mode 100644 index 0000000..be2a86e --- /dev/null +++ b/apps/backend/src/constants.ts @@ -0,0 +1,7 @@ +import process from 'node:process' + +export const DEFAULT_CONFIG_FILE_PATH = + process.env.SNOWBALL_BACKEND_CONFIG_FILE_PATH || + 'apps/backend/environments/local.toml' + +export const DEFAULT_GQL_PATH = '/graphql' diff --git a/apps/backend/src/database.ts b/apps/backend/src/database.ts new file mode 100644 index 0000000..6528bfe --- /dev/null +++ b/apps/backend/src/database.ts @@ -0,0 +1,694 @@ +import assert from 'node:assert' +import path from 'node:path' +import debug from 'debug' +import { customAlphabet } from 'nanoid' +import { lowercase, numbers } from 'nanoid-dictionary' +import { + DataSource, + type DeepPartial, + type FindManyOptions, + type FindOneOptions, + type FindOptionsWhere, + IsNull, + Not +} from 'typeorm' + +import type { DatabaseConfig } from './config' +import { Deployer } from './entity/Deployer' +import { Deployment, DeploymentStatus } from './entity/Deployment' +import { Domain } from './entity/Domain' +import { EnvironmentVariable } from './entity/EnvironmentVariable' +import { Organization } from './entity/Organization' +import { Project } from './entity/Project' +import { ProjectMember } from './entity/ProjectMember' +import { User } from './entity/User' +import { UserOrganization } from './entity/UserOrganization' +import type { DNSRecordAttributes } from './types' +import { getEntities, loadAndSaveData } from './utils' + +const ORGANIZATION_DATA_PATH = '../test/fixtures/organizations.json' + +const log = debug('snowball:database') + +const nanoid = customAlphabet(lowercase + numbers, 8) + +// TODO: Fix order of methods +export class Database { + private dataSource: DataSource + + constructor({ dbPath }: DatabaseConfig) { + this.dataSource = new DataSource({ + type: 'better-sqlite3', + database: dbPath, + entities: [path.join(__dirname, '/entity/*')], + synchronize: true, + logging: false + }) + } + + async init(): Promise { + await this.dataSource.initialize() + log('database initialized') + + let organizations = await this.getOrganizations({}) + + // Load an organization if none exist + if (!organizations.length) { + const orgEntities = await getEntities( + path.resolve(__dirname, ORGANIZATION_DATA_PATH) + ) + organizations = await loadAndSaveData(Organization, this.dataSource, [ + orgEntities[0] + ]) + } + + // Hotfix for updating old DB data + if (organizations[0].slug === 'snowball-tools-1') { + const [orgEntity] = await getEntities( + path.resolve(__dirname, ORGANIZATION_DATA_PATH) + ) + + await this.updateOrganization(organizations[0].id, { + slug: orgEntity.slug as string, + name: orgEntity.name as string + }) + } + } + + async getUser(options: FindOneOptions): Promise { + const userRepository = this.dataSource.getRepository(User) + const user = await userRepository.findOne(options) + + return user + } + + async addUser(data: DeepPartial): Promise { + const userRepository = this.dataSource.getRepository(User) + const user = await userRepository.save(data) + + return user + } + + async updateUser(user: User, data: DeepPartial): Promise { + const userRepository = this.dataSource.getRepository(User) + const updateResult = await userRepository.update({ id: user.id }, data) + assert(updateResult.affected) + + return updateResult.affected > 0 + } + + async getOrganizations( + options: FindManyOptions + ): Promise { + const organizationRepository = this.dataSource.getRepository(Organization) + const organizations = await organizationRepository.find(options) + + return organizations + } + + async getOrganization( + options: FindOneOptions + ): Promise { + const organizationRepository = this.dataSource.getRepository(Organization) + const organization = await organizationRepository.findOne(options) + + return organization + } + + async getOrganizationsByUserId(userId: string): Promise { + const organizationRepository = this.dataSource.getRepository(Organization) + + const userOrgs = await organizationRepository.find({ + where: { + userOrganizations: { + member: { + id: userId + } + } + } + }) + + return userOrgs + } + + async addUserOrganization( + data: DeepPartial + ): Promise { + const userOrganizationRepository = + this.dataSource.getRepository(UserOrganization) + const newUserOrganization = await userOrganizationRepository.save(data) + + return newUserOrganization + } + + async updateOrganization( + organizationId: string, + data: DeepPartial + ): Promise { + const organizationRepository = this.dataSource.getRepository(Organization) + const updateResult = await organizationRepository.update( + { id: organizationId }, + data + ) + assert(updateResult.affected) + + return updateResult.affected > 0 + } + + async getProjects(options: FindManyOptions): Promise { + const projectRepository = this.dataSource.getRepository(Project) + const projects = await projectRepository.find(options) + + return projects + } + + async getProjectById(projectId: string): Promise { + const projectRepository = this.dataSource.getRepository(Project) + + const project = await projectRepository + .createQueryBuilder('project') + .leftJoinAndSelect( + 'project.deployments', + 'deployments', + 'deployments.isCurrent = true AND deployments.isCanonical = true' + ) + .leftJoinAndSelect('deployments.createdBy', 'user') + .leftJoinAndSelect('deployments.deployer', 'deployer') + .leftJoinAndSelect('project.owner', 'owner') + .leftJoinAndSelect('project.deployers', 'deployers') + .leftJoinAndSelect('project.organization', 'organization') + .where('project.id = :projectId', { + projectId + }) + .getOne() + + return project + } + + async allProjectsWithoutDeployments(): Promise { + const allProjects = await this.getProjects({ + where: { + auctionId: Not(IsNull()) + }, + relations: ['deployments'], + withDeleted: true + }) + + const projects = allProjects.filter((project) => { + if (project.deletedAt !== null) return false + + return project.deployments.length === 0 + }) + + return projects + } + + async getProjectsInOrganization( + userId: string, + organizationSlug: string + ): Promise { + const projectRepository = this.dataSource.getRepository(Project) + + const projects = await projectRepository + .createQueryBuilder('project') + .leftJoinAndSelect( + 'project.deployments', + 'deployments', + 'deployments.isCurrent = true AND deployments.isCanonical = true' + ) + .leftJoin('project.projectMembers', 'projectMembers') + .leftJoin('project.organization', 'organization') + .where( + '(project.ownerId = :userId OR projectMembers.userId = :userId) AND organization.slug = :organizationSlug', + { + userId, + organizationSlug + } + ) + .getMany() + + return projects + } + + /** + * Get deployments with specified filter + */ + async getDeployments( + options: FindManyOptions + ): Promise { + const deploymentRepository = this.dataSource.getRepository(Deployment) + const deployments = await deploymentRepository.find(options) + + return deployments + } + + async getDeploymentsByProjectId(projectId: string): Promise { + return this.getDeployments({ + relations: { + project: true, + createdBy: true, + deployer: true + }, + where: { + project: { + id: projectId + } + }, + order: { + createdAt: 'DESC' + } + }) + } + + async getNonCanonicalDeploymentsByProjectId( + projectId: string + ): Promise { + return this.getDeployments({ + relations: { + project: true, + createdBy: true, + deployer: true + }, + where: { + project: { + id: projectId + }, + isCanonical: false + }, + order: { + createdAt: 'DESC' + } + }) + } + + async getDeployment( + options: FindOneOptions + ): Promise { + const deploymentRepository = this.dataSource.getRepository(Deployment) + const deployment = await deploymentRepository.findOne(options) + + return deployment + } + + async getDomains(options: FindManyOptions): Promise { + const domainRepository = this.dataSource.getRepository(Domain) + const domains = await domainRepository.find(options) + + return domains + } + + async addDeployment(data: DeepPartial): Promise { + const deploymentRepository = this.dataSource.getRepository(Deployment) + + const id = nanoid() + + const updatedData = { + ...data, + id + } + const deployment = await deploymentRepository.save(updatedData) + + return deployment + } + + async getProjectMembersByProjectId( + projectId: string + ): Promise { + const projectMemberRepository = this.dataSource.getRepository(ProjectMember) + + const projectMembers = await projectMemberRepository.find({ + relations: { + project: true, + member: true + }, + where: { + project: { + id: projectId + } + } + }) + + return projectMembers + } + + async getEnvironmentVariablesByProjectId( + projectId: string, + filter?: FindOptionsWhere + ): Promise { + const environmentVariableRepository = + this.dataSource.getRepository(EnvironmentVariable) + + const environmentVariables = await environmentVariableRepository.find({ + where: { + project: { + id: projectId + }, + ...filter + } + }) + + return environmentVariables + } + + async removeProjectMemberById(projectMemberId: string): Promise { + const projectMemberRepository = this.dataSource.getRepository(ProjectMember) + + const deleteResult = await projectMemberRepository.delete({ + id: projectMemberId + }) + + if (deleteResult.affected) { + return deleteResult.affected > 0 + } + + return false + } + + async updateProjectMemberById( + projectMemberId: string, + data: DeepPartial + ): Promise { + const projectMemberRepository = this.dataSource.getRepository(ProjectMember) + const updateResult = await projectMemberRepository.update( + { id: projectMemberId }, + data + ) + + return Boolean(updateResult.affected) + } + + async addProjectMember( + data: DeepPartial + ): Promise { + const projectMemberRepository = this.dataSource.getRepository(ProjectMember) + const newProjectMember = await projectMemberRepository.save(data) + + return newProjectMember + } + + async addEnvironmentVariables( + data: DeepPartial[] + ): Promise { + const environmentVariableRepository = + this.dataSource.getRepository(EnvironmentVariable) + const savedEnvironmentVariables = + await environmentVariableRepository.save(data) + + return savedEnvironmentVariables + } + + async updateEnvironmentVariable( + environmentVariableId: string, + data: DeepPartial + ): Promise { + const environmentVariableRepository = + this.dataSource.getRepository(EnvironmentVariable) + const updateResult = await environmentVariableRepository.update( + { id: environmentVariableId }, + data + ) + + return Boolean(updateResult.affected) + } + + async deleteEnvironmentVariable( + environmentVariableId: string + ): Promise { + const environmentVariableRepository = + this.dataSource.getRepository(EnvironmentVariable) + const deleteResult = await environmentVariableRepository.delete({ + id: environmentVariableId + }) + + if (deleteResult.affected) { + return deleteResult.affected > 0 + } + + return false + } + + async getProjectMemberById(projectMemberId: string): Promise { + const projectMemberRepository = this.dataSource.getRepository(ProjectMember) + + const projectMemberWithProject = await projectMemberRepository.find({ + relations: { + project: { + owner: true + }, + member: true + }, + where: { + id: projectMemberId + } + }) + + if (projectMemberWithProject.length === 0) { + throw new Error('Member does not exist') + } + + return projectMemberWithProject[0] + } + + async getProjectsBySearchText( + userId: string, + searchText: string + ): Promise { + const projectRepository = this.dataSource.getRepository(Project) + + const projects = await projectRepository + .createQueryBuilder('project') + .leftJoinAndSelect('project.organization', 'organization') + .leftJoin('project.projectMembers', 'projectMembers') + .where( + '(project.owner = :userId OR projectMembers.member.id = :userId) AND project.name LIKE :searchText', + { + userId, + searchText: `%${searchText}%` + } + ) + .getMany() + + return projects + } + + async updateDeploymentById( + deploymentId: string, + data: DeepPartial + ): Promise { + return this.updateDeployment({ id: deploymentId }, data) + } + + async updateDeployment( + criteria: FindOptionsWhere, + data: DeepPartial + ): Promise { + const deploymentRepository = this.dataSource.getRepository(Deployment) + const updateResult = await deploymentRepository.update(criteria, data) + + return Boolean(updateResult.affected) + } + + async updateDeploymentsByProjectIds( + projectIds: string[], + data: DeepPartial + ): Promise { + const deploymentRepository = this.dataSource.getRepository(Deployment) + + const updateResult = await deploymentRepository + .createQueryBuilder() + .update(Deployment) + .set(data) + .where('projectId IN (:...projectIds)', { projectIds }) + .execute() + + return Boolean(updateResult.affected) + } + + async deleteDeploymentById(deploymentId: string): Promise { + const deploymentRepository = this.dataSource.getRepository(Deployment) + const deployment = await deploymentRepository.findOneOrFail({ + where: { + id: deploymentId + } + }) + + const deleteResult = await deploymentRepository.softRemove(deployment) + + return Boolean(deleteResult) + } + + async addProject( + user: User, + organizationId: string, + data: DeepPartial + ): Promise { + const projectRepository = this.dataSource.getRepository(Project) + + // TODO: Check if organization exists + const newProject = projectRepository.create(data) + // TODO: Set default empty array for webhooks in TypeORM + newProject.webhooks = [] + // TODO: Set icon according to framework + newProject.icon = '' + + newProject.owner = user + + newProject.organization = Object.assign(new Organization(), { + id: organizationId + }) + + return projectRepository.save(newProject) + } + + async saveProject(project: Project): Promise { + const projectRepository = this.dataSource.getRepository(Project) + + return projectRepository.save(project) + } + + async updateProjectById( + projectId: string, + data: DeepPartial + ): Promise { + const projectRepository = this.dataSource.getRepository(Project) + const updateResult = await projectRepository.update({ id: projectId }, data) + + return Boolean(updateResult.affected) + } + + async deleteProjectById(projectId: string): Promise { + const projectRepository = this.dataSource.getRepository(Project) + const project = await projectRepository.findOneOrFail({ + where: { + id: projectId + }, + relations: { + projectMembers: true + } + }) + + const deleteResult = await projectRepository.softRemove(project) + + return Boolean(deleteResult) + } + + async deleteDomainById(domainId: string): Promise { + const domainRepository = this.dataSource.getRepository(Domain) + + const deleteResult = await domainRepository.softDelete({ id: domainId }) + + if (deleteResult.affected) { + return deleteResult.affected > 0 + } + + return false + } + + async addDomain(data: DeepPartial): Promise { + const domainRepository = this.dataSource.getRepository(Domain) + const newDomain = await domainRepository.save(data) + + return newDomain + } + + async getDomain(options: FindOneOptions): Promise { + const domainRepository = this.dataSource.getRepository(Domain) + const domain = await domainRepository.findOne(options) + + return domain + } + + async updateDomainById( + domainId: string, + data: DeepPartial + ): Promise { + const domainRepository = this.dataSource.getRepository(Domain) + const updateResult = await domainRepository.update({ id: domainId }, data) + + return Boolean(updateResult.affected) + } + + async getDomainsByProjectId( + projectId: string, + filter?: FindOptionsWhere + ): Promise { + const domainRepository = this.dataSource.getRepository(Domain) + + const domains = await domainRepository.find({ + relations: { + redirectTo: true + }, + where: { + project: { + id: projectId + }, + ...filter + } + }) + + return domains + } + + async getOldestDomainByProjectId(projectId: string): Promise { + const domainRepository = this.dataSource.getRepository(Domain) + + const domain = await domainRepository.findOne({ + where: { + project: { + id: projectId + } + }, + order: { + createdAt: 'ASC' + } + }) + + return domain + } + + async getLatestDNSRecordByProjectId( + projectId: string + ): Promise { + const deploymentRepository = this.dataSource.getRepository(Deployment) + + const deployment = await deploymentRepository.findOne({ + where: { + project: { + id: projectId + }, + status: DeploymentStatus.Ready + }, + order: { + createdAt: 'DESC' + } + }) + + if (deployment === null) { + throw new Error(`No deployment found for project ${projectId}`) + } + + return deployment.dnsRecordData + } + + async addDeployer(data: DeepPartial): Promise { + const deployerRepository = this.dataSource.getRepository(Deployer) + const newDomain = await deployerRepository.save(data) + + return newDomain + } + + async getDeployers(): Promise { + const deployerRepository = this.dataSource.getRepository(Deployer) + const deployers = await deployerRepository.find() + return deployers + } + + async getDeployerByLRN(deployerLrn: string): Promise { + const deployerRepository = this.dataSource.getRepository(Deployer) + const deployer = await deployerRepository.findOne({ + where: { deployerLrn } + }) + + return deployer + } +} diff --git a/apps/backend/src/entity/Deployer.ts b/apps/backend/src/entity/Deployer.ts new file mode 100644 index 0000000..95feb76 --- /dev/null +++ b/apps/backend/src/entity/Deployer.ts @@ -0,0 +1,32 @@ +import { Column, Entity, ManyToMany, PrimaryColumn } from 'typeorm' +import { Project } from './Project' + +@Entity() +export class Deployer { + @PrimaryColumn('varchar') + deployerLrn!: string + + @Column('varchar') + deployerId!: string + + @Column('varchar') + deployerApiUrl!: string + + @Column('varchar') + baseDomain!: string + + @Column('varchar', { nullable: true }) + publicKey!: string | null + + @Column('varchar', { nullable: true }) + minimumPayment!: string | null + + @Column('varchar', { nullable: true }) + paymentAddress!: string | null + + @ManyToMany( + () => Project, + (project) => project.deployers + ) + projects!: Project[] +} diff --git a/apps/backend/src/entity/Deployment.ts b/apps/backend/src/entity/Deployment.ts new file mode 100644 index 0000000..5e02678 --- /dev/null +++ b/apps/backend/src/entity/Deployment.ts @@ -0,0 +1,159 @@ +import { + Column, + CreateDateColumn, + DeleteDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryColumn, + UpdateDateColumn +} from 'typeorm' + +import type { + AppDeploymentRecordAttributes, + AppDeploymentRemovalRecordAttributes, + DNSRecordAttributes +} from '../types' +import { Deployer } from './Deployer' +import { Project } from './Project' +import { User } from './User' + +export enum Environment { + Production = 'Production', + Preview = 'Preview', + Development = 'Development' +} + +export enum DeploymentStatus { + Building = 'Building', + Ready = 'Ready', + Error = 'Error', + Deleting = 'Deleting' +} + +export interface ApplicationDeploymentRequest { + type: string + version: string + name: string + application: string + lrn?: string + auction?: string + config: string + meta: string + payment?: string + dns?: string +} + +export interface ApplicationDeploymentRemovalRequest { + type: string + version: string + deployment: string + auction?: string + payment?: string +} + +export interface ApplicationRecord { + type: string + version: string + name: string + description?: string + homepage?: string + license?: string + author?: string + repository?: string[] + app_version?: string + repository_ref: string + app_type: string +} + +@Entity() +export class Deployment { + // TODO: set custom generated id + @PrimaryColumn('varchar') + id!: string + + @Column() + projectId!: string + + @ManyToOne(() => Project, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'projectId' }) + project!: Project + + @Column('varchar') + branch!: string + + @Column('varchar') + commitHash!: string + + @Column('varchar') + commitMessage!: string + + @Column('varchar', { nullable: true }) + url!: string | null + + @Column('varchar') + applicationRecordId!: string + + @Column('simple-json') + applicationRecordData!: ApplicationRecord + + @Column('varchar', { nullable: true }) + applicationDeploymentRequestId!: string | null + + @Column('simple-json', { nullable: true }) + applicationDeploymentRequestData!: ApplicationDeploymentRequest | null + + @Column('varchar', { nullable: true }) + applicationDeploymentRecordId!: string | null + + @Column('simple-json', { nullable: true }) + applicationDeploymentRecordData!: AppDeploymentRecordAttributes | null + + @Column('varchar', { nullable: true }) + applicationDeploymentRemovalRequestId!: string | null + + @Column('simple-json', { nullable: true }) + applicationDeploymentRemovalRequestData!: ApplicationDeploymentRemovalRequest | null + + @Column('varchar', { nullable: true }) + applicationDeploymentRemovalRecordId!: string | null + + @Column('simple-json', { nullable: true }) + applicationDeploymentRemovalRecordData!: AppDeploymentRemovalRecordAttributes | null + + @Column('simple-json', { nullable: true }) + dnsRecordData!: DNSRecordAttributes | null + + @ManyToOne(() => Deployer) + @JoinColumn({ name: 'deployerLrn' }) + deployer!: Deployer + + @Column({ + enum: Environment + }) + environment!: Environment + + @Column('boolean', { default: false }) + isCurrent!: boolean + + @Column('boolean', { default: false }) + isCanonical!: boolean + + @Column({ + enum: DeploymentStatus + }) + status!: DeploymentStatus + + @ManyToOne(() => User) + @JoinColumn({ name: 'createdBy' }) + createdBy!: User + + @CreateDateColumn() + createdAt!: Date + + @UpdateDateColumn() + updatedAt!: Date + + @DeleteDateColumn() + deletedAt!: Date | null +} diff --git a/apps/backend/src/entity/Domain.ts b/apps/backend/src/entity/Domain.ts new file mode 100644 index 0000000..93a263f --- /dev/null +++ b/apps/backend/src/entity/Domain.ts @@ -0,0 +1,59 @@ +import { + Column, + CreateDateColumn, + DeleteDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn +} from 'typeorm' + +import { Project } from './Project' + +export enum Status { + Live = 'Live', + Pending = 'Pending' +} + +@Entity() +export class Domain { + @PrimaryGeneratedColumn('uuid') + id!: string + + @Column('varchar') + projectId!: string + + @ManyToOne(() => Project, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'projectId' }) + project!: Project + + @Column('varchar', { length: 255, default: 'main' }) + branch!: string + + @Column('varchar', { length: 255 }) + name!: string + + @Column('string', { nullable: true }) + redirectToId!: string | null + + @ManyToOne(() => Domain) + @JoinColumn({ name: 'redirectToId' }) + // eslint-disable-next-line no-use-before-define + redirectTo!: Domain | null + + @Column({ + enum: Status, + default: Status.Pending + }) + status!: Status + + @CreateDateColumn() + createdAt!: Date + + @UpdateDateColumn() + updatedAt!: Date + + @DeleteDateColumn() + deletedAt!: Date | null +} diff --git a/apps/backend/src/entity/EnvironmentVariable.ts b/apps/backend/src/entity/EnvironmentVariable.ts new file mode 100644 index 0000000..45ef5eb --- /dev/null +++ b/apps/backend/src/entity/EnvironmentVariable.ts @@ -0,0 +1,44 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn +} from 'typeorm' + +import { Project } from './Project' + +enum Environment { + Production = 'Production', + Preview = 'Preview', + Development = 'Development' +} + +@Entity() +export class EnvironmentVariable { + @PrimaryGeneratedColumn('uuid') + id!: string + + @ManyToOne(() => Project, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'projectId' }) + project!: Project + + @Column({ + enum: Environment + }) + environment!: Environment + + @Column('varchar') + key!: string + + @Column('varchar') + value!: string + + @CreateDateColumn() + createdAt!: Date + + @UpdateDateColumn() + updatedAt!: Date +} diff --git a/apps/backend/src/entity/Organization.ts b/apps/backend/src/entity/Organization.ts new file mode 100644 index 0000000..b2fe727 --- /dev/null +++ b/apps/backend/src/entity/Organization.ts @@ -0,0 +1,38 @@ +import { + Column, + CreateDateColumn, + Entity, + OneToMany, + PrimaryGeneratedColumn, + Unique, + UpdateDateColumn +} from 'typeorm' +import { UserOrganization } from './UserOrganization' + +@Entity() +@Unique(['slug']) +export class Organization { + @PrimaryGeneratedColumn('uuid') + id!: string + + @Column('varchar', { length: 255 }) + name!: string + + @Column('varchar') + slug!: string + + @CreateDateColumn() + createdAt!: Date + + @UpdateDateColumn() + updatedAt!: Date + + @OneToMany( + () => UserOrganization, + (userOrganization) => userOrganization.organization, + { + cascade: ['soft-remove'] + } + ) + userOrganizations!: UserOrganization[] +} diff --git a/apps/backend/src/entity/Project.ts b/apps/backend/src/entity/Project.ts new file mode 100644 index 0000000..7de2418 --- /dev/null +++ b/apps/backend/src/entity/Project.ts @@ -0,0 +1,111 @@ +import { + Column, + CreateDateColumn, + DeleteDateColumn, + Entity, + JoinColumn, + JoinTable, + ManyToMany, + ManyToOne, + OneToMany, + PrimaryGeneratedColumn, + UpdateDateColumn +} from 'typeorm' + +import { Deployer } from './Deployer' +import { Deployment } from './Deployment' +import { Organization } from './Organization' +import { ProjectMember } from './ProjectMember' +import { User } from './User' + +@Entity() +export class Project { + @PrimaryGeneratedColumn('uuid') + id!: string + + @ManyToOne(() => User) + @JoinColumn({ name: 'ownerId' }) + owner!: User + + @Column({ nullable: false }) + ownerId!: string + + @ManyToOne(() => Organization, { nullable: true }) + @JoinColumn({ name: 'organizationId' }) + organization!: Organization | null + + @Column('varchar') + organizationId!: string + + @Column('varchar') + name!: string + + @Column('varchar') + repository!: string + + @Column('varchar', { length: 255, default: 'main' }) + prodBranch!: string + + @Column('text', { default: '' }) + description!: string + + @Column('varchar', { nullable: true }) + auctionId!: string | null + + // Tx hash for sending coins from snowball to deployer + @Column('varchar', { nullable: true }) + txHash!: string | null + + @ManyToMany( + () => Deployer, + (deployer) => deployer.projects + ) + @JoinTable() + deployers!: Deployer[] + + @Column('boolean', { default: false, nullable: true }) + fundsReleased!: boolean + + // TODO: Compute template & framework in import repository + @Column('varchar', { nullable: true }) + template!: string | null + + @Column('varchar', { nullable: true }) + framework!: string | null + + // Address of the user who created the project i.e. requested deployments + @Column('varchar') + paymentAddress!: string + + @Column({ + type: 'simple-array' + }) + webhooks!: string[] + + @Column('varchar') + icon!: string + + @CreateDateColumn() + createdAt!: Date + + @UpdateDateColumn() + updatedAt!: Date + + @DeleteDateColumn() + deletedAt!: Date | null + + @OneToMany( + () => Deployment, + (deployment) => deployment.project + ) + deployments!: Deployment[] + + @OneToMany( + () => ProjectMember, + (projectMember) => projectMember.project, + { + cascade: ['soft-remove'] + } + ) + projectMembers!: ProjectMember[] +} diff --git a/apps/backend/src/entity/ProjectMember.ts b/apps/backend/src/entity/ProjectMember.ts new file mode 100644 index 0000000..0e75ee0 --- /dev/null +++ b/apps/backend/src/entity/ProjectMember.ts @@ -0,0 +1,57 @@ +import { + Column, + CreateDateColumn, + DeleteDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + Unique, + UpdateDateColumn +} from 'typeorm' + +import { Project } from './Project' +import { User } from './User' + +export enum Permission { + View = 'View', + Edit = 'Edit' +} + +@Entity() +@Unique(['project', 'member']) +export class ProjectMember { + @PrimaryGeneratedColumn('uuid') + id!: string + + @ManyToOne( + () => User, + (user) => user.projectMembers + ) + @JoinColumn({ name: 'userId' }) + member!: User + + @ManyToOne( + () => Project, + (project) => project.projectMembers + ) + @JoinColumn({ name: 'projectId' }) + project!: Project + + @Column({ + type: 'simple-array' + }) + permissions!: Permission[] + + @Column('boolean', { default: false }) + isPending!: boolean + + @CreateDateColumn() + createdAt!: Date + + @UpdateDateColumn() + updatedAt!: Date + + @DeleteDateColumn() + deletedAt!: Date | null +} diff --git a/apps/backend/src/entity/User.ts b/apps/backend/src/entity/User.ts new file mode 100644 index 0000000..86cdc76 --- /dev/null +++ b/apps/backend/src/entity/User.ts @@ -0,0 +1,65 @@ +import { + Column, + CreateDateColumn, + Entity, + OneToMany, + PrimaryGeneratedColumn, + Unique +} from 'typeorm' + +import { ProjectMember } from './ProjectMember' +import { UserOrganization } from './UserOrganization' + +@Entity() +@Unique(['email']) +@Unique(['ethAddress']) +export class User { + @PrimaryGeneratedColumn('uuid') + id!: string + + // TODO: Set ethAddress as ID + @Column() + ethAddress!: string + + @Column('varchar', { length: 255, nullable: true }) + name!: string | null + + @Column() + email!: string + + @Column('varchar', { nullable: true }) + gitHubToken!: string | null + + @Column('boolean', { default: false }) + isVerified!: boolean + + @CreateDateColumn() + createdAt!: Date + + @CreateDateColumn() + updatedAt!: Date + + @Column() + subOrgId!: string + + @Column() + turnkeyWalletId!: string + + @OneToMany( + () => ProjectMember, + (projectMember) => projectMember.project, + { + cascade: ['soft-remove'] + } + ) + projectMembers!: ProjectMember[] + + @OneToMany( + () => UserOrganization, + (UserOrganization) => UserOrganization.member, + { + cascade: ['soft-remove'] + } + ) + userOrganizations!: UserOrganization[] +} diff --git a/apps/backend/src/entity/UserOrganization.ts b/apps/backend/src/entity/UserOrganization.ts new file mode 100644 index 0000000..2f34491 --- /dev/null +++ b/apps/backend/src/entity/UserOrganization.ts @@ -0,0 +1,47 @@ +import { + Column, + CreateDateColumn, + DeleteDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn +} from 'typeorm' + +import { Organization } from './Organization' +import { User } from './User' + +export enum Role { + Owner = 'Owner', + Maintainer = 'Maintainer', + Reader = 'Reader' +} + +@Entity() +export class UserOrganization { + @PrimaryGeneratedColumn('uuid') + id!: string + + @ManyToOne(() => User) + @JoinColumn({ name: 'userId' }) + member!: User + + @ManyToOne(() => Organization) + @JoinColumn({ name: 'organizationId' }) + organization!: Organization + + @Column({ + enum: Role + }) + role!: Role + + @CreateDateColumn() + createdAt!: Date + + @UpdateDateColumn() + updatedAt!: Date + + @DeleteDateColumn() + deletedAt!: Date | null +} diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts new file mode 100644 index 0000000..5195e9b --- /dev/null +++ b/apps/backend/src/index.ts @@ -0,0 +1,53 @@ +import 'express-async-errors' +import 'reflect-metadata' +import fs from 'node:fs' +import path from 'node:path' +import debug from 'debug' + +import { OAuthApp } from '@octokit/oauth-app' + +import { Database } from './database' +import { Registry } from './registry' +import { createResolvers } from './resolvers' +import { createAndStartServer } from './server' +import { Service } from './service' +import { getConfig } from './utils' + +const log = debug('snowball:server') +const OAUTH_CLIENT_TYPE = 'oauth-app' + +export const main = async (): Promise => { + const { server, database, gitHub, registryConfig } = await getConfig() + + const app = new OAuthApp({ + clientType: OAUTH_CLIENT_TYPE, + clientId: gitHub.oAuth.clientId, + clientSecret: gitHub.oAuth.clientSecret + }) + + const db = new Database(database) + await db.init() + + const registry = new Registry(registryConfig) + const service = new Service( + { gitHubConfig: gitHub, registryConfig }, + db, + app, + registry + ) + + const typeDefs = fs + .readFileSync(path.join(__dirname, 'schema.gql')) + .toString() + const resolvers = await createResolvers(service) + + await createAndStartServer(server, typeDefs, resolvers, service) +} + +main() + .then(() => { + log('Starting server...') + }) + .catch((err) => { + log(err) + }) diff --git a/apps/backend/src/registry.ts b/apps/backend/src/registry.ts new file mode 100644 index 0000000..b74ce8b --- /dev/null +++ b/apps/backend/src/registry.ts @@ -0,0 +1,624 @@ +import assert from 'node:assert' +import debug from 'debug' +import { DateTime } from 'luxon' +import type { Octokit } from 'octokit' +import * as openpgp from 'openpgp' +import { inc as semverInc } from 'semver' +import type { DeepPartial } from 'typeorm' + +import { + Account, + DEFAULT_GAS_ESTIMATION_MULTIPLIER, + Registry as LaconicRegistry, + getGasPrice, + parseGasAndFees +} from '@cerc-io/registry-sdk' +import type { DeliverTxResponse, IndexedTx } from '@cosmjs/stargate' + +import type { RegistryConfig } from './config' +import type { + ApplicationDeploymentRemovalRequest, + ApplicationDeploymentRequest, + ApplicationRecord, + Deployment +} from './entity/Deployment' +import type { + AppDeploymentRecord, + AppDeploymentRemovalRecord, + AuctionParams, + DeployerRecord, + RegistryRecord +} from './types' +import { + getConfig, + getRepoDetails, + registryTransactionWithRetry, + sleep +} from './utils' + +const log = debug('snowball:registry') + +const APP_RECORD_TYPE = 'ApplicationRecord' +const APP_DEPLOYMENT_AUCTION_RECORD_TYPE = 'ApplicationDeploymentAuction' +const APP_DEPLOYMENT_REQUEST_TYPE = 'ApplicationDeploymentRequest' +const APP_DEPLOYMENT_REMOVAL_REQUEST_TYPE = + 'ApplicationDeploymentRemovalRequest' +const APP_DEPLOYMENT_RECORD_TYPE = 'ApplicationDeploymentRecord' +const APP_DEPLOYMENT_REMOVAL_RECORD_TYPE = 'ApplicationDeploymentRemovalRecord' +const WEBAPP_DEPLOYER_RECORD_TYPE = 'WebappDeployer' +const SLEEP_DURATION = 1000 + +// TODO: Move registry code to registry-sdk/watcher-ts +export class Registry { + private registry: LaconicRegistry + private registryConfig: RegistryConfig + + constructor(registryConfig: RegistryConfig) { + this.registryConfig = registryConfig + + const gasPrice = getGasPrice(registryConfig.fee.gasPrice) + + this.registry = new LaconicRegistry( + registryConfig.gqlEndpoint, + registryConfig.restEndpoint, + { chainId: registryConfig.chainId, gasPrice } + ) + } + + async createApplicationRecord({ + octokit, + repository, + commitHash, + appType + }: { + octokit: Octokit + repository: string + commitHash: string + appType: string + }): Promise<{ + applicationRecordId: string + applicationRecordData: ApplicationRecord + }> { + const { repo, repoUrl, packageJSON } = await getRepoDetails( + octokit, + repository, + commitHash + ) + // Use registry-sdk to publish record + // Reference: https://git.vdb.to/cerc-io/test-progressive-web-app/src/branch/main/scripts/publish-app-record.sh + // Fetch previous records + const records = await this.registry.queryRecords( + { + type: APP_RECORD_TYPE, + name: packageJSON.name + }, + true + ) + + // Get next version of record + const bondRecords = records.filter( + (record: any) => record.bondId === this.registryConfig.bondId + ) + const [latestBondRecord] = bondRecords.sort( + (a: any, b: any) => + new Date(b.createTime).getTime() - new Date(a.createTime).getTime() + ) + const nextVersion = semverInc( + latestBondRecord?.attributes.version ?? '0.0.0', + 'patch' + ) + + assert(nextVersion, 'Application record version not valid') + + // Create record of type ApplicationRecord and publish + const applicationRecord = { + type: APP_RECORD_TYPE, + version: nextVersion, + repository_ref: commitHash, + repository: [repoUrl], + app_type: appType, + name: repo, + ...(packageJSON.description && { description: packageJSON.description }), + ...(packageJSON.homepage && { homepage: packageJSON.homepage }), + ...(packageJSON.license && { license: packageJSON.license }), + ...(packageJSON.author && { + author: + typeof packageJSON.author === 'object' + ? JSON.stringify(packageJSON.author) + : packageJSON.author + }), + ...(packageJSON.version && { app_version: packageJSON.version }) + } + + const result = await this.publishRecord(applicationRecord) + + log(`Published application record ${result.id}`) + log('Application record data:', applicationRecord) + + // TODO: Discuss computation of LRN + const lrn = this.getLrn(repo) + log(`Setting name: ${lrn} for record ID: ${result.id}`) + + const fee = parseGasAndFees( + this.registryConfig.fee.gas, + this.registryConfig.fee.fees + ) + + await sleep(SLEEP_DURATION) + await registryTransactionWithRetry(() => + this.registry.setName( + { + cid: result.id, + lrn + }, + this.registryConfig.privateKey, + fee + ) + ) + + await sleep(SLEEP_DURATION) + await registryTransactionWithRetry(() => + this.registry.setName( + { + cid: result.id, + lrn: `${lrn}@${applicationRecord.app_version}` + }, + this.registryConfig.privateKey, + fee + ) + ) + + await sleep(SLEEP_DURATION) + await registryTransactionWithRetry(() => + this.registry.setName( + { + cid: result.id, + lrn: `${lrn}@${applicationRecord.repository_ref}` + }, + this.registryConfig.privateKey, + fee + ) + ) + + return { + applicationRecordId: result.id, + applicationRecordData: applicationRecord + } + } + + async createApplicationDeploymentAuction( + appName: string, + octokit: Octokit, + auctionParams: AuctionParams, + data: DeepPartial + ): Promise<{ + applicationDeploymentAuctionId: string + }> { + assert(data.project?.repository, 'Project repository not found') + + await this.createApplicationRecord({ + octokit, + repository: data.project.repository, + appType: data.project!.template!, + commitHash: data.commitHash! + }) + + const lrn = this.getLrn(appName) + const config = await getConfig() + const auctionConfig = config.auction + + const fee = parseGasAndFees( + this.registryConfig.fee.gas, + this.registryConfig.fee.fees + ) + const auctionResult = await registryTransactionWithRetry(() => + this.registry.createProviderAuction( + { + commitFee: auctionConfig.commitFee, + commitsDuration: auctionConfig.commitsDuration, + revealFee: auctionConfig.revealFee, + revealsDuration: auctionConfig.revealsDuration, + denom: auctionConfig.denom, + maxPrice: auctionParams.maxPrice, + numProviders: auctionParams.numProviders + }, + this.registryConfig.privateKey, + fee + ) + ) + + if (!auctionResult.auction) { + throw new Error('Error creating auction') + } + + // Create record of type applicationDeploymentAuction and publish + const applicationDeploymentAuction = { + application: lrn, + auction: auctionResult.auction.id, + type: APP_DEPLOYMENT_AUCTION_RECORD_TYPE + } + + const result = await this.publishRecord(applicationDeploymentAuction) + + log(`Application deployment auction created: ${auctionResult.auction.id}`) + log(`Application deployment auction record published: ${result.id}`) + log('Application deployment auction data:', applicationDeploymentAuction) + + return { + applicationDeploymentAuctionId: auctionResult.auction.id + } + } + + async createApplicationDeploymentRequest(data: { + deployment: Deployment + appName: string + repository: string + auctionId?: string | null + lrn: string + apiUrl: string + environmentVariables: { [key: string]: string } + dns: string + requesterAddress: string + publicKey: string + payment?: string | null + }): Promise<{ + applicationDeploymentRequestId: string + applicationDeploymentRequestData: ApplicationDeploymentRequest + }> { + const lrn = this.getLrn(data.appName) + const records = await this.registry.resolveNames([lrn]) + const applicationRecord = records[0] + + if (!applicationRecord) { + throw new Error(`No record found for ${lrn}`) + } + + let hash: string | undefined + if (Object.keys(data.environmentVariables).length !== 0) { + hash = await this.generateConfigHash( + data.environmentVariables, + data.requesterAddress, + data.publicKey, + data.apiUrl + ) + } + + // Create record of type ApplicationDeploymentRequest and publish + const applicationDeploymentRequest = { + type: APP_DEPLOYMENT_REQUEST_TYPE, + version: '1.0.0', + name: `${applicationRecord.attributes.name}@${applicationRecord.attributes.app_version}`, + application: `${lrn}@${applicationRecord.attributes.app_version}`, + dns: data.dns, + + // https://git.vdb.to/cerc-io/laconic-registry-cli/commit/129019105dfb93bebcea02fde0ed64d0f8e5983b + config: JSON.stringify(hash ? { ref: hash } : {}), + meta: JSON.stringify({ + note: `Added by Snowball @ ${DateTime.utc().toFormat( + "EEE LLL dd HH:mm:ss 'UTC' yyyy" + )}`, + repository: data.repository, + repository_ref: data.deployment.commitHash + }), + deployer: data.lrn, + ...(data.auctionId && { auction: data.auctionId }), + ...(data.payment && { payment: data.payment }) + } + + await sleep(SLEEP_DURATION) + + const result = await this.publishRecord(applicationDeploymentRequest) + + log(`Application deployment request record published: ${result.id}`) + log('Application deployment request data:', applicationDeploymentRequest) + + return { + applicationDeploymentRequestId: result.id, + applicationDeploymentRequestData: applicationDeploymentRequest + } + } + + async getAuctionWinningDeployerRecords( + auctionId: string + ): Promise { + const records = await this.registry.getAuctionsByIds([auctionId]) + const auctionResult = records[0] + + const deployerRecords = [] + const { winnerAddresses } = auctionResult + + for (const auctionWinner of winnerAddresses) { + const records = await this.getDeployerRecordsByFilter({ + paymentAddress: auctionWinner + }) + + const newRecords = records.filter((record) => { + return record.names !== null && record.names.length > 0 + }) + + for (const record of newRecords) { + if (record.id) { + deployerRecords.push(record) + break + } + } + } + + return deployerRecords + } + + async releaseDeployerFunds(auctionId: string): Promise { + const fee = parseGasAndFees( + this.registryConfig.fee.gas, + this.registryConfig.fee.fees + ) + const auction = await registryTransactionWithRetry(() => + this.registry.releaseFunds( + { + auctionId + }, + this.registryConfig.privateKey, + fee + ) + ) + + return auction + } + + /** + * Fetch ApplicationDeploymentRecords for deployments + */ + async getDeploymentRecords( + deployments: Deployment[] + ): Promise { + // Fetch ApplicationDeploymentRecords for corresponding ApplicationRecord set in deployments + // TODO: Implement Laconicd GQL query to filter records by multiple values for an attribute + const records = await this.registry.queryRecords( + { + type: APP_DEPLOYMENT_RECORD_TYPE + }, + true + ) + + // Filter records with ApplicationDeploymentRequestId ID + return records.filter((record: AppDeploymentRecord) => + deployments.some( + (deployment) => + deployment.applicationDeploymentRequestId === + record.attributes.request + ) + ) + } + + /** + * Fetch WebappDeployer Records by filter + */ + async getDeployerRecordsByFilter(filter: { [key: string]: any }): Promise< + DeployerRecord[] + > { + return this.registry.queryRecords( + { + type: WEBAPP_DEPLOYER_RECORD_TYPE, + ...filter + }, + true + ) + } + + /** + * Fetch ApplicationDeploymentRecords by filter + */ + async getDeploymentRecordsByFilter(filter: { [key: string]: any }): Promise< + AppDeploymentRecord[] + > { + return this.registry.queryRecords( + { + type: APP_DEPLOYMENT_RECORD_TYPE, + ...filter + }, + true + ) + } + + /** + * Fetch ApplicationDeploymentRemovalRecords for deployments + */ + async getDeploymentRemovalRecords( + deployments: Deployment[] + ): Promise { + // Fetch ApplicationDeploymentRemovalRecords for corresponding ApplicationDeploymentRecord set in deployments + const records = await this.registry.queryRecords( + { + type: APP_DEPLOYMENT_REMOVAL_RECORD_TYPE + }, + true + ) + + // Filter records with ApplicationDeploymentRecord and ApplicationDeploymentRemovalRequest IDs + return records.filter((record: AppDeploymentRemovalRecord) => + deployments.some( + (deployment) => + deployment.applicationDeploymentRemovalRequestId === + record.attributes.request && + deployment.applicationDeploymentRecordId === + record.attributes.deployment + ) + ) + } + + /** + * Fetch record by Id + */ + async getRecordById(id: string): Promise { + const [record] = await this.registry.getRecordsByIds([id]) + return record ?? null + } + + async createApplicationDeploymentRemovalRequest(data: { + deploymentId: string + deployerLrn: string + auctionId?: string | null + payment?: string | null + }): Promise<{ + applicationDeploymentRemovalRequestId: string + applicationDeploymentRemovalRequestData: ApplicationDeploymentRemovalRequest + }> { + const applicationDeploymentRemovalRequest = { + type: APP_DEPLOYMENT_REMOVAL_REQUEST_TYPE, + version: '1.0.0', + deployment: data.deploymentId, + deployer: data.deployerLrn, + ...(data.auctionId && { auction: data.auctionId }), + ...(data.payment && { payment: data.payment }) + } + + const result = await this.publishRecord(applicationDeploymentRemovalRequest) + + log(`Application deployment removal request record published: ${result.id}`) + log( + 'Application deployment removal request data:', + applicationDeploymentRemovalRequest + ) + + return { + applicationDeploymentRemovalRequestId: result.id, + applicationDeploymentRemovalRequestData: + applicationDeploymentRemovalRequest + } + } + + async getCompletedAuctionIds(auctionIds: string[]): Promise { + if (auctionIds.length === 0) { + return [] + } + + const auctions = await this.registry.getAuctionsByIds(auctionIds) + + const completedAuctions = auctions + .filter( + (auction: { id: string; status: string }) => + auction.status === 'completed' + ) + .map((auction: { id: string; status: string }) => auction.id) + + return completedAuctions + } + + async publishRecord(recordData: any): Promise { + const fee = parseGasAndFees( + this.registryConfig.fee.gas, + this.registryConfig.fee.fees + ) + + const result = await registryTransactionWithRetry(() => + this.registry.setRecord( + { + privateKey: this.registryConfig.privateKey, + record: recordData, + bondId: this.registryConfig.bondId + }, + this.registryConfig.privateKey, + fee + ) + ) + + return result + } + + async getRecordsByName(name: string): Promise { + return this.registry.resolveNames([name]) + } + + async getAuctionData(auctionId: string): Promise { + return this.registry.getAuctionsByIds([auctionId]) + } + + async sendTokensToAccount( + receiverAddress: string, + amount: string + ): Promise { + const fee = parseGasAndFees( + this.registryConfig.fee.gas, + this.registryConfig.fee.fees + ) + const account = await this.getAccount() + const laconicClient = await this.registry.getLaconicClient(account) + const txResponse: DeliverTxResponse = await registryTransactionWithRetry( + () => + laconicClient.sendTokens( + account.address, + receiverAddress, + [ + { + denom: 'alnt', + amount + } + ], + fee || DEFAULT_GAS_ESTIMATION_MULTIPLIER + ) + ) + + return txResponse + } + + async getAccount(): Promise { + const account = new Account( + Buffer.from(this.registryConfig.privateKey, 'hex') + ) + await account.init() + + return account + } + + async getTxResponse(txHash: string): Promise { + const account = await this.getAccount() + const laconicClient = await this.registry.getLaconicClient(account) + const txResponse: IndexedTx | null = await laconicClient.getTx(txHash) + + return txResponse + } + + getLrn(appName: string): string { + assert(this.registryConfig.authority, "Authority doesn't exist") + return `lrn://${this.registryConfig.authority}/applications/${appName}` + } + + async generateConfigHash( + environmentVariables: { [key: string]: string }, + requesterAddress: string, + pubKey: string, + url: string + ): Promise { + // Config to be encrypted + const config = { + authorized: [requesterAddress], + config: { env: environmentVariables } + } + + // Serialize the config + const serialized = JSON.stringify(config, null, 2) + + const armoredKey = `-----BEGIN PGP PUBLIC KEY BLOCK-----\n\n${pubKey}\n\n-----END PGP PUBLIC KEY BLOCK-----` + const publicKey = await openpgp.readKey({ armoredKey }) + + // Encrypt the config + const encrypted = await openpgp.encrypt({ + message: await openpgp.createMessage({ text: serialized }), + encryptionKeys: publicKey, + format: 'binary' + }) + + // Get the hash after uploading encrypted config + const response = await fetch(`${url}/upload/config`, { + method: 'POST', + headers: { + 'Content-Type': 'application/octet-stream' + }, + body: encrypted + }) + + const configHash = await response.json() + + return configHash.id + } +} diff --git a/apps/backend/src/resolvers.ts b/apps/backend/src/resolvers.ts new file mode 100644 index 0000000..38a4a31 --- /dev/null +++ b/apps/backend/src/resolvers.ts @@ -0,0 +1,413 @@ +import debug from 'debug' +import type { DeepPartial, FindOptionsWhere } from 'typeorm' + +import type { Domain } from './entity/Domain' +import type { EnvironmentVariable } from './entity/EnvironmentVariable' +import type { Project } from './entity/Project' +import type { Permission } from './entity/ProjectMember' +import type { Service } from './service' +import type { + AddProjectFromTemplateInput, + AuctionParams, + EnvironmentVariables +} from './types' + +const log = debug('snowball:resolver') + +export const createResolvers = async (service: Service): Promise => { + return { + Query: { + // TODO: add custom type for context + user: (_: any, __: any, context: any) => { + return context.user + }, + + organizations: async (_: any, __: any, context: any) => { + return service.getOrganizationsByUserId(context.user) + }, + + project: async ( + _: any, + { projectId }: { projectId: string }, + context: any + ) => { + return service.getProjectById(context.user, projectId) + }, + + projectsInOrganization: async ( + _: any, + { organizationSlug }: { organizationSlug: string }, + context: any + ) => { + return service.getProjectsInOrganization(context.user, organizationSlug) + }, + + deployments: async (_: any, { projectId }: { projectId: string }) => { + return service.getNonCanonicalDeploymentsByProjectId(projectId) + }, + + environmentVariables: async ( + _: any, + { projectId }: { projectId: string } + ) => { + return service.getEnvironmentVariablesByProjectId(projectId) + }, + + projectMembers: async (_: any, { projectId }: { projectId: string }) => { + return service.getProjectMembersByProjectId(projectId) + }, + + searchProjects: async ( + _: any, + { searchText }: { searchText: string }, + context: any + ) => { + return service.searchProjects(context.user, searchText) + }, + + domains: async ( + _: any, + { + projectId, + filter + }: { projectId: string; filter?: FindOptionsWhere } + ) => { + return service.getDomainsByProjectId(projectId, filter) + }, + + getAuctionData: async (_: any, { auctionId }: { auctionId: string }) => { + return service.getAuctionData(auctionId) + }, + + deployers: async (_: any, __: any) => { + return service.getDeployers() + }, + + address: async (_: any, __: any) => { + return service.getAddress() + }, + + verifyTx: async ( + _: any, + { + txHash, + amount, + senderAddress + }: { txHash: string; amount: string; senderAddress: string } + ) => { + return service.verifyTx(txHash, amount, senderAddress) + }, + + latestDNSRecord: async (_: any, { projectId }: { projectId: string }) => { + return service.getLatestDNSRecordByProjectId(projectId) + } + }, + + // TODO: Return error in GQL response + Mutation: { + removeProjectMember: async ( + _: any, + { projectMemberId }: { projectMemberId: string }, + context: any + ) => { + try { + return await service.removeProjectMember( + context.user, + projectMemberId + ) + } catch (err) { + log(err) + return false + } + }, + + updateProjectMember: async ( + _: any, + { + projectMemberId, + data + }: { + projectMemberId: string + data: { + permissions: Permission[] + } + } + ) => { + try { + return await service.updateProjectMember(projectMemberId, data) + } catch (err) { + log(err) + return false + } + }, + + addProjectMember: async ( + _: any, + { + projectId, + data + }: { + projectId: string + data: { + email: string + permissions: Permission[] + } + } + ) => { + try { + return Boolean(await service.addProjectMember(projectId, data)) + } catch (err) { + log(err) + return false + } + }, + + addEnvironmentVariables: async ( + _: any, + { + projectId, + data + }: { + projectId: string + data: { environments: string[]; key: string; value: string }[] + } + ) => { + try { + return Boolean(await service.addEnvironmentVariables(projectId, data)) + } catch (err) { + log(err) + return false + } + }, + + updateEnvironmentVariable: async ( + _: any, + { + environmentVariableId, + data + }: { + environmentVariableId: string + data: DeepPartial + } + ) => { + try { + return await service.updateEnvironmentVariable( + environmentVariableId, + data + ) + } catch (err) { + log(err) + return false + } + }, + + removeEnvironmentVariable: async ( + _: any, + { environmentVariableId }: { environmentVariableId: string } + ) => { + try { + return await service.removeEnvironmentVariable(environmentVariableId) + } catch (err) { + log(err) + return false + } + }, + + updateDeploymentToProd: async ( + _: any, + { deploymentId }: { deploymentId: string }, + context: any + ) => { + try { + return Boolean( + await service.updateDeploymentToProd(context.user, deploymentId) + ) + } catch (err) { + log(err) + return false + } + }, + + addProjectFromTemplate: async ( + _: any, + { + organizationSlug, + data, + lrn, + auctionParams, + environmentVariables + }: { + organizationSlug: string + data: AddProjectFromTemplateInput + lrn: string + auctionParams: AuctionParams + environmentVariables: EnvironmentVariables[] + }, + context: any + ) => { + try { + return await service.addProjectFromTemplate( + context.user, + organizationSlug, + data, + lrn, + auctionParams, + environmentVariables + ) + } catch (err) { + log(err) + throw err + } + }, + + addProject: async ( + _: any, + { + organizationSlug, + data, + lrn, + auctionParams, + environmentVariables + }: { + organizationSlug: string + data: DeepPartial + lrn: string + auctionParams: AuctionParams + environmentVariables: EnvironmentVariables[] + }, + context: any + ) => { + try { + return await service.addProject( + context.user, + organizationSlug, + data, + lrn, + auctionParams, + environmentVariables + ) + } catch (err) { + log(err) + throw err + } + }, + + updateProject: async ( + _: any, + { projectId, data }: { projectId: string; data: DeepPartial } + ) => { + try { + return await service.updateProject(projectId, data) + } catch (err) { + log(err) + return false + } + }, + + redeployToProd: async ( + _: any, + { deploymentId }: { deploymentId: string }, + context: any + ) => { + try { + return Boolean( + await service.redeployToProd(context.user, deploymentId) + ) + } catch (err) { + log(err) + return false + } + }, + + deleteProject: async (_: any, { projectId }: { projectId: string }) => { + try { + return await service.deleteProject(projectId) + } catch (err) { + log(err) + return false + } + }, + + deleteDomain: async (_: any, { domainId }: { domainId: string }) => { + try { + return await service.deleteDomain(domainId) + } catch (err) { + log(err) + return false + } + }, + + rollbackDeployment: async ( + _: any, + { projectId, deploymentId }: { deploymentId: string; projectId: string } + ) => { + try { + return await service.rollbackDeployment(projectId, deploymentId) + } catch (err) { + log(err) + return false + } + }, + + deleteDeployment: async ( + _: any, + { deploymentId }: { deploymentId: string } + ) => { + try { + return await service.deleteDeployment(deploymentId) + } catch (err) { + log(err) + return false + } + }, + + addDomain: async ( + _: any, + { projectId, data }: { projectId: string; data: { name: string } } + ) => { + try { + return Boolean(await service.addDomain(projectId, data)) + } catch (err) { + log(err) + return false + } + }, + + updateDomain: async ( + _: any, + { domainId, data }: { domainId: string; data: DeepPartial } + ) => { + try { + return await service.updateDomain(domainId, data) + } catch (err) { + log(err) + return false + } + }, + + authenticateGitHub: async ( + _: any, + { code }: { code: string }, + context: any + ) => { + try { + return await service.authenticateGitHub(code, context.user) + } catch (err) { + log(err) + return false + } + }, + + unauthenticateGitHub: async (_: any, __: object, context: any) => { + try { + return service.unauthenticateGitHub(context.user, { + gitHubToken: null + }) + } catch (err) { + log(err) + return false + } + } + } + } +} diff --git a/apps/backend/src/routes/auth.ts b/apps/backend/src/routes/auth.ts new file mode 100644 index 0000000..3d2ff5d --- /dev/null +++ b/apps/backend/src/routes/auth.ts @@ -0,0 +1,97 @@ +import { Router } from 'express' +import { SiweMessage } from 'siwe' +import type { Service } from '../service' +import { authenticateUser, createUser } from '../turnkey-backend' + +const router: Router = Router() + +// +// Turnkey +// +router.get('/registration/:email', async (req, res) => { + const service: Service = req.app.get('service') + const user = await service.getUserByEmail(req.params.email) + if (user) { + return res.send({ subOrganizationId: user?.subOrgId }) + } + + return res.sendStatus(204) +}) + +router.post('/register', async (req, res) => { + console.log('Register', req.body) + const { email, challenge, attestation } = req.body + const user = await createUser(req.app.get('service'), { + challenge, + attestation, + userEmail: email, + userName: email.split('@')[0] + }) + req.session.address = user.id + res.sendStatus(200) +}) + +router.post('/authenticate', async (req, res) => { + console.log('Authenticate', req.body) + const { signedWhoamiRequest } = req.body + const user = await authenticateUser( + req.app.get('service'), + signedWhoamiRequest + ) + if (user) { + req.session.address = user.id + res.sendStatus(200) + } else { + res.sendStatus(401) + } +}) + +// +// SIWE Auth +// +router.post('/validate', async (req, res) => { + const { message, signature } = req.body + const { success, data } = await new SiweMessage(message).verify({ + signature + }) + + if (!success) { + return res.send({ success }) + } + const service: Service = req.app.get('service') + const user = await service.getUserByEthAddress(data.address) + + if (!user) { + const newUser = await service.createUser({ + ethAddress: data.address, + email: `${data.address}@example.com`, + subOrgId: '', + turnkeyWalletId: '' + }) + + // SIWESession from the web3modal library requires both address and chain ID + req.session.address = newUser.id + req.session.chainId = data.chainId + } else { + req.session.address = user.id + req.session.chainId = data.chainId + } + + res.send({ success }) +}) + +// +// General +// +router.get('/session', (req, res) => { + if (req.session.address && req.session.chainId) { + res.send({ + address: req.session.address, + chainId: req.session.chainId + }) + } else { + res.status(401).send({ error: 'Unauthorized: No active session' }) + } +}) + +export default router diff --git a/apps/backend/src/routes/github.ts b/apps/backend/src/routes/github.ts new file mode 100644 index 0000000..a64f023 --- /dev/null +++ b/apps/backend/src/routes/github.ts @@ -0,0 +1,26 @@ +import debug from 'debug' +import { Router } from 'express' + +import type { Service } from '../service' + +const log = debug('snowball:routes-github') +const router: Router = Router() + +/* POST GitHub webhook handler */ +// https://docs.github.com/en/webhooks/using-webhooks/handling-webhook-deliveries#javascript-example +router.post('/webhook', async (req, res) => { + // Server should respond with a 2XX response within 10 seconds of receiving a webhook delivery + // If server takes longer than that to respond, then GitHub terminates the connection and considers the delivery a failure + res.status(202).send('Accepted') + + const service = req.app.get('service') as Service + const githubEvent = req.headers['x-github-event'] + log(`Received GitHub webhook for event ${githubEvent}`) + + if (githubEvent === 'push') { + // Create deployments using push event data + await service.handleGitHubPush(req.body) + } +}) + +export default router diff --git a/apps/backend/src/routes/staging.ts b/apps/backend/src/routes/staging.ts new file mode 100644 index 0000000..e6cbd0b --- /dev/null +++ b/apps/backend/src/routes/staging.ts @@ -0,0 +1,9 @@ +import { Router } from 'express' + +const router: Router = Router() + +router.get('/version', async (_req, res) => { + return res.send({ version: '0.0.9' }) +}) + +export default router diff --git a/apps/backend/src/schema.gql b/apps/backend/src/schema.gql new file mode 100644 index 0000000..4dbff1d --- /dev/null +++ b/apps/backend/src/schema.gql @@ -0,0 +1,337 @@ +enum Role { + Owner + Maintainer + Reader +} + +enum Permission { + View + Edit +} + +enum Environment { + Production + Preview + Development +} + +enum DeploymentStatus { + Building + Ready + Error + Deleting +} + +enum AuctionStatus { + completed + reveal + commit + expired +} + +enum DomainStatus { + Live + Pending +} + +type User { + id: String! + name: String + email: String! + organizations: [Organization!] + projects: [Project!] + isVerified: Boolean! + createdAt: String! + updatedAt: String! + gitHubToken: String +} + +type Organization { + id: String! + name: String! + slug: String! + projects: [Project!] + createdAt: String! + updatedAt: String! + members: [OrganizationMember!] +} + +type OrganizationMember { + id: String! + member: User! + role: Role! + createdAt: String! + updatedAt: String! +} + +type Project { + id: String! + owner: User! + deployments: [Deployment!] + name: String! + repository: String! + prodBranch: String! + description: String + deployers: [Deployer!] + auctionId: String + fundsReleased: Boolean + template: String + framework: String + paymentAddress: String! + txHash: String! + webhooks: [String!] + members: [ProjectMember!] + environmentVariables: [EnvironmentVariable!] + createdAt: String! + updatedAt: String! + organization: Organization! + icon: String + baseDomains: [String!] +} + +type ProjectMember { + id: String! + member: User! + permissions: [Permission!]! + isPending: Boolean! + createdAt: String! + updatedAt: String! +} + +type Deployment { + id: String! + branch: String! + commitHash: String! + commitMessage: String! + url: String + environment: Environment! + deployer: Deployer + applicationDeploymentRequestId: String + applicationDeploymentRecordData: AppDeploymentRecordAttributes + isCurrent: Boolean! + baseDomain: String + status: DeploymentStatus! + createdAt: String! + updatedAt: String! + createdBy: User! +} + +type Domain { + id: String! + branch: String! + name: String! + redirectTo: Domain + status: DomainStatus! + createdAt: String! + updatedAt: String! +} + +type EnvironmentVariable { + id: String! + environment: Environment! + key: String! + value: String! + createdAt: String! + updatedAt: String! +} + +type Deployer { + deployerLrn: String! + deployerId: String! + deployerApiUrl: String! + minimumPayment: String + paymentAddress: String + createdAt: String! + updatedAt: String! + baseDomain: String +} + +type AuthResult { + token: String! +} + +input AddEnvironmentVariableInput { + environments: [Environment!]! + key: String! + value: String! +} + +input AddProjectFromTemplateInput { + templateOwner: String! + templateRepo: String! + owner: String! + name: String! + isPrivate: Boolean! + paymentAddress: String! + txHash: String! +} + +input AddProjectInput { + name: String! + repository: String! + prodBranch: String! + template: String + paymentAddress: String! + txHash: String! +} + +input UpdateProjectInput { + name: String + description: String + prodBranch: String + organizationId: String + webhooks: [String!] +} + +input AddDomainInput { + name: String! +} + +input UpdateDomainInput { + name: String + branch: String + redirectToId: String +} + +input UpdateEnvironmentVariableInput { + key: String + value: String +} + +input AddProjectMemberInput { + email: String! + permissions: [Permission!] +} + +input UpdateProjectMemberInput { + permissions: [Permission] +} + +input FilterDomainsInput { + branch: String + status: DomainStatus +} + +type Fee { + type: String! + quantity: String! +} + +type Bid { + auctionId: String! + bidderAddress: String! + status: String! + commitHash: String! + commitTime: String + commitFee: Fee + revealTime: String + revealFee: Fee + bidAmount: Fee +} + +type Auction { + id: String! + kind: String! + status: String! + ownerAddress: String! + createTime: String! + commitsEndTime: String! + revealsEndTime: String! + commitFee: Fee! + revealFee: Fee! + minimumBid: Fee + winnerAddresses: [String!]! + winnerBids: [Fee!] + winnerPrice: Fee + maxPrice: Fee + numProviders: Int! + fundsReleased: Boolean! + bids: [Bid!]! +} + +type DNSRecordAttributes { + name: String + value: String + request: String + resourceType: String + version: String +} + +type AppDeploymentRecordAttributes { + application: String + auction: String + deployer: String + dns: String + meta: String + name: String + request: String + type: String + url: String + version: String +} + +input AuctionParams { + maxPrice: String + numProviders: Int +} + +type Query { + user: User! + organizations: [Organization!] + projects: [Project!] + projectsInOrganization(organizationSlug: String!): [Project!] + project(projectId: String!): Project + deployments(projectId: String!): [Deployment!] + environmentVariables(projectId: String!): [EnvironmentVariable!] + projectMembers(projectId: String!): [ProjectMember!] + searchProjects(searchText: String!): [Project!] + getAuctionData(auctionId: String!): Auction! + latestDNSRecord(projectId: String!): DNSRecordAttributes + domains(projectId: String!, filter: FilterDomainsInput): [Domain] + deployers: [Deployer] + address: String! + verifyTx(txHash: String!, amount: String!, senderAddress: String!): Boolean! +} + +type Mutation { + addProjectMember(projectId: String!, data: AddProjectMemberInput): Boolean! + updateProjectMember( + projectMemberId: String! + data: UpdateProjectMemberInput + ): Boolean! + removeProjectMember(projectMemberId: String!): Boolean! + addEnvironmentVariables( + projectId: String! + data: [AddEnvironmentVariableInput!] + ): Boolean! + updateEnvironmentVariable( + environmentVariableId: String! + data: UpdateEnvironmentVariableInput! + ): Boolean! + removeEnvironmentVariable(environmentVariableId: String!): Boolean! + updateDeploymentToProd(deploymentId: String!): Boolean! + addProjectFromTemplate( + organizationSlug: String! + data: AddProjectFromTemplateInput + lrn: String + auctionParams: AuctionParams + environmentVariables: [AddEnvironmentVariableInput!] + ): Project! + addProject( + organizationSlug: String! + data: AddProjectInput! + lrn: String + auctionParams: AuctionParams + environmentVariables: [AddEnvironmentVariableInput!] + ): Project! + updateProject(projectId: String!, data: UpdateProjectInput): Boolean! + redeployToProd(deploymentId: String!): Boolean! + deleteProject(projectId: String!): Boolean! + deleteDomain(domainId: String!): Boolean! + rollbackDeployment(projectId: String!, deploymentId: String!): Boolean! + deleteDeployment(deploymentId: String!): Boolean! + addDomain(projectId: String!, data: AddDomainInput!): Boolean! + updateDomain(domainId: String!, data: UpdateDomainInput!): Boolean! + authenticateGitHub(code: String!): AuthResult! + unauthenticateGitHub: Boolean! +} diff --git a/apps/backend/src/server.ts b/apps/backend/src/server.ts new file mode 100644 index 0000000..af3ff87 --- /dev/null +++ b/apps/backend/src/server.ts @@ -0,0 +1,130 @@ +import { + ApolloServerPluginDrainHttpServer, + ApolloServerPluginLandingPageLocalDefault, + AuthenticationError +} from 'apollo-server-core' +import { ApolloServer } from 'apollo-server-express' +import cors from 'cors' +import debug from 'debug' +import express from 'express' +import session from 'express-session' +import { createServer } from 'node:http' + +import { makeExecutableSchema } from '@graphql-tools/schema' +import type { TypeSource } from '@graphql-tools/utils' + +import type { ServerConfig } from './config' +import authRouter from './routes/auth' +import githubRouter from './routes/github' +import stagingRouter from './routes/staging' +import type { Service } from './service' + +const log = debug('snowball:server') + +// Set cookie expiration to 1 month in milliseconds +const COOKIE_MAX_AGE = 30 * 24 * 60 * 60 * 1000 + +declare module 'express-session' { + interface SessionData { + address: string + chainId: number + } +} + +export const createAndStartServer = async ( + serverConfig: ServerConfig, + typeDefs: TypeSource, + resolvers: any, + service: Service +): Promise => { + const { host, port, gqlPath = '/graphql' } = serverConfig + const { appOriginUrl, secret, domain, trustProxy } = serverConfig.session + + const app = express() + + // Create HTTP server + const httpServer = createServer(app) + + // Create the schema + const schema = makeExecutableSchema({ + typeDefs, + resolvers + }) + + const server = new ApolloServer({ + schema, + csrfPrevention: true, + context: async ({ req }) => { + // https://www.apollographql.com/docs/apollo-server/v3/security/authentication#api-wide-authorization + + const { address } = req.session + + if (!address) { + throw new AuthenticationError('Unauthorized: No active session') + } + + const user = await service.getUser(address) + return { user } + }, + plugins: [ + // Proper shutdown for the HTTP server + ApolloServerPluginDrainHttpServer({ httpServer }), + ApolloServerPluginLandingPageLocalDefault({ embed: true }) + ] + }) + + await server.start() + + app.use( + cors({ + origin: appOriginUrl, + credentials: true + }) + ) + + const sessionOptions: session.SessionOptions = { + secret: secret, + resave: false, + saveUninitialized: true, + cookie: { + secure: new URL(appOriginUrl).protocol === 'https:', + maxAge: COOKIE_MAX_AGE, + domain: domain || undefined, + sameSite: new URL(appOriginUrl).protocol === 'https:' ? 'none' : 'lax' + } + } + + if (trustProxy) { + // trust first proxy + app.set('trust proxy', 1) + } + + app.use(session(sessionOptions)) + + server.applyMiddleware({ + app: app as any, + path: gqlPath, + cors: { + origin: [appOriginUrl], + credentials: true + } + }) + + app.use(express.json()) + + app.set('service', service) + app.use('/auth', authRouter) + app.use('/api/github', githubRouter) + app.use('/staging', stagingRouter) + + app.use((err: any, _req: any, res: any, _next: any) => { + console.error(err) + res.status(500).json({ error: err.message }) + }) + + httpServer.listen(port, host, () => { + log(`Server is listening on ${host}:${port}${server.graphqlPath}`) + }) + + return server +} diff --git a/apps/backend/src/service.ts b/apps/backend/src/service.ts new file mode 100644 index 0000000..d7c1f26 --- /dev/null +++ b/apps/backend/src/service.ts @@ -0,0 +1,1783 @@ +import assert from 'node:assert' +import debug from 'debug' +import { DateTime } from 'luxon' + +import { Octokit, RequestError } from 'octokit' +import type { DeepPartial, FindOptionsWhere } from 'typeorm' + +import type { OAuthApp } from '@octokit/oauth-app' + +import type { GitHubConfig, RegistryConfig } from './config' +import type { Database } from './database' +import { Deployer } from './entity/Deployer' +import { + type ApplicationRecord, + type Deployment, + DeploymentStatus, + Environment +} from './entity/Deployment' +import type { Domain } from './entity/Domain' +import type { EnvironmentVariable } from './entity/EnvironmentVariable' +import type { Organization } from './entity/Organization' +import { Project } from './entity/Project' +import type { Permission, ProjectMember } from './entity/ProjectMember' +import { User } from './entity/User' +import { Role } from './entity/UserOrganization' +import type { Registry } from './registry' +import type { + AddProjectFromTemplateInput, + AppDeploymentRecord, + AppDeploymentRemovalRecord, + AuctionParams, + DNSRecord, + DNSRecordAttributes, + DeployerRecord, + EnvironmentVariables, + GitPushEventPayload +} from './types' +import { getRepoDetails } from './utils' + +const log = debug('snowball:service') + +const GITHUB_UNIQUE_WEBHOOK_ERROR = 'Hook already exists on this repository' + +// Define a constant for an hour in milliseconds +const HOUR = 1000 * 60 * 60 + +interface Config { + gitHubConfig: GitHubConfig + registryConfig: RegistryConfig +} + +export class Service { + private db: Database + private oauthApp: OAuthApp + private laconicRegistry: Registry + private config: Config + + private deployRecordCheckTimeout?: NodeJS.Timeout + private auctionStatusCheckTimeout?: NodeJS.Timeout + + constructor(config: Config, db: Database, app: OAuthApp, registry: Registry) { + this.db = db + this.oauthApp = app + this.laconicRegistry = registry + this.config = config + this.init() + } + + /** + * Initialize services + */ + init(): void { + // Start check for ApplicationDeploymentRecords asynchronously + this.checkDeployRecordsAndUpdate() + // Start check for ApplicationDeploymentRemovalRecords asynchronously + this.checkDeploymentRemovalRecordsAndUpdate() + // Start check for Deployment Auctions asynchronously + this.checkAuctionStatus() + } + + /** + * Destroy services + */ + destroy(): void { + clearTimeout(this.deployRecordCheckTimeout) + clearTimeout(this.auctionStatusCheckTimeout) + } + + /** + * Checks for ApplicationDeploymentRecord and update corresponding deployments + * Continues check in loop after a delay of registryConfig.fetchDeploymentRecordDelay + */ + async checkDeployRecordsAndUpdate(): Promise { + // Fetch deployments in building state + const deployments = await this.db.getDeployments({ + where: { + status: DeploymentStatus.Building + } + }) + + if (deployments.length) { + log( + `Found ${deployments.length} deployments in ${DeploymentStatus.Building} state` + ) + + // Calculate a timestamp for one hour ago + const anHourAgo = Date.now() - HOUR + + // Filter out deployments started more than an hour ago and mark them as Error + const oldDeploymentsToUpdate = deployments + .filter((deployment) => Number(deployment.updatedAt) < anHourAgo) + .map((deployment) => { + return this.db.updateDeploymentById(deployment.id, { + status: DeploymentStatus.Error, + isCurrent: false + }) + }) + + // If there are old deployments to update, log and perform the updates + if (oldDeploymentsToUpdate.length > 0) { + log( + `Cleaning up ${oldDeploymentsToUpdate.length} deployments stuck in ${DeploymentStatus.Building} state for over an hour` + ) + await Promise.all(oldDeploymentsToUpdate) + } + + // Fetch ApplicationDeploymentRecord for deployments + const records = + await this.laconicRegistry.getDeploymentRecords(deployments) + log(`Found ${records.length} ApplicationDeploymentRecords`) + + // Update deployments for which ApplicationDeploymentRecords were returned + if (records.length) { + await this.updateDeploymentsWithRecordData(records) + } + } + + this.deployRecordCheckTimeout = setTimeout(() => { + this.checkDeployRecordsAndUpdate() + }, this.config.registryConfig.fetchDeploymentRecordDelay) + } + + /** + * Checks for ApplicationDeploymentRemovalRecord and remove corresponding deployments + * Continues check in loop after a delay of registryConfig.fetchDeploymentRecordDelay + */ + async checkDeploymentRemovalRecordsAndUpdate(): Promise { + // Fetch deployments in deleting state + const deployments = await this.db.getDeployments({ + where: { + status: DeploymentStatus.Deleting + } + }) + + if (deployments.length) { + log( + `Found ${deployments.length} deployments in ${DeploymentStatus.Deleting} state` + ) + + // Fetch ApplicationDeploymentRemovalRecords for deployments + const records = + await this.laconicRegistry.getDeploymentRemovalRecords(deployments) + log(`Found ${records.length} ApplicationDeploymentRemovalRecords`) + + // Update deployments for which ApplicationDeploymentRemovalRecords were returned + if (records.length) { + await this.deleteDeploymentsWithRecordData(records, deployments) + } + } + + this.deployRecordCheckTimeout = setTimeout(() => { + this.checkDeploymentRemovalRecordsAndUpdate() + }, this.config.registryConfig.fetchDeploymentRecordDelay) + } + + /** + * Update deployments with ApplicationDeploymentRecord data + * Deployments that are completed but not updated in DB + */ + async updateDeploymentsWithRecordData( + records: AppDeploymentRecord[] + ): Promise { + // Fetch the deployments to be updated using deployment requestId + const deployments = await this.db.getDeployments({ + where: records.map((record) => ({ + applicationDeploymentRequestId: record.attributes.request + })), + relations: { + deployer: true, + project: true + }, + order: { + createdAt: 'DESC' + } + }) + + const recordToDeploymentsMap = deployments.reduce( + (acc: { [key: string]: Deployment }, deployment) => { + if (deployment.applicationDeploymentRequestId) { + acc[deployment.applicationDeploymentRequestId] = deployment + } + return acc + }, + {} + ) + + // Update deployment data for ApplicationDeploymentRecords + const deploymentUpdatePromises = records.map(async (record) => { + const deployment = recordToDeploymentsMap[record.attributes.request] + + if (!deployment.project) { + log(`Project ${deployment.projectId} not found`) + return + } + + const registryRecord = await this.laconicRegistry.getRecordById( + record.attributes.dns + ) + + if (!registryRecord) { + log(`DNS record not found for deployment ${deployment.id}`) + return + } + + const dnsRecord = registryRecord as DNSRecord + + const dnsRecordData: DNSRecordAttributes = { + name: dnsRecord.attributes.name, + request: dnsRecord.attributes.request, + resourceType: dnsRecord.attributes.resource_type, + value: dnsRecord.attributes.value, + version: dnsRecord.attributes.version + } + + deployment.applicationDeploymentRecordId = record.id + deployment.applicationDeploymentRecordData = record.attributes + deployment.url = record.attributes.url + deployment.status = DeploymentStatus.Ready + deployment.isCurrent = deployment.environment === Environment.Production + deployment.dnsRecordData = dnsRecordData + + if (deployment.isCanonical) { + const previousCanonicalDeployment = await this.db.getDeployment({ + where: { + projectId: deployment.project.id, + deployer: deployment.deployer, + isCanonical: true, + isCurrent: true + }, + relations: { + project: true, + deployer: true + } + }) + + if (previousCanonicalDeployment) { + // Send removal request for the previous canonical deployment and delete DB entry + if (previousCanonicalDeployment.url !== deployment.url) { + await this.laconicRegistry.createApplicationDeploymentRemovalRequest( + { + deploymentId: + previousCanonicalDeployment.applicationDeploymentRecordId!, + deployerLrn: previousCanonicalDeployment.deployer.deployerLrn, + auctionId: previousCanonicalDeployment.project.auctionId, + payment: previousCanonicalDeployment.project.txHash + } + ) + } + + await this.db.deleteDeploymentById(previousCanonicalDeployment.id) + } + } + + await this.db.updateDeploymentById(deployment.id, deployment) + + // Release deployer funds on successful deployment + if (!deployment.project.fundsReleased) { + const fundsReleased = await this.releaseDeployerFundsByProjectId( + deployment.projectId + ) + + // Return remaining amount to owner + await this.returnUserFundsByProjectId(deployment.projectId, true) + + await this.db.updateProjectById(deployment.projectId, { + fundsReleased + }) + } + + log( + `Updated deployment ${deployment.id} with URL ${record.attributes.url}` + ) + }) + + await Promise.all(deploymentUpdatePromises) + + // Get deployments that are in production environment + const prodDeployments = Object.values(recordToDeploymentsMap).filter( + (deployment) => deployment.isCurrent + ) + // Set the isCurrent state to false for the old deployments + for (const deployment of prodDeployments) { + const projectDeployments = await this.db.getDeploymentsByProjectId( + deployment.projectId + ) + + const oldDeployments = projectDeployments.filter( + (projectDeployment) => + projectDeployment.deployer.deployerLrn === + deployment.deployer.deployerLrn && + projectDeployment.id !== deployment.id && + projectDeployment.isCanonical === deployment.isCanonical + ) + for (const oldDeployment of oldDeployments) { + await this.db.updateDeployment( + { id: oldDeployment.id }, + { isCurrent: false } + ) + } + } + } + + /** + * Delete deployments with ApplicationDeploymentRemovalRecord data + */ + async deleteDeploymentsWithRecordData( + records: AppDeploymentRemovalRecord[], + deployments: Deployment[] + ): Promise { + const removedApplicationDeploymentRecordIds = records.map( + (record) => record.attributes.deployment + ) + + // Get removed deployments for ApplicationDeploymentRecords + const removedDeployments = deployments.filter((deployment) => + removedApplicationDeploymentRecordIds.includes( + deployment.applicationDeploymentRecordId! + ) + ) + + const recordToDeploymentsMap = removedDeployments.reduce( + (acc: { [key: string]: Deployment }, deployment) => { + if (deployment.applicationDeploymentRecordId) { + acc[deployment.applicationDeploymentRecordId] = deployment + } + return acc + }, + {} + ) + + // Update deployment data for ApplicationDeploymentRecords and delete + const deploymentUpdatePromises = records.map(async (record) => { + const deployment = recordToDeploymentsMap[record.attributes.deployment] + + await this.db.updateDeploymentById(deployment.id, { + applicationDeploymentRemovalRecordId: record.id, + applicationDeploymentRemovalRecordData: record.attributes + }) + + log( + `Updated deployment ${deployment.id} with ApplicationDeploymentRemovalRecord ${record.id}` + ) + + await this.db.deleteDeploymentById(deployment.id) + }) + + await Promise.all(deploymentUpdatePromises) + } + + /** + * Checks the status for all ongoing auctions + * Calls the createDeploymentFromAuction method for deployments with completed auctions + */ + async checkAuctionStatus(): Promise { + const projects = await this.db.allProjectsWithoutDeployments() + + const validAuctionIds = projects + .map((project) => project.auctionId) + .filter((id): id is string => Boolean(id)) + const completedAuctionIds = + await this.laconicRegistry.getCompletedAuctionIds(validAuctionIds) + + const projectsToBedeployed = projects.filter( + (project) => + project.auctionId && completedAuctionIds.includes(project.auctionId) + ) + + for (const project of projectsToBedeployed) { + const deployerRecords = + await this.laconicRegistry.getAuctionWinningDeployerRecords( + project!.auctionId! + ) + + if (!deployerRecords) { + log(`No winning deployer for auction ${project!.auctionId}`) + + // Return all funds to the owner + await this.returnUserFundsByProjectId(project.id, false) + } else { + const deployers = + await this.saveDeployersByDeployerRecords(deployerRecords) + for (const deployer of deployers) { + log(`Creating deployment for deployer ${deployer.deployerLrn}`) + await this.createDeploymentFromAuction(project, deployer) + // Update project with deployer + await this.updateProjectWithDeployer(project.id, deployer) + } + } + } + + this.auctionStatusCheckTimeout = setTimeout(() => { + this.checkAuctionStatus() + }, this.config.registryConfig.checkAuctionStatusDelay) + } + + async getUser(userId: string): Promise { + return this.db.getUser({ + where: { + id: userId + } + }) + } + + async getUserByEmail(email: string): Promise { + return await this.db.getUser({ + where: { + email + } + }) + } + + async getUserBySubOrgId(subOrgId: string): Promise { + return await this.db.getUser({ + where: { + subOrgId + } + }) + } + + async getUserByEthAddress(ethAddress: string): Promise { + return await this.db.getUser({ + where: { + ethAddress + } + }) + } + + async createUser(params: { + name?: string + email: string + subOrgId: string + ethAddress: string + turnkeyWalletId: string + }): Promise { + const [org] = await this.db.getOrganizations({}) + assert(org, 'No organizations exists in database') + + // Create user with new address + const user = await this.db.addUser({ + email: params.email, + name: params.name, + subOrgId: params.subOrgId, + ethAddress: params.ethAddress, + isVerified: true, + turnkeyWalletId: params.turnkeyWalletId + }) + + await this.db.addUserOrganization({ + member: user, + organization: org, + role: Role.Owner + }) + + return user + } + + async getOctokit(userId: string): Promise { + const user = await this.db.getUser({ where: { id: userId } }) + assert( + user?.gitHubToken, + 'User needs to be authenticated with GitHub token' + ) + + return new Octokit({ auth: user.gitHubToken }) + } + + async getOrganizationsByUserId(user: User): Promise { + const dbOrganizations = await this.db.getOrganizationsByUserId(user.id) + return dbOrganizations + } + + async getProjectById(user: User, projectId: string): Promise { + const dbProject = await this.db.getProjectById(projectId) + + if (dbProject && dbProject.owner.id !== user.id) { + return null + } + + return dbProject + } + + async getProjectsInOrganization( + user: User, + organizationSlug: string + ): Promise { + const dbProjects = await this.db.getProjectsInOrganization( + user.id, + organizationSlug + ) + return dbProjects + } + + async getNonCanonicalDeploymentsByProjectId( + projectId: string + ): Promise { + const nonCanonicalDeployments = + await this.db.getNonCanonicalDeploymentsByProjectId(projectId) + return nonCanonicalDeployments + } + + async getLatestDNSRecordByProjectId( + projectId: string + ): Promise { + const dnsRecord = await this.db.getLatestDNSRecordByProjectId(projectId) + return dnsRecord + } + + async getEnvironmentVariablesByProjectId( + projectId: string + ): Promise { + const dbEnvironmentVariables = + await this.db.getEnvironmentVariablesByProjectId(projectId) + return dbEnvironmentVariables + } + + async getProjectMembersByProjectId( + projectId: string + ): Promise { + const dbProjectMembers = + await this.db.getProjectMembersByProjectId(projectId) + return dbProjectMembers + } + + async searchProjects(user: User, searchText: string): Promise { + const dbProjects = await this.db.getProjectsBySearchText( + user.id, + searchText + ) + return dbProjects + } + + async getDomainsByProjectId( + projectId: string, + filter?: FindOptionsWhere + ): Promise { + const dbDomains = await this.db.getDomainsByProjectId(projectId, filter) + return dbDomains + } + + async updateProjectMember( + projectMemberId: string, + data: { permissions: Permission[] } + ): Promise { + return this.db.updateProjectMemberById(projectMemberId, data) + } + + async addProjectMember( + projectId: string, + data: { + email: string + permissions: Permission[] + } + ): Promise { + // TODO: Send invitation + let user = await this.db.getUser({ + where: { + email: data.email + } + }) + + if (!user) { + user = await this.db.addUser({ + email: data.email + }) + } + + const newProjectMember = await this.db.addProjectMember({ + project: { + id: projectId + }, + permissions: data.permissions, + isPending: true, + member: { + id: user.id + } + }) + + return newProjectMember + } + + async removeProjectMember( + user: User, + projectMemberId: string + ): Promise { + const member = await this.db.getProjectMemberById(projectMemberId) + + if (String(member.member.id) === user.id) { + throw new Error('Invalid operation: cannot remove self') + } + + const memberProject = member.project + assert(memberProject) + + if (String(user.id) === String(memberProject.owner.id)) { + return this.db.removeProjectMemberById(projectMemberId) + } + + throw new Error('Invalid operation: not authorized') + } + + async addEnvironmentVariables( + projectId: string, + data: { environments: string[]; key: string; value: string }[] + ): Promise { + const formattedEnvironmentVariables = data.flatMap( + (environmentVariable) => { + return environmentVariable.environments.map((environment) => { + return { + key: environmentVariable.key, + value: environmentVariable.value, + environment: environment as Environment, + project: Object.assign(new Project(), { + id: projectId + }) + } + }) + } + ) + + const savedEnvironmentVariables = await this.db.addEnvironmentVariables( + formattedEnvironmentVariables + ) + return savedEnvironmentVariables + } + + async updateEnvironmentVariable( + environmentVariableId: string, + data: DeepPartial + ): Promise { + return this.db.updateEnvironmentVariable(environmentVariableId, data) + } + + async removeEnvironmentVariable( + environmentVariableId: string + ): Promise { + return this.db.deleteEnvironmentVariable(environmentVariableId) + } + + async updateDeploymentToProd( + user: User, + deploymentId: string + ): Promise { + const oldDeployment = await this.db.getDeployment({ + where: { id: deploymentId }, + relations: { + project: true, + deployer: true + } + }) + + if (!oldDeployment) { + throw new Error('Deployment does not exist') + } + + const octokit = await this.getOctokit(user.id) + + const newDeployment = await this.createDeployment(user.id, octokit, { + project: oldDeployment.project, + branch: oldDeployment.branch, + environment: Environment.Production, + commitHash: oldDeployment.commitHash, + commitMessage: oldDeployment.commitMessage, + deployer: oldDeployment.deployer + }) + + return newDeployment + } + + async createDeployment( + userId: string, + octokit: Octokit, + data: DeepPartial, + deployerLrn?: string + ): Promise { + assert(data.project?.repository, 'Project repository not found') + log( + `Creating deployment in project ${data.project.name} from branch ${data.branch}` + ) + + // TODO: Set environment variables for each deployment (environment variables can`t be set in application record) + const { applicationRecordId, applicationRecordData } = + await this.laconicRegistry.createApplicationRecord({ + octokit, + repository: data.project.repository, + appType: data.project!.template!, + commitHash: data.commitHash! + }) + + let deployer: Deployer | undefined + if (deployerLrn) { + const found = await this.db.getDeployerByLRN(deployerLrn) + deployer = found || undefined + } else { + deployer = data.deployer as Deployer + } + + const deployment = await this.createDeploymentFromData( + userId, + data, + deployer!.deployerLrn!, + applicationRecordId, + applicationRecordData, + false + ) + + const address = await this.getAddress() + const { repo, repoUrl } = await getRepoDetails( + octokit, + data.project.repository, + data.commitHash + ) + const environmentVariablesObj = await this.getEnvVariables( + data.project!.id! + ) + + // To set project DNS + if (data.environment === Environment.Production) { + const canonicalDeployment = await this.createDeploymentFromData( + userId, + data, + deployer!.deployerLrn!, + applicationRecordId, + applicationRecordData, + true + ) + // If a custom domain is present then use that as the DNS in the deployment request + const customDomain = await this.db.getOldestDomainByProjectId( + data.project!.id! + ) + + // On deleting deployment later, project canonical deployment is also deleted + // So publish project canonical deployment first so that ApplicationDeploymentRecord for the same is available when deleting deployment later + const { + applicationDeploymentRequestData, + applicationDeploymentRequestId + } = await this.laconicRegistry.createApplicationDeploymentRequest({ + deployment: canonicalDeployment, + appName: repo, + repository: repoUrl, + environmentVariables: environmentVariablesObj, + dns: customDomain?.name ?? `${canonicalDeployment.project.name}`, + lrn: deployer!.deployerLrn!, + apiUrl: deployer!.deployerApiUrl!, + payment: data.project.txHash, + auctionId: data.project.auctionId, + requesterAddress: address, + publicKey: deployer!.publicKey! + }) + + await this.db.updateDeploymentById(canonicalDeployment.id, { + applicationDeploymentRequestId, + applicationDeploymentRequestData + }) + } + + const { applicationDeploymentRequestId, applicationDeploymentRequestData } = + await this.laconicRegistry.createApplicationDeploymentRequest({ + deployment: deployment, + appName: repo, + repository: repoUrl, + lrn: deployer!.deployerLrn!, + apiUrl: deployer!.deployerApiUrl!, + environmentVariables: environmentVariablesObj, + dns: `${deployment.project.name}-${deployment.id}`, + payment: data.project.txHash, + auctionId: data.project.auctionId, + requesterAddress: address, + publicKey: deployer!.publicKey! + }) + + await this.db.updateDeploymentById(deployment.id, { + applicationDeploymentRequestId, + applicationDeploymentRequestData + }) + + return deployment + } + + async createDeploymentFromAuction( + project: DeepPartial, + deployer: Deployer + ): Promise { + const octokit = await this.getOctokit(project.ownerId!) + const [owner, repo] = project.repository!.split('/') + + const repoUrl = ( + await octokit.rest.repos.get({ + owner, + repo + }) + ).data.html_url + + const { + data: [latestCommit] + } = await octokit.rest.repos.listCommits({ + owner, + repo, + sha: project.prodBranch, + per_page: 1 + }) + + const lrn = this.laconicRegistry.getLrn(repo) + const [record] = await this.laconicRegistry.getRecordsByName(lrn) + const applicationRecordId = record.id + const applicationRecordData = record.attributes + + const deployerLrn = deployer!.deployerLrn + + // Create deployment with prod branch and latest commit + const deploymentData = { + project, + branch: project.prodBranch, + environment: Environment.Production, + domain: null, + commitHash: latestCommit.sha, + commitMessage: latestCommit.commit.message + } + + const deployment = await this.createDeploymentFromData( + project.ownerId!, + deploymentData, + deployerLrn, + applicationRecordId, + applicationRecordData, + false + ) + const address = await this.getAddress() + + const environmentVariablesObj = await this.getEnvVariables(project!.id!) + // To set project DNS + if (deploymentData.environment === Environment.Production) { + const canonicalDeployment = await this.createDeploymentFromData( + project.ownerId!, + deploymentData, + deployerLrn, + applicationRecordId, + applicationRecordData, + true + ) + // If a custom domain is present then use that as the DNS in the deployment request + const customDomain = await this.db.getOldestDomainByProjectId( + project!.id! + ) + + // On deleting deployment later, project canonical deployment is also deleted + // So publish project canonical deployment first so that ApplicationDeploymentRecord for the same is available when deleting deployment later + const { + applicationDeploymentRequestId, + applicationDeploymentRequestData + } = await this.laconicRegistry.createApplicationDeploymentRequest({ + deployment: canonicalDeployment, + appName: repo, + repository: repoUrl, + environmentVariables: environmentVariablesObj, + dns: customDomain?.name ?? `${canonicalDeployment.project.name}`, + auctionId: project.auctionId!, + lrn: deployerLrn, + apiUrl: deployer!.deployerApiUrl!, + requesterAddress: address, + publicKey: deployer!.publicKey! + }) + + await this.db.updateDeploymentById(canonicalDeployment.id, { + applicationDeploymentRequestId, + applicationDeploymentRequestData + }) + } + + const { applicationDeploymentRequestId, applicationDeploymentRequestData } = + // Create requests for all the deployers + await this.laconicRegistry.createApplicationDeploymentRequest({ + deployment: deployment, + appName: repo, + repository: repoUrl, + auctionId: project.auctionId!, + lrn: deployerLrn, + apiUrl: deployer!.deployerApiUrl!, + environmentVariables: environmentVariablesObj, + dns: `${deployment.project.name}-${deployment.id}`, + requesterAddress: address, + publicKey: deployer!.publicKey! + }) + + await this.db.updateDeploymentById(deployment.id, { + applicationDeploymentRequestId, + applicationDeploymentRequestData + }) + + return deployment + } + + async createDeploymentFromData( + userId: string, + data: DeepPartial, + deployerLrn: string, + applicationRecordId: string, + applicationRecordData: ApplicationRecord, + isCanonical: boolean + ): Promise { + const newDeployment = await this.db.addDeployment({ + project: data.project, + branch: data.branch, + commitHash: data.commitHash, + commitMessage: data.commitMessage, + environment: data.environment, + status: DeploymentStatus.Building, + applicationRecordId, + applicationRecordData, + createdBy: Object.assign(new User(), { + id: userId + }), + deployer: Object.assign(new Deployer(), { + deployerLrn + }), + isCanonical + }) + + log(`Created deployment ${newDeployment.id}`) + + return newDeployment + } + + async updateProjectWithDeployer( + projectId: string, + deployer: Deployer + ): Promise { + const deploymentProject = await this.db.getProjects({ + where: { id: projectId }, + relations: ['deployers'] + }) + + if (!deploymentProject[0].deployers) { + deploymentProject[0].deployers = [] + } + + deploymentProject[0].deployers.push(deployer) + + await this.db.saveProject(deploymentProject[0]) + + return deployer + } + + async addProjectFromTemplate( + user: User, + organizationSlug: string, + data: AddProjectFromTemplateInput, + lrn?: string, + auctionParams?: AuctionParams, + environmentVariables?: EnvironmentVariables[] + ): Promise { + try { + const octokit = await this.getOctokit(user.id) + + const gitRepo = await octokit?.rest.repos.createUsingTemplate({ + template_owner: data.templateOwner, + template_repo: data.templateRepo, + owner: data.owner, + name: data.name, + include_all_branches: false, + private: data.isPrivate + }) + + if (!gitRepo) { + throw new Error('Failed to create repository from template') + } + + const createdTemplateRepo = await octokit.rest.repos.get({ + owner: data.owner, + repo: data.name + }) + + const prodBranch = createdTemplateRepo.data.default_branch ?? 'main' + + const project = await this.addProject( + user, + organizationSlug, + { + name: `${gitRepo.data.owner!.login}-${gitRepo.data.name}`, + prodBranch, + repository: gitRepo.data.full_name, + // TODO: Set selected template + template: 'webapp', + paymentAddress: data.paymentAddress, + txHash: data.txHash + }, + lrn, + auctionParams, + environmentVariables + ) + + if (!project || !project.id) { + throw new Error('Failed to create project from template') + } + + return project + } catch (error) { + console.error('Error creating project from template:', error) + throw error + } + } + + async addProject( + user: User, + organizationSlug: string, + data: DeepPartial, + lrn?: string, + auctionParams?: AuctionParams, + environmentVariables?: EnvironmentVariables[] + ): Promise { + const organization = await this.db.getOrganization({ + where: { + slug: organizationSlug + } + }) + + if (!organization) { + throw new Error('Organization does not exist') + } + + const project = await this.db.addProject(user, organization.id, data) + + if (environmentVariables) { + await this.addEnvironmentVariables(project.id, environmentVariables) + } + + const octokit = await this.getOctokit(user.id) + const [owner, repo] = project.repository.split('/') + + const { + data: [latestCommit] + } = await octokit.rest.repos.listCommits({ + owner, + repo, + sha: project.prodBranch, + per_page: 1 + }) + + if (auctionParams) { + // Create deployment with prod branch and latest commit + const deploymentData = { + project, + branch: project.prodBranch, + environment: Environment.Production, + domain: null, + commitHash: latestCommit.sha, + commitMessage: latestCommit.commit.message + } + const { applicationDeploymentAuctionId } = + await this.laconicRegistry.createApplicationDeploymentAuction( + repo, + octokit, + auctionParams!, + deploymentData + ) + await this.updateProject(project.id, { + auctionId: applicationDeploymentAuctionId + }) + } else { + const deployer = await this.db.getDeployerByLRN(lrn!) + + if (!deployer) { + log('Invalid deployer LRN') + return + } + + if (deployer.minimumPayment && project.txHash) { + const amountToBePaid = deployer?.minimumPayment + .replace(/\D/g, '') + .toString() + + const txResponse = await this.laconicRegistry.sendTokensToAccount( + deployer?.paymentAddress!, + amountToBePaid + ) + + const txHash = txResponse.transactionHash + if (txHash) { + await this.updateProject(project.id, { txHash }) + project.txHash = txHash + log('Funds transferrend to deployer') + } + } + + const deploymentData = { + project, + branch: project.prodBranch, + environment: Environment.Production, + domain: null, + commitHash: latestCommit.sha, + commitMessage: latestCommit.commit.message, + deployer + } + + const newDeployment = await this.createDeployment( + user.id, + octokit, + deploymentData + ) + // Update project with deployer + await this.updateProjectWithDeployer( + newDeployment.projectId, + newDeployment.deployer + ) + } + + await this.createRepoHook(octokit, project) + + return project + } + + async createRepoHook(octokit: Octokit, project: Project): Promise { + try { + const [owner, repo] = project.repository.split('/') + await octokit.rest.repos.createWebhook({ + owner, + repo, + config: { + url: new URL( + 'api/github/webhook', + this.config.gitHubConfig.webhookUrl + ).href, + content_type: 'json' + }, + events: ['push'] + }) + } catch (err) { + // https://docs.github.com/en/rest/repos/webhooks?apiVersion=2022-11-28#create-a-repository-webhook--status-codes + if ( + !( + err instanceof RequestError && + err.status === 422 && + (err.response?.data as any).errors.some( + (err: any) => err.message === GITHUB_UNIQUE_WEBHOOK_ERROR + ) + ) + ) { + throw err + } + + log(GITHUB_UNIQUE_WEBHOOK_ERROR) + } + } + + async handleGitHubPush(data: GitPushEventPayload): Promise { + const { repository, ref, head_commit: headCommit, deleted } = data + + if (deleted) { + log(`Branch ${ref} deleted for project ${repository.full_name}`) + return + } + + log( + `Handling GitHub push event from repository: ${repository.full_name}, branch: ${ref}` + ) + const projects = await this.db.getProjects({ + where: { repository: repository.full_name }, + relations: { + deployers: true + } + }) + + if (!projects.length) { + log(`No projects found for repository ${repository.full_name}`) + } + + // The `ref` property contains the full reference, including the branch name + // For example, "refs/heads/main" or "refs/heads/feature-branch" + const branch = ref.split('/').pop() + + for await (const project of projects) { + const octokit = await this.getOctokit(project.ownerId) + + const deployers = project.deployers + if (!deployers) { + log(`No deployer present for project ${project.id}`) + return + } + + for (const deployer of deployers) { + // Create deployment with branch and latest commit in GitHub data + await this.createDeployment(project.ownerId, octokit, { + project, + branch, + environment: + project.prodBranch === branch + ? Environment.Production + : Environment.Preview, + commitHash: headCommit.id, + commitMessage: headCommit.message, + deployer: deployer + }) + } + } + } + + async updateProject( + projectId: string, + data: DeepPartial + ): Promise { + return this.db.updateProjectById(projectId, data) + } + + async deleteProject(projectId: string): Promise { + // TODO: Remove GitHub repo hook + return this.db.deleteProjectById(projectId) + } + + async deleteDomain(domainId: string): Promise { + const domainsRedirectedFrom = await this.db.getDomains({ + where: { + redirectToId: domainId + } + }) + + if (domainsRedirectedFrom.length > 0) { + throw new Error( + 'Cannot delete domain since it has redirects from other domains' + ) + } + + return this.db.deleteDomainById(domainId) + } + + async redeployToProd(user: User, deploymentId: string): Promise { + const oldDeployment = await this.db.getDeployment({ + relations: { + project: true, + deployer: true, + createdBy: true + }, + where: { + id: deploymentId + } + }) + + if (oldDeployment === null) { + throw new Error('Deployment not found') + } + + const octokit = await this.getOctokit(user.id) + + let newDeployment: Deployment + + if (oldDeployment.project.auctionId) { + newDeployment = await this.createDeploymentFromAuction( + oldDeployment.project, + oldDeployment.deployer + ) + } else { + newDeployment = await this.createDeployment(user.id, octokit, { + project: oldDeployment.project, + // TODO: Put isCurrent field in project + branch: oldDeployment.branch, + environment: Environment.Production, + commitHash: oldDeployment.commitHash, + commitMessage: oldDeployment.commitMessage, + deployer: oldDeployment.deployer + }) + } + + return newDeployment + } + + async rollbackDeployment( + projectId: string, + deploymentId: string + ): Promise { + // TODO: Implement transactions + const oldCurrentDeployment = await this.db.getDeployment({ + relations: { + project: true, + deployer: true + }, + where: { + project: { + id: projectId + }, + isCurrent: true, + isCanonical: false + } + }) + + if (!oldCurrentDeployment) { + throw new Error('Current deployment does not exist') + } + + const oldCurrentDeploymentUpdate = await this.db.updateDeploymentById( + oldCurrentDeployment.id, + { isCurrent: false } + ) + + const newCurrentDeploymentUpdate = await this.db.updateDeploymentById( + deploymentId, + { isCurrent: true } + ) + + if (!newCurrentDeploymentUpdate || !oldCurrentDeploymentUpdate) { + return false + } + + const newCurrentDeployment = await this.db.getDeployment({ + where: { id: deploymentId }, + relations: { project: true, deployer: true } + }) + + if (!newCurrentDeployment) { + throw new Error(`Deployment with Id ${deploymentId} not found`) + } + + const applicationDeploymentRequestData = + newCurrentDeployment.applicationDeploymentRequestData + + const customDomain = await this.db.getOldestDomainByProjectId(projectId) + + if (customDomain && applicationDeploymentRequestData) { + applicationDeploymentRequestData.dns = customDomain.name + } + + // Create a canonical deployment for the new current deployment + const canonicalDeployment = await this.createDeploymentFromData( + newCurrentDeployment.project.ownerId, + newCurrentDeployment, + newCurrentDeployment.deployer!.deployerLrn!, + newCurrentDeployment.applicationRecordId, + newCurrentDeployment.applicationRecordData, + true + ) + + applicationDeploymentRequestData!.meta = JSON.stringify({ + ...JSON.parse(applicationDeploymentRequestData!.meta), + note: `Updated by Snowball @ ${DateTime.utc().toFormat( + "EEE LLL dd HH:mm:ss 'UTC' yyyy" + )}` + }) + + const result = await this.laconicRegistry.publishRecord( + applicationDeploymentRequestData + ) + + log(`Application deployment request record published: ${result.id}`) + + const updateResult = await this.db.updateDeploymentById( + canonicalDeployment.id, + { + applicationDeploymentRequestId: result.id, + applicationDeploymentRequestData + } + ) + + return updateResult + } + + async deleteDeployment(deploymentId: string): Promise { + const deployment = await this.db.getDeployment({ + where: { + id: deploymentId + }, + relations: { + project: true, + deployer: true + } + }) + + if (deployment?.applicationDeploymentRecordId) { + // If deployment is current, remove deployment for project subdomain as well + if (deployment.isCurrent) { + const canonicalDeployment = await this.db.getDeployment({ + where: { + projectId: deployment.project.id, + deployer: deployment.deployer, + isCanonical: true + }, + relations: { + project: true, + deployer: true + } + }) + + // If the canonical deployment is not present then query the chain for the deployment record for backward compatibility + if (!canonicalDeployment) { + log( + `Canonical deployment for deployment with id ${deployment.id} not found, querying the chain..` + ) + const currentDeploymentURL = `https://${(deployment.project.name).toLowerCase()}.${deployment.deployer.baseDomain}` + + const deploymentRecords = + await this.laconicRegistry.getDeploymentRecordsByFilter({ + application: deployment.applicationRecordId, + url: currentDeploymentURL + }) + + if (!deploymentRecords.length) { + log( + `No ApplicationDeploymentRecord found for URL ${currentDeploymentURL} and ApplicationDeploymentRecord id ${deployment.applicationDeploymentRecordId}` + ) + + return false + } + + // Multiple records are fetched, take the latest record + const latestRecord = deploymentRecords.sort( + (a, b) => + new Date(b.createTime).getTime() - + new Date(a.createTime).getTime() + )[0] + + await this.laconicRegistry.createApplicationDeploymentRemovalRequest({ + deploymentId: latestRecord.id, + deployerLrn: deployment.deployer.deployerLrn, + auctionId: deployment.project.auctionId, + payment: deployment.project.txHash + }) + } else { + // If canonical deployment is found in the DB, then send the removal request with that deployment record Id + const result = + await this.laconicRegistry.createApplicationDeploymentRemovalRequest( + { + deploymentId: + canonicalDeployment.applicationDeploymentRecordId!, + deployerLrn: canonicalDeployment.deployer.deployerLrn, + auctionId: canonicalDeployment.project.auctionId, + payment: canonicalDeployment.project.txHash + } + ) + + await this.db.updateDeploymentById(canonicalDeployment.id, { + status: DeploymentStatus.Deleting, + applicationDeploymentRemovalRequestId: + result.applicationDeploymentRemovalRequestId, + applicationDeploymentRemovalRequestData: + result.applicationDeploymentRemovalRequestData + }) + } + } + + const result = + await this.laconicRegistry.createApplicationDeploymentRemovalRequest({ + deploymentId: deployment.applicationDeploymentRecordId, + deployerLrn: deployment.deployer.deployerLrn, + auctionId: deployment.project.auctionId, + payment: deployment.project.txHash + }) + + await this.db.updateDeploymentById(deployment.id, { + status: DeploymentStatus.Deleting, + applicationDeploymentRemovalRequestId: + result.applicationDeploymentRemovalRequestId, + applicationDeploymentRemovalRequestData: + result.applicationDeploymentRemovalRequestData + }) + + return result !== undefined || result !== null + } + + return false + } + + async addDomain( + projectId: string, + data: { name: string } + ): Promise<{ + primaryDomain: Domain + // redirectedDomain: Domain; + }> { + const currentProject = await this.db.getProjectById(projectId) + + if (currentProject === null) { + throw new Error(`Project with ${projectId} not found`) + } + + const primaryDomainDetails = { + ...data, + branch: currentProject.prodBranch, + project: currentProject + } + + const savedPrimaryDomain = await this.db.addDomain(primaryDomainDetails) + + // const domainArr = data.name.split('www.'); + + // const redirectedDomainDetails = { + // name: domainArr.length > 1 ? domainArr[1] : `www.${domainArr[0]}`, + // branch: currentProject.prodBranch, + // project: currentProject, + // redirectTo: savedPrimaryDomain, + // }; + + // const savedRedirectedDomain = await this.db.addDomain( + // redirectedDomainDetails, + // ); + + return { + primaryDomain: savedPrimaryDomain + // redirectedDomain: savedRedirectedDomain, + } + } + + async updateDomain( + domainId: string, + data: DeepPartial + ): Promise { + const domain = await this.db.getDomain({ + where: { + id: domainId + } + }) + + if (domain === null) { + throw new Error(`Error finding domain with id ${domainId}`) + } + + const newDomain = { + ...data + } + + const domainsRedirectedFrom = await this.db.getDomains({ + where: { + project: { + id: domain.projectId + }, + redirectToId: domain.id + } + }) + + // If there are domains redirecting to current domain, only branch of current domain can be updated + if (domainsRedirectedFrom.length > 0 && data.branch === domain.branch) { + throw new Error('Remove all redirects to this domain before updating') + } + + if (data.redirectToId) { + const redirectedDomain = await this.db.getDomain({ + where: { + id: data.redirectToId + } + }) + + if (redirectedDomain === null) { + throw new Error('Could not find Domain to redirect to') + } + + if (redirectedDomain.redirectToId) { + throw new Error( + 'Unable to redirect to the domain because it is already redirecting elsewhere. Redirects cannot be chained.' + ) + } + + newDomain.redirectTo = redirectedDomain + } + + const updateResult = await this.db.updateDomainById(domainId, newDomain) + + return updateResult + } + + async authenticateGitHub( + code: string, + user: User + ): Promise<{ token: string }> { + const { + authentication: { token } + } = await this.oauthApp.createToken({ + code + }) + + await this.db.updateUser(user, { gitHubToken: token }) + + return { token } + } + + async unauthenticateGitHub( + user: User, + data: DeepPartial + ): Promise { + return this.db.updateUser(user, data) + } + + async getEnvVariables(projectId: string): Promise<{ [key: string]: string }> { + const environmentVariables = + await this.db.getEnvironmentVariablesByProjectId(projectId, { + environment: Environment.Production + }) + + const environmentVariablesObj = environmentVariables.reduce( + (acc, env) => { + acc[env.key] = env.value + return acc + }, + {} as { [key: string]: string } + ) + + return environmentVariablesObj + } + + async getAuctionData(auctionId: string): Promise { + const auctions = await this.laconicRegistry.getAuctionData(auctionId) + return auctions[0] + } + + async releaseDeployerFundsByProjectId(projectId: string): Promise { + const project = await this.db.getProjectById(projectId) + + if (!project || !project.auctionId) { + log( + `Project ${projectId} ${!project ? 'not found' : 'does not have an auction'}` + ) + + return false + } + + const auction = await this.laconicRegistry.releaseDeployerFunds( + project.auctionId + ) + + if (auction.auction.fundsReleased) { + log(`Funds released for auction ${project.auctionId}`) + await this.db.updateProjectById(projectId, { fundsReleased: true }) + + return true + } + + log(`Error releasing funds for auction ${project.auctionId}`) + + return false + } + + async returnUserFundsByProjectId( + projectId: string, + winningDeployersPresent: boolean + ) { + const project = await this.db.getProjectById(projectId) + + if (!project || !project.auctionId) { + log( + `Project ${projectId} ${!project ? 'not found' : 'does not have an auction'}` + ) + + return false + } + + const auction = await this.getAuctionData(project.auctionId) + const totalAuctionPrice = + Number(auction.maxPrice.quantity) * auction.numProviders + + let amountToBeReturned: number + if (winningDeployersPresent) { + amountToBeReturned = + totalAuctionPrice - + auction.winnerAddresses.length * Number(auction.winnerPrice.quantity) + } else { + amountToBeReturned = totalAuctionPrice + } + + if (amountToBeReturned !== 0) { + await this.laconicRegistry.sendTokensToAccount( + project.paymentAddress, + amountToBeReturned.toString() + ) + } + } + + async getDeployers(): Promise { + const dbDeployers = await this.db.getDeployers() + + if (dbDeployers.length > 0) { + // Call asynchronously to fetch the records from the registry and update the DB + this.updateDeployersFromRegistry() + return dbDeployers + } + + // Fetch from the registry and populate empty DB + return await this.updateDeployersFromRegistry() + } + + async updateDeployersFromRegistry(): Promise { + const deployerRecords = + await this.laconicRegistry.getDeployerRecordsByFilter({}) + await this.saveDeployersByDeployerRecords(deployerRecords) + + return await this.db.getDeployers() + } + + async saveDeployersByDeployerRecords( + deployerRecords: DeployerRecord[] + ): Promise { + const deployers: Deployer[] = [] + + for (const record of deployerRecords) { + if (record.names && record.names.length > 0) { + const deployerId = record.id + const deployerLrn = record.names[0] + const deployerApiUrl = record.attributes.apiUrl + const minimumPayment = record.attributes.minimumPayment + const paymentAddress = record.attributes.paymentAddress + const publicKey = record.attributes.publicKey + const baseDomain = deployerApiUrl.substring( + deployerApiUrl.indexOf('.') + 1 + ) + + const deployerData = { + deployerLrn, + deployerId, + deployerApiUrl, + baseDomain, + minimumPayment, + paymentAddress, + publicKey + } + + // TODO: Update deployers table in a separate job + const deployer = await this.db.addDeployer(deployerData) + deployers.push(deployer) + } + } + + return deployers + } + + async getAddress(): Promise { + const account = await this.laconicRegistry.getAccount() + + return account.address + } + + async verifyTx( + txHash: string, + amountSent: string, + senderAddress: string + ): Promise { + const txResponse = await this.laconicRegistry.getTxResponse(txHash) + if (!txResponse) { + log('Transaction response not found') + return false + } + + const transfer = txResponse.events.find( + (e: any) => + e.type === 'transfer' && + e.attributes.some((a: any) => a.key === 'msg_index') + ) + if (!transfer) { + log('No transfer event found') + return false + } + + const sender = transfer.attributes.find( + (a: any) => a.key === 'sender' + )?.value + const recipient = transfer.attributes.find( + (a: any) => a.key === 'recipient' + )?.value + const amount = transfer.attributes.find( + (a: any) => a.key === 'amount' + )?.value + + const recipientAddress = await this.getAddress() + + return ( + amount === amountSent && + sender === senderAddress && + recipient === recipientAddress + ) + } +} diff --git a/apps/backend/src/turnkey-backend.ts b/apps/backend/src/turnkey-backend.ts new file mode 100644 index 0000000..2336bdd --- /dev/null +++ b/apps/backend/src/turnkey-backend.ts @@ -0,0 +1,130 @@ +import { Turnkey, type TurnkeyApiTypes } from '@turnkey/sdk-server' + +// Default path for the first Ethereum address in a new HD wallet. +// See https://github.com/bitcoin/bips/blob/master/bip-0044.mediawiki, paths are in the form: +// m / purpose' / coin_type' / account' / change / address_index +// - Purpose is a constant set to 44' following the BIP43 recommendation. +// - Coin type is set to 60 (ETH) -- see https://github.com/satoshilabs/slips/blob/master/slip-0044.md +// - Account, Change, and Address Index are set to 0 +import { DEFAULT_ETHEREUM_ACCOUNTS } from '@turnkey/sdk-server' +import type { Service } from './service' +import { getConfig } from './utils' + +type TAttestation = TurnkeyApiTypes['v1Attestation'] + +type CreateUserParams = { + userName: string + userEmail: string + challenge: string + attestation: TAttestation +} + +export async function createUser( + service: Service, + { userName, userEmail, challenge, attestation }: CreateUserParams +) { + try { + if (await service.getUserByEmail(userEmail)) { + throw new Error(`User already exists: ${userEmail}`) + } + + const config = await getConfig() + const turnkey = new Turnkey(config.turnkey) + + const apiClient = turnkey.api() + + const walletName = 'Default ETH Wallet' + + const createSubOrgResponse = await apiClient.createSubOrganization({ + subOrganizationName: `Default SubOrg for ${userEmail}`, + rootQuorumThreshold: 1, + rootUsers: [ + { + userName, + userEmail, + apiKeys: [], + authenticators: [ + { + authenticatorName: 'Passkey', + challenge, + attestation + } + ] + } + ], + wallet: { + walletName: walletName, + accounts: DEFAULT_ETHEREUM_ACCOUNTS + } + }) + + const subOrgId = refineNonNull(createSubOrgResponse.subOrganizationId) + const wallet = refineNonNull(createSubOrgResponse.wallet) + + const result = { + id: wallet.walletId, + address: wallet.addresses[0], + subOrgId: subOrgId + } + console.log('Turnkey success', result) + + const user = await service.createUser({ + name: userName, + email: userEmail, + subOrgId, + ethAddress: wallet.addresses[0], + turnkeyWalletId: wallet.walletId + }) + console.log('New user', user) + + return user + } catch (e) { + console.error('Failed to create user:', e) + throw e + } +} + +export async function authenticateUser( + service: Service, + signedWhoamiRequest: { + url: string + body: any + stamp: { + stampHeaderName: string + stampHeaderValue: string + } + } +) { + try { + const tkRes = await fetch(signedWhoamiRequest.url, { + method: 'POST', + body: signedWhoamiRequest.body, + headers: { + [signedWhoamiRequest.stamp.stampHeaderName]: + signedWhoamiRequest.stamp.stampHeaderValue + } + }) + console.log('AUTH RESULT', tkRes.status) + if (tkRes.status !== 200) { + console.log(await tkRes.text()) + return null + } + const orgId = (await tkRes.json()).organizationId + const user = await service.getUserBySubOrgId(orgId) + return user + } catch (e) { + console.error('Failed to authenticate:', e) + throw e + } +} + +function refineNonNull( + input: T | null | undefined, + errorMessage?: string +): T { + if (input == null) { + throw new Error(errorMessage ?? `Unexpected ${JSON.stringify(input)}`) + } + + return input +} diff --git a/apps/backend/src/types.ts b/apps/backend/src/types.ts new file mode 100644 index 0000000..82f6111 --- /dev/null +++ b/apps/backend/src/types.ts @@ -0,0 +1,124 @@ +export interface PackageJSON { + name: string + version: string + author?: string + description?: string + homepage?: string + license?: string + repository?: string +} + +export interface GitRepositoryDetails { + id: number + name: string + full_name: string + visibility?: string + updated_at?: string | null + default_branch?: string +} + +export interface GitPushEventPayload { + repository: GitRepositoryDetails + ref: string + head_commit: { + id: string + message: string + } + deleted: boolean +} + +export interface AppDeploymentRecordAttributes { + application: string + auction: string + deployer: string + dns: string + meta: string + name: string + request: string + type: string + url: string + version: string +} + +export interface DNSRecordAttributes { + name: string + value: string + request: string + resourceType: string + version: string +} + +export interface RegistryDNSRecordAttributes { + name: string + value: string + request: string + resource_type: string + version: string +} + +export interface AppDeploymentRemovalRecordAttributes { + deployment: string + request: string + type: 'ApplicationDeploymentRemovalRecord' + version: string +} + +export interface RegistryRecord { + id: string + names: string[] | null + owners: string[] + bondId: string + createTime: string + expiryTime: string +} + +export interface AppDeploymentRecord extends RegistryRecord { + attributes: AppDeploymentRecordAttributes +} + +export interface AppDeploymentRemovalRecord extends RegistryRecord { + attributes: AppDeploymentRemovalRecordAttributes +} + +export interface DNSRecord extends RegistryRecord { + attributes: RegistryDNSRecordAttributes +} + +export interface AddProjectFromTemplateInput { + templateOwner: string + templateRepo: string + owner: string + name: string + isPrivate: boolean + paymentAddress: string + txHash: string +} + +export interface AuctionParams { + maxPrice: string + numProviders: number +} + +export interface EnvironmentVariables { + environments: string[] + key: string + value: string +} + +export interface DeployerRecord { + id: string + names: string[] + owners: string[] + bondId: string + createTime: string + expiryTime: string + attributes: { + apiUrl: string + minimumPayment: string | null + name: string + paymentAddress: string + publicKey: string + type: string + version: string + } +} diff --git a/apps/backend/src/utils.ts b/apps/backend/src/utils.ts new file mode 100644 index 0000000..072ba44 --- /dev/null +++ b/apps/backend/src/utils.ts @@ -0,0 +1,160 @@ +import debug from 'debug' +import fs from 'fs-extra' +import assert from 'node:assert' +import path from 'node:path' +import type { Octokit } from 'octokit' +import toml from 'toml' +import type { + DataSource, + DeepPartial, + EntityTarget, + ObjectLiteral +} from 'typeorm' + +import type { Config } from './config' + +interface PackageJSON { + name: string + description?: string + homepage?: string + license?: string + author?: string | { [key: string]: unknown } + version?: string + [key: string]: unknown +} + +const log = debug('snowball:utils') + +export async function getConfig() { + return await _getConfig( + path.join(__dirname, '../environments/local.toml') + ) +} + +const _getConfig = async ( + configFile: string +): Promise => { + const fileExists = await fs.pathExists(configFile) + if (!fileExists) { + throw new Error(`Config file not found: ${configFile}`) + } + + const config = toml.parse(await fs.readFile(configFile, 'utf8')) + log('config', JSON.stringify(config, null, 2)) + + return config +} + +export const checkFileExists = async (filePath: string): Promise => { + try { + await fs.access(filePath, fs.constants.F_OK) + return true + } catch (err) { + log(err) + return false + } +} + +export const getEntities = async (filePath: string): Promise => { + const entitiesData = await fs.readFile(filePath, 'utf-8') + const entities = JSON.parse(entitiesData) + return entities +} + +export const loadAndSaveData = async ( + entityType: EntityTarget, + dataSource: DataSource, + entities: any, + relations?: any | undefined +): Promise => { + const entityRepository = dataSource.getRepository(entityType) + + const savedEntity: Entity[] = [] + + for (const entityData of entities) { + let entity = entityRepository.create(entityData as DeepPartial) + + if (relations) { + for (const field in relations) { + const valueIndex = `${field}Index` + + entity = { + ...entity, + [field]: relations[field][entityData[valueIndex]] + } + } + } + const dbEntity = await entityRepository.save(entity) + savedEntity.push(dbEntity) + } + + return savedEntity +} + +export const sleep = async (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)) + +export const getRepoDetails = async ( + octokit: Octokit, + repository: string, + commitHash: string | undefined +): Promise<{ + repo: string + packageJSON: PackageJSON + repoUrl: string +}> => { + const [owner, repo] = repository.split('/') + const { data: packageJSONData } = await octokit.rest.repos.getContent({ + owner, + repo, + path: 'package.json', + ref: commitHash + }) + + if (!packageJSONData) { + throw new Error('Package.json file not found') + } + + assert(!Array.isArray(packageJSONData) && packageJSONData.type === 'file') + const packageJSON: PackageJSON = JSON.parse(atob(packageJSONData.content)) + + assert(packageJSON.name, "name field doesn't exist in package.json") + + const repoUrl = ( + await octokit.rest.repos.get({ + owner, + repo + }) + ).data.html_url + + return { + repo, + packageJSON, + repoUrl + } +} + +// Wrapper method for registry txs to retry once if 'account sequence mismatch' occurs +export const registryTransactionWithRetry = async ( + txMethod: () => Promise +): Promise => { + try { + return await txMethod() + } catch (error: any) { + if (!error.message.includes('account sequence mismatch')) { + throw error + } + + console.error( + 'Transaction failed due to account sequence mismatch. Retrying...' + ) + + try { + return await txMethod() + } catch (retryError: any) { + throw new Error( + `Transaction failed again after retry: ${retryError.message}` + ) + } + } +} diff --git a/apps/backend/test/delete-db.ts b/apps/backend/test/delete-db.ts new file mode 100644 index 0000000..1872b30 --- /dev/null +++ b/apps/backend/test/delete-db.ts @@ -0,0 +1,19 @@ +import * as fs from 'node:fs/promises' +import debug from 'debug' + +import { getConfig } from '../src/utils' + +const log = debug('snowball:delete-database') + +const deleteFile = async (filePath: string) => { + await fs.unlink(filePath) + log(`File ${filePath} has been deleted.`) +} + +const main = async () => { + const config = await getConfig() + + deleteFile(config.database.dbPath) +} + +main().catch((err) => log(err)) diff --git a/apps/backend/test/fixtures/deployments.json b/apps/backend/test/fixtures/deployments.json new file mode 100644 index 0000000..b73956f --- /dev/null +++ b/apps/backend/test/fixtures/deployments.json @@ -0,0 +1,189 @@ +[ + { + "projectIndex": 0, + "domainIndex": 0, + "createdByIndex": 0, + "id": "ffhae3zq", + "status": "Ready", + "environment": "Production", + "isCurrent": true, + "applicationRecordId": "qbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi", + "applicationRecordData": {}, + "applicationDeploymentRequestId": "xqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi", + "applicationDeploymentRequestData": {}, + "branch": "main", + "commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00", + "commitMessage": "subscription added", + "url": "testProject-ffhae3zq.snowball.xyz" + }, + { + "projectIndex": 0, + "domainIndex": 1, + "createdByIndex": 0, + "id": "vehagei8", + "status": "Ready", + "environment": "Preview", + "isCurrent": false, + "applicationRecordId": "wbafyreihvzya6ovp4yfpkqnddkui2iw7thbhwq74lbqs7bhobvmfhrowoi", + "applicationRecordData": {}, + "applicationDeploymentRequestId": "wqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi", + "applicationDeploymentRequestData": {}, + "branch": "test", + "commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00", + "commitMessage": "subscription added", + "url": "testProject-vehagei8.snowball.xyz" + }, + { + "projectIndex": 0, + "domainIndex": 2, + "createdByIndex": 0, + "id": "qmgekyte", + "status": "Ready", + "environment": "Development", + "isCurrent": false, + "applicationRecordId": "ebafyreihvzya6ovp4yfpkqnddkui2iw7t6bhwq74lbqs7bhobvmfhrowoi", + "applicationRecordData": {}, + "applicationDeploymentRequestId": "kqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi", + "applicationDeploymentRequestData": {}, + "branch": "test", + "commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00", + "commitMessage": "subscription added", + "url": "testProject-qmgekyte.snowball.xyz" + }, + { + "projectIndex": 0, + "domainIndex": null, + "createdByIndex": 0, + "id": "f8wsyim6", + "status": "Ready", + "environment": "Production", + "isCurrent": false, + "applicationRecordId": "rbafyreihvzya6ovp4yfpkqnddkui2iw7t6hbhw74lbqs7bhobvmfhrowoi", + "applicationRecordData": {}, + "applicationDeploymentRequestId": "yqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi", + "applicationDeploymentRequestData": {}, + "branch": "prod", + "commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00", + "commitMessage": "subscription added", + "url": "testProject-f8wsyim6.snowball.xyz" + }, + { + "projectIndex": 1, + "domainIndex": 3, + "createdByIndex": 1, + "id": "eO8cckxk", + "status": "Ready", + "environment": "Production", + "isCurrent": true, + "applicationRecordId": "tbafyreihvzya6ovp4yfpqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi", + "applicationRecordData": {}, + "applicationDeploymentRequestId": "pqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi", + "applicationDeploymentRequestData": {}, + "branch": "main", + "commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00", + "commitMessage": "subscription added", + "url": "testProject-2-eO8cckxk.snowball.xyz" + }, + { + "projectIndex": 1, + "domainIndex": 4, + "createdByIndex": 1, + "id": "yaq0t5yw", + "status": "Ready", + "environment": "Preview", + "isCurrent": false, + "applicationRecordId": "ybafyreihvzya6ovp4yfpkqnddkui2iw7t6bhwq74lbqs7bhobvmfhrowoi", + "applicationRecordData": {}, + "applicationDeploymentRequestId": "tqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi", + "applicationDeploymentRequestData": {}, + "branch": "test", + "commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00", + "commitMessage": "subscription added", + "url": "testProject-2-yaq0t5yw.snowball.xyz" + }, + { + "projectIndex": 1, + "domainIndex": 5, + "createdByIndex": 1, + "id": "hwwr6sbx", + "status": "Ready", + "environment": "Development", + "isCurrent": false, + "applicationRecordId": "ubafyreihvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvfhrowoi", + "applicationRecordData": {}, + "applicationDeploymentRequestId": "eqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi", + "applicationDeploymentRequestData": {}, + "branch": "test", + "commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00", + "commitMessage": "subscription added", + "url": "testProject-2-hwwr6sbx.snowball.xyz" + }, + { + "projectIndex": 2, + "domainIndex": 9, + "createdByIndex": 2, + "id": "ndxje48a", + "status": "Ready", + "environment": "Production", + "isCurrent": true, + "applicationRecordId": "ibayreihvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi", + "applicationRecordData": {}, + "applicationDeploymentRequestId": "dqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi", + "applicationDeploymentRequestData": {}, + "branch": "main", + "commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00", + "commitMessage": "subscription added", + "url": "iglootools-ndxje48a.snowball.xyz" + }, + { + "projectIndex": 2, + "domainIndex": 7, + "createdByIndex": 2, + "id": "gtgpgvei", + "status": "Ready", + "environment": "Preview", + "isCurrent": false, + "applicationRecordId": "obafyreihvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi", + "applicationRecordData": {}, + "applicationDeploymentRequestId": "aqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi", + "applicationDeploymentRequestData": {}, + "branch": "test", + "commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00", + "commitMessage": "subscription added", + "url": "iglootools-gtgpgvei.snowball.xyz" + }, + { + "projectIndex": 2, + "domainIndex": 8, + "createdByIndex": 2, + "id": "b4bpthjr", + "status": "Ready", + "environment": "Development", + "isCurrent": false, + "applicationRecordId": "pbafyreihvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowo", + "applicationRecordData": {}, + "applicationDeploymentRequestId": "uqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi", + "applicationDeploymentRequestData": {}, + "branch": "test", + "commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00", + "commitMessage": "subscription added", + "url": "iglootools-b4bpthjr.snowball.xyz" + }, + { + "projectIndex": 3, + "domainIndex": 6, + "createdByIndex": 2, + "id": "b4bpthjr", + "status": "Ready", + "environment": "Production", + "isCurrent": true, + "applicationRecordId": "pbafyreihvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowo", + "applicationRecordData": {}, + "applicationDeploymentRequestId": "pqbafyrehvzya6ovp4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi", + "applicationDeploymentRequestData": {}, + "branch": "test", + "commitHash": "d5dfd7b827226b0d09d897346d291c256e113e00", + "commitMessage": "subscription added", + "url": "iglootools-b4bpthjr.snowball.xyz" + } +] diff --git a/apps/backend/test/fixtures/environment-variables.json b/apps/backend/test/fixtures/environment-variables.json new file mode 100644 index 0000000..2af52b3 --- /dev/null +++ b/apps/backend/test/fixtures/environment-variables.json @@ -0,0 +1,92 @@ +[ + { + "projectIndex": 0, + "key": "ABC", + "value": "ABC", + "environment": "Production" + }, + { + "projectIndex": 0, + "key": "ABC", + "value": "ABC", + "environment": "Preview" + }, + { + "projectIndex": 0, + "key": "XYZ", + "value": "abc3", + "environment": "Preview" + }, + { + "projectIndex": 1, + "key": "ABC", + "value": "ABC", + "environment": "Production" + }, + { + "projectIndex": 1, + "key": "ABC", + "value": "ABC", + "environment": "Preview" + }, + { + "projectIndex": 1, + "key": "XYZ", + "value": "abc3", + "environment": "Preview" + }, + { + "projectIndex": 2, + "key": "ABC", + "value": "ABC", + "environment": "Production" + }, + { + "projectIndex": 2, + "key": "ABC", + "value": "ABC", + "environment": "Preview" + }, + { + "projectIndex": 2, + "key": "XYZ", + "value": "abc3", + "environment": "Preview" + }, + { + "projectIndex": 3, + "key": "ABC", + "value": "ABC", + "environment": "Production" + }, + { + "projectIndex": 3, + "key": "ABC", + "value": "ABC", + "environment": "Preview" + }, + { + "projectIndex": 3, + "key": "XYZ", + "value": "abc3", + "environment": "Preview" + }, + { + "projectIndex": 4, + "key": "ABC", + "value": "ABC", + "environment": "Production" + }, + { + "projectIndex": 4, + "key": "ABC", + "value": "ABC", + "environment": "Preview" + }, + { + "projectIndex": 4, + "key": "XYZ", + "value": "abc3", + "environment": "Preview" + } +] diff --git a/apps/backend/test/fixtures/organizations.json b/apps/backend/test/fixtures/organizations.json new file mode 100644 index 0000000..0d688fb --- /dev/null +++ b/apps/backend/test/fixtures/organizations.json @@ -0,0 +1,7 @@ +[ + { + "id": "2379cf1f-a232-4ad2-ae14-4d881131cc26", + "name": "Deploy Tools", + "slug": "deploy-tools" + } +] diff --git a/apps/backend/test/fixtures/primary-domains.json b/apps/backend/test/fixtures/primary-domains.json new file mode 100644 index 0000000..23e2666 --- /dev/null +++ b/apps/backend/test/fixtures/primary-domains.json @@ -0,0 +1,44 @@ +[ + { + "projectIndex": 0, + "name": "example.snowballtools.xyz", + "status": "Live", + "branch": "main" + }, + { + "projectIndex": 0, + "name": "example.org", + "status": "Pending", + "branch": "test" + }, + { + "projectIndex": 1, + "name": "example.snowballtools.xyz", + "status": "Live", + "branch": "main" + }, + { + "projectIndex": 1, + "name": "example.org", + "status": "Pending", + "branch": "test" + }, + { + "projectIndex": 2, + "name": "example.snowballtools.xyz", + "status": "Live", + "branch": "main" + }, + { + "projectIndex": 2, + "name": "example.org", + "status": "Pending", + "branch": "test" + }, + { + "projectIndex": 3, + "name": "iglootools-2.com", + "status": "Pending", + "branch": "test" + } +] diff --git a/apps/backend/test/fixtures/project-members.json b/apps/backend/test/fixtures/project-members.json new file mode 100644 index 0000000..84029c2 --- /dev/null +++ b/apps/backend/test/fixtures/project-members.json @@ -0,0 +1,56 @@ +[ + { + "memberIndex": 1, + "projectIndex": 0, + "permissions": ["View"], + "isPending": false + }, + { + "memberIndex": 2, + "projectIndex": 0, + "permissions": ["View", "Edit"], + "isPending": false + }, + { + "memberIndex": 2, + "projectIndex": 1, + "permissions": ["View"], + "isPending": false + }, + { + "memberIndex": 0, + "projectIndex": 2, + "permissions": ["View"], + "isPending": false + }, + { + "memberIndex": 1, + "projectIndex": 2, + "permissions": ["View", "Edit"], + "isPending": false + }, + { + "memberIndex": 0, + "projectIndex": 3, + "permissions": ["View"], + "isPending": false + }, + { + "memberIndex": 2, + "projectIndex": 3, + "permissions": ["View", "Edit"], + "isPending": false + }, + { + "memberIndex": 1, + "projectIndex": 4, + "permissions": ["View"], + "isPending": false + }, + { + "memberIndex": 2, + "projectIndex": 4, + "permissions": ["View", "Edit"], + "isPending": false + } +] diff --git a/apps/backend/test/fixtures/projects.json b/apps/backend/test/fixtures/projects.json new file mode 100644 index 0000000..f456464 --- /dev/null +++ b/apps/backend/test/fixtures/projects.json @@ -0,0 +1,67 @@ +[ + { + "ownerIndex": 0, + "organizationIndex": 0, + "name": "testProject", + "repository": "snowball-tools/snowball-ts-framework-template", + "prodBranch": "main", + "description": "test", + "template": "webapp", + "framework": "test", + "webhooks": [], + "icon": "", + "subDomain": "testProject.snowball.xyz" + }, + { + "ownerIndex": 1, + "organizationIndex": 0, + "name": "testProject-2", + "repository": "snowball-tools/snowball-ts-framework-template", + "prodBranch": "main", + "description": "test-2", + "template": "webapp", + "framework": "test-2", + "webhooks": [], + "icon": "", + "subDomain": "testProject-2.snowball.xyz" + }, + { + "ownerIndex": 2, + "organizationIndex": 0, + "name": "iglootools", + "repository": "snowball-tools/snowball-ts-framework-template", + "prodBranch": "main", + "description": "test-3", + "template": "webapp", + "framework": "test-3", + "webhooks": [], + "icon": "", + "subDomain": "iglootools.snowball.xyz" + }, + { + "ownerIndex": 1, + "organizationIndex": 0, + "name": "iglootools-2", + "repository": "snowball-tools/snowball-ts-framework-template", + "prodBranch": "main", + "description": "test-4", + "template": "webapp", + "framework": "test-4", + "webhooks": [], + "icon": "", + "subDomain": "iglootools-2.snowball.xyz" + }, + { + "ownerIndex": 0, + "organizationIndex": 1, + "name": "snowball-2", + "repository": "snowball-tools/snowball-ts-framework-template", + "prodBranch": "main", + "description": "test-5", + "template": "webapp", + "framework": "test-5", + "webhooks": [], + "icon": "", + "subDomain": "snowball-2.snowball.xyz" + } +] diff --git a/apps/backend/test/fixtures/redirected-domains.json b/apps/backend/test/fixtures/redirected-domains.json new file mode 100644 index 0000000..64fa973 --- /dev/null +++ b/apps/backend/test/fixtures/redirected-domains.json @@ -0,0 +1,23 @@ +[ + { + "projectIndex": 0, + "name": "www.example.org", + "status": "Pending", + "redirectToIndex": 1, + "branch": "test" + }, + { + "projectIndex": 1, + "name": "www.example.org", + "status": "Pending", + "redirectToIndex": 3, + "branch": "test" + }, + { + "projectIndex": 2, + "name": "www.example.org", + "status": "Pending", + "redirectToIndex": 5, + "branch": "test" + } +] diff --git a/apps/backend/test/fixtures/user-organizations.json b/apps/backend/test/fixtures/user-organizations.json new file mode 100644 index 0000000..adff4ae --- /dev/null +++ b/apps/backend/test/fixtures/user-organizations.json @@ -0,0 +1,22 @@ +[ + { + "role": "Owner", + "memberIndex": 0, + "organizationIndex": 0 + }, + { + "role": "Maintainer", + "memberIndex": 1, + "organizationIndex": 0 + }, + { + "role": "Owner", + "memberIndex": 2, + "organizationIndex": 0 + }, + { + "role": "Owner", + "memberIndex": 0, + "organizationIndex": 1 + } +] diff --git a/apps/backend/test/fixtures/users.json b/apps/backend/test/fixtures/users.json new file mode 100644 index 0000000..bc1a44d --- /dev/null +++ b/apps/backend/test/fixtures/users.json @@ -0,0 +1,23 @@ +[ + { + "id": "59f4355d-9549-4aac-9b54-eeefceeabef0", + "name": "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", + "email": "snowball@snowballtools.xyz", + "isVerified": true, + "ethAddress": "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2" + }, + { + "id": "e505b212-8da6-48b2-9614-098225dab34b", + "name": "0xbe0eb53f46cd790cd13851d5eff43d12404d33e8", + "email": "alice@snowballtools.xyz", + "isVerified": true, + "ethAddress": "0xbe0eb53f46cd790cd13851d5eff43d12404d33e8" + }, + { + "id": "cd892fad-9138-4aa2-a62c-414a32776ea7", + "name": "0x8315177ab297ba92a06054ce80a67ed4dbd7ed3a", + "email": "bob@snowballtools.xyz", + "isVerified": true, + "ethAddress": "0x8315177ab297ba92a06054ce80a67ed4dbd7ed3a" + } +] diff --git a/apps/backend/test/initialize-db.ts b/apps/backend/test/initialize-db.ts new file mode 100644 index 0000000..3aff317 --- /dev/null +++ b/apps/backend/test/initialize-db.ts @@ -0,0 +1,176 @@ +import path from 'node:path' +import debug from 'debug' +import { DataSource } from 'typeorm' + +import { Deployment } from '../src/entity/Deployment' +import { Domain } from '../src/entity/Domain' +import { EnvironmentVariable } from '../src/entity/EnvironmentVariable' +import { Organization } from '../src/entity/Organization' +import { Project } from '../src/entity/Project' +import { ProjectMember } from '../src/entity/ProjectMember' +import { User } from '../src/entity/User' +import { UserOrganization } from '../src/entity/UserOrganization' +import { + checkFileExists, + getConfig, + getEntities, + loadAndSaveData +} from '../src/utils' + +const log = debug('snowball:initialize-database') + +const USER_DATA_PATH = './fixtures/users.json' +const PROJECT_DATA_PATH = './fixtures/projects.json' +const ORGANIZATION_DATA_PATH = './fixtures/organizations.json' +const USER_ORGANIZATION_DATA_PATH = './fixtures/user-organizations.json' +const PROJECT_MEMBER_DATA_PATH = './fixtures/project-members.json' +const PRIMARY_DOMAIN_DATA_PATH = './fixtures/primary-domains.json' +const DEPLOYMENT_DATA_PATH = './fixtures/deployments.json' +const ENVIRONMENT_VARIABLE_DATA_PATH = './fixtures/environment-variables.json' +const REDIRECTED_DOMAIN_DATA_PATH = './fixtures/redirected-domains.json' + +const generateTestData = async (dataSource: DataSource) => { + const userEntities = await getEntities( + path.resolve(__dirname, USER_DATA_PATH) + ) + const savedUsers = await loadAndSaveData(User, dataSource, userEntities) + + const orgEntities = await getEntities( + path.resolve(__dirname, ORGANIZATION_DATA_PATH) + ) + const savedOrgs = await loadAndSaveData(Organization, dataSource, orgEntities) + + const projectRelations = { + owner: savedUsers, + organization: savedOrgs + } + + const projectEntities = await getEntities( + path.resolve(__dirname, PROJECT_DATA_PATH) + ) + const savedProjects = await loadAndSaveData( + Project, + dataSource, + projectEntities, + projectRelations + ) + + const domainRepository = dataSource.getRepository(Domain) + + const domainPrimaryRelations = { + project: savedProjects + } + + const primaryDomainsEntities = await getEntities( + path.resolve(__dirname, PRIMARY_DOMAIN_DATA_PATH) + ) + const savedPrimaryDomains = await loadAndSaveData( + Domain, + dataSource, + primaryDomainsEntities, + domainPrimaryRelations + ) + + const domainRedirectedRelations = { + project: savedProjects, + redirectTo: savedPrimaryDomains + } + + const redirectDomainsEntities = await getEntities( + path.resolve(__dirname, REDIRECTED_DOMAIN_DATA_PATH) + ) + await loadAndSaveData( + Domain, + dataSource, + redirectDomainsEntities, + domainRedirectedRelations + ) + + const savedDomains = await domainRepository.find() + + const userOrganizationRelations = { + member: savedUsers, + organization: savedOrgs + } + + const userOrganizationsEntities = await getEntities( + path.resolve(__dirname, USER_ORGANIZATION_DATA_PATH) + ) + await loadAndSaveData( + UserOrganization, + dataSource, + userOrganizationsEntities, + userOrganizationRelations + ) + + const projectMemberRelations = { + member: savedUsers, + project: savedProjects + } + + const projectMembersEntities = await getEntities( + path.resolve(__dirname, PROJECT_MEMBER_DATA_PATH) + ) + await loadAndSaveData( + ProjectMember, + dataSource, + projectMembersEntities, + projectMemberRelations + ) + + const deploymentRelations = { + project: savedProjects, + domain: savedDomains, + createdBy: savedUsers + } + + const deploymentsEntities = await getEntities( + path.resolve(__dirname, DEPLOYMENT_DATA_PATH) + ) + await loadAndSaveData( + Deployment, + dataSource, + deploymentsEntities, + deploymentRelations + ) + + const environmentVariableRelations = { + project: savedProjects + } + + const environmentVariablesEntities = await getEntities( + path.resolve(__dirname, ENVIRONMENT_VARIABLE_DATA_PATH) + ) + await loadAndSaveData( + EnvironmentVariable, + dataSource, + environmentVariablesEntities, + environmentVariableRelations + ) +} + +const main = async () => { + const config = await getConfig() + const isDbPresent = await checkFileExists(config.database.dbPath) + + if (!isDbPresent) { + const dataSource = new DataSource({ + type: 'better-sqlite3', + database: config.database.dbPath, + synchronize: true, + logging: true, + entities: [path.join(__dirname, '../src/entity/*')] + }) + + await dataSource.initialize() + + await generateTestData(dataSource) + log('Data loaded successfully') + } else { + log('WARNING: Database already exists') + } +} + +main().catch((err) => { + log(err) +}) diff --git a/apps/backend/test/initialize-registry.ts b/apps/backend/test/initialize-registry.ts new file mode 100644 index 0000000..27c303d --- /dev/null +++ b/apps/backend/test/initialize-registry.ts @@ -0,0 +1,49 @@ +import debug from 'debug' + +import { Registry, parseGasAndFees } from '@cerc-io/registry-sdk' + +import { getConfig } from '../src/utils' + +const log = debug('snowball:initialize-registry') + +const DENOM = 'alnt' +const BOND_AMOUNT = '1000000000' + +async function main() { + const { registryConfig } = await getConfig() + + // TODO: Get authority names from args + const authorityNames = ['snowballtools', registryConfig.authority] + + const registry = new Registry( + registryConfig.gqlEndpoint, + registryConfig.restEndpoint, + { chainId: registryConfig.chainId } + ) + + const bondId = await registry.getNextBondId(registryConfig.privateKey) + log('bondId:', bondId) + + const fee = parseGasAndFees(registryConfig.fee.gas, registryConfig.fee.fees) + + await registry.createBond( + { denom: DENOM, amount: BOND_AMOUNT }, + registryConfig.privateKey, + fee + ) + + for await (const name of authorityNames) { + await registry.reserveAuthority({ name }, registryConfig.privateKey, fee) + log('Reserved authority name:', name) + await registry.setAuthorityBond( + { name, bondId }, + registryConfig.privateKey, + fee + ) + log(`Bond ${bondId} set for authority ${name}`) + } +} + +main().catch((err) => { + log(err) +}) diff --git a/apps/backend/test/publish-deploy-records.ts b/apps/backend/test/publish-deploy-records.ts new file mode 100644 index 0000000..61d424e --- /dev/null +++ b/apps/backend/test/publish-deploy-records.ts @@ -0,0 +1,100 @@ +import path from 'node:path' +import debug from 'debug' +import { DataSource } from 'typeorm' + +import { Registry, parseGasAndFees } from '@cerc-io/registry-sdk' + +import { + Deployment, + DeploymentStatus, + Environment +} from '../src/entity/Deployment' +import { getConfig } from '../src/utils' + +const log = debug('snowball:publish-deploy-records') + +async function main() { + const { registryConfig, database } = await getConfig() + + const registry = new Registry( + registryConfig.gqlEndpoint, + registryConfig.restEndpoint, + { chainId: registryConfig.chainId } + ) + + const dataSource = new DataSource({ + type: 'better-sqlite3', + database: database.dbPath, + synchronize: true, + entities: [path.join(__dirname, '../src/entity/*')] + }) + + await dataSource.initialize() + + const deploymentRepository = dataSource.getRepository(Deployment) + const deployments = await deploymentRepository.find({ + relations: { + project: true + }, + where: { + status: DeploymentStatus.Building + } + }) + + for await (const deployment of deployments) { + const url = `https://${(deployment.project.name).toLowerCase()}-${deployment.id}.${deployment.deployer.baseDomain}` + + const applicationDeploymentRecord = { + type: 'ApplicationDeploymentRecord', + version: '0.0.1', + name: deployment.applicationRecordData.name, + application: deployment.applicationRecordId, + + // TODO: Create DNS record + dns: 'bafyreihlymqggsgqiqawvehkpr2imt4l3u6q7um7xzjrux5rhsvwnuyewm', + + // Using dummy values + meta: JSON.stringify({ + config: 'da39a3ee5e6b4b0d3255bfef95601890afd80709', + so: '66fcfa49a1664d4cb4ce4f72c1c0e151' + }), + + request: deployment.applicationDeploymentRequestId, + url + } + + const fee = parseGasAndFees(registryConfig.fee.gas, registryConfig.fee.fees) + + const result = await registry.setRecord( + { + privateKey: registryConfig.privateKey, + record: applicationDeploymentRecord, + bondId: registryConfig.bondId + }, + '', + fee + ) + + // Remove deployment for project subdomain if deployment is for production environment + if (deployment.environment === Environment.Production) { + applicationDeploymentRecord.url = `https://${deployment.project.name}.${deployment.deployer.baseDomain}` + + await registry.setRecord( + { + privateKey: registryConfig.privateKey, + record: applicationDeploymentRecord, + bondId: registryConfig.bondId + }, + '', + fee + ) + } + + log('Application deployment record data:', applicationDeploymentRecord) + log(`Application deployment record published: ${result.id}`) + } +} + +main().catch((err) => { + log(err) +}) diff --git a/apps/backend/test/publish-deployment-removal-records.ts b/apps/backend/test/publish-deployment-removal-records.ts new file mode 100644 index 0000000..e902fc7 --- /dev/null +++ b/apps/backend/test/publish-deployment-removal-records.ts @@ -0,0 +1,70 @@ +import path from 'node:path' +import debug from 'debug' +import { DataSource } from 'typeorm' + +import { Registry, parseGasAndFees } from '@cerc-io/registry-sdk' + +import { Deployment, DeploymentStatus } from '../src/entity/Deployment' +import { getConfig } from '../src/utils' + +const log = debug('snowball:publish-deployment-removal-records') + +async function main() { + const { registryConfig, database } = await getConfig() + + const registry = new Registry( + registryConfig.gqlEndpoint, + registryConfig.restEndpoint, + { chainId: registryConfig.chainId } + ) + + const dataSource = new DataSource({ + type: 'better-sqlite3', + database: database.dbPath, + synchronize: true, + entities: [path.join(__dirname, '../src/entity/*')] + }) + + await dataSource.initialize() + + const deploymentRepository = dataSource.getRepository(Deployment) + const deployments = await deploymentRepository.find({ + relations: { + project: true + }, + where: { + status: DeploymentStatus.Deleting + } + }) + + for await (const deployment of deployments) { + const applicationDeploymentRemovalRecord = { + type: 'ApplicationDeploymentRemovalRecord', + version: '1.0.0', + deployment: deployment.applicationDeploymentRecordId, + request: deployment.applicationDeploymentRemovalRequestId + } + + const fee = parseGasAndFees(registryConfig.fee.gas, registryConfig.fee.fees) + + const result = await registry.setRecord( + { + privateKey: registryConfig.privateKey, + record: applicationDeploymentRemovalRecord, + bondId: registryConfig.bondId + }, + '', + fee + ) + + log( + 'Application deployment removal record data:', + applicationDeploymentRemovalRecord + ) + log(`Application deployment removal record published: ${result.id}`) + } +} + +main().catch((err) => { + log(err) +}) diff --git a/apps/backend/tsconfig.json b/apps/backend/tsconfig.json new file mode 100644 index 0000000..d4cfe92 --- /dev/null +++ b/apps/backend/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "baseUrl": ".", + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["dist", "src/**/*.test.ts"] +} diff --git a/apps/deploy-fe/.env.example b/apps/deploy-fe/.env.example new file mode 100644 index 0000000..861ca8e --- /dev/null +++ b/apps/deploy-fe/.env.example @@ -0,0 +1,6 @@ +NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY= +CLERK_SECRET_KEY= +NEXT_PUBLIC_WALLET_IFRAME_URL= # wherever your wallet is running +NEXT_PUBLIC_LACONICD_CHAIN_ID= # the appropriate chain ID for your network +NEXT_PUBLIC_API_URL= +NEXT_PUBLIC_GITHUB_FALLBACK_TOKEN= \ No newline at end of file diff --git a/apps/deploy-fe/.gitignore b/apps/deploy-fe/.gitignore new file mode 100644 index 0000000..d021fb9 --- /dev/null +++ b/apps/deploy-fe/.gitignore @@ -0,0 +1,12 @@ +node_modules +.next/ +.turbo/ +.env +.env.local +.env.development.local +.env.test.local + +# clerk configuration (can include secrets) +/.clerk/ +.vercel +.clerk/ \ No newline at end of file diff --git a/apps/deploy-fe/.vscode/settings.json b/apps/deploy-fe/.vscode/settings.json new file mode 100644 index 0000000..28c0bf2 --- /dev/null +++ b/apps/deploy-fe/.vscode/settings.json @@ -0,0 +1,45 @@ +{ + // Project-specific formatter choice + "editor.defaultFormatter": "biomejs.biome", + "editor.formatOnSave": true, + + // TypeScript configuration + "typescript.tsdk": "node_modules/typescript/lib", + "typescript.enablePromptUseWorkspaceTsdk": true, + + // Code actions + "editor.codeActionsOnSave": { + "source.fixAll": "explicit", + "source.addMissingImports.ts": "explicit", + "source.organizeImports.biome": "explicit", + "source.removeUnused.ts": "explicit" + }, + + // Language-specific formatters for this project + "[typescript]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "biomejs.biome" + }, + + // TypeScript-specific performance settings for this project + "typescript.preferGoToSourceDefinition": true, + "typescript.suggest.paths": true, + "typescript.tsserver.disableAutomaticTypeAcquisition": false, + + // TypeScript server project-specific settings + "typescript.tsserver.maxTsServerMemory": 8192, + "typescript.tsserver.experimental.enableProjectDiagnostics": true, + "typescript.tsserver.enableTracing": false, + + // For large TypeScript projects + "search.exclude": { + "**/node_modules": true, + "**/dist": true, + "**/build": true + }, + + // For better JSDoc documentation + "javascript.suggest.completeJSDocs": true +} diff --git a/apps/deploy-fe/biome.jsonc b/apps/deploy-fe/biome.jsonc new file mode 100644 index 0000000..52a1d9f --- /dev/null +++ b/apps/deploy-fe/biome.jsonc @@ -0,0 +1,3 @@ +{ + "extends": ["../../biome.json"] +} diff --git a/apps/deploy-fe/components.json b/apps/deploy-fe/components.json new file mode 100644 index 0000000..bdeb87e --- /dev/null +++ b/apps/deploy-fe/components.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "../../services/ui/tailwind.config.ts", + "css": "../../services/ui/src/styles/globals.css", + "baseColor": "zinc", + "cssVariables": true + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "hooks": "@/hooks", + "lib": "@/lib", + "utils": "@workspace/ui/lib/utils", + "ui": "@workspace/ui/components" + } +} diff --git a/apps/deploy-fe/next.config.mjs b/apps/deploy-fe/next.config.mjs new file mode 100644 index 0000000..1ebcb2b --- /dev/null +++ b/apps/deploy-fe/next.config.mjs @@ -0,0 +1,16 @@ +import dotenv from 'dotenv' + +// Load environment variables from .env.development.local +dotenv.config({ path: '.env.development.local' }) + +/** @type {import('next').NextConfig} */ +const nextConfig = { + transpilePackages: ['@workspace/ui'], + env: { + NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: + process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY, + CLERK_SECRET_KEY: process.env.CLERK_SECRET_KEY + } +} + +export default nextConfig diff --git a/apps/deploy-fe/package.json b/apps/deploy-fe/package.json new file mode 100644 index 0000000..1142665 --- /dev/null +++ b/apps/deploy-fe/package.json @@ -0,0 +1,90 @@ +{ + "name": "deploy-fe", + "version": "0.0.1", + "type": "module", + "private": true, + "scripts": { + "dev": "NODE_OPTIONS='--inspect' next dev --turbopack", + "build": "next build", + "start": "next start", + "lint": "biome check .", + "lint:fix": "biome check --write .", + "format": "biome format .", + "format:fix": "biome format --write .", + "check-types": "tsc --noEmit", + "fix-types": "tsc --noEmit --pretty --incremental" + }, + "dependencies": { + "@biomejs/biome": "^1.9.4", + "@clerk/nextjs": "^6.12.4", + "@clerk/themes": "^2.2.20", + "@hookform/resolvers": "^4.1.2", + "@octokit/rest": "^21.1.1", + "@octokit/webhooks-types": "^7.6.1", + "@radix-ui/react-accordion": "^1.2.3", + "@radix-ui/react-alert-dialog": "^1.1.6", + "@radix-ui/react-aspect-ratio": "^1.1.2", + "@radix-ui/react-avatar": "^1.1.3", + "@radix-ui/react-checkbox": "^1.1.4", + "@radix-ui/react-collapsible": "^1.1.3", + "@radix-ui/react-context-menu": "^2.2.6", + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-dropdown-menu": "^2.1.6", + "@radix-ui/react-hover-card": "^1.1.6", + "@radix-ui/react-icons": "^1.3.2", + "@radix-ui/react-label": "^2.1.2", + "@radix-ui/react-menubar": "^1.1.6", + "@radix-ui/react-navigation-menu": "^1.2.5", + "@radix-ui/react-popover": "^1.1.6", + "@radix-ui/react-progress": "^1.1.2", + "@radix-ui/react-radio-group": "^1.2.3", + "@radix-ui/react-scroll-area": "^1.2.3", + "@radix-ui/react-select": "^2.1.6", + "@radix-ui/react-separator": "^1.1.2", + "@radix-ui/react-slider": "^1.2.3", + "@radix-ui/react-slot": "^1.1.2", + "@radix-ui/react-switch": "^1.1.3", + "@radix-ui/react-tabs": "^1.1.3", + "@radix-ui/react-toggle": "^1.1.2", + "@radix-ui/react-toggle-group": "^1.1.2", + "@radix-ui/react-tooltip": "^1.1.8", + "@radix-ui/react-visually-hidden": "^1.1.2", + "@workspace/ui": "workspace:*", + "axios": "^1.8.4", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.1", + "cmdk": "1.0.4", + "date-fns": "^4.1.0", + "downshift": "^9.0.9", + "embla-carousel-react": "^8.5.2", + "input-otp": "^1.4.2", + "lucide-react": "0.477.0", + "next": "^15.2.1", + "next-themes": "^0.4.4", + "octokit": "^3.1.2", + "react": "^19.0.0", + "react-day-picker": "8.10.1", + "react-dom": "^19.0.0", + "react-hook-form": "^7.54.2", + "react-resizable-panels": "^2.1.7", + "recharts": "^2.15.1", + "siwe": "^3.0.0", + "sonner": "^2.0.1", + "tailwind-merge": "^3.0.2", + "usehooks-ts": "^3.1.1", + "vaul": "^1.1.2", + "zod": "^3.23.8", + "zustand": "^5.0.3" + }, + "devDependencies": { + "@types/node": "^20", + "@types/react": "18.3.0", + "@types/react-dom": "18.3.1", + "@workspace/gql-client": "workspace:*", + "@workspace/typescript-config": "workspace:*", + "dotenv": "^16.4.7", + "postcss": "^8", + "tailwindcss": "^3.4.17", + "typescript": "^5" + } +} diff --git a/apps/deploy-fe/postcss.config.mjs b/apps/deploy-fe/postcss.config.mjs new file mode 100644 index 0000000..5f90e69 --- /dev/null +++ b/apps/deploy-fe/postcss.config.mjs @@ -0,0 +1 @@ +export { default } from '@workspace/ui/postcss.config' diff --git a/apps/deploy-fe/repo_structure.txt b/apps/deploy-fe/repo_structure.txt new file mode 100644 index 0000000..7e31950 --- /dev/null +++ b/apps/deploy-fe/repo_structure.txt @@ -0,0 +1,220 @@ +./.env.local +./.gitignore +./.turbo/turbo-build.log +./.vscode/settings.json +./biome.jsonc +./components.json +./next-env.d.ts +./next.config.mjs +./package.json +./postcss.config.mjs +./repo_structure.txt +./src/actions/github.ts +./src/app/(web3-authenticated)/(dashboard)/documentation/DocumentationPlaceholder.tsx +./src/app/(web3-authenticated)/(dashboard)/documentation/page.tsx +./src/app/(web3-authenticated)/(dashboard)/home/loading.tsx +./src/app/(web3-authenticated)/(dashboard)/home/page.tsx +./src/app/(web3-authenticated)/(dashboard)/layout.tsx +./src/app/(web3-authenticated)/(dashboard)/projects/[provider]/(projects)/ps/(create)/cr/(configure)/cf/page.tsx +./src/app/(web3-authenticated)/(dashboard)/projects/[provider]/(projects)/ps/(create)/cr/(deploy)/dp/page.tsx +./src/app/(web3-authenticated)/(dashboard)/projects/[provider]/(projects)/ps/(create)/cr/(success)/sc/[id]/page.tsx +./src/app/(web3-authenticated)/(dashboard)/projects/[provider]/(projects)/ps/(create)/cr/(template)/tm/(configure)/cf/page.tsx +./src/app/(web3-authenticated)/(dashboard)/projects/[provider]/(projects)/ps/(create)/cr/(template)/tm/(deploy)/dp/page.tsx +./src/app/(web3-authenticated)/(dashboard)/projects/[provider]/(projects)/ps/[id]/(deployments)/dep/page.tsx +./src/app/(web3-authenticated)/(dashboard)/projects/[provider]/(projects)/ps/[id]/(integrations)/int/GitPage.tsx +./src/app/(web3-authenticated)/(dashboard)/projects/[provider]/(projects)/ps/[id]/(integrations)/int/page.tsx +./src/app/(web3-authenticated)/(dashboard)/projects/[provider]/(projects)/ps/[id]/(settings)/set/(collaborators)/col/page.tsx +./src/app/(web3-authenticated)/(dashboard)/projects/[provider]/(projects)/ps/[id]/(settings)/set/(domains)/dom/(add)/cf/page.tsx +./src/app/(web3-authenticated)/(dashboard)/projects/[provider]/(projects)/ps/[id]/(settings)/set/(domains)/dom/(add)/config/cf/page.tsx +./src/app/(web3-authenticated)/(dashboard)/projects/[provider]/(projects)/ps/[id]/(settings)/set/(environment-variables)/env/EnvVarsPage.tsx +./src/app/(web3-authenticated)/(dashboard)/projects/[provider]/(projects)/ps/[id]/(settings)/set/(environment-variables)/env/page.tsx +./src/app/(web3-authenticated)/(dashboard)/projects/[provider]/(projects)/ps/[id]/(settings)/set/(git)/page.tsx +./src/app/(web3-authenticated)/(dashboard)/projects/[provider]/(projects)/ps/[id]/(settings)/set/ProjectSettingsPage.tsx +./src/app/(web3-authenticated)/(dashboard)/projects/[provider]/(projects)/ps/[id]/(settings)/set/page.tsx +./src/app/(web3-authenticated)/(dashboard)/projects/[provider]/(projects)/ps/[id]/deployments/page.tsx +./src/app/(web3-authenticated)/(dashboard)/projects/[provider]/(projects)/ps/[id]/layout.tsx +./src/app/(web3-authenticated)/(dashboard)/projects/[provider]/(projects)/ps/[id]/loading.tsx +./src/app/(web3-authenticated)/(dashboard)/projects/[provider]/(projects)/ps/[id]/page.tsx +./src/app/(web3-authenticated)/(dashboard)/projects/error.tsx +./src/app/(web3-authenticated)/(dashboard)/projects/loading.tsx +./src/app/(web3-authenticated)/(dashboard)/projects/page.tsx +./src/app/(web3-authenticated)/(dashboard)/purchase/BuyServices.tsx +./src/app/(web3-authenticated)/(dashboard)/purchase/page.tsx +./src/app/(web3-authenticated)/(dashboard)/store/page.tsx +./src/app/(web3-authenticated)/(dashboard)/support/SupportPlaceholder.tsx +./src/app/(web3-authenticated)/(dashboard)/support/page.tsx +./src/app/(web3-authenticated)/(dashboard)/wallet/page.tsx +./src/app/(web3-authenticated)/layout.tsx +./src/app/actions/github.ts +./src/app/api/auth/route.ts +./src/app/api/github/webhook/route.ts +./src/app/favicon.ico +./src/app/layout.tsx +./src/app/loading.tsx +./src/app/page.tsx +./src/app/sign-in/[[...sign-in]]/page.tsx +./src/components/assets/laconic-mark.tsx +./src/components/core/dropdown/Dropdown.tsx +./src/components/core/dropdown/README.md +./src/components/core/dropdown/index.ts +./src/components/core/dropdown/types.ts +./src/components/core/format-milli-second/FormatMilliSecond.tsx +./src/components/core/format-milli-second/README.md +./src/components/core/format-milli-second/index.ts +./src/components/core/format-milli-second/types.ts +./src/components/core/logo/Logo.tsx +./src/components/core/logo/README.md +./src/components/core/logo/index.ts +./src/components/core/logo/types.ts +./src/components/core/search-bar/README.md +./src/components/core/search-bar/SearchBar.tsx +./src/components/core/search-bar/index.ts +./src/components/core/search-bar/types.ts +./src/components/core/stepper/README.md +./src/components/core/stepper/Stepper.tsx +./src/components/core/stepper/index.ts +./src/components/core/stepper/types.ts +./src/components/core/stop-watch/README.md +./src/components/core/stop-watch/StopWatch.tsx +./src/components/core/stop-watch/index.ts +./src/components/core/stop-watch/types.ts +./src/components/core/vertical-stepper/README.md +./src/components/core/vertical-stepper/VerticalStepper.tsx +./src/components/core/vertical-stepper/index.ts +./src/components/core/vertical-stepper/types.ts +./src/components/foundation/coming-soon-overlay/ComingSoonOverlay.tsx +./src/components/foundation/coming-soon-overlay/index.ts +./src/components/foundation/github-session-button/GitHubSessionButton.tsx +./src/components/foundation/github-session-button/README.md +./src/components/foundation/github-session-button/index.ts +./src/components/foundation/github-session-button/types.ts +./src/components/foundation/index.ts +./src/components/foundation/laconic-icon/LaconicIcon.tsx +./src/components/foundation/laconic-icon/README.md +./src/components/foundation/laconic-icon/index.ts +./src/components/foundation/laconic-icon/types.ts +./src/components/foundation/loading/loading-overlay/LoadingOverlay.tsx +./src/components/foundation/loading/loading-overlay/README.md +./src/components/foundation/loading/loading-overlay/index.ts +./src/components/foundation/navigation-wrapper/NavigationWrapper.tsx +./src/components/foundation/navigation-wrapper/README.md +./src/components/foundation/navigation-wrapper/index.ts +./src/components/foundation/page-header/PageHeader.tsx +./src/components/foundation/page-header/README.md +./src/components/foundation/page-header/index.ts +./src/components/foundation/page-wrapper/PageWrapper.tsx +./src/components/foundation/page-wrapper/README.md +./src/components/foundation/page-wrapper/index.ts +./src/components/foundation/project-search-bar/ProjectSearchBar.tsx +./src/components/foundation/project-search-bar/README.md +./src/components/foundation/project-search-bar/index.ts +./src/components/foundation/project-search-bar/types.ts +./src/components/foundation/top-navigation/README.md +./src/components/foundation/top-navigation/dark-mode-toggle/DarkModeToggle.tsx +./src/components/foundation/top-navigation/dark-mode-toggle/README.md +./src/components/foundation/top-navigation/dark-mode-toggle/index.ts +./src/components/foundation/top-navigation/index.ts +./src/components/foundation/top-navigation/main-navigation/MainNavigation.tsx +./src/components/foundation/top-navigation/main-navigation/README.md +./src/components/foundation/top-navigation/main-navigation/index.ts +./src/components/foundation/top-navigation/navigation-item/NavigationItem.tsx +./src/components/foundation/top-navigation/navigation-item/README.md +./src/components/foundation/top-navigation/navigation-item/index.ts +./src/components/foundation/top-navigation/types.ts +./src/components/foundation/top-navigation/wallet-session-badge/README.md +./src/components/foundation/top-navigation/wallet-session-badge/WalletSessionBadge.tsx +./src/components/foundation/top-navigation/wallet-session-badge/index.ts +./src/components/foundation/types.ts +./src/components/foundation/wallet-session-id/README.md +./src/components/foundation/wallet-session-id/WalletSessionId.tsx +./src/components/foundation/wallet-session-id/index.ts +./src/components/foundation/wallet-session-id/types.ts +./src/components/iframe/auto-sign-in/AutoSignInIFrameModal.tsx +./src/components/iframe/auto-sign-in/README.md +./src/components/iframe/auto-sign-in/index.ts +./src/components/iframe/auto-sign-in/types.ts +./src/components/iframe/check-balance-iframe/CheckBalanceIframe.tsx +./src/components/iframe/check-balance-iframe/useCheckBalance.tsx +./src/components/layout/index.ts +./src/components/layout/navigation/github-session-button/GitHubSessionButton.tsx +./src/components/layout/navigation/github-session-button/README.md +./src/components/layout/navigation/github-session-button/index.ts +./src/components/layout/navigation/github-session-button/types.ts +./src/components/layout/navigation/laconic-icon/LaconicIcon.tsx +./src/components/layout/navigation/laconic-icon/README.md +./src/components/layout/navigation/laconic-icon/index.ts +./src/components/layout/navigation/laconic-icon/types.ts +./src/components/layout/navigation/navigation-actions/NavigationActions.tsx +./src/components/layout/navigation/navigation-actions/README.md +./src/components/layout/navigation/navigation-actions/index.ts +./src/components/layout/navigation/navigation-actions/types.ts +./src/components/layout/navigation/wallet-session-id/README.md +./src/components/layout/navigation/wallet-session-id/WalletSessionId.tsx +./src/components/layout/navigation/wallet-session-id/index.ts +./src/components/layout/navigation/wallet-session-id/types.ts +./src/components/loading/loading-overlay.tsx +./src/components/onboarding/OPTIMIZATION.md +./src/components/onboarding/Onboarding.tsx +./src/components/onboarding/OnboardingButton.tsx +./src/components/onboarding/OnboardingDialog.tsx +./src/components/onboarding/README.md +./src/components/onboarding/common/background-svg.tsx +./src/components/onboarding/common/index.ts +./src/components/onboarding/common/laconic-icon-lettering.tsx +./src/components/onboarding/common/onboarding-container.tsx +./src/components/onboarding/common/step-header.tsx +./src/components/onboarding/common/step-navigation.tsx +./src/components/onboarding/configure-step/configure-step.tsx +./src/components/onboarding/configure-step/index.ts +./src/components/onboarding/connect-step/connect-button.tsx +./src/components/onboarding/connect-step/connect-deploy-first-app.tsx +./src/components/onboarding/connect-step/connect-initial.tsx +./src/components/onboarding/connect-step/connect-step.tsx +./src/components/onboarding/connect-step/index.ts +./src/components/onboarding/connect-step/repository-list.tsx +./src/components/onboarding/connect-step/template-list.tsx +./src/components/onboarding/deploy-step/deploy-step.tsx +./src/components/onboarding/deploy-step/index.ts +./src/components/onboarding/index.ts +./src/components/onboarding/sidebar/index.ts +./src/components/onboarding/sidebar/sidebar-nav.tsx +./src/components/onboarding/store.ts +./src/components/onboarding/types.ts +./src/components/onboarding/useOnboarding.ts +./src/components/projects/project/ProjectCard/FixedProjectCard.tsx +./src/components/projects/project/ProjectCard/ProjectCard.tsx +./src/components/projects/project/ProjectCard/ProjectCardActions.tsx +./src/components/projects/project/ProjectCard/ProjectDeploymentInfo.tsx +./src/components/projects/project/ProjectCard/ProjectStatusDot.tsx +./src/components/projects/project/ProjectCard/index.ts +./src/components/projects/project/ProjectSearchBar/ProjectSearchBar.tsx +./src/components/projects/project/ProjectSearchBar/ProjectSearchBarDialog.tsx +./src/components/projects/project/ProjectSearchBar/ProjectSearchBarEmpty.tsx +./src/components/projects/project/ProjectSearchBar/ProjectSearchBarItem.tsx +./src/components/projects/project/ProjectSearchBar/index.ts +./src/components/projects/project/deployments/DeploymentDetailsCard.tsx +./src/components/projects/project/deployments/FilterForm.tsx +./src/components/projects/project/overview/Activity/AuctionCard.tsx +./src/components/projects/project/overview/OverviewInfo.tsx +./src/components/providers.tsx +./src/context/GQLClientContext.tsx +./src/context/OctokitContext.tsx +./src/context/OctokitProviderWithRouter.tsx +./src/context/WalletContext.tsx +./src/context/WalletContextProvider.tsx +./src/context/index.ts +./src/hooks/useRepoData.tsx +./src/lib/utils.ts +./src/middleware.ts +./src/types/common.ts +./src/types/dashboard.ts +./src/types/deployment.ts +./src/types/hooks/.gitkeep +./src/types/hooks/use-mobile.tsx +./src/types/index.ts +./src/types/project.ts +./src/utils/getInitials.ts +./src/utils/time.ts +./standards/architecture/routes.md +./tailwind.config.ts +./tsconfig.json diff --git a/apps/deploy-fe/src/actions/github.ts b/apps/deploy-fe/src/actions/github.ts new file mode 100644 index 0000000..ba43da0 --- /dev/null +++ b/apps/deploy-fe/src/actions/github.ts @@ -0,0 +1,36 @@ +// app/actions/github.ts +'use server' + +import { auth, currentUser } from '@clerk/nextjs/server' +import { Octokit } from '@octokit/rest' +import type { Organization } from '@octokit/webhooks-types' + +export async function getGitHubOrgs() { + const { userId } = await auth() + + if (!userId) { + throw new Error('Unauthorized') + } + + const user = await currentUser() + const githubAccount = user?.externalAccounts.find( + (account) => account.provider === 'github' + ) + + const token = + githubAccount?.provider === 'github' ? githubAccount.externalId : null + + if (!token) { + throw new Error('GitHub not connected') + } + + const octokit = new Octokit({ auth: token }) + const { data } = await octokit.rest.orgs.listForAuthenticatedUser() + + return data.map((org: Organization) => ({ + id: org.id, + name: org.login, + login: org.login, + avatarUrl: org.avatar_url + })) +} diff --git a/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/documentation/DocumentationPlaceholder.tsx b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/documentation/DocumentationPlaceholder.tsx new file mode 100644 index 0000000..c579a9b --- /dev/null +++ b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/documentation/DocumentationPlaceholder.tsx @@ -0,0 +1,669 @@ +'use client' + +import { Separator } from '@radix-ui/react-dropdown-menu' +import { Button } from '@workspace/ui/components/button' +import { Input } from '@workspace/ui/components/input' +import { + Sheet, + SheetContent, + SheetTrigger +} from '@workspace/ui/components/sheet' +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger +} from '@workspace/ui/components/tabs' +import { + ChevronDown, + Code, + Copy, + Github, + Globe, + Menu, + Moon, + Search, + Sun, + Terminal, + X +} from 'lucide-react' +import Link from 'next/link' +import { useRouter } from 'next/navigation' +import { useState } from 'react' + +// Add this component after the imports +function ComingSoonOverlay({ routerAction }: { routerAction: () => void }) { + return ( +
+
+ +

Coming Soon

+

+ Our documentation is currently under development. Check back soon for + comprehensive guides and tutorials. +

+
+ +
+
+
+ ) +} + +export default function DocumentationPage() { + const [isMobileNavOpen, setIsMobileNavOpen] = useState(false) + const [isDarkMode, setIsDarkMode] = useState(false) + const router = useRouter() + const toggleDarkMode = () => { + setIsDarkMode(!isDarkMode) + document.documentElement.classList.toggle('dark') + } + + return ( +
+ router.back()} /> + {/* Header */} +
+
+
+ + + Laconic Deploy + + +
+
+
+
+ + +
+
+ +
+
+
+ +
+ {/* Sidebar */} + + + {/* Main content */} +
+
+
+
+ Docs +
+ +
Getting Started
+
+
+

+ Getting Started with Laconic Deploy +

+

+ Learn how to deploy your applications with Laconic Deploy in + minutes. +

+
+ + +
+

+ Laconic Deploy is a modern deployment platform that makes it + easy to deploy your applications to the cloud. With Laconic + Deploy, you can deploy your applications with just a few clicks + or commands. +

+ +

Installation

+

+ To get started with Laconic Deploy, you need to install the + Laconic CLI. You can install it using npm: +

+ +
+
+ + Terminal + +
+
+                  npm install -g laconic-cli
+                
+
+ +

Authentication

+

+ After installing the CLI, you need to authenticate with Laconic + Deploy: +

+ +
+
+ + Terminal + +
+
+                  laconic login
+                
+
+ +

Creating Your First Project

+

+ To create a new project, use the laconic init{' '} + command: +

+ +
+
+ + Terminal + +
+
+                  laconic init my-awesome-project
+                
+
+ +

Deploying Your Application

+

+ Once your project is set up, you can deploy it with a single + command: +

+ +
+
+ + Terminal + +
+
+                  laconic deploy
+                
+
+ +

Configuration

+

+ Laconic Deploy uses a laconic.config.js file to + configure your deployments. Here's an example configuration: +

+ +
+
+ + laconic.config.js + +
+
+                  {`module.exports = {
+  name: 'my-awesome-project',
+  region: 'us-west-1',
+  environment: {
+    NODE_ENV: 'production',
+    API_URL: 'https://api.example.com'
+  },
+  resources: {
+    compute: {
+      type: 'container',
+      size: 'small',
+      port: 3000
+    },
+    database: {
+      type: 'postgres',
+      version: '14'
+    }
+  }
+}`}
+                
+
+ +

Next Steps

+

+ Now that you've deployed your first application, you might want + to explore: +

+ +
    +
  • Setting up custom domains
  • +
  • Configuring environment variables
  • +
  • Setting up CI/CD pipelines
  • +
  • Monitoring and logging
  • +
  • Scaling your application
  • +
+ +
+ + + CLI + API + Dashboard + + +

+ The Laconic CLI provides a powerful command-line interface + for managing your deployments. See the{' '} + + CLI Reference + {' '} + for more information. +

+
+ +

+ The Laconic API allows you to programmatically manage your + deployments. See the{' '} + + API Reference + {' '} + for more information. +

+
+ +

+ The Laconic Dashboard provides a web interface for + managing your deployments. Visit the{' '} + + Dashboard + {' '} + to get started. +

+
+
+
+
+ +
+ + +
+
+ + {/* Table of contents - desktop only */} + +
+
+ +
+
+

+ © {new Date().getFullYear()} Laconic Deploy. All rights reserved. +

+
+ + Terms + + + Privacy + + + Contact + +
+
+
+
+ ) +} + +function MobileNav({ onNavClose }: { onNavClose: () => void }) { + return ( +
+
+ + + Laconic Deploy + + +
+
+
+ +
+ +
+
+ ) +} + +function DocsSidebar({ + mobile = false, + onNavClose +}: { mobile?: boolean; onNavClose?: () => void }) { + const handleLinkClick = () => { + if (mobile && onNavClose) { + onNavClose() + } + } + + return ( +
+
+

+ Getting Started +

+
+ + Introduction + + + Getting Started + + + Installation + + + CLI Setup + +
+
+
+

+ Core Concepts +

+
+ + Projects + + + Environments + + + Deployments + + + Resources + +
+
+
+

+ Guides +

+
+ + Custom Domains + + + Environment Variables + + + CI/CD Integration + + + Monitoring & Logging + +
+
+
+

+ API Reference +

+
+ + Authentication + + + Projects API + + + Deployments API + + + Resources API + +
+
+
+ ) +} diff --git a/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/documentation/page.tsx b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/documentation/page.tsx new file mode 100644 index 0000000..2c2055e --- /dev/null +++ b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/documentation/page.tsx @@ -0,0 +1,12 @@ +// Documentation page for the Deploy platform using Pagewrapper component + +import PageWrapper from '@/components/foundation/page-wrapper/PageWrapper' +import DocumentationPlaceholder from './DocumentationPlaceholder' + +export default function DocumentationPage() { + return ( + + + + ) +} diff --git a/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/home/loading.tsx b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/home/loading.tsx new file mode 100644 index 0000000..0e7311c --- /dev/null +++ b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/home/loading.tsx @@ -0,0 +1,6 @@ +'use client' +import { LoadingOverlay } from '@/components/foundation/loading/loading-overlay' + +export default function Loading() { + return +} diff --git a/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/home/page.tsx b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/home/page.tsx new file mode 100644 index 0000000..8f2dd8b --- /dev/null +++ b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/home/page.tsx @@ -0,0 +1,191 @@ +import { PageWrapper } from '@/components/foundation' +import { auth, currentUser } from '@clerk/nextjs/server' +import { notFound } from 'next/navigation' +import Link from 'next/link' +import { Octokit } from '@octokit/rest' +import { Shapes } from 'lucide-react' +import { Button } from '@workspace/ui/components/button' + +/** + * Dashboard page + * @returns {React.ReactNode} The rendered component + */ +export default async function Page() { + const authenticated = await auth() + const userId = authenticated.userId + + if (!userId) { + return notFound() + } + + try { + const user = await currentUser() + const githubAccount = user?.externalAccounts.find( + (account) => account.provider === 'oauth_github' + ) + + if (!githubAccount) { + return ( + +
+
+
+ +
+
+
+ GitHub Account Not Connected +
+
+ You need to connect your GitHub account to use the dashboard features. + Please visit your user profile in Clerk to connect GitHub. +
+ +
+
+ ) + } + + // NOTE: We're keeping the token approach for now, but aware it's not working + const authToken = githubAccount.accessToken; + + // Try using GitHub token + let octokit; + try { + octokit = new Octokit({ + auth: authToken || process.env.GITHUB_TOKEN + }); + + // Test with a simple request + + // Try listing repositories + const repoResponse = await octokit.repos.listForAuthenticatedUser(); + + return ( + +
+
+ {repoResponse.data.length > 0 ? ( + repoResponse.data.map((repo) => ( +
+

{repo.name}

+

+ {repo.description || 'No description provided'} +

+
+
+ + {repo.default_branch} + +
+ + View on GitHub + +
+
+ )) + ) : ( +
+

No repositories found

+
+ )} +
+
+
+ ); + } catch (authError) { + console.error("GitHub API error:", authError); + return ( + +
+
+
+ +
+
+
+ Failed to access GitHub API +
+
+ {authError.message} +
+
+

This issue may be related to how Clerk is managing the GitHub token.

+

Try reconnecting your GitHub account with the correct permissions.

+
+ +
+
+ ); + } + } catch (error) { + console.error("GitHub authentication error:", error); + return ( + +
+
+ Failed to authenticate with GitHub +
+
+ {error.message} +
+
+
+ ); + } +} \ No newline at end of file diff --git a/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/layout.tsx b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/layout.tsx new file mode 100644 index 0000000..d315720 --- /dev/null +++ b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/layout.tsx @@ -0,0 +1,19 @@ +'use client' + +import NavigationWrapper from '@/components/foundation/navigation-wrapper/NavigationWrapper' +import { TopNavigation } from '@/components/foundation/top-navigation' + +interface LayoutProps { + children: React.ReactNode +} + +const Layout: React.FC = ({ children }) => { + return ( + + + {children} + + ) +} + +export default Layout diff --git a/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/(create)/cr/(configure)/cf/page.tsx b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/(create)/cr/(configure)/cf/page.tsx new file mode 100644 index 0000000..a47d879 --- /dev/null +++ b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/(create)/cr/(configure)/cf/page.tsx @@ -0,0 +1,227 @@ +'use client' +import { useState, useEffect } from 'react' +import { useParams, useRouter } from 'next/navigation' +import { PageWrapper } from '@/components/foundation' +import { LoadingOverlay } from '@/components/foundation/loading/loading-overlay' +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@workspace/ui/components/card' +import { Input } from '@workspace/ui/components/input' +import { Label } from '@workspace/ui/components/label' +import { Button } from '@workspace/ui/components/button' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@workspace/ui/components/select' +import { toast } from 'sonner' +import { Stepper } from '@/components/core/stepper/Stepper' +import { useRepoData } from '@/hooks/useRepoData' + +export default function ConfigureDeploymentPage() { + const router = useRouter() + const params = useParams() + const providerParam = params?.provider ? String(params.provider) : 'github' + + // Use the existing useRepoData hook to fetch all repos (empty string for ID means all repos) + const { repoData: repositories, isLoading } = useRepoData('') + + const [selectedRepo, setSelectedRepo] = useState('') + const [selectedBranch, setSelectedBranch] = useState('main') + const [projectName, setProjectName] = useState('') + const [branches, setBranches] = useState(['main']) + const [envVars, setEnvVars] = useState<{ key: string; value: string }[]>([ + { key: '', value: '' } + ]) + + // Define stepper values for the existing Stepper component + const stepperValues = [ + { step: 1, label: 'Select Repository', route: '/projects/github/ps/cr/tm/cf' }, + { step: 2, label: 'Configure', route: '/projects/github/ps/cr/cf' }, + { step: 3, label: 'Deploy', route: '/projects/github/ps/cr/dp' }, + { step: 4, label: 'Success', route: '/projects/github/ps/cr/sc' } + ] + + // When a repository is selected, update project name and branch + useEffect(() => { + if (!selectedRepo || !repositories) return + + const repo = repositories.find(r => r.full_name === selectedRepo) + if (repo) { + setProjectName(repo.name) + setSelectedBranch(repo.default_branch) + + // For simplicity, just use the default branch and some common branch names + // In a real implementation, you would fetch branches for the selected repo + setBranches([repo.default_branch, 'develop', 'feature/new-ui']) + } + }, [selectedRepo, repositories]) + + const handleRepoChange = (repo: string) => { + setSelectedRepo(repo) + } + + const handleBranchChange = (branch: string) => { + setSelectedBranch(branch) + } + + const handleProjectNameChange = (e: React.ChangeEvent) => { + setProjectName(e.target.value) + } + + const handleEnvVarChange = (index: number, field: 'key' | 'value', value: string) => { + const newEnvVars = [...envVars] + newEnvVars[index][field] = value + + // Add a new empty row if the last row has both key and value filled + if ( + index === newEnvVars.length - 1 && + newEnvVars[index].key !== '' && + newEnvVars[index].value !== '' + ) { + newEnvVars.push({ key: '', value: '' }) + } + + setEnvVars(newEnvVars) + } + + const handleSubmit = () => { + if (!selectedRepo || !selectedBranch || !projectName) { + toast.error('Please fill in all required fields') + return + } + + // Filter out empty env vars + const filteredEnvVars = envVars.filter( + envVar => envVar.key.trim() !== '' && envVar.value.trim() !== '' + ) + + // Convert env vars array to object + const environmentVariables = filteredEnvVars.reduce( + (acc, { key, value }) => ({ ...acc, [key]: value }), + {} + ) + + // Find the selected repository to get its URL + const repo = repositories?.find(r => r.full_name === selectedRepo) + + // Store the configuration in session storage to be used in the next step + sessionStorage.setItem( + 'deploymentConfig', + JSON.stringify({ + repositoryUrl: selectedRepo, + repositoryHtmlUrl: repo?.html_url || `https://github.com/${selectedRepo}`, + branch: selectedBranch, + projectName, + environmentVariables + }) + ) + + // Navigate to the deployment page + router.push(`/projects/${providerParam}/ps/cr/dp`) + } + + if (isLoading) { + return + } + + return ( + +
+ {/* Using the existing Stepper component with the correct props */} + + + + + Project Configuration + + Configure your project settings for deployment + + + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ {envVars.map((envVar, index) => ( +
+ handleEnvVarChange(index, 'key', e.target.value)} + className="flex-1" + /> + handleEnvVarChange(index, 'value', e.target.value)} + className="flex-1" + /> +
+ ))} +
+

+ Environment variables will be securely stored and available during build and runtime. +

+
+
+ + + + +
+
+
+ ) +} \ No newline at end of file diff --git a/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/(create)/cr/(deploy)/dp/page.tsx b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/(create)/cr/(deploy)/dp/page.tsx new file mode 100644 index 0000000..bd619a9 --- /dev/null +++ b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/(create)/cr/(deploy)/dp/page.tsx @@ -0,0 +1,270 @@ +'use client' +import { useState, useEffect } from 'react' +import { useParams, useRouter } from 'next/navigation' +import { PageWrapper } from '@/components/foundation' +import { LoadingOverlay } from '@/components/foundation/loading/loading-overlay' +import { Stepper } from '@/components/core/stepper/Stepper' +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@workspace/ui/components/card' +import { Button } from '@workspace/ui/components/button' +import { toast } from 'sonner' +import { Progress } from '@workspace/ui/components/progress' +import { Loader2, CheckCircle, AlertCircle, GitBranch } from 'lucide-react' +import { StopWatch } from '@/components/core/stop-watch' + +interface DeploymentConfig { + repositoryUrl: string; + repositoryHtmlUrl: string; + branch: string; + projectName: string; + environmentVariables: Record; +} + +export default function DeployPage() { + const router = useRouter() + const params = useParams() + const providerParam = params?.provider ? String(params.provider) : 'github' + + const [deploymentConfig, setDeploymentConfig] = useState(null) + const [isLoading, setIsLoading] = useState(true) + const [isDeploying, setIsDeploying] = useState(false) + const [deploymentStatus, setDeploymentStatus] = useState<'idle' | 'pending' | 'building' | 'ready' | 'error'>('idle') + const [deploymentProgress, setDeploymentProgress] = useState(0) + const [, setElapsedTime] = useState(0) + const [deploymentId, setDeploymentId] = useState('') + + // Define stepper values for the existing Stepper component + const stepperValues = [ + { step: 1, label: 'Select Repository', route: '/projects/github/ps/cr/tm/cf' }, + { step: 2, label: 'Configure', route: '/projects/github/ps/cr/cf' }, + { step: 3, label: 'Deploy', route: '/projects/github/ps/cr/dp' }, + { step: 4, label: 'Success', route: '/projects/github/ps/cr/sc' } + ] + + // Load deployment config from session storage + useEffect(() => { + const storedConfig = sessionStorage.getItem('deploymentConfig') + + if (storedConfig) { + setDeploymentConfig(JSON.parse(storedConfig)) + } else { + toast.error('Deployment configuration not found') + router.push(`/projects/${providerParam}/ps/cr/cf`) + } + + setIsLoading(false) + }, [router, providerParam]) + + // Handle elapsed time updates from StopWatch component + const handleTimeUpdate = (time: number) => { + setElapsedTime(time) + } + + // Simulate deployment process (would connect to your backend in a real implementation) + const startDeployment = () => { + if (!deploymentConfig) { + toast.error('Deployment configuration not found') + return + } + + setIsDeploying(true) + setDeploymentStatus('pending') + setDeploymentProgress(10) + + // Simulate deployment steps with timeouts + setTimeout(() => { + setDeploymentStatus('building') + setDeploymentProgress(40) + + setTimeout(() => { + // 80% chance of success, 20% chance of failure (for demo purposes) + const success = Math.random() < 0.8 + + if (success) { + setDeploymentStatus('ready') + setDeploymentProgress(100) + + // Generate a random ID for the deployment + const id = Math.random().toString(36).substring(2, 10) + setDeploymentId(id) + + // Store deployment details in session storage + const deploymentDetails = { + id, + url: `https://${deploymentConfig.projectName.toLowerCase().replace(/[^a-z0-9]/g, '-')}.laconic.deploy`, + projectId: 'project_' + Math.random().toString(36).substring(2, 10), + projectName: deploymentConfig.projectName, + status: 'ready', + createdAt: new Date().toISOString(), + repository: { + name: deploymentConfig.repositoryUrl.split('/')[1], + url: deploymentConfig.repositoryHtmlUrl || `https://github.com/${deploymentConfig.repositoryUrl}`, + branch: deploymentConfig.branch + } + }; + + sessionStorage.setItem('deploymentResult', JSON.stringify(deploymentDetails)) + + // Navigate to success page after a short delay + setTimeout(() => { + router.push(`/projects/${providerParam}/ps/cr/sc/${id}`) + }, 2000) + } else { + setDeploymentStatus('error') + setDeploymentProgress(100) + } + + setIsDeploying(false) + }, 5000) // 5 seconds for building + }, 3000) // 3 seconds for pending + } + + const getStatusIcon = () => { + switch (deploymentStatus) { + case 'pending': + return + case 'building': + return + case 'ready': + return + case 'error': + return + default: + return null + } + } + + const getStatusText = () => { + switch (deploymentStatus) { + case 'pending': + return 'Preparing deployment...' + case 'building': + return 'Building your project...' + case 'ready': + return 'Deployment successful!' + case 'error': + return 'Deployment failed' + default: + return 'Ready to deploy' + } + } + + if (isLoading) { + return + } + + return ( + +
+ {/* Using the existing Stepper component with the correct props */} + + + + + Deployment + + Deploy your project to Laconic's decentralized hosting + + + + {deploymentConfig && ( +
+
+

Repository

+
+ +

+ {deploymentConfig.repositoryUrl} ({deploymentConfig.branch}) +

+
+
+
+

Project Name

+

+ {deploymentConfig.projectName} +

+
+ {Object.keys(deploymentConfig.environmentVariables || {}).length > 0 && ( +
+

Environment Variables

+

+ {Object.keys(deploymentConfig.environmentVariables).length} environment variables configured +

+
+ )} +
+ )} + + {deploymentStatus === 'idle' ? ( +
+

+ Ready to deploy your project? Click the button below to start the deployment process. +

+ +
+ ) : ( +
+
+
+ {getStatusIcon()} +

{getStatusText()}

+
+ {/* Using your existing StopWatch component */} + +
+ +

+ {deploymentStatus === 'pending' && 'Setting up the deployment environment...'} + {deploymentStatus === 'building' && 'Building your application...'} + {deploymentStatus === 'ready' && 'Deployment completed successfully!'} + {deploymentStatus === 'error' && 'There was an error deploying your application. Please try again.'} +

+
+ )} +
+ + + {deploymentStatus === 'ready' && ( + + )} + {deploymentStatus === 'error' && ( + + )} + +
+
+
+ ) +} \ No newline at end of file diff --git a/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/(create)/cr/(success)/sc/[id]/page.tsx b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/(create)/cr/(success)/sc/[id]/page.tsx new file mode 100644 index 0000000..11122f7 --- /dev/null +++ b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/(create)/cr/(success)/sc/[id]/page.tsx @@ -0,0 +1,258 @@ +'use client' +import { useState, useEffect } from 'react' +import { useParams, useRouter } from 'next/navigation' +import { PageWrapper } from '@/components/foundation' +import { LoadingOverlay } from '@/components/foundation/loading/loading-overlay' +import { Stepper } from '@/components/core/stepper/Stepper' +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@workspace/ui/components/card' +import { Button } from '@workspace/ui/components/button' +import { toast } from 'sonner' +import { CheckCircle, Copy, ExternalLink, Clock } from 'lucide-react' +import Link from 'next/link' +import { relativeTimeMs } from '@/utils/time' +import { getInitials } from '@/utils/getInitials' +import { Avatar, AvatarFallback } from '@workspace/ui/components/avatar' + +interface DeploymentDetails { + id: string; + url: string; + projectId: string; + projectName: string; + status: string; + createdAt: string; + repository: { + name: string; + url: string; + branch: string; + }; +} + +export default function SuccessPage({ params }: { params: { id: string } }) { + const router = useRouter() + const paramsObj = useParams() + const providerParam = paramsObj?.provider ? String(paramsObj.provider) : 'github' + + const [isLoading, setIsLoading] = useState(true) + const [deployment, setDeployment] = useState(null) + const deploymentId = params.id + + // Define stepper values for the existing Stepper component + const stepperValues = [ + { step: 1, label: 'Select Repository', route: '/projects/github/ps/cr/tm/cf' }, + { step: 2, label: 'Configure', route: '/projects/github/ps/cr/cf' }, + { step: 3, label: 'Deploy', route: '/projects/github/ps/cr/dp' }, + { step: 4, label: 'Success', route: '/projects/github/ps/cr/sc' } + ] + + // Get deployment details from session storage + useEffect(() => { + // For now, we'll get the deployment details from session storage + // In a real app, you'd fetch this from your API + const storedDeployment = sessionStorage.getItem('deploymentResult') + + if (storedDeployment) { + setDeployment(JSON.parse(storedDeployment)) + } else { + // If not found in session storage, simulate it (for demo purposes) + // In a real app, you'd fetch from the API using the ID + simulateDeploymentDetails() + } + + setIsLoading(false) + }, [deploymentId]) + + // Simulate deployment details if needed (for demo purposes) + const simulateDeploymentDetails = () => { + const mockDeployment: DeploymentDetails = { + id: deploymentId, + url: `https://project-${deploymentId}.laconic.deploy`, + projectId: 'project_' + Math.random().toString(36).substring(2, 10), + projectName: 'Demo Project', + status: 'ready', + createdAt: new Date().toISOString(), + repository: { + name: 'demo-repo', + url: 'https://github.com/yourusername/demo-repo', + branch: 'main' + } + } + + setDeployment(mockDeployment) + } + + const copyToClipboard = (text: string) => { + navigator.clipboard.writeText(text) + toast.success('Copied to clipboard') + } + + if (isLoading) { + return + } + + if (!deployment) { + return ( + +
+ + +
+

+ We couldn't find the deployment you're looking for. It may have been deleted or expired. +

+ +
+
+
+
+
+ ) + } + + // Calculate relative time for the deployment + const deploymentTime = new Date(deployment.createdAt).getTime() + const deployedBy = 'You' // In a real app, you'd get this from the deployment data + + return ( + +
+ {/* Using the existing Stepper component with the correct props */} + + + + +
+ + Deployment Successful +
+ + Your project has been successfully deployed to Laconic's decentralized hosting + +
+ +
+

Deployment URL

+
+ + {deployment.url} + + + +
+
+ +
+
+

Project Details

+
    +
  • + Project Name:{' '} + {deployment.projectName} +
  • +
  • + Repository:{' '} + {deployment.repository.name} +
  • +
  • + Branch:{' '} + {deployment.repository.branch} +
  • +
  • + Deployment ID:{' '} + {deployment.id} +
  • +
+
+
+

Deployment Information

+
    +
  • + Status:{' '} + + {deployment.status.toUpperCase()} + +
  • +
  • +
    + + Deployed at:{' '} + {relativeTimeMs(deploymentTime)} +
    +
  • +
  • +
    + Deployed by:{' '} + + + {getInitials(deployedBy)} + + {deployedBy} + +
    +
  • +
+
+
+ +
+

What's Next?

+
    +
  • • Configure custom domains for your deployment
  • +
  • • Set up automatic deployments for new commits
  • +
  • • Add collaborators to your project
  • +
  • • Monitor deployment performance and analytics
  • +
+
+
+ + + + +
+
+
+ ) \ No newline at end of file diff --git a/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/(create)/cr/(template)/tm/(configure)/cf/page.tsx b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/(create)/cr/(template)/tm/(configure)/cf/page.tsx new file mode 100644 index 0000000..85cd9ce --- /dev/null +++ b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/(create)/cr/(template)/tm/(configure)/cf/page.tsx @@ -0,0 +1,12 @@ +const Page = () => { + return ( +
+

+ Hello from + (web3-authenticated)/(dashboard)/(projects)/pr/[provider]/[orgSlug]/(projects)/ps/(create)/cr/(template)/tm/(configure)/cf +

+
+ ) +} + +export default Page diff --git a/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/(create)/cr/(template)/tm/(deploy)/dp/page.tsx b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/(create)/cr/(template)/tm/(deploy)/dp/page.tsx new file mode 100644 index 0000000..fdbc735 --- /dev/null +++ b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/(create)/cr/(template)/tm/(deploy)/dp/page.tsx @@ -0,0 +1,16 @@ +import { PageWrapper } from '@/components/foundation' + +const Page = () => { + return ( + +
+

+ Hello from + (web3-authenticated)/(dashboard)/(projects)/pr/[provider]/[orgSlug]/(projects)/ps/(create)/cr/(template)/tm/(deploy)/dp +

+
+
+ ) +} + +export default Page diff --git a/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/(deployments)/dep/page.tsx b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/(deployments)/dep/page.tsx new file mode 100644 index 0000000..5c97da1 --- /dev/null +++ b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/(deployments)/dep/page.tsx @@ -0,0 +1,185 @@ +'use client'; + +import { useParams } from 'next/navigation'; +import { PageWrapper } from '@/components/foundation'; +import { DeploymentDetailsCard } from '@/components/projects/project/deployments/DeploymentDetailsCard'; +import { FilterForm } from '@/components/projects/project/deployments/FilterForm'; +import { Tabs, TabsList, TabsTrigger } from '@workspace/ui/components/tabs'; +import { IconButton } from '@workspace/ui/components/button'; +import { Rocket } from 'lucide-react'; +import { useRouter } from 'next/navigation'; +import { useEffect, useState } from 'react'; +import { useRepoData } from '@/hooks/useRepoData'; +import type { Deployment, Domain } from '@/types'; + +export default function DeploymentsPage() { + const router = useRouter(); + const params = useParams(); + // Safely unwrap params + const id = params?.id ? String(params.id) : ''; + const provider = params?.provider ? String(params.provider) : ''; + + // Use the hook to get repo data + const { repoData } = useRepoData(id); + + // Mock deployments data - in a real app, you would fetch this from an API + const [deployments, setDeployments] = useState([]); + const [filteredDeployments, setFilteredDeployments] = useState([]); + const [prodBranchDomains, setProdBranchDomains] = useState([]); + + // Create a default deployment + const defaultDeployment: Deployment = { + id: 'default', + branch: 'main', + status: 'COMPLETED', + isCurrent: true, + createdAt: Date.now() - 24 * 60 * 60 * 1000, // 1 day ago + applicationDeploymentRecordData: { + url: repoData ? `https://${repoData.name.toLowerCase()}.example.com` : 'https://example.com' + }, + createdBy: { + name: repoData?.owner?.login || 'username' + } + }; + + const secondDeployment: Deployment = { + id: 'previous', + branch: 'feature/new-ui', + status: 'COMPLETED', + isCurrent: false, + createdAt: Date.now() - 3 * 24 * 60 * 60 * 1000, // 3 days ago + applicationDeploymentRecordData: { + url: repoData ? `https://dev.${repoData.name.toLowerCase()}.example.com` : 'https://dev.example.com' + }, + createdBy: { + name: repoData?.owner?.login || 'username' + } + }; + + // Initialize with mock data + useEffect(() => { + const mockDeployments = [defaultDeployment, secondDeployment]; + setDeployments(mockDeployments); + setFilteredDeployments(mockDeployments); + + // Mock domains + const mockDomains: Domain[] = [ + { + id: '1', + name: repoData ? `${repoData.name.toLowerCase()}.example.com` : 'example.com', + status: 'ACTIVE', + isCustom: false + } + ]; + setProdBranchDomains(mockDomains); + }, [repoData]); + + // Handle tab changes by navigating to the correct folder + const handleTabChange = (value: string) => { + const basePath = `/projects/${provider}/ps/${id}`; + + switch (value) { + case 'overview': + router.push(basePath); + break; + case 'deployment': + router.push(`${basePath}/dep`); + break; + case 'settings': + router.push(`${basePath}/set`); + break; + case 'git': + router.push(`${basePath}/int`); + break; + case 'env-vars': + router.push(`${basePath}/set/env`); + break; + } + }; + + // Reset filters handler + const handleResetFilters = () => { + setFilteredDeployments(deployments); + }; + + const project = { + id: id, + prodBranch: 'main', + name: repoData?.name || 'Project' + }; + + const currentDeployment = deployments.find(deployment => deployment.isCurrent) || defaultDeployment; + + return ( + +
+ {/* Tabs navigation */} + + + Overview + Deployment + Settings + Git + Environment Variables + + + +
+ +
+ {filteredDeployments.length > 0 ? ( + filteredDeployments.map((deployment) => ( + + )) + ) : ( +
+
+

+ No deployments found +

+

+ Please change your search query or filters. +

+
+ } + onClick={handleResetFilters} + > + RESET FILTERS + +
+ )} +
+
+
+
+ ); +} \ No newline at end of file diff --git a/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/(integrations)/int/GitPage.tsx b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/(integrations)/int/GitPage.tsx new file mode 100644 index 0000000..9c03b46 --- /dev/null +++ b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/(integrations)/int/GitPage.tsx @@ -0,0 +1,198 @@ +"use client"; + +import { useState } from "react"; +import { useRouter, useParams } from "next/navigation"; +import { LoadingOverlay } from "@/components/foundation/loading/loading-overlay"; + +interface SwitchProps { + id: string; + checked: boolean; + onChange: (checked: boolean) => void; + disabled?: boolean; +} + +function Switch({ id, checked, onChange, disabled = false }: SwitchProps) { + return ( + + ); +} + +export default function GitPage() { + const params = useParams(); + const { provider, id } = params; + + const [pullRequestComments, setPullRequestComments] = useState(true); + const [commitComments, setCommitComments] = useState(false); + const [productionBranch, setProductionBranch] = useState("main"); + const [webhookUrl, setWebhookUrl] = useState(""); + const [isSavingBranch, setIsSavingBranch] = useState(false); + const [isSavingWebhook, setIsSavingWebhook] = useState(false); + + const handleSaveBranch = async () => { + try { + setIsSavingBranch(true); + // Save production branch + console.log("Saving production branch:", productionBranch); + // Implement API call to save production branch + await new Promise(resolve => setTimeout(resolve, 1000)); // Simulate API call + + // Show success notification + } catch (error) { + console.error("Failed to save production branch:", error); + // Show error notification + } finally { + setIsSavingBranch(false); + } + }; + + const handleSaveWebhook = async () => { + try { + setIsSavingWebhook(true); + // Save webhook URL + console.log("Saving webhook URL:", webhookUrl); + // Implement API call to save webhook URL + await new Promise(resolve => setTimeout(resolve, 1000)); // Simulate API call + + // Show success notification + } catch (error) { + console.error("Failed to save webhook URL:", error); + // Show error notification + } finally { + setIsSavingWebhook(false); + } + }; + + return ( + <> + {(isSavingBranch || isSavingWebhook) && } + +
+
+

Git repository

+ +
+
+
+
+ + +
+

+ Laconic will comment on pull requests opened against this project. +

+
+
+ +
+
+
+ + +
+

+ Laconic will comment on commits deployed to production. +

+
+
+
+
+ +
+

Production branch

+ +

+ By default, each commit pushed to the main branch initiates a production deployment. You can opt for a + different branch for deployment in the settings. +

+ +
+
+ + setProductionBranch(e.target.value)} + className="w-full px-3 py-2 rounded-md bg-gray-900 border border-gray-700 text-white" + /> +
+ + +
+
+ +
+

Deploy webhooks

+ +

+ Webhooks configured to trigger when there is a change in a project's build or deployment status. +

+ +
+
+ +
+ setWebhookUrl(e.target.value)} + placeholder="https://" + className="flex-1 px-3 py-2 rounded-l-md bg-gray-900 border border-gray-700 text-white" + /> + +
+
+
+
+
+ + ); +} \ No newline at end of file diff --git a/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/(integrations)/int/page.tsx b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/(integrations)/int/page.tsx new file mode 100644 index 0000000..4c493bc --- /dev/null +++ b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/(integrations)/int/page.tsx @@ -0,0 +1,82 @@ +'use client'; + +import { useParams } from 'next/navigation'; +import { PageWrapper } from "@/components/foundation"; +import { Tabs, TabsList, TabsTrigger } from '@workspace/ui/components/tabs'; +import GitPage from "./GitPage"; +import { useRouter } from 'next/navigation'; +import { useRepoData } from '@/hooks/useRepoData'; + +export default function GitIntegrationsPage() { + const router = useRouter(); + const params = useParams(); + const id = params.id as string; + const provider = params.provider as string; + // Use the hook to get repo data + const { repoData } = useRepoData(id); + + // Handle tab changes by navigating to the correct folder + const handleTabChange = (value: string) => { + const basePath = `/projects/${provider}/ps/${id}`; + + switch (value) { + case 'overview': + router.push(basePath); + break; + case 'deployment': + router.push(`${basePath}/dep`); + break; + case 'settings': + router.push(`${basePath}/set`); + break; + case 'git': + router.push(`${basePath}/int`); + break; + case 'env-vars': + router.push(`${basePath}/set/env`); + break; + } + }; + + return ( + +
+ {/* Tabs navigation */} + + + Overview + Deployment + Settings + Git + Environment Variables + + + + {/* Git content */} +
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/(settings)/set/(collaborators)/col/page.tsx b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/(settings)/set/(collaborators)/col/page.tsx new file mode 100644 index 0000000..cb6805a --- /dev/null +++ b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/(settings)/set/(collaborators)/col/page.tsx @@ -0,0 +1,66 @@ +'use client' +import { PageWrapper } from "@/components/foundation" +import { Tabs, TabsList, TabsTrigger } from '@workspace/ui/components/tabs' +import { useRouter } from 'next/navigation' + +interface PageProps { + params: { + id: string + provider: string + orgSlug: string + } +} + +const Page = ({ params }: PageProps) => { + const router = useRouter(); + // Mock data for the project + + // Handle tab changes by navigating to the correct folder + const handleTabChange = (value: string) => { + const basePath = `/projects/${params.provider}/ps/${params.id}`; + + switch (value) { + case 'overview': + router.push(basePath); + break; + case 'deployment': + router.push(`${basePath}/dep`); + break; + case 'settings': + router.push(`${basePath}/set`); + break; + case 'git': + router.push(`${basePath}/int`); + break; + case 'env-vars': + router.push(`${basePath}/set/env`); + break; + } + }; + + return ( + +
{/* Take full width in bento grid */} + {/* Tabs navigation */} + + + Overview + Deployment + Settings + Git + Environment Variables + + +
+
+ ) +} + +export default Page diff --git a/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/(settings)/set/(domains)/dom/(add)/cf/page.tsx b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/(settings)/set/(domains)/dom/(add)/cf/page.tsx new file mode 100644 index 0000000..62369b0 --- /dev/null +++ b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/(settings)/set/(domains)/dom/(add)/cf/page.tsx @@ -0,0 +1,12 @@ +const Page = () => { + return ( +
+

+ Hello from + (web3-authenticated)/(dashboard)/(projects)/pr/[provider]/[orgSlug]/(projects)/ps/[id]/(settings)/set/(domains)/dom/(add)/cf +

+
+ ) +} + +export default Page diff --git a/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/(settings)/set/(domains)/dom/(add)/config/cf/page.tsx b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/(settings)/set/(domains)/dom/(add)/config/cf/page.tsx new file mode 100644 index 0000000..6246ab7 --- /dev/null +++ b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/(settings)/set/(domains)/dom/(add)/config/cf/page.tsx @@ -0,0 +1,12 @@ +const Page = () => { + return ( +
+

+ Hello from + (web3-authenticated)/(dashboard)/(projects)/pr/[provider]/[orgSlug]/(projects)/ps/[id]/(settings)/set/(domains)/dom/(add)/config/cf +

+
+ ) +} + +export default Page diff --git a/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/(settings)/set/(environment-variables)/env/EnvVarsPage.tsx b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/(settings)/set/(environment-variables)/env/EnvVarsPage.tsx new file mode 100644 index 0000000..67718a3 --- /dev/null +++ b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/(settings)/set/(environment-variables)/env/EnvVarsPage.tsx @@ -0,0 +1,415 @@ +"use client"; + +import { useState } from "react"; +import { useRouter, useParams } from "next/navigation"; +import { LoadingOverlay } from "@/components/foundation/loading/loading-overlay"; +import { PlusIcon, ChevronDownIcon, ChevronUpIcon, PencilIcon, TrashIcon } from "lucide-react"; + +interface EnvVarItem { + key: string; + value: string; + isEditing?: boolean; +} + +interface EnvGroupProps { + title: string; + isOpen: boolean; + onToggle: () => void; + children: React.ReactNode; + varCount: number; +} + +function EnvGroup({ title, isOpen, onToggle, children, varCount }: EnvGroupProps) { + return ( +
+
+
+

{title}

+ ({varCount}) +
+ +
+ {isOpen && ( +
+ {children} +
+ )} +
+ ); +} + +export default function EnvVarsPage() { + const params = useParams(); + const { provider, id } = params; + + const [isAddingVar, setIsAddingVar] = useState(false); + const [newVarKey, setNewVarKey] = useState(""); + const [newVarValue, setNewVarValue] = useState(""); + const [isSaving, setIsSaving] = useState(false); + + // Group states + const [productionOpen, setProductionOpen] = useState(true); + const [previewOpen, setPreviewOpen] = useState(true); + const [deploymentOpen, setDeploymentOpen] = useState(true); + + // Environment variables + const [productionVars, setProductionVars] = useState([]); + const [previewVars, setPreviewVars] = useState([ + { key: "TEST_KEY", value: "1" } + ]); + const [deploymentVars, setDeploymentVars] = useState([]); + + // Checkboxes for environment selection + const [envSelection, setEnvSelection] = useState({ + production: true, + preview: true, + development: true + }); + + const handleEnvSelectionChange = (env: 'production' | 'preview' | 'development') => { + setEnvSelection({ + ...envSelection, + [env]: !envSelection[env] + }); + }; + + const addVariable = () => { + if (!newVarKey.trim() || !newVarValue.trim()) return; + + const newVar = { key: newVarKey, value: newVarValue }; + + if (envSelection.production) { + setProductionVars([...productionVars, { ...newVar }]); + } + + if (envSelection.preview) { + setPreviewVars([...previewVars, { ...newVar }]); + } + + if (envSelection.development) { + setDeploymentVars([...deploymentVars, { ...newVar }]); + } + + // Reset form + setNewVarKey(""); + setNewVarValue(""); + setIsAddingVar(false); + }; + + const cancelAddVariable = () => { + setNewVarKey(""); + setNewVarValue(""); + setIsAddingVar(false); + }; + + const removeVariable = (env: 'production' | 'preview' | 'development', index: number) => { + if (env === 'production') { + setProductionVars(productionVars.filter((_, i) => i !== index)); + } else if (env === 'preview') { + setPreviewVars(previewVars.filter((_, i) => i !== index)); + } else if (env === 'development') { + setDeploymentVars(deploymentVars.filter((_, i) => i !== index)); + } + }; + + const editVariable = (env: 'production' | 'preview' | 'development', index: number) => { + if (env === 'production') { + const updatedVars = [...productionVars]; + updatedVars[index] = { ...updatedVars[index], isEditing: true }; + setProductionVars(updatedVars); + } else if (env === 'preview') { + const updatedVars = [...previewVars]; + updatedVars[index] = { ...updatedVars[index], isEditing: true }; + setPreviewVars(updatedVars); + } else if (env === 'development') { + const updatedVars = [...deploymentVars]; + updatedVars[index] = { ...updatedVars[index], isEditing: true }; + setDeploymentVars(updatedVars); + } + }; + + const updateVariable = (env: 'production' | 'preview' | 'development', index: number, key: string, value: string) => { + if (env === 'production') { + const updatedVars = [...productionVars]; + updatedVars[index] = { key, value, isEditing: false }; + setProductionVars(updatedVars); + } else if (env === 'preview') { + const updatedVars = [...previewVars]; + updatedVars[index] = { key, value, isEditing: false }; + setPreviewVars(updatedVars); + } else if (env === 'development') { + const updatedVars = [...deploymentVars]; + updatedVars[index] = { key, value, isEditing: false }; + setDeploymentVars(updatedVars); + } + }; + + const saveChanges = async () => { + try { + setIsSaving(true); + // Save environment variables + console.log("Saving environment variables:", { + production: productionVars, + preview: previewVars, + deployment: deploymentVars + }); + // Implement API call to save environment variables + await new Promise(resolve => setTimeout(resolve, 1000)); // Simulate API call + + // Show success notification + } catch (error) { + console.error("Failed to save environment variables:", error); + // Show error notification + } finally { + setIsSaving(false); + } + }; + + const renderEnvVarRow = (env: 'production' | 'preview' | 'development', variable: EnvVarItem, index: number) => { + if (variable.isEditing) { + return ( +
+ { + const updatedVars = env === 'production' + ? [...productionVars] + : env === 'preview' + ? [...previewVars] + : [...deploymentVars]; + updatedVars[index] = { ...updatedVars[index], key: e.target.value }; + if (env === 'production') setProductionVars(updatedVars); + else if (env === 'preview') setPreviewVars(updatedVars); + else setDeploymentVars(updatedVars); + }} + placeholder="KEY" + /> + { + const updatedVars = env === 'production' + ? [...productionVars] + : env === 'preview' + ? [...previewVars] + : [...deploymentVars]; + updatedVars[index] = { ...updatedVars[index], value: e.target.value }; + if (env === 'production') setProductionVars(updatedVars); + else if (env === 'preview') setPreviewVars(updatedVars); + else setDeploymentVars(updatedVars); + }} + placeholder="Value" + /> + + +
+ ); + } + + return ( +
+
+ {variable.key} + {variable.value} +
+
+ + +
+
+ ); + }; + + return ( + <> + {isSaving && } + +
+
+

Environment Variables

+

+ A new deployment is required for your changes to take effect. +

+ + {!isAddingVar ? ( + + ) : ( +
+
+
+ + setNewVarKey(e.target.value)} + className="w-full px-3 py-2 rounded-md bg-gray-900 border border-gray-700 text-white" + placeholder="KEY" + /> +
+
+ + setNewVarValue(e.target.value)} + className="w-full px-3 py-2 rounded-md bg-gray-900 border border-gray-700 text-white" + placeholder="Value" + /> +
+
+ +
+ +
+
+ handleEnvSelectionChange('production')} + className="mr-2" + /> + +
+
+ handleEnvSelectionChange('preview')} + className="mr-2" + /> + +
+
+ handleEnvSelectionChange('development')} + className="mr-2" + /> + +
+
+
+ +
+ + +
+
+ )} + +
+ setProductionOpen(!productionOpen)} + varCount={productionVars.length} + > + {productionVars.length > 0 ? ( +
+ {productionVars.map((variable, index) => renderEnvVarRow('production', variable, index))} +
+ ) : ( +

No variables defined

+ )} +
+ + setPreviewOpen(!previewOpen)} + varCount={previewVars.length} + > + {previewVars.length > 0 ? ( +
+ {previewVars.map((variable, index) => renderEnvVarRow('preview', variable, index))} +
+ ) : ( +

No variables defined

+ )} +
+ + setDeploymentOpen(!deploymentOpen)} + varCount={deploymentVars.length} + > + {deploymentVars.length > 0 ? ( +
+ {deploymentVars.map((variable, index) => renderEnvVarRow('development', variable, index))} +
+ ) : ( +

No variables defined

+ )} +
+
+ +
+ +
+
+
+ + ); +} \ No newline at end of file diff --git a/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/(settings)/set/(environment-variables)/env/page.tsx b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/(settings)/set/(environment-variables)/env/page.tsx new file mode 100644 index 0000000..b8a2b8c --- /dev/null +++ b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/(settings)/set/(environment-variables)/env/page.tsx @@ -0,0 +1,83 @@ +'use client'; + +import { useParams } from 'next/navigation'; +import { PageWrapper } from "@/components/foundation"; +import { Tabs, TabsList, TabsTrigger } from '@workspace/ui/components/tabs'; +import EnvVarsPage from "./EnvVarsPage"; +import { useRouter } from 'next/navigation'; +import { useRepoData } from '@/hooks/useRepoData'; + +export default function EnvironmentVariablesPage() { + const router = useRouter(); + const params = useParams(); + const id = params.id as string; + const provider = params.provider as string; + + // Use the hook to get repo data + const { repoData } = useRepoData(id); + + // Handle tab changes by navigating to the correct folder + const handleTabChange = (value: string) => { + const basePath = `/projects/${provider}/ps/${id}`; + + switch (value) { + case 'overview': + router.push(basePath); + break; + case 'deployment': + router.push(`${basePath}/dep`); + break; + case 'settings': + router.push(`${basePath}/set`); + break; + case 'git': + router.push(`${basePath}/int`); + break; + case 'env-vars': + router.push(`${basePath}/set/env`); + break; + } + }; + + return ( + +
+ {/* Tabs navigation */} + + + Overview + Deployment + Settings + Git + Environment Variables + + + + {/* Environment Variables content */} +
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/(settings)/set/(git)/page.tsx b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/(settings)/set/(git)/page.tsx new file mode 100644 index 0000000..1ca5673 --- /dev/null +++ b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/(settings)/set/(git)/page.tsx @@ -0,0 +1,82 @@ +'use client'; + +import { useParams } from 'next/navigation'; +import { PageWrapper } from "@/components/foundation"; +import { Tabs, TabsList, TabsTrigger } from '@workspace/ui/components/tabs'; +import GitPage from "../../../(integrations)/int/GitPage"; +import { useRouter } from 'next/navigation'; +import { useRepoData } from '@/hooks/useRepoData'; + +export default function GitIntegrationsPage() { + const router = useRouter(); + const params = useParams(); + const id = params.id as string; + const provider = params.provider as string; + // Use the hook to get repo data + const { repoData } = useRepoData(id); + + // Handle tab changes by navigating to the correct folder + const handleTabChange = (value: string) => { + const basePath = `/projects/${provider}/ps/${id}`; + + switch (value) { + case 'overview': + router.push(basePath); + break; + case 'deployment': + router.push(`${basePath}/dep`); + break; + case 'settings': + router.push(`${basePath}/set`); + break; + case 'git': + router.push(`${basePath}/int`); + break; + case 'env-vars': + router.push(`${basePath}/set/env`); + break; + } + }; + + return ( + +
+ {/* Tabs navigation */} + + + Overview + Deployment + Settings + Git + Environment Variables + + + + {/* Git content */} +
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/(settings)/set/ProjectSettingsPage.tsx b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/(settings)/set/ProjectSettingsPage.tsx new file mode 100644 index 0000000..74f8b56 --- /dev/null +++ b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/(settings)/set/ProjectSettingsPage.tsx @@ -0,0 +1,274 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useRouter, useParams } from "next/navigation"; +import { Clipboard } from "lucide-react"; +import { Dropdown } from "@/components/core/dropdown"; +import { LoadingOverlay } from "@/components/foundation/loading/loading-overlay"; +import { useRepoData } from "@/hooks/useRepoData"; + +// Create a simple modal component +interface ModalProps { + isOpen: boolean; + onClose: () => void; + title: string; + children: React.ReactNode; + footer?: React.ReactNode; +} + +function Modal({ isOpen, onClose, title, children, footer }: ModalProps) { + if (!isOpen) return null; + + return ( +
+
+
+

{title}

+ +
+
{children}
+ {footer &&
{footer}
} +
+
+ ); +} + +export default function ProjectSettingsPage() { + const router = useRouter(); + const params = useParams(); + const id = params?.id ? String(params.id) : ''; + + // Use the hook to get repo data + const { repoData, isLoading } = useRepoData(id); + + const [projectName, setProjectName] = useState(""); + const [projectDescription, setProjectDescription] = useState(""); + const [projectId, setProjectId] = useState(""); + const [selectedAccount, setSelectedAccount] = useState(""); + const [isSaving, setIsSaving] = useState(false); + const [isTransferring, setIsTransferring] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + + // Update form values when project data is loaded + useEffect(() => { + if (repoData) { + setProjectName(repoData.name || ""); + setProjectDescription(repoData.description || ""); + setProjectId(repoData.id?.toString() || ""); + } + }, [repoData]); + + const accountOptions = [ + { label: "Personal Account", value: "account1" }, + { label: "Team Account", value: "account2" } + ]; + + const handleSave = async () => { + try { + setIsSaving(true); + console.log("Saving project info:", { projectName, projectDescription }); + + // Simulate API call + await new Promise(resolve => setTimeout(resolve, 1000)); + + // Show success notification - in a real app you'd use a toast library + console.log("Project updated successfully"); + } catch (error) { + console.error("Failed to save project info:", error); + // Show error notification + } finally { + setIsSaving(false); + } + }; + + const handleTransfer = async () => { + try { + setIsTransferring(true); + // Transfer project to selected account + console.log("Transferring project to:", selectedAccount); + // Implement API call to transfer project + await new Promise(resolve => setTimeout(resolve, 1000)); // Simulate API call + + // After successful transfer, navigate back to projects list + router.push("/dashboard/projects"); + } catch (error) { + console.error("Failed to transfer project:", error); + // Show error notification + } finally { + setIsTransferring(false); + } + }; + + const handleDelete = async () => { + try { + setIsDeleting(true); + // Delete project + console.log("Deleting project"); + // Implement API call to delete project + await new Promise(resolve => setTimeout(resolve, 1000)); // Simulate API call + + // After successful deletion, navigate back to projects list + router.push("/dashboard/projects"); + } catch (error) { + console.error("Failed to delete project:", error); + // Show error notification + } finally { + setIsDeleting(false); + setIsDeleteModalOpen(false); + } + }; + + const copyToClipboard = (text: string) => { + navigator.clipboard.writeText(text); + }; + + const DeleteModalFooter = ( +
+ + +
+ ); + + if (isLoading) { + return ; + } + + return ( + <> + {(isSaving || isTransferring || isDeleting) && } + +
+
+

Project Info

+ +
+
+ + setProjectName(e.target.value)} + className="w-full px-3 py-2 rounded-md bg-gray-900 border border-gray-700 text-white" + /> +
+ +
+ + setProjectDescription(e.target.value)} + className="w-full px-3 py-2 rounded-md bg-gray-900 border border-gray-700 text-white" + /> +
+ +
+ +
+ + +
+
+ + +
+
+ +
+

Transfer Project

+ +
+ + setSelectedAccount(value)} + className="w-full" + /> + +

+ Transfer this app to your personal account or a team you are a member of. +

+ + +
+
+ +
+

Delete Project

+ +

+ The project will be permanently deleted, including its deployments and domains. This action is + irreversible and cannot be undone. +

+ + + + !isDeleting && setIsDeleteModalOpen(false)} + title="Are you absolutely sure?" + footer={DeleteModalFooter} + > +

+ This action cannot be undone. This will permanently delete the project + and all associated deployments and domains. +

+
+
+
+ + ); +} \ No newline at end of file diff --git a/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/(settings)/set/page.tsx b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/(settings)/set/page.tsx new file mode 100644 index 0000000..71b7a78 --- /dev/null +++ b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/(settings)/set/page.tsx @@ -0,0 +1,84 @@ +'use client'; + +import { useParams } from 'next/navigation'; +import { PageWrapper } from "@/components/foundation"; +import { Tabs, TabsList, TabsTrigger } from '@workspace/ui/components/tabs'; +import ProjectSettingsPage from "./ProjectSettingsPage"; +import { useRouter } from 'next/navigation'; +import { useRepoData } from '@/hooks/useRepoData'; + +export default function SettingsPage() { + const router = useRouter(); + const params = useParams(); + // Safely unwrap params + const id = params?.id ? String(params.id) : ''; + const provider = params?.provider ? String(params.provider) : ''; + + // Use the hook to get repo data + const { repoData } = useRepoData(id); + + // Handle tab changes by navigating to the correct folder + const handleTabChange = (value: string) => { + const basePath = `/projects/${provider}/ps/${id}`; + + switch (value) { + case 'overview': + router.push(basePath); + break; + case 'deployment': + router.push(`${basePath}/dep`); + break; + case 'settings': + router.push(`${basePath}/set`); + break; + case 'git': + router.push(`${basePath}/int`); + break; + case 'env-vars': + router.push(`${basePath}/set/env`); + break; + } + }; + + return ( + +
+ {/* Tabs navigation */} + + + Overview + Deployment + Settings + Git + Environment Variables + + + + {/* Settings content */} +
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/deployments/page.tsx b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/deployments/page.tsx new file mode 100644 index 0000000..1c65931 --- /dev/null +++ b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/deployments/page.tsx @@ -0,0 +1,159 @@ +'use client' +import { useEffect, useState } from 'react' +import { PageWrapper } from '@/components/foundation' +import { FixedProjectCard } from '@/components/projects/project/ProjectCard/FixedProjectCard' +import { Button } from '@workspace/ui/components/button' +import { Shapes } from 'lucide-react' +import { useAuth } from '@clerk/nextjs' + +interface Deployment { + id: string + name: string + repositoryId: string + status: 'running' | 'complete' | 'failed' + url?: string + branch: string + createdAt: string + createdBy: { + name: string + } +} + +export default function ProjectsPage() { + const [deployments, setDeployments] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + + const { isLoaded: isAuthLoaded, userId } = useAuth() + + useEffect(() => { + async function fetchDeployments() { + if (!isAuthLoaded) { + return; + } + + setIsLoading(true); + + try { + if (!userId) { + setError('Not authenticated'); + return; + } + + // In a real implementation, you would query your GraphQL backend + // For now, we'll mock some deployments + const mockDeployments: Deployment[] = [ + { + id: 'dep_abc123', + name: 'My Project', + repositoryId: '123456', + status: 'complete', + url: 'https://my-project.example.com', + branch: 'main', + createdAt: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(), + createdBy: { + name: 'John Doe' + } + }, + { + id: 'dep_def456', + name: 'Another Project', + repositoryId: '789012', + status: 'running', + branch: 'develop', + createdAt: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(), + createdBy: { + name: 'Jane Smith' + } + } + ]; + + setDeployments(mockDeployments); + } catch (err) { + console.error('Error fetching deployments:', err) + setError('Failed to fetch deployments') + } finally { + setIsLoading(false) + } + } + + fetchDeployments() + }, [isAuthLoaded, userId]); + + return ( + + {isLoading ? ( +
+
+
+ ) : error ? ( +
+
+
+ +
+
+

Error: {error}

+

+ There was an error loading your deployments. +

+ +
+ ) : deployments.length === 0 ? ( +
+
+
+ +
+
+

Deploy your first app

+

+ You haven't deployed any projects yet. Start by importing a repository from your GitHub account. +

+ +
+ ) : ( +
+
+ {deployments.map((deployment) => ( + + ))} +
+
+ )} +
+ ) +} \ No newline at end of file diff --git a/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/layout.tsx b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/layout.tsx new file mode 100644 index 0000000..cc27352 --- /dev/null +++ b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/layout.tsx @@ -0,0 +1,14 @@ +import type { ReactNode } from 'react' + +interface LayoutProps { + children: ReactNode + params: { + id: string + provider: string + orgSlug: string + } +} + +export default function ProjectLayout({ children }: LayoutProps) { + return
{children}
+} diff --git a/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/loading.tsx b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/loading.tsx new file mode 100644 index 0000000..a7dae9c --- /dev/null +++ b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/loading.tsx @@ -0,0 +1,14 @@ +import { PageWrapper } from '@/components/foundation' + +export default function Loading() { + return ( + +
+
+
+
+
+
+ + ) +} diff --git a/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/page.tsx b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/page.tsx new file mode 100644 index 0000000..20049ad --- /dev/null +++ b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/[provider]/ps/[id]/page.tsx @@ -0,0 +1,267 @@ +'use client'; + +import { useParams } from 'next/navigation'; +import { PageWrapper } from '@/components/foundation'; +import { getInitials } from '@/utils/getInitials'; +import { relativeTimeMs } from '@/utils/time'; +import { + Avatar, + AvatarFallback} from '@workspace/ui/components/avatar'; +import { Button } from '@workspace/ui/components/button'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@workspace/ui/components/tabs'; +import { Activity, Clock, GitBranch, ExternalLink } from 'lucide-react'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { useRepoData } from '@/hooks/useRepoData'; +import { useEffect, useState } from 'react'; + +export default function ProjectOverviewPage() { + const router = useRouter(); + const params = useParams(); + // Safely unwrap params + const id = params?.id ? String(params.id) : ''; + const provider = params?.provider ? String(params.provider) : ''; + + // Use the hook to get repo data + const { repoData } = useRepoData(id); + + // Default deployment details + const [deploymentUrl, setDeploymentUrl] = useState(''); + const [deploymentDate, setDeploymentDate] = useState(Date.now() - 60 * 60 * 1000); // 1 hour ago + const [deployedBy, setDeployedBy] = useState(''); + const [projectName, setProjectName] = useState(''); + const [branch, setBranch] = useState('main'); + + // Update details when repo data is loaded + useEffect(() => { + if (repoData) { + setProjectName(repoData.name); + setBranch(repoData.default_branch || 'main'); + setDeployedBy(repoData.owner?.login || 'username'); + // Create a deployment URL based on the repo name + setDeploymentUrl(`https://${repoData.name.toLowerCase()}.example.com`); + } + }, [repoData]); + + // Auction data + const auctionId = 'laconic1sdfjwei4jfkasifgjiai45ioasjf5jjjafij355'; + + // Activities data + const activities = [ + { + username: deployedBy || 'username', + branch: branch, + action: 'deploy: source cargo', + time: '5 minutes ago' + }, + { + username: deployedBy || 'username', + branch: branch, + action: 'bump', + time: '5 minutes ago' + }, + { + username: deployedBy || 'username', + branch: branch, + action: 'version: update version', + time: '5 minutes ago' + }, + { + username: deployedBy || 'username', + branch: branch, + action: 'build: updates', + time: '5 minutes ago' + } + ]; + + // Handle tab changes by navigating to the correct folder + const handleTabChange = (value: string) => { + const basePath = `/projects/${provider}/ps/${id}`; + + switch (value) { + case 'overview': + router.push(basePath); + break; + case 'deployment': + router.push(`${basePath}/dep`); + break; + case 'settings': + router.push(`${basePath}/set`); + break; + case 'git': + router.push(`${basePath}/int`); + break; + case 'env-vars': + router.push(`${basePath}/set/env`); + break; + } + }; + + return ( + +
{/* Take full width in bento grid */} + {/* Tabs navigation */} + + + Overview + Deployment + Settings + Git + Environment Variables + + + + {/* Main content card (containing project info and auction details) */} +
+ {/* Project info section */} +
+
+ + {getInitials(projectName || '')} + +
+

{projectName}

+

+ {deploymentUrl.replace(/^https?:\/\//, '')} +

+
+
+ +
+
+
+ + Source +
+
+ + {branch} +
+
+ +
+
+ + Deployment URL +
+ + {deploymentUrl} + +
+
+ +
+
+ + Deployment date +
+
+ + {relativeTimeMs(deploymentDate)} + + by + + {getInitials(deployedBy)} + + {deployedBy} +
+
+ + {/* Divider between project info and auction details */} +
+ + {/* Auction Details section */} +
+

Auction Details

+ +
+
+

Auction ID

+

{auctionId}

+
+
+

Auction Status

+
+ COMPLETED +
+
+
+ +
+
+

Deployer LRNs

+

{auctionId}

+
+
+

Deployer Funds Status

+
+ RELEASED +
+
+
+ +
+ +
+
+
+
+ + {/* Activity section - not in a card */} +
+

+ + Activity +

+ +
+ {activities.map((activity, index) => ( +
+
+
+ {activity.username} + + {activity.branch} + {activity.action} +
+
{activity.time}
+
+ ))} +
+
+
+ + {/* These content sections won't be shown - we'll navigate to respective pages instead */} + + + + +
+
+
+ ); +} \ No newline at end of file diff --git a/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/error.tsx b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/error.tsx new file mode 100644 index 0000000..79eb833 --- /dev/null +++ b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/error.tsx @@ -0,0 +1,30 @@ +'use client' + +import { Button } from '@workspace/ui/components/button' +import { useEffect } from 'react' + +export default function ClientError({ + error, + reset +}: { + error: Error & { digest?: string } + reset: () => void +}) { + useEffect(() => { + // Log the error to an error reporting service + console.error(error) + }, [error]) + + return ( +
+

Something went wrong!

+

{error.message}

+ +
+ ) +} diff --git a/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/loading.tsx b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/loading.tsx new file mode 100644 index 0000000..cf2e39c --- /dev/null +++ b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/loading.tsx @@ -0,0 +1,11 @@ +export default function Loading() { + return ( +
+
+
+ +
+
+
+ ) +} diff --git a/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/page.tsx b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/page.tsx new file mode 100644 index 0000000..efbd9e4 --- /dev/null +++ b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/projects/page.tsx @@ -0,0 +1,161 @@ +'use client' +import { PageWrapper } from '@/components/foundation' +import CheckBalanceIframe from '@/components/iframe/check-balance-iframe/CheckBalanceIframe' +import type { Project } from '@octokit/webhooks-types' +import { FixedProjectCard } from '@/components/projects/project/ProjectCard/FixedProjectCard' +import { Button } from '@workspace/ui/components/button' +import { useEffect, useState } from 'react' +import { Shapes } from 'lucide-react' +import { useAuth, useUser } from '@clerk/nextjs' +import { useRepoData } from '@/hooks/useRepoData' + +interface ProjectData { + id: string + name: string + icon?: string + deployments: any[] + // Additional fields from GitHub repo + full_name?: string + html_url?: string + updated_at?: string + default_branch?: string +} + +export default function ProjectsPage() { + const [, setIsBalanceSufficient] = useState() + const [projects, setProjects] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + + const { isLoaded: isAuthLoaded, userId } = useAuth() + const { isLoaded: isUserLoaded, user } = useUser() + + // Use the hook to fetch all repos (with an empty ID to get all) + const { repoData: allRepos, isLoading: reposLoading, error: reposError } = useRepoData(''); + + const handleConnectGitHub = () => { + window.open('https://accounts.clerk.dev/user', '_blank'); + } + + useEffect(() => { + // Process repos data when it's loaded + if (!reposLoading && allRepos) { + // Transform GitHub repos to match ProjectData interface + const projectData: ProjectData[] = allRepos.map((repo: any) => ({ + id: repo.id.toString(), + name: repo.name, + full_name: repo.full_name, + // Create a deployment object that matches your existing structure + deployments: [ + { + applicationDeploymentRecordData: { + url: repo.html_url + }, + branch: repo.default_branch, + createdAt: repo.updated_at, + createdBy: { + name: repo.owner?.login || 'Unknown' + } + } + ] + })); + + setProjects(projectData); + setIsLoading(false); + } else if (!reposLoading && reposError) { + setError(reposError); + setIsLoading(false); + } + }, [allRepos, reposLoading, reposError]); + + return ( + + {isLoading ? ( + // Full width loading spinner in bento layout +
+
+
+ ) : error ? ( + // Full width error state in bento layout +
+
+
+ +
+
+

Error: {error}

+

+ Please connect your GitHub account to see your repositories. +

+ +
+ ) : projects.length === 0 ? ( + // Full width empty state in bento layout +
+
+
+ +
+
+

Deploy your first app

+

+ Once connected, you can import a repository from your account or start with one of our templates. +

+ +
+ ) : ( + // Custom grid that spans the entire bento layout +
+
+ {projects.map((project) => ( + + ))} +
+
+ )} + + {/* Wrap in try/catch to prevent breaking if there are issues */} + {(() => { + try { + return ( + + ); + } catch (error) { + console.error('Failed to render CheckBalanceIframe:', error); + return null; + } + })()} +
+ ) +} \ No newline at end of file diff --git a/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/purchase/BuyServices.tsx b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/purchase/BuyServices.tsx new file mode 100644 index 0000000..7a2d8dc --- /dev/null +++ b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/purchase/BuyServices.tsx @@ -0,0 +1,142 @@ +'use client' + +import { PageWrapper } from '@/components/foundation/page-wrapper' +import { Button } from '@workspace/ui/components/button' +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle +} from '@workspace/ui/components/card' +/** + * BuyPrepaidService component allows users to buy prepaid services. + * It checks if the user's balance is sufficient and redirects them to the home page if it is. + * + * @returns {JSX.Element} A JSX element that renders the buy prepaid service page. + */ +const BuyPrepaidService = () => { + return ( + +
+
+
+

+ Laconic +

+

+ Webapp Deployment Plans +

+

+ Choose the perfect deployment plan for your needs. Scale your + applications with confidence. +

+
+ +
+ {/* Basic Plan */} + + + Basic + + A simple deployment option for small projects + +
+ $5.00 + /month +
+
+ +

+ 1 monthly webapp deployment +

+
+ + + +
+ + {/* Standard Plan */} + +
+ Most popular +
+ + Standard + + Perfect for growing projects and businesses + +
+ $50.00 + /month +
+
+ +

+ 10 monthly webapp deployments +

+
+ + + +
+ + {/* Premium Plan */} + + + Premium + + For enterprises with high-volume needs + +
+ $500.00 + /month +
+
+ +

+ 100 monthly webapp deployments +

+
+ + + +
+
+
+
+
+ ) +} + +export default BuyPrepaidService diff --git a/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/purchase/page.tsx b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/purchase/page.tsx new file mode 100644 index 0000000..98bdb78 --- /dev/null +++ b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/purchase/page.tsx @@ -0,0 +1,13 @@ +import type { Metadata } from 'next' +import BuyPrepaidService from './BuyServices' + +export const metadata: Metadata = { + title: 'Buy Prepaid Service', + description: 'Buy prepaid service page description' +} + +const Page = () => { + return +} + +export default Page diff --git a/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/store/page.tsx b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/store/page.tsx new file mode 100644 index 0000000..deefc07 --- /dev/null +++ b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/store/page.tsx @@ -0,0 +1,19 @@ +import { PageWrapper } from '@/components/foundation' +import type { Metadata } from 'next' + +export const metadata: Metadata = { + title: 'Store Page', + description: 'Store page description' +} + +const Page = () => { + return ( + +
+

Hello from store

+
+
+ ) +} + +export default Page diff --git a/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/support/SupportPlaceholder.tsx b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/support/SupportPlaceholder.tsx new file mode 100644 index 0000000..e29e6f7 --- /dev/null +++ b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/support/SupportPlaceholder.tsx @@ -0,0 +1,255 @@ +'use client' + +import type React from 'react' + +import { + Bell, + ChevronRight, + CreditCard, + Globe, + HelpCircle, + Lock, + Shield, + User +} from 'lucide-react' +import Link from 'next/link' +import { useState } from 'react' + +import { ComingSoonOverlay } from '@/components/foundation' +import { Button } from '@workspace/ui/components/button' +import { Separator } from '@workspace/ui/components/separator' +import { useRouter } from 'next/navigation' +/** + * Settings category item component + * Renders a single settings category with an icon and label + */ +interface SettingsCategoryProps { + /** The icon to display for this category */ + icon: React.ReactNode + /** The label text for this category */ + label: string + /** Whether this category is currently active */ + isActive?: boolean +} + +function SettingsCategory({ + icon, + label, + isActive = false +}: SettingsCategoryProps) { + return ( + + {icon} + {label} + {isActive && } + + ) +} + +/** + * Settings page component + * Displays a simple settings interface with categories and placeholder content + */ +export default function SettingsPage() { + const router = useRouter() + const [activeCategory] = useState('profile') + const [formState, setFormState] = useState({ + darkTheme: false, + autoDetectTimezone: true, + allowAnalytics: true + }) + + /** + * Handle checkbox changes + */ + const handleCheckboxChange = (e: React.ChangeEvent) => { + const { id, checked } = e.target + setFormState((prev) => ({ + ...prev, + [id]: checked + })) + } + + return ( +
+ router.back()} + /> + + {/* Header */} +
+
+
+ + + Laconic Deploy + +

Settings

+
+
+
+ +
+
+ {/* Sidebar */} + + + {/* Main content */} +
+
+

+ Profile Settings +

+

+ Manage your account information and preferences. +

+
+ + + +
+
+

Personal Information

+

+ Update your personal details and how we contact you. +

+
+ +
+
+ + +
+ +
+ + +
+
+ +
+ +
+
+ + + +
+
+

Preferences

+

+ Customize your experience with Laconic Deploy. +

+
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ +
+
+
+
+
+ +
+
+

+ © {new Date().getFullYear()} Laconic Deploy. All rights reserved. +

+
+
+
+ ) +} diff --git a/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/support/page.tsx b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/support/page.tsx new file mode 100644 index 0000000..bfb0692 --- /dev/null +++ b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/support/page.tsx @@ -0,0 +1,10 @@ +import { PageWrapper } from '@/components/foundation' +import SupportPlaceholder from './SupportPlaceholder' + +export default function SupportPage() { + return ( + + + + ) +} diff --git a/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/wallet/page.tsx b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/wallet/page.tsx new file mode 100644 index 0000000..773e97f --- /dev/null +++ b/apps/deploy-fe/src/app/(web3-authenticated)/(dashboard)/wallet/page.tsx @@ -0,0 +1,19 @@ +import { PageWrapper } from '@/components/foundation' +import type { Metadata } from 'next' + +export const metadata: Metadata = { + title: 'Wallet Page', + description: 'Wallet page description' +} + +const Page = () => { + return ( + +
+

Hello from wallet

+
+
+ ) +} + +export default Page diff --git a/apps/deploy-fe/src/app/(web3-authenticated)/layout.tsx b/apps/deploy-fe/src/app/(web3-authenticated)/layout.tsx new file mode 100644 index 0000000..8b38c2c --- /dev/null +++ b/apps/deploy-fe/src/app/(web3-authenticated)/layout.tsx @@ -0,0 +1,11 @@ +import type React from 'react' + +interface LayoutProps { + children: React.ReactNode +} + +const Layout: React.FC = async ({ children }) => { + return
{children}
+} + +export default Layout diff --git a/apps/deploy-fe/src/app/actions/github.ts b/apps/deploy-fe/src/app/actions/github.ts new file mode 100644 index 0000000..ba43da0 --- /dev/null +++ b/apps/deploy-fe/src/app/actions/github.ts @@ -0,0 +1,36 @@ +// app/actions/github.ts +'use server' + +import { auth, currentUser } from '@clerk/nextjs/server' +import { Octokit } from '@octokit/rest' +import type { Organization } from '@octokit/webhooks-types' + +export async function getGitHubOrgs() { + const { userId } = await auth() + + if (!userId) { + throw new Error('Unauthorized') + } + + const user = await currentUser() + const githubAccount = user?.externalAccounts.find( + (account) => account.provider === 'github' + ) + + const token = + githubAccount?.provider === 'github' ? githubAccount.externalId : null + + if (!token) { + throw new Error('GitHub not connected') + } + + const octokit = new Octokit({ auth: token }) + const { data } = await octokit.rest.orgs.listForAuthenticatedUser() + + return data.map((org: Organization) => ({ + id: org.id, + name: org.login, + login: org.login, + avatarUrl: org.avatar_url + })) +} diff --git a/apps/deploy-fe/src/app/api/auth/route.ts b/apps/deploy-fe/src/app/api/auth/route.ts new file mode 100644 index 0000000..dc56365 --- /dev/null +++ b/apps/deploy-fe/src/app/api/auth/route.ts @@ -0,0 +1,52 @@ +// nextjs route handler, checks import { NextResponse } from 'next/server' +import { auth } from '@clerk/nextjs/server' +import { clerkClient } from '@clerk/nextjs/server' +import { NextResponse } from 'next/server' + +export async function GET() { + await auth.protect() + + const clerk = await clerkClient() + const authenticated = await auth() + + // if (!userId) { + // return new Response('Unauthorized', { status: 401 }) + // } + + // if (!userId) { + // return new Response('Unauthorized', { status: 401 }) + // } + + const userId = authenticated.userId + + if (!userId) { + return new Response(JSON.stringify({ error: 'Error: No signed in user' }), { + status: 401 + }) + } + + console.log('Frome the api') + // try { + // let res + // if (userId) { + const res = await clerk.users.getUserOauthAccessToken(userId, 'github') + // } + // } catch (error) { + // console.error(error) + // } + // const github = await clerk.getUserOAuthAccessToken('oauth_github') + + // const { userId } = await auth() + // if (!userId) { + // return new Response('Unauthorized', { status: 401 }) + // } + + // const token = await getToken({ template: 'oauth_github' }) + + // Fetch data from Supabase and return it. + // const data = { token: token } + // console.log(data) + // + const data = res.data[0]?.token + return NextResponse.json({ res: data }) +} diff --git a/apps/deploy-fe/src/app/api/github/webhook/route.ts b/apps/deploy-fe/src/app/api/github/webhook/route.ts new file mode 100644 index 0000000..94620fb --- /dev/null +++ b/apps/deploy-fe/src/app/api/github/webhook/route.ts @@ -0,0 +1,66 @@ +import { createHmac, timingSafeEqual } from 'node:crypto' +import type { WebhookEventName } from '@octokit/webhooks-types' + +// Add GET handler for GitHub webhook verification + +export async function GET() { + return new Response('Ready to receive webhooks', { status: 200 }) +} + +export async function POST(request: Request) { + try { + const payload = await request.text() + const signature256 = request.headers.get('x-hub-signature-256') + const signature1 = request.headers.get('x-hub-signature') + const eventType = request.headers.get('x-github-event') + const event = eventType as WebhookEventName + + console.log('Received webhook:', { + event, + signatures: { signature256, signature1 } + }) + + // Always use SHA256 if available + if (!signature256) { + return new Response('SHA256 signature required', { status: 401 }) + } + + const secret = process.env.GITHUB_WEBHOOK_SECRET + if (!secret) { + return new Response('Webhook secret not configured', { status: 500 }) + } + + // Calculate expected SHA256 hash + const expectedHash = createHmac('sha256', secret) + .update(payload) + .digest('hex') + + const providedHash = signature256.replace('sha256=', '') + + console.log('Verifying signature:', { + provided: providedHash, + expected: expectedHash, + match: providedHash === expectedHash + }) + + const signatureMatches = timingSafeEqual( + Buffer.from(providedHash), + Buffer.from(expectedHash) + ) + + if (!signatureMatches) { + return new Response('Invalid signature', { status: 401 }) + } + + const data = JSON.parse(payload) + console.log('Processing webhook:', { event, data }) + + return new Response('OK', { status: 200 }) + } catch (error: unknown) { + console.error('Webhook error:', error) + return new Response( + error instanceof Error ? error.message : 'Unknown error', + { status: 500 } + ) + } +} diff --git a/apps/deploy-fe/src/app/favicon.ico b/apps/deploy-fe/src/app/favicon.ico new file mode 100644 index 0000000..ebae81e Binary files /dev/null and b/apps/deploy-fe/src/app/favicon.ico differ diff --git a/apps/deploy-fe/src/app/layout.tsx b/apps/deploy-fe/src/app/layout.tsx new file mode 100644 index 0000000..9f557fd --- /dev/null +++ b/apps/deploy-fe/src/app/layout.tsx @@ -0,0 +1,38 @@ +import { Providers } from '@/components/providers' +import { ClerkProvider } from '@clerk/nextjs' +import '@workspace/ui/globals.css' +import type { Metadata } from 'next' +import { Inter } from 'next/font/google' +import { CheckBalanceWrapper } from '@/components/iframe/check-balance-iframe/CheckBalanceWrapper' + +// Add root metadata with template pattern +export const metadata: Metadata = { + title: { + template: '%s | Laconic Deploy', + default: 'Laconic Deploy - Cloud Deployment Platform' + }, + description: 'Deploy your applications with Laconic', + metadataBase: new URL('https://deploy-laconic.qwrk.app'), + icons: { + icon: '/favicon.ico' + } +} + +const inter = Inter({ subsets: ['latin'] }) + +export default function RootLayout({ + children +}: Readonly<{ children: React.ReactNode }>) { + return ( + + + +
+ {children} + +
+ + +
+ ) +} diff --git a/apps/deploy-fe/src/app/loading.tsx b/apps/deploy-fe/src/app/loading.tsx new file mode 100644 index 0000000..6b9794c --- /dev/null +++ b/apps/deploy-fe/src/app/loading.tsx @@ -0,0 +1,5 @@ +import { LoadingOverlay } from '@/components/loading/loading-overlay' + +export default function Loading() { + return +} diff --git a/apps/deploy-fe/src/app/page.tsx b/apps/deploy-fe/src/app/page.tsx new file mode 100644 index 0000000..8ebba71 --- /dev/null +++ b/apps/deploy-fe/src/app/page.tsx @@ -0,0 +1,87 @@ +'use client' +import { useEffect, useState } from 'react' +import { PageWrapper } from '@/components/foundation' +import { Button } from '@workspace/ui/components/button' +import { Shapes } from 'lucide-react' +import Link from 'next/link' +import { useAuth, useUser } from '@clerk/nextjs' +import { LoadingOverlay } from '@/components/foundation/loading/loading-overlay' + +export default function HomePage() { + const [isLoading, setIsLoading] = useState(true) + const { isLoaded: isAuthLoaded, userId } = useAuth() + const { isLoaded: isUserLoaded } = useUser() + + useEffect(() => { + // Check authentication status + if (isAuthLoaded && isUserLoaded) { + setIsLoading(false) + } + }, [isAuthLoaded, isUserLoaded]) + + const handleConnectWallet = () => { + // Handle wallet connection + console.log('Connect wallet clicked') + } + + if (isLoading) { + return + } + + return ( + +
+ {userId ? ( + // User is authenticated +
+
+ +
+

Welcome to Laconic Deploy

+

+ Deploy your applications quickly and securely on the decentralized web. +

+ +
+ + +
+
+ ) : ( + // User is not authenticated +
+
+ +
+

Decentralized Web Deployment

+

+ Deploy your applications securely and efficiently with Laconic's web3 deployment platform. + Connect your wallet to get started. +

+ + + +

+ Already have an account? Sign in +

+
+ )} +
+ + {/* Removed the CheckBalanceIframe component that was causing errors */} +
+ ) +} \ No newline at end of file diff --git a/apps/deploy-fe/src/app/sign-in/[[...sign-in]]/page.tsx b/apps/deploy-fe/src/app/sign-in/[[...sign-in]]/page.tsx new file mode 100644 index 0000000..e04d5a6 --- /dev/null +++ b/apps/deploy-fe/src/app/sign-in/[[...sign-in]]/page.tsx @@ -0,0 +1,30 @@ +'use client' +import { PageWrapper } from '@/components/foundation' +import { SignIn } from '@clerk/nextjs' +import { dark } from '@clerk/themes' + +const Page = () => { + return ( + +
+ +
+
+ ) +} + +export default Page diff --git a/apps/deploy-fe/src/components/assets/laconic-mark.tsx b/apps/deploy-fe/src/components/assets/laconic-mark.tsx new file mode 100644 index 0000000..faa858e --- /dev/null +++ b/apps/deploy-fe/src/components/assets/laconic-mark.tsx @@ -0,0 +1,43 @@ +/** + * The Laconic logo mark component. + * + * @component + * @example + * // Basic usage + * + * + * // With custom color + * + * + * // With custom size + * + */ +export function LaconicMark({ + /** + * Optional className for styling the SVG + * Use this to customize the size, color, and other SVG properties + */ + className = '' +}: { + className?: string +}) { + return ( + + Laconic logo + + + ) +} diff --git a/apps/deploy-fe/src/components/core/dropdown/Dropdown.tsx b/apps/deploy-fe/src/components/core/dropdown/Dropdown.tsx new file mode 100644 index 0000000..eb10dfb --- /dev/null +++ b/apps/deploy-fe/src/components/core/dropdown/Dropdown.tsx @@ -0,0 +1,54 @@ +import type React from 'react' +import type { DropdownProps } from './types' + +/** + * A simple dropdown component using the native select element. + * + * @component + * @param {DropdownProps} props - The props for the Dropdown component. + * @returns {React.ReactElement} A dropdown element. + * + * @example + * ```tsx + * console.log(option)} + * placeholder="Select an option" + * /> + * ``` + */ +export const Dropdown = ({ + placeholder, + options, + onChange, + value +}: DropdownProps) => { + const handleChange = (event: React.ChangeEvent) => { + const selectedOption = options.find( + (option) => option.value === event.target.value + ) + if (selectedOption) { + onChange(selectedOption) + } + } + + return ( + + ) +} diff --git a/apps/deploy-fe/src/components/core/dropdown/README.md b/apps/deploy-fe/src/components/core/dropdown/README.md new file mode 100644 index 0000000..d1f9315 --- /dev/null +++ b/apps/deploy-fe/src/components/core/dropdown/README.md @@ -0,0 +1,12 @@ +# Dropdown Component + +## Overview +This component was migrated from the original Laconic repository. + +## Usage +```tsx +import { Dropdown } from '@/components/dropdown'; + +// Example usage + +``` diff --git a/apps/deploy-fe/src/components/core/dropdown/index.ts b/apps/deploy-fe/src/components/core/dropdown/index.ts new file mode 100644 index 0000000..4d5b33b --- /dev/null +++ b/apps/deploy-fe/src/components/core/dropdown/index.ts @@ -0,0 +1,2 @@ +export * from './Dropdown' +export * from './types' diff --git a/apps/deploy-fe/src/components/core/dropdown/types.ts b/apps/deploy-fe/src/components/core/dropdown/types.ts new file mode 100644 index 0000000..586c7d7 --- /dev/null +++ b/apps/deploy-fe/src/components/core/dropdown/types.ts @@ -0,0 +1,11 @@ +export interface Option { + value: string + label: string +} + +export interface DropdownProps { + options: Option[] + onChange: (option: Option) => void + placeholder?: string + value?: Option +} diff --git a/apps/deploy-fe/src/components/core/format-milli-second/FormatMilliSecond.tsx b/apps/deploy-fe/src/components/core/format-milli-second/FormatMilliSecond.tsx new file mode 100644 index 0000000..7c70883 --- /dev/null +++ b/apps/deploy-fe/src/components/core/format-milli-second/FormatMilliSecond.tsx @@ -0,0 +1,31 @@ +import { intervalToDuration } from 'date-fns' +import React from 'react' +import type { FormatMilliSecondProps } from './types' + +/** + * A component that formats a given time in milliseconds into a human-readable format. + * + * @component + * @param {FormatMilliSecondProps} props - The props for the FormatMilliSecond component. + * @returns {React.ReactElement} A formatted time element. + * + * @example + * ```tsx + * + * ``` + */ +export const FormatMilliSecond = ({ + time, + ...props +}: FormatMilliSecondProps) => { + const duration = intervalToDuration({ start: 0, end: time }) + + return ( +
+ {duration.days !== 0 && {duration.days}d } + {duration.hours !== 0 && {duration.hours}h } + {duration.minutes !== 0 && {duration.minutes}m } + {duration.seconds}s +
+ ) +} diff --git a/apps/deploy-fe/src/components/core/format-milli-second/README.md b/apps/deploy-fe/src/components/core/format-milli-second/README.md new file mode 100644 index 0000000..42e8303 --- /dev/null +++ b/apps/deploy-fe/src/components/core/format-milli-second/README.md @@ -0,0 +1,12 @@ +# FormatMilliSecond Component + +## Overview +This component was migrated from the original Laconic repository. + +## Usage +```tsx +import { FormatMilliSecond } from '@/components/formatmillisecond'; + +// Example usage + +``` diff --git a/apps/deploy-fe/src/components/core/format-milli-second/index.ts b/apps/deploy-fe/src/components/core/format-milli-second/index.ts new file mode 100644 index 0000000..b8295b5 --- /dev/null +++ b/apps/deploy-fe/src/components/core/format-milli-second/index.ts @@ -0,0 +1,2 @@ +export * from './FormatMilliSecond' +export * from './types' diff --git a/apps/deploy-fe/src/components/core/format-milli-second/types.ts b/apps/deploy-fe/src/components/core/format-milli-second/types.ts new file mode 100644 index 0000000..f67de78 --- /dev/null +++ b/apps/deploy-fe/src/components/core/format-milli-second/types.ts @@ -0,0 +1,11 @@ +import type { ComponentPropsWithoutRef } from 'react' + +/** + * Props for the FormatMillisecond component. + * @interface FormatMilliSecondProps + * @property {number} time - The time in milliseconds to format. + */ +export interface FormatMilliSecondProps + extends ComponentPropsWithoutRef<'div'> { + time: number +} diff --git a/apps/deploy-fe/src/components/core/logo/Logo.tsx b/apps/deploy-fe/src/components/core/logo/Logo.tsx new file mode 100644 index 0000000..dffe8b0 --- /dev/null +++ b/apps/deploy-fe/src/components/core/logo/Logo.tsx @@ -0,0 +1,26 @@ +import Link from 'next/link' +import React from 'react' +import type { LogoProps } from './types' + +/** + * A component that renders the Laconic logo with a link to the organization's page. + * + * @component + * @param {LogoProps} props - The props for the Logo component. + * @returns {React.ReactElement} A logo element. + * + * @example + * ```tsx + * + * ``` + */ +export const Logo = ({ orgSlug }: LogoProps) => { + return ( + + Laconic Logo + + ) +} diff --git a/apps/deploy-fe/src/components/core/logo/README.md b/apps/deploy-fe/src/components/core/logo/README.md new file mode 100644 index 0000000..08bebbd --- /dev/null +++ b/apps/deploy-fe/src/components/core/logo/README.md @@ -0,0 +1,12 @@ +# Logo Component + +## Overview +This component was migrated from the original Laconic repository. + +## Usage +```tsx +import { Logo } from '@/components/logo'; + +// Example usage + +``` diff --git a/apps/deploy-fe/src/components/core/logo/index.ts b/apps/deploy-fe/src/components/core/logo/index.ts new file mode 100644 index 0000000..3371ed8 --- /dev/null +++ b/apps/deploy-fe/src/components/core/logo/index.ts @@ -0,0 +1,2 @@ +export * from './Logo' +export * from './types' diff --git a/apps/deploy-fe/src/components/core/logo/types.ts b/apps/deploy-fe/src/components/core/logo/types.ts new file mode 100644 index 0000000..fe6d492 --- /dev/null +++ b/apps/deploy-fe/src/components/core/logo/types.ts @@ -0,0 +1,8 @@ +/** + * Props for the Logo component. + * @interface LogoProps + * @property {string} [orgSlug] - The organization slug used for the link. + */ +export interface LogoProps { + orgSlug?: string +} diff --git a/apps/deploy-fe/src/components/core/search-bar/README.md b/apps/deploy-fe/src/components/core/search-bar/README.md new file mode 100644 index 0000000..7363e46 --- /dev/null +++ b/apps/deploy-fe/src/components/core/search-bar/README.md @@ -0,0 +1,12 @@ +# SearchBar Component + +## Overview +This component was migrated from the original Laconic repository. + +## Usage +```tsx +import { SearchBar } from '@/components/searchbar'; + +// Example usage + +``` diff --git a/apps/deploy-fe/src/components/core/search-bar/SearchBar.tsx b/apps/deploy-fe/src/components/core/search-bar/SearchBar.tsx new file mode 100644 index 0000000..bb43ed7 --- /dev/null +++ b/apps/deploy-fe/src/components/core/search-bar/SearchBar.tsx @@ -0,0 +1,51 @@ +import React, { forwardRef } from 'react' +import type { SearchBarProps } from './types' + +/** + * A search bar component with an icon input. + * + * @component + * @param {SearchBarProps} props - The props for the SearchBar component. + * @returns {React.ReactElement} A search bar element. + * + * @example + * ```tsx + * console.log(e.target.value)} /> + * ``` + */ +export const SearchBar = forwardRef( + ({ value, onChange, placeholder = 'Search', ...props }, ref) => { + return ( +
+
+ {/* Search icon SVG */} + +
+ +
+ ) + } +) diff --git a/apps/deploy-fe/src/components/core/search-bar/index.ts b/apps/deploy-fe/src/components/core/search-bar/index.ts new file mode 100644 index 0000000..2434dcf --- /dev/null +++ b/apps/deploy-fe/src/components/core/search-bar/index.ts @@ -0,0 +1,2 @@ +export * from './SearchBar' +export * from './types' diff --git a/apps/deploy-fe/src/components/core/search-bar/types.ts b/apps/deploy-fe/src/components/core/search-bar/types.ts new file mode 100644 index 0000000..fec58e6 --- /dev/null +++ b/apps/deploy-fe/src/components/core/search-bar/types.ts @@ -0,0 +1,4 @@ +export interface SearchBarProps + extends React.InputHTMLAttributes { + placeholder?: string +} diff --git a/apps/deploy-fe/src/components/core/stepper/README.md b/apps/deploy-fe/src/components/core/stepper/README.md new file mode 100644 index 0000000..b3dbb62 --- /dev/null +++ b/apps/deploy-fe/src/components/core/stepper/README.md @@ -0,0 +1,12 @@ +# Stepper Component + +## Overview +This component was migrated from the original Laconic repository. + +## Usage +```tsx +import { Stepper } from '@/components/stepper'; + +// Example usage + +``` diff --git a/apps/deploy-fe/src/components/core/stepper/Stepper.tsx b/apps/deploy-fe/src/components/core/stepper/Stepper.tsx new file mode 100644 index 0000000..d240c36 --- /dev/null +++ b/apps/deploy-fe/src/components/core/stepper/Stepper.tsx @@ -0,0 +1,48 @@ +import React from 'react' +import { StepperNav } from '../vertical-stepper/VerticalStepper' +import type { StepperProps, StepperValue } from './types' + +const COLOR_COMPLETED = '#059669' +const COLOR_ACTIVE = '#CFE6FC' +const COLOR_NOT_STARTED = '#F1F5F9' + +/** + * A stepper component that displays a series of steps with different states. + * + * @component + * @param {StepperProps} props - The props for the Stepper component. + * @returns {React.ReactElement} A stepper element. + * + * @example + * ```tsx + * + * ``` + */ +export const Stepper = ({ activeStep, stepperValues }: StepperProps) => { + return ( + { + return { + stepContent: () => ( +
+ {stepperValue.label} +
+ ), + stepStatusCircleSize: 30, + stepStateColor: + activeStep > stepperValue.step + ? COLOR_COMPLETED + : activeStep === stepperValue.step + ? COLOR_ACTIVE + : COLOR_NOT_STARTED + } + })} + /> + ) +} diff --git a/apps/deploy-fe/src/components/core/stepper/index.ts b/apps/deploy-fe/src/components/core/stepper/index.ts new file mode 100644 index 0000000..faa47e8 --- /dev/null +++ b/apps/deploy-fe/src/components/core/stepper/index.ts @@ -0,0 +1,2 @@ +export * from './Stepper' +export * from './types' diff --git a/apps/deploy-fe/src/components/core/stepper/types.ts b/apps/deploy-fe/src/components/core/stepper/types.ts new file mode 100644 index 0000000..e2b7364 --- /dev/null +++ b/apps/deploy-fe/src/components/core/stepper/types.ts @@ -0,0 +1,23 @@ +/** + * Represents a step in the stepper. + * @interface StepperValue + * @property {number} step - The step number. + * @property {string} route - The route associated with the step. + * @property {string} label - The label for the step. + */ +export interface StepperValue { + step: number + route: string + label: string +} + +/** + * Props for the Stepper component. + * @interface StepperProps + * @property {number} activeStep - The currently active step. + * @property {StepperValue[]} stepperValues - The values for each step. + */ +export interface StepperProps { + activeStep: number + stepperValues: StepperValue[] +} diff --git a/apps/deploy-fe/src/components/core/stop-watch/README.md b/apps/deploy-fe/src/components/core/stop-watch/README.md new file mode 100644 index 0000000..7ab0a90 --- /dev/null +++ b/apps/deploy-fe/src/components/core/stop-watch/README.md @@ -0,0 +1,12 @@ +# StopWatch Component + +## Overview +This component was migrated from the original Laconic repository. + +## Usage +```tsx +import { StopWatch } from '@/components/stopwatch'; + +// Example usage + +``` diff --git a/apps/deploy-fe/src/components/core/stop-watch/StopWatch.tsx b/apps/deploy-fe/src/components/core/stop-watch/StopWatch.tsx new file mode 100644 index 0000000..5bb576a --- /dev/null +++ b/apps/deploy-fe/src/components/core/stop-watch/StopWatch.tsx @@ -0,0 +1,65 @@ +import React, { useEffect, useRef, useState } from 'react' +import { FormatMilliSecond } from '../format-milli-second' +import type { StopwatchProps } from './types' + +export const setStopWatchOffset = (time: string) => { + const providedTime = new Date(time) + const currentTime = new Date() + const timeDifference = currentTime.getTime() - providedTime.getTime() + currentTime.setMilliseconds(currentTime.getMilliseconds() + timeDifference) + return currentTime +} + +/** + * A stopwatch component that tracks elapsed time. + * + * @component + * @param {StopwatchProps} props - The props for the Stopwatch component. + * @returns {React.ReactElement} A stopwatch element. + * + * @example + * ```tsx + * + * ``` + */ +export const StopWatch = ({ + offsetTimestamp, + isPaused, + ...props +}: StopwatchProps) => { + const [elapsedTime, setElapsedTime] = useState(0) + const intervalRef = useRef(null) + const startTimeRef = useRef(offsetTimestamp.getTime()) + + // Set start time when offsetTimestamp changes + useEffect(() => { + startTimeRef.current = offsetTimestamp.getTime() + }, [offsetTimestamp]) + + // Handle timer start/stop based on isPaused state + useEffect(() => { + // Clear any existing interval + if (intervalRef.current !== null) { + window.clearInterval(intervalRef.current) + intervalRef.current = null + } + + if (!isPaused) { + // Start the timer + intervalRef.current = window.setInterval(() => { + const now = Date.now() + const elapsed = now - startTimeRef.current + setElapsedTime(elapsed) + }, 1000) // Update every second + } + + // Cleanup on unmount + return () => { + if (intervalRef.current !== null) { + window.clearInterval(intervalRef.current) + } + } + }, [isPaused]) // Only re-run when isPaused changes + + return +} diff --git a/apps/deploy-fe/src/components/core/stop-watch/index.ts b/apps/deploy-fe/src/components/core/stop-watch/index.ts new file mode 100644 index 0000000..c18a27c --- /dev/null +++ b/apps/deploy-fe/src/components/core/stop-watch/index.ts @@ -0,0 +1,2 @@ +export * from './StopWatch' +export * from './types' diff --git a/apps/deploy-fe/src/components/core/stop-watch/types.ts b/apps/deploy-fe/src/components/core/stop-watch/types.ts new file mode 100644 index 0000000..8b1b2c7 --- /dev/null +++ b/apps/deploy-fe/src/components/core/stop-watch/types.ts @@ -0,0 +1,12 @@ +import type { FormatMilliSecondProps } from '../format-milli-second' + +/** + * Props for the Stopwatch component. + * @interface StopwatchProps + * @property {Date} offsetTimestamp - The initial timestamp for the stopwatch. + * @property {boolean} isPaused - Whether the stopwatch is paused. + */ +export interface StopwatchProps extends Omit { + offsetTimestamp: Date + isPaused: boolean +} diff --git a/apps/deploy-fe/src/components/core/vertical-stepper/README.md b/apps/deploy-fe/src/components/core/vertical-stepper/README.md new file mode 100644 index 0000000..ff9de29 --- /dev/null +++ b/apps/deploy-fe/src/components/core/vertical-stepper/README.md @@ -0,0 +1,12 @@ +# VerticalStepper Component + +## Overview +This component was migrated from the original Laconic repository. + +## Usage +```tsx +import { VerticalStepper } from '@/components/verticalstepper'; + +// Example usage + +``` diff --git a/apps/deploy-fe/src/components/core/vertical-stepper/VerticalStepper.tsx b/apps/deploy-fe/src/components/core/vertical-stepper/VerticalStepper.tsx new file mode 100644 index 0000000..a37fa69 --- /dev/null +++ b/apps/deploy-fe/src/components/core/vertical-stepper/VerticalStepper.tsx @@ -0,0 +1,103 @@ +import type React from 'react' +import type { ISeparator, IStep, IStepperNavProps } from './types' + +/** + * A navigation component for displaying steps in a vertical layout. + * + * @component + * @param {IStepperNavProps} props - The props for the StepperNav component. + * @returns {React.ReactElement} A stepper navigation element. + * + * @example + * ```tsx + *
Step 1
}]} /> + * ``` + */ +export const StepperNav = (props: IStepperNavProps): JSX.Element => { + return ( + + ) +} + +/** + * A separator component for the vertical stepper. + * + * @component + * @param {ISeparator} props - The props for the Separator component. + * @returns {React.ReactElement} A separator element. + */ +export const Separator = ({ height }: ISeparator): JSX.Element => { + return ( +
+ ) +} + +/** + * A step component for the vertical stepper. + * + * @component + * @param {IStep} props - The props for the Step component. + * @returns {React.ReactElement} A step element. + */ +export const Step = ({ + stepContent, + statusColor, + statusCircleSize, + onClickHandler +}: IStep): JSX.Element => { + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter' || event.key === ' ') { + onClickHandler?.() + } + } + + return ( + + ) +} diff --git a/apps/deploy-fe/src/components/core/vertical-stepper/index.ts b/apps/deploy-fe/src/components/core/vertical-stepper/index.ts new file mode 100644 index 0000000..9f0ca5c --- /dev/null +++ b/apps/deploy-fe/src/components/core/vertical-stepper/index.ts @@ -0,0 +1,2 @@ +export * from './types' +export * from './VerticalStepper' diff --git a/apps/deploy-fe/src/components/core/vertical-stepper/types.ts b/apps/deploy-fe/src/components/core/vertical-stepper/types.ts new file mode 100644 index 0000000..f8490ca --- /dev/null +++ b/apps/deploy-fe/src/components/core/vertical-stepper/types.ts @@ -0,0 +1,47 @@ +/** + * Describes a step in the stepper navigation. + * @interface IStepDescription + * @property {() => JSX.Element} stepContent - The content of the step. + * @property {string} [stepStateColor] - The color representing the step's state. + * @property {number} [stepStatusCircleSize] - The size of the status circle. + * @property {() => void} [onClickHandler] - Handler for click events on the step. + */ +export interface IStepDescription { + stepContent: () => JSX.Element + stepStateColor?: string + stepStatusCircleSize?: number + onClickHandler?: () => void +} + +/** + * Props for the StepperNav component. + * @interface IStepperNavProps + * @property {IStepDescription[]} steps - The steps to display in the navigation. + */ +export interface IStepperNavProps { + steps: IStepDescription[] +} + +/** + * Props for the Separator component. + * @interface ISeparator + * @property {string | number} [height] - The height of the separator. + */ +export interface ISeparator { + height?: string | number +} + +/** + * Props for the Step component. + * @interface IStep + * @property {() => JSX.Element} stepContent - The content of the step. + * @property {string} [statusColor] - The color of the status circle. + * @property {number} [statusCircleSize] - The size of the status circle. + * @property {() => void} [onClickHandler] - Handler for click events on the step. + */ +export interface IStep { + stepContent: () => JSX.Element + statusColor?: string + statusCircleSize?: number + onClickHandler?: () => void +} diff --git a/apps/deploy-fe/src/components/foundation/coming-soon-overlay/ComingSoonOverlay.tsx b/apps/deploy-fe/src/components/foundation/coming-soon-overlay/ComingSoonOverlay.tsx new file mode 100644 index 0000000..b8545ee --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/coming-soon-overlay/ComingSoonOverlay.tsx @@ -0,0 +1,32 @@ +import { Button } from '@workspace/ui/components/button' +import { Globe } from 'lucide-react' + +/** + * Coming Soon overlay component + * Displays a message indicating a feature is not yet available + * + * @param props.message - Optional custom message to display + * @param props.routerAction - Optional router action to perform when the button is clicked + */ +interface ComingSoonOverlayProps { + message?: string + routerAction?: () => void + buttonText?: string +} + +export function ComingSoonOverlay({ + message = 'This feature will be available in the next release.', + routerAction, + buttonText = 'Get Notified' +}: ComingSoonOverlayProps) { + return ( +
+
+ +

Coming Soon

+

{message}

+ {routerAction && } +
+
+ ) +} diff --git a/apps/deploy-fe/src/components/foundation/coming-soon-overlay/index.ts b/apps/deploy-fe/src/components/foundation/coming-soon-overlay/index.ts new file mode 100644 index 0000000..c6c0302 --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/coming-soon-overlay/index.ts @@ -0,0 +1 @@ +export * from './ComingSoonOverlay' diff --git a/apps/deploy-fe/src/components/foundation/github-session-button/GitHubSessionButton.tsx b/apps/deploy-fe/src/components/foundation/github-session-button/GitHubSessionButton.tsx new file mode 100644 index 0000000..727e50e --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/github-session-button/GitHubSessionButton.tsx @@ -0,0 +1,9 @@ +import type { FC } from 'react' +import type { GitHubSessionButtonProps } from './types' + +/** + * GitHubSessionButton component + */ +export const GitHubSessionButton: FC = (props) => { + return
{/* Component implementation will be migrated here */}
+} diff --git a/apps/deploy-fe/src/components/foundation/github-session-button/README.md b/apps/deploy-fe/src/components/foundation/github-session-button/README.md new file mode 100644 index 0000000..29fdb70 --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/github-session-button/README.md @@ -0,0 +1,12 @@ +# GitHubSessionButton Component + +## Overview +This component was migrated from the original Laconic repository. + +## Usage +```tsx +import { GitHubSessionButton } from '@/components/githubsessionbutton'; + +// Example usage + +``` diff --git a/apps/deploy-fe/src/components/foundation/github-session-button/index.ts b/apps/deploy-fe/src/components/foundation/github-session-button/index.ts new file mode 100644 index 0000000..0fb333b --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/github-session-button/index.ts @@ -0,0 +1,2 @@ +export * from './GitHubSessionButton' +export * from './types' diff --git a/apps/deploy-fe/src/components/foundation/github-session-button/types.ts b/apps/deploy-fe/src/components/foundation/github-session-button/types.ts new file mode 100644 index 0000000..ee34f16 --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/github-session-button/types.ts @@ -0,0 +1,3 @@ +import type { Button } from '@workspace/ui/components/button' + +export type GitHubSessionButtonProps = typeof Button diff --git a/apps/deploy-fe/src/components/foundation/index.ts b/apps/deploy-fe/src/components/foundation/index.ts new file mode 100644 index 0000000..086b985 --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/index.ts @@ -0,0 +1,17 @@ +// Exporting all components and types from the foundation directory + +// Page Header +export * from './page-header' + +// Page Wrapper +export * from './page-wrapper' + +// Navigation Wrapper +export * from './navigation-wrapper' + +// Top Navigation +export * from './coming-soon-overlay' +export * from './top-navigation/dark-mode-toggle' +export * from './top-navigation/main-navigation' +export * from './top-navigation/navigation-item' +export * from './top-navigation/wallet-session-badge' diff --git a/apps/deploy-fe/src/components/foundation/laconic-icon/LaconicIcon.tsx b/apps/deploy-fe/src/components/foundation/laconic-icon/LaconicIcon.tsx new file mode 100644 index 0000000..53d8b6f --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/laconic-icon/LaconicIcon.tsx @@ -0,0 +1,28 @@ +import type { FC } from 'react' +import type { LaconicIconProps } from './types' + +export const LaconicIcon: FC = ({ + className = '', + width = 40, + height = 40 +}) => { + return ( + + ) +} diff --git a/apps/deploy-fe/src/components/foundation/laconic-icon/README.md b/apps/deploy-fe/src/components/foundation/laconic-icon/README.md new file mode 100644 index 0000000..ed2056b --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/laconic-icon/README.md @@ -0,0 +1,12 @@ +# LaconicIcon Component + +## Overview +This component was migrated from the original Laconic repository. + +## Usage +```tsx +import { LaconicIcon } from '@/components/laconicicon'; + +// Example usage + +``` diff --git a/apps/deploy-fe/src/components/foundation/laconic-icon/index.ts b/apps/deploy-fe/src/components/foundation/laconic-icon/index.ts new file mode 100644 index 0000000..1014922 --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/laconic-icon/index.ts @@ -0,0 +1,2 @@ +export * from './LaconicIcon' +export * from './types' diff --git a/apps/deploy-fe/src/components/foundation/laconic-icon/types.ts b/apps/deploy-fe/src/components/foundation/laconic-icon/types.ts new file mode 100644 index 0000000..84dfc99 --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/laconic-icon/types.ts @@ -0,0 +1,19 @@ +/** + * LaconicIconProps interface defines the props for the LaconicIcon component. + */ +export interface LaconicIconProps { + /** + * Optional CSS class names to apply to the component. + */ + className?: string + /** + * The width of the icon. + * @default 40 + */ + width?: number + /** + * The height of the icon. + * @default 40 + */ + height?: number +} diff --git a/apps/deploy-fe/src/components/foundation/loading/loading-overlay/LoadingOverlay.tsx b/apps/deploy-fe/src/components/foundation/loading/loading-overlay/LoadingOverlay.tsx new file mode 100644 index 0000000..c8c2c8a --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/loading/loading-overlay/LoadingOverlay.tsx @@ -0,0 +1,83 @@ +'use client' + +import { LaconicMark } from '@/components/assets/laconic-mark' +import { cn } from '@workspace/ui/lib/utils' +import { Loader2 } from 'lucide-react' + +export interface LoadingOverlayProps { + /** + * Controls the visibility of the overlay. + * When false, the component returns null. + * @default true + */ + isLoading?: boolean + + /** + * Optional className for styling the overlay container. + * This will be merged with the default styles. + */ + className?: string + + /** + * Whether to show the Laconic logo in the overlay. + * @default true + */ + showLogo?: boolean + + /** + * Whether to show the loading spinner below the logo. + * @default true + */ + showSpinner?: boolean + + /** + * The z-index value for the overlay. + * Adjust this if you need the overlay to appear above or below other elements. + * @default 50 + */ + zIndex?: number + + /** + * Whether to use solid black background instead of semi-transparent. + * Useful for initial page load and full-screen loading states. + * @default false + */ + solid?: boolean +} + +export function LoadingOverlay({ + isLoading = true, + className, + showLogo = true, + showSpinner = true, + zIndex = 50, + solid = false +}: LoadingOverlayProps) { + if (!isLoading) return null + + return ( +
+ {showLogo && ( +
+ +
+ )} + {showSpinner && ( +
+ ) +} diff --git a/apps/deploy-fe/src/components/foundation/loading/loading-overlay/README.md b/apps/deploy-fe/src/components/foundation/loading/loading-overlay/README.md new file mode 100644 index 0000000..c052823 --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/loading/loading-overlay/README.md @@ -0,0 +1,29 @@ +# LoadingOverlay Component + +A component to display a loading overlay with optional logo and spinner. + +## Features + +- Displays a loading overlay +- Optional Laconic logo and spinner +- Customizable styling and z-index + +## Usage + +```tsx +import { LoadingOverlay } from 'path/to/components/foundation/loading/loading-overlay' + +// Basic usage + +``` + +## Props + +| Prop | Type | Description | +|------|------|-------------| +| `isLoading` | `boolean` | Controls the visibility of the overlay | +| `className` | `string` | Optional className for styling | +| `showLogo` | `boolean` | Whether to show the Laconic logo | +| `showSpinner` | `boolean` | Whether to show the loading spinner | +| `zIndex` | `number` | The z-index value for the overlay | +| `solid` | `boolean` | Whether to use a solid black background | \ No newline at end of file diff --git a/apps/deploy-fe/src/components/foundation/loading/loading-overlay/index.ts b/apps/deploy-fe/src/components/foundation/loading/loading-overlay/index.ts new file mode 100644 index 0000000..77095f9 --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/loading/loading-overlay/index.ts @@ -0,0 +1 @@ +export { LoadingOverlay } from './LoadingOverlay' diff --git a/apps/deploy-fe/src/components/foundation/navigation-wrapper/NavigationWrapper.tsx b/apps/deploy-fe/src/components/foundation/navigation-wrapper/NavigationWrapper.tsx new file mode 100644 index 0000000..5165e8e --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/navigation-wrapper/NavigationWrapper.tsx @@ -0,0 +1,225 @@ +'use client' + +import { cn } from '@workspace/ui/lib/utils' +import type { ReactNode } from 'react' + +/** + * Props for the NavigationWrapper component + * @remarks + * Configuration interface for NavigationWrapper, a layout component that provides: + * - Full-height, full-width container with flex column layout + * - Support for top navigation bar (currently commented out) + * - Flexible content area for page content + * - Customizable styling through className prop + * + * @see {@link NavigationWrapper} for the component implementation + */ +export interface NavigationWrapperProps { + /** + * Main content for the navigation wrapper + * @remarks + * - Typically contains {@link PageWrapper} components + * - Rendered in the main content area below navigation + * - Can include any valid React nodes + * - Takes up remaining vertical space + * + * @example + * ```tsx + * // Basic usage with PageWrapper + * + * + * + * + * + * + * // Multiple pages in tabs/routes + * + * {selectedTab === 'dashboard' && ( + * + * + * + * )} + * {selectedTab === 'settings' && ( + * + * + * + * )} + * + * ``` + */ + children: ReactNode + + /** + * Optional CSS classes for the wrapper + * @remarks + * - Applied to the wrapper's root container + * - Combined with default classes using the cn utility + * - Default classes: 'flex flex-col min-h-screen w-full' + * + * @example + * ```tsx + * // Custom background + * className="bg-background" + * + * // Custom max width + * className="max-w-7xl mx-auto" + * + * // Custom padding + * className="px-4 md:px-6" + * ``` + */ + className?: string +} + +/** + * A layout component that provides navigation structure and content organization. + * + * @description + * NavigationWrapper is a foundational layout component that: + * - Creates a full-height, full-width container + * - Supports top navigation (implementation commented out) + * - Provides flexible content area for page content + * - Typically wraps {@link PageWrapper} components + * + * @keywords layout, navigation, container, foundation-component + * @category Layout + * @scope Foundation + * + * @usage + * Common patterns: + * + * Basic app layout: + * ```tsx + * // In app/layout.tsx + * export default function RootLayout({ children }) { + * return ( + * + * + * + * {children} + * + * + * + * ) + * } + * ``` + * + * With custom styling: + * ```tsx + * + * + * + * + * + * ``` + * + * With route-based content: + * ```tsx + * + * + * + * + * + * } + * /> + * + * + * + * } + * /> + * + * + * ``` + * + * @example + * ```tsx + * // Basic usage + * + * + *
Page content
+ *
+ *
+ * + * // With custom styling + * + * + *
Styled content
+ *
+ *
+ * ``` + * + * @param props - Component props + * @param props.children - Main content to be rendered within the wrapper + * @param props.className - Additional CSS classes for the root container + * + * @returns A layout wrapper with navigation structure + * + * @related {@link PageWrapper} - Commonly wrapped by NavigationWrapper + * @composition Uses {@link cn} for class name merging + * + * @cssUtilities + * - flex-col: Column layout + * - min-h-screen: Minimum full viewport height + * - w-full: Full width + * + * @accessibility + * - Maintains semantic HTML structure + * - Preserves content hierarchy + * - Supports keyboard navigation (when nav is implemented) + * + * @performance + * - Minimal DOM nesting + * - Uses utility classes for styling + * - Conditional navigation rendering + */ +export default function NavigationWrapper({ + children, + className +}: NavigationWrapperProps) { + return ( +
+ {/* Top Navigation */} + {/*
+
+
+ + App Logo + +
+ +
+ +
+
+
*/} + + {/* Main Content */} + {children} +
+ ) +} diff --git a/apps/deploy-fe/src/components/foundation/navigation-wrapper/README.md b/apps/deploy-fe/src/components/foundation/navigation-wrapper/README.md new file mode 100644 index 0000000..1e5effd --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/navigation-wrapper/README.md @@ -0,0 +1,28 @@ +# NavigationWrapper Component + +A container component that wraps page content with navigation functionality. + +## Features + +- Wraps content with navigation +- Customizable styling + +## Usage + +```tsx +import { NavigationWrapper } from 'path/to/components/foundation/navigation-wrapper' + +// Basic usage + +
Content with navigation
+
+``` + +## Props + +See [types.ts](./types.ts) for detailed type definitions. + +| Prop | Type | Description | +|------|------|-------------| +| `children` | `ReactNode` | Content to be displayed within the navigation wrapper | +| `className` | `string` | Additional CSS classes | \ No newline at end of file diff --git a/apps/deploy-fe/src/components/foundation/navigation-wrapper/index.ts b/apps/deploy-fe/src/components/foundation/navigation-wrapper/index.ts new file mode 100644 index 0000000..f35672a --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/navigation-wrapper/index.ts @@ -0,0 +1,4 @@ +export { + default as NavigationWrapper, + type NavigationWrapperProps +} from './NavigationWrapper' diff --git a/apps/deploy-fe/src/components/foundation/page-header/PageHeader.tsx b/apps/deploy-fe/src/components/foundation/page-header/PageHeader.tsx new file mode 100644 index 0000000..7f8e998 --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/page-header/PageHeader.tsx @@ -0,0 +1,343 @@ +'use client' + +import { Button } from '@workspace/ui/components/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from '@workspace/ui/components/dropdown-menu' +import { cn } from '@workspace/ui/lib/utils' +import { MoreVertical } from 'lucide-react' +import Link from 'next/link' +import type { ReactNode } from 'react' + +/** + * Configuration for header action buttons/links + * @remarks + * Interactive elements with configurable styling and behavior: + * - Use onClick for JS actions OR href for navigation (not both) + * - Multiple visual styles via variant prop + * - Optional primary emphasis for main call-to-action + */ +export interface PageAction { + /** + * Display text for the action button/link + * @remarks Shown as the button/link text content + */ + label: string + + /** + * Visual style variant for the button + * @remarks + * Available styles: + * - `default`: Standard appearance + * - `destructive`: Dangerous actions + * - `outline`: Bordered, transparent bg + * - `secondary`: Less prominent + * - `ghost`: Minimal styling + * - `link`: Hyperlink style + * @default 'default' + */ + variant?: + | 'default' // Standard appearance + | 'destructive' // Dangerous actions + | 'outline' // Bordered, transparent bg + | 'secondary' // Less prominent + | 'ghost' // Minimal styling + | 'link' // Hyperlink style + + /** + * Click handler for button-based actions + * @remarks + * - Use for JavaScript-triggered actions + * - Mutually exclusive with `href` + */ + onClick?: () => void + + /** + * URL for link-based actions + * @remarks + * - Use for navigation to new URLs + * - Mutually exclusive with `onClick` + */ + href?: string + + /** + * Whether this action should have primary visual emphasis + * @remarks + * - When true, applies prominent styling + * - Useful for main call-to-action buttons + * - Affects mobile layout (primary actions shown, secondary in dropdown) + * @default false + */ + isPrimary?: boolean +} + +/** + * Props for the PageHeader component + * @remarks + * Configuration interface for PageHeader, providing: + * - Required title as main heading + * - Optional subtitle for additional context + * - Optional action buttons/links + * - Responsive layout with mobile optimization + * - Customizable styling + */ +export interface PageHeaderProps { + /** + * Main heading text + * @remarks + * - Rendered as h1 element + * - Responsive text size (2xl on mobile, 30px on desktop) + * - Bold weight with consistent line height + */ + title: string + + /** + * Additional content below the title + * @remarks + * - Can be plain text or custom component + * - Text is muted and slightly smaller + * - Components receive full width + * + * @example + * ```tsx + * // Text subtitle + * subtitle="Optional description" + * + * // Component subtitle + * subtitle={} + * ``` + */ + subtitle?: string | ReactNode + + /** + * Array of action buttons/links + * @remarks + * - Desktop: All actions shown in a row + * - Mobile: Primary actions shown, secondary in dropdown + * - Actions can be buttons (onClick) or links (href) + * - Support multiple visual styles via variant prop + * + * @see {@link PageAction} for detailed action configuration + * + * @example + * ```tsx + * actions={[ + * { + * label: "Create New", + * isPrimary: true, + * onClick: () => setOpen(true) + * }, + * { + * label: "View All", + * href: "/items", + * variant: "outline" + * } + * ]} + * ``` + */ + actions?: PageAction[] + + /** + * Optional CSS classes + * @remarks + * - Applied to the header's root container + * - Combined with default classes using cn utility + * - Default max-width of 1232px with auto margins + */ + className?: string +} + +/** + * A responsive page header component with title, subtitle, and actions. + * + * @description + * PageHeader provides a consistent header structure with: + * - Prominent title as h1 + * - Optional subtitle or custom component + * - Configurable action buttons/links + * - Responsive layout with mobile optimization + * - Customizable styling + * + * @keywords header, page-title, action-buttons, responsive-header, foundation-component + * @category Layout + * @scope Foundation + * + * @usage + * Common patterns: + * + * Basic title only: + * ```tsx + * + * ``` + * + * With subtitle and primary action: + * ```tsx + * + * ``` + * + * With search component and multiple actions: + * ```tsx + * } + * actions={[ + * { label: "Invite", isPrimary: true, onClick: handleInvite }, + * { label: "Export", variant: "outline", onClick: handleExport }, + * { label: "Settings", href: "/team/settings", variant: "ghost" } + * ]} + * /> + * ``` + * + * With navigation actions: + * ```tsx + * + * ``` + * + * @example + * ```tsx + * console.log("clicked") + * } + * ]} + * className="mb-8" + * /> + * ``` + * + * @related {@link PageWrapper} - Often used together for page layout + * @related {@link Button} - Used for rendering actions + * @composition Uses {@link DropdownMenu} for mobile action menu + * + * @cssUtilities + * - flex-col/flex-row: Responsive layout + * - gap-6/gap-2: Consistent spacing + * - text-2xl/text-[30px]: Responsive typography + * - text-foreground/text-muted-foreground: Text hierarchy + * + * @accessibility + * - Uses semantic h1 for title + * - Maintains text contrast ratios + * - Dropdown menu is keyboard navigable + * - Preserves action button/link semantics + * + * @performance + * - Conditional rendering of subtitle and actions + * - Mobile-first CSS with responsive modifiers + * - Efficient action rendering with key prop + */ +export default function PageHeader({ + title, + subtitle, + actions = [], + className +}: PageHeaderProps) { + // Separate primary actions from secondary actions + const primaryActions = actions.filter((action) => action.isPrimary) + const secondaryActions = actions.filter((action) => !action.isPrimary) + + // Render an action (either as button or link) + const renderAction = (action: PageAction, key: string) => { + const variant = action.variant || (action.isPrimary ? 'default' : 'outline') + + if (action.href) { + return ( + + ) + } + + return ( + + ) + } + + return ( +
+
+
+

+ {title} +

+ {subtitle && ( +
+ {typeof subtitle === 'string' ? ( +

+ {subtitle} +

+ ) : ( + subtitle + )} +
+ )} +
+ + {actions.length > 0 && ( + <> + {/* Desktop buttons */} +
+ {actions.map((action, index) => + renderAction(action, `desktop-${index}`) + )} +
+ + {/* Mobile buttons */} +
+ {primaryActions.map((action, index) => + renderAction(action, `mobile-primary-${index}`) + )} + + {secondaryActions.length > 0 && ( + + + + + + {secondaryActions.map((action) => + action.href ? ( + + {action.label} + + ) : ( + + {action.label} + + ) + )} + + + )} +
+ + )} +
+
+ ) +} diff --git a/apps/deploy-fe/src/components/foundation/page-header/README.md b/apps/deploy-fe/src/components/foundation/page-header/README.md new file mode 100644 index 0000000..066dbc0 --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/page-header/README.md @@ -0,0 +1,62 @@ +# PageHeader Component + +A flexible page header component that displays a title with an optional subtitle and action buttons. + +## Features + +- Title with optional subtitle (text or component) +- Support for primary and secondary actions +- Responsive design with mobile-optimized action menu +- Customizable styling + +## Usage + +```tsx +import { PageHeader } from 'path/to/components/foundation/page-header' + +// Basic usage + + +// With actions + {} }, + { label: 'Secondary Action', href: '/some-path' } + ]} +/> + +// With component as subtitle +} + actions={[...]} +/> +``` + +## Props + +See [types.ts](./types.ts) for detailed type definitions. + +| Prop | Type | Description | +|------|------|-------------| +| `title` | `string` | The main heading text | +| `subtitle` | `string \| ReactNode` | Text or component to display below the title | +| `actions` | `PageAction[]` | Array of action buttons/links | +| `className` | `string` | Additional CSS classes | + +## Action Configuration + +Each action in the `actions` array can have the following properties: + +| Property | Type | Description | +|----------|------|-------------| +| `label` | `string` | Display text for the button/link | +| `variant` | `string` | Button style variant | +| `onClick` | `() => void` | Click handler (for button actions) | +| `href` | `string` | URL (for link actions) | +| `isPrimary` | `boolean` | Whether this is a primary action | \ No newline at end of file diff --git a/apps/deploy-fe/src/components/foundation/page-header/index.ts b/apps/deploy-fe/src/components/foundation/page-header/index.ts new file mode 100644 index 0000000..69d4bfd --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/page-header/index.ts @@ -0,0 +1,5 @@ +export { + default as PageHeader, + type PageAction, + type PageHeaderProps +} from './PageHeader' diff --git a/apps/deploy-fe/src/components/foundation/page-wrapper/PageWrapper.tsx b/apps/deploy-fe/src/components/foundation/page-wrapper/PageWrapper.tsx new file mode 100644 index 0000000..87bd4f7 --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/page-wrapper/PageWrapper.tsx @@ -0,0 +1,273 @@ +import { cn } from '@workspace/ui/lib/utils' +import type { ReactNode } from 'react' +import { + type PageAction, + PageHeader, + type PageHeaderProps +} from '../page-header' + +/** + * Props for the PageWrapper component + * @remarks + * Configuration interface for PageWrapper, a layout component that provides: + * - Optional header with title, subtitle, and actions + * - Flexible content area with two layout modes + * - Responsive padding and spacing + * - Customizable styling + * + * @see {@link PageWrapper} for the component implementation + */ +export interface PageWrapperProps { + /** + * Header configuration for the page + * @remarks + * Configures the page header section with: + * - Required title: Main heading text + * - Optional subtitle: Text or custom component below title + * - Optional actions: Array of clickable/linkable buttons + * • label: Button text + * • variant: Visual style ('default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link') + * • onClick/href: Action handler (mutually exclusive) + * • isPrimary: Gives visual emphasis and affects mobile layout + * - Optional className: Custom styling for header + * + * @example + * ```tsx + * header={{ + * title: "Page Title", + * subtitle: "Optional description or component", + * actions: [ + * { + * label: "Primary Action", + * isPrimary: true, + * onClick: () => console.log("clicked") + * }, + * { + * label: "Secondary Action", + * href: "/some-path", + * variant: "outline" + * } + * ] + * }} + * ``` + * + * @see {@link PageHeaderProps} for complete header configuration + * @see {@link PageAction} for detailed action button options + */ + header?: PageHeaderProps + + /** + * Main content for the page + * @remarks + * - Rendered in the main content area below header + * - In 'default' layout: Single column with max-width + * - In 'bento' layout: 3-column grid on desktop, single column on mobile + * + * @example + * ```tsx + * // Single column content + *
+ * Content block 1 + * Content block 2 + *
+ * + * // Bento grid content + * <> + * Wide card + * Sidebar card + * + * ``` + */ + children: ReactNode + + /** + * Layout style for the page + * @remarks + * - 'default': Single-column layout with max-width (4xl) + * - 'bento': Responsive grid layout + * • Desktop: 3-column grid with 1232px max width + * • Mobile: Single column + * @defaultValue "default" + */ + layout?: 'default' | 'bento' + + /** + * Optional CSS classes for the wrapper + * @remarks + * - Applied to the wrapper's root container + * - Combined with default classes using the cn utility + * - Default classes: 'flex flex-col h-full' + * + * @example + * ```tsx + * // Custom background + * className="bg-muted" + * + * // Custom padding + * className="p-8" + * + * // Full height with scrolling content + * className="h-screen overflow-auto" + * ``` + */ + className?: string +} + +/** + * A flexible page layout component that provides consistent structure and styling. + * + * @description + * PageWrapper is a container component that provides a consistent layout structure + * for page content. It supports an optional header section and two layout modes: + * - default: Single-column layout with max-width + * - bento: Grid-based layout with multiple sections + * + * @keywords page-layout, page-container, header-layout, responsive-grid, bento-grid, foundation-component + * @category Layout + * @scope Foundation + * + * @usage + * Common patterns: + * + * Basic page with title only: + * ```tsx + * + *
Simple content
+ *
+ * ``` + * + * Dashboard section with actions: + * ```tsx + * {} }, + * { label: "Filter", variant: "outline", onClick: () => {} } + * ] + * }} + * layout="bento" + * > + * + * + * + * + * + * + * + * ``` + * + * Form page with navigation: + * ```tsx + * + *
+ * + * + *
+ * ``` + * + * Settings page with sections: + * ```tsx + * + * + *

General Settings

+ * + *
+ * + *

Quick Actions

+ * + *
+ *
+ * ``` + * + * @example + * ```tsx + * // Basic usage + * + *
Page content
+ *
+ * + * // With header and custom layout + * console.log("clicked") + * }] + * }} + * layout="bento" + * > + *
Grid-based content
+ *
+ * ``` + * + * @param props - Component props + * @param props.header - Optional header configuration for the page. See {@link PageHeaderProps} for full details + * @param props.children - Main content to be rendered within the wrapper + * @param props.layout - Layout style for content organization ('default' | 'bento') + * @param props.className - Additional CSS classes for the root container + * + * @returns A structured page layout with optional header and content areas + * + * @related {@link PageHeader} - Used internally for header rendering + * @related {@link NavigationWrapper} - Often used as parent component + * @composition Uses {@link cn} for class name merging + * + * @cssUtilities + * - flex-col: Column layout + * - h-full: Full height + * - max-w-4xl: Maximum width for default layout + * - grid-cols-1: Single column on mobile + * - md:grid-cols-3: Three columns on desktop + * + * @accessibility + * - Maintains proper heading hierarchy with h1 in header + * - Preserves content structure for screen readers + * - Supports keyboard navigation through action buttons + * + * @performance + * - Minimal DOM nesting + * - Conditional rendering of header + * - Uses utility classes for styling + */ +export default function PageWrapper({ + header, + children, + layout = 'default', + className +}: PageWrapperProps) { + return ( +
+ {header && ( +
+ +
+ )} +
+
+ {children} +
+
+
+ ) +} diff --git a/apps/deploy-fe/src/components/foundation/page-wrapper/README.md b/apps/deploy-fe/src/components/foundation/page-wrapper/README.md new file mode 100644 index 0000000..20cac18 --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/page-wrapper/README.md @@ -0,0 +1,28 @@ +# PageWrapper Component + +A container component for wrapping page content with optional styling. + +## Features + +- Wraps main content +- Customizable styling + +## Usage + +```tsx +import { PageWrapper } from 'path/to/components/foundation/page-wrapper' + +// Basic usage + +
Main content here
+
+``` + +## Props + +See [types.ts](./types.ts) for detailed type definitions. + +| Prop | Type | Description | +|------|------|-------------| +| `children` | `ReactNode` | Main content for the page | +| `className` | `string` | Additional CSS classes | \ No newline at end of file diff --git a/apps/deploy-fe/src/components/foundation/page-wrapper/index.ts b/apps/deploy-fe/src/components/foundation/page-wrapper/index.ts new file mode 100644 index 0000000..15bb838 --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/page-wrapper/index.ts @@ -0,0 +1,2 @@ +export { default as PageWrapper } from './PageWrapper' +export type { PageWrapperProps } from './PageWrapper' diff --git a/apps/deploy-fe/src/components/foundation/project-search-bar/ProjectSearchBar.tsx b/apps/deploy-fe/src/components/foundation/project-search-bar/ProjectSearchBar.tsx new file mode 100644 index 0000000..08c89bd --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/project-search-bar/ProjectSearchBar.tsx @@ -0,0 +1,185 @@ +import { Button } from '@workspace/ui/components/button' +import type React from 'react' +import { useEffect, useRef, useState } from 'react' +import type { Project, ProjectSearchBarProps } from './types' + +/** + * A search bar component that allows the user to search for projects. + * This is a simplified version without external dependencies. + * + * @param {ProjectSearchBarProps} props - The props for the component. + * @returns {React.ReactElement} A div element containing the search bar and project list. + */ +export const ProjectSearchBar: React.FC = ({ + onChange, + placeholder = 'Search projects...' +}) => { + const [searchTerm, setSearchTerm] = useState('') + const [isOpen, setIsOpen] = useState(false) + const [items, setItems] = useState([]) + const [selectedIndex, setSelectedIndex] = useState(-1) + const [debouncedSearchTerm, setDebouncedSearchTerm] = useState('') + const resultsRef = useRef(null) + + // Mock data - in real implementation this would come from API + const mockProjects: Project[] = [ + { id: '1', name: 'Project Alpha', description: 'A test project' }, + { id: '2', name: 'Project Beta', description: 'Another test project' }, + { id: '3', name: 'Project Gamma', description: 'Yet another test project' }, + { + id: '4', + name: 'Deploy Frontend', + description: 'Frontend deployment project' + }, + { id: '5', name: 'API Service', description: 'Backend API service project' } + ] + + // Handle debounced search term + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedSearchTerm(searchTerm) + }, 300) + + return () => { + clearTimeout(handler) + } + }, [searchTerm]) + + // Search projects on debounced input change + useEffect(() => { + if (debouncedSearchTerm.trim()) { + const filtered = mockProjects.filter( + (project) => + project.name + .toLowerCase() + .includes(debouncedSearchTerm.toLowerCase()) || + project.description + ?.toLowerCase() + .includes(debouncedSearchTerm.toLowerCase()) + ) + setItems(filtered) + setIsOpen(filtered.length > 0) + } else { + setItems([]) + setIsOpen(false) + } + }, [debouncedSearchTerm]) + + // Handle keyboard navigation + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!isOpen) return + + // Arrow down + if (e.key === 'ArrowDown') { + e.preventDefault() + setSelectedIndex((prev: number) => + prev < items.length - 1 ? prev + 1 : prev + ) + } + // Arrow up + else if (e.key === 'ArrowUp') { + e.preventDefault() + setSelectedIndex((prev: number) => (prev > 0 ? prev - 1 : 0)) + } + // Enter + else if (e.key === 'Enter' && selectedIndex >= 0 && items[selectedIndex]) { + e.preventDefault() + handleSelectItem(items[selectedIndex]) + } + // Escape + else if (e.key === 'Escape') { + e.preventDefault() + setIsOpen(false) + } + } + + // Handle item selection + const handleSelectItem = (project: Project) => { + if (onChange) { + onChange(project) + } + setSearchTerm(project.name) + setIsOpen(false) + setSelectedIndex(-1) + } + + return ( +
+ {/* Search input */} +
+
+ +
+ setSearchTerm(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={placeholder} + className="w-full pl-8 px-3 py-2 border rounded" + onFocus={() => searchTerm.trim() && setIsOpen(items.length > 0)} + onBlur={() => setTimeout(() => setIsOpen(false), 200)} // Delay to allow clicking on results + aria-expanded={isOpen} + aria-controls={isOpen ? 'project-search-results' : undefined} + aria-label="Search projects" + /> +
+ + {/* Dropdown results */} + {isOpen && ( +
+
+ Suggestions +
+
    + {items.map((project: Project, index: number) => ( + + ))} +
+ {items.length === 0 && ( +
+ No projects found matching "{debouncedSearchTerm}" +
+ )} +
+ )} +
+ ) +} diff --git a/apps/deploy-fe/src/components/foundation/project-search-bar/README.md b/apps/deploy-fe/src/components/foundation/project-search-bar/README.md new file mode 100644 index 0000000..dffbba1 --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/project-search-bar/README.md @@ -0,0 +1,12 @@ +# ProjectSearchBar Component + +## Overview +This component was migrated from the original Laconic repository. + +## Usage +```tsx +import { ProjectSearchBar } from '@/components/projectsearchbar'; + +// Example usage + +``` diff --git a/apps/deploy-fe/src/components/foundation/project-search-bar/index.ts b/apps/deploy-fe/src/components/foundation/project-search-bar/index.ts new file mode 100644 index 0000000..3c97651 --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/project-search-bar/index.ts @@ -0,0 +1,2 @@ +export * from './ProjectSearchBar' +export * from './types' diff --git a/apps/deploy-fe/src/components/foundation/project-search-bar/types.ts b/apps/deploy-fe/src/components/foundation/project-search-bar/types.ts new file mode 100644 index 0000000..0a01d08 --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/project-search-bar/types.ts @@ -0,0 +1,25 @@ +/** + * Simplified Project type to represent project data + */ +export interface Project { + id: string + name: string + description?: string + repoUrl?: string +} + +/** + * ProjectSearchBarProps interface defines the props for the ProjectSearchBar component. + */ +export interface ProjectSearchBarProps { + /** + * Callback function to be called when a project is selected. + * @param data - The selected project data. + */ + onChange?: (data: Project) => void + + /** + * Optional placeholder text for the search input. + */ + placeholder?: string +} diff --git a/apps/deploy-fe/src/components/foundation/top-navigation/README.md b/apps/deploy-fe/src/components/foundation/top-navigation/README.md new file mode 100644 index 0000000..d5071c5 --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/top-navigation/README.md @@ -0,0 +1,33 @@ +# TopNavigation Component + +A component for the top navigation bar, providing navigation controls and additional features like dark mode toggle and wallet session badge. + +## Features + +- Navigation controls +- Dark mode toggle +- Wallet session badge +- Customizable styling + +## Usage + +```tsx +import { TopNavigation } from 'path/to/components/foundation/top-navigation' + +// Basic usage + +``` + +## Props + +See [types.ts](./types.ts) for detailed type definitions. + +| Prop | Type | Description | +|------|------|-------------| +| `className` | `string` | Additional CSS classes | + +## Additional Components + +- **DarkModeToggle**: Toggles between light and dark themes +- **WalletSessionBadge**: Displays wallet session status +- **NavigationItem**: Represents individual navigation items \ No newline at end of file diff --git a/apps/deploy-fe/src/components/foundation/top-navigation/dark-mode-toggle/DarkModeToggle.tsx b/apps/deploy-fe/src/components/foundation/top-navigation/dark-mode-toggle/DarkModeToggle.tsx new file mode 100644 index 0000000..39e9d3e --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/top-navigation/dark-mode-toggle/DarkModeToggle.tsx @@ -0,0 +1,40 @@ +'use client' + +import { Button } from '@workspace/ui/components/button' +import { Moon, Sun } from 'lucide-react' +import { useTheme } from 'next-themes' +import { useEffect, useState } from 'react' + +export function DarkModeToggle() { + const { setTheme } = useTheme() + const [mounted, setMounted] = useState(false) + const [isDark, setIsDark] = useState(false) + + useEffect(() => { + setMounted(true) + }, []) + + useEffect(() => { + if (mounted) { + setIsDark(document.documentElement.classList.contains('dark')) + } + }, [mounted]) + + const handleThemeToggle = () => { + const newTheme = isDark ? 'light' : 'dark' + setTheme(newTheme) + setIsDark(!isDark) + } + + return ( + + ) +} diff --git a/apps/deploy-fe/src/components/foundation/top-navigation/dark-mode-toggle/README.md b/apps/deploy-fe/src/components/foundation/top-navigation/dark-mode-toggle/README.md new file mode 100644 index 0000000..575ebcc --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/top-navigation/dark-mode-toggle/README.md @@ -0,0 +1,22 @@ +# DarkModeToggle Component + +A button component to toggle between light and dark themes. + +## Features + +- Toggles between light and dark themes +- Uses `next-themes` for theme management +- Customizable styling + +## Usage + +```tsx +import { DarkModeToggle } from 'path/to/components/foundation/top-navigation/dark-mode-toggle' + +// Basic usage + +``` + +## Props + +This component does not accept any props. \ No newline at end of file diff --git a/apps/deploy-fe/src/components/foundation/top-navigation/dark-mode-toggle/index.ts b/apps/deploy-fe/src/components/foundation/top-navigation/dark-mode-toggle/index.ts new file mode 100644 index 0000000..7e5abe1 --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/top-navigation/dark-mode-toggle/index.ts @@ -0,0 +1 @@ +export { DarkModeToggle } from './DarkModeToggle' diff --git a/apps/deploy-fe/src/components/foundation/top-navigation/index.ts b/apps/deploy-fe/src/components/foundation/top-navigation/index.ts new file mode 100644 index 0000000..c614eac --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/top-navigation/index.ts @@ -0,0 +1,5 @@ +export { + default as TopNavigation, + type TopNavigationProps +} from './main-navigation/MainNavigation' +export type { NavigationItemConfig, TopNavigationConfig } from './types' diff --git a/apps/deploy-fe/src/components/foundation/top-navigation/main-navigation/MainNavigation.tsx b/apps/deploy-fe/src/components/foundation/top-navigation/main-navigation/MainNavigation.tsx new file mode 100644 index 0000000..20c6371 --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/top-navigation/main-navigation/MainNavigation.tsx @@ -0,0 +1,300 @@ +'use client' + +import { LaconicMark } from '@/components/assets/laconic-mark' +import { UserButton } from '@clerk/nextjs' +import { Button } from '@workspace/ui/components/button' +import { + Sheet, + SheetClose, + SheetContent, + SheetTitle, + SheetTrigger +} from '@workspace/ui/components/sheet' +import { CreditCard, Menu, Shapes, WalletIcon } from 'lucide-react' +import Link from 'next/link' +import type React from 'react' +import { DarkModeToggle } from '../dark-mode-toggle' +import { NavigationItem } from '../navigation-item' +import type { TopNavigationConfig } from '../types' +import { WalletSessionBadge } from '../wallet-session-badge' + +/** + * Props for the TopNavigation component + * @remarks + * Configuration interface for TopNavigation, a layout component that provides: + * - Responsive navigation bar with mobile drawer + * - Left and right navigation items + * - Dark mode toggle + * - User authentication button + * - Wallet session badge + * - Logo/home link + * + * @see {@link TopNavigation} for the component implementation + */ +export interface TopNavigationProps { + /** + * Configuration for navigation items and layout + * @remarks + * - Defines left and right navigation items + * - Each item can have label, href/onClick, icon, and active state + * - Items are rendered as buttons or links based on presence of href + * - Mobile view combines all items into a drawer menu + * + * @example + * ```tsx + * config={{ + * leftItems: [ + * { label: 'Projects', href: '/projects', active: true }, + * { label: 'Wallet', href: '/wallets', icon: WalletIcon } + * ], + * rightItems: [ + * { label: 'Support', href: '/support' }, + * { + * label: 'Documentation', + * onClick: () => window.open('https://docs.example.com') + * } + * ] + * }} + * ``` + * + * @defaultValue + * ```tsx + * { + * leftItems: [ + * { label: 'Projects', href: '/projects' }, + * { label: 'Wallet', href: '/wallets' } + * ], + * rightItems: [ + * { label: 'Support', href: '/support' }, + * { label: 'Documentation', href: '/documentation' } + * ] + * } + * ``` + */ + config?: TopNavigationConfig + + /** + * Optional child elements + * @remarks + * - Can be used to add custom elements to the navigation + * - Rendered after the default navigation items + * - Not commonly used as the config prop handles most use cases + */ + children?: React.ReactNode +} + +/** + * A responsive navigation bar component with mobile support and integrated features. + * + * @description + * TopNavigation is a foundational component that provides: + * - Responsive navigation with mobile drawer menu + * - Configurable left and right navigation items + * - Integrated dark mode toggle + * - User authentication button + * - Wallet session display + * - Logo/home link + * + * @keywords navigation, header, responsive, mobile-menu, foundation-component + * @category Navigation + * @scope Foundation + * + * @usage + * Common patterns: + * + * Basic navigation: + * ```tsx + * + * ``` + * + * With active states and icons: + * ```tsx + * + * ``` + * + * With click handlers: + * ```tsx + * setHelpOpen(true), + * icon: HelpIcon + * } + * ] + * }} + * /> + * ``` + * + * @example + * ```tsx + * // Basic usage + * + * + * // Custom navigation items + * + * ``` + * + * @param props - Component props + * @param props.config - Navigation configuration object + * @param props.children - Optional child elements + * + * @returns A responsive navigation bar with mobile support + * + * @related {@link NavigationItem} - Used for individual nav items + * @related {@link DarkModeToggle} - Integrated dark mode control + * @related {@link WalletSessionBadge} - Displays wallet info + * @composition Uses {@link Sheet} for mobile menu + * + * @cssUtilities + * - sticky: Fixed to top of viewport + * - z-50: High z-index for overlay + * - border-b: Bottom border + * - bg-background: Theme-aware background + * - text-foreground: Theme-aware text + * + * @accessibility + * - Uses semantic header and nav elements + * - Includes sr-only labels for screen readers + * - Supports keyboard navigation + * - Mobile menu follows drawer pattern + * + * @performance + * - Conditionally renders mobile/desktop views + * - Uses CSS utilities for styling + * - Lazy loads mobile drawer content + */ +export default function TopNavigation({ + config = { + leftItems: [ + { icon: Shapes, label: 'Projects', href: '/projects' }, + { icon: WalletIcon, label: 'Wallet', href: '/wallet' }, + { icon: CreditCard, label: 'Purchase', href: '/purchase' } + ], + rightItems: [ + { label: 'Support', href: '/support' }, + { label: 'Documentation', href: '/documentation' } + ] + } +}: TopNavigationProps) { + const { leftItems = [], rightItems = [] } = config + + return ( +
+
+ {/* Mobile menu trigger - positioned on the left */} +
+ + + + + + Navigation Menu +
+ + + + + Close + +
+
+ +
+
+
+
+ + + + + +
+ {leftItems.map((item) => ( + + {item.label} + + ))} +
+ +
+ + + + + +
+
+
+ ) +} diff --git a/apps/deploy-fe/src/components/foundation/top-navigation/main-navigation/README.md b/apps/deploy-fe/src/components/foundation/top-navigation/main-navigation/README.md new file mode 100644 index 0000000..a2253bf --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/top-navigation/main-navigation/README.md @@ -0,0 +1,32 @@ +# MainNavigation Component + +A component for the main navigation bar, providing navigation controls and additional features like dark mode toggle and wallet session badge. + +## Features + +- Navigation controls +- Dark mode toggle +- Wallet session badge +- Customizable styling + +## Usage + +```tsx +import { TopNavigation } from 'path/to/components/foundation/top-navigation/main-navigation' + +// Basic usage + +``` + +## Props + +| Prop | Type | Description | +|------|------|-------------| +| `config` | `TopNavigationConfig` | Configuration for navigation items | +| `children` | `ReactNode` | Additional elements to render | + +## Additional Components + +- **DarkModeToggle**: Toggles between light and dark themes +- **WalletSessionBadge**: Displays wallet session status +- **NavigationItem**: Represents individual navigation items \ No newline at end of file diff --git a/apps/deploy-fe/src/components/foundation/top-navigation/main-navigation/index.ts b/apps/deploy-fe/src/components/foundation/top-navigation/main-navigation/index.ts new file mode 100644 index 0000000..81b6cfd --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/top-navigation/main-navigation/index.ts @@ -0,0 +1 @@ +export { default as TopNavigation } from './MainNavigation' diff --git a/apps/deploy-fe/src/components/foundation/top-navigation/navigation-item/NavigationItem.tsx b/apps/deploy-fe/src/components/foundation/top-navigation/navigation-item/NavigationItem.tsx new file mode 100644 index 0000000..e464ab6 --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/top-navigation/navigation-item/NavigationItem.tsx @@ -0,0 +1,315 @@ +'use client' + +import { Button } from '@workspace/ui/components/button' +import { cn } from '@workspace/ui/lib/utils' +import type { LucideIcon } from 'lucide-react' +import Link from 'next/link' +import type React from 'react' + +/** + * Props for the NavigationItem component + * @remarks + * Configuration interface for NavigationItem, a flexible navigation element that: + * - Supports both link and button behaviors + * - Handles mobile drawer and desktop navigation styles + * - Includes icon support + * - Provides active state styling + * - Uses shadcn/ui Button component for consistent styling + * + * @see {@link NavigationItem} for the component implementation + */ +export interface NavigationItemProps { + /** + * URL for link navigation + * @remarks + * - When provided, renders as a Next.js Link + * - Takes precedence over onClick + * - Uses Next.js routing for client-side navigation + * + * @example + * ```tsx + * href="/dashboard" + * href="/settings/profile" + * ``` + */ + href?: string + + /** + * Click handler for button behavior + * @remarks + * - Used when href is not provided + * - Renders as a button element + * - Useful for actions that don't navigate + * + * @example + * ```tsx + * onClick={() => setIsOpen(true)} + * onClick={() => handleLogout()} + * ``` + */ + onClick?: () => void + + /** + * Optional CSS classes + * @remarks + * - Applied to the root element + * - Combined with default classes using cn utility + * - Different defaults for drawer vs regular items + * + * @example + * ```tsx + * className="text-primary" + * className="hidden lg:flex" + * ``` + */ + className?: string + + /** + * Active state flag + * @remarks + * - Adds semibold font weight when true + * - In drawer mode, also changes text color + * - Use for current page/section indication + * + * @example + * ```tsx + * active={pathname === '/dashboard'} + * active={section === 'settings'} + * ``` + * + * @defaultValue false + */ + active?: boolean + + /** + * Optional Lucide icon component + * @remarks + * - Rendered before children when provided + * - Only shown in desktop navigation + * - Sized and spaced automatically + * + * @example + * ```tsx + * icon={HomeIcon} + * icon={Settings} + * ``` + */ + icon?: LucideIcon + + /** + * Content of the navigation item + * @remarks + * - Typically a text label + * - Can include other elements + * - Positioned after icon if present + * + * @example + * ```tsx + * children="Dashboard" + * children={<>Home New} + * ``` + */ + children: React.ReactNode + + /** + * Button variant from shadcn/ui + * @remarks + * - Only applies to desktop navigation + * - Drawer items use custom styling + * - Uses shadcn/ui Button variants + * + * @example + * ```tsx + * variant="default" + * variant="ghost" + * ``` + * + * @defaultValue "ghost" + */ + variant?: + | 'default' + | 'destructive' + | 'outline' + | 'secondary' + | 'ghost' + | 'link' + + /** + * Flag for drawer-specific styling + * @remarks + * - When true, uses simpler drawer-specific styles + * - Affects hover and active states + * - Changes padding and spacing + * + * @defaultValue false + */ + isDrawerItem?: boolean +} + +/** + * A flexible navigation item component that adapts to mobile and desktop contexts. + * + * @description + * NavigationItem is a foundational component that: + * - Renders as either a link or button + * - Adapts styling for mobile drawer or desktop navigation + * - Supports icons and active states + * - Maintains consistent styling with shadcn/ui + * + * @keywords navigation, link, button, responsive, foundation-component + * @category Navigation + * @scope Foundation + * + * @usage + * Common patterns: + * + * Basic link: + * ```tsx + * + * Dashboard + * + * ``` + * + * With icon and active state: + * ```tsx + * + * Settings + * + * ``` + * + * As a button with click handler: + * ```tsx + * setIsOpen(true)} + * variant="ghost" + * > + * Open Menu + * + * ``` + * + * In mobile drawer: + * ```tsx + * + * Profile + * + * ``` + * + * @example + * ```tsx + * // Basic usage + * Home + * + * // With all props + * + * Settings + * + * ``` + * + * @param props - Component props + * @param props.href - URL for link navigation + * @param props.onClick - Click handler for button behavior + * @param props.className - Additional CSS classes + * @param props.active - Active state flag + * @param props.icon - Optional Lucide icon component + * @param props.children - Content of the navigation item + * @param props.variant - Button variant from shadcn/ui + * @param props.isDrawerItem - Flag for drawer-specific styling + * + * @returns A navigation item as either a link or button + * + * @related {@link TopNavigation} - Parent component + * @composition Uses {@link Button} from shadcn/ui + * + * @cssUtilities + * Desktop: + * - font-semibold: Applied when active + * - mr-2: Icon margin + * - h-4 w-4: Icon size + * + * Drawer: + * - px-6 py-1: Padding + * - text-sm: Font size + * - font-medium: Font weight + * - hover:text-white/80: Hover state + * + * @accessibility + * - Maintains button/link semantics + * - Preserves keyboard navigation + * - Supports screen readers + * - Indicates current page + * + * @performance + * - Conditional rendering based on props + * - Uses CSS utilities + * - Minimal state management + */ +export function NavigationItem({ + href, + onClick, + className, + active = false, + icon: Icon, + children, + variant = 'ghost', + isDrawerItem = false +}: NavigationItemProps) { + // For drawer items, use a simpler styling approach + if (isDrawerItem) { + const content = ( +
+ {children} +
+ ) + + if (href) { + return {content} + } + + return + } + + // For regular navigation items, use the Button component + const content = ( + <> + {Icon && } + {children} + + ) + + const buttonClassName = cn(active && 'font-semibold', className) + + if (href) { + return ( + + ) + } + + return ( + + ) +} diff --git a/apps/deploy-fe/src/components/foundation/top-navigation/navigation-item/README.md b/apps/deploy-fe/src/components/foundation/top-navigation/navigation-item/README.md new file mode 100644 index 0000000..8c1a83c --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/top-navigation/navigation-item/README.md @@ -0,0 +1,21 @@ +# NavigationItem Component + +A component representing an individual navigation item. + +## Features + +- Represents a navigation item +- Customizable styling + +## Usage + +```tsx +import { NavigationItem } from 'path/to/components/foundation/top-navigation/navigation-item' + +// Basic usage + +``` + +## Props + +This component does not accept any props. \ No newline at end of file diff --git a/apps/deploy-fe/src/components/foundation/top-navigation/navigation-item/index.ts b/apps/deploy-fe/src/components/foundation/top-navigation/navigation-item/index.ts new file mode 100644 index 0000000..2668383 --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/top-navigation/navigation-item/index.ts @@ -0,0 +1 @@ +export { NavigationItem, type NavigationItemProps } from './NavigationItem' diff --git a/apps/deploy-fe/src/components/foundation/top-navigation/types.ts b/apps/deploy-fe/src/components/foundation/top-navigation/types.ts new file mode 100644 index 0000000..f1a8e5d --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/top-navigation/types.ts @@ -0,0 +1,14 @@ +import type { LucideIcon } from 'lucide-react' + +export interface NavigationItemConfig { + label: string + href?: string + onClick?: () => void + icon?: LucideIcon + active?: boolean +} + +export interface TopNavigationConfig { + leftItems?: NavigationItemConfig[] + rightItems?: NavigationItemConfig[] +} diff --git a/apps/deploy-fe/src/components/foundation/top-navigation/wallet-session-badge/README.md b/apps/deploy-fe/src/components/foundation/top-navigation/wallet-session-badge/README.md new file mode 100644 index 0000000..44d4527 --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/top-navigation/wallet-session-badge/README.md @@ -0,0 +1,21 @@ +# WalletSessionBadge Component + +A component to display the wallet session status. + +## Features + +- Displays wallet session status +- Customizable styling + +## Usage + +```tsx +import { WalletSessionBadge } from 'path/to/components/foundation/top-navigation/wallet-session-badge' + +// Basic usage + +``` + +## Props + +This component does not accept any props. \ No newline at end of file diff --git a/apps/deploy-fe/src/components/foundation/top-navigation/wallet-session-badge/WalletSessionBadge.tsx b/apps/deploy-fe/src/components/foundation/top-navigation/wallet-session-badge/WalletSessionBadge.tsx new file mode 100644 index 0000000..c042eda --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/top-navigation/wallet-session-badge/WalletSessionBadge.tsx @@ -0,0 +1,175 @@ +// 'use client' + +// import { Button } from '@workspace/ui/components/button' +// import { +// DropdownMenu, +// DropdownMenuContent, +// DropdownMenuItem, +// DropdownMenuTrigger +// } from '@workspace/ui/components/dropdown-menu' +// import { cn } from '@workspace/ui/lib/utils' +// import { ChevronDown, LogOut } from 'lucide-react' +// import { useState } from 'react' + +// interface WalletSessionBadgeProps { +// address: string +// className?: string +// } + +// export function WalletSessionBadge({ +// address, +// className +// }: WalletSessionBadgeProps) { +// const [isConnected, setIsConnected] = useState(true) + +// return ( +// +// +// +// +// +// setIsConnected(false)} +// > +// +// Disconnect +// +// +// +// ) +// } + +'use client' + +import { useWallet } from '@/context/WalletContext' +import { Button } from '@workspace/ui/components/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from '@workspace/ui/components/dropdown-menu' +import { cn } from '@workspace/ui/lib/utils' +import { ChevronDown, LogOut } from 'lucide-react' +import useCheckBalance from '@/components/iframe/check-balance-iframe/useCheckBalance' +import { useEffect } from 'react' + +const IFRAME_ID = 'checkBalanceIframe' + +export function WalletSessionBadge({ className }: { className?: string }) { + const { wallet, isConnected, connect, disconnect } = useWallet() + const { isBalanceSufficient, checkBalance } = useCheckBalance("1", IFRAME_ID) + + // Check balance when wallet connects + useEffect(() => { + if (isConnected) { + checkBalance() + } + }, [isConnected, checkBalance]) + + // Format address for display (first 6 chars + ... + last 4 chars) + const formatAddress = (address?: string) => { + if (!address) return 'Connect Wallet' + return `${address.substring(0, 6)}...${address.substring(address.length - 4)}` + } + + // Determine the status indicator color based on connection and balance + const getStatusColor = () => { + if (!isConnected) return 'bg-red-500' + if (isBalanceSufficient === false) return 'bg-yellow-500' + if (isBalanceSufficient === true) return 'bg-green-500' + return 'bg-blue-500' // Checking balance + } + + return ( + + + + + + {isConnected ? ( + <> +
+

Connected to:

+

{wallet?.address}

+

+ Balance: {isBalanceSufficient === undefined ? 'Checking...' : + isBalanceSufficient ? 'Sufficient' : 'Insufficient'} +

+
+ + + Disconnect + + + ) : ( + + Connect Wallet + + )} +
+
+ ) +} \ No newline at end of file diff --git a/apps/deploy-fe/src/components/foundation/top-navigation/wallet-session-badge/index.ts b/apps/deploy-fe/src/components/foundation/top-navigation/wallet-session-badge/index.ts new file mode 100644 index 0000000..68ffea5 --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/top-navigation/wallet-session-badge/index.ts @@ -0,0 +1 @@ +export { WalletSessionBadge } from './WalletSessionBadge' diff --git a/apps/deploy-fe/src/components/foundation/types.ts b/apps/deploy-fe/src/components/foundation/types.ts new file mode 100644 index 0000000..9193ca4 --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/types.ts @@ -0,0 +1,77 @@ +import type { ReactNode } from 'react' +import type { PageAction, PageHeaderProps } from './page-header' + +/** + * Props for the NavigationWrapper component + * @remarks + * Container component that wraps page content with navigation functionality. + * Typically used as the outer container for PageWrapper components. + */ +export type NavigationWrapperProps = { + /** + * Content to be displayed within the navigation wrapper + * @remarks + * - Typically contains {@link PageWrapper} components + * - Can include any valid React nodes + */ + children: ReactNode + + /** + * Optional CSS class name for custom styling + * @remarks Applied to the wrapper's root container element + */ + className?: string +} + +// Re-export types from component folders +export type { PageAction, PageHeaderProps } + +/** + * Layout options for page content + * @remarks + + * - default: Single-column layout + * - bento: Grid-based layout with multiple sections + */ +export type PageWrapperLayout = 'default' | 'bento' + +/** + * Configuration for the main page container + * @property {PageHeaderProps} header - Header configuration for the page + * @property {ReactNode} children - Main content for the page + * @property {PageWrapperLayout} layout - Layout style for the page + * @property {string} className - Custom CSS classes for the wrapper + * @property {string} contentClassName - Custom CSS classes for the content area + * Main container for page content with optional header, flexible layout options, + * and customizable styling for both wrapper and content areas. + */ +export interface PageWrapperProps { + /** + * Header configuration for the page + * @see {@link PageHeaderProps} + */ + header?: PageHeaderProps + /** + * Main content for the page + * @remarks Rendered in the main content area below header + */ + children: ReactNode + /** + * Layout style for the page + * @see {@link PageWrapperLayout} + * @defaultValue "default" + */ + layout?: PageWrapperLayout + /** + * Optional CSS classes for the wrapper + * @remarks Applied to the wrapper's root container + */ + className?: string + /** + * Optional CSS classes for the content area + * @remarks + * - Applied to the main content container + * - Separate from the wrapper's root className + */ + contentClassName?: string +} diff --git a/apps/deploy-fe/src/components/foundation/wallet-session-id/README.md b/apps/deploy-fe/src/components/foundation/wallet-session-id/README.md new file mode 100644 index 0000000..dd89995 --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/wallet-session-id/README.md @@ -0,0 +1,12 @@ +# WalletSessionId Component + +## Overview +This component was migrated from the original Laconic repository. + +## Usage +```tsx +import { WalletSessionId } from '@/components/walletsessionid'; + +// Example usage + +``` diff --git a/apps/deploy-fe/src/components/foundation/wallet-session-id/WalletSessionId.tsx b/apps/deploy-fe/src/components/foundation/wallet-session-id/WalletSessionId.tsx new file mode 100644 index 0000000..62bcffe --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/wallet-session-id/WalletSessionId.tsx @@ -0,0 +1,105 @@ +// import type React from 'react' +// import type { WalletSessionIdProps } from './types' + +// /** +// * A component that displays the wallet session ID with a connection status indicator. +// * +// * @param {WalletSessionIdProps} props - The props for the component. +// * @returns {React.ReactElement} A div element containing the wallet session ID. +// */ +// export const WalletSessionId: React.FC = ({ +// walletId, +// className = '', +// isConnected = true +// }) => { +// // For demonstration, use provided wallet ID or a placeholder +// const displayId = walletId || 'x123xxx' + +// return ( +//
+//
+// {displayId} +//
+// ) +// } + +'use client' + +import { useWallet } from '@/context/WalletContext' // or WalletContextProvider +import { Button } from '@workspace/ui/components/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from '@workspace/ui/components/dropdown-menu' +import { cn } from '@workspace/ui/lib/utils' +import { ChevronDown, LogOut } from 'lucide-react' + +export function WalletSessionBadge({ className }: { className?: string }) { + const { wallet, isConnected, connect, disconnect } = useWallet() + + // Format address for display (first 6 chars + ... + last 4 chars) + const formatAddress = (address?: string) => { + if (!address) return 'Connect Wallet' + return `${address.substring(0, 6)}...${address.substring(address.length - 4)}` + } + + return ( + + + + + + {isConnected ? ( + + + Disconnect + + ) : ( + + Connect Wallet + + )} + + + ) +} \ No newline at end of file diff --git a/apps/deploy-fe/src/components/foundation/wallet-session-id/index.ts b/apps/deploy-fe/src/components/foundation/wallet-session-id/index.ts new file mode 100644 index 0000000..372858c --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/wallet-session-id/index.ts @@ -0,0 +1,2 @@ +export * from './WalletSessionId' +export * from './types' diff --git a/apps/deploy-fe/src/components/foundation/wallet-session-id/types.ts b/apps/deploy-fe/src/components/foundation/wallet-session-id/types.ts new file mode 100644 index 0000000..a07b6fa --- /dev/null +++ b/apps/deploy-fe/src/components/foundation/wallet-session-id/types.ts @@ -0,0 +1,20 @@ +/** + * WalletSessionIdProps interface defines the props for the WalletSessionId component. + */ +export interface WalletSessionIdProps { + /** + * The wallet ID to display. + */ + walletId?: string + + /** + * Optional CSS class names to apply to the component. + */ + className?: string + + /** + * Whether the wallet is connected. + * @default true + */ + isConnected?: boolean +} diff --git a/apps/deploy-fe/src/components/iframe/auto-sign-in/AutoSignInIFrameModal.tsx b/apps/deploy-fe/src/components/iframe/auto-sign-in/AutoSignInIFrameModal.tsx new file mode 100644 index 0000000..72eb14c --- /dev/null +++ b/apps/deploy-fe/src/components/iframe/auto-sign-in/AutoSignInIFrameModal.tsx @@ -0,0 +1,182 @@ +import { useCallback, useEffect, useState } from 'react' +// Commenting out these imports as they cause linter errors due to missing dependencies +// In an actual implementation, these would be properly installed +// import { generateNonce, SiweMessage } from 'siwe' +// import axios from 'axios' + +// Define proper types to replace 'any' +interface SiweMessageProps { + version: string + domain: string + uri: string + chainId: number + address: string + nonce: string + statement: string +} + +interface ValidateRequestData { + message: string + signature: string +} + +// Mock implementations to demonstrate functionality without dependencies +// In a real project, use the actual dependencies +const generateNonce = () => Math.random().toString(36).substring(2, 15) +const SiweMessage = class { + constructor(props: SiweMessageProps) { + this.props = props + } + props: SiweMessageProps + prepareMessage() { + return JSON.stringify(this.props) + } +} + +// Access environment variables from .env.local with fallbacks for safety +// In a production environment, these would be properly configured +const WALLET_IFRAME_URL = + process.env.NEXT_PUBLIC_WALLET_IFRAME_URL || 'https://wallet.example.com' + +// Mock axios implementation +const axiosInstance = { + post: async (url: string, data: ValidateRequestData) => { + console.log('Mock API call to', url, 'with data', data) + return { data: { success: true } } + } +} + +/** + * AutoSignInIFrameModal component that handles wallet authentication through an iframe. + * This component is responsible for: + * 1. Getting the wallet address + * 2. Creating a Sign-In With Ethereum message + * 3. Requesting signature from the wallet + * 4. Validating the signature with the backend + * + * @returns {JSX.Element} A modal with an iframe for wallet authentication + */ +export function AutoSignInIFrameModal() { + const [accountAddress, setAccountAddress] = useState() + + // Handle sign-in response from the wallet iframe + useEffect(() => { + const handleSignInResponse = async (event: MessageEvent) => { + if (event.origin !== WALLET_IFRAME_URL) return + + if (event.data.type === 'SIGN_IN_RESPONSE') { + try { + const response = await axiosInstance.post('/auth/validate', { + message: event.data.data.message, + signature: event.data.data.signature + }) + + if (response.data.success === true) { + // In Next.js, we would use router.push instead + window.location.href = '/' + } + } catch (error) { + console.error('Error signing in:', error) + } + } + } + + window.addEventListener('message', handleSignInResponse) + + return () => { + window.removeEventListener('message', handleSignInResponse) + } + }, []) + + // Initiate auto sign-in when account address is available + useEffect(() => { + const initiateAutoSignIn = async () => { + if (!accountAddress) return + + const iframe = document.getElementById( + 'walletAuthFrame' + ) as HTMLIFrameElement + + if (!iframe.contentWindow) { + console.error('Iframe not found or not loaded') + return + } + + const message = new SiweMessage({ + version: '1', + domain: window.location.host, + uri: window.location.origin, + chainId: 1, + address: accountAddress, + nonce: generateNonce(), + statement: 'Sign in With Ethereum.' + }).prepareMessage() + + iframe.contentWindow.postMessage( + { + type: 'AUTO_SIGN_IN', + chainId: '1', + message + }, + WALLET_IFRAME_URL + ) + } + + initiateAutoSignIn() + }, [accountAddress]) + + // Listen for wallet accounts data + useEffect(() => { + const handleAccountsDataResponse = async (event: MessageEvent) => { + if (event.origin !== WALLET_IFRAME_URL) return + + if ( + event.data.type === 'WALLET_ACCOUNTS_DATA' && + event.data.data?.length > 0 + ) { + setAccountAddress(event.data.data[0].address) + } + } + + window.addEventListener('message', handleAccountsDataResponse) + + return () => { + window.removeEventListener('message', handleAccountsDataResponse) + } + }, []) + + // Request wallet address when iframe is loaded + const getAddressFromWallet = useCallback(() => { + const iframe = document.getElementById( + 'walletAuthFrame' + ) as HTMLIFrameElement + + if (!iframe.contentWindow) { + console.error('Iframe not found or not loaded') + return + } + + iframe.contentWindow.postMessage( + { + type: 'REQUEST_CREATE_OR_GET_ACCOUNTS', + chainId: '1' + }, + WALLET_IFRAME_URL + ) + }, []) + + return ( +
+
+