Compare commits

..

1 Commits

Author SHA1 Message Date
Vivian Phung
b7e8f98859 getItemProps should supersede prev attrs 2024-05-22 18:52:45 +00:00
137 changed files with 6293 additions and 7100 deletions

View File

@ -1,29 +0,0 @@
name: Lint
on:
pull_request:
push:
branches:
- main
jobs:
lint:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20.x]
steps:
- uses: actions/checkout@v2
- name: Download yarn
run: |
curl -fsSL -o /usr/local/bin/yarn https://github.com/yarnpkg/yarn/releases/download/v1.22.21/yarn-1.22.21.js
chmod +x /usr/local/bin/yarn
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
- run: yarn
- name: Build libs
run: yarn workspace gql-client run build
- name: Linter check
run: yarn lint

5
.gitignore vendored
View File

@ -6,7 +6,4 @@ yarn-error.log
packages/backend/environments/local.toml packages/backend/environments/local.toml
packages/backend/dev/ packages/backend/dev/
packages/frontend/dist/ packages/frontend/dist/
# ignore all .DS_Store files
**/.DS_Store

223
README.md
View File

@ -1,23 +1,220 @@
# snowballtools-base # snowballtools-base
This is a [yarn workspace](https://yarnpkg.com/features/workspaces) monorepo for the dashboard. ## Setup
## Getting Started - Clone the `snowballtools-base` repo
### Install dependencies ```bash
git clone git@github.com:snowball-tools/snowballtools-base.git
cd snowballtools-base
```
In the root of the project, run: - Install dependencies in root
```zsh ```bash
yarn yarn
``` ```
### Build backend - Build packages
```zsh ```bash
yarn build --ignore frontend yarn build --ignore frontend
``` ```
### Environment variables, running the development server, and deployment Let us assume the following domains for backend and frontend
- Backend server: `api.snowballtools.com`
- Frontend app: `dashboard.snowballtools.com`
Follow the instructions in the README.md files of the [backend](packages/backend/README.md) and [frontend](packages/frontend/README.md) packages. - Configuration Files
- For Backend:
```bash
cp packages/backend/environments/local.toml.example packages/backend/environments/local.toml
```
- Production
- Update the following in backend [config file](packages/backend/environments/local.toml)
```toml
[server]
...
[server.session]
# Secret should be changed to a different random string
secret = "p4yfpkqnddkui2iw7t6hbhwq74lbqs7sidnc382"
# Set URL of the frontend app
appOriginUrl = "https://dashboard.snowballtools.com"
# Set to true for session cookies to work behind proxy
trustProxy = true
# Set empty domain when using secure connection
domain = ""
```
- Set `gitHub.oAuth.clientId` and `gitHub.oAuth.clientSecret` in backend [config file](packages/backend/environments/local.toml)
- [OAuth App Creation](https://github.com/organizations/<org>/settings/applications/new)
- Homepage URL: `https://dashboard.snowballtools.com`
- Authorization callback URL: `https://dashboard.snowballtools.com/organization/projects/create`
- Generate a new client secret after app is created
- Set `gitHub.webhookUrl` in backend [config file](packages/backend/environments/local.toml)
```toml
[gitHub]
webhookUrl = "https://api.snowballtools.com"
```
- Let us assume domain for Laconicd to be `api.laconic.com` and set the following in backend [config file](packages/backend/environments/local.toml)
```toml
[registryConfig]
fetchDeploymentRecordDelay = 5000
# Use actual port for REST endpoint
restEndpoint = "http://api.laconic.com:1317"
# Use actual port for GQL endpoint
gqlEndpoint = "http://api.laconic.com:9473/api"
# Set private key of account to be used in Laconicd
privateKey = "0wtu92cd4f1y791ezpjwgzzazni4dmd3q3mzqc3t6i6r9v06ji784tey6hwmnn69"
# Set Bond ID to be used for publishing records
bondId = "8xk8c2pb61kajwixpm223zvptr2x2ncajq0vd998p6aqhvqqep2reu6pik245epf"
chainId = "laconic_9000-1"
# Set authority that is existing in the chain
authority = "laconic"
[registryConfig.fee]
amount = "200000"
denom = "aphoton"
gas = "750000"
```
- Development
- Set `gitHub.oAuth.clientId` and `gitHub.oAuth.clientSecret` in backend [config file](packages/backend/environments/local.toml)
- [OAuth App Creation](https://github.com/organizations/<org>/settings/applications/new)
- Homepage URL: `http://localhost:3000`
- Authorization callback URL: `http://localhost:3000/organization/projects/create`
- Generate a new client secret after app is created
- Setup Laconicd
- Run the laconicd stack following this [doc](https://git.vdb.to/cerc-io/stack-orchestrator/src/branch/main/docs/laconicd-with-console.md)
- Get the private key and set `registryConfig.privateKey` in backend [config file](packages/backend/environments/local.toml)
```bash
laconic-so deployment --dir laconic-loaded-deployment exec laconicd "laconicd keys export mykey --unarmored-hex --unsafe"
# WARNING: The private key will be exported as an unarmored hexadecimal string. USE AT YOUR OWN RISK. Continue? [y/N]: y
# 754cca7b4b729a99d156913aea95366411d072856666e95ba09ef6c664357d81
```
- Set authority in `registryConfig.authority` in backend [config file](packages/backend/environments/local.toml)
- Run the script to create bond, reserve the authority and set authority bond
```bash
yarn test:registry:init
# snowball:initialize-registry bondId: 6af0ab81973b93d3511ae79841756fb5da3fd2f70ea1279e81fae7c9b19af6c4 +0ms
```
- Get the bond id and set `registryConfig.bondId` in backend [config file](packages/backend/environments/local.toml)
- Setup ngrok for GitHub webhooks
- [ngrok getting started](https://ngrok.com/docs/getting-started/)
- Start ngrok and point to backend server endpoint
```bash
ngrok http http://localhost:8000
```
- Look for the forwarding URL in ngrok
```bash
Forwarding https://19c1-61-95-158-116.ngrok-free.app -> http://localhost:8000
```
- Set `gitHub.webhookUrl` in backend [config file](packages/backend/environments/local.toml)
```toml
[gitHub]
webhookUrl = "https://19c1-61-95-158-116.ngrok-free.app"
```
- For Frontend:
```bash
cp packages/frontend/.env.example packages/frontend/.env
```
- Copy the GitHub OAuth app client ID from previous steps and set it in frontend [.env](packages/frontend/.env) file
```env
VITE_GITHUB_CLIENT_ID = <CLIENT_ID>
```
- Set `VITE_GITHUB_PWA_TEMPLATE_REPO` and `VITE_GITHUB_IMAGE_UPLOAD_PWA_TEMPLATE_REPO` in [.env](packages/frontend/.env) file
```env
VITE_GITHUB_PWA_TEMPLATE_REPO = 'cerc-io/test-progressive-web-app' # Set actual owner/name of the template repo that will be used for creating new repo
VITE_GITHUB_IMAGE_UPLOAD_PWA_TEMPLATE_REPO = 'cerc-io/image-upload-pwa-example' # Set actual owner/name of the template repo that will be used for creating new repo
```
- Production
- Set the following values in [.env](packages/frontend/.env) file
```env
VITE_SERVER_URL = 'https://api.snowballtools.com' # Backend server endpoint
```
- Sign in to [wallet connect](https://cloud.walletconnect.com/sign-in) to create a project ID
- Create a project and add information to use wallet connect SDK
- Add project name and select project type as `App`
- Set project home page URL to `https://dashboard.snowballtools.com`
- On creation of project, use the `Project ID` and set it in `VITE_WALLET_CONNECT_ID` in [.env](packages/frontend/.env) file
```env
VITE_WALLET_CONNECT_ID = <PROJECT_ID>
```
- Build the React application
```bash
yarn build
```
- Use a web server for hosting static built files
```bash
python3 -m http.server -d build 3000
```
- Development
- Copy the graphQL endpoint from terminal and add the endpoint in the [.env](packages/frontend/.env) file present in `packages/frontend`
```env
VITE_SERVER_URL = 'http://localhost:8000'
```
- Sign in to [wallet connect](https://cloud.walletconnect.com/sign-in) to create a project ID.
- Create a project and add information to use wallet connect SDK
- Add project name and select project type as `App`
- Project home page URL is not required to be set
- On creation of project, use the `Project ID` and set it in `VITE_WALLET_CONNECT_ID` in [.env](packages/frontend/.env) file
```env
VITE_WALLET_CONNECT_ID = <Project_ID>
```
- The React application will be running in `http://localhost:3000/`
## Development
- Start the backend server
```bash
yarn start:backend
```
- Start the frontend
```bash
yarn start
```

View File

@ -15,8 +15,8 @@ VITE_GITHUB_CLIENT_ID = 'LACONIC_HOSTED_CONFIG_github_clientid'
VITE_GITHUB_PWA_TEMPLATE_REPO = 'LACONIC_HOSTED_CONFIG_github_pwa_templaterepo' VITE_GITHUB_PWA_TEMPLATE_REPO = 'LACONIC_HOSTED_CONFIG_github_pwa_templaterepo'
VITE_GITHUB_IMAGE_UPLOAD_PWA_TEMPLATE_REPO = 'LACONIC_HOSTED_CONFIG_github_image_upload_templaterepo' VITE_GITHUB_IMAGE_UPLOAD_PWA_TEMPLATE_REPO = 'LACONIC_HOSTED_CONFIG_github_image_upload_templaterepo'
VITE_WALLET_CONNECT_ID = 'LACONIC_HOSTED_CONFIG_wallet_connect_id' VITE_WALLET_CONNECT_ID = 'LACONIC_HOSTED_CONFIG_wallet_connect_id'
VITE_LACONICD_CHAIN_ID = 'LACONIC_HOSTED_CONFIG_laconicd_chain_id'
VITE_LIT_RELAY_API_KEY = 'LACONIC_HOSTED_CONFIG_lit_relay_api_key' VITE_LIT_RELAY_API_KEY = 'LACONIC_HOSTED_CONFIG_lit_relay_api_key'
VITE_ALCHEMY_API_KEY = 'LACONIC_HOSTED_CONFIG_aplchemy_api_key'
VITE_BUGSNAG_API_KEY = 'LACONIC_HOSTED_CONFIG_bugsnag_api_key' VITE_BUGSNAG_API_KEY = 'LACONIC_HOSTED_CONFIG_bugsnag_api_key'
VITE_PASSKEY_WALLET_RPID = 'LACONIC_HOSTED_CONFIG_passkey_wallet_rpid' VITE_PASSKEY_WALLET_RPID = 'LACONIC_HOSTED_CONFIG_passkey_wallet_rpid'
VITE_TURNKEY_API_BASE_URL = 'LACONIC_HOSTED_CONFIG_turnkey_api_base_url' VITE_TURNKEY_API_BASE_URL = 'LACONIC_HOSTED_CONFIG_turnkey_api_base_url'
@ -24,7 +24,7 @@ VITE_TURNKEY_ORGANIZATION_ID = 'LACONIC_HOSTED_CONFIG_turnkey_organization_id'
EOF EOF
yarn || exit 1 yarn || exit 1
yarn build --ignore backend || exit 1 yarn build || exit 1
if [[ ! -d "$OUTPUT_DIR" ]]; then if [[ ! -d "$OUTPUT_DIR" ]]; then
echo "Missing output directory: $OUTPUT_DIR" 1>&2 echo "Missing output directory: $OUTPUT_DIR" 1>&2

View File

@ -1,76 +1 @@
# backend # Backend for Snowball Tools
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)

View File

@ -3,12 +3,9 @@
port = 8000 port = 8000
gqlPath = "/graphql" gqlPath = "/graphql"
[server.session] [server.session]
secret = "" secret = "p4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi"
# Frontend webapp URL origin
appOriginUrl = "http://localhost:3000" appOriginUrl = "http://localhost:3000"
# Set to true if server running behind proxy
trustProxy = false trustProxy = false
# Backend URL hostname
domain = "localhost" domain = "localhost"
[database] [database]
@ -22,7 +19,6 @@
[registryConfig] [registryConfig]
fetchDeploymentRecordDelay = 5000 fetchDeploymentRecordDelay = 5000
checkAuctionStatusDelay = 5000
restEndpoint = "http://localhost:1317" restEndpoint = "http://localhost:1317"
gqlEndpoint = "http://localhost:9473/api" gqlEndpoint = "http://localhost:9473/api"
chainId = "laconic_9000-1" chainId = "laconic_9000-1"
@ -30,14 +26,9 @@
bondId = "" bondId = ""
authority = "" authority = ""
[registryConfig.fee] [registryConfig.fee]
gas = "" amount = "200000"
fees = "" denom = "aphoton"
gasPrice = "1alnt" gas = "750000"
# Durations are set to 2 mins as deployers may take time with ongoing deployments and auctions [misc]
[auction] projectDomain = "apps.snowballtools.com"
commitFee = "100000"
commitsDuration = "120s"
revealFee = "100000"
revealsDuration = "120s"
denom = "alnt"

View File

@ -4,10 +4,10 @@
"version": "1.0.0", "version": "1.0.0",
"main": "index.js", "main": "index.js",
"dependencies": { "dependencies": {
"@cerc-io/registry-sdk": "^0.2.11",
"@graphql-tools/schema": "^10.0.2", "@graphql-tools/schema": "^10.0.2",
"@graphql-tools/utils": "^10.0.12", "@graphql-tools/utils": "^10.0.12",
"@octokit/oauth-app": "^6.1.0", "@octokit/oauth-app": "^6.1.0",
"@snowballtools/laconic-sdk": "^0.1.17",
"@turnkey/sdk-server": "^0.1.0", "@turnkey/sdk-server": "^0.1.0",
"@types/debug": "^4.1.5", "@types/debug": "^4.1.5",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
@ -19,7 +19,6 @@
"cors": "^2.8.5", "cors": "^2.8.5",
"debug": "^4.3.1", "debug": "^4.3.1",
"express": "^4.18.2", "express": "^4.18.2",
"express-async-errors": "^3.1.1",
"express-session": "^1.18.0", "express-session": "^1.18.0",
"fs-extra": "^11.2.0", "fs-extra": "^11.2.0",
"graphql": "^16.8.1", "graphql": "^16.8.1",

View File

@ -34,21 +34,16 @@ export interface RegistryConfig {
privateKey: string; privateKey: string;
bondId: string; bondId: string;
fetchDeploymentRecordDelay: number; fetchDeploymentRecordDelay: number;
checkAuctionStatusDelay: number;
authority: string; authority: string;
fee: { fee: {
amount: string;
denom: string;
gas: string; gas: string;
fees: string;
gasPrice: string;
}; };
} }
export interface AuctionConfig { export interface MiscConfig {
commitFee: string; projectDomain: string;
commitsDuration: string;
revealFee: string;
revealsDuration: string;
denom: string;
} }
export interface Config { export interface Config {
@ -56,7 +51,7 @@ export interface Config {
database: DatabaseConfig; database: DatabaseConfig;
gitHub: GitHubConfig; gitHub: GitHubConfig;
registryConfig: RegistryConfig; registryConfig: RegistryConfig;
auction: AuctionConfig; misc: MiscConfig;
turnkey: { turnkey: {
apiBaseUrl: string; apiBaseUrl: string;
apiPublicKey: string; apiPublicKey: string;

View File

@ -3,9 +3,7 @@ import {
DeepPartial, DeepPartial,
FindManyOptions, FindManyOptions,
FindOneOptions, FindOneOptions,
FindOptionsWhere, FindOptionsWhere
IsNull,
Not
} from 'typeorm'; } from 'typeorm';
import path from 'path'; import path from 'path';
import debug from 'debug'; import debug from 'debug';
@ -13,7 +11,7 @@ import assert from 'assert';
import { customAlphabet } from 'nanoid'; import { customAlphabet } from 'nanoid';
import { lowercase, numbers } from 'nanoid-dictionary'; import { lowercase, numbers } from 'nanoid-dictionary';
import { DatabaseConfig } from './config'; import { DatabaseConfig, MiscConfig } from './config';
import { User } from './entity/User'; import { User } from './entity/User';
import { Organization } from './entity/Organization'; import { Organization } from './entity/Organization';
import { Project } from './entity/Project'; import { Project } from './entity/Project';
@ -23,7 +21,6 @@ import { EnvironmentVariable } from './entity/EnvironmentVariable';
import { Domain } from './entity/Domain'; import { Domain } from './entity/Domain';
import { getEntities, loadAndSaveData } from './utils'; import { getEntities, loadAndSaveData } from './utils';
import { UserOrganization } from './entity/UserOrganization'; import { UserOrganization } from './entity/UserOrganization';
import { Deployer } from './entity/Deployer';
const ORGANIZATION_DATA_PATH = '../test/fixtures/organizations.json'; const ORGANIZATION_DATA_PATH = '../test/fixtures/organizations.json';
@ -34,8 +31,9 @@ const nanoid = customAlphabet(lowercase + numbers, 8);
// TODO: Fix order of methods // TODO: Fix order of methods
export class Database { export class Database {
private dataSource: DataSource; private dataSource: DataSource;
private projectDomain: string;
constructor({ dbPath }: DatabaseConfig) { constructor ({ dbPath } : DatabaseConfig, { projectDomain } : MiscConfig) {
this.dataSource = new DataSource({ this.dataSource = new DataSource({
type: 'better-sqlite3', type: 'better-sqlite3',
database: dbPath, database: dbPath,
@ -43,9 +41,11 @@ export class Database {
synchronize: true, synchronize: true,
logging: false logging: false
}); });
this.projectDomain = projectDomain;
} }
async init(): Promise<void> { async init (): Promise<void> {
await this.dataSource.initialize(); await this.dataSource.initialize();
log('database initialized'); log('database initialized');
@ -58,21 +58,21 @@ export class Database {
} }
} }
async getUser(options: FindOneOptions<User>): Promise<User | null> { async getUser (options: FindOneOptions<User>): Promise<User | null> {
const userRepository = this.dataSource.getRepository(User); const userRepository = this.dataSource.getRepository(User);
const user = await userRepository.findOne(options); const user = await userRepository.findOne(options);
return user; return user;
} }
async addUser(data: DeepPartial<User>): Promise<User> { async addUser (data: DeepPartial<User>): Promise<User> {
const userRepository = this.dataSource.getRepository(User); const userRepository = this.dataSource.getRepository(User);
const user = await userRepository.save(data); const user = await userRepository.save(data);
return user; return user;
} }
async updateUser(user: User, data: DeepPartial<User>): Promise<boolean> { async updateUser (user: User, data: DeepPartial<User>): Promise<boolean> {
const userRepository = this.dataSource.getRepository(User); const userRepository = this.dataSource.getRepository(User);
const updateResult = await userRepository.update({ id: user.id }, data); const updateResult = await userRepository.update({ id: user.id }, data);
assert(updateResult.affected); assert(updateResult.affected);
@ -80,7 +80,7 @@ export class Database {
return updateResult.affected > 0; return updateResult.affected > 0;
} }
async getOrganizations( async getOrganizations (
options: FindManyOptions<Organization> options: FindManyOptions<Organization>
): Promise<Organization[]> { ): Promise<Organization[]> {
const organizationRepository = this.dataSource.getRepository(Organization); const organizationRepository = this.dataSource.getRepository(Organization);
@ -89,7 +89,7 @@ export class Database {
return organizations; return organizations;
} }
async getOrganization( async getOrganization (
options: FindOneOptions<Organization> options: FindOneOptions<Organization>
): Promise<Organization | null> { ): Promise<Organization | null> {
const organizationRepository = this.dataSource.getRepository(Organization); const organizationRepository = this.dataSource.getRepository(Organization);
@ -98,7 +98,7 @@ export class Database {
return organization; return organization;
} }
async getOrganizationsByUserId(userId: string): Promise<Organization[]> { async getOrganizationsByUserId (userId: string): Promise<Organization[]> {
const organizationRepository = this.dataSource.getRepository(Organization); const organizationRepository = this.dataSource.getRepository(Organization);
const userOrgs = await organizationRepository.find({ const userOrgs = await organizationRepository.find({
@ -114,21 +114,21 @@ export class Database {
return userOrgs; return userOrgs;
} }
async addUserOrganization(data: DeepPartial<UserOrganization>): Promise<UserOrganization> { async addUserOrganization (data: DeepPartial<UserOrganization>): Promise<UserOrganization> {
const userOrganizationRepository = this.dataSource.getRepository(UserOrganization); const userOrganizationRepository = this.dataSource.getRepository(UserOrganization);
const newUserOrganization = await userOrganizationRepository.save(data); const newUserOrganization = await userOrganizationRepository.save(data);
return newUserOrganization; return newUserOrganization;
} }
async getProjects(options: FindManyOptions<Project>): Promise<Project[]> { async getProjects (options: FindManyOptions<Project>): Promise<Project[]> {
const projectRepository = this.dataSource.getRepository(Project); const projectRepository = this.dataSource.getRepository(Project);
const projects = await projectRepository.find(options); const projects = await projectRepository.find(options);
return projects; return projects;
} }
async getProjectById(projectId: string): Promise<Project | null> { async getProjectById (projectId: string): Promise<Project | null> {
const projectRepository = this.dataSource.getRepository(Project); const projectRepository = this.dataSource.getRepository(Project);
const project = await projectRepository const project = await projectRepository
@ -140,9 +140,7 @@ export class Database {
) )
.leftJoinAndSelect('deployments.createdBy', 'user') .leftJoinAndSelect('deployments.createdBy', 'user')
.leftJoinAndSelect('deployments.domain', 'domain') .leftJoinAndSelect('deployments.domain', 'domain')
.leftJoinAndSelect('deployments.deployer', 'deployer')
.leftJoinAndSelect('project.owner', 'owner') .leftJoinAndSelect('project.owner', 'owner')
.leftJoinAndSelect('project.deployers', 'deployers')
.leftJoinAndSelect('project.organization', 'organization') .leftJoinAndSelect('project.organization', 'organization')
.where('project.id = :projectId', { .where('project.id = :projectId', {
projectId projectId
@ -152,25 +150,7 @@ export class Database {
return project; return project;
} }
async allProjectsWithoutDeployments(): Promise<Project[]> { async getProjectsInOrganization (
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, userId: string,
organizationSlug: string organizationSlug: string
): Promise<Project[]> { ): Promise<Project[]> {
@ -201,7 +181,7 @@ export class Database {
/** /**
* Get deployments with specified filter * Get deployments with specified filter
*/ */
async getDeployments( async getDeployments (
options: FindManyOptions<Deployment> options: FindManyOptions<Deployment>
): Promise<Deployment[]> { ): Promise<Deployment[]> {
const deploymentRepository = this.dataSource.getRepository(Deployment); const deploymentRepository = this.dataSource.getRepository(Deployment);
@ -210,13 +190,12 @@ export class Database {
return deployments; return deployments;
} }
async getDeploymentsByProjectId(projectId: string): Promise<Deployment[]> { async getDeploymentsByProjectId (projectId: string): Promise<Deployment[]> {
return this.getDeployments({ return this.getDeployments({
relations: { relations: {
project: true, project: true,
domain: true, domain: true,
createdBy: true, createdBy: true
deployer: true,
}, },
where: { where: {
project: { project: {
@ -229,7 +208,7 @@ export class Database {
}); });
} }
async getDeployment( async getDeployment (
options: FindOneOptions<Deployment> options: FindOneOptions<Deployment>
): Promise<Deployment | null> { ): Promise<Deployment | null> {
const deploymentRepository = this.dataSource.getRepository(Deployment); const deploymentRepository = this.dataSource.getRepository(Deployment);
@ -238,14 +217,14 @@ export class Database {
return deployment; return deployment;
} }
async getDomains(options: FindManyOptions<Domain>): Promise<Domain[]> { async getDomains (options: FindManyOptions<Domain>): Promise<Domain[]> {
const domainRepository = this.dataSource.getRepository(Domain); const domainRepository = this.dataSource.getRepository(Domain);
const domains = await domainRepository.find(options); const domains = await domainRepository.find(options);
return domains; return domains;
} }
async addDeployment(data: DeepPartial<Deployment>): Promise<Deployment> { async addDeployment (data: DeepPartial<Deployment>): Promise<Deployment> {
const deploymentRepository = this.dataSource.getRepository(Deployment); const deploymentRepository = this.dataSource.getRepository(Deployment);
const id = nanoid(); const id = nanoid();
@ -259,7 +238,7 @@ export class Database {
return deployment; return deployment;
} }
async getProjectMembersByProjectId( async getProjectMembersByProjectId (
projectId: string projectId: string
): Promise<ProjectMember[]> { ): Promise<ProjectMember[]> {
const projectMemberRepository = const projectMemberRepository =
@ -280,7 +259,7 @@ export class Database {
return projectMembers; return projectMembers;
} }
async getEnvironmentVariablesByProjectId( async getEnvironmentVariablesByProjectId (
projectId: string, projectId: string,
filter?: FindOptionsWhere<EnvironmentVariable> filter?: FindOptionsWhere<EnvironmentVariable>
): Promise<EnvironmentVariable[]> { ): Promise<EnvironmentVariable[]> {
@ -299,7 +278,7 @@ export class Database {
return environmentVariables; return environmentVariables;
} }
async removeProjectMemberById(projectMemberId: string): Promise<boolean> { async removeProjectMemberById (projectMemberId: string): Promise<boolean> {
const projectMemberRepository = const projectMemberRepository =
this.dataSource.getRepository(ProjectMember); this.dataSource.getRepository(ProjectMember);
@ -314,7 +293,7 @@ export class Database {
} }
} }
async updateProjectMemberById( async updateProjectMemberById (
projectMemberId: string, projectMemberId: string,
data: DeepPartial<ProjectMember> data: DeepPartial<ProjectMember>
): Promise<boolean> { ): Promise<boolean> {
@ -328,7 +307,7 @@ export class Database {
return Boolean(updateResult.affected); return Boolean(updateResult.affected);
} }
async addProjectMember( async addProjectMember (
data: DeepPartial<ProjectMember> data: DeepPartial<ProjectMember>
): Promise<ProjectMember> { ): Promise<ProjectMember> {
const projectMemberRepository = const projectMemberRepository =
@ -338,7 +317,7 @@ export class Database {
return newProjectMember; return newProjectMember;
} }
async addEnvironmentVariables( async addEnvironmentVariables (
data: DeepPartial<EnvironmentVariable>[] data: DeepPartial<EnvironmentVariable>[]
): Promise<EnvironmentVariable[]> { ): Promise<EnvironmentVariable[]> {
const environmentVariableRepository = const environmentVariableRepository =
@ -349,7 +328,7 @@ export class Database {
return savedEnvironmentVariables; return savedEnvironmentVariables;
} }
async updateEnvironmentVariable( async updateEnvironmentVariable (
environmentVariableId: string, environmentVariableId: string,
data: DeepPartial<EnvironmentVariable> data: DeepPartial<EnvironmentVariable>
): Promise<boolean> { ): Promise<boolean> {
@ -363,7 +342,7 @@ export class Database {
return Boolean(updateResult.affected); return Boolean(updateResult.affected);
} }
async deleteEnvironmentVariable( async deleteEnvironmentVariable (
environmentVariableId: string environmentVariableId: string
): Promise<boolean> { ): Promise<boolean> {
const environmentVariableRepository = const environmentVariableRepository =
@ -379,7 +358,7 @@ export class Database {
} }
} }
async getProjectMemberById(projectMemberId: string): Promise<ProjectMember> { async getProjectMemberById (projectMemberId: string): Promise<ProjectMember> {
const projectMemberRepository = const projectMemberRepository =
this.dataSource.getRepository(ProjectMember); this.dataSource.getRepository(ProjectMember);
@ -402,7 +381,7 @@ export class Database {
return projectMemberWithProject[0]; return projectMemberWithProject[0];
} }
async getProjectsBySearchText( async getProjectsBySearchText (
userId: string, userId: string,
searchText: string searchText: string
): Promise<Project[]> { ): Promise<Project[]> {
@ -424,14 +403,14 @@ export class Database {
return projects; return projects;
} }
async updateDeploymentById( async updateDeploymentById (
deploymentId: string, deploymentId: string,
data: DeepPartial<Deployment> data: DeepPartial<Deployment>
): Promise<boolean> { ): Promise<boolean> {
return this.updateDeployment({ id: deploymentId }, data); return this.updateDeployment({ id: deploymentId }, data);
} }
async updateDeployment( async updateDeployment (
criteria: FindOptionsWhere<Deployment>, criteria: FindOptionsWhere<Deployment>,
data: DeepPartial<Deployment> data: DeepPartial<Deployment>
): Promise<boolean> { ): Promise<boolean> {
@ -441,7 +420,7 @@ export class Database {
return Boolean(updateResult.affected); return Boolean(updateResult.affected);
} }
async updateDeploymentsByProjectIds( async updateDeploymentsByProjectIds (
projectIds: string[], projectIds: string[],
data: DeepPartial<Deployment> data: DeepPartial<Deployment>
): Promise<boolean> { ): Promise<boolean> {
@ -457,7 +436,7 @@ export class Database {
return Boolean(updateResult.affected); return Boolean(updateResult.affected);
} }
async deleteDeploymentById(deploymentId: string): Promise<boolean> { async deleteDeploymentById (deploymentId: string): Promise<boolean> {
const deploymentRepository = this.dataSource.getRepository(Deployment); const deploymentRepository = this.dataSource.getRepository(Deployment);
const deployment = await deploymentRepository.findOneOrFail({ const deployment = await deploymentRepository.findOneOrFail({
where: { where: {
@ -470,7 +449,7 @@ export class Database {
return Boolean(deleteResult); return Boolean(deleteResult);
} }
async addProject(user: User, organizationId: string, data: DeepPartial<Project>): Promise<Project> { async addProject (user: User, organizationId: string, data: DeepPartial<Project>): Promise<Project> {
const projectRepository = this.dataSource.getRepository(Project); const projectRepository = this.dataSource.getRepository(Project);
// TODO: Check if organization exists // TODO: Check if organization exists
@ -486,16 +465,12 @@ export class Database {
id: organizationId id: organizationId
}); });
newProject.subDomain = `${newProject.name}.${this.projectDomain}`;
return projectRepository.save(newProject); return projectRepository.save(newProject);
} }
async saveProject(project: Project): Promise<Project> { async updateProjectById (
const projectRepository = this.dataSource.getRepository(Project);
return projectRepository.save(project);
}
async updateProjectById(
projectId: string, projectId: string,
data: DeepPartial<Project> data: DeepPartial<Project>
): Promise<boolean> { ): Promise<boolean> {
@ -508,7 +483,7 @@ export class Database {
return Boolean(updateResult.affected); return Boolean(updateResult.affected);
} }
async deleteProjectById(projectId: string): Promise<boolean> { async deleteProjectById (projectId: string): Promise<boolean> {
const projectRepository = this.dataSource.getRepository(Project); const projectRepository = this.dataSource.getRepository(Project);
const project = await projectRepository.findOneOrFail({ const project = await projectRepository.findOneOrFail({
where: { where: {
@ -524,7 +499,7 @@ export class Database {
return Boolean(deleteResult); return Boolean(deleteResult);
} }
async deleteDomainById(domainId: string): Promise<boolean> { async deleteDomainById (domainId: string): Promise<boolean> {
const domainRepository = this.dataSource.getRepository(Domain); const domainRepository = this.dataSource.getRepository(Domain);
const deleteResult = await domainRepository.softDelete({ id: domainId }); const deleteResult = await domainRepository.softDelete({ id: domainId });
@ -536,21 +511,21 @@ export class Database {
} }
} }
async addDomain(data: DeepPartial<Domain>): Promise<Domain> { async addDomain (data: DeepPartial<Domain>): Promise<Domain> {
const domainRepository = this.dataSource.getRepository(Domain); const domainRepository = this.dataSource.getRepository(Domain);
const newDomain = await domainRepository.save(data); const newDomain = await domainRepository.save(data);
return newDomain; return newDomain;
} }
async getDomain(options: FindOneOptions<Domain>): Promise<Domain | null> { async getDomain (options: FindOneOptions<Domain>): Promise<Domain | null> {
const domainRepository = this.dataSource.getRepository(Domain); const domainRepository = this.dataSource.getRepository(Domain);
const domain = await domainRepository.findOne(options); const domain = await domainRepository.findOne(options);
return domain; return domain;
} }
async updateDomainById( async updateDomainById (
domainId: string, domainId: string,
data: DeepPartial<Domain> data: DeepPartial<Domain>
): Promise<boolean> { ): Promise<boolean> {
@ -560,7 +535,7 @@ export class Database {
return Boolean(updateResult.affected); return Boolean(updateResult.affected);
} }
async getDomainsByProjectId( async getDomainsByProjectId (
projectId: string, projectId: string,
filter?: FindOptionsWhere<Domain> filter?: FindOptionsWhere<Domain>
): Promise<Domain[]> { ): Promise<Domain[]> {
@ -580,24 +555,4 @@ export class Database {
return domains; return domains;
} }
async addDeployer(data: DeepPartial<Deployer>): Promise<Deployer> {
const deployerRepository = this.dataSource.getRepository(Deployer);
const newDomain = await deployerRepository.save(data);
return newDomain;
}
async getDeployers(): Promise<Deployer[]> {
const deployerRepository = this.dataSource.getRepository(Deployer);
const deployers = await deployerRepository.find();
return deployers;
}
async getDeployerByLRN(deployerLrn: string): Promise<Deployer | null> {
const deployerRepository = this.dataSource.getRepository(Deployer);
const deployer = await deployerRepository.findOne({ where: { deployerLrn } });
return deployer;
}
} }

View File

@ -1,26 +0,0 @@
import { Entity, PrimaryColumn, Column, ManyToMany } 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 })
minimumPayment!: string | null;
@Column('varchar', { nullable: true })
paymentAddress!: string | null;
@ManyToMany(() => Project, (project) => project.deployers)
projects!: Project[];
}

View File

@ -13,7 +13,6 @@ import {
import { Project } from './Project'; import { Project } from './Project';
import { Domain } from './Domain'; import { Domain } from './Domain';
import { User } from './User'; import { User } from './User';
import { Deployer } from './Deployer';
import { AppDeploymentRecordAttributes, AppDeploymentRemovalRecordAttributes } from '../types'; import { AppDeploymentRecordAttributes, AppDeploymentRemovalRecordAttributes } from '../types';
export enum Environment { export enum Environment {
@ -34,21 +33,21 @@ export interface ApplicationDeploymentRequest {
version: string; version: string;
name: string; name: string;
application: string; application: string;
lrn?: string;
auction?: string;
config: string; config: string;
meta: string; meta: string;
payment?: string;
} }
export interface ApplicationDeploymentRemovalRequest { export interface ApplicationDeploymentRemovalRequest {
type: string; type: string;
version: string; version: string;
deployment: string; deployment: string;
auction?: string;
payment?: string;
} }
export interface ApplicationDeploymentRemovalRequest {
type: string;
version: string;
deployment: string;
}
export interface ApplicationRecord { export interface ApplicationRecord {
type: string; type: string;
@ -113,23 +112,19 @@ export class Deployment {
@Column('simple-json', { nullable: true }) @Column('simple-json', { nullable: true })
applicationDeploymentRecordData!: AppDeploymentRecordAttributes | null; applicationDeploymentRecordData!: AppDeploymentRecordAttributes | null;
@Column('varchar', { nullable: true }) @Column('varchar', { nullable: true })
applicationDeploymentRemovalRequestId!: string | null; applicationDeploymentRemovalRequestId!: string | null;
@Column('simple-json', { nullable: true }) @Column('simple-json', { nullable: true })
applicationDeploymentRemovalRequestData!: ApplicationDeploymentRemovalRequest | null; applicationDeploymentRemovalRequestData!: ApplicationDeploymentRemovalRequest | null;
@Column('varchar', { nullable: true }) @Column('varchar', { nullable: true })
applicationDeploymentRemovalRecordId!: string | null; applicationDeploymentRemovalRecordId!: string | null;
@Column('simple-json', { nullable: true }) @Column('simple-json', { nullable: true })
applicationDeploymentRemovalRecordData!: AppDeploymentRemovalRecordAttributes | null; applicationDeploymentRemovalRecordData!: AppDeploymentRemovalRecordAttributes | null;
@ManyToOne(() => Deployer)
@JoinColumn({ name: 'deployerLrn' })
deployer!: Deployer;
@Column({ @Column({
enum: Environment enum: Environment
}) })
@ -152,7 +147,7 @@ export class Deployment {
@UpdateDateColumn() @UpdateDateColumn()
updatedAt!: Date; updatedAt!: Date;
@DeleteDateColumn() @DeleteDateColumn()
deletedAt!: Date | null; deletedAt!: Date | null;
} }

View File

@ -7,16 +7,13 @@ import {
ManyToOne, ManyToOne,
JoinColumn, JoinColumn,
OneToMany, OneToMany,
DeleteDateColumn, DeleteDateColumn
JoinTable,
ManyToMany
} from 'typeorm'; } from 'typeorm';
import { User } from './User'; import { User } from './User';
import { Organization } from './Organization'; import { Organization } from './Organization';
import { ProjectMember } from './ProjectMember'; import { ProjectMember } from './ProjectMember';
import { Deployment } from './Deployment'; import { Deployment } from './Deployment';
import { Deployer } from './Deployer';
@Entity() @Entity()
export class Project { export class Project {
@ -49,20 +46,6 @@ export class Project {
@Column('text', { default: '' }) @Column('text', { default: '' })
description!: string; 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 // TODO: Compute template & framework in import repository
@Column('varchar', { nullable: true }) @Column('varchar', { nullable: true })
template!: string | null; template!: string | null;
@ -70,10 +53,6 @@ export class Project {
@Column('varchar', { nullable: true }) @Column('varchar', { nullable: true })
framework!: string | null; framework!: string | null;
// Address of the user who created the project i.e. requested deployments
@Column('varchar')
paymentAddress!: string;
@Column({ @Column({
type: 'simple-array' type: 'simple-array'
}) })
@ -82,6 +61,9 @@ export class Project {
@Column('varchar') @Column('varchar')
icon!: string; icon!: string;
@Column('varchar')
subDomain!: string;
@CreateDateColumn() @CreateDateColumn()
createdAt!: Date; createdAt!: Date;

View File

@ -1,4 +1,3 @@
import 'express-async-errors';
import 'reflect-metadata'; import 'reflect-metadata';
import debug from 'debug'; import debug from 'debug';
import fs from 'fs'; import fs from 'fs';
@ -17,7 +16,7 @@ const log = debug('snowball:server');
const OAUTH_CLIENT_TYPE = 'oauth-app'; const OAUTH_CLIENT_TYPE = 'oauth-app';
export const main = async (): Promise<void> => { export const main = async (): Promise<void> => {
const { server, database, gitHub, registryConfig } = await getConfig(); const { server, database, gitHub, registryConfig, misc } = await getConfig();
const app = new OAuthApp({ const app = new OAuthApp({
clientType: OAUTH_CLIENT_TYPE, clientType: OAUTH_CLIENT_TYPE,
@ -25,7 +24,7 @@ export const main = async (): Promise<void> => {
clientSecret: gitHub.oAuth.clientSecret, clientSecret: gitHub.oAuth.clientSecret,
}); });
const db = new Database(database); const db = new Database(database, misc);
await db.init(); await db.init();
const registry = new Registry(registryConfig); const registry = new Registry(registryConfig);

View File

@ -1,12 +1,9 @@
import assert from 'assert';
import debug from 'debug'; import debug from 'debug';
import { DateTime } from 'luxon'; import assert from 'assert';
import { Octokit } from 'octokit';
import { inc as semverInc } from 'semver'; import { inc as semverInc } from 'semver';
import { DeepPartial } from 'typeorm'; import { DateTime } from 'luxon';
import { Account, DEFAULT_GAS_ESTIMATION_MULTIPLIER, Registry as LaconicRegistry, getGasPrice, parseGasAndFees } from '@cerc-io/registry-sdk'; import { Registry as LaconicRegistry } from '@snowballtools/laconic-sdk';
import { DeliverTxResponse, IndexedTx } from '@cosmjs/stargate';
import { RegistryConfig } from './config'; import { RegistryConfig } from './config';
import { import {
@ -15,53 +12,49 @@ import {
ApplicationDeploymentRequest, ApplicationDeploymentRequest,
ApplicationDeploymentRemovalRequest ApplicationDeploymentRemovalRequest
} from './entity/Deployment'; } from './entity/Deployment';
import { AppDeploymentRecord, AppDeploymentRemovalRecord, AuctionParams, DeployerRecord } from './types'; import { AppDeploymentRecord, AppDeploymentRemovalRecord, PackageJSON } from './types';
import { getConfig, getRepoDetails, registryTransactionWithRetry, sleep } from './utils'; import { sleep } from './utils';
const log = debug('snowball:registry'); const log = debug('snowball:registry');
const APP_RECORD_TYPE = 'ApplicationRecord'; const APP_RECORD_TYPE = 'ApplicationRecord';
const APP_DEPLOYMENT_AUCTION_RECORD_TYPE = 'ApplicationDeploymentAuction';
const APP_DEPLOYMENT_REQUEST_TYPE = 'ApplicationDeploymentRequest'; const APP_DEPLOYMENT_REQUEST_TYPE = 'ApplicationDeploymentRequest';
const APP_DEPLOYMENT_REMOVAL_REQUEST_TYPE = 'ApplicationDeploymentRemovalRequest'; const APP_DEPLOYMENT_REMOVAL_REQUEST_TYPE = 'ApplicationDeploymentRemovalRequest';
const APP_DEPLOYMENT_RECORD_TYPE = 'ApplicationDeploymentRecord'; const APP_DEPLOYMENT_RECORD_TYPE = 'ApplicationDeploymentRecord';
const APP_DEPLOYMENT_REMOVAL_RECORD_TYPE = 'ApplicationDeploymentRemovalRecord'; const APP_DEPLOYMENT_REMOVAL_RECORD_TYPE = 'ApplicationDeploymentRemovalRecord';
const WEBAPP_DEPLOYER_RECORD_TYPE = 'WebappDeployer'
const SLEEP_DURATION = 1000; const SLEEP_DURATION = 1000;
// TODO: Move registry code to registry-sdk/watcher-ts // TODO: Move registry code to laconic-sdk/watcher-ts
export class Registry { export class Registry {
private registry: LaconicRegistry; private registry: LaconicRegistry;
private registryConfig: RegistryConfig; private registryConfig: RegistryConfig;
constructor(registryConfig: RegistryConfig) { constructor (registryConfig: RegistryConfig) {
this.registryConfig = registryConfig; this.registryConfig = registryConfig;
const gasPrice = getGasPrice(registryConfig.fee.gasPrice);
this.registry = new LaconicRegistry( this.registry = new LaconicRegistry(
registryConfig.gqlEndpoint, registryConfig.gqlEndpoint,
registryConfig.restEndpoint, registryConfig.restEndpoint,
{ chainId: registryConfig.chainId, gasPrice } registryConfig.chainId
); );
} }
async createApplicationRecord({ async createApplicationRecord ({
octokit, appName,
repository, packageJSON,
commitHash, commitHash,
appType, appType,
repoUrl
}: { }: {
octokit: Octokit appName: string;
repository: string; packageJSON: PackageJSON;
commitHash: string; commitHash: string;
appType: string; appType: string;
repoUrl: string;
}): Promise<{ }): Promise<{
applicationRecordId: string; applicationRecordId: string;
applicationRecordData: ApplicationRecord; applicationRecordData: ApplicationRecord;
}> { }> {
const { repo, repoUrl, packageJSON } = await getRepoDetails(octokit, repository, commitHash) // Use laconic-sdk to publish record
// 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 // Reference: https://git.vdb.to/cerc-io/test-progressive-web-app/src/branch/main/scripts/publish-app-record.sh
// Fetch previous records // Fetch previous records
const records = await this.registry.queryRecords( const records = await this.registry.queryRecords(
@ -94,7 +87,7 @@ export class Registry {
repository_ref: commitHash, repository_ref: commitHash,
repository: [repoUrl], repository: [repoUrl],
app_type: appType, app_type: appType,
name: repo, name: appName,
...(packageJSON.description && { description: packageJSON.description }), ...(packageJSON.description && { description: packageJSON.description }),
...(packageJSON.homepage && { homepage: packageJSON.homepage }), ...(packageJSON.homepage && { homepage: packageJSON.homepage }),
...(packageJSON.license && { license: packageJSON.license }), ...(packageJSON.license && { license: packageJSON.license }),
@ -107,158 +100,68 @@ export class Registry {
...(packageJSON.version && { app_version: packageJSON.version }) ...(packageJSON.version && { app_version: packageJSON.version })
}; };
const fee = parseGasAndFees(this.registryConfig.fee.gas, this.registryConfig.fee.fees); const result = await this.registry.setRecord(
{
const result = await registryTransactionWithRetry(() => privateKey: this.registryConfig.privateKey,
this.registry.setRecord( record: applicationRecord,
{ bondId: this.registryConfig.bondId
privateKey: this.registryConfig.privateKey, },
record: applicationRecord, '',
bondId: this.registryConfig.bondId this.registryConfig.fee
},
this.registryConfig.privateKey,
fee
)
); );
log(`Published application record ${result.id}`);
log('Application record data:', applicationRecord); log('Application record data:', applicationRecord);
// TODO: Discuss computation of LRN // TODO: Discuss computation of CRN
const lrn = this.getLrn(repo); const crn = this.getCrn(appName);
log(`Setting name: ${lrn} for record ID: ${result.id}`); log(`Setting name: ${crn} for record ID: ${result.data.id}`);
await sleep(SLEEP_DURATION); await sleep(SLEEP_DURATION);
await registryTransactionWithRetry(() => await this.registry.setName(
this.registry.setName( { cid: result.data.id, crn },
{ this.registryConfig.privateKey,
cid: result.id, this.registryConfig.fee
lrn
},
this.registryConfig.privateKey,
fee
)
); );
await sleep(SLEEP_DURATION); await sleep(SLEEP_DURATION);
await registryTransactionWithRetry(() => await this.registry.setName(
this.registry.setName( { cid: result.data.id, crn: `${crn}@${applicationRecord.app_version}` },
{ this.registryConfig.privateKey,
cid: result.id, this.registryConfig.fee
lrn: `${lrn}@${applicationRecord.app_version}`
},
this.registryConfig.privateKey,
fee
)
); );
await sleep(SLEEP_DURATION); await sleep(SLEEP_DURATION);
await registryTransactionWithRetry(() => await this.registry.setName(
this.registry.setName( {
{ cid: result.data.id,
cid: result.id, crn: `${crn}@${applicationRecord.repository_ref}`
lrn: `${lrn}@${applicationRecord.repository_ref}` },
}, this.registryConfig.privateKey,
this.registryConfig.privateKey, this.registryConfig.fee
fee
)
); );
return { return {
applicationRecordId: result.id, applicationRecordId: result.data.id,
applicationRecordData: applicationRecord applicationRecordData: applicationRecord
}; };
} }
async createApplicationDeploymentAuction( async createApplicationDeploymentRequest (data: {
appName: string,
octokit: Octokit,
auctionParams: AuctionParams,
data: DeepPartial<Deployment>,
): 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 registryTransactionWithRetry(() =>
this.registry.setRecord(
{
privateKey: this.registryConfig.privateKey,
record: applicationDeploymentAuction,
bondId: this.registryConfig.bondId
},
this.registryConfig.privateKey,
fee
)
);
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, deployment: Deployment,
appName: string, appName: string,
repository: string, repository: string,
auctionId?: string | null,
lrn: string,
environmentVariables: { [key: string]: string }, environmentVariables: { [key: string]: string },
dns: string, dns: string,
payment?: string | null
}): Promise<{ }): Promise<{
applicationDeploymentRequestId: string; applicationDeploymentRequestId: string;
applicationDeploymentRequestData: ApplicationDeploymentRequest; applicationDeploymentRequestData: ApplicationDeploymentRequest;
}> { }> {
const lrn = this.getLrn(data.appName); const crn = this.getCrn(data.appName);
const records = await this.registry.resolveNames([lrn]); const records = await this.registry.resolveNames([crn]);
const applicationRecord = records[0]; const applicationRecord = records[0];
if (!applicationRecord) { if (!applicationRecord) {
throw new Error(`No record found for ${lrn}`); throw new Error(`No record found for ${crn}`);
} }
// Create record of type ApplicationDeploymentRequest and publish // Create record of type ApplicationDeploymentRequest and publish
@ -266,9 +169,12 @@ export class Registry {
type: APP_DEPLOYMENT_REQUEST_TYPE, type: APP_DEPLOYMENT_REQUEST_TYPE,
version: '1.0.0', version: '1.0.0',
name: `${applicationRecord.attributes.name}@${applicationRecord.attributes.app_version}`, name: `${applicationRecord.attributes.name}@${applicationRecord.attributes.app_version}`,
application: `${lrn}@${applicationRecord.attributes.app_version}`, application: `${crn}@${applicationRecord.attributes.app_version}`,
dns: data.dns, dns: data.dns,
// TODO: Not set in test-progressive-web-app CI
// deployment: '$CERC_REGISTRY_DEPLOYMENT_CRN',
// https://git.vdb.to/cerc-io/laconic-registry-cli/commit/129019105dfb93bebcea02fde0ed64d0f8e5983b // https://git.vdb.to/cerc-io/laconic-registry-cli/commit/129019105dfb93bebcea02fde0ed64d0f8e5983b
config: JSON.stringify({ config: JSON.stringify({
env: data.environmentVariables env: data.environmentVariables
@ -279,87 +185,32 @@ export class Registry {
)}`, )}`,
repository: data.repository, repository: data.repository,
repository_ref: data.deployment.commitHash repository_ref: data.deployment.commitHash
}), })
deployer: data.lrn,
...(data.auctionId && { auction: data.auctionId }),
...(data.payment && { payment: data.payment }),
}; };
await sleep(SLEEP_DURATION); await sleep(SLEEP_DURATION);
const result = await this.registry.setRecord(
const fee = parseGasAndFees(this.registryConfig.fee.gas, this.registryConfig.fee.fees); {
privateKey: this.registryConfig.privateKey,
const result = await registryTransactionWithRetry(() => record: applicationDeploymentRequest,
this.registry.setRecord( bondId: this.registryConfig.bondId
{ },
privateKey: this.registryConfig.privateKey, '',
record: applicationDeploymentRequest, this.registryConfig.fee
bondId: this.registryConfig.bondId
},
this.registryConfig.privateKey,
fee
)
); );
log(`Application deployment request record published: ${result.data.id}`);
log(`Application deployment request record published: ${result.id}`);
log('Application deployment request data:', applicationDeploymentRequest); log('Application deployment request data:', applicationDeploymentRequest);
return { return {
applicationDeploymentRequestId: result.id, applicationDeploymentRequestId: result.data.id,
applicationDeploymentRequestData: applicationDeploymentRequest applicationDeploymentRequestData: applicationDeploymentRequest
}; };
} }
async getAuctionWinningDeployerRecords(
auctionId: string
): Promise<DeployerRecord[]> {
const records = await this.registry.getAuctionsByIds([auctionId]);
const auctionResult = records[0];
let 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<any> {
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 * Fetch ApplicationDeploymentRecords for deployments
*/ */
async getDeploymentRecords( async getDeploymentRecords (
deployments: Deployment[] deployments: Deployment[]
): Promise<AppDeploymentRecord[]> { ): Promise<AppDeploymentRecord[]> {
// Fetch ApplicationDeploymentRecords for corresponding ApplicationRecord set in deployments // Fetch ApplicationDeploymentRecords for corresponding ApplicationRecord set in deployments
@ -371,33 +222,20 @@ export class Registry {
true true
); );
// Filter records with ApplicationDeploymentRequestId ID and Deployment specific URL // Filter records with ApplicationRecord ID and Deployment specific URL
return records.filter((record: AppDeploymentRecord) => return records.filter((record: AppDeploymentRecord) =>
deployments.some( deployments.some(
(deployment) => (deployment) =>
deployment.applicationDeploymentRequestId === record.attributes.request && deployment.applicationRecordId === record.attributes.application &&
record.attributes.url.includes(deployment.id) record.attributes.url.includes(deployment.id)
) )
); );
} }
/**
* 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 * Fetch ApplicationDeploymentRecords by filter
*/ */
async getDeploymentRecordsByFilter(filter: { [key: string]: any }): Promise<AppDeploymentRecord[]> { async getDeploymentRecordsByFilter (filter: { [key: string]: any }): Promise<AppDeploymentRecord[]> {
return this.registry.queryRecords( return this.registry.queryRecords(
{ {
type: APP_DEPLOYMENT_RECORD_TYPE, type: APP_DEPLOYMENT_RECORD_TYPE,
@ -410,7 +248,7 @@ export class Registry {
/** /**
* Fetch ApplicationDeploymentRemovalRecords for deployments * Fetch ApplicationDeploymentRemovalRecords for deployments
*/ */
async getDeploymentRemovalRecords( async getDeploymentRemovalRecords (
deployments: Deployment[] deployments: Deployment[]
): Promise<AppDeploymentRemovalRecord[]> { ): Promise<AppDeploymentRemovalRecord[]> {
// Fetch ApplicationDeploymentRemovalRecords for corresponding ApplicationDeploymentRecord set in deployments // Fetch ApplicationDeploymentRemovalRecords for corresponding ApplicationDeploymentRecord set in deployments
@ -431,11 +269,8 @@ export class Registry {
); );
} }
async createApplicationDeploymentRemovalRequest(data: { async createApplicationDeploymentRemovalRequest (data: {
deploymentId: string; deploymentId: string;
deployerLrn: string;
auctionId?: string | null;
payment?: string | null;
}): Promise<{ }): Promise<{
applicationDeploymentRemovalRequestId: string; applicationDeploymentRemovalRequestId: string;
applicationDeploymentRemovalRequestData: ApplicationDeploymentRemovalRequest; applicationDeploymentRemovalRequestData: ApplicationDeploymentRemovalRequest;
@ -443,93 +278,30 @@ export class Registry {
const applicationDeploymentRemovalRequest = { const applicationDeploymentRemovalRequest = {
type: APP_DEPLOYMENT_REMOVAL_REQUEST_TYPE, type: APP_DEPLOYMENT_REMOVAL_REQUEST_TYPE,
version: '1.0.0', version: '1.0.0',
deployment: data.deploymentId, deployment: data.deploymentId
deployer: data.deployerLrn,
...(data.auctionId && { auction: data.auctionId }),
...(data.payment && { payment: data.payment }),
}; };
const fee = parseGasAndFees(this.registryConfig.fee.gas, this.registryConfig.fee.fees); const result = await this.registry.setRecord(
{
const result = await registryTransactionWithRetry(() => privateKey: this.registryConfig.privateKey,
this.registry.setRecord( record: applicationDeploymentRemovalRequest,
{ bondId: this.registryConfig.bondId
privateKey: this.registryConfig.privateKey, },
record: applicationDeploymentRemovalRequest, '',
bondId: this.registryConfig.bondId this.registryConfig.fee
},
this.registryConfig.privateKey,
fee
)
); );
log(`Application deployment removal request record published: ${result.id}`); log(`Application deployment removal request record published: ${result.data.id}`);
log('Application deployment removal request data:', applicationDeploymentRemovalRequest); log('Application deployment removal request data:', applicationDeploymentRemovalRequest);
return { return {
applicationDeploymentRemovalRequestId: result.id, applicationDeploymentRemovalRequestId: result.data.id,
applicationDeploymentRemovalRequestData: applicationDeploymentRemovalRequest applicationDeploymentRemovalRequestData: applicationDeploymentRemovalRequest
}; };
} }
async getCompletedAuctionIds(auctionIds: string[]): Promise<string[]> { getCrn (appName: string): string {
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 getRecordsByName(name: string): Promise<any> {
return this.registry.resolveNames([name]);
}
async getAuctionData(auctionId: string): Promise<any> {
return this.registry.getAuctionsByIds([auctionId]);
}
async sendTokensToAccount(receiverAddress: string, amount: string): Promise<DeliverTxResponse> {
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<Account> {
const account = new Account(Buffer.from(this.registryConfig.privateKey, 'hex'));
await account.init();
return account;
}
async getTxResponse(txHash: string): Promise<IndexedTx | null> {
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"); assert(this.registryConfig.authority, "Authority doesn't exist");
return `lrn://${this.registryConfig.authority}/applications/${appName}`; return `crn://${this.registryConfig.authority}/applications/${appName}`;
} }
} }

View File

@ -6,7 +6,6 @@ import { Permission } from './entity/ProjectMember';
import { Domain } from './entity/Domain'; import { Domain } from './entity/Domain';
import { Project } from './entity/Project'; import { Project } from './entity/Project';
import { EnvironmentVariable } from './entity/EnvironmentVariable'; import { EnvironmentVariable } from './entity/EnvironmentVariable';
import { AddProjectFromTemplateInput, AuctionParams, EnvironmentVariables } from './types';
const log = debug('snowball:resolver'); const log = debug('snowball:resolver');
@ -18,23 +17,16 @@ export const createResolvers = async (service: Service): Promise<any> => {
return context.user; return context.user;
}, },
organizations: async (_: any, __: any, context: any) => { organizations: async (_:any, __: any, context: any) => {
return service.getOrganizationsByUserId(context.user); return service.getOrganizationsByUserId(context.user);
}, },
project: async (_: any, { projectId }: { projectId: string }, context: any) => { project: async (_: any, { projectId }: { projectId: string }) => {
return service.getProjectById(context.user, projectId); return service.getProjectById(projectId);
}, },
projectsInOrganization: async ( projectsInOrganization: async (_: any, { organizationSlug }: {organizationSlug: string }, context: any) => {
_: any, return service.getProjectsInOrganization(context.user, organizationSlug);
{ organizationSlug }: { organizationSlug: string },
context: any,
) => {
return service.getProjectsInOrganization(
context.user,
organizationSlug,
);
}, },
deployments: async (_: any, { projectId }: { projectId: string }) => { deployments: async (_: any, { projectId }: { projectId: string }) => {
@ -43,7 +35,7 @@ export const createResolvers = async (service: Service): Promise<any> => {
environmentVariables: async ( environmentVariables: async (
_: any, _: any,
{ projectId }: { projectId: string }, { projectId }: { projectId: string }
) => { ) => {
return service.getEnvironmentVariablesByProjectId(projectId); return service.getEnvironmentVariablesByProjectId(projectId);
}, },
@ -52,11 +44,7 @@ export const createResolvers = async (service: Service): Promise<any> => {
return service.getProjectMembersByProjectId(projectId); return service.getProjectMembersByProjectId(projectId);
}, },
searchProjects: async ( searchProjects: async (_: any, { searchText }: { searchText: string }, context: any) => {
_: any,
{ searchText }: { searchText: string },
context: any,
) => {
return service.searchProjects(context.user, searchText); return service.searchProjects(context.user, searchText);
}, },
@ -64,37 +52,11 @@ export const createResolvers = async (service: Service): Promise<any> => {
_: any, _: any,
{ {
projectId, projectId,
filter, filter
}: { projectId: string; filter?: FindOptionsWhere<Domain> }, }: { projectId: string; filter?: FindOptionsWhere<Domain> }
) => { ) => {
return service.getDomainsByProjectId(projectId, filter); return service.getDomainsByProjectId(projectId, filter);
}, }
getAuctionData: async (
_: any,
{ auctionId }: { auctionId: string },
) => {
return service.getAuctionData(auctionId);
},
deployers: async (_: any, __: any, context: any) => {
return service.getDeployers();
},
address: async (_: any, __: any, context: any) => {
return service.getAddress();
},
verifyTx: async (
_: any,
{
txHash,
amount,
senderAddress,
}: { txHash: string; amount: string; senderAddress: string },
) => {
return service.verifyTx(txHash, amount, senderAddress);
},
}, },
// TODO: Return error in GQL response // TODO: Return error in GQL response
@ -102,13 +64,10 @@ export const createResolvers = async (service: Service): Promise<any> => {
removeProjectMember: async ( removeProjectMember: async (
_: any, _: any,
{ projectMemberId }: { projectMemberId: string }, { projectMemberId }: { projectMemberId: string },
context: any, context: any
) => { ) => {
try { try {
return await service.removeProjectMember( return await service.removeProjectMember(context.user, projectMemberId);
context.user,
projectMemberId,
);
} catch (err) { } catch (err) {
log(err); log(err);
return false; return false;
@ -119,13 +78,13 @@ export const createResolvers = async (service: Service): Promise<any> => {
_: any, _: any,
{ {
projectMemberId, projectMemberId,
data, data
}: { }: {
projectMemberId: string; projectMemberId: string;
data: { data: {
permissions: Permission[]; permissions: Permission[];
}; };
}, }
) => { ) => {
try { try {
return await service.updateProjectMember(projectMemberId, data); return await service.updateProjectMember(projectMemberId, data);
@ -139,14 +98,14 @@ export const createResolvers = async (service: Service): Promise<any> => {
_: any, _: any,
{ {
projectId, projectId,
data, data
}: { }: {
projectId: string; projectId: string;
data: { data: {
email: string; email: string;
permissions: Permission[]; permissions: Permission[];
}; };
}, }
) => { ) => {
try { try {
return Boolean(await service.addProjectMember(projectId, data)); return Boolean(await service.addProjectMember(projectId, data));
@ -160,15 +119,15 @@ export const createResolvers = async (service: Service): Promise<any> => {
_: any, _: any,
{ {
projectId, projectId,
data, data
}: { }: {
projectId: string; projectId: string;
data: { environments: string[]; key: string; value: string }[]; data: { environments: string[]; key: string; value: string }[];
}, }
) => { ) => {
try { try {
return Boolean( return Boolean(
await service.addEnvironmentVariables(projectId, data), await service.addEnvironmentVariables(projectId, data)
); );
} catch (err) { } catch (err) {
log(err); log(err);
@ -180,16 +139,16 @@ export const createResolvers = async (service: Service): Promise<any> => {
_: any, _: any,
{ {
environmentVariableId, environmentVariableId,
data, data
}: { }: {
environmentVariableId: string; environmentVariableId: string;
data: DeepPartial<EnvironmentVariable>; data: DeepPartial<EnvironmentVariable>;
}, }
) => { ) => {
try { try {
return await service.updateEnvironmentVariable( return await service.updateEnvironmentVariable(
environmentVariableId, environmentVariableId,
data, data
); );
} catch (err) { } catch (err) {
log(err); log(err);
@ -199,7 +158,7 @@ export const createResolvers = async (service: Service): Promise<any> => {
removeEnvironmentVariable: async ( removeEnvironmentVariable: async (
_: any, _: any,
{ environmentVariableId }: { environmentVariableId: string }, { environmentVariableId }: { environmentVariableId: string }
) => { ) => {
try { try {
return await service.removeEnvironmentVariable(environmentVariableId); return await service.removeEnvironmentVariable(environmentVariableId);
@ -212,76 +171,26 @@ export const createResolvers = async (service: Service): Promise<any> => {
updateDeploymentToProd: async ( updateDeploymentToProd: async (
_: any, _: any,
{ deploymentId }: { deploymentId: string }, { deploymentId }: { deploymentId: string },
context: any, context: any
) => { ) => {
try { try {
return Boolean( return Boolean(await service.updateDeploymentToProd(context.user, deploymentId));
await service.updateDeploymentToProd(context.user, deploymentId),
);
} catch (err) { } catch (err) {
log(err); log(err);
return false; 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 ( addProject: async (
_: any, _: any,
{ {
organizationSlug, organizationSlug,
data, data
lrn, }: { organizationSlug: string; data: DeepPartial<Project> },
auctionParams, context: any
environmentVariables
}: {
organizationSlug: string;
data: DeepPartial<Project>;
lrn: string;
auctionParams: AuctionParams,
environmentVariables: EnvironmentVariables[];
},
context: any,
) => { ) => {
try { try {
return await service.addProject( return await service.addProject(context.user, organizationSlug, data);
context.user,
organizationSlug,
data,
lrn,
auctionParams,
environmentVariables
);
} catch (err) { } catch (err) {
log(err); log(err);
throw err; throw err;
@ -290,7 +199,7 @@ export const createResolvers = async (service: Service): Promise<any> => {
updateProject: async ( updateProject: async (
_: any, _: any,
{ projectId, data }: { projectId: string; data: DeepPartial<Project> }, { projectId, data }: { projectId: string; data: DeepPartial<Project> }
) => { ) => {
try { try {
return await service.updateProject(projectId, data); return await service.updateProject(projectId, data);
@ -303,12 +212,10 @@ export const createResolvers = async (service: Service): Promise<any> => {
redeployToProd: async ( redeployToProd: async (
_: any, _: any,
{ deploymentId }: { deploymentId: string }, { deploymentId }: { deploymentId: string },
context: any, context: any
) => { ) => {
try { try {
return Boolean( return Boolean(await service.redeployToProd(context.user, deploymentId));
await service.redeployToProd(context.user, deploymentId),
);
} catch (err) { } catch (err) {
log(err); log(err);
return false; return false;
@ -337,8 +244,8 @@ export const createResolvers = async (service: Service): Promise<any> => {
_: any, _: any,
{ {
projectId, projectId,
deploymentId, deploymentId
}: { deploymentId: string; projectId: string }, }: { deploymentId: string; projectId: string }
) => { ) => {
try { try {
return await service.rollbackDeployment(projectId, deploymentId); return await service.rollbackDeployment(projectId, deploymentId);
@ -350,7 +257,9 @@ export const createResolvers = async (service: Service): Promise<any> => {
deleteDeployment: async ( deleteDeployment: async (
_: any, _: any,
{ deploymentId }: { deploymentId: string }, {
deploymentId
}: { deploymentId: string; }
) => { ) => {
try { try {
return await service.deleteDeployment(deploymentId); return await service.deleteDeployment(deploymentId);
@ -362,7 +271,7 @@ export const createResolvers = async (service: Service): Promise<any> => {
addDomain: async ( addDomain: async (
_: any, _: any,
{ projectId, data }: { projectId: string; data: { name: string } }, { projectId, data }: { projectId: string; data: { name: string } }
) => { ) => {
try { try {
return Boolean(await service.addDomain(projectId, data)); return Boolean(await service.addDomain(projectId, data));
@ -374,7 +283,7 @@ export const createResolvers = async (service: Service): Promise<any> => {
updateDomain: async ( updateDomain: async (
_: any, _: any,
{ domainId, data }: { domainId: string; data: DeepPartial<Domain> }, { domainId, data }: { domainId: string; data: DeepPartial<Domain> }
) => { ) => {
try { try {
return await service.updateDomain(domainId, data); return await service.updateDomain(domainId, data);
@ -387,7 +296,7 @@ export const createResolvers = async (service: Service): Promise<any> => {
authenticateGitHub: async ( authenticateGitHub: async (
_: any, _: any,
{ code }: { code: string }, { code }: { code: string },
context: any, context: any
) => { ) => {
try { try {
return await service.authenticateGitHub(code, context.user); return await service.authenticateGitHub(code, context.user);
@ -399,14 +308,12 @@ export const createResolvers = async (service: Service): Promise<any> => {
unauthenticateGitHub: async (_: any, __: object, context: any) => { unauthenticateGitHub: async (_: any, __: object, context: any) => {
try { try {
return service.unauthenticateGitHub(context.user, { return service.unauthenticateGitHub(context.user, { gitHubToken: null });
gitHubToken: null,
});
} catch (err) { } catch (err) {
log(err); log(err);
return false; return false;
} }
}, }
}, }
}; };
}; };

View File

@ -19,7 +19,6 @@ router.get('/registration/:email', async (req, res) => {
}); });
router.post('/register', async (req, res) => { router.post('/register', async (req, res) => {
console.log('Register', req.body);
const { email, challenge, attestation } = req.body; const { email, challenge, attestation } = req.body;
const user = await createUser(req.app.get('service'), { const user = await createUser(req.app.get('service'), {
challenge, challenge,
@ -27,19 +26,18 @@ router.post('/register', async (req, res) => {
userEmail: email, userEmail: email,
userName: email.split('@')[0], userName: email.split('@')[0],
}); });
req.session.address = user.id; req.session.userId = user.id;
res.sendStatus(200); res.sendStatus(200);
}); });
router.post('/authenticate', async (req, res) => { router.post('/authenticate', async (req, res) => {
console.log('Authenticate', req.body);
const { signedWhoamiRequest } = req.body; const { signedWhoamiRequest } = req.body;
const user = await authenticateUser( const user = await authenticateUser(
req.app.get('service'), req.app.get('service'),
signedWhoamiRequest, signedWhoamiRequest,
); );
if (user) { if (user) {
req.session.address = user.id; req.session.userId = user.id;
res.sendStatus(200); res.sendStatus(200);
} else { } else {
res.sendStatus(401); res.sendStatus(401);
@ -47,10 +45,11 @@ router.post('/authenticate', async (req, res) => {
}); });
// //
// SIWE Auth // Lit
// //
router.post('/validate', async (req, res) => { router.post('/validate', async (req, res) => {
const { message, signature } = req.body; const { message, signature, action } = req.body;
const { success, data } = await new SiweMessage(message).verify({ const { success, data } = await new SiweMessage(message).verify({
signature, signature,
}); });
@ -61,20 +60,23 @@ router.post('/validate', async (req, res) => {
const service: Service = req.app.get('service'); const service: Service = req.app.get('service');
const user = await service.getUserByEthAddress(data.address); const user = await service.getUserByEthAddress(data.address);
if (!user) { if (action === 'signup') {
if (user) {
return res.send({ success: false, error: 'user_already_exists' });
}
const newUser = await service.createUser({ const newUser = await service.createUser({
ethAddress: data.address, ethAddress: data.address,
email: `${data.address}@example.com`, email: '',
name: '',
subOrgId: '', subOrgId: '',
turnkeyWalletId: '', turnkeyWalletId: '',
}); });
req.session.userId = newUser.id;
// SIWESession from the web3modal library requires both address and chain ID } else if (action === 'login') {
req.session.address = newUser.id; if (!user) {
req.session.chainId = data.chainId; return res.send({ success: false, error: 'user_not_found' });
} else { }
req.session.address = user.id; req.session.userId = user.id;
req.session.chainId = data.chainId;
} }
res.send({ success }); res.send({ success });
@ -84,10 +86,9 @@ router.post('/validate', async (req, res) => {
// General // General
// //
router.get('/session', (req, res) => { router.get('/session', (req, res) => {
if (req.session.address && req.session.chainId) { if (req.session.userId) {
res.send({ res.send({
address: req.session.address, userId: req.session.userId,
chainId: req.session.chainId
}); });
} else { } else {
res.status(401).send({ error: 'Unauthorized: No active session' }); res.status(401).send({ error: 'Unauthorized: No active session' });
@ -95,12 +96,9 @@ router.get('/session', (req, res) => {
}); });
router.post('/logout', (req, res) => { router.post('/logout', (req, res) => {
req.session.destroy((err) => { // This is how you clear cookie-session
if (err) { (req as any).session = null;
return res.send({ success: false }); res.send({ success: true });
}
res.send({ success: true });
});
}); });
export default router; export default router;

View File

@ -1,9 +0,0 @@
import { Router } from 'express';
const router = Router();
router.get('/version', async (req, res) => {
return res.send({ version: '0.0.9' });
});
export default router;

View File

@ -22,13 +22,6 @@ enum DeploymentStatus {
Deleting Deleting
} }
enum AuctionStatus {
completed
reveal
commit
expired
}
enum DomainStatus { enum DomainStatus {
Live Live
Pending Pending
@ -72,13 +65,8 @@ type Project {
repository: String! repository: String!
prodBranch: String! prodBranch: String!
description: String description: String
deployers: [Deployer!]
auctionId: String
fundsReleased: Boolean
template: String template: String
framework: String framework: String
paymentAddress: String!
txHash: String!
webhooks: [String!] webhooks: [String!]
members: [ProjectMember!] members: [ProjectMember!]
environmentVariables: [EnvironmentVariable!] environmentVariables: [EnvironmentVariable!]
@ -86,7 +74,7 @@ type Project {
updatedAt: String! updatedAt: String!
organization: Organization! organization: Organization!
icon: String icon: String
baseDomains: [String!] subDomain: String
} }
type ProjectMember { type ProjectMember {
@ -106,10 +94,7 @@ type Deployment {
commitMessage: String! commitMessage: String!
url: String url: String
environment: Environment! environment: Environment!
deployer: Deployer
applicationDeploymentRequestId: String
isCurrent: Boolean! isCurrent: Boolean!
baseDomain: String
status: DeploymentStatus! status: DeploymentStatus!
createdAt: String! createdAt: String!
updatedAt: String! updatedAt: String!
@ -135,17 +120,6 @@ type EnvironmentVariable {
updatedAt: String! updatedAt: String!
} }
type Deployer {
deployerLrn: String!
deployerId: String!
deployerApiUrl: String!
minimumPayment: String
paymentAddress: String
createdAt: String!
updatedAt: String!
baseDomain: String
}
type AuthResult { type AuthResult {
token: String! token: String!
} }
@ -156,23 +130,11 @@ input AddEnvironmentVariableInput {
value: String! value: String!
} }
input AddProjectFromTemplateInput {
templateOwner: String!
templateRepo: String!
owner: String!
name: String!
isPrivate: Boolean!
paymentAddress: String!
txHash: String!
}
input AddProjectInput { input AddProjectInput {
name: String! name: String!
repository: String! repository: String!
prodBranch: String! prodBranch: String!
template: String template: String
paymentAddress: String!
txHash: String!
} }
input UpdateProjectInput { input UpdateProjectInput {
@ -212,48 +174,6 @@ input FilterDomainsInput {
status: DomainStatus 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!]!
}
input AuctionParams {
maxPrice: String,
numProviders: Int,
}
type Query { type Query {
user: User! user: User!
organizations: [Organization!] organizations: [Organization!]
@ -264,11 +184,7 @@ type Query {
environmentVariables(projectId: String!): [EnvironmentVariable!] environmentVariables(projectId: String!): [EnvironmentVariable!]
projectMembers(projectId: String!): [ProjectMember!] projectMembers(projectId: String!): [ProjectMember!]
searchProjects(searchText: String!): [Project!] searchProjects(searchText: String!): [Project!]
getAuctionData(auctionId: String!): Auction!
domains(projectId: String!, filter: FilterDomainsInput): [Domain] domains(projectId: String!, filter: FilterDomainsInput): [Domain]
deployers: [Deployer]
address: String!
verifyTx(txHash: String!, amount: String!, senderAddress: String!): Boolean!
} }
type Mutation { type Mutation {
@ -288,20 +204,7 @@ type Mutation {
): Boolean! ): Boolean!
removeEnvironmentVariable(environmentVariableId: String!): Boolean! removeEnvironmentVariable(environmentVariableId: String!): Boolean!
updateDeploymentToProd(deploymentId: String!): Boolean! updateDeploymentToProd(deploymentId: String!): Boolean!
addProjectFromTemplate( addProject(organizationSlug: String!, data: AddProjectInput): Project!
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! updateProject(projectId: String!, data: UpdateProjectInput): Boolean!
redeployToProd(deploymentId: String!): Boolean! redeployToProd(deploymentId: String!): Boolean!
deleteProject(projectId: String!): Boolean! deleteProject(projectId: String!): Boolean!

View File

@ -8,7 +8,7 @@ import {
ApolloServerPluginLandingPageLocalDefault, ApolloServerPluginLandingPageLocalDefault,
AuthenticationError, AuthenticationError,
} from 'apollo-server-core'; } from 'apollo-server-core';
import session from 'express-session'; import cookieSession from 'cookie-session';
import { TypeSource } from '@graphql-tools/utils'; import { TypeSource } from '@graphql-tools/utils';
import { makeExecutableSchema } from '@graphql-tools/schema'; import { makeExecutableSchema } from '@graphql-tools/schema';
@ -17,18 +17,13 @@ import { ServerConfig } from './config';
import { DEFAULT_GQL_PATH } from './constants'; import { DEFAULT_GQL_PATH } from './constants';
import githubRouter from './routes/github'; import githubRouter from './routes/github';
import authRouter from './routes/auth'; import authRouter from './routes/auth';
import stagingRouter from './routes/staging';
import { Service } from './service'; import { Service } from './service';
const log = debug('snowball:server'); 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' { declare module 'express-session' {
interface SessionData { interface SessionData {
address: string; userId: string;
chainId: number;
} }
} }
@ -58,13 +53,14 @@ export const createAndStartServer = async (
context: async ({ req }) => { context: async ({ req }) => {
// https://www.apollographql.com/docs/apollo-server/v3/security/authentication#api-wide-authorization // https://www.apollographql.com/docs/apollo-server/v3/security/authentication#api-wide-authorization
const { address } = req.session; const { userId } = req.session;
if (!address) { if (!userId) {
throw new AuthenticationError('Unauthorized: No active session'); throw new AuthenticationError('Unauthorized: No active session');
} }
const user = await service.getUser(address); const user = await service.getUser(userId);
return { user }; return { user };
}, },
plugins: [ plugins: [
@ -83,25 +79,20 @@ export const createAndStartServer = async (
}), }),
); );
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) { if (trustProxy) {
// trust first proxy // trust first proxy
app.set('trust proxy', 1); app.set('trust proxy', 1);
} }
app.use( app.use(
session(sessionOptions) cookieSession({
secret: secret,
secure: new URL(appOriginUrl).protocol === 'https:',
// 23 hours (less than 24 hours to avoid sessionSigs expiration issues)
maxAge: 23 * 60 * 60 * 1000,
sameSite: new URL(appOriginUrl).protocol === 'https:' ? 'none' : 'lax',
domain: domain || undefined,
}),
); );
server.applyMiddleware({ server.applyMiddleware({
@ -118,12 +109,6 @@ export const createAndStartServer = async (
app.set('service', service); app.set('service', service);
app.use('/auth', authRouter); app.use('/auth', authRouter);
app.use('/api/github', githubRouter); 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, () => { httpServer.listen(port, host, () => {
log(`Server is listening on ${host}:${port}${server.graphqlPath}`); log(`Server is listening on ${host}:${port}${server.graphqlPath}`);

File diff suppressed because it is too large Load Diff

View File

@ -29,8 +29,6 @@ export interface GitPushEventPayload {
export interface AppDeploymentRecordAttributes { export interface AppDeploymentRecordAttributes {
application: string; application: string;
auction: string;
deployer: string;
dns: string; dns: string;
meta: string; meta: string;
name: string; name: string;
@ -43,7 +41,7 @@ export interface AppDeploymentRecordAttributes {
export interface AppDeploymentRemovalRecordAttributes { export interface AppDeploymentRemovalRecordAttributes {
deployment: string; deployment: string;
request: string; request: string;
type: 'ApplicationDeploymentRemovalRecord'; type: "ApplicationDeploymentRemovalRecord";
version: string; version: string;
} }
@ -63,42 +61,3 @@ export interface AppDeploymentRecord extends RegistryRecord {
export interface AppDeploymentRemovalRecord extends RegistryRecord { export interface AppDeploymentRemovalRecord extends RegistryRecord {
attributes: AppDeploymentRemovalRecordAttributes; attributes: AppDeploymentRemovalRecordAttributes;
} }
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;
};
}

View File

@ -1,14 +1,10 @@
import assert from 'assert';
import debug from 'debug';
import fs from 'fs-extra'; import fs from 'fs-extra';
import { Octokit } from 'octokit';
import path from 'path'; import path from 'path';
import toml from 'toml'; import toml from 'toml';
import debug from 'debug';
import { DataSource, DeepPartial, EntityTarget, ObjectLiteral } from 'typeorm'; import { DataSource, DeepPartial, EntityTarget, ObjectLiteral } from 'typeorm';
import { Config } from './config'; import { Config } from './config';
import { DEFAULT_CONFIG_FILE_PATH } from './constants'; import { DEFAULT_CONFIG_FILE_PATH } from './constants';
import { PackageJSON } from './types';
const log = debug('snowball:utils'); const log = debug('snowball:utils');
@ -80,64 +76,3 @@ export const loadAndSaveData = async <Entity extends ObjectLiteral>(
export const sleep = async (ms: number): Promise<void> => export const sleep = async (ms: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, ms)); 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<any>
): Promise<any> => {
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}`);
}
}
}

View File

@ -2,6 +2,8 @@ import * as fs from 'fs/promises';
import debug from 'debug'; import debug from 'debug';
import { getConfig } from '../src/utils'; import { getConfig } from '../src/utils';
import { Config } from '../src/config';
import { DEFAULT_CONFIG_FILE_PATH } from '../src/constants';
const log = debug('snowball:delete-database'); const log = debug('snowball:delete-database');
@ -11,7 +13,7 @@ const deleteFile = async (filePath: string) => {
}; };
const main = async () => { const main = async () => {
const config = await getConfig(); const config = await getConfig<Config>(DEFAULT_CONFIG_FILE_PATH);
deleteFile(config.database.dbPath); deleteFile(config.database.dbPath);
}; };

View File

@ -1,8 +1,8 @@
[ [
{ {
"id": "2379cf1f-a232-4ad2-ae14-4d881131cc26", "id": "2379cf1f-a232-4ad2-ae14-4d881131cc26",
"name": "Deploy Tools", "name": "Snowball Tools",
"slug": "deploy-tools" "slug": "snowball-tools-1"
}, },
{ {
"id": "7eb9b3eb-eb74-4b53-b59a-69884c82a7fb", "id": "7eb9b3eb-eb74-4b53-b59a-69884c82a7fb",

View File

@ -16,6 +16,8 @@ import {
getEntities, getEntities,
loadAndSaveData loadAndSaveData
} from '../src/utils'; } from '../src/utils';
import { Config } from '../src/config';
import { DEFAULT_CONFIG_FILE_PATH } from '../src/constants';
const log = debug('snowball:initialize-database'); const log = debug('snowball:initialize-database');
@ -154,7 +156,7 @@ const generateTestData = async (dataSource: DataSource) => {
}; };
const main = async () => { const main = async () => {
const config = await getConfig(); const config = await getConfig<Config>(DEFAULT_CONFIG_FILE_PATH);
const isDbPresent = await checkFileExists(config.database.dbPath); const isDbPresent = await checkFileExists(config.database.dbPath);
if (!isDbPresent) { if (!isDbPresent) {

View File

@ -1,40 +1,39 @@
import debug from 'debug'; import debug from 'debug';
import { parseGasAndFees, Registry } from '@cerc-io/registry-sdk'; import { Registry } from '@snowballtools/laconic-sdk';
import { DEFAULT_CONFIG_FILE_PATH } from '../src/constants';
import { Config } from '../src/config';
import { getConfig } from '../src/utils'; import { getConfig } from '../src/utils';
const log = debug('snowball:initialize-registry'); const log = debug('snowball:initialize-registry');
const DENOM = 'alnt'; const DENOM = 'aphoton';
const BOND_AMOUNT = '1000000000'; const BOND_AMOUNT = '1000000000';
async function main () { async function main () {
const { registryConfig } = await getConfig(); const { registryConfig } = await getConfig<Config>(DEFAULT_CONFIG_FILE_PATH);
// TODO: Get authority names from args // TODO: Get authority names from args
const authorityNames = ['snowballtools', registryConfig.authority]; const authorityNames = ['snowballtools', registryConfig.authority];
const registry = new Registry(registryConfig.gqlEndpoint, registryConfig.restEndpoint, {chainId: registryConfig.chainId}); const registry = new Registry(registryConfig.gqlEndpoint, registryConfig.restEndpoint, registryConfig.chainId);
const bondId = await registry.getNextBondId(registryConfig.privateKey); const bondId = await registry.getNextBondId(registryConfig.privateKey);
log('bondId:', bondId); log('bondId:', bondId);
const fee = parseGasAndFees(registryConfig.fee.gas, registryConfig.fee.fees);
await registry.createBond( await registry.createBond(
{ denom: DENOM, amount: BOND_AMOUNT }, { denom: DENOM, amount: BOND_AMOUNT },
registryConfig.privateKey, registryConfig.privateKey,
fee registryConfig.fee
); );
for await (const name of authorityNames) { for await (const name of authorityNames) {
await registry.reserveAuthority({ name }, registryConfig.privateKey, fee); await registry.reserveAuthority({ name }, registryConfig.privateKey, registryConfig.fee);
log('Reserved authority name:', name); log('Reserved authority name:', name);
await registry.setAuthorityBond( await registry.setAuthorityBond(
{ name, bondId }, { name, bondId },
registryConfig.privateKey, registryConfig.privateKey,
fee registryConfig.fee
); );
log(`Bond ${bondId} set for authority ${name}`); log(`Bond ${bondId} set for authority ${name}`);
} }

View File

@ -2,20 +2,22 @@ import debug from 'debug';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import path from 'path'; import path from 'path';
import { parseGasAndFees, Registry } from '@cerc-io/registry-sdk'; import { Registry } from '@snowballtools/laconic-sdk';
import { Config } from '../src/config';
import { DEFAULT_CONFIG_FILE_PATH } from '../src/constants';
import { getConfig } from '../src/utils'; import { getConfig } from '../src/utils';
import { Deployment, DeploymentStatus, Environment } from '../src/entity/Deployment'; import { Deployment, DeploymentStatus, Environment } from '../src/entity/Deployment';
const log = debug('snowball:publish-deploy-records'); const log = debug('snowball:publish-deploy-records');
async function main() { async function main () {
const { registryConfig, database, misc } = await getConfig(); const { registryConfig, database, misc } = await getConfig<Config>(DEFAULT_CONFIG_FILE_PATH);
const registry = new Registry( const registry = new Registry(
registryConfig.gqlEndpoint, registryConfig.gqlEndpoint,
registryConfig.restEndpoint, registryConfig.restEndpoint,
{ chainId: registryConfig.chainId } registryConfig.chainId
); );
const dataSource = new DataSource({ const dataSource = new DataSource({
@ -38,7 +40,7 @@ async function main() {
}); });
for await (const deployment of deployments) { for await (const deployment of deployments) {
const url = `https://${(deployment.project.name).toLowerCase()}-${deployment.id}.${deployment.deployer.baseDomain}`; const url = `https://${deployment.project.name}-${deployment.id}.${misc.projectDomain}`;
const applicationDeploymentRecord = { const applicationDeploymentRecord = {
type: 'ApplicationDeploymentRecord', type: 'ApplicationDeploymentRecord',
@ -59,8 +61,6 @@ async function main() {
url url
}; };
const fee = parseGasAndFees(registryConfig.fee.gas, registryConfig.fee.fees);
const result = await registry.setRecord( const result = await registry.setRecord(
{ {
privateKey: registryConfig.privateKey, privateKey: registryConfig.privateKey,
@ -68,12 +68,12 @@ async function main() {
bondId: registryConfig.bondId bondId: registryConfig.bondId
}, },
'', '',
fee registryConfig.fee
); );
// Remove deployment for project subdomain if deployment is for production environment // Remove deployment for project subdomain if deployment is for production environment
if (deployment.environment === Environment.Production) { if (deployment.environment === Environment.Production) {
applicationDeploymentRecord.url = `https://${deployment.project.name}.${deployment.deployer.baseDomain}`; applicationDeploymentRecord.url = `https://${deployment.project.subDomain}`
await registry.setRecord( await registry.setRecord(
{ {
@ -82,12 +82,12 @@ async function main() {
bondId: registryConfig.bondId bondId: registryConfig.bondId
}, },
'', '',
fee registryConfig.fee
); );
} }
log('Application deployment record data:', applicationDeploymentRecord); log('Application deployment record data:', applicationDeploymentRecord);
log(`Application deployment record published: ${result.id}`); log(`Application deployment record published: ${result.data.id}`);
} }
} }

View File

@ -2,20 +2,22 @@ import debug from 'debug';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import path from 'path'; import path from 'path';
import { parseGasAndFees, Registry } from '@cerc-io/registry-sdk'; import { Registry } from '@cerc-io/laconic-sdk';
import { Config } from '../src/config';
import { DEFAULT_CONFIG_FILE_PATH } from '../src/constants';
import { getConfig } from '../src/utils'; import { getConfig } from '../src/utils';
import { Deployment, DeploymentStatus } from '../src/entity/Deployment'; import { Deployment, DeploymentStatus } from '../src/entity/Deployment';
const log = debug('snowball:publish-deployment-removal-records'); const log = debug('snowball:publish-deployment-removal-records');
async function main () { async function main () {
const { registryConfig, database } = await getConfig(); const { registryConfig, database, misc } = await getConfig<Config>(DEFAULT_CONFIG_FILE_PATH);
const registry = new Registry( const registry = new Registry(
registryConfig.gqlEndpoint, registryConfig.gqlEndpoint,
registryConfig.restEndpoint, registryConfig.restEndpoint,
{ chainId: registryConfig.chainId } registryConfig.chainId
); );
const dataSource = new DataSource({ const dataSource = new DataSource({
@ -45,8 +47,6 @@ async function main () {
request: deployment.applicationDeploymentRemovalRequestId, request: deployment.applicationDeploymentRemovalRequestId,
} }
const fee = parseGasAndFees(registryConfig.fee.gas, registryConfig.fee.fees);
const result = await registry.setRecord( const result = await registry.setRecord(
{ {
privateKey: registryConfig.privateKey, privateKey: registryConfig.privateKey,
@ -54,11 +54,11 @@ async function main () {
bondId: registryConfig.bondId bondId: registryConfig.bondId
}, },
'', '',
fee registryConfig.fee
); );
log('Application deployment removal record data:', applicationDeploymentRemovalRecord); log('Application deployment removal record data:', applicationDeploymentRemovalRecord);
log(`Application deployment removal record published: ${result.id}`); log(`Application deployment removal record published: ${result.data.id}`);
} }
} }

View File

@ -1,3 +0,0 @@
REGISTRY_BOND_ID=
DEPLOYER_LRN=
AUTHORITY=

View File

@ -1,11 +1,9 @@
# deployer # deployer
- Install dependencies - Install dependencies
```bash ```bash
yarn yarn
``` ```
```bash ```bash
brew install jq # if you do not have jq installed already brew install jq # if you do not have jq installed already
``` ```
@ -14,13 +12,13 @@
- To deploy frontend app to `dashboard.staging.apps.snowballtools.com` - To deploy frontend app to `dashboard.staging.apps.snowballtools.com`
```bash ```
./deploy-frontend.staging.sh ./deploy-frontend.staging.sh
``` ```
- To deploy frontend app to `dashboard.apps.snowballtools.com` - To deploy frontend app to `dashboard.apps.snowballtools.com`
```bash ```
./deploy-frontend.sh ./deploy-frontend.sh
``` ```
@ -29,7 +27,6 @@
## Notes ## Notes
- Any config env can be updated in [records/application-deployment-request.yml](records/application-deployment-request.yml) - Any config env can be updated in [records/application-deployment-request.yml](records/application-deployment-request.yml)
```yml ```yml
record: record:
... ...
@ -38,7 +35,6 @@
LACONIC_HOSTED_CONFIG_app_server_url: https://snowballtools-base-api-001.apps.snowballtools.com LACONIC_HOSTED_CONFIG_app_server_url: https://snowballtools-base-api-001.apps.snowballtools.com
... ...
``` ```
- On changing `LACONIC_HOSTED_CONFIG_app_github_clientid`, the GitHub client ID and secret need to be changed in backend config too - On changing `LACONIC_HOSTED_CONFIG_app_github_clientid`, the GitHub client ID and secret need to be changed in backend config too
## Troubleshoot ## Troubleshoot
@ -48,17 +44,14 @@
- If deployment fails due to low bond balance - If deployment fails due to low bond balance
- Check balances - Check balances
```bash ```bash
# Account balance # Account balance
yarn laconic registry account get yarn laconic cns account get
# Bond balance # Bond balance
yarn laconic registry bond get --id 99c0e9aec0ac1b8187faa579be3b54f93fafb6060ac1fd29170b860df605be32 yarn laconic cns bond get --id 99c0e9aec0ac1b8187faa579be3b54f93fafb6060ac1fd29170b860df605be32
``` ```
- Command to refill bond - Command to refill bond
```bash ```bash
yarn laconic registry bond refill --id 99c0e9aec0ac1b8187faa579be3b54f93fafb6060ac1fd29170b860df605be32 --type alnt --quantity 10000000 yarn laconic cns bond refill --id 99c0e9aec0ac1b8187faa579be3b54f93fafb6060ac1fd29170b860df605be32 --type aphoton --quantity 10000000
``` ```

View File

@ -1,10 +1,9 @@
services: services:
registry: cns:
restEndpoint: 'http://console.laconic.com:1317' restEndpoint: 'http://console.laconic.com:1317'
gqlEndpoint: 'http://console.laconic.com:9473/api' gqlEndpoint: 'http://console.laconic.com:9473/api'
userKey: 87d00f66a73e2ca428adeb49ba9164d0ad9a87edc60e33d46ad3031b9c5701fe userKey: 87d00f66a73e2ca428adeb49ba9164d0ad9a87edc60e33d46ad3031b9c5701fe
bondId: 89c75c7bc5759861d10285aff6f9e7227d6855e446b77ad5d8324822dfec7deb bondId: 098c906850b87412f02200e41f449bc79e055eab77acfef32c0b22443bb46661
chainId: laconic_9000-1 chainId: laconic_9000-1
gas: gas: 550000
fees: fees: 200000aphoton
gasPrice: 1

View File

@ -1,8 +1,9 @@
services: services:
registry: cns:
rpcEndpoint: https://laconicd-sapo.laconic.com restEndpoint: http://console.laconic.com:1317
gqlEndpoint: https://laconicd-sapo.laconic.com/api gqlEndpoint: http://console.laconic.com:9473/api
userKey: userKey: 489c9dd3931c2a2d4dd77973302dc5eb01e2a49552f9d932c58d9da823512311
bondId: bondId: 99c0e9aec0ac1b8187faa579be3b54f93fafb6060ac1fd29170b860df605be32
chainId: laconic_9000-2 chainId: laconic_9000-1
gasPrice: 1alnt gas: 1200000
fees: 200000aphoton

View File

@ -1,10 +1,5 @@
#!/bin/bash #!/bin/bash
source .env
echo "Using REGISTRY_BOND_ID: $REGISTRY_BOND_ID"
echo "Using DEPLOYER_LRN: $DEPLOYER_LRN"
echo "Using AUTHORITY: $AUTHORITY"
# Repository URL # Repository URL
REPO_URL="https://git.vdb.to/cerc-io/snowballtools-base" REPO_URL="https://git.vdb.to/cerc-io/snowballtools-base"
@ -18,131 +13,83 @@ PACKAGE_VERSION=$(jq -r '.version' ../frontend/package.json)
CURRENT_DATE_TIME=$(date -u) CURRENT_DATE_TIME=$(date -u)
CONFIG_FILE=config.yml CONFIG_FILE=config.yml
REGISTRY_BOND_ID="99c0e9aec0ac1b8187faa579be3b54f93fafb6060ac1fd29170b860df605be32"
# Reference: https://git.vdb.to/cerc-io/test-progressive-web-app/src/branch/main/scripts # Reference: https://git.vdb.to/cerc-io/test-progressive-web-app/src/branch/main/scripts
# Get latest version from registry and increment application-record version # Get latest version from registry and increment application-record version
NEW_APPLICATION_VERSION=$(yarn --silent laconic -c $CONFIG_FILE registry record list --type ApplicationRecord --all --name "deploy-frontend" 2>/dev/null | jq -r -s ".[] | sort_by(.createTime) | reverse | [ .[] | select(.bondId == \"$REGISTRY_BOND_ID\") ] | .[0].attributes.version" | awk -F. -v OFS=. '{$NF += 1 ; print}') NEW_APPLICATION_VERSION=$(yarn --silent laconic -c $CONFIG_FILE cns record list --type ApplicationRecord --all --name "snowballtools-base-frontend" 2>/dev/null | jq -r -s ".[] | sort_by(.createTime) | reverse | [ .[] | select(.bondId == \"$REGISTRY_BOND_ID\") ] | .[0].attributes.version" | awk -F. -v OFS=. '{$NF += 1 ; print}')
if [ -z "$NEW_APPLICATION_VERSION" ] || [ "1" == "$NEW_APPLICATION_VERSION" ]; then if [ -z "$NEW_APPLICATION_VERSION" ] || [ "1" == "$NEW_APPLICATION_VERSION" ]; then
# Set application-record version if no previous records were found # Set application-record version if no previous records were found
NEW_APPLICATION_VERSION=0.0.1 NEW_APPLICATION_VERSION=0.0.1
fi fi
# Generate application-deployment-request.yml
cat > ./records/application-deployment-request.yml <<EOF
record:
type: ApplicationDeploymentRequest
version: '1.0.0'
name: snowballtools-base-frontend@$PACKAGE_VERSION
application: crn://snowballtools/applications/snowballtools-base-frontend@$PACKAGE_VERSION
dns: dashboard
config:
env:
LACONIC_HOSTED_CONFIG_server_url: https://snowballtools-base-api-001.apps.snowballtools.com
LACONIC_HOSTED_CONFIG_github_clientid: b7c63b235ca1dd5639ab
LACONIC_HOSTED_CONFIG_github_templaterepo: snowball-tools-platform/test-progressive-web-app
LACONIC_HOSTED_CONFIG_github_pwa_templaterepo: snowball-tools-platform/test-progressive-web-app
LACONIC_HOSTED_CONFIG_github_image_upload_templaterepo: snowball-tools-platform/image-upload-pwa-example
LACONIC_HOSTED_CONFIG_wallet_connect_id: eda9ba18042a5ea500f358194611ece2
meta:
note: Added by Snowball @ $CURRENT_DATE_TIME
repository: "$REPO_URL"
repository_ref: $LATEST_HASH
EOF
# Generate application-record.yml with incremented version # Generate application-record.yml with incremented version
cat >./records/application-record.yml <<EOF cat > ./records/application-record.yml <<EOF
record: record:
type: ApplicationRecord type: ApplicationRecord
version: $NEW_APPLICATION_VERSION version: $NEW_APPLICATION_VERSION
repository_ref: $LATEST_HASH repository_ref: $LATEST_HASH
repository: ["$REPO_URL"] repository: ["$REPO_URL"]
app_type: webapp app_type: webapp
name: deploy-frontend name: snowballtools-base-frontend
app_version: $PACKAGE_VERSION app_version: $PACKAGE_VERSION
EOF EOF
echo "Files generated successfully" echo "Files generated successfully."
RECORD_FILE=records/application-record.yml RECORD_FILE=records/application-record.yml
# Publish ApplicationRecord # Publish ApplicationRecord
publish_response=$(yarn --silent laconic -c $CONFIG_FILE registry record publish --filename $RECORD_FILE) RECORD_ID=$(yarn --silent laconic -c $CONFIG_FILE cns record publish --filename $RECORD_FILE | jq -r '.id')
rc=$?
if [ $rc -ne 0 ]; then
echo "FATAL: Failed to publish record"
exit $rc
fi
RECORD_ID=$(echo $publish_response | jq -r '.id')
echo "ApplicationRecord published" echo "ApplicationRecord published"
echo $RECORD_ID echo $RECORD_ID
# Set name to record # Set name to record
REGISTRY_APP_LRN="lrn://$AUTHORITY/applications/deploy-frontend" REGISTRY_APP_CRN="crn://snowballtools/applications/snowballtools-base-frontend"
sleep 2 sleep 2
yarn --silent laconic -c $CONFIG_FILE registry name set "$REGISTRY_APP_LRN@${PACKAGE_VERSION}" "$RECORD_ID" yarn --silent laconic -c $CONFIG_FILE cns name set "$REGISTRY_APP_CRN@${PACKAGE_VERSION}" "$RECORD_ID"
rc=$?
if [ $rc -ne 0 ]; then
echo "FATAL: Failed to set name: $REGISTRY_APP_LRN@${PACKAGE_VERSION}"
exit $rc
fi
sleep 2 sleep 2
yarn --silent laconic -c $CONFIG_FILE registry name set "$REGISTRY_APP_LRN@${LATEST_HASH}" "$RECORD_ID" yarn --silent laconic -c $CONFIG_FILE cns name set "$REGISTRY_APP_CRN@${LATEST_HASH}" "$RECORD_ID"
rc=$?
if [ $rc -ne 0 ]; then
echo "FATAL: Failed to set hash"
exit $rc
fi
sleep 2 sleep 2
# Set name if latest release # Set name if latest release
yarn --silent laconic -c $CONFIG_FILE registry name set "$REGISTRY_APP_LRN" "$RECORD_ID" yarn --silent laconic -c $CONFIG_FILE cns name set "$REGISTRY_APP_CRN" "$RECORD_ID"
rc=$? echo "$REGISTRY_APP_CRN set for ApplicationRecord"
if [ $rc -ne 0 ]; then
echo "FATAL: Failed to set release"
exit $rc
fi
echo "$REGISTRY_APP_LRN set for ApplicationRecord"
# Check if record found for REGISTRY_APP_LRN # Check if record found for REGISTRY_APP_CRN
query_response=$(yarn --silent laconic -c $CONFIG_FILE registry name resolve "$REGISTRY_APP_LRN") APP_RECORD=$(yarn --silent laconic -c $CONFIG_FILE cns name resolve "$REGISTRY_APP_CRN" | jq '.[0]')
rc=$?
if [ $rc -ne 0 ]; then
echo "FATAL: Failed to query name"
exit $rc
fi
APP_RECORD=$(echo $query_response | jq '.[0]')
if [ -z "$APP_RECORD" ] || [ "null" == "$APP_RECORD" ]; then if [ -z "$APP_RECORD" ] || [ "null" == "$APP_RECORD" ]; then
echo "No record found for $REGISTRY_APP_LRN." echo "No record found for $REGISTRY_APP_CRN."
exit 1 exit 1
fi fi
# Get payment address for deployer
paymentAddress=$(yarn --silent laconic -c config.yml registry name resolve "$DEPLOYER_LRN" | jq -r '.[0].attributes.paymentAddress')
paymentAmount=$(yarn --silent laconic -c config.yml registry name resolve "$DEPLOYER_LRN" | jq -r '.[0].attributes.minimumPayment' | sed 's/alnt//g')
# Pay deployer if paymentAmount is not null
if [[ -n "$paymentAmount" && "$paymentAmount" != "null" ]]; then
payment=$(yarn --silent laconic -c config.yml registry tokens send --address "$paymentAddress" --type alnt --quantity "$paymentAmount")
# Extract the transaction hash
txHash=$(echo "$payment" | jq -r '.tx.hash')
echo "Paid deployer with txHash as $txHash"
else
echo "Payment amount is null; skipping payment."
fi
# Generate application-deployment-request.yml
cat >./records/application-deployment-request.yml <<EOF
record:
type: ApplicationDeploymentRequest
version: '1.0.0'
name: deploy-frontend@$PACKAGE_VERSION
application: lrn://$AUTHORITY/applications/deploy-frontend@$PACKAGE_VERSION
deployer: $DEPLOYER_LRN
dns: deploy
config:
env:
LACONIC_HOSTED_CONFIG_server_url: https://deploy-backend.apps.vaasl.io
LACONIC_HOSTED_CONFIG_github_clientid: Ov23liaet4yc0KX0iM1c
LACONIC_HOSTED_CONFIG_github_pwa_templaterepo: laconic-templates/test-progressive-web-app
LACONIC_HOSTED_CONFIG_github_image_upload_templaterepo: laconic-templates/image-upload-pwa-example
LACONIC_HOSTED_CONFIG_wallet_connect_id: 63cad7ba97391f63652161f484670e15
LACONIC_HOSTED_CONFIG_laconicd_chain_id: laconic-testnet-2
meta:
note: Added by Snowball @ $CURRENT_DATE_TIME
repository: "$REPO_URL"
repository_ref: $LATEST_HASH
payment: $txHash
EOF
RECORD_FILE=records/application-deployment-request.yml RECORD_FILE=records/application-deployment-request.yml
sleep 2 sleep 2
deployment_response=$(yarn --silent laconic -c $CONFIG_FILE registry record publish --filename $RECORD_FILE) DEPLOYMENT_REQUEST_ID=$(yarn --silent laconic -c $CONFIG_FILE cns record publish --filename $RECORD_FILE | jq -r '.id')
if [ $rc -ne 0 ]; then
echo "FATAL: Failed to query deployment request"
exit $rc
fi
DEPLOYMENT_REQUEST_ID=$(echo $deployment_response | jq -r '.id')
echo "ApplicationDeploymentRequest published" echo "ApplicationDeploymentRequest published"
echo $DEPLOYMENT_REQUEST_ID echo $DEPLOYMENT_REQUEST_ID

View File

@ -18,7 +18,7 @@ REGISTRY_BOND_ID="098c906850b87412f02200e41f449bc79e055eab77acfef32c0b22443bb466
# Reference: https://git.vdb.to/cerc-io/test-progressive-web-app/src/branch/main/scripts # Reference: https://git.vdb.to/cerc-io/test-progressive-web-app/src/branch/main/scripts
# Get latest version from registry and increment application-record version # Get latest version from registry and increment application-record version
NEW_APPLICATION_VERSION=$(yarn --silent laconic -c $CONFIG_FILE registry record list --type ApplicationRecord --all --name "staging-snowballtools-base-frontend" 2>/dev/null | jq -r -s ".[] | sort_by(.createTime) | reverse | [ .[] | select(.bondId == \"$REGISTRY_BOND_ID\") ] | .[0].attributes.version" | awk -F. -v OFS=. '{$NF += 1 ; print}') NEW_APPLICATION_VERSION=$(yarn --silent laconic -c $CONFIG_FILE cns record list --type ApplicationRecord --all --name "staging-snowballtools-base-frontend" 2>/dev/null | jq -r -s ".[] | sort_by(.createTime) | reverse | [ .[] | select(.bondId == \"$REGISTRY_BOND_ID\") ] | .[0].attributes.version" | awk -F. -v OFS=. '{$NF += 1 ; print}')
if [ -z "$NEW_APPLICATION_VERSION" ] || [ "1" == "$NEW_APPLICATION_VERSION" ]; then if [ -z "$NEW_APPLICATION_VERSION" ] || [ "1" == "$NEW_APPLICATION_VERSION" ]; then
# Set application-record version if no previous records were found # Set application-record version if no previous records were found
@ -26,27 +26,26 @@ if [ -z "$NEW_APPLICATION_VERSION" ] || [ "1" == "$NEW_APPLICATION_VERSION" ]; t
fi fi
# Generate application-deployment-request.yml # Generate application-deployment-request.yml
cat >./staging-records/application-deployment-request.yml <<EOF cat > ./staging-records/application-deployment-request.yml <<EOF
record: record:
type: ApplicationDeploymentRequest type: ApplicationDeploymentRequest
version: '1.0.0' version: '1.0.0'
name: staging-snowballtools-base-frontend@$PACKAGE_VERSION name: staging-snowballtools-base-frontend@$PACKAGE_VERSION
application: lrn://staging-snowballtools/applications/staging-snowballtools-base-frontend@$PACKAGE_VERSION application: crn://staging-snowballtools/applications/staging-snowballtools-base-frontend@$PACKAGE_VERSION
dns: dashboard.staging.apps.snowballtools.com dns: dashboard.staging.apps.snowballtools.com
config: config:
env: env:
LACONIC_HOSTED_CONFIG_server_url: https://snowballtools-base-api.staging.apps.snowballtools.com LACONIC_HOSTED_CONFIG_server_url: https://snowballtools-base-api.staging.apps.snowballtools.com
LACONIC_HOSTED_CONFIG_github_clientid: Ov23liOaoahRTYd4nSCV LACONIC_HOSTED_CONFIG_github_clientid: 905c09553f527d2cdff5
LACONIC_HOSTED_CONFIG_github_templaterepo: snowball-tools/test-progressive-web-app LACONIC_HOSTED_CONFIG_github_templaterepo: snowball-tools/test-progressive-web-app
LACONIC_HOSTED_CONFIG_github_pwa_templaterepo: snowball-tools/test-progressive-web-app LACONIC_HOSTED_CONFIG_github_pwa_templaterepo: snowball-tools/image-upload-pwa-example
LACONIC_HOSTED_CONFIG_github_image_upload_templaterepo: snowball-tools/image-upload-pwa-example LACONIC_HOSTED_CONFIG_github_image_upload_templaterepo: snowball-tools-platform/image-upload-pwa-example
LACONIC_HOSTED_CONFIG_wallet_connect_id: eda9ba18042a5ea500f358194611ece2 LACONIC_HOSTED_CONFIG_wallet_connect_id: eda9ba18042a5ea500f358194611ece2
LACONIC_HOSTED_CONFIG_laconicd_chain_id: laconic-testnet-2
LACONIC_HOSTED_CONFIG_lit_relay_api_key: 15DDD969-E75F-404D-AAD9-58A37C4FD354_snowball LACONIC_HOSTED_CONFIG_lit_relay_api_key: 15DDD969-E75F-404D-AAD9-58A37C4FD354_snowball
LACONIC_HOSTED_CONFIG_aplchemy_api_key: THvPart_gqI5x02RNYSBntlmwA66I_qc LACONIC_HOSTED_CONFIG_aplchemy_api_key: THvPart_gqI5x02RNYSBntlmwA66I_qc
LACONIC_HOSTED_CONFIG_bugsnag_api_key: 8c480cd5386079f9dd44f9581264a073 LACONIC_HOSTED_CONFIG_bugsnag_api_key: 8c480cd5386079f9dd44f9581264a073
LACONIC_HOSTED_CONFIG_passkey_wallet_rpid: dashboard.staging.apps.snowballtools.com LACONIC_HOSTED_CONFIG_passkey_wallet_rpid: localhost
LACONIC_HOSTED_CONFIG_turnkey_api_base_url: https://api.turnkey.com LACONIC_HOSTED_CONFIG_turnkey_api_base_url: https://api.turnkey.com/
LACONIC_HOSTED_CONFIG_turnkey_organization_id: 5049ae99-5bca-40b3-8317-504384d4e591 LACONIC_HOSTED_CONFIG_turnkey_organization_id: 5049ae99-5bca-40b3-8317-504384d4e591
meta: meta:
note: Added by Snowball @ $CURRENT_DATE_TIME note: Added by Snowball @ $CURRENT_DATE_TIME
@ -55,7 +54,7 @@ record:
EOF EOF
# Generate application-record.yml with incremented version # Generate application-record.yml with incremented version
cat >./staging-records/application-record.yml <<EOF cat > ./staging-records/application-record.yml <<EOF
record: record:
type: ApplicationRecord type: ApplicationRecord
version: $NEW_APPLICATION_VERSION version: $NEW_APPLICATION_VERSION
@ -71,64 +70,32 @@ echo "Files generated successfully."
RECORD_FILE=staging-records/application-record.yml RECORD_FILE=staging-records/application-record.yml
# Publish ApplicationRecord # Publish ApplicationRecord
publish_response=$(yarn --silent laconic -c $CONFIG_FILE registry record publish --filename $RECORD_FILE) RECORD_ID=$(yarn --silent laconic -c $CONFIG_FILE cns record publish --filename $RECORD_FILE | jq -r '.id')
rc=$?
if [ $rc -ne 0 ]; then
echo "FATAL: Failed to publish record"
exit $rc
fi
RECORD_ID=$(echo $publish_response | jq -r '.id')
echo "ApplicationRecord published" echo "ApplicationRecord published"
echo $RECORD_ID echo $RECORD_ID
# Set name to record # Set name to record
REGISTRY_APP_LRN="lrn://staging-snowballtools/applications/staging-snowballtools-base-frontend" REGISTRY_APP_CRN="crn://staging-snowballtools/applications/staging-snowballtools-base-frontend"
sleep 2 sleep 2
yarn --silent laconic -c $CONFIG_FILE registry name set "$REGISTRY_APP_LRN@${PACKAGE_VERSION}" "$RECORD_ID" yarn --silent laconic -c $CONFIG_FILE cns name set "$REGISTRY_APP_CRN@${PACKAGE_VERSION}" "$RECORD_ID"
rc=$?
if [ $rc -ne 0 ]; then
echo "FATAL: Failed to set name: $REGISTRY_APP_LRN@${PACKAGE_VERSION}"
exit $rc
fi
sleep 2 sleep 2
yarn --silent laconic -c $CONFIG_FILE registry name set "$REGISTRY_APP_LRN@${LATEST_HASH}" "$RECORD_ID" yarn --silent laconic -c $CONFIG_FILE cns name set "$REGISTRY_APP_CRN@${LATEST_HASH}" "$RECORD_ID"
rc=$?
if [ $rc -ne 0 ]; then
echo "FATAL: Failed to set hash"
exit $rc
fi
sleep 2 sleep 2
# Set name if latest release # Set name if latest release
yarn --silent laconic -c $CONFIG_FILE registry name set "$REGISTRY_APP_LRN" "$RECORD_ID" yarn --silent laconic -c $CONFIG_FILE cns name set "$REGISTRY_APP_CRN" "$RECORD_ID"
rc=$? echo "$REGISTRY_APP_CRN set for ApplicationRecord"
if [ $rc -ne 0 ]; then
echo "FATAL: Failed to set release"
exit $rc
fi
echo "$REGISTRY_APP_LRN set for ApplicationRecord"
# Check if record found for REGISTRY_APP_LRN # Check if record found for REGISTRY_APP_CRN
query_response=$(yarn --silent laconic -c $CONFIG_FILE registry name resolve "$REGISTRY_APP_LRN") APP_RECORD=$(yarn --silent laconic -c $CONFIG_FILE cns name resolve "$REGISTRY_APP_CRN" | jq '.[0]')
rc=$?
if [ $rc -ne 0 ]; then
echo "FATAL: Failed to query name"
exit $rc
fi
APP_RECORD=$(echo $query_response | jq '.[0]')
if [ -z "$APP_RECORD" ] || [ "null" == "$APP_RECORD" ]; then if [ -z "$APP_RECORD" ] || [ "null" == "$APP_RECORD" ]; then
echo "No record found for $REGISTRY_APP_LRN." echo "No record found for $REGISTRY_APP_CRN."
exit 1 exit 1
fi fi
RECORD_FILE=staging-records/application-deployment-request.yml RECORD_FILE=staging-records/application-deployment-request.yml
sleep 2 sleep 2
deployment_response=$(yarn --silent laconic -c $CONFIG_FILE registry record publish --filename $RECORD_FILE) DEPLOYMENT_REQUEST_ID=$(yarn --silent laconic -c $CONFIG_FILE cns record publish --filename $RECORD_FILE | jq -r '.id')
if [ $rc -ne 0 ]; then
echo "FATAL: Failed to query deployment request"
exit $rc
fi
DEPLOYMENT_REQUEST_ID=$(echo $deployment_response | jq -r '.id')
echo "ApplicationDeploymentRequest published" echo "ApplicationDeploymentRequest published"
echo $DEPLOYMENT_REQUEST_ID echo $DEPLOYMENT_REQUEST_ID

View File

@ -4,6 +4,6 @@
"main": "index.js", "main": "index.js",
"private": true, "private": true,
"devDependencies": { "devDependencies": {
"@cerc-io/laconic-registry-cli": "^0.2.9" "@snowballtools/laconic-registry-cli": "^0.1.13"
} }
} }

View File

@ -1,16 +1,17 @@
record: record:
type: ApplicationDeploymentRequest type: ApplicationDeploymentRequest
version: '1.0.0' version: '1.0.0'
name: deploy-frontend@1.0.0 name: snowballtools-base-frontend@0.1.8
application: lrn://vaasl/applications/deploy-frontend@1.0.0 application: crn://snowballtools/applications/snowballtools-base-frontend@0.1.8
dns: deploy dns: dashboard
config: config:
env: env:
LACONIC_HOSTED_CONFIG_server_url: https://deploy-backend.apps.vaasl.io LACONIC_HOSTED_CONFIG_app_server_url: https://snowballtools-base-api-001.apps.snowballtools.com
LACONIC_HOSTED_CONFIG_github_clientid: Ov23liaet4yc0KX0iM1c LACONIC_HOSTED_CONFIG_app_github_clientid: b7c63b235ca1dd5639ab
LACONIC_HOSTED_CONFIG_github_pwa_templaterepo: laconic-templates/test-progressive-web-app LACONIC_HOSTED_CONFIG_app_github_templaterepo: snowball-tools-platform/test-progressive-web-app
LACONIC_HOSTED_CONFIG_github_image_upload_templaterepo: laconic-templates/image-upload-pwa-example LACONIC_HOSTED_CONFIG_app_github_pwa_templaterepo: snowball-tools-platform/test-progressive-web-app
LACONIC_HOSTED_CONFIG_wallet_connect_id: 63cad7ba97391f63652161f484670e15 LACONIC_HOSTED_CONFIG_app_github_image_upload_templaterepo: snowball-tools-platform/image-upload-pwa-example
LACONIC_HOSTED_CONFIG_app_wallet_connect_id: eda9ba18042a5ea500f358194611ece2
meta: meta:
note: Added by Snowball @ Thu Apr 4 14:49:41 UTC 2024 note: Added by Snowball @ Thu Apr 4 14:49:41 UTC 2024
repository: "https://git.vdb.to/cerc-io/snowballtools-base" repository: "https://git.vdb.to/cerc-io/snowballtools-base"

View File

@ -4,5 +4,5 @@ record:
repository_ref: 351db16336eacc3e1f9119ceb8d1282b8e27a27e repository_ref: 351db16336eacc3e1f9119ceb8d1282b8e27a27e
repository: ["https://git.vdb.to/cerc-io/snowballtools-base"] repository: ["https://git.vdb.to/cerc-io/snowballtools-base"]
app_type: webapp app_type: webapp
name: deploy-frontend name: snowballtools-base-frontend
app_version: 1.0.0 app_version: 0.1.8

View File

@ -7,18 +7,18 @@ record:
config: config:
env: env:
LACONIC_HOSTED_CONFIG_server_url: https://snowballtools-base-api.staging.apps.snowballtools.com LACONIC_HOSTED_CONFIG_server_url: https://snowballtools-base-api.staging.apps.snowballtools.com
LACONIC_HOSTED_CONFIG_github_clientid: Ov23liOaoahRTYd4nSCV LACONIC_HOSTED_CONFIG_github_clientid: 905c09553f527d2cdff5
LACONIC_HOSTED_CONFIG_github_templaterepo: snowball-tools/test-progressive-web-app LACONIC_HOSTED_CONFIG_github_templaterepo: snowball-tools/test-progressive-web-app
LACONIC_HOSTED_CONFIG_github_pwa_templaterepo: snowball-tools/test-progressive-web-app LACONIC_HOSTED_CONFIG_github_pwa_templaterepo: snowball-tools/image-upload-pwa-example
LACONIC_HOSTED_CONFIG_github_image_upload_templaterepo: snowball-tools/image-upload-pwa-example LACONIC_HOSTED_CONFIG_github_image_upload_templaterepo: snowball-tools-platform/image-upload-pwa-example
LACONIC_HOSTED_CONFIG_wallet_connect_id: eda9ba18042a5ea500f358194611ece2 LACONIC_HOSTED_CONFIG_wallet_connect_id: eda9ba18042a5ea500f358194611ece2
LACONIC_HOSTED_CONFIG_lit_relay_api_key: 15DDD969-E75F-404D-AAD9-58A37C4FD354_snowball LACONIC_HOSTED_CONFIG_lit_relay_api_key: 15DDD969-E75F-404D-AAD9-58A37C4FD354_snowball
LACONIC_HOSTED_CONFIG_aplchemy_api_key: THvPart_gqI5x02RNYSBntlmwA66I_qc LACONIC_HOSTED_CONFIG_aplchemy_api_key: THvPart_gqI5x02RNYSBntlmwA66I_qc
LACONIC_HOSTED_CONFIG_bugsnag_api_key: 8c480cd5386079f9dd44f9581264a073 LACONIC_HOSTED_CONFIG_bugsnag_api_key: 8c480cd5386079f9dd44f9581264a073
LACONIC_HOSTED_CONFIG_passkey_wallet_rpid: dashboard.staging.apps.snowballtools.com LACONIC_HOSTED_CONFIG_passkey_wallet_rpid: localhost
LACONIC_HOSTED_CONFIG_turnkey_api_base_url: https://api.turnkey.com LACONIC_HOSTED_CONFIG_turnkey_api_base_url: https://api.turnkey.com/
LACONIC_HOSTED_CONFIG_turnkey_organization_id: 5049ae99-5bca-40b3-8317-504384d4e591 LACONIC_HOSTED_CONFIG_turnkey_organization_id: 5049ae99-5bca-40b3-8317-504384d4e591
meta: meta:
note: Added by Snowball @ Mon Jun 24 23:51:48 UTC 2024 note: Added by Snowball @ Tuesday 21 May 2024 06:17:23 AM UTC
repository: "https://git.vdb.to/cerc-io/snowballtools-base" repository: "https://git.vdb.to/cerc-io/snowballtools-base"
repository_ref: 61e3e88a6c9d57e95441059369ee5a46f5c07601 repository_ref: 8488cfab8353321ed05c4234bf1b914c9ad3aa99

View File

@ -1,7 +1,7 @@
record: record:
type: ApplicationRecord type: ApplicationRecord
version: 0.0.1 version: 0.0.2
repository_ref: 61e3e88a6c9d57e95441059369ee5a46f5c07601 repository_ref: 8488cfab8353321ed05c4234bf1b914c9ad3aa99
repository: ["https://git.vdb.to/cerc-io/snowballtools-base"] repository: ["https://git.vdb.to/cerc-io/snowballtools-base"]
app_type: webapp app_type: webapp
name: staging-snowballtools-base-frontend name: staging-snowballtools-base-frontend

View File

@ -2,7 +2,7 @@
Check if the live web app deployer is in a working state Check if the live web app deployer is in a working state
- Web app repo used: <https://github.com/snowball-tools/test-progressive-web-app> (main branch) - Web app repo used: https://github.com/snowball-tools-platform/test-progressive-web-app (main branch)
- Config used: [../config.yml](../config.yml) - Config used: [../config.yml](../config.yml)
- The script [test-webapp-deployment-undeployment.sh](./test-webapp-deployment-undeployment.sh) performs the following: - The script [test-webapp-deployment-undeployment.sh](./test-webapp-deployment-undeployment.sh) performs the following:
- Create / update [`ApplicationRecord`](./records/application-record.yml) and [`ApplicationDeploymentRequest`](./records/application-deployment-request.yml) records with latest meta data from the repo - Create / update [`ApplicationRecord`](./records/application-record.yml) and [`ApplicationDeploymentRequest`](./records/application-deployment-request.yml) records with latest meta data from the repo

View File

@ -1,6 +1,6 @@
record: record:
type: ApplicationDeploymentRequest type: ApplicationDeploymentRequest
version: "1.0.0" version: '1.0.0'
name: deployment-test-app@0.1.24 name: deployment-test-app@0.1.24
application: crn://snowballtools/applications/deployment-test-app@0.1.24 application: crn://snowballtools/applications/deployment-test-app@0.1.24
dns: deployment-ci-test dns: deployment-ci-test
@ -11,5 +11,5 @@ record:
CERC_WEBAPP_DEBUG: 0 CERC_WEBAPP_DEBUG: 0
meta: meta:
note: Deployment test @ Thu 11 Apr 2024 07:29:19 AM UTC note: Deployment test @ Thu 11 Apr 2024 07:29:19 AM UTC
repository: "https://github.com/snowball-tools/test-progressive-web-app" repository: "https://github.com/snowball-tools-platform/test-progressive-web-app"
repository_ref: 05819619487a0d2dbc5453b6d1ccff3044c0dd26 repository_ref: 05819619487a0d2dbc5453b6d1ccff3044c0dd26

View File

@ -2,7 +2,7 @@ record:
type: ApplicationRecord type: ApplicationRecord
version: 0.0.1 version: 0.0.1
repository_ref: 05819619487a0d2dbc5453b6d1ccff3044c0dd26 repository_ref: 05819619487a0d2dbc5453b6d1ccff3044c0dd26
repository: ["https://github.com/snowball-tools/test-progressive-web-app"] repository: ["https://github.com/snowball-tools-platform/test-progressive-web-app"]
app_type: webapp app_type: webapp
name: deployment-test-app name: deployment-test-app
app_version: 0.1.24 app_version: 0.1.24

View File

@ -1,7 +1,7 @@
#!/bin/bash #!/bin/bash
# Repository URL # Repository URL
REPO_URL="https://github.com/snowball-tools/test-progressive-web-app" REPO_URL="https://github.com/snowball-tools-platform/test-progressive-web-app"
# Get the latest commit hash from the repository # Get the latest commit hash from the repository
LATEST_HASH=$(git ls-remote $REPO_URL HEAD | awk '{print $1}') LATEST_HASH=$(git ls-remote $REPO_URL HEAD | awk '{print $1}')
@ -22,7 +22,7 @@ REGISTRY_BOND_ID="99c0e9aec0ac1b8187faa579be3b54f93fafb6060ac1fd29170b860df605be
APP_NAME=deployment-test-app APP_NAME=deployment-test-app
# Get latest version from registry and increment application-record version # Get latest version from registry and increment application-record version
NEW_APPLICATION_VERSION=$(yarn --silent laconic -c $CONFIG_FILE registry record list --type ApplicationRecord --all --name "$APP_NAME" 2>/dev/null | jq -r -s ".[] | sort_by(.createTime) | reverse | [ .[] | select(.bondId == \"$REGISTRY_BOND_ID\") ] | .[0].attributes.version" | awk -F. -v OFS=. '{$NF += 1 ; print}') NEW_APPLICATION_VERSION=$(yarn --silent laconic -c $CONFIG_FILE cns record list --type ApplicationRecord --all --name "$APP_NAME" 2>/dev/null | jq -r -s ".[] | sort_by(.createTime) | reverse | [ .[] | select(.bondId == \"$REGISTRY_BOND_ID\") ] | .[0].attributes.version" | awk -F. -v OFS=. '{$NF += 1 ; print}')
if [ -z "$NEW_APPLICATION_VERSION" ] || [ "1" == "$NEW_APPLICATION_VERSION" ]; then if [ -z "$NEW_APPLICATION_VERSION" ] || [ "1" == "$NEW_APPLICATION_VERSION" ]; then
# Set application-record version if no previous records were found # Set application-record version if no previous records were found
@ -32,7 +32,7 @@ fi
# Generate application-record.yml with incremented version # Generate application-record.yml with incremented version
RECORD_FILE=packages/deployer/test/records/application-record.yml RECORD_FILE=packages/deployer/test/records/application-record.yml
cat >$RECORD_FILE <<EOF cat > $RECORD_FILE <<EOF
record: record:
type: ApplicationRecord type: ApplicationRecord
version: $NEW_APPLICATION_VERSION version: $NEW_APPLICATION_VERSION
@ -46,12 +46,12 @@ EOF
# Generate application-deployment-request.yml # Generate application-deployment-request.yml
REQUEST_RECORD_FILE=packages/deployer/test/records/application-deployment-request.yml REQUEST_RECORD_FILE=packages/deployer/test/records/application-deployment-request.yml
cat >$REQUEST_RECORD_FILE <<EOF cat > $REQUEST_RECORD_FILE <<EOF
record: record:
type: ApplicationDeploymentRequest type: ApplicationDeploymentRequest
version: '1.0.0' version: '1.0.0'
name: $APP_NAME@$PACKAGE_VERSION name: $APP_NAME@$PACKAGE_VERSION
application: lrn://snowballtools/applications/$APP_NAME@$PACKAGE_VERSION application: crn://snowballtools/applications/$APP_NAME@$PACKAGE_VERSION
dns: deployment-ci-test dns: deployment-ci-test
config: config:
env: env:
@ -67,31 +67,31 @@ EOF
echo "Record files generated successfully." echo "Record files generated successfully."
# Publish ApplicationRecord # Publish ApplicationRecord
RECORD_ID=$(yarn --silent laconic -c $CONFIG_FILE registry record publish --filename $RECORD_FILE | jq -r '.id') RECORD_ID=$(yarn --silent laconic -c $CONFIG_FILE cns record publish --filename $RECORD_FILE | jq -r '.id')
echo "ApplicationRecord published" echo "ApplicationRecord published"
echo $RECORD_ID echo $RECORD_ID
# Set name to record # Set name to record
REGISTRY_APP_LRN="lrn://snowballtools/applications/$APP_NAME" REGISTRY_APP_CRN="crn://snowballtools/applications/$APP_NAME"
sleep 2 sleep 2
yarn --silent laconic -c $CONFIG_FILE registry name set "$REGISTRY_APP_LRN@${PACKAGE_VERSION}" "$RECORD_ID" yarn --silent laconic -c $CONFIG_FILE cns name set "$REGISTRY_APP_CRN@${PACKAGE_VERSION}" "$RECORD_ID"
sleep 2 sleep 2
yarn --silent laconic -c $CONFIG_FILE registry name set "$REGISTRY_APP_LRN@${LATEST_HASH}" "$RECORD_ID" yarn --silent laconic -c $CONFIG_FILE cns name set "$REGISTRY_APP_CRN@${LATEST_HASH}" "$RECORD_ID"
sleep 2 sleep 2
# Set name if latest release # Set name if latest release
yarn --silent laconic -c $CONFIG_FILE registry name set "$REGISTRY_APP_LRN" "$RECORD_ID" yarn --silent laconic -c $CONFIG_FILE cns name set "$REGISTRY_APP_CRN" "$RECORD_ID"
echo "$REGISTRY_APP_LRN set for ApplicationRecord" echo "$REGISTRY_APP_CRN set for ApplicationRecord"
# Check if record exists for REGISTRY_APP_LRN # Check if record exists for REGISTRY_APP_CRN
APP_RECORD=$(yarn --silent laconic -c $CONFIG_FILE registry name resolve "$REGISTRY_APP_LRN" | jq '.[0]') APP_RECORD=$(yarn --silent laconic -c $CONFIG_FILE cns name resolve "$REGISTRY_APP_CRN" | jq '.[0]')
if [ -z "$APP_RECORD" ] || [ "null" == "$APP_RECORD" ]; then if [ -z "$APP_RECORD" ] || [ "null" == "$APP_RECORD" ]; then
echo "No record found for $REGISTRY_APP_LRN." echo "No record found for $REGISTRY_APP_CRN."
exit 1 exit 1
fi fi
sleep 2 sleep 2
DEPLOYMENT_REQUEST_ID=$(yarn --silent laconic -c $CONFIG_FILE registry record publish --filename $REQUEST_RECORD_FILE | jq -r '.id') DEPLOYMENT_REQUEST_ID=$(yarn --silent laconic -c $CONFIG_FILE cns record publish --filename $REQUEST_RECORD_FILE | jq -r '.id')
echo "ApplicationDeploymentRequest published" echo "ApplicationDeploymentRequest published"
echo $DEPLOYMENT_REQUEST_ID echo $DEPLOYMENT_REQUEST_ID
@ -102,7 +102,7 @@ MAX_RETRIES=20
# Check that a ApplicationDeploymentRecord is published # Check that a ApplicationDeploymentRecord is published
retry_count=0 retry_count=0
while true; do while true; do
deployment_records_response=$(yarn --silent laconic -c $CONFIG_FILE registry record list --type ApplicationDeploymentRecord --all --name "$APP_NAME" request $DEPLOYMENT_REQUEST_ID) deployment_records_response=$(yarn --silent laconic -c $CONFIG_FILE cns record list --type ApplicationDeploymentRecord --all --name "$APP_NAME" request $DEPLOYMENT_REQUEST_ID)
len_deployment_records=$(echo $deployment_records_response | jq 'length') len_deployment_records=$(echo $deployment_records_response | jq 'length')
# Check if number of records returned is 0 # Check if number of records returned is 0
@ -115,7 +115,7 @@ while true; do
else else
echo "ApplicationDeploymentRecord not found, retrying in $RETRY_INTERVAL sec..." echo "ApplicationDeploymentRecord not found, retrying in $RETRY_INTERVAL sec..."
sleep $RETRY_INTERVAL sleep $RETRY_INTERVAL
retry_count=$((retry_count + 1)) retry_count=$((retry_count+1))
fi fi
else else
echo "ApplicationDeploymentRecord found" echo "ApplicationDeploymentRecord found"
@ -140,7 +140,7 @@ fetched_url=$(echo $deployment_records_response | jq -r '.[0].attributes.url')
retry_count=0 retry_count=0
max_retries=10 max_retries=10
retry_interval=10 retry_interval=5
while true; do while true; do
url_response=$(curl -s -o /dev/null -I -w "%{http_code}" $fetched_url) url_response=$(curl -s -o /dev/null -I -w "%{http_code}" $fetched_url)
if [ "$url_response" = "200" ]; then if [ "$url_response" = "200" ]; then
@ -154,7 +154,7 @@ while true; do
else else
echo "Deployment URL $fetched_url is not active, received code $url_response, retrying in $retry_interval sec..." echo "Deployment URL $fetched_url is not active, received code $url_response, retrying in $retry_interval sec..."
sleep $retry_interval sleep $retry_interval
retry_count=$((retry_count + 1)) retry_count=$((retry_count+1))
fi fi
fi fi
done done
@ -162,7 +162,7 @@ done
# Generate application-deployment-removal-request.yml # Generate application-deployment-removal-request.yml
REMOVAL_REQUEST_RECORD_FILE=packages/deployer/test/records/application-deployment-removal-request.yml REMOVAL_REQUEST_RECORD_FILE=packages/deployer/test/records/application-deployment-removal-request.yml
cat >$REMOVAL_REQUEST_RECORD_FILE <<EOF cat > $REMOVAL_REQUEST_RECORD_FILE <<EOF
record: record:
deployment: $DEPLOYMENT_RECORD_ID deployment: $DEPLOYMENT_RECORD_ID
type: ApplicationDeploymentRemovalRequest type: ApplicationDeploymentRemovalRequest
@ -170,14 +170,14 @@ record:
EOF EOF
sleep 2 sleep 2
REMOVAL_REQUEST_ID=$(yarn --silent laconic -c $CONFIG_FILE registry record publish --filename $REMOVAL_REQUEST_RECORD_FILE | jq -r '.id') REMOVAL_REQUEST_ID=$(yarn --silent laconic -c $CONFIG_FILE cns record publish --filename $REMOVAL_REQUEST_RECORD_FILE | jq -r '.id')
echo "ApplicationDeploymentRemovalRequest published" echo "ApplicationDeploymentRemovalRequest published"
echo $REMOVAL_REQUEST_ID echo $REMOVAL_REQUEST_ID
# Check that an ApplicationDeploymentRemovalRecord is published # Check that an ApplicationDeploymentRemovalRecord is published
retry_count=0 retry_count=0
while true; do while true; do
removal_records_response=$(yarn --silent laconic -c $CONFIG_FILE registry record list --type ApplicationDeploymentRemovalRecord --all request $REMOVAL_REQUEST_ID) removal_records_response=$(yarn --silent laconic -c $CONFIG_FILE cns record list --type ApplicationDeploymentRemovalRecord --all request $REMOVAL_REQUEST_ID)
len_removal_records=$(echo $removal_records_response | jq 'length') len_removal_records=$(echo $removal_records_response | jq 'length')
# Check if number of records returned is 0 # Check if number of records returned is 0
@ -190,7 +190,7 @@ while true; do
else else
echo "ApplicationDeploymentRemovalRecord not found, retrying in $RETRY_INTERVAL sec..." echo "ApplicationDeploymentRemovalRecord not found, retrying in $RETRY_INTERVAL sec..."
sleep $RETRY_INTERVAL sleep $RETRY_INTERVAL
retry_count=$((retry_count + 1)) retry_count=$((retry_count+1))
fi fi
else else
echo "ApplicationDeploymentRemovalRecord found" echo "ApplicationDeploymentRemovalRecord found"
@ -217,7 +217,7 @@ while true; do
else else
echo "Deployment URL $fetched_url is still active, received code $url_response, retrying in $retry_interval sec..." echo "Deployment URL $fetched_url is still active, received code $url_response, retrying in $retry_interval sec..."
sleep $retry_interval sleep $retry_interval
retry_count=$((retry_count + 1)) retry_count=$((retry_count+1))
fi fi
fi fi
done done

View File

@ -14,6 +14,4 @@ VITE_BUGSNAG_API_KEY=
VITE_PASSKEY_WALLET_RPID= VITE_PASSKEY_WALLET_RPID=
VITE_TURNKEY_API_BASE_URL= VITE_TURNKEY_API_BASE_URL=
VITE_TURNKEY_ORGANIZATION_ID= VITE_TURNKEY_ORGANIZATION_ID=
VITE_LACONICD_CHAIN_ID=

View File

@ -9,7 +9,6 @@ import { join, dirname } from 'path';
function getAbsolutePath(value: string): any { function getAbsolutePath(value: string): any {
return dirname(require.resolve(join(value, 'package.json'))); return dirname(require.resolve(join(value, 'package.json')));
} }
const config: StorybookConfig = { const config: StorybookConfig = {
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
addons: [ addons: [
@ -27,7 +26,6 @@ const config: StorybookConfig = {
docs: { docs: {
autodocs: 'tag', autodocs: 'tag',
}, },
staticDirs: ['../public'],
}; };
export default config; export default config;

View File

@ -1,63 +1,30 @@
# frontend # React + TypeScript + Vite
This is a [vite](https://vitejs.dev/) [react](https://reactjs.org/) [nextjs](https://nextjs.org/) project in a [yarn workspace](https://yarnpkg.com/features/workspaces). This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
## Getting Started Currently, two official plugins are available:
### Install dependencies - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
In the root of the project, run: ## Expanding the ESLint configuration
```zsh If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
yarn
- Configure the top-level `parserOptions` property like this:
```js
export default {
// other rules...
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
project: ['./tsconfig.json', './tsconfig.node.json'],
tsconfigRootDir: __dirname,
},
};
``` ```
### Build backend - Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
- Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
```zsh - Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list
yarn build --ignore frontend
```
### Environment variables
#### Local
Copy the `.env.example` file to `.env`:
```zsh
cp .env.example .env
```
#### Staging environment variables
Change in [deployer/deploy-frontend.staging.sh](/packages/deployer/deploy-frontend.staging.sh)
#### Production environment variables
Change in [deployer/deploy-frontend.sh](/packages/deployer/deploy-frontend.sh)
### Run development server
```zsh
yarn dev
```
## Deployment
From the root of the project,
### Staging
```zsh
cd packages/deployer && ./deploy-frontend.staging.sh
```
### Production
```zsh
cd packages/deployer && ./deploy-frontend.sh
```
### Deployment status
Check the status of the deployment [here](https://webapp-deployer.apps.snowballtools.com)

View File

@ -1,7 +1,7 @@
{ {
"name": "frontend", "name": "frontend",
"private": true, "private": true,
"version": "1.0.0", "version": "0.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --port 3000", "dev": "vite --port 3000",
@ -16,11 +16,8 @@
"@bugsnag/browser-performance": "^2.4.1", "@bugsnag/browser-performance": "^2.4.1",
"@bugsnag/js": "^7.22.7", "@bugsnag/js": "^7.22.7",
"@bugsnag/plugin-react": "^7.22.7", "@bugsnag/plugin-react": "^7.22.7",
"@emotion/react": "^11.13.3",
"@emotion/styled": "^11.13.0",
"@fontsource-variable/jetbrains-mono": "^5.0.19", "@fontsource-variable/jetbrains-mono": "^5.0.19",
"@fontsource/inter": "^5.0.16", "@fontsource/inter": "^5.0.16",
"@mui/material": "^6.1.3",
"@radix-ui/react-avatar": "^1.0.4", "@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dialog": "^1.0.5",
@ -30,6 +27,10 @@
"@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toast": "^1.1.5", "@radix-ui/react-toast": "^1.1.5",
"@radix-ui/react-tooltip": "^1.0.7", "@radix-ui/react-tooltip": "^1.0.7",
"@snowballtools/auth": "^0.2.0",
"@snowballtools/auth-lit": "^0.2.0",
"@snowballtools/js-sdk": "^0.1.1",
"@snowballtools/link-lit-alchemy-light": "^0.2.0",
"@snowballtools/material-tailwind-react-fork": "^2.1.10", "@snowballtools/material-tailwind-react-fork": "^2.1.10",
"@snowballtools/smartwallet-alchemy-light": "^0.2.0", "@snowballtools/smartwallet-alchemy-light": "^0.2.0",
"@snowballtools/types": "^0.2.0", "@snowballtools/types": "^0.2.0",
@ -42,8 +43,8 @@
"@turnkey/sdk-react": "^0.1.0", "@turnkey/sdk-react": "^0.1.0",
"@turnkey/webauthn-stamper": "^0.5.0", "@turnkey/webauthn-stamper": "^0.5.0",
"@walletconnect/ethereum-provider": "^2.12.2", "@walletconnect/ethereum-provider": "^2.12.2",
"@web3modal/siwe": "4.0.5", "@web3modal/siwe": "^4.0.5",
"@web3modal/wagmi": "4.0.5", "@web3modal/wagmi": "^4.0.5",
"assert": "^2.1.0", "assert": "^2.1.0",
"axios": "^1.6.7", "axios": "^1.6.7",
"clsx": "^2.1.0", "clsx": "^2.1.0",
@ -61,15 +62,15 @@
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-dropdown": "^1.11.0", "react-dropdown": "^1.11.0",
"react-hook-form": "^7.49.0", "react-hook-form": "^7.49.0",
"react-hot-toast": "^2.4.1",
"react-oauth-popup": "^1.0.5", "react-oauth-popup": "^1.0.5",
"react-router-dom": "^6.20.1", "react-router-dom": "^6.20.1",
"react-timer-hook": "^3.0.7", "react-timer-hook": "^3.0.7",
"siwe": "2.1.4", "siwe": "^2.1.4",
"tailwind-variants": "^0.2.0", "tailwind-variants": "^0.2.0",
"usehooks-ts": "^2.15.1", "usehooks-ts": "^2.15.1",
"uuid": "^9.0.1", "uuid": "^9.0.1",
"viem": "^2.7.11", "viem": "^2.7.11",
"wagmi": "2.5.7",
"web-vitals": "^2.1.4" "web-vitals": "^2.1.4"
}, },
"devDependencies": { "devDependencies": {

View File

@ -1 +0,0 @@
350e9ac2-8b27-4a79-9a82-78cfdb68ef71=0eacb7ae462f82c8b0199d28193b0bfa5265973dbb1fe991eec2cab737dfc1ec

View File

@ -1,4 +1,3 @@
import { useEffect } from 'react';
import { createBrowserRouter, RouterProvider } from 'react-router-dom'; import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import Projects from './pages/org-slug'; import Projects from './pages/org-slug';
@ -11,8 +10,8 @@ import ProjectSearchLayout from './layouts/ProjectSearch';
import Index from './pages'; import Index from './pages';
import AuthPage from './pages/AuthPage'; import AuthPage from './pages/AuthPage';
import { DashboardLayout } from './pages/org-slug/layout'; import { DashboardLayout } from './pages/org-slug/layout';
import { useEffect } from 'react';
import Web3Provider from 'context/Web3Provider'; import Web3Provider from 'context/Web3Provider';
import { BASE_URL } from 'utils/constants';
const router = createBrowserRouter([ const router = createBrowserRouter([
{ {
@ -50,26 +49,25 @@ const router = createBrowserRouter([
path: '/login', path: '/login',
element: <AuthPage />, element: <AuthPage />,
}, },
{
path: '/signup',
element: <AuthPage />,
},
]); ]);
function App() { function App() {
// Hacky way of checking session // Hacky way of checking session
// TODO: Handle redirect backs // TODO: Handle redirect backs
useEffect(() => { useEffect(() => {
fetch(`${BASE_URL}/auth/session`, { fetch(`${import.meta.env.VITE_SERVER_URL}/auth/session`, {
credentials: 'include', credentials: 'include',
}).then((res) => { }).then((res) => {
const path = window.location.pathname;
if (res.status !== 200) { if (res.status !== 200) {
localStorage.clear(); localStorage.clear();
const path = window.location.pathname;
if (path !== '/login') { if (path !== '/login' && path !== '/signup') {
window.location.pathname = '/login'; window.location.pathname = '/login';
} }
} else {
if (path === '/login') {
window.location.pathname = '/';
}
} }
}); });
}, []); }, []);

View File

@ -1,21 +1,16 @@
import {
VITE_GITHUB_IMAGE_UPLOAD_PWA_TEMPLATE_REPO,
VITE_GITHUB_PWA_TEMPLATE_REPO,
} from 'utils/constants';
export default [ export default [
{ {
id: '1', id: '1',
name: 'Progressive Web App (PWA)', name: 'Progressive Web App (PWA)',
icon: 'pwa', icon: 'pwa',
repoFullName: `${VITE_GITHUB_PWA_TEMPLATE_REPO}`, repoFullName: `${import.meta.env.VITE_GITHUB_PWA_TEMPLATE_REPO}`,
isComingSoon: false, isComingSoon: false,
}, },
{ {
id: '2', id: '2',
name: 'Image Upload PWA', name: 'Image Upload PWA',
icon: 'pwa', icon: 'pwa',
repoFullName: `${VITE_GITHUB_IMAGE_UPLOAD_PWA_TEMPLATE_REPO}`, repoFullName: `${import.meta.env.VITE_GITHUB_IMAGE_UPLOAD_PWA_TEMPLATE_REPO}`,
isComingSoon: false, isComingSoon: false,
}, },
{ {

View File

@ -6,7 +6,7 @@ import { Input, InputProps } from './shared/Input';
const SearchBar: React.ForwardRefRenderFunction< const SearchBar: React.ForwardRefRenderFunction<
HTMLInputElement, HTMLInputElement,
InputProps & RefAttributes<HTMLInputElement> InputProps & RefAttributes<HTMLInputElement>
> = ({ value, onChange, placeholder = 'Search', ...props }, ref) => { > = ({ value, onChange, placeholder = 'Search', ...props }) => {
return ( return (
<div className="relative flex w-full"> <div className="relative flex w-full">
<Input <Input
@ -18,7 +18,6 @@ const SearchBar: React.ForwardRefRenderFunction<
appearance="borderless" appearance="borderless"
className="w-full lg:w-[459px]" className="w-full lg:w-[459px]"
{...props} {...props}
ref={ref}
/> />
</div> </div>
); );

View File

@ -1,4 +1,3 @@
import { useEffect } from 'react';
import { useStopwatch } from 'react-timer-hook'; import { useStopwatch } from 'react-timer-hook';
import FormatMillisecond, { FormatMilliSecondProps } from './FormatMilliSecond'; import FormatMillisecond, { FormatMilliSecondProps } from './FormatMilliSecond';
@ -13,19 +12,14 @@ const setStopWatchOffset = (time: string) => {
interface StopwatchProps extends Omit<FormatMilliSecondProps, 'time'> { interface StopwatchProps extends Omit<FormatMilliSecondProps, 'time'> {
offsetTimestamp: Date; offsetTimestamp: Date;
isPaused: boolean;
} }
const Stopwatch = ({ offsetTimestamp, isPaused, ...props }: StopwatchProps) => { const Stopwatch = ({ offsetTimestamp, ...props }: StopwatchProps) => {
const { totalSeconds, pause, start } = useStopwatch({ const { totalSeconds } = useStopwatch({
autoStart: true, autoStart: true,
offsetTimestamp: offsetTimestamp, offsetTimestamp: offsetTimestamp,
}); });
useEffect(() => {
isPaused ? pause() : start();
}, [isPaused]);
return <FormatMillisecond time={totalSeconds * 1000} {...props} />; return <FormatMillisecond time={totalSeconds * 1000} {...props} />;
}; };

View File

@ -10,16 +10,11 @@ import {
LinkChainIcon, LinkChainIcon,
} from 'components/shared/CustomIcon'; } from 'components/shared/CustomIcon';
import { TagProps } from 'components/shared/Tag'; import { TagProps } from 'components/shared/Tag';
import {
ArrowRightCircleFilledIcon,
LoadingIcon,
} from 'components/shared/CustomIcon';
interface ChangeStateToProductionDialogProps extends ConfirmDialogProps { interface ChangeStateToProductionDialogProps extends ConfirmDialogProps {
deployment: Deployment; deployment: Deployment;
newDeployment?: Deployment; newDeployment?: Deployment;
domains: Domain[]; domains: Domain[];
isConfirmButtonLoading?: boolean;
} }
export const ChangeStateToProductionDialog = ({ export const ChangeStateToProductionDialog = ({
@ -29,7 +24,6 @@ export const ChangeStateToProductionDialog = ({
open, open,
handleCancel, handleCancel,
handleConfirm, handleConfirm,
isConfirmButtonLoading,
...props ...props
}: ChangeStateToProductionDialogProps) => { }: ChangeStateToProductionDialogProps) => {
const currentChip = { const currentChip = {
@ -47,14 +41,6 @@ export const ChangeStateToProductionDialog = ({
handleCancel={handleCancel} handleCancel={handleCancel}
open={open} open={open}
handleConfirm={handleConfirm} handleConfirm={handleConfirm}
confirmButtonProps={{
disabled: isConfirmButtonLoading,
rightIcon: isConfirmButtonLoading ? (
<LoadingIcon className="animate-spin" />
) : (
<ArrowRightCircleFilledIcon />
),
}}
> >
<div className="flex flex-col gap-7"> <div className="flex flex-col gap-7">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">

View File

@ -1,47 +0,0 @@
import ConfirmDialog, {
ConfirmDialogProps,
} from 'components/shared/ConfirmDialog';
import {
ArrowRightCircleFilledIcon,
LoadingIcon,
} from 'components/shared/CustomIcon';
interface DeleteDeploymentDialogProps extends ConfirmDialogProps {
isConfirmButtonLoading?: boolean;
}
export const DeleteDeploymentDialog = ({
open,
handleCancel,
handleConfirm,
isConfirmButtonLoading,
...props
}: DeleteDeploymentDialogProps) => {
return (
<ConfirmDialog
{...props}
dialogTitle="Delete deployment?"
handleCancel={handleCancel}
open={open}
confirmButtonTitle={
isConfirmButtonLoading
? 'Deleting deployment'
: 'Yes, delete deployment'
}
handleConfirm={handleConfirm}
confirmButtonProps={{
variant: 'danger',
disabled: isConfirmButtonLoading,
rightIcon: isConfirmButtonLoading ? (
<LoadingIcon className="animate-spin" />
) : (
<ArrowRightCircleFilledIcon />
),
}}
>
<p className="text-sm text-elements-high-em">
Once deleted, the deployment will not be accessible.
</p>
</ConfirmDialog>
);
};

View File

@ -1,7 +1,7 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useCombobox } from 'downshift'; import { useCombobox } from 'downshift';
import { Project } from 'gql-client'; import { Project } from 'gql-client';
import { useDebounceValue } from 'usehooks-ts'; import { useDebounce } from 'usehooks-ts';
import SearchBar from 'components/SearchBar'; import SearchBar from 'components/SearchBar';
import { useGQLClient } from 'context/GQLClientContext'; import { useGQLClient } from 'context/GQLClientContext';
@ -42,7 +42,7 @@ export const ProjectSearchBar = ({ onChange }: ProjectSearchBarProps) => {
}, },
}); });
const [debouncedInputValue, _] = useDebounceValue<string>(inputValue, 300); const debouncedInputValue = useDebounce<string>(inputValue, 300);
const fetchProjects = useCallback( const fetchProjects = useCallback(
async (inputValue: string) => { async (inputValue: string) => {
@ -62,7 +62,7 @@ export const ProjectSearchBar = ({ onChange }: ProjectSearchBarProps) => {
<div className="relative w-full lg:w-fit"> <div className="relative w-full lg:w-fit">
<SearchBar {...getInputProps()} /> <SearchBar {...getInputProps()} />
<div <div
{...getMenuProps({}, { suppressRefError: true })} {...getMenuProps()}
className={cn( className={cn(
'flex flex-col shadow-dropdown rounded-xl bg-surface-card absolute w-[459px] max-h-52 overflow-y-auto px-2 py-2 gap-1 z-50', 'flex flex-col shadow-dropdown rounded-xl bg-surface-card absolute w-[459px] max-h-52 overflow-y-auto px-2 py-2 gap-1 z-50',
{ hidden: !inputValue || !isOpen }, { hidden: !inputValue || !isOpen },

View File

@ -5,7 +5,7 @@ import { CrossIcon, SearchIcon } from 'components/shared/CustomIcon';
import { Input } from 'components/shared/Input'; import { Input } from 'components/shared/Input';
import { useGQLClient } from 'context/GQLClientContext'; import { useGQLClient } from 'context/GQLClientContext';
import { Project } from 'gql-client'; import { Project } from 'gql-client';
import { useDebounceValue } from 'usehooks-ts'; import { useDebounce } from 'usehooks-ts';
import { ProjectSearchBarItem } from './ProjectSearchBarItem'; import { ProjectSearchBarItem } from './ProjectSearchBarItem';
import { ProjectSearchBarEmpty } from './ProjectSearchBarEmpty'; import { ProjectSearchBarEmpty } from './ProjectSearchBarEmpty';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
@ -27,30 +27,25 @@ export const ProjectSearchBarDialog = ({
const client = useGQLClient(); const client = useGQLClient();
const navigate = useNavigate(); const navigate = useNavigate();
const { const { getInputProps, getItemProps, inputValue, setInputValue } =
getInputProps, useCombobox({
getItemProps, items,
getMenuProps, itemToString(item) {
inputValue, return item ? item.name : '';
setInputValue, },
} = useCombobox({ selectedItem,
items, onSelectedItemChange: ({ selectedItem: newSelectedItem }) => {
itemToString(item) { if (newSelectedItem) {
return item ? item.name : ''; setSelectedItem(newSelectedItem);
}, onClickItem?.(newSelectedItem);
selectedItem, navigate(
onSelectedItemChange: ({ selectedItem: newSelectedItem }) => { `/${newSelectedItem.organization.slug}/projects/${newSelectedItem.id}`,
if (newSelectedItem) { );
setSelectedItem(newSelectedItem); }
onClickItem?.(newSelectedItem); },
navigate( });
`/${newSelectedItem.organization.slug}/projects/${newSelectedItem.id}`,
);
}
},
});
const [debouncedInputValue, _] = useDebounceValue<string>(inputValue, 300); const debouncedInputValue = useDebounce<string>(inputValue, 300);
const fetchProjects = useCallback( const fetchProjects = useCallback(
async (inputValue: string) => { async (inputValue: string) => {
@ -80,7 +75,7 @@ export const ProjectSearchBarDialog = ({
<div className="h-full flex flex-col fixed top-0 inset-0"> <div className="h-full flex flex-col fixed top-0 inset-0">
<div className="py-2.5 px-4 flex items-center justify-between border-b border-border-separator/[0.06]"> <div className="py-2.5 px-4 flex items-center justify-between border-b border-border-separator/[0.06]">
<Input <Input
{...getInputProps({}, { suppressRefError: true })} {...getInputProps()}
leftIcon={<SearchIcon />} leftIcon={<SearchIcon />}
placeholder="Search" placeholder="Search"
appearance="borderless" appearance="borderless"
@ -91,33 +86,23 @@ export const ProjectSearchBarDialog = ({
</Button> </Button>
</div> </div>
{/* Content */} {/* Content */}
<div <div className="flex flex-col gap-1 px-2 py-2">
className="flex flex-col gap-1 px-2 py-2" {items.length > 0
{...getMenuProps( ? items.map((item, index) => (
{}, <>
{ <div className="px-2 py-2">
suppressRefError: true, <p className="text-elements-mid-em text-xs font-medium">
}, Suggestions
)} </p>
> </div>
{items.length > 0 ? ( <ProjectSearchBarItem
<> key={item.id}
<div className="px-2 py-2"> item={item}
<p className="text-elements-mid-em text-xs font-medium"> {...getItemProps({ item, index })}
Suggestions />
</p> </>
</div> ))
{items.map((item, index) => ( : inputValue && <ProjectSearchBarEmpty />}
<ProjectSearchBarItem
key={item.id}
item={item}
{...getItemProps({ item, index })}
/>
))}
</>
) : (
inputValue && <ProjectSearchBarEmpty />
)}
</div> </div>
</div> </div>
</Dialog.Content> </Dialog.Content>

View File

@ -1,571 +0,0 @@
import { useCallback, useState, useEffect } from 'react';
import { useForm, Controller } from 'react-hook-form';
import { FormProvider, FieldValues } from 'react-hook-form';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useMediaQuery } from 'usehooks-ts';
import {
AddEnvironmentVariableInput,
AuctionParams,
Deployer,
} from 'gql-client';
import { Select, MenuItem, FormControl, FormHelperText } from '@mui/material';
import {
ArrowRightCircleFilledIcon,
LoadingIcon,
} from 'components/shared/CustomIcon';
import { Heading } from '../../shared/Heading';
import { Button } from '../../shared/Button';
import { Input } from 'components/shared/Input';
import { useToast } from 'components/shared/Toast';
import { useGQLClient } from '../../../context/GQLClientContext';
import EnvironmentVariablesForm from 'pages/org-slug/projects/id/settings/EnvironmentVariablesForm';
import { EnvironmentVariablesFormValues } from 'types/types';
import ConnectWallet from './ConnectWallet';
import { useWalletConnectClient } from 'context/WalletConnectContext';
type ConfigureDeploymentFormValues = {
option: string;
lrn?: string;
numProviders?: number;
maxPrice?: string;
};
type ConfigureFormValues = ConfigureDeploymentFormValues &
EnvironmentVariablesFormValues;
const DEFAULT_MAX_PRICE = '10000';
const Configure = () => {
const { signClient, session, accounts } = useWalletConnectClient();
const [isLoading, setIsLoading] = useState(false);
const [deployers, setDeployers] = useState<Deployer[]>([]);
const [selectedAccount, setSelectedAccount] = useState<string>();
const [selectedDeployer, setSelectedDeployer] = useState<Deployer>();
const [isPaymentLoading, setIsPaymentLoading] = useState(false);
const [isPaymentDone, setIsPaymentDone] = useState(false);
const [searchParams] = useSearchParams();
const templateId = searchParams.get('templateId');
const queryParams = new URLSearchParams(location.search);
const owner = queryParams.get('owner');
const name = queryParams.get('name');
const defaultBranch = queryParams.get('defaultBranch');
const fullName = queryParams.get('fullName');
const orgSlug = queryParams.get('orgSlug');
const templateOwner = queryParams.get('templateOwner');
const templateRepo = queryParams.get('templateRepo');
const isPrivate = queryParams.get('isPrivate') === 'true';
const navigate = useNavigate();
const { toast, dismiss } = useToast();
const client = useGQLClient();
const methods = useForm<ConfigureFormValues>({
defaultValues: {
option: 'Auction',
maxPrice: DEFAULT_MAX_PRICE,
lrn: '',
numProviders: 1,
variables: [],
},
});
const selectedOption = methods.watch('option');
const isTabletView = useMediaQuery('(min-width: 720px)'); // md:
const buttonSize = isTabletView ? { size: 'lg' as const } : {};
const createProject = async (
data: FieldValues,
envVariables: AddEnvironmentVariableInput[],
senderAddress: string,
txHash: string,
): Promise<string> => {
setIsLoading(true);
let projectId: string | null = null;
try {
let lrn: string | undefined;
let auctionParams: AuctionParams | undefined;
if (data.option === 'LRN') {
lrn = data.lrn;
} else if (data.option === 'Auction') {
auctionParams = {
numProviders: Number(data.numProviders!),
maxPrice: data.maxPrice!.toString(),
};
}
if (templateId) {
const projectData: any = {
templateOwner,
templateRepo,
owner,
name,
isPrivate,
paymentAddress: senderAddress,
txHash,
};
const { addProjectFromTemplate } = await client.addProjectFromTemplate(
orgSlug!,
projectData,
lrn,
auctionParams,
envVariables,
);
projectId = addProjectFromTemplate.id;
} else {
const { addProject } = await client.addProject(
orgSlug!,
{
name: `${owner}-${name}`,
prodBranch: defaultBranch!,
repository: fullName!,
template: 'webapp',
paymentAddress: senderAddress,
txHash,
},
lrn,
auctionParams,
envVariables,
);
projectId = addProject.id;
}
} catch (error) {
console.error('Error creating project:', error);
toast({
id: 'error-creating-project',
title: 'Error creating project',
variant: 'error',
onDismiss: dismiss,
});
} finally {
setIsLoading(false);
}
if (projectId) {
return projectId;
} else {
throw new Error('Project creation failed');
}
};
const verifyTx = async (
senderAddress: string,
txHash: string,
amount: string,
): Promise<boolean> => {
const isValid = await client.verifyTx(
txHash,
`${amount.toString()}alnt`,
senderAddress,
);
return isValid;
};
const handleFormSubmit = useCallback(
async (createFormData: FieldValues) => {
try {
const deployerLrn = createFormData.lrn;
const deployer = deployers.find(
(deployer) => deployer.deployerLrn === deployerLrn,
);
let amount: string;
let senderAddress: string;
let txHash: string;
if (createFormData.option === 'LRN' && !deployer?.minimumPayment) {
toast({
id: 'no-payment-required',
title: 'No payment required. Deploying app...',
variant: 'info',
onDismiss: dismiss,
});
txHash = '';
senderAddress = '';
} else {
if (!selectedAccount) return;
senderAddress = selectedAccount.split(':')[2];
if (createFormData.option === 'LRN') {
amount = deployer?.minimumPayment!;
} else {
amount = (
createFormData.numProviders * createFormData.maxPrice
).toString();
}
const amountToBePaid = amount.replace(/\D/g, '').toString();
const txHashResponse = await cosmosSendTokensHandler(
selectedAccount,
amountToBePaid,
);
if (!txHashResponse) {
console.error('Tx not successful');
return;
}
txHash = txHashResponse;
const isTxHashValid = await verifyTx(
senderAddress,
txHash,
amountToBePaid.toString(),
);
if (isTxHashValid === false) {
console.error('Invalid Tx hash', txHash);
return;
}
}
const environmentVariables = createFormData.variables.map(
(variable: any) => {
return {
key: variable.key,
value: variable.value,
environments: Object.entries(createFormData.environment)
.filter(([, value]) => value === true)
.map(([key]) => key.charAt(0).toUpperCase() + key.slice(1)),
};
},
);
const projectId = await createProject(
createFormData,
environmentVariables,
senderAddress,
txHash,
);
await client.getEnvironmentVariables(projectId);
if (templateId) {
createFormData.option === 'Auction'
? navigate(
`/${orgSlug}/projects/create/success/${projectId}?isAuction=true`,
)
: navigate(
`/${orgSlug}/projects/create/template/deploy?projectId=${projectId}&templateId=${templateId}`,
);
} else {
createFormData.option === 'Auction'
? navigate(
`/${orgSlug}/projects/create/success/${projectId}?isAuction=true`,
)
: navigate(
`/${orgSlug}/projects/create/deploy?projectId=${projectId}`,
);
}
} catch (error) {
console.error(error);
toast({
id: 'error-deploying-app',
title: 'Error deploying app',
variant: 'error',
onDismiss: dismiss,
});
}
},
[client, createProject, dismiss, toast],
);
const fetchDeployers = useCallback(async () => {
const res = await client.getDeployers();
setDeployers(res.deployers);
}, [client]);
const onAccountChange = useCallback((account: string) => {
setSelectedAccount(account);
}, []);
const onDeployerChange = useCallback(
(selectedLrn: string) => {
const deployer = deployers.find((d) => d.deployerLrn === selectedLrn);
setSelectedDeployer(deployer);
},
[deployers],
);
const cosmosSendTokensHandler = useCallback(
async (selectedAccount: string, amount: string) => {
if (!signClient || !session || !selectedAccount) {
return;
}
const chainId = selectedAccount.split(':')[1];
const senderAddress = selectedAccount.split(':')[2];
const snowballAddress = await client.getAddress();
try {
setIsPaymentDone(false);
setIsPaymentLoading(true);
toast({
id: 'sending-payment-request',
title: 'Check your wallet and approve payment request',
variant: 'loading',
onDismiss: dismiss,
});
const result: { signature: string } = await signClient.request({
topic: session.topic,
chainId: `cosmos:${chainId}`,
request: {
method: 'cosmos_sendTokens',
params: [
{
from: senderAddress,
to: snowballAddress,
value: amount,
},
],
},
});
if (!result) {
throw new Error('Error completing transaction');
}
toast({
id: 'payment-successful',
title: 'Payment successful',
variant: 'success',
onDismiss: dismiss,
});
setIsPaymentDone(true);
return result.signature;
} catch (error: any) {
console.error('Error sending tokens', error);
toast({
id: 'error-sending-tokens',
title: 'Error sending tokens',
variant: 'error',
onDismiss: dismiss,
});
setIsPaymentDone(false);
} finally {
setIsPaymentLoading(false);
}
},
[session, signClient, toast],
);
useEffect(() => {
fetchDeployers();
}, []);
return (
<div className="space-y-7 px-4 py-6">
<div className="flex justify-between mb-6">
<div className="space-y-1.5">
<Heading as="h4" className="md:text-lg font-medium">
Configure deployment
</Heading>
<Heading as="h5" className="text-sm font-sans text-elements-low-em">
The app can be deployed by setting the deployer LRN for a single
deployment or by creating a deployer auction for multiple
deployments
</Heading>
</div>
</div>
<div className="flex flex-col gap-6 lg:gap-8 w-full">
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(handleFormSubmit)}>
<div className="flex flex-col justify-start gap-4 mb-6">
<Controller
name="option"
control={methods.control}
render={({ field: { value, onChange } }) => (
<Select
value={value}
onChange={(event) => onChange(event.target.value)}
size="small"
displayEmpty
sx={{
fontFamily: 'inherit',
'& .MuiOutlinedInput-notchedOutline': {
borderColor: '#e0e0e0',
borderRadius: '8px',
},
}}
>
<MenuItem value="Auction">Create Auction</MenuItem>
<MenuItem value="LRN">Deployer LRN</MenuItem>
</Select>
)}
/>
</div>
{selectedOption === 'LRN' && (
<div className="flex flex-col justify-start gap-4 mb-6">
<Heading
as="h5"
className="text-sm font-sans text-elements-low-em"
>
The app will be deployed by the configured deployer
</Heading>
<Controller
name="lrn"
control={methods.control}
rules={{ required: true }}
render={({ field: { value, onChange }, fieldState }) => (
<FormControl fullWidth error={Boolean(fieldState.error)}>
<span className="text-sm text-elements-high-em mb-4">
Select deployer LRN
</span>
<Select
value={value}
onChange={(event) => {
onChange(event.target.value);
onDeployerChange(event.target.value);
}}
displayEmpty
size="small"
>
{deployers.map((deployer) => (
<MenuItem
key={deployer.deployerLrn}
value={deployer.deployerLrn}
>
{`${deployer.deployerLrn} ${deployer.minimumPayment ? `(${deployer.minimumPayment})` : ''}`}
</MenuItem>
))}
</Select>
{fieldState.error && (
<FormHelperText>
{fieldState.error.message}
</FormHelperText>
)}
</FormControl>
)}
/>
</div>
)}
{selectedOption === 'Auction' && (
<>
<div className="flex flex-col justify-start gap-4 mb-6">
<Heading
as="h5"
className="text-sm font-sans text-elements-low-em"
>
Set the number of deployers and maximum price for each
deployment
</Heading>
<span className="text-sm text-elements-high-em">
Number of Deployers
</span>
<Controller
name="numProviders"
control={methods.control}
rules={{ required: true }}
render={({ field: { value, onChange } }) => (
<Input
type="number"
value={value}
onChange={(e) => onChange(e)}
/>
)}
/>
</div>
<div className="flex flex-col justify-start gap-4 mb-6">
<span className="text-sm text-elements-high-em">
Maximum Price (alnt)
</span>
<Controller
name="maxPrice"
control={methods.control}
rules={{ required: true }}
render={({ field: { value, onChange } }) => (
<Input type="number" value={value} onChange={onChange} />
)}
/>
</div>
</>
)}
<Heading as="h4" className="md:text-lg font-medium mb-3">
Environment Variables
</Heading>
<div className="p-4 bg-slate-100 rounded-lg mb-6">
<EnvironmentVariablesForm />
</div>
{selectedOption === 'LRN' && !selectedDeployer?.minimumPayment ? (
<div>
<Button
{...buttonSize}
type="submit"
disabled={isLoading || !selectedDeployer || !selectedAccount}
rightIcon={
isLoading ? (
<LoadingIcon className="animate-spin" />
) : (
<ArrowRightCircleFilledIcon />
)
}
>
{isLoading ? 'Deploying' : 'Deploy'}
</Button>
</div>
) : (
<>
<Heading as="h4" className="md:text-lg font-medium mb-3">
Connect to your wallet
</Heading>
<ConnectWallet onAccountChange={onAccountChange} />
{accounts && accounts?.length > 0 && (
<div>
<Button
{...buttonSize}
type="submit"
disabled={
isLoading || isPaymentLoading || !selectedAccount
}
rightIcon={
isLoading || isPaymentLoading ? (
<LoadingIcon className="animate-spin" />
) : (
<ArrowRightCircleFilledIcon />
)
}
>
{!isPaymentDone
? isPaymentLoading
? 'Transaction Requested'
: 'Pay and Deploy'
: isLoading
? 'Deploying'
: 'Deploy'}
</Button>
</div>
)}
</>
)}
</form>
</FormProvider>
</div>
</div>
);
};
export default Configure;

View File

@ -14,10 +14,11 @@ import { useToast } from '../../shared/Toast';
import { IconWithFrame } from '../../shared/IconWithFrame'; import { IconWithFrame } from '../../shared/IconWithFrame';
import { Heading } from '../../shared/Heading'; import { Heading } from '../../shared/Heading';
import { MockConnectGitCard } from './MockConnectGitCard'; import { MockConnectGitCard } from './MockConnectGitCard';
import { VITE_GITHUB_CLIENT_ID } from 'utils/constants';
const SCOPES = 'repo user'; const SCOPES = 'repo user';
const GITHUB_OAUTH_URL = `https://github.com/login/oauth/authorize?client_id=${VITE_GITHUB_CLIENT_ID}&scope=${encodeURIComponent(SCOPES)}`; const GITHUB_OAUTH_URL = `https://github.com/login/oauth/authorize?client_id=${
import.meta.env.VITE_GITHUB_CLIENT_ID
}&scope=${encodeURIComponent(SCOPES)}`;
interface ConnectAccountInterface { interface ConnectAccountInterface {
onAuth: (token: string) => void; onAuth: (token: string) => void;

View File

@ -1,46 +0,0 @@
import { Select, Option } from '@snowballtools/material-tailwind-react-fork';
import { Button } from '../../shared/Button';
import { useWalletConnectClient } from 'context/WalletConnectContext';
const ConnectWallet = ({
onAccountChange,
}: {
onAccountChange: (selectedAccount: string) => void;
}) => {
const { onConnect, accounts } = useWalletConnectClient();
const handleConnect = async () => {
await onConnect();
};
return (
<div className="p-4 bg-slate-100 rounded-lg mb-6">
{!accounts ? (
<div>
<Button type={'button'} onClick={handleConnect}>
Connect Wallet
</Button>
</div>
) : (
<div>
<Select
label="Select Account"
defaultValue={accounts[0].address}
onChange={(value) => {
value && onAccountChange(value);
}}
>
{accounts.map((account, index) => (
<Option key={index} value={account.address}>
{account.address.split(':').slice(1).join(':')}
</Option>
))}
</Select>
</div>
)}
</div>
);
};
export default ConnectWallet;

View File

@ -1,7 +1,5 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect } from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import axios from 'axios';
import { Deployment } from 'gql-client';
import { DeployStep, DeployStatus } from './DeployStep'; import { DeployStep, DeployStatus } from './DeployStep';
import { Stopwatch, setStopWatchOffset } from '../../StopWatch'; import { Stopwatch, setStopWatchOffset } from '../../StopWatch';
@ -9,37 +7,13 @@ import { Heading } from '../../shared/Heading';
import { Button } from '../../shared/Button'; import { Button } from '../../shared/Button';
import { ClockOutlineIcon, WarningIcon } from '../../shared/CustomIcon'; import { ClockOutlineIcon, WarningIcon } from '../../shared/CustomIcon';
import { CancelDeploymentDialog } from '../../projects/Dialog/CancelDeploymentDialog'; import { CancelDeploymentDialog } from '../../projects/Dialog/CancelDeploymentDialog';
import { useGQLClient } from 'context/GQLClientContext';
const FETCH_DEPLOYMENTS_INTERVAL = 5000;
type RequestState =
| 'SUBMITTED'
| 'DEPLOYING'
| 'DEPLOYED'
| 'REMOVED'
| 'CANCELLED'
| 'ERROR';
type Record = {
id: string;
createTime: string;
app: string;
lastState: RequestState;
lastUpdate: string;
logAvailable: boolean;
};
const TIMEOUT_DURATION = 5000;
const Deploy = () => { const Deploy = () => {
const client = useGQLClient();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const projectId = searchParams.get('projectId'); const projectId = searchParams.get('projectId');
const [open, setOpen] = React.useState(false); const [open, setOpen] = React.useState(false);
const [deployment, setDeployment] = useState<Deployment>();
const [record, setRecord] = useState<Record>();
const handleOpen = () => setOpen(!open); const handleOpen = () => setOpen(!open);
const navigate = useNavigate(); const navigate = useNavigate();
@ -49,67 +23,13 @@ const Deploy = () => {
navigate(`/${orgSlug}/projects/create`); navigate(`/${orgSlug}/projects/create`);
}, []); }, []);
const isDeploymentFailed = useMemo(() => {
if (!record) {
return false;
}
// Not checking for `REMOVED` status as this status is received for a brief period before receiving `DEPLOYED` status
if (record.lastState === 'CANCELLED' || record.lastState === 'ERROR') {
return true;
} else {
return false;
}
}, [record]);
const fetchDeploymentRecords = useCallback(async () => {
if (!deployment) {
return;
}
try {
const response = await axios.get(
`${deployment.deployer.deployerApiUrl}/${deployment.applicationDeploymentRequestId}`,
);
const record: Record = response.data;
setRecord(record);
} catch (err: any) {
console.log('Error fetching data from deployer', err);
}
}, [deployment]);
const fetchDeployment = useCallback(async () => {
if (!projectId) {
return;
}
const { deployments } = await client.getDeployments(projectId);
setDeployment(deployments[0]);
}, [client, projectId]);
useEffect(() => { useEffect(() => {
fetchDeployment(); const timerID = setTimeout(() => {
fetchDeploymentRecords();
const interval = setInterval(() => {
fetchDeploymentRecords();
}, FETCH_DEPLOYMENTS_INTERVAL);
return () => {
clearInterval(interval);
};
}, [fetchDeployment, fetchDeploymentRecords]);
useEffect(() => {
if (!record) {
return;
}
if (record.lastState === 'DEPLOYED') {
navigate(`/${orgSlug}/projects/create/success/${projectId}`); navigate(`/${orgSlug}/projects/create/success/${projectId}`);
} }, TIMEOUT_DURATION);
}, [record]);
return () => clearInterval(timerID);
}, []);
return ( return (
<div className="space-y-7"> <div className="space-y-7">
@ -122,7 +42,6 @@ const Deploy = () => {
<ClockOutlineIcon size={16} className="text-elements-mid-em" /> <ClockOutlineIcon size={16} className="text-elements-mid-em" />
<Stopwatch <Stopwatch
offsetTimestamp={setStopWatchOffset(Date.now().toString())} offsetTimestamp={setStopWatchOffset(Date.now().toString())}
isPaused={isDeploymentFailed}
/> />
</div> </div>
</div> </div>
@ -141,36 +60,30 @@ const Deploy = () => {
/> />
</div> </div>
{!isDeploymentFailed ? ( <div>
<div> <DeployStep
<DeployStep title="Building"
title={record ? 'Submitted' : 'Submitting'} status={DeployStatus.COMPLETE}
status={record ? DeployStatus.COMPLETE : DeployStatus.PROCESSING} step="1"
step="1" processTime="72000"
/> />
<DeployStep
<DeployStep title="Deployment summary"
title={ status={DeployStatus.PROCESSING}
record && record.lastState === 'DEPLOYED' step="2"
? 'Deployed' startTime={Date.now().toString()}
: 'Deploying' />
} <DeployStep
status={ title="Running checks"
!record status={DeployStatus.NOT_STARTED}
? DeployStatus.NOT_STARTED step="3"
: record.lastState === 'DEPLOYED' />
? DeployStatus.COMPLETE <DeployStep
: DeployStatus.PROCESSING title="Assigning domains"
} status={DeployStatus.NOT_STARTED}
step="2" step="4"
startTime={Date.now().toString()} />
/> </div>
</div>
) : (
<div>
<DeployStep title={record!.lastState} status={DeployStatus.ERROR} />
</div>
)}
</div> </div>
); );
}; };

View File

@ -1,16 +1,27 @@
import { useState } from 'react';
import { Collapse } from '@snowballtools/material-tailwind-react-fork';
import { Stopwatch, setStopWatchOffset } from '../../StopWatch'; import { Stopwatch, setStopWatchOffset } from '../../StopWatch';
import FormatMillisecond from '../../FormatMilliSecond';
import processLogs from '../../../assets/process-logs.json';
import { cn } from 'utils/classnames'; import { cn } from 'utils/classnames';
import { import {
CheckRoundFilledIcon, CheckRoundFilledIcon,
ClockOutlineIcon, ClockOutlineIcon,
CopyIcon,
LoaderIcon, LoaderIcon,
MinusCircleIcon,
PlusIcon,
} from 'components/shared/CustomIcon'; } from 'components/shared/CustomIcon';
import { Button } from 'components/shared/Button';
import { useToast } from 'components/shared/Toast';
import { useIntersectionObserver } from 'usehooks-ts';
enum DeployStatus { enum DeployStatus {
PROCESSING = 'progress', PROCESSING = 'progress',
COMPLETE = 'complete', COMPLETE = 'complete',
NOT_STARTED = 'notStarted', NOT_STARTED = 'notStarted',
ERROR = 'error',
} }
interface DeployStepsProps { interface DeployStepsProps {
@ -21,11 +32,35 @@ interface DeployStepsProps {
processTime?: string; processTime?: string;
} }
const DeployStep = ({ step, status, title, startTime }: DeployStepsProps) => { const DeployStep = ({
step,
status,
title,
startTime,
processTime,
}: DeployStepsProps) => {
const [isOpen, setIsOpen] = useState(false);
const { toast, dismiss } = useToast();
const { isIntersecting: hideGradientOverlay, ref } = useIntersectionObserver({
threshold: 1,
});
const disableCollapse = status !== DeployStatus.COMPLETE;
return ( return (
<div className="border-b border-border-separator"> <div className="border-b border-border-separator">
{/* Collapisble trigger */}
<button <button
className={cn('flex justify-between w-full py-5 gap-2', 'cursor-auto')} className={cn(
'flex justify-between w-full py-5 gap-2',
disableCollapse && 'cursor-auto',
)}
tabIndex={disableCollapse ? -1 : undefined}
onClick={() => {
if (!disableCollapse) {
setIsOpen((val) => !val);
}
}}
> >
<div className={cn('grow flex items-center gap-3')}> <div className={cn('grow flex items-center gap-3')}>
{/* Icon */} {/* Icon */}
@ -38,6 +73,12 @@ const DeployStep = ({ step, status, title, startTime }: DeployStepsProps) => {
{status === DeployStatus.PROCESSING && ( {status === DeployStatus.PROCESSING && (
<LoaderIcon className="animate-spin text-elements-link" /> <LoaderIcon className="animate-spin text-elements-link" />
)} )}
{status === DeployStatus.COMPLETE && (
<div className="text-controls-primary">
{!isOpen && <PlusIcon size={24} />}
{isOpen && <MinusCircleIcon size={24} />}
</div>
)}
</div> </div>
{/* Title */} {/* Title */}
@ -55,10 +96,7 @@ const DeployStep = ({ step, status, title, startTime }: DeployStepsProps) => {
{status === DeployStatus.PROCESSING && ( {status === DeployStatus.PROCESSING && (
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<ClockOutlineIcon size={16} className="text-elements-low-em" /> <ClockOutlineIcon size={16} className="text-elements-low-em" />
<Stopwatch <Stopwatch offsetTimestamp={setStopWatchOffset(startTime!)} />
offsetTimestamp={setStopWatchOffset(startTime!)}
isPaused={false}
/>
</div> </div>
)} )}
{status === DeployStatus.COMPLETE && ( {status === DeployStatus.COMPLETE && (
@ -69,9 +107,51 @@ const DeployStep = ({ step, status, title, startTime }: DeployStepsProps) => {
size={15} size={15}
/> />
</div> </div>
<FormatMillisecond time={Number(processTime)} />{' '}
</div> </div>
)} )}
</button> </button>
{/* Collapsible */}
<Collapse open={isOpen}>
<div className="relative text-xs text-elements-low-em h-36 overflow-y-auto">
{/* Logs */}
{processLogs.map((log, key) => {
return (
<p className="font-mono" key={key}>
{log}
</p>
);
})}
{/* End of logs ref used for hiding gradient overlay */}
<div ref={ref} />
{/* Overflow gradient overlay */}
{!hideGradientOverlay && (
<div className="h-14 w-full sticky bottom-0 inset-x-0 bg-gradient-to-t from-white to-transparent" />
)}
{/* Copy log button */}
<div className={cn('sticky bottom-4 left-1/2 flex justify-center')}>
<Button
size="xs"
onClick={() => {
navigator.clipboard.writeText(processLogs.join('\n'));
toast({
title: 'Logs copied',
variant: 'success',
id: 'logs',
onDismiss: dismiss,
});
}}
leftIcon={<CopyIcon size={16} />}
>
Copy log
</Button>
</div>
</div>
</Collapse>
</div> </div>
); );
}; };

View File

@ -38,9 +38,36 @@ export const ProjectRepoCard: React.FC<ProjectRepoCardProps> = ({
}); });
} }
navigate( try {
`configure?owner=${repository.owner?.login}&name=${repository.name}&defaultBranch=${repository.default_branch}&fullName=${repository.full_name}&orgSlug=${orgSlug}`, setIsLoading(true);
); const { addProject } = await client.addProject(orgSlug, {
name: `${repository.owner?.login}-${repository.name}`,
prodBranch: repository.default_branch as string,
repository: repository.full_name,
// TODO: Compute template from repo
template: 'webapp',
});
if (addProject) {
navigate(`import?projectId=${addProject.id}`);
} else {
toast({
id: 'failed-to-create-project',
title: 'Failed to create project',
variant: 'error',
onDismiss: dismiss,
});
}
} catch (error) {
console.error((error as Error).message);
toast({
id: 'failed-to-create-project',
title: 'Failed to create project',
variant: 'error',
onDismiss: dismiss,
});
} finally {
setIsLoading(false);
}
}, [client, repository, orgSlug, setIsLoading, navigate, toast]); }, [client, repository, orgSlug, setIsLoading, navigate, toast]);
return ( return (

View File

@ -64,9 +64,9 @@ export const RepositoryList = () => {
// Check if selected account is an organization // Check if selected account is an organization
if (selectedAccount.value === gitUser.login) { if (selectedAccount.value === gitUser.login) {
query = query + ` user:${selectedAccount.value}`; query = query + ` user:${selectedAccount}`;
} else { } else {
query = query + ` org:${selectedAccount.value}`; query = query + ` org:${selectedAccount}`;
} }
const result = await octokit.rest.search.repos({ const result = await octokit.rest.search.repos({

View File

@ -1,8 +1,13 @@
import { CopyBlock, atomOneLight } from 'react-code-blocks'; import { CopyBlock, atomOneLight } from 'react-code-blocks';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Modal } from 'components/shared/Modal'; import {
import { Button } from 'components/shared/Button'; Button,
Dialog,
DialogHeader,
DialogBody,
DialogFooter,
} from '@snowballtools/material-tailwind-react-fork';
interface AssignDomainProps { interface AssignDomainProps {
open: boolean; open: boolean;
@ -11,31 +16,36 @@ interface AssignDomainProps {
const AssignDomainDialog = ({ open, handleOpen }: AssignDomainProps) => { const AssignDomainDialog = ({ open, handleOpen }: AssignDomainProps) => {
return ( return (
<Modal open={open} onOpenChange={handleOpen}> <Dialog open={open} handler={handleOpen}>
<Modal.Content> <DialogHeader>Assign Domain</DialogHeader>
<Modal.Header>Assign Domain</Modal.Header> <DialogBody>
<Modal.Body> In order to assign a domain to your production deployments, configure it
In order to assign a domain to your production deployments, configure in the{' '}
it in the{' '} {/* TODO: Fix selection of project settings tab on navigation to domains */}
{/* TODO: Fix selection of project settings tab on navigation to domains */} <Link to="../settings/domains" className="text-light-blue-800 inline">
<Link to="../settings/domains" className="text-light-blue-800 inline"> project settings{' '}
project settings{' '} </Link>
</Link> (recommended). If you want to assign to this specific deployment,
(recommended). If you want to assign to this specific deployment, however, you can do so using our command-line interface:
however, you can do so using our command-line interface: {/* https://github.com/rajinwonderland/react-code-blocks/issues/138 */}
{/* https://github.com/rajinwonderland/react-code-blocks/issues/138 */} <CopyBlock
<CopyBlock text="snowball alias <deployment> <domain>"
text="snowball alias <deployment> <domain>" language=""
language="" showLineNumbers={false}
showLineNumbers={false} theme={atomOneLight}
theme={atomOneLight} />
/> </DialogBody>
</Modal.Body> <DialogFooter className="flex justify-start">
<Modal.Footer className="flex justify-start"> <Button
<Button onClick={handleOpen}>Okay</Button> className="rounded-3xl"
</Modal.Footer> variant="gradient"
</Modal.Content> color="blue"
</Modal> onClick={handleOpen}
>
<span>Okay</span>
</Button>
</DialogFooter>
</Dialog>
); );
}; };

View File

@ -1,4 +1,4 @@
import { useCallback, useState } from 'react'; import { useCallback } from 'react';
import { import {
Deployment, Deployment,
DeploymentStatus, DeploymentStatus,
@ -6,15 +6,6 @@ import {
Environment, Environment,
Project, Project,
} from 'gql-client'; } from 'gql-client';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Tooltip,
} from '@mui/material';
import { Avatar } from 'components/shared/Avatar'; import { Avatar } from 'components/shared/Avatar';
import { import {
BranchStrokeIcon, BranchStrokeIcon,
@ -27,23 +18,12 @@ import {
import { Heading } from 'components/shared/Heading'; import { Heading } from 'components/shared/Heading';
import { OverflownText } from 'components/shared/OverflownText'; import { OverflownText } from 'components/shared/OverflownText';
import { Tag, TagTheme } from 'components/shared/Tag'; import { Tag, TagTheme } from 'components/shared/Tag';
import { Button } from 'components/shared/Button';
import { getInitials } from 'utils/geInitials'; import { getInitials } from 'utils/geInitials';
import { relativeTimeMs } from 'utils/time'; import { relativeTimeMs } from 'utils/time';
import { SHORT_COMMIT_HASH_LENGTH } from '../../../../constants'; import { SHORT_COMMIT_HASH_LENGTH } from '../../../../constants';
import { formatAddress } from '../../../../utils/format'; import { formatAddress } from '../../../../utils/format';
import { DeploymentMenu } from './DeploymentMenu'; import { DeploymentMenu } from './DeploymentMenu';
const DEPLOYMENT_LOGS_STYLE = {
backgroundColor: 'rgba(0,0,0, .9)',
padding: '2em',
borderRadius: '0.5em',
marginLeft: '0.5em',
marginRight: '0.5em',
color: 'gray',
fontSize: 'small',
};
interface DeployDetailsCardProps { interface DeployDetailsCardProps {
deployment: Deployment; deployment: Deployment;
currentDeployment: Deployment; currentDeployment: Deployment;
@ -68,14 +48,6 @@ const DeploymentDetailsCard = ({
project, project,
prodBranchDomains, prodBranchDomains,
}: DeployDetailsCardProps) => { }: DeployDetailsCardProps) => {
const [openDialog, setOpenDialog] = useState<boolean>(false);
const [deploymentLogs, setDeploymentLogs] = useState<string>(
'No deployment logs available',
);
const handleOpenDialog = () => setOpenDialog(true);
const handleCloseDialog = () => setOpenDialog(false);
const getIconByDeploymentStatus = (status: DeploymentStatus) => { const getIconByDeploymentStatus = (status: DeploymentStatus) => {
if ( if (
status === DeploymentStatus.Building || status === DeploymentStatus.Building ||
@ -92,39 +64,18 @@ const DeploymentDetailsCard = ({
} }
}; };
const fetchDeploymentLogs = async () => {
setDeploymentLogs('Loading logs...');
handleOpenDialog();
const statusUrl = `${deployment.deployer.deployerApiUrl}/${deployment.applicationDeploymentRequestId}`;
const statusRes = await fetch(statusUrl, { cache: 'no-store' }).then(
(res) => res.json(),
);
if (!statusRes.logAvailable) {
setDeploymentLogs(statusRes.lastState);
} else {
const logsUrl = `${deployment.deployer.deployerApiUrl}/log/${deployment.applicationDeploymentRequestId}`;
const logsRes = await fetch(logsUrl, { cache: 'no-store' }).then((res) =>
res.text(),
);
setDeploymentLogs(logsRes);
}
};
const renderDeploymentStatus = useCallback( const renderDeploymentStatus = useCallback(
(className?: string) => { (className?: string) => {
return ( return (
<Tooltip title="Click to view build logs"> <div className={className}>
<div className={className} style={{ cursor: 'pointer' }}> <Tag
<Tag leftIcon={getIconByDeploymentStatus(deployment.status)}
leftIcon={getIconByDeploymentStatus(deployment.status)} size="xs"
size="xs" type={STATUS_COLORS[deployment.status] ?? 'neutral'}
type={STATUS_COLORS[deployment.status] ?? 'neutral'} >
onClick={fetchDeploymentLogs} {deployment.status}
> </Tag>
{deployment.status} </div>
</Tag>
</div>
</Tooltip>
); );
}, },
[deployment.status, deployment.commitHash], [deployment.status, deployment.commitHash],
@ -132,7 +83,7 @@ const DeploymentDetailsCard = ({
return ( return (
<div className="flex md:flex-row flex-col gap-6 py-4 px-3 pb-6 mb-2 last:mb-0 last:pb-4 border-b border-border-separator last:border-b-transparent relative"> <div className="flex md:flex-row flex-col gap-6 py-4 px-3 pb-6 mb-2 last:mb-0 last:pb-4 border-b border-border-separator last:border-b-transparent relative">
<div className="flex-1 flex justify-between w-full md:max-w-[30%] lg:max-w-[33%]"> <div className="flex-1 flex justify-between w-full md:max-w-[25%] lg:max-w-[28%]">
<div className="flex-1 w-full space-y-2 max-w-[90%] sm:max-w-full"> <div className="flex-1 w-full space-y-2 max-w-[90%] sm:max-w-full">
{/* DEPLOYMENT URL */} {/* DEPLOYMENT URL */}
{deployment.url && ( {deployment.url && (
@ -145,12 +96,7 @@ const DeploymentDetailsCard = ({
</OverflownText> </OverflownText>
</Heading> </Heading>
)} )}
{deployment.deployer.deployerLrn && ( <span className="text-sm text-elements-low-em tracking-tight">
<span className="text-sm text-elements-low-em tracking-tight block mt-2">
Deployer LRN: {deployment.deployer.deployerLrn}
</span>
)}
<span className="text-sm text-elements-low-em tracking-tight block">
{deployment.environment === Environment.Production {deployment.environment === Environment.Production
? `Production ${deployment.isCurrent ? '(Current)' : ''}` ? `Production ${deployment.isCurrent ? '(Current)' : ''}`
: 'Preview'} : 'Preview'}
@ -216,20 +162,6 @@ const DeploymentDetailsCard = ({
prodBranchDomains={prodBranchDomains} prodBranchDomains={prodBranchDomains}
/> />
</div> </div>
<Dialog
open={openDialog}
onClose={handleCloseDialog}
fullWidth
maxWidth="md"
>
<DialogTitle>Deployment logs</DialogTitle>
<DialogContent style={DEPLOYMENT_LOGS_STYLE}>
{deploymentLogs && <pre>{deploymentLogs}</pre>}
</DialogContent>
<DialogActions>
<Button onClick={handleCloseDialog}>Close</Button>
</DialogActions>
</Dialog>
</div> </div>
); );
}; };

View File

@ -1,12 +1,5 @@
import { useState, ComponentPropsWithRef } from 'react'; import { useState } from 'react';
import toast from 'react-hot-toast';
import {
Menu,
MenuHandler,
MenuItem,
MenuList,
} from '@snowballtools/material-tailwind-react-fork';
import { Deployment, Domain, Environment, Project } from 'gql-client'; import { Deployment, Domain, Environment, Project } from 'gql-client';
import { Button } from 'components/shared/Button'; import { Button } from 'components/shared/Button';
import { import {
@ -18,12 +11,17 @@ import {
UndoIcon, UndoIcon,
CrossCircleIcon, CrossCircleIcon,
} from 'components/shared/CustomIcon'; } from 'components/shared/CustomIcon';
import {
Menu,
MenuHandler,
MenuItem,
MenuList,
} from '@snowballtools/material-tailwind-react-fork';
import { ComponentPropsWithRef } from 'react';
import AssignDomainDialog from './AssignDomainDialog'; import AssignDomainDialog from './AssignDomainDialog';
import { useGQLClient } from 'context/GQLClientContext'; import { useGQLClient } from 'context/GQLClientContext';
import { cn } from 'utils/classnames'; import { cn } from 'utils/classnames';
import { ChangeStateToProductionDialog } from 'components/projects/Dialog/ChangeStateToProductionDialog'; import { ChangeStateToProductionDialog } from 'components/projects/Dialog/ChangeStateToProductionDialog';
import { useToast } from 'components/shared/Toast';
import { DeleteDeploymentDialog } from 'components/projects/Dialog/DeleteDeploymentDialog';
interface DeploymentMenuProps extends ComponentPropsWithRef<'div'> { interface DeploymentMenuProps extends ComponentPropsWithRef<'div'> {
deployment: Deployment; deployment: Deployment;
@ -43,55 +41,29 @@ export const DeploymentMenu = ({
...props ...props
}: DeploymentMenuProps) => { }: DeploymentMenuProps) => {
const client = useGQLClient(); const client = useGQLClient();
const { toast, dismiss } = useToast();
const [changeToProduction, setChangeToProduction] = useState(false); const [changeToProduction, setChangeToProduction] = useState(false);
const [redeployToProduction, setRedeployToProduction] = useState(false); const [redeployToProduction, setRedeployToProduction] = useState(false);
const [deleteDeploymentDialog, setDeleteDeploymentDialog] = useState(false);
const [isConfirmDeleteLoading, setIsConfirmDeleteLoading] = useState(false);
const [rollbackDeployment, setRollbackDeployment] = useState(false); const [rollbackDeployment, setRollbackDeployment] = useState(false);
const [assignDomainDialog, setAssignDomainDialog] = useState(false); const [assignDomainDialog, setAssignDomainDialog] = useState(false);
const [isConfirmButtonLoading, setConfirmButtonLoadingLoading] =
useState(false);
const updateDeployment = async () => { const updateDeployment = async () => {
const isUpdated = await client.updateDeploymentToProd(deployment.id); const isUpdated = await client.updateDeploymentToProd(deployment.id);
if (isUpdated.updateDeploymentToProd) { if (isUpdated) {
await onUpdate(); await onUpdate();
toast({ toast.success('Deployment changed to production');
id: 'deployment_changed_to_production',
title: 'Deployment changed to production',
variant: 'success',
onDismiss: dismiss,
});
} else { } else {
toast({ toast.error('Unable to change deployment to production');
id: 'deployment_not_changed_to_production',
title: 'Error changing deployment to production',
variant: 'error',
onDismiss: dismiss,
});
} }
}; };
const redeployToProd = async () => { const redeployToProd = async () => {
const isRedeployed = await client.redeployToProd(deployment.id); const isRedeployed = await client.redeployToProd(deployment.id);
setConfirmButtonLoadingLoading(false); if (isRedeployed) {
if (isRedeployed.redeployToProd) {
await onUpdate(); await onUpdate();
toast({ toast.success('Redeployed to production');
id: 'redeployed_to_production',
title: 'Redeployed to production',
variant: 'success',
onDismiss: dismiss,
});
} else { } else {
toast({ toast.error('Unable to redeploy to production');
id: 'redeployed_to_production_failed',
title: 'Error redeploying to production',
variant: 'error',
onDismiss: dismiss,
});
} }
}; };
@ -100,45 +72,21 @@ export const DeploymentMenu = ({
project.id, project.id,
deployment.id, deployment.id,
); );
if (isRollbacked.rollbackDeployment) { if (isRollbacked) {
await onUpdate(); await onUpdate();
toast({ toast.success('Deployment rolled back');
id: 'deployment_rolled_back',
title: 'Deployment rolled back',
variant: 'success',
onDismiss: dismiss,
});
} else { } else {
toast({ toast.error('Unable to rollback deployment');
id: 'deployment_rollback_failed',
title: 'Error rolling back deployment',
variant: 'error',
onDismiss: dismiss,
});
} }
}; };
const deleteDeployment = async () => { const deleteDeployment = async () => {
const isDeleted = await client.deleteDeployment(deployment.id); const isDeleted = await client.deleteDeployment(deployment.id);
if (isDeleted) {
setIsConfirmDeleteLoading(false);
setDeleteDeploymentDialog((preVal) => !preVal);
if (isDeleted.deleteDeployment) {
await onUpdate(); await onUpdate();
toast({ toast.success('Deleted deployment');
id: 'deployment_removal_requested',
title: 'Deployment removal requested',
variant: 'success',
onDismiss: dismiss,
});
} else { } else {
toast({ toast.error('Unable to delete deployment');
id: 'deployment_not_deleted',
title: 'Error deleting deployment',
variant: 'error',
onDismiss: dismiss,
});
} }
}; };
@ -212,7 +160,7 @@ export const DeploymentMenu = ({
</MenuItem> </MenuItem>
<MenuItem <MenuItem
className="hover:bg-base-bg-emphasized flex items-center gap-3" className="hover:bg-base-bg-emphasized flex items-center gap-3"
onClick={() => setDeleteDeploymentDialog((preVal) => !preVal)} onClick={() => deleteDeployment()}
> >
<CrossCircleIcon /> Delete deployment <CrossCircleIcon /> Delete deployment
</MenuItem> </MenuItem>
@ -238,13 +186,11 @@ export const DeploymentMenu = ({
open={redeployToProduction} open={redeployToProduction}
confirmButtonTitle="Redeploy" confirmButtonTitle="Redeploy"
handleConfirm={async () => { handleConfirm={async () => {
setConfirmButtonLoadingLoading(true);
await redeployToProd(); await redeployToProd();
setRedeployToProduction((preVal) => !preVal); setRedeployToProduction((preVal) => !preVal);
}} }}
deployment={deployment} deployment={deployment}
domains={deployment.domain ? [deployment.domain] : []} domains={deployment.domain ? [deployment.domain] : []}
isConfirmButtonLoading={isConfirmButtonLoading}
/> />
{Boolean(currentDeployment) && ( {Boolean(currentDeployment) && (
<ChangeStateToProductionDialog <ChangeStateToProductionDialog
@ -265,15 +211,6 @@ export const DeploymentMenu = ({
open={assignDomainDialog} open={assignDomainDialog}
handleOpen={() => setAssignDomainDialog(!assignDomainDialog)} handleOpen={() => setAssignDomainDialog(!assignDomainDialog)}
/> />
<DeleteDeploymentDialog
open={deleteDeploymentDialog}
handleConfirm={async () => {
setIsConfirmDeleteLoading(true);
await deleteDeployment();
}}
handleCancel={() => setDeleteDeploymentDialog((preVal) => !preVal)}
isConfirmButtonLoading={isConfirmDeleteLoading}
/>
</> </>
); );
}; };

View File

@ -1,171 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import { Auction, Deployer, Project } from 'gql-client';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
} from '@mui/material';
import {
CheckRoundFilledIcon,
LoadingIcon,
} from 'components/shared/CustomIcon';
import { useGQLClient } from 'context/GQLClientContext';
import { Button, Heading, Tag } from 'components/shared';
const WAIT_DURATION = 5000;
const DIALOG_STYLE = {
backgroundColor: 'rgba(0,0,0, .9)',
padding: '2em',
borderRadius: '0.5em',
marginLeft: '0.5em',
marginRight: '0.5em',
color: 'gray',
fontSize: 'small',
};
export const AuctionCard = ({ project }: { project: Project }) => {
const [auctionStatus, setAuctionStatus] = useState<string>('');
const [deployers, setDeployers] = useState<Deployer[]>([]);
const [fundsStatus, setFundsStatus] = useState<boolean>(false);
const [auctionDetails, setAuctionDetails] = useState<Auction | null>(null);
const [openDialog, setOpenDialog] = useState<boolean>(false);
const client = useGQLClient();
const getIconByAuctionStatus = (status: string) =>
status === 'completed' ? (
<CheckRoundFilledIcon />
) : (
<LoadingIcon className="animate-spin" />
);
const checkAuctionStatus = useCallback(async () => {
const result = await client.getAuctionData(project.auctionId);
setAuctionStatus(result.status);
setAuctionDetails(result);
}, [project.auctionId, project.deployers, project.fundsReleased]);
const fetchUpdatedProject = useCallback(async () => {
const updatedProject = await client.getProject(project.id);
setDeployers(updatedProject.project!.deployers!);
setFundsStatus(updatedProject.project!.fundsReleased!);
}, [project.id]);
const fetchData = useCallback(async () => {
await Promise.all([checkAuctionStatus(), fetchUpdatedProject()]);
}, [checkAuctionStatus, fetchUpdatedProject]);
useEffect(() => {
fetchData();
const timerId = setInterval(() => {
fetchData();
}, WAIT_DURATION);
return () => clearInterval(timerId);
}, [fetchData]);
const renderAuctionStatus = useCallback(
() => (
<Tag
leftIcon={getIconByAuctionStatus(auctionStatus)}
size="xs"
type={auctionStatus === 'completed' ? 'positive' : 'emphasized'}
>
{auctionStatus.toUpperCase()}
</Tag>
),
[auctionStatus],
);
const handleOpenDialog = () => setOpenDialog(true);
const handleCloseDialog = () => setOpenDialog(false);
return (
<>
<div className="p-3 gap-2 rounded-xl border border-gray-200 transition-colors hover:bg-base-bg-alternate flex flex-col mt-8">
<div className="flex justify-between items-center">
<Heading className="text-lg leading-6 font-medium">
Auction details
</Heading>
<Button onClick={handleOpenDialog} variant="tertiary" size="sm">
View details
</Button>
</div>
<div className="flex justify-between items-center mt-2">
<span className="text-elements-high-em text-sm font-medium tracking-tight">
Auction Id
</span>
<span className="text-elements-mid-em text-sm text-right">
{project.auctionId}
</span>
</div>
<div className="flex justify-between items-center mt-1">
<span className="text-elements-high-em text-sm font-medium tracking-tight">
Auction Status
</span>
<div className="ml-2">{renderAuctionStatus()}</div>
</div>
{auctionStatus === 'completed' && (
<>
{deployers?.length > 0 ? (
<div>
<span className="text-elements-high-em text-sm font-medium tracking-tight">
Deployer LRNs
</span>
{deployers.map((deployer, index) => (
<p key={index} className="text-elements-mid-em text-sm">
{'\u2022'} {deployer.deployerLrn}
</p>
))}
<div className="flex justify-between items-center mt-1">
<span className="text-elements-high-em text-sm font-medium tracking-tight">
Deployer Funds Status
</span>
<div className="ml-2">
<Tag
size="xs"
type={fundsStatus ? 'positive' : 'emphasized'}
>
{fundsStatus ? 'RELEASED' : 'WAITING'}
</Tag>
</div>
</div>
</div>
) : (
<div className="mt-3">
<span className="text-elements-high-em text-sm font-medium tracking-tight">
No winning deployers
</span>
</div>
)}
</>
)}
</div>
<Dialog
open={openDialog}
onClose={handleCloseDialog}
fullWidth
maxWidth="md"
>
<DialogTitle>Auction Details</DialogTitle>
<DialogContent style={DIALOG_STYLE}>
{auctionDetails && (
<pre>{JSON.stringify(auctionDetails, null, 2)}</pre>
)}
</DialogContent>
<DialogActions>
<Button onClick={handleCloseDialog}>Close</Button>
</DialogActions>
</Dialog>
</>
);
};

View File

@ -1,13 +1,13 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { AddProjectMemberInput, Permission } from 'gql-client';
import { Typography } from '@snowballtools/material-tailwind-react-fork'; import { Typography } from '@snowballtools/material-tailwind-react-fork';
import { Button } from 'components/shared/Button'; import { Button } from 'components/shared/Button';
import { Modal } from 'components/shared/Modal'; import { Modal } from 'components/shared/Modal';
import { Input } from 'components/shared/Input'; import { Input } from 'components/shared/Input';
import { Select, SelectOption } from 'components/shared/Select'; import { Checkbox } from 'components/shared/Checkbox';
import { AddProjectMemberInput, Permission } from 'gql-client';
interface AddMemberDialogProp { interface AddMemberDialogProp {
open: boolean; open: boolean;
@ -17,30 +17,18 @@ interface AddMemberDialogProp {
interface formData { interface formData {
emailAddress: string; emailAddress: string;
canEdit: boolean; permissions: {
view: boolean;
edit: boolean;
};
} }
const permissionViewOptions: SelectOption = {
value: Permission.View,
label: Permission.View,
};
const permissionEditOptions: SelectOption = {
value: Permission.Edit,
label: Permission.Edit,
};
const permissionsDropdownOptions: SelectOption[] = [
permissionViewOptions,
permissionEditOptions,
];
const AddMemberDialog = ({ const AddMemberDialog = ({
open, open,
handleOpen, handleOpen,
handleAddMember, handleAddMember,
}: AddMemberDialogProp) => { }: AddMemberDialogProp) => {
const { const {
watch,
setValue,
handleSubmit, handleSubmit,
register, register,
reset, reset,
@ -48,7 +36,10 @@ const AddMemberDialog = ({
} = useForm({ } = useForm({
defaultValues: { defaultValues: {
emailAddress: '', emailAddress: '',
canEdit: false, permissions: {
view: true,
edit: false,
},
}, },
}); });
@ -56,7 +47,11 @@ const AddMemberDialog = ({
reset(); reset();
handleOpen(); handleOpen();
const permissions = [data.canEdit ? Permission.Edit : Permission.View]; const permissions = Object.entries(data.permissions)
.filter(([, value]) => value)
.map(
([key]) => key.charAt(0).toUpperCase() + key.slice(1),
) as Permission[];
await handleAddMember({ email: data.emailAddress, permissions }); await handleAddMember({ email: data.emailAddress, permissions });
}, []); }, []);
@ -77,19 +72,19 @@ const AddMemberDialog = ({
required: 'email field cannot be empty', required: 'email field cannot be empty',
})} })}
/> />
<Select <Typography variant="small">Permissions</Typography>
label="Permissions" <Typography variant="small">
description="You can change this later if required." You can change this later if required.
options={permissionsDropdownOptions} </Typography>
value={ <Checkbox
watch('canEdit') ? permissionEditOptions : permissionViewOptions label={Permission.View}
} {...register(`permissions.view`)}
onChange={(value) => color="blue"
setValue( />
'canEdit', <Checkbox
(value as SelectOption)!.value === Permission.Edit, label={Permission.Edit}
) {...register(`permissions.edit`)}
} color="blue"
/> />
</Modal.Body> </Modal.Body>
<Modal.Footer> <Modal.Footer>

View File

@ -61,9 +61,7 @@ const DeleteProjectDialog = ({
<Input <Input
label={ label={
"Deleting your project is irreversible. Enter your project's name " + "Deleting your project is irreversible. Enter your project's name " +
'"' +
project.name + project.name +
'"' +
' below to confirm you want to permanently delete it:' ' below to confirm you want to permanently delete it:'
} }
id="input" id="input"

View File

@ -2,6 +2,7 @@ import { useState } from 'react';
import { Domain, DomainStatus, Project } from 'gql-client'; import { Domain, DomainStatus, Project } from 'gql-client';
import { import {
Chip,
Typography, Typography,
Menu, Menu,
MenuHandler, MenuHandler,
@ -14,15 +15,6 @@ import EditDomainDialog from './EditDomainDialog';
import { useGQLClient } from 'context/GQLClientContext'; import { useGQLClient } from 'context/GQLClientContext';
import { DeleteDomainDialog } from 'components/projects/Dialog/DeleteDomainDialog'; import { DeleteDomainDialog } from 'components/projects/Dialog/DeleteDomainDialog';
import { useToast } from 'components/shared/Toast'; import { useToast } from 'components/shared/Toast';
import { Tag } from 'components/shared/Tag';
import {
CheckIcon,
CrossIcon,
GearIcon,
LoadingIcon,
} from 'components/shared/CustomIcon';
import { Heading } from 'components/shared/Heading';
import { Button } from 'components/shared/Button';
enum RefreshStatus { enum RefreshStatus {
IDLE, IDLE,
@ -87,29 +79,22 @@ const DomainCard = ({
<> <>
<div className="flex justify-between py-3"> <div className="flex justify-between py-3">
<div className="flex justify-start gap-1"> <div className="flex justify-start gap-1">
<Heading as="h6" className="flex-col"> <Typography variant="h6">
{domain.name}{' '} <i>^</i> {domain.name}
<Tag </Typography>
type={ <Chip
domain.status === DomainStatus.Live ? 'positive' : 'negative' className="w-fit capitalize"
} value={domain.status}
leftIcon={ color={domain.status === DomainStatus.Live ? 'green' : 'orange'}
domain.status === DomainStatus.Live ? ( variant="ghost"
<CheckIcon /> icon={<i>^</i>}
) : ( />
<CrossIcon />
)
}
>
{domain.status}
</Tag>
</Heading>
</div> </div>
<div className="flex justify-start gap-1"> <div className="flex justify-start gap-1">
<i <i
id="refresh" id="refresh"
className="cursor-pointer" className="cursor-pointer w-8 h-8"
onClick={() => { onClick={() => {
SetRefreshStatus(RefreshStatus.CHECKING); SetRefreshStatus(RefreshStatus.CHECKING);
setTimeout(() => { setTimeout(() => {
@ -117,17 +102,11 @@ const DomainCard = ({
}, CHECK_FAIL_TIMEOUT); }, CHECK_FAIL_TIMEOUT);
}} }}
> >
{refreshStatus === RefreshStatus.CHECKING ? ( {refreshStatus === RefreshStatus.CHECKING ? 'L' : 'R'}
<LoadingIcon className="animate-spin" />
) : (
'L'
)}
</i> </i>
<Menu placement="bottom-end"> <Menu placement="bottom-end">
<MenuHandler> <MenuHandler>
<Button iconOnly> <button className="border-2 rounded-full w-8 h-8">...</button>
<GearIcon />
</Button>
</MenuHandler> </MenuHandler>
<MenuList> <MenuList>
<MenuItem <MenuItem
@ -164,13 +143,13 @@ const DomainCard = ({
{domain.status === DomainStatus.Pending && ( {domain.status === DomainStatus.Pending && (
<Card className="bg-slate-100 p-4 text-sm"> <Card className="bg-slate-100 p-4 text-sm">
{refreshStatus === RefreshStatus.IDLE ? ( {refreshStatus === RefreshStatus.IDLE ? (
<Heading> <Typography variant="small">
^ Add these records to your domain and refresh to check ^ Add these records to your domain and refresh to check
</Heading> </Typography>
) : refreshStatus === RefreshStatus.CHECKING ? ( ) : refreshStatus === RefreshStatus.CHECKING ? (
<Heading className="text-blue-500"> <Typography variant="small" className="text-blue-500">
^ Checking records for {domain.name} ^ Checking records for {domain.name}
</Heading> </Typography>
) : ( ) : (
<div className="flex gap-2 text-red-500 mb-2"> <div className="flex gap-2 text-red-500 mb-2">
<div className="grow"> <div className="grow">

View File

@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo } from 'react'; import { useCallback, useEffect, useMemo } from 'react';
import { Controller, useForm, SubmitHandler } from 'react-hook-form'; import { Controller, useForm, SubmitHandler } from 'react-hook-form';
import toast from 'react-hot-toast';
import { Domain } from 'gql-client'; import { Domain } from 'gql-client';
import { import {
@ -8,11 +9,10 @@ import {
Option, Option,
} from '@snowballtools/material-tailwind-react-fork'; } from '@snowballtools/material-tailwind-react-fork';
import { useGQLClient } from 'context/GQLClientContext'; import { useGQLClient } from '../../../../context/GQLClientContext';
import { Modal } from 'components/shared/Modal'; import { Modal } from 'components/shared/Modal';
import { Button } from 'components/shared/Button'; import { Button } from 'components/shared/Button';
import { Input } from 'components/shared/Input'; import { Input } from 'components/shared/Input';
import { useToast } from 'components/shared/Toast';
const DEFAULT_REDIRECT_OPTIONS = ['none']; const DEFAULT_REDIRECT_OPTIONS = ['none'];
@ -40,7 +40,6 @@ const EditDomainDialog = ({
onUpdate, onUpdate,
}: EditDomainDialogProp) => { }: EditDomainDialogProp) => {
const client = useGQLClient(); const client = useGQLClient();
const { toast, dismiss } = useToast();
const getRedirectUrl = (domain: Domain) => { const getRedirectUrl = (domain: Domain) => {
const redirectDomain = domain.redirectTo; const redirectDomain = domain.redirectTo;
@ -100,20 +99,10 @@ const EditDomainDialog = ({
if (updateDomain) { if (updateDomain) {
await onUpdate(); await onUpdate();
toast({ toast.success(`Domain ${domain.name} has been updated`);
id: 'domain_id_updated',
title: `Domain ${domain.name} has been updated`,
variant: 'success',
onDismiss: dismiss,
});
} else { } else {
reset(); reset();
toast({ toast.error(`Error updating domain ${domain.name}`);
id: 'domain_id_error_update',
title: `Error updating domain ${domain.name}`,
variant: 'error',
onDismiss: dismiss,
});
} }
handleOpen(); handleOpen();

View File

@ -3,8 +3,6 @@ import { useState } from 'react';
import { DeleteWebhookDialog } from 'components/projects/Dialog/DeleteWebhookDialog'; import { DeleteWebhookDialog } from 'components/projects/Dialog/DeleteWebhookDialog';
import { Button } from 'components/shared/Button'; import { Button } from 'components/shared/Button';
import { useToast } from 'components/shared/Toast'; import { useToast } from 'components/shared/Toast';
import { Input } from 'components/shared/Input';
import { CopyIcon, TrashIcon } from 'components/shared/CustomIcon';
interface WebhookCardProps { interface WebhookCardProps {
webhookUrl: string; webhookUrl: string;
@ -16,12 +14,11 @@ const WebhookCard = ({ webhookUrl, onDelete }: WebhookCardProps) => {
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
return ( return (
<div className="flex justify-between w-full mb-3 gap-3"> <div className="flex justify-between w-full mb-3">
<Input value={webhookUrl} disabled /> {webhookUrl}
<div className="flex gap-3"> <div className="flex gap-3">
<Button <Button
iconOnly size="sm"
size="md"
onClick={() => { onClick={() => {
navigator.clipboard.writeText(webhookUrl); navigator.clipboard.writeText(webhookUrl);
toast({ toast({
@ -32,17 +29,16 @@ const WebhookCard = ({ webhookUrl, onDelete }: WebhookCardProps) => {
}); });
}} }}
> >
<CopyIcon /> Copy
</Button> </Button>
<Button <Button
iconOnly size="sm"
size="md"
variant="danger" variant="danger"
onClick={() => { onClick={() => {
setDeleteDialogOpen(true); setDeleteDialogOpen(true);
}} }}
> >
<TrashIcon /> X
</Button> </Button>
</div> </div>
<DeleteWebhookDialog <DeleteWebhookDialog

View File

@ -4,6 +4,7 @@ import {
useMemo, useMemo,
ComponentPropsWithoutRef, ComponentPropsWithoutRef,
} from 'react'; } from 'react';
import { FieldValues, UseFormRegister } from 'react-hook-form';
import { WarningIcon } from 'components/shared/CustomIcon'; import { WarningIcon } from 'components/shared/CustomIcon';
import { cloneIcon } from 'utils/cloneIcon'; import { cloneIcon } from 'utils/cloneIcon';
@ -11,7 +12,7 @@ import { cn } from 'utils/classnames';
import { InputTheme, inputTheme } from './Input.theme'; import { InputTheme, inputTheme } from './Input.theme';
export interface InputProps export interface InputProps<T extends FieldValues = FieldValues>
extends InputTheme, extends InputTheme,
Omit<ComponentPropsWithoutRef<'input'>, 'size'> { Omit<ComponentPropsWithoutRef<'input'>, 'size'> {
label?: string; label?: string;
@ -19,6 +20,9 @@ export interface InputProps
leftIcon?: ReactNode; leftIcon?: ReactNode;
rightIcon?: ReactNode; rightIcon?: ReactNode;
helperText?: string; helperText?: string;
// react-hook-form optional register
register?: ReturnType<UseFormRegister<T>>;
} }
const Input = forwardRef<HTMLInputElement, InputProps>( const Input = forwardRef<HTMLInputElement, InputProps>(
@ -30,6 +34,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
leftIcon, leftIcon,
rightIcon, rightIcon,
helperText, helperText,
register,
size, size,
state, state,
appearance, appearance,
@ -102,11 +107,12 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
<div className={containerCls({ class: className })}> <div className={containerCls({ class: className })}>
{leftIcon && renderLeftIcon} {leftIcon && renderLeftIcon}
<input <input
{...(register ? register : {})}
className={cn(inputCls(), { className={cn(inputCls(), {
'pl-10': leftIcon, 'pl-10': leftIcon,
})} })}
ref={ref}
{...props} {...props}
ref={ref}
/> />
{rightIcon && renderRightIcon} {rightIcon && renderRightIcon}
</div> </div>

View File

@ -1,8 +1,7 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { NavLink, useNavigate, useParams } from 'react-router-dom';
import { User } from 'gql-client'; import { Organization, User } from 'gql-client';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { useDisconnect } from 'wagmi';
import { useGQLClient } from 'context/GQLClientContext'; import { useGQLClient } from 'context/GQLClientContext';
import { import {
@ -19,7 +18,8 @@ import { getInitials } from 'utils/geInitials';
import { Button } from 'components/shared/Button'; import { Button } from 'components/shared/Button';
import { cn } from 'utils/classnames'; import { cn } from 'utils/classnames';
import { useMediaQuery } from 'usehooks-ts'; import { useMediaQuery } from 'usehooks-ts';
import { BASE_URL } from 'utils/constants'; import { SIDEBAR_MENU } from './constants';
import { UserSelect } from 'components/shared/UserSelect';
interface SidebarProps { interface SidebarProps {
mobileOpen?: boolean; mobileOpen?: boolean;
@ -32,7 +32,6 @@ export const Sidebar = ({ mobileOpen }: SidebarProps) => {
const isDesktop = useMediaQuery('(min-width: 960px)'); const isDesktop = useMediaQuery('(min-width: 960px)');
const [user, setUser] = useState<User>(); const [user, setUser] = useState<User>();
const { disconnect } = useDisconnect();
const fetchUser = useCallback(async () => { const fetchUser = useCallback(async () => {
const { user } = await client.getUser(); const { user } = await client.getUser();
@ -43,15 +42,54 @@ export const Sidebar = ({ mobileOpen }: SidebarProps) => {
fetchUser(); fetchUser();
}, []); }, []);
const [selectedOrgSlug, setSelectedOrgSlug] = useState(orgSlug);
const [organizations, setOrganizations] = useState<Organization[]>([]);
const fetchUserOrganizations = useCallback(async () => {
const { organizations } = await client.getOrganizations();
setOrganizations(organizations);
}, [orgSlug]);
useEffect(() => {
fetchUserOrganizations();
setSelectedOrgSlug(orgSlug);
}, [orgSlug]);
const formattedSelected = useMemo(() => {
const selected = organizations.find((org) => org.slug === selectedOrgSlug);
return {
value: selected?.slug ?? '',
label: selected?.name ?? '',
imgSrc: '/logo.svg',
};
}, [organizations, selectedOrgSlug, orgSlug]);
const formattedSelectOptions = useMemo(() => {
return organizations.map((org) => ({
value: org.slug,
label: org.name,
imgSrc: '/logo.svg',
}));
}, [organizations, selectedOrgSlug, orgSlug]);
const renderMenu = useMemo(() => {
return SIDEBAR_MENU(orgSlug).map(({ title, icon, url }, index) => (
<NavLink to={url} key={index}>
<Tabs.Trigger icon={icon} value={title}>
{title}
</Tabs.Trigger>
</NavLink>
));
}, [orgSlug]);
const handleLogOut = useCallback(async () => { const handleLogOut = useCallback(async () => {
await fetch(`${BASE_URL}/auth/logout`, { await fetch(`${import.meta.env.VITE_SERVER_URL}/auth/logout`, {
method: 'POST', method: 'POST',
credentials: 'include', credentials: 'include',
}); });
localStorage.clear(); localStorage.clear();
disconnect();
navigate('/login'); navigate('/login');
}, [disconnect, navigate]); }, [navigate]);
return ( return (
<motion.nav <motion.nav
@ -75,8 +113,16 @@ export const Sidebar = ({ mobileOpen }: SidebarProps) => {
<div className="hidden lg:flex"> <div className="hidden lg:flex">
<Logo orgSlug={orgSlug} /> <Logo orgSlug={orgSlug} />
</div> </div>
{/* This element ensures the space between logo and navigation */} {/* Switch organization */}
<div className="flex-1"></div> <div className="flex flex-1 flex-col gap-4">
<UserSelect
value={formattedSelected}
options={formattedSelectOptions}
/>
<Tabs defaultValue="Projects" orientation="vertical">
<Tabs.List>{renderMenu}</Tabs.List>
</Tabs>
</div>
{/* Bottom navigation */} {/* Bottom navigation */}
<div className="flex flex-col gap-5 justify-end"> <div className="flex flex-col gap-5 justify-end">
<Tabs defaultValue="Projects" orientation="vertical"> <Tabs defaultValue="Projects" orientation="vertical">

View File

@ -103,12 +103,10 @@ export const UserSelect = ({ options, value }: UserSelectProps) => {
<div className={theme.container()}> <div className={theme.container()}>
{/* Input */} {/* Input */}
<div <div
{...getToggleButtonProps( {...getToggleButtonProps({
{ ref: inputWrapperRef,
ref: inputWrapperRef, suppressRefError: true,
}, })}
{ suppressRefError: true },
)}
onClick={() => !dropdownOpen && openMenu()} onClick={() => !dropdownOpen && openMenu()}
className="cursor-pointer relative py-2 pl-2 pr-4 flex min-w-[200px] w-full items-center justify-between rounded-xl bg-surface-card shadow-sm" className="cursor-pointer relative py-2 pl-2 pr-4 flex min-w-[200px] w-full items-center justify-between rounded-xl bg-surface-card shadow-sm"
> >

View File

@ -1,117 +0,0 @@
import { useEffect, useRef } from 'react';
import { Input } from '../Input';
export interface VerifyCodeInputProps {
code: string;
setCode: (code: string) => void;
submitCode: () => void;
loading: boolean;
}
export const VerifyCodeInput = ({
code,
setCode,
submitCode,
loading,
}: VerifyCodeInputProps) => {
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
const handlePaste = (
e: React.ClipboardEvent<HTMLInputElement>,
i: number,
) => {
e.preventDefault();
const pasteData = e.clipboardData.getData('text').replace(/\D/g, ''); // Only digits
if (pasteData.length > 0) {
let newCodeArray = code.split('');
for (let j = 0; j < pasteData.length && i + j < 6; j++) {
newCodeArray[i + j] = pasteData[j];
}
const newCode = newCodeArray.join('');
setCode(newCode);
const nextIndex = Math.min(i + pasteData.length, 5);
const nextInput = inputRefs.current[nextIndex];
if (nextInput) nextInput.focus();
if (!newCode.includes(' ')) {
submitCode();
}
}
};
const handleKeyDown = (
e: React.KeyboardEvent<HTMLInputElement>,
i: number,
) => {
if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return;
if (e.key === 'Backspace') {
e.preventDefault();
const isEmpty = code[i] === ' ';
const newCode = !isEmpty
? code.slice(0, i) + ' ' + code.slice(i + 1, 6)
: code.slice(0, i - 1) + ' ' + code.slice(i, 6);
setCode(newCode.slice(0, 6));
if (i === 0 || !isEmpty) return;
const prev = inputRefs.current[i - 1];
if (prev) prev.focus();
return;
}
if (!e.key.match(/[0-9]/)) return;
e.preventDefault(); // Prevent the default event to avoid duplicate input
const newCode = code.slice(0, i) + e.key + code.slice(i + 1, 6);
setCode(newCode);
if (i === 5) {
submitCode();
return;
}
const next = inputRefs.current[i + 1];
if (next) next.focus();
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>, i: number) => {
const value = e.target.value.slice(-1);
if (!value.match(/[0-9]/)) return;
const newCode = code.slice(0, i) + value + code.slice(i + 1, 6);
setCode(newCode);
if (i < 5) {
const next = inputRefs.current[i + 1];
if (next) next.focus();
}
if (!newCode.includes(' ')) {
submitCode();
}
};
useEffect(() => {
if (inputRefs.current[0]) {
inputRefs.current[0].focus();
}
}, []);
return (
<div className="flex gap-2">
{code.split('').map((char, i) => (
<Input
key={i}
value={char === ' ' ? '' : char}
ref={(el) => (inputRefs.current[i] = el)}
onChange={(e) => handleChange(e, i)}
onPaste={(e) => handlePaste(e, i)}
onKeyDown={(e) => handleKeyDown(e, i)}
disabled={!!loading}
style={{ textAlign: 'center' }} // Add this line to center text
/>
))}
</div>
);
};

View File

@ -1 +0,0 @@
export { VerifyCodeInput } from './VerifyCodeInput';

View File

@ -1,27 +0,0 @@
export { Avatar } from './Avatar';
export { Badge } from './Badge';
export { Button } from './Button';
export { Calendar } from './Calendar';
export { Checkbox } from './Checkbox';
export { DatePicker } from './DatePicker';
export { DotBorder } from './DotBorder';
export { Heading } from './Heading';
export { IconWithFrame } from './IconWithFrame';
export { InlineNotification } from './InlineNotification';
export { Input } from './Input';
export { Modal } from './Modal';
export { OverflownText } from './OverflownText';
export { Radio } from './Radio';
export { SegmentedControls } from './SegmentedControls';
export { Select } from './Select';
export { Sidebar } from './Sidebar';
export { Step } from './Steps';
export { Switch } from './Switch';
export { Table } from './Table';
export { Tabs } from './Tabs';
export { Tag } from './Tag';
export { useToast } from './Toast';
export { Tooltip } from './Tooltip';
export { UserSelect } from './UserSelect';
export { VerifyCodeInput } from './VerifyCodeInput';
export { WavyBorder } from './WavyBorder';

View File

@ -1,210 +0,0 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from 'react';
import SignClient from '@walletconnect/sign-client';
import { getSdkError } from '@walletconnect/utils';
import { SessionTypes } from '@walletconnect/types';
import { walletConnectModal } from '../utils/web3modal';
import {
VITE_LACONICD_CHAIN_ID,
VITE_WALLET_CONNECT_ID,
} from 'utils/constants';
interface ClientInterface {
signClient: SignClient | undefined;
session: SessionTypes.Struct | undefined;
loadingSession: boolean;
onConnect: () => Promise<void>;
onDisconnect: () => Promise<void>;
onSessionDelete: () => void;
accounts: { address: string }[] | undefined;
}
const ClientContext = createContext({} as ClientInterface);
export const useWalletConnectClient = () => {
return useContext(ClientContext);
};
export const WalletConnectClientProvider = ({
children,
}: {
children: JSX.Element;
}) => {
const [signClient, setSignClient] = useState<SignClient>();
const [session, setSession] = useState<SessionTypes.Struct>();
const [loadingSession, setLoadingSession] = useState(true);
const [accounts, setAccounts] = useState<{ address: string }[]>();
const isSignClientInitializing = useRef<boolean>(false);
const onSessionConnect = useCallback(async (session: SessionTypes.Struct) => {
setSession(session);
}, []);
const subscribeToEvents = useCallback(
async (client: SignClient) => {
client.on('session_update', ({ topic, params }) => {
const { namespaces } = params;
const currentSession = client.session.get(topic);
const updatedSession = { ...currentSession, namespaces };
setSession(updatedSession);
});
},
[setSession],
);
const onConnect = async () => {
const proposalNamespace = {
cosmos: {
methods: ['cosmos_sendTokens'],
chains: [`cosmos:${VITE_LACONICD_CHAIN_ID}`],
events: [],
},
};
try {
const { uri, approval } = await signClient!.connect({
requiredNamespaces: proposalNamespace,
});
if (uri) {
walletConnectModal.openModal({ uri });
const session = await approval();
onSessionConnect(session);
walletConnectModal.closeModal();
}
} catch (e) {
console.error(e);
}
};
const onDisconnect = useCallback(async () => {
if (typeof signClient === 'undefined') {
throw new Error('WalletConnect is not initialized');
}
if (typeof session === 'undefined') {
throw new Error('Session is not connected');
}
await signClient.disconnect({
topic: session.topic,
reason: getSdkError('USER_DISCONNECTED'),
});
onSessionDelete();
}, [signClient, session]);
const onSessionDelete = () => {
setAccounts(undefined);
setSession(undefined);
};
const checkPersistedState = useCallback(
async (signClient: SignClient) => {
if (typeof signClient === 'undefined') {
throw new Error('WalletConnect is not initialized');
}
if (typeof session !== 'undefined') return;
if (signClient.session.length) {
const lastKeyIndex = signClient.session.keys.length - 1;
const previousSsession = signClient.session.get(
signClient.session.keys[lastKeyIndex],
);
await onSessionConnect(previousSsession);
return previousSsession;
}
},
[session, onSessionConnect],
);
const createClient = useCallback(async () => {
isSignClientInitializing.current = true;
try {
const signClient = await SignClient.init({
projectId: VITE_WALLET_CONNECT_ID,
metadata: {
name: 'Deploy App',
description: '',
url: window.location.href,
icons: ['https://avatars.githubusercontent.com/u/92608123'],
},
});
setSignClient(signClient);
await checkPersistedState(signClient);
await subscribeToEvents(signClient);
setLoadingSession(false);
} catch (e) {
console.error('error in createClient', e);
}
isSignClientInitializing.current = false;
}, [setSignClient, checkPersistedState, subscribeToEvents]);
useEffect(() => {
if (!signClient && !isSignClientInitializing.current) {
createClient();
}
}, [signClient, createClient]);
useEffect(() => {
const populateAccounts = async () => {
if (!session) {
return;
}
if (!session.namespaces['cosmos']) {
console.log('Accounts for cosmos namespace not found');
return;
}
const cosmosAddresses = session.namespaces['cosmos'].accounts;
const cosmosAccounts = cosmosAddresses.map((address) => ({
address,
}));
const allAccounts = cosmosAccounts;
setAccounts(allAccounts);
};
populateAccounts();
}, [session]);
useEffect(() => {
if (!signClient) {
return;
}
signClient.on('session_delete', onSessionDelete);
return () => {
signClient.off('session_delete', onSessionDelete);
};
});
return (
<ClientContext.Provider
value={{
signClient,
onConnect,
onDisconnect,
onSessionDelete,
loadingSession,
session,
accounts,
}}
>
{children}
</ClientContext.Provider>
);
};

View File

@ -1,116 +1,14 @@
import { ReactNode } from 'react'; import { ReactNode } from 'react';
import assert from 'assert';
import { SiweMessage, generateNonce } from 'siwe';
import { WagmiProvider } from 'wagmi';
import { mainnet } from 'wagmi/chains';
import axios from 'axios';
import { createWeb3Modal } from '@web3modal/wagmi/react';
import { defaultWagmiConfig } from '@web3modal/wagmi/react/config';
import { createSIWEConfig } from '@web3modal/siwe';
import type {
SIWECreateMessageArgs,
SIWEVerifyMessageArgs,
} from '@web3modal/core';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { VITE_WALLET_CONNECT_ID, BASE_URL } from 'utils/constants'; const queryClient = new QueryClient();
if (!VITE_WALLET_CONNECT_ID) { if (!import.meta.env.VITE_WALLET_CONNECT_ID) {
throw new Error('Error: REACT_APP_WALLET_CONNECT_ID env config is not set'); throw new Error('Error: REACT_APP_WALLET_CONNECT_ID env config is not set');
} }
assert(BASE_URL, 'VITE_SERVER_URL is not set in env');
const queryClient = new QueryClient(); export default function Web3Provider({ children }: { children: ReactNode }) {
const axiosInstance = axios.create({
baseURL: BASE_URL,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
},
withCredentials: true,
});
const metadata = {
name: 'Deploy App Auth',
description: '',
url: window.location.origin,
icons: ['https://avatars.githubusercontent.com/u/37784886'],
};
const chains = [mainnet] as const;
const config = defaultWagmiConfig({
chains,
projectId: VITE_WALLET_CONNECT_ID,
metadata,
});
const siweConfig = createSIWEConfig({
createMessage: ({ nonce, address, chainId }: SIWECreateMessageArgs) =>
new SiweMessage({
version: '1',
domain: window.location.host,
uri: window.location.origin,
address,
chainId,
nonce,
// Human-readable ASCII assertion that the user will sign, and it must not contain `\n`.
statement: 'Sign in With Ethereum.',
}).prepareMessage(),
getNonce: async () => {
return generateNonce();
},
getSession: async () => {
try {
const session = (await axiosInstance.get('/auth/session')).data;
const { address, chainId } = session;
return { address, chainId };
} catch (err) {
if (window.location.pathname !== '/login') {
window.location.href = '/login';
}
throw new Error('Failed to get session!');
}
},
verifyMessage: async ({ message, signature }: SIWEVerifyMessageArgs) => {
try {
const { success } = (
await axiosInstance.post('/auth/validate', {
message,
signature,
})
).data;
return success;
} catch (error) {
return false;
}
},
signOut: async () => {
try {
const { success } = (await axiosInstance.post('/auth/logout')).data;
return success;
} catch (error) {
return false;
}
},
onSignOut: () => {
window.location.href = '/login';
},
onSignIn: () => {
window.location.href = '/';
},
});
createWeb3Modal({
siweConfig,
wagmiConfig: config,
projectId: VITE_WALLET_CONNECT_ID,
});
export default function Web3ModalProvider({
children,
}: {
children: ReactNode;
}) {
return ( return (
<WagmiProvider config={config}> <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</WagmiProvider>
); );
} }

View File

@ -14,34 +14,25 @@ import { GQLClientProvider } from './context/GQLClientContext';
import { SERVER_GQL_PATH } from './constants'; import { SERVER_GQL_PATH } from './constants';
import { Toaster } from 'components/shared/Toast'; import { Toaster } from 'components/shared/Toast';
import { LogErrorBoundary } from 'utils/log-error'; import { LogErrorBoundary } from 'utils/log-error';
import { BASE_URL } from 'utils/constants';
import Web3ModalProvider from './context/Web3Provider';
import { WalletConnectClientProvider } from 'context/WalletConnectContext';
console.log(`v-0.0.9`);
const root = ReactDOM.createRoot( const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement, document.getElementById('root') as HTMLElement,
); );
assert(BASE_URL, 'VITE_SERVER_URL is not set in env'); assert(import.meta.env.VITE_SERVER_URL, 'VITE_SERVER_URL is not set in env');
const gqlEndpoint = `${BASE_URL}/${SERVER_GQL_PATH}`; const gqlEndpoint = `${import.meta.env.VITE_SERVER_URL}/${SERVER_GQL_PATH}`;
const gqlClient = new GQLClient({ gqlEndpoint }); const gqlClient = new GQLClient({ gqlEndpoint });
root.render( root.render(
<LogErrorBoundary> <LogErrorBoundary>
<React.StrictMode> <React.StrictMode>
<WalletConnectClientProvider> <ThemeProvider>
<ThemeProvider> <GQLClientProvider client={gqlClient}>
<Web3ModalProvider> <App />
<GQLClientProvider client={gqlClient}> <Toaster />
<App /> </GQLClientProvider>
<Toaster /> </ThemeProvider>
</GQLClientProvider>
</Web3ModalProvider>
</ThemeProvider>
</WalletConnectClientProvider>
</React.StrictMode> </React.StrictMode>
</LogErrorBoundary>, </LogErrorBoundary>,
); );

View File

@ -32,7 +32,7 @@ const ProjectSearch = () => {
return ( return (
<section className="h-full flex flex-col"> <section className="h-full flex flex-col">
{/* Header */} {/* Header */}
<div className="sticky hidden lg:block top-0 border-b bg-base-bg border-border-separator/[0.06] hover:z-30"> <div className="sticky hidden lg:block top-0 border-b bg-base-bg border-border-separator/[0.06] z-30">
<div className="flex pr-6 pl-2 py-2 items-center"> <div className="flex pr-6 pl-2 py-2 items-center">
<div className="flex-1"> <div className="flex-1">
<ProjectSearchBar <ProjectSearchBar

View File

@ -1,5 +1,5 @@
import { CloudyFlow } from 'components/CloudyFlow'; import { CloudyFlow } from 'components/CloudyFlow';
import { Login } from './auth/Login'; import { SnowballAuth } from './auth/SnowballAuth';
const AuthPage = () => { const AuthPage = () => {
return ( return (
@ -18,7 +18,7 @@ const AuthPage = () => {
</div> </div>
<div className="pb-12 relative z-10 flex-1 flex-center"> <div className="pb-12 relative z-10 flex-1 flex-center">
<div className="max-w-[520px] w-full bg-white rounded-xl shadow"> <div className="max-w-[520px] w-full bg-white rounded-xl shadow">
<Login /> <SnowballAuth />
</div> </div>
</div> </div>
</CloudyFlow> </CloudyFlow>

View File

@ -0,0 +1,83 @@
import { Button } from 'components/shared/Button';
import { LoaderIcon } from 'components/shared/CustomIcon';
import { KeyIcon } from 'components/shared/CustomIcon/KeyIcon';
import { InlineNotification } from 'components/shared/InlineNotification';
import { Input } from 'components/shared/Input';
import { WavyBorder } from 'components/shared/WavyBorder';
import { useState } from 'react';
import { IconRight } from 'react-day-picker';
import { useSnowball } from 'utils/use-snowball';
type Props = {
onDone: () => void;
};
export const CreatePasskey = ({}: Props) => {
const snowball = useSnowball();
const [name, setName] = useState('');
const auth = snowball.auth.passkey;
const loading = !!auth.state.loading;
async function createPasskey() {
await auth.register(name);
}
return (
<div>
<div className="self-stretch p-3 xs:p-6 flex-col justify-center items-center gap-5 flex">
<div className="w-16 h-16 p-2 bg-sky-100 rounded-[800px] justify-center items-center gap-2 inline-flex">
<KeyIcon />
</div>
<div>
<div className="self-stretch text-center text-sky-950 text-2xl font-medium font-display leading-loose">
Create a passkey
</div>
<div className="text-center text-slate-600 text-sm font-normal font-['Inter'] leading-tight">
Passkeys allow you to sign in securely without using passwords.
</div>
</div>
</div>
<WavyBorder className="self-stretch" variant="stroke" />
<div className="p-6 flex-col justify-center items-center gap-8 inline-flex">
<div className="self-stretch h-36 flex-col justify-center items-center gap-2 flex">
<div className="self-stretch h-[72px] flex-col justify-start items-start gap-2 flex">
<div className="self-stretch h-5 px-1 flex-col justify-start items-start gap-1 flex">
<div className="self-stretch text-sky-950 text-sm font-normal font-['Inter'] leading-tight">
Give it a name
</div>
</div>
<Input
value={name}
onInput={(e: any) => {
setName(e.target.value);
}}
/>
</div>
{auth.state.error ? (
<InlineNotification
title={auth.state.error.message}
variant="danger"
/>
) : (
<InlineNotification
title={`Once you press the "Create passkeys" button, you'll receive a prompt to create the passkey.`}
variant="info"
/>
)}
</div>
<Button
rightIcon={
loading ? <LoaderIcon className="animate-spin" /> : <IconRight />
}
className="self-stretch"
disabled={!name || loading}
onClick={createPasskey}
>
Create Passkey
</Button>
</div>
</div>
);
};

View File

@ -1,6 +1,98 @@
import { Button } from 'components/shared/Button';
import {
ArrowRightCircleFilledIcon,
GithubIcon,
LinkIcon,
LoaderIcon,
QuestionMarkRoundFilledIcon,
} from 'components/shared/CustomIcon';
import { GoogleIcon } from 'components/shared/CustomIcon/GoogleIcon';
import { DotBorder } from 'components/shared/DotBorder';
import { WavyBorder } from 'components/shared/WavyBorder'; import { WavyBorder } from 'components/shared/WavyBorder';
import { useEffect, useState } from 'react';
import { CreatePasskey } from './CreatePasskey';
import { AppleIcon } from 'components/shared/CustomIcon/AppleIcon';
import { KeyIcon } from 'components/shared/CustomIcon/KeyIcon';
import { useToast } from 'components/shared/Toast';
import { Link } from 'react-router-dom';
import { PKPEthersWallet } from '@lit-protocol/pkp-ethers';
import { signInWithEthereum } from 'utils/siwe';
import { useSnowball } from 'utils/use-snowball';
import { logError } from 'utils/log-error';
type Provider = 'google' | 'github' | 'apple' | 'email' | 'passkey';
type Props = {
onDone: () => void;
};
export const Login = ({ onDone }: Props) => {
const snowball = useSnowball();
const [error, setError] = useState<string>('');
const [provider, setProvider] = useState<Provider | false>(false);
// const loading = snowball.auth.state.loading && provider;
const loading = provider;
const { toast } = useToast();
if (provider === 'email') {
return <CreatePasskey onDone={onDone} />;
}
async function handleSigninRedirect() {
let wallet: PKPEthersWallet | undefined;
const { google } = snowball.auth;
if (google.canHandleOAuthRedirectBack()) {
setProvider('google');
console.log('Handling google redirect back');
try {
await google.handleOAuthRedirectBack();
wallet = await google.getEthersWallet();
const result = await signInWithEthereum(1, 'login', wallet);
if (result.error) {
setError(result.error);
setProvider(false);
wallet = undefined;
logError(new Error(result.error));
return;
}
} catch (err: any) {
setError(err.message);
logError(err);
setProvider(false);
return;
}
}
// if (apple.canHandleOAuthRedirectBack()) {
// setProvider('apple');
// console.log('Handling apple redirect back');
// try {
// await apple.handleOAuthRedirectBack();
// wallet = await apple.getEthersWallet();
// const result = await signInWithEthereum(1, 'login', wallet);
// if (result.error) {
// setError(result.error);
// setProvider(false);
// wallet = undefined;
// return;
// }
// } catch (err: any) {
// setError(err.message);
// console.log(err.message, err.name, err.details);
// setProvider(false);
// return;
// }
// }
if (wallet) {
window.location.pathname = '/';
}
}
useEffect(() => {
handleSigninRedirect();
}, []);
export const Login = () => {
return ( return (
<div> <div>
<div className="self-stretch p-3 xs:p-6 flex-col justify-center items-center gap-5 flex"> <div className="self-stretch p-3 xs:p-6 flex-col justify-center items-center gap-5 flex">
@ -11,8 +103,160 @@ export const Login = () => {
<WavyBorder className="self-stretch" variant="stroke" /> <WavyBorder className="self-stretch" variant="stroke" />
<div className="self-stretch p-4 xs:p-6 flex-col justify-center items-center gap-8 flex"> <div className="self-stretch p-4 xs:p-6 flex-col justify-center items-center gap-8 flex">
<div className="self-stretch p-5 bg-slate-50 rounded-xl shadow flex-col justify-center items-center gap-6 flex">
<div className="self-stretch flex-col justify-center items-center gap-4 flex">
<KeyIcon />
<div className="self-stretch flex-col justify-center items-center gap-2 flex">
<div className="self-stretch text-center text-sky-950 text-lg font-medium font-display leading-normal">
Got a Passkey?
</div>
<div className="self-stretch text-center text-slate-600 text-sm font-normal font-['Inter'] leading-tight">
Use it to sign in securely without using a password.
</div>
</div>
</div>
<div className="self-stretch justify-center items-stretch xxs:items-center gap-3 flex flex-col xxs:flex-row">
<Button
as="a"
leftIcon={<QuestionMarkRoundFilledIcon />}
variant={'tertiary'}
target="_blank"
href="https://safety.google/authentication/passkey/"
>
Learn more
</Button>
<Button
rightIcon={
loading && loading === 'passkey' ? (
<LoaderIcon className="animate-spin" />
) : (
<ArrowRightCircleFilledIcon height="16" />
)
}
className="flex-1"
disabled={!!loading}
onClick={async () => {
setProvider('passkey');
}}
>
Sign In with Passkey
</Button>
</div>
<div className="h-5 justify-center items-center gap-2 inline-flex">
<div className="text-center text-slate-600 text-sm font-normal font-['Inter'] leading-tight">
Lost your passkey?
</div>
<div className="justify-center items-center gap-1.5 flex">
<button className="text-sky-950 text-sm font-normal font-['Inter'] underline leading-tight">
Recover account
</button>
<LinkIcon />
</div>
</div>
</div>
<div className="self-stretch justify-start items-center gap-8 inline-flex">
<DotBorder className="flex-1" />
<div className="text-center text-slate-400 text-xs font-normal font-['JetBrains Mono'] leading-none">
OR
</div>
<DotBorder className="flex-1" />
</div>
<div className="self-stretch flex-col justify-center items-center gap-3 flex"> <div className="self-stretch flex-col justify-center items-center gap-3 flex">
<w3m-button /> <Button
leftIcon={<GoogleIcon />}
rightIcon={
loading && loading === 'google' ? (
<LoaderIcon className="animate-spin" />
) : null
}
onClick={() => {
setProvider('google');
snowball.auth.google.startOAuthRedirect();
}}
className="flex-1 self-stretch"
variant={'tertiary'}
disabled={!!loading}
>
Continue with Google
</Button>
<Button
leftIcon={<GithubIcon />}
rightIcon={
loading && loading === 'github' ? (
<LoaderIcon className="animate-spin" />
) : null
}
onClick={async () => {
setProvider('github');
await new Promise((resolve) => setTimeout(resolve, 800));
setProvider(false);
toast({
id: 'coming-soon',
title: 'Sign-in with GitHub is coming soon!',
variant: 'info',
onDismiss() {},
});
}}
className="flex-1 self-stretch"
variant={'tertiary'}
disabled={!!loading}
>
Continue with GitHub
</Button>
<Button
leftIcon={<AppleIcon />}
rightIcon={
loading && loading === 'apple' ? (
<LoaderIcon className="animate-spin text-white" />
) : null
}
onClick={async () => {
setProvider('apple');
// snowball.auth.apple.startOAuthRedirect();
await new Promise((resolve) => setTimeout(resolve, 800));
setProvider(false);
toast({
id: 'coming-soon',
title: 'Sign-in with Apple is coming soon!',
variant: 'info',
onDismiss() {},
});
}}
className={`flex-1 self-stretch border-black enabled:bg-black text-white ${
loading && loading === 'apple' ? 'disabled:bg-black' : ''
}`}
variant={'tertiary'}
disabled={!!loading}
>
Continue with Apple
</Button>
</div>
<div className="flex flex-col gap-3">
{error && (
<div className="justify-center items-center gap-2 inline-flex">
<div className="text-red-500 text-sm">Error: {error}</div>
</div>
)}
<div className="h-5 justify-center items-center gap-2 inline-flex">
<div className="text-center text-slate-600 text-sm font-normal font-['Inter'] leading-tight">
Don't have an account?
</div>
<div className="justify-center items-center gap-1.5 flex">
<Link
to="/signup"
className="text-sky-950 text-sm font-normal font-['Inter'] underline leading-tight"
>
Sign up now
</Link>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -0,0 +1,261 @@
import { Button } from 'components/shared/Button';
import {
ArrowRightCircleFilledIcon,
GithubIcon,
LoaderIcon,
} from 'components/shared/CustomIcon';
import { GoogleIcon } from 'components/shared/CustomIcon/GoogleIcon';
import { DotBorder } from 'components/shared/DotBorder';
import { WavyBorder } from 'components/shared/WavyBorder';
import { useEffect, useState } from 'react';
import { useSnowball } from 'utils/use-snowball';
import { Input } from 'components/shared/Input';
import { AppleIcon } from 'components/shared/CustomIcon/AppleIcon';
import { Link } from 'react-router-dom';
import { useToast } from 'components/shared/Toast';
import { PKPEthersWallet } from '@lit-protocol/pkp-ethers';
import { signInWithEthereum } from 'utils/siwe';
import { logError } from 'utils/log-error';
import {
subOrganizationIdForEmail,
turnkeySignin,
turnkeySignup,
} from 'utils/turnkey-frontend';
type Provider = 'google' | 'github' | 'apple' | 'email';
type Err = { type: 'email' | 'provider'; message: string };
type Props = {
onDone: () => void;
};
export const SignUp = ({ onDone }: Props) => {
const [email, setEmail] = useState('');
const [error, setError] = useState<Err | null>();
const [provider, setProvider] = useState<Provider | false>(false);
const { toast } = useToast();
const snowball = useSnowball();
async function handleSignupRedirect() {
let wallet: PKPEthersWallet | undefined;
const { google } = snowball.auth;
if (google.canHandleOAuthRedirectBack()) {
setProvider('google');
try {
await google.handleOAuthRedirectBack();
wallet = await google.getEthersWallet();
const result = await signInWithEthereum(1, 'signup', wallet);
if (result.error) {
setError({ type: 'provider', message: result.error });
setProvider(false);
wallet = undefined;
logError(new Error(result.error));
return;
}
} catch (err: any) {
setError({ type: 'provider', message: err.message });
setProvider(false);
logError(err);
return;
}
}
// if (apple.canHandleOAuthRedirectBack()) {
// setProvider('apple');
// try {
// await apple.handleOAuthRedirectBack();
// wallet = await apple.getEthersWallet();
// const result = await signInWithEthereum(1, 'signup', wallet);
// if (result.error) {
// setError({ type: 'provider', message: result.error });
// setProvider(false);
// wallet = undefined;
// return;
// }
// } catch (err: any) {
// setError({ type: 'provider', message: err.message });
// setProvider(false);
// return;
// }
// }
if (wallet) {
onDone();
}
}
async function authEmail() {
setProvider('email');
try {
const orgId = await subOrganizationIdForEmail(email);
console.log('orgId', orgId);
if (orgId) {
await turnkeySignin(orgId);
window.location.href = '/dashboard';
} else {
await turnkeySignup(email);
onDone();
}
} catch (err: any) {
setError({ type: 'email', message: err.message });
}
}
useEffect(() => {
handleSignupRedirect();
}, []);
const loading = provider;
const emailValid = /.@./.test(email);
return (
<div>
<div className="self-stretch p-3 xs:p-6 flex-col justify-center items-center gap-5 flex">
<div className="self-stretch text-center text-sky-950 text-2xl font-medium font-display leading-tight">
Sign up to Snowball
</div>
</div>
<WavyBorder className="self-stretch" variant="stroke" />
<div className="self-stretch p-4 xs:p-6 flex-col justify-center items-center gap-8 flex">
<div className="self-stretch flex-col justify-center items-center gap-3 flex">
<Button
leftIcon={loading && loading === 'google' ? null : <GoogleIcon />}
rightIcon={
loading && loading === 'google' ? (
<LoaderIcon className="animate-spin" />
) : null
}
onClick={() => {
setProvider('google');
snowball.auth.google.startOAuthRedirect();
}}
className="flex-1 self-stretch"
variant={'tertiary'}
disabled={!!loading}
>
Continue with Google
</Button>
<Button
leftIcon={<GithubIcon />}
rightIcon={
loading && loading === 'github' ? (
<LoaderIcon className="animate-spin" />
) : null
}
onClick={async () => {
setProvider('github');
await new Promise((resolve) => setTimeout(resolve, 800));
setProvider(false);
toast({
id: 'coming-soon',
title: 'Sign-in with GitHub is coming soon!',
variant: 'info',
onDismiss() {},
});
}}
className="flex-1 self-stretch"
variant={'tertiary'}
disabled={!!loading}
>
Continue with GitHub
</Button>
<Button
leftIcon={<AppleIcon />}
rightIcon={
loading && loading === 'apple' ? (
<LoaderIcon className="animate-spin text-white" />
) : null
}
onClick={async () => {
setProvider('apple');
// snowball.auth.apple.startOAuthRedirect();
await new Promise((resolve) => setTimeout(resolve, 800));
setProvider(false);
toast({
id: 'coming-soon',
title: 'Sign-in with Apple is coming soon!',
variant: 'info',
onDismiss() {},
});
}}
className={`flex-1 self-stretch border-black enabled:bg-black text-white ${
loading && loading === 'apple' ? 'disabled:bg-black' : ''
}`}
variant={'tertiary'}
disabled={!!loading}
>
Continue with Apple
</Button>
</div>
{error && error.type === 'provider' && (
<div className="-mt-3 justify-center items-center inline-flex">
<div className="text-red-500 text-sm">Error: {error.message}</div>
</div>
)}
<div className="self-stretch justify-start items-center gap-8 inline-flex">
<DotBorder className="flex-1" />
<div className="text-center text-slate-400 text-xs font-normal font-['JetBrains Mono'] leading-none">
OR
</div>
<DotBorder className="flex-1" />
</div>
<div className="self-stretch flex-col gap-8 flex">
<div className="flex-col justify-start items-start gap-2 inline-flex">
<div className="text-sky-950 text-sm font-normal font-['Inter'] leading-tight">
Email
</div>
<Input
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={!!loading}
/>
</div>
<Button
rightIcon={
loading && loading === 'email' ? (
<LoaderIcon className="animate-spin" />
) : (
<ArrowRightCircleFilledIcon height="16" />
)
}
onClick={() => {
authEmail();
}}
variant={'secondary'}
disabled={!email || !emailValid || !!loading}
>
Continue with Email
</Button>
<div className="flex flex-col gap-3">
{error && error.type === 'email' && (
<div className="justify-center items-center gap-2 inline-flex">
<div className="text-red-500 text-sm">
Error: {error.message}
</div>
</div>
)}
<div className="justify-center items-center gap-2 inline-flex">
<div className="text-center text-slate-600 text-sm font-normal font-['Inter'] leading-tight">
Already an user?
</div>
<div className="justify-center items-center gap-1.5 flex">
<Link
to="/login"
className="text-sky-950 text-sm font-normal font-['Inter'] underline leading-tight"
>
Sign in now
</Link>
</div>
</div>
</div>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,52 @@
import { snowball } from 'utils/use-snowball';
import { Login } from './Login';
import { SignUp } from './SignUp';
import { useEffect, useState } from 'react';
import { Done } from './Done';
type Screen = 'login' | 'signup' | 'success';
const DASHBOARD_URL = '/';
export const SnowballAuth = () => {
const path = window.location.pathname;
const [screen, setScreen] = useState<Screen>(
path === '/login' ? 'login' : 'signup',
);
useEffect(() => {
if (snowball.session) {
window.location.href = DASHBOARD_URL;
}
}, []);
useEffect(() => {
if (path === '/login') {
setScreen('login');
} else if (path === '/signup') {
setScreen('signup');
}
}, [path]);
if (screen === 'signup') {
return (
<SignUp
onDone={() => {
setScreen('success');
}}
/>
);
}
if (screen === 'login') {
return (
<Login
onDone={() => {
setScreen('success');
}}
/>
);
}
if (screen === 'success') {
return <Done continueTo={DASHBOARD_URL} />;
}
};

View File

@ -36,13 +36,6 @@ const deployment: Deployment = {
url: 'https://deploy1.example.com', url: 'https://deploy1.example.com',
environment: Environment.Production, environment: Environment.Production,
isCurrent: true, isCurrent: true,
deployer: {
deployerApiUrl: 'https://webapp-deployer-api.example.com',
deployerId: 'bafyreicrtgmkir4evvvysxdqxddf2ftdq2wrzuodgvwnxr4rmubi4obdfu',
deployerLrn: 'lrn://example/deployers/webapp-deployer-api.example.com',
minimumPayment: '1000alnt',
baseDomain: 'pwa.example.com',
},
status: DeploymentStatus.Ready, status: DeploymentStatus.Ready,
createdBy: { createdBy: {
id: 'user1', id: 'user1',
@ -55,8 +48,6 @@ const deployment: Deployment = {
}, },
createdAt: '1677676800', // 2023-03-01T12:00:00Z createdAt: '1677676800', // 2023-03-01T12:00:00Z
updatedAt: '1677680400', // 2023-03-01T13:00:00Z updatedAt: '1677680400', // 2023-03-01T13:00:00Z
applicationDeploymentRequestId:
'bafyreiaycvq6imoppnpwdve4smj6t6ql5svt5zl3x6rimu4qwyzgjorize',
}; };
const domains: Domain[] = [ const domains: Domain[] = [

View File

@ -1,19 +1,5 @@
import { Heading } from 'components/shared/Heading';
const Settings = () => { const Settings = () => {
return ( return <div className="p-5">Settings page</div>;
<section className="px-4 md:px-6 py-6 flex flex-col gap-6">
{/* Header */}
<div className="flex items-center">
<div className="grow">
<div className="flex gap-4 items-center">
<Heading as="h2" className="text-[24px]">
Settings
</Heading>
</div>
</div>
</div>
</section>
);
}; };
export default Settings; export default Settings;

View File

@ -1,11 +1,13 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { Link, useParams } from 'react-router-dom'; import { Link, useParams } from 'react-router-dom';
import { ProjectCard } from 'components/projects/ProjectCard';
import { Heading, Badge, Button } from 'components/shared';
import { PlusIcon } from 'components/shared/CustomIcon';
import { useGQLClient } from 'context/GQLClientContext';
import { Project } from 'gql-client'; import { Project } from 'gql-client';
import { Button } from 'components/shared/Button';
import { PlusIcon } from 'components/shared/CustomIcon';
import { ProjectCard } from 'components/projects/ProjectCard';
import { Heading } from 'components/shared/Heading';
import { Badge } from 'components/shared/Badge';
import { useGQLClient } from 'context/GQLClientContext';
const Projects = () => { const Projects = () => {
const client = useGQLClient(); const client = useGQLClient();
@ -26,7 +28,6 @@ const Projects = () => {
return ( return (
<section className="px-4 md:px-6 py-6 flex flex-col gap-6"> <section className="px-4 md:px-6 py-6 flex flex-col gap-6">
{/* Header */} {/* Header */}
<div className="flex items-center"> <div className="flex items-center">
<div className="grow"> <div className="grow">
<div className="flex gap-4 items-center"> <div className="flex gap-4 items-center">

View File

@ -92,13 +92,9 @@ const Id = () => {
Open repo Open repo
</Button> </Button>
</Link> </Link>
{(project.deployments.length > 0) && <Button {...buttonSize} className="h-11 transition-colors">
<Link to={`https://${project.name.toLowerCase()}.${project.deployments[0].deployer.baseDomain}`}> Go to app
<Button {...buttonSize} className="h-11 transition-colors"> </Button>
Go to app
</Button>
</Link>
}
</div> </div>
</div> </div>
<WavyBorder /> <WavyBorder />

View File

@ -31,11 +31,6 @@ const CreateWithTemplate = () => {
}, },
{ {
step: 2, step: 2,
route: `/${orgSlug}/projects/create/template/configure`,
label: 'Configure',
},
{
step: 3,
route: `/${orgSlug}/projects/create/template/deploy`, route: `/${orgSlug}/projects/create/template/deploy`,
label: 'Deploy', label: 'Deploy',
}, },

View File

@ -2,8 +2,7 @@ import NewProject from './index';
import CreateWithTemplate from './Template'; import CreateWithTemplate from './Template';
import { templateRoutes } from './template/routes'; import { templateRoutes } from './template/routes';
import Id from './success/Id'; import Id from './success/Id';
import Configure from 'components/projects/create/Configure'; import Import from './Import';
import Deploy from 'components/projects/create/Deploy';
export const createProjectRoutes = [ export const createProjectRoutes = [
{ {
@ -20,11 +19,7 @@ export const createProjectRoutes = [
element: <Id />, element: <Id />,
}, },
{ {
path: 'configure', path: 'import',
element: <Configure />, element: <Import />,
},
{
path: 'deploy',
element: <Deploy />,
}, },
]; ];

View File

@ -1,123 +1,108 @@
import { Link, useParams, useSearchParams } from 'react-router-dom'; import { Link, useParams } from 'react-router-dom';
import Lottie from 'lottie-react'; import Lottie from 'lottie-react';
import { Badge } from 'components/shared/Badge'; import { Badge } from 'components/shared/Badge';
import { Button } from 'components/shared/Button'; import { Button } from 'components/shared/Button';
import { import {
ArrowLeftCircleFilledIcon, ArrowLeftCircleFilledIcon,
LinkChainIcon,
QuestionMarkRoundFilledIcon, QuestionMarkRoundFilledIcon,
} from 'components/shared/CustomIcon'; } from 'components/shared/CustomIcon';
import { Heading } from 'components/shared/Heading'; import { Heading } from 'components/shared/Heading';
import logoAnimation from 'components/../../public/lottie/logo.json'; import logoAnimation from 'components/../../public/lottie/logo.json';
import { useGQLClient } from 'context/GQLClientContext';
import { Project } from 'gql-client';
import { useEffect, useState } from 'react';
const Id = () => { const Id = () => {
const { id, orgSlug } = useParams(); const { id, orgSlug } = useParams();
const client = useGQLClient();
const [project, setProject] = useState<Project | null>(null);
const [searchParams] = useSearchParams();
const isAuction = searchParams.get('isAuction') === 'true';
const handleSetupDomain = async () => { const handleSetupDomain = () => {
if (id) { //TODO: Implement this
const project = await client.getProject(id);
if (project && project.project) {
setProject(project.project);
}
} else {
window.location.href = '/';
}
}; };
useEffect(() => {
handleSetupDomain();
}, []);
return ( return (
<> <div className="flex flex-col gap-8 lg:gap-11 max-w-[522px] mx-auto py-6 lg:py-12">
{project ? ( {/* Icon */}
<div className="flex flex-col gap-8 lg:gap-11 max-w-[522px] mx-auto py-6 lg:py-12"> <div className="flex justify-center">
{/* Icon */} <Lottie animationData={logoAnimation} loop={false} size={40} />
<div className="flex justify-center"> </div>
<Lottie animationData={logoAnimation} loop={false} size={40} />
</div>
{/* Heading */} {/* Heading */}
<div className="flex flex-col items-center gap-1.5"> <div className="flex flex-col items-center gap-1.5">
<Heading as="h3" className="font-medium text-xl"> <Heading as="h3" className="font-medium text-xl">
{isAuction Project deployed successfully.
? 'Auction created successfully.' </Heading>
: 'Project deployment created successfully.'} <p className="flex flex-col items-center lg:flex-row font-sans gap-0.5 lg:gap-2 text-sm text-elements-high-em">
</Heading> Your project has been deployed at{' '}
</div> <Button
className="no-underline text-elements-link"
// TODO: use dynamic value
href="https://www.iglootools.snowballtools.xyz"
as="a"
variant="link-emphasized"
external
leftIcon={<LinkChainIcon />}
>
{/* // TODO: use dynamic value */}
www.iglootools.snowballtools.xyz
</Button>
</p>
</div>
{/* Card */} {/* Card */}
<div className="bg-base-bg-alternate rounded-xl shadow-inset w-full px-1 py-1"> <div className="bg-base-bg-alternate rounded-xl shadow-inset w-full px-1 py-1">
{/* Trigger question */} {/* Trigger question */}
<div className="flex gap-2 justify-center items-center py-3"> <div className="flex gap-2 justify-center items-center py-3">
<div className="h-5 w-5"> <div className="h-5 w-5">
<QuestionMarkRoundFilledIcon size={18} /> <QuestionMarkRoundFilledIcon size={18} />
</div>
<Heading as="h5" className="font-sans font-medium text-sm">
{`Wondering what's next?`}
</Heading>
</div>
{/* CTA card */}
<div className="bg-surface-card rounded-xl shadow-card-sm px-4 py-4">
<div className="flex gap-2">
<Badge variant="secondary">1</Badge>
<div className="space-y-3">
<div className="flex flex-col gap-1">
<Heading as="h6" className="text-sm font-sans">
Add a custom domain
</Heading>
<p className="text-xs text-elements-low-em font-sans">
Make it easy for your visitors to remember your URL with a
custom domain.
</p>
</div> </div>
<Heading as="h5" className="font-sans font-medium text-sm"> <Button onClick={handleSetupDomain} variant="tertiary" size="sm">
{`Wondering what's next?`} Setup domain
</Heading> </Button>
</div>
{/* CTA card */}
<div className="bg-surface-card rounded-xl shadow-card-sm px-4 py-4">
<div className="flex gap-2">
<Badge variant="secondary">1</Badge>
<div className="space-y-3">
<div className="flex flex-col gap-1">
<Heading as="h6" className="text-sm font-sans">
Add a custom domain
</Heading>
<p className="text-xs text-elements-low-em font-sans">
Make it easy for your visitors to remember your URL with a
custom domain.
</p>
</div>
<Button
onClick={handleSetupDomain}
variant="tertiary"
size="sm"
>
Setup domain
</Button>
</div>
</div>
</div>
</div>
{/* CTA Buttons */}
<div className="flex flex-col lg:flex-row justify-center gap-3">
<div className="w-full lg:w-fit">
<Link to="/">
<Button
leftIcon={<ArrowLeftCircleFilledIcon />}
fullWidth
variant="tertiary"
>
Back to projects
</Button>
</Link>
</div>
<div className="w-full lg:w-fit">
<Link to={`/${orgSlug}/projects/${id}`}>
<Button fullWidth variant="primary">
View project
</Button>
</Link>
</div> </div>
</div> </div>
</div> </div>
) : ( </div>
<></>
)} {/* CTA Buttons */}
</> <div className="flex flex-col lg:flex-row justify-center gap-3">
<div className="w-full lg:w-fit">
<Link to="/">
<Button
leftIcon={<ArrowLeftCircleFilledIcon />}
fullWidth
variant="tertiary"
>
Back to projects
</Button>
</Link>
</div>
<div className="w-full lg:w-fit">
<Link to={`/${orgSlug}/projects/${id}`}>
<Button fullWidth variant="primary">
View project
</Button>
</Link>
</div>
</div>
</div>
); );
}; };

View File

@ -1,7 +0,0 @@
import ConfigureComponent from '../../../../../components/projects/create/Configure';
const Configure = () => {
return <ConfigureComponent />;
};
export default Configure;

View File

@ -6,14 +6,13 @@ import { useMediaQuery } from 'usehooks-ts';
import { RequestError } from 'octokit'; import { RequestError } from 'octokit';
import { useOctokit } from '../../../../../context/OctokitContext'; import { useOctokit } from '../../../../../context/OctokitContext';
import { useGQLClient } from '../../../../../context/GQLClientContext';
import { Template } from '../../../../../types/types'; import { Template } from '../../../../../types/types';
import { Heading } from 'components/shared/Heading'; import { Heading } from 'components/shared/Heading';
import { Input } from 'components/shared/Input'; import { Input } from 'components/shared/Input';
import { Select, SelectOption } from 'components/shared/Select'; import { Select, SelectOption } from 'components/shared/Select';
import { import { ArrowRightCircleFilledIcon } from 'components/shared/CustomIcon';
ArrowRightCircleFilledIcon, import { Checkbox } from 'components/shared/Checkbox';
LoadingIcon,
} from 'components/shared/CustomIcon';
import { Button } from 'components/shared/Button'; import { Button } from 'components/shared/Button';
import { useToast } from 'components/shared/Toast'; import { useToast } from 'components/shared/Toast';
@ -29,6 +28,7 @@ type SubmitRepoValues = {
const CreateRepo = () => { const CreateRepo = () => {
const { octokit, isAuth } = useOctokit(); const { octokit, isAuth } = useOctokit();
const { template } = useOutletContext<{ template: Template }>(); const { template } = useOutletContext<{ template: Template }>();
const client = useGQLClient();
const { orgSlug } = useParams(); const { orgSlug } = useParams();
const { toast, dismiss } = useToast(); const { toast, dismiss } = useToast();
@ -41,17 +41,6 @@ const CreateRepo = () => {
const [gitAccounts, setGitAccounts] = useState<string[]>([]); const [gitAccounts, setGitAccounts] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const checkRepoExists = async (account: string, repoName: string) => {
try {
await octokit.rest.repos.get({ owner: account, repo: repoName });
return true;
} catch (error) {
// Error handled by octokit error hook interceptor in Octokit context
console.error(error);
return;
}
};
const submitRepoHandler: SubmitHandler<SubmitRepoValues> = useCallback( const submitRepoHandler: SubmitHandler<SubmitRepoValues> = useCallback(
async (data) => { async (data) => {
assert(data.account); assert(data.account);
@ -61,23 +50,36 @@ const CreateRepo = () => {
assert(template.repoFullName, 'Template URL not provided'); assert(template.repoFullName, 'Template URL not provided');
const [owner, repo] = template.repoFullName.split('/'); const [owner, repo] = template.repoFullName.split('/');
const repoExists = await checkRepoExists(data.account, data.repoName); // TODO: Handle this functionality in backend
if (repoExists) { const gitRepo = await octokit?.rest.repos.createUsingTemplate({
toast({ template_owner: owner,
id: 'repo-exist-error', template_repo: repo,
title: 'Repository already exists with this name', owner: data.account,
variant: 'warning', name: data.repoName,
onDismiss: dismiss, include_all_branches: false,
}); private: data.isPrivate,
setIsLoading(false); });
if (!gitRepo) {
return; return;
} }
setIsLoading(true); // Refetch to always get correct default branch
const templateRepo = await octokit.rest.repos.get({
owner: template.repoFullName.split('/')[0],
repo: template.repoFullName.split('/')[1],
});
const prodBranch = templateRepo.data.default_branch ?? 'main';
navigate( const { addProject } = await client.addProject(orgSlug!, {
`configure?templateId=${template.id}&templateOwner=${owner}&templateRepo=${repo}&owner=${data.account}&name=${data.repoName}&isPrivate=false&orgSlug=${orgSlug}`, name: `${gitRepo.data.owner!.login}-${gitRepo.data.name}`,
); prodBranch,
repository: gitRepo.data.full_name,
// TODO: Set selected template
template: 'webapp',
});
navigate(`deploy?projectId=${addProject.id}&templateId=${template.id}`);
} catch (err) { } catch (err) {
setIsLoading(false); setIsLoading(false);
@ -104,7 +106,7 @@ const CreateRepo = () => {
}); });
} }
}, },
[octokit, toast], [octokit],
); );
useEffect(() => { useEffect(() => {
@ -185,26 +187,28 @@ const CreateRepo = () => {
<Controller <Controller
name="repoName" name="repoName"
control={control} control={control}
rules={{ required: true }}
render={({ field: { value, onChange } }) => ( render={({ field: { value, onChange } }) => (
<Input value={value} onChange={onChange} /> <Input value={value} onChange={onChange} />
)} )}
/> />
</div> </div>
<div>
<Controller
name="isPrivate"
control={control}
render={({}) => (
<Checkbox label="Make this repo private" disabled={true} />
)}
/>
</div>
<div> <div>
<Button <Button
{...buttonSize} {...buttonSize}
type="submit" type="submit"
disabled={!Boolean(template.repoFullName) || isLoading} disabled={!Boolean(template.repoFullName) || isLoading}
rightIcon={ rightIcon={<ArrowRightCircleFilledIcon />}
isLoading ? (
<LoadingIcon className="animate-spin" />
) : (
<ArrowRightCircleFilledIcon />
)
}
> >
Next Deploy
</Button> </Button>
</div> </div>
</div> </div>

View File

@ -1,5 +1,4 @@
import CreateRepo from './index'; import CreateRepo from './index';
import Configure from './Configure';
import Deploy from './Deploy'; import Deploy from './Deploy';
export const templateRoutes = [ export const templateRoutes = [
@ -7,10 +6,6 @@ export const templateRoutes = [
index: true, index: true,
element: <CreateRepo />, element: <CreateRepo />,
}, },
{
path: 'configure',
element: <Configure />,
},
{ {
path: 'deploy', path: 'deploy',
element: <Deploy />, element: <Deploy />,

View File

@ -1,11 +1,14 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Domain, DomainStatus } from 'gql-client';
import { Link, useNavigate, useOutletContext } from 'react-router-dom'; import { Link, useNavigate, useOutletContext } from 'react-router-dom';
import { RequestError } from 'octokit'; import { RequestError } from 'octokit';
import { useOctokit } from 'context/OctokitContext'; import { useOctokit } from '../../../../context/OctokitContext';
import { GitCommitWithBranch, OutletContextType } from '../../../../types'; import { GitCommitWithBranch, OutletContextType } from '../../../../types';
import { useGQLClient } from 'context/GQLClientContext'; import { useGQLClient } from '../../../../context/GQLClientContext';
import { Button, Heading, Avatar, Tag } from 'components/shared'; import { Button } from 'components/shared/Button';
import { Heading } from 'components/shared/Heading';
import { Avatar } from 'components/shared/Avatar';
import { getInitials } from 'utils/geInitials'; import { getInitials } from 'utils/geInitials';
import { import {
BranchStrokeIcon, BranchStrokeIcon,
@ -15,13 +18,12 @@ import {
GithubStrokeIcon, GithubStrokeIcon,
GlobeIcon, GlobeIcon,
LinkIcon, LinkIcon,
CalendarDaysIcon,
} from 'components/shared/CustomIcon'; } from 'components/shared/CustomIcon';
import { Tag } from 'components/shared/Tag';
import { Activity } from 'components/projects/project/overview/Activity'; import { Activity } from 'components/projects/project/overview/Activity';
import { OverviewInfo } from 'components/projects/project/overview/OverviewInfo'; import { OverviewInfo } from 'components/projects/project/overview/OverviewInfo';
import { CalendarDaysIcon } from 'components/shared/CustomIcon/CalendarDaysIcon';
import { relativeTimeMs } from 'utils/time'; import { relativeTimeMs } from 'utils/time';
import { Domain, DomainStatus } from 'gql-client';
import { AuctionCard } from 'components/projects/project/overview/Activity/AuctionCard';
const COMMITS_PER_PAGE = 4; const COMMITS_PER_PAGE = 4;
@ -129,19 +131,9 @@ const OverviewTabPanel = () => {
<Heading className="text-lg leading-6 font-medium truncate"> <Heading className="text-lg leading-6 font-medium truncate">
{project.name} {project.name}
</Heading> </Heading>
{project.deployments && <p className="text-sm text-elements-low-em tracking-tight truncate">
project.deployments.length > 0 && {project.subDomain}
project.deployments.map((deployment, index) => ( </p>
<p>
<a
key={index}
href={`https://${project.name.toLowerCase()}.${deployment.deployer.baseDomain}`}
className="text-sm text-elements-low-em tracking-tight truncate"
>
{deployment.deployer.baseDomain}
</a>
</p>
))}
</div> </div>
</div> </div>
<OverviewInfo label="Domain" icon={<GlobeIcon />}> <OverviewInfo label="Domain" icon={<GlobeIcon />}>
@ -180,18 +172,14 @@ const OverviewTabPanel = () => {
{/* DEPLOYMENT */} {/* DEPLOYMENT */}
<OverviewInfo label="Deployment URL" icon={<CursorBoxIcon />}> <OverviewInfo label="Deployment URL" icon={<CursorBoxIcon />}>
{project.deployments &&
project.deployments.length > 0 &&
project.deployments.map((deployment) => (
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<Link to={`https://${project.name.toLowerCase()}.${deployment.deployer.baseDomain}`}> <Link to="#">
<span className="text-controls-primary group hover:border-controls-primary transition-colors border-b border-b-transparent flex gap-2 items-center text-sm tracking-tight"> <span className="text-controls-primary group hover:border-controls-primary transition-colors border-b border-b-transparent flex gap-2 items-center text-sm tracking-tight">
{`https://${project.name.toLowerCase()}.${deployment.deployer.baseDomain}`} {liveDomain?.name}{' '}
<LinkIcon className="group-hover:rotate-45 transition-transform" /> <LinkIcon className="group-hover:rotate-45 transition-transform" />
</span> </span>
</Link> </Link>
</div> </div>
))}
</OverviewInfo> </OverviewInfo>
{/* DEPLOYMENT DATE */} {/* DEPLOYMENT DATE */}
@ -217,7 +205,6 @@ const OverviewTabPanel = () => {
No current deployment found. No current deployment found.
</p> </p>
)} )}
{project.auctionId && <AuctionCard project={project} />}
</div> </div>
<Activity activities={activities} isLoading={fetchingActivities} /> <Activity activities={activities} isLoading={fetchingActivities} />
</div> </div>

View File

@ -16,7 +16,7 @@ const FIRST_MEMBER_CARD = 0;
const CollaboratorsTabPanel = () => { const CollaboratorsTabPanel = () => {
const client = useGQLClient(); const client = useGQLClient();
const { toast, dismiss } = useToast(); const { toast } = useToast();
const { project } = useOutletContext<OutletContextType>(); const { project } = useOutletContext<OutletContextType>();
const [addmemberDialogOpen, setAddMemberDialogOpen] = useState(false); const [addmemberDialogOpen, setAddMemberDialogOpen] = useState(false);
@ -39,14 +39,14 @@ const CollaboratorsTabPanel = () => {
id: 'member_added', id: 'member_added',
title: 'Member added to project', title: 'Member added to project',
variant: 'success', variant: 'success',
onDismiss: dismiss, onDismiss() {},
}); });
} else { } else {
toast({ toast({
id: 'member_not_added', id: 'member_not_added',
title: 'Invitation not sent', title: 'Invitation not sent',
variant: 'error', variant: 'error',
onDismiss: dismiss, onDismiss() {},
}); });
} }
}, },
@ -63,14 +63,14 @@ const CollaboratorsTabPanel = () => {
id: 'member_removed', id: 'member_removed',
title: 'Member removed from project', title: 'Member removed from project',
variant: 'success', variant: 'success',
onDismiss: dismiss, onDismiss() {},
}); });
} else { } else {
toast({ toast({
id: 'member_not_removed', id: 'member_not_removed',
title: 'Not able to remove member', title: 'Not able to remove member',
variant: 'error', variant: 'error',
onDismiss: dismiss, onDismiss() {},
}); });
} }
}; };
@ -86,14 +86,14 @@ const CollaboratorsTabPanel = () => {
id: 'member_permission_updated', id: 'member_permission_updated',
title: 'Project member permission updated', title: 'Project member permission updated',
variant: 'success', variant: 'success',
onDismiss: dismiss, onDismiss() {},
}); });
} else { } else {
toast({ toast({
id: 'member_permission_not_updated', id: 'member_permission_not_updated',
title: 'Project member permission not updated', title: 'Project member permission not updated',
variant: 'error', variant: 'error',
onDismiss: dismiss, onDismiss() {},
}); });
} }
}, },

View File

@ -1,33 +1,38 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { useFieldArray, useForm } from 'react-hook-form';
import toast from 'react-hot-toast';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { Environment, EnvironmentVariable } from 'gql-client';
import { Collapse } from '@snowballtools/material-tailwind-react-fork'; import { Collapse } from '@snowballtools/material-tailwind-react-fork';
import AddEnvironmentVariableRow from 'components/projects/project/settings/AddEnvironmentVariableRow';
import DisplayEnvironmentVariables from 'components/projects/project/settings/DisplayEnvironmentVariables'; import DisplayEnvironmentVariables from 'components/projects/project/settings/DisplayEnvironmentVariables';
import { useGQLClient } from 'context/GQLClientContext'; import { useGQLClient } from '../../../../../context/GQLClientContext';
import { EnvironmentVariablesFormValues } from '../../../../../types'; import { EnvironmentVariablesFormValues } from '../../../../../types';
import HorizontalLine from 'components/HorizontalLine'; import HorizontalLine from 'components/HorizontalLine';
import { Heading } from 'components/shared/Heading'; import { Heading } from 'components/shared/Heading';
import { Button } from 'components/shared/Button';
import { Checkbox } from 'components/shared/Checkbox';
import { PlusIcon } from 'components/shared/CustomIcon'; import { PlusIcon } from 'components/shared/CustomIcon';
import { InlineNotification } from 'components/shared/InlineNotification';
import { ProjectSettingContainer } from 'components/projects/project/settings/ProjectSettingContainer'; import { ProjectSettingContainer } from 'components/projects/project/settings/ProjectSettingContainer';
import { useToast } from 'components/shared/Toast';
import { Environment, EnvironmentVariable } from 'gql-client';
import EnvironmentVariablesForm from './EnvironmentVariablesForm';
import { FieldValues, FormProvider, useForm } from 'react-hook-form';
import { Button } from 'components/shared';
export const EnvironmentVariablesTabPanel = () => { export const EnvironmentVariablesTabPanel = () => {
const { id } = useParams(); const { id } = useParams();
const client = useGQLClient(); const client = useGQLClient();
const { toast, dismiss } = useToast();
const [environmentVariables, setEnvironmentVariables] = useState< const [environmentVariables, setEnvironmentVariables] = useState<
EnvironmentVariable[] EnvironmentVariable[]
>([]); >([]);
const [createNewVariable, setCreateNewVariable] = useState(false); const {
handleSubmit,
const methods = useForm<EnvironmentVariablesFormValues>({ register,
control,
reset,
formState: { isSubmitSuccessful, errors },
} = useForm<EnvironmentVariablesFormValues>({
defaultValues: { defaultValues: {
variables: [{ key: '', value: '' }], variables: [{ key: '', value: '' }],
environment: { environment: {
@ -37,6 +42,21 @@ export const EnvironmentVariablesTabPanel = () => {
}, },
}, },
}); });
const [createNewVariable, setCreateNewVariable] = useState(false);
const { fields, append, remove } = useFieldArray({
name: 'variables',
control,
rules: {
required: 'Add at least 1 environment variables',
},
});
useEffect(() => {
if (isSubmitSuccessful) {
reset();
}
}, [isSubmitSuccessful, reset, id]);
const getEnvironmentVariables = useCallback( const getEnvironmentVariables = useCallback(
(environment: Environment) => { (environment: Environment) => {
@ -47,6 +67,21 @@ export const EnvironmentVariablesTabPanel = () => {
[environmentVariables, id], [environmentVariables, id],
); );
const isFieldEmpty = useMemo(() => {
if (errors.variables) {
return fields.some((_, index) => {
if (
errors.variables![index]?.value?.type === 'required' ||
errors.variables![index]?.key?.type === 'required'
) {
return true;
}
});
}
return false;
}, [fields, errors.variables, id]);
const fetchEnvironmentVariables = useCallback( const fetchEnvironmentVariables = useCallback(
async (id: string | undefined) => { async (id: string | undefined) => {
if (id) { if (id) {
@ -63,47 +98,33 @@ export const EnvironmentVariablesTabPanel = () => {
}, [id]); }, [id]);
const createEnvironmentVariablesHandler = useCallback( const createEnvironmentVariablesHandler = useCallback(
async (createFormData: FieldValues) => { async (createFormData: EnvironmentVariablesFormValues) => {
const environmentVariables = createFormData.variables.map( const environmentVariables = createFormData.variables.map((variable) => {
(variable: any) => { return {
return { key: variable.key,
key: variable.key, value: variable.value,
value: variable.value, environments: Object.entries(createFormData.environment)
environments: Object.entries(createFormData.environment) .filter(([, value]) => value === true)
.filter(([, value]) => value === true) .map(([key]) => key.charAt(0).toUpperCase() + key.slice(1)),
.map(([key]) => key.charAt(0).toUpperCase() + key.slice(1)), };
}; });
},
);
const { addEnvironmentVariables: isEnvironmentVariablesAdded } = const { addEnvironmentVariables: isEnvironmentVariablesAdded } =
await client.addEnvironmentVariables(id!, environmentVariables); await client.addEnvironmentVariables(id!, environmentVariables);
if (isEnvironmentVariablesAdded) { if (isEnvironmentVariablesAdded) {
methods.reset(); reset();
setCreateNewVariable((cur) => !cur); setCreateNewVariable((cur) => !cur);
fetchEnvironmentVariables(id); fetchEnvironmentVariables(id);
toast({ toast.success(
id: createFormData.variables.length > 1
createFormData.variables.length > 1 ? `${createFormData.variables.length} variables added`
? 'env_variable_added' : `Variable added`,
: 'env_variables_added', );
title:
createFormData.variables.length > 1
? `${createFormData.variables.length} variables added`
: `Variable added`,
variant: 'success',
onDismiss: dismiss,
});
} else { } else {
toast({ toast.error('Environment variables not added');
id: 'env_variables_not_added',
title: 'Environment variables not added',
variant: 'error',
onDismiss: dismiss,
});
} }
}, },
[id, client], [id, client],
@ -125,14 +146,59 @@ export const EnvironmentVariablesTabPanel = () => {
</div> </div>
</Heading> </Heading>
<Collapse open={createNewVariable}> <Collapse open={createNewVariable}>
<FormProvider {...methods}> <div className="p-4 bg-slate-100">
<form <form onSubmit={handleSubmit(createEnvironmentVariablesHandler)}>
onSubmit={methods.handleSubmit((data) => {fields.map((field, index) => {
createEnvironmentVariablesHandler(data), return (
<AddEnvironmentVariableRow
key={field.id}
index={index}
register={register}
onDelete={() => remove(index)}
isDeleteDisabled={fields.length === 1}
/>
);
})}
<div className="flex gap-1 p-2">
<Button
size="md"
onClick={() =>
append({
key: '',
value: '',
})
}
>
+ Add variable
</Button>
{/* TODO: Implement import environment varible functionality */}
<Button size="md" disabled>
Import .env
</Button>
</div>
{isFieldEmpty && (
<InlineNotification
title="Please ensure no fields are empty before saving."
variant="danger"
size="md"
/>
)} )}
> <div className="flex gap-2 p-2">
<div className="p-4 bg-slate-100"> <Checkbox
<EnvironmentVariablesForm /> label="Production"
{...register(`environment.production`)}
color="blue"
/>
<Checkbox
label="Preview"
{...register(`environment.preview`)}
color="blue"
/>
<Checkbox
label="Development"
{...register(`environment.development`)}
color="blue"
/>
</div> </div>
<div className="p-2"> <div className="p-2">
<Button size="md" type="submit"> <Button size="md" type="submit">
@ -140,7 +206,7 @@ export const EnvironmentVariablesTabPanel = () => {
</Button> </Button>
</div> </div>
</form> </form>
</FormProvider> </div>
</Collapse> </Collapse>
</div> </div>
<div className="p-2"> <div className="p-2">

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