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:
Nabarun Gogoi 2024-02-22 17:26:26 +05:30 committed by GitHub
parent a846531e43
commit ef0eac8293
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 2970 additions and 177 deletions

View File

@ -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

View 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

View File

@ -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"

View File

@ -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",

View File

@ -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;
}

View File

@ -3,8 +3,3 @@ import process from 'process';
export const DEFAULT_CONFIG_FILE_PATH = process.env.SNOWBALL_BACKEND_CONFIG_FILE_PATH || 'environments/local.toml';
export const DEFAULT_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';

View File

@ -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);
}

View File

@ -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;

View File

@ -12,7 +12,7 @@ import {
import { User } from './User';
import { Organization } from './Organization';
enum Role {
export enum Role {
Owner = 'Owner',
Maintainer = 'Maintainer',
Reader = 'Reader',

View File

@ -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);

View File

@ -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;

View File

@ -0,0 +1,41 @@
import { Router } from 'express';
import { SiweMessage, generateNonce } from 'siwe';
const router = Router();
router.get('/nonce', async (_, res) => {
res.send(generateNonce());
});
router.post('/validate', async (req, res) => {
const { message, signature } = req.body;
const { success, data } = await new SiweMessage(message).verify({
signature
});
if (success) {
req.session.address = data.address;
req.session.chainId = data.chainId;
}
res.send({ success });
});
router.get('/session', (req, res) => {
if (req.session.address && req.session.chainId) {
res.send({ address: req.session.address, chainId: req.session.chainId });
} else {
res.status(401).send({ error: 'Unauthorized: No active session' });
}
});
router.post('/logout', (req, res) => {
req.session.destroy((err) => {
if (err) {
return res.send({ success: false });
}
res.send({ success: true });
});
});
export default router;

View File

@ -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, () => {

View File

@ -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);
}
}

View File

@ -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"
}
]

View File

@ -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',

View File

@ -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 =

View File

@ -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": {

View File

@ -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;

View File

@ -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>
);

View File

@ -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>

View File

@ -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>
);

View File

@ -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>

View File

@ -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';

View 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>
);
}

View File

@ -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>
<GQLClientProvider client={gqlClient}>
<App />
<Toaster position="bottom-center" />
</GQLClientProvider>
<Web3ModalProvider>
<GQLClientProvider client={gqlClient}>
<App />
<Toaster position="bottom-center" />
</GQLClientProvider>
</Web3ModalProvider>
</ThemeProvider>
</React.StrictMode>,
);

View File

@ -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>

View File

@ -0,0 +1,14 @@
import React from 'react';
const Login = () => {
return (
<div className="grid grid-cols-5 h-screen bg-light-blue-50 py-10">
<div className="col-span-2"></div>
<div className="col-span-1">
<w3m-button />
</div>
</div>
);
};
export default Login;

View File

@ -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">
<Outlet />
<OctokitProvider>
<Outlet />
</OctokitProvider>
</div>
</div>
</>

View File

@ -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>
</>

View 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;
};

View File

@ -27,7 +27,8 @@ export class GQLClient {
this.client = new ApolloClient({
uri: config.gqlEndpoint,
cache: new InMemoryCache(),
defaultOptions
defaultOptions,
credentials: 'include'
});
}

2561
yarn.lock

File diff suppressed because it is too large Load Diff