From b8529eda5d3328fadcebff97ae75287187f6d9f3 Mon Sep 17 00:00:00 2001 From: Derek Worthen Date: Mon, 4 Dec 2023 06:55:44 -0800 Subject: [PATCH] Update auth flow - Separate out user login and joining communities to allow for joining multiple communities. - Submit GH request to join from UI. Addresses gov4git/gov4git#118 --- .changeset/dry-trains-behave.md | 10 + .vscode/launch.json | 9 +- migrations/0003_married_winter_soldier.sql | 4 + migrations/meta/0003_snapshot.json | 398 ++++++++++++++++++ migrations/meta/_journal.json | 7 + src/electron/db/migrate.ts | 4 +- src/electron/db/schema.ts | 15 +- src/electron/main.ts | 26 +- src/electron/services/CommunityService.ts | 72 +++- src/electron/services/GitService.ts | 115 ++++- src/electron/services/SettingsService.ts | 72 ++-- src/electron/services/UserCommunityService.ts | 214 ++++++++++ src/electron/services/UserService.ts | 208 ++------- src/electron/services/ValidationService.ts | 61 +++ src/electron/services/index.ts | 2 + src/renderer/src/App/Router.tsx | 12 + .../src/components/CommunityJoin.styles.ts | 22 + src/renderer/src/components/CommunityJoin.tsx | 143 +++++++ src/renderer/src/components/DataLoader.tsx | 22 +- src/renderer/src/components/IssueBallot.tsx | 22 +- src/renderer/src/components/Login.styles.ts | 22 + src/renderer/src/components/Login.tsx | 149 +++++++ src/renderer/src/components/RequireAuth.tsx | 57 ++- src/renderer/src/components/SettingsForm.tsx | 6 +- src/renderer/src/components/SiteNav.tsx | 6 +- src/renderer/src/components/UserBadge.tsx | 4 +- src/renderer/src/components/index.ts | 2 + src/renderer/src/hooks/useCatchError.ts | 8 +- src/renderer/src/lib/eventBus.ts | 9 +- .../src/pages/CommunityJoin.styles.ts | 18 + src/renderer/src/pages/CommunityJoin.tsx | 25 ++ src/renderer/src/pages/Login.styles.ts | 18 + src/renderer/src/pages/Login.tsx | 24 +- src/renderer/src/services/SettingsService.ts | 7 - .../src/services/ValidationService.ts | 8 + src/renderer/src/services/index.ts | 2 +- src/renderer/src/state/community.ts | 15 +- src/renderer/src/state/config.ts | 7 - src/renderer/src/state/user.ts | 4 +- src/renderer/src/styles/badges.ts | 9 + src/renderer/src/styles/index.ts | 2 + src/shared/services/CommunityService.ts | 11 - src/shared/services/Service.ts | 2 + src/shared/services/SettingsService.ts | 26 -- src/shared/services/UserService.ts | 16 +- src/shared/services/ValidationService.ts | 3 + src/shared/services/index.ts | 3 +- test/BallotService.test.ts | 31 +- test/CommunityService.test.ts | 21 +- test/SettingsService.test.ts | 7 +- test/testRunner.test.ts | 24 +- 51 files changed, 1584 insertions(+), 400 deletions(-) create mode 100644 .changeset/dry-trains-behave.md create mode 100644 migrations/0003_married_winter_soldier.sql create mode 100644 migrations/meta/0003_snapshot.json create mode 100644 src/electron/services/UserCommunityService.ts create mode 100644 src/electron/services/ValidationService.ts create mode 100644 src/renderer/src/components/CommunityJoin.styles.ts create mode 100644 src/renderer/src/components/CommunityJoin.tsx create mode 100644 src/renderer/src/components/Login.styles.ts create mode 100644 src/renderer/src/components/Login.tsx create mode 100644 src/renderer/src/pages/CommunityJoin.styles.ts create mode 100644 src/renderer/src/pages/CommunityJoin.tsx create mode 100644 src/renderer/src/pages/Login.styles.ts delete mode 100644 src/renderer/src/services/SettingsService.ts create mode 100644 src/renderer/src/services/ValidationService.ts delete mode 100644 src/renderer/src/state/config.ts create mode 100644 src/renderer/src/styles/badges.ts delete mode 100644 src/shared/services/SettingsService.ts create mode 100644 src/shared/services/ValidationService.ts diff --git a/.changeset/dry-trains-behave.md b/.changeset/dry-trains-behave.md new file mode 100644 index 0000000..74f199e --- /dev/null +++ b/.changeset/dry-trains-behave.md @@ -0,0 +1,10 @@ +--- +'gov4git-desktop-app': minor +--- + +Update auth flow + +- Separate out user login and joining communities + to allow for joining multiple communities. +- Submit GH request to join from UI. + Addresses gov4git/gov4git#118 diff --git a/.vscode/launch.json b/.vscode/launch.json index 0111629..5962091 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -25,12 +25,15 @@ "type": "node", "request": "launch", "cwd": "${workspaceFolder}", - "runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron", + "runtimeExecutable": "${workspaceFolder}/node_modules/.bin/dotenv", "windows": { - "runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron.cmd" + "runtimeExecutable": "${workspaceFolder}/node_modules/.bin/dotenv.cmd" }, - "envFile": "${workspaceFolder}/.env", "args": [ + "-e", + ".env", + "--", + "./node_modules/.bin/electron.cmd", ".", "--remote-debugging-port=9222" ], diff --git a/migrations/0003_married_winter_soldier.sql b/migrations/0003_married_winter_soldier.sql new file mode 100644 index 0000000..275c402 --- /dev/null +++ b/migrations/0003_married_winter_soldier.sql @@ -0,0 +1,4 @@ +ALTER TABLE userCommunities ADD `uniqueId` text;--> statement-breakpoint +ALTER TABLE userCommunities ADD `joinRequestUrl` text;--> statement-breakpoint +ALTER TABLE userCommunities ADD `joinRequestStatus` text;--> statement-breakpoint +CREATE UNIQUE INDEX `userCommunities_uniqueId_unique` ON `userCommunities` (`uniqueId`); \ No newline at end of file diff --git a/migrations/meta/0003_snapshot.json b/migrations/meta/0003_snapshot.json new file mode 100644 index 0000000..0805676 --- /dev/null +++ b/migrations/meta/0003_snapshot.json @@ -0,0 +1,398 @@ +{ + "version": "5", + "dialect": "sqlite", + "id": "aa58311c-ef2e-4d0b-95b0-c7f47c47757c", + "prevId": "a4363b5b-6556-4e59-83b7-0aa26ceb6c9c", + "tables": { + "ballots": { + "name": "ballots", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "communityUrl": { + "name": "communityUrl", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "choices": { + "name": "choices", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "choice": { + "name": "choice", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "score": { + "name": "score", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user": { + "name": "user", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "communities": { + "name": "communities", + "columns": { + "url": { + "name": "url", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "configPath": { + "name": "configPath", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "projectUrl": { + "name": "projectUrl", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "selected": { + "name": "selected", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "configStore": { + "name": "configStore", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "communityUrl": { + "name": "communityUrl", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "projectUrl": { + "name": "projectUrl", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "configs": { + "name": "configs", + "columns": { + "communityUrl": { + "name": "communityUrl", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "projectUrl": { + "name": "projectUrl", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "userCommunities": { + "name": "userCommunities", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "communityId": { + "name": "communityId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uniqueId": { + "name": "uniqueId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "isMember": { + "name": "isMember", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "isMaintainer": { + "name": "isMaintainer", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "votingCredits": { + "name": "votingCredits", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "votingScore": { + "name": "votingScore", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "joinRequestUrl": { + "name": "joinRequestUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "joinRequestStatus": { + "name": "joinRequestStatus", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "userCommunities_communityId_unique": { + "name": "userCommunities_communityId_unique", + "columns": [ + "communityId" + ], + "isUnique": true + }, + "userCommunities_uniqueId_unique": { + "name": "userCommunities_uniqueId_unique", + "columns": [ + "uniqueId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "userCommunities_userId_users_username_fk": { + "name": "userCommunities_userId_users_username_fk", + "tableFrom": "userCommunities", + "tableTo": "users", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "username" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "userCommunities_communityId_communities_url_fk": { + "name": "userCommunities_communityId_communities_url_fk", + "tableFrom": "userCommunities", + "tableTo": "communities", + "columnsFrom": [ + "communityId" + ], + "columnsTo": [ + "url" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "username": { + "name": "username", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "pat": { + "name": "pat", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "memberPublicUrl": { + "name": "memberPublicUrl", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "memberPublicBranch": { + "name": "memberPublicBranch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "memberPrivateUrl": { + "name": "memberPrivateUrl", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "memberPrivateBranch": { + "name": "memberPrivateBranch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + } +} \ No newline at end of file diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json index 4dc60b8..a050a5c 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1701096291441, "tag": "0002_marvelous_zemo", "breakpoints": true + }, + { + "idx": 3, + "version": "5", + "when": 1701484393709, + "tag": "0003_married_winter_soldier", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/electron/db/migrate.ts b/src/electron/db/migrate.ts index d65d4ff..e403cfc 100644 --- a/src/electron/db/migrate.ts +++ b/src/electron/db/migrate.ts @@ -3,8 +3,6 @@ import { resolve } from 'node:path' import { migrate } from 'drizzle-orm/better-sqlite3/migrator' -import { Config } from '~/shared' - import { DB, loadDb } from './db.js' import { communities, configStore, users } from './schema.js' export async function migrateDb(dbPath: string, isPackaged = false) { @@ -36,7 +34,7 @@ async function migrateData(db: DB) { return } - const config: Config = JSON.parse(readFileSync(configPath, 'utf-8')) + const config = JSON.parse(readFileSync(configPath, 'utf-8')) if ( config.user != null && diff --git a/src/electron/db/schema.ts b/src/electron/db/schema.ts index 32e8275..59479ad 100644 --- a/src/electron/db/schema.ts +++ b/src/electron/db/schema.ts @@ -1,6 +1,6 @@ import { integer, real, sqliteTable, text } from 'drizzle-orm/sqlite-core' -import { type Ballot as BallotType } from '~/shared' +import { type Ballot as BallotType, Expand, ExpandRecursive } from '~/shared' export const ballots = sqliteTable('ballots', { identifier: text('identifier').primaryKey(), @@ -53,8 +53,6 @@ export const communities = sqliteTable('communities', { name: text('name').notNull(), configPath: text('configPath').notNull(), projectUrl: text('projectUrl').notNull(), - // isMember: integer('isMember', { mode: 'boolean' }).notNull(), - // isMaintainer: integer('isMaintainer', { mode: 'boolean' }).notNull(), selected: integer('selected', { mode: 'boolean' }).notNull(), }) @@ -82,11 +80,22 @@ export const userCommunities = sqliteTable('userCommunities', { .notNull() .unique() .references(() => communities.url), + uniqueId: text('uniqueId').unique(), isMember: integer('isMember', { mode: 'boolean' }).notNull(), isMaintainer: integer('isMaintainer', { mode: 'boolean' }).notNull(), votingCredits: real('votingCredits').notNull(), votingScore: real('votingScore').notNull(), + joinRequestUrl: text('joinRequestUrl'), + joinRequestStatus: text('joinRequestStatus', { enum: ['open', 'closed'] }), }) export type UserCommunity = typeof userCommunities.$inferSelect export type UserCommunityInsert = typeof userCommunities.$inferInsert + +export type FullUserCommunity = Expand + +export type FullUser = ExpandRecursive< + User & { + communities: FullUserCommunity[] + } +> diff --git a/src/electron/main.ts b/src/electron/main.ts index 59a663d..f6ec020 100644 --- a/src/electron/main.ts +++ b/src/electron/main.ts @@ -22,6 +22,8 @@ import { GitService, LogService, Services, + UserCommunityService, + ValidationService, } from './services/index.js' import { SettingsService } from './services/SettingsService.js' import { UserService } from './services/UserService.js' @@ -55,16 +57,21 @@ async function setup(): Promise { const gov4GitService = new Gov4GitService(services) services.register('gov4git', gov4GitService) - const userService = new UserService({ + const settingsService = new SettingsService({ services, - identityRepoName: COMMUNITY_REPO_NAME, }) - services.register('user', userService) + services.register('settings', settingsService) - const ballotService = new BallotService({ + const userCommunityService = new UserCommunityService({ services, }) - services.register('ballots', ballotService) + services.register('userCommunity', userCommunityService) + + const userService = new UserService({ + services, + identityRepoName: COMMUNITY_REPO_NAME, + }) + services.register('user', userService) const communityService = new CommunityService({ services, @@ -72,10 +79,15 @@ async function setup(): Promise { }) services.register('community', communityService) - const settingsService = new SettingsService({ + const ballotService = new BallotService({ services, }) - services.register('settings', settingsService) + services.register('ballots', ballotService) + + const validationService = new ValidationService({ + services, + }) + services.register('validation', validationService) const appUpdaterService = new AppUpdaterService(services) services.register('appUpdater', appUpdaterService) diff --git a/src/electron/services/CommunityService.ts b/src/electron/services/CommunityService.ts index 110bbd6..ef6dfd4 100644 --- a/src/electron/services/CommunityService.ts +++ b/src/electron/services/CommunityService.ts @@ -4,10 +4,11 @@ import { resolve } from 'path' import { AbstractCommunityService } from '~/shared' import { DB } from '../db/db.js' -import { communities, Community, users } from '../db/schema.js' +import { communities, Community, userCommunities } from '../db/schema.js' import { hashString, toResolvedPath } from '../lib/paths.js' import { GitService } from './GitService.js' import { Services } from './Services.js' +import { UserService } from './UserService.js' export type CommunityServiceOptions = { services: Services @@ -19,6 +20,7 @@ export class CommunityService extends AbstractCommunityService { private declare readonly configDir: string private declare readonly db: DB private declare readonly gitService: GitService + private declare readonly userService: UserService constructor({ services, configDir = '~/.gov4git' }: CommunityServiceOptions) { super() @@ -26,19 +28,14 @@ export class CommunityService extends AbstractCommunityService { this.configDir = toResolvedPath(configDir) this.db = this.services.load('db') this.gitService = this.services.load('git') + this.userService = this.services.load('user') } public validateCommunityUrl = async ( url: string, ): Promise<[string | null, string[] | null]> => { - const user = (await this.db.select().from(users).limit(1))[0] - - if (user == null) { - return [null, ['User not set. Cannot validate community URL.']] - } - const errors: string[] = [] - if (!(await this.gitService.doesRemoteRepoExist(url, user))) { + if (!(await this.gitService.doesRemoteRepoExist(url))) { errors.push( `Community url, ${url}, does not exist. Please enter a valid community URL.`, ) @@ -46,7 +43,7 @@ export class CommunityService extends AbstractCommunityService { } const communityMainBranch = - (await this.gitService.getDefaultBranch(url, user)) ?? 'main' + (await this.gitService.getDefaultBranch(url)) ?? 'main' return [communityMainBranch, null] } @@ -78,17 +75,60 @@ export class CommunityService extends AbstractCommunityService { .where(eq(communities.url, url)) } + private requestToJoin = async (community: Community): Promise => { + const user = await this.userService.loadUser() + + if (user == null) { + return + } + + const currentCommunity = user.communities.find( + (c) => c.url === community.url, + ) + + if (currentCommunity == null) { + return + } + + if ( + currentCommunity.joinRequestUrl != null && + currentCommunity.joinRequestStatus != null + ) { + return + } + + const title = "I'd like to join this project's community" + const body = `### Your public repo + +${user.memberPublicUrl} + +### Your public branch + +${user.memberPublicBranch}` + const labels = ['gov4git:join'] + + const url = await this.gitService.createIssue({ + url: community.projectUrl, + user, + title, + body, + labels, + }) + + await this.db + .update(userCommunities) + .set({ + joinRequestUrl: url, + joinRequestStatus: 'open', + }) + .where(eq(userCommunities.id, currentCommunity.id)) + } + public insertCommunity = async (projectUrl: string): Promise => { if (projectUrl === '') { return [`Community URL is a required field.`] } - const user = (await this.db.select().from(users).limit(1))[0] - console.log(`================ ${user?.username} ===================`) - if (user == null) { - return [] - } - const projectRepoUrl = projectUrl.replace(/(\/|\.git)$/i, '') const communityName = projectRepoUrl.split('/').at(-1)! const communityUrl = `${projectRepoUrl.replace( @@ -121,6 +161,8 @@ export class CommunityService extends AbstractCommunityService { set: community, }) + await this.requestToJoin(community) + return [] } } diff --git a/src/electron/services/GitService.ts b/src/electron/services/GitService.ts index 4a055bf..d679413 100644 --- a/src/electron/services/GitService.ts +++ b/src/electron/services/GitService.ts @@ -6,6 +6,28 @@ export type GitUserInfo = { pat: string } +export type IssueDescription = { + url: string + user: GitUserInfo + title: string + body: string + labels?: string[] +} + +export type IssueSearch = { + url: string + user: GitUserInfo + title: string +} + +export type IssueSearchResult = { + id: number + html_url: string + title: string + body: string + state: 'open' | 'closed' +} + export class GitService { protected declare apiBaseUrl: string constructor() { @@ -92,12 +114,13 @@ export class GitService { public doesRemoteRepoExist = async ( repoUrl: string, - user: GitUserInfo, + user?: GitUserInfo, ): Promise => { this.throwIfNotUrl(repoUrl, 'doesRemoteRepoExist') - await this.throwIfUserDoesNotExist(user, 'doesRemoteRepoExist') + if (user != null) + await this.throwIfUserDoesNotExist(user, 'doesRemoteRepoExist') try { - const authHeader = this.getAuthHeader(user) + const authHeader = user != null ? this.getAuthHeader(user) : null const repoSegment = this.getRepoSegment(repoUrl) const response = await fetch(`${this.apiBaseUrl}/repos/${repoSegment}`, { method: 'GET', @@ -117,13 +140,13 @@ export class GitService { public getDefaultBranch = async ( repoUrl: string, - user: GitUserInfo, + user?: GitUserInfo, ): Promise => { this.throwIfNotUrl(repoUrl, 'getDefaultBranch') - await this.throwIfUserDoesNotExist(user, 'getDefaultBranch') - await this.throwIfRepoDoesNotExist(repoUrl, user, 'getDefaultBranch') + if (user != null) + await this.throwIfUserDoesNotExist(user, 'getDefaultBranch') try { - const authHeader = this.getAuthHeader(user) + const authHeader = user != null ? this.getAuthHeader(user) : null const repoSegment = this.getRepoSegment(repoUrl) const response = await fetch(`${this.apiBaseUrl}/repos/${repoSegment}`, { method: 'GET', @@ -240,7 +263,7 @@ export class GitService { name: repoName, private: isPrivate, auto_init: autoInit, - has_issues: false, + has_issues: true, has_projects: false, has_wiki: false, has_discussions: false, @@ -274,7 +297,81 @@ export class GitService { throw new Error(`Status code: ${result.status}`) } } catch (ex) { - throw new Error(`Unable to delete repo ${repoUrl}. Error: ${ex}`) + throw new Error(`Failed to delete repo ${repoUrl}. Error: ${ex}`) + } + } + + public createIssue = async (issue: IssueDescription) => { + this.throwIfNotUrl(issue.url, 'createIssue') + await this.throwIfUserDoesNotExist(issue.user, 'createIssue') + try { + const repoSegment = this.getRepoSegment(issue.url) + const authHeader = this.getAuthHeader(issue.user) + + const response = await fetch( + `${this.apiBaseUrl}/repos/${repoSegment}/issues`, + { + method: 'POST', + headers: { + Accept: 'application/vnd.github+json', + ...authHeader, + }, + body: JSON.stringify({ + title: issue.title, + body: issue.body, + labels: issue.labels ?? [], + }), + }, + ) + const results: any = await response.json() + if (response.status !== 201) { + throw new Error(`Status code: ${response.status}`) + } + return results.html_url as string + } catch (ex) { + throw new Error( + `Failed to create issue in ${issue.url} for ${issue.user.username}. Error: ${ex}`, + ) + } + } + + public searchUserIssues = async (issue: IssueSearch) => { + this.throwIfNotUrl(issue.url, 'searchUserIssues') + await this.throwIfUserDoesNotExist(issue.user, 'searchUserIssue') + try { + const repoSegment = this.getRepoSegment(issue.url) + const authHeader = this.getAuthHeader(issue.user) + let allResponses: IssueSearchResult[] = [] + let currentResponse: IssueSearchResult[] + + let url = `${this.apiBaseUrl}/repos/${repoSegment}/issues` + url += `?creator=${issue.user.username}` + url += '&state=all' + url += `&per_page=100` + + do { + const response = await fetch(url, { + method: 'GET', + headers: { + Accept: 'application/vnd.github+json', + ...authHeader, + }, + }) + if (response.status !== 200) { + throw new Error(`Status code: ${response.status}`) + } + currentResponse = (await response.json()) as IssueSearchResult[] + allResponses = [ + ...allResponses, + ...currentResponse.filter((i) => i.title === issue.title), + ] + } while (currentResponse.length === 100) + + return allResponses + } catch (ex) { + throw new Error( + `Failed to search user issues for ${issue.url} and ${issue.user.username}. Error: ${ex}`, + ) } } } diff --git a/src/electron/services/SettingsService.ts b/src/electron/services/SettingsService.ts index 560fb35..154bc25 100644 --- a/src/electron/services/SettingsService.ts +++ b/src/electron/services/SettingsService.ts @@ -3,39 +3,37 @@ import { existsSync, writeFileSync } from 'fs' import { mkdir } from 'fs/promises' import { dirname } from 'path' -import { AbstractSettingsService, Config } from '~/shared' - import { DB } from '../db/db.js' -import { communities, users } from '../db/schema.js' -import { CommunityService } from './CommunityService.js' +import { communities, Community, User, users } from '../db/schema.js' import { GitService } from './GitService.js' import { Gov4GitService } from './Gov4GitService.js' import { Services } from './Services.js' -import { UserService } from './UserService.js' export type SettingsServiceOptions = { services: Services } -export class SettingsService extends AbstractSettingsService { +export class SettingsService { private declare readonly services: Services - private declare readonly communityService: CommunityService - private declare readonly userService: UserService private declare readonly gitService: GitService private declare readonly govService: Gov4GitService private declare readonly db: DB constructor({ services }: SettingsServiceOptions) { - super() this.services = services this.db = this.services.load('db') - this.communityService = this.services.load('community') - this.userService = this.services.load('user') this.gitService = this.services.load('git') this.govService = this.services.load('gov4git') } - private runGov4GitInit = async (config: Config) => { + private runGov4GitInit = async (config: { + member_public_url: string + member_private_url: string + user: { + username: string + pat: string + } + }) => { const user = config.user const isPublicEmpty = !(await this.gitService.hasCommits( config.member_public_url!, @@ -51,23 +49,7 @@ export class SettingsService extends AbstractSettingsService { } } - public generateConfig = async () => { - const [allCommunities, allUsers] = await Promise.all([ - this.db - .select() - .from(communities) - .where(eq(communities.selected, true)) - .limit(1), - this.db.select().from(users).limit(1), - ]) - - const community = allCommunities[0] - const user = allUsers[0] - - if (community == null || user == null) { - return null - } - + public generateConfig = async (user: User, community: Community) => { const config = { notice: 'Do not modify this file. It will be overwritten by Gov4Git application', @@ -113,18 +95,26 @@ export class SettingsService extends AbstractSettingsService { return config } - public validateConfig = async (): Promise => { - const config = await this.generateConfig() - if (config == null) return [] - const userErrors = await this.userService.validateUser( - config.user.username, - config.user.pat, - ) - if (userErrors.length > 0) { - return userErrors + public generateConfigs = async (): Promise => { + const [allCommunities, allUsers] = await Promise.all([ + this.db + .select() + .from(communities) + .where(eq(communities.selected, true)) + .limit(1), + this.db.select().from(users).limit(1), + ]) + + const user = allUsers[0] + + if (user == null) { + return } - const [, communityErrors] = - await this.communityService.validateCommunityUrl(config.gov_public_url) - return communityErrors ?? [] + + const updates = allCommunities.map((r) => { + return this.generateConfig(user, r) + }) + + await Promise.all(updates) } } diff --git a/src/electron/services/UserCommunityService.ts b/src/electron/services/UserCommunityService.ts new file mode 100644 index 0000000..ba7e98a --- /dev/null +++ b/src/electron/services/UserCommunityService.ts @@ -0,0 +1,214 @@ +import { and, eq, isNull } from 'drizzle-orm' + +import { DB } from '../db/db.js' +import { + ballots, + communities, + Community, + FullUserCommunity, + User, + userCommunities, + UserCommunityInsert, + users, +} from '../db/schema.js' +import { GitService } from './GitService.js' +import { Gov4GitService } from './Gov4GitService.js' +import { Services } from './Services.js' +import { SettingsService } from './SettingsService.js' + +export type UserCommunityServiceOptions = { + services: Services +} + +export class UserCommunityService { + private declare readonly services: Services + private declare readonly db: DB + private declare readonly gitService: GitService + private declare readonly govService: Gov4GitService + private declare readonly settingsService: SettingsService + + constructor({ services }: UserCommunityServiceOptions) { + this.services = services + this.db = this.services.load('db') + this.gitService = this.services.load('git') + this.govService = this.services.load('gov4git') + this.settingsService = this.services.load('settings') + } + + private updateUserCommunities = async () => { + const rows = await this.db + .select() + .from(userCommunities) + .where(isNull(userCommunities.uniqueId)) + + for (const row of rows) { + const uniqueId = `${row.userId}-${row.communityId}` + await this.db + .update(userCommunities) + .set({ + uniqueId, + }) + .where(eq(userCommunities.id, row.id)) + } + } + + private isCommunityMember = async ( + username: string, + ): Promise => { + const command = ['group', 'list', '--name', 'everybody'] + const users = await this.govService.mustRun(...command) + const existingInd = users.findIndex((u) => { + return u.toLocaleLowerCase() === username.toLocaleLowerCase() + }) + if (existingInd !== -1) { + return users[existingInd]! + } + return null + } + + private getOpenBallots = async (communityUrl: string) => { + const community = ( + await this.db + .select() + .from(communities) + .where(eq(communities.url, communityUrl)) + )[0] + if (community == null) { + return [] + } + const results = await this.db + .select() + .from(ballots) + .where(and(eq(ballots.status, 'open'))) + return results + } + + private getVotingCredits = async ( + username: string, + communityUrl: string, + ): Promise => { + const command = [ + 'balance', + 'get', + '--user', + username, + '--key', + 'voting_credits', + ] + const [credits, openTallies] = await Promise.all([ + this.govService.mustRun(...command), + this.getOpenBallots(communityUrl), + ]) + const totalPendingSpentCredits = openTallies.reduce((acc, cur) => { + const spentCredits = cur.user.talliedCredits + const score = cur.user.newScore + const scoreSign = score < 0 ? -1 : 1 + const totalCredits = scoreSign * Math.pow(score, 2) + const additionalCosts = Math.abs(totalCredits) - Math.abs(spentCredits) + return acc + additionalCosts + }, 0) + return credits - totalPendingSpentCredits + } + + private getMembershipStatus = async ( + user: User, + projectUrl: string, + ): Promise<{ status: 'open' | 'closed'; url: string } | null> => { + const userJoinRequest = ( + await this.gitService.searchUserIssues({ + url: projectUrl, + user, + title: "I'd like to join this project's community", + }) + )[0] + + if (userJoinRequest == null) { + return null + } else { + return { + status: userJoinRequest.state, + url: userJoinRequest.html_url, + } + } + } + + private loadUserCommunity = async ( + user: User, + community: Community, + ): Promise => { + const newUsername = await this.isCommunityMember(user.username) + const isMember = newUsername != null + const votingCredits = isMember + ? await this.getVotingCredits(newUsername, community.url) + : 0 + const votingScore = Math.sqrt(Math.abs(votingCredits)) + + if (newUsername != null && newUsername !== user.username) { + await this.db + .update(users) + .set({ username: newUsername }) + .where(eq(users.username, user.username)) + } + + const membership = await this.getMembershipStatus( + user, + community.projectUrl, + ) + + const userCommunity: UserCommunityInsert = { + userId: newUsername ?? user.username, + communityId: community.url, + uniqueId: `${user.username}-${community.url}`, + isMember: isMember, + isMaintainer: false, + votingCredits: votingCredits, + votingScore: votingScore, + ...(membership != null + ? { + joinRequestStatus: membership.status, + joinRequestUrl: membership.url, + } + : null), + } + + const insertedUserCommunity = ( + await this.db + .insert(userCommunities) + .values(userCommunity) + .onConflictDoUpdate({ + target: userCommunities.uniqueId, + set: userCommunity, + }) + .returning() + )[0]! + + return { + ...insertedUserCommunity, + ...community, + } + } + + public loadUserCommunities = async (): Promise => { + await this.updateUserCommunities() + + const user = (await this.db.select().from(users).limit(1))[0] + + if (user == null) { + return [] + } + + const communityRows = await this.db.select().from(communities) + + if (communityRows.length === 0) { + return [] + } + + await this.settingsService.generateConfigs() + + const updates = communityRows.map((r) => { + return this.loadUserCommunity(user, r) + }) + + return await Promise.all(updates) + } +} diff --git a/src/electron/services/UserService.ts b/src/electron/services/UserService.ts index 296a60c..14579c3 100644 --- a/src/electron/services/UserService.ts +++ b/src/electron/services/UserService.ts @@ -1,4 +1,4 @@ -import { and, eq } from 'drizzle-orm' +import { eq } from 'drizzle-orm' import { AbstractUserService, serialAsync } from '~/shared' @@ -6,13 +6,14 @@ import { DB } from '../db/db.js' import { ballots, communities, + FullUser, userCommunities, UserInsert, users, } from '../db/schema.js' import { GitService } from './GitService.js' -import { Gov4GitService } from './Gov4GitService.js' import { Services } from './Services.js' +import { UserCommunityService } from './UserCommunityService.js' export type UserServiceOptions = { services: Services @@ -24,7 +25,7 @@ export class UserService extends AbstractUserService { private declare readonly repoUrlBase: string private declare readonly db: DB private declare readonly gitService: GitService - private declare readonly govService: Gov4GitService + private declare readonly userCommunityService: UserCommunityService constructor({ services, @@ -36,7 +37,8 @@ export class UserService extends AbstractUserService { this.repoUrlBase = 'https://github.com' this.db = this.services.load('db') this.gitService = this.services.load('git') - this.govService = this.services.load('gov4git') + this.userCommunityService = + this.services.load('userCommunity') } private deleteDBTables = async () => { @@ -73,61 +75,6 @@ export class UserService extends AbstractUserService { }) } - private getOpenBallots = async () => { - const community = ( - await this.db - .select() - .from(communities) - .where(eq(communities.selected, true)) - )[0] - if (community == null) { - return [] - } - const results = await this.db - .select() - .from(ballots) - .where(and(eq(ballots.status, 'open'))) - return results - } - - private isCommunityMember = async ( - username: string, - ): Promise => { - const command = ['group', 'list', '--name', 'everybody'] - const users = await this.govService.mustRun(...command) - const existingInd = users.findIndex((u) => { - return u.toLocaleLowerCase() === username.toLocaleLowerCase() - }) - if (existingInd !== -1) { - return users[existingInd]! - } - return null - } - - private getVotingCredits = async (username: string): Promise => { - const command = [ - 'balance', - 'get', - '--user', - username, - '--key', - 'voting_credits', - ] - const [credits, openTallies] = await Promise.all([ - this.govService.mustRun(...command), - this.getOpenBallots(), - ]) - const totalPendingSpentCredits = openTallies.reduce((acc, cur) => { - const spentCredits = cur.user.talliedCredits - const score = cur.user.newScore - const scoreSign = score < 0 ? -1 : 1 - const totalCredits = scoreSign * Math.pow(score, 2) - const additionalCosts = Math.abs(totalCredits) - Math.abs(spentCredits) - return acc + additionalCosts - }, 0) - return credits - totalPendingSpentCredits - } - private validateIdRepo = async ( username: string, pat: string, @@ -191,123 +138,60 @@ export class UserService extends AbstractUserService { target: users.username, set: newUser, }) + + await this.userCommunityService.loadUserCommunities() + return [] } - public loadUser = serialAsync(async () => { - const [allUsers, selectedCommunities] = await Promise.all([ - this.db.select().from(users), - this.db.select().from(communities).where(eq(communities.selected, true)), - ]) - const user = allUsers[0] - const community = selectedCommunities[0] + public loadUser = serialAsync(async (): Promise => { + const user = (await this.db.select().from(users).limit(1))[0] if (user == null) { - return { - users: null, - communities: null, - userCommunities: null, - } - } - if (community == null) { - return { - users: user, - communities: null, - userCommunities: null, - } + return null } - const newUsername = await this.isCommunityMember(user.username) - const isMember = newUsername != null - const votingCredits = isMember - ? await this.getVotingCredits(newUsername) - : 0 - const votingScore = Math.sqrt(Math.abs(votingCredits)) + const allUserCommunities = + await this.userCommunityService.loadUserCommunities() - if (newUsername != null && newUsername !== user.username) { - await this.db - .update(users) - .set({ username: newUsername }) - .where(eq(users.username, user.username)) - } - - const userCommunity = { - userId: newUsername ?? user.username, - communityId: community.url, - isMember: isMember, - isMaintainer: false, - votingCredits: votingCredits, - votingScore: votingScore, + return { + ...user, + communities: allUserCommunities, } + }) - await this.db - .insert(userCommunities) - .values(userCommunity) - .onConflictDoUpdate({ - target: userCommunities.communityId, - set: userCommunity, - }) + public getUser = async (): Promise => { + const userInfo = await this.db + .select() + .from(userCommunities) + .innerJoin(users, eq(userCommunities.userId, users.username)) + .innerJoin(communities, eq(userCommunities.communityId, communities.url)) - return { - users: { - ...user, - username: newUsername ?? user.username, - }, - communities: community, - userCommunities: userCommunity, + if (userInfo.length === 0) { + return await this.loadUser() } - }) - public getUser = async () => { - const userInfos = ( - await this.db - .select() - .from(userCommunities) - .rightJoin( - communities, - eq(userCommunities.communityId, communities.url), - ) - .leftJoin(users, eq(userCommunities.userId, users.username)) - .where(eq(communities.selected, true)) - .limit(1) - )[0] + const userRecord = userInfo.reduce>((acc, cur) => { + const user = cur.users + const userCommunity = cur.userCommunities + const community = cur.communities - if ( - userInfos == null || - userInfos.userCommunities == null || - userInfos.users == null - ) { - const newUserInfo = await this.loadUser() - if (newUserInfo.users == null) { - return null - } else if (newUserInfo.userCommunities == null) { - return { - ...newUserInfo.users, - communityId: '', - isMember: false, - isMaintainer: false, - votingCredits: 0, - votingScore: 0, - } - } else { - return { - ...newUserInfo.users, - communityId: newUserInfo.userCommunities.communityId, - isMember: newUserInfo.userCommunities.isMember, - isMaintainer: false, - votingCredits: newUserInfo.userCommunities.votingCredits, - votingScore: newUserInfo.userCommunities.votingScore, - } - } - } else { - return { - ...userInfos.users, - communityId: userInfos.userCommunities.communityId, - isMember: userInfos.userCommunities.isMember, - isMaintainer: false, - votingCredits: userInfos.userCommunities.votingCredits, - votingScore: userInfos.userCommunities.votingScore, + if (!(user.username in acc)) { + acc[user.username] = user as FullUser } - } + + const fullUser = acc[user.username]! + + fullUser.communities = [ + ...(fullUser.communities ?? []), + { + ...userCommunity, + ...community, + }, + ] + return acc + }, {}) + + return userRecord[userInfo[0]!.users.username]! } } diff --git a/src/electron/services/ValidationService.ts b/src/electron/services/ValidationService.ts new file mode 100644 index 0000000..3a4fc2c --- /dev/null +++ b/src/electron/services/ValidationService.ts @@ -0,0 +1,61 @@ +import { eq } from 'drizzle-orm' + +import { AbstractValidationService } from '~/shared' + +import { DB } from '../db/db.js' +import { communities, users } from '../db/schema.js' +import { CommunityService } from './CommunityService.js' +import { Services } from './Services.js' +import { SettingsService } from './SettingsService.js' +import { UserService } from './UserService.js' + +export type ValidationServiceOptions = { + services: Services +} + +export class ValidationService extends AbstractValidationService { + private declare readonly services: Services + private declare readonly communityService: CommunityService + private declare readonly userService: UserService + private declare readonly settingsService: SettingsService + private declare db: DB + + constructor({ services }: ValidationServiceOptions) { + super() + this.services = services + this.communityService = this.services.load('community') + this.userService = this.services.load('user') + this.settingsService = this.services.load('settings') + this.db = this.services.load('db') + } + + public validateConfig = async (): Promise => { + const [userRows, communityRows] = await Promise.all([ + this.db.select().from(users).limit(1), + this.db + .select() + .from(communities) + .where(eq(communities.selected, true)) + .limit(1), + ]) + + const [user, community] = [userRows[0], communityRows[0]] + + if (user == null || community == null) { + return [] + } + + const config = await this.settingsService.generateConfig(user, community) + if (config == null) return [] + const userErrors = await this.userService.validateUser( + config.user.username, + config.user.pat, + ) + if (userErrors.length > 0) { + return userErrors + } + const [, communityErrors] = + await this.communityService.validateCommunityUrl(config.gov_public_url) + return communityErrors ?? [] + } +} diff --git a/src/electron/services/index.ts b/src/electron/services/index.ts index c441625..254af1a 100644 --- a/src/electron/services/index.ts +++ b/src/electron/services/index.ts @@ -8,3 +8,5 @@ export * from './AppUpdaterService.js' export * from './CacheService.js' export * from './CommunityService.js' export * from './SettingsService.js' +export * from './UserCommunityService.js' +export * from './ValidationService.js' diff --git a/src/renderer/src/App/Router.tsx b/src/renderer/src/App/Router.tsx index fbc14cb..d7eba85 100644 --- a/src/renderer/src/App/Router.tsx +++ b/src/renderer/src/App/Router.tsx @@ -2,6 +2,7 @@ import { createHashRouter, RouterProvider } from 'react-router-dom' import { RequireAuth } from '../components/RequireAuth.js' import { AboutPage } from '../pages/About.js' +import { CommunityJoinPage } from '../pages/CommunityJoin.js' import { CreatePage } from '../pages/Create.js' import { ErrorPage } from '../pages/Error.js' import { Layout } from '../pages/Layout.js' @@ -29,6 +30,7 @@ export type Routes = { info: Route settings: Route login: Route + communityJoin: Route license: Route logs: Route } @@ -140,6 +142,16 @@ export const routes = { toolTip: '', element: , }, + communityJoin: { + path: '/community-join', + name: 'Community Join', + siteNav: false, + forAdmin: false, + iconClass: '', + footer: false, + toolTip: '', + element: , + }, license: { path: '/license', name: 'License', diff --git a/src/renderer/src/components/CommunityJoin.styles.ts b/src/renderer/src/components/CommunityJoin.styles.ts new file mode 100644 index 0000000..0f493a7 --- /dev/null +++ b/src/renderer/src/components/CommunityJoin.styles.ts @@ -0,0 +1,22 @@ +import { makeStyles, shorthands } from '@fluentui/react-components' + +import { gov4GitTokens } from '../App/theme/index.js' + +export const useCommunityJoinStyles = makeStyles({ + buttons: { + display: 'flex', + justifyContent: 'flex-start', + }, + card: { + ...shorthands.padding(gov4GitTokens.spacingHorizontalXXL), + ...shorthands.borderRadius(gov4GitTokens.borderRadiusLarge), + }, + field: { + paddingBottom: '2rem', + }, + labelText: { + fontSize: '1.25rem', + display: 'block', + ...shorthands.padding(0, 0, '0.5rem', 0), + }, +}) diff --git a/src/renderer/src/components/CommunityJoin.tsx b/src/renderer/src/components/CommunityJoin.tsx new file mode 100644 index 0000000..5663bc1 --- /dev/null +++ b/src/renderer/src/components/CommunityJoin.tsx @@ -0,0 +1,143 @@ +import { Button, Card, Field, Input } from '@fluentui/react-components' +import { useAtomValue } from 'jotai' +import { FC, FormEvent, useCallback, useEffect, useMemo, useState } from 'react' +import { useNavigate } from 'react-router-dom' + +import { routes } from '../App/index.js' +import { useCatchError } from '../hooks/useCatchError.js' +import { eventBus } from '../lib/index.js' +import { communityService } from '../services/CommunityService.js' +import { communityAtom } from '../state/community.js' +import { useButtonStyles, useMessageStyles } from '../styles/index.js' +import { useCommunityJoinStyles } from './CommunityJoin.styles.js' +import { Message } from './Message.js' + +export const CommunityJoin: FC = function Login() { + const styles = useCommunityJoinStyles() + const buttonStyles = useButtonStyles() + const catchError = useCatchError() + const [communityErrors, setCommunityErrors] = useState([]) + const [loading, setLoading] = useState(false) + const messageStyles = useMessageStyles() + const community = useAtomValue(communityAtom) + const [projectUrl, setProjectUrl] = useState(community?.projectUrl ?? '') + const [statusMessage, setStatusMessage] = useState('') + const navigate = useNavigate() + + useEffect(() => { + let message = '' + if (community?.isMember) { + navigate(routes.issues.path) + // message = `Your request has been approved. You are a member of ${community.name}.` + } else if (community?.joinRequestStatus === 'closed') { + message = `Your request is closed. ` + } else if (community?.joinRequestStatus === 'open') { + message = `Your request to join ${community.name} is pending.` + } + if (community?.joinRequestUrl != null) { + message += ` View your request in GitHub` + } + if (projectUrl !== community?.projectUrl) { + message = '' + } + setStatusMessage(message) + }, [community, setStatusMessage, navigate, projectUrl]) + + const dismissError = useCallback(() => { + setCommunityErrors([]) + }, [setCommunityErrors]) + + const submitEnabled = useMemo(() => { + return ( + projectUrl !== '' && + (community?.projectUrl != projectUrl || statusMessage === '') + ) + }, [projectUrl, statusMessage, community]) + + const save = useCallback( + async (ev: FormEvent) => { + ev.preventDefault() + setCommunityErrors([]) + try { + setLoading(true) + const communityErrors = + await communityService.insertCommunity(projectUrl) + if (communityErrors.length > 0) { + setCommunityErrors(communityErrors) + setLoading(false) + } else { + setLoading(false) + eventBus.emit('community-saved') + } + } catch (ex) { + setLoading(false) + await catchError(`Failed to save config. ${ex}`) + } + }, + [setCommunityErrors, catchError, setLoading, projectUrl], + ) + + return ( + + {communityErrors.length > 0 && ( + + )} +
+ ( + + ), + }} + > + + setProjectUrl(e.target.value)} + /> + + +
+ +
+ + {statusMessage !== '' && ( +
+

+
+ )} +
+ ) +} + +const CommunityUrlMoreInfo: FC = function CommunityUrlMoreInfo() { + return ( +
+

+ URL to a GitHub hosted community repo provided by a community maintainer + is required. +

+
+ ) +} diff --git a/src/renderer/src/components/DataLoader.tsx b/src/renderer/src/components/DataLoader.tsx index edb9189..65a3a9e 100644 --- a/src/renderer/src/components/DataLoader.tsx +++ b/src/renderer/src/components/DataLoader.tsx @@ -10,10 +10,8 @@ import { eventBus } from '../lib/index.js' import { appUpdaterService } from '../services/AppUpdaterService.js' import { ballotService } from '../services/BallotService.js' import { cacheService } from '../services/CacheService.js' -import { communityService } from '../services/CommunityService.js' import { userService } from '../services/UserService.js' import { ballotsAtom } from '../state/ballots.js' -import { communityAtom } from '../state/community.js' import { loaderAtom } from '../state/loader.js' import { updatesAtom } from '../state/updates.js' import { userAtom, userLoadedAtom } from '../state/user.js' @@ -25,7 +23,6 @@ export const DataLoader: FC = function DataLoader() { const setUser = useSetAtom(userAtom) const setUserLoaded = useSetAtom(userLoadedAtom) const setLoading = useSetAtom(loaderAtom) - const setCommunity = useSetAtom(communityAtom) const [loadingQueue, setLoadingQueue] = useState[]>([]) const navigate = useNavigate() @@ -73,19 +70,6 @@ export const DataLoader: FC = function DataLoader() { return serialAsync(_getUser) }, [_getUser]) - const _getCommunity = useCallback(async () => { - try { - const community = await communityService.getCommunity() - setCommunity(community) - } catch (ex) { - await catchError(`Failed to load community. ${ex}`) - } - }, [catchError, setCommunity]) - - const getCommunity = useMemo(() => { - return serialAsync(_getCommunity) - }, [_getCommunity]) - const _getBallots = useCallback(async () => { try { const ballots = await ballotService.getBallots() @@ -133,7 +117,6 @@ export const DataLoader: FC = function DataLoader() { useEffect(() => { const listeners: Array<() => void> = [] addToQueue(getUser()) - addToQueue(getCommunity()) addToQueue(getBallots()) const updateCacheInterval = setInterval(async () => { @@ -156,8 +139,8 @@ export const DataLoader: FC = function DataLoader() { }) listeners.push( - eventBus.subscribe('user-logged-in', async () => { - const prom = Promise.all([getUser(), getCommunity(), getBallots()]) + eventBus.subscribe('user-logged-in, community-saved', async () => { + const prom = Promise.all([getUser(), getBallots()]) addToQueue(prom) await prom navigate(routes.issues.path) @@ -191,7 +174,6 @@ export const DataLoader: FC = function DataLoader() { getBallot, navigate, checkForUpdates, - getCommunity, refreshCache, ]) diff --git a/src/renderer/src/components/IssueBallot.tsx b/src/renderer/src/components/IssueBallot.tsx index c6c4ad8..63e6cc3 100644 --- a/src/renderer/src/components/IssueBallot.tsx +++ b/src/renderer/src/components/IssueBallot.tsx @@ -26,6 +26,7 @@ import { eventBus } from '../lib/index.js' import { ballotService } from '../services/index.js' import { communityAtom } from '../state/community.js' import { userAtom } from '../state/user.js' +import { useBadgeStyles } from '../styles/badges.js' import { useMessageStyles } from '../styles/messages.js' import { BubbleSlider } from './BubbleSlider.js' import { useIssueBallotStyles } from './IssueBallot.styles.js' @@ -39,6 +40,7 @@ export const IssueBallot: FC = function IssueBallot({ ballot, }) { const styles = useIssueBallotStyles() + const badgeStyles = useBadgeStyles() const [voteScore, setVoteScore] = useState(ballot.user.newScore) const [displayVoteScore, setDisplayVoteScore] = useState( formatDecimal(voteScore), @@ -79,23 +81,23 @@ export const IssueBallot: FC = function IssueBallot({ }, [community, ballot]) const maxScore = useMemo(() => { - if (user == null) return 0 + if (community == null) return 0 console.log(ballot) return Math.sqrt( - user.votingCredits + + community.votingCredits + Math.abs(ballot.user.pendingCredits) + Math.abs(ballot.user.talliedCredits), ) - }, [ballot, user]) + }, [ballot, community]) const minScore = useMemo(() => { - if (user == null) return 0 + if (community == null) return 0 return -Math.sqrt( - user.votingCredits + + community.votingCredits + Math.abs(ballot.user.pendingCredits) + Math.abs(ballot.user.talliedCredits), ) - }, [ballot, user]) + }, [ballot, community]) useEffect(() => { const score = voteScore @@ -247,7 +249,11 @@ export const IssueBallot: FC = function IssueBallot({
- + {formatDecimal(ballot.score)} @@ -335,7 +341,7 @@ export const IssueBallot: FC = function IssueBallot({ disabled={true} min={0} max={ - (user?.votingCredits ?? 0) + + (community?.votingCredits ?? 0) + ballot.user.pendingCredits + ballot.user.talliedCredits } diff --git a/src/renderer/src/components/Login.styles.ts b/src/renderer/src/components/Login.styles.ts new file mode 100644 index 0000000..761a47c --- /dev/null +++ b/src/renderer/src/components/Login.styles.ts @@ -0,0 +1,22 @@ +import { makeStyles, shorthands } from '@fluentui/react-components' + +import { gov4GitTokens } from '../App/theme/index.js' + +export const useLoginStyles = makeStyles({ + buttons: { + display: 'flex', + justifyContent: 'flex-start', + }, + card: { + ...shorthands.padding(gov4GitTokens.spacingHorizontalXXL), + ...shorthands.borderRadius(gov4GitTokens.borderRadiusLarge), + }, + field: { + paddingBottom: '2rem', + }, + labelText: { + fontSize: '1.25rem', + display: 'block', + ...shorthands.padding(0, 0, '0.5rem', 0), + }, +}) diff --git a/src/renderer/src/components/Login.tsx b/src/renderer/src/components/Login.tsx new file mode 100644 index 0000000..a862507 --- /dev/null +++ b/src/renderer/src/components/Login.tsx @@ -0,0 +1,149 @@ +import { Button, Card, Field, Input } from '@fluentui/react-components' +import { useAtomValue } from 'jotai' +import { FC, FormEvent, useCallback, useMemo, useState } from 'react' + +import { useCatchError } from '../hooks/useCatchError.js' +import { eventBus } from '../lib/index.js' +import { userService } from '../services/UserService.js' +import { userAtom } from '../state/user.js' +import { useButtonStyles, useMessageStyles } from '../styles/index.js' +import { useLoginStyles } from './Login.styles.js' +import { Message } from './Message.js' + +export const Login: FC = function Login() { + const styles = useLoginStyles() + const buttonStyles = useButtonStyles() + const catchError = useCatchError() + const user = useAtomValue(userAtom) + const [loginErrors, setLoginErrors] = useState([]) + const [loading, setLoading] = useState(false) + const messageStyles = useMessageStyles() + const [username, setUsername] = useState(user?.username ?? '') + const [pat, setPat] = useState(user?.pat ?? '') + + const dismissError = useCallback(() => { + setLoginErrors([]) + }, [setLoginErrors]) + + const submitEnabled = useMemo(() => { + return username !== '' && pat !== '' + }, [username, pat]) + + const save = useCallback( + async (ev: FormEvent) => { + ev.preventDefault() + setLoginErrors([]) + try { + setLoading(true) + const userErrors = await userService.authenticate(username, pat) + if (userErrors.length > 0) { + setLoginErrors(userErrors) + setLoading(false) + } else { + setLoading(false) + eventBus.emit('user-logged-in') + } + } catch (ex) { + setLoading(false) + await catchError(`Failed to save config. ${ex}`) + } + }, + [username, pat, setLoginErrors, catchError, setLoading], + ) + + return ( + + {loginErrors.length > 0 && ( + + )} +
+ ( + + ), + }} + > + setUsername(e.target.value)} + /> + + ( + + ), + }} + > + + setPat(e.target.value)} + /> + + +
+ +
+ +
+ ) +} + +const PATMoreInfo: FC = function PATMoreInfo() { + return ( +
+

+ A GitHub personal access token with repo rights is required.{' '} + + How to create a GitHub personal access token + + . Provide a description for the token and an expiration period. Tokens + with expirations will need to be regenerated and provided, here in the + settings, after expiration for continued use of this application. Under + "Select scopes", check the top-level repo option. Select + Generate token and copy the token to provide here.
+ GitHub Access Tokens rights +
+
+

+
+ ) +} diff --git a/src/renderer/src/components/RequireAuth.tsx b/src/renderer/src/components/RequireAuth.tsx index 9d4704c..dc25a3b 100644 --- a/src/renderer/src/components/RequireAuth.tsx +++ b/src/renderer/src/components/RequireAuth.tsx @@ -3,8 +3,6 @@ import { useAtomValue } from 'jotai' import { FC, PropsWithChildren } from 'react' import { Navigate } from 'react-router-dom' -import type { User } from '~/shared' - import { routes } from '../App/index.js' import { communityAtom } from '../state/community.js' import { userAtom, userLoadedAtom } from '../state/user.js' @@ -14,41 +12,42 @@ export const RequireAuth: FC = function RequireAuth({ }) { const user = useAtomValue(userAtom) const isUserLoaded = useAtomValue(userLoadedAtom) + const community = useAtomValue(communityAtom) if (!isUserLoaded) { return <> } else if (user == null) { return - } else if (!user.isMember) { - return + } else if (community == null || !community.isMember) { + return } else { return children } } -const Unauthorized: FC = function Unauthorized() { - const community = useAtomValue(communityAtom) - const user = useAtomValue(userAtom) as User +// const Unauthorized: FC = function Unauthorized() { +// const community = useAtomValue(communityAtom) +// const user = useAtomValue(userAtom) as User - return ( - - Unauthorized {user?.username} is not a member of {community?.url}.   - - Request access here - - . - - ) -} +// return ( +// +// Unauthorized {user?.username} is not a member of {community?.url}.   +// +// Request access here +// +// . +// +// ) +// } diff --git a/src/renderer/src/components/SettingsForm.tsx b/src/renderer/src/components/SettingsForm.tsx index df9786f..f63ba19 100644 --- a/src/renderer/src/components/SettingsForm.tsx +++ b/src/renderer/src/components/SettingsForm.tsx @@ -7,10 +7,9 @@ import { routes } from '../App/index.js' import { useCatchError } from '../hooks/useCatchError.js' import { eventBus } from '../lib/index.js' import { communityService } from '../services/CommunityService.js' -import { settingsService } from '../services/SettingsService.js' import { userService } from '../services/UserService.js' import { communityAtom } from '../state/community.js' -import { configErrorsAtom } from '../state/config.js' +import { settingsErrorAtom } from '../state/settings.js' import { userAtom } from '../state/user.js' import { useButtonStyles } from '../styles/index.js' import { useMessageStyles } from '../styles/messages.js' @@ -25,7 +24,7 @@ export const SettingsForm = function SettingsForm() { const catchError = useCatchError() const user = useAtomValue(userAtom) const community = useAtomValue(communityAtom) - const [configErrors, setConfigErrors] = useAtom(configErrorsAtom) + const [configErrors, setConfigErrors] = useAtom(settingsErrorAtom) const [loading, setLoading] = useState(false) const messageStyles = useMessageStyles() const [username, setUsername] = useState(user?.username ?? '') @@ -49,7 +48,6 @@ export const SettingsForm = function SettingsForm() { setConfigErrors(communityErrors) setLoading(false) } else { - await settingsService.generateConfig() setUnsavedChanges(false) setLoading(false) eventBus.emit('user-logged-in') diff --git a/src/renderer/src/components/SiteNav.tsx b/src/renderer/src/components/SiteNav.tsx index 62d2ebe..422db12 100644 --- a/src/renderer/src/components/SiteNav.tsx +++ b/src/renderer/src/components/SiteNav.tsx @@ -4,12 +4,14 @@ import { FC, useCallback, useState } from 'react' import { NavLink } from 'react-router-dom' import { routes } from '../App/index.js' +import { communityAtom } from '../state/community.js' import { userAtom } from '../state/user.js' import { useSiteNavStyles } from './SiteNav.styles.js' export const SiteNav: FC = function SiteNav() { const styles = useSiteNavStyles() const user = useAtomValue(userAtom) + const community = useAtomValue(communityAtom) const [pinned, setPinned] = useState('') const onExpand = useCallback(() => { @@ -63,7 +65,7 @@ export const SiteNav: FC = function SiteNav() { {user && Object.entries(routes).map(([key, route]) => { if (!route.siteNav || route.footer) return - if (!user?.isMaintainer && route.forAdmin) return + if (!community?.isMaintainer && route.forAdmin) return return (
{Object.entries(routes).map(([key, route]) => { if (!route.siteNav || !route.footer) return - if (!user?.isMaintainer && route.forAdmin) return + if (!community?.isMaintainer && route.forAdmin) return return (
@@ -21,7 +23,7 @@ export const UserBadge: FC = function UserBadge() { {user.username} -
{formatDecimal(user.votingCredits ?? 0)}
+
{formatDecimal(community?.votingCredits ?? 0)}
diff --git a/src/renderer/src/components/index.ts b/src/renderer/src/components/index.ts index 56b777a..af87de1 100644 --- a/src/renderer/src/components/index.ts +++ b/src/renderer/src/components/index.ts @@ -13,3 +13,5 @@ export * from './LogViewer.js' export * from './LicenseViewer.js' export * from './RefreshButton.js' export * from './BubbleSlider.js' +export * from './Login.js' +export * from './CommunityJoin.js' diff --git a/src/renderer/src/hooks/useCatchError.ts b/src/renderer/src/hooks/useCatchError.ts index a36178c..d9c2dd6 100644 --- a/src/renderer/src/hooks/useCatchError.ts +++ b/src/renderer/src/hooks/useCatchError.ts @@ -3,19 +3,19 @@ import { useCallback } from 'react' import { useNavigate } from 'react-router-dom' import { routes } from '../App/Router.js' -import { settingsService } from '../services/SettingsService.js' -import { configErrorsAtom } from '../state/config.js' +import { validationService } from '../services/ValidationService.js' import { errorAtom } from '../state/error.js' +import { settingsErrorAtom } from '../state/settings.js' export function useCatchError() { const setErrorMessage = useSetAtom(errorAtom) const navigate = useNavigate() - const setConfigErrors = useSetAtom(configErrorsAtom) + const setConfigErrors = useSetAtom(settingsErrorAtom) const setError = useCallback( async (error: string) => { try { - const configErrors = await settingsService.validateConfig() + const configErrors = await validationService.validateConfig() if (configErrors.length > 0) { setConfigErrors(configErrors) navigate(routes.settings.path) diff --git a/src/renderer/src/lib/eventBus.ts b/src/renderer/src/lib/eventBus.ts index beef966..a4dff5f 100644 --- a/src/renderer/src/lib/eventBus.ts +++ b/src/renderer/src/lib/eventBus.ts @@ -15,9 +15,14 @@ class EventBus extends EventTarget { event: string, fn: (e: CustomEvent) => void | Promise, ) => { - this.addEventListener(event, fn as EventListenerOrEventListenerObject) + const events = event.replace(/\s/g, '').split(',') + for (const e of events) { + this.addEventListener(e, fn as EventListenerOrEventListenerObject) + } return () => { - this.removeEventListener(event, fn as EventListenerOrEventListenerObject) + for (const e of events) { + this.removeEventListener(e, fn as EventListenerOrEventListenerObject) + } } } } diff --git a/src/renderer/src/pages/CommunityJoin.styles.ts b/src/renderer/src/pages/CommunityJoin.styles.ts new file mode 100644 index 0000000..46c5c2b --- /dev/null +++ b/src/renderer/src/pages/CommunityJoin.styles.ts @@ -0,0 +1,18 @@ +import { makeStyles, shorthands } from '@fluentui/react-components' + +import { gov4GitTokens } from '../App/theme/tokens.js' + +export const useCommunityJoinStyles = makeStyles({ + title: { + display: 'flex', + alignItems: 'center', + ...shorthands.padding('0.4rem', 0, '2rem', 0), + ...shorthands.gap('12px'), + }, + pageHeading: { + ...shorthands.padding(0, 0, '2px', 0), + fontSize: '2rem', + color: gov4GitTokens.g4gColorNeutralDarkest, + textShadow: '0px 1px 0px #FFFFFF', + }, +}) diff --git a/src/renderer/src/pages/CommunityJoin.tsx b/src/renderer/src/pages/CommunityJoin.tsx new file mode 100644 index 0000000..fc7afd7 --- /dev/null +++ b/src/renderer/src/pages/CommunityJoin.tsx @@ -0,0 +1,25 @@ +import { Badge, Text } from '@fluentui/react-components' + +import { CommunityJoin } from '../components/index.js' +import { useBadgeStyles } from '../styles/index.js' +import { useCommunityJoinStyles } from './CommunityJoin.styles.js' + +export const CommunityJoinPage = function LoginPage() { + const loginStyles = useCommunityJoinStyles() + const badgeStyles = useBadgeStyles() + return ( + <> +
+ + 2 + +

Join a Community

+
+ + + ) +} diff --git a/src/renderer/src/pages/Login.styles.ts b/src/renderer/src/pages/Login.styles.ts new file mode 100644 index 0000000..b60aeb8 --- /dev/null +++ b/src/renderer/src/pages/Login.styles.ts @@ -0,0 +1,18 @@ +import { makeStyles, shorthands } from '@fluentui/react-components' + +import { gov4GitTokens } from '../App/theme/tokens.js' + +export const useLoginStyles = makeStyles({ + title: { + display: 'flex', + alignItems: 'center', + ...shorthands.padding('0.4rem', 0, '2rem', 0), + ...shorthands.gap('12px'), + }, + pageHeading: { + ...shorthands.padding(0, 0, '2px', 0), + fontSize: '2rem', + color: gov4GitTokens.g4gColorNeutralDarkest, + textShadow: '0px 1px 0px #FFFFFF', + }, +}) diff --git a/src/renderer/src/pages/Login.tsx b/src/renderer/src/pages/Login.tsx index 148d4de..6b0a72a 100644 --- a/src/renderer/src/pages/Login.tsx +++ b/src/renderer/src/pages/Login.tsx @@ -1,5 +1,25 @@ -import { SettingsForm } from '../components/index.js' +import { Badge, Text } from '@fluentui/react-components' + +import { Login } from '../components/index.js' +import { useBadgeStyles } from '../styles/index.js' +import { useLoginStyles } from './Login.styles.js' export const LoginPage = function LoginPage() { - return + const loginStyles = useLoginStyles() + const badgeStyles = useBadgeStyles() + return ( + <> +
+ + 1 + +

Login

+
+ + + ) } diff --git a/src/renderer/src/services/SettingsService.ts b/src/renderer/src/services/SettingsService.ts deleted file mode 100644 index e12b17c..0000000 --- a/src/renderer/src/services/SettingsService.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { AbstractSettingsService } from '~/shared' - -import { proxyService } from './proxyService.js' - -const SettingsService = proxyService('settings') - -export const settingsService = new SettingsService() diff --git a/src/renderer/src/services/ValidationService.ts b/src/renderer/src/services/ValidationService.ts new file mode 100644 index 0000000..066ff9d --- /dev/null +++ b/src/renderer/src/services/ValidationService.ts @@ -0,0 +1,8 @@ +import { AbstractValidationService } from '~/shared' + +import { proxyService } from './proxyService.js' + +const ValidationService = + proxyService('settings') + +export const validationService = new ValidationService() diff --git a/src/renderer/src/services/index.ts b/src/renderer/src/services/index.ts index adf3a1f..a902a64 100644 --- a/src/renderer/src/services/index.ts +++ b/src/renderer/src/services/index.ts @@ -3,4 +3,4 @@ export * from './UserService.js' export * from './LogService.js' export * from './AppUpdaterService.js' export * from './CommunityService.js' -export * from './SettingsService.js' +export * from './ValidationService.js' diff --git a/src/renderer/src/state/community.ts b/src/renderer/src/state/community.ts index 07d0169..fcdae07 100644 --- a/src/renderer/src/state/community.ts +++ b/src/renderer/src/state/community.ts @@ -1,5 +1,16 @@ import { atom } from 'jotai' -import type { Community } from '~/shared' +import { type FullUserCommunity } from '../../../electron/db/schema.js' +import { userAtom } from './user.js' -export const communityAtom = atom(null) +export const communitiesAtom = atom((get) => { + const user = get(userAtom) + if (user == null) return [] + return user.communities +}) + +export const communityAtom = atom((get) => { + const communities = get(communitiesAtom) + const selectedCommunity = communities.filter((c) => c.selected === true)[0] + return selectedCommunity ?? null +}) diff --git a/src/renderer/src/state/config.ts b/src/renderer/src/state/config.ts deleted file mode 100644 index a5a930c..0000000 --- a/src/renderer/src/state/config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { atom } from 'jotai' - -import type { Config } from '~/shared' - -export const configAtom = atom(null) - -export const configErrorsAtom = atom([]) diff --git a/src/renderer/src/state/user.ts b/src/renderer/src/state/user.ts index d086e1f..f803898 100644 --- a/src/renderer/src/state/user.ts +++ b/src/renderer/src/state/user.ts @@ -1,6 +1,6 @@ import { atom } from 'jotai' -import { User } from '~/shared' +import { type FullUser } from '../../../electron/db/schema.js' export const userLoadedAtom = atom(false) -export const userAtom = atom(null) +export const userAtom = atom(null) diff --git a/src/renderer/src/styles/badges.ts b/src/renderer/src/styles/badges.ts new file mode 100644 index 0000000..644f4e4 --- /dev/null +++ b/src/renderer/src/styles/badges.ts @@ -0,0 +1,9 @@ +import { makeStyles } from '@fluentui/react-components' + +import { gov4GitTokens } from '../App/theme/index.js' + +export const useBadgeStyles = makeStyles({ + primary: { + backgroundColor: gov4GitTokens.g4gColorSecondaryGreen8, + }, +}) diff --git a/src/renderer/src/styles/index.ts b/src/renderer/src/styles/index.ts index 9b24cf6..01101d1 100644 --- a/src/renderer/src/styles/index.ts +++ b/src/renderer/src/styles/index.ts @@ -1,2 +1,4 @@ export * from './buttons.js' export * from './headings.js' +export * from './messages.js' +export * from './badges.js' diff --git a/src/shared/services/CommunityService.ts b/src/shared/services/CommunityService.ts index 006c94c..d51a8ad 100644 --- a/src/shared/services/CommunityService.ts +++ b/src/shared/services/CommunityService.ts @@ -1,14 +1,3 @@ -export type Community = { - url: string - name: string - branch: string - configPath: string - projectUrl: string - selected: boolean -} - export abstract class AbstractCommunityService { - public abstract getCommunity(): Promise - public abstract insertCommunity(projectUrl: string): Promise } diff --git a/src/shared/services/Service.ts b/src/shared/services/Service.ts index 356539c..cecba0e 100644 --- a/src/shared/services/Service.ts +++ b/src/shared/services/Service.ts @@ -12,6 +12,8 @@ export type ServiceId = | 'community' | 'settings' | 'cache' + | 'userCommunity' + | 'validation' export type ObjectProxy = { [P in keyof T]: ServiceProxy diff --git a/src/shared/services/SettingsService.ts b/src/shared/services/SettingsService.ts deleted file mode 100644 index 8828af7..0000000 --- a/src/shared/services/SettingsService.ts +++ /dev/null @@ -1,26 +0,0 @@ -export type Config = { - notice: string - configPath: string - cache_dir?: string - cache_ttl_seconds?: number - user: { - username: string - pat: string - } - community_name: string - project_repo: string - auth: Record - gov_public_url: string - gov_public_branch: string - gov_private_url?: string - gov_private_branch?: string - member_public_url: string - member_public_branch: string - member_private_url: string - member_private_branch: string -} - -export abstract class AbstractSettingsService { - public abstract validateConfig(): Promise - public abstract generateConfig(): Promise -} diff --git a/src/shared/services/UserService.ts b/src/shared/services/UserService.ts index 6be0995..cc92e1e 100644 --- a/src/shared/services/UserService.ts +++ b/src/shared/services/UserService.ts @@ -1,18 +1,6 @@ -export type User = { - communityId: string - username: string - pat: string - votingCredits: number - votingScore: number - isMaintainer: boolean - isMember: boolean - memberPublicUrl: string - memberPublicBranch: string - memberPrivateUrl: string - memberPrivateBranch: string -} +import { type FullUser } from '../../electron/db/schema.js' export abstract class AbstractUserService { public abstract authenticate(username: string, pat: string): Promise - public abstract getUser(): Promise + public abstract getUser(): Promise } diff --git a/src/shared/services/ValidationService.ts b/src/shared/services/ValidationService.ts new file mode 100644 index 0000000..9cd5205 --- /dev/null +++ b/src/shared/services/ValidationService.ts @@ -0,0 +1,3 @@ +export abstract class AbstractValidationService { + public abstract validateConfig(): Promise +} diff --git a/src/shared/services/index.ts b/src/shared/services/index.ts index a202ddd..79abf85 100644 --- a/src/shared/services/index.ts +++ b/src/shared/services/index.ts @@ -4,5 +4,6 @@ export * from './UserService.js' export * from './LogService.js' export * from './AppUpdaterService.js' export * from './CommunityService.js' -export * from './SettingsService.js' +export * from './ValidationService.js' export * from './CacheService.js' +export * from './ValidationService.js' diff --git a/test/BallotService.test.ts b/test/BallotService.test.ts index fb10ef5..69e1669 100644 --- a/test/BallotService.test.ts +++ b/test/BallotService.test.ts @@ -35,7 +35,13 @@ export default function run(services: Services) { await govService.mustRun('init-gov') } - if (!user?.isMember) { + const selectedCommunity = user.communities.filter((c) => c.selected)[0] + + if (selectedCommunity == null) { + throw new Error(`No selected Community`) + } + + if (!selectedCommunity?.isMember) { await govService.mustRun( 'user', 'add', @@ -63,6 +69,14 @@ export default function run(services: Services) { expect(true).toEqual(true) await userService.loadUser() const user1 = await userService.getUser() + if (user1 == null) { + throw new Error(`No User to load`) + } + const selectedCommunity1 = user1.communities.filter((c) => c.selected)[0] + + if (selectedCommunity1 == null) { + throw new Error(`No selected Community`) + } if (user1 == null) { throw new Error(`No user to load`) } @@ -81,15 +95,26 @@ export default function run(services: Services) { }) await ballotService.tallyBallot(`github/issues/12`) await ballotService.loadBallots() + await ballotService.loadBallots() ballots = await ballotService.getBallots() - const user2 = await userService.getUser() + const user2 = await userService.loadUser() + if (user2 == null) { + throw new Error(`No User to load`) + } + const selectedCommunity2 = user2.communities.filter((c) => c.selected)[0] + + if (selectedCommunity2 == null) { + throw new Error(`No selected Community`) + } expect(ballots.length).toEqual(1) const ballot = ballots[0] if (ballot == null) { throw new Error(`No ballots`) } expect(ballot.score).toEqual(2) - expect(user2?.votingCredits).toEqual(user1.votingCredits - 4) + expect(selectedCommunity2?.votingCredits).toEqual( + selectedCommunity1.votingCredits - 4, + ) }, 1200000) }) } diff --git a/test/CommunityService.test.ts b/test/CommunityService.test.ts index b31fcdc..720d1f4 100644 --- a/test/CommunityService.test.ts +++ b/test/CommunityService.test.ts @@ -2,7 +2,7 @@ import { beforeAll, describe, expect, test } from '@jest/globals' import { eq } from 'drizzle-orm' import { DB } from '../src/electron/db/db.js' -import { communities } from '../src/electron/db/schema.js' +import { communities, userCommunities } from '../src/electron/db/schema.js' import { CommunityService, Services } from '../src/electron/services/index.js' import { config } from './config.js' @@ -59,6 +59,25 @@ export default function run(services: Services) { expect(selectedCommunity).not.toBeUndefined() expect(selectedCommunity?.projectUrl).toEqual(config.projectRepo) + + const selectedUserCommunity = ( + await db + .select() + .from(userCommunities) + .innerJoin( + communities, + eq(userCommunities.communityId, communities.url), + ) + .where(eq(communities.selected, true)) + .limit(1) + )[0] + + expect( + selectedUserCommunity?.userCommunities.joinRequestUrl, + ).not.toBeNull() + expect( + selectedUserCommunity?.userCommunities.joinRequestStatus, + ).toEqual('open') }) }) }) diff --git a/test/SettingsService.test.ts b/test/SettingsService.test.ts index f85b682..4739f91 100644 --- a/test/SettingsService.test.ts +++ b/test/SettingsService.test.ts @@ -8,24 +8,27 @@ import { GitService, Services, SettingsService, + ValidationService, } from '../src/electron/services/index.js' import { config } from './config.js' export default function run(services: Services) { let settingsService: SettingsService + let validationService: ValidationService let gitService: GitService let db: DB describe('Settings Tests', () => { beforeAll(async () => { settingsService = services.load('settings') + validationService = services.load('validation') db = services.load('db') gitService = services.load('git') }) describe('Generate Config', () => { test('Generate', async () => { - await settingsService.generateConfig() + await settingsService.generateConfigs() const selectedCommunity = ( await db @@ -67,7 +70,7 @@ export default function run(services: Services) { describe('Validate', () => { test('Validate', async () => { - const errors = await settingsService.validateConfig() + const errors = await validationService.validateConfig() expect(errors.length).toEqual(0) }) diff --git a/test/testRunner.test.ts b/test/testRunner.test.ts index 88985b1..714b26e 100644 --- a/test/testRunner.test.ts +++ b/test/testRunner.test.ts @@ -12,7 +12,9 @@ import { LogService, Services, SettingsService, + UserCommunityService, UserService, + ValidationService, } from '../src/electron/services/index.js' // eslint-disable-next-line import runBallotTests from './BallotService.test' @@ -38,26 +40,32 @@ beforeAll(async () => { services.register('git', gitService) const govService = new Gov4GitService(services) services.register('gov4git', govService) + const settingsService = new SettingsService({ + services, + }) + services.register('settings', settingsService) + const userCommunityService = new UserCommunityService({ + services, + }) + services.register('userCommunity', userCommunityService) const userService = new UserService({ services, identityRepoName: config.identityName, }) services.register('user', userService) - const ballotService = new BallotService({ - services, - }) - services.register('ballots', ballotService) - const communityService = new CommunityService({ services, configDir: config.configDir, }) services.register('community', communityService) - - const settingsService = new SettingsService({ + const ballotService = new BallotService({ services, }) - services.register('settings', settingsService) + services.register('ballots', ballotService) + const validationService = new ValidationService({ + services, + }) + services.register('validation', validationService) await gitService.initializeRemoteRepo( config.projectRepo,