Compare commits

...
Author SHA1 Message Date
nabarun c96385f40c Implement Gitea authentication and get access token 2024-02-26 16:23:35 +05:30
telackeyandVivian Phung 6126c03eee Restore https://github.com/snowball-tools/snowballtools-base/pull/103 (#106)
* Update logo.svg

* Update logo.svg

* Delete packages/frontend/public/favicon.ico

* Delete packages/frontend/public/logo192.png

* Delete packages/frontend/public/logo512.png

* Add files via upload

* Add files via upload

* Add files via upload

* Update site.webmanifest

* Update index.html

---------

Co-authored-by: Vivian Phung <dev+github@vivianphung.com>
2024-02-23 16:09:45 -06:00
telackeyandVivian Phung 35be62b28e readme formatting (#105)
Co-authored-by: Vivian Phung <dev+github@vivianphung.com>
2024-02-23 15:57:34 -06:00
nabarunandneeraj 353fd0f621 Set authority for record name from backend config (#104)
* Fix format of eth address to display

* Set authority for record name from backend config

* Add functionality to open repo button

* Use commit author image and commit url in activity card

* Handle review changes

* Fix commit author as null

* Add timeout to move to next page on project creation

---------

Co-authored-by: neeraj <neeraj.rtly@gmail.com>
2024-02-23 14:47:29 +05:30
nabarunandneeraj 9acb9daacc Update README after implementation of authentication (#101)
* Update README

* Add tooltip to display ethereum address

* Update README for production deployment

---------

Co-authored-by: neeraj <neeraj.rtly@gmail.com>
2024-02-22 19:24:06 +05:30
nabarun f7c45ca045 Unset cookie domain to localhost by default (#100) 2024-02-22 17:52:49 +05:30
nabarunandneeraj ef0eac8293 Implement authentication with SIWE (#99)
* Create web3 modal provider with SIWE

* Add auth router to handle SIWE authentication

* Use axios instance to make request

* Add button for SIWE authentication

* Add changes to access session in web-app GQL requests

* Add auth check in GQL context and load/create user

* Use authenticated user from context

* Redirect to sign in page if unauthenticated and logout button

* Change sign-in route to login

* Get project domain from config file

* Set user ethAddress column as unique

* Use formatted user name

* Get session secret and origin url from config file

* Add unique constraint for eth address

* Get secure and samesite from origin url

* Get wallet connect id and backend url from env file

* Format user email in member tab panel

* Add backend config isProduction to set trust proxy

* Use only one server url config

* Add tool tip for displaying email

* Add trustProxy and domain in server.session config

* Add SERVER_GQL_PATH constant in frontend

---------

Co-authored-by: neeraj <neeraj.rtly@gmail.com>
2024-02-22 17:26:26 +05:30
nabarunandneeraj a846531e43 Set DNS in application deployment request (#94)
Co-authored-by: neeraj <neeraj.rtly@gmail.com>
2024-02-22 12:50:35 +05:30
nabarunandneeraj 6b17dce2ae UI fixes in Snowball frontend app (#93)
* Fix alignment of deployment status chip

* Use template name from env

* Use env for git template link

* Add loading spinner for create project

* Display user name

* Format the displayed user name

---------

Co-authored-by: neeraj <neeraj.rtly@gmail.com>
2024-02-22 11:25:17 +05:30
67 changed files with 3477 additions and 340 deletions
+150 -44
View File
@@ -1,8 +1,8 @@
# snowballtools # snowballtools-base
## Setup ## Setup
- Clone the `snowballtools` repo - Clone the `snowballtools-base` repo
```bash ```bash
git clone git@github.com:snowball-tools/snowballtools-base.git git clone git@github.com:snowball-tools/snowballtools-base.git
@@ -28,63 +28,138 @@
cd packages/backend cd packages/backend
``` ```
- Load fixtures in database
```bash
yarn test:db:load:fixtures
```
- Set `gitHub.oAuth.clientId` and `gitHub.oAuth.clientSecret` in backend [config file](packages/backend/environments/local.toml) - Set `gitHub.oAuth.clientId` and `gitHub.oAuth.clientSecret` in backend [config file](packages/backend/environments/local.toml)
- Client ID and secret will be available after creating Github OAuth app - Client ID and secret will be available after [creating an OAuth app](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app)
- https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app
- In "Homepage URL", type `http://localhost:3000` - In "Homepage URL", type `http://localhost:3000`
- In "Authorization callback URL", type `http://localhost:3000/organization/projects/create` - In "Authorization callback URL", type `http://localhost:3000/organization/projects/create`
- Generate a new client secret after app is created - Generate a new client secret after app is created
- Run the laconicd stack following this [doc](https://git.vdb.to/cerc-io/stack-orchestrator/src/branch/main/docs/laconicd-with-console.md) ### Backend Production
- Get the private key and set `registryConfig.privateKey` in backend [config file](packages/backend/environments/local.toml) - Let us assume the following domains for backend and frontend
- Backend server: `api.snowballtools.com`
- Frontend app: `dashboard.snowballtools.com`
```bash - Update the following in backend [config file](packages/backend/environments/local.toml)
laconic-so --stack fixturenet-laconic-loaded deploy 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 ```toml
# 754cca7b4b729a99d156913aea95366411d072856666e95ba09ef6c664357d81 [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 = ""
``` ```
- Get the REST and GQL endpoint ports of Laconicd and replace the ports for `registryConfig.restEndpoint` and `registryConfig.gqlEndpoint` in backend [config file](packages/backend/environments/local.toml) - Set `gitHub.oAuth.clientId` and `gitHub.oAuth.clientSecret` in backend [config file](packages/backend/environments/local.toml)
- Client ID and secret will be available after [creating an OAuth app](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app)
- In "Homepage URL", type `https://dashboard.snowballtools.com`
- In "Authorization callback URL", type `https://dashboard.snowballtools.com/organization/projects/create`
- Generate a new client secret after app is created
```bash - Set `gitHub.webhookUrl` in backend [config file](packages/backend/environments/local.toml)
# For registryConfig.restEndpoint
laconic-so --stack fixturenet-laconic-loaded deploy port laconicd 1317
# 0.0.0.0:32777
# For registryConfig.gqlEndpoint ```toml
laconic-so --stack fixturenet-laconic-loaded deploy port laconicd 9473 ...
# 0.0.0.0:32771 [gitHub]
webhookUrl = "https://api.snowballtools.com"
...
``` ```
- Run the script to create bond, reserve the authority and set authority bond - Let us assume domain for Laconicd to be `api.laconic.com` and set the following in backend [config file](packages/backend/environments/local.toml)
```bash ```toml
yarn test:registry:init ...
# snowball:initialize-registry bondId: 6af0ab81973b93d3511ae79841756fb5da3fd2f70ea1279e81fae7c9b19af6c4 +0ms [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"
...
``` ```
- Get the bond id and set `registryConfig.bondId` in backend [config file](packages/backend/environments/local.toml) - Start the server in `packages/backend`
```bash
yarn start
```
### Backend Development
- Set `gitHub.oAuth.clientId` and `gitHub.oAuth.clientSecret` in backend [config file](packages/backend/environments/local.toml)
- Client ID and secret will be available after [creating an OAuth app](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app)
- In "Homepage URL", type `http://localhost:3000`
- In "Authorization callback URL", type `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 --stack fixturenet-laconic-loaded deploy 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
```
- Get the REST and GQL endpoint ports of Laconicd and replace the ports for `registryConfig.restEndpoint` and `registryConfig.gqlEndpoint` in backend [config file](packages/backend/environments/local.toml)
```bash
# For registryConfig.restEndpoint
laconic-so --stack fixturenet-laconic-loaded deploy port laconicd 1317
# 0.0.0.0:32777
# For registryConfig.gqlEndpoint
laconic-so --stack fixturenet-laconic-loaded deploy port laconicd 9473
# 0.0.0.0:32771
```
- 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 - Setup ngrok for GitHub webhooks
- https://ngrok.com/docs/getting-started/ - [ngrok getting started](https://ngrok.com/docs/getting-started/)
- Start ngrok and point to backend server endpoint - Start ngrok and point to backend server endpoint
```bash ```bash
ngrok http http://localhost:8000 ngrok http http://localhost:8000
``` ```
- Look for the forwarding URL in ngrok - Look for the forwarding URL in ngrok
```
```bash
... ...
Forwarding https://19c1-61-95-158-116.ngrok-free.app -> http://localhost:8000 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) - Set `gitHub.webhookUrl` in backend [config file](packages/backend/environments/local.toml)
```toml ```toml
... ...
[gitHub] [gitHub]
@@ -95,7 +170,7 @@
- Start the server in `packages/backend` - Start the server in `packages/backend`
```bash ```bash
yarn start yarn start:dev
``` ```
## Frontend ## Frontend
@@ -106,12 +181,6 @@
cd packages/frontend cd packages/frontend
``` ```
- Copy the graphQL endpoint from terminal and add the endpoint in the [.env](packages/frontend/.env) file present in `packages/frontend`
```env
REACT_APP_GQL_SERVER_URL = 'http://localhost:8000/graphql'
```
- Copy the GitHub OAuth app client ID from previous steps and set it in frontend [.env](packages/frontend/.env) file - Copy the GitHub OAuth app client ID from previous steps and set it in frontend [.env](packages/frontend/.env) file
```env ```env
@@ -121,20 +190,32 @@
- Set `REACT_APP_GITHUB_TEMPLATE_REPO` in [.env](packages/frontend/.env) file - Set `REACT_APP_GITHUB_TEMPLATE_REPO` in [.env](packages/frontend/.env) file
```env ```env
# Set actual owner/name of the template repo that will be used for creating new repo
REACT_APP_GITHUB_TEMPLATE_REPO = cerc-io/test-progressive-web-app REACT_APP_GITHUB_TEMPLATE_REPO = cerc-io/test-progressive-web-app
``` ```
### Development ### Frontend Production
- Start the React application - Let us assume the following domains for backend and frontend
- Backend server: `api.snowballtools.com`
- Frontend app: `dashboard.snowballtools.com`
```bash - Set the following values in [.env](packages/frontend/.env) file
yarn start
```env
# Backend server endpoint
REACT_APP_SERVER_URL = 'https://api.snowballtools.com'
``` ```
- The React application will be running in `http://localhost:3000/` - 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 `REACT_APP_WALLET_CONNECT_ID` in [.env](packages/frontend/.env) file
### Production ```env
REACT_APP_WALLET_CONNECT_ID = <PROJECT_ID>
```
- Build the React application - Build the React application
@@ -148,3 +229,28 @@
python3 -m http.server -d build 3000 python3 -m http.server -d build 3000
``` ```
### Frontend Development
- Copy the graphQL endpoint from terminal and add the endpoint in the [.env](packages/frontend/.env) file present in `packages/frontend`
```env
REACT_APP_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 `REACT_APP_WALLET_CONNECT_ID` in [.env](packages/frontend/.env) file
```env
REACT_APP_WALLET_CONNECT_ID = <Project_ID>
```
- Start the React application
```bash
yarn start
```
- The React application will be running in `http://localhost:3000/`
+2 -1
View File
@@ -10,9 +10,10 @@ if [[ -d "$DEST_DIR" ]]; then
fi fi
cat > $PKG_DIR/.env <<EOF cat > $PKG_DIR/.env <<EOF
REACT_APP_GQL_SERVER_URL = 'LACONIC_HOSTED_CONFIG_app_gql_url' REACT_APP_SERVER_URL = 'LACONIC_HOSTED_CONFIG_app_server_url'
REACT_APP_GITHUB_CLIENT_ID = 'LACONIC_HOSTED_CONFIG_app_github_clientid' REACT_APP_GITHUB_CLIENT_ID = 'LACONIC_HOSTED_CONFIG_app_github_clientid'
REACT_APP_GITHUB_TEMPLATE_REPO = 'LACONIC_HOSTED_CONFIG_app_github_templaterepo' REACT_APP_GITHUB_TEMPLATE_REPO = 'LACONIC_HOSTED_CONFIG_app_github_templaterepo'
REACT_APP_WALLET_CONNECT_ID = 'LACONIC_HOSTED_CONFIG_app_wallet_connect_id'
EOF EOF
yarn || exit 1 yarn || exit 1
+15 -1
View File
@@ -2,6 +2,11 @@
host = "127.0.0.1" host = "127.0.0.1"
port = 8000 port = 8000
gqlPath = "/graphql" gqlPath = "/graphql"
[server.session]
secret = "p4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi"
appOriginUrl = "http://localhost:3000"
trustProxy = false
domain = "localhost"
[database] [database]
dbPath = "db/snowball" dbPath = "db/snowball"
@@ -12,6 +17,11 @@
clientId = "" clientId = ""
clientSecret = "" clientSecret = ""
[gitea]
[gitea.oAuth]
clientId = ""
clientSecret = ""
[registryConfig] [registryConfig]
fetchDeploymentRecordDelay = 5000 fetchDeploymentRecordDelay = 5000
restEndpoint = "http://localhost:1317" restEndpoint = "http://localhost:1317"
@@ -19,7 +29,11 @@
chainId = "laconic_9000-1" chainId = "laconic_9000-1"
privateKey = "" privateKey = ""
bondId = "" bondId = ""
authority = ""
[registryConfig.fee] [registryConfig.fee]
amount = "200000" amount = "200000"
denom = "aphoton" denom = "aphoton"
gas = "550000" gas = "750000"
[misc]
projectDomain = "apps.snowballtools.com"
+5
View File
@@ -12,13 +12,16 @@
"@types/node": "^20.11.0", "@types/node": "^20.11.0",
"apollo-server-core": "^3.13.0", "apollo-server-core": "^3.13.0",
"apollo-server-express": "^3.13.0", "apollo-server-express": "^3.13.0",
"cors": "^2.8.5",
"debug": "^4.3.1", "debug": "^4.3.1",
"express": "^4.18.2", "express": "^4.18.2",
"express-session": "^1.18.0",
"fs-extra": "^11.2.0", "fs-extra": "^11.2.0",
"graphql": "^16.8.1", "graphql": "^16.8.1",
"luxon": "^3.4.4", "luxon": "^3.4.4",
"nanoid": "3", "nanoid": "3",
"nanoid-dictionary": "^5.0.0-beta.1", "nanoid-dictionary": "^5.0.0-beta.1",
"node-fetch": "2",
"octokit": "^3.1.2", "octokit": "^3.1.2",
"reflect-metadata": "^0.2.1", "reflect-metadata": "^0.2.1",
"semver": "^7.6.0", "semver": "^7.6.0",
@@ -42,7 +45,9 @@
"test:db:delete": "DEBUG=snowball:* ts-node ./test/delete-db.ts" "test:db:delete": "DEBUG=snowball:* ts-node ./test/delete-db.ts"
}, },
"devDependencies": { "devDependencies": {
"@types/express-session": "^1.17.10",
"@types/fs-extra": "^11.0.4", "@types/fs-extra": "^11.0.4",
"@types/node-fetch": "^2.6.11",
"@typescript-eslint/eslint-plugin": "^6.18.1", "@typescript-eslint/eslint-plugin": "^6.18.1",
"@typescript-eslint/parser": "^6.18.1", "@typescript-eslint/parser": "^6.18.1",
"better-sqlite3": "^9.2.2", "better-sqlite3": "^9.2.2",
+17
View File
@@ -1,7 +1,18 @@
export interface SessionConfig {
secret: string;
appOriginUrl: string;
trustProxy: boolean;
domain: string;
}
export interface ServerConfig { export interface ServerConfig {
host: string; host: string;
port: number; port: number;
gqlPath?: string; gqlPath?: string;
sessionSecret: string;
appOriginUrl: string;
isProduction: boolean;
session: SessionConfig;
} }
export interface DatabaseConfig { export interface DatabaseConfig {
@@ -23,6 +34,7 @@ export interface RegistryConfig {
privateKey: string; privateKey: string;
bondId: string; bondId: string;
fetchDeploymentRecordDelay: number; fetchDeploymentRecordDelay: number;
authority: string;
fee: { fee: {
amount: string; amount: string;
denom: string; denom: string;
@@ -30,9 +42,14 @@ export interface RegistryConfig {
} }
} }
export interface MiscConfig {
projectDomain: string;
}
export interface Config { export interface Config {
server: ServerConfig; server: ServerConfig;
database: DatabaseConfig; database: DatabaseConfig;
gitHub: GitHubConfig; gitHub: GitHubConfig;
registryConfig: RegistryConfig; registryConfig: RegistryConfig;
misc: MiscConfig;
} }
-5
View File
@@ -3,8 +3,3 @@ import process from 'process';
export const DEFAULT_CONFIG_FILE_PATH = process.env.SNOWBALL_BACKEND_CONFIG_FILE_PATH || 'environments/local.toml'; export const DEFAULT_CONFIG_FILE_PATH = process.env.SNOWBALL_BACKEND_CONFIG_FILE_PATH || 'environments/local.toml';
export const DEFAULT_GQL_PATH = '/graphql'; export const DEFAULT_GQL_PATH = '/graphql';
// Note: temporary hardcoded user, later to be derived from auth token
export const USER_ID = process.env.SNOWBALL_BACKEND_USER_ID || '59f4355d-9549-4aac-9b54-eeefceeabef0';
export const PROJECT_DOMAIN = process.env.SNOWBALL_BACKEND_PROJECT_DOMAIN || 'snowball.xyz';
+19 -25
View File
@@ -5,7 +5,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';
@@ -13,13 +13,10 @@ import { Deployment } from './entity/Deployment';
import { ProjectMember } from './entity/ProjectMember'; import { ProjectMember } from './entity/ProjectMember';
import { EnvironmentVariable } from './entity/EnvironmentVariable'; import { EnvironmentVariable } from './entity/EnvironmentVariable';
import { Domain } from './entity/Domain'; import { Domain } from './entity/Domain';
import { PROJECT_DOMAIN } from './constants';
import { getEntities, loadAndSaveData } from './utils'; import { getEntities, loadAndSaveData } from './utils';
import { UserOrganization } from './entity/UserOrganization'; import { UserOrganization } from './entity/UserOrganization';
const ORGANIZATION_DATA_PATH = '../test/fixtures/organizations.json'; const ORGANIZATION_DATA_PATH = '../test/fixtures/organizations.json';
const USER_DATA_PATH = '../test/fixtures/users.json';
const USER_ORGANIZATION_DATA_PATH = '../test/fixtures/user-organizations.json';
const log = debug('snowball:database'); const log = debug('snowball:database');
@@ -28,8 +25,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,
@@ -37,6 +35,8 @@ export class Database {
synchronize: true, synchronize: true,
logging: false logging: false
}); });
this.projectDomain = projectDomain;
} }
async init (): Promise<void> { async init (): Promise<void> {
@@ -45,21 +45,10 @@ export class Database {
const organizations = await this.getOrganizations({}); const organizations = await this.getOrganizations({});
// Load an organization if none exist
if (!organizations.length) { if (!organizations.length) {
const orgEntities = await getEntities(path.resolve(__dirname, ORGANIZATION_DATA_PATH)); const orgEntities = await getEntities(path.resolve(__dirname, ORGANIZATION_DATA_PATH));
const savedOrgs = await loadAndSaveData(Organization, this.dataSource, [orgEntities[0]]); await loadAndSaveData(Organization, this.dataSource, [orgEntities[0]]);
// TODO: Remove user once authenticated
const userEntities = await getEntities(path.resolve(__dirname, USER_DATA_PATH));
const savedUsers = await loadAndSaveData(User, this.dataSource, [userEntities[0]]);
const userOrganizationRelations = {
member: savedUsers,
organization: savedOrgs
};
const userOrgEntities = await getEntities(path.resolve(__dirname, USER_ORGANIZATION_DATA_PATH));
await loadAndSaveData(UserOrganization, this.dataSource, [userOrgEntities[0]], userOrganizationRelations);
} }
} }
@@ -77,9 +66,9 @@ export class Database {
return user; return user;
} }
async updateUser (userId: string, 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: userId }, data); const updateResult = await userRepository.update({ id: user.id }, data);
assert(updateResult.affected); assert(updateResult.affected);
return updateResult.affected > 0; return updateResult.affected > 0;
@@ -115,6 +104,13 @@ export class Database {
return userOrgs; return userOrgs;
} }
async addUserOrganization (data: DeepPartial<UserOrganization>): Promise<UserOrganization> {
const userOrganizationRepository = this.dataSource.getRepository(UserOrganization);
const newUserOrganization = await userOrganizationRepository.save(data);
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);
@@ -361,7 +357,7 @@ export class Database {
return Boolean(updateResult.affected); return Boolean(updateResult.affected);
} }
async addProject (userId: string, 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
@@ -371,15 +367,13 @@ export class Database {
// TODO: Set icon according to framework // TODO: Set icon according to framework
newProject.icon = ''; newProject.icon = '';
newProject.owner = Object.assign(new User(), { newProject.owner = user;
id: userId
});
newProject.organization = Object.assign(new Organization(), { newProject.organization = Object.assign(new Organization(), {
id: organizationId id: organizationId
}); });
newProject.subDomain = `${newProject.name}.${PROJECT_DOMAIN}`; newProject.subDomain = `${newProject.name}.${this.projectDomain}`;
return projectRepository.save(newProject); return projectRepository.save(newProject);
} }
+4 -4
View File
@@ -87,11 +87,11 @@ export class Deployment {
@Column('simple-json') @Column('simple-json')
applicationRecordData!: ApplicationRecord; applicationRecordData!: ApplicationRecord;
@Column('varchar') @Column('varchar', { nullable: true })
applicationDeploymentRequestId!: string; applicationDeploymentRequestId!: string | null;
@Column('simple-json') @Column('simple-json', { nullable: true })
applicationDeploymentRequestData!: ApplicationDeploymentRequest; applicationDeploymentRequestData!: ApplicationDeploymentRequest | null;
@Column('varchar', { nullable: true }) @Column('varchar', { nullable: true })
applicationDeploymentRecordId!: string | null; applicationDeploymentRecordId!: string | null;
+5
View File
@@ -12,10 +12,15 @@ import { UserOrganization } from './UserOrganization';
@Entity() @Entity()
@Unique(['email']) @Unique(['email'])
@Unique(['ethAddress'])
export class User { export class User {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')
id!: string; id!: string;
// TODO: Set ethAddress as ID
@Column()
ethAddress!: string;
@Column('varchar', { length: 255, nullable: true }) @Column('varchar', { length: 255, nullable: true })
name!: string | null; name!: string | null;
@@ -12,7 +12,7 @@ import {
import { User } from './User'; import { User } from './User';
import { Organization } from './Organization'; import { Organization } from './Organization';
enum Role { export enum Role {
Owner = 'Owner', Owner = 'Owner',
Maintainer = 'Maintainer', Maintainer = 'Maintainer',
Reader = 'Reader', Reader = 'Reader',
+2 -2
View File
@@ -19,7 +19,7 @@ const OAUTH_CLIENT_TYPE = 'oauth-app';
export const main = async (): Promise<void> => { export const main = async (): Promise<void> => {
// TODO: get config path using cli // TODO: get config path using cli
const { server, database, gitHub, registryConfig } = await getConfig<Config>(DEFAULT_CONFIG_FILE_PATH); const { server, database, gitHub, registryConfig, misc } = await getConfig<Config>(DEFAULT_CONFIG_FILE_PATH);
const app = new OAuthApp({ const app = new OAuthApp({
clientType: OAUTH_CLIENT_TYPE, clientType: OAUTH_CLIENT_TYPE,
@@ -27,7 +27,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);
+8 -10
View File
@@ -81,7 +81,7 @@ export class Registry {
log('Application record data:', applicationRecord); log('Application record data:', applicationRecord);
// TODO: Discuss computation of CRN // TODO: Discuss computation of CRN
const crn = this.getCrn(packageJSON.name, appName); const crn = this.getCrn(appName);
log(`Setting name: ${crn} for record ID: ${result.data.id}`); log(`Setting name: ${crn} for record ID: ${result.data.id}`);
await this.registry.setName({ cid: result.data.id, crn }, this.registryConfig.privateKey, this.registryConfig.fee); await this.registry.setName({ cid: result.data.id, crn }, this.registryConfig.privateKey, this.registryConfig.fee);
@@ -92,16 +92,16 @@ export class Registry {
} }
async createApplicationDeploymentRequest (data: { async createApplicationDeploymentRequest (data: {
deployment: Deployment,
appName: string, appName: string,
packageJsonName: string, packageJsonName: string,
commitHash: string,
repository: string, repository: string,
environmentVariables: { [key: string]: string } environmentVariables: { [key: string]: string }
}): Promise<{ }): Promise<{
applicationDeploymentRequestId: string, applicationDeploymentRequestId: string,
applicationDeploymentRequestData: ApplicationDeploymentRequest applicationDeploymentRequestData: ApplicationDeploymentRequest
}> { }> {
const crn = this.getCrn(data.packageJsonName, data.appName); const crn = this.getCrn(data.appName);
const records = await this.registry.resolveNames([crn]); const records = await this.registry.resolveNames([crn]);
const applicationRecord = records[0]; const applicationRecord = records[0];
@@ -115,9 +115,9 @@ export class Registry {
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: `${crn}@${applicationRecord.attributes.app_version}`, application: `${crn}@${applicationRecord.attributes.app_version}`,
dns: `${data.deployment.project.name}-${data.deployment.id}`,
// TODO: Not set in test-progressive-web-app CI // TODO: Not set in test-progressive-web-app CI
// dns: '$CERC_REGISTRY_DEPLOYMENT_SHORT_HOSTNAME',
// deployment: '$CERC_REGISTRY_DEPLOYMENT_CRN', // 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
@@ -127,7 +127,7 @@ export class Registry {
meta: JSON.stringify({ meta: JSON.stringify({
note: `Added by Snowball @ ${DateTime.utc().toFormat('EEE LLL dd HH:mm:ss \'UTC\' yyyy')}`, note: `Added by Snowball @ ${DateTime.utc().toFormat('EEE LLL dd HH:mm:ss \'UTC\' yyyy')}`,
repository: data.repository, repository: data.repository,
repository_ref: data.commitHash repository_ref: data.deployment.commitHash
}) })
}; };
@@ -160,10 +160,8 @@ export class Registry {
return records.filter((record: AppDeploymentRecord) => deployments.some(deployment => deployment.applicationRecordId === record.attributes.application)); return records.filter((record: AppDeploymentRecord) => deployments.some(deployment => deployment.applicationRecordId === record.attributes.application));
} }
getCrn (packageJsonName: string, appName: string): string { getCrn (appName: string): string {
const [arg1] = packageJsonName.split('/'); assert(this.registryConfig.authority, "Authority doesn't exist");
const authority = arg1.replace('@', ''); return `crn://${this.registryConfig.authority}/applications/${appName}`;
return `crn://${authority}/applications/${appName}`;
} }
} }
+20 -10
View File
@@ -6,6 +6,7 @@ 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 { GitType } from './types';
const log = debug('snowball:resolver'); const log = debug('snowball:resolver');
@@ -14,11 +15,11 @@ export const createResolvers = async (service: Service): Promise<any> => {
Query: { Query: {
// TODO: add custom type for context // TODO: add custom type for context
user: (_: any, __: any, context: any) => { user: (_: any, __: any, context: any) => {
return service.getUser(context.userId); return context.user;
}, },
organizations: async (_:any, __: any, context: any) => { organizations: async (_:any, __: any, context: any) => {
return service.getOrganizationsByUserId(context.userId); return service.getOrganizationsByUserId(context.user);
}, },
project: async (_: any, { projectId }: { projectId: string }) => { project: async (_: any, { projectId }: { projectId: string }) => {
@@ -26,7 +27,7 @@ export const createResolvers = async (service: Service): Promise<any> => {
}, },
projectsInOrganization: async (_: any, { organizationSlug }: {organizationSlug: string }, context: any) => { projectsInOrganization: async (_: any, { organizationSlug }: {organizationSlug: string }, context: any) => {
return service.getProjectsInOrganization(context.userId, organizationSlug); return service.getProjectsInOrganization(context.user, organizationSlug);
}, },
deployments: async (_: any, { projectId }: { projectId: string }) => { deployments: async (_: any, { projectId }: { projectId: string }) => {
@@ -42,7 +43,7 @@ export const createResolvers = async (service: Service): Promise<any> => {
}, },
searchProjects: async (_: any, { searchText }: { searchText: string }, context: any) => { searchProjects: async (_: any, { searchText }: { searchText: string }, context: any) => {
return service.searchProjects(context.userId, searchText); return service.searchProjects(context.user, searchText);
}, },
domains: async (_:any, { projectId, filter }: { projectId: string, filter?: FindOptionsWhere<Domain> }) => { domains: async (_:any, { projectId, filter }: { projectId: string, filter?: FindOptionsWhere<Domain> }) => {
@@ -54,7 +55,7 @@ export const createResolvers = async (service: Service): Promise<any> => {
Mutation: { Mutation: {
removeProjectMember: async (_: any, { projectMemberId }: { projectMemberId: string }, context: any) => { removeProjectMember: async (_: any, { projectMemberId }: { projectMemberId: string }, context: any) => {
try { try {
return await service.removeProjectMember(context.userId, projectMemberId); return await service.removeProjectMember(context.user, projectMemberId);
} catch (err) { } catch (err) {
log(err); log(err);
return false; return false;
@@ -119,7 +120,7 @@ export const createResolvers = async (service: Service): Promise<any> => {
updateDeploymentToProd: async (_: any, { deploymentId }: { deploymentId: string }, context: any) => { updateDeploymentToProd: async (_: any, { deploymentId }: { deploymentId: string }, context: any) => {
try { try {
return Boolean(await service.updateDeploymentToProd(context.userId, deploymentId)); return Boolean(await service.updateDeploymentToProd(context.user, deploymentId));
} catch (err) { } catch (err) {
log(err); log(err);
return false; return false;
@@ -128,7 +129,7 @@ export const createResolvers = async (service: Service): Promise<any> => {
addProject: async (_: any, { organizationSlug, data }: { organizationSlug: string, data: DeepPartial<Project> }, context: any) => { addProject: async (_: any, { organizationSlug, data }: { organizationSlug: string, data: DeepPartial<Project> }, context: any) => {
try { try {
return await service.addProject(context.userId, organizationSlug, data); return await service.addProject(context.user, organizationSlug, data);
} catch (err) { } catch (err) {
log(err); log(err);
throw err; throw err;
@@ -146,7 +147,7 @@ export const createResolvers = async (service: Service): Promise<any> => {
redeployToProd: async (_: any, { deploymentId }: { deploymentId: string }, context: any) => { redeployToProd: async (_: any, { deploymentId }: { deploymentId: string }, context: any) => {
try { try {
return Boolean(await service.redeployToProd(context.userId, deploymentId)); return Boolean(await service.redeployToProd(context.user, deploymentId));
} catch (err) { } catch (err) {
log(err); log(err);
return false; return false;
@@ -199,7 +200,16 @@ export const createResolvers = async (service: Service): Promise<any> => {
authenticateGitHub: async (_: any, { code }: { code: string }, context: any) => { authenticateGitHub: async (_: any, { code }: { code: string }, context: any) => {
try { try {
return await service.authenticateGitHub(code, context.userId); return await service.authenticateGitHub(code, context.user);
} catch (err) {
log(err);
return false;
}
},
authenticateGit: async (_: any, { type, code }: { type: GitType, code: string }, context: any) => {
try {
return await service.authenticateGit(type, code, context.user);
} catch (err) { } catch (err) {
log(err); log(err);
return false; return false;
@@ -208,7 +218,7 @@ 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.userId, { gitHubToken: null }); return service.unauthenticateGitHub(context.user, { gitHubToken: null });
} catch (err) { } catch (err) {
log(err); log(err);
return false; return false;
+41
View File
@@ -0,0 +1,41 @@
import { Router } from 'express';
import { SiweMessage, generateNonce } from 'siwe';
const router = Router();
router.get('/nonce', async (_, res) => {
res.send(generateNonce());
});
router.post('/validate', async (req, res) => {
const { message, signature } = req.body;
const { success, data } = await new SiweMessage(message).verify({
signature
});
if (success) {
req.session.address = data.address;
req.session.chainId = data.chainId;
}
res.send({ success });
});
router.get('/session', (req, res) => {
if (req.session.address && req.session.chainId) {
res.send({ address: req.session.address, chainId: req.session.chainId });
} else {
res.status(401).send({ error: 'Unauthorized: No active session' });
}
});
router.post('/logout', (req, res) => {
req.session.destroy((err) => {
if (err) {
return res.send({ success: false });
}
res.send({ success: true });
});
});
export default router;
+6
View File
@@ -26,6 +26,11 @@ enum DomainStatus {
Pending Pending
} }
enum GitType {
GitHub
Gitea
}
type User { type User {
id: String! id: String!
name: String name: String
@@ -203,5 +208,6 @@ type Mutation {
addDomain(projectId: String!, data: AddDomainInput!): Boolean! addDomain(projectId: String!, data: AddDomainInput!): Boolean!
updateDomain(domainId: String!, data: UpdateDomainInput!): Boolean! updateDomain(domainId: String!, data: UpdateDomainInput!): Boolean!
authenticateGitHub(code: String!): AuthResult! authenticateGitHub(code: String!): AuthResult!
authenticateGit(type: GitType!, code: String!): AuthResult!
unauthenticateGitHub: Boolean! unauthenticateGitHub: Boolean!
} }
+65 -7
View File
@@ -1,22 +1,33 @@
import debug from 'debug'; import debug from 'debug';
import express from 'express'; import express from 'express';
import cors from 'cors';
import { ApolloServer } from 'apollo-server-express'; import { ApolloServer } from 'apollo-server-express';
import { createServer } from 'http'; import { createServer } from 'http';
import { import {
ApolloServerPluginDrainHttpServer, ApolloServerPluginDrainHttpServer,
ApolloServerPluginLandingPageLocalDefault ApolloServerPluginLandingPageLocalDefault,
AuthenticationError
} from 'apollo-server-core'; } from 'apollo-server-core';
import session from 'express-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';
import { ServerConfig } from './config'; import { ServerConfig } from './config';
import { DEFAULT_GQL_PATH, USER_ID } from './constants'; import { DEFAULT_GQL_PATH } from './constants';
import githubRouter from './routes/github'; import githubRouter from './routes/github';
import authRouter from './routes/auth';
import { Service } from './service'; import { Service } from './service';
const log = debug('snowball:server'); const log = debug('snowball:server');
declare module 'express-session' {
interface SessionData {
address: string;
chainId: number;
}
}
export const createAndStartServer = async ( export const createAndStartServer = async (
serverConfig: ServerConfig, serverConfig: ServerConfig,
typeDefs: TypeSource, typeDefs: TypeSource,
@@ -24,6 +35,7 @@ export const createAndStartServer = async (
service: Service service: Service
): Promise<ApolloServer> => { ): Promise<ApolloServer> => {
const { host, port, gqlPath = DEFAULT_GQL_PATH } = serverConfig; const { host, port, gqlPath = DEFAULT_GQL_PATH } = serverConfig;
const { appOriginUrl, secret, domain, trustProxy } = serverConfig.session;
const app = express(); const app = express();
@@ -39,9 +51,19 @@ export const createAndStartServer = async (
const server = new ApolloServer({ const server = new ApolloServer({
schema, schema,
csrfPrevention: true, csrfPrevention: true,
context: () => { context: async ({ req }) => {
// TODO: Use userId derived from auth token // https://www.apollographql.com/docs/apollo-server/v3/security/authentication#api-wide-authorization
return { userId: USER_ID };
const { address } = req.session;
if (!address) {
throw new AuthenticationError('Unauthorized: No active session');
}
// Find/create user from ETH address in request session
const user = await service.loadOrCreateUser(address);
return { user };
}, },
plugins: [ plugins: [
// Proper shutdown for the HTTP server // Proper shutdown for the HTTP server
@@ -52,13 +74,49 @@ export const createAndStartServer = async (
await server.start(); await server.start();
app.use(cors({
origin: appOriginUrl,
credentials: true
}));
const sessionOptions: session.SessionOptions = {
secret: secret,
resave: false,
saveUninitialized: true,
cookie: {
secure: new URL(appOriginUrl).protocol === 'https:',
// TODO: Set cookie maxAge and handle cookie expiry in frontend
// maxAge: SESSION_COOKIE_MAX_AGE,
sameSite: new URL(appOriginUrl).protocol === 'https:' ? 'none' : 'lax'
}
};
if (domain) {
sessionOptions.cookie!.domain = domain;
}
if (trustProxy) {
// trust first proxy
app.set('trust proxy', 1);
}
app.use(
session(sessionOptions)
);
server.applyMiddleware({ server.applyMiddleware({
app, app,
path: gqlPath path: gqlPath,
cors: {
origin: [appOriginUrl],
credentials: true
}
}); });
app.set('service', service);
app.use(express.json()); app.use(express.json());
app.set('service', service);
app.use('/auth', authRouter);
app.use('/api/github', githubRouter); app.use('/api/github', githubRouter);
httpServer.listen(port, host, () => { httpServer.listen(port, host, () => {
+126 -48
View File
@@ -2,6 +2,7 @@ import assert from 'assert';
import debug from 'debug'; import debug from 'debug';
import { DeepPartial, FindOptionsWhere } from 'typeorm'; import { DeepPartial, FindOptionsWhere } from 'typeorm';
import { Octokit, RequestError } from 'octokit'; import { Octokit, RequestError } from 'octokit';
import fetch from 'node-fetch';
import { OAuthApp } from '@octokit/oauth-app'; import { OAuthApp } from '@octokit/oauth-app';
@@ -15,11 +16,13 @@ import { Permission, ProjectMember } from './entity/ProjectMember';
import { User } from './entity/User'; import { User } from './entity/User';
import { Registry } from './registry'; import { Registry } from './registry';
import { GitHubConfig, RegistryConfig } from './config'; import { GitHubConfig, RegistryConfig } from './config';
import { AppDeploymentRecord, GitPushEventPayload, PackageJSON } from './types'; import { AppDeploymentRecord, GitPushEventPayload, GitType, PackageJSON } from './types';
import { Role } from './entity/UserOrganization';
const log = debug('snowball:service'); const log = debug('snowball:service');
const GITHUB_UNIQUE_WEBHOOK_ERROR = 'Hook already exists on this repository'; const GITHUB_UNIQUE_WEBHOOK_ERROR = 'Hook already exists on this repository';
const GITEA_ACCESS_TOKEN_ENDPOINT = 'https://git.vdb.to/login/oauth/access_token';
interface Config { interface Config {
gitHubConfig: GitHubConfig gitHubConfig: GitHubConfig
@@ -148,6 +151,36 @@ export class Service {
}); });
} }
async loadOrCreateUser (ethAddress: string): Promise<User> {
// Get user by ETH address
let user = await this.db.getUser({
where: {
ethAddress
}
});
if (!user) {
const [org] = await this.db.getOrganizations({});
assert(org, 'No organizations exists in database');
// Create user with new address
user = await this.db.addUser({
email: `${ethAddress}@example.com`,
name: ethAddress,
isVerified: true,
ethAddress
});
await this.db.addUserOrganization({
member: user,
organization: org,
role: Role.Owner
});
}
return user;
}
async getOctokit (userId: string): Promise<Octokit> { async getOctokit (userId: string): Promise<Octokit> {
const user = await this.db.getUser({ where: { id: userId } }); const user = await this.db.getUser({ where: { id: userId } });
assert(user && user.gitHubToken, 'User needs to be authenticated with GitHub token'); assert(user && user.gitHubToken, 'User needs to be authenticated with GitHub token');
@@ -155,8 +188,8 @@ export class Service {
return new Octokit({ auth: user.gitHubToken }); return new Octokit({ auth: user.gitHubToken });
} }
async getOrganizationsByUserId (userId: string): Promise<Organization[]> { async getOrganizationsByUserId (user: User): Promise<Organization[]> {
const dbOrganizations = await this.db.getOrganizationsByUserId(userId); const dbOrganizations = await this.db.getOrganizationsByUserId(user.id);
return dbOrganizations; return dbOrganizations;
} }
@@ -165,8 +198,8 @@ export class Service {
return dbProject; return dbProject;
} }
async getProjectsInOrganization (userId:string, organizationSlug: string): Promise<Project[]> { async getProjectsInOrganization (user: User, organizationSlug: string): Promise<Project[]> {
const dbProjects = await this.db.getProjectsInOrganization(userId, organizationSlug); const dbProjects = await this.db.getProjectsInOrganization(user.id, organizationSlug);
return dbProjects; return dbProjects;
} }
@@ -185,8 +218,8 @@ export class Service {
return dbProjectMembers; return dbProjectMembers;
} }
async searchProjects (userId: string, searchText: string): Promise<Project[]> { async searchProjects (user: User, searchText: string): Promise<Project[]> {
const dbProjects = await this.db.getProjectsBySearchText(userId, searchText); const dbProjects = await this.db.getProjectsBySearchText(user.id, searchText);
return dbProjects; return dbProjects;
} }
@@ -231,17 +264,17 @@ export class Service {
return newProjectMember; return newProjectMember;
} }
async removeProjectMember (userId: string, projectMemberId: string): Promise<boolean> { async removeProjectMember (user: User, projectMemberId: string): Promise<boolean> {
const member = await this.db.getProjectMemberById(projectMemberId); const member = await this.db.getProjectMemberById(projectMemberId);
if (String(member.member.id) === userId) { if (String(member.member.id) === user.id) {
throw new Error('Invalid operation: cannot remove self'); throw new Error('Invalid operation: cannot remove self');
} }
const memberProject = member.project; const memberProject = member.project;
assert(memberProject); assert(memberProject);
if (String(userId) === String(memberProject.owner.id)) { if (String(user.id) === String(memberProject.owner.id)) {
return this.db.removeProjectMemberById(projectMemberId); return this.db.removeProjectMemberById(projectMemberId);
} else { } else {
throw new Error('Invalid operation: not authorized'); throw new Error('Invalid operation: not authorized');
@@ -274,7 +307,7 @@ export class Service {
return this.db.deleteEnvironmentVariable(environmentVariableId); return this.db.deleteEnvironmentVariable(environmentVariableId);
} }
async updateDeploymentToProd (userId: string, deploymentId: string): Promise<Deployment> { async updateDeploymentToProd (user: User, deploymentId: string): Promise<Deployment> {
const oldDeployment = await this.db.getDeployment({ const oldDeployment = await this.db.getDeployment({
where: { id: deploymentId }, where: { id: deploymentId },
relations: { relations: {
@@ -288,9 +321,9 @@ export class Service {
const prodBranchDomains = await this.db.getDomainsByProjectId(oldDeployment.project.id, { branch: oldDeployment.project.prodBranch }); const prodBranchDomains = await this.db.getDomainsByProjectId(oldDeployment.project.id, { branch: oldDeployment.project.prodBranch });
const octokit = await this.getOctokit(userId); const octokit = await this.getOctokit(user.id);
const newDeployment = await this.createDeployment(userId, const newDeployment = await this.createDeployment(user.id,
octokit, octokit,
{ {
project: oldDeployment.project, project: oldDeployment.project,
@@ -344,30 +377,15 @@ export class Service {
repoUrl: recordData.repoUrl repoUrl: recordData.repoUrl
}); });
const environmentVariables = await this.db.getEnvironmentVariablesByProjectId(data.project.id!, { environment: Environment.Production });
const environmentVariablesObj = environmentVariables.reduce((acc, env) => {
acc[env.key] = env.value;
return acc;
}, {} as { [key: string]: string });
const { applicationDeploymentRequestId, applicationDeploymentRequestData } = await this.registry.createApplicationDeploymentRequest(
{
appName: repo,
packageJsonName: packageJSON.name,
commitHash: data.commitHash!,
repository: recordData.repoUrl,
environmentVariables: environmentVariablesObj
});
// Update previous deployment with prod branch domain // Update previous deployment with prod branch domain
// TODO: Fix unique constraint error for domain // TODO: Fix unique constraint error for domain
await this.db.updateDeployment({ if (data.domain) {
domainId: data.domain?.id await this.db.updateDeployment({
}, { domainId: data.domain.id
domain: null }, {
}); domain: null
});
}
const newDeployment = await this.db.addDeployment({ const newDeployment = await this.db.addDeployment({
project: data.project, project: data.project,
@@ -378,8 +396,6 @@ export class Service {
status: DeploymentStatus.Building, status: DeploymentStatus.Building,
applicationRecordId, applicationRecordId,
applicationRecordData, applicationRecordData,
applicationDeploymentRequestId,
applicationDeploymentRequestData,
domain: data.domain, domain: data.domain,
createdBy: Object.assign(new User(), { createdBy: Object.assign(new User(), {
id: userId id: userId
@@ -387,10 +403,30 @@ export class Service {
}); });
log(`Created deployment ${newDeployment.id} and published application record ${applicationRecordId}`); log(`Created deployment ${newDeployment.id} and published application record ${applicationRecordId}`);
const environmentVariables = await this.db.getEnvironmentVariablesByProjectId(data.project.id!, { environment: Environment.Production });
const environmentVariablesObj = environmentVariables.reduce((acc, env) => {
acc[env.key] = env.value;
return acc;
}, {} as { [key: string]: string });
const { applicationDeploymentRequestId, applicationDeploymentRequestData } = await this.registry.createApplicationDeploymentRequest(
{
deployment: newDeployment,
appName: repo,
packageJsonName: packageJSON.name,
repository: recordData.repoUrl,
environmentVariables: environmentVariablesObj
});
await this.db.updateDeploymentById(newDeployment.id, { applicationDeploymentRequestId, applicationDeploymentRequestData });
return newDeployment; return newDeployment;
} }
async addProject (userId: string, organizationSlug: string, data: DeepPartial<Project>): Promise<Project | undefined> { async addProject (user: User, organizationSlug: string, data: DeepPartial<Project>): Promise<Project | undefined> {
const organization = await this.db.getOrganization({ const organization = await this.db.getOrganization({
where: { where: {
slug: organizationSlug slug: organizationSlug
@@ -400,9 +436,9 @@ export class Service {
throw new Error('Organization does not exist'); throw new Error('Organization does not exist');
} }
const project = await this.db.addProject(userId, organization.id, data); const project = await this.db.addProject(user, organization.id, data);
const octokit = await this.getOctokit(userId); const octokit = await this.getOctokit(user.id);
const [owner, repo] = project.repository.split('/'); const [owner, repo] = project.repository.split('/');
const { data: [latestCommit] } = await octokit.rest.repos.listCommits({ const { data: [latestCommit] } = await octokit.rest.repos.listCommits({
@@ -415,7 +451,7 @@ export class Service {
const { data: repoDetails } = await octokit.rest.repos.get({ owner, repo }); const { data: repoDetails } = await octokit.rest.repos.get({ owner, repo });
// Create deployment with prod branch and latest commit // Create deployment with prod branch and latest commit
await this.createDeployment(userId, await this.createDeployment(user.id,
octokit, octokit,
{ {
project, project,
@@ -515,7 +551,7 @@ export class Service {
return this.db.deleteDomainById(domainId); return this.db.deleteDomainById(domainId);
} }
async redeployToProd (userId: string, deploymentId: string): Promise<Deployment> { async redeployToProd (user: User, deploymentId: string): Promise<Deployment> {
const oldDeployment = await this.db.getDeployment({ const oldDeployment = await this.db.getDeployment({
relations: { relations: {
project: true, project: true,
@@ -531,9 +567,9 @@ export class Service {
throw new Error('Deployment not found'); throw new Error('Deployment not found');
} }
const octokit = await this.getOctokit(userId); const octokit = await this.getOctokit(user.id);
const newDeployment = await this.createDeployment(userId, const newDeployment = await this.createDeployment(user.id,
octokit, octokit,
{ {
project: oldDeployment.project, project: oldDeployment.project,
@@ -657,17 +693,59 @@ export class Service {
return updateResult; return updateResult;
} }
async authenticateGitHub (code:string, userId: string): Promise<{token: string}> { async authenticateGitHub (code:string, user: User): Promise<{token: string}> {
const { authentication: { token } } = await this.oauthApp.createToken({ const { authentication: { token } } = await this.oauthApp.createToken({
code code
}); });
await this.db.updateUser(userId, { gitHubToken: token }); await this.db.updateUser(user, { gitHubToken: token });
return { token }; return { token };
} }
async unauthenticateGitHub (userId: string, data: DeepPartial<User>): Promise<boolean> { async authenticateGit (type: GitType, code:string, user: User): Promise<{token: string}> {
return this.db.updateUser(userId, data); let token: string;
switch (type) {
case GitType.GitHub:
({ authentication: { token } } = await this.oauthApp.createToken({
code
}));
break;
case GitType.Gitea: {
const response = await fetch(GITEA_ACCESS_TOKEN_ENDPOINT, {
method: 'post',
body: JSON.stringify({
// TODO: Fetch from config
client_id: '',
client_secret: '',
code,
grant_type: 'authorization_code',
// TODO: Get frontend app URL from config
redirect_uri: 'http://localhost:3000/organization/projects/create'
}),
headers: { 'Content-Type': 'application/json' }
});
assert(response.ok, `HTTP Error Response: ${response.status} ${response.statusText}`);
const data: any = await response.json();
({ access_token: token } = data);
break;
}
default: throw new Error(`Type ${type} not handled for Git authentication`);
}
assert(token, `Access token is not set for type ${type}`);
await this.db.updateUser(user, { gitHubToken: token });
return { token };
}
async unauthenticateGitHub (user: User, data: DeepPartial<User>): Promise<boolean> {
return this.db.updateUser(user, data);
} }
} }
+5
View File
@@ -49,3 +49,8 @@ interface RegistryRecord {
export interface AppDeploymentRecord extends RegistryRecord { export interface AppDeploymentRecord extends RegistryRecord {
attributes: AppDeploymentRecordAttributes attributes: AppDeploymentRecordAttributes
} }
export enum GitType {
GitHub = 'GitHub',
Gitea = 'Gitea',
}
+9 -6
View File
@@ -1,20 +1,23 @@
[ [
{ {
"id": "59f4355d-9549-4aac-9b54-eeefceeabef0", "id": "59f4355d-9549-4aac-9b54-eeefceeabef0",
"name": "Snowball", "name": "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2",
"email": "snowball@snowballtools.xyz", "email": "snowball@snowballtools.xyz",
"isVerified": true "isVerified": true,
"ethAddress": "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2"
}, },
{ {
"id": "e505b212-8da6-48b2-9614-098225dab34b", "id": "e505b212-8da6-48b2-9614-098225dab34b",
"name": "Alice Anderson", "name": "0xbe0eb53f46cd790cd13851d5eff43d12404d33e8",
"email": "alice@snowballtools.xyz", "email": "alice@snowballtools.xyz",
"isVerified": true "isVerified": true,
"ethAddress": "0xbe0eb53f46cd790cd13851d5eff43d12404d33e8"
}, },
{ {
"id": "cd892fad-9138-4aa2-a62c-414a32776ea7", "id": "cd892fad-9138-4aa2-a62c-414a32776ea7",
"name": "Bob Banner", "name": "0x8315177ab297ba92a06054ce80a67ed4dbd7ed3a",
"email": "bob@snowballtools.xyz", "email": "bob@snowballtools.xyz",
"isVerified": true "isVerified": true,
"ethAddress": "0x8315177ab297ba92a06054ce80a67ed4dbd7ed3a"
} }
] ]
+4 -4
View File
@@ -11,19 +11,19 @@ const log = debug('snowball:initialize-registry');
const DENOM = 'aphoton'; const DENOM = 'aphoton';
const BOND_AMOUNT = '1000000000'; const BOND_AMOUNT = '1000000000';
// TODO: Get authority names from args
const AUTHORITY_NAMES = ['snowballtools', 'cerc-io'];
async function main () { async function main () {
const { registryConfig } = await getConfig<Config>(DEFAULT_CONFIG_FILE_PATH); const { registryConfig } = await getConfig<Config>(DEFAULT_CONFIG_FILE_PATH);
// TODO: Get authority names from args
const authorityNames = ['snowballtools', registryConfig.authority];
const registry = new Registry(registryConfig.gqlEndpoint, registryConfig.restEndpoint, 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);
await registry.createBond({ denom: DENOM, amount: BOND_AMOUNT }, registryConfig.privateKey, registryConfig.fee); await registry.createBond({ denom: DENOM, amount: BOND_AMOUNT }, registryConfig.privateKey, registryConfig.fee);
for await (const name of AUTHORITY_NAMES) { for await (const name of authorityNames) {
await registry.reserveAuthority({ name }, registryConfig.privateKey, registryConfig.fee); await registry.reserveAuthority({ name }, registryConfig.privateKey, registryConfig.fee);
log('Reserved authority name:', name); log('Reserved authority name:', name);
await registry.setAuthorityBond({ name, bondId }, registryConfig.privateKey, registryConfig.fee); await registry.setAuthorityBond({ name, bondId }, registryConfig.privateKey, registryConfig.fee);
@@ -5,14 +5,14 @@ import path from 'path';
import { Registry } from '@cerc-io/laconic-sdk'; import { Registry } from '@cerc-io/laconic-sdk';
import { Config } from '../src/config'; import { Config } from '../src/config';
import { DEFAULT_CONFIG_FILE_PATH, PROJECT_DOMAIN } from '../src/constants'; 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-deploy-records'); const log = debug('snowball:publish-deploy-records');
async function main () { async function main () {
const { registryConfig, database } = await getConfig<Config>(DEFAULT_CONFIG_FILE_PATH); const { registryConfig, database, misc } = await getConfig<Config>(DEFAULT_CONFIG_FILE_PATH);
const registry = new Registry(registryConfig.gqlEndpoint, registryConfig.restEndpoint, registryConfig.chainId); const registry = new Registry(registryConfig.gqlEndpoint, registryConfig.restEndpoint, registryConfig.chainId);
@@ -36,7 +36,7 @@ async function main () {
}); });
for await (const deployment of deployments) { for await (const deployment of deployments) {
const url = `${deployment.project.name}-${deployment.id}.${PROJECT_DOMAIN}`; const url = `${deployment.project.name}-${deployment.id}.${misc.projectDomain}`;
const applicationDeploymentRecord = { const applicationDeploymentRecord = {
type: 'ApplicationDeploymentRecord', type: 'ApplicationDeploymentRecord',
+5 -1
View File
@@ -1,4 +1,8 @@
REACT_APP_GQL_SERVER_URL = 'http://localhost:8000/graphql' REACT_APP_SERVER_URL = 'http://localhost:8000'
REACT_APP_GITHUB_CLIENT_ID = REACT_APP_GITHUB_CLIENT_ID =
REACT_APP_GITHUB_TEMPLATE_REPO = REACT_APP_GITHUB_TEMPLATE_REPO =
REACT_APP_GITEA_CLIENT_ID =
REACT_APP_WALLET_CONNECT_ID =
+7
View File
@@ -0,0 +1,7 @@
<svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="500" height="500" fill="#0F86F5"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M191.873 125.126C224.893 126.765 250.458 150.121 274.042 172.995C297.925 196.158 323.089 221.108 324.868 254.114C326.718 288.42 308.902 321.108 283.281 344.355C258.67 366.687 225.288 373.859 191.873 374.788C157.228 375.752 119.038 374.394 95.1648 349.588C71.6207 325.125 74.6696 287.843 75.7341 254.114C76.7518 221.865 79.2961 188.525 101.009 164.41C123.845 139.047 157.543 123.423 191.873 125.126Z" fill="#4BA4F7"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M229.373 125.126C262.393 126.765 287.958 150.121 311.542 172.995C335.425 196.158 360.589 221.108 362.368 254.114C364.218 288.42 346.402 321.108 320.781 344.355C296.17 366.687 262.788 373.859 229.373 374.788C194.728 375.752 156.538 374.394 132.665 349.588C109.121 325.125 112.17 287.843 113.234 254.114C114.252 221.865 116.796 188.525 138.509 164.41C161.345 139.047 195.043 123.423 229.373 125.126Z" fill="#8AC4FA"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M266.873 125.126C299.893 126.765 325.458 150.121 349.042 172.995C372.925 196.158 398.089 221.108 399.868 254.114C401.718 288.42 383.902 321.108 358.281 344.355C333.67 366.687 300.288 373.859 266.873 374.788C232.228 375.752 194.038 374.394 170.165 349.588C146.621 325.125 149.67 287.843 150.734 254.114C151.752 221.865 154.296 188.525 176.009 164.41C198.845 139.047 232.543 123.423 266.873 125.126Z" fill="#CAE4FD"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M304.373 125.126C337.393 126.765 362.958 150.121 386.542 172.995C410.425 196.158 435.589 221.108 437.368 254.114C439.218 288.42 421.402 321.108 395.781 344.355C371.17 366.687 337.788 373.859 304.373 374.788C269.728 375.752 231.538 374.394 207.665 349.588C184.121 325.125 187.17 287.843 188.234 254.114C189.252 221.865 191.796 188.525 213.509 164.41C236.345 139.047 270.043 123.423 304.373 125.126Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

+7
View File
@@ -4,6 +4,7 @@
"private": true, "private": true,
"dependencies": { "dependencies": {
"@material-tailwind/react": "^2.1.7", "@material-tailwind/react": "^2.1.7",
"@tanstack/react-query": "^5.22.2",
"@testing-library/jest-dom": "^5.17.0", "@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0", "@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0", "@testing-library/user-event": "^13.5.0",
@@ -11,7 +12,10 @@
"@types/node": "^16.18.68", "@types/node": "^16.18.68",
"@types/react": "^18.2.42", "@types/react": "^18.2.42",
"@types/react-dom": "^18.2.17", "@types/react-dom": "^18.2.17",
"@web3modal/siwe": "^4.0.5",
"@web3modal/wagmi": "^4.0.5",
"assert": "^2.1.0", "assert": "^2.1.0",
"axios": "^1.6.7",
"date-fns": "^3.0.1", "date-fns": "^3.0.1",
"downshift": "^8.2.3", "downshift": "^8.2.3",
"eslint-config-react-app": "^7.0.1", "eslint-config-react-app": "^7.0.1",
@@ -29,9 +33,12 @@
"react-router-dom": "^6.20.1", "react-router-dom": "^6.20.1",
"react-scripts": "5.0.1", "react-scripts": "5.0.1",
"react-timer-hook": "^3.0.7", "react-timer-hook": "^3.0.7",
"siwe": "^2.1.4",
"typescript": "^4.9.5", "typescript": "^4.9.5",
"usehooks-ts": "^2.10.0", "usehooks-ts": "^2.10.0",
"vertical-stepper-nav": "^1.0.2", "vertical-stepper-nav": "^1.0.2",
"viem": "^2.7.11",
"wagmi": "^2.5.7",
"web-vitals": "^2.1.4" "web-vitals": "^2.1.4"
}, },
"scripts": { "scripts": {
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square150x150logo src="/mstile-150x150.png"/>
<TileColor>#2d89ef</TileColor>
</tile>
</msapplication>
</browserconfig>
Binary file not shown.

After

Width:  |  Height:  |  Size: 674 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 989 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

+8 -27
View File
@@ -2,42 +2,23 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta <meta
name="description" name="description"
content="Web site created using create-react-app" content="snowball tools dashboard"
/> />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" /> <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!-- <link rel="apple-touch-icon" sizes="180x180" href="%PUBLIC_URL%/apple-touch-icon.png">
manifest.json provides metadata used when your web app is installed on a <link rel="icon" type="image/png" sizes="32x32" href="%PUBLIC_URL%/favicon-32x32.png">
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/ <link rel="icon" type="image/png" sizes="16x16" href="%PUBLIC_URL%/favicon-16x16.png">
--> <link rel="manifest" href="%PUBLIC_URL%/site.webmanifest">
<meta name="msapplication-TileColor" content="#2d89ef">
<meta name="theme-color" content="#ffffff">
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" /> <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>Snowball</title> <title>Snowball</title>
</head> </head>
<body> <body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div> <div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body> </body>
</html> </html>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

+19
View File
@@ -0,0 +1,19 @@
{
"name": "Snowball Tools Dashboard",
"short_name": "snowball tools",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}
+6 -6
View File
@@ -9,8 +9,8 @@ import {
projectsRoutesWithoutSearch, projectsRoutesWithoutSearch,
} from './pages/org-slug/projects/routes'; } from './pages/org-slug/projects/routes';
import ProjectSearchLayout from './layouts/ProjectSearch'; import ProjectSearchLayout from './layouts/ProjectSearch';
import { OctokitProvider } from './context/OctokitContext';
import Index from './pages'; import Index from './pages';
import Login from './pages/Login';
const router = createBrowserRouter([ const router = createBrowserRouter([
{ {
@@ -44,14 +44,14 @@ const router = createBrowserRouter([
path: '/', path: '/',
element: <Index />, element: <Index />,
}, },
{
path: '/login',
element: <Login />,
},
]); ]);
function App() { function App() {
return ( return <RouterProvider router={router} />;
<OctokitProvider>
<RouterProvider router={router} />
</OctokitProvider>
);
} }
export default App; export default App;
+12 -2
View File
@@ -3,6 +3,7 @@ import { Link, NavLink, useNavigate, useParams } from 'react-router-dom';
import { Organization } from 'gql-client'; import { Organization } from 'gql-client';
import { Typography, Option } from '@material-tailwind/react'; import { Typography, Option } from '@material-tailwind/react';
import { useDisconnect } from 'wagmi';
import { useGQLClient } from '../context/GQLClientContext'; import { useGQLClient } from '../context/GQLClientContext';
import AsyncSelect from './shared/AsyncSelect'; import AsyncSelect from './shared/AsyncSelect';
@@ -11,6 +12,7 @@ const Sidebar = () => {
const { orgSlug } = useParams(); const { orgSlug } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const client = useGQLClient(); const client = useGQLClient();
const { disconnect } = useDisconnect();
const [selectedOrgSlug, setSelectedOrgSlug] = useState(orgSlug); const [selectedOrgSlug, setSelectedOrgSlug] = useState(orgSlug);
const [organizations, setOrganizations] = useState<Organization[]>([]); const [organizations, setOrganizations] = useState<Organization[]>([]);
@@ -25,6 +27,11 @@ const Sidebar = () => {
setSelectedOrgSlug(orgSlug); setSelectedOrgSlug(orgSlug);
}, [orgSlug]); }, [orgSlug]);
const handleLogOut = useCallback(() => {
disconnect();
navigate('/login');
}, [disconnect, navigate]);
return ( return (
<div className="flex flex-col h-full p-4"> <div className="flex flex-col h-full p-4">
<div className="grow"> <div className="grow">
@@ -76,8 +83,11 @@ const Sidebar = () => {
</div> </div>
</div> </div>
<div className="grow flex flex-col justify-end"> <div className="grow flex flex-col justify-end">
<div>Documentation</div> <a className="cursor-pointer" onClick={handleLogOut}>
<div>Support</div> Log Out
</a>
<a className="cursor-pointer">Documentation</a>
<a className="cursor-pointer">Support</a>
</div> </div>
</div> </div>
); );
@@ -1,15 +1,21 @@
import { Button } from '@material-tailwind/react';
import React from 'react'; import React from 'react';
import OauthPopup from 'react-oauth-popup'; import OauthPopup from 'react-oauth-popup';
import { GitType } from 'gql-client';
import { Button } from '@material-tailwind/react';
import { useGQLClient } from '../../../context/GQLClientContext'; import { useGQLClient } from '../../../context/GQLClientContext';
import ConnectAccountTabPanel from './ConnectAccountTabPanel'; import ConnectAccountTabPanel from './ConnectAccountTabPanel';
const SCOPES = 'repo user'; const SCOPES = 'repo user';
const GITHUB_OAUTH_URL = `https://github.com/login/oauth/authorize?client_id=${ const GITHUB_OAUTH_URL = `https://github.com/login/oauth/authorize?client_id=${
process.env.REACT_APP_GITHUB_CLIENT_ID process.env.REACT_APP_GITHUB_CLIENT_ID
}&scope=${encodeURIComponent(SCOPES)}`; }&scope=${encodeURIComponent(SCOPES)}`;
const REDIRECT_URI = `${window.location.origin}/organization/projects/create`;
const GITEA_OAUTH_URL = `https://git.vdb.to/login/oauth/authorize?client_id=${process.env.REACT_APP_GITEA_CLIENT_ID}&redirect_uri=${REDIRECT_URI}&response_type=code`;
interface ConnectAccountInterface { interface ConnectAccountInterface {
onAuth: (token: string) => void; onAuth: (token: string) => void;
} }
@@ -17,11 +23,13 @@ interface ConnectAccountInterface {
const ConnectAccount = ({ onAuth: onToken }: ConnectAccountInterface) => { const ConnectAccount = ({ onAuth: onToken }: ConnectAccountInterface) => {
const client = useGQLClient(); const client = useGQLClient();
const handleCode = async (code: string) => { const handleCode = async (type: GitType, code: string) => {
// Pass code to backend and get access token // Pass code to backend and get access token
const { const {
authenticateGitHub: { token }, authenticateGit: { token },
} = await client.authenticateGitHub(code); } = await client.authenticateGit(type, code);
// TODO: Handle token according to Git type
onToken(token); onToken(token);
}; };
@@ -40,7 +48,7 @@ const ConnectAccount = ({ onAuth: onToken }: ConnectAccountInterface) => {
<div className="mt-2 flex"> <div className="mt-2 flex">
<OauthPopup <OauthPopup
url={GITHUB_OAUTH_URL} url={GITHUB_OAUTH_URL}
onCode={handleCode} onCode={(code) => handleCode(GitType.GitHub, code)}
onClose={() => {}} onClose={() => {}}
title="Snowball" title="Snowball"
width={1000} width={1000}
@@ -48,7 +56,16 @@ const ConnectAccount = ({ onAuth: onToken }: ConnectAccountInterface) => {
> >
<Button className="rounded-full mx-2">Connect to Github</Button> <Button className="rounded-full mx-2">Connect to Github</Button>
</OauthPopup> </OauthPopup>
<Button className="rounded-full mx-2">Connect to Gitea</Button> <OauthPopup
url={GITEA_OAUTH_URL}
onCode={(code) => handleCode(GitType.Gitea, code)}
onClose={() => {}}
title="Snowball"
width={1000}
height={1000}
>
<Button className="rounded-full mx-2">Connect to Gitea</Button>
</OauthPopup>
</div> </div>
<ConnectAccountTabPanel /> <ConnectAccountTabPanel />
</div> </div>
@@ -1,4 +1,4 @@
import React, { useCallback } 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 { Button, Typography } from '@material-tailwind/react'; import { Button, Typography } from '@material-tailwind/react';
@@ -7,6 +7,7 @@ import { DeployStep, DeployStatus } from './DeployStep';
import { Stopwatch, setStopWatchOffset } from '../../StopWatch'; import { Stopwatch, setStopWatchOffset } from '../../StopWatch';
import ConfirmDialog from '../../shared/ConfirmDialog'; import ConfirmDialog from '../../shared/ConfirmDialog';
const INTERVAL_DURATION = 5000;
const Deploy = () => { const Deploy = () => {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const projectId = searchParams.get('projectId'); const projectId = searchParams.get('projectId');
@@ -21,6 +22,14 @@ const Deploy = () => {
navigate(`/${orgSlug}/projects/create`); navigate(`/${orgSlug}/projects/create`);
}, []); }, []);
useEffect(() => {
const timerID = setTimeout(() => {
navigate(`/${orgSlug}/projects/create/success/${projectId}`);
}, INTERVAL_DURATION);
return () => clearInterval(timerID);
}, []);
return ( return (
<div> <div>
<div className="flex justify-between mb-6"> <div className="flex justify-between mb-6">
@@ -74,14 +83,6 @@ const Deploy = () => {
status={DeployStatus.NOT_STARTED} status={DeployStatus.NOT_STARTED}
step="4" step="4"
/> />
<Button
onClick={() => {
navigate(`/${orgSlug}/projects/create/success/${projectId}`);
}}
>
VIEW DEMO
</Button>
</div> </div>
); );
}; };
@@ -1,7 +1,8 @@
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import toast from 'react-hot-toast';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { Chip, IconButton } from '@material-tailwind/react'; import { Chip, IconButton, Spinner } from '@material-tailwind/react';
import { relativeTimeISO } from '../../../utils/time'; import { relativeTimeISO } from '../../../utils/time';
import { GitRepositoryDetails } from '../../../types'; import { GitRepositoryDetails } from '../../../types';
@@ -14,6 +15,7 @@ interface ProjectRepoCardProps {
const ProjectRepoCard: React.FC<ProjectRepoCardProps> = ({ repository }) => { const ProjectRepoCard: React.FC<ProjectRepoCardProps> = ({ repository }) => {
const client = useGQLClient(); const client = useGQLClient();
const navigate = useNavigate(); const navigate = useNavigate();
const [isLoading, setIsLoading] = React.useState(false);
const { orgSlug } = useParams(); const { orgSlug } = useParams();
@@ -22,6 +24,7 @@ const ProjectRepoCard: React.FC<ProjectRepoCardProps> = ({ repository }) => {
return; return;
} }
setIsLoading(true);
const { addProject } = await client.addProject(orgSlug!, { const { addProject } = await client.addProject(orgSlug!, {
name: `${repository.owner!.login}-${repository.name}`, name: `${repository.owner!.login}-${repository.name}`,
prodBranch: repository.default_branch!, prodBranch: repository.default_branch!,
@@ -30,7 +33,13 @@ const ProjectRepoCard: React.FC<ProjectRepoCardProps> = ({ repository }) => {
template: 'webapp', template: 'webapp',
}); });
navigate(`import?projectId=${addProject.id}`); if (Boolean(addProject)) {
setIsLoading(false);
navigate(`import?projectId=${addProject.id}`);
} else {
setIsLoading(false);
toast.error('Failed to create project');
}
}, [client, repository]); }, [client, repository]);
return ( return (
@@ -53,9 +62,13 @@ const ProjectRepoCard: React.FC<ProjectRepoCardProps> = ({ repository }) => {
</div> </div>
<p>{repository.updated_at && relativeTimeISO(repository.updated_at)}</p> <p>{repository.updated_at && relativeTimeISO(repository.updated_at)}</p>
</div> </div>
<div className="hidden group-hover:block"> {isLoading ? (
<IconButton size="sm">{'>'}</IconButton> <Spinner className="h-4 w-4" />
</div> ) : (
<div className="hidden group-hover:block">
<IconButton size="sm">{'>'}</IconButton>
</div>
)}
</div> </div>
); );
}; };
@@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { Typography, IconButton } from '@material-tailwind/react'; import { Typography, IconButton, Avatar } from '@material-tailwind/react';
import { relativeTimeISO } from '../../../utils/time'; import { relativeTimeISO } from '../../../utils/time';
import { GitCommitWithBranch } from '../../../types'; import { GitCommitWithBranch } from '../../../types';
@@ -11,9 +11,10 @@ interface ActivityCardProps {
const ActivityCard = ({ activity }: ActivityCardProps) => { const ActivityCard = ({ activity }: ActivityCardProps) => {
return ( return (
<div className="group flex hover:bg-gray-200 rounded mt-1"> <div className="group flex gap-2 hover:bg-gray-200 rounded mt-1">
<div className="w-4">^</div> <div className="w-8">
<Avatar src={activity.author?.avatar_url} variant="rounded" size="sm" />
</div>
<div className="grow"> <div className="grow">
<Typography>{activity.commit.author?.name}</Typography> <Typography>{activity.commit.author?.name}</Typography>
<Typography variant="small" color="gray"> <Typography variant="small" color="gray">
@@ -16,6 +16,7 @@ import {
Typography, Typography,
Chip, Chip,
ChipProps, ChipProps,
Tooltip,
} from '@material-tailwind/react'; } from '@material-tailwind/react';
import { relativeTimeMs } from '../../../../utils/time'; import { relativeTimeMs } from '../../../../utils/time';
@@ -24,6 +25,7 @@ import DeploymentDialogBodyCard from './DeploymentDialogBodyCard';
import AssignDomainDialog from './AssignDomainDialog'; import AssignDomainDialog from './AssignDomainDialog';
import { useGQLClient } from '../../../../context/GQLClientContext'; import { useGQLClient } from '../../../../context/GQLClientContext';
import { SHORT_COMMIT_HASH_LENGTH } from '../../../../constants'; import { SHORT_COMMIT_HASH_LENGTH } from '../../../../constants';
import { formatAddress } from '../../../../utils/format';
interface DeployDetailsCardProps { interface DeployDetailsCardProps {
deployment: Deployment; deployment: Deployment;
@@ -87,18 +89,12 @@ const DeploymentDetailsCard = ({
}; };
return ( return (
<div className="grid grid-cols-4 gap-2 border-b border-gray-300 p-3 my-2"> <div className="grid grid-cols-8 gap-2 border-b border-gray-300 p-3 my-2">
<div className="col-span-2"> <div className="col-span-3">
<div className="flex"> <div className="flex">
{deployment.url && ( {deployment.url && (
<Typography className=" basis-3/4">{deployment.url}</Typography> <Typography className=" basis-3/4">{deployment.url}</Typography>
)} )}
<Chip
value={deployment.status}
color={STATUS_COLORS[deployment.status] ?? 'gray'}
variant="ghost"
icon={<i>^</i>}
/>
</div> </div>
<Typography color="gray"> <Typography color="gray">
{deployment.environment === Environment.Production {deployment.environment === Environment.Production
@@ -107,15 +103,26 @@ const DeploymentDetailsCard = ({
</Typography> </Typography>
</div> </div>
<div className="col-span-1"> <div className="col-span-1">
<Chip
value={deployment.status}
color={STATUS_COLORS[deployment.status] ?? 'gray'}
variant="ghost"
icon={<i>^</i>}
/>
</div>
<div className="col-span-2">
<Typography color="gray">^ {deployment.branch}</Typography> <Typography color="gray">^ {deployment.branch}</Typography>
<Typography color="gray"> <Typography color="gray">
^ {deployment.commitHash.substring(0, SHORT_COMMIT_HASH_LENGTH)}{' '} ^ {deployment.commitHash.substring(0, SHORT_COMMIT_HASH_LENGTH)}{' '}
{deployment.commitMessage} {deployment.commitMessage}
</Typography> </Typography>
</div> </div>
<div className="col-span-1 flex items-center"> <div className="col-span-2 flex items-center">
<Typography color="gray" className="grow"> <Typography color="gray" className="grow">
^ {relativeTimeMs(deployment.createdAt)} ^ {deployment.createdBy.name} ^ {relativeTimeMs(deployment.createdAt)} ^{' '}
<Tooltip content={deployment.createdBy.name}>
{formatAddress(deployment.createdBy.name ?? '')}
</Tooltip>
</Typography> </Typography>
<Menu placement="bottom-start"> <Menu placement="bottom-start">
<MenuHandler> <MenuHandler>
@@ -5,6 +5,7 @@ import { Typography, Chip, Card } from '@material-tailwind/react';
import { color } from '@material-tailwind/react/types/components/chip'; import { color } from '@material-tailwind/react/types/components/chip';
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';
interface DeploymentDialogBodyCardProps { interface DeploymentDialogBodyCardProps {
deployment: Deployment; deployment: Deployment;
@@ -39,7 +40,8 @@ const DeploymentDialogBodyCard = ({
{deployment.commitMessage} {deployment.commitMessage}
</Typography> </Typography>
<Typography variant="small"> <Typography variant="small">
^ {relativeTimeMs(deployment.createdAt)} ^ {deployment.createdBy.name} ^ {relativeTimeMs(deployment.createdAt)} ^{' '}
{formatAddress(deployment.createdBy.name ?? '')}
</Typography> </Typography>
</Card> </Card>
); );
@@ -44,18 +44,18 @@ const FilterForm = ({ value, onChange }: FilterFormProps) => {
}, [value]); }, [value]);
return ( return (
<div className="grid grid-cols-4 gap-2 text-sm text-gray-600"> <div className="grid grid-cols-8 gap-2 text-sm text-gray-600">
<div className="col-span-2"> <div className="col-span-4">
<SearchBar <SearchBar
placeholder="Search branches" placeholder="Search branches"
value={searchedBranch} value={searchedBranch}
onChange={(event) => setSearchedBranch(event.target.value)} onChange={(event) => setSearchedBranch(event.target.value)}
/> />
</div> </div>
<div className="col-span-1"> <div className="col-span-2">
<DatePicker mode="range" selected={dateRange} onSelect={setDateRange} /> <DatePicker mode="range" selected={dateRange} onSelect={setDateRange} />
</div> </div>
<div className="col-span-1 relative"> <div className="col-span-2 relative">
<Select <Select
value={selectedStatus} value={selectedStatus}
onChange={(value) => setSelectedStatus(value as StatusOptions)} onChange={(value) => setSelectedStatus(value as StatusOptions)}
@@ -7,9 +7,11 @@ import {
Option, Option,
Chip, Chip,
IconButton, IconButton,
Tooltip,
} from '@material-tailwind/react'; } from '@material-tailwind/react';
import ConfirmDialog from '../../../shared/ConfirmDialog'; import ConfirmDialog from '../../../shared/ConfirmDialog';
import { formatAddress } from '../../../../utils/format';
const PERMISSION_OPTIONS = [ const PERMISSION_OPTIONS = [
{ {
@@ -48,6 +50,7 @@ const MemberCard = ({
onRemoveProjectMember, onRemoveProjectMember,
onUpdateProjectMember, onUpdateProjectMember,
}: MemberCardProps) => { }: MemberCardProps) => {
const [ethAddress, emailDomain] = member.email.split('@');
const [selectedPermission, setSelectedPermission] = useState( const [selectedPermission, setSelectedPermission] = useState(
permissions.join('+'), permissions.join('+'),
); );
@@ -79,8 +82,16 @@ const MemberCard = ({
> >
<div>^</div> <div>^</div>
<div className="basis-1/2"> <div className="basis-1/2">
{member.name && <Typography variant="small">{member.name}</Typography>} {member.name && (
<Typography variant="small">{member.email}</Typography> <Tooltip content={member.name}>
{formatAddress(member.name ?? '')}
</Tooltip>
)}
<Tooltip content={member.email}>
<p>
{formatAddress(ethAddress)}@{emailDomain}
</p>
</Tooltip>
</div> </div>
<div className="basis-1/2"> <div className="basis-1/2">
{!isPending ? ( {!isPending ? (
@@ -142,8 +153,9 @@ const MemberCard = ({
color="red" color="red"
> >
<Typography variant="small"> <Typography variant="small">
Once removed, {member.name} ({member.email}) will not be able to Once removed, {formatAddress(member.name ?? '')} (
access this project. {formatAddress(ethAddress)}@{emailDomain}) will not be able to access
this project.
</Typography> </Typography>
</ConfirmDialog> </ConfirmDialog>
</div> </div>
+3 -2
View File
@@ -1,4 +1,5 @@
export const GIT_TEMPLATE_LINK = export const GIT_TEMPLATE_LINK = `https://github.com/${process.env.REACT_APP_GITHUB_TEMPLATE_REPO}`;
'https://git.vdb.to/cerc-io/test-progressive-web-app';
export const SHORT_COMMIT_HASH_LENGTH = 8; export const SHORT_COMMIT_HASH_LENGTH = 8;
export const SERVER_GQL_PATH = 'graphql';
@@ -0,0 +1,125 @@
import React, { ReactNode } from 'react';
import { SiweMessage } from 'siwe';
import { WagmiProvider } from 'wagmi';
import { arbitrum, 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';
const queryClient = new QueryClient();
const axiosInstance = axios.create({
baseURL: process.env.REACT_APP_SERVER_URL,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
},
withCredentials: true,
});
const metadata = {
name: 'Web3Modal',
description: 'Snowball Web3Modal',
url: window.location.origin,
icons: ['https://avatars.githubusercontent.com/u/37784886'],
};
const chains = [mainnet, arbitrum] as const;
const config = defaultWagmiConfig({
chains,
projectId: process.env.REACT_APP_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 () => {
const nonce = (await axiosInstance.get('/auth/nonce')).data;
if (!nonce) {
throw new Error('Failed to get nonce!');
}
return nonce;
},
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 = '/';
},
});
if (!process.env.REACT_APP_WALLET_CONNECT_ID) {
throw new Error('Error: REACT_APP_WALLET_CONNECT_ID env config is not set');
}
createWeb3Modal({
siweConfig,
wagmiConfig: config,
projectId: process.env.REACT_APP_WALLET_CONNECT_ID,
});
export default function Web3ModalProvider({
children,
}: {
children: ReactNode;
}) {
return (
<WagmiProvider config={config}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</WagmiProvider>
);
}
+13 -6
View File
@@ -10,23 +10,30 @@ import './index.css';
import App from './App'; import App from './App';
import reportWebVitals from './reportWebVitals'; import reportWebVitals from './reportWebVitals';
import { GQLClientProvider } from './context/GQLClientContext'; import { GQLClientProvider } from './context/GQLClientContext';
import Web3ModalProvider from './context/Web3ModalProvider';
import { SERVER_GQL_PATH } from './constants';
const root = ReactDOM.createRoot( const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement, document.getElementById('root') as HTMLElement,
); );
const gqlEndpoint = process.env.REACT_APP_GQL_SERVER_URL; assert(
assert(gqlEndpoint, 'GQL server URL not provided'); process.env.REACT_APP_SERVER_URL,
'REACT_APP_SERVER_URL is not set in env',
);
const gqlEndpoint = `${process.env.REACT_APP_SERVER_URL}/${SERVER_GQL_PATH}`;
const gqlClient = new GQLClient({ gqlEndpoint }); const gqlClient = new GQLClient({ gqlEndpoint });
root.render( root.render(
<React.StrictMode> <React.StrictMode>
<ThemeProvider> <ThemeProvider>
<GQLClientProvider client={gqlClient}> <Web3ModalProvider>
<App /> <GQLClientProvider client={gqlClient}>
<Toaster position="bottom-center" /> <App />
</GQLClientProvider> <Toaster position="bottom-center" />
</GQLClientProvider>
</Web3ModalProvider>
</ThemeProvider> </ThemeProvider>
</React.StrictMode>, </React.StrictMode>,
); );
@@ -1,13 +1,27 @@
import React from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { Outlet, useNavigate } from 'react-router-dom'; import { Outlet, useNavigate } from 'react-router-dom';
import { User } from 'gql-client';
import { IconButton, Typography } from '@material-tailwind/react'; import { IconButton, Tooltip, Typography } from '@material-tailwind/react';
import HorizontalLine from '../components/HorizontalLine'; import HorizontalLine from '../components/HorizontalLine';
import ProjectSearchBar from '../components/projects/ProjectSearchBar'; import ProjectSearchBar from '../components/projects/ProjectSearchBar';
import { useGQLClient } from '../context/GQLClientContext';
import { formatAddress } from '../utils/format';
const ProjectSearch = () => { const ProjectSearch = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const client = useGQLClient();
const [user, setUser] = useState<User>();
const fetchUser = useCallback(async () => {
const { user } = await client.getUser();
setUser(user);
}, []);
useEffect(() => {
fetchUser();
}, []);
return ( return (
<div> <div>
@@ -28,8 +42,10 @@ const ProjectSearch = () => {
<div className="mr-2 flex items-center"> <div className="mr-2 flex items-center">
<Typography>^</Typography> <Typography>^</Typography>
</div> </div>
<div className="px-2 py-1 bg-blue-gray-50 rounded-lg"> <div className="px-2 py-1 bg-blue-gray-50 rounded-lg flex items-center">
<Typography variant="lead">SY</Typography> {user?.name && (
<Tooltip content={user.name}>{formatAddress(user.name)}</Tooltip>
)}
</div> </div>
</div> </div>
<HorizontalLine /> <HorizontalLine />
+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg> <svg width="500" height="500" viewBox="0 0 500 500" fill="none" xmlns="http://www.w3.org/2000/svg"><rect width="500" height="500" fill="#0F86F5"/><path fill-rule="evenodd" clip-rule="evenodd" d="M191.873 125.126C224.893 126.765 250.458 150.121 274.042 172.995C297.925 196.158 323.089 221.108 324.868 254.114C326.718 288.42 308.902 321.108 283.281 344.355C258.67 366.687 225.288 373.859 191.873 374.788C157.228 375.752 119.038 374.394 95.1648 349.588C71.6207 325.125 74.6696 287.843 75.7341 254.114C76.7518 221.865 79.2961 188.525 101.009 164.41C123.845 139.047 157.543 123.423 191.873 125.126Z" fill="#4BA4F7"/><path fill-rule="evenodd" clip-rule="evenodd" d="M229.373 125.126C262.393 126.765 287.958 150.121 311.542 172.995C335.425 196.158 360.589 221.108 362.368 254.114C364.218 288.42 346.402 321.108 320.781 344.355C296.17 366.687 262.788 373.859 229.373 374.788C194.728 375.752 156.538 374.394 132.665 349.588C109.121 325.125 112.17 287.843 113.234 254.114C114.252 221.865 116.796 188.525 138.509 164.41C161.345 139.047 195.043 123.423 229.373 125.126Z" fill="#8AC4FA"/><path fill-rule="evenodd" clip-rule="evenodd" d="M266.873 125.126C299.893 126.765 325.458 150.121 349.042 172.995C372.925 196.158 398.089 221.108 399.868 254.114C401.718 288.42 383.902 321.108 358.281 344.355C333.67 366.687 300.288 373.859 266.873 374.788C232.228 375.752 194.038 374.394 170.165 349.588C146.621 325.125 149.67 287.843 150.734 254.114C151.752 221.865 154.296 188.525 176.009 164.41C198.845 139.047 232.543 123.423 266.873 125.126Z" fill="#CAE4FD"/><path fill-rule="evenodd" clip-rule="evenodd" d="M304.373 125.126C337.393 126.765 362.958 150.121 386.542 172.995C410.425 196.158 435.589 221.108 437.368 254.114C439.218 288.42 421.402 321.108 395.781 344.355C371.17 366.687 337.788 373.859 304.373 374.788C269.728 375.752 231.538 374.394 207.665 349.588C184.121 325.125 187.17 287.843 188.234 254.114C189.252 221.865 191.796 188.525 213.509 164.41C236.345 139.047 270.043 123.423 304.373 125.126Z" fill="white"/></svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

+14
View File
@@ -0,0 +1,14 @@
import React from 'react';
const Login = () => {
return (
<div className="grid grid-cols-5 h-screen bg-light-blue-50 py-10">
<div className="col-span-2"></div>
<div className="col-span-1">
<w3m-button />
</div>
</div>
);
};
export default Login;
+4 -1
View File
@@ -2,6 +2,7 @@ import React from 'react';
import { Outlet } from 'react-router-dom'; import { Outlet } from 'react-router-dom';
import Sidebar from '../components/Sidebar'; import Sidebar from '../components/Sidebar';
import { OctokitProvider } from '../context/OctokitContext';
const OrgSlug = () => { const OrgSlug = () => {
return ( return (
@@ -12,7 +13,9 @@ const OrgSlug = () => {
</div> </div>
<div className="col-span-4 h-full p-3 overflow-y-hidden"> <div className="col-span-4 h-full p-3 overflow-y-hidden">
<div className="bg-white rounded-3xl h-full overflow-y-auto"> <div className="bg-white rounded-3xl h-full overflow-y-auto">
<Outlet /> <OctokitProvider>
<Outlet />
</OctokitProvider>
</div> </div>
</div> </div>
</> </>
@@ -19,19 +19,31 @@ import {
import HorizontalLine from '../../../components/HorizontalLine'; import HorizontalLine from '../../../components/HorizontalLine';
import { useGQLClient } from '../../../context/GQLClientContext'; import { useGQLClient } from '../../../context/GQLClientContext';
import { useOctokit } from '../../../context/OctokitContext';
const Id = () => { const Id = () => {
const { id } = useParams(); const { id } = useParams();
const { octokit } = useOctokit();
const navigate = useNavigate(); const navigate = useNavigate();
const client = useGQLClient(); const client = useGQLClient();
const location = useLocation(); const location = useLocation();
const [project, setProject] = useState<ProjectType | null>(null); const [project, setProject] = useState<ProjectType | null>(null);
const [repoUrl, setRepoUrl] = useState('');
const fetchProject = useCallback(async (id: string | undefined) => { const fetchProject = useCallback(async (id: string | undefined) => {
if (id) { if (id) {
const { project } = await client.getProject(id); const { project } = await client.getProject(id);
setProject(project); setProject(project);
if (project) {
const [owner, repo] = project.repository.split('/');
const { data: repoDetails } = await octokit.rest.repos.get({
owner,
repo,
});
setRepoUrl(repoDetails.html_url);
}
} }
}, []); }, []);
@@ -68,9 +80,11 @@ const Id = () => {
<Typography variant="h3" className="grow"> <Typography variant="h3" className="grow">
{project?.name} {project?.name}
</Typography> </Typography>
<Button className="rounded-full" variant="outlined"> <Link to={repoUrl} target="_blank">
Open Repo <Button className="rounded-full" variant="outlined">
</Button> Open Repo
</Button>
</Link>
<Button className="rounded-full" color="blue"> <Button className="rounded-full" color="blue">
Go to app Go to app
</Button> </Button>
@@ -50,7 +50,7 @@ const CreateWithTemplate = () => {
<div className="grow px-2">{template?.name}</div> <div className="grow px-2">{template?.name}</div>
<div> <div>
<a href={GIT_TEMPLATE_LINK} target="_blank" rel="noreferrer"> <a href={GIT_TEMPLATE_LINK} target="_blank" rel="noreferrer">
^ cerc-io/test-progressive-web-app ^ {process.env.REACT_APP_GITHUB_TEMPLATE_REPO}
</a> </a>
</div> </div>
</div> </div>
@@ -4,7 +4,7 @@ import { useNavigate, useParams } from 'react-router-dom';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import assert from 'assert'; import assert from 'assert';
import { Option, Typography } from '@material-tailwind/react'; import { Button, Option, Typography } from '@material-tailwind/react';
import { useOctokit } from '../../../../../context/OctokitContext'; import { useOctokit } from '../../../../../context/OctokitContext';
import { useGQLClient } from '../../../../../context/GQLClientContext'; import { useGQLClient } from '../../../../../context/GQLClientContext';
@@ -27,10 +27,12 @@ const CreateRepo = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const [gitAccounts, setGitAccounts] = useState<string[]>([]); const [gitAccounts, setGitAccounts] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false);
const submitRepoHandler: SubmitHandler<SubmitRepoValues> = useCallback( const submitRepoHandler: SubmitHandler<SubmitRepoValues> = useCallback(
async (data) => { async (data) => {
assert(data.account); assert(data.account);
setIsLoading(true);
try { try {
assert( assert(
@@ -62,11 +64,17 @@ const CreateRepo = () => {
template: 'webapp', template: 'webapp',
}); });
navigate( if (Boolean(addProject)) {
`/${orgSlug}/projects/create/template/deploy?projectId=${addProject.id}`, setIsLoading(true);
); navigate(
`/${orgSlug}/projects/create/template/deploy?projectId=${addProject.id}`,
);
} else {
setIsLoading(false);
}
} catch (err) { } catch (err) {
console.error(err); console.error(err);
setIsLoading(false);
toast.error('Error deploying project'); toast.error('Error deploying project');
} }
}, },
@@ -174,9 +182,14 @@ const CreateRepo = () => {
</label> </label>
</div> </div>
<div className="mb-2"> <div className="mb-2">
<button className="bg-blue-500 rounded-xl p-2" type="submit"> <Button
className="bg-blue-500 rounded-xl p-2"
type="submit"
disabled={isLoading}
loading={isLoading}
>
Deploy ^ Deploy ^
</button> </Button>
</div> </div>
</form> </form>
); );
@@ -1,15 +1,22 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Domain, DomainStatus } from 'gql-client'; import { Domain, DomainStatus } from 'gql-client';
import { useNavigate, useOutletContext } from 'react-router-dom'; import { Link, useNavigate, useOutletContext } from 'react-router-dom';
import { RequestError } from 'octokit'; import { RequestError } from 'octokit';
import { Typography, Button, Chip, Avatar } from '@material-tailwind/react'; import {
Typography,
Button,
Chip,
Avatar,
Tooltip,
} from '@material-tailwind/react';
import ActivityCard from '../../../../components/projects/project/ActivityCard'; import ActivityCard from '../../../../components/projects/project/ActivityCard';
import { relativeTimeMs } from '../../../../utils/time'; import { relativeTimeMs } from '../../../../utils/time';
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 { formatAddress } from '../../../../utils/format';
const COMMITS_PER_PAGE = 4; const COMMITS_PER_PAGE = 4;
@@ -122,9 +129,9 @@ const OverviewTabPanel = () => {
color="green" color="green"
/> />
) : ( ) : (
<div className="flex justify-between items-center w-full m-2"> <div className="flex items-center">
<Chip <Chip
className="normal-case inline font-normal" className="normal-case inline font-normal mx-2"
size="sm" size="sm"
value="Not connected" value="Not connected"
icon="^" icon="^"
@@ -157,7 +164,9 @@ const OverviewTabPanel = () => {
<p>^ Created</p> <p>^ Created</p>
<p> <p>
{relativeTimeMs(project.deployments[0].createdAt)} by ^{' '} {relativeTimeMs(project.deployments[0].createdAt)} by ^{' '}
{project.deployments[0].createdBy.name} <Tooltip content={project.deployments[0].createdBy.name}>
{formatAddress(project.deployments[0].createdBy.name ?? '')}
</Tooltip>
</p> </p>
</div> </div>
</> </>
@@ -174,7 +183,11 @@ const OverviewTabPanel = () => {
</div> </div>
<div className="p-2"> <div className="p-2">
{activities.map((activity, index) => { {activities.map((activity, index) => {
return <ActivityCard activity={activity} key={index} />; return (
<Link to={activity.html_url} target="_blank" key={index}>
<ActivityCard activity={activity} />
</Link>
);
})} })}
</div> </div>
</div> </div>
+4
View File
@@ -21,6 +21,9 @@ export interface GitBranchDetails {
} }
export interface GitCommitWithBranch { export interface GitCommitWithBranch {
author: {
avatar_url: string;
} | null;
branch: GitBranchDetails; branch: GitBranchDetails;
commit: { commit: {
author: { author: {
@@ -29,6 +32,7 @@ export interface GitCommitWithBranch {
} | null; } | null;
message: string; message: string;
}; };
html_url: string;
} }
export enum GitSelect { export enum GitSelect {
+7
View File
@@ -0,0 +1,7 @@
export const formatAddress = (address: string) => {
if (address.startsWith('0x') && address.length > 10) {
return address.slice(0, 6) + '..' + address.slice(-4);
}
return address;
};
+14 -1
View File
@@ -27,7 +27,8 @@ export class GQLClient {
this.client = new ApolloClient({ this.client = new ApolloClient({
uri: config.gqlEndpoint, uri: config.gqlEndpoint,
cache: new InMemoryCache(), cache: new InMemoryCache(),
defaultOptions defaultOptions,
credentials: 'include'
}); });
} }
@@ -310,6 +311,18 @@ export class GQLClient {
return data; return data;
} }
async authenticateGit (type: types.GitType, code: string): Promise<types.AuthenticateGitResponse> {
const { data } = await this.client.mutate({
mutation: mutations.authenticateGit,
variables: {
type,
code
}
});
return data;
}
async unauthenticateGithub (): Promise<types.UnauthenticateGitHubResponse> { async unauthenticateGithub (): Promise<types.UnauthenticateGitHubResponse> {
const { data } = await this.client.mutate({ const { data } = await this.client.mutate({
mutation: mutations.unauthenticateGitHub mutation: mutations.unauthenticateGitHub
+7
View File
@@ -95,6 +95,13 @@ mutation ($code: String!) {
} }
}`; }`;
export const authenticateGit = gql`
mutation ($type: GitType!, $code: String!) {
authenticateGit(type: $type, code: $code) {
token
}
}`;
export const unauthenticateGitHub = gql` export const unauthenticateGitHub = gql`
mutation { mutation {
unauthenticateGitHub unauthenticateGitHub
+11
View File
@@ -28,6 +28,11 @@ export enum DomainStatus {
Pending = 'Pending', Pending = 'Pending',
} }
export enum GitType {
GitHub = 'GitHub',
Gitea = 'Gitea',
}
export type EnvironmentVariable = { export type EnvironmentVariable = {
id: string id: string
environment: Environment environment: Environment
@@ -288,6 +293,12 @@ export type AuthenticateGitHubResponse = {
} }
} }
export type AuthenticateGitResponse = {
authenticateGit: {
token: string
}
}
export type UnauthenticateGitHubResponse = { export type UnauthenticateGitHubResponse = {
unauthenticateGitHub: boolean unauthenticateGitHub: boolean
} }
+2522 -59
View File
File diff suppressed because it is too large Load Diff