forked from cerc-io/snowballtools-base
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`
|
||||
|
||||
```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
|
||||
|
@ -10,9 +10,10 @@ if [[ -d "$DEST_DIR" ]]; then
|
||||
fi
|
||||
|
||||
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_TEMPLATE_REPO = 'LACONIC_HOSTED_CONFIG_app_github_templaterepo'
|
||||
REACT_APP_WALLET_CONNECT_ID = 'LACONIC_HOSTED_CONFIG_app_wallet_connect_id'
|
||||
EOF
|
||||
|
||||
yarn || exit 1
|
||||
|
@ -2,6 +2,11 @@
|
||||
host = "127.0.0.1"
|
||||
port = 8000
|
||||
gqlPath = "/graphql"
|
||||
[server.session]
|
||||
secret = "p4yfpkqnddkui2iw7t6hbhwq74lbqs7bhobvmfhrowoi"
|
||||
appOriginUrl = 'http://localhost:3000'
|
||||
trustProxy = false
|
||||
domain = "localhost"
|
||||
|
||||
[database]
|
||||
dbPath = "db/snowball"
|
||||
@ -22,4 +27,7 @@
|
||||
[registryConfig.fee]
|
||||
amount = "200000"
|
||||
denom = "aphoton"
|
||||
gas = "550000"
|
||||
gas = "750000"
|
||||
|
||||
[misc]
|
||||
projectDomain = "apps.snowballtools.com"
|
||||
|
@ -12,8 +12,10 @@
|
||||
"@types/node": "^20.11.0",
|
||||
"apollo-server-core": "^3.13.0",
|
||||
"apollo-server-express": "^3.13.0",
|
||||
"cors": "^2.8.5",
|
||||
"debug": "^4.3.1",
|
||||
"express": "^4.18.2",
|
||||
"express-session": "^1.18.0",
|
||||
"fs-extra": "^11.2.0",
|
||||
"graphql": "^16.8.1",
|
||||
"luxon": "^3.4.4",
|
||||
@ -42,6 +44,7 @@
|
||||
"test:db:delete": "DEBUG=snowball:* ts-node ./test/delete-db.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express-session": "^1.17.10",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@typescript-eslint/eslint-plugin": "^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 {
|
||||
host: string;
|
||||
port: number;
|
||||
gqlPath?: string;
|
||||
sessionSecret: string;
|
||||
appOriginUrl: string;
|
||||
isProduction: boolean;
|
||||
session: SessionConfig;
|
||||
}
|
||||
|
||||
export interface DatabaseConfig {
|
||||
@ -30,9 +41,14 @@ export interface RegistryConfig {
|
||||
}
|
||||
}
|
||||
|
||||
export interface MiscConfig {
|
||||
projectDomain: string;
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
server: ServerConfig;
|
||||
database: DatabaseConfig;
|
||||
gitHub: GitHubConfig;
|
||||
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_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 { lowercase, numbers } from 'nanoid-dictionary';
|
||||
|
||||
import { DatabaseConfig } from './config';
|
||||
import { DatabaseConfig, MiscConfig } from './config';
|
||||
import { User } from './entity/User';
|
||||
import { Organization } from './entity/Organization';
|
||||
import { Project } from './entity/Project';
|
||||
@ -13,13 +13,10 @@ import { Deployment } from './entity/Deployment';
|
||||
import { ProjectMember } from './entity/ProjectMember';
|
||||
import { EnvironmentVariable } from './entity/EnvironmentVariable';
|
||||
import { Domain } from './entity/Domain';
|
||||
import { PROJECT_DOMAIN } from './constants';
|
||||
import { getEntities, loadAndSaveData } from './utils';
|
||||
import { UserOrganization } from './entity/UserOrganization';
|
||||
|
||||
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');
|
||||
|
||||
@ -28,8 +25,9 @@ const nanoid = customAlphabet(lowercase + numbers, 8);
|
||||
// TODO: Fix order of methods
|
||||
export class Database {
|
||||
private dataSource: DataSource;
|
||||
private projectDomain: string;
|
||||
|
||||
constructor ({ dbPath }: DatabaseConfig) {
|
||||
constructor ({ dbPath } : DatabaseConfig, { projectDomain } : MiscConfig) {
|
||||
this.dataSource = new DataSource({
|
||||
type: 'better-sqlite3',
|
||||
database: dbPath,
|
||||
@ -37,6 +35,8 @@ export class Database {
|
||||
synchronize: true,
|
||||
logging: false
|
||||
});
|
||||
|
||||
this.projectDomain = projectDomain;
|
||||
}
|
||||
|
||||
async init (): Promise<void> {
|
||||
@ -45,21 +45,10 @@ export class Database {
|
||||
|
||||
const organizations = await this.getOrganizations({});
|
||||
|
||||
// Load an organization if none exist
|
||||
if (!organizations.length) {
|
||||
const orgEntities = await getEntities(path.resolve(__dirname, ORGANIZATION_DATA_PATH));
|
||||
const savedOrgs = 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);
|
||||
await loadAndSaveData(Organization, this.dataSource, [orgEntities[0]]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -77,9 +66,9 @@ export class Database {
|
||||
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 updateResult = await userRepository.update({ id: userId }, data);
|
||||
const updateResult = await userRepository.update({ id: user.id }, data);
|
||||
assert(updateResult.affected);
|
||||
|
||||
return updateResult.affected > 0;
|
||||
@ -115,6 +104,13 @@ export class Database {
|
||||
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[]> {
|
||||
const projectRepository = this.dataSource.getRepository(Project);
|
||||
const projects = await projectRepository.find(options);
|
||||
@ -361,7 +357,7 @@ export class Database {
|
||||
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);
|
||||
|
||||
// TODO: Check if organization exists
|
||||
@ -371,15 +367,13 @@ export class Database {
|
||||
// TODO: Set icon according to framework
|
||||
newProject.icon = '';
|
||||
|
||||
newProject.owner = Object.assign(new User(), {
|
||||
id: userId
|
||||
});
|
||||
newProject.owner = user;
|
||||
|
||||
newProject.organization = Object.assign(new Organization(), {
|
||||
id: organizationId
|
||||
});
|
||||
|
||||
newProject.subDomain = `${newProject.name}.${PROJECT_DOMAIN}`;
|
||||
newProject.subDomain = `${newProject.name}.${this.projectDomain}`;
|
||||
|
||||
return projectRepository.save(newProject);
|
||||
}
|
||||
|
@ -12,10 +12,15 @@ import { UserOrganization } from './UserOrganization';
|
||||
|
||||
@Entity()
|
||||
@Unique(['email'])
|
||||
@Unique(['ethAddress'])
|
||||
export class User {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
// TODO: Set ethAddress as ID
|
||||
@Column()
|
||||
ethAddress!: string;
|
||||
|
||||
@Column('varchar', { length: 255, nullable: true })
|
||||
name!: string | null;
|
||||
|
||||
|
@ -12,7 +12,7 @@ import {
|
||||
import { User } from './User';
|
||||
import { Organization } from './Organization';
|
||||
|
||||
enum Role {
|
||||
export enum Role {
|
||||
Owner = 'Owner',
|
||||
Maintainer = 'Maintainer',
|
||||
Reader = 'Reader',
|
||||
|
@ -19,7 +19,7 @@ const OAUTH_CLIENT_TYPE = 'oauth-app';
|
||||
|
||||
export const main = async (): Promise<void> => {
|
||||
// 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({
|
||||
clientType: OAUTH_CLIENT_TYPE,
|
||||
@ -27,7 +27,7 @@ export const main = async (): Promise<void> => {
|
||||
clientSecret: gitHub.oAuth.clientSecret
|
||||
});
|
||||
|
||||
const db = new Database(database);
|
||||
const db = new Database(database, misc);
|
||||
await db.init();
|
||||
|
||||
const registry = new Registry(registryConfig);
|
||||
|
@ -14,11 +14,11 @@ export const createResolvers = async (service: Service): Promise<any> => {
|
||||
Query: {
|
||||
// TODO: add custom type for context
|
||||
user: (_: any, __: any, context: any) => {
|
||||
return service.getUser(context.userId);
|
||||
return context.user;
|
||||
},
|
||||
|
||||
organizations: async (_:any, __: any, context: any) => {
|
||||
return service.getOrganizationsByUserId(context.userId);
|
||||
return service.getOrganizationsByUserId(context.user);
|
||||
},
|
||||
|
||||
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) => {
|
||||
return service.getProjectsInOrganization(context.userId, organizationSlug);
|
||||
return service.getProjectsInOrganization(context.user, organizationSlug);
|
||||
},
|
||||
|
||||
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) => {
|
||||
return service.searchProjects(context.userId, searchText);
|
||||
return service.searchProjects(context.user, searchText);
|
||||
},
|
||||
|
||||
domains: async (_:any, { projectId, filter }: { projectId: string, filter?: FindOptionsWhere<Domain> }) => {
|
||||
@ -54,7 +54,7 @@ export const createResolvers = async (service: Service): Promise<any> => {
|
||||
Mutation: {
|
||||
removeProjectMember: async (_: any, { projectMemberId }: { projectMemberId: string }, context: any) => {
|
||||
try {
|
||||
return await service.removeProjectMember(context.userId, projectMemberId);
|
||||
return await service.removeProjectMember(context.user, projectMemberId);
|
||||
} catch (err) {
|
||||
log(err);
|
||||
return false;
|
||||
@ -119,7 +119,7 @@ export const createResolvers = async (service: Service): Promise<any> => {
|
||||
|
||||
updateDeploymentToProd: async (_: any, { deploymentId }: { deploymentId: string }, context: any) => {
|
||||
try {
|
||||
return Boolean(await service.updateDeploymentToProd(context.userId, deploymentId));
|
||||
return Boolean(await service.updateDeploymentToProd(context.user, deploymentId));
|
||||
} catch (err) {
|
||||
log(err);
|
||||
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) => {
|
||||
try {
|
||||
return await service.addProject(context.userId, organizationSlug, data);
|
||||
return await service.addProject(context.user, organizationSlug, data);
|
||||
} catch (err) {
|
||||
log(err);
|
||||
throw err;
|
||||
@ -146,7 +146,7 @@ export const createResolvers = async (service: Service): Promise<any> => {
|
||||
|
||||
redeployToProd: async (_: any, { deploymentId }: { deploymentId: string }, context: any) => {
|
||||
try {
|
||||
return Boolean(await service.redeployToProd(context.userId, deploymentId));
|
||||
return Boolean(await service.redeployToProd(context.user, deploymentId));
|
||||
} catch (err) {
|
||||
log(err);
|
||||
return false;
|
||||
@ -199,7 +199,7 @@ export const createResolvers = async (service: Service): Promise<any> => {
|
||||
|
||||
authenticateGitHub: async (_: any, { code }: { code: string }, context: any) => {
|
||||
try {
|
||||
return await service.authenticateGitHub(code, context.userId);
|
||||
return await service.authenticateGitHub(code, context.user);
|
||||
} catch (err) {
|
||||
log(err);
|
||||
return false;
|
||||
@ -208,7 +208,7 @@ export const createResolvers = async (service: Service): Promise<any> => {
|
||||
|
||||
unauthenticateGitHub: async (_: any, __: object, context: any) => {
|
||||
try {
|
||||
return service.unauthenticateGitHub(context.userId, { gitHubToken: null });
|
||||
return service.unauthenticateGitHub(context.user, { gitHubToken: null });
|
||||
} catch (err) {
|
||||
log(err);
|
||||
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 express from 'express';
|
||||
import cors from 'cors';
|
||||
import { ApolloServer } from 'apollo-server-express';
|
||||
import { createServer } from 'http';
|
||||
import {
|
||||
ApolloServerPluginDrainHttpServer,
|
||||
ApolloServerPluginLandingPageLocalDefault
|
||||
ApolloServerPluginLandingPageLocalDefault,
|
||||
AuthenticationError
|
||||
} from 'apollo-server-core';
|
||||
import session from 'express-session';
|
||||
|
||||
import { TypeSource } from '@graphql-tools/utils';
|
||||
import { makeExecutableSchema } from '@graphql-tools/schema';
|
||||
|
||||
import { ServerConfig } from './config';
|
||||
import { DEFAULT_GQL_PATH, USER_ID } from './constants';
|
||||
import { DEFAULT_GQL_PATH } from './constants';
|
||||
import githubRouter from './routes/github';
|
||||
import authRouter from './routes/auth';
|
||||
import { Service } from './service';
|
||||
|
||||
const log = debug('snowball:server');
|
||||
|
||||
declare module 'express-session' {
|
||||
interface SessionData {
|
||||
address: string;
|
||||
chainId: number;
|
||||
}
|
||||
}
|
||||
|
||||
export const createAndStartServer = async (
|
||||
serverConfig: ServerConfig,
|
||||
typeDefs: TypeSource,
|
||||
@ -24,6 +35,7 @@ export const createAndStartServer = async (
|
||||
service: Service
|
||||
): Promise<ApolloServer> => {
|
||||
const { host, port, gqlPath = DEFAULT_GQL_PATH } = serverConfig;
|
||||
const { appOriginUrl, secret, domain, trustProxy } = serverConfig.session;
|
||||
|
||||
const app = express();
|
||||
|
||||
@ -39,9 +51,19 @@ export const createAndStartServer = async (
|
||||
const server = new ApolloServer({
|
||||
schema,
|
||||
csrfPrevention: true,
|
||||
context: () => {
|
||||
// TODO: Use userId derived from auth token
|
||||
return { userId: USER_ID };
|
||||
context: async ({ req }) => {
|
||||
// https://www.apollographql.com/docs/apollo-server/v3/security/authentication#api-wide-authorization
|
||||
|
||||
const { address } = req.session;
|
||||
|
||||
if (!address) {
|
||||
throw new AuthenticationError('Unauthorized: No active session');
|
||||
}
|
||||
|
||||
// Find/create user from ETH address in request session
|
||||
const user = await service.loadOrCreateUser(address);
|
||||
|
||||
return { user };
|
||||
},
|
||||
plugins: [
|
||||
// Proper shutdown for the HTTP server
|
||||
@ -52,13 +74,50 @@ export const createAndStartServer = async (
|
||||
|
||||
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({
|
||||
app,
|
||||
path: gqlPath
|
||||
path: gqlPath,
|
||||
cors: {
|
||||
origin: [appOriginUrl],
|
||||
credentials: true
|
||||
}
|
||||
});
|
||||
|
||||
app.set('service', service);
|
||||
app.use(express.json());
|
||||
|
||||
app.set('service', service);
|
||||
app.use('/auth', authRouter);
|
||||
app.use('/api/github', githubRouter);
|
||||
|
||||
httpServer.listen(port, host, () => {
|
||||
|
@ -16,6 +16,7 @@ import { User } from './entity/User';
|
||||
import { Registry } from './registry';
|
||||
import { GitHubConfig, RegistryConfig } from './config';
|
||||
import { AppDeploymentRecord, GitPushEventPayload, PackageJSON } from './types';
|
||||
import { Role } from './entity/UserOrganization';
|
||||
|
||||
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> {
|
||||
const user = await this.db.getUser({ where: { id: userId } });
|
||||
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 });
|
||||
}
|
||||
|
||||
async getOrganizationsByUserId (userId: string): Promise<Organization[]> {
|
||||
const dbOrganizations = await this.db.getOrganizationsByUserId(userId);
|
||||
async getOrganizationsByUserId (user: User): Promise<Organization[]> {
|
||||
const dbOrganizations = await this.db.getOrganizationsByUserId(user.id);
|
||||
return dbOrganizations;
|
||||
}
|
||||
|
||||
@ -165,8 +196,8 @@ export class Service {
|
||||
return dbProject;
|
||||
}
|
||||
|
||||
async getProjectsInOrganization (userId:string, organizationSlug: string): Promise<Project[]> {
|
||||
const dbProjects = await this.db.getProjectsInOrganization(userId, organizationSlug);
|
||||
async getProjectsInOrganization (user: User, organizationSlug: string): Promise<Project[]> {
|
||||
const dbProjects = await this.db.getProjectsInOrganization(user.id, organizationSlug);
|
||||
return dbProjects;
|
||||
}
|
||||
|
||||
@ -185,8 +216,8 @@ export class Service {
|
||||
return dbProjectMembers;
|
||||
}
|
||||
|
||||
async searchProjects (userId: string, searchText: string): Promise<Project[]> {
|
||||
const dbProjects = await this.db.getProjectsBySearchText(userId, searchText);
|
||||
async searchProjects (user: User, searchText: string): Promise<Project[]> {
|
||||
const dbProjects = await this.db.getProjectsBySearchText(user.id, searchText);
|
||||
return dbProjects;
|
||||
}
|
||||
|
||||
@ -231,17 +262,17 @@ export class Service {
|
||||
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);
|
||||
|
||||
if (String(member.member.id) === userId) {
|
||||
if (String(member.member.id) === user.id) {
|
||||
throw new Error('Invalid operation: cannot remove self');
|
||||
}
|
||||
|
||||
const memberProject = member.project;
|
||||
assert(memberProject);
|
||||
|
||||
if (String(userId) === String(memberProject.owner.id)) {
|
||||
if (String(user.id) === String(memberProject.owner.id)) {
|
||||
return this.db.removeProjectMemberById(projectMemberId);
|
||||
} else {
|
||||
throw new Error('Invalid operation: not authorized');
|
||||
@ -274,7 +305,7 @@ export class Service {
|
||||
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({
|
||||
where: { id: deploymentId },
|
||||
relations: {
|
||||
@ -288,9 +319,9 @@ export class Service {
|
||||
|
||||
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,
|
||||
{
|
||||
project: oldDeployment.project,
|
||||
@ -393,7 +424,7 @@ export class Service {
|
||||
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({
|
||||
where: {
|
||||
slug: organizationSlug
|
||||
@ -403,9 +434,9 @@ export class Service {
|
||||
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 { data: [latestCommit] } = await octokit.rest.repos.listCommits({
|
||||
@ -418,7 +449,7 @@ export class Service {
|
||||
const { data: repoDetails } = await octokit.rest.repos.get({ owner, repo });
|
||||
|
||||
// Create deployment with prod branch and latest commit
|
||||
await this.createDeployment(userId,
|
||||
await this.createDeployment(user.id,
|
||||
octokit,
|
||||
{
|
||||
project,
|
||||
@ -518,7 +549,7 @@ export class Service {
|
||||
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({
|
||||
relations: {
|
||||
project: true,
|
||||
@ -534,9 +565,9 @@ export class Service {
|
||||
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,
|
||||
{
|
||||
project: oldDeployment.project,
|
||||
@ -660,17 +691,17 @@ export class Service {
|
||||
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({
|
||||
code
|
||||
});
|
||||
|
||||
await this.db.updateUser(userId, { gitHubToken: token });
|
||||
await this.db.updateUser(user, { gitHubToken: token });
|
||||
|
||||
return { token };
|
||||
}
|
||||
|
||||
async unauthenticateGitHub (userId: string, data: DeepPartial<User>): Promise<boolean> {
|
||||
return this.db.updateUser(userId, data);
|
||||
async unauthenticateGitHub (user: User, data: DeepPartial<User>): Promise<boolean> {
|
||||
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",
|
||||
"name": "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2",
|
||||
"email": "snowball@snowballtools.xyz",
|
||||
"isVerified": true
|
||||
"isVerified": true,
|
||||
"ethAddress": "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2"
|
||||
},
|
||||
{
|
||||
"id": "e505b212-8da6-48b2-9614-098225dab34b",
|
||||
"name": "0xbe0eb53f46cd790cd13851d5eff43d12404d33e8",
|
||||
"email": "alice@snowballtools.xyz",
|
||||
"isVerified": true
|
||||
"isVerified": true,
|
||||
"ethAddress": "0xbe0eb53f46cd790cd13851d5eff43d12404d33e8"
|
||||
},
|
||||
{
|
||||
"id": "cd892fad-9138-4aa2-a62c-414a32776ea7",
|
||||
"name": "0x8315177ab297ba92a06054ce80a67ed4dbd7ed3a",
|
||||
"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 { 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 { Deployment, DeploymentStatus } from '../src/entity/Deployment';
|
||||
|
||||
const log = debug('snowball:publish-deploy-records');
|
||||
|
||||
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);
|
||||
|
||||
@ -36,7 +36,7 @@ async function main () {
|
||||
});
|
||||
|
||||
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 = {
|
||||
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_TEMPLATE_REPO =
|
||||
|
||||
REACT_APP_WALLET_CONNECT_ID =
|
||||
|
@ -4,6 +4,7 @@
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@material-tailwind/react": "^2.1.7",
|
||||
"@tanstack/react-query": "^5.22.2",
|
||||
"@testing-library/jest-dom": "^5.17.0",
|
||||
"@testing-library/react": "^13.4.0",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
@ -11,7 +12,10 @@
|
||||
"@types/node": "^16.18.68",
|
||||
"@types/react": "^18.2.42",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@web3modal/siwe": "^4.0.5",
|
||||
"@web3modal/wagmi": "^4.0.5",
|
||||
"assert": "^2.1.0",
|
||||
"axios": "^1.6.7",
|
||||
"date-fns": "^3.0.1",
|
||||
"downshift": "^8.2.3",
|
||||
"eslint-config-react-app": "^7.0.1",
|
||||
@ -29,9 +33,12 @@
|
||||
"react-router-dom": "^6.20.1",
|
||||
"react-scripts": "5.0.1",
|
||||
"react-timer-hook": "^3.0.7",
|
||||
"siwe": "^2.1.4",
|
||||
"typescript": "^4.9.5",
|
||||
"usehooks-ts": "^2.10.0",
|
||||
"vertical-stepper-nav": "^1.0.2",
|
||||
"viem": "^2.7.11",
|
||||
"wagmi": "^2.5.7",
|
||||
"web-vitals": "^2.1.4"
|
||||
},
|
||||
"scripts": {
|
||||
|
@ -9,8 +9,8 @@ import {
|
||||
projectsRoutesWithoutSearch,
|
||||
} from './pages/org-slug/projects/routes';
|
||||
import ProjectSearchLayout from './layouts/ProjectSearch';
|
||||
import { OctokitProvider } from './context/OctokitContext';
|
||||
import Index from './pages';
|
||||
import Login from './pages/Login';
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
@ -44,14 +44,14 @@ const router = createBrowserRouter([
|
||||
path: '/',
|
||||
element: <Index />,
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
element: <Login />,
|
||||
},
|
||||
]);
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<OctokitProvider>
|
||||
<RouterProvider router={router} />
|
||||
</OctokitProvider>
|
||||
);
|
||||
return <RouterProvider router={router} />;
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
@ -3,6 +3,7 @@ import { Link, NavLink, useNavigate, useParams } from 'react-router-dom';
|
||||
import { Organization } from 'gql-client';
|
||||
|
||||
import { Typography, Option } from '@material-tailwind/react';
|
||||
import { useDisconnect } from 'wagmi';
|
||||
|
||||
import { useGQLClient } from '../context/GQLClientContext';
|
||||
import AsyncSelect from './shared/AsyncSelect';
|
||||
@ -11,6 +12,7 @@ const Sidebar = () => {
|
||||
const { orgSlug } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const client = useGQLClient();
|
||||
const { disconnect } = useDisconnect();
|
||||
|
||||
const [selectedOrgSlug, setSelectedOrgSlug] = useState(orgSlug);
|
||||
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
||||
@ -25,6 +27,11 @@ const Sidebar = () => {
|
||||
setSelectedOrgSlug(orgSlug);
|
||||
}, [orgSlug]);
|
||||
|
||||
const handleLogOut = useCallback(() => {
|
||||
disconnect();
|
||||
navigate('/login');
|
||||
}, [disconnect, navigate]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full p-4">
|
||||
<div className="grow">
|
||||
@ -76,8 +83,11 @@ const Sidebar = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="grow flex flex-col justify-end">
|
||||
<div>Documentation</div>
|
||||
<div>Support</div>
|
||||
<a className="cursor-pointer" onClick={handleLogOut}>
|
||||
Log Out
|
||||
</a>
|
||||
<a className="cursor-pointer">Documentation</a>
|
||||
<a className="cursor-pointer">Support</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -24,6 +24,7 @@ import DeploymentDialogBodyCard from './DeploymentDialogBodyCard';
|
||||
import AssignDomainDialog from './AssignDomainDialog';
|
||||
import { useGQLClient } from '../../../../context/GQLClientContext';
|
||||
import { SHORT_COMMIT_HASH_LENGTH } from '../../../../constants';
|
||||
import { formatAddress } from '../../../../utils/format';
|
||||
|
||||
interface DeployDetailsCardProps {
|
||||
deployment: Deployment;
|
||||
@ -117,7 +118,8 @@ const DeploymentDetailsCard = ({
|
||||
</div>
|
||||
<div className="col-span-2 flex items-center">
|
||||
<Typography color="gray" className="grow">
|
||||
^ {relativeTimeMs(deployment.createdAt)} ^ {deployment.createdBy.name}
|
||||
^ {relativeTimeMs(deployment.createdAt)} ^{' '}
|
||||
{formatAddress(deployment.createdBy.name ?? '')}
|
||||
</Typography>
|
||||
<Menu placement="bottom-start">
|
||||
<MenuHandler>
|
||||
|
@ -5,6 +5,7 @@ import { Typography, Chip, Card } from '@material-tailwind/react';
|
||||
import { color } from '@material-tailwind/react/types/components/chip';
|
||||
import { relativeTimeMs } from '../../../../utils/time';
|
||||
import { SHORT_COMMIT_HASH_LENGTH } from '../../../../constants';
|
||||
import { formatAddress } from '../../../../utils/format';
|
||||
|
||||
interface DeploymentDialogBodyCardProps {
|
||||
deployment: Deployment;
|
||||
@ -39,7 +40,8 @@ const DeploymentDialogBodyCard = ({
|
||||
{deployment.commitMessage}
|
||||
</Typography>
|
||||
<Typography variant="small">
|
||||
^ {relativeTimeMs(deployment.createdAt)} ^ {deployment.createdBy.name}
|
||||
^ {relativeTimeMs(deployment.createdAt)} ^{' '}
|
||||
{formatAddress(deployment.createdBy.name ?? '')}
|
||||
</Typography>
|
||||
</Card>
|
||||
);
|
||||
|
@ -7,9 +7,11 @@ import {
|
||||
Option,
|
||||
Chip,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
} from '@material-tailwind/react';
|
||||
|
||||
import ConfirmDialog from '../../../shared/ConfirmDialog';
|
||||
import { formatAddress } from '../../../../utils/format';
|
||||
|
||||
const PERMISSION_OPTIONS = [
|
||||
{
|
||||
@ -48,6 +50,7 @@ const MemberCard = ({
|
||||
onRemoveProjectMember,
|
||||
onUpdateProjectMember,
|
||||
}: MemberCardProps) => {
|
||||
const [ethAddress, emailDomain] = member.email.split('@');
|
||||
const [selectedPermission, setSelectedPermission] = useState(
|
||||
permissions.join('+'),
|
||||
);
|
||||
@ -79,8 +82,16 @@ const MemberCard = ({
|
||||
>
|
||||
<div>^</div>
|
||||
<div className="basis-1/2">
|
||||
{member.name && <Typography variant="small">{member.name}</Typography>}
|
||||
<Typography variant="small">{member.email}</Typography>
|
||||
{member.name && (
|
||||
<Typography variant="small">
|
||||
{formatAddress(member.name ?? '')}
|
||||
</Typography>
|
||||
)}
|
||||
<Tooltip content={member.email}>
|
||||
<p>
|
||||
{formatAddress(ethAddress)}@{emailDomain}
|
||||
</p>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="basis-1/2">
|
||||
{!isPending ? (
|
||||
@ -142,8 +153,9 @@ const MemberCard = ({
|
||||
color="red"
|
||||
>
|
||||
<Typography variant="small">
|
||||
Once removed, {member.name} ({member.email}) will not be able to
|
||||
access this project.
|
||||
Once removed, {formatAddress(member.name ?? '')} (
|
||||
{formatAddress(ethAddress)}@{emailDomain}) will not be able to access
|
||||
this project.
|
||||
</Typography>
|
||||
</ConfirmDialog>
|
||||
</div>
|
||||
|
@ -1,3 +1,5 @@
|
||||
export const GIT_TEMPLATE_LINK = `https://github.com/${process.env.REACT_APP_GITHUB_TEMPLATE_REPO}`;
|
||||
|
||||
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 reportWebVitals from './reportWebVitals';
|
||||
import { GQLClientProvider } from './context/GQLClientContext';
|
||||
import Web3ModalProvider from './context/Web3ModalProvider';
|
||||
import { SERVER_GQL_PATH } from './constants';
|
||||
|
||||
const root = ReactDOM.createRoot(
|
||||
document.getElementById('root') as HTMLElement,
|
||||
);
|
||||
|
||||
const gqlEndpoint = process.env.REACT_APP_GQL_SERVER_URL;
|
||||
assert(gqlEndpoint, 'GQL server URL not provided');
|
||||
assert(
|
||||
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 });
|
||||
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<ThemeProvider>
|
||||
<Web3ModalProvider>
|
||||
<GQLClientProvider client={gqlClient}>
|
||||
<App />
|
||||
<Toaster position="bottom-center" />
|
||||
</GQLClientProvider>
|
||||
</Web3ModalProvider>
|
||||
</ThemeProvider>
|
||||
</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 { User } from 'gql-client';
|
||||
|
||||
@ -7,6 +7,7 @@ import { IconButton, Tooltip, Typography } from '@material-tailwind/react';
|
||||
import HorizontalLine from '../components/HorizontalLine';
|
||||
import ProjectSearchBar from '../components/projects/ProjectSearchBar';
|
||||
import { useGQLClient } from '../context/GQLClientContext';
|
||||
import { formatAddress } from '../utils/format';
|
||||
|
||||
const ProjectSearch = () => {
|
||||
const navigate = useNavigate();
|
||||
@ -18,20 +19,6 @@ const ProjectSearch = () => {
|
||||
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(() => {
|
||||
fetchUser();
|
||||
}, []);
|
||||
@ -57,7 +44,7 @@ const ProjectSearch = () => {
|
||||
</div>
|
||||
<div className="px-2 py-1 bg-blue-gray-50 rounded-lg flex items-center">
|
||||
{user?.name && (
|
||||
<Tooltip content={user.name}>{formattedAddress}</Tooltip>
|
||||
<Tooltip content={user.name}>{formatAddress(user.name)}</Tooltip>
|
||||
)}
|
||||
</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 Sidebar from '../components/Sidebar';
|
||||
import { OctokitProvider } from '../context/OctokitContext';
|
||||
|
||||
const OrgSlug = () => {
|
||||
return (
|
||||
@ -12,7 +13,9 @@ const OrgSlug = () => {
|
||||
</div>
|
||||
<div className="col-span-4 h-full p-3 overflow-y-hidden">
|
||||
<div className="bg-white rounded-3xl h-full overflow-y-auto">
|
||||
<OctokitProvider>
|
||||
<Outlet />
|
||||
</OctokitProvider>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
@ -10,6 +10,7 @@ import { relativeTimeMs } from '../../../../utils/time';
|
||||
import { useOctokit } from '../../../../context/OctokitContext';
|
||||
import { GitCommitWithBranch, OutletContextType } from '../../../../types';
|
||||
import { useGQLClient } from '../../../../context/GQLClientContext';
|
||||
import { formatAddress } from '../../../../utils/format';
|
||||
|
||||
const COMMITS_PER_PAGE = 4;
|
||||
|
||||
@ -122,9 +123,9 @@ const OverviewTabPanel = () => {
|
||||
color="green"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex justify-between items-center w-full m-2">
|
||||
<div className="flex items-center">
|
||||
<Chip
|
||||
className="normal-case inline font-normal"
|
||||
className="normal-case inline font-normal mx-2"
|
||||
size="sm"
|
||||
value="Not connected"
|
||||
icon="^"
|
||||
@ -157,7 +158,7 @@ const OverviewTabPanel = () => {
|
||||
<p>^ Created</p>
|
||||
<p>
|
||||
{relativeTimeMs(project.deployments[0].createdAt)} by ^{' '}
|
||||
{project.deployments[0].createdBy.name}
|
||||
{formatAddress(project.deployments[0].createdBy.name ?? '')}
|
||||
</p>
|
||||
</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({
|
||||
uri: config.gqlEndpoint,
|
||||
cache: new InMemoryCache(),
|
||||
defaultOptions
|
||||
defaultOptions,
|
||||
credentials: 'include'
|
||||
});
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user