mirror of
https://github.com/snowball-tools/snowballtools-base.git
synced 2024-11-18 00:49:23 +00:00
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>
This commit is contained in:
parent
a846531e43
commit
ef0eac8293
@ -109,7 +109,7 @@
|
|||||||
- Copy the graphQL endpoint from terminal and add the endpoint in the [.env](packages/frontend/.env) file present in `packages/frontend`
|
- Copy the graphQL endpoint from terminal and add the endpoint in the [.env](packages/frontend/.env) file present in `packages/frontend`
|
||||||
|
|
||||||
```env
|
```env
|
||||||
REACT_APP_GQL_SERVER_URL = 'http://localhost:8000/graphql'
|
REACT_APP_SERVER_URL = 'http://localhost:8000'
|
||||||
```
|
```
|
||||||
|
|
||||||
- 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
|
||||||
|
@ -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
|
||||||
|
@ -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"
|
||||||
@ -22,4 +27,7 @@
|
|||||||
[registryConfig.fee]
|
[registryConfig.fee]
|
||||||
amount = "200000"
|
amount = "200000"
|
||||||
denom = "aphoton"
|
denom = "aphoton"
|
||||||
gas = "550000"
|
gas = "750000"
|
||||||
|
|
||||||
|
[misc]
|
||||||
|
projectDomain = "apps.snowballtools.com"
|
||||||
|
@ -12,8 +12,10 @@
|
|||||||
"@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",
|
||||||
@ -42,6 +44,7 @@
|
|||||||
"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",
|
||||||
"@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",
|
||||||
|
@ -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 {
|
||||||
@ -30,9 +41,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;
|
||||||
}
|
}
|
||||||
|
@ -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';
|
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -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',
|
||||||
|
@ -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);
|
||||||
|
@ -14,11 +14,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 +26,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 +42,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 +54,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 +119,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 +128,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 +146,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 +199,7 @@ 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) {
|
} catch (err) {
|
||||||
log(err);
|
log(err);
|
||||||
return false;
|
return false;
|
||||||
@ -208,7 +208,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
packages/backend/src/routes/auth.ts
Normal file
41
packages/backend/src/routes/auth.ts
Normal 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;
|
@ -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,50 @@ 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,
|
||||||
|
domain: 'localhost',
|
||||||
|
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, () => {
|
||||||
|
@ -16,6 +16,7 @@ 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, PackageJSON } from './types';
|
||||||
|
import { Role } from './entity/UserOrganization';
|
||||||
|
|
||||||
const log = debug('snowball:service');
|
const log = debug('snowball:service');
|
||||||
|
|
||||||
@ -148,6 +149,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 +186,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 +196,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 +216,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 +262,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 +305,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 +319,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,
|
||||||
@ -393,7 +424,7 @@ export class Service {
|
|||||||
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
|
||||||
@ -403,9 +434,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({
|
||||||
@ -418,7 +449,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,
|
||||||
@ -518,7 +549,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,
|
||||||
@ -534,9 +565,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,
|
||||||
@ -660,17 +691,17 @@ 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 unauthenticateGitHub (user: User, data: DeepPartial<User>): Promise<boolean> {
|
||||||
return this.db.updateUser(userId, data);
|
return this.db.updateUser(user, data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
9
packages/backend/test/fixtures/users.json
vendored
9
packages/backend/test/fixtures/users.json
vendored
@ -3,18 +3,21 @@
|
|||||||
"id": "59f4355d-9549-4aac-9b54-eeefceeabef0",
|
"id": "59f4355d-9549-4aac-9b54-eeefceeabef0",
|
||||||
"name": "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2",
|
"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": "0xbe0eb53f46cd790cd13851d5eff43d12404d33e8",
|
"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": "0x8315177ab297ba92a06054ce80a67ed4dbd7ed3a",
|
"name": "0x8315177ab297ba92a06054ce80a67ed4dbd7ed3a",
|
||||||
"email": "bob@snowballtools.xyz",
|
"email": "bob@snowballtools.xyz",
|
||||||
"isVerified": true
|
"isVerified": true,
|
||||||
|
"ethAddress": "0x8315177ab297ba92a06054ce80a67ed4dbd7ed3a"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
@ -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',
|
||||||
|
@ -1,4 +1,6 @@
|
|||||||
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_WALLET_CONNECT_ID =
|
||||||
|
@ -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": {
|
||||||
|
@ -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;
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
|
@ -24,6 +24,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;
|
||||||
@ -117,7 +118,8 @@ const DeploymentDetailsCard = ({
|
|||||||
</div>
|
</div>
|
||||||
<div className="col-span-2 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)} ^{' '}
|
||||||
|
{formatAddress(deployment.createdBy.name ?? '')}
|
||||||
</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>
|
||||||
);
|
);
|
||||||
|
@ -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>
|
<Typography variant="small">
|
||||||
|
{formatAddress(member.name ?? '')}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<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>
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
export const GIT_TEMPLATE_LINK = `https://github.com/${process.env.REACT_APP_GITHUB_TEMPLATE_REPO}`;
|
export const GIT_TEMPLATE_LINK = `https://github.com/${process.env.REACT_APP_GITHUB_TEMPLATE_REPO}`;
|
||||||
|
|
||||||
export const SHORT_COMMIT_HASH_LENGTH = 8;
|
export const SHORT_COMMIT_HASH_LENGTH = 8;
|
||||||
|
|
||||||
|
export const SERVER_GQL_PATH = 'graphql';
|
||||||
|
125
packages/frontend/src/context/Web3ModalProvider.tsx
Normal file
125
packages/frontend/src/context/Web3ModalProvider.tsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
@ -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,4 +1,4 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } 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 { User } from 'gql-client';
|
||||||
|
|
||||||
@ -7,6 +7,7 @@ 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 { useGQLClient } from '../context/GQLClientContext';
|
||||||
|
import { formatAddress } from '../utils/format';
|
||||||
|
|
||||||
const ProjectSearch = () => {
|
const ProjectSearch = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@ -18,20 +19,6 @@ const ProjectSearch = () => {
|
|||||||
setUser(user);
|
setUser(user);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const formattedAddress = useMemo(() => {
|
|
||||||
const address = user?.name || '';
|
|
||||||
|
|
||||||
if (address.length <= 8) {
|
|
||||||
return address;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (address.startsWith('0x')) {
|
|
||||||
return address.slice(0, 4) + '..' + address.slice(-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
return address;
|
|
||||||
}, [user?.name]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchUser();
|
fetchUser();
|
||||||
}, []);
|
}, []);
|
||||||
@ -57,7 +44,7 @@ const ProjectSearch = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="px-2 py-1 bg-blue-gray-50 rounded-lg flex items-center">
|
<div className="px-2 py-1 bg-blue-gray-50 rounded-lg flex items-center">
|
||||||
{user?.name && (
|
{user?.name && (
|
||||||
<Tooltip content={user.name}>{formattedAddress}</Tooltip>
|
<Tooltip content={user.name}>{formatAddress(user.name)}</Tooltip>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
14
packages/frontend/src/pages/Login.tsx
Normal file
14
packages/frontend/src/pages/Login.tsx
Normal 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;
|
@ -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>
|
||||||
</>
|
</>
|
||||||
|
@ -10,6 +10,7 @@ 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 +123,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 +158,7 @@ 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}
|
{formatAddress(project.deployments[0].createdBy.name ?? '')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
7
packages/frontend/src/utils/format.ts
Normal file
7
packages/frontend/src/utils/format.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
export const formatAddress = (address: string) => {
|
||||||
|
if (address.startsWith('0x') && address.length > 8) {
|
||||||
|
return address.slice(0, 4) + '..' + address.slice(-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
return address;
|
||||||
|
};
|
@ -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'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user